Skip to content

Commit 146267f

Browse files
authored
Fix WoAirPurifier/WoAirPurifierTable BLE parsing: serviceData fallback, mode mapping, createDevice registration (#329)
1 parent 338ba9b commit 146267f

File tree

4 files changed

+117
-35
lines changed

4 files changed

+117
-35
lines changed

package-lock.json

Lines changed: 9 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/device.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'
22

33
import { describe, expect, it } from 'vitest'
44

5-
import { Advertising, ErrorUtils, LogLevel, ValidationUtils, WoAirPurifier, WoPlugMiniEU } from './device.js'
5+
import { Advertising, ErrorUtils, LogLevel, ValidationUtils, WoAirPurifier, WoPlugMiniEU, WoPlugMiniJP, WoPlugMiniUS } from './device.js'
66

77
describe('validationUtils', () => {
88
describe('validatePercentage', () => {
@@ -290,6 +290,66 @@ describe('advertising', () => {
290290
expect(result?.sequence_number).toBe(1)
291291
})
292292

293+
it('should parse Air Purifier service data from serviceData-only (no manufacturerData)', () => {
294+
// Air Purifier advertising with state data in serviceData (9 bytes), no manufacturerData
295+
const serviceData = Buffer.from([
296+
0x2B, // '+' model byte
297+
0x02, // sequenceNumber
298+
0x80 | 0x02, // isOn=true, mode=2
299+
0x04, // isAqiValid=true, childLock=false
300+
0x32, // speed=50
301+
0x02, // aqiLevelRaw=1 (good)
302+
0x00, 0x0A, // workTime=10
303+
0x00, // errCode=0
304+
])
305+
306+
const emitLog = () => {}
307+
const result = WoAirPurifier.parseServiceData(serviceData, null, emitLog)
308+
309+
expect(result).not.toBeNull()
310+
expect(result?.model).toBe('+')
311+
expect(result?.modelName).toBe('WoAirPurifier')
312+
expect(result?.isOn).toBe(true)
313+
expect(result?.mode).toBe('auto')
314+
expect(result?.speed).toBe(50)
315+
expect(result?.aqi_level).toBe('good')
316+
expect(result?.isAqiValid).toBe(true)
317+
expect(result?.child_lock).toBe(false)
318+
expect(result?.filter_element_working_time).toBe(10)
319+
expect(result?.err_code).toBe(0)
320+
expect(result?.sequence_number).toBe(2)
321+
})
322+
323+
it('should map Air Purifier mode=2 to auto, mode=3 to sleep, mode=4 to manual', () => {
324+
const makeManufData = (modeByte: number) => Buffer.from([
325+
0x59, 0x00, 0x12, 0x34, 0x56, 0x78,
326+
0x01, // sequenceNumber
327+
modeByte,
328+
0x00, // isAqiValid=false, childLock=false
329+
0x50, // speed=80
330+
0x00, // aqiLevelRaw=0 (excellent)
331+
0x00, 0x00, // workTime=0
332+
0x00, // errCode=0
333+
])
334+
335+
const emitLog = () => {}
336+
337+
const resultAuto = WoAirPurifier.parseServiceData(Buffer.from('+'), makeManufData(0x80 | 0x02), emitLog)
338+
expect(resultAuto?.mode).toBe('auto')
339+
340+
const resultSleep = WoAirPurifier.parseServiceData(Buffer.from('+'), makeManufData(0x80 | 0x03), emitLog)
341+
expect(resultSleep?.mode).toBe('sleep')
342+
343+
const resultManual = WoAirPurifier.parseServiceData(Buffer.from('+'), makeManufData(0x80 | 0x04), emitLog)
344+
expect(resultManual?.mode).toBe('manual')
345+
})
346+
347+
it('should return null for Air Purifier when both buffers are insufficient', () => {
348+
const emitLog = () => {}
349+
const result = WoAirPurifier.parseServiceData(Buffer.from('+'), null, emitLog)
350+
expect(result).toBeNull()
351+
})
352+
293353
it('should parse Plug Mini EU service data correctly', async () => {
294354
// bytes 0-8: header/MAC/sequence (unused by parser), bytes 9-13: state/flags/rssi/power
295355
const manufacturerData = Buffer.from([
@@ -382,5 +442,24 @@ describe('advertising', () => {
382442
expect(result?.serviceData.model).toBe('l')
383443
expect(result?.serviceData.modelName).toBe('WoPlugMini')
384444
})
445+
446+
const plugMiniClasses = [
447+
{ name: 'WoPlugMiniJP', cls: WoPlugMiniJP },
448+
{ name: 'WoPlugMiniEU', cls: WoPlugMiniEU },
449+
{ name: 'WoPlugMiniUS', cls: WoPlugMiniUS },
450+
] as const
451+
452+
for (const { name, cls } of plugMiniClasses) {
453+
it(`should return null when ${name} manufacturerData is undefined`, async () => {
454+
const errors: string[] = []
455+
const emitLog = (_level: string, msg: string) => { errors.push(msg) }
456+
457+
const result = await cls.parseServiceData(undefined, emitLog)
458+
459+
expect(result).toBeNull()
460+
expect(errors.length).toBeGreaterThan(0)
461+
expect(errors[0]).toContain('should be 14')
462+
})
463+
}
385464
})
386465
})

src/device.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2532,7 +2532,7 @@ export class WoPlugMiniJP extends SwitchbotDevice {
25322532

25332533
/**
25342534
* Parses the service data for WoPlugMini JP.
2535-
* @param {Buffer} manufacturerData - The manufacturer data buffer.
2535+
* @param {Buffer | undefined} manufacturerData - The manufacturer data buffer, or undefined if not present.
25362536
* @param {Function} emitLog - The function to emit log messages.
25372537
* @returns {Promise<plugMiniJPServiceData | null>} - Parsed service data or null if invalid.
25382538
*/
@@ -2654,7 +2654,7 @@ export class WoPlugMiniEU extends SwitchbotDevice {
26542654

26552655
/**
26562656
* Parses the service data for WoPlugMini EU.
2657-
* @param {Buffer} manufacturerData - The manufacturer data buffer.
2657+
* @param {Buffer | undefined} manufacturerData - The manufacturer data buffer, or undefined if not present.
26582658
* @param {Function} emitLog - The function to emit log messages.
26592659
* @returns {Promise<plugMiniEUServiceData | null>} - Parsed service data or null if invalid.
26602660
*/
@@ -2775,7 +2775,7 @@ export class WoPlugMiniUS extends SwitchbotDevice {
27752775

27762776
/**
27772777
* Parses the service data for WoPlugMini US.
2778-
* @param {Buffer} manufacturerData - The manufacturer data buffer.
2778+
* @param {Buffer | undefined} manufacturerData - The manufacturer data buffer, or undefined if not present.
27792779
* @param {Function} emitLog - The function to emit log messages.
27802780
* @returns {Promise<plugMiniUSServiceData | null>} - Parsed service data or null if invalid.
27812781
*/
@@ -3969,13 +3969,17 @@ export class WoAirPurifier extends SwitchbotDevice {
39693969
* @returns {airPurifierServiceData | null} - The parsed service data or null.
39703970
*/
39713971
static parseServiceData(serviceData: Buffer | null, manufacturerData: Buffer | null, emitLog?: (level: string, message: string) => void): airPurifierServiceData | null {
3972-
if (!manufacturerData || manufacturerData.length < 14) {
3973-
return null
3974-
}
3972+
let deviceData: Buffer | null = null
39753973

3976-
const deviceData = manufacturerData.subarray(6)
3974+
if (manufacturerData && manufacturerData.length >= 14) {
3975+
// Primary path: device state is in manufacturerData starting at byte 6
3976+
deviceData = manufacturerData.subarray(6)
3977+
} else if (serviceData && serviceData.length >= 9) {
3978+
// Fallback path: device state is in serviceData starting at byte 1 (after model byte)
3979+
deviceData = serviceData.subarray(1)
3980+
}
39773981

3978-
if (deviceData.length < 8) {
3982+
if (!deviceData || deviceData.length < 8) {
39793983
return null
39803984
}
39813985

@@ -4009,8 +4013,8 @@ export class WoAirPurifier extends SwitchbotDevice {
40094013
modeString = AIR_PURIFIER_MODES.LEVEL_3
40104014
}
40114015
} else if (mode > 1 && mode <= 4) {
4012-
const modeMap = [null, null, 'auto', 'sleep', 'manual']
4013-
modeString = modeMap[mode + 2] || null
4016+
const modeMap = [null, null, AIR_PURIFIER_MODES.AUTO, AIR_PURIFIER_MODES.SLEEP, AIR_PURIFIER_MODES.MANUAL]
4017+
modeString = modeMap[mode] || null
40144018
}
40154019

40164020
if (emitLog) {
@@ -4119,13 +4123,17 @@ export class WoAirPurifierTable extends SwitchbotDevice {
41194123
* @returns {airPurifierTableServiceData | null} - The parsed service data or null.
41204124
*/
41214125
static parseServiceData(serviceData: Buffer | null, manufacturerData: Buffer | null, emitLog?: (level: string, message: string) => void): airPurifierTableServiceData | null {
4122-
if (!manufacturerData || manufacturerData.length < 14) {
4123-
return null
4124-
}
4126+
let deviceData: Buffer | null = null
41254127

4126-
const deviceData = manufacturerData.subarray(6)
4128+
if (manufacturerData && manufacturerData.length >= 14) {
4129+
// Primary path: device state is in manufacturerData starting at byte 6
4130+
deviceData = manufacturerData.subarray(6)
4131+
} else if (serviceData && serviceData.length >= 9) {
4132+
// Fallback path: device state is in serviceData starting at byte 1 (after model byte)
4133+
deviceData = serviceData.subarray(1)
4134+
}
41274135

4128-
if (deviceData.length < 8) {
4136+
if (!deviceData || deviceData.length < 8) {
41294137
return null
41304138
}
41314139

@@ -4159,8 +4167,8 @@ export class WoAirPurifierTable extends SwitchbotDevice {
41594167
modeString = AIR_PURIFIER_MODES.LEVEL_3
41604168
}
41614169
} else if (mode > 1 && mode <= 4) {
4162-
const modeMap = [null, null, 'auto', 'sleep', 'manual']
4163-
modeString = modeMap[mode + 2] || null
4170+
const modeMap = [null, null, AIR_PURIFIER_MODES.AUTO, AIR_PURIFIER_MODES.SLEEP, AIR_PURIFIER_MODES.MANUAL]
4171+
modeString = modeMap[mode] || null
41644172
}
41654173

41664174
if (emitLog) {

src/switchbot-ble.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ad, NobleTypes, onadvertisement, ondiscover, Params, Rule } from '
66

77
import { EventEmitter } from 'node:events'
88

9-
import { Advertising, LogLevel, SwitchBotBLEModel, SwitchbotDevice, WoBlindTilt, WoBulb, WoCeilingLight, WoContact, WoCurtain, WoHand, WoHub2, WoHumi, WoHumi2, WoIOSensorTH, WoKeypad, WoLeak, WoPlugMiniEU, WoPlugMiniJP, WoPlugMiniUS, WoPresence, WoRelaySwitch1, WoRelaySwitch1PM, WoRemote, WoSensorTH, WoSensorTHPlus, WoSensorTHPro, WoSensorTHProCO2, WoSmartLock, WoSmartLockPro, WoSmartLockUltra, WoStrip } from './device.js'
9+
import { Advertising, LogLevel, SwitchBotBLEModel, SwitchbotDevice, WoAirPurifier, WoAirPurifierTable, WoBlindTilt, WoBulb, WoCeilingLight, WoContact, WoCurtain, WoHand, WoHub2, WoHumi, WoHumi2, WoIOSensorTH, WoKeypad, WoLeak, WoPlugMiniEU, WoPlugMiniJP, WoPlugMiniUS, WoPresence, WoRelaySwitch1, WoRelaySwitch1PM, WoRemote, WoSensorTH, WoSensorTHPlus, WoSensorTHPro, WoSensorTHProCO2, WoSmartLock, WoSmartLockPro, WoSmartLockUltra, WoStrip } from './device.js'
1010
import { parameterChecker } from './parameter-checker.js'
1111
import { DEFAULT_DISCOVERY_DURATION, PRIMARY_SERVICE_UUID_LIST } from './settings.js'
1212

@@ -238,6 +238,8 @@ export class SwitchBotBLE extends EventEmitter {
238238
case SwitchBotBLEModel.Keypad: return new WoKeypad(peripheral, this.noble)
239239
case SwitchBotBLEModel.RelaySwitch1: return new WoRelaySwitch1(peripheral, this.noble)
240240
case SwitchBotBLEModel.RelaySwitch1PM: return new WoRelaySwitch1PM(peripheral, this.noble)
241+
case SwitchBotBLEModel.AirPurifier: return new WoAirPurifier(peripheral, this.noble)
242+
case SwitchBotBLEModel.AirPurifierTable: return new WoAirPurifierTable(peripheral, this.noble)
241243
default: return new SwitchbotDevice(peripheral, this.noble)
242244
}
243245
}

0 commit comments

Comments
 (0)