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:
parent
18a582455c
commit
ba59fd4b4a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue