velxio/backend/app/services/esp32_signals.py

71 lines
2.8 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
"""ESP32 GPIO Matrix output signal source IDs.
Lifted from the ESP32 Technical Reference Manual (Espressif ESP32 TRM
section 4.11, "IO_MUX and GPIO Matrix"). Each output GPIO has a
configuration register `GPIO_FUNCx_OUT_SEL_CFG_REG[x]` whose low 9
bits (`FUNCx_OUT_SEL`) select one of 256 internal peripheral signals
to drive the pin. The constants below name the signals that velxio
actually emulates today; add more here when a new peripheral wants
to participate in the SignalRouter.
The signal ID range mirrors the QEMU plugin's interpretation of
`gpio_out_sel`; the existing worker code at
`esp32_worker.py:_refresh_ledc_gpio_map` already reads these values
out of the matrix via `qemu_picsimlab_get_internals(2)`.
"""
from __future__ import annotations
# ── LEDC (PWM peripheral) ─────────────────────────────────────────────────
# High-speed channels (group 0): signals 72-79 → ledc channel 0-7
# Low-speed channels (group 1): signals 80-87 → ledc channel 0-7
SIG_LEDC_HS_CH0_OUT_IDX = 72 # add N for HS channel N (0..7)
SIG_LEDC_HS_CH_LAST = 79
SIG_LEDC_LS_CH0_OUT_IDX = 80 # add N for LS channel N (0..7)
SIG_LEDC_LS_CH_LAST = 87
def ledc_signal_for_channel(channel: int) -> int:
"""Map a velxio-style unified LEDC channel index (0..15) to its
GPIO Matrix signal source id.
The ESP32 LEDC hardware has two channel groups: 8 high-speed (HS)
and 8 low-speed (LS). velxio unifies them into a single 0..15
space where ch 0-7 = HS, ch 8-15 = LS (matches the encoding the
QEMU plugin emits on the 0x5000 duty callback).
"""
if not 0 <= channel < 16:
raise ValueError(f"ledc channel out of range: {channel}")
if channel < 8:
return SIG_LEDC_HS_CH0_OUT_IDX + channel
return SIG_LEDC_LS_CH0_OUT_IDX + (channel - 8)
def channel_for_ledc_signal(signal_id: int) -> int | None:
"""Inverse of :func:`ledc_signal_for_channel`. Returns None when
the signal id is not an LEDC channel."""
if SIG_LEDC_HS_CH0_OUT_IDX <= signal_id <= SIG_LEDC_HS_CH_LAST:
return signal_id - SIG_LEDC_HS_CH0_OUT_IDX
if SIG_LEDC_LS_CH0_OUT_IDX <= signal_id <= SIG_LEDC_LS_CH_LAST:
return 8 + (signal_id - SIG_LEDC_LS_CH0_OUT_IDX)
return None
# ── Sentinel for "GPIO not routed to any peripheral" ──────────────────────
# When `gpio_out_sel[N]` carries this value the pin is driven by
# normal GPIO output (the value in the GPIO_OUT_REG bit N), not a
# peripheral signal.
SIG_GPIO_DIRECT_OUT_IDX = 256
__all__ = [
"SIG_LEDC_HS_CH0_OUT_IDX",
"SIG_LEDC_HS_CH_LAST",
"SIG_LEDC_LS_CH0_OUT_IDX",
"SIG_LEDC_LS_CH_LAST",
"SIG_GPIO_DIRECT_OUT_IDX",
"ledc_signal_for_channel",
"channel_for_ledc_signal",
]