#!/bin/sh
# tlp-func-disk - Storage Device and Filesystem Functions
#
# Copyright (c) 2023 Thomas Koch <linrunner at gmx.net> and others.
# SPDX-License-Identifier: GPL-2.0-or-later

# Needs: tlp-func-base

# ----------------------------------------------------------------------------
# Constants

readonly AHCID=$PCID'/*/ata*'
readonly ALPMD=$AHCID'/host*/scsi_host/host*'

readonly DISK_NOP_WORDS="_ keep"

# ----------------------------------------------------------------------------
# Functions

# --- Device Helpers

get_disk_dev () { # translate disk id to device (sdX)
    # and determine disk type and capabilities
    # $1: id or device basename
    # $2: target disk device (optional)
    # rc: 0=disk exists (optional: amd matches target)
    #     1=disk non-existent (optional: or does not match target)
    # retval: $_disk_dev:       device basename - below /dev or /sys/block;
    #         $_disk_id:        id basename - below /dev/disk/by-id;
    #         $_disk_type:      nvme/ata/sata/usb/ieee1394;
    #         $_disk_apm:       0=no apm/1=apm capable;
    #         $_disk_mq:        scheduler: 0=single queue/1=multi queue;
    #         $_disk_runpm:     runtime pm: 0=allowed/1=denied by kernel/2=denied by tlp/3=incapable

    local dev id idpath
    local target="$2"
    _disk_dev=""

    if [ -h "/dev/disk/by-id/$1" ]; then
        # $1 is disk id
        _disk_id=$1
        _disk_dev=$(printf '%s' "$_disk_id" | sed -r 's/-part[1-9][0-9]*$//')
        _disk_dev=$(readlink "/dev/disk/by-id/$_disk_dev")
        _disk_dev=${_disk_dev##*/}
    else
        # $1 is disk dev
        _disk_dev=$1
        _disk_id=""
        if [ -b "/dev/$_disk_dev" ]; then
            # disk exists, lookup id
            for idpath in /dev/disk/by-id/*; do
                id="${idpath##*/}"
                # filter partitions
                [ -n "${id%%*-part[1-9]*}" ] || continue
                case "$id" in
                    ata-*|usb-*)
                        dev=$(readlink "$idpath")
                        dev=${dev##*/}
                        if [ "$dev" = "$_disk_dev" ]; then
                            _disk_id="$id"
                            break
                        fi
                        ;;

                    nvme-*)
                        # filter 'nvme-eui.*'
                        if [ -n "${id##nvme-eui.*}" ]; then
                            dev=$(readlink "$idpath")
                            dev=${dev##*/}
                            if [ "$dev" = "$_disk_dev" ]; then
                                _disk_id="$id"
                                break
                            fi
                        fi
                        ;;
                esac
            done
        fi
    fi

    if [ -b "/dev/$_disk_dev" ]; then
        # retrieve device attributes
        local bus dpath path udevadm_data
        local DEVPATH=
        local ID_PATH=
        local ID_BUS=
        local ID_ATA_FEATURE_SET_PM_ENABLED=

        if udevadm_data="$(
            $UDEVADM info -q property "/dev/$_disk_dev" 2>/dev/null | \
                grep -E '^(DEVPATH|ID_BUS|ID_PATH|ID_ATA_FEATURE_SET_PM_ENABLED)='
            )"; then
            eval "${udevadm_data}"
        fi

        # determine device type (bus)
        path="$ID_PATH"
        bus="$ID_BUS"
        case "$path" in
            pci-*-nvme-*)     _disk_type="nvme" ;;
            pci-*-ata-*)      _disk_type="ata"  ;;
            pci-*-usb-*)      _disk_type="usb"  ;;
            pci-*-ieee1394-*) _disk_type="ieee1394" ;;
            *) case "$bus" in
                nvme)      _disk_type="nvme" ;;
                ata)       _disk_type="ata"  ;;
                usb)       _disk_type="usb"  ;;
                ieee1394)  _disk_type="ieee1394" ;;

                *)
                    dpath="${DEVPATH##*/}"
                    case $dpath in
                        nvme*) _disk_type="nvme" ;;
                        *)     _disk_type="unknown" ;;
                    esac
                    ;;
            esac
        esac

        # distinguish sata from ata(ide) disks
        if [ "$_disk_type" = "ata" ]; then
            if glob_files "/link_power_management_policy" "/sys${DEVPATH%/target*}/scsi_host/host*" > /dev/null 2>&1; then
                _disk_type=sata
            fi
        fi

        # determine APM capability
        _disk_apm=0
        if [ "$ID_ATA_FEATURE_SET_PM_ENABLED" = "1" ]; then
            _disk_apm=1
        fi

        # determine if single- or multi-queue scheduler
        if [ -d "/sys/block/$_disk_dev/mq" ]; then
            _disk_mq="1"
        else
            _disk_mq="0"
        fi

        # determine if runtime pm is possible and allowed
        if [ -f "/sys/block/$_disk_dev/device/power/control" ]; then
            # disk has a runtime pm control sysfile
            case "$_disk_type" in
                nvme)
                    # nvme disks do not have a readable autosuspend_delay_ms,
                    # nevertheless runtime pm changes are safe
                    _disk_runpm="0"
                    ;;

                sata|ata|usb)
                    if ! readable_sysf "/sys/block/$_disk_dev/device/power/autosuspend_delay_ms"; then
                        # autosuspend_delay_ms is missing or not readable
                        # --> kernel itself denies runtime pm for the disk
                        _disk_runpm="1"
                    else
                        # kernel allows runtime pm for the disk
                        # --> tlp decides if it is safe
                        if [ "$_disk_mq" = "0" ] || kernel_version_ge 4.19; then
                            # singlequeue scheduler is considered safe for all kernels,
                            # multiqueue and kernel >= 4.19 too
                            _disk_runpm="0"
                        else
                            # multiqueue scheduler and kernel < 4.19 is considered unsafe,
                            # because system freezes and data loss may occur when enabling
                            # runtime pm for a sata or ata disk.
                            # only with kernel 4.19 a lock was introduced which prevents that mq
                            # is forced via command line options:
                            # https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?h=v4.19&id=b233f127042dba991229e3882c6217c80492f6ef
                            # distribution kernels < 4.19 may be patched too, but better safe than sorry.
                            _disk_runpm="2"
                        fi
                    fi
                    ;;

                *)
                    # tlp shall not (yet) touch runtime pm for other disk types
                    _disk_runpm="2"
                    ;;
            esac
        else
            # disk is not runtime pm capable
            _disk_runpm="3"
        fi

        if [ -n "$target" ]; then
            # in target mode trace output only in case of match
            if [ "$target" = "$_disk_dev" ]; then
                echo_debug "disk" "get_disk_dev($1).target: dev=$_disk_dev; id=$_disk_id; type=$_disk_type; path=$path; bus=$bus; dpath=$dpath; apm=$_disk_apm; mq=$_disk_mq; runpm=$_disk_runpm"
                return 0
            else
                # no match
                return 1
            fi
        else
            echo_debug "disk" "get_disk_dev($1): dev=$_disk_dev; id=$_disk_id; type=$_disk_type; path=$path; bus=$bus; dpath=$dpath; apm=$_disk_apm; mq=$_disk_mq; runpm=$_disk_runpm"
            return 0
        fi
    else
        _disk_type="none"
        echo_debug "disk" "get_disk_dev($1).missing"
        return 1
    fi
}

