Skip to content

Commit a17e34d

Browse files
committed
perf(api): debounce post-SET force update to coalesce rapid changes
The post-SET force update used a leading-lockout debounce: the first change scheduled a poll, and subsequent changes within the window were dropped. A poll could therefore fire mid-sequence, and the next change would schedule a fresh poll - so a burst of interactive adjustments (power, fan speed, mode) triggered multiple full device polls and burned extra API calls. Switch to a trailing debounce that resets the timer on every change, so a burst collapses into a single poll fired after the last change. The periodic interval is paused on the first change of a burst and restarted once the debounced poll completes. Add integration tests covering both the coalescing of rapid changes and the single poll for an isolated change.
1 parent 34c80a1 commit a17e34d

2 files changed

Lines changed: 85 additions & 7 deletions

File tree

src/platform.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -312,16 +312,20 @@ export class DaikinCloudPlatform implements DynamicPlatformPlugin {
312312
}
313313

314314
forceUpdateDevices(delay: number = Math.max(0, this.config.forceUpdateDelay || DEFAULT_FORCE_UPDATE_DELAY_MS)) {
315-
// Debounce: if a force update is already pending, don't restart timers
315+
// Trailing debounce: reset the timer on every change so a burst of rapid
316+
// SETs (e.g. toggling power, fan speed and mode in quick succession)
317+
// collapses into a single poll fired `delay` ms after the *last* change,
318+
// instead of one poll per change. This avoids redundant API calls.
316319
if (this.forceUpdateTimeout) {
317-
this.log.debug('[API Syncing] Force update already pending, skipping duplicate request');
318-
return;
320+
clearTimeout(this.forceUpdateTimeout);
321+
this.log.debug(`[API Syncing] Force update rescheduled (debouncing rapid changes, delayed by ${delay}ms)`);
322+
} else {
323+
this.log.debug(`[API Syncing] Force update devices data (delayed by ${delay}ms)`);
324+
// Pause periodic polling while we wait for the change to settle; it is
325+
// restarted once the debounced update fires.
326+
clearInterval(this.updateInterval);
319327
}
320328

321-
this.log.debug(`[API Syncing] Force update devices data (delayed by ${delay}ms)`);
322-
323-
clearInterval(this.updateInterval);
324-
325329
this.forceUpdateTimeout = setTimeout(async () => {
326330
this.forceUpdateTimeout = undefined;
327331
try {

test/integration/platform.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,80 @@ test('DaikinCloudPlatform registers the accessory when its raw device ID is NOT
152152
expect(registerSpy).toHaveBeenCalled();
153153
});
154154

155+
test('forceUpdateDevices debounces rapid changes into a single poll fired after the last change', async () => {
156+
const mockDevice = {
157+
getId: () => 'MOCK_ID',
158+
getDescription: () => ({ deviceModel: 'Airco' }),
159+
getData: () => 'MOCK_DATE',
160+
desc: { managementPoints: [{ embeddedId: 'climateControl', managementPointType: 'climateControl' }] },
161+
} as unknown as DaikinCloudDevice;
162+
163+
MockDaikinCloudController.mockImplementation(function(this: any) {
164+
this.getCloudDevices = vi.fn().mockResolvedValue([mockDevice]);
165+
this.isAuthenticated = vi.fn().mockReturnValue(true);
166+
this.on = vi.fn();
167+
this.updateAllDeviceData = vi.fn().mockResolvedValue(undefined);
168+
});
169+
170+
const api = new HomebridgeAPI();
171+
const config = new MockPlatformConfig(true);
172+
(config as any).forceUpdateDelay = 10000;
173+
174+
const platform = new DaikinCloudPlatform(new Logger(), config, api);
175+
api.signalFinished();
176+
await vi.advanceTimersByTimeAsync(100);
177+
178+
const updateSpy = (platform.controller as any).updateAllDeviceData as ReturnType<typeof vi.fn>;
179+
updateSpy.mockClear();
180+
181+
// Three rapid SETs, each within the 10s debounce window of the previous one.
182+
platform.forceUpdateDevices();
183+
await vi.advanceTimersByTimeAsync(3000);
184+
platform.forceUpdateDevices();
185+
await vi.advanceTimersByTimeAsync(3000);
186+
platform.forceUpdateDevices();
187+
188+
// 9s after the last call the timer has not yet elapsed (it was reset each time).
189+
await vi.advanceTimersByTimeAsync(9000);
190+
expect(updateSpy).not.toHaveBeenCalled();
191+
192+
// The poll fires exactly once, 10s after the *last* change.
193+
await vi.advanceTimersByTimeAsync(1000);
194+
expect(updateSpy).toHaveBeenCalledTimes(1);
195+
});
196+
197+
test('forceUpdateDevices performs a single poll for an isolated change', async () => {
198+
const mockDevice = {
199+
getId: () => 'MOCK_ID',
200+
getDescription: () => ({ deviceModel: 'Airco' }),
201+
getData: () => 'MOCK_DATE',
202+
desc: { managementPoints: [{ embeddedId: 'climateControl', managementPointType: 'climateControl' }] },
203+
} as unknown as DaikinCloudDevice;
204+
205+
MockDaikinCloudController.mockImplementation(function(this: any) {
206+
this.getCloudDevices = vi.fn().mockResolvedValue([mockDevice]);
207+
this.isAuthenticated = vi.fn().mockReturnValue(true);
208+
this.on = vi.fn();
209+
this.updateAllDeviceData = vi.fn().mockResolvedValue(undefined);
210+
});
211+
212+
const api = new HomebridgeAPI();
213+
const config = new MockPlatformConfig(true);
214+
(config as any).forceUpdateDelay = 10000;
215+
216+
const platform = new DaikinCloudPlatform(new Logger(), config, api);
217+
api.signalFinished();
218+
await vi.advanceTimersByTimeAsync(100);
219+
220+
const updateSpy = (platform.controller as any).updateAllDeviceData as ReturnType<typeof vi.fn>;
221+
updateSpy.mockClear();
222+
223+
platform.forceUpdateDevices();
224+
await vi.advanceTimersByTimeAsync(10000);
225+
226+
expect(updateSpy).toHaveBeenCalledTimes(1);
227+
});
228+
155229
test('DaikinCloudPlatform with new Altherma accessory', async () => {
156230
const mockDevice = {
157231
getId: () => 'MOCK_ID',

0 commit comments

Comments
 (0)