152 lines
3.3 KiB
Python
Executable file
152 lines
3.3 KiB
Python
Executable file
#! /usr/bin/env python3
|
|
# encoding: utf-8
|
|
|
|
"""
|
|
Fan control service.
|
|
|
|
Measures temperatures and sets fan PWM according to a configured curve.
|
|
|
|
See /etc/fand/config.json.example for more.
|
|
"""
|
|
|
|
import dec8
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
class Sensor:
|
|
def __init__(self, source, weight=0.2):
|
|
self.f = open(source, "rb")
|
|
self.weight = weight
|
|
self.value = None
|
|
|
|
def measure(self):
|
|
self.f.seek(0)
|
|
raw = int(self.f.read())
|
|
|
|
if self.value is None:
|
|
self.value = raw
|
|
else:
|
|
self.value = int(self.weight * raw + (1-self.weight) * self.value)
|
|
|
|
return self.value
|
|
|
|
class PWM:
|
|
def __init__(self, chip, ch, period):
|
|
self.chip_base = "/sys/class/pwm/pwmchip%d/" %(chip)
|
|
self.ch_base = "/sys/class/pwm/pwmchip%d/pwm%d/" %(chip, ch)
|
|
self.period = period
|
|
self.duty_cycle = 0
|
|
|
|
# export the channel to sysfs if necessary
|
|
if not os.path.isdir(self.ch_base):
|
|
with open(self.chip_base + "export", "w") as f:
|
|
f.write(str(ch))
|
|
|
|
# disable channel
|
|
self.set_enabled(False)
|
|
|
|
# acquire a duty cycle file descriptor
|
|
self.f_dc = open(self.ch_base + "duty_cycle", "w")
|
|
|
|
# Set 0 duty cycle so period can be freely set even if the channel was used before.
|
|
# This may fail: ignore.
|
|
try:
|
|
self.set_duty_cycle(0)
|
|
except OSError:
|
|
pass
|
|
|
|
# set period & polarity
|
|
with open(self.ch_base + "period", "w") as f:
|
|
f.write(str(period))
|
|
|
|
with open(self.ch_base + "polarity", "w") as f:
|
|
f.write("normal")
|
|
|
|
# re-enable channel
|
|
self.set_enabled(True)
|
|
|
|
def set_enabled(self, enabled):
|
|
with open(self.ch_base + "enable", "r+") as f:
|
|
current = f.read().strip()
|
|
requested = "1" if enabled else "0"
|
|
|
|
if current != requested:
|
|
f.seek(0)
|
|
f.write(requested)
|
|
|
|
def set_percent(self, pc):
|
|
self.set_duty_cycle(self.period * pc/100)
|
|
|
|
def set_duty_cycle(self, dc):
|
|
self.duty_cycle = int(dc)
|
|
self.f_dc.seek(0)
|
|
self.f_dc.write(str(self.duty_cycle))
|
|
self.f_dc.flush()
|
|
|
|
def linlut(a, seq):
|
|
"""
|
|
Given a value `a` and a sorted list of tuples `seq`,
|
|
finds a pair in `seq` whose first elements are closest
|
|
to ... well you know what I mean. Read the code. It's
|
|
shorter than this paragraph.
|
|
"""
|
|
|
|
smaller = None
|
|
larger = None
|
|
|
|
for d in seq:
|
|
if d[0] <= a:
|
|
smaller = d
|
|
|
|
elif d[0] > a:
|
|
larger = d
|
|
break
|
|
|
|
if not smaller:
|
|
return seq[0][1]
|
|
|
|
if not larger:
|
|
return seq[-1][1]
|
|
|
|
ratio = (a - smaller[0]) / (larger[0] - smaller[0])
|
|
dy = larger[1] - smaller[1]
|
|
|
|
return smaller[1] + ratio * dy
|
|
|
|
def printf(fmt, *args, stream=sys.stderr):
|
|
print(fmt % args, file=stream, flush=True)
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
cfg = dec8.cfg.Config.from_file("/etc/fand/config.json")
|
|
except FileNotFoundError:
|
|
printf("!!! missing /etc/fand/config.json")
|
|
|
|
s = Sensor(cfg.sensor.source, cfg.sensor.weight)
|
|
p = PWM(cfg.pwm.chip, cfg.pwm.channel, cfg.pwm.period)
|
|
|
|
if len(sys.argv) == 3 and sys.argv[1] == "--set":
|
|
try:
|
|
percentage = int(sys.argv[2])
|
|
except:
|
|
print("--set must be followed by a number")
|
|
sys.exit(1)
|
|
|
|
p.set_percent(percentage)
|
|
sys.exit(0)
|
|
|
|
while True:
|
|
temperature = s.measure()
|
|
percentage = linlut(temperature, cfg.map)
|
|
p.set_percent(percentage)
|
|
|
|
if cfg.control.verbose:
|
|
printf("%.01f °C -> %d %% (%d/%d µs)",
|
|
temperature / 1000,
|
|
percentage,
|
|
p.duty_cycle // 1000,
|
|
p.period // 1000
|
|
)
|
|
|
|
time.sleep(cfg.control.interval)
|