diff --git a/backend/app/services/esp32_signals.py b/backend/app/services/esp32_signals.py index 34b3c50c..16e099da 100644 --- a/backend/app/services/esp32_signals.py +++ b/backend/app/services/esp32_signals.py @@ -18,12 +18,24 @@ 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 +# Per ESP32 Technical Reference Manual section 4.11, Table 4-3 (GPIO +# Matrix output signals): +# 71-78 → LEDC_HS_SIG_OUT[0..7] (high-speed channels 0-7) +# 79-86 → LEDC_LS_SIG_OUT[0..7] (low-speed channels 0-7) +# +# The legacy worker code at esp32_worker.py:426 used the off-by-one +# range 72-87; that masked itself because the channel index encoded in +# the 0x5000 duty callback (0..15) was internally consistent with the +# bogus signal-id math, so single-servo demos still appeared to work. +# Multi-servo projects (e.g. solar-tracker, project 5218f9e3) exposed +# the bug — `ledcAttachPin(13, 0)` actually writes signal 71 to +# gpio_out_sel[13], which the off-by-one scan REJECTED, so channel 0 +# resolved to GPIO 12 (the next servo's pin, whose signal 72 WAS in +# range and was misinterpreted as channel 0). +SIG_LEDC_HS_CH0_OUT_IDX = 71 # add N for HS channel N (0..7) +SIG_LEDC_HS_CH_LAST = 78 +SIG_LEDC_LS_CH0_OUT_IDX = 79 # add N for LS channel N (0..7) +SIG_LEDC_LS_CH_LAST = 86 def ledc_signal_for_channel(channel: int) -> int: diff --git a/frontend/src/__tests__/SignalRouter.test.ts b/frontend/src/__tests__/SignalRouter.test.ts index ff489642..a2732ba6 100644 --- a/frontend/src/__tests__/SignalRouter.test.ts +++ b/frontend/src/__tests__/SignalRouter.test.ts @@ -115,10 +115,10 @@ describe('SignalRouter — core update/lookup', () => { describe('esp32-signals — channel ↔ signal id helpers', () => { it.each([ - [0, SIG_LEDC_HS_CH0_OUT_IDX], // 72 - [7, SIG_LEDC_HS_CH0_OUT_IDX + 7], // 79 - [8, SIG_LEDC_LS_CH0_OUT_IDX], // 80 - [15, SIG_LEDC_LS_CH0_OUT_IDX + 7], // 87 + [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) ])('ledcSignalForChannel(%d) roundtrips through channelForLedcSignal → %d', (channel, sig) => { expect(ledcSignalForChannel(channel)).toBe(sig); expect(channelForLedcSignal(sig)).toBe(channel); @@ -131,9 +131,10 @@ describe('esp32-signals — channel ↔ signal id helpers', () => { }); it('channelForLedcSignal returns null for non-LEDC signal ids', () => { + // 70 sits immediately below the LEDC range, 87 immediately above. expect(channelForLedcSignal(0)).toBeNull(); - expect(channelForLedcSignal(71)).toBeNull(); - expect(channelForLedcSignal(88)).toBeNull(); + expect(channelForLedcSignal(70)).toBeNull(); + expect(channelForLedcSignal(87)).toBeNull(); expect(channelForLedcSignal(256)).toBeNull(); }); }); diff --git a/frontend/src/simulation/esp32-signals.ts b/frontend/src/simulation/esp32-signals.ts index fa9bf2ed..c5e0e348 100644 --- a/frontend/src/simulation/esp32-signals.ts +++ b/frontend/src/simulation/esp32-signals.ts @@ -9,12 +9,16 @@ */ // ── 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 -export const SIG_LEDC_HS_CH0_OUT_IDX = 72; -export const SIG_LEDC_HS_CH_LAST = 79; -export const SIG_LEDC_LS_CH0_OUT_IDX = 80; -export const SIG_LEDC_LS_CH_LAST = 87; +// Per ESP32 TRM section 4.11, Table 4-3 (GPIO Matrix output signals): +// 71-78 → LEDC_HS_SIG_OUT[0..7] (high-speed channels 0-7) +// 79-86 → LEDC_LS_SIG_OUT[0..7] (low-speed channels 0-7) +// The legacy worker code used 72-87 (off by one). That bug rerouted +// every multi-servo project's channels — see +// backend/app/services/esp32_signals.py for the full explanation. +export const SIG_LEDC_HS_CH0_OUT_IDX = 71; +export const SIG_LEDC_HS_CH_LAST = 78; +export const SIG_LEDC_LS_CH0_OUT_IDX = 79; +export const SIG_LEDC_LS_CH_LAST = 86; /** * Map a velxio-style unified LEDC channel index (0..15) to its GPIO diff --git a/test/backend/unit/test_signal_router.py b/test/backend/unit/test_signal_router.py index 6b80f614..bcbefcd0 100644 --- a/test/backend/unit/test_signal_router.py +++ b/test/backend/unit/test_signal_router.py @@ -173,10 +173,10 @@ def test_replace_snapshot_combines_changes_and_clears() -> None: @pytest.mark.parametrize( "channel,expected", [ - (0, SIG_LEDC_HS_CH0_OUT_IDX), # 72 - (7, SIG_LEDC_HS_CH0_OUT_IDX + 7), # 79 - (8, SIG_LEDC_LS_CH0_OUT_IDX), # 80 - (15, SIG_LEDC_LS_CH0_OUT_IDX + 7), # 87 + (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) ], ) def test_ledc_signal_for_channel_roundtrip(channel: int, expected: int) -> None: @@ -192,9 +192,12 @@ def test_ledc_signal_for_channel_rejects_out_of_range() -> None: def test_channel_for_ledc_signal_returns_none_for_non_ledc() -> None: + # 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. assert channel_for_ledc_signal(0) is None - assert channel_for_ledc_signal(71) is None - assert channel_for_ledc_signal(88) is None + assert channel_for_ledc_signal(70) is None + assert channel_for_ledc_signal(87) is None assert channel_for_ledc_signal(256) is None