add backend
This commit is contained in:
commit
c5131d269a
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
60
README.org
Normal file
60
README.org
Normal file
@ -0,0 +1,60 @@
|
||||
* PiFanControlTUI
|
||||
Welcome, this project aim to provide a simple minimal intuitive TUI to set your RaspberryPi 5 fan curves.
|
||||
|
||||
** Install This
|
||||
#TODO
|
||||
|
||||
** Workings
|
||||
This project works by setting the following dtparams in =/boot/firmware/config.txt=. Settings require reboot to work.
|
||||
#+begin_src txt
|
||||
[all]
|
||||
dtparam=fan_temp0=55000,fan_temp0_hyst=2000,fan_temp0_speed=90
|
||||
dtparam=fan_temp1=65000,fan_temp1_hyst=3000,fan_temp1_speed=160
|
||||
dtparam=fan_temp2=70000,fan_temp2_hyst=4000,fan_temp2_speed=200
|
||||
dtparam=fan_temp3=72000,fan_temp3_hyst=5000,fan_temp3_speed=255
|
||||
#+end_src
|
||||
|
||||
1. *fan_tempN*: temperature fan activates at in millicelsius
|
||||
2. *fan_tempN_hyst*: fan turns off (or to lower setting) when temperature is =fan_tempN - fan_tempN_hyst=. Also in millicelsius
|
||||
3. *fan_tempN_speed*: fan speed in range [0, 255] where 255 is 100% fan speed
|
||||
|
||||
The RaspberryPi 5 firmware supports up to 4 different [[https://github.com/raspberrypi/firmware/blob/4aa2a51fecb99ce16fe591391f6779174b9b5bea/boot/overlays/README#L271][thresholds]].
|
||||
The following are the default values used by the firmware:
|
||||
#+begin_src txt
|
||||
fan_temp0 Temperature threshold (in millicelcius) for
|
||||
1st cooling level (default 50000).
|
||||
|
||||
fan_temp0_hyst Temperature hysteresis (in millicelcius) for
|
||||
1st cooling level (default 5000).
|
||||
|
||||
fan_temp0_speed Fan PWM setting for 1st cooling level (0-255,
|
||||
default 75).
|
||||
|
||||
fan_temp1 Temperature threshold (in millicelcius) for
|
||||
2nd cooling level (default 60000).
|
||||
|
||||
fan_temp1_hyst Temperature hysteresis (in millicelcius) for
|
||||
2nd cooling level (default 5000).
|
||||
|
||||
fan_temp1_speed Fan PWM setting for 2nd cooling level (0-255,
|
||||
default 125).
|
||||
|
||||
fan_temp2 Temperature threshold (in millicelcius) for
|
||||
3rd cooling level (default 67500).
|
||||
|
||||
fan_temp2_hyst Temperature hysteresis (in millicelcius) for
|
||||
3rd cooling level (default 5000).
|
||||
|
||||
fan_temp2_speed Fan PWM setting for 3rd cooling level (0-255,
|
||||
default 175).
|
||||
|
||||
fan_temp3 Temperature threshold (in millicelcius) for
|
||||
4th cooling level (default 75000).
|
||||
|
||||
fan_temp3_hyst Temperature hysteresis (in millicelcius) for
|
||||
4th cooling level (default 5000).
|
||||
|
||||
fan_temp3_speed Fan PWM setting for 4th cooling level (0-255,
|
||||
default 250).
|
||||
|
||||
#+end_src
|
||||
106
main.py
Normal file
106
main.py
Normal file
@ -0,0 +1,106 @@
|
||||
#!/usr/bin/python3
|
||||
import difflib
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from tui import TUI
|
||||
|
||||
class DtParam:
|
||||
__slots__ = ('temp', 'hyst', 'speed', 'N')
|
||||
def __init__(self, temp : int, hyst : int, speed : int, n: int):
|
||||
self.temp = temp # Temperature in MilliCelsius
|
||||
self.hyst = hyst # Temperature Hysteresis in MilliCelsius
|
||||
self.speed = speed # Fan Speed in 0-255
|
||||
self.N = n # Fan Threshold number
|
||||
self._assert_valid()
|
||||
|
||||
def _assert_valid(self):
|
||||
assert self.temp // 1000 > 0 and self.temp // 1000 < 100, f"Temperature threshold not in sensible range (0, 100) C, found {self.temp}"
|
||||
assert self.hyst // 1000 > 0 and self.hyst // 1000 < 100, f"Temperature hysteresis threshold not in sensible range (0, 100) C, found {self.temp}"
|
||||
assert self.speed >= 0 and self.speed <= 255, f"Fan speed not in value range (0, 255), found {self.speed}"
|
||||
assert self.N >= 0 and self.N <= 3, f"Threshold number not in supported set (0, 1, 2, 3), found {self.N}"
|
||||
|
||||
def __str__(self):
|
||||
return f"dtparam=fan_temp{self.N}={self.temp},fan_temp{self.N}_hyst={self.hyst},fan_temp{self.N}_speed={self.speed}"
|
||||
|
||||
|
||||
class FanCurve:
|
||||
def __init__(self):
|
||||
self.config_path = "/tmp/boot/firmware/config.txt"
|
||||
self.settings = self._get_current_settings()
|
||||
self._assert_valid_cfg()
|
||||
|
||||
def update(self, settings: list[DtParam]):
|
||||
self.settings = settings
|
||||
self.settings.sort(key=lambda s: s.temp)
|
||||
for i, setting in enumerate(self.settings):
|
||||
setting.N = i
|
||||
self._assert_valid_cfg()
|
||||
self._write_cfg()
|
||||
|
||||
def _write_cfg(self):
|
||||
with open(self.config_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
old_cfg = [line for line in lines if not (line.startswith("dtparam=fan_temp") or line.startswith("# Auto-Generated Fan Curve"))]
|
||||
|
||||
insert_at = None
|
||||
for i, line in enumerate(old_cfg):
|
||||
if line.strip() == "[all]":
|
||||
insert_at = i
|
||||
|
||||
new_lines = ["# Auto-Generated Fan Curve parameters by PiFanUI\n"]
|
||||
new_lines.extend([str(setting)+"\n" for setting in self.settings])
|
||||
|
||||
new_cfg = old_cfg[:insert_at + 1] + new_lines + old_cfg[insert_at + 1:]
|
||||
|
||||
if new_cfg == lines:
|
||||
print("No new changes")
|
||||
return
|
||||
|
||||
with tempfile.NamedTemporaryFile("w", dir="/tmp", delete=False) as new_conf:
|
||||
new_conf.writelines(new_cfg)
|
||||
|
||||
diff = difflib.unified_diff(lines, new_cfg, fromfile=self.config_path, tofile=new_conf.name)
|
||||
print(f"Updated values:")
|
||||
for line in diff:
|
||||
print(line)
|
||||
|
||||
shutil.move(new_conf.name, self.config_path)
|
||||
|
||||
def _assert_valid_cfg(self):
|
||||
assert len(self.settings) <= 4 and len(self.settings) >= 1, f"Only up to 4 thresholds supported, found {len(self.settings)}"
|
||||
assert sorted(self.settings, key=lambda s:s.temp) == sorted(self.settings, key=lambda s:s.N), f"Thresholds are not ordered correctly:\n{[str(s) for s in self.settings]}"
|
||||
|
||||
def _get_default_settings(self) -> list[DtParam]:
|
||||
settings = []
|
||||
settings.append(DtParam(temp=55000, hyst=2000, speed=90, n=0))
|
||||
settings.append(DtParam(temp=65000, hyst=3000, speed=160, n=1))
|
||||
settings.append(DtParam(temp=70000, hyst=4000, speed=200, n=2))
|
||||
settings.append(DtParam(temp=72000, hyst=5000, speed=255, n=3))
|
||||
return settings
|
||||
|
||||
def _get_current_settings(self) -> list[DtParam]:
|
||||
settings = []
|
||||
with open(self.config_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
config_lines = [line.replace(" ", "").lower().split("#")[0][8:] for line in lines if line.startswith("dtparam=fan_temp")]
|
||||
config_lines = sorted([item[8:].replace("_", "=") for string in config_lines for item in string.split(',')])
|
||||
cfg = [item[2:].rstrip('\n') for item in config_lines]
|
||||
# TODO if not specified fill in with defaults
|
||||
assert len(cfg) % 3 == 0, "Partially Specified Fan Behaviour found, make sure to specify _temp, _hyst & _speed"
|
||||
for i in range(0, len(config_lines), 3):
|
||||
settings.append(DtParam(temp=int(cfg[i]), hyst=int(cfg[i+1][5:]), speed=int(cfg[i+2][6:]), n=i//3))
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
sc = FanCurve()
|
||||
tui = TUI(sc)
|
||||
print("Bye from pifantui!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@ -0,0 +1,9 @@
|
||||
[project]
|
||||
name = "pifantui"
|
||||
version = "0.1.0"
|
||||
description = "Minimal Pi5 Fan Control UI"
|
||||
readme = "README.org"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"rich>=14.2.0",
|
||||
]
|
||||
413
test.py
Normal file
413
test.py
Normal file
@ -0,0 +1,413 @@
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
from main import DtParam, FanCurve
|
||||
|
||||
|
||||
def _write_config(path: str, content: str) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
class TestDtParamAsserts(unittest.TestCase):
|
||||
def test_temp_must_be_in_sensible_range(self) -> None:
|
||||
with self.assertRaises(AssertionError):
|
||||
DtParam(temp=0, hyst=2000, speed=90, n=0)
|
||||
with self.assertRaises(AssertionError):
|
||||
DtParam(temp=100_000, hyst=2000, speed=90, n=0)
|
||||
|
||||
def test_hyst_must_be_in_sensible_range(self) -> None:
|
||||
with self.assertRaises(AssertionError):
|
||||
DtParam(temp=55_000, hyst=0, speed=90, n=0)
|
||||
with self.assertRaises(AssertionError):
|
||||
DtParam(temp=55_000, hyst=100_000, speed=90, n=0)
|
||||
|
||||
def test_speed_must_be_in_range(self) -> None:
|
||||
with self.assertRaises(AssertionError):
|
||||
DtParam(temp=55_000, hyst=2000, speed=-1, n=0)
|
||||
with self.assertRaises(AssertionError):
|
||||
DtParam(temp=55_000, hyst=2000, speed=256, n=0)
|
||||
|
||||
def test_threshold_index_must_be_supported(self) -> None:
|
||||
with self.assertRaises(AssertionError):
|
||||
DtParam(temp=55_000, hyst=2000, speed=90, n=-1)
|
||||
with self.assertRaises(AssertionError):
|
||||
DtParam(temp=55_000, hyst=2000, speed=90, n=4)
|
||||
|
||||
|
||||
class TestFanCurveAsserts(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.fc = FanCurve.__new__(FanCurve)
|
||||
self.fc.config_path = os.path.join(self.tmpdir.name, "config.txt")
|
||||
_write_config(
|
||||
self.fc.config_path,
|
||||
"\n".join(
|
||||
[
|
||||
"# test config",
|
||||
"[all]",
|
||||
"dtoverlay=dwc2,dr_mode=host",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def test_assert_valid_cfg_allows_1_to_4_thresholds(self) -> None:
|
||||
self.fc.settings = [DtParam(temp=55_000, hyst=2000, speed=90, n=0)]
|
||||
self.fc._assert_valid_cfg()
|
||||
|
||||
self.fc.settings = [
|
||||
DtParam(temp=55_000, hyst=2000, speed=90, n=0),
|
||||
DtParam(temp=65_000, hyst=3000, speed=160, n=1),
|
||||
DtParam(temp=70_000, hyst=4000, speed=200, n=2),
|
||||
DtParam(temp=72_000, hyst=5000, speed=255, n=3),
|
||||
]
|
||||
self.fc._assert_valid_cfg()
|
||||
|
||||
def test_assert_valid_cfg_rejects_0_or_more_than_4_thresholds(self) -> None:
|
||||
self.fc.settings = []
|
||||
with self.assertRaises(AssertionError):
|
||||
self.fc._assert_valid_cfg()
|
||||
|
||||
self.fc.settings = [
|
||||
DtParam(temp=51_000, hyst=2000, speed=1, n=0),
|
||||
DtParam(temp=52_000, hyst=2000, speed=2, n=1),
|
||||
DtParam(temp=53_000, hyst=2000, speed=3, n=2),
|
||||
DtParam(temp=54_000, hyst=2000, speed=4, n=3),
|
||||
DtParam(temp=55_000, hyst=2000, speed=5, n=0),
|
||||
]
|
||||
with self.assertRaises(AssertionError):
|
||||
self.fc._assert_valid_cfg()
|
||||
|
||||
def test_assert_valid_cfg_requires_temp_order_matches_threshold_number_order(self) -> None:
|
||||
a = DtParam(temp=55_000, hyst=2000, speed=90, n=3)
|
||||
b = DtParam(temp=65_000, hyst=3000, speed=160, n=2)
|
||||
c = DtParam(temp=70_000, hyst=4000, speed=200, n=1)
|
||||
d = DtParam(temp=72_000, hyst=5000, speed=255, n=0)
|
||||
self.fc.settings = [a, b, c, d]
|
||||
with self.assertRaises(AssertionError):
|
||||
self.fc._assert_valid_cfg()
|
||||
|
||||
def test_assert_valid_cfg_fails_when_temps_not_increasing_by_threshold_number(self) -> None:
|
||||
a = DtParam(temp=65_000, hyst=2000, speed=90, n=0)
|
||||
b = DtParam(temp=55_000, hyst=3000, speed=160, n=1)
|
||||
c = DtParam(temp=70_000, hyst=4000, speed=200, n=2)
|
||||
d = DtParam(temp=72_000, hyst=5000, speed=255, n=3)
|
||||
self.fc.settings = [a, b, c, d]
|
||||
with self.assertRaises(AssertionError):
|
||||
self.fc._assert_valid_cfg()
|
||||
|
||||
|
||||
class TestFanCurveWriteBehavior(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.fc = FanCurve.__new__(FanCurve)
|
||||
self.fc.config_path = os.path.join(self.tmpdir.name, "config.txt")
|
||||
_write_config(
|
||||
self.fc.config_path,
|
||||
"\n".join(
|
||||
[
|
||||
"# test config",
|
||||
"[all]",
|
||||
"dtoverlay=dwc2,dr_mode=host",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def test_update_writes_fan_curve_and_is_quiet_when_unchanged(self) -> None:
|
||||
# First run should write and typically print a diff.
|
||||
buf1 = io.StringIO()
|
||||
with redirect_stdout(buf1):
|
||||
self.fc.update(self.fc._get_default_settings())
|
||||
with open(self.fc.config_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
self.assertIn("# Auto-Generated Fan Curve parameters by PiFanUI\n", content)
|
||||
self.assertIn("dtparam=fan_temp0=", content)
|
||||
self.assertIn("dtparam=fan_temp3=", content)
|
||||
|
||||
# Second run should be a no-op and print nothing.
|
||||
buf2 = io.StringIO()
|
||||
with redirect_stdout(buf2):
|
||||
self.fc.update(self.fc._get_default_settings())
|
||||
# A no-op run must not print a diff.
|
||||
self.assertNotIn("Updated values:", buf2.getvalue())
|
||||
|
||||
def test_update_rewrites_when_settings_change(self) -> None:
|
||||
# Write defaults.
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.fc.update(self.fc._get_default_settings())
|
||||
with open(self.fc.config_path, "r", encoding="utf-8") as f:
|
||||
before = f.read()
|
||||
|
||||
# Change one setting (speed) and update again.
|
||||
changed = self.fc._get_default_settings()
|
||||
changed[0] = DtParam(temp=55_000, hyst=2000, speed=91, n=0)
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
self.fc.update(changed)
|
||||
with open(self.fc.config_path, "r", encoding="utf-8") as f:
|
||||
after = f.read()
|
||||
|
||||
self.assertNotEqual(before, after)
|
||||
self.assertIn("Updated values:", buf.getvalue())
|
||||
self.assertIn("fan_temp0_speed=91", after)
|
||||
|
||||
def test_update_renumbers_thresholds_by_temperature(self) -> None:
|
||||
# Provide thresholds out of temp order and with incorrect N values.
|
||||
s0 = DtParam(temp=72_000, hyst=5000, speed=255, n=2)
|
||||
s1 = DtParam(temp=55_000, hyst=2000, speed=90, n=3)
|
||||
s2 = DtParam(temp=70_000, hyst=4000, speed=200, n=0)
|
||||
s3 = DtParam(temp=65_000, hyst=3000, speed=160, n=1)
|
||||
settings = [s0, s1, s2, s3]
|
||||
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.fc.update(settings)
|
||||
|
||||
with open(self.fc.config_path, "r", encoding="utf-8") as f:
|
||||
dtparam_lines = [line.rstrip("\n") for line in f if line.startswith("dtparam=fan_temp")]
|
||||
|
||||
# Extract (N, temp) from "dtparam=fan_tempN=temp,..."
|
||||
pairs: list[tuple[int, int]] = []
|
||||
for line in dtparam_lines:
|
||||
# Note: line contains two '=' characters: "dtparam=fan_tempN=temp,..."
|
||||
tail = line[len("dtparam=fan_temp") :] # "N=temp,..."
|
||||
n_str, rest = tail.split("=", 1) # "N", "temp,..."
|
||||
n = int(n_str)
|
||||
temp = int(rest.split(",", 1)[0])
|
||||
pairs.append((n, temp))
|
||||
|
||||
self.assertEqual(sorted(n for n, _ in pairs), [0, 1, 2, 3])
|
||||
# N order must correspond to increasing temps.
|
||||
temps_by_n = [temp for _, temp in sorted(pairs, key=lambda p: p[0])]
|
||||
self.assertEqual(temps_by_n, sorted(temps_by_n))
|
||||
|
||||
# Also assert the passed-in objects were renumbered in-place by update().
|
||||
self.assertEqual(sorted(s.N for s in settings), [0, 1, 2, 3])
|
||||
self.assertEqual([s.N for s in sorted(settings, key=lambda s: s.temp)], [0, 1, 2, 3])
|
||||
|
||||
def test_written_dtparam_lines_match_dtparam_str(self) -> None:
|
||||
settings = self.fc._get_default_settings()
|
||||
# Pre-seed the config with stale/duplicate fan_temp lines to ensure update deletes them.
|
||||
_write_config(
|
||||
self.fc.config_path,
|
||||
"\n".join(
|
||||
[
|
||||
"# test config",
|
||||
"[all]",
|
||||
"# Auto-Generated Fan Curve parameters by PiFanUI",
|
||||
"dtparam=fan_temp0=1,fan_temp0_hyst=1,fan_temp0_speed=1",
|
||||
"dtparam=fan_temp1=2,fan_temp1_hyst=2,fan_temp1_speed=2",
|
||||
"dtparam=fan_temp2=3,fan_temp2_hyst=3,fan_temp2_speed=3",
|
||||
"dtparam=fan_temp3=4,fan_temp3_hyst=4,fan_temp3_speed=4",
|
||||
# duplicates that should be removed
|
||||
"dtparam=fan_temp0=999,fan_temp0_hyst=999,fan_temp0_speed=999",
|
||||
"dtparam=fan_temp3=999,fan_temp3_hyst=999,fan_temp3_speed=999",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.fc.update(settings)
|
||||
|
||||
with open(self.fc.config_path, "r", encoding="utf-8") as f:
|
||||
all_lines = list(f)
|
||||
|
||||
# Ensure deletion worked: the only remaining fan_temp dtparam lines are the 4 expected ones.
|
||||
self.assertEqual(sum(1 for line in all_lines if "dtparam=fan_temp" in line), 4)
|
||||
written_lines = [line.rstrip("\n") for line in all_lines if line.startswith("dtparam=fan_temp")]
|
||||
|
||||
expected_lines = [str(s) for s in settings]
|
||||
self.assertEqual(written_lines, expected_lines)
|
||||
|
||||
def test_update_preserves_unrelated_config_lines(self) -> None:
|
||||
original = "\n".join(
|
||||
[
|
||||
"# test config",
|
||||
"dtparam=audio=on",
|
||||
"",
|
||||
"[pi5]",
|
||||
"dtoverlay=foo",
|
||||
"",
|
||||
"[all]",
|
||||
"dtoverlay=dwc2,dr_mode=host",
|
||||
"dtparam=i2c_arm=on",
|
||||
"",
|
||||
"[extra]",
|
||||
"somekey=somevalue",
|
||||
"",
|
||||
]
|
||||
) + "\n"
|
||||
_write_config(self.fc.config_path, original)
|
||||
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.fc.update(self.fc._get_default_settings())
|
||||
|
||||
with open(self.fc.config_path, "r", encoding="utf-8") as f:
|
||||
after_lines = f.readlines()
|
||||
|
||||
# Strip out the auto-generated fan curve block; remaining content should match exactly.
|
||||
after_stripped = [
|
||||
line
|
||||
for line in after_lines
|
||||
if not (line.startswith("dtparam=fan_temp") or line.startswith("# Auto-Generated Fan Curve"))
|
||||
]
|
||||
self.assertEqual(after_stripped, original.splitlines(keepends=True))
|
||||
|
||||
|
||||
class TestFanCurveGetCurrentSettings(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.fc = FanCurve.__new__(FanCurve)
|
||||
self.fc.config_path = os.path.join(self.tmpdir.name, "config.txt")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def _read_settings_as_tuples(self) -> list[tuple[int, int, int, int]]:
|
||||
settings = self.fc._get_current_settings()
|
||||
return [(s.N, s.temp, s.hyst, s.speed) for s in settings]
|
||||
|
||||
def test_get_current_settings_parses_separate_lines_per_param(self) -> None:
|
||||
_write_config(
|
||||
self.fc.config_path,
|
||||
"\n".join(
|
||||
[
|
||||
"[all]",
|
||||
"dtparam=fan_temp0=65000",
|
||||
"dtparam=fan_temp0_hyst=5000",
|
||||
"dtparam=fan_temp0_speed=75",
|
||||
"",
|
||||
"dtparam=fan_temp1=70000",
|
||||
"dtparam=fan_temp1_hyst=5000",
|
||||
"dtparam=fan_temp1_speed=128",
|
||||
"",
|
||||
"dtparam=fan_temp2=75000",
|
||||
"dtparam=fan_temp2_hyst=5000",
|
||||
"dtparam=fan_temp2_speed=192",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
self._read_settings_as_tuples(),
|
||||
[
|
||||
(0, 65000, 5000, 75),
|
||||
(1, 70000, 5000, 128),
|
||||
(2, 75000, 5000, 192),
|
||||
],
|
||||
)
|
||||
|
||||
def test_get_current_settings_parses_comma_separated_dtparam_lines(self) -> None:
|
||||
_write_config(
|
||||
self.fc.config_path,
|
||||
"\n".join(
|
||||
[
|
||||
"[all]",
|
||||
"dtparam=fan_temp0=54000,fan_temp0_hyst=2000,fan_temp0_speed=90",
|
||||
"dtparam=fan_temp1=65000,fan_temp1_hyst=3000,fan_temp1_speed=160",
|
||||
"dtparam=fan_temp2=70000,fan_temp2_hyst=4000,fan_temp2_speed=200",
|
||||
"dtparam=fan_temp3=72000,fan_temp3_hyst=5000,fan_temp3_speed=255",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
self._read_settings_as_tuples(),
|
||||
[
|
||||
(0, 54000, 2000, 90),
|
||||
(1, 65000, 3000, 160),
|
||||
(2, 70000, 4000, 200),
|
||||
(3, 72000, 5000, 255),
|
||||
],
|
||||
)
|
||||
|
||||
def test_get_current_settings_parses_three_thresholds_set(self) -> None:
|
||||
_write_config(
|
||||
self.fc.config_path,
|
||||
"\n".join(
|
||||
[
|
||||
"[all]",
|
||||
"dtparam=fan_temp0=54000,fan_temp0_hyst=2000,fan_temp0_speed=90",
|
||||
"dtparam=fan_temp1=65000,fan_temp1_hyst=3000,fan_temp1_speed=160",
|
||||
"dtparam=fan_temp2=70000,fan_temp2_hyst=4000,fan_temp2_speed=200",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
self._read_settings_as_tuples(),
|
||||
[
|
||||
(0, 54000, 2000, 90),
|
||||
(1, 65000, 3000, 160),
|
||||
(2, 70000, 4000, 200),
|
||||
],
|
||||
)
|
||||
|
||||
def test_get_current_settings_rejects_partially_specified_threshold(self) -> None:
|
||||
# fan_temp0 is missing hyst and speed -> must fail rather than silently mis-parsing.
|
||||
_write_config(
|
||||
self.fc.config_path,
|
||||
"\n".join(
|
||||
[
|
||||
"[all]",
|
||||
"dtparam=fan_temp0=65000",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
self.fc._get_current_settings()
|
||||
|
||||
def test_get_current_settings_parses_mixed_styles(self) -> None:
|
||||
_write_config(
|
||||
self.fc.config_path,
|
||||
"\n".join(
|
||||
[
|
||||
"[all]",
|
||||
# fan_temp0: all in one line
|
||||
"dtparam=fan_temp0=54000,fan_temp0_hyst=2000,fan_temp0_speed=90",
|
||||
# fan_temp1: split across 3 lines
|
||||
"dtparam=fan_temp1=65000",
|
||||
"dtparam=fan_temp1_hyst=3000",
|
||||
"dtparam=fan_temp1_speed=160",
|
||||
# fan_temp2: split (temp+speed) then hyst
|
||||
"dtparam=fan_temp2=70000,fan_temp2_speed=200",
|
||||
"dtparam=fan_temp2_hyst=4000",
|
||||
# fan_temp3: all in one line
|
||||
"dtparam=fan_temp3=72000,fan_temp3_hyst=5000,fan_temp3_speed=255",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
self._read_settings_as_tuples(),
|
||||
[
|
||||
(0, 54000, 2000, 90),
|
||||
(1, 65000, 3000, 160),
|
||||
(2, 70000, 4000, 200),
|
||||
(3, 72000, 5000, 255),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
57
uv.lock
generated
Normal file
57
uv.lock
generated
Normal file
@ -0,0 +1,57 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pifantui"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "rich", specifier = ">=14.2.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user