#!/bin/sh
# tlp - Base Functions
#
# Copyright (c) 2025 Thomas Koch <linrunner at gmx.net> and others.
# SPDX-License-Identifier: GPL-2.0-or-later

# shellcheck disable=SC2034

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

readonly TLPVER="1.9.1"

readonly RUNDIR=/run/tlp
readonly VARDIR=/var/lib/tlp

readonly CONF_DEF=/usr/share/tlp/defaults.conf
readonly CONF_DIR=/etc/tlp.d
readonly CONF_USR=/etc/tlp.conf
readonly CONF_OLD=/etc/default/tlp
readonly CONF_RUN="$RUNDIR/run.conf"

readonly FLOCK=flock
readonly GDBUS=gdbus
readonly HDPARM=hdparm
readonly LAPMODE=laptop_mode
readonly LOGGER=logger
readonly MKTEMP=mktemp
readonly MODPRO=modprobe
readonly MODINFO=modinfo
readonly PPD=power-profiles-daemon
readonly READCONFS=/usr/share/tlp/tlp-readconfs
readonly SYSTEMCTL=systemctl
readonly TPACPIBAT=@TPACPIBAT@
readonly TUNEDPPD="tuned-ppd"
readonly UDEVADM=udevadm
readonly UDEVADM_TEST="udevadm test"

readonly TLPRDW=tlp-rdw
readonly TLPPD=tlp-pd

readonly TLPPD_BUS_NAME="org.freedesktop.UPower.PowerProfiles"
readonly TLPPD_INTERFACE_NAME="$TLPPD_BUS_NAME"
readonly TLPPD_OBJECT_PATH="/org/freedesktop/UPower/PowerProfiles"
readonly PROPERTY_INTERFACE_NAME="org.freedesktop.DBus.Properties"

readonly LOCKFILE=$RUNDIR/lock
readonly LOCKTIMEOUT=2

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

readonly DMID=/sys/devices/virtual/dmi/id/
readonly NETD=/sys/class/net
readonly TPACPID=/sys/devices/platform/thinkpad_acpi

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

# power supplies: ignore MacBook Pro 2017 sbs-charger, ThinkPad X13s ARM qcom-battmgr-ac, hid devices, game controllers
readonly RE_PS_IGNORE='sbs-charger|qcom-battmgr-ac|hidpp_battery|hid-|controller-battery-|controller_battery_'

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

readonly TLP_SERVICE="tlp.service"
readonly TLPPD_SERVICE="tlp-pd.service"
readonly PPD_SERVICE="power-profiles-daemon.service"
readonly RFKILL_SERVICES="systemd-rfkill.service systemd-rfkill.socket"

# power supplies
readonly PS_AC=0
readonly PS_BAT=1
readonly PS_UNKNOWN=128

# power profiles
readonly PP_PRF=0
readonly PP_BAL=1
readonly PP_SAV=2
readonly PP_SUS=3
readonly PP_VALID="0 1 2"

# default/manual/persistent mode exceptional cases
readonly MPM_UNKNOWN=128
readonly MPM_OFF=255

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

_nodebug=0

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

# -- Exit

do_exit () { # cleanup and exit  -- $1: rc
    # remove temporary runconf
    [ -z "$_conf_tmp" ] || rm -f -- "$_conf_tmp"

    exit "$1"
}

# --- Messages

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

    if wordinlist "$1" "$TLP_DEBUG"; then
        $LOGGER -p debug -t "tlp" --id=$$ -- "$2" > /dev/null 2>&1
    fi
}

cprintf_init () {
    # preset ANSI sequences for colorized message output
    # retval: $_cprintf_color_err
    #         $_cprintf_color_warn
    #         $_cprintf_color_note
    #         $_cprintf_color_succ
    #         $_cprintf_color_dbg
    #         $_cprintf_color_rst

    # proceed only when external printf command exists
    if [ -n "$TLP_MSG_COLORS" ]; then
        # shellcheck disable=SC2086
        set -- $TLP_MSG_COLORS
        # note: dash internal printf does *not* support '\x1b' notation for ESC
        is_uint "$1" 3 && _cprintf_color_err="\033[1;${1}m"
        is_uint "$2" 3 && _cprintf_color_warn="\033[1;${2}m"
        is_uint "$3" 3 && _cprintf_color_note="\033[1;${3}m"
        is_uint "$4" 3 && _cprintf_color_succ="\033[1;${4}m"
        is_uint "$5" 3 && _cprintf_color_dbg="\033[1;${5}m"
        _cprintf_color_rst="\033[0m"
    fi
}

cprintf () {
    # printf colorized message to stdout
    # color is selected by class from $1 or, if the argument is left blank, by the message prefix
    # $1: message class: err/warning/notice/debug
    # $2: printf format string == message
    # $3..n: printf arguments
    # prerequisite: cprintf_init()

    local class color fmt

    class="$1"; shift
    fmt="$1"; shift
    if [ -z "$class" ]; then
        # explicit class not specified -> cut out message prefix preceding ": "
        case "${fmt}" in
            Error*)   class="err" ;;
            Warning*) class="warning" ;;
            Notice*)  class="notice" ;;
            Debug*)   class="debug" ;;
        esac
    fi
    case "$class" in
        err)     color="$_cprintf_color_err" ;;
        warning) color="$_cprintf_color_warn" ;;
        notice)  color="$_cprintf_color_note" ;;
        success) color="$_cprintf_color_succ" ;;
        debug)   color="$_cprintf_color_dbg" ;;
        *)       color="" ;;
    esac

    if  [ -n "$color" ] && [ -t 1 ]; then
        # color is specd and output is a terminal (not a pipe)
        # shellcheck disable=SC2059
        printf "${color}${fmt}${_cprintf_color_rst}" "$@"
    else
        # shellcheck disable=SC2059
        printf "$fmt" "$@"
    fi

    return 0
}

cecho () {
    # echo colorized message to stdout, terminated by LF
    # color is selected by the message prefix
    # $1: message string
    # $2: message class: err/warning/notice/debug
    # prerequisite: cprintf_init()
    cprintf "$2" "$1\n"
}

echo_message () {
    # output message according to TLP_MSG_LEVEL
    # $1: message
    # $2: message class/log level for syslog: : err/warning/notice/debug/info

    local msg
    local class

    msg="$1"
    class="$2"

    # shellcheck disable=SC2154
    if [ "$_bgtask" = "1" ]; then
        # called from background task --> use syslog
        if [ -z "$class" ]; then
            # explicit class not specified -> cut out message prefix preceding ": "
            case "${msg%: *}" in
                Error)   class="err" ;;
                Warning) class="warning" ;;
                Notice)  class="notice" ;;
                Debug)   class="debug" ;;
                *)       class="info" ;;
            esac
        fi

        if [ -n "$msg" ]; then
            case "$TLP_WARN_LEVEL" in
                1|3) $LOGGER -p "$class" -t "tlp" --id=$$ -- "$msg" > /dev/null 2>&1 ;;
            esac
        fi
    else
        # called from command line task --> use stderr
        case "$TLP_WARN_LEVEL" in
            2|3)
                # shellcheck disable=SC2059
                cecho "$msg" "$class" 1>&2
                ;;
        esac
    fi
}

print_version () {
    echo "TLP version $TLPVER"
}

# --- Strings

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

toupper () { # print string in uppercase -- $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

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

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

read_sysval () {
    # read and print contents of a sysfile
    # print '0' if file is non-existent, read fails or content 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
}

# --- Globbing

glob_files () {
    # @stdout glob_files ( glob_pattern, dir[, dir...] )
    #
    # Nested loop that applies a glob expression to several directories
    # (or path prefixes) and prints matching file paths (including symlinks)
    # to stdout.
    #
    # NOTE: for x in $(glob_files 'a*' dirpath ); do ...; done
    # globs twice:
    #   (a) once in the "for file_iter" loop in glob_files()
    #   (b) another time when x gets word expanded in the "for x" loop
    # crafted filenames (e.g. a file named '*') will break this function,
    # as such it should be only be used with 'sort-of trustworthy' directories
    # (sysfs, proc).

    [ -n "${1-}" ] || return 64
    local glob_pattern file_iter
    local rc=1

    glob_pattern="${1}"

    while shift && [ $# -gt 0 ]; do
        for file_iter in ${1}${glob_pattern}; do
            if [ -f "${file_iter}" ] || [ -L "${file_iter}" ]; then
                printf '%s\n' "${file_iter}"
                rc=0
            fi
        done
    done

    return $rc
}

glob_dirs () {
    # @stdout glob_dirs ( glob_pattern, dir[, dir...] )
    #
    # Nested loop that applies a glob expression to several directories
    # (or path prefixes) and prints matching directory paths to stdout.
    #
    # NOTE: globs twice, see glob_files().

    [ -n "${1-}" ] || return 64
    local glob_pattern dir_iter
    local rc=1

    glob_pattern="${1}"

    while shift && [ $# -gt 0 ]; do
        for dir_iter in ${1}${glob_pattern}; do
            if [ -d "${dir_iter}" ]; then
                printf '%s\n' "${dir_iter}"
                rc=0
            fi
        done
    done

    return $rc
}

# --- Checks

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 quit when root privilege missing
    if ! test_root; then
        cecho "Error: missing root privilege." 1>&2
        do_exit 1
    fi
}

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

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

check_tlp_pd_installed () {
    cmd_exists "$TLPPD"
}

check_systemd () {
    # check if systemd is the active init system (PID 1) and systemctl is installed
    # rc: 0=yes, 1=no
    [ -d /run/systemd/system ] && cmd_exists $SYSTEMCTL
}

check_service_state () {
    # check service state
    # $1: service
    # $2: state match: active/enabled/masked
    # rc: 0=yes, 1=no
    case "$2" in
        active)  $SYSTEMCTL is-active "$1" > /dev/null 2>&1 ;;
        enabled) $SYSTEMCTL is-enabled "$1" > /dev/null 2>&1 ;;
        masked)  $SYSTEMCTL is-enabled "$1" 2> /dev/null | grep -q 'masked' ;;
    esac
}

check_services_activation_status () {
    # issue messages for
    # - TLP service(s) not enabled
    # - conflicting services enabled
    # rc: 0=no messages/messages issued

    local rc=0

    if check_systemd; then
        if ! check_service_state "$TLP_SERVICE" enabled > /dev/null 2>&1; then
            echo_message "Error: TLP's power saving will not apply on boot because $TLP_SERVICE is not enabled "`
                        `"--> Invoke 'systemctl enable $TLP_SERVICE' to ensure the full functionality of TLP." "err"
            echo_message ""
            rc=1
        fi
        if check_tlp_pd_installed && ! check_service_state "$TLPPD_SERVICE" enabled > /dev/null 2>&1; then
            if ! check_service_state "$TLPPD_SERVICE" active; then
                echo_message "Error: You can't switch power profiles on the desktop because $TLPPD_SERVICE is not enabled "`
                            `"--> Invoke 'systemctl enable --now $TLPPD_SERVICE' to ensure the full functionality of TLP." "err"
            else
                echo_message "Error: After the next restart, you won't be able to switch power profiles on the desktop because "`
                            `"$TLPPD_SERVICE is not enabled --> Invoke 'systemctl enable $TLPPD_SERVICE' to ensure the "`
                            `"full functionality of TLP." "err"
            fi
            echo_message ""
            rc=1
        fi
        for su in $RFKILL_SERVICES; do
            if ! check_service_state "$su" masked 2> /dev/null; then
                if [ "$RESTORE_DEVICE_STATE_ON_STARTUP" = "1" ]; then
                    echo_message "Warning: TLP's radio device switching on boot may not work as expected because "`
                                `"RESTORE_DEVICE_STATE_ON_STARTUP=1 is configured and $su is not masked "`
                                `"--> Invoke 'systemctl mask $su' to ensure the full functionality of TLP." "err"
                    echo_message ""
                elif [ -n "$DEVICES_TO_DISABLE_ON_STARTUP" ] || [ -n "$DEVICES_TO_ENABLE_ON_STARTUP" ]; then
                    echo_message "Warning: TLP's radio device switching on boot may not work as expected because "`
                                `"DEVICES_TO_DISABLE_ON_STARTUP or DEVICES_TO_ENABLE_ON_STARTUP "`
                                `"is configured and $su is not masked "`
                                `"--> Invoke 'systemctl mask $su' to ensure the full functionality of TLP." "err"
                    echo_message ""
                fi
                rc=1
            fi
        done
    fi

    return $rc
}

check_ppd_running () {
    # check if power-profiles-daemon is running
    # rc: 0=yes, 1=no
    # shellcheck disable=SC2009
    ps aux 2> /dev/null | grep -v "grep" | grep -q "/$PPD"
}

# --- Type and value checking

is_uint () { # check for unsigned integer -- $1: string; $2: max digits
    printf "%s" "$1" | grep -E -q "^[0-9]{1,$2}$" 2> /dev/null
}

is_within_bounds () { # check condition min <= value <= max
    # $1: value; $2: min; $3: max (all unsigned int)
    # rc: 0=within/1=below/2=above/255=invalid
    #
    # value, min or max undefined/non-numeric means that this branch of the
    # condition is fulfilled

    is_uint "$1" || return 255
    if is_uint "$2"; then
        [ "$1" -ge "$2" ] || return 1
    fi
    if is_uint "$3"; then
        [ "$1" -le "$3" ] || return 2
    fi

    return  0
}

# --- 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 && timeout $LOCKTIMEOUT $FLOCK -x 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
    cecho "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
}

# --- Environment
print_shell () { # determine the shell executing this script
    readlink -n "/proc/$$/exe"
}

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

    _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
}

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

chmod_readable4all () { # make file world readable -- $1: file
    chmod -f o+r "$1"
}

# -- Battery Plugins

