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:
self._last_touch = 0.0
self._oled_lines = ("", "", "")
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
# field index — the redraw tick dedups against this (never against
# upstream state like the rec record) so an unchanged render costs
......@@ -1424,14 +1429,36 @@ class Driver:
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
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 —
under the firmware timeout — and _oled_up_at is stamped ONLY when the
bring-up was actually sent.
that. The 0.9 s re-summon cadence stays (under the firmware timeout),
and _oled_up_at is stamped on every bring-up we actually send.
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()
if now - self._last_touch < 0.05:
return
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))
if self.tempo and v2 == self.tempo[0]:
# The tempo knob gets BPM, a landmark arrow, and — while a mute is
......@@ -1457,19 +1484,70 @@ class Driver:
self.ghost_write()
return
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:
self.surface.configure_display(L.TGT_TEMPORARY, 2)
for field, line in enumerate(lines):
if summon or line != self._oled_lines[field]:
self.surface.set_text(L.TGT_TEMPORARY, field, line)
if summon:
self.surface.configure_display(L.TGT_TEMPORARY, 0x7F)
self._oled_up_at = now
self._oled_write_at = now
self._oled_lines = lines
self._last_cc = v2
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 ----
# "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
......@@ -1490,7 +1568,9 @@ class Driver:
self._home_cache.get(1, self._home_detail()),
self._home_cache.get(2, self._home_footer()))
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
elif self._oled_owner == "firmware":
# The one case the ghost must NOT claim to mirror pixel for
......@@ -1501,8 +1581,18 @@ class Driver:
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)"
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:
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(
"┌──────────────────┐ showing: %s\n" % up
+ "".join("│ %-16s │\n" % l.replace("\x1e", "<3") for l in lines)
......@@ -1513,7 +1603,13 @@ class Driver:
pass
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)
if cc is None or now - self._oled_up_at > 1.1:
return
......@@ -1523,7 +1619,13 @@ class Driver:
return
frame = self.lang.ZERO_FRAMES[int(now * 3) % len(self.lang.ZERO_FRAMES)]
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)
# 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.ghost_write()
......@@ -1711,6 +1813,10 @@ class Driver:
last_recon = now
if self.lang and now - last_pulse > 0.05:
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)
last_pulse = now
if self.lang and now - last_assert > 2.0:
......@@ -1726,15 +1832,25 @@ class Driver:
# WRITE when the rendered string actually changed — dedup on
# the final string, never on upstream state, so a steady
# "ParVagues" or a steady "REC h:mm:ss" (same second twice)
# costs no SysEx/ACK round trip. A field write on the
# stationary target needs no re-summon.
# costs no SysEx/ACK round trip. 2026-09-21 probe: even on the
# 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()
wrote = False
if detail != self._home_cache.get(1):
self.surface.set_text(L.TGT_STATIONARY, 1, detail)
self._home_cache[1] = detail
wrote = True
if footer != self._home_cache.get(2):
self.surface.set_text(L.TGT_STATIONARY, 2, footer)
self._home_cache[2] = footer
wrote = True
if wrote:
self.surface.configure_display(L.TGT_STATIONARY, 0x7F)
self.ghost_write()
last_home = now
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