Commit 9507ad6a by PLN (Algolia)

fix(rig): LCXL3 OLED repaints only on bring-up — send 0x7F after every write

Eye-in-the-loop probe (lcxl3-probe-display, 2026-09-21) falsified the
'a field write needs no re-summon' assumption: bare set_text on a visible
target (0x35 AND 0x36) never reaches the glass. Every text path now ends
in configure_display(target, 0x7F): touch overlay, 2s home tick, zero-anim.
Plus: ~10 Hz rate gate with latest-value-wins pending flush (H2 ceiling
untested — conservative), and the at-rest overlay re-arms every 0.9s
indefinitely so DJF state/zero stops self-destructing after 1.2s.
parent 8c8cab36
...@@ -913,6 +913,11 @@ class Driver: ...@@ -913,6 +913,11 @@ class Driver:
self._last_touch = 0.0 self._last_touch = 0.0
self._oled_lines = ("", "", "") self._oled_lines = ("", "", "")
self._oled_up_at = 0.0 self._oled_up_at = 0.0
# Rate-cap window for the 0x36 text path (~10 writes/s, see
# oled_touch), and the newest (cc, value) a capped sweep left
# behind for tick_overlay to flush once the window reopens.
self._oled_write_at = 0.0
self._oled_pending: tuple[int, int] | None = None
# What is ACTUALLY on the STATIONARY target's fields right now, by # What is ACTUALLY on the STATIONARY target's fields right now, by
# field index — the redraw tick dedups against this (never against # field index — the redraw tick dedups against this (never against
# upstream state like the rec record) so an unchanged render costs # upstream state like the rec record) so an unchanged render costs
...@@ -1424,14 +1429,36 @@ class Driver: ...@@ -1424,14 +1429,36 @@ class Driver:
the firmware's 1.2 s timeout dropped OUR overlay after the first the firmware's 1.2 s timeout dropped OUR overlay after the first
summon, and its own native value display (which shows the raw wire summon, and its own native value display (which shows the raw wire
value: "0" for a relative encoder) owned the screen. PLN saw exactly value: "0" for a relative encoder) owned the screen. PLN saw exactly
that. Now the bring-up is re-sent every 0.9 s of continuous activity — that. The 0.9 s re-summon cadence stays (under the firmware timeout),
under the firmware timeout — and _oled_up_at is stamped ONLY when the and _oled_up_at is stamped on every bring-up we actually send.
bring-up was actually sent.
2026-09-21 eye-in-the-loop probe (results JSON:
/tmp/claude-1000/-home-pln-Work-Sound-Tidal/0f7ff2b8-ccb4-40d8-b86c-5741f929f419/
scratchpad/display-probe-results.json): a text field written to an
ALREADY-VISIBLE display target does NOT repaint the panel — neither on
the temporary 36h nor on the stationary 35h. The bring-up
(configure_display(target, 0x7F)) must FOLLOW the write (a bring-up
before the write works too, after is the measured one). That falsified
the old "a field write on the stationary target needs no re-summon"
note, and explains the frozen lap clock and the DJF Hz readout that
only moved at the 0.9 s re-summon cadence. So every text change now
ends in a bring-up; the full re-summon (configure 2 + unconditional
rewrite) is reserved for the 0.9 s cadence, kept running at rest by
tick_overlay to defeat the firmware's 1.2 s overlay timeout.
Sweeps are rate-capped to ~10 writes/s, latest-value-wins: the glass
renders ~2 Hz no matter how fast we write, so a sweep renders its
NEWEST value instead of queueing stale intermediates. The 20 Hz touch
throttle below stays as the coarse front door.
""" """
now = time.time() now = time.time()
if now - self._last_touch < 0.05: if now - self._last_touch < 0.05:
return return
self._last_touch = now self._last_touch = now
self._touch_apply(v2, value, now)
def _touch_apply(self, v2: int, value: int, now: float) -> None:
"""Compute a touch's lines and get them onto the glass (or not)."""
role, _who = self.grid.CC_ROLE.get(v2, ("", 0)) role, _who = self.grid.CC_ROLE.get(v2, ("", 0))
if self.tempo and v2 == self.tempo[0]: if self.tempo and v2 == self.tempo[0]:
# The tempo knob gets BPM, a landmark arrow, and — while a mute is # The tempo knob gets BPM, a landmark arrow, and — while a mute is
...@@ -1457,19 +1484,70 @@ class Driver: ...@@ -1457,19 +1484,70 @@ class Driver:
self.ghost_write() self.ghost_write()
return return
self._oled_owner = "driver" self._oled_owner = "driver"
summon = now - self._oled_up_at > 0.9 # ~10 writes/s cap. A call inside the window does not queue — it is
# superseded: park the newest (v2, value) and let tick_overlay flush it
# once the window reopens, so a sweep's FINAL value still lands even
# though every intermediate was dropped.
if now - self._oled_write_at < 0.1:
self._oled_pending = (v2, value)
return
self._oled_pending = None
self._last_cc = v2
self._overlay_send(lines, now)
def _overlay_send(self, lines: tuple[str, str, str], now: float,
force: bool = False) -> None:
"""Write the overlay fields and bring the glass up AFTER them.
A full re-summon (configure 2 + rewrite of every field) happens at the
0.9 s cadence or when forced; between re-summons only changed fields
are written (dedup on the last SENT lines). Every path ends in the
0x7F bring-up — the 2026-09-21 probe measured that a bare field write
on an already-visible target never repaints.
"""
summon = force or now - self._oled_up_at > 0.9
if summon: if summon:
self.surface.configure_display(L.TGT_TEMPORARY, 2) self.surface.configure_display(L.TGT_TEMPORARY, 2)
for field, line in enumerate(lines): for field, line in enumerate(lines):
if summon or line != self._oled_lines[field]: if summon or line != self._oled_lines[field]:
self.surface.set_text(L.TGT_TEMPORARY, field, line) self.surface.set_text(L.TGT_TEMPORARY, field, line)
if summon:
self.surface.configure_display(L.TGT_TEMPORARY, 0x7F) self.surface.configure_display(L.TGT_TEMPORARY, 0x7F)
self._oled_up_at = now self._oled_up_at = now
self._oled_write_at = now
self._oled_lines = lines self._oled_lines = lines
self._last_cc = v2
self.ghost_write() self.ghost_write()
def tick_overlay(self, now: float) -> None:
"""At-rest persistence: keep OUR overlay up while it still means state.
The firmware self-destructs the overlay 1.2 s after the last touch, and
the code used to let it die — home came back, and "can't see DJF state
/ zero" was the result. Now, while the last-touched control left a
driver-owned overlay (firmware-native controls stand down in
_touch_apply), the 0.9 s re-arm cadence runs INDEFINITELY, so the DJF
Hz / BPM readout stays on the glass until another control takes it —
and tick_zero_anim's 3 fps zero heartbeat can run at rest too, because
_oled_up_at never goes stale.
Also flushes a rate-capped sweep's final value: the pending (v2, value)
parked by _touch_apply is re-derived from current state, so what lands
is the newest, not the queued.
"""
if self._oled_pending is not None and now - self._oled_write_at >= 0.1:
v2, value = self._oled_pending
self._oled_pending = None
self._touch_apply(v2, value, now)
if getattr(self, "_last_cc", None) is None:
return
if self._oled_owner != "driver" or not any(self._oled_lines):
return
if now - self._oled_up_at <= 0.9:
return
# Forced re-summon, unconditionally: the firmware may have already
# timed the overlay out, and the dedup cache only knows what WE last
# sent, not what the glass still shows.
self._overlay_send(self._oled_lines, now, force=True)
# ---------------------------------------------------------- the ghost ---- # ---------------------------------------------------------- the ghost ----
# "can you pull state of oled?" — the protocol has NO display readback, so # "can you pull state of oled?" — the protocol has NO display readback, so
# nobody can PULL the screen. What we can do is mirror every byte WE send # nobody can PULL the screen. What we can do is mirror every byte WE send
...@@ -1490,7 +1568,9 @@ class Driver: ...@@ -1490,7 +1568,9 @@ class Driver:
self._home_cache.get(1, self._home_detail()), self._home_cache.get(1, self._home_detail()),
self._home_cache.get(2, self._home_footer())) self._home_cache.get(2, self._home_footer()))
cc = getattr(self, "_last_cc", None) cc = getattr(self, "_last_cc", None)
if age >= 1.2: if self._oled_owner == "firmware" and age >= 1.2:
# The firmware's own overlay self-destructs 1.2 s after the
# last touch too — we never held that screen, so home it is.
up, lines = "STATIONARY (home, ours)", home up, lines = "STATIONARY (home, ours)", home
elif self._oled_owner == "firmware": elif self._oled_owner == "firmware":
# The one case the ghost must NOT claim to mirror pixel for # The one case the ghost must NOT claim to mirror pixel for
...@@ -1501,8 +1581,18 @@ class Driver: ...@@ -1501,8 +1581,18 @@ class Driver:
name = dict((a, c) for a, _b, c, _d in self._labels).get(v3, "?") name = dict((a, c) for a, _b, c, _d in self._labels).get(v3, "?")
up = f"PER-CONTROL 0x{v3:02X} (firmware draws, name is ours)" up = f"PER-CONTROL 0x{v3:02X} (firmware draws, name is ours)"
lines = (name, "^ its numeric value below", "(we only set the name)") lines = (name, "^ its numeric value below", "(we only set the name)")
elif cc is None or not any(self._oled_lines):
# No touch yet (or nothing to show): the stationary home is all
# there is, and the re-arm in tick_overlay is deliberately idle.
up, lines = "STATIONARY (home, ours)", home
else: else:
up, lines = "TEMPORARY 0x36 (touch overlay, ours)", self._oled_lines # 2026-09-21: a driver-owned overlay no longer falls back to
# home after 1.2 s — tick_overlay re-arms the summon every
# 0.9 s for as long as this control owns the screen. So age is
# only how long since the last bring-up, not whether the glass
# moved on; the overlay is still up, latest sent lines on it.
up = "TEMPORARY 0x36 (touch overlay, ours, re-arm 0.9s)"
lines = self._oled_lines
self.GHOST.write_text( self.GHOST.write_text(
"┌──────────────────┐ showing: %s\n" % up "┌──────────────────┐ showing: %s\n" % up
+ "".join("│ %-16s │\n" % l.replace("\x1e", "<3") for l in lines) + "".join("│ %-16s │\n" % l.replace("\x1e", "<3") for l in lines)
...@@ -1513,7 +1603,13 @@ class Driver: ...@@ -1513,7 +1603,13 @@ class Driver:
pass pass
def tick_zero_anim(self, now: float) -> None: def tick_zero_anim(self, now: float) -> None:
"""~3 fps heartbeat on the value line while a DJF rests at its zero.""" """~3 fps heartbeat on the value line while a DJF rests at its zero.
Since tick_overlay re-arms the driver-owned overlay every 0.9 s, the
old 1.1 s staleness guard no longer cuts the anim off at rest — it now
runs for as long as the DJF owns the screen, which is the point: the
zero state stays legible instead of blinking out after a second.
"""
cc = getattr(self, "_last_cc", None) cc = getattr(self, "_last_cc", None)
if cc is None or now - self._oled_up_at > 1.1: if cc is None or now - self._oled_up_at > 1.1:
return return
...@@ -1523,7 +1619,13 @@ class Driver: ...@@ -1523,7 +1619,13 @@ class Driver:
return return
frame = self.lang.ZERO_FRAMES[int(now * 3) % len(self.lang.ZERO_FRAMES)] frame = self.lang.ZERO_FRAMES[int(now * 3) % len(self.lang.ZERO_FRAMES)]
if frame != self._oled_lines[1]: if frame != self._oled_lines[1]:
if now - self._oled_write_at < 0.1:
return # same ~10 writes/s window as the touch path
self._oled_write_at = now
self.surface.set_text(L.TGT_TEMPORARY, 1, frame) self.surface.set_text(L.TGT_TEMPORARY, 1, frame)
# 2026-09-21 probe: the field write alone repaints nothing — the
# bring-up must follow it.
self.surface.configure_display(L.TGT_TEMPORARY, 0x7F)
self._oled_lines = (self._oled_lines[0], frame, self._oled_lines[2]) self._oled_lines = (self._oled_lines[0], frame, self._oled_lines[2])
self.ghost_write() self.ghost_write()
...@@ -1711,6 +1813,10 @@ class Driver: ...@@ -1711,6 +1813,10 @@ class Driver:
last_recon = now last_recon = now
if self.lang and now - last_pulse > 0.05: if self.lang and now - last_pulse > 0.05:
self.tick_glow() self.tick_glow()
# Overlay housekeeping on the same 20 Hz pulse — the re-arm
# cadence and the rate-cap flush are both cheap (usually one
# timestamp comparison) and need no timer of their own.
self.tick_overlay(now)
self.tick_zero_anim(now) self.tick_zero_anim(now)
last_pulse = now last_pulse = now
if self.lang and now - last_assert > 2.0: if self.lang and now - last_assert > 2.0:
...@@ -1726,15 +1832,25 @@ class Driver: ...@@ -1726,15 +1832,25 @@ class Driver:
# WRITE when the rendered string actually changed — dedup on # WRITE when the rendered string actually changed — dedup on
# the final string, never on upstream state, so a steady # the final string, never on upstream state, so a steady
# "ParVagues" or a steady "REC h:mm:ss" (same second twice) # "ParVagues" or a steady "REC h:mm:ss" (same second twice)
# costs no SysEx/ACK round trip. A field write on the # costs no SysEx/ACK round trip. 2026-09-21 probe: even on the
# stationary target needs no re-summon. # STATIONARY target a field write on an already-visible panel
# repaints NOTHING by itself — the 0x7F bring-up must FOLLOW
# the write (results JSON:
# /tmp/claude-1000/-home-pln-Work-Sound-Tidal/0f7ff2b8-ccb4-40d8-b86c-5741f929f419/
# scratchpad/display-probe-results.json). That old "no
# re-summon needed" assumption was the frozen lap clock.
detail, footer = self._home_detail(), self._home_footer() detail, footer = self._home_detail(), self._home_footer()
wrote = False
if detail != self._home_cache.get(1): if detail != self._home_cache.get(1):
self.surface.set_text(L.TGT_STATIONARY, 1, detail) self.surface.set_text(L.TGT_STATIONARY, 1, detail)
self._home_cache[1] = detail self._home_cache[1] = detail
wrote = True
if footer != self._home_cache.get(2): if footer != self._home_cache.get(2):
self.surface.set_text(L.TGT_STATIONARY, 2, footer) self.surface.set_text(L.TGT_STATIONARY, 2, footer)
self._home_cache[2] = footer self._home_cache[2] = footer
wrote = True
if wrote:
self.surface.configure_display(L.TGT_STATIONARY, 0x7F)
self.ghost_write() self.ghost_write()
last_home = now last_home = now
if self.lang and now - last_labels > 20.0: if self.lang and now - last_labels > 20.0:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment