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
6 changes: 6 additions & 0 deletions src/ble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
46 changes: 40 additions & 6 deletions src/utils/lock-advertisement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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, 'status' | 'lockState'>): LockAdvertisement {
return { ...fields, status, lockState: lockStateFromStatus(status) }
const lockState = lockStateFromStatus(status)
if (!lockState) {
throw new UnknownLockStatus(String(status))
}
return { ...fields, status, lockState }
}

/** process_locklite */
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions test/devices/pyswitchbot-3.8.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
33 changes: 32 additions & 1 deletion test/utils/lock-advertisement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
})
})
Loading