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"]