refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo

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) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-19 04:05:34 +02:00
parent 18a582455c
commit ba59fd4b4a
22 changed files with 62 additions and 267 deletions

View File

@ -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

View File

@ -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

View File

@ -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 <wokwi-pushbutton>
**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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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<number, Set<(pin: number, duty: number) => void>>();
private pwmValues = new Map<number, number>();
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 });
});
});

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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<number, number>();
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<SimulatorState>((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<SimulatorState>((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<SimulatorState>((set, get) => {
});
};
signalRouterMap.set(boardId, new SignalRouter());
bridge.onLedcUpdate = makeLedcUpdateHandler(boardId);
bridge.onLedcDuty = makeLedcDutyHandler(boardId);
bridge.onGpioRouting = makeGpioRoutingHandler(boardId);
bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId);