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
|
|
|
/**
|
|
|
|
|
* Regression test for the multi-servo blink bug (user report,
|
|
|
|
|
* project 5218f9e3-136d-43b3-bba1-6cebde21e1a4).
|
|
|
|
|
*
|
|
|
|
|
* Background: a solar-tracker project with TWO ESP32 servos on
|
|
|
|
|
* GPIO 13 and 12, driven by LEDC channels 0 and 1 respectively.
|
|
|
|
|
* The user observed both servos snapping between two positions
|
|
|
|
|
* (mirroring each other) instead of moving independently.
|
|
|
|
|
*
|
|
|
|
|
* Root cause: the legacy `ledc_update` event carried an embedded
|
|
|
|
|
* `gpio` value that the backend's gpio_out_sel poll wasn't always
|
|
|
|
|
* able to resolve before emission; on `gpio=-1` the frontend fell
|
|
|
|
|
* back to `PinManager.broadcastPwm` which fanned the duty out to
|
|
|
|
|
* EVERY registered PWM listener, making both servos mirror.
|
|
|
|
|
*
|
|
|
|
|
* This test exercises the canonical SignalRouter path end-to-end:
|
|
|
|
|
* 1. SignalRouter is fed two `gpio_routing` events (one per servo)
|
|
|
|
|
* 2. Two `ledc_duty` events fire (one per channel, different duties)
|
|
|
|
|
* 3. Each pin receives ONLY its own channel's duty
|
|
|
|
|
*
|
|
|
|
|
* If `PinManager.broadcastPwm` ever creeps back into the LEDC code
|
|
|
|
|
* path, this test fails because pin 12 would observe pin 13's duty.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-05-19 09:05:34 +07:00
|
|
|
import { describe, it, expect } from 'vitest';
|
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
|
|
|
import { PinManager } from '../simulation/PinManager';
|
|
|
|
|
import { SignalRouter } from '../simulation/SignalRouter';
|
|
|
|
|
import { ledcSignalForChannel } from '../simulation/esp32-signals';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mini factory that replicates the wiring `useSimulatorStore` does:
|
|
|
|
|
* per-board PinManager + SignalRouter + the three handlers
|
|
|
|
|
* (gpio_routing, gpio_routing_clear, ledc_duty). We don't import
|
|
|
|
|
* the store directly because it's tied to Zustand + global state;
|
|
|
|
|
* this is the pure functional core.
|
|
|
|
|
*/
|
|
|
|
|
function setupBoard() {
|
|
|
|
|
const pm = new PinManager();
|
|
|
|
|
const router = new SignalRouter();
|
|
|
|
|
|
|
|
|
|
const ledcDuty = (duty: { channel: number; duty_pct: number }) => {
|
|
|
|
|
const dutyCycle = duty.duty_pct / 100;
|
|
|
|
|
const sig = ledcSignalForChannel(duty.channel);
|
|
|
|
|
for (const pin of router.pinsForSignal(sig)) {
|
|
|
|
|
pm.updatePwm(pin, dutyCycle);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const gpioRouting = (routing: { gpio: number; signal_id: number }) => {
|
|
|
|
|
router.updateRouting(routing.gpio, routing.signal_id);
|
|
|
|
|
};
|
|
|
|
|
const gpioRoutingClear = (gpio: number) => {
|
|
|
|
|
router.clearRouting(gpio);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return { pm, router, ledcDuty, gpioRouting, gpioRoutingClear };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('multi-servo via SignalRouter — solar-tracker regression', () => {
|
|
|
|
|
it('two servos on different LEDC channels move independently', () => {
|
|
|
|
|
const { pm, ledcDuty, gpioRouting } = setupBoard();
|
|
|
|
|
|
|
|
|
|
// Capture duties seen per pin via onPwmChange listeners — exactly
|
|
|
|
|
// what the real `servo` PartSimulator registers in production.
|
|
|
|
|
const panDuties: number[] = [];
|
|
|
|
|
const tiltDuties: number[] = [];
|
|
|
|
|
pm.onPwmChange(13, (_pin, duty) => panDuties.push(duty));
|
|
|
|
|
pm.onPwmChange(12, (_pin, duty) => tiltDuties.push(duty));
|
|
|
|
|
|
|
|
|
|
// Backend's worker observes the firmware's ledcAttachPin calls
|
|
|
|
|
// and emits two gpio_routing events — one per servo channel.
|
|
|
|
|
gpioRouting({ gpio: 13, signal_id: ledcSignalForChannel(0) }); // servoPan
|
|
|
|
|
gpioRouting({ gpio: 12, signal_id: ledcSignalForChannel(1) }); // servoTilt
|
|
|
|
|
|
|
|
|
|
// Servo.write(0) → ledc duty 2.72% (~544 µs pulse, 0°)
|
|
|
|
|
// Servo.write(180) → ledc duty 12.0% (~2400 µs pulse, 180°)
|
|
|
|
|
ledcDuty({ channel: 0, duty_pct: 7.5 }); // servoPan → ~90°
|
|
|
|
|
ledcDuty({ channel: 1, duty_pct: 2.72 }); // servoTilt → 0°
|
|
|
|
|
ledcDuty({ channel: 0, duty_pct: 8.0 }); // servoPan → ~95°
|
|
|
|
|
ledcDuty({ channel: 1, duty_pct: 3.0 }); // servoTilt → ~3°
|
|
|
|
|
|
|
|
|
|
// Pan saw ONLY pan duties; tilt saw ONLY tilt duties.
|
|
|
|
|
// Use toBeCloseTo because dividing a 2-decimal percentage by 100
|
|
|
|
|
// doesn't produce exact binary floats (0.0272 ≠ 2.72/100).
|
|
|
|
|
expect(panDuties).toHaveLength(2);
|
|
|
|
|
expect(panDuties[0]).toBeCloseTo(0.075, 10);
|
|
|
|
|
expect(panDuties[1]).toBeCloseTo(0.08, 10);
|
|
|
|
|
expect(tiltDuties).toHaveLength(2);
|
|
|
|
|
expect(tiltDuties[0]).toBeCloseTo(0.0272, 10);
|
|
|
|
|
expect(tiltDuties[1]).toBeCloseTo(0.03, 10);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('clearing a routing stops duty updates from reaching the pin', () => {
|
|
|
|
|
const { pm, ledcDuty, gpioRouting, gpioRoutingClear } = setupBoard();
|
|
|
|
|
const duties: number[] = [];
|
|
|
|
|
pm.onPwmChange(13, (_pin, d) => duties.push(d));
|
|
|
|
|
|
|
|
|
|
gpioRouting({ gpio: 13, signal_id: ledcSignalForChannel(0) });
|
|
|
|
|
ledcDuty({ channel: 0, duty_pct: 7.5 });
|
|
|
|
|
expect(duties).toEqual([0.075]);
|
|
|
|
|
|
|
|
|
|
gpioRoutingClear(13);
|
|
|
|
|
ledcDuty({ channel: 0, duty_pct: 12.0 }); // pin 13 no longer routed
|
|
|
|
|
expect(duties).toEqual([0.075]); // unchanged
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('multi-pin routing — one channel driving two pins gets both', () => {
|
|
|
|
|
// Rare but legal in real ESP32 hardware: the same LEDC channel
|
|
|
|
|
// routed to two GPIOs via the matrix. The SignalRouter must
|
|
|
|
|
// dispatch one duty event to BOTH pins (different from the buggy
|
|
|
|
|
// broadcast which dispatched to *all* PWM listeners regardless
|
|
|
|
|
// of routing).
|
|
|
|
|
const { pm, ledcDuty, gpioRouting } = setupBoard();
|
|
|
|
|
const a: number[] = [];
|
|
|
|
|
const b: number[] = [];
|
|
|
|
|
const c: number[] = [];
|
|
|
|
|
pm.onPwmChange(13, (_p, d) => a.push(d));
|
|
|
|
|
pm.onPwmChange(12, (_p, d) => b.push(d));
|
|
|
|
|
pm.onPwmChange(14, (_p, d) => c.push(d)); // unrelated channel
|
|
|
|
|
|
|
|
|
|
const sigCh0 = ledcSignalForChannel(0);
|
|
|
|
|
const sigCh1 = ledcSignalForChannel(1);
|
|
|
|
|
gpioRouting({ gpio: 13, signal_id: sigCh0 });
|
|
|
|
|
gpioRouting({ gpio: 12, signal_id: sigCh0 }); // same channel!
|
|
|
|
|
gpioRouting({ gpio: 14, signal_id: sigCh1 });
|
|
|
|
|
|
|
|
|
|
ledcDuty({ channel: 0, duty_pct: 7.5 });
|
|
|
|
|
|
|
|
|
|
expect(a).toEqual([0.075]); // pin 13: ch 0
|
|
|
|
|
expect(b).toEqual([0.075]); // pin 12: ch 0
|
|
|
|
|
expect(c).toEqual([]); // pin 14: ch 1, untouched
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('re-routing a pin between channels carries the next duty correctly', () => {
|
|
|
|
|
const { pm, ledcDuty, gpioRouting } = setupBoard();
|
|
|
|
|
const duties: number[] = [];
|
|
|
|
|
pm.onPwmChange(13, (_p, d) => duties.push(d));
|
|
|
|
|
|
|
|
|
|
// Pin 13 initially on channel 0.
|
|
|
|
|
gpioRouting({ gpio: 13, signal_id: ledcSignalForChannel(0) });
|
|
|
|
|
ledcDuty({ channel: 0, duty_pct: 5.0 });
|
|
|
|
|
expect(duties).toEqual([0.05]);
|
|
|
|
|
|
|
|
|
|
// Firmware re-attaches pin 13 to channel 1 (legal — Servo.detach
|
|
|
|
|
// then re-attach with a different channel).
|
|
|
|
|
gpioRouting({ gpio: 13, signal_id: ledcSignalForChannel(1) });
|
|
|
|
|
|
|
|
|
|
// A duty on the OLD channel must NOT reach pin 13 anymore.
|
|
|
|
|
ledcDuty({ channel: 0, duty_pct: 9.0 });
|
|
|
|
|
expect(duties).toEqual([0.05]); // unchanged
|
|
|
|
|
|
|
|
|
|
// A duty on the NEW channel reaches it.
|
|
|
|
|
ledcDuty({ channel: 1, duty_pct: 10.0 });
|
|
|
|
|
expect(duties).toEqual([0.05, 0.1]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ledc_duty with no routing yet is silently dropped (no broadcast)', () => {
|
|
|
|
|
// The crux of the original bug: if a duty arrives BEFORE the
|
|
|
|
|
// matrix is populated, the legacy path broadcast it to every
|
|
|
|
|
// listener. The SignalRouter path correctly drops it — the
|
|
|
|
|
// backend's next gpio_routing event will trigger a fresh duty
|
|
|
|
|
// emission anyway, so missing the first frame is invisible.
|
|
|
|
|
const { pm, ledcDuty } = setupBoard();
|
|
|
|
|
const seen: Array<[number, number]> = [];
|
|
|
|
|
pm.onPwmChange(13, (p, d) => seen.push([p, d]));
|
|
|
|
|
pm.onPwmChange(12, (p, d) => seen.push([p, d]));
|
|
|
|
|
|
|
|
|
|
// No gpio_routing has happened yet.
|
|
|
|
|
ledcDuty({ channel: 0, duty_pct: 7.5 });
|
|
|
|
|
expect(seen).toEqual([]); // both pins untouched, no broadcast
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 09:05:34 +07:00
|
|
|
it('PinManager exposes no broadcastPwm fallback', () => {
|
|
|
|
|
// The pre-SignalRouter patch shipped a `broadcastPwm` method on
|
|
|
|
|
// PinManager that fanned a duty out to every PWM listener as a
|
|
|
|
|
// gpio=-1 fallback. The SignalRouter rewrite deletes that method
|
|
|
|
|
// entirely. This test guards the deletion: if a future refactor
|
|
|
|
|
// adds it back, the regression fails here rather than in
|
|
|
|
|
// production multi-servo wiring.
|
|
|
|
|
const { pm } = setupBoard();
|
|
|
|
|
expect((pm as unknown as { broadcastPwm?: unknown }).broadcastPwm).toBeUndefined();
|
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
|
|
|
});
|
|
|
|
|
});
|