From 77bf8971ff447ee3013c935c53de1388a84c4866 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Sun, 17 May 2026 04:22:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(esp32):=20multi-servo=20blink=20=E2=80=94?= =?UTF-8?q?=20don't=20broadcast=20LEDC=20duty=20across=20consumers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/simulation/PinManager.ts | 16 ++++++++++++ frontend/src/store/useSimulatorStore.ts | 33 ++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/frontend/src/simulation/PinManager.ts b/frontend/src/simulation/PinManager.ts index c16b8448..19fa2596 100644 --- a/frontend/src/simulation/PinManager.ts +++ b/frontend/src/simulation/PinManager.ts @@ -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; } diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index e8a7ceba..966eac5d 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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(); + 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.01–0.20, LEDs use 0–1.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); } };