From 0974d50d42c55535dac843123fe42de525681209 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 09:13:30 -0600 Subject: [PATCH] fix(lock): ignore advertisements with an unknown lock status A Lock Ultra on firmware 3.1 reports lock status values 11, 13, 14 and 15, which pySwitchbot's LockStatus does not define (SwitchBotAPI-BLE #54, pySwitchbot #595). lockStateFromStatus mapped every unknown value to "locked", so such an advertisement could show an unlocked door as locked. As in pySwitchbot PR #596, an advertisement with an unknown status is now ignored entirely, so the last known state stands: parseLockAdvertisement returns null and the scanner drops the advertisement. lockStateFromStatus returns undefined for unknown values, and isKnownLockStatus is exported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- src/ble.ts | 6 ++++ src/utils/index.ts | 2 +- src/utils/lock-advertisement.ts | 46 +++++++++++++++++++++++---- test/devices/pyswitchbot-3.8.test.ts | 5 +++ test/utils/lock-advertisement.test.ts | 33 ++++++++++++++++++- 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/ble.ts b/src/ble.ts index 53fc5701..963303b0 100644 --- a/src/ble.ts +++ b/src/ble.ts @@ -333,6 +333,12 @@ export class BLEScanner extends EventEmitter { const lockFamily = LOCK_FAMILY_BY_DEVICE_TYPE[modelName] if (lockFamily) { const lock = parseLockAdvertisement(lockFamily, data, manufacturerPayload) + if (lock === null) { + // A status value pySwitchbot does not define: ignore the whole advertisement so the + // last known lock state stands (an unknown value must never read as locked) + this.logger.debug(`Ignoring ${modelName} advertisement with an unknown lock status`) + return null + } if (lock) { for (const [key, value] of Object.entries(lock)) { if (value !== undefined) { diff --git a/src/utils/index.ts b/src/utils/index.ts index 0e77c562..855cd627 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -392,7 +392,7 @@ export { FallbackHandlerManager, type FallbackHandlerOptions, } from './fallback-handler.js' -export { LOCK_FAMILY_BY_DEVICE_TYPE, type LockAdvertisement, type LockFamily, lockStateFromStatus, LockStatusCode, parseLockAdvertisement } from './lock-advertisement.js' +export { isKnownLockStatus, LOCK_FAMILY_BY_DEVICE_TYPE, type LockAdvertisement, type LockFamily, lockStateFromStatus, LockStatusCode, parseLockAdvertisement } from './lock-advertisement.js' // Export advanced utilities export { type RetryConfig, RetryExecutor, type RetryResult } from './retry.js' export { isPassiveSensorModel, parseSensorAdvertisement, type SensorAdvertisement, switchBotManufacturerPayload } from './sensor-advertisement.js' diff --git a/src/utils/lock-advertisement.ts b/src/utils/lock-advertisement.ts index 59caff29..a3e039a5 100644 --- a/src/utils/lock-advertisement.ts +++ b/src/utils/lock-advertisement.ts @@ -63,11 +63,20 @@ export interface LockAdvertisement { chargingFault?: boolean } +/** Whether a status value is one pySwitchbot's LockStatus defines (0-7) */ +export function isKnownLockStatus(status: number): boolean { + return Number.isInteger(status) && status >= LockStatusCode.Locked && status <= LockStatusCode.HalfLocked +} + /** * The lock state reported to consumers. Moving states report where the lock is heading, and a - * blocked motor reports `jammed`; latch locked and half locked are locked. + * blocked motor reports `jammed`; latch locked and half locked are locked. Unknown status values + * (a Lock Ultra reports 11, 13, 14 and 15) have no state: undefined. */ -export function lockStateFromStatus(status: number): 'locked' | 'unlocked' | 'jammed' { +export function lockStateFromStatus(status: number): 'locked' | 'unlocked' | 'jammed' | undefined { + if (!isKnownLockStatus(status)) { + return undefined + } switch (status) { case LockStatusCode.Unlocked: case LockStatusCode.Unlocking: @@ -80,8 +89,18 @@ export function lockStateFromStatus(status: number): 'locked' | 'unlocked' | 'ja } } +/** + * Thrown inside the parsers for a status value pySwitchbot does not define, and turned into a + * `null` result by parseLockAdvertisement. + */ +class UnknownLockStatus extends Error {} + function withState(status: number, fields: Omit): LockAdvertisement { - return { ...fields, status, lockState: lockStateFromStatus(status) } + const lockState = lockStateFromStatus(status) + if (!lockState) { + throw new UnknownLockStatus(String(status)) + } + return { ...fields, status, lockState } } /** process_locklite */ @@ -158,13 +177,28 @@ function parseNano(mfr: Buffer): LockAdvertisement | undefined { } /** - * Parse a lock's advertisement. Returns undefined when the manufacturer data is missing or too - * short for the family's layout. + * Parse a lock's advertisement. + * + * Returns undefined when the manufacturer data is missing or too short for the family's layout, + * and **null when the lock reports a status value pySwitchbot does not define**. Ignore such an + * advertisement entirely rather than guessing a state (pySwitchbot PR #596; SwitchBotAPI-BLE + * issue #54): an unknown value must never be reported as locked. */ -export function parseLockAdvertisement(family: LockFamily, data: Buffer | undefined, mfr: Buffer | undefined): LockAdvertisement | undefined { +export function parseLockAdvertisement(family: LockFamily, data: Buffer | undefined, mfr: Buffer | undefined): LockAdvertisement | null | undefined { if (!mfr) { return undefined } + try { + return parseFamily(family, data, mfr) + } catch (error) { + if (error instanceof UnknownLockStatus) { + return null + } + throw error + } +} + +function parseFamily(family: LockFamily, data: Buffer | undefined, mfr: Buffer): LockAdvertisement | undefined { switch (family) { case 'lite': return parseLite(data, mfr) diff --git a/test/devices/pyswitchbot-3.8.test.ts b/test/devices/pyswitchbot-3.8.test.ts index 14a43bf0..5608e82a 100644 --- a/test/devices/pyswitchbot-3.8.test.ts +++ b/test/devices/pyswitchbot-3.8.test.ts @@ -197,6 +197,11 @@ describe('product IDs and lock advertisements in the scanner', () => { expect(parse('00806400999999')).toBeNull() }) + it('drops a lock advertisement with an unknown status, so the last state stands', () => { + // Lock Ultra fixture with status 15 in byte 7 (SwitchBotAPI-BLE #54) + expect(parse('0080340010a5b8', 'b0e9feb66a3d25f830340004')).toBeNull() + }) + it('parses the lock state from the manufacturer data', () => { expect(parse('00806400122488', 'b0e9fe1122332a88086470')).toMatchObject({ modelName: 'Lock Nano', diff --git a/test/utils/lock-advertisement.test.ts b/test/utils/lock-advertisement.test.ts index 4e382740..96e1ebd3 100644 --- a/test/utils/lock-advertisement.test.ts +++ b/test/utils/lock-advertisement.test.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer' import { describe, expect, it } from 'vitest' -import { lockStateFromStatus, LockStatusCode, parseLockAdvertisement } from '../../src/utils/lock-advertisement.js' +import { isKnownLockStatus, lockStateFromStatus, LockStatusCode, parseLockAdvertisement } from '../../src/utils/lock-advertisement.js' // Fixtures and expected values are pySwitchbot's (tests/test_adv_parser.py, the lock cases). // `mfr` is the manufacturer data after the company ID; `data` is the service data. @@ -147,4 +147,35 @@ describe('lockStateFromStatus', () => { 'locked', ]) }) + + it('has no state for values pySwitchbot does not define', () => { + expect([8, 11, 13, 14, 15].map(lockStateFromStatus)).toEqual([undefined, undefined, undefined, undefined, undefined]) + expect(isKnownLockStatus(7)).toBe(true) + expect(isKnownLockStatus(11)).toBe(false) + }) +}) + +// A Lock Ultra on firmware 3.1 reports status values 11, 13, 14 and 15 (SwitchBotAPI-BLE #54, +// pySwitchbot #595). They must be ignored, never read as locked (pySwitchbot PR #596). +describe('unknown lock status values', () => { + // The Lock Ultra fixture with byte 7 (calibrated, status in bits 6-3) replaced + const ultra = (status: number) => { + const mfr = hex('b0e9feb66a3d258230340004') + mfr[7] = 0x80 | (status << 3) + return mfr + } + + it.each([11, 13, 14, 15])('ignores status %i', (status) => { + expect(parseLockAdvertisement('lock2', hex('0080340010a5b8'), ultra(status))).toBeNull() + }) + + it('still parses known values in the same layout', () => { + expect(parseLockAdvertisement('lock2', hex('0080340010a5b8'), ultra(1))).toMatchObject({ status: 1, lockState: 'unlocked' }) + }) + + it('ignores unknown values in the Lock Nano layout too', () => { + const mfr = hex('b0e9fe1122332a88086470') + mfr[7] = 0x80 | (15 << 3) + expect(parseLockAdvertisement('nano', hex('00806400122488'), mfr)).toBeNull() + }) })