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.
This commit is contained in:
davidmonterocrespo24 2026-05-17 05:42:52 +02:00
parent f6131d432e
commit adad446518
4 changed files with 44 additions and 24 deletions

View File

@ -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:

View File

@ -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();
});
});

View File

@ -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

View File

@ -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