From 027dc4ed58923d82b46a241aadb5ffcce387c87c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:54:40 -0500 Subject: [PATCH] feat(air-purifier): add verified filter usage reset (pySwitchbot 3.1.0) Port sblibs/pySwitchbot#583, released in pySwitchbot 3.1.0. `resetFilterUsage()` on WoAirPurifier (and Air Purifier Table) and WoAirPurifierPM25 sends 57 0F 4C 09 once, never retried, and requires status 0x01. It then reads the usage with 57 0F 4D 09 and returns true only when the device reports 0 used hours. Otherwise it throws CommandFailedError: reset rejected, read rejected, could not be verified (no or short reply), or still N used hours. BLE only: the OpenAPI has no filter reset command. node-switchbot does not decrypt BLE replies yet, so on an encrypted connection the readback cannot be checked, and the reset is refused before anything is sent rather than reported as done. sendBLECommand gains options `retry` and `validateResponse` so a command can be sent once and its reply read raw. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- src/devices/air-purifier-api.ts | 47 +++++++++++ src/devices/base.ts | 10 ++- src/devices/wo-air-purifier-pm25.ts | 14 +++- src/devices/wo-air-purifier.ts | 14 +++- src/settings.ts | 3 + test/devices/airpurifier-filter-reset.test.ts | 83 +++++++++++++++++++ 6 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 test/devices/airpurifier-filter-reset.test.ts diff --git a/src/devices/air-purifier-api.ts b/src/devices/air-purifier-api.ts index f4025032..2e9028f1 100644 --- a/src/devices/air-purifier-api.ts +++ b/src/devices/air-purifier-api.ts @@ -3,6 +3,13 @@ * devices/air-purifier-api.ts: OpenAPI parameters for air purifiers */ +import type { CommandResult } from '../types/index.js' + +import { Buffer } from 'node:buffer' + +import { CommandFailedError } from '../errors.js' +import { DEVICE_COMMANDS } from '../settings.js' + /** * OpenAPI `setMode` parameter for air purifiers: `{"mode": mode_int, "fanGear": fan_level_int}`, * where mode is 1 (normal/fan), 2 (auto), 3 (sleep) or 4 (pet), and fanGear (1-3) applies only @@ -34,3 +41,43 @@ export function airPurifierPresetParameter(preset: 'level_1' | 'level_2' | 'leve pet: { mode: 4 }, }[preset] as AirPurifierAPIMode } + +type SendOnce = (command: readonly number[], options: { retry?: boolean, validateResponse?: boolean }) => Promise + +/** + * Reset an air purifier's filter usage over BLE and confirm it, as pySwitchbot 3.1.0 + * `reset_filter_usage` does: send the reset once (never retried), require status 0x01, then read + * the usage back and succeed only if it is 0 hours. Throws CommandFailedError otherwise. + * + * OpenAPI has no filter reset command. node-switchbot cannot decrypt replies yet, so on an + * encrypted connection the readback cannot be checked; the reset is refused before anything is + * sent rather than reported as done. + */ +export async function resetAirPurifierFilterUsage(device: { encrypted: boolean, hasBLE: boolean, send: SendOnce }): Promise { + if (!device.hasBLE) { + throw new CommandFailedError('Filter usage reset needs a BLE connection (OpenAPI has no reset command)', 'ble') + } + if (device.encrypted) { + throw new CommandFailedError('Filter usage reset cannot be verified on an encrypted connection: node-switchbot does not decrypt replies yet', 'ble') + } + + const reset = await device.send(DEVICE_COMMANDS.AIR_PURIFIER.RESET_FILTER_USAGE, { retry: false, validateResponse: false }) + const resetReply = Buffer.isBuffer(reset.data) ? reset.data : undefined + if (!reset.success || resetReply?.[0] !== 0x01) { + throw new CommandFailedError(`Filter usage reset was rejected${reset.error ? `: ${reset.error}` : ''}`, 'ble') + } + + const read = await device.send(DEVICE_COMMANDS.AIR_PURIFIER.READ_FILTER_USAGE, { validateResponse: false }) + const reply = Buffer.isBuffer(read.data) ? read.data : undefined + if (!read.success || !reply || reply.length < 3) { + throw new CommandFailedError('Reset was sent, but filter usage could not be verified. Check the device before retrying', 'ble') + } + if (reply[0] !== 0x01) { + throw new CommandFailedError('Filter usage read was rejected', 'ble') + } + const usedHours = reply.readUInt16BE(1) + if (usedHours !== 0) { + throw new CommandFailedError(`Reset was sent, but the device still reports ${usedHours} used hours. Check the device before retrying`, 'ble') + } + return true +} diff --git a/src/devices/base.ts b/src/devices/base.ts index 7dae3fa3..3395685b 100644 --- a/src/devices/base.ts +++ b/src/devices/base.ts @@ -384,7 +384,13 @@ export abstract class SwitchBotDevice extends EventEmitter { protected async sendBLECommand( command: readonly number[] | number[] | Buffer, writeOnly = false, + options: { retry?: boolean, validateResponse?: boolean } = {}, ): Promise { + // `retry: false` sends once, for commands that must not be repeated (pySwitchbot `retry=0`). + // `validateResponse: false` returns the reply without checking its status byte, for callers + // that interpret it themselves. + const retry = options.retry ?? this.enableRetry + const validateResponse = options.validateResponse ?? true if (!this.hasBLE()) { return { success: false, @@ -425,7 +431,7 @@ export abstract class SwitchBotDevice extends EventEmitter { } else if (this.bleConnection?.sendCommand) { response = await this.bleConnection.sendCommand(mac, buffer, { expectResponse: true, - validateResponse: true, + validateResponse, responseTimeoutMs: 1200, }) } else { @@ -454,7 +460,7 @@ export abstract class SwitchBotDevice extends EventEmitter { try { return await this.runWithBLELock(async () => { - if (this.enableRetry) { + if (retry) { this.circuitBreakerBLE.markHalfOpenAttempt() return await this.retryExecutor.executeOrThrow(executeCommand, `BLE command for ${this.info.id}`) } else { diff --git a/src/devices/wo-air-purifier-pm25.ts b/src/devices/wo-air-purifier-pm25.ts index 300058ac..ec46403f 100644 --- a/src/devices/wo-air-purifier-pm25.ts +++ b/src/devices/wo-air-purifier-pm25.ts @@ -2,7 +2,7 @@ import type { AirPurifierStatus } from '../types/device.js' import { Buffer } from 'node:buffer' -import { airPurifierFanParameter, airPurifierModeParameter, airPurifierPresetParameter } from './air-purifier-api.js' +import { airPurifierFanParameter, airPurifierModeParameter, airPurifierPresetParameter, resetAirPurifierFilterUsage } from './air-purifier-api.js' import { SwitchBotDevice } from './base.js' export class WoAirPurifierPM25 extends SwitchBotDevice { @@ -121,6 +121,18 @@ export class WoAirPurifierPM25 extends SwitchBotDevice { throw new Error('No connection method available') } + /** + * Reset filter usage and confirm the device reads 0 hours afterwards (BLE only). + * Throws if the reset is rejected or cannot be verified. See resetAirPurifierFilterUsage. + */ + async resetFilterUsage(): Promise { + return resetAirPurifierFilterUsage({ + encrypted: !!this.info.encryptionKey, + hasBLE: this.hasBLE(), + send: (command, options) => this.sendBLECommand(command, false, options), + }) + } + /** * Set preset mode (level_1, level_2, level_3, auto, sleep, pet) */ diff --git a/src/devices/wo-air-purifier.ts b/src/devices/wo-air-purifier.ts index b0028c14..85179885 100644 --- a/src/devices/wo-air-purifier.ts +++ b/src/devices/wo-air-purifier.ts @@ -5,7 +5,7 @@ import { Buffer } from 'node:buffer' import { DEVICE_COMMANDS } from '../settings.js' import { clamp, validateResponseLength } from '../utils/index.js' import { encryptRelayCommandIfNeeded } from '../utils/relay-encryption.js' -import { airPurifierFanParameter, airPurifierModeParameter, airPurifierPresetParameter } from './air-purifier-api.js' +import { airPurifierFanParameter, airPurifierModeParameter, airPurifierPresetParameter, resetAirPurifierFilterUsage } from './air-purifier-api.js' import { SequenceDevice } from './sequence-device.js' /** @@ -143,6 +143,18 @@ export class WoAirPurifier extends SequenceDevice implements AirPurifierCommands ) } + /** + * Reset filter usage and confirm the device reads 0 hours afterwards (BLE only). + * Throws if the reset is rejected or cannot be verified. See resetAirPurifierFilterUsage. + */ + async resetFilterUsage(): Promise { + return resetAirPurifierFilterUsage({ + encrypted: !!this.info.encryptionKey, + hasBLE: this.hasBLE(), + send: (command, options) => this.sendBLECommand(command, false, options), + }) + } + /** * Set preset mode (level_1, level_2, level_3, auto, sleep, pet) */ diff --git a/src/settings.ts b/src/settings.ts index 543bf419..dd37e004 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -123,6 +123,9 @@ export const DEVICE_COMMANDS = { TURN_OFF: [0x57, 0x01, 0x02] as const, SET_MODE: [0x57, 0x02] as const, // Add mode byte SET_SPEED: [0x57, 0x03] as const, // Add speed byte + // pySwitchbot 3.1.0 (air_purifier.py): reset filter usage, then read it back + RESET_FILTER_USAGE: [0x57, 0x0F, 0x4C, 0x09] as const, + READ_FILTER_USAGE: [0x57, 0x0F, 0x4D, 0x09] as const, }, // Vacuum diff --git a/test/devices/airpurifier-filter-reset.test.ts b/test/devices/airpurifier-filter-reset.test.ts new file mode 100644 index 00000000..df5f23ae --- /dev/null +++ b/test/devices/airpurifier-filter-reset.test.ts @@ -0,0 +1,83 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * airpurifier-filter-reset.test.ts: verified filter usage reset (pySwitchbot 3.1.0, sblibs/pySwitchbot#583) + */ + +import { Buffer } from 'node:buffer' + +import { describe, expect, it, vi } from 'vitest' + +import { WoAirPurifierPM25 } from '../../src/devices/wo-air-purifier-pm25.js' +import { WoAirPurifier } from '../../src/devices/wo-air-purifier.js' +import { DEVICE_COMMANDS } from '../../src/settings.js' + +function purifier(replies: Array, options: { encryptionKey?: string, Class?: any } = {}) { + const queue = [...replies] + const sendCommand = vi.fn(async (_mac: string, _command: Buffer, _options?: Record) => { + const next = queue.shift() + if (next instanceof Error) { + throw next + } + return next + }) + const Class = options.Class ?? WoAirPurifier + const device = new Class( + { id: 'AP', name: 'Purifier', deviceType: 'Air Purifier', mac: 'aa:bb:cc:dd:ee:ff', connectionTypes: ['ble'], encryptionKey: options.encryptionKey }, + { bleConnection: { sendCommand, read: vi.fn(), write: vi.fn() } as any, enableRetry: true }, + ) + return { device, sendCommand } +} + +const bytes = (...b: number[]) => Buffer.from(b) + +describe('air purifier resetFilterUsage', () => { + it.each([WoAirPurifier, WoAirPurifierPM25])('%o sends the reset once, then confirms 0 hours', async (Class) => { + const { device, sendCommand } = purifier([bytes(0x01), bytes(0x01, 0x00, 0x00)], { Class }) + await expect(device.resetFilterUsage()).resolves.toBe(true) + expect(sendCommand.mock.calls.map(([, command]) => [...(command as Buffer)])).toEqual([ + [...DEVICE_COMMANDS.AIR_PURIFIER.RESET_FILTER_USAGE], + [...DEVICE_COMMANDS.AIR_PURIFIER.READ_FILTER_USAGE], + ]) + expect(sendCommand.mock.calls[0][2]).toMatchObject({ validateResponse: false }) + }) + + // The same failure cases as pySwitchbot's test_reset_filter_usage_failure + it.each([ + ['reset rejected', [bytes(0x02)], 'reset was rejected', 1], + ['empty reset reply', [bytes()], 'reset was rejected', 1], + ['no read reply', [bytes(0x01), undefined], 'could not be verified', 2], + ['empty read reply', [bytes(0x01), bytes()], 'could not be verified', 2], + ['short read reply', [bytes(0x01), bytes(0x01)], 'could not be verified', 2], + ['partial counter', [bytes(0x01), bytes(0x01, 0x00)], 'could not be verified', 2], + ['read rejected', [bytes(0x01), bytes(0x02, 0x00, 0x00)], 'read was rejected', 2], + ['counter not reset', [bytes(0x01), bytes(0x01, 0x07, 0x68)], '1896 used hours', 2], + ] as const)('fails when %s', async (_name, replies, message, calls) => { + const { device, sendCommand } = purifier([...replies]) + await expect(device.resetFilterUsage()).rejects.toThrow(message) + expect(sendCommand).toHaveBeenCalledTimes(calls) + }) + + // pySwitchbot sends the reset with retry=0: a reset that may have landed must not be repeated. + it('does not retry the reset when the connection fails', async () => { + const { device, sendCommand } = purifier([new Error('Connection failed'), bytes(0x01), bytes(0x01, 0x00, 0x00)]) + await expect(device.resetFilterUsage()).rejects.toThrow('reset was rejected: Connection failed') + expect(sendCommand).toHaveBeenCalledOnce() + }) + + it('retries the read, which is safe to repeat', async () => { + const { device, sendCommand } = purifier([bytes(0x01), new Error('timeout'), bytes(0x01, 0x00, 0x00)]) + await expect(device.resetFilterUsage()).resolves.toBe(true) + expect(sendCommand).toHaveBeenCalledTimes(3) + }) + + it('refuses on an encrypted connection without sending anything', async () => { + const { device, sendCommand } = purifier([], { encryptionKey: '00112233445566778899aabbccddeeff' }) + await expect(device.resetFilterUsage()).rejects.toThrow('encrypted connection') + expect(sendCommand).not.toHaveBeenCalled() + }) + + it('needs BLE', async () => { + const device = new WoAirPurifier({ id: 'AP', name: 'Purifier', deviceType: 'Air Purifier', connectionTypes: ['api'] }) + await expect(device.resetFilterUsage()).rejects.toThrow('needs a BLE connection') + }) +})