velxio/frontend/src/simulation/SignalRouter.ts

113 lines
3.7 KiB
TypeScript
Raw Normal View History

feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
/**
* GPIO Matrix-aware signal router (frontend mirror).
*
* The ESP32 SoC's IO_MUX + GPIO Matrix decouples *signal sources*
* (LEDC channels, RMT channels, UART TX, SPI MOSI, ...) from
* physical *GPIO pins* via a 40-entry routing table. The backend
* worker observes writes to that table and broadcasts a
* `gpio_routing` event for each change; this class is the frontend's
* replicated view, used to route peripheral events (e.g.
* `ledc_duty {channel, duty_pct}`) to the correct pin(s).
*
* Replaces `PinManager.broadcastPwm` + the per-channel memo
* workaround that previously masked the multi-servo blink bug
* (see commit 77bf897). With the router in place the frontend
* always knows which pin a signal source drives no broadcasting,
* no guessing.
*
* 1-to-1 port of `backend/app/services/signal_router.py`. Tests in
* `__tests__/SignalRouter.test.ts` are the mirror of
* `test/backend/unit/test_signal_router.py`.
*/
export class SignalRouter {
// gpio_pin → signal_id
private readonly matrix = new Map<number, number>();
// signal_id → set of gpio_pins (reverse index)
private readonly sources = new Map<number, Set<number>>();
// ── Mutators ────────────────────────────────────────────────────────
/**
* Record that `gpioPin` is now driven by `signalId`. If the pin
* previously routed from a different signal, it is removed from
* that signal's set first the reverse index stays a true
* partition of the matrix.
*/
updateRouting(gpioPin: number, signalId: number): void {
const old = this.matrix.get(gpioPin);
if (old === signalId) return; // idempotent
if (old !== undefined) {
this.sources.get(old)?.delete(gpioPin);
if (this.sources.get(old)?.size === 0) {
this.sources.delete(old);
}
}
this.matrix.set(gpioPin, signalId);
let set = this.sources.get(signalId);
if (!set) {
set = new Set();
this.sources.set(signalId, set);
}
set.add(gpioPin);
}
/**
* Remove `gpioPin` from the matrix entirely. Equivalent to the
* firmware resetting `gpio_out_sel[gpioPin]` back to the default
* 'GPIO direct out' sentinel. Idempotent.
*/
clearRouting(gpioPin: number): void {
const old = this.matrix.get(gpioPin);
if (old === undefined) return;
this.matrix.delete(gpioPin);
this.sources.get(old)?.delete(gpioPin);
if (this.sources.get(old)?.size === 0) {
this.sources.delete(old);
}
}
/**
* Drop the entire matrix (e.g. on board reset / simulation stop).
*/
reset(): void {
this.matrix.clear();
this.sources.clear();
}
// ── Readers ─────────────────────────────────────────────────────────
/**
* Return every gpio_pin currently driven by `signalId`. The
* returned array is a snapshot safe to iterate while the router
* mutates (unlike a live view into the reverse index).
*/
pinsForSignal(signalId: number): number[] {
const set = this.sources.get(signalId);
if (!set) return [];
return Array.from(set).sort((a, b) => a - b);
}
/**
* Return the signal id currently routed to `gpioPin`, or undefined
* when the pin is unmapped (GPIO direct-out).
*/
signalForGpio(gpioPin: number): number | undefined {
return this.matrix.get(gpioPin);
}
/**
* Iterate the full matrix as [gpioPin, signalId] entries. Useful
* for snapshot-style debugging.
*/
*routes(): Iterable<[number, number]> {
for (const entry of this.matrix.entries()) {
yield entry;
}
}
get size(): number {
return this.matrix.size;
}
}