select_batdrv () { # source battery feature drivers and
                   # activate the one that matches the hardware

    # do not execute twice
    # shellcheck disable=SC2154
    [ -z "$_batdrv_selected" ] || return 0

    # iterate until a matching driver is found
    for batdrv in /usr/share/tlp/bat.d/[0-9][0-9]-[a-z]*; do
        # shellcheck disable=SC1090
        . "$batdrv" || exit 70

        # end iteration when a matching driver is found
        batdrv_init && break
    done

    return 0
}

# --- Configuration

read_config () { # read all config files and write temporary runconf file
    # $1: 1=no trace
    # $2..n: arguments of the caller for examination if in-cmd-config TLP_DISABLE_DEFAULTS=1 is set
    # rc: 0=ok/5=tlp.conf missing/6=defaults.conf missing/7=file creation error
    # retval: config parameters;
    #         _conf_tmp: runconf
    local rc=0
    local in_cmd_cfg=0
    local no_trace="$1"
    local tmpdir
    local xargs=""

    # check if caller holds in-cmd-config TLP_DISABLE_DEFAULTS=1
    shift
    while [ $# -gt 0 ]; do
        if [ "$in_cmd_cfg" = "1" ] && [ "$1" = "TLP_DISABLE_DEFAULTS=1" ]  ; then
            xargs="--skipdefs"
        elif [ "$1" = "--" ]; then
            in_cmd_cfg=1
        fi
        shift # next argument
    done # while arguments

    if test_root; then
        tmpdir=$RUNDIR
        create_rundir
    else
        tmpdir=${TMPDIR:-/tmp}
    fi
    if _conf_tmp=$($MKTEMP -p "$tmpdir" "tlp-run.conf_tmpXXXXXX"); then
        # external perl script: merge all config files
        if [ "$no_trace" = "1" ]; then
            if [ -z "$xargs" ]; then
                xargs="--notrace"
            else
                xargs="$xargs --notrace"
            fi
        fi
        # shellcheck disable=SC2086
        $READCONFS --outfile "$_conf_tmp" $xargs; rc=$?
        # shellcheck disable=SC1090
        [ $rc -eq 0 ] && . "$_conf_tmp"
    else
        rc=7
    fi

    if [ $rc -ne 0 ]; then
        case $rc in
            5) cecho "Error: cannot read user configuration from $CONF_USR or $CONF_OLD." 1>&2 ;;
            6) cecho "Error: cannot read default configuration from $CONF_DEF." 1>&2 ;;
            7) cecho "Error: cannot write runtime configuration to $_conf_tmp." 1>&2 ;;
        esac
    fi

    return 0
}

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

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

    return 0
}

save_runconf () { # copy temporary to final runconf
    create_rundir
    if cp --preserve=timestamps "$_conf_tmp" $CONF_RUN > /dev/null 2>&1; then
        chmod 664 "$_conf_tmp" $CONF_RUN > /dev/null 2>&1
        echo_debug "run" "save_runconf.ok: $_conf_tmp -> $CONF_RUN"
    else
        echo_debug "run" "save_runconf.failed: $_conf_tmp -> $CONF_RUN"
    fi
}

# --- Kernel

kernel_version_ge () { # check if running kernel version >= $1: minimum version

    [ "$1" = "$(printf "%s\n%s\n" "$1" "$(uname -r)" | sort -V | head -n 1)" ]
}

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 and Hardware

read_dmi () { # read DMI data
    # $1: dmi id
    # stdout: dmi string
    # rc: 0=ok/1=nonexistent

    local out

    out="$(read_sysf "${DMID}/$1" | \
            grep -E -v -i 'not available|to be filled|DMI table is broken')"
    printf '%s' "$out"
    if [ -n "$out" ]; then
        return 0
    else
        return 1
    fi
}

is_laptop () { # check if machine is a laptop
    # rc: 0=laptop/1=other/desktop
    case "$(read_dmi "chassis_type")" in
        8|9|10|11) return 0 ;;
        *) return 1 ;;
    esac
}

# --- Power Source

