#!/bin/sh
# tlp - Base Functions
#
# Copyright (c) 2019 Thomas Koch <linrunner at gmx.net> and others.
# This software is licensed under the GPL v2 or later.

# shellcheck disable=SC2086

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

readonly TLPVER="1.2.1"

readonly CONFFILE=/etc/default/tlp
readonly RUNDIR=/run/tlp

readonly FLOCK=flock
readonly HDPARM=hdparm
readonly LAPMODE=laptop_mode
readonly LOGGER=logger
readonly MODPRO=modprobe
readonly TPACPIBAT=/usr/share/tlp/tpacpi-bat
readonly UDEVADM=udevadm

readonly LOCKFILE=$RUNDIR/lock
readonly LOCKTIMEOUT=2

readonly PWRRUNFILE=$RUNDIR/last_pwr
readonly MANUALMODEFILE=$RUNDIR/manual_mode

readonly MOD_MSR="msr"
readonly MOD_TEMP="coretemp"
readonly MOD_TPSMAPI="tp_smapi"
readonly MOD_TPACPI="acpi_call"

readonly DMID=/sys/class/dmi/id/
readonly NETD=/sys/class/net

readonly RE_TPSMAPI_ONLY='^(Edge( 13.*)?|G41|R[56][012][eip]?|R[45]00|SL[45]10|T23|T[346][0123][p]?|T[45][01]0[s]?|W[57]0[01]|X[346][012][s]?( Tablet)?|X1[02]0e|X[23]0[01][s]?( Tablet)?|Z6[01][mpt])$'
readonly RE_TPSMAPI_AND_TPACPI='^(X1|X220[s]?( Tablet)?|T[45]20[s]?|W520)$'
readonly RE_TP_NONE='^(L[45]20|SL[345]00|X121e)$'

readonly RE_PARAM='^[A-Z_]+[0-9]*\=[0-9a-zA-Z _-]*$'

# power supplies: ignore MacBook Pro 2017 sbs-charger and hid devices
readonly RE_PS_IGNORE='sbs-charger|hidpp_battery'

readonly DEBUG_TAGS_ALL="arg bat disk lock nm path pm ps rf run sysfs udev usb"

readonly DEFAULT_TLP_ENABLE=0
readonly DEFAULT_TLP_PERSISTENT_DEFAULT=0

# ----------------------------------------------------------------------------
# Control

_nodebug=0

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

# --- Debug

echo_debug () { # write debug msg if tag matches -- $1: tag; $2: msg;
    [ "$_nodebug" = "1" ] && return 0

    if wordinlist "$1" "$TLP_DEBUG"; then
        $LOGGER -p debug -t "tlp" --id=$$ "$2"
    fi
}

# --- Strings

tolower () { # echo string in lowercase -- $1: string
    printf "%s" "$1" | tr "[:upper:]" "[:lower:]"
}

toupper () { # echo string in lowercase -- $1: string
    printf "%s" "$1" | tr "[:lower:]" "[:upper:]"
}

wordinlist () { # test if word in list
                # $1: word, $2: whitespace-separated list of words
    local word

    if [ -n "${1-}" ]; then
        for word in ${2-}; do
            [ "${word}" != "${1}" ] || return 0 # exact match
        done
    fi

    return 1 # no match
}

# --- Sysfiles

check_sysfs () { # debug: check if sysfile exists -- $1: routine; $2: sysfs path
    if wordinlist "sysfs" "$TLP_DEBUG"; then
        if [ ! -e $2 ]; then
            $LOGGER -p debug -t "tlp[$$,$PPID]" "$1: $2 nonexistent"
        fi
    fi
}

read_sysf () { # read and echo contents of sysfile
    # return 1 and echo default if read fails
    # $1: sysfile, $2: default; rc: 0=ok/1=error
    if ! cat "$1" 2> /dev/null; then
        printf "%s" "$2"
        return 1
    else
        return 0
    fi
}

readable_sysf () { # check if file is actually readable -- $1: file
    # rc: 0=readable/1=read error
    cat "$1" > /dev/null 2>&1
}

