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
31 changes: 22 additions & 9 deletions src/ble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { EventEmitter } from 'node:events'

import { BLENotAvailableError, CommandFailedError, DeviceNotFoundError } from './errors.js'
import { BLE_COMMAND_TIMEOUT, BLE_CONNECT_TIMEOUT, BLE_NOTIFY_CHARACTERISTIC_UUID, BLE_SCAN_TIMEOUT, BLE_SERVICE_UUID, BLE_WRITE_CHARACTERISTIC_UUID, DEVICE_MODEL_MAP } from './settings.js'
import { extractMacFromManufacturerData, isValidMAC, Logger, macToDeviceId, mergeAdvertisement, normalizeMAC, withTimeout } from './utils/index.js'
import { extractMacFromManufacturerData, isPassiveSensorModel, isValidMAC, Logger, macToDeviceId, mergeAdvertisement, normalizeMAC, parseSensorAdvertisement, switchBotManufacturerPayload, withTimeout } from './utils/index.js'
// Move RegExp to module scope to avoid re-compilation
const CHARACTERISTIC_REGEX = /characteristic/i
const UUID_DASH_REGEX = /-/g
Expand Down Expand Up @@ -177,11 +177,6 @@ export class BLEScanner extends EventEmitter {

const { advertisement, rssi, connectable } = peripheral

// Skip non-connectable devices
if (connectable === false) {
return
}

// Skip devices with invalid RSSI (typical range: -120 to 0 dBm)
if (typeof rssi !== 'number' || rssi < -120 || rssi > 0) {
return
Expand Down Expand Up @@ -213,12 +208,21 @@ export class BLEScanner extends EventEmitter {
continue
}

const serviceData = this.parseServiceData(serviceDataItem.data)
const serviceData = this.parseServiceData(
serviceDataItem.data,
switchBotManufacturerPayload(peripheral.advertisement.manufacturerData),
)

if (!serviceData) {
continue
}

// Sensors may advertise as non-connectable; they are read from the advertisement only.
// Skip other non-connectable devices, which cannot be controlled.
if (connectable === false && !isPassiveSensorModel(serviceData.model)) {
continue
}

const normalizedAddress = resolvePeripheralMac(peripheral)

const fallbackId = typeof peripheral.id === 'string' && peripheral.id.length > 0
Expand Down Expand Up @@ -272,7 +276,7 @@ export class BLEScanner extends EventEmitter {
/**
* Parse service data from BLE advertisement
*/
private parseServiceData(data: Buffer): BLEServiceData | null {
private parseServiceData(data: Buffer, manufacturerPayload?: Buffer): BLEServiceData | null {
if (!data || data.length < 1) {
return null
}
Expand Down Expand Up @@ -332,6 +336,16 @@ export class BLEScanner extends EventEmitter {
}
}

// Meters, contact, motion and leak sensors: readings from the advertisement (pySwitchbot)
const sensor = parseSensorAdvertisement(model, data, manufacturerPayload)
if (sensor) {
for (const [key, value] of Object.entries(sensor)) {
if (value !== undefined) {
serviceData[key] = value
}
}
}

if ([';', '<', '\x1B', '\x1C', '\x0E'].includes(model) && data.length > 1) {
// Relay Switch 1PM / Relay Switch 1
serviceData.state = (data[1] & 0x01) === 0x01
Expand Down Expand Up @@ -1234,7 +1248,6 @@ export class BLEConnection {
}
}


/**
* Write data to device
*/
Expand Down
22 changes: 10 additions & 12 deletions src/devices/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,30 +364,28 @@ export abstract class SwitchBotDevice extends EventEmitter {
try {
return await tryBLE()
} catch (err) {
if (this.enableFallback && this.hasAPI()) {
this.logger?.debug?.('BLE getStatus failed, falling back to API', err)
} else {
if (!this.enableFallback || !this.hasAPI()) {
// Nothing to fall back to: report this failure rather than trying BLE again below
this.logger?.debug?.('BLE getStatus failed', err)
throw err
}
this.logger?.debug?.('BLE getStatus failed, falling back to API', err)
}
if (this.enableFallback && this.hasAPI()) {
return await tryAPI()
}
return await tryAPI()
}
// API-first
if (!preferBLE && this.hasAPI()) {
try {
return await tryAPI()
} catch (err) {
if (this.enableFallback && this.hasBLE()) {
this.logger?.debug?.('API getStatus failed, falling back to BLE', err)
} else {
if (!this.enableFallback || !this.hasBLE()) {
// Nothing to fall back to: report this failure rather than trying the API again below
this.logger?.debug?.('API getStatus failed', err)
throw err
}
this.logger?.debug?.('API getStatus failed, falling back to BLE', err)
}
if (this.enableFallback && this.hasBLE()) {
return await tryBLE()
}
return await tryBLE()
}
// Neither preference branch applied, which happens when the preferred
// transport is unavailable: a BLE-preferring device with no BLE, or an
Expand Down
4 changes: 2 additions & 2 deletions src/devices/wo-contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ export class WoContact extends SwitchBotDevice {
bleData => ({
deviceId: this.info.id,
connectionType: 'ble',
openState: bleData.position || 'closed',
openState: bleData.openState ?? 'closed',
moveDetected: bleData.movement,
brightness: bleData.lightLevel,
brightness: bleData.brightness,
battery: bleData.battery,
updatedAt: new Date(),
}),
Expand Down
2 changes: 1 addition & 1 deletion src/devices/wo-presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class WoPresence extends SwitchBotDevice {
deviceId: this.info.id,
connectionType: 'ble',
moveDetected: bleData.movement || false,
brightness: bleData.lightLevel,
brightness: bleData.brightness,
battery: bleData.battery,
updatedAt: new Date(),
}),
Expand Down
1 change: 1 addition & 0 deletions src/devices/wo-sensor-th-pro-co2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class WoSensorTHProCO2 extends WoSensorTH {
humidity: bleData.humidity || 0,
temperatureScale: bleData.fahrenheit ? 'f' : 'c',
battery: bleData.battery,
...(typeof bleData.co2 === 'number' ? { co2: bleData.co2 } : {}),
updatedAt: new Date(),
}),
(apiStatus) => {
Expand Down
35 changes: 35 additions & 0 deletions src/switchbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { SwitchBotDevice } from './devices/base.js'
import type { BLEAdvertisement } from './types/ble.js'
import type { ConnectionType, DiscoveryOptions, LogLevel, SwitchBotConfig } from './types/index.js'

import { EventEmitter } from 'node:events'
Expand Down Expand Up @@ -163,6 +164,11 @@ export class SwitchBot extends EventEmitter {
private initialized = false
// Track pending device creations for async API discovery
private _pendingDeviceCreations: Promise<void>[] = []
// Keeps known devices' advertisement data current whenever any scan is running, not only during
// discover(), so sensors read from advertisements stay fresh
private readonly advertisementListener = (advertisement: BLEAdvertisement): void => {
this.applyAdvertisement(advertisement)
}

constructor(config: SwitchBotConfig = {}) {
super()
Expand Down Expand Up @@ -220,6 +226,8 @@ export class SwitchBot extends EventEmitter {
logLevel: this.config.logLevel,
})

this.bleScanner.on('discover', this.advertisementListener)

this.logger.info('BLE initialized')
} catch (error) {
this.logger.error('Failed to initialize BLE', error)
Expand Down Expand Up @@ -652,13 +660,40 @@ export class SwitchBot extends EventEmitter {
return !!this.apiClient
}

/** Find the known device an advertisement belongs to */
private findAdvertisedDevice(advertisement: BLEAdvertisement): SwitchBotDevice | undefined {
if (advertisement.address) {
const byMac = this.deviceManager.get(macToDeviceId(advertisement.address))
if (byMac) {
return byMac
}
}
return this.deviceManager.list().find(device => device.getInfo().bleId === advertisement.id)
}

/** Store an advertisement's data on the known device it belongs to */
private applyAdvertisement(advertisement: BLEAdvertisement): void {
const device = this.findAdvertisedDevice(advertisement)
if (!device) {
return
}
const { serviceData, rssi } = advertisement
device.updateInfo({
bleServiceData: serviceData as Record<string, unknown>,
bleServiceDataAt: Date.now(),
rssi,
...(typeof serviceData.battery === 'number' ? { battery: serviceData.battery } : {}),
})
}

/**
* Cleanup and disconnect
*/
async cleanup(): Promise<void> {
this.logger.info('Cleaning up')

if (this.bleScanner) {
this.bleScanner.off('discover', this.advertisementListener)
this.bleScanner.destroy()
}

Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,4 @@ export {
} from './fallback-handler.js'
// Export advanced utilities
export { type RetryConfig, RetryExecutor, type RetryResult } from './retry.js'
export { isPassiveSensorModel, parseSensorAdvertisement, type SensorAdvertisement, switchBotManufacturerPayload } from './sensor-advertisement.js'
176 changes: 176 additions & 0 deletions src/utils/sensor-advertisement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved.
*
* utils/sensor-advertisement.ts: Passive sensor readings from BLE advertisements
*
* Meters, contact, motion and leak sensors report their state in their advertisements, and
* pySwitchbot reads them that way without ever connecting (switchbot/adv_parsers/meter.py,
* contact.py, motion.py, leak.py). Many of these sensors refuse GATT connections.
*
* `mfrData` is the SwitchBot manufacturer data payload after the 2-byte company ID (0x0969), as
* pySwitchbot's `mfr_data`.
*/

import { Buffer } from 'node:buffer'

/** Readings parsed from a passive sensor advertisement */
export interface SensorAdvertisement {
/** Set when the advertisement carried a sensor reading */
sensor: true
battery?: number
temperature?: number
humidity?: number
fahrenheit?: boolean
co2?: number
openState?: 'open' | 'closed' | 'timeout'
movement?: boolean
brightness?: 'bright' | 'dim'
waterLeakDetected?: boolean
tampered?: boolean
}

const METER_MODELS = new Set(['T', 't', 'i', 'I', '4', '\x14', '5', '\x15', 'w', 'W'])
const CO2_MODELS = new Set(['5', '\x15'])
const CONTACT_MODELS = new Set(['d', 'D'])
const MOTION_MODELS = new Set(['s', 'S'])
const LEAK_MODELS = new Set(['&', '\x06'])

// Meter Pro CO2 spec range is 400-9999 ppm; higher values are parsing artifacts (pySwitchbot)
const CO2_MAX_PPM = 9999

/** Advertisement model codes whose state is read passively, without connecting */
export function isPassiveSensorModel(model: string): boolean {
return METER_MODELS.has(model) || CONTACT_MODELS.has(model) || MOTION_MODELS.has(model) || LEAK_MODELS.has(model)
}

/**
* The SwitchBot manufacturer data payload after the company ID, or undefined for other vendors.
* noble includes the little-endian company ID (0x69 0x09) in `manufacturerData`.
*/
export function switchBotManufacturerPayload(manufacturerData: unknown): Buffer | undefined {
if (!Buffer.isBuffer(manufacturerData) || manufacturerData.length < 2) {
return undefined
}
if (manufacturerData[0] !== 0x69 || manufacturerData[1] !== 0x09) {
return undefined
}
return manufacturerData.subarray(2)
}

function decodeTempHumidity(t: Buffer): Pick<SensorAdvertisement, 'temperature' | 'humidity' | 'fahrenheit'> | undefined {
const sign = (t[1] & 0x80) ? 1 : -1
const temperature = sign * ((t[1] & 0x7F) + (t[0] & 0x0F) / 10)
const humidity = t[2] & 0x7F
if (temperature === 0 && humidity === 0) {
return undefined
}
return { temperature: Math.round(temperature * 10) / 10, humidity, fahrenheit: (t[2] & 0x80) !== 0 }
}

function parseMeter(model: string, data: Buffer, mfr?: Buffer): SensorAdvertisement | undefined {
let tempData: Buffer | undefined
if (mfr && mfr.length >= 11) {
tempData = mfr.subarray(8, 11)
} else if (data.length >= 6) {
tempData = data.subarray(3, 6)
}
if (!tempData) {
return undefined
}
const reading = decodeTempHumidity(tempData)
if (!reading) {
return undefined
}
const result: SensorAdvertisement = { sensor: true, ...reading }
if (data.length >= 3) {
result.battery = data[2] & 0x7F
}
if (CO2_MODELS.has(model) && mfr && mfr.length >= 15) {
const co2 = mfr.readUInt16BE(13)
if (co2 <= CO2_MAX_PPM) {
result.co2 = co2
}
}
return result
}

function parseContact(data: Buffer, mfr?: Buffer): SensorAdvertisement | undefined {
const fullMfr = !!mfr && mfr.length >= 13
const fullData = data.length >= 9
if (!fullMfr && !fullData) {
return undefined
}
let open: boolean
let timeout: boolean
let movement: boolean
let light: boolean
if (fullMfr) {
movement = (mfr![7] & 0x80) !== 0
open = (mfr![7] & 0x10) !== 0
timeout = (mfr![7] & 0x20) !== 0
light = (mfr![7] & 0x40) !== 0
} else {
movement = (data[1] & 0x40) !== 0
open = (data[3] & 0x02) !== 0
timeout = (data[3] & 0x04) !== 0
light = (data[3] & 0x01) !== 0
}
return {
sensor: true,
battery: data.length >= 3 ? data[2] & 0x7F : undefined,
// A timeout still means the door is open (pySwitchbot)
openState: timeout ? 'timeout' : open ? 'open' : 'closed',
movement,
brightness: light ? 'bright' : 'dim',
}
}

function parseMotion(data: Buffer, mfr?: Buffer): SensorAdvertisement | undefined {
let movement: boolean | undefined
let light: boolean | undefined
let battery: number | undefined
if (data.length >= 6) {
movement = (data[1] & 0x40) !== 0
battery = data[2] & 0x7F
light = (data[5] & 0x02) !== 0
}
if (mfr && mfr.length >= 8) {
movement = (mfr[7] & 0x40) !== 0
light = (mfr[7] & 0x20) !== 0
}
if (movement === undefined) {
return undefined
}
return { sensor: true, battery, movement, brightness: light ? 'bright' : 'dim' }
}

function parseLeak(mfr?: Buffer): SensorAdvertisement | undefined {
if (!mfr || mfr.length < 9) {
return undefined
}
return {
sensor: true,
battery: mfr[7] & 0x7F,
waterLeakDetected: (mfr[8] & 0x01) !== 0,
tampered: (mfr[8] & 0x02) !== 0,
}
}

/**
* Parse a passive sensor's readings from its service data and manufacturer data payload.
* Returns undefined for other models, or when the advertisement carries no reading.
*/
export function parseSensorAdvertisement(model: string, data: Buffer, mfr?: Buffer): SensorAdvertisement | undefined {
if (METER_MODELS.has(model)) {
return parseMeter(model, data, mfr)
}
if (CONTACT_MODELS.has(model)) {
return parseContact(data, mfr)
}
if (MOTION_MODELS.has(model)) {
return parseMotion(data, mfr)
}
if (LEAK_MODELS.has(model)) {
return parseLeak(mfr)
}
return undefined
}
Loading
Loading