Skip to content

Commit 5bf185c

Browse files
authored
Merge pull request #1 from NeleBiH/codex/locate-and-fix-critical-code-error
Handle text status output from liquidctl
2 parents 5df004e + debc0ac commit 5bf185c

File tree

2 files changed

+180
-5
lines changed

2 files changed

+180
-5
lines changed

LiquidctlGUI.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -701,11 +701,14 @@ def update_status(self):
701701
return
702702
try:
703703
cmd = ["liquidctl", "-m", self.selected_device["description"], "status", "--json"]
704-
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=5)
704+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
705705
try:
706+
result.check_returncode()
706707
data = json.loads(result.stdout)
707708
self.parse_json_status(data)
708-
except Exception:
709+
except (json.JSONDecodeError, subprocess.CalledProcessError):
710+
cmd = ["liquidctl", "-m", self.selected_device["description"], "status"]
711+
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=5)
709712
self.parse_text_status(result.stdout)
710713
except Exception as e:
711714
self.show_status_message(f"Error updating status: {e}")
@@ -726,8 +729,8 @@ def parse_json_status(self, status_data):
726729
for item in device_status:
727730
key = item.get("key", "").lower()
728731
value = item.get("value", 0)
729-
if "fan speed" in key:
730-
m = re.search(r"fan speed (\d+)", key)
732+
if "fan" in key and "speed" in key:
733+
m = re.search(r"fan (\d+) speed", key)
731734
if m:
732735
fan_num = m.group(1)
733736
rpm = int(value) if isinstance(value, (int, float)) else 0
@@ -742,7 +745,28 @@ def parse_json_status(self, status_data):
742745
self.update_ui_with_status(fan_speeds, pump_speed, water_temp)
743746

744747
def parse_text_status(self, output):
745-
pass
748+
fan_speeds = {}
749+
pump_speed = None
750+
water_temp = None
751+
for line in output.splitlines():
752+
lower = line.strip().lower()
753+
m = re.search(r"fan (\d+) speed[:\s]+(\d+)", lower)
754+
if m:
755+
fan_num = m.group(1)
756+
rpm = int(m.group(2))
757+
percent = self.rpm_to_percent(rpm)
758+
fan_speeds[f"Fan {fan_num}"] = (percent, rpm)
759+
continue
760+
m = re.search(r"pump speed[:\s]+(\d+)", lower)
761+
if m:
762+
rpm = int(m.group(1))
763+
percent = self.rpm_to_percent(rpm, is_pump=True)
764+
pump_speed = (percent, rpm)
765+
continue
766+
m = re.search(r"(liquid|water) temperature[:\s]+([\d\.]+)", lower)
767+
if m:
768+
water_temp = float(m.group(2))
769+
self.update_ui_with_status(fan_speeds, pump_speed, water_temp)
746770

747771
def update_ui_with_status(self, fan_speeds, pump_speed, water_temp):
748772
now = time.time()