read_sysval () { # read and echo contents of sysfile
    # echo '0' if file is non-existent, read fails or is non-numeric
    # $1: sysfile; rc: 0=ok/1=error
    printf "%d" "$(read_sysf $1)" 2> /dev/null
}

write_sysf () { # write string to a sysfile
    # $1: string, $2: sysfile
    # rc: 0=ok/1=error
    { printf '%s\n' "$1" > "$2"; } 2> /dev/null
}

# --- Tests

cmd_exists () { # test if command exists -- $1: command
    command -v $1 > /dev/null 2>&1
}

test_root () { # test root privilege -- rc: 0=root, 1=not root
    [ "$(id -u)" = "0" ]
}

check_root () { # show error message and exit when root privilege missing
    if ! test_root; then
        echo "Error: missing root privilege." 1>&2
        exit 1
    fi
}

check_tlp_enabled () { # check if TLP is enabled in config file --
    # $1: 1=verbose
    # rc: 0=disabled/1=enabled

    : ${TLP_ENABLE:=${DEFAULT_TLP_ENABLE}}

    if [ "$TLP_ENABLE" = "1" ]; then
        return 0
    else
        [ "$1" = "1" ] && echo "Error: TLP power save is disabled. Set TLP_ENABLE=1 in $CONFFILE." 1>&2
        return 1
    fi
}

# --- Locking and Semaphores

set_run_flag () { # set flag -- $1: flag name
                  # rc: 0=success/1,2=failed
    local rc

    create_rundir
    touch $RUNDIR/$1; rc=$?
    echo_debug "lock" "set_run_flag.touch: $1; rc=$rc"

    return $rc
}

reset_run_flag () { # reset flag -- $1: flag name
    if rm $RUNDIR/$1 2> /dev/null 1>&2 ; then
        echo_debug "lock" "reset_run_flag($1).remove"
    else
        echo_debug "lock" "reset_run_flag($1).not_found"
    fi

    return 0
}

check_run_flag () { # check flag -- $1: flag name
                    # rc: 0=flag set/1=flag not set
    local rc

    [ -f $RUNDIR/$1 ]; rc=$?
    echo_debug "lock" "check_run_flag($1): rc=$rc"

    return $rc
}

lock_tlp () { # get exclusive lock: blocking with timeout
              # $1: lock id (default: tlp)
              # rc: 0=success/1=failed

    create_rundir
    # open file for writing and attach fd 9
    # when successful lock fd 9 exclusive and blocking
    # wait $LOCKTIMEOUT secs to obtain the lock
    if { exec 9> ${LOCKFILE}_${1:-tlp} ; } 2> /dev/null && $FLOCK -x -w $LOCKTIMEOUT 9 ; then
        echo_debug "lock" "lock_tlp($1).success"
        return 0
    else
        echo_debug "lock" "lock_tlp($1).failed"
        return 1
    fi
}

lock_tlp_nb () { # get exclusive lock: non-blocking
                 # $1: lock id (default: tlp)
                 # rc: 0=success/1=failed

    create_rundir
    # open file for writing and attach fd 9
    # when successful lock fd 9 exclusive and non-blocking
    if { exec 9> ${LOCKFILE}_${1:-tlp} ; } 2> /dev/null && $FLOCK -x -n 9 ; then
        echo_debug "lock" "lock_tlp_nb($1).success"
        return 0
    else
        echo_debug "lock" "lock_tlp_nb($1).failed"
        return 1
    fi
}

unlock_tlp () { # free exclusive lock
                # $1: lock id (default: tlp)

    # defer unlock for $X_DEFER_UNLOCK seconds -- debugging only
    [ -n "$X_DEFER_UNLOCK" ] && sleep $X_DEFER_UNLOCK

    # free fd 9 and scrap lockfile
    { exec 9>&- ; } 2> /dev/null
    rm -f ${LOCKFILE}_${1:-tlp}
    echo_debug "lock" "unlock_tlp($1)"

    return 0
}

