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') + }) +})