Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/devices/air-purifier-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CommandResult>

/**
* 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<boolean> {
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
}
10 changes: 8 additions & 2 deletions src/devices/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommandResult> {
// `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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 13 additions & 1 deletion src/devices/wo-air-purifier-pm25.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<boolean> {
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)
*/
Expand Down
14 changes: 13 additions & 1 deletion src/devices/wo-air-purifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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<boolean> {
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)
*/
Expand Down
3 changes: 3 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions test/devices/airpurifier-filter-reset.test.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer | Error | undefined>, options: { encryptionKey?: string, Class?: any } = {}) {
const queue = [...replies]
const sendCommand = vi.fn(async (_mac: string, _command: Buffer, _options?: Record<string, unknown>) => {
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')
})
})
Loading