lockpeek_tlp () { # check for pending lock (by looking for the lockfile)
                  # $1: lock id (default: tlp)
    if [ -f ${LOCKFILE}_${1:-tlp} ]; then
        echo_debug "lock" "lockpeek_tlp($1).locked"
        return 0
    else
        echo_debug "lock" "lockpeek_tlp($1).not_locked"
        return 1
    fi
}

echo_tlp_locked () { # print "locked" message
    echo "Error: TLP is locked by another operation." 1>&2
    return 0
}

set_timed_lock () { # create timestamp n seconds in the future
    # $1: lock id, $2: lock duration [s]
    local lock rc time

    lock=${1}_timed_lock_$(date +%s -d "+${2} seconds")
    set_run_flag $lock; rc=$?
    echo_debug "lock" "set_timed_lock($1, $2): $lock; rc=$rc"

    # cleanup obsolete locks
    time=$(date +%s)
    for lockfile in $RUNDIR/${1}_timed_lock_*; do
        if [ -f "$lockfile" ]; then
            locktime=${lockfile#${RUNDIR}/${1}_timed_lock_}
            if [ $time -ge $locktime ]; then
                rm -f $lockfile
                echo_debug "lock" "set_timed_lock($1, $2).remove_obsolete: ${lockfile#${RUNDIR}/}"
            fi
        fi
    done

    return $rc
}

check_timed_lock () { # check if active timestamp exists
    # $1: lock id; rc: 0=locked/1=not locked
    local lockfile locktime time

    time=$(date +%s)
    for lockfile in $RUNDIR/${1}_timed_lock_*; do
        if [ -f "$lockfile" ]; then
            locktime=${lockfile#${RUNDIR}/${1}_timed_lock_}
            if [ $time -lt $(( locktime - 120 )) ]; then
                # timestamp is more than 120 secs in the future,
                # something weird has happened -> remove it
                rm -f $lockfile
                echo_debug "lock" "check_timed_lock($1).remove_invalid: ${lockfile#${RUNDIR}/}"
            elif [ $time -lt $locktime ]; then
                # timestamp in the future -> we're locked
                echo_debug "lock" "check_timed_lock($1).locked: $time, $locktime"
                return 0
            else
                # obsolete timestamp -> remove it
                rm -f $lockfile
                echo_debug "lock" "check_timed_lock($1).remove_obsolete: ${lockfile#${RUNDIR}/}"
            fi
        fi
    done

    echo_debug "lock" "check_timed_lock($1).not_locked: $time"
    return 1
}

# --- PATH
add_sbin2path () { # check if /sbin /usr/sbin in $PATH, otherwise add them
                   # retval: $PATH, $_oldpath, $_addpath
    local sp

    # shellcheck disable=SC2034
    _oldpath="$PATH"
    _addpath=""

    for sp in /usr/sbin /sbin; do
        if [ -d $sp ] && [ ! -h $sp ]; then
            # dir exists and is not a symlink
            case ":$PATH:" in
                *":$sp:"*) # $sp already in $PATH
                    ;;

                *) # $sp not in $PATH, add it
                    _addpath="$_addpath:$sp"
                    ;;
            esac
        fi
    done

    if [ -n "$_addpath" ]; then
      export PATH="${PATH}${_addpath}"
    fi

    return 0
}

create_rundir () { # make sure $RUNDIR exists
    [ -d $RUNDIR ] || mkdir -p $RUNDIR 2> /dev/null 1>&2
}

# --- Configuration

read_defaults () { # read config file
    if [ -f $CONFFILE ]; then
        # shellcheck disable=SC1090
        . $CONFFILE
        return 0
    else
        return 1
    fi
}

parse_args4config () { # parse command-line arguments: everything after the
                       # delimiter '--' is interpreted as a config parameter
    # retval: config parameters
    local dflag=0 param value

    echo_debug "arg" "parse_args4config: ${0##/*/} $*"
    # iterate arguments
    while [ $# -gt 0 ]; do
        if [ $dflag -eq 1 ]; then
            # delimiter was passed --> sanitize and parse argument:
            #   format is PARAMETER=value (quotes stripped by the shell calling tlp)
            #   parameter name allows 'A'..'Z' and '_' only, may end in a number (_BAT0)
            #   parameter value allows  'A'..'Z', 'a'..'z', '0'..'9', ' ', '_', '-'
            if printf "%s" "$1" | grep -E -q "$RE_PARAM"; then
                param="${1%%=*}"
                value="${1#*=}"
                if [ -n "$param" ] && [ -n "$value" ]; then
                    echo_debug "arg" "parse_args4config.param: $param=$value"
                    eval "$param='$value'" 2> /dev/null
                fi
            fi
        elif [ "$1" = "--" ]; then
            # delimiter reached --> begin interpretation
            dflag=1
        fi
        shift # next argument
    done # while arguments

    return 0
}

# --- Kernel Modules

load_modules () { # load kernel module(s) -- $*: modules
    local mod

    # verify module loading is allowed (else explicitly disabled)
    # and possible (else implicitly disabled)
    [ "${TLP_LOAD_MODULES:-y}" = "y" ] && [ -e /proc/modules ] || return 0

    # load modules, ignore any errors
    # shellcheck disable=SC2048
    for mod in $*; do
        $MODPRO $mod > /dev/null 2>&1
    done

    return 0
}

# --- DMI

read_dmi () { # read DMI data -- $*: keywords; stdout: dmi strings
    local ds key outr

    outr=""
    # shellcheck disable=SC2048
    for key in $*; do
        ds="$(read_sysf ${DMID}/$key | \
                grep -E -v -i 'not available|to be filled|DMI table is broken')"
        if [ -n "$outr" ]; then
            [ -n "$ds" ] && outr="$outr $ds"
        else
            outr="$ds"
        fi
    done

    printf '%s' "$outr"
    return 0
}

# --- ThinkPad Checks

supports_tpsmapi_only () {
    # rc: 0=ThinkPad supports tpsmapi only/1=false
    # prerequisite: check_thinkpad()
    printf '%s' "$_tpmodel" | grep -E -q "${RE_TPSMAPI_ONLY}"
}

supports_tpsmapi_and_tpacpi () {
    # rc: 0=ThinkPad supports tpsmapi, tpacpi-bat, natacpi/1=false
    # prerequisite: check_thinkpad()
    printf '%s' "$_tpmodel" | grep -E -q "${RE_TPSMAPI_AND_TPACPI}"
}

supports_no_tp_bat_funcs () {
    # rc: 0=ThinkPad doesn't support battery features/1=false
    # prerequisite: check_thinkpad()
    printf '%s' "$_tpmodel" | grep -E -q "${RE_TP_NONE}"
}

supports_tpacpi () {
    # rc: 0=ThinkPad does support tpacpi-bat, natacpi/1=false
    # prerequisite: check_thinkpad()
    # assumption: all newer models support tpapaci-bat/natacapi except
    # explicitly unsupported or older tpsmapi only models
    ! supports_no_tp_bat_funcs && ! supports_tpsmapi_only
}

check_thinkpad () { # check for ThinkPad hardware and save model string,
                 # load ThinkPad specific kernel modules
                 # rc: 0=ThinkPad, 1=other hardware
                 # retval: $_tpmodel
    local pv

    _tpmodel=""

    if [ -d $TPACPIDIR ]; then
        # kernel module thinkpad_acpi is loaded

        if [ -z "$X_SIMULATE_MODEL" ]; then
            # get DMI product string and sanitize it
            pv="$(read_dmi product_version | tr -C -d 'a-zA-Z0-9 ')"
        else
            # simulate arbitrary model
            pv="$X_SIMULATE_MODEL"
        fi

        # check DMI product string for occurrence of "ThinkPad"
        if printf '%s' "$pv" | grep -E -q 'Think[Pp]ad'; then
            # it's a real ThinkPad --> save model substring
            _tpmodel=$(echo $pv | sed -r 's/^Think[Pp]ad //')
        fi
    fi

    if [ -n "$_tpmodel" ]; then
        # load tp-smapi for supported models only; prevents kernel messages
        if supports_tpsmapi_only || supports_tpsmapi_and_tpacpi; then
            load_modules $MOD_TPSMAPI
        fi

        # load acpi-call for supported models only; prevents kernel messages
        if supports_tpacpi; then
            load_modules $MOD_TPACPI
        fi

        echo_debug "bat" "check_thinkpad: tpmodel=$_tpmodel"
        return 0
    fi

    # not a ThinkPad
    echo_debug "bat" "check_thinkpad.not_a_thinkpad: model=$pv"
    return 1
}

is_thinkpad () { # check for ThinkPad by saved model string
                 # rc: 0=ThinkPad, 1=other hardware
    [ -n "$_tpmodel" ]
}

# --- Power Source

get_sys_power_supply () { # get current power supply
                          # retval/rc: $_syspwr (0=ac, 1=battery, 2=unknown)

    # _syspwr is determined as follows:
    #
    #             AC online:  | none | 1    | 0    |
    #           +------------ +------+------+------+
    # battery   | none        | 2    | 0    | 1    |
    # status:   | discharging | 1    | 0    | 1    |
    #           | idle        | 0    | 0    | 1    |
    #
    # Note: existing AC online status has precendence over any battery status

    local psrc
    _syspwr=

    for psrc in /sys/class/power_supply/*; do
        # -f $psrc/type not necessary - read_sysf() handles this

        # ignore atypical power supplies
        echo "$psrc" | grep -E -q "$RE_PS_IGNORE" && continue

        case "$(read_sysf $psrc/type)" in
            Mains|USB)
                # AC detected
                # skip device to simulate broken AC detection
                [ "$X_SIMULATE_AC_QUIRK" = "1" ] && continue

                # check if online
                if [ "$(read_sysf $psrc/online)" = "1" ]; then
                    # AC online
                    _syspwr=0
                    echo_debug "ps" "get_sys_power_supply(${psrc##/*/}).ac_online: syspwr=$_syspwr"
                else
                    # AC offline means battery
                    _syspwr=1
                    echo_debug "ps" "get_sys_power_supply(${psrc##/*/}).ac_offline: syspwr=$_syspwr"
                fi
                break # AC found --> end search
                ;;

            Battery)
                # battery detected
                # --> inspect unless discharging battery has been found beforehand
                [ "$_syspwr" = "1" ] && continue

                case "$(read_sysf $psrc/status)" in
                    Discharging)
                        if ! lockpeek_tlp tlp_discharge; then
                            # discharging means battery ...
                            _syspwr=1
                            echo_debug "ps" "get_sys_power_supply(${psrc##/*/}).bat_discharging: syspwr=$_syspwr"
                        else
                            # ... unless forced discharge is in progress,
                            # which means AC and end of iteration
                            _syspwr=0
                            echo_debug "ps" "get_sys_power_supply(${psrc##/*/}).forced_discharge: syspwr=$_syspwr"
                            break
                        fi
                        ;;

                    *) # everything else including "Unknown" could mean AC,
                       # so re-check after a short delay to be sure to catch
                       # lagging battery status updates
                        sleep 0.5
                        if [ "$(read_sysf $psrc/status)" = "Discharging" ]; then
                            _syspwr=1
                            echo_debug "ps" "get_sys_power_supply(${psrc##/*/}).bat_discharging2: syspwr=$_syspwr"
                        else
                            _syspwr=0
                            echo_debug "ps" "get_sys_power_supply(${psrc##/*/}).bat_idle: syspwr=$_syspwr"
                        fi
                        ;;
                esac
                # battery found: continue looking for AC and more batteries
                ;;

            *) # unknown
                ;;
        esac
    done

    # set _syspwr to unknown if we haven't seen any AC/battery power source so far
    if [ -z "$_syspwr" ]; then
        _syspwr=2
        echo_debug "ps" "get_sys_power_supply.unknown: syspwr=$_syspwr"
    fi

    return $_syspwr
}