show_disk_ids () { # show disk id's
    local dev

    { # iterate SATA and NVMe disks
        for dev in $(glob_files '/*' /dev/disk/by-id/ | sed -rn 's/.*\/((ata|ieee1394|nvme|usb))/\1/p' | grep -E -v '(^nvme-eui|-part[1-9]+)'); do
            if [ -n "$dev" ]; then
                get_disk_dev "$dev" && printf '%s: %s\n' "$_disk_dev" "$_disk_id"
            fi
        done
    } | sort

    return 0
}

# --- Disk APM Features

set_disk_apm_level () { # set disk apm level
    # $1: 0=ac mode, 1=battery mode
    # $2: target disk device (optional)

    local pwrmode="$1"
    local target="$2"
    local dev log_message

    # quit when disabled
    if [ -z "$DISK_DEVICES" ]; then
        echo_debug "disk" "set_disk_apm_level($pwrmode).disabled"
        return 0
    fi

    # set @argv := apmlist (blanks removed - relying on a sane $IFS)
    if [ "$pwrmode" = "1" ]; then
        # shellcheck disable=SC2086
        set -- $DISK_APM_LEVEL_ON_BAT
    else
        # shellcheck disable=SC2086
        set -- $DISK_APM_LEVEL_ON_AC
    fi

    # quit if empty apmlist
    if [ $# -eq 0 ]; then
        echo_debug "disk" "set_disk_apm_level($pwrmode).not_configured"
        return 0
    fi

    if [ -z "$target" ]; then
        echo_debug "disk" "*** set_disk_apm_level($pwrmode).all"
    else
        echo_debug "disk" "*** set_disk_apm_level($pwrmode).target: $target"
    fi

    # pairwise iteration DISK_DEVICES[1,n], apmlist[1,m]; m > 0
    #  for j in [1,n]: disk_dev[j], apmlist[min(j,m)]
    # operation modes:
    # 1. work on all disks in $DISK_DEVICES
    # 2. work on $target only -- when called by udev event

    for dev in $DISK_DEVICES; do
        : "${1:?BUG: broken DISK_APM_LEVEL list handling}"

        if get_disk_dev "$dev" "$target"; then
            log_message="set_disk_apm_level($pwrmode): $_disk_dev [$_disk_id] $1"

            if wordinlist "$_disk_type" "$DISK_APM_CLASS_DENYLIST"; then
                echo_debug "disk" "${log_message}; class denylist"
            elif [ "$_disk_apm" = "0" ]; then
                echo_debug "disk" "${log_message}; not supported"
            elif wordinlist "$1" "$DISK_NOP_WORDS"; then
                echo_debug "disk" "${log_message}; keep as is"
            else
                $HDPARM -B "$1" "/dev/$_disk_dev" > /dev/null 2>&1
                echo_debug "disk" "${log_message}; rc=$?"
            fi
        fi
        # quit the loop after reaching the target
        [ -n "$target" ] && [ "$target" = "$_disk_dev" ] && break

        # last entry in apmlist applies to all remaining disks
        [ $# -lt 2 ] || shift
    done

    return 0
}

set_disk_spindown_timeout () { # set disk spindown timeout
    # $1: 0=ac mode, 1=battery mode
    # $2: target disk device (optional)

    local pwrmode="$1"
    local target="$2"
    local dev log_message

    # quit when disabled
    if [ -z "$DISK_DEVICES" ]; then
        echo_debug "disk" "set_disk_spindown_timeout($pwrmode).disabled"
        return 0
    fi

    # set @argv := timeoutlist
    if [ "$pwrmode" = "1" ]; then
        # shellcheck disable=SC2086
        set -- $DISK_SPINDOWN_TIMEOUT_ON_BAT
    else
        # shellcheck disable=SC2086
        set -- $DISK_SPINDOWN_TIMEOUT_ON_AC
    fi

    # quit if empty timeoutlist
    if [ $# -eq 0 ]; then
        echo_debug "disk" "set_disk_spindown_timeout($pwrmode).not_configured"
        return 0
    fi

    if [ -z "$target" ]; then
        echo_debug "disk" "*** set_disk_spindown_timeout($pwrmode).all"
    else
        echo_debug "disk" "*** set_disk_spindown_timeout($pwrmode).target: $target"
    fi

    # pairwise iteration DISK_DEVICES[1,n], timeoutlist[1,m]; m > 0
    #  for j in [1,n]: disk_dev[j], timeoutlist[min(j,m)]
    # operation modes:
    # 1. work on all disks in $DISK_DEVICES
    # 2. work on $target only -- when called by udev event

    for dev in $DISK_DEVICES; do
        : "${1:?BUG: broken DISK_SPINDOWN_TIMEOUT list handling}"

        if get_disk_dev "$dev" "$target"; then
            log_message="set_disk_spindown_timeout($pwrmode): $_disk_dev [$_disk_id] $1"

            if wordinlist "$1" "$DISK_NOP_WORDS"; then
                echo_debug "disk" "${log_message}; keep as is"
            else
                $HDPARM -S "$1" "/dev/$_disk_dev" > /dev/null 2>&1
                echo_debug "disk" "${log_message}; rc=$?"
            fi
        fi
        # quit the loop after reaching the target
        [ -n "$target" ] && [ "$target" = "$_disk_dev" ] && break

        # last entry in timeoutlist applies to all remaining disks
        [ $# -lt 2 ] || shift
    done

    return 0
}

spindown_disk () { # stop spindle motor -- $1: dev
    $HDPARM -y "/dev/$1" > /dev/null 2>&1

    return 0
}

set_disk_iosched () { # set disk io scheduler
    # $1: target disk device (optional)

    local target="$1"
    local dev log_message

    # quit when disabled
    if [ -z "$DISK_DEVICES" ]; then
        echo_debug "disk" "set_disk_iosched.disabled"
        return 0
    fi

    # set @argv := schedlist
    # shellcheck disable=SC2086
    set -- $DISK_IOSCHED

    # quit if empty schedlist
    if [ $# -eq 0 ]; then
        echo_debug "disk" "set_disk_iosched.not_configured"
        return 0
    fi

    if [ -z "$target" ]; then
        echo_debug "disk" "*** set_disk_iosched($pwrmode).all"
    else
        echo_debug "disk" "*** set_disk_iosched($pwrmode).target: $target"
    fi

    # pairwise iteration DISK_DEVICES[1,n], schedlist[1,m]; m > 0
    #  for j in [1,min(n,m)]   : disk_dev[j], schedlistj]
    #  for j in [min(n,m)+1,n] : disk_dev[j], %keep
    # operation modes:
    # 1. work on all disks in $DISK_DEVICES
    # 2. work on $target only -- when called by udev event

    for dev in $DISK_DEVICES; do
        local sched schedctrl
        if get_disk_dev "$dev" "$target"; then
            # get sched from argv, use "keep" when list is too short
            sched=${1:-keep}
            schedctrl="/sys/block/$_disk_dev/queue/scheduler"
            log_message="set_disk_iosched: $_disk_dev [$_disk_id] $sched"

            if [ ! -f "$schedctrl" ]; then
                echo_debug "disk" "${log_message}; not supported"
            elif wordinlist "$sched" "$DISK_NOP_WORDS"; then
                echo_debug "disk" "${log_message}; keep as is"
            else
                write_sysf "$sched" "$schedctrl"
                echo_debug "disk" "${log_message}; rc=$?"
            fi
        fi
        # quit the loop after reaching the target
        [ -n "$target" ] && [ "$target" = "$_disk_dev" ] && break

        # using "keep" when argv is empty
        [ $# -eq 0 ] || shift
    done

    return 0
}

# --- Power Saving

set_sata_link_power () { # set ahci link power management
    # $1: 0=ac mode, 1=battery mode

    local pm="$1"
    local host host_bl hostid linkpol pwr rc
    local pwrlist=""
    local ctrl_avail="0"

    if [ "$pm" = "1" ]; then
        pwrlist=${SATA_LINKPWR_ON_BAT:-}
    else
        pwrlist=${SATA_LINKPWR_ON_AC:-}
    fi

    if [ -z "$pwrlist" ]; then
        # do nothing if unconfigured
        echo_debug "disk" "set_sata_link_power($pm).not_configured"
        return 0
    fi

    # ALPM denylist
    host_bl=${SATA_LINKPWR_DENYLIST:-}

    # copy configured values to args
    # shellcheck disable=SC2086
    set -- $pwrlist
    # iterate SATA hosts
    for host in $ALPMD ; do
        linkpol="$host/link_power_management_policy"
        if [ -f "$linkpol" ]; then
            hostid=${host##*/}
            if wordinlist "$hostid" "$host_bl"; then
                # host denylisted --> skip
                echo_debug "disk" "set_sata_link_power($pm).deny: $host"
                ctrl_avail="1"
            else
                # host not denylisted --> iterate all configured values
                for pwr in "$@"; do
                    write_sysf "$pwr" "$linkpol"; rc=$?
                    echo_debug "disk" "set_sata_link_power($pm).$pwr: $host; rc=$rc"
                    if [ $rc -eq 0 ]; then
                        # write successful --> goto next host
                        ctrl_avail="1"
                        break
                    else
                        # write failed --> don't use this value for remaining hosts
                        # and try next value
                        shift
                    fi
                done
            fi
        fi
    done

    [ "$ctrl_avail" = "0" ] && echo_debug "disk" "set_sata_link_power($pm).not_available"
    return 0
}

set_ahci_disk_runtime_pm () { # set runtime power management for ahci disks
    # $1: 0=ac mode, 1=battery mode, 2=suspend mode
    # $2: target disk device (optional)

    local target="$2"
    local control dev timeout rc

    case "$1" in
        0) control=${AHCI_RUNTIME_PM_ON_AC:-} ;;
        1) control=${AHCI_RUNTIME_PM_ON_BAT:-} ;;
        2) # reset on suspend only when configured
            if [ -n "${AHCI_RUNTIME_PM_ON_AC:-}${AHCI_RUNTIME_PM_ON_BAT:-}" ]; then
                control="on"
            fi
            ;;
    esac

    # calc timeout in millisecs
    timeout="$AHCI_RUNTIME_PM_TIMEOUT"
    [ -z "$timeout" ] || timeout=$((timeout * 1000))

    # check values
    case "$control" in
        on|auto) ;;
        *) control="" ;; # invalid input --> unconfigured
    esac

    if [ -z "$control" ]; then
        # do nothing if unconfigured
        echo_debug "disk" "set_ahci_disk_runtime_pm($1).not_configured"
        return 0
    fi

    # when timeout is unconfigured we're done here
    if [ -z "$timeout" ]; then
        echo_debug "disk" "set_ahci_disk_runtime_pm($1).timeout_not_configured"
        return 0
    fi

    if [ -z "$target" ]; then
        echo_debug "disk" "*** set_ahci_disk_runtime_pm($pwrmode).all"
    else
        echo_debug "disk" "*** set_ahci_disk_runtime_pm($pwrmode).target: $target"
    fi

    # iterate DISK_DEVICES
    for dev in $DISK_DEVICES; do
        if get_disk_dev "$dev" "$target"; then
            case "$_disk_runpm" in
                0) # runtime pm allowed for disk
                    rc=0
                    # write timeout first to prevent lockups
                    if ! write_sysf "$timeout" "/sys/block/$_disk_dev/device/power/autosuspend_delay_ms"; then
                        # writing timeout failed
                        rc=1
                    fi
                    # proceed with activation
                    if ! write_sysf "$control" "/sys/block/$_disk_dev/device/power/control"; then
                        # activation failed
                        rc=2
                    fi
                    echo_debug "disk" "set_ahci_disk_runtime_pm($1).$control: disk=$_disk_dev timeout=$timeout; rc=$rc"
                    ;;

                1|2) # runtime pm denied for disk
                    echo_debug "disk" "set_ahci_disk_runtime_pm($1).denied: disk=$_disk_dev; runpm=$_disk_runpm"
                    ;;

                3) # disk not runtime pm capable
                    echo_debug "disk" "set_ahci_disk_runtime_pm($1).incapable: disk=$_disk_dev; runpm=$_disk_runpm"
                    ;;

            esac
        fi
        # quit the loop after reaching the target
        [ -n "$target" ] && [ "$target" = "$_disk_dev" ] && break
    done

    return 0
}