get_sys_power_supply () {
    # determine active power supply
    # $1: tlp command
    # rc: PS_AC=0/PS_BAT=/PS_UNKNOWN=128
    # retval: $_syspwr == rc
    #         $_psdev: 1st power supply (sysfs path) found (for udev rule check)
    #
    # examine all power supply devices in lexical order, typically this is:
    #   AC, ADPx (AC chargers) -> BATx, CMBx (batteries) -> ucsi* (USB).
    # names in $RE_PS_IGNORE are ignored.
    #
    # the ranking of power source classes for the determination of the active
    # power supply is as follows:
    #   1. AC chargers
    #   2. USB chargers (also power banks)
    #   3. Batteries
    # $TLP_PS_IGNORE may be used to ignore one or more power source classes

    local bs ps_ignore psrc psrc_name
    local ac0seen=
    local tlp_cmd="$1"
    local wait=
    _psdev=""

    _syspwr="$X_SIMULATE_PS"
    if [ -n "$_syspwr" ]; then
        # simulate power supply
        echo_debug "ps" "get_sys_power_supply.simulate: syspwr=$_syspwr"
        return "$_syspwr"
    fi

    ps_ignore=$(toupper "$TLP_PS_IGNORE")
    for psrc in /sys/class/power_supply/*; do
        # -f $psrc/type not necessary - read_sysf() handles this
        psrc_name="${psrc##*/}"

        # ignore atypical power supplies and batteries
        printf '%s\n' "$psrc_name" | grep -E -q "$RE_PS_IGNORE" && continue

        case "$(read_sysf "$psrc/type")" in
            Mains)
                # AC detected
                _psdev="${_psdev:-$psrc}"
                # if configured, skip device to ignore incorrect AC status
                if wordinlist "AC" "$ps_ignore"; then
                    echo_debug "ps" "get_sys_power_supply(${psrc_name}).ac_ignored: syspwr=$_syspwr"
                    continue
                fi

                # check AC status
                if [ "$(read_sysf "$psrc/online")" = "1" ]; then
                    # AC online --> end iteration
                    _syspwr=$PS_AC
                    echo_debug "ps" "get_sys_power_supply(${psrc_name}).ac_online: syspwr=$_syspwr"
                    break
                else
                    # AC offine --> end iteration
                    _syspwr=$PS_BAT
                    echo_debug "ps" "get_sys_power_supply(${psrc_name}).ac_offline: syspwr=$_syspwr"
                    break
                fi
                ;;

            USB)
                # USB PS detected
                _psdev="${_psdev:-$psrc}"
                # if configured, skip device to ignore incorrect USB status
                if wordinlist "USB" "$ps_ignore"; then
                    echo_debug "ps" "get_sys_power_supply(${psrc_name}).usb_ignored: syspwr=$_syspwr"
                    continue
                fi

                # check USB PS status
                if [ "$(read_sysf "$psrc/online")" = "1" ]; then
                    # USB online --> end iteration
                    _syspwr=$PS_AC
                    echo_debug "ps" "get_sys_power_supply(${psrc_name}).usb_online: syspwr=$_syspwr"
                    break
                else
                    # USB PS offline could mean battery, but multiple connectors may exist
                    # --> remember and continue looking
                    ac0seen=1
                    echo_debug "ps" "get_sys_power_supply(${psrc_name}).remember_usb_offline"
                fi
                ;;

            Battery)
                # battery detected
                _psdev="${_psdev:-$psrc}"
                # if configured, skip device to ignore incorrect battery status
                if wordinlist "BAT" "$ps_ignore"; then
                    echo_debug "ps" "get_sys_power_supply(${psrc_name}).bat_ignored: syspwr=$_syspwr"
                    continue
                fi

                # check battery status
                bs="$(read_sysf "$psrc/status")"
                if [ "$bs" != "Discharging" ] && [ "$tlp_cmd" = "auto" ] && [ -z "$wait" ]; then
                    # if command is 'tlp auto', not "Discharging" might be caused by lagging battery status updates
                    # --> recheck every 0.1 secs for 1.5 secs (or user value in deciseconds) max
                    # use delay loop only once
                    wait="$X_PS_WAIT_DS"
                    is_uint "$wait" 2 || wait=15
                    echo_debug "ps" "get_sys_power_supply(${psrc_name}).bat_not_discharging_recheck: bs=$bs; syspwr=$_syspwr; wait=$wait"
                    while [ "$wait" -gt 0 ]; do
                        sleep 0.1
                        wait=$((wait - 1))
                        bs="$(read_sysf "$psrc/status")"
                        [ "$bs" = "Discharging" ] && break
                    done
                fi
                case "$bs" in
                    Discharging)
                        if ! lockpeek_tlp tlp_discharge; then
                            # battery status "Discharging" means battery mode ...
                            _syspwr=$PS_BAT
                            echo_debug "ps" "get_sys_power_supply(${psrc_name}).bat_discharging: syspwr=$_syspwr; wait=$wait"
                        else
                            # ... unless forced discharge is in progress, which means AC
                            _syspwr=$PS_AC
                            echo_debug "ps" "get_sys_power_supply(${psrc_name}).forced_discharge: syspwr=$_syspwr; wait=$wait"
                        fi
                        break # --> end iteration
                        ;;

                    *) # assume AC mode for everything else, e.g. "Charging", "Full", "Not charging", "Unknown"
                       # --> continue looking because there may be multiple batteries
                        _syspwr=$PS_AC
                        echo_debug "ps" "get_sys_power_supply(${psrc_name}).bat_not_discharging: bs=$bs; syspwr=$_syspwr; wait=$wait"
                        ;;
                esac
                ;;

            *) # unknown power source type --> ignore
                ;;
        esac
    done

    if [ -z "$_syspwr" ]; then
        # _syspwr result yet undecided
        if [ "$ac0seen" = "1" ]; then
            # AC offline remembered --> battery mode
            _syspwr=$PS_BAT
            echo_debug "ps" "get_sys_power_supply(${ac0seen##/*/}).ac_offline_remembered: syspwr=$_syspwr"
        else
            # we have seen neither a AC nor a battery power source --> unknown mode
            _syspwr=$PS_UNKNOWN
            echo_debug "ps" "get_sys_power_supply.none_found: syspwr=$_syspwr"
        fi
    fi

    return "$_syspwr"
}

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"
        cecho "Error: $1 is possible on AC power only." 1>&2
        return 1
    fi

    return 0
}

# --- Default, Persistent and Manual Profiles

get_default_mode () { # get default and persistent power profile
    # retval: $_default_mode, $_persist_mode (PP_PRF=0, PP_BAL=1, PP_SAV=2, MPM_OFF=255)

    _default_mode=$MPM_OFF
    _persist_mode=$MPM_OFF

    if [ "$TLP_AUTO_SWITCH" = "0" ]; then
        # auto switching disabled, return default profile
        case "$(toupper "$TLP_DEFAULT_MODE")" in
            PRF|AC)  _default_mode="$PP_PRF" ;;
            BAL|BAT) _default_mode="$PP_BAL" ;;
            SAV)     _default_mode="$PP_SAV" ;;
        esac
    fi

    if [ "$TLP_PERSISTENT_DEFAULT" = "1" ]; then
        # persistent profile
        case "$(toupper "$TLP_DEFAULT_MODE")" in
            PRF|AC)  _persist_mode="$PP_PRF" ;;
            BAL|BAT) _persist_mode="$PP_BAL" ;;
            SAV)     _persist_mode="$PP_SAV" ;;
        esac
    fi
}

set_manual_mode () { # set manual profile
    # $1: profile: PP_PRF=0/PP_BAL=1/PP_SAV=2
    # retval: $_manual_mode (PP_PRF=0, PP_BAL=1, PP_SAV=2)

    if ! wordinlist "$1" "$PP_PRF $PP_BAL $PP_SAV"; then
        echo_debug "ps" "set_manual_mode($1).invalid"
        return 1
    fi

    create_rundir
    if write_sysf "$1" $MANUALMODEFILE; then
        _manual_mode="$1"
        echo_debug "ps" "set_manual_mode($1).ok"
        return 0
    else
        echo_debug "ps" "set_manual_mode($1).write_error"
        return 1
    fi
}

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

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

    echo_debug "ps" "clear_manual_mode"
    return 0
}

get_manual_mode () { # get manual profile
    # rc: 0=active/1=inactive
    # retval: $_manual_mode (PP_PRF=0, PP_BAL=1, PP_SAV=2, MPM_OFF=255
    local rc=1
    _manual_mode="$MPM_OFF"

    if [ -f $MANUALMODEFILE ]; then
        # read mode file
        if _manual_mode=$(read_sysf $MANUALMODEFILE); then
            case $_manual_mode in
                "$PP_PRF"|"$PP_BAL"|"$PP_SAV") rc=0 ;;
                *) _manual_mode=$MPM_UNKNOWN ;;
            esac
        else
            # cannot read mode file - possible cause:
            #   tlp-stat -s is running without root privilege and the file is not world readable
            #   because tlp ac/bat is invoked with sudo when creating the file and thereby a
            #   restrictive umask applies; see https://github.com/linrunner/TLP/issues/702
            # -> manual mode is active but operation mode cannot be determined
            _manual_mode=$MPM_UNKNOWN
            rc=0
        fi
    fi

    return $rc
}

# --- Power Profiles

pp2str () { # convert power profile code to string
    # $1: profile: PP_PRF=0/PP_BAL=1/PP_SAV=2

    case "$1" in
        "$PP_PRF")  printf "performance" ;;
        "$PP_BAL")  printf "balanced" ;;
        "$PP_SAV")  printf "power-saver" ;;
        *)          printf "unknown" ;;
    esac
}

pp2strx () { # convert power profile code to string plus config suffix
    # $1: profile: PP_PRF=0/PP_BAL=1/PP_SAV=2

    pp2str "$1"
    case "$1" in
        "$PP_PRF")  printf "/AC" ;;
        "$PP_BAL")  printf "/BAT" ;;
        "$PP_SAV")  printf "/SAV" ;;
    esac
}

