From ba59fd4b4a17780cf310afbe71c2866d0a382fb0 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Tue, 19 May 2026 04:05:34 +0200 Subject: [PATCH] refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/esp32_lib_manager.py | 4 +- backend/app/services/esp32_worker.py | 25 +--- docs/ESP32_EMULATION.md | 34 ++++-- .../src/__tests__/arduino-arduino-spi.test.ts | 1 - .../src/__tests__/arduino-esp32-uart.test.ts | 1 - .../__tests__/arduino-pico-digital.test.ts | 1 - .../src/__tests__/arduino-pico-i2c.test.ts | 1 - .../__tests__/arduino-pico-mixed-uart.test.ts | 1 - .../__tests__/dual-arduino-digital.test.ts | 1 - .../__tests__/dual-arduino-hw-uart.test.ts | 1 - .../dual-arduino-software-serial.test.ts | 1 - .../src/__tests__/dual-esp32-uart.test.ts | 1 - .../src/__tests__/dual-pico-digital.test.ts | 1 - .../esp32-multi-servo-gpio-matrix.test.ts | 29 ++--- .../src/__tests__/esp32-servo-pot.test.ts | 111 ++---------------- .../src/__tests__/helpers/hoistedMocks.ts | 1 - .../__tests__/interconnect-routing.test.ts | 1 - frontend/src/__tests__/pi3-pico-uart.test.ts | 1 - .../triple-pico-digital-chain.test.ts | 1 - frontend/src/simulation/Esp32Bridge.ts | 29 ++--- frontend/src/simulation/PinManager.ts | 28 ----- frontend/src/store/useSimulatorStore.ts | 55 ++------- 22 files changed, 62 insertions(+), 267 deletions(-) diff --git a/backend/app/services/esp32_lib_manager.py b/backend/app/services/esp32_lib_manager.py index ed2e00fd..08339b87 100644 --- a/backend/app/services/esp32_lib_manager.py +++ b/backend/app/services/esp32_lib_manager.py @@ -29,7 +29,9 @@ Events emitted via callback(event_type, data): rmt_event {channel: int, config0: int, value: int, level0: int, dur0: int, level1: int, dur1: int} ws2812_update {channel: int, pixels: list[{r,g,b}]} - ledc_update {channel: int, duty: int, duty_pct: float} + ledc_duty {channel: int, duty_pct: float} + gpio_routing {gpio: int, signal_id: int} + gpio_routing_clear {gpio: int} error {message: str} """ import asyncio diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index 5b0b0a89..6a7341c2 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -25,7 +25,7 @@ stdout : JSON event lines (one per line, flushed immediately) {"type": "gpio_change", "pin": N, "state": V} {"type": "gpio_dir", "pin": N, "dir": V} {"type": "uart_tx", "uart": N, "byte": V} - {"type": "ledc_update", "channel": N, "duty": V, "duty_pct": F, "gpio": N|-1} + {"type": "ledc_duty", "channel": N, "duty_pct": F} {"type": "rmt_event", "channel": N, ...} {"type": "ws2812_update","channel": N, "pixels": [...]} {"type": "i2c_event", "bus": N, "addr": N, "event": N, "response": N} @@ -785,18 +785,6 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _emit({'type': 'ledc_duty', 'channel': ledc_ch, 'duty_pct': intensity}) - - # Legacy event (still consumed by the old broadcast/memo - # path during rollout). Resolve channel→gpio from the - # router's reverse index — same value the legacy - # `_ledc_gpio_map.get(ledc_ch, -1)` returned. - sig_id = ledc_signal_for_channel(ledc_ch) - pins = _signal_router.pins_for_signal(sig_id) - gpio = pins[0] if pins else -1 - _emit({'type': 'ledc_update', 'channel': ledc_ch, - 'duty': intensity, - 'duty_pct': intensity, - 'gpio': gpio}) return # ── DHT22: track direction changes + trigger sync response ─────── @@ -1423,20 +1411,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _last_duty[ch] = duty_pct if duty_pct > 0: rounded = round(duty_pct, 2) - # New canonical event for the SignalRouter - # path — channel + duty only. _emit({'type': 'ledc_duty', 'channel': ch, 'duty_pct': rounded}) - # Legacy event still consumed by the old - # ledc_update handler during rollout. - sig_id = ledc_signal_for_channel(ch) - pins = _signal_router.pins_for_signal(sig_id) - gpio = pins[0] if pins else -1 - _emit({'type': 'ledc_update', 'channel': ch, - 'duty': rounded, - 'duty_pct': rounded, - 'gpio': gpio}) except Exception: pass diff --git a/docs/ESP32_EMULATION.md b/docs/ESP32_EMULATION.md index abeba1e8..04d30302 100644 --- a/docs/ESP32_EMULATION.md +++ b/docs/ESP32_EMULATION.md @@ -502,7 +502,9 @@ Converts hardware callbacks into **WebSocket events** for the frontend: | `spi_event` | `{bus, event, response}` | SPI transaction | | `rmt_event` | `{channel, config0, value, level0, dur0, level1, dur1}` | RMT pulse | | `ws2812_update` | `{channel, pixels: [[r,g,b],...]}` | Complete NeoPixel frame | -| `ledc_update` | `{channel, duty, duty_pct, gpio}` | PWM duty cycle + GPIO controlled by that channel | +| `ledc_duty` | `{channel, duty_pct}` | PWM duty cycle on an LEDC channel; frontend resolves channel→pin via the per-board SignalRouter mirror | +| `gpio_routing` | `{gpio, signal_id}` | `gpio_out_sel[gpio]` was set to `signal_id` — frontend updates its SignalRouter mirror so subsequent `ledc_duty` events route correctly | +| `gpio_routing_clear` | `{gpio}` | Pin no longer routed to any peripheral (matrix entry reset) | | `error` | `{message: str}` | Boot error | **Crash and reboot detection:** @@ -720,7 +722,9 @@ The event emitted to the frontend: ```python await esp_lib_manager.poll_ledc(client_id) -# Emits: {"type": "ledc_update", "data": {"channel": 0, "duty": 4096, "duty_pct": 50.0, "gpio": 2}} +# Emits: {"type": "ledc_duty", "data": {"channel": 0, "duty_pct": 50.0}} +# The frontend resolves channel→pin via the SignalRouter mirror that was +# populated by earlier gpio_routing events from the GPIO matrix poller. ``` The typical maximum duty is 8192 (13-bit timer). For LED brightness: `duty_pct / 100`. @@ -742,7 +746,7 @@ drive multiple pins, or a pin can be unrouted. velxio models this 1-to-1 with a **SignalRouter** abstraction on both sides of the WebSocket. -**Why this matters:** previously the worker emitted +**Why this matters:** before SignalRouter, the worker emitted `ledc_update {channel, duty, gpio}` where `gpio` was resolved from a worker-local `_ledc_gpio_map` cache. When the cache hadn't seen the GPIO matrix write yet (race window during `ledcAttachPin`), `gpio` @@ -750,9 +754,11 @@ came through as `-1` and the frontend fell back to `PinManager.broadcastPwm` — fanning the duty to ALL PWM listeners. With two servos in the same duty-range that produced the multi- servo blink reported in project -`5218f9e3-136d-43b3-bba1-6cebde21e1a4`. +`5218f9e3-136d-43b3-bba1-6cebde21e1a4`. Both `ledc_update` and the +`broadcastPwm` fallback have been removed; the SignalRouter path +below is now the only PWM dispatch route. -**Architecture (post-SignalRouter):** +**Architecture (SignalRouter):** ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -778,9 +784,7 @@ servo blink reported in project │ │ │ 0x5000 callback / ledc_poll_thread: │ │ - first calls _refresh_signal_routing() so routing is current │ -│ - emits `ledc_duty {channel, duty_pct}` (canonical, no gpio) │ -│ - also emits legacy `ledc_update {channel, duty, gpio}` for │ -│ back-compat during the rollout │ +│ - emits `ledc_duty {channel, duty_pct}` — channel + duty only │ └─────────────────────────────────────────────────────────────────┘ │ ▼ WebSocket @@ -1093,7 +1097,7 @@ All backend events are wired to the frontend: | Event | Component | Status | |-------|-----------|--------| | `gpio_change` | `PinManager.triggerPinChange()` → connected LEDs/components | ✅ Implemented | -| `ledc_update` | `PinManager.updatePwm(gpio, duty)` → CSS opacity of element connected to the GPIO | ✅ Implemented | +| `ledc_duty` + `gpio_routing` | SignalRouter resolves channel→pin, then `PinManager.updatePwm(gpio, duty)` → CSS opacity of element connected to the GPIO | ✅ Implemented | | `ws2812_update` | `NeoPixel.tsx` — RGB LED strip with canvas | ✅ Implemented | | `gpio_dir` | Callback `onPinDir` in `Esp32Bridge.ts` | ✅ Implemented | | `i2c_event` | Callback `onI2cEvent` in `Esp32Bridge.ts` | ✅ Implemented | @@ -1319,11 +1323,19 @@ User clicks **Visual flow:** ```text +ledcAttachPin(gpio, ch) in firmware + → writes to gpio_out_sel[gpio] in the GPIO matrix + → backend poller observes the diff + → gpio_routing {gpio, signal_id} sent to frontend + → frontend SignalRouter mirror records the gpio↔signal mapping + ledcWrite(ch, duty) in firmware → QEMU updates duty in internal LEDC array → poll_ledc() reads the array every ~50ms - → ledc_update {channel, duty, duty_pct, gpio} sent to frontend - → useSimulatorStore: bridge.onLedcUpdate → pinManager.updatePwm(gpio, duty/100) + → ledc_duty {channel, duty_pct} sent to frontend + → makeLedcDutyHandler: resolves channel → signal_id → list of pins + via the SignalRouter mirror, then for each pin: + pinManager.updatePwm(pin, duty_pct/100) → PinManager fires callbacks registered for that pin → SimulatorCanvas: onPwmChange → el.style.opacity = String(duty) → The visual element (wokwi-led) shows proportional brightness diff --git a/frontend/src/__tests__/arduino-arduino-spi.test.ts b/frontend/src/__tests__/arduino-arduino-spi.test.ts index 3b81ef7d..d317be82 100644 --- a/frontend/src/__tests__/arduino-arduino-spi.test.ts +++ b/frontend/src/__tests__/arduino-arduino-spi.test.ts @@ -74,7 +74,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/arduino-esp32-uart.test.ts b/frontend/src/__tests__/arduino-esp32-uart.test.ts index 21117f63..82d0e8fa 100644 --- a/frontend/src/__tests__/arduino-esp32-uart.test.ts +++ b/frontend/src/__tests__/arduino-esp32-uart.test.ts @@ -75,7 +75,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/arduino-pico-digital.test.ts b/frontend/src/__tests__/arduino-pico-digital.test.ts index 6b0f6f56..94c496a2 100644 --- a/frontend/src/__tests__/arduino-pico-digital.test.ts +++ b/frontend/src/__tests__/arduino-pico-digital.test.ts @@ -69,7 +69,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/arduino-pico-i2c.test.ts b/frontend/src/__tests__/arduino-pico-i2c.test.ts index 88ae7a08..bd93ea74 100644 --- a/frontend/src/__tests__/arduino-pico-i2c.test.ts +++ b/frontend/src/__tests__/arduino-pico-i2c.test.ts @@ -78,7 +78,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/arduino-pico-mixed-uart.test.ts b/frontend/src/__tests__/arduino-pico-mixed-uart.test.ts index 55837421..0fe882e2 100644 --- a/frontend/src/__tests__/arduino-pico-mixed-uart.test.ts +++ b/frontend/src/__tests__/arduino-pico-mixed-uart.test.ts @@ -70,7 +70,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/dual-arduino-digital.test.ts b/frontend/src/__tests__/dual-arduino-digital.test.ts index 7a4f4c22..fc6c911e 100644 --- a/frontend/src/__tests__/dual-arduino-digital.test.ts +++ b/frontend/src/__tests__/dual-arduino-digital.test.ts @@ -79,7 +79,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/dual-arduino-hw-uart.test.ts b/frontend/src/__tests__/dual-arduino-hw-uart.test.ts index b25dfcc3..43f3a9ee 100644 --- a/frontend/src/__tests__/dual-arduino-hw-uart.test.ts +++ b/frontend/src/__tests__/dual-arduino-hw-uart.test.ts @@ -77,7 +77,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/dual-arduino-software-serial.test.ts b/frontend/src/__tests__/dual-arduino-software-serial.test.ts index 5b042e28..8acffb11 100644 --- a/frontend/src/__tests__/dual-arduino-software-serial.test.ts +++ b/frontend/src/__tests__/dual-arduino-software-serial.test.ts @@ -76,7 +76,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/dual-esp32-uart.test.ts b/frontend/src/__tests__/dual-esp32-uart.test.ts index 6761b53c..8560e702 100644 --- a/frontend/src/__tests__/dual-esp32-uart.test.ts +++ b/frontend/src/__tests__/dual-esp32-uart.test.ts @@ -73,7 +73,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/dual-pico-digital.test.ts b/frontend/src/__tests__/dual-pico-digital.test.ts index 6a3c452e..f2cd5c7e 100644 --- a/frontend/src/__tests__/dual-pico-digital.test.ts +++ b/frontend/src/__tests__/dual-pico-digital.test.ts @@ -71,7 +71,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts b/frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts index d79740af..22135462 100644 --- a/frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts +++ b/frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts @@ -22,7 +22,7 @@ * path, this test fails because pin 12 would observe pin 13's duty. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { PinManager } from '../simulation/PinManager'; import { SignalRouter } from '../simulation/SignalRouter'; import { ledcSignalForChannel } from '../simulation/esp32-signals'; @@ -169,23 +169,14 @@ describe('multi-servo via SignalRouter — solar-tracker regression', () => { expect(seen).toEqual([]); // both pins untouched, no broadcast }); - it('does not call PinManager.broadcastPwm', () => { - // Strong regression guard: the SignalRouter path must NEVER - // touch broadcastPwm. If a future refactor accidentally - // reintroduces the old fallback path, this test fails before - // the multi-servo bug regresses. - const { pm, ledcDuty, gpioRouting } = setupBoard(); - const spy = vi.spyOn(pm, 'broadcastPwm'); - - pm.onPwmChange(13, () => {}); - pm.onPwmChange(12, () => {}); - gpioRouting({ gpio: 13, signal_id: ledcSignalForChannel(0) }); - gpioRouting({ gpio: 12, signal_id: ledcSignalForChannel(1) }); - - ledcDuty({ channel: 0, duty_pct: 7.5 }); - ledcDuty({ channel: 1, duty_pct: 3.0 }); - ledcDuty({ channel: 0, duty_pct: 8.0 }); - - expect(spy).not.toHaveBeenCalled(); + it('PinManager exposes no broadcastPwm fallback', () => { + // The pre-SignalRouter patch shipped a `broadcastPwm` method on + // PinManager that fanned a duty out to every PWM listener as a + // gpio=-1 fallback. The SignalRouter rewrite deletes that method + // entirely. This test guards the deletion: if a future refactor + // adds it back, the regression fails here rather than in + // production multi-servo wiring. + const { pm } = setupBoard(); + expect((pm as unknown as { broadcastPwm?: unknown }).broadcastPwm).toBeUndefined(); }); }); diff --git a/frontend/src/__tests__/esp32-servo-pot.test.ts b/frontend/src/__tests__/esp32-servo-pot.test.ts index c5891601..2446b474 100644 --- a/frontend/src/__tests__/esp32-servo-pot.test.ts +++ b/frontend/src/__tests__/esp32-servo-pot.test.ts @@ -616,14 +616,15 @@ describe('GPIO out_sel scanning for LEDC mapping', () => { describe('End-to-end: LEDC → servo angle', () => { const logic = () => PartSimulationRegistry.get('servo')!; - it('ledc_update with gpio=13 → updatePwm(13, duty) → servo moves', () => { + it('ledc_duty for ch=0 routed to gpio=13 → updatePwm(13, duty) → servo moves', () => { const shim = makeEsp32Shim(); const el = makeElement() as any; logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-e2e'); - // Simulate what useSimulatorStore.onLedcUpdate does: - const update = { channel: 0, duty: 7.36, duty_pct: 7.36, gpio: 13 }; - const targetPin = update.gpio >= 0 ? update.gpio : update.channel; + // Simulate what makeLedcDutyHandler does after the SignalRouter + // resolves ch=0 → SIG_LEDC_HS_CH0 → pin 13: + const update = { channel: 0, duty_pct: 7.36 }; + const targetPin = 13; // from router.pinsForSignal(SIG_LEDC_HS_CH0_OUT_IDX + 0) const dutyCycleFraction = update.duty_pct / 100; // This is what the store calls: @@ -641,10 +642,12 @@ describe('End-to-end: LEDC → servo angle', () => { expect(el.angle).toBeLessThanOrEqual(92); }); - it('ledc_update with WRONG ch=80 and gpio=-1 would NOT reach servo on pin 13', () => { - // This demonstrates the bug that was fixed: - // ch=80 (from broken & 0xFF) with gpio=-1 → updatePwm(80, duty) - // But servo listens on pin 13 → callback never fires + it('updatePwm targeted at the wrong pin number does NOT reach a servo on pin 13', () => { + // Regression guard for the pre-SignalRouter bug where the worker + // would emit duty with gpio=-1 and the store fell back to using the + // channel number as the pin (ch=80 from broken & 0xFF). With the + // SignalRouter path the channel→pin lookup is authoritative; this + // test now just keeps the dispatch-by-pin invariant honest. const shim = makeEsp32Shim(); const el = makeElement() as any; logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-bug-demo'); @@ -728,95 +731,3 @@ describe('LEDC polling — data format', () => { }); }); -// ───────────────────────────────────────────────────────────────────────────── -// 14. PinManager.broadcastPwm — gpio=-1 fallback -// ───────────────────────────────────────────────────────────────────────────── - -describe('PinManager.broadcastPwm fallback', () => { - // Use a simple inline PinManager-like class to test broadcastPwm logic - // (The real PinManager can't be imported here due to vi.mock overrides) - - class SimplePinManager { - private pwmListeners = new Map void>>(); - private pwmValues = new Map(); - - onPwmChange(pin: number, cb: (pin: number, duty: number) => void): () => void { - if (!this.pwmListeners.has(pin)) this.pwmListeners.set(pin, new Set()); - this.pwmListeners.get(pin)!.add(cb); - return () => { - this.pwmListeners.get(pin)?.delete(cb); - }; - } - - updatePwm(pin: number, dutyCycle: number): void { - this.pwmValues.set(pin, dutyCycle); - this.pwmListeners.get(pin)?.forEach((cb) => cb(pin, dutyCycle)); - } - - broadcastPwm(dutyCycle: number): void { - this.pwmListeners.forEach((callbacks, pin) => { - this.pwmValues.set(pin, dutyCycle); - callbacks.forEach((cb) => cb(pin, dutyCycle)); - }); - } - } - - it('broadcastPwm dispatches to all registered PWM listeners', () => { - const pm = new SimplePinManager(); - const received: { pin: number; duty: number }[] = []; - - pm.onPwmChange(13, (pin, duty) => received.push({ pin, duty })); - pm.onPwmChange(5, (pin, duty) => received.push({ pin, duty })); - - pm.broadcastPwm(0.075); // 7.5% servo duty - - expect(received).toHaveLength(2); - expect(received).toContainEqual({ pin: 13, duty: 0.075 }); - expect(received).toContainEqual({ pin: 5, duty: 0.075 }); - }); - - it('broadcastPwm does nothing when no listeners registered', () => { - const pm = new SimplePinManager(); - // Should not throw - pm.broadcastPwm(0.075); - }); - - it('servo filters broadcastPwm by duty range (0.01-0.20)', () => { - const MIN_DC = 544 / 20000; // 0.0272 - const MAX_DC = 2400 / 20000; // 0.12 - let servoAngle = -1; - - const servoCallback = (_pin: number, dutyCycle: number) => { - if (dutyCycle < 0.01 || dutyCycle > 0.2) return; - const angle = Math.round(((dutyCycle - MIN_DC) / (MAX_DC - MIN_DC)) * 180); - servoAngle = Math.max(0, Math.min(180, angle)); - }; - - servoCallback(13, 0.075); - expect(servoAngle).toBeGreaterThanOrEqual(88); - expect(servoAngle).toBeLessThanOrEqual(95); - - const prevAngle = servoAngle; - servoCallback(13, 0.5); // 50% — not a servo signal - expect(servoAngle).toBe(prevAngle); - }); - - it('onLedcUpdate with gpio=-1 should use broadcastPwm (integration logic)', () => { - const pm = new SimplePinManager(); - const received: { pin: number; duty: number }[] = []; - - pm.onPwmChange(13, (pin, duty) => received.push({ pin, duty })); - - const update = { channel: 0, duty: 7.36, duty_pct: 7.36, gpio: -1 }; - const dutyCycle = update.duty_pct / 100; - - if (update.gpio >= 0) { - pm.updatePwm(update.gpio, dutyCycle); - } else { - pm.broadcastPwm(dutyCycle); - } - - expect(received).toHaveLength(1); - expect(received[0]).toEqual({ pin: 13, duty: 0.0736 }); - }); -}); diff --git a/frontend/src/__tests__/helpers/hoistedMocks.ts b/frontend/src/__tests__/helpers/hoistedMocks.ts index eda4564e..ef811517 100644 --- a/frontend/src/__tests__/helpers/hoistedMocks.ts +++ b/frontend/src/__tests__/helpers/hoistedMocks.ts @@ -72,7 +72,6 @@ export const simulatorMocks = () => { this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/interconnect-routing.test.ts b/frontend/src/__tests__/interconnect-routing.test.ts index fd8c1cce..9d368bb2 100644 --- a/frontend/src/__tests__/interconnect-routing.test.ts +++ b/frontend/src/__tests__/interconnect-routing.test.ts @@ -70,7 +70,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/pi3-pico-uart.test.ts b/frontend/src/__tests__/pi3-pico-uart.test.ts index c0491bd9..0ef12815 100644 --- a/frontend/src/__tests__/pi3-pico-uart.test.ts +++ b/frontend/src/__tests__/pi3-pico-uart.test.ts @@ -71,7 +71,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/__tests__/triple-pico-digital-chain.test.ts b/frontend/src/__tests__/triple-pico-digital-chain.test.ts index d6af74e9..500c23e0 100644 --- a/frontend/src/__tests__/triple-pico-digital-chain.test.ts +++ b/frontend/src/__tests__/triple-pico-digital-chain.test.ts @@ -73,7 +73,6 @@ vi.mock('../simulation/Esp32Bridge', () => ({ this.onPinDir = null; this.onCrash = null; this.onDisconnected = null; - this.onLedcUpdate = null; this.onWs2812Update = null; this.onWifiStatus = null; this.onBleStatus = null; diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 7ef9be7e..7f1f8457 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -22,7 +22,9 @@ * { type: 'serial_output', data: { data: string, uart?: number } } * { type: 'gpio_change', data: { pin: number, state: 0 | 1 } } * { type: 'gpio_dir', data: { pin: number, dir: 0 | 1 } } - * { type: 'ledc_update', data: { channel: number, duty: number, duty_pct: number } } + * { type: 'ledc_duty', data: { channel: number, duty_pct: number } } + * { type: 'gpio_routing', data: { gpio: number, signal_id: number } } + * { type: 'gpio_routing_clear', data: { gpio: number } } * { type: 'ws2812_update', data: { channel: number, pixels: [number, number, number][] } } * { type: 'i2c_event', data: { addr: number, data: number } } * { type: 'i2c_transaction', data: { addr: number, data: number[] } } @@ -67,15 +69,8 @@ export interface Ws2812Pixel { g: number; b: number; } -export interface LedcUpdate { - channel: number; - duty: number; - duty_pct: number; - gpio?: number; -} -/** Canonical (SignalRouter) LEDC duty event — channel + duty only. - * The frontend resolves channel→signal_id→pin via its SignalRouter - * mirror. Emitted alongside the legacy `ledc_update` during rollout. */ +/** LEDC duty event — channel + duty only. The frontend resolves + * channel→signal_id→pin via its SignalRouter mirror. */ export interface LedcDuty { channel: number; duty_pct: number; @@ -107,11 +102,8 @@ export class Esp32Bridge { onSerialData: ((char: string, uart?: number) => void) | null = null; onPinChange: ((gpioPin: number, state: boolean) => void) | null = null; onPinDir: ((gpioPin: number, dir: 0 | 1) => void) | null = null; - onLedcUpdate: ((update: LedcUpdate) => void) | null = null; - /** SignalRouter-aware companion to `onLedcUpdate`. The store wires - * this to `makeLedcDutyHandler` which routes channel→pin via the - * per-board SignalRouter; `onLedcUpdate` stays around for back-compat - * during the rollout and is removed once SignalRouter is stable. */ + /** Wired by the store to `makeLedcDutyHandler` which routes + * channel→pin via the per-board SignalRouter mirror. */ onLedcDuty: ((duty: LedcDuty) => void) | null = null; /** Fires whenever the backend observes a write to `gpio_out_sel[N]`. * The store's handler updates the per-board SignalRouter mirror so @@ -291,13 +283,6 @@ export class Esp32Bridge { this.onPinDir?.(pin, dir); break; } - case 'ledc_update': { - console.log( - `[Esp32Bridge:${this.boardId}] ledc_update ch=${msg.data.channel} duty=${msg.data.duty_pct}% gpio=${msg.data.gpio}`, - ); - this.onLedcUpdate?.(msg.data as unknown as LedcUpdate); - break; - } case 'ledc_duty': { this.onLedcDuty?.(msg.data as unknown as LedcDuty); break; diff --git a/frontend/src/simulation/PinManager.ts b/frontend/src/simulation/PinManager.ts index 19fa2596..e2cfbbc3 100644 --- a/frontend/src/simulation/PinManager.ts +++ b/frontend/src/simulation/PinManager.ts @@ -139,34 +139,6 @@ export class PinManager { } } - /** - * Broadcast PWM duty to ALL registered PWM listeners. - * Used when the LEDC channel→GPIO mapping is unknown (gpio=-1). - * Components filter by duty range (e.g., servo accepts 0.01-0.20). - */ - broadcastPwm(dutyCycle: number): void { - this.pwmListeners.forEach((callbacks, pin) => { - this.pwmValues.set(pin, dutyCycle); - callbacks.forEach((cb) => cb(pin, dutyCycle)); - }); - } - - /** - * Count of distinct GPIO pins that currently have at least one PWM - * listener registered. Used by the ledc_update router so it can - * skip a gpio=-1 broadcast when multiple consumers exist — sending - * the same duty to two servos would corrupt the second one - * (`servo blinks between two positions` symptom). With a single - * consumer the broadcast is unambiguous and useful. - */ - pwmListenerPinCount(): number { - let n = 0; - this.pwmListeners.forEach((cbs) => { - if (cbs.size > 0) n++; - }); - return n; - } - getPwmValue(pin: number): number { return this.pwmValues.get(pin) ?? 0; } diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index e2939206..8b184e6e 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -528,52 +528,14 @@ class Esp32BridgeShim { } } -// ── Shared LEDC update handler (used by addBoard, setBoardType, initSimulator) ─ +// ── LEDC duty handler ─────────────────────────────────────────────────── // -// Two handlers ship side by side during the SignalRouter rollout: -// -// * makeLedcUpdateHandler — legacy, consumes the old `ledc_update` -// event (with embedded gpio). Drops the update when gpio=-1 and -// multiple PWM consumers are registered, to avoid the multi-servo -// blink symptom (see commit 77bf897). Kept until the SignalRouter -// path has logged a full prod cycle without regressions. -// -// * makeLedcDutyHandler — canonical, consumes the new `ledc_duty` -// event (channel + duty only) and resolves channel → pins via the -// per-board SignalRouter mirror. Zero broadcasts, zero memos, -// multi-pin routing supported. -// -// Both run unconditionally; updatePwm is idempotent so two handlers -// firing for the same (pin, duty) are a no-op. Once the legacy path -// is retired, makeLedcUpdateHandler + broadcastPwm + pwmListenerPinCount -// are deleted in the same commit. - -function makeLedcUpdateHandler(boardId: string) { - // Per-channel gpio memo: when the backend's _ledc_gpio_map emits - // gpio=-1 (race window during attach), use the last-known gpio for - // this channel to avoid corrupting multi-servo setups. - const channelGpioMemo = new Map(); - - return (update: { channel: number; duty_pct: number; gpio?: number }) => { - const boardPm = pinManagerMap.get(boardId); - if (!boardPm) return; - const dutyCycle = update.duty_pct / 100; - - if (update.gpio !== undefined && update.gpio >= 0) { - channelGpioMemo.set(update.channel, update.gpio); - boardPm.updatePwm(update.gpio, dutyCycle); - return; - } - const rememberedGpio = channelGpioMemo.get(update.channel); - if (rememberedGpio !== undefined) { - boardPm.updatePwm(rememberedGpio, dutyCycle); - return; - } - if (boardPm.pwmListenerPinCount() <= 1) { - boardPm.broadcastPwm(dutyCycle); - } - }; -} +// Resolves a (channel, duty_pct) event from the worker into one or more +// (gpio_pin, duty_cycle) updates by consulting the per-board +// SignalRouter mirror. Replaces the legacy `ledc_update` path that +// embedded the gpio in the event and needed a per-channel memo + a +// PinManager.broadcastPwm fallback to survive the worker's gpio=-1 +// race window. function makeLedcDutyHandler(boardId: string) { return (duty: { channel: number; duty_pct: number }) => { @@ -982,7 +944,6 @@ export const useSimulatorStore = create((set, get) => { }); }; signalRouterMap.set(id, new SignalRouter()); - bridge.onLedcUpdate = makeLedcUpdateHandler(id); bridge.onLedcDuty = makeLedcDutyHandler(id); bridge.onGpioRouting = makeGpioRoutingHandler(id); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(id); @@ -1637,7 +1598,6 @@ export const useSimulatorStore = create((set, get) => { }); }; signalRouterMap.set(boardId, new SignalRouter()); - bridge.onLedcUpdate = makeLedcUpdateHandler(boardId); bridge.onLedcDuty = makeLedcDutyHandler(boardId); bridge.onGpioRouting = makeGpioRoutingHandler(boardId); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId); @@ -1739,7 +1699,6 @@ export const useSimulatorStore = create((set, get) => { }); }; signalRouterMap.set(boardId, new SignalRouter()); - bridge.onLedcUpdate = makeLedcUpdateHandler(boardId); bridge.onLedcDuty = makeLedcDutyHandler(boardId); bridge.onGpioRouting = makeGpioRoutingHandler(boardId); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId);