velxio/test/backend/unit/test_signal_router.py

228 lines
9.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
"""Unit tests for app.services.signal_router.SignalRouter.
Mirror of the ESP32 GPIO Matrix abstraction. Covers:
* idempotent updates (re-emitting the same routing is a no-op)
* signal re-routing (a pin moves from signal A to signal B reverse
index stays a clean partition; A's set loses the pin, B's set
gains it)
* multi-pin routing (rare but legal: one signal drives multiple pins)
* clear_routing (firmware resets the matrix entry)
* snapshot replace returning the diff (changed_routes + cleared_pins)
* the LEDC channel signal id translation helpers in esp32_signals
Fidelity rule (memory `feedback_tests_import_real_code`): imports
the real production modules; no duplicated mock implementation.
"""
from __future__ import annotations
import pytest
from app.services.signal_router import SignalRouter
from app.services.esp32_signals import (
SIG_LEDC_HS_CH0_OUT_IDX,
SIG_LEDC_LS_CH0_OUT_IDX,
channel_for_ledc_signal,
ledc_signal_for_channel,
)
# ──────────────────────────────────────────────────────────────────────────
# SignalRouter — core update/lookup
# ──────────────────────────────────────────────────────────────────────────
def test_update_routing_populates_both_indexes() -> None:
r = SignalRouter()
r.update_routing(13, SIG_LEDC_HS_CH0_OUT_IDX)
assert r.signal_for_gpio(13) == SIG_LEDC_HS_CH0_OUT_IDX
assert r.pins_for_signal(SIG_LEDC_HS_CH0_OUT_IDX) == (13,)
def test_update_routing_idempotent() -> None:
r = SignalRouter()
r.update_routing(13, SIG_LEDC_HS_CH0_OUT_IDX)
r.update_routing(13, SIG_LEDC_HS_CH0_OUT_IDX) # exact same call
r.update_routing(13, SIG_LEDC_HS_CH0_OUT_IDX)
assert r.pins_for_signal(SIG_LEDC_HS_CH0_OUT_IDX) == (13,)
def test_rerouting_pin_moves_it_in_reverse_index() -> None:
r = SignalRouter()
sig_a = SIG_LEDC_HS_CH0_OUT_IDX # 72
sig_b = SIG_LEDC_HS_CH0_OUT_IDX + 1 # 73
r.update_routing(13, sig_a)
r.update_routing(13, sig_b)
# Forward: pin 13 now points at signal B
assert r.signal_for_gpio(13) == sig_b
# Reverse: signal A is empty; signal B has pin 13
assert r.pins_for_signal(sig_a) == ()
assert r.pins_for_signal(sig_b) == (13,)
def test_multi_pin_routing_for_one_signal() -> None:
"""Same signal driving two pins (legal in ESP32 GPIO Matrix —
e.g. clock-out signal mirrored to two debug pins)."""
r = SignalRouter()
sig = SIG_LEDC_HS_CH0_OUT_IDX
r.update_routing(12, sig)
r.update_routing(13, sig)
assert r.pins_for_signal(sig) == (12, 13) # sorted
def test_clear_routing_removes_pin() -> None:
r = SignalRouter()
sig = SIG_LEDC_HS_CH0_OUT_IDX
r.update_routing(13, sig)
r.clear_routing(13)
assert r.signal_for_gpio(13) is None
assert r.pins_for_signal(sig) == ()
def test_clear_routing_idempotent_when_unset() -> None:
r = SignalRouter()
r.clear_routing(99) # never set; should not raise
assert r.signal_for_gpio(99) is None
def test_pins_for_signal_returns_tuple_safe_for_iteration() -> None:
"""The returned tuple must not change if the router mutates
afterwards protects callers iterating the result while the
QEMU thread is updating the routing."""
r = SignalRouter()
sig = SIG_LEDC_HS_CH0_OUT_IDX
r.update_routing(13, sig)
snapshot = r.pins_for_signal(sig)
r.update_routing(12, sig) # add another pin
assert snapshot == (13,) # unchanged
assert r.pins_for_signal(sig) == (12, 13)
def test_routes_iterator_returns_full_matrix() -> None:
r = SignalRouter()
r.update_routing(13, 72)
r.update_routing(12, 73)
r.update_routing(14, 80)
assert dict(r.routes()) == {13: 72, 12: 73, 14: 80}
def test_len_reports_number_of_routed_pins() -> None:
r = SignalRouter()
assert len(r) == 0
r.update_routing(13, 72)
assert len(r) == 1
r.update_routing(12, 73)
assert len(r) == 2
r.clear_routing(13)
assert len(r) == 1
# ──────────────────────────────────────────────────────────────────────────
# replace_snapshot — used by the polling-fallback path
# ──────────────────────────────────────────────────────────────────────────
def test_replace_snapshot_returns_diff_for_brand_new_entries() -> None:
r = SignalRouter()
changed, cleared = r.replace_snapshot({13: 72, 12: 73})
assert sorted(changed) == [(12, 73), (13, 72)]
assert cleared == []
def test_replace_snapshot_returns_diff_for_changed_routes_only() -> None:
r = SignalRouter()
r.update_routing(13, 72)
r.update_routing(12, 73)
# Move pin 13 to a new signal; keep pin 12; add pin 14.
changed, cleared = r.replace_snapshot({13: 75, 12: 73, 14: 80})
assert (13, 75) in changed
assert (14, 80) in changed
assert (12, 73) not in changed # unchanged, not in diff
assert cleared == []
def test_replace_snapshot_reports_cleared_pins() -> None:
r = SignalRouter()
r.update_routing(13, 72)
r.update_routing(12, 73)
changed, cleared = r.replace_snapshot({13: 72}) # pin 12 dropped
assert changed == []
assert cleared == [12]
def test_replace_snapshot_combines_changes_and_clears() -> None:
r = SignalRouter()
r.update_routing(13, 72)
r.update_routing(12, 73)
r.update_routing(14, 80)
# Drop 13, re-route 12, keep 14.
changed, cleared = r.replace_snapshot({12: 75, 14: 80})
assert (12, 75) in changed
assert (14, 80) not in changed
assert cleared == [13]
# Post-state matches the snapshot exactly.
assert dict(r.routes()) == {12: 75, 14: 80}
# ──────────────────────────────────────────────────────────────────────────
# esp32_signals — channel ↔ signal id helpers
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"channel,expected",
[
fix(esp32): LEDC signal IDs are 71-86 per ESP32 TRM, not 72-87 User report: on the solar-tracker project (5218f9e3) only one servo moved and the log showed `ch=0 duty=X% gpio=12` (wrong — servoPan was attached to GPIO 13) and `ch=1 ... gpio=-1` (servoTilt's channel never resolved). Root cause traced through the GPIO Matrix dump: the firmware does exactly what the Arduino-ESP32 Servo library says — `ledcAttachPin( 13, 0)` writes signal 71 (LEDC_HS_SIG_OUT0) into `gpio_out_sel[13]`, and `ledcAttachPin(12, 1)` writes signal 72 (LEDC_HS_SIG_OUT1) into `gpio_out_sel[12]`. Per the ESP32 Technical Reference Manual section 4.11, Table 4-3: 71 .. 78 → LEDC HS channels 0..7 79 .. 86 → LEDC LS channels 0..7 The legacy worker code at esp32_worker.py:426 used the off-by-one range `72 <= signal <= 87` with `ledc_ch = signal - 72`. The mistake masked itself for single-servo projects because the 0x5000 duty callback's channel index was internally consistent with the bogus math, so the duty STILL reached the correctly-routed pin (just labelled wrong). The new SignalRouter unit tests caught the discrepancy the moment two servos drove distinct channels: signal 71 (HS_CH0, gpio 13) was REJECTED by the off-by-one filter and signal 72 (HS_CH1, gpio 12) was misclassified as channel 0. When I ported the legacy range into `esp32_signals.SIG_LEDC_HS_CH0_OUT_IDX` the bug came along for the ride. Fix both modules: * `backend/app/services/esp32_signals.py`: HS 71-78, LS 79-86. * `frontend/src/simulation/esp32-signals.ts`: mirror. * tests updated; 20 backend + 23 frontend pass. After deploy the user's two servos will resolve to their declared pins: ch=0 duty=X% gpio=13 (servoPan, was wrongly emitting gpio=12) ch=1 duty=X% gpio=12 (servoTilt, was wrongly emitting gpio=-1) This is also why the multi-servo blink "patch" in commit 77bf897 appeared to help: with both pins ALIASED to the same channel via the off-by-one, the broadcast fallback was the only thing producing ANY movement on the second servo at all.
2026-05-17 10:42:52 +07:00
(0, SIG_LEDC_HS_CH0_OUT_IDX), # 71 (HS ch 0)
(7, SIG_LEDC_HS_CH0_OUT_IDX + 7), # 78 (HS ch 7)
(8, SIG_LEDC_LS_CH0_OUT_IDX), # 79 (LS ch 0)
(15, SIG_LEDC_LS_CH0_OUT_IDX + 7), # 86 (LS ch 7)
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
],
)
def test_ledc_signal_for_channel_roundtrip(channel: int, expected: int) -> None:
assert ledc_signal_for_channel(channel) == expected
assert channel_for_ledc_signal(expected) == channel
def test_ledc_signal_for_channel_rejects_out_of_range() -> None:
with pytest.raises(ValueError):
ledc_signal_for_channel(-1)
with pytest.raises(ValueError):
ledc_signal_for_channel(16)
def test_channel_for_ledc_signal_returns_none_for_non_ledc() -> None:
fix(esp32): LEDC signal IDs are 71-86 per ESP32 TRM, not 72-87 User report: on the solar-tracker project (5218f9e3) only one servo moved and the log showed `ch=0 duty=X% gpio=12` (wrong — servoPan was attached to GPIO 13) and `ch=1 ... gpio=-1` (servoTilt's channel never resolved). Root cause traced through the GPIO Matrix dump: the firmware does exactly what the Arduino-ESP32 Servo library says — `ledcAttachPin( 13, 0)` writes signal 71 (LEDC_HS_SIG_OUT0) into `gpio_out_sel[13]`, and `ledcAttachPin(12, 1)` writes signal 72 (LEDC_HS_SIG_OUT1) into `gpio_out_sel[12]`. Per the ESP32 Technical Reference Manual section 4.11, Table 4-3: 71 .. 78 → LEDC HS channels 0..7 79 .. 86 → LEDC LS channels 0..7 The legacy worker code at esp32_worker.py:426 used the off-by-one range `72 <= signal <= 87` with `ledc_ch = signal - 72`. The mistake masked itself for single-servo projects because the 0x5000 duty callback's channel index was internally consistent with the bogus math, so the duty STILL reached the correctly-routed pin (just labelled wrong). The new SignalRouter unit tests caught the discrepancy the moment two servos drove distinct channels: signal 71 (HS_CH0, gpio 13) was REJECTED by the off-by-one filter and signal 72 (HS_CH1, gpio 12) was misclassified as channel 0. When I ported the legacy range into `esp32_signals.SIG_LEDC_HS_CH0_OUT_IDX` the bug came along for the ride. Fix both modules: * `backend/app/services/esp32_signals.py`: HS 71-78, LS 79-86. * `frontend/src/simulation/esp32-signals.ts`: mirror. * tests updated; 20 backend + 23 frontend pass. After deploy the user's two servos will resolve to their declared pins: ch=0 duty=X% gpio=13 (servoPan, was wrongly emitting gpio=12) ch=1 duty=X% gpio=12 (servoTilt, was wrongly emitting gpio=-1) This is also why the multi-servo blink "patch" in commit 77bf897 appeared to help: with both pins ALIASED to the same channel via the off-by-one, the broadcast fallback was the only thing producing ANY movement on the second servo at all.
2026-05-17 10:42:52 +07:00
# 70 is the signal immediately below the LEDC range; 87 is the
# signal immediately above. Both must return None — anything
# else implies the constants drifted away from the ESP32 TRM.
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
assert channel_for_ledc_signal(0) is None
fix(esp32): LEDC signal IDs are 71-86 per ESP32 TRM, not 72-87 User report: on the solar-tracker project (5218f9e3) only one servo moved and the log showed `ch=0 duty=X% gpio=12` (wrong — servoPan was attached to GPIO 13) and `ch=1 ... gpio=-1` (servoTilt's channel never resolved). Root cause traced through the GPIO Matrix dump: the firmware does exactly what the Arduino-ESP32 Servo library says — `ledcAttachPin( 13, 0)` writes signal 71 (LEDC_HS_SIG_OUT0) into `gpio_out_sel[13]`, and `ledcAttachPin(12, 1)` writes signal 72 (LEDC_HS_SIG_OUT1) into `gpio_out_sel[12]`. Per the ESP32 Technical Reference Manual section 4.11, Table 4-3: 71 .. 78 → LEDC HS channels 0..7 79 .. 86 → LEDC LS channels 0..7 The legacy worker code at esp32_worker.py:426 used the off-by-one range `72 <= signal <= 87` with `ledc_ch = signal - 72`. The mistake masked itself for single-servo projects because the 0x5000 duty callback's channel index was internally consistent with the bogus math, so the duty STILL reached the correctly-routed pin (just labelled wrong). The new SignalRouter unit tests caught the discrepancy the moment two servos drove distinct channels: signal 71 (HS_CH0, gpio 13) was REJECTED by the off-by-one filter and signal 72 (HS_CH1, gpio 12) was misclassified as channel 0. When I ported the legacy range into `esp32_signals.SIG_LEDC_HS_CH0_OUT_IDX` the bug came along for the ride. Fix both modules: * `backend/app/services/esp32_signals.py`: HS 71-78, LS 79-86. * `frontend/src/simulation/esp32-signals.ts`: mirror. * tests updated; 20 backend + 23 frontend pass. After deploy the user's two servos will resolve to their declared pins: ch=0 duty=X% gpio=13 (servoPan, was wrongly emitting gpio=12) ch=1 duty=X% gpio=12 (servoTilt, was wrongly emitting gpio=-1) This is also why the multi-servo blink "patch" in commit 77bf897 appeared to help: with both pins ALIASED to the same channel via the off-by-one, the broadcast fallback was the only thing producing ANY movement on the second servo at all.
2026-05-17 10:42:52 +07:00
assert channel_for_ledc_signal(70) is None
assert channel_for_ledc_signal(87) is None
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
assert channel_for_ledc_signal(256) is None
# ──────────────────────────────────────────────────────────────────────────
# Multi-servo blink regression — the original bug
# ──────────────────────────────────────────────────────────────────────────
def test_multi_servo_routing_does_not_alias() -> None:
"""The exact scenario from the user report
(project 5218f9e3, solar-tracker): two servos on GPIO 13 and 12,
each on its own LEDC HS channel. Writing duty to channel 0
must only affect pin 13; writing to channel 1 only affects
pin 12. The old broadcastPwm path made both pins mirror the
last channel written."""
r = SignalRouter()
sig_pan = ledc_signal_for_channel(0) # 72
sig_tilt = ledc_signal_for_channel(1) # 73
r.update_routing(13, sig_pan)
r.update_routing(12, sig_tilt)
# The router must produce disjoint pin sets per channel.
assert r.pins_for_signal(sig_pan) == (13,)
assert r.pins_for_signal(sig_tilt) == (12,)
# A duty on channel 0 routes ONLY to pin 13, not pin 12.
assert 12 not in r.pins_for_signal(sig_pan)
assert 13 not in r.pins_for_signal(sig_tilt)