#!/usr/bin/tclsh
#
# i8kmon -- Monitor the temperature on Dell laptops.
#
# Copyright (C) 2023        Armin Wolf <W_Armin@gmx.de>
# Copyright (C) 2013-2017   Vitor Augusto <vitorafsr@gmail.com>
# Copyright (C) 2001-2005   Massimo Dal Zotto <dz@debian.org>
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 2, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.

package require Tcl 8.6
package require cmdline 1.5
package require coroutine
package require defer
package require fileutil
package require generator
package require logger

package require i8k::thermal
package require i8k::hwmon

set onAcPower 0
set error ""

proc setFans {fans fanStates log} {
    set fanNumber 0

    foreach fanState $fanStates {
        incr fanNumber

        if {$fanState == "-"} {
            continue
        }

        if {[dict exists $fans $fanNumber]} {
            set fan [dict get $fans $fanNumber]

            try {
                $fan setState $fanState
            } trap {FAN {invalid state}} {} {
                set maxState [$fan getMaxState]

                ${log}::notice "fan $fanNumber does not support fan state $fanState, using max. fan state $maxState"
                $fan setState $maxState
            }
        } else {
            ${log}::info "fan $fanNumber not found"
        }
    }

    return
}

proc fanControl {config fans temp log} {
    global onAcPower

    # Force initial fan speed update when ondemand is set
    set previousState -1
    set currentState 0

    while {1} {
        set index [expr {$onAcPower ? 1 : 3}]

        try {
            set temperature [expr {[$temp getTemperature] / 1000}]

            ${log}::debug "temperature: $temperature °C"

            for {} {$currentState < [expr {[dict get $config num_configs] - 1}]} {incr currentState} {
                set temperatureHigh [lindex [dict get $config $currentState] [expr $index + 1]]

                if {$temperature < $temperatureHigh} {
                    break
                }

                ${log}::debug "temperature above state $currentState threshold ($temperatureHigh °C)"
            }

            for {} {$currentState > 0} {incr currentState -1} {
                set temperatureLow [lindex [dict get $config $currentState] $index]

                if {$temperature > $temperatureLow} {
                    break
                }

                ${log}::debug "temperature below state $currentState threshold ($temperatureLow °C)"
            }

            # When ondemand is set, fan speed updates only happen at state change
            if {$currentState == $previousState && [dict get $config ondemand] > 0} {
                ${log}::debug "skipping fan speed update for state $currentState"
            } else {
                try {
                    setFans $fans [lindex [dict get $config $currentState] 0] $log
                    set previousState $currentState
                    ${log}::debug "switched to state $currentState"
                } trap {POSIX} {msg} {
                    ${log}::warn "unable to update fan speed: $msg"
                    # Force fan speed update on next iteration
                    set previousState -1
                }
            }
        } trap {POSIX} {msg} {
            ${log}::warn "unable to read temperature: $msg"
        }

        coroutine::util after [expr {[dict get $config timeout] * 1000}]
    }

    return
}

proc updatePowerStatus {log} {
    global onAcPower

    while {1} {
        if {[catch {exec "acpi" "-a"} result]} {
            # Assume AC power when execution fails
            ${log}::info "power status: assuming ac power"
            set onAcPower 1
        } else {
            if {[string match "" $result]} {
                # The command might succeed without printing anything
                ${log}::info "power status: assuming ac power"
                set onAcPower 1
            } elseif {[string match *on-line* $result]} {
                ${log}::info "power status: running on ac power ($result)"
                set onAcPower 1
            } else {
                ${log}::info "power status: running on battery power ($result)"
                set onAcPower 0
            }
        }

        # Read AC status once per minute
        coroutine::util after 60000
    }

    return
}

# Gets called by tcl when a coroutine dies
proc bgerror {msg} {
    global error

    set error $msg

    return
}

