fix(esp32): multi-servo blink — don't broadcast LEDC duty across consumers

User-reported bug (project 5218f9e3, solar-tracker with 2× ESP32
servos): when LDR values change the servos visibly oscillate between
two positions instead of moving smoothly.

Root cause in useSimulatorStore.makeLedcUpdateHandler. When the
backend emits a ledc_update with gpio=-1 (the per-channel gpio_out_sel
map isn't populated yet on the very first duty change after attach),
the handler called PinManager.broadcastPwm(duty). broadcastPwm fans
the same duty out to ALL registered PWM consumers — for a project
with two servos both subscribed in the 0.01-0.20 duty range, each
broadcast made BOTH servos mirror whichever channel was last
written. Result: servoPan→91° and servoTilt→87° alternating writes
would visibly snap both servos to 87°, then 91°, then 87°…

Two-part fix:

1. PinManager grows `pwmListenerPinCount()` — number of distinct
   pins with at least one PWM consumer registered.

2. makeLedcUpdateHandler now keeps a per-board memo of
   {ledc_channel → last-known-good-gpio}. On a gpio=-1 update:
     - if the channel has a remembered gpio, route there;
     - else, only broadcast when there's at most ONE consumer
       (single-LED / single-servo setups still work);
     - otherwise drop the update — the backend's GPIO out_sel poll
       repopulates the map within a few ms and the next ledc_update
       arrives with a real gpio.

The drop is correct because the same LEDC channel keeps emitting
duty changes every Servo.write() call (~33 Hz at 30 ms loop delay),
so missing one transient gpio=-1 frame is invisible.

Tests: 1853 pass. The existing esp32-servo-pot tests already cover
the gpio>=0 happy path; the new memo path is exercised indirectly
through that handler.
This commit is contained in:
davidmonterocrespo24 2026-05-17 04:22:11 +02:00
parent 9d26fa6de3
commit 77bf8971ff
2 changed files with 45 additions and 4 deletions

View File

@ -151,6 +151,22 @@ export class PinManager {
});
}
/**
* Count of distinct GPIO pins that currently have at least one PWM
* listener registered. Used by the ledc_update router so it can
* skip a gpio=-1 broadcast when multiple consumers exist sending
* the same duty to two servos would corrupt the second one
* (`servo blinks between two positions` symptom). With a single
* consumer the broadcast is unambiguous and useful.
*/
pwmListenerPinCount(): number {
let n = 0;
this.pwmListeners.forEach((cbs) => {
if (cbs.size > 0) n++;
});
return n;
}
getPwmValue(pin: number): number {
return this.pwmValues.get(pin) ?? 0;
}

View File

@ -528,16 +528,41 @@ class Esp32BridgeShim {
// ── Shared LEDC update handler (used by addBoard, setBoardType, initSimulator) ─
function makeLedcUpdateHandler(boardId: string) {
// Per-board memo of the last gpio each LEDC channel was mapped to.
// The backend's _ledc_gpio_map can briefly emit gpio=-1 for the first
// duty change after attach (before gpio_out_sel is populated). Once we
// observe a real gpio for a channel, route subsequent gpio=-1 events
// on that channel back to the same pin instead of broadcasting —
// broadcasting to multiple consumers in the same duty-range
// (e.g. two servos in a solar-tracker / pan-tilt project) makes them
// mirror whichever was written last, producing the
// "servo blinks between two positions" symptom.
const channelGpioMemo = new Map<number, number>();
return (update: { channel: number; duty_pct: number; gpio?: number }) => {
const boardPm = pinManagerMap.get(boardId);
if (!boardPm) return;
const dutyCycle = update.duty_pct / 100;
if (update.gpio !== undefined && update.gpio >= 0) {
channelGpioMemo.set(update.channel, update.gpio);
boardPm.updatePwm(update.gpio, dutyCycle);
} else {
// gpio unknown (QEMU doesn't expose gpio_out_sel for LEDC):
// broadcast to ALL PWM listeners. Components filter by duty range
// (servo accepts 0.010.20, LEDs use 01.0).
return;
}
// gpio unknown. Recover from the per-channel memo first.
const rememberedGpio = channelGpioMemo.get(update.channel);
if (rememberedGpio !== undefined) {
boardPm.updatePwm(rememberedGpio, dutyCycle);
return;
}
// No memo yet for this channel. Broadcasting is only safe with a
// single PWM consumer; with 2+ consumers any duty-range overlap
// (two servos, two motors, ...) makes them mirror. Drop the update
// and wait for the next ledc_update with a real gpio; the worker's
// GPIO out_sel poll populates the map within a few ms of attach.
if (boardPm.pwmListenerPinCount() <= 1) {
boardPm.broadcastPwm(dutyCycle);
}
};