read_saved_power_profile () {
    # get saved power profile
    # retval: $_pp_last (PP_PRF=0, PP_BAL=1, PP_SAV=2)
    #         $_ps_last (PS_AC=0, PS_BAT=1, PS_UNKNOWN=128)

    # read last saved profile and power source from statefile
    if [ ! -f $PWRRUNFILE ]; then
        # file non-existent
        _pp_last="$PS_UNKNOWN"
        _ps_last="$PS_UNKNOWN"
    else
        read -r _pp_last _ps_last < $PWRRUNFILE 2> /dev/null
    fi

    # intercept invalid saved states
    case "$_pp_last" in
        "$PP_PRF"|"$PP_BAL"|"$PP_SAV"|"$PS_UNKNOWN")
            ;;

        "") # nothing
            echo_debug "ps" "read_saved_power_profile.no_pp"
            _pp_last="$PS_UNKNOWN"
            ;;

        *) # invalid
            echo_debug "ps" "read_saved_power_profile.invalid_pp: saved=$_pp_last"
            _pp_last="$PS_UNKNOWN"
            ;;
    esac

    case "$_ps_last" in
        "$PS_AC"|"$PS_BAT"|"$PS_UNKNOWN")  # valid
            ;;

        "") # nothing
             echo_debug "ps" "read_saved_power_profile.no_ps"
            _ps_last="$PS_UNKNOWN"
            ;;

        *) # invalid
            echo_debug "ps" "read_saved_power_profile.invalid_ps: saved=$_ps_last"
            _ps_last="$PS_UNKNOWN"
            ;;
    esac

    return 0
}

get_next_power_profile () {
    # determine next power profile: either
    # - depending on the tlp command,
    # - actual power source or
    # - default/persistence setting
    # $1: tlp command
    # retval: $_pp_next, $_pp_last (PP_PRF=0, PP_BAL=1, PP_SAV=2),
    #         $_default_mode, $persist_mode (PP_PRF=0, PP_BAL=1, PP_SAV=2, MPM_OFF=255)
    # rc: same as $_pp_next
    # like get_sys_power_supply(), plus:
    # - maps unknown power source to TLP_DEFAULT_MODE
    # - returns manual or persistent mode if applicable and not 'tlp start'
    # - if TLP_DEFAULT_MODE is unconfigured and power source is unknown,
    #   returns PP_BAL for laptop / PP_PRF for desktop/other

    local neutral tlp_cmd

    tlp_cmd="$1"
    neutral=0

    read_saved_power_profile

    # determine current power supply
    get_sys_power_supply "$tlp_cmd"
    get_manual_mode
    get_default_mode
    _pp_next="$PS_UNKNOWN"

    # determine next power profile based on command
    if [ "$_persist_mode" != "$MPM_OFF" ]; then
        # persistent mode
        _pp_next=$_persist_mode
    else
        # non-persistent mode
        case "$tlp_cmd" in
            init|start)
                if [ "$_default_mode" != "$MPM_OFF" ]; then
                    # use default profile
                    _pp_next="$_default_mode"
                else
                    # use profile for power source
                    _pp_next=$_syspwr
                fi
                clear_manual_mode
                ;;

            auto|resume)
                if wordinlist "$_manual_mode" "$PP_VALID"; then
                    # use manual mode
                    _pp_next=$_manual_mode
                else
                    if     { [ "$TLP_AUTO_SWITCH" = "0" ] && [ "$_pp_last" != "$PS_UNKNOWN" ]; } \
                        || { [ "$TLP_AUTO_SWITCH" = "1" ] && [ "$_syspwr" = "$_ps_last" ]; } \
                        || { [ "$TLP_AUTO_SWITCH" = "2" ] && [ "$_pp_last" != "$_ps_last" ]; }; then
                        # 1. switching disabled AND last profile is known
                        #  OR
                        # 2. auto switching AND power source did not change
                        #    (ignores meaningless power_supply events triggered by unplugging USB-C (disk) devices)
                        #  OR
                        # 3. smart switching AND non-standard profile selected (by user)
                        # --> keep profile
                        _pp_next=$_pp_last
                    else
                        # 3. (plain old) auto switching
                        #  OR
                        # 4. last profile is unknown
                        # --> set next profile according to default or current power source
                        if [ "$_default_mode" != "$MPM_OFF" ]; then
                            # use default profile
                            _pp_next="$_default_mode"
                        else
                            # use profile for power source
                            _pp_next=$_syspwr
                        fi
                    fi
                fi
                ;;

            bat)
                _pp_next="$PP_BAL"
                set_manual_mode "$PP_BAL"
                ;;

            ac)
                _pp_next="$PP_PRF"
                set_manual_mode "$PP_PRF"
                ;;

            power-saver)
                _pp_next="$PP_SAV"
                clear_manual_mode
                ;;

            balanced)
                _pp_next="$PP_BAL"
                clear_manual_mode
                ;;

            performance)
                _pp_next="$PP_PRF"
                clear_manual_mode
                ;;

            *)
                # all other tlp commands and *tlp-usb-udev disk* that do not touch the power profile
                neutral=1
                if wordinlist "$_manual_mode" "$PP_VALID"; then
                    # use manual mode
                    _pp_next=$_manual_mode
                else
                    # use previous profile
                    # note: this can even be $PS_UNKNOWN if 'tlp init start' has not yet been run,
                    # i.e. no last_pwr exists
                    _pp_next=$_pp_last
                fi
                ;;

        esac # $tlp_cmd

        if [ "$neutral" != "1" ] && [ "$_pp_next" -eq "$PS_UNKNOWN" ]; then
            # non-neutral command and profile still undefined -> use configured default mode
            case $(toupper "$TLP_DEFAULT_MODE") in
                AC|PRF)  _pp_next=$PP_PRF ;;
                BAT|BAL) _pp_next=$PP_BAL ;;
                SAV)     _pp_next=$PP_SAV ;;

                *) # unconfigured or invalid default mode -> check machine type
                    if is_laptop; then
                        _pp_next=$PP_BAL # laptop: assume battery
                    else
                        _pp_next=$PP_PRF # desktop/other: assume AC
                    fi
                    ;;
            esac
        fi # unknown power source
    fi # non-persistent mode

    if [ "$_pp_next" != "$_pp_last" ]; then
        echo_debug "ps" "get_next_power_profile($tlp_cmd).change: sw=$TLP_AUTO_SWITCH; def=$_default_mode; man=$_manual_mode; pers=$_persist_mode; ps_last=$_ps_last; pp_last=$_pp_last; pp_next=$_pp_next"
    else
        echo_debug "ps" "get_next_power_profile($tlp_cmd).keep: sw=$TLP_AUTO_SWITCH; def=$_default_mode; man=$_manual_mode; pers=$_persist_mode; ps_last=$_ps_last; pp_last=$_pp_last; pp_next=$_pp_next"
    fi

    return "$_pp_next"
}

