Skip to content

Commit 592efae

Browse files
Lexonight1claude
andcommitted
fix(gui): restore live metrics + preview on LED devices (cutover regressions)
Summoned LED/DIGITAL coolers showed M1-M6 = `--` and a dead preview. Three regressions from the next/ cutover, all restored; LED now uses the SAME paths as LCD (the device's render IS the preview, off one source): 1. Handler built without `app`. `_add_handler` hand-wired two divergent branches; the LED branch forgot `app=`, so `LEDHandler._app` stayed None and `update_metrics` early-returned every tick. Collapsed both branches into one `_build_handler` chokepoint that injects `app` + panels uniformly, driven by the new shared presentation backbone. `LEDHandler` now REQUIRES `app` (raises, like LCDHandler) and the dead `set_app` is gone. 2. Sensor tick skipped LED. The render observer only re-rendered devices with a theme (LCD); LED has none, so `RenderLed` never fired on a sensor update. Now ticks `RenderLed` for connected LED devices — the LED twin of RenderAndSend. 3. Render output never reached the preview. `RenderLed` published only a count. Now it publishes its colors on `FrameSent` — the SAME event/slot LCD uses (`surface` for LCD, `display_colors` for LED) → one `_on_bus_frame_sent` → `handle_frame` → preview. One render → FrameSent → preview, for both kinds. No parallel pipe. New shared backbone: `ui/presentation/device_presentation.py` — `presentation_for(ProductInfo) -> DevicePresentation` (kind→view+gauges, keyed on vid/pid+handshake), Qt-free, for both ui/gui and ui/qtgui to join against instead of re-deriving device→view. Verified deterministically (real event loop, real widget state, not the formatter): summoned AX120 → M1-M6 show live CPU/GPU + preview 30/30 segments lit. Tests: presentation_for over the registry; LEDHandler-rejects-None-app. Logging filled on the silent hops (handle_frame, update_sensor_metrics). 1498 green, ruff + pyright clean. dev/tools/diagnose_metrics.py: deterministic single-device probe (force-added; .gitignore `tools/` catches dev/tools). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fc9ed7c commit 592efae

11 files changed

Lines changed: 279 additions & 114 deletions

File tree

dev/audit_present.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,12 @@ def ok(self) -> bool:
6060
return not self.failures
6161

6262

63-
def _summon_and_check(window: Any, d: dict, snapshot: Any, unit: str) -> Finding:
63+
def _summon_and_check(window: Any, d: dict, snapshot: Any) -> Finding:
6464
"""Run the real VariantPanel summon for one variant + assert it shows properly."""
6565
from trcc.core.commands import ConnectDevice
6666
from trcc.core.models import Wire
6767
from trcc.ui.gui.lcd_handler import LCDHandler
6868
from trcc.ui.gui.led_handler import LEDHandler
69-
from trcc.ui.presentation.led_metrics_format import format_sensor_gauges
7069

7170
app = window._app
7271
vid, pid, pm, sub, fbl = d["vid"], d["pid"], d["pm"], d["sub"], d["fbl"]
@@ -109,23 +108,28 @@ def _summon_and_check(window: Any, d: dict, snapshot: Any, unit: str) -> Finding
109108
if not (res and res[0] > 0 and res[1] > 0):
110109
f.failures.append(f"canvas: invalid resolution {res}")
111110

112-
# 4. metrics — a real OS snapshot fans out to the active handler without
113-
# error and renders real values (the `--` bug class).
111+
# 4. metrics — a real OS snapshot fans out to the active handler and the
112+
# panel WIDGET renders real values. Read the actual gauge ``_text``
113+
# (what the user sees), NOT format_sensor_gauges — checking the
114+
# recompute was the false-pass that let the `app=None` bug through.
114115
window._last_metrics = snapshot
115116
try:
116117
window._fan_out_metrics(reason=f"audit:{f.model}")
117-
except Exception as e: # the None-crash bug would land here
118+
except Exception as e:
118119
f.failures.append(f"metrics: fan-out raised {type(e).__name__}: {e}")
119120
return f
120-
# _temp_label renders "NC" for a genuinely-absent (0.0) temp — correct —
121-
# so assert the always-readable usage/clock fields are numeric (only when
122-
# CPU is actually live, else the check is vacuous).
121+
# LED segment panels carry the M1-M6 gauges; assert the always-readable
122+
# usage/clock fields show a live number (only when CPU is live, else
123+
# the check is vacuous — a genuinely-absent temp renders "NC", correct).
123124
if is_led and snapshot.cpu_percent > 0:
124-
g = format_sensor_gauges(snapshot, unit)
125+
imgs = getattr(getattr(handler, "_panel", None), "_info_images", {})
126+
if not imgs:
127+
f.failures.append("metrics: LED panel has no _info_images")
125128
for gk in ("cpu_usage", "cpu_clock"):
126-
if not _NUMERIC.match(g[gk][1]):
129+
txt = getattr(imgs.get(gk), "_text", None)
130+
if not (txt and _NUMERIC.match(txt)):
127131
f.failures.append(
128-
f"metrics: {gk} placeholder {g[gk][1]!r} despite live CPU")
132+
f"metrics: gauge {gk} _text={txt!r} (panel not populated)")
129133
except Exception as e:
130134
f.failures.append(f"EXC during summon: {type(e).__name__}: {e}")
131135
log.exception("audit: %s (%s) raised", f.model, key)
@@ -160,7 +164,6 @@ def _run_audit(window: Any) -> None:
160164
app.platform.sensors().snapshot(),
161165
temp_unit=s.temp_unit, hdd_enabled=s.hdd_enabled,
162166
)
163-
unit = "°F" if s.temp_unit == "F" else "°C"
164167
live = (snapshot.cpu_percent > 0 or snapshot.cpu_temp > 0)
165168
log.info("audit: live sensors=%s (cpu %.0f%% %.0f°, gpu %.0f° %.0fMHz)",
166169
"yes" if live else "NO", snapshot.cpu_percent, snapshot.cpu_temp,
@@ -169,7 +172,7 @@ def _run_audit(window: Any) -> None:
169172
variants = _variant_dicts()
170173
findings: list[Finding] = []
171174
for i, d in enumerate(variants):
172-
f = _summon_and_check(window, d, snapshot, unit)
175+
f = _summon_and_check(window, d, snapshot)
173176
findings.append(f)
174177
# Flushed per-variant so progress + any hang is visible even if the run
175178
# is killed before the summary prints.

dev/tools/diagnose_metrics.py

Lines changed: 55 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
#!/usr/bin/env python3
2-
"""Diagnose the live metrics path for a summoned mock device.
2+
"""Diagnose the live metrics + preview path for one summoned mock device.
33
4-
dev/mock_gui fakes ONLY the handshake; sensors/render/observe are the real app.
5-
This summons one device deterministically (no flaky offscreen auto-click), then
6-
drives the REAL observe slot — builds a SensorsUpdated exactly as MetricsLoop
7-
does and calls ``window._on_bus_sensors_updated(event)`` DIRECTLY so any
8-
exception propagates here instead of being swallowed by Qt's queued-slot
9-
wrapper. Prints, flushed, every hop: connect → handler → active → fan-out →
10-
the resolved gauge values. Answers "why do the device's M1-M6 show --".
4+
dev/mock_gui fakes ONLY the handshake; sensors/render/observe/preview are the
5+
real app. This summons ONE device deterministically (no flaky offscreen
6+
auto-click — it does NOT mount the dev console), then lets the REAL event loop
7+
run for a few seconds so the genuine chain fires end to end:
8+
9+
MetricsLoop tick → SensorsUpdated → render observer → RenderAndSend/RenderLed
10+
→ device send + (LCD) FrameSent / (LED) LedColorsChanged
11+
→ GUI observes → handler.handle_frame → preview updates + M1-M6 gauges
12+
13+
then reads the ACTUAL widget state (gauge ``_text`` + preview ``_colors``) — what
14+
the user sees — and prints it. Answers "do metrics + preview populate for this
15+
device" with no guessing.
1116
1217
PYTHONPATH=src QT_QPA_PLATFORM=offscreen python3.12 dev/tools/diagnose_metrics.py
1318
PYTHONPATH=src QT_QPA_PLATFORM=offscreen python3.12 dev/tools/diagnose_metrics.py 0416:8001 3
@@ -22,21 +27,16 @@
2227
sys.path.insert(0, str(_DEV))
2328
from _mock_bootstrap import bootstrap
2429

30+
_SETTLE_MS = 5000 # let several real MetricsLoop ticks drive the full chain
31+
2532

2633
def _say(msg: str) -> None:
2734
print(msg, flush=True)
2835

2936

30-
def _diagnose(window, vid: int, pid: int, pm: int, sub: int) -> None:
31-
from trcc.adapters.repo.http import HttpFetchError, UrllibHttpFetcher
32-
UrllibHttpFetcher.fetch = ( # cut net: theme downloads 404-with-retry, slow
33-
lambda self, url, *a, **k: (_ for _ in ()).throw(HttpFetchError("net off")))
34-
37+
def _summon(window, vid: int, pid: int, pm: int, sub: int) -> str:
3538
from trcc.core.commands import ConnectDevice
36-
from trcc.core.events import SensorsUpdated
3739
from trcc.core.protocol import pm_to_fbl
38-
from trcc.services.metrics_personalize import (
39-
personalize_metrics, personalize_readings)
4040

4141
app = window._app
4242
key = f"{vid:04x}:{pid:04x}"
@@ -49,39 +49,37 @@ def _diagnose(window, vid: int, pid: int, pm: int, sub: int) -> None:
4949
device = app.devices.get(key)
5050
window._add_handler(device)
5151
window._active_key = ""
52-
_say("activating…")
5352
window._activate_device(key)
5453
h = window._handlers.get(key)
55-
_say(f"handler={type(h).__name__} active={getattr(h, 'active', '?')} "
56-
f"active_key={window._active_key!r}")
57-
58-
s = app.settings.app
59-
raw = app.platform.sensors().read_all()
60-
readings = personalize_readings(raw, temp_unit=s.temp_unit,
61-
hdd_enabled=s.hdd_enabled)
62-
metrics = personalize_metrics(app.platform.sensors().snapshot(),
63-
temp_unit=s.temp_unit, hdd_enabled=s.hdd_enabled)
64-
_say(f"snapshot cpu_temp={metrics.cpu_temp} cpu%={metrics.cpu_percent:.0f} "
65-
f"gpu_temp={metrics.gpu_temp}")
66-
ev = SensorsUpdated(reading_count=len(readings), readings=readings,
67-
temp_unit=s.temp_unit, metrics=metrics)
68-
_say("calling the live observe slot _on_bus_sensors_updated(event)…")
54+
_say(f"handler={type(h).__name__} active={getattr(h, 'active', '?')}")
55+
_say(f"letting the real loop run {_SETTLE_MS} ms…")
56+
return key
57+
58+
59+
def _inspect_and_exit(window, key: str) -> None:
60+
import os
6961
try:
70-
window._on_bus_sensors_updated(ev)
71-
_say("OK — observe slot returned without raising")
62+
h = window._handlers.get(key)
63+
panel = getattr(h, "_panel", None)
64+
# M1-M6 gauges
65+
imgs = getattr(panel, "_info_images", {}) or {}
66+
if imgs:
67+
_say("M1-M6 gauges: " + ", ".join(
68+
f"{k}={getattr(w, '_text', '?')}" for k, w in imgs.items()))
69+
# LED preview segment colors (lit = non-black)
70+
preview = getattr(panel, "_preview", None)
71+
colors = getattr(preview, "_colors", None)
72+
if colors is not None:
73+
lit = sum(1 for c in colors if tuple(c) != (0, 0, 0))
74+
_say(f"PREVIEW _colors: {len(colors)} segments, {lit} lit "
75+
f"({'POPULATED' if lit else 'all black — NOT populated'})")
76+
else:
77+
_say("PREVIEW: not an LED segment panel (LCD frame preview)")
7278
except Exception:
73-
_say("EXCEPTION in the live observe slot:")
7479
traceback.print_exc()
75-
return
76-
77-
# Show the values the panel actually renders into the M1-M6 gauges — the
78-
# exact text the user sees (or `--`). This is what `update_sensor_metrics`
79-
# pushes via `UCInfoImage.set_value`.
80-
from trcc.ui.presentation.led_metrics_format import format_sensor_gauges
81-
unit = "°F" if s.temp_unit == "F" else "°C"
82-
gauges = format_sensor_gauges(metrics, unit)
83-
_say("rendered M1-M6 gauge text: "
84-
+ ", ".join(f"{k}={t}{u}" for k, (_v, t, u) in gauges.items()))
80+
finally:
81+
sys.stdout.flush()
82+
os._exit(0)
8583

8684

8785
def main() -> None:
@@ -94,14 +92,20 @@ def main() -> None:
9492
specs=[{"vid": f"{vid:04x}", "pid": f"{pid:04x}", "pm": pm}])
9593