proc readConfig {path} {
    array set config {
        verbose     0
        ondemand    0
        timeout     2
        num_configs 5
        0           {{0 0}  -1  50  -1  50}
        1           {{0 1}  45  55  45  55}
        2           {{1 1}  50  65  50  65}
        3           {{1 2}  60  70  60  70}
        4           {{2 2}  65 128  65 128}
    }

    if {[file exists $path]} {
        try {
            set configContent [fileutil::cat $path]
            set defaultConfig [array get config]
            set safeInterp [interp create -safe]

            defer::defer interp delete $safeInterp

            $safeInterp eval array set config \{ {*}$defaultConfig \}
            $safeInterp eval $configContent

            return [$safeInterp eval {array get config}]
        } trap {} {msg} {
            throw {I8KMON ERROR} "unable to read config: $msg"
        }
    }

    return [array get config]
}

proc parseOptions {configName args} {
    upvar $configName config

    set timeout [list "t.arg" [dict get $config timeout] "Interval for checking hardware status in seconds"]
    set ondemand [list "o" "Only update fan speed when thresholds where exceeded"]
    set verbose [list "v" "Report verbose information"]
    set options [list $timeout $ondemand $verbose]

    array set params [cmdline::getoptions args $options]

    if {$params(o)} {
        dict set config ondemand 1
    }

    if {$params(v)} {
        dict set config verbose 1
    }

    dict set config timeout $params(t)

    return
}

proc validateConfig {configName log} {
    upvar $configName config

    if {[dict get $config verbose] > 0} {
        ${log}::setlevel debug
    }

    for {set index 0}  {$index < [dict get $config num_configs]} {incr index} {
        lassign [dict get $config $index] fanStates lowAc highAc lowBat highBat

	    # check that for each key high temp > low temp
	    if {$lowAc > $highAc} {
            ${log}::notice "config entry $index: high temperature $highAc °C is lower or equals low temperature $lowAc °C (ac)"
            set highAc [expr {$lowAc + 5}]
        }
	    if {$lowBat > $highBat} {
            ${log}::notice "config entry $index: high temperature $highBat °C is lower or equals low temperature $lowBat °C (battery)"
            set highBat [expr {$lowBat + 5}]
        }

        # The fan state order is reversed (..., fan 3, fan 2, fan 1) due
        # to backwards compatibility
        dict set config $index [list [lreverse $fanStates] $lowAc $highAc $lowBat $highBat]

        ${log}::debug "config entry $index: battery = $lowBat °C - $highBat °C, ac = $lowAc °C - $highAc °C"
    }
}

proc main {args path} {
    global error

    set config [dict create {*}[readConfig $path]]
    set uncontrollableFans 0
    set fans [dict create]
    set log [logger::init i8kmon]

    defer::defer ${log}::delete
    ${log}::setlevel notice

    parseOptions config {*}$args
    validateConfig config $log

    try {
        set hwmonChip [hwmon::detectChip]
        set temp [hwmon::getSensor $hwmonChip 1]
    } trap {} {msg} {
        throw {I8KMON ERROR} "temperature sensor not found: $msg"
    }

    defer::defer $temp destroy

    generator foreach fan [thermal::detectFans] {
        if {![$fan isSetable]} {
            $fan destroy
            incr uncontrollableFans

            continue
        }

        defer::with {fan} {
            $fan destroy
        }

        dict set fans [$fan getIndex] $fan
    }

    if {[dict size $fans] == 0} {
        if {$uncontrollableFans == 0} {
            throw {I8KMON ERROR} "no fans detected"
        } else {
            throw {I8KMON ERROR} "no controllable fans detected, likely due to missing permissions"
        }
    }

    # Start updatePowerStatus first to ensure onAcStatus is valid
    coroutine::util create updatePowerStatus $log
    coroutine::util create fanControl $config $fans $temp $log

    vwait error

    throw {I8KMON ERROR} "background error: $error"
}

if {$tcl_interactive == 0} {
    try {
        main $argv "/etc/i8kmon.conf"
    } trap {I8KMON ERROR} {msg} {
        puts stderr "i8kmon: $msg"
        exit 1
    } trap {CMDLINE USAGE} {msg} {
        puts $msg
        exit 1
    }
}
