luxstats_configurator.py 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342
  1. #!/usr/bin/env python3
  2. """LuxStats Configurator v3 - PyQt5 applet for the Tiny 2040 status-LED firmware.
  3. v3 changes
  4. ----------
  5. * Known source types are enforced: picking cpu/eth/wifi/vpn/cam/*_temp/etc
  6. locks the zone to its correct type so displays can't be misconfigured
  7. * Temperature sources report RAW Celsius; temp zones map them onto a
  8. gradient via per-zone min/max degC
  9. * Ethernet & Wi-Fi are positional: one color per position (down/10M/100M/
  10. 1G/2.5G/10G and down/2.4/5/6 GHz) instead of an interpolated range;
  11. eth reports 0 unless the link is actually active (carrier up)
  12. * Camera detection ignores PipeWire/WirePlumber/PulseAudio holding the
  13. device open, and camera state is edge-triggered (on AND off are sent)
  14. * Camera glow uses assigned LEDs with range syntax ("1-5,9-13") and the
  15. same animation presets as ambient zones (solid/breathe/rainbow/sparkle)
  16. * Any start/stop test (zone range, camera glow, raw fill) pauses automated
  17. polling and resumes it when the test ends; fake-status buttons pause
  18. polling for 10 s so the fake value stays visible
  19. * Polling interval is set in 100 ms steps (minimum 100 ms; slow probes are
  20. internally cached so 10 Hz polling stays cheap)
  21. * Config persists everything on the host - zones, device identity
  22. ("Tiny 2040 (8MB) - CircuitPython CDC2 control" on any /dev/ttyACM#),
  23. blinker period/count, notification-watcher toggle, camera-automation
  24. toggle - and the app auto-connects to the named device on startup
  25. Run: python3 luxstats_configurator.py
  26. """
  27. import copy
  28. import json
  29. import os
  30. import sys
  31. import threading
  32. import time
  33. import serial
  34. import serial.tools.list_ports
  35. from PyQt5.QtCore import Qt, QThread, QTimer, pyqtSignal
  36. from PyQt5.QtGui import QColor
  37. from PyQt5.QtWidgets import (
  38. QApplication, QCheckBox, QColorDialog, QComboBox, QDialog,
  39. QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGridLayout,
  40. QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
  41. QPushButton, QSlider, QSpinBox, QStatusBar, QTableWidget, QTabWidget,
  42. QTextEdit, QVBoxLayout, QWidget,
  43. )
  44. import system_monitor
  45. RP2040_VID = 0x2E8A
  46. DEFAULT_DEVICE_NAME = "Tiny 2040 (8MB) - CircuitPython CDC2 control"
  47. SEG_TYPES = ["percent", "temp", "link", "wifi", "vpn", "bool", "custom",
  48. "ambient"]
  49. GRADIENT_TYPES = {"percent", "temp"}
  50. PALETTE_TYPES = {"link", "wifi"}
  51. PIXEL_ORDERS = ["GRBW", "RGBW", "GRB", "RGB"]
  52. AMBIENT_EFFECTS = ["off", "solid", "breathe", "rainbow", "sparkle"]
  53. CAMERA_EFFECTS = ["solid", "breathe", "rainbow", "sparkle"]
  54. LINK_POSITIONS = ["down", "10M", "100M", "1G", "2.5G", "10G"]
  55. DEFAULT_LINK_COLORS = [[0, 0, 0, 0], [255, 0, 0, 0], [255, 90, 0, 0],
  56. [0, 255, 0, 0], [0, 255, 180, 0], [80, 0, 255, 0]]
  57. WIFI_POSITIONS = ["down", "2.4 GHz", "5 GHz", "6 GHz"]
  58. DEFAULT_WIFI_COLORS = [[0, 0, 0, 0], [255, 120, 0, 0], [0, 255, 60, 0],
  59. [40, 80, 255, 0]]
  60. DEFAULT_GRADIENT = [[0.0, [0, 0, 255, 0]], [0.5, [0, 255, 0, 0]],
  61. [1.0, [255, 0, 0, 0]]]
  62. # Per-source default degC windows for temp zones
  63. TEMP_DEFAULTS = {"cpu_temp": (20, 90), "gpu_temp": (20, 95)}
  64. DISK_TEMP_DEFAULT = (20, 70)
  65. DEFAULT_SEGMENTS = [
  66. {"name": "cpu", "type": "percent", "start": 0, "count": 2, "fill": True,
  67. "gradient": copy.deepcopy(DEFAULT_GRADIENT)},
  68. {"name": "eth", "type": "link", "start": 2, "count": 1},
  69. {"name": "wifi", "type": "wifi", "start": 3, "count": 1},
  70. {"name": "vpn", "type": "vpn", "start": 4, "count": 1},
  71. {"name": "glow", "type": "ambient", "start": 5, "count": 3,
  72. "effect": "breathe", "color": [10, 10, 30, 0], "speed": 1.0},
  73. ]
  74. def known_type(name):
  75. """Fixed type for a known source name (None = user's choice)."""
  76. name = (name or "").strip()
  77. if name in ("cpu", "ram", "gpu"):
  78. return "percent"
  79. if name == "eth":
  80. return "link"
  81. if name == "wifi":
  82. return "wifi"
  83. if name == "vpn":
  84. return "vpn"
  85. if name == "cam":
  86. return "bool"
  87. if name.endswith("_temp") or name == "disk_temp":
  88. return "temp"
  89. if name.startswith("disk"):
  90. return "percent"
  91. return None
  92. def default_temp_range(name):
  93. if name in TEMP_DEFAULTS:
  94. return TEMP_DEFAULTS[name]
  95. if name.startswith("disk"):
  96. return DISK_TEMP_DEFAULT
  97. return (20, 90)
  98. def parse_ranges(text):
  99. """'1-5,9-13,20' -> [1,2,3,4,5,9,10,11,12,13,20] (LED indices)."""
  100. out = []
  101. for part in (text or "").replace(";", ",").split(","):
  102. part = part.strip()
  103. if not part:
  104. continue
  105. if "-" in part:
  106. a, _, b = part.partition("-")
  107. if a.strip().isdigit() and b.strip().isdigit():
  108. lo, hi = int(a), int(b)
  109. if lo > hi:
  110. lo, hi = hi, lo
  111. out.extend(range(lo, hi + 1))
  112. elif part.isdigit():
  113. out.append(int(part))
  114. return sorted(set(out))
  115. def format_ranges(indices):
  116. """[1,2,3,4,5,9] -> '1-5,9'."""
  117. indices = sorted(set(int(i) for i in indices))
  118. if not indices:
  119. return ""
  120. parts, a, b = [], indices[0], indices[0]
  121. for i in indices[1:]:
  122. if i == b + 1:
  123. b = i
  124. else:
  125. parts.append(f"{a}-{b}" if b > a else f"{a}")
  126. a = b = i
  127. parts.append(f"{a}-{b}" if b > a else f"{a}")
  128. return ",".join(parts)
  129. def app_config_path():
  130. if sys.platform == "win32":
  131. base = os.environ.get("APPDATA", os.path.expanduser("~"))
  132. else:
  133. base = os.environ.get("XDG_CONFIG_HOME",
  134. os.path.expanduser("~/.config"))
  135. d = os.path.join(base, "luxstats")
  136. os.makedirs(d, exist_ok=True)
  137. return os.path.join(d, "config.json")
  138. # --------------------------------------------------------------------------
  139. # Serial link
  140. # --------------------------------------------------------------------------
  141. class SerialLink:
  142. def __init__(self, log):
  143. self.ser = None
  144. self.log = log
  145. self.lock = threading.Lock()
  146. self.last_activity = 0.0
  147. @staticmethod
  148. def list_ports():
  149. ports = list(serial.tools.list_ports.comports())
  150. ports.sort(key=lambda p: (p.vid != RP2040_VID, p.device))
  151. return ports
  152. @staticmethod
  153. def find_device(device_name):
  154. """Locate the named CDC2 control port on any /dev/ttyACM#."""
  155. matches = []
  156. for p in serial.tools.list_ports.comports():
  157. desc = " ".join(filter(None, [p.description, p.interface or ""]))
  158. if device_name and device_name.lower() in desc.lower():
  159. matches.append(p)
  160. if not matches:
  161. return None
  162. # Prefer the interface explicitly named as the CDC2 data channel,
  163. # otherwise the highest-numbered interface of the board.
  164. for p in matches:
  165. joined = f"{p.description} {p.interface or ''}".lower()
  166. if "cdc2" in joined or "data" in joined:
  167. return p.device
  168. matches.sort(key=lambda p: (p.location or "", p.device))
  169. return matches[-1].device
  170. def open(self, device):
  171. self.close()
  172. self.ser = serial.Serial(device, 115200, timeout=0.5, write_timeout=1)
  173. time.sleep(0.2)
  174. self.ser.reset_input_buffer()
  175. self.last_activity = time.monotonic()
  176. def close(self):
  177. if self.ser:
  178. try:
  179. self.ser.close()
  180. except OSError:
  181. pass
  182. self.ser = None
  183. @property
  184. def connected(self):
  185. return self.ser is not None and self.ser.is_open
  186. def _drain(self):
  187. while self.ser.in_waiting:
  188. self.ser.readline()
  189. def send(self, obj, expect_reply=True):
  190. if not self.connected:
  191. return None
  192. with self.lock:
  193. if not self.connected:
  194. return None
  195. try:
  196. self._drain()
  197. self.ser.write((json.dumps(obj) + "\n").encode())
  198. self.last_activity = time.monotonic()
  199. if not expect_reply:
  200. return None
  201. reply = self.ser.readline().decode(errors="replace").strip()
  202. if reply:
  203. self.last_activity = time.monotonic()
  204. try:
  205. return json.loads(reply)
  206. except ValueError:
  207. return {"raw": reply}
  208. except (OSError, serial.SerialException) as e:
  209. self.log(f"serial error: {e}")
  210. self.close()
  211. return None
  212. # --------------------------------------------------------------------------
  213. # Background automation thread (pausable)
  214. # --------------------------------------------------------------------------
  215. class MonitorThread(QThread):
  216. statuses = pyqtSignal(dict)
  217. def __init__(self):
  218. super().__init__()
  219. self.interval = 2.0
  220. self.enabled_probes = None
  221. self.running = True
  222. self.paused = False
  223. def run(self):
  224. system_monitor.psutil.cpu_percent(interval=None)
  225. while self.running:
  226. if not self.paused:
  227. try:
  228. self.statuses.emit(
  229. system_monitor.collect(self.enabled_probes))
  230. except Exception as e:
  231. self.statuses.emit({"_error": str(e)})
  232. t = 0.0
  233. while self.running and t < self.interval:
  234. self.msleep(50)
  235. t += 0.05
  236. def stop(self):
  237. self.running = False
  238. # --------------------------------------------------------------------------
  239. # Small widgets
  240. # --------------------------------------------------------------------------
  241. class ColorButton(QPushButton):
  242. changed = pyqtSignal()
  243. def __init__(self, rgbw=(0, 60, 255, 0)):
  244. super().__init__()
  245. self._rgbw = list(rgbw)[:4] + [0] * (4 - len(rgbw))
  246. self.clicked.connect(self.pick)
  247. self._refresh()
  248. def _refresh(self):
  249. r, g, b, _ = self._rgbw
  250. lum = 0.299 * r + 0.587 * g + 0.114 * b
  251. fg = "black" if lum > 128 else "white"
  252. self.setStyleSheet(
  253. f"background-color: rgb({r},{g},{b}); color:{fg};"
  254. "min-width: 72px;")
  255. self.setText(f"({r},{g},{b},{self._rgbw[3]})")
  256. def pick(self):
  257. r, g, b, _ = self._rgbw
  258. c = QColorDialog.getColor(QColor(r, g, b), self, "Pick color")
  259. if c.isValid():
  260. self._rgbw[:3] = [c.red(), c.green(), c.blue()]
  261. self._refresh()
  262. self.changed.emit()
  263. def set_white(self, w):
  264. self._rgbw[3] = int(w)
  265. self._refresh()
  266. self.changed.emit()
  267. def rgbw(self):
  268. return list(self._rgbw)
  269. def set_rgbw(self, rgbw):
  270. self._rgbw = list(rgbw)[:4] + [0] * (4 - len(rgbw))
  271. self._refresh()
  272. # --------------------------------------------------------------------------
  273. # Per-zone settings dialog
  274. # --------------------------------------------------------------------------
  275. class SegmentDialog(QDialog):
  276. def __init__(self, seg, parent=None):
  277. super().__init__(parent)
  278. self.seg = seg
  279. stype = seg.get("type", "percent")
  280. name = seg.get("name", "")
  281. self.setWindowTitle(f"Zone settings - {name} ({stype})")
  282. lay = QVBoxLayout(self)
  283. self.grad_table = None
  284. self.palette_btns = None
  285. self.effect_combo = None
  286. self.on_btn = self.off_btn = self.custom_btn = None
  287. self.min_c = self.max_c = None
  288. if stype == "temp":
  289. form = QFormLayout()
  290. lo, hi = default_temp_range(name)
  291. self.min_c = QDoubleSpinBox()
  292. self.min_c.setRange(-50, 200); self.min_c.setSuffix(" °C")
  293. self.min_c.setValue(float(seg.get("min_c", lo)))
  294. self.max_c = QDoubleSpinBox()
  295. self.max_c.setRange(-50, 200); self.max_c.setSuffix(" °C")
  296. self.max_c.setValue(float(seg.get("max_c", hi)))
  297. form.addRow("Gradient start at:", self.min_c)
  298. form.addRow("Gradient end at:", self.max_c)
  299. lay.addLayout(form)
  300. lay.addWidget(QLabel(
  301. "Raw Celsius values map onto the gradient between these "
  302. "temperatures (clamped outside)."))
  303. if stype in GRADIENT_TYPES:
  304. lay.addWidget(QLabel("Gradient color stops (position 0.0-1.0). "
  305. "Remove all stops for the built-in "
  306. "blue→green→red."))
  307. self.grad_table = QTableWidget(0, 3)
  308. self.grad_table.setHorizontalHeaderLabels(
  309. ["Position", "Color", "White"])
  310. self.grad_table.horizontalHeader().setStretchLastSection(True)
  311. for pos, color in seg.get("gradient", []) or []:
  312. self._add_stop(pos, color)
  313. lay.addWidget(self.grad_table)
  314. row = QHBoxLayout()
  315. b_add = QPushButton("Add stop")
  316. b_add.clicked.connect(lambda: self._add_stop(1.0, [255, 0, 0, 0]))
  317. b_del = QPushButton("Remove selected")
  318. b_del.clicked.connect(lambda: self.grad_table.removeRow(
  319. self.grad_table.currentRow()))
  320. b_def = QPushButton("Load default gradient")
  321. b_def.clicked.connect(self._load_default)
  322. row.addWidget(b_add); row.addWidget(b_del); row.addWidget(b_def)
  323. row.addStretch(1)
  324. lay.addLayout(row)
  325. elif stype in PALETTE_TYPES:
  326. positions = LINK_POSITIONS if stype == "link" else WIFI_POSITIONS
  327. defaults = (DEFAULT_LINK_COLORS if stype == "link"
  328. else DEFAULT_WIFI_COLORS)
  329. lay.addWidget(QLabel(
  330. "One color per position (no blending between positions)."))
  331. form = QFormLayout()
  332. colors = seg.get("colors") or copy.deepcopy(defaults)
  333. self.palette_btns = []
  334. for i, label in enumerate(positions):
  335. c = colors[i] if i < len(colors) else defaults[i]
  336. btn = ColorButton(c)
  337. w = QSpinBox(); w.setRange(0, 255)
  338. w.setValue(int(c[3]) if len(c) > 3 else 0)
  339. w.valueChanged.connect(btn.set_white)
  340. row = QHBoxLayout()
  341. row.addWidget(btn); row.addWidget(QLabel("W:"))
  342. row.addWidget(w); row.addStretch(1)
  343. form.addRow(f"{label}:", row)
  344. self.palette_btns.append(btn)
  345. lay.addLayout(form)
  346. elif stype == "ambient":
  347. form = QFormLayout()
  348. self.effect_combo = QComboBox()
  349. self.effect_combo.addItems(AMBIENT_EFFECTS)
  350. self.effect_combo.setCurrentText(seg.get("effect", "breathe"))
  351. self.amb_color = ColorButton(seg.get("color", [10, 10, 30, 0]))
  352. self.amb_white = QSpinBox(); self.amb_white.setRange(0, 255)
  353. col = seg.get("color", [0, 0, 0, 0])
  354. self.amb_white.setValue(int(col[3]) if len(col) > 3 else 0)
  355. self.amb_white.valueChanged.connect(self.amb_color.set_white)
  356. self.amb_speed = QDoubleSpinBox()
  357. self.amb_speed.setRange(0.1, 5.0); self.amb_speed.setSingleStep(0.1)
  358. self.amb_speed.setValue(float(seg.get("speed", 1.0)))
  359. crow = QHBoxLayout()
  360. crow.addWidget(self.amb_color)
  361. crow.addWidget(QLabel("W:")); crow.addWidget(self.amb_white)
  362. form.addRow("Effect:", self.effect_combo)
  363. form.addRow("Color:", crow)
  364. form.addRow("Speed:", self.amb_speed)
  365. lay.addLayout(form)
  366. elif stype in ("vpn", "bool"):
  367. form = QFormLayout()
  368. on_def = [170, 0, 255, 0] if stype == "vpn" else [0, 255, 0, 0]
  369. off_def = [6, 6, 6, 0] if stype == "vpn" else [4, 0, 0, 0]
  370. self.on_btn = ColorButton(seg.get("on_color", on_def))
  371. self.off_btn = ColorButton(seg.get("off_color", off_def))
  372. form.addRow("Color when ON:", self.on_btn)
  373. form.addRow("Color when OFF:", self.off_btn)
  374. lay.addLayout(form)
  375. elif stype == "custom":
  376. form = QFormLayout()
  377. self.custom_btn = ColorButton(seg.get("color", [0, 0, 0, 0]))
  378. form.addRow("Fallback color (until a value is pushed):",
  379. self.custom_btn)
  380. lay.addLayout(form)
  381. buttons = QDialogButtonBox(
  382. QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
  383. buttons.accepted.connect(self.accept)
  384. buttons.rejected.connect(self.reject)
  385. lay.addWidget(buttons)
  386. def _add_stop(self, pos, color):
  387. r = self.grad_table.rowCount()
  388. self.grad_table.insertRow(r)
  389. spin = QDoubleSpinBox()
  390. spin.setRange(0.0, 1.0); spin.setSingleStep(0.05)
  391. spin.setDecimals(2); spin.setValue(float(pos))
  392. btn = ColorButton(color)
  393. w = QSpinBox(); w.setRange(0, 255)
  394. w.setValue(int(color[3]) if len(color) > 3 else 0)
  395. w.valueChanged.connect(btn.set_white)
  396. self.grad_table.setCellWidget(r, 0, spin)
  397. self.grad_table.setCellWidget(r, 1, btn)
  398. self.grad_table.setCellWidget(r, 2, w)
  399. def _load_default(self):
  400. self.grad_table.setRowCount(0)
  401. for pos, color in copy.deepcopy(DEFAULT_GRADIENT):
  402. self._add_stop(pos, color)
  403. def apply_to_segment(self):
  404. if self.min_c is not None:
  405. self.seg["min_c"] = self.min_c.value()
  406. self.seg["max_c"] = self.max_c.value()
  407. if self.grad_table is not None:
  408. stops = []
  409. for r in range(self.grad_table.rowCount()):
  410. stops.append([self.grad_table.cellWidget(r, 0).value(),
  411. self.grad_table.cellWidget(r, 1).rgbw()])
  412. stops.sort(key=lambda s: s[0])
  413. self.seg["gradient"] = stops
  414. if self.palette_btns is not None:
  415. self.seg["colors"] = [b.rgbw() for b in self.palette_btns]
  416. if self.effect_combo is not None:
  417. self.seg["effect"] = self.effect_combo.currentText()
  418. self.seg["color"] = self.amb_color.rgbw()
  419. self.seg["speed"] = self.amb_speed.value()
  420. if self.on_btn is not None:
  421. self.seg["on_color"] = self.on_btn.rgbw()
  422. self.seg["off_color"] = self.off_btn.rgbw()
  423. if self.custom_btn is not None:
  424. self.seg["color"] = self.custom_btn.rgbw()
  425. # --------------------------------------------------------------------------
  426. # Main window
  427. # --------------------------------------------------------------------------
  428. class MainWindow(QMainWindow):
  429. KEEPALIVE_S = 10
  430. RECONNECT_S = 3
  431. FAKE_PAUSE_S = 10 # fake-status buttons pause polling this long
  432. notification_seen = pyqtSignal()
  433. def __init__(self):
  434. super().__init__()
  435. self.setWindowTitle("LuxStats Configurator")
  436. self.resize(960, 720)
  437. self.link = SerialLink(self.log)
  438. self.monitor = MonitorThread()
  439. self.monitor.statuses.connect(self.on_statuses)
  440. self.notify_watcher = None
  441. self.notification_seen.connect(self.fire_notification)
  442. self.segments = copy.deepcopy(DEFAULT_SEGMENTS)
  443. self._building = False
  444. self._active_test_row = None
  445. self._raw_test = False
  446. self._last_cam = None
  447. self.device_name = DEFAULT_DEVICE_NAME
  448. self._last_port = None
  449. self._user_disconnected = False # auto-connect from the start
  450. self.push_timer = QTimer(self)
  451. self.push_timer.setSingleShot(True)
  452. self.push_timer.setInterval(400)
  453. self.push_timer.timeout.connect(self._auto_push)
  454. self.fake_resume_timer = QTimer(self)
  455. self.fake_resume_timer.setSingleShot(True)
  456. self.fake_resume_timer.setInterval(self.FAKE_PAUSE_S * 1000)
  457. self.fake_resume_timer.timeout.connect(
  458. lambda: self.resume_polling("fake status"))
  459. self.keepalive_timer = QTimer(self)
  460. self.keepalive_timer.setInterval(self.RECONNECT_S * 1000)
  461. self.keepalive_timer.timeout.connect(self._keepalive)
  462. self.keepalive_timer.start()
  463. tabs = QTabWidget()
  464. tabs.addTab(self._build_connection_tab(), "Connection")
  465. tabs.addTab(self._build_device_tab(), "Device")
  466. tabs.addTab(self._build_segments_tab(), "Zones && Mapping")
  467. tabs.addTab(self._build_automation_tab(), "Automation")
  468. tabs.addTab(self._build_test_tab(), "Test")
  469. self.setCentralWidget(tabs)
  470. self.setStatusBar(QStatusBar())
  471. self._wire_autopush()
  472. self.load_app_config()
  473. self.refresh_ports()
  474. self.rebuild_seg_table()
  475. QTimer.singleShot(300, self._startup_connect)
  476. # ---------------- Connection tab ----------------
  477. def _build_connection_tab(self):
  478. w = QWidget()
  479. lay = QVBoxLayout(w)
  480. row0 = QHBoxLayout()
  481. self.device_name_edit = QLineEdit(DEFAULT_DEVICE_NAME)
  482. row0.addWidget(QLabel("Device name (auto-connect):"))
  483. row0.addWidget(self.device_name_edit, 1)
  484. lay.addLayout(row0)
  485. row = QHBoxLayout()
  486. self.port_combo = QComboBox()
  487. btn_refresh = QPushButton("Refresh")
  488. btn_refresh.clicked.connect(self.refresh_ports)
  489. self.btn_connect = QPushButton("Connect")
  490. self.btn_connect.clicked.connect(self.toggle_connect)
  491. row.addWidget(QLabel("Serial port:"))
  492. row.addWidget(self.port_combo, 1)
  493. row.addWidget(btn_refresh)
  494. row.addWidget(self.btn_connect)
  495. lay.addLayout(row)
  496. hint = QLabel(
  497. "The app looks for the named device on any /dev/ttyACM# port at "
  498. "startup and whenever the link drops, and re-pushes the stored "
  499. "config on every connect. Config lives on this computer only.")
  500. hint.setWordWrap(True)
  501. lay.addWidget(hint)
  502. self.log_box = QTextEdit()
  503. self.log_box.setReadOnly(True)
  504. lay.addWidget(self.log_box, 1)
  505. row2 = QHBoxLayout()
  506. for label, fn in [
  507. ("Ping", self.do_ping),
  508. ("Push config now", self.push_config),
  509. ("Export profile...", self.save_profile),
  510. ("Import profile...", self.load_profile),
  511. ]:
  512. b = QPushButton(label)
  513. b.clicked.connect(fn)
  514. row2.addWidget(b)
  515. lay.addLayout(row2)
  516. return w
  517. def refresh_ports(self):
  518. self.port_combo.clear()
  519. for p in SerialLink.list_ports():
  520. tag = " [RP2040]" if p.vid == RP2040_VID else ""
  521. self.port_combo.addItem(
  522. f"{p.device} - {p.description}{tag}", p.device)
  523. def _startup_connect(self):
  524. if self.link.connected or self._user_disconnected:
  525. return
  526. dev = SerialLink.find_device(self.device_name_edit.text()) \
  527. or self._last_port
  528. if dev and self._connect_to(dev, quiet=True):
  529. self.log("auto-connected on startup")
  530. def toggle_connect(self):
  531. if self.link.connected:
  532. self._user_disconnected = True
  533. self.link.close()
  534. self.btn_connect.setText("Connect")
  535. self.log("disconnected")
  536. return
  537. dev = self.port_combo.currentData() \
  538. or SerialLink.find_device(self.device_name_edit.text())
  539. if not dev:
  540. self.log("no port selected and named device not found")
  541. return
  542. self._user_disconnected = False
  543. self._connect_to(dev)
  544. def _connect_to(self, dev, quiet=False):
  545. try:
  546. self.link.open(dev)
  547. except (OSError, serial.SerialException) as e:
  548. if not quiet:
  549. self.log(f"open failed: {e}")
  550. return False
  551. self._last_port = dev
  552. self.btn_connect.setText("Disconnect")
  553. self.log(f"connected to {dev}")
  554. reply = self.link.send({"cmd": "ping"})
  555. if reply and reply.get("pong"):
  556. self.log("device answered ping - pushing config")
  557. self.push_config()
  558. return True
  559. self.log("no pong - is this the DATA port and is code.py running?")
  560. return False
  561. def _keepalive(self):
  562. if self._user_disconnected:
  563. return
  564. if self.link.connected:
  565. if time.monotonic() - self.link.last_activity >= self.KEEPALIVE_S:
  566. reply = self.link.send({"cmd": "ping"})
  567. if not (reply and reply.get("pong")):
  568. self.log("keepalive ping failed - link lost, will "
  569. "auto-reconnect")
  570. self.link.close()
  571. return
  572. self.btn_connect.setText("Connect")
  573. dev = SerialLink.find_device(self.device_name_edit.text()) \
  574. or self._last_port
  575. if dev and self._connect_to(dev, quiet=True):
  576. self.log("auto-reconnected")
  577. def do_ping(self):
  578. self.log(f"ping -> {self.link.send({'cmd': 'ping'})}")
  579. def send_logged(self, obj):
  580. self.log(f"{obj.get('cmd')} -> {self.link.send(obj)}")
  581. def log(self, msg):
  582. self.log_box.append(f"[{time.strftime('%H:%M:%S')}] {msg}")
  583. self.statusBar().showMessage(msg, 4000)
  584. # ---------------- Device tab ----------------
  585. def _build_device_tab(self):
  586. w = QWidget()
  587. lay = QVBoxLayout(w)
  588. gb = QGroupBox("Strip")
  589. form = QFormLayout(gb)
  590. self.num_pixels = QSpinBox(); self.num_pixels.setRange(1, 1024)
  591. self.num_pixels.setValue(8)
  592. self.pin_edit = QLineEdit("GP0")
  593. self.order_combo = QComboBox(); self.order_combo.addItems(PIXEL_ORDERS)
  594. self.fps_spin = QSpinBox(); self.fps_spin.setRange(5, 120)
  595. self.fps_spin.setValue(30)
  596. self.brightness = QSlider(Qt.Horizontal)
  597. self.brightness.setRange(1, 100); self.brightness.setValue(10)
  598. self.bright_label = QLabel("10%")
  599. self.brightness.valueChanged.connect(
  600. lambda v: self.bright_label.setText(f"{v}%"))
  601. brow = QHBoxLayout(); brow.addWidget(self.brightness, 1)
  602. brow.addWidget(self.bright_label)
  603. form.addRow("Number of LEDs:", self.num_pixels)
  604. form.addRow("Data pin:", self.pin_edit)
  605. form.addRow("Pixel order:", self.order_combo)
  606. form.addRow("Frame rate:", self.fps_spin)
  607. form.addRow("Brightness:", brow)
  608. lay.addWidget(gb)
  609. lay.addWidget(QLabel("Changes push to the device automatically."))
  610. lay.addStretch(1)
  611. return w
  612. # ---------------- Zones & mapping tab ----------------
  613. def _build_segments_tab(self):
  614. w = QWidget()
  615. lay = QVBoxLayout(w)
  616. lay.addWidget(QLabel(
  617. "Ordered zones. Known sources lock to their correct type "
  618. "(temps use raw °C, eth/wifi use one color per position). "
  619. "Zones may not overlap; unassigned pixels stay off. Test blinks "
  620. "a zone's range and pauses polling until stopped."))
  621. self.seg_table = QTableWidget(0, 7)
  622. self.seg_table.setHorizontalHeaderLabels(
  623. ["Source / name", "Type", "Start", "Count", "Fill",
  624. "Settings", "Test"])
  625. self.seg_table.horizontalHeader().setStretchLastSection(True)
  626. lay.addWidget(self.seg_table, 1)
  627. self.overlap_label = QLabel("")
  628. self.overlap_label.setStyleSheet("color: #c02020; font-weight: bold;")
  629. self.overlap_label.setWordWrap(True)
  630. lay.addWidget(self.overlap_label)
  631. row = QHBoxLayout()
  632. for label, fn in [("Add zone", self.add_segment),
  633. ("Remove selected", self.remove_segment),
  634. ("Move up", lambda: self.move_segment(-1)),
  635. ("Move down", lambda: self.move_segment(+1))]:
  636. b = QPushButton(label)
  637. b.clicked.connect(fn)
  638. row.addWidget(b)
  639. row.addStretch(1)
  640. lay.addLayout(row)
  641. gb = QGroupBox("Camera glow (overlay on assigned LEDs)")
  642. form = QFormLayout(gb)
  643. self.cam_pixels = QLineEdit("5-7")
  644. self.cam_pixels.setPlaceholderText("e.g. 1-5,9-13")
  645. self.cam_effect = QComboBox(); self.cam_effect.addItems(CAMERA_EFFECTS)
  646. self.cam_effect.setCurrentText("breathe")
  647. self.cam_speed = QDoubleSpinBox()
  648. self.cam_speed.setRange(0.1, 5.0); self.cam_speed.setSingleStep(0.1)
  649. self.cam_speed.setValue(1.0)
  650. self.cam_color = ColorButton((255, 40, 0, 0))
  651. self.cam_test_btn = QPushButton("Test glow (start/stop)")
  652. self.cam_test_btn.setCheckable(True)
  653. self.cam_test_btn.toggled.connect(self.toggle_camera_test)
  654. form.addRow("LEDs (ranges, e.g. 1-5,9-13):", self.cam_pixels)
  655. form.addRow("Animation:", self.cam_effect)
  656. form.addRow("Speed:", self.cam_speed)
  657. form.addRow("Glow color:", self.cam_color)
  658. form.addRow("", self.cam_test_btn)
  659. lay.addWidget(gb)
  660. gb2 = QGroupBox("Notification blinker (overlay)")
  661. form2 = QFormLayout(gb2)
  662. self.notify_start = QSpinBox(); self.notify_start.setRange(0, 1023)
  663. self.notify_count = QSpinBox(); self.notify_count.setRange(0, 1024)
  664. self.notify_count.setValue(0)
  665. self.notify_color = ColorButton((0, 60, 255, 0))
  666. self.notify_period = QDoubleSpinBox()
  667. self.notify_period.setRange(0.05, 5.0)
  668. self.notify_period.setValue(0.4); self.notify_period.setSingleStep(0.05)
  669. self.notify_blinks = QSpinBox()
  670. self.notify_blinks.setRange(-1, 100); self.notify_blinks.setValue(6)
  671. nrow = QHBoxLayout()
  672. nrow.addWidget(QLabel("Start:")); nrow.addWidget(self.notify_start)
  673. nrow.addWidget(QLabel("Count (0 = all):"))
  674. nrow.addWidget(self.notify_count)
  675. nrow.addStretch(1)
  676. form2.addRow("Range:", nrow)
  677. form2.addRow("Default color:", self.notify_color)
  678. form2.addRow("Blink period (s):", self.notify_period)
  679. form2.addRow("Blink count (-1 = forever):", self.notify_blinks)
  680. lay.addWidget(gb2)
  681. return w
  682. # ----- segment model <-> table -----
  683. def rebuild_seg_table(self):
  684. self._building = True
  685. sources = system_monitor.discover_sources() + ["glow", "custom1"]
  686. self.seg_table.setRowCount(0)
  687. for idx, seg in enumerate(self.segments):
  688. # enforce known types on load/rebuild (requirement 7)
  689. fixed = known_type(seg.get("name", ""))
  690. if fixed and seg.get("type") != fixed:
  691. seg["type"] = fixed
  692. r = self.seg_table.rowCount()
  693. self.seg_table.insertRow(r)
  694. name = QComboBox(); name.setEditable(True)
  695. name.addItems(sources)
  696. name.setCurrentText(str(seg.get("name", "")))
  697. name.currentTextChanged.connect(
  698. lambda v, i=idx: self._seg_edit(i, "name", v))
  699. stype = QComboBox(); stype.addItems(SEG_TYPES)
  700. stype.setCurrentText(seg.get("type", "percent"))
  701. stype.setEnabled(fixed is None)
  702. stype.setToolTip("Type is fixed for known sources"
  703. if fixed else "")
  704. stype.currentTextChanged.connect(
  705. lambda v, i=idx: self._seg_edit(i, "type", v))
  706. start = QSpinBox(); start.setRange(0, 1023)
  707. start.setValue(int(seg.get("start", 0)))
  708. start.valueChanged.connect(
  709. lambda v, i=idx: self._seg_edit(i, "start", v))
  710. count = QSpinBox(); count.setRange(1, 1024)
  711. count.setValue(int(seg.get("count", 1)))
  712. count.valueChanged.connect(
  713. lambda v, i=idx: self._seg_edit(i, "count", v))
  714. fill = QCheckBox()
  715. fill.setChecked(bool(seg.get("fill", False)))
  716. fill.setEnabled(seg.get("type") == "percent")
  717. fill.toggled.connect(
  718. lambda v, i=idx: self._seg_edit(i, "fill", v))
  719. settings = QPushButton("Settings...")
  720. settings.clicked.connect(lambda _, i=idx: self.open_settings(i))
  721. test = QPushButton("Test")
  722. test.setCheckable(True)
  723. test.setChecked(self._active_test_row == idx)
  724. test.toggled.connect(lambda on, i=idx: self.toggle_seg_test(i, on))
  725. for col, widget in enumerate(
  726. [name, stype, start, count, fill, settings, test]):
  727. self.seg_table.setCellWidget(r, col, widget)
  728. self._building = False
  729. self.validate_segments()
  730. def _seg_edit(self, idx, key, value):
  731. if self._building or idx >= len(self.segments):
  732. return
  733. seg = self.segments[idx]
  734. seg[key] = value
  735. if key == "name":
  736. fixed = known_type(value)
  737. if fixed and seg.get("type") != fixed:
  738. seg["type"] = fixed
  739. seg.pop("gradient", None)
  740. seg.pop("colors", None)
  741. self.rebuild_seg_table()
  742. else:
  743. type_combo = self.seg_table.cellWidget(idx, 1)
  744. if type_combo:
  745. type_combo.setEnabled(fixed is None)
  746. if key == "type":
  747. seg.pop("gradient", None)
  748. seg.pop("colors", None)
  749. fill = self.seg_table.cellWidget(idx, 4)
  750. if fill:
  751. fill.setEnabled(value == "percent")
  752. self.schedule_push()
  753. self.validate_segments()
  754. if self._active_test_row == idx and key in ("start", "count"):
  755. self.link.send({"cmd": "test", "start": seg["start"],
  756. "count": seg["count"], "active": True},
  757. expect_reply=False)
  758. def add_segment(self):
  759. end = max((s.get("start", 0) + s.get("count", 1)
  760. for s in self.segments), default=0)
  761. self.segments.append({"name": "custom1", "type": "custom",
  762. "start": end, "count": 1})
  763. self.rebuild_seg_table()
  764. self.schedule_push()
  765. def remove_segment(self):
  766. r = self.seg_table.currentRow()
  767. if 0 <= r < len(self.segments):
  768. if self._active_test_row == r:
  769. self._stop_range_test()
  770. del self.segments[r]
  771. self._active_test_row = None
  772. self.rebuild_seg_table()
  773. self.schedule_push()
  774. def move_segment(self, delta):
  775. r = self.seg_table.currentRow()
  776. nr = r + delta
  777. if 0 <= r < len(self.segments) and 0 <= nr < len(self.segments):
  778. self.segments[r], self.segments[nr] = \
  779. self.segments[nr], self.segments[r]
  780. if self._active_test_row == r:
  781. self._active_test_row = nr
  782. elif self._active_test_row == nr:
  783. self._active_test_row = r
  784. self.rebuild_seg_table()
  785. self.seg_table.selectRow(nr)
  786. self.schedule_push()
  787. def open_settings(self, idx):
  788. if idx >= len(self.segments):
  789. return
  790. dlg = SegmentDialog(self.segments[idx], self)
  791. if dlg.exec_() == QDialog.Accepted:
  792. dlg.apply_to_segment()
  793. self.schedule_push()
  794. # ----- polling pause/resume around tests (requirement 8) -----
  795. def pause_polling(self, reason):
  796. if not self.monitor.paused:
  797. self.monitor.paused = True
  798. self.log(f"polling paused ({reason})")
  799. def resume_polling(self, reason):
  800. if self.monitor.paused and self._active_test_row is None \
  801. and not self.cam_test_btn.isChecked() and not self._raw_test \
  802. and not self.fake_resume_timer.isActive():
  803. self.monitor.paused = False
  804. self.log(f"polling resumed ({reason} done)")
  805. # ----- range tests -----
  806. def toggle_seg_test(self, idx, on):
  807. if self._building:
  808. return
  809. if on:
  810. if self._active_test_row is not None \
  811. and self._active_test_row != idx:
  812. old = self.seg_table.cellWidget(self._active_test_row, 6)
  813. if old:
  814. old.blockSignals(True)
  815. old.setChecked(False)
  816. old.blockSignals(False)
  817. self._active_test_row = idx
  818. self.pause_polling("zone test")
  819. seg = self.segments[idx]
  820. self.send_logged({"cmd": "test", "start": seg.get("start", 0),
  821. "count": seg.get("count", 1), "active": True})
  822. else:
  823. if self._active_test_row == idx:
  824. self._stop_range_test()
  825. def _stop_range_test(self):
  826. self._active_test_row = None
  827. self.link.send({"cmd": "test", "active": False}, expect_reply=False)
  828. self.resume_polling("zone test")
  829. def toggle_camera_test(self, on):
  830. if on:
  831. self.pause_polling("camera glow test")
  832. self.send_logged({"cmd": "camera", "active": bool(on)})
  833. self.cam_test_btn.setText(
  834. "Stop glow test" if on else "Test glow (start/stop)")
  835. if not on:
  836. self._last_cam = None # re-sync real camera state
  837. self.resume_polling("camera glow test")
  838. # ----- overlap validation -----
  839. def validate_segments(self):
  840. n = self.num_pixels.value()
  841. owner = {}
  842. problems = []
  843. for idx, seg in enumerate(self.segments):
  844. s, c = int(seg.get("start", 0)), int(seg.get("count", 1))
  845. label = f"#{idx + 1} {seg.get('name', '?')}"
  846. if s + c > n:
  847. problems.append(f"{label} runs past the strip "
  848. f"(LED {s + c - 1} > {n - 1})")
  849. for i in range(s, min(s + c, n)):
  850. if i in owner:
  851. problems.append(f"{label} overlaps {owner[i]} at LED {i}")
  852. break
  853. owner[i] = label
  854. bad_cam = [i for i in parse_ranges(self.cam_pixels.text())
  855. if i >= n] if hasattr(self, "cam_pixels") else []
  856. if bad_cam:
  857. problems.append(f"camera glow LEDs {bad_cam} past the strip")
  858. if problems:
  859. self.overlap_label.setText(
  860. "Not pushed - fix these first:\n" + "\n".join(problems))
  861. else:
  862. self.overlap_label.setText("")
  863. return not problems
  864. # ---------------- Automation tab ----------------
  865. def _build_automation_tab(self):
  866. w = QWidget()
  867. lay = QVBoxLayout(w)
  868. self.probe_group = QGroupBox(
  869. "Live status sources (drives auto-detected)")
  870. self.probe_grid = QGridLayout(self.probe_group)
  871. self.probe_checks = {}
  872. self._rebuild_probes()
  873. lay.addWidget(self.probe_group)
  874. row = QHBoxLayout()
  875. b_re = QPushButton("Re-detect sources")
  876. b_re.clicked.connect(self._rebuild_probes)
  877. self.interval_spin = QDoubleSpinBox()
  878. self.interval_spin.setRange(0.1, 60.0) # 100 ms steps (req 9)
  879. self.interval_spin.setSingleStep(0.1)
  880. self.interval_spin.setDecimals(1)
  881. self.interval_spin.setSuffix(" s")
  882. self.interval_spin.setValue(1.0)
  883. self.btn_monitor = QPushButton("Start automation")
  884. self.btn_monitor.setCheckable(True)
  885. self.btn_monitor.toggled.connect(self.toggle_monitor)
  886. row.addWidget(b_re)
  887. row.addWidget(QLabel("Poll interval (100 ms steps):"))
  888. row.addWidget(self.interval_spin)
  889. row.addWidget(self.btn_monitor)
  890. row.addStretch(1)
  891. lay.addLayout(row)
  892. gb2 = QGroupBox("Desktop notification watcher (Linux/DBus)")
  893. v2 = QVBoxLayout(gb2)
  894. self.notify_watch_chk = QCheckBox(
  895. "Blink the notification zone on any desktop notification")
  896. self.notify_watch_chk.toggled.connect(self.toggle_notify_watcher)
  897. v2.addWidget(self.notify_watch_chk)
  898. lay.addWidget(gb2)
  899. gb3 = QGroupBox("Camera automation")
  900. v3 = QVBoxLayout(gb3)
  901. self.cam_auto_chk = QCheckBox(
  902. "Automatically glow while a real app uses the camera "
  903. "(media daemons like PipeWire are ignored)")
  904. self.cam_auto_chk.setChecked(True)
  905. v3.addWidget(self.cam_auto_chk)
  906. lay.addWidget(gb3)
  907. self.live_label = QLabel("Live values: -")
  908. self.live_label.setWordWrap(True)
  909. lay.addWidget(self.live_label)
  910. lay.addStretch(1)
  911. return w
  912. def _rebuild_probes(self):
  913. prev = {k: cb.isChecked() for k, cb in self.probe_checks.items()}
  914. while self.probe_grid.count():
  915. item = self.probe_grid.takeAt(0)
  916. if item.widget():
  917. item.widget().deleteLater()
  918. self.probe_checks = {}
  919. for i, src in enumerate(system_monitor.discover_sources()):
  920. cb = QCheckBox(src)
  921. cb.setChecked(prev.get(src, True))
  922. cb.toggled.connect(self._probes_changed)
  923. self.probe_checks[src] = cb
  924. self.probe_grid.addWidget(cb, i // 4, i % 4)
  925. def _probes_changed(self):
  926. self.monitor.enabled_probes = {
  927. k for k, cb in self.probe_checks.items() if cb.isChecked()}
  928. self.save_app_config()
  929. def toggle_monitor(self, on):
  930. if on:
  931. self.monitor.interval = self.interval_spin.value()
  932. self._probes_changed()
  933. if not self.monitor.isRunning():
  934. self.monitor.running = True
  935. self.monitor.start()
  936. self.btn_monitor.setText("Stop automation")
  937. self.log("automation started")
  938. else:
  939. self.monitor.stop()
  940. self.btn_monitor.setText("Start automation")
  941. self.log("automation stopped")
  942. def on_statuses(self, values):
  943. if "_error" in values:
  944. self.log(f"monitor error: {values['_error']}")
  945. return
  946. self.live_label.setText("Live values: " + json.dumps(values))
  947. if not self.link.connected:
  948. return
  949. cam = values.pop("cam", None)
  950. self.link.send({"cmd": "status", "values": values},
  951. expect_reply=False)
  952. # edge-triggered camera state: send BOTH transitions (fixes stuck-on)
  953. if cam is not None and self.cam_auto_chk.isChecked() \
  954. and not self.cam_test_btn.isChecked():
  955. if cam != self._last_cam:
  956. self._last_cam = cam
  957. self.send_logged({"cmd": "camera", "active": bool(cam)})
  958. def toggle_notify_watcher(self, on):
  959. if on:
  960. if self.notify_watcher is None:
  961. self.notify_watcher = system_monitor.NotificationWatcher(
  962. self.notification_seen.emit)
  963. self.notify_watcher.start()
  964. self.log("notification watcher started (needs dbus-monitor)")
  965. elif self.notify_watcher:
  966. self.notify_watcher.stop()
  967. self.notify_watcher = None
  968. self.log("notification watcher stopped")
  969. self.save_app_config()
  970. def fire_notification(self):
  971. self.link.send({"cmd": "notify", "color": self.notify_color.rgbw(),
  972. "period": self.notify_period.value(),
  973. "count": self.notify_blinks.value()},
  974. expect_reply=False)
  975. # ---------------- Test tab ----------------
  976. def _build_test_tab(self):
  977. w = QWidget()
  978. lay = QVBoxLayout(w)
  979. lay.addWidget(QLabel(
  980. "Raw fill pauses polling until you exit test mode; fake-status "
  981. f"buttons pause polling for {self.FAKE_PAUSE_S} s so the value "
  982. "stays visible."))
  983. self.test_color = ColorButton((0, 255, 0, 0))
  984. row = QHBoxLayout()
  985. row.addWidget(QLabel("Test color:"))
  986. row.addWidget(self.test_color)
  987. row.addStretch(1)
  988. lay.addLayout(row)
  989. grid = QGridLayout()
  990. fakes = [
  991. ("Fake CPU 90% / GPU 60%", {"cpu": 0.9, "gpu": 0.6}),
  992. ("Fake CPU 85 °C / GPU 70 °C", {"cpu_temp": 85.0,
  993. "gpu_temp": 70.0}),
  994. ("Fake 10G link", {"eth": 10000}),
  995. ("Fake eth inactive", {"eth": 0}),
  996. ("Fake Wi-Fi 6 GHz", {"wifi": 6}),
  997. ("Fake VPN on", {"vpn": True}),
  998. ("Fake disk 85% full", {"disk": 0.85}),
  999. ("Fake disk 55 °C", {"disk_temp": 55.0}),
  1000. ]
  1001. buttons = [
  1002. ("Fire notification", self.fire_notification),
  1003. ("Clear notification",
  1004. lambda: self.send_logged({"cmd": "notify_clear"})),
  1005. ("Fill all (test color)", self.test_fill),
  1006. ("Exit raw/test mode", self.test_raw_off),
  1007. ] + [(label, lambda _=None, v=vals: self.fake_status(v))
  1008. for label, vals in fakes]
  1009. for i, (label, fn) in enumerate(buttons):
  1010. b = QPushButton(label)
  1011. b.clicked.connect(fn)
  1012. grid.addWidget(b, i // 2, i % 2)
  1013. lay.addLayout(grid)
  1014. lay.addStretch(1)
  1015. return w
  1016. def fake_status(self, values):
  1017. self.pause_polling("fake status")
  1018. self.fake_resume_timer.start()
  1019. self.send_logged({"cmd": "status", "values": values, "ack": 1})
  1020. def test_fill(self):
  1021. self._raw_test = True
  1022. self.pause_polling("raw fill")
  1023. self.send_logged({"cmd": "raw",
  1024. "pixels": [self.test_color.rgbw()]
  1025. * self.num_pixels.value()})
  1026. def test_raw_off(self):
  1027. self.send_logged({"cmd": "raw_off"})
  1028. self._raw_test = False
  1029. self.resume_polling("raw fill")
  1030. # ---------------- Config assembly / persistence ----------------
  1031. def build_config(self):
  1032. return {
  1033. "num_pixels": self.num_pixels.value(),
  1034. "pin": self.pin_edit.text().strip() or "GP0",
  1035. "pixel_order": self.order_combo.currentText(),
  1036. "brightness": self.brightness.value() / 100.0,
  1037. "fps": self.fps_spin.value(),
  1038. "segments": copy.deepcopy(self.segments),
  1039. "camera": {"pixels": parse_ranges(self.cam_pixels.text()),
  1040. "color": self.cam_color.rgbw(),
  1041. "effect": self.cam_effect.currentText(),
  1042. "speed": self.cam_speed.value()},
  1043. "notify": {"start": self.notify_start.value(),
  1044. "count": self.notify_count.value(),
  1045. "blinks": self.notify_blinks.value(),
  1046. "default_color": self.notify_color.rgbw(),
  1047. "default_period": self.notify_period.value()},
  1048. }
  1049. def apply_config_to_ui(self, cfg):
  1050. self._building = True
  1051. self.num_pixels.setValue(int(cfg.get("num_pixels", 8)))
  1052. self.pin_edit.setText(cfg.get("pin", "GP0"))
  1053. self.order_combo.setCurrentText(cfg.get("pixel_order", "GRBW"))
  1054. self.brightness.setValue(int(float(cfg.get("brightness", 0.1)) * 100))
  1055. self.fps_spin.setValue(int(cfg.get("fps", 30)))
  1056. self.segments = copy.deepcopy(cfg.get("segments", DEFAULT_SEGMENTS))
  1057. cam = cfg.get("camera", {})
  1058. pixels = cam.get("pixels")
  1059. if pixels is None and "start" in cam: # migrate v2 start/count
  1060. pixels = list(range(int(cam.get("start", 5)),
  1061. int(cam.get("start", 5))
  1062. + int(cam.get("count", 3))))
  1063. self.cam_pixels.setText(format_ranges(pixels or [5, 6, 7]))
  1064. self.cam_color.set_rgbw(cam.get("color", [255, 40, 0, 0]))
  1065. self.cam_effect.setCurrentText(cam.get(
  1066. "effect", "breathe" if cam.get("breathe", True) else "solid"))
  1067. self.cam_speed.setValue(float(cam.get("speed", 1.0)))
  1068. nt = cfg.get("notify", {})
  1069. self.notify_start.setValue(int(nt.get("start", 0)))
  1070. self.notify_count.setValue(int(nt.get("count", 0)))
  1071. self.notify_blinks.setValue(int(nt.get("blinks", 6)))
  1072. self.notify_color.set_rgbw(nt.get("default_color", [0, 60, 255, 0]))
  1073. self.notify_period.setValue(float(nt.get("default_period", 0.4)))
  1074. self._building = False
  1075. self.rebuild_seg_table()
  1076. def _wire_autopush(self):
  1077. for w in (self.num_pixels, self.fps_spin, self.notify_start,
  1078. self.notify_count, self.notify_blinks):
  1079. w.valueChanged.connect(self.schedule_push)
  1080. for w in (self.notify_period, self.cam_speed):
  1081. w.valueChanged.connect(self.schedule_push)
  1082. self.brightness.valueChanged.connect(self.schedule_push)
  1083. self.order_combo.currentIndexChanged.connect(self.schedule_push)
  1084. self.cam_effect.currentIndexChanged.connect(self.schedule_push)
  1085. self.pin_edit.editingFinished.connect(self.schedule_push)
  1086. self.cam_pixels.editingFinished.connect(self.schedule_push)
  1087. self.device_name_edit.editingFinished.connect(self.save_app_config)
  1088. for cb in (self.cam_color, self.notify_color):
  1089. cb.changed.connect(self.schedule_push)
  1090. self.interval_spin.valueChanged.connect(
  1091. lambda v: (setattr(self.monitor, "interval", v),
  1092. self.save_app_config()))
  1093. self.cam_auto_chk.toggled.connect(lambda _: self.save_app_config())
  1094. def schedule_push(self, *_):
  1095. if self._building:
  1096. return
  1097. self.push_timer.start()
  1098. def _auto_push(self):
  1099. self.save_app_config()
  1100. if not self.validate_segments():
  1101. self.log("auto-push blocked: fix the flagged zone issues")
  1102. return
  1103. if self.link.connected:
  1104. reply = self.link.send(
  1105. {"cmd": "config", "config": self.build_config()})
  1106. self.log(f"auto-push -> {reply}")
  1107. def push_config(self):
  1108. if not self.validate_segments():
  1109. self.log("push blocked: fix the flagged zone issues")
  1110. return
  1111. self.log("push config -> "
  1112. f"{self.link.send({'cmd': 'config', 'config': self.build_config()})}")
  1113. self.save_app_config()
  1114. def save_app_config(self):
  1115. data = {
  1116. "device": self.build_config(),
  1117. "connection": {
  1118. "device_name": self.device_name_edit.text().strip()
  1119. or DEFAULT_DEVICE_NAME,
  1120. "last_port": self._last_port,
  1121. },
  1122. "ui": {
  1123. "interval": self.interval_spin.value(),
  1124. "probes": {k: cb.isChecked()
  1125. for k, cb in self.probe_checks.items()},
  1126. "cam_auto": self.cam_auto_chk.isChecked(),
  1127. "notify_watch": self.notify_watch_chk.isChecked(),
  1128. },
  1129. }
  1130. try:
  1131. with open(app_config_path(), "w") as f:
  1132. json.dump(data, f, indent=2)
  1133. except OSError as e:
  1134. self.log(f"config save failed: {e}")
  1135. def load_app_config(self):
  1136. try:
  1137. with open(app_config_path()) as f:
  1138. data = json.load(f)
  1139. except (OSError, ValueError):
  1140. return
  1141. self.apply_config_to_ui(data.get("device", {}))
  1142. conn = data.get("connection", {})
  1143. self.device_name_edit.setText(
  1144. conn.get("device_name") or DEFAULT_DEVICE_NAME)
  1145. self._last_port = conn.get("last_port")
  1146. ui = data.get("ui", {})
  1147. self.interval_spin.setValue(float(ui.get("interval", 1.0)))
  1148. for k, on in ui.get("probes", {}).items():
  1149. if k in self.probe_checks:
  1150. self.probe_checks[k].setChecked(bool(on))
  1151. self.cam_auto_chk.setChecked(bool(ui.get("cam_auto", True)))
  1152. self.notify_watch_chk.setChecked(bool(ui.get("notify_watch", False)))
  1153. self.log(f"loaded config from {app_config_path()}")
  1154. def save_profile(self):
  1155. path, _ = QFileDialog.getSaveFileName(
  1156. self, "Export profile", "luxstats_profile.json", "JSON (*.json)")
  1157. if path:
  1158. with open(path, "w") as f:
  1159. json.dump(self.build_config(), f, indent=2)
  1160. self.log(f"profile exported: {path}")
  1161. def load_profile(self):
  1162. path, _ = QFileDialog.getOpenFileName(
  1163. self, "Import profile", "", "JSON (*.json)")
  1164. if path:
  1165. try:
  1166. with open(path) as f:
  1167. self.apply_config_to_ui(json.load(f))
  1168. self.schedule_push()
  1169. self.log(f"profile imported: {path}")
  1170. except (OSError, ValueError) as e:
  1171. QMessageBox.warning(self, "Import failed", str(e))
  1172. def closeEvent(self, event):
  1173. self.save_app_config()
  1174. self._stop_range_test()
  1175. self.monitor.stop()
  1176. self.monitor.wait(1500)
  1177. if self.notify_watcher:
  1178. self.notify_watcher.stop()
  1179. self.link.close()
  1180. event.accept()
  1181. def main():
  1182. app = QApplication(sys.argv)
  1183. win = MainWindow()
  1184. win.show()
  1185. sys.exit(app.exec_())
  1186. if __name__ == "__main__":
  1187. main()