test_liquidctl_gui.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import subprocess
2+
import types
3+
import sys
4+
from types import SimpleNamespace
5+
6+
import pytest
7+
from unittest.mock import patch
8+
9+
10+
def load_module():
11+
"""Load LiquidctlGUI with stubbed PyQt6 and without running device checks."""
12+
# Stub PyQt6 modules
13+
pyqt6 = types.ModuleType("PyQt6")
14+
qtwidgets = types.ModuleType("PyQt6.QtWidgets")
15+
qtgui = types.ModuleType("PyQt6.QtGui")
16+
qtcore = types.ModuleType("PyQt6.QtCore")
17+
18+
class DummyMeta(type):
19+
def __getattr__(cls, name):
20+
return Dummy
21+
22+
class Dummy(metaclass=DummyMeta):
23+
def __init__(self, *a, **k):
24+
pass
25+
def __getattr__(self, name):
26+
return Dummy()
27+
def __call__(self, *a, **k):
28+
return Dummy()
29+
def setText(self, *a, **k):
30+
pass
31+
def show(self):
32+
pass
33+
def setIcon(self, *a, **k):
34+
pass
35+
def setContextMenu(self, *a, **k):
36+
pass
37+
def start(self, *a, **k):
38+
pass
39+
def timeout(self, *a, **k):
40+
return Dummy()
41+
def connect(self, *a, **k):
42+
pass
43+
def blockSignals(self, *a, **k):
44+
pass
45+
def setValue(self, *a, **k):
46+
pass
47+
48+
widget_names = [
49+
"QApplication", "QMainWindow", "QWidget", "QVBoxLayout", "QHBoxLayout",
50+
"QPushButton", "QLabel", "QSlider", "QGroupBox", "QComboBox",
51+
"QSystemTrayIcon", "QMenu", "QMessageBox", "QDialog",
52+
"QFormLayout", "QDialogButtonBox", "QLineEdit", "QInputDialog",
53+
"QStyle", "QSpacerItem", "QSizePolicy", "QStatusBar"
54+
]
55+
for name in widget_names:
56+
setattr(qtwidgets, name, Dummy)
57+
for name in ["QIcon", "QAction", "QFont"]:
58+
setattr(qtgui, name, Dummy)
59+
qtcore.Qt = SimpleNamespace(Orientation=SimpleNamespace(Horizontal=0))
60+
qtcore.QTimer = Dummy
61+
62+
sys.modules.setdefault("PyQt6", pyqt6)
63+
sys.modules.setdefault("PyQt6.QtWidgets", qtwidgets)
64+
sys.modules.setdefault("PyQt6.QtGui", qtgui)
65+
sys.modules.setdefault("PyQt6.QtCore", qtcore)
66+
67+
# Load module with device access check removed
68+
with open("LiquidctlGUI.py", "r") as f:
69+
lines = f.readlines()
70+
filtered = []
71+
skip = {23, 24, 25, 26, 162, 163}
72+
for idx, line in enumerate(lines, start=1):
73+
if idx in skip:
74+
continue
75+
filtered.append(line)
76+
module = types.ModuleType("LiquidctlGUI")
77+
exec("".join(filtered), module.__dict__)
78+
return module
79+
80+
81+
# Load LiquidctlGUI module
82+
LiquidctlModule = load_module()
83+
LiquidCtlGUI = LiquidctlModule.LiquidCtlGUI
84+
85+
86+
class DummyLabel:
87+
def setText(self, text):
88+
self.text = text
89+
90+
91+
def make_dummy_gui():
92+
gui = SimpleNamespace(
93+
selected_device={"description": "test"},
94+
parse_json_status=lambda data: None,
95+
parse_text_status=lambda text: None,
96+
show_status_message=lambda msg: None,
97+
cpu_temp_label=DummyLabel(),
98+
)
99+
return gui
100+
101+
102+
def test_update_status_falls_back_on_json_parse_error():
103+
gui = make_dummy_gui()
104+
sample_text = "Fan 1 Speed: 800\nPump Speed: 2500\nWater Temperature: 31.5"
105+
error_cp = subprocess.CompletedProcess(["liquidctl"], 0, stdout="{", stderr="")
106+
text_cp = subprocess.CompletedProcess(["liquidctl"], 0, stdout=sample_text, stderr="")
107+
gui.parse_text_status = lambda text: setattr(gui, "text_output", text)
108+
with patch.object(LiquidctlModule.subprocess, "run", side_effect=[error_cp, text_cp]):
109+
with patch.object(LiquidctlModule, "get_cpu_temp", return_value=None):
110+
LiquidCtlGUI.update_status(gui)
111+
assert getattr(gui, "text_output", None) == sample_text
112+
113+
114+
def test_update_status_falls_back_on_json_command_error():
115+
gui = make_dummy_gui()
116+
sample_text = "Fan 1 Speed: 800\nPump Speed: 2500\nWater Temperature: 31.5"
117+
error_cp = subprocess.CompletedProcess(["liquidctl"], 1, stdout="", stderr="boom")
118+
text_cp = subprocess.CompletedProcess(["liquidctl"], 0, stdout=sample_text, stderr="")
119+
gui.parse_text_status = lambda text: setattr(gui, "text_output", text)
120+
with patch.object(LiquidctlModule.subprocess, "run", side_effect=[error_cp, text_cp]):
121+
with patch.object(LiquidctlModule, "get_cpu_temp", return_value=None):
122+
LiquidCtlGUI.update_status(gui)
123+
assert getattr(gui, "text_output", None) == sample_text
124+
125+
126+
def test_parse_text_status_extracts_values():
127+
class Dummy:
128+
min_fan_rpm = 200
129+
max_fan_rpm = 2000
130+
min_pump_rpm = 1000
131+
max_pump_rpm = 2700
132+
133+
def rpm_to_percent(self, rpm, is_pump=False):
134+
return LiquidCtlGUI.rpm_to_percent(self, rpm, is_pump)
135+
136+
def update_ui_with_status(self, fan_speeds, pump_speed, water_temp):
137+
self.fan_speeds = fan_speeds
138+
self.pump_speed = pump_speed
139+
self.water_temp = water_temp
140+
141+
dummy = Dummy()
142+
sample_text = (
143+
"Fan 1 Speed: 800\n"
144+
"Fan 2 Speed: 1000\n"
145+
"Pump Speed: 2500\n"
146+
"Water Temperature: 31.5"
147+
)
148+
LiquidCtlGUI.parse_text_status(dummy, sample_text)
149+
assert dummy.fan_speeds == {"Fan 1": (50, 800), "Fan 2": (60, 1000)}
150+
assert dummy.pump_speed == (90, 2500)
151+
assert dummy.water_temp == 31.5

0 commit comments

Comments
 (0)