notify_profile_daemon () {
    # Notify TLP-PD of a profile change via D-Bus
    # $1: profile id

    if [ -n "$1" ]; then
       if cmd_exists "$GDBUS"; then
           $GDBUS call -y -d $TLPPD_BUS_NAME -o $TLPPD_OBJECT_PATH -m "${TLPPD_INTERFACE_NAME}.SyncProfile" "$1" 2> /dev/null 1>&2
           echo_debug "run" "notify_profile_daemon.gdbus.call.SyncProfile($1): rc=$?"
       else
           echo_debug "run" "notify_profile_daemon($1).gdbus_missing"
       fi
    else
        echo_debug "run" "notify_profile_daemon($1).no_profile"
    fi
}

has_power_profile_changed () {
    # compare $_pp_next to $_pp_last, save $_pp_next when different
    # rc: 0=different, 1=equal
    # global params: $_pp_last, $_pp_next, $_syspwr

    # save new state to
    # - record power source for smart switching decision
    # - record time of last run
    create_rundir
    write_sysf "$_pp_next $_syspwr" $PWRRUNFILE

    if [ -z "$_pp_last" ] || [ "$_pp_next" != "$_pp_last" ]; then
        # profile changes (including the edge case that previous profile is unknown)
        echo_debug "ps" "has_power_profile_changed.yes: last=$_pp_last; next=$_pp_next; syspwr=$_syspwr"
        return 0
    else
        # no profile change
        echo_debug "ps" "has_power_profile_changed.no: next=$_pp_next; syspwr=$_syspwr"
        return 1
    fi
}

clear_saved_power_profile () {
    # remove last saved power profile

    rm -f $PWRRUNFILE 2> /dev/null

    return 0
}

echo_started_profile () { # announce power profile and auto/manual mode after start
    # $1: profile: PP_PRF=0/PP_BAL=1/PP_SAV=2
    # $2: tlp command

    printf "TLP started using profile %s" "$(pp2strx "$1")"

    if [ "$_manual_mode" != "$MPM_OFF" ]; then
        printf " (manual).\n"
    elif [ "$_default_mode" = "$1" ]; then
        printf " (default).\n"
    elif [ "$2" = "start" ]; then
        printf " (auto).\n"
    else
        printf ".\n"
    fi

    return 0
}