get_power_state () { # get current power mode -- rc: 0=ac, 1=battery
    # similar to get_sys_power_supply(),
    # but maps unknown power source to TLP_DEFAULT_MODE;
    # returns TLP_DEFAULT_MODE when TLP_PERSISTENT_DEFAULT=1

    get_sys_power_supply
    local rc=$?

    : ${TLP_DEFAULT_MODE:=}
    : ${TLP_PERSISTENT_DEFAULT:=${DEFAULT_TLP_PERSISTENT_DEFAULT}}

    if [ -n "$TLP_DEFAULT_MODE" ] \
        && [ "$TLP_PERSISTENT_DEFAULT" = "1" ]; then
        # persistent mode, use configured default mode
        case "$TLP_DEFAULT_MODE" in
            bat|BAT) rc=1 ;;
            *)       rc=0 ;;
        esac
    else
        # non-persistent mode, use current power source
        if [ $rc -eq 2 ]; then
            # unknown power supply, use configured default mode
            case "$TLP_DEFAULT_MODE" in
                ac|AC)   rc=0 ;;
                bat|BAT) rc=1 ;;
                *)       rc=0 ;; # use AC if no default mode configured
            esac
        fi
    fi

    return $rc
}

compare_and_save_power_state() { # compare $1 to last saved power state,
    # save $1 afterwards when different
    # $1: new state 0=ac, 1=battery
    # rc: 0=different, 1=equal
    local lp

    # intercept invalid states
    case $1 in
        0|1) ;; # valid state
        *) # invalid new state --> return "different"
            echo_debug "pm" "compare_and_save_power_state($1).invalid"
            return 0
            ;;
    esac

    # read saved state
    lp=$(read_sysf $PWRRUNFILE)

    # compare
    if [ -z "$lp" ] || [ "$lp" != "$1" ]; then
        # saved state is nonexistent/empty or is different --> save new state
        create_rundir
        write_sysf "$1" $PWRRUNFILE
        echo_debug "pm" "compare_and_save_power_state($1).different: old=$lp"
        return 0
    else
        # touch file for last run
        touch $PWRRUNFILE
        echo_debug "pm" "compare_and_save_power_state($1).equal"
        return 1
    fi
}