9694
def on_ready(window) -> None:
97-
import os
95+
from PySide6.QtCore import QTimer
96+
97+
from trcc.adapters.repo.http import HttpFetchError, UrllibHttpFetcher
98+
UrllibHttpFetcher.fetch = lambda self, url, *a, **k: (
99+
_ for _ in ()).throw(HttpFetchError("net off"))
98100
try:
99-
_diagnose(window, vid, pid, pm, sub)
100-
finally:
101-
# Hard exit: the result is printed + flushed; skip the GUI teardown
102-
# (native-lib shutdown can hang offscreen) — this is a one-shot probe.
103-
sys.stdout.flush()
104-
os._exit(0)
101+
key = _summon(window, vid, pid, pm, sub)
102+
except Exception:
103+
traceback.print_exc()
104+
import os
105+
os._exit(1)
106+
# Let the REAL MetricsLoop → observer → RenderLed → preview chain run
107+
# (queued signals need the event loop spinning), THEN inspect.
108+
QTimer.singleShot(_SETTLE_MS, lambda: _inspect_and_exit(window, key))
105109

106110
from trcc.ui.gui import run_gui
107111
run_gui(platform, single_instance=False, ipc=False, force_exit=False,

src/trcc/app.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -462,8 +462,9 @@ def __init__(self, app: App) -> None:
462462
self._app = app
463463
# Lazy import — RenderAndSend lives in core.commands, which
464464
# already imports from app.py via TYPE_CHECKING.
465-
from .core.commands import RenderAndSend
465+
from .core.commands import RenderAndSend, RenderLed
466466
self._RenderAndSend = RenderAndSend
467+
self._RenderLed = RenderLed
467468
for event_cls in (
468469
BrightnessChanged, MaskApplied, MaskPositionChanged,
469470
MaskVisibilityChanged, OverlayChanged, FitModeChanged,
@@ -507,23 +508,34 @@ def _on_visual_change(self, event: Any) -> None:
507508
if evt_key:
508509
keys = [evt_key]
509510
else:
511+
# Device-wide event (SensorsUpdated): re-render every connected
512+
# device — LCDs with a theme, AND LED devices (their segment
513+
# display IS the live metric, no theme involved). LED was
514+
# dropped from this list at the cutover, which is why LED panels
515+
# stopped updating on sensor ticks.
510516
keys = [
511517
k for k, d in self._app.devices.items()
512-
if d.is_connected and k in self._app.active_themes
518+
if d.is_connected
519+
and (d.is_led or k in self._app.active_themes)
513520
]
514521
for key in keys:
515522
device = self._app.devices.get(key)
523+
if device is None or not device.is_connected:
524+
continue
525+
# LED: segments are computed straight from live sensors — no
526+
# theme. RenderLed sends to the device AND drives the preview.
527+
if device.is_led:
528+
log.debug(
529+
"DeviceRenderObserver: %s for %s → RenderLed",
530+
type(event).__name__, key,
531+
)
532+
self._app.dispatch(self._RenderLed(key=key))
533+
continue
516534
theme = self._app.active_themes.get(key)
517-
if (
518-
device is None or not device.is_connected
519-
or theme is None
520-
):
535+
if theme is None:
521536
log.debug(
522-
"DeviceRenderObserver: skip %s for %s "
523-
"(connected=%s theme=%s)",
537+
"DeviceRenderObserver: skip %s for %s (no theme)",
524538
type(event).__name__, key,
525-
device is not None and device.is_connected,
526-
theme is not None,
527539
)
528540
continue
529541
log.debug(

src/trcc/core/commands/led.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from ..events import (
1414
ErrorOccurred,
15+
FrameSent,
1516
HddEnabledChanged,
1617
LedColorsChanged,
1718
)
@@ -314,6 +315,12 @@ def execute(self, app: App) -> LedColorsResult:
314315
app.events.publish(LedColorsChanged(
315316
key=self.key, color_count=len(colors),
316317
))
318+
# Same preview path as LCD: publish the rendered output on
319+
# FrameSent so the GUI preview shows exactly what went to the
320+
# device (LCD carries a surface; LED carries the colors).
321+
app.events.publish(FrameSent(
322+
key=self.key, bytes_sent=len(colors), display_colors=colors,
323+
))
317324
return LedColorsResult(
318325
ok=ok, key=self.key, colors=colors,
319326
message=(f"Rendered {style.value} {effective_settings.mode.name} "

src/trcc/core/events.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ class FrameSent(Event):
5656
# SendColor / SendImage / keepalive) and would be None across IPC,
5757
# where a remote client must fall back to a re-render.
5858
surface: Any = None
59+
# LED twin of ``surface``: an LED render has no image, it has per-LED
60+
# colors. Carried on the SAME event so LED uses the SAME preview path as
61+
# LCD (one render → FrameSent → handle_frame → preview), not a parallel
62+
# one. Empty for LCD / pure-bytes sends.
63+
display_colors: list = field(default_factory=list)
5964

6065

6166
@dataclass(frozen=True, slots=True)

src/trcc/ui/gui/led_handler.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,10 @@
4646
class LEDHandler(BaseHandler):
4747
"""Per-LED-device GUI handler.
4848
49-
Constructor signature matches the legacy positional shape so the
50-
window's ``LEDHandler(device, uc_led_control, on_temp_unit_changed)``
51-
call works untouched. The ``app`` handle comes through the parent
52-
window's ``self._app`` reference; we re-fetch it from the device's
53-
bound App via :meth:`set_app`, or pass it via ``app=`` kwarg.
49+
Built through ``TRCCApp._build_handler`` (the single handler chokepoint),
50+
which always injects ``app=self._app``. ``app`` is REQUIRED: a None handle
51+
silently disabled every metrics tick (``update_metrics`` gates on it), which
52+
was the LED ``--`` bug — so we fail loudly at the composition root instead.
5453
"""
5554

5655
_SAVE_INTERVAL = 20 # cycles between best-effort settings flushes
@@ -63,13 +62,16 @@ def __init__(
6362
app: App | None = None,
6463
) -> None:
6564
super().__init__(device, 'led')
65+
if app is None:
66+
raise RuntimeError(
67+
"LEDHandler requires an App handle — the composition root must "
68+
"pass one (a None handle silently disables metric updates)"
69+
)
6670
self._panel = panel
6771
self._on_temp_unit_changed = on_temp_unit_changed
6872
# next/ Device + key (ProductInfo.key = "vid:pid")
6973
self._device_key: str = device.info.key if device is not None else ''
70-
# The window owns the App reference; LED handlers don't drive
71-
# render ticks themselves, so we only need it for dispatching.
72-
self._app: App | None = app
74+
self._app: App = app
7375
self._active = False
7476
self._style: Any = None # LedStyle enum
7577
self._style_id_int = 0 # legacy 1..12 for uc_led_control
@@ -78,10 +80,6 @@ def __init__(
7880

7981
# ── Public API ────────────────────────────────────────────────────
8082

81-
def set_app(self, app: App) -> None:
82-
"""Inject the App reference post-construction (legacy parity)."""
83-
self._app = app
84-
8583
@property
8684
def active(self) -> bool:
8785
return self._active
@@ -330,8 +328,10 @@ def _on_test_mode_changed(self, on: bool) -> None:
330328

331329
def handle_frame(self, image: Any) -> None:
332330
"""Receive tick result — update LED color display on the panel."""
331+
display_colors = image.get('display_colors') if isinstance(image, dict) else None
332+
log.debug("handle_frame: active=%s colors=%s",
333+
self._active, len(display_colors) if display_colors else None)
333334
if not self._active:
334335
return
335-
display_colors = image.get('display_colors') if isinstance(image, dict) else None
336336
if display_colors is not None:
337337
self._panel.set_led_colors(display_colors)

0 commit comments

Comments
 (0)