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
|
|
|
/**
|
|
|
|
|
* Frontend mirror of test/backend/unit/test_signal_router.py.
|
|
|
|
|
*
|
|
|
|
|
* Covers SignalRouter's core update/clear semantics, multi-pin
|
|
|
|
|
* routing, the symmetric reverse index after re-routes, and the
|
|
|
|
|
* esp32-signals channel↔signal-id translation. The Python side
|
|
|
|
|
* has identical coverage so both ends of the WebSocket stay in
|
|
|
|
|
* lock-step.
|
|
|
|
|
*
|
|
|
|
|
* Fidelity rule (memory `feedback_tests_import_real_code`):
|
|
|
|
|
* imports the real production modules. Adding a new signal in
|
|
|
|
|
* `esp32-signals.ts` only requires a new test case here; nothing
|
|
|
|
|
* duplicates the source-of-truth.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
import { SignalRouter } from '../simulation/SignalRouter';
|
|
|
|
|
import {
|
|
|
|
|
SIG_LEDC_HS_CH0_OUT_IDX,
|
|
|
|
|
SIG_LEDC_LS_CH0_OUT_IDX,
|
|
|
|
|
channelForLedcSignal,
|
|
|
|
|
ledcSignalForChannel,
|
|
|
|
|
} from '../simulation/esp32-signals';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
describe('SignalRouter — core update/lookup', () => {
|
|
|
|
|
it('update_routing populates both indexes', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
r.updateRouting(13, SIG_LEDC_HS_CH0_OUT_IDX);
|
|
|
|
|
expect(r.signalForGpio(13)).toBe(SIG_LEDC_HS_CH0_OUT_IDX);
|
|
|
|
|
expect(r.pinsForSignal(SIG_LEDC_HS_CH0_OUT_IDX)).toEqual([13]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updateRouting is idempotent — same call twice is a no-op', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
r.updateRouting(13, SIG_LEDC_HS_CH0_OUT_IDX);
|
|
|
|
|
r.updateRouting(13, SIG_LEDC_HS_CH0_OUT_IDX);
|
|
|
|
|
r.updateRouting(13, SIG_LEDC_HS_CH0_OUT_IDX);
|
|
|
|
|
expect(r.pinsForSignal(SIG_LEDC_HS_CH0_OUT_IDX)).toEqual([13]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rerouting a pin moves it cleanly across reverse-index sets', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
const sigA = SIG_LEDC_HS_CH0_OUT_IDX; // 72
|
|
|
|
|
const sigB = SIG_LEDC_HS_CH0_OUT_IDX + 1; // 73
|
|
|
|
|
r.updateRouting(13, sigA);
|
|
|
|
|
r.updateRouting(13, sigB);
|
|
|
|
|
expect(r.signalForGpio(13)).toBe(sigB);
|
|
|
|
|
expect(r.pinsForSignal(sigA)).toEqual([]);
|
|
|
|
|
expect(r.pinsForSignal(sigB)).toEqual([13]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('multi-pin routing — one signal driving two pins', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
const sig = SIG_LEDC_HS_CH0_OUT_IDX;
|
|
|
|
|
r.updateRouting(12, sig);
|
|
|
|
|
r.updateRouting(13, sig);
|
|
|
|
|
expect(r.pinsForSignal(sig)).toEqual([12, 13]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('clearRouting removes the pin from both indexes', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
const sig = SIG_LEDC_HS_CH0_OUT_IDX;
|
|
|
|
|
r.updateRouting(13, sig);
|
|
|
|
|
r.clearRouting(13);
|
|
|
|
|
expect(r.signalForGpio(13)).toBeUndefined();
|
|
|
|
|
expect(r.pinsForSignal(sig)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('clearRouting is idempotent when the pin was never routed', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
expect(() => r.clearRouting(99)).not.toThrow();
|
|
|
|
|
expect(r.signalForGpio(99)).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('pinsForSignal returns a snapshot — safe to iterate during mutation', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
const sig = SIG_LEDC_HS_CH0_OUT_IDX;
|
|
|
|
|
r.updateRouting(13, sig);
|
|
|
|
|
const snapshot = r.pinsForSignal(sig);
|
|
|
|
|
r.updateRouting(12, sig); // mutate
|
|
|
|
|
expect(snapshot).toEqual([13]); // snapshot unchanged
|
|
|
|
|
expect(r.pinsForSignal(sig)).toEqual([12, 13]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('routes() iterator returns the full matrix', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
r.updateRouting(13, 72);
|
|
|
|
|
r.updateRouting(12, 73);
|
|
|
|
|
r.updateRouting(14, 80);
|
|
|
|
|
expect(Object.fromEntries(r.routes())).toEqual({ 12: 73, 13: 72, 14: 80 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('size reflects the number of currently routed pins', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
expect(r.size).toBe(0);
|
|
|
|
|
r.updateRouting(13, 72);
|
|
|
|
|
r.updateRouting(12, 73);
|
|
|
|
|
expect(r.size).toBe(2);
|
|
|
|
|
r.clearRouting(13);
|
|
|
|
|
expect(r.size).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reset() drops the entire matrix', () => {
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
r.updateRouting(13, 72);
|
|
|
|
|
r.updateRouting(12, 73);
|
|
|
|
|
r.reset();
|
|
|
|
|
expect(r.size).toBe(0);
|
|
|
|
|
expect(r.pinsForSignal(72)).toEqual([]);
|
|
|
|
|
expect(r.signalForGpio(13)).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
describe('esp32-signals — channel ↔ signal id helpers', () => {
|
|
|
|
|
it.each([
|
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
|
|
|
])('ledcSignalForChannel(%d) roundtrips through channelForLedcSignal → %d', (channel, sig) => {
|
|
|
|
|
expect(ledcSignalForChannel(channel)).toBe(sig);
|
|
|
|
|
expect(channelForLedcSignal(sig)).toBe(channel);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ledcSignalForChannel rejects out-of-range', () => {
|
|
|
|
|
expect(() => ledcSignalForChannel(-1)).toThrow();
|
|
|
|
|
expect(() => ledcSignalForChannel(16)).toThrow();
|
|
|
|
|
expect(() => ledcSignalForChannel(3.5)).toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('channelForLedcSignal returns null for non-LEDC signal ids', () => {
|
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 sits immediately below the LEDC range, 87 immediately above.
|
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
|
|
|
expect(channelForLedcSignal(0)).toBeNull();
|
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
|
|
|
expect(channelForLedcSignal(70)).toBeNull();
|
|
|
|
|
expect(channelForLedcSignal(87)).toBeNull();
|
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
|
|
|
expect(channelForLedcSignal(256)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
describe('SignalRouter — multi-servo blink regression', () => {
|
|
|
|
|
it('two servos on different channels do NOT alias on the reverse index', () => {
|
|
|
|
|
// Exact scenario from user report (project 5218f9e3, solar-tracker):
|
|
|
|
|
// GPIO 13 driven by LEDC HS channel 0 (signal 72),
|
|
|
|
|
// GPIO 12 driven by LEDC HS channel 1 (signal 73).
|
|
|
|
|
const r = new SignalRouter();
|
|
|
|
|
const sigPan = ledcSignalForChannel(0);
|
|
|
|
|
const sigTilt = ledcSignalForChannel(1);
|
|
|
|
|
r.updateRouting(13, sigPan);
|
|
|
|
|
r.updateRouting(12, sigTilt);
|
|
|
|
|
|
|
|
|
|
// A duty event on channel 0 (sigPan) routes ONLY to pin 13.
|
|
|
|
|
expect(r.pinsForSignal(sigPan)).toEqual([13]);
|
|
|
|
|
expect(r.pinsForSignal(sigPan)).not.toContain(12);
|
|
|
|
|
|
|
|
|
|
// Symmetrically: a duty on channel 1 routes ONLY to pin 12.
|
|
|
|
|
expect(r.pinsForSignal(sigTilt)).toEqual([12]);
|
|
|
|
|
expect(r.pinsForSignal(sigTilt)).not.toContain(13);
|
|
|
|
|
});
|
|
|
|
|
});
|