clear_saved_power_state() { # remove last saved power state

    rm -f $PWRRUNFILE 2> /dev/null

    return 0
}

check_ac_power () { # check if ac power connected -- $1: function

    if ! get_sys_power_supply ; then
        echo_debug "bat" "check_ac_power($1).no_ac_power"
        echo "Error: $1 is possible on AC power only." 1>&2
        return 1
    fi

    return 0
}

echo_started_mode () { # print operation mode -- $1: 0=ac mode, 1=battery mode
    if [ "$1" = "0" ]; then
        echo "TLP started in AC mode."
    else
        echo "TLP started in battery mode."
    fi

    return 0
}

set_manual_mode () { # set manual operation mode
    # $1: 0=ac mode, 1=battery mode
    # retval: $_manual_mode (0=ac, 1=battery, none=not found)

    create_rundir
    write_sysf "$1" $MANUALMODEFILE
    _manual_mode="$1"

    echo_debug "pm" "set_manual_mode($1)"
    return 0
}

clear_manual_mode () { # remove manual operation mode
    # retval: $_manual_mode (none)

    rm -f $MANUALMODEFILE 2> /dev/null
    _manual_mode="none"

    echo_debug "pm" "clear_manual_mode"
    return 0
}

get_manual_mode () { # get manual operation mode
    # rc: 0=ok/1=not found
    # retval: $_manual_mode (0=ac, 1=battery, none=not found)
    local rc=1
    _manual_mode="none"

    if [ -f $MANUALMODEFILE ]; then
        # read mode file
        _manual_mode=$(read_sysf $MANUALMODEFILE)
        case $_manual_mode in
            0|1) rc=0 ;;
            *) _manual_mode="none" ;;
        esac
    fi
    return $rc
}

# --- Misc Checks

check_laptop_mode_tools () { # check if lmt installed -- rc: 0=not installed, 1=installed
    if cmd_exists $LAPMODE; then
        echo 1>&2
        echo "***Warning: laptop-mode-tools detected, this may cause conflicts with TLP." 1>&2
        echo "            Please uninstall laptop-mode-tools." 1>&2
        echo 1>&2
        echo_debug "pm" "check_laptop_mode_tools: yes"
        return 1
    else
        return 0
    fi
}

