velxio/backend/app/services/signal_router.py

144 lines
6.0 KiB
Python
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 for emulated ESP32 boards.
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 (`gpio_out_sel[40]`). Each
entry records which signal id drives the pin; any number of pins
can be routed from the same signal (rare, but legal).
This module is the velxio-side mirror of that routing table. The
worker (`esp32_worker.py`) feeds it every time the firmware writes
to a `GPIO_FUNCx_OUT_SEL_CFG_REG` register; the worker then emits
peripheral-event payloads (LEDC duty changes, RMT pulses, …) that
identify the signal SOURCE rather than the pin, and the routing
table fans them out to all the pins that signal currently drives.
This replaces the previous per-peripheral ad hoc maps (e.g.
`_ledc_gpio_map`) and the lossy `broadcastPwm` fallback in the
frontend, both of which violated the hardware abstraction and
caused the multi-servo blink bug documented in commit 77bf897.
"""
from __future__ import annotations
from collections import defaultdict
from typing import Iterator
class SignalRouter:
"""Mirror of the ESP32 GPIO Matrix.
Maintains both a forward index (gpio_pin → signal_id) and a
reverse index (signal_id → set of gpio_pins) so each direction
of lookup is O(1). All mutations are atomic across the two
indexes — concurrent reads from another thread see a consistent
pair before/after, never an intermediate state with the gpio
in BOTH the old and new signal's set.
The class is intentionally simple: no locking, no async, no
persistence. Threading is the caller's concern; the worker runs
everything on the QEMU emulation thread which serialises
register writes.
"""
def __init__(self) -> None:
self._matrix: dict[int, int] = {}
# defaultdict(set) keeps lookups symmetric — a signal id
# with zero current routees returns an empty set rather than
# raising KeyError, matching real-hardware semantics.
self._sources: dict[int, set[int]] = defaultdict(set)
# ── Mutators ────────────────────────────────────────────────────────
def update_routing(self, gpio_pin: int, signal_id: int) -> None:
"""Record that `gpio_pin` is now driven by `signal_id`.
If the pin previously routed from a different signal, it is
removed from that signal's set first — keeping the reverse
index a true partition of the matrix.
"""
old = self._matrix.get(gpio_pin)
if old == signal_id:
return # idempotent — common case during polling fallback
if old is not None:
self._sources[old].discard(gpio_pin)
if not self._sources[old]:
del self._sources[old]
self._matrix[gpio_pin] = signal_id
self._sources[signal_id].add(gpio_pin)
def clear_routing(self, gpio_pin: int) -> None:
"""Remove `gpio_pin` from the matrix entirely.
Equivalent to the firmware setting `gpio_out_sel[gpio_pin]`
back to the default 'GPIO direct out' sentinel. Idempotent
if the pin isn't currently routed.
"""
old = self._matrix.pop(gpio_pin, None)
if old is None:
return
self._sources[old].discard(gpio_pin)
if not self._sources[old]:
del self._sources[old]
def replace_snapshot(
self, matrix: dict[int, int],
) -> tuple[list[tuple[int, int]], list[int]]:
"""Adopt a wholesale snapshot of the matrix and return the
diff vs the previous state.
Used by the polling-fallback path (no synchronous C callback
available): the worker periodically scans `gpio_out_sel[40]`
and hands us the whole table; we diff against our state and
return:
- ``changed_routes``: list of ``(gpio_pin, new_signal_id)``
for pins whose signal id moved or that became newly
routed;
- ``cleared_pins``: list of gpio_pins that USED to be
routed and no longer are.
Callers use these lists to emit `gpio_routing` /
`gpio_routing_clear` WebSocket events to the frontend so its
mirror stays in sync without re-broadcasting the whole table.
"""
changed: list[tuple[int, int]] = []
for gpio_pin, signal_id in matrix.items():
if self._matrix.get(gpio_pin) != signal_id:
changed.append((gpio_pin, signal_id))
cleared = [g for g in self._matrix if g not in matrix]
for gpio_pin, signal_id in changed:
self.update_routing(gpio_pin, signal_id)
for gpio_pin in cleared:
self.clear_routing(gpio_pin)
return changed, cleared
# ── Readers ─────────────────────────────────────────────────────────
def pins_for_signal(self, signal_id: int) -> tuple[int, ...]:
"""Return every gpio_pin currently driven by ``signal_id``.
Returns a tuple (immutable) so callers can iterate without
worrying about concurrent mutations invalidating the result.
"""
return tuple(sorted(self._sources.get(signal_id, ())))
def signal_for_gpio(self, gpio_pin: int) -> int | None:
"""Return the signal id currently routed to ``gpio_pin``, or
None when the pin is unmapped (GPIO direct-out)."""
return self._matrix.get(gpio_pin)
def routes(self) -> Iterator[tuple[int, int]]:
"""Iterate the full matrix as ``(gpio_pin, signal_id)`` pairs.
Useful for snapshot-style replication to a new connection
(e.g. the frontend re-attaching after a WebSocket reconnect).
"""
return iter(self._matrix.items())
def __len__(self) -> int:
return len(self._matrix)
__all__ = ["SignalRouter"]