set_ahci_port_runtime_pm () { # set runtime power management for ahci ports
    # $1: 0=ac mode, 1=battery mode, 2=suspend mode

    local control device

    case "$1" in
        0) control=${AHCI_RUNTIME_PM_ON_AC:-} ;;
        1) control=${AHCI_RUNTIME_PM_ON_BAT:-} ;;
        2) # reset on suspend only when configured
            if [ -n "${AHCI_RUNTIME_PM_ON_AC:-}${AHCI_RUNTIME_PM_ON_BAT:-}" ]; then
                control="on"
            fi
            ;;
    esac

    # check values
    case "$control" in
        on|auto) ;;
        *) control="" ;; # invalid input --> unconfigured
    esac

    if [ -z "$control" ]; then
        # do nothing if unconfigured
        echo_debug "disk" "set_ahci_port_runtime_pm($1).not_configured"
        return 0
    fi

    # iterate ahci ports
    for device in $AHCID; do
        if write_sysf "$control" "${device}/power/control"; then
            echo_debug "disk" "set_ahci_port_runtime_pm($1).$control: port=$device; rc=0"
        else
            echo_debug "disk" "set_ahci_port_runtime_pm($1).no-port"
        fi
    done

    return 0
}

# --- Filesystem Parameters

set_laptopmode () {
    # set kernel laptop mode
    # $1: 0=ac mode, 1=battery mode

    local isec

    if [ "$1" = "1" ]; then
        isec="$DISK_IDLE_SECS_ON_BAT"
    else
        isec="$DISK_IDLE_SECS_ON_AC"
    fi
    # replace with empty string if non-numeric chars are contained
    isec=$(printf '%s' "$isec" | grep -E '^[0-9]+$')

    if [ -z "$isec" ]; then
        # do nothing if unconfigured or non numeric value
        echo_debug "disk" "set_laptopmode($1).not_configured"
        return 0
    fi

    write_sysf "$isec" /proc/sys/vm/laptop_mode
    echo_debug "disk" "set_laptopmode($1): $isec; rc=$?"

    return 0
}

set_dirty_parms () {
    # set filesystem buffer params
    # $1: 0=ac mode, 1=battery mode

    local age cage df ec

    if [ "$1" = "1" ]; then
        age=${MAX_LOST_WORK_SECS_ON_BAT:-0}
    else
        age=${MAX_LOST_WORK_SECS_ON_AC:-0}
    fi

    # calc age in centisecs, non numeric values result in "0"
    cage=$((age * 100))

    if [ "$cage" = "0" ]; then
        # do nothing if unconfigured or invalid age
        echo_debug "disk" "set_dirty_parms($1).not_configured"
        return 0
    fi

    ec=0
    for df in /proc/sys/vm/dirty_writeback_centisecs \
             /proc/sys/vm/dirty_expire_centisecs \
             /proc/sys/fs/xfs/age_buffer_centisecs \
             /proc/sys/fs/xfs/xfssyncd_centisecs; do
        if [ -f "$df" ] && ! write_sysf "$cage" $df; then
            echo_debug "disk" "set_dirty_parms($1).write_error: $df $cage; rc=$?"
            ec=$((ec+1))
        fi
    done
    # shellcheck disable=SC2043
    for df in /proc/sys/fs/xfs/xfsbufd_centisecs; do
        if [ -f "$df" ] && ! write_sysf "3000" $df; then
            echo_debug "disk" "set_dirty_parms($1).write_error: $df 3000; rc=$?"
            ec=$((ec+1))
        fi
    done
    echo_debug "disk" "set_dirty_parms($1): $cage; ec=$ec"

    return 0
}
