From daf2f22146126a398350661a51235d46a1c3ec22 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:19:27 -0600 Subject: [PATCH 01/22] Add Smart Lock C30 (T85D0) device support Register T85D0 as a SmartLock-class device with full parity to T8506/T8502: device type enum, property metadata, station metadata, command set, type-check methods, isLock() composite, serial prefix detection, and all 25 SmartLock conditionals in station.ts plus the P2P session sequence error handler. Closes #617, closes #654 --- src/http/device.ts | 11 +++++++- src/http/station.ts | 69 ++++++++++++++++++++++++++++++--------------- src/http/types.ts | 11 ++++++-- src/p2p/session.ts | 3 +- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/src/http/device.ts b/src/http/device.ts index c0a2d2ed..ddbfdba9 100644 --- a/src/http/device.ts +++ b/src/http/device.ts @@ -2222,6 +2222,10 @@ export class Device extends TypedEmitter { return DeviceType.LOCK_8502 == type; } + static isLockWifiT85D0(type: number): boolean { + return DeviceType.LOCK_85D0 == type; + } + static isLockWifiT8510P(type: number, serialnumber: string): boolean { if ( type == DeviceType.LOCK_WIFI && @@ -2526,7 +2530,8 @@ export class Device extends TypedEmitter { sn.startsWith("T8504") || sn.startsWith("T8506") || sn.startsWith("T85L0") || - sn.startsWith("T8530") + sn.startsWith("T8530") || + sn.startsWith("T85D0") ); } @@ -2673,6 +2678,10 @@ export class Device extends TypedEmitter { return Device.isLockWifiT8502(this.rawDevice.device_type); } + public isLockWifiT85D0(): boolean { + return Device.isLockWifiT85D0(this.rawDevice.device_type); + } + public isLockWifiT8510P(): boolean { return Device.isLockWifiT8510P(this.rawDevice.device_type, this.rawDevice.device_sn); } diff --git a/src/http/station.ts b/src/http/station.ts index 05a6f7ec..cf2531c9 100644 --- a/src/http/station.ts +++ b/src/http/station.ts @@ -320,7 +320,8 @@ export class Station extends TypedEmitter { !Device.isLockWifiT8510P(stationData.device_type, stationData.station_sn) && !Device.isLockWifiT8520P(stationData.device_type, stationData.station_sn) && !Device.isLockWifiT85L0(stationData.device_type) && - !Device.isLockWifiT85V0(stationData.device_type, stationData.station_sn) + !Device.isLockWifiT85V0(stationData.device_type, stationData.station_sn) && + !Device.isLockWifiT85D0(stationData.device_type) ) { publicKey = await api.getPublicKey(stationData.station_sn, PublicKeyType.LOCK); } @@ -8473,7 +8474,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { const command = getSmartLockP2PCommand( this.rawStation.station_sn, @@ -10203,7 +10205,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { const command = getSmartLockP2PCommand( this.rawStation.station_sn, @@ -12746,7 +12749,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceScramblePasscode, value); } else if (device.isSmartSafe()) { @@ -12803,7 +12807,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceWrongTryProtection, value); } else if (device.isSmartSafe()) { @@ -12860,7 +12865,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceWrongTryAttempts, value); } else if (device.isSmartSafe()) { @@ -12917,7 +12923,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceWrongTryLockdownTime, value); } else if (device.isSmartSafe()) { @@ -13553,7 +13560,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { const command = getSmartLockP2PCommand( this.rawStation.station_sn, @@ -13702,7 +13710,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { const command = getSmartLockP2PCommand( this.rawStation.station_sn, @@ -13866,7 +13875,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { const command = getSmartLockP2PCommand( this.rawStation.station_sn, @@ -14022,7 +14032,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { const command = getSmartLockP2PCommand( this.rawStation.station_sn, @@ -14234,7 +14245,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { let payload: Buffer; switch (property) { @@ -14400,7 +14412,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceAutoLock, value); } else { @@ -14455,7 +14468,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceAutoLockSchedule, value); } else { @@ -14510,7 +14524,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceAutoLockScheduleStartTime, value); } else { @@ -14565,7 +14580,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceAutoLockScheduleEndTime, value); } else { @@ -14620,7 +14636,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceAutoLockTimer, value); } else { @@ -14675,7 +14692,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceOneTouchLocking, value); } else { @@ -14730,7 +14748,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { this.setSmartLockParams(device, PropertyName.DeviceSound, value); } else { @@ -14796,6 +14815,7 @@ export class Station extends TypedEmitter { device.isLockWifiT8502() || device.isLockWifiT8510P() || device.isLockWifiT8520P() || + device.isLockWifiT85D0() || device.isLockWifiR10() || device.isLockWifiT85L0() || device.isLockWifiR20() @@ -14878,6 +14898,7 @@ export class Station extends TypedEmitter { device.isLockWifiT8502() || device.isLockWifiT8510P() || device.isLockWifiT8520P() || + device.isLockWifiT85D0() || device.isLockWifiR10() || device.isLockWifiT85L0() || device.isLockWifiR20() @@ -14960,6 +14981,7 @@ export class Station extends TypedEmitter { device.isLockWifiT8502() || device.isLockWifiT8510P() || device.isLockWifiT8520P() || + device.isLockWifiT85D0() || device.isLockWifiR10() || device.isLockWifiT85L0() || device.isLockWifiR20() @@ -15131,7 +15153,8 @@ export class Station extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() + device.isLockWifiT85V0() || + device.isLockWifiT85D0() ) { const command = getSmartLockP2PCommand( this.rawStation.station_sn, @@ -17868,7 +17891,8 @@ export class Station extends TypedEmitter { Device.isLockWifiT8510P(this.getDeviceType(), this.getSerial()) || Device.isLockWifiT8520P(this.getDeviceType(), this.getSerial()) || Device.isLockWifiT85V0(this.getDeviceType(), this.getSerial()) || - Device.isLockWifiT85L0(this.getDeviceType()) + Device.isLockWifiT85L0(this.getDeviceType()) || + Device.isLockWifiT85D0(this.getDeviceType()) ) { rootHTTPLogger.debug(`Station smart lock send get lock parameters command`, { stationSN: this.getSerial() }); const command = getSmartLockP2PCommand( @@ -17903,7 +17927,8 @@ export class Station extends TypedEmitter { Device.isLockWifiT8510P(this.getDeviceType(), this.getSerial()) || Device.isLockWifiT8520P(this.getDeviceType(), this.getSerial()) || Device.isLockWifiT85V0(this.getDeviceType(), this.getSerial()) || - Device.isLockWifiT85L0(this.getDeviceType()) + Device.isLockWifiT85L0(this.getDeviceType()) || + Device.isLockWifiT85D0(this.getDeviceType()) ) { rootHTTPLogger.debug(`Station smart lock send get lock status command`, { stationSN: this.getSerial() }); const command = getSmartLockP2PCommand( diff --git a/src/http/types.ts b/src/http/types.ts index 9747ce58..c041b21f 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -92,8 +92,8 @@ export enum DeviceType { LOCK_8502 = 180, LOCK_8506 = 184, LOCK_8531 = 189, - LOCK_85D0 = 202, LOCK_85L0 = 201, + LOCK_85D0 = 202, LOCK_85V0 = 203, WALL_LIGHT_CAM_81A0 = 10005, INDOOR_PT_CAMERA_C220 = 10008, // T8W11C @@ -10259,7 +10259,14 @@ export const DeviceCommands: Commands = { CommandName.DeviceUpdateUserSchedule, CommandName.DeviceUpdateUsername, ], - [DeviceType.LOCK_85D0]: [CommandName.DeviceLockCalibration], + [DeviceType.LOCK_85D0]: [ + CommandName.DeviceLockCalibration, + CommandName.DeviceAddUser, + CommandName.DeviceDeleteUser, + CommandName.DeviceUpdateUserPasscode, + CommandName.DeviceUpdateUserSchedule, + CommandName.DeviceUpdateUsername, + ], [DeviceType.LOCK_8502]: [ CommandName.DeviceLockCalibration, CommandName.DeviceAddUser, diff --git a/src/p2p/session.ts b/src/p2p/session.ts index 6368bf21..16ad0229 100644 --- a/src/p2p/session.ts +++ b/src/p2p/session.ts @@ -2928,7 +2928,8 @@ export class P2PClientProtocol extends TypedEmitter { this.rawStation.devices[0]?.device_sn ) || Device.isLockWifiT8531(this.rawStation.devices[0]?.device_type) || - Device.isLockWifiT85L0(this.rawStation.devices[0]?.device_type) + Device.isLockWifiT85L0(this.rawStation.devices[0]?.device_type) || + Device.isLockWifiT85D0(this.rawStation.devices[0]?.device_type) ) { this.emit( "sequence error", From 253782cb4998ec48c7431a8115cc2349ac184090 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:55:11 -0600 Subject: [PATCH 02/22] Add MQTT-based lock control for T85D0 Smart Lock C30 Replace the non-functional Cloud API handler (upload_devs_params returns success but doesn't physically move the lock) with a working MQTT-based approach using the security-mqtt broker. T85D0 uses the same BLE command protocol as T8506/T8502 smart locks (FF09 frames, AES-128-CBC encryption, TLV payloads), but tunneled over MQTT instead of P2P. The new SecurityMQTTService handles: - 3-step EufyHome auth chain to obtain mTLS certificates - MQTT connection to security-mqtt-{region}.anker.com:8883 - Lock/unlock command publishing (reuses getSmartLockP2PCommand) - Heartbeat parsing for battery level and lock state updates Station emits "security mqtt command" events which EufySecurity routes to SecurityMQTTService, keeping clean separation of concerns. Verified working: lock/unlock on two T85D0 locks via Home Assistant UI, with real-time status updates from device heartbeats. --- src/eufysecurity.ts | 114 ++++++++- src/http/interfaces.ts | 13 +- src/http/station.ts | 23 +- src/mqtt/interface.ts | 7 + src/mqtt/security-mqtt.ts | 475 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 628 insertions(+), 4 deletions(-) create mode 100644 src/mqtt/security-mqtt.ts diff --git a/src/eufysecurity.ts b/src/eufysecurity.ts index d7aa846f..31a529ba 100644 --- a/src/eufysecurity.ts +++ b/src/eufysecurity.ts @@ -81,7 +81,7 @@ import { MotionZone, CrossTrackingGroupEntry, } from "./p2p/interfaces"; -import { CommandResult, StorageInfoBodyHB3 } from "./p2p/models"; +import { CommandResult, PropertyData, StorageInfoBodyHB3 } from "./p2p/models"; import { generateSerialnumber, generateUDID, @@ -109,6 +109,7 @@ import { libVersion } from "."; import { InvalidPropertyError } from "./http/error"; import { ServerPushEvent } from "./push/types"; import { MQTTService } from "./mqtt/service"; +import { SecurityMQTTService } from "./mqtt/security-mqtt"; import { TalkbackStream } from "./p2p/talkback"; import { getRandomPhoneModel, hexStringScheduleToSchedule, randomNumber } from "./http/utils"; import { @@ -139,6 +140,7 @@ export class EufySecurity extends TypedEmitter { private pushService!: PushNotificationService; private mqttService!: MQTTService; + private securityMqttService: SecurityMQTTService | null = null; private pushCloudRegistered = false; private pushCloudChecked = false; private persistentFile!: string; @@ -423,6 +425,58 @@ export class EufySecurity extends TypedEmitter { }); } + private async initSecurityMqtt(email: string, apiBase: string): Promise { + // Check if any T85D0 locks exist before connecting + const hasT85D0 = Object.values(this.devices).some((device) => device.isLockWifiT85D0()); + if (!hasT85D0) { + rootMainLogger.debug("No T85D0 locks found, skipping SecurityMQTT initialization"); + return; + } + + try { + this.securityMqttService = new SecurityMQTTService( + email, + this.config.password, + this.persistentData.openudid, + this.config.country || "US", + ); + + this.securityMqttService.on("connect", () => { + rootMainLogger.info("SecurityMQTT connected"); + // Subscribe all T85D0 locks + for (const device of Object.values(this.devices)) { + if (device.isLockWifiT85D0()) { + this.securityMqttService!.subscribeLock(device.getSerial()); + } + } + }); + + this.securityMqttService.on("close", () => { + rootMainLogger.info("SecurityMQTT disconnected"); + }); + + this.securityMqttService.on("lock-status", (deviceSN, locked, battery) => { + this.getDevice(deviceSN) + .then((device) => { + device.updateProperty(PropertyName.DeviceLocked, locked); + if (battery >= 0) { + device.updateProperty(PropertyName.DeviceBattery, battery); + } + }) + .catch(() => { /* device not found */ }); + }); + + this.securityMqttService.on("command-response", (deviceSN, success) => { + rootMainLogger.debug("SecurityMQTT command response", { deviceSN, success }); + }); + + await this.securityMqttService.connect(apiBase); + } catch (err) { + const error = ensureError(err); + rootMainLogger.error("SecurityMQTT initialization failed", { error: getError(error) }); + } + } + public setLoggingLevel(category: LoggingCategories, level: LogLevel): void { if ( typeof level === "number" && @@ -507,6 +561,9 @@ export class EufySecurity extends TypedEmitter { this.emit("device added", device); if (device.isLock()) this.mqttService.subscribeLock(device.getSerial()); + if (device.isLockWifiT85D0() && this.securityMqttService) { + this.securityMqttService.subscribeLock(device.getSerial()); + } } else { rootMainLogger.debug(`Device with this serial ${device.getSerial()} exists already and couldn't be added again!`); } @@ -789,6 +846,20 @@ export class EufySecurity extends TypedEmitter { station.on("storage info hb3", (station: Station, channel: number, storageInfo: StorageInfoBodyHB3) => this.onStorageInfoHb3(station, channel, storageInfo) ); + station.on( + "security mqtt command", + ( + _station: Station, + deviceSN: string, + adminUserId: string, + shortUserId: string, + nickName: string, + channel: number, + sequence: number, + lock: boolean, + _property: PropertyData, + ) => this.onSecurityMqttCommand(deviceSN, adminUserId, shortUserId, nickName, channel, sequence, lock) + ); this.addStation(station); station.initialize(); } catch (err) { @@ -1108,6 +1179,10 @@ export class EufySecurity extends TypedEmitter { this.pushService.close(); this.mqttService.close(); + if (this.securityMqttService) { + this.securityMqttService.close(); + this.securityMqttService = null; + } Object.values(this.stations).forEach((station) => { station.close(); @@ -1207,6 +1282,9 @@ export class EufySecurity extends TypedEmitter { const loginData = this.api.getPersistentData(); if (loginData) { this.mqttService.connect(loginData.user_id, this.persistentData.openudid, this.api.getAPIBase(), loginData.email); + + // Initialize SecurityMQTTService for T85D0 locks (BLE-over-MQTT protocol) + this.initSecurityMqtt(loginData.email, this.api.getAPIBase()); } else { rootMainLogger.warn("No login data recevied to initialize MQTT connection..."); } @@ -2773,6 +2851,40 @@ export class EufySecurity extends TypedEmitter { this.emit("device locked", device, state); } + private onSecurityMqttCommand( + deviceSN: string, + adminUserId: string, + shortUserId: string, + nickName: string, + channel: number, + sequence: number, + lock: boolean, + ): void { + if (!this.securityMqttService || !this.securityMqttService.isConnected()) { + rootMainLogger.error("SecurityMQTT not connected, cannot send lock command", { deviceSN }); + return; + } + this.securityMqttService.publishLockCommand( + this.api.getPersistentData()?.user_id || adminUserId, + deviceSN, + adminUserId, + shortUserId, + nickName, + channel, + sequence, + lock, + ).then((success) => { + if (success) { + // Optimistically update lock state + this.getDevice(deviceSN).then((device) => { + device.updateProperty(PropertyName.DeviceLocked, lock); + }).catch(() => { /* device not found, ignore */ }); + } + }).catch((err) => { + rootMainLogger.error("SecurityMQTT lock command error", { error: getError(ensureError(err)) }); + }); + } + private onDeviceOpen(device: Device, state: boolean): void { this.emit("device open", device, state); } diff --git a/src/http/interfaces.ts b/src/http/interfaces.ts index 56023bd7..08e462fb 100644 --- a/src/http/interfaces.ts +++ b/src/http/interfaces.ts @@ -13,7 +13,7 @@ import { DatabaseQueryLocal, StreamMetadata, } from "../p2p/interfaces"; -import { CommandResult, StorageInfoBodyHB3 } from "../p2p/models"; +import { CommandResult, PropertyData, StorageInfoBodyHB3 } from "../p2p/models"; import { AlarmEvent, DatabaseReturnCode, @@ -273,6 +273,17 @@ export interface StationEvents { "sensor status": (station: Station, channel: number, status: number) => void; "garage door status": (station: Station, channel: number, doorId: number, status: number) => void; "storage info hb3": (station: Station, channel: number, storageInfo: StorageInfoBodyHB3) => void; + "security mqtt command": ( + station: Station, + deviceSN: string, + adminUserId: string, + shortUserId: string, + nickName: string, + channel: number, + sequence: number, + lock: boolean, + property: PropertyData, + ) => void; } export interface DeviceEvents { diff --git a/src/http/station.ts b/src/http/station.ts index cf2531c9..023f1179 100644 --- a/src/http/station.ts +++ b/src/http/station.ts @@ -8468,14 +8468,33 @@ export class Station extends TypedEmitter { this._sendLockV12P2PCommand(command, { property: propertyData, }); + } else if (device.isLockWifiT85D0()) { + // T85D0 Smart Lock C30: Uses BLE-over-MQTT protocol (same BLE frames as T8506, + // but sent via security-mqtt broker instead of P2P) + rootHTTPLogger.debug("Station lock device - Using security MQTT for T85D0...", { + station: this.getSerial(), + device: device.getSerial(), + value: value, + }); + this.emit( + "security mqtt command", + this, + device.getSerial(), + this.rawStation.member.admin_user_id, + this.rawStation.member.short_user_id, + this.rawStation.member.nick_name, + device.getChannel(), + this.p2pSession.incLockSequenceNumber(), + value, + propertyData, + ); } else if ( device.isLockWifiT8506() || device.isLockWifiT8502() || device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT85L0() || - device.isLockWifiT85V0() || - device.isLockWifiT85D0() + device.isLockWifiT85V0() ) { const command = getSmartLockP2PCommand( this.rawStation.station_sn, diff --git a/src/mqtt/interface.ts b/src/mqtt/interface.ts index 44fe0af9..d8db1387 100644 --- a/src/mqtt/interface.ts +++ b/src/mqtt/interface.ts @@ -5,3 +5,10 @@ export interface MQTTServiceEvents { close: () => void; "lock message": (message: DeviceSmartLockMessage) => void; } + +export interface SecurityMQTTServiceEvents { + connect: () => void; + close: () => void; + "lock-status": (deviceSN: string, locked: boolean, battery: number) => void; + "command-response": (deviceSN: string, success: boolean) => void; +} diff --git a/src/mqtt/security-mqtt.ts b/src/mqtt/security-mqtt.ts new file mode 100644 index 00000000..6e1288b0 --- /dev/null +++ b/src/mqtt/security-mqtt.ts @@ -0,0 +1,475 @@ +import * as mqtt from "mqtt"; +import * as https from "https"; +import { createHash } from "crypto"; +import { TypedEmitter } from "tiny-typed-emitter"; + +import { SecurityMQTTServiceEvents } from "./interface"; +import { rootMQTTLogger } from "../logging"; +import { ensureError } from "../error"; +import { getError } from "../utils"; +import { SmartLockP2PCommandPayloadType } from "../p2p/models"; +import { getSmartLockP2PCommand } from "../p2p/utils"; +import { SmartLockCommand, SmartLockFunctionType, CommandType } from "../p2p/types"; +import { Lock } from "../http/device"; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const EUFYHOME_CLIENT_ID = "eufyhome-app"; +const EUFYHOME_CLIENT_SECRET = "GQCpr9dSp3uQpsOMgJ4xQ"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface MQTTCertInfo { + certificate_pem: string; + private_key: string; + aws_root_ca1_pem: string; + thing_name: string; + endpoint_addr: string; +} + +// ─── SecurityMQTTService ───────────────────────────────────────────────────── + +export class SecurityMQTTService extends TypedEmitter { + private client: mqtt.MqttClient | null = null; + private connected = false; + private connecting = false; + + private mqttInfo: MQTTCertInfo | null = null; + private userCenterId = ""; + private clientId = ""; + + private email: string; + private password: string; + private openudid: string; + private country: string; + + private subscribedLocks: Set = new Set(); + private pendingLockSubscriptions: Set = new Set(); + private msgSeq = 1; + + constructor(email: string, password: string, openudid: string, country = "US") { + super(); + this.email = email; + this.password = password; + this.openudid = openudid; + this.country = country; + } + + // ─── Auth Chain ────────────────────────────────────────────────────────── + + private httpRequest( + url: string, + method: string, + headers: Record, + body?: string + ): Promise<{ status: number; data: any }> { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const reqOpts: https.RequestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || 443, + path: urlObj.pathname + urlObj.search, + method: method, + headers: headers, + }; + + const req = https.request(reqOpts, (res) => { + let data = ""; + res.on("data", (chunk: string) => (data += chunk)); + res.on("end", () => { + try { + resolve({ status: res.statusCode || 0, data: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode || 0, data: data }); + } + }); + }); + + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); + } + + private async authenticate(): Promise { + // Step 1: EufyHome login + rootMQTTLogger.debug("SecurityMQTT auth step 1: EufyHome login..."); + const loginRes = await this.httpRequest( + "https://home-api.eufylife.com/v1/user/email/login", + "POST", + { "Content-Type": "application/json", category: "Home" }, + JSON.stringify({ + client_id: EUFYHOME_CLIENT_ID, + client_secret: EUFYHOME_CLIENT_SECRET, + email: this.email, + password: this.password, + }) + ); + if (!loginRes.data.access_token) { + throw new Error(`EufyHome login failed: ${JSON.stringify(loginRes.data)}`); + } + rootMQTTLogger.debug("SecurityMQTT auth step 1: OK"); + + // Step 2: User center token + rootMQTTLogger.debug("SecurityMQTT auth step 2: user_center_token..."); + const ucRes = await this.httpRequest( + "https://home-api.eufylife.com/v1/user/user_center_info", + "GET", + { + "Content-Type": "application/json", + category: "Home", + token: loginRes.data.access_token, + } + ); + if (!ucRes.data.user_center_token) { + throw new Error(`user_center_info failed: ${JSON.stringify(ucRes.data)}`); + } + this.userCenterId = ucRes.data.user_center_id; + rootMQTTLogger.debug("SecurityMQTT auth step 2: OK", { userCenterId: this.userCenterId }); + + // Step 3: MQTT certificates + rootMQTTLogger.debug("SecurityMQTT auth step 3: MQTT certs..."); + const gtoken = createHash("md5").update(this.userCenterId).digest("hex"); + const mqttRes = await this.httpRequest( + "https://aiot-clean-api-pr.eufylife.com/app/devicemanage/get_user_mqtt_info", + "POST", + { + "Content-Type": "application/json", + "X-Auth-Token": ucRes.data.user_center_token, + GToken: gtoken, + "User-Agent": "EufySecurity-Android-4.6.0-1630", + category: "eufy_security", + "App-name": "eufy_security", + openudid: this.openudid, + language: "en", + country: this.country, + "Os-version": "Android", + "Model-type": "PHONE", + timezone: "America/New_York", + }, + "{}" + ); + if (mqttRes.data.code !== 0) { + throw new Error(`MQTT certs failed: ${JSON.stringify(mqttRes.data)}`); + } + this.mqttInfo = mqttRes.data.data as MQTTCertInfo; + rootMQTTLogger.debug("SecurityMQTT auth step 3: OK", { + thingName: this.mqttInfo.thing_name, + endpointAddr: this.mqttInfo.endpoint_addr, + }); + } + + // ─── Broker URL ────────────────────────────────────────────────────────── + + private getSecurityBrokerHost(apiBase: string): string { + if (apiBase.includes("-eu.")) { + return "security-mqtt-eu.anker.com"; + } + return "security-mqtt-us.anker.com"; + } + + // ─── Connect ───────────────────────────────────────────────────────────── + + public async connect(apiBase: string): Promise { + if (this.connected || this.connecting) return; + + this.connecting = true; + + try { + await this.authenticate(); + } catch (err) { + this.connecting = false; + const error = ensureError(err); + rootMQTTLogger.error("SecurityMQTT authentication failed", { error: getError(error) }); + throw error; + } + + if (!this.mqttInfo) { + this.connecting = false; + throw new Error("SecurityMQTT: No MQTT certificates after authentication"); + } + + const host = this.getSecurityBrokerHost(apiBase); + const endpointNoDashes = host.replace(/[.\-]/g, ""); + this.clientId = `android-eufy_security-${this.userCenterId}-${this.openudid}${endpointNoDashes}`; + + rootMQTTLogger.info(`SecurityMQTT connecting to ${host}:8883`, { + clientId: this.clientId, + username: this.mqttInfo.thing_name, + }); + + this.client = mqtt.connect({ + host: host, + port: 8883, + protocol: "mqtts", + clientId: this.clientId, + username: this.mqttInfo.thing_name, + cert: this.mqttInfo.certificate_pem, + key: this.mqttInfo.private_key, + ca: this.mqttInfo.aws_root_ca1_pem, + rejectUnauthorized: false, + clean: true, + keepalive: 60, + connectTimeout: 30000, + }); + + this.client.on("connect", () => { + this.connected = true; + this.connecting = false; + rootMQTTLogger.info("SecurityMQTT connected successfully"); + this.emit("connect"); + + // Subscribe any pending locks + for (const deviceSN of this.pendingLockSubscriptions) { + this._subscribeLock(deviceSN); + } + this.pendingLockSubscriptions.clear(); + }); + + this.client.on("close", () => { + this.connected = false; + rootMQTTLogger.info("SecurityMQTT connection closed"); + this.emit("close"); + }); + + this.client.on("error", (error) => { + this.connecting = false; + rootMQTTLogger.error("SecurityMQTT error", { error: getError(error) }); + }); + + this.client.on("message", (topic, message) => { + this.handleMessage(topic, message); + }); + } + + // ─── Lock Subscriptions ────────────────────────────────────────────────── + + private _subscribeLock(deviceSN: string): void { + if (!this.client || this.subscribedLocks.has(deviceSN)) return; + + const topics = [ + `cmd/eufy_security/T85D0/${deviceSN}/res`, + `cmd/eufy_security/T85D0/${deviceSN}/req`, + ]; + + for (const topic of topics) { + this.client.subscribe(topic, { qos: 1 }, (err) => { + if (err) { + rootMQTTLogger.error(`SecurityMQTT subscribe failed: ${topic}`, { error: getError(err) }); + } else { + rootMQTTLogger.info(`SecurityMQTT subscribed: ${topic}`); + } + }); + } + + this.subscribedLocks.add(deviceSN); + } + + public subscribeLock(deviceSN: string): void { + if (this.connected) { + this._subscribeLock(deviceSN); + } else { + this.pendingLockSubscriptions.add(deviceSN); + } + } + + // ─── Command Publishing ────────────────────────────────────────────────── + + public publishLockCommand( + userId: string, + deviceSN: string, + adminUserId: string, + shortUserId: string, + nickName: string, + channel: number, + sequence: number, + lock: boolean, + ): Promise { + return new Promise((resolve) => { + if (!this.client || !this.connected) { + rootMQTTLogger.error("SecurityMQTT not connected, cannot send lock command"); + resolve(false); + return; + } + + // Reuse the existing smart lock command builder + const command = getSmartLockP2PCommand( + deviceSN, + adminUserId, + SmartLockCommand.ON_OFF_LOCK, + channel, + sequence, + Lock.encodeCmdSmartLockUnlock(adminUserId, lock, nickName, shortUserId), + SmartLockFunctionType.TYPE_2, + ); + + // Extract the trans payload from the P2P command + const transPayload: SmartLockP2PCommandPayloadType = JSON.parse(command.payload.value); + + // Build MQTT message envelope + const sessId = Math.random().toString(16).substring(2, 6); + const seed = Array.from({ length: 16 }, () => + Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + ).join(""); + const now = Math.trunc(Date.now() / 1000); + + const trans = { + cmd: transPayload.cmd, + mChannel: transPayload.mChannel, + mValue3: transPayload.mValue3, + payload: transPayload.payload, + }; + const transB64 = Buffer.from(JSON.stringify(trans)).toString("base64"); + + const payload = JSON.stringify({ + account_id: transPayload.account_id, + device_sn: deviceSN, + trans: transB64, + }); + + const message = JSON.stringify({ + head: { + version: "1.0.0.1", + client_id: this.clientId, + sess_id: sessId, + msg_seq: this.msgSeq++, + seed: seed, + timestamp: now, + cmd_status: 2, + cmd: 9, + sign_code: 0, + }, + payload: payload, + }); + + const topic = `cmd/eufy_security/T85D0/${deviceSN}/req`; + rootMQTTLogger.debug("SecurityMQTT publishing lock command", { + topic: topic, + deviceSN: deviceSN, + lock: lock, + }); + + this.client.publish(topic, message, { qos: 1 }, (err) => { + if (err) { + rootMQTTLogger.error("SecurityMQTT publish failed", { error: getError(err) }); + resolve(false); + } else { + rootMQTTLogger.info("SecurityMQTT lock command published", { + deviceSN: deviceSN, + lock: lock, + }); + resolve(true); + } + }); + }); + } + + // ─── Message Handling ──────────────────────────────────────────────────── + + private handleMessage(topic: string, message: Buffer): void { + try { + const parsed = JSON.parse(message.toString()); + + // Only process device responses (cmd=8) on /res topics + if (!topic.endsWith("/res")) return; + + if (typeof parsed.payload !== "string") return; + const payload = JSON.parse(parsed.payload); + if (!payload.trans) return; + + const trans = JSON.parse(Buffer.from(payload.trans, "base64").toString("utf8")); + if (trans.cmd !== CommandType.CMD_TRANSFER_PAYLOAD) return; + + const lp = trans.payload; + if (!lp || !lp.lock_payload) return; + + const deviceSN = lp.dev_sn || payload.device_sn; + const buf = Buffer.from(lp.lock_payload, "hex"); + + // Parse FF09 BLE frame + if (buf.length < 10 || buf[0] !== 0xff || buf[1] !== 0x09) return; + + const flags = buf.readUInt16BE(7); + const isEncrypted = !!(flags & (1 << 14)); + const isResponse = !!(flags & (1 << 11)); + const cmdCode = flags & 0x7ff; + const data = buf.subarray(9, buf.length - 1); + + rootMQTTLogger.debug("SecurityMQTT received BLE frame", { + deviceSN: deviceSN, + flags: `0x${flags.toString(16)}`, + encrypted: isEncrypted, + response: isResponse, + cmdCode: cmdCode, + }); + + if (!isEncrypted && cmdCode === 74) { + // NOTIFY heartbeat — unencrypted TLV with battery and lock status + this.parseHeartbeat(deviceSN, data); + } else if (isResponse && cmdCode === 35) { + // ON_OFF_LOCK response — command acknowledgment + // First byte after encrypted data decryption is return code (0 = success) + rootMQTTLogger.info("SecurityMQTT received lock command response", { + deviceSN: deviceSN, + cmdCode: cmdCode, + }); + this.emit("command-response", deviceSN, true); + } + } catch (err) { + const error = ensureError(err); + rootMQTTLogger.error("SecurityMQTT message parse error", { error: getError(error) }); + } + } + + private parseHeartbeat(deviceSN: string, data: Buffer): void { + let offset = 0; + + // Skip return code byte if present + if (data.length > 0 && data[0] < 0xa0) { + offset = 1; + } + + let battery = -1; + let lockStatus = -1; + + while (offset + 2 <= data.length) { + const tag = data[offset]; + const len = data[offset + 1]; + if (offset + 2 + len > data.length) break; + + if (tag === 0xa1 && len === 1) { + battery = data[offset + 2]; + } else if (tag === 0xa2 && len === 1) { + lockStatus = data[offset + 2]; + } + + offset += 2 + len; + } + + if (lockStatus !== -1) { + const locked = lockStatus === 4; // 3=unlocked, 4=locked + rootMQTTLogger.info("SecurityMQTT heartbeat", { + deviceSN: deviceSN, + locked: locked, + battery: battery, + rawStatus: lockStatus, + }); + this.emit("lock-status", deviceSN, locked, battery); + } + } + + // ─── Lifecycle ─────────────────────────────────────────────────────────── + + public isConnected(): boolean { + return this.connected; + } + + public close(): void { + if (this.client) { + this.client.end(true); + this.client = null; + this.connected = false; + this.connecting = false; + } + } +} From c65c1498450e05155a4191c16e1dd73386344c6d Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:01:13 -0600 Subject: [PATCH 03/22] Add T85D0 Smart Lock C30 protocol specification Comprehensive end-to-end spec documenting the BLE-over-MQTT protocol used by the T85D0 Smart Lock C30, covering: - 3-step EufyHome authentication chain for MQTT certificates - Security MQTT broker connection (vs AIOT broker) - MQTT message envelope format with double-serialized JSON - FF09 BLE frame format (shared with T8506/T8502) - AES-128-CBC encryption key/IV derivation - TLV payload structure for lock/unlock commands - Status heartbeat parsing - Implementation architecture and code reuse map --- docs/t85d0-smart-lock-protocol.md | 1063 +++++++++++++++++++++++++++++ 1 file changed, 1063 insertions(+) create mode 100644 docs/t85d0-smart-lock-protocol.md diff --git a/docs/t85d0-smart-lock-protocol.md b/docs/t85d0-smart-lock-protocol.md new file mode 100644 index 00000000..33897f5b --- /dev/null +++ b/docs/t85d0-smart-lock-protocol.md @@ -0,0 +1,1063 @@ +# T85D0 Smart Lock C30 — MQTT Control Protocol Specification + +> **Status:** Implemented and verified +> **Applies to:** Eufy Smart Lock C30 (device type `T85D0`) +> **Implementation:** `src/mqtt/security-mqtt.ts` + +## Table of Contents + +1. [Overview](#1-overview) +2. [Authentication Chain](#2-authentication-chain) +3. [MQTT Connection](#3-mqtt-connection) +4. [MQTT Message Envelope](#4-mqtt-message-envelope) +5. [BLE-over-MQTT Protocol (FF09 Frames)](#5-ble-over-mqtt-protocol-ff09-frames) +6. [AES-128-CBC Encryption](#6-aes-128-cbc-encryption) +7. [Lock/Unlock Command (TLV Payload)](#7-lockunlock-command-tlv-payload) +8. [Status Heartbeats](#8-status-heartbeats) +9. [Implementation Architecture](#9-implementation-architecture) +10. [Existing Code Reuse](#10-existing-code-reuse) + +--- + +## 1. Overview + +The **Eufy Smart Lock C30** (device type prefix `T85D0`) is a Wi-Fi smart lock that +**cannot be controlled through the same P2P or Cloud API channels** used by other +eufy-security devices. Specifically: + +- **P2P fails** with error code 20028 — the lock lacks DSK (Device Shared Key) provisioning + for the P2P protocol. +- **Cloud API (`upload_devs_params`)** returns `{"code":0}` but is a **false positive** — it + stores parameters without physically actuating the lock. + +The T85D0 is instead controlled via **BLE frames tunneled over MQTT**. The Eufy Security +Android app uses a dedicated `SecurityMqttManager` class (distinct from the platform-level +`EufyMqttManage`) to send BLE command frames to the lock through a cloud MQTT broker. The +lock maintains a persistent connection to this broker over Wi-Fi, receives the BLE frames, +and executes them as if they arrived over Bluetooth. + +The critical insight is that the BLE command protocol (FF09 frames, AES-128-CBC encryption, +TLV payloads) is **identical** to the protocol used by T8506 and T8502 smart locks over +Bluetooth/P2P. The only difference is the transport layer — MQTT instead of P2P — and the +authentication chain required to obtain MQTT connection credentials. + +### High-Level Architecture + +``` + Home Assistant + | + eufy-security-client + | + SecurityMQTTService + | + ┌────────────────┼────────────────┐ + | | | + Auth Chain MQTT Publish MQTT Subscribe + | | | + ┌─────────┴─────────┐ | | + | EufyHome API | | | + | (3-step auth) | | | + └─────────┬─────────┘ | | + | | | + mTLS Certs | | + | | | + └────────┐ | | + v v v + ┌──────────────────────────┐ + | security-mqtt-us.anker.com| + | (EMQX5 Broker) | + └──────────┬───────────────┘ + | + Wi-Fi / Cloud + | + ┌───────┴───────┐ + | T85D0 Lock | + | (via Wi-Fi) | + └───────────────┘ +``` + +--- + +## 2. Authentication Chain + +The MQTT broker requires mutual TLS (mTLS) authentication with X.509 client certificates. +These certificates are obtained through a **three-step authentication chain** that begins with +the **EufyHome API** (not the Security API). + +The Security API's `cloud_token` (from `security-app.eufylife.com`) does **NOT** work on the +AIOT API — it returns `{"code": 26002, "msg": "token not exist"}`. Only the +`user_center_token` from the EufyHome API is accepted. + +### Step 1: EufyHome Login + +**Endpoint:** `POST https://home-api.eufylife.com/v1/user/email/login` + +**Headers:** +``` +Content-Type: application/json +category: Home +``` + +**Request Body:** +```json +{ + "client_id": "eufyhome-app", + "client_secret": "GQCpr9dSp3uQpsOMgJ4xQ", + "email": "", + "password": "" +} +``` + +**Response (200 OK):** +```json +{ + "res_code": 0, + "access_token": "", + "user_id": "643e0e4c-c8de-4c20-9d0e-96f0c5fa7b7a", + "email": "user@example.com", + "nick_name": "User Name", + "ab": "US" +} +``` + +**Notes:** +- `res_code: 0` indicates success at this step. +- The `access_token` is a EufyHome session token — it is NOT the AIOT token and will NOT + work directly on the AIOT API. +- The `user_id` is the EufyHome user ID in UUID format. It is distinct from the Security + API `user_id`. + +### Step 2: Get User Center Token + +**Endpoint:** `GET https://home-api.eufylife.com/v1/user/user_center_info` + +**Headers:** +``` +Content-Type: application/json +category: Home +token: +``` + +**Response (200 OK):** +```json +{ + "res_code": 1, + "user_center_token": "<48_char_hex_string>", + "user_center_id": "<40_char_hex_string>" +} +``` + +**Critical details:** +- `res_code: 1` is **success** at this endpoint (unlike Step 1 where `0` is success). +- `user_center_token` is the **AIOT authentication token**. This is the value that the + encrypted `AccountDelegate.k()` method returns in the Android app's ijiami-protected code. +- `user_center_id` is **identical** to the Security API `user_id` (the 40-character hex + hash). This shared identifier links the two authentication systems. + +### Step 3: Get MQTT Certificates + +**Endpoint:** `POST https://aiot-clean-api-pr.eufylife.com/app/devicemanage/get_user_mqtt_info` + +**Method:** Must be POST (GET returns nothing useful). + +**Headers:** +``` +Content-Type: application/json +X-Auth-Token: +GToken: +User-Agent: EufySecurity-Android-4.6.0-1630 +category: eufy_security +App-name: eufy_security +openudid: +language: en +country: +Os-version: Android +Model-type: PHONE +timezone: America/New_York +``` + +**Request Body:** `{}` (empty JSON object) + +**Response (200 OK):** +```json +{ + "code": 0, + "msg": "success!", + "data": { + "thing_name": "-eufy_home", + "endpoint_addr": "aiot-mqtt-us.anker.com", + "certificate_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", + "aws_root_ca1_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + "certificate_id": "137926761952696052801933895160865679090", + "pkcs12": "" + } +} +``` + +**Notes:** +- `GToken` is the MD5 hex digest of the `user_center_id` string. +- `thing_name` follows the pattern `{user_center_id}-eufy_home`. +- `endpoint_addr` is the **AIOT** MQTT broker hostname. However, locks are NOT on this + broker — see Section 3 for the correct broker. +- `aws_root_ca1_pem` is actually the **Go Daddy Class 2 CA** certificate despite the + field name suggesting Amazon Root CA. This appears to be a legacy field name from an + infrastructure migration. +- Certificates are valid for approximately 10 years. +- The `pkcs12` field contains the same certificate and key as a PKCS#12 bundle. + +### Authentication Flow Diagram + +``` +User Credentials (email + password) + | + v + ┌──────────────────────────────────────────┐ + | Step 1: POST home-api.eufylife.com | + | /v1/user/email/login | + | | + | client_id: "eufyhome-app" | + | client_secret: "GQCpr9dSp3uQpsOMgJ4xQ" | + | | + | Returns: access_token | + └──────────────┬───────────────────────────┘ + | + v + ┌──────────────────────────────────────────┐ + | Step 2: GET home-api.eufylife.com | + | /v1/user/user_center_info | + | | + | Header: token: | + | | + | Returns: user_center_token (AIOT token!) | + | user_center_id | + └──────────────┬───────────────────────────┘ + | + v + ┌──────────────────────────────────────────┐ + | Step 3: POST aiot-clean-api-pr | + | .eufylife.com/app/devicemanage/ | + | get_user_mqtt_info | + | | + | Header: X-Auth-Token: | + | Header: GToken: MD5(user_center_id) | + | | + | Returns: certificate_pem, private_key, | + | aws_root_ca1_pem, thing_name, | + | endpoint_addr | + └──────────────┬───────────────────────────┘ + | + v + mTLS credentials ready +``` + +### What Does NOT Work as an AIOT Token + +| Token Type | Source | AIOT API Response | +|------------|--------|-------------------| +| Security `cloud_token` | `security-app.eufylife.com` | `{"code": 26002, "msg": "token not exist"}` | +| EufyHome `access_token` | Step 1 | `{"code": 26003, "msg": "token error"}` | +| Tuya `sid` session | `px.tuyaus.com` | `{"code": 26003, "msg": "token error"}` | + +Only the `user_center_token` from Step 2 is accepted. + +--- + +## 3. MQTT Connection + +### Broker Selection — Security vs. AIOT + +There are **two separate MQTT brokers** in Eufy's infrastructure: + +| Broker | Hostname | Purpose | +|--------|----------|---------| +| **Security MQTT** | `security-mqtt-{region}.anker.com` | Lock control (SecurityMqttManager) | +| **AIOT MQTT** | `aiot-mqtt-{region}.anker.com` | Platform IoT (EufyMqttManage) | + +The `endpoint_addr` returned by `get_user_mqtt_info` points to the **AIOT** broker +(`aiot-mqtt-us.anker.com`). However, **lock topics only exist on the Security broker** +(`security-mqtt-us.anker.com`). The mTLS certificates work on both brokers, but you must +connect to the Security broker for lock control. + +The Security broker is an **EMQX5** instance running on AWS (us-east-2): +- CNAME: `security-emqx5-us-prod-nlb-*.elb.us-east-2.amazonaws.com` + +### Connection Parameters + +| Parameter | Value | +|-----------|-------| +| **Host** | `security-mqtt-{region}.anker.com` (e.g., `security-mqtt-us.anker.com`) | +| **Port** | `8883` | +| **Protocol** | `mqtts` (MQTT over TLS with mutual authentication) | +| **Client Certificate** | `certificate_pem` from Step 3 | +| **Client Private Key** | `private_key` from Step 3 | +| **CA Certificate** | `aws_root_ca1_pem` from Step 3 | +| **TLS Verify** | `false` (EMQX5 uses a different CA than the one returned) | +| **Username** | `{thing_name}` from Step 3 (e.g., `{user_center_id}-eufy_home`) | +| **Password** | Empty | +| **Client ID** | `android-eufy_security-{user_center_id}-{openudid}{broker_host_no_dots_dashes}` | +| **Clean Session** | `true` | +| **Keep Alive** | `60` seconds | +| **QoS** | `1` | + +### Client ID Construction + +``` +"android-eufy_security-" + + user_center_id + + "-" + + openudid + + broker_host.replace(/[.\-]/g, "") + +Example: + android-eufy_security-ff7c59...04e84-d5d1343a6cca9c78securitymqttusankercom +``` + +### Topic Patterns + +For each lock device (identified by its serial number, e.g., `T85D0K1024470204`): + +| Direction | Topic Pattern | Example | +|-----------|--------------|---------| +| Commands (app to lock) | `cmd/eufy_security/T85D0/{deviceSn}/req` | `cmd/eufy_security/T85D0/T85D0K1024470204/req` | +| Responses (lock to cloud) | `cmd/eufy_security/T85D0/{deviceSn}/res` | `cmd/eufy_security/T85D0/T85D0K1024470204/res` | +| Business events | `biz/eufy_security/T85D0/{deviceSn}/res` | `biz/eufy_security/T85D0/T85D0K1024470204/res` | +| Device parameters | `dt/eufy_security/T85D0/{deviceSn}/param_info` | `dt/eufy_security/T85D0/T85D0K1024470204/param_info` | + +**Wildcard topics are denied** by broker ACL policy: +``` +cmd/eufy_security/T85D0/# DENIED +cmd/# DENIED +# DENIED +``` + +You must subscribe to each device's topics individually. + +### Region Mapping + +The Security broker hostname is derived from the AIOT API base domain or endpoint: + +| Region | AIOT Endpoint | Security Broker | +|--------|--------------|-----------------| +| US | `aiot-mqtt-us.anker.com` | `security-mqtt-us.anker.com` | +| EU | `aiot-mqtt-eu.anker.com` | `security-mqtt-eu.anker.com` | + +--- + +## 4. MQTT Message Envelope + +All MQTT messages use a **double-serialized JSON** structure: the outer message contains a +`head` object and a `payload` string. The `payload` string is itself a JSON-encoded object +that contains a `trans` field, which is a **base64-encoded** JSON string containing the +actual command or response data. + +### App-to-Lock Command (publish to `/req`) + +**Outer message:** +```json +{ + "head": { + "version": "1.0.0.1", + "client_id": "android-eufy_security-{userId}-{openudid}{brokerNoDots}", + "sess_id": "56a7", + "msg_seq": 1, + "seed": "513aff33805d400d968546fab2517932", + "timestamp": 1771171178, + "cmd_status": 2, + "cmd": 9, + "sign_code": 0 + }, + "payload": "" +} +``` + +**Payload (JSON string, parsed):** +```json +{ + "account_id": "{userId}", + "device_sn": "{deviceSn}", + "trans": "" +} +``` + +**Trans (base64-decoded JSON):** +```json +{ + "cmd": 1940, + "mChannel": 0, + "mValue3": 0, + "payload": { + "apiCommand": 6018, + "lock_payload": "ff096a00030002402300...", + "seq_num": 1771170526, + "time": 1771171195 + } +} +``` + +### Lock-to-Cloud Response (received on `/res`) + +**Outer message:** +```json +{ + "head": { + "version": "1.0.0.1", + "client_id": "T85D0K10244822EB", + "sess_id": "7117-1053", + "msg_seq": 238, + "seed": "null", + "timestamp": 1771171053, + "cmd_status": 1, + "cmd": 8, + "sign_code": 0 + }, + "payload": "" +} +``` + +**Payload (JSON string, parsed):** +```json +{ + "trans": "" +} +``` + +**Trans (base64-decoded JSON):** +```json +{ + "cmd": 1940, + "payload": { + "dev_sn": "T85D0K10244822EB", + "lock_payload": "FF091100030102004A00A10117A20103BA", + "time": "6991EC5F" + } +} +``` + +### Field Differences: App vs. Device + +| Field | App Command (req) | Device Response (res) | +|-------|-------------------|----------------------| +| `head.cmd` | `9` | `8` | +| `head.cmd_status` | `2` (send) | `1` (receive) | +| `head.client_id` | Client ID string | Device serial number | +| `head.seed` | 32-char random hex nonce | `"null"` (string literal) | +| `payload.account_id` | Present (user ID) | Absent | +| `trans.mChannel` | `0` | Absent | +| `trans.mValue3` | `0` | Absent | +| `trans.payload.apiCommand` | Command code (e.g., `6018`) | Absent | +| `trans.payload.seq_num` | Monotonic sequence number | Absent | +| `trans.payload.dev_sn` | Absent | Device serial number | + +### Key Constants + +| Field | Value | Meaning | +|-------|-------|---------| +| `head.cmd` | `9` | App-originated command | +| `head.cmd` | `8` | Device-originated response | +| `head.cmd_status` | `2` | Sending/publishing | +| `head.cmd_status` | `1` | Receiving | +| `trans.cmd` | `1940` | `CommandType.CMD_TRANSFER_PAYLOAD` — BLE payload transfer | +| `head.version` | `"1.0.0.1"` | Protocol version (constant) | +| `head.sign_code` | `0` | No signature (constant) | + +--- + +## 5. BLE-over-MQTT Protocol (FF09 Frames) + +The `lock_payload` field in the trans object contains a hex-encoded BLE frame. This is the +same frame format used for Bluetooth communication with T8506/T8502 smart locks, tunneled +through MQTT. + +### FF09 Frame Byte Layout + +``` +Offset Size Field Description +────── ──── ───── ─────────── +0 1 Magic[0] Always 0xFF +1 1 Magic[1] Always 0x09 +2 2 Length (LE) Total frame length (header + data + checksum) +4 1 Version 3 = VERSION_CODE_SMART_LOCK +5 1 Direction 0x00 = from app, 0x01 = from device +6 1 Data Type 2 = SmartLockFunctionType.TYPE_2 +7 2 Flags (BE) Bit field encoding (see below) +9 N Data Ciphertext (if encrypted) or plaintext TLV +9+N 1 Checksum XOR of all preceding bytes (offsets 0 through 8+N) +``` + +### Flags Field (uint16 big-endian at offset 7) + +``` +Bit 15 14 13-12 11 10-0 + ┌──┐ ┌──┐ ┌────┐ ┌──┐ ┌──────────┐ + |P | |E | |Rsvd| |R | |Command | + └──┘ └──┘ └────┘ └──┘ └──────────┘ + +P (bit 15): Partial — 1 = multi-part frame (fragmented BLE packet) +E (bit 14): Encrypted — 1 = data is AES-128-CBC encrypted + (bits 13-12): Reserved / unused +R (bit 11): Response — 1 = response from lock, 0 = command to lock +Command (bits 10-0): SmartLockBleCommandFunctionType2 code +``` + +### Example Flag Values + +| Flags (hex) | Binary | P | E | R | Command | Meaning | +|-------------|--------|---|---|---|---------|---------| +| `0x004A` | `0000 0000 0100 1010` | 0 | 0 | 0 | 74 | Unencrypted NOTIFY heartbeat | +| `0x4023` | `0100 0000 0010 0011` | 0 | 1 | 0 | 35 | Encrypted ON_OFF_LOCK command | +| `0x4823` | `0100 1000 0010 0011` | 0 | 1 | 1 | 35 | Encrypted ON_OFF_LOCK response | +| `0x4022` | `0100 0000 0010 0010` | 0 | 1 | 0 | 34 | Encrypted QUERY_STATUS command | +| `0x4822` | `0100 1000 0010 0010` | 0 | 1 | 1 | 34 | Encrypted QUERY_STATUS response | + +### SmartLockBleCommandFunctionType2 Codes + +These are the BLE command codes carried in bits 10-0 of the flags field, and their +corresponding API command numbers used in `trans.payload.apiCommand`: + +| BLE Code | API Command | Enum Name | Purpose | +|----------|-------------|-----------|---------| +| 33 | 6017 | `CALIBRATE_LOCK` | Lock calibration | +| 34 | 6012 | `QUERY_STATUS_IN_LOCK` | Query battery and lock state | +| **35** | **6018** | **`ON_OFF_LOCK`** | **Lock or unlock** | +| 36 | 6002 | `ADD_PW` | Add password | +| 37 | 146 | `UPDATE_USER_TIME` | Update user time | +| 38 | 6014 | `UPDATE_PW` | Update password | +| 39 | 6009 | `QUERY_PW` | Query passwords | +| 40 | 6003 | `ADD_FINGER` | Add fingerprint | +| 41 | 115 | `CANCEL_ADD_FINGER` | Cancel fingerprint enrollment | +| 42 | 6006 | `DELETE_FINGER` | Delete fingerprint | +| 43 | 119 | `UPDATE_FINGER_NAME` | Rename fingerprint | +| 44 | 6007 | `QUERY_ALL_USERS` | List all users | +| 45 | 6004 | `DELETE_USER` | Delete user | +| 46 | 6022 | `GET_FINGER_PW_USAGE` | Fingerprint/password usage stats | +| 48 | 104 | `WIFI_SCAN` | Wi-Fi scan | +| 49 | 105 | `WIFI_LIST` | Wi-Fi network list | +| 50 | 106 | `WIFI_CONNECT` | Wi-Fi connect | +| 51 | 107 | `ACTIVATE_DEVICE` | Device activation | +| 52 | 6015 | `SET_LOCK_PARAM` | Set lock parameters | +| 53 | 6016 | `GET_LOCK_PARAM` | Get lock parameters | +| 56 | — | `GET_LOCK_EVENT` | Get lock event log | +| 61 | — | `PULL_BLE` | BLE pull | +| 74 | 142 | `NOTIFY` | Status heartbeat (unencrypted) | + +### Checksum Calculation + +The checksum byte is the XOR of all bytes from offset 0 through the last data byte +(everything except the checksum byte itself): + +```typescript +function generateChecksum(data: Buffer): number { + let checksum = 0; + for (let i = 0; i < data.length; i++) { + checksum ^= data[i]; + } + return checksum & 0xFF; +} +``` + +--- + +## 6. AES-128-CBC Encryption + +Encrypted BLE frames (flags bit 14 = 1) use **AES-128-CBC** encryption. The key and IV are +derived from known values — no key exchange or DSK is required. + +### Key Derivation (16 bytes) + +The AES key is constructed by concatenating: +1. The **last 12 ASCII characters** of the `admin_user_id` (12 bytes) +2. The **current time** as a uint32 big-endian value (4 bytes) + +```typescript +// src/p2p/utils.ts:909 +export const generateSmartLockAESKey = (adminUserId: string, time: number): Buffer => { + const buffer = Buffer.allocUnsafe(4); + buffer.writeUint32BE(time); + return Buffer.concat([ + Buffer.from(adminUserId.substring(adminUserId.length - 12)), + buffer + ]); +}; +``` + +**Example:** +``` +admin_user_id = "ff7c594365c30f5618e1e4b5c34ea1ad70104e84" (40 chars) +Last 12 chars = "a1ad70104e84" (substring from index 28) +time = 1771171195 (epoch seconds with random low bits) + +Key (hex) = 61 31 61 64 37 30 31 30 34 65 38 34 69 91 EC 5B + |--- "a1ad70104e84" as ASCII ------| |-- time BE --| +``` + +### IV Derivation (16 bytes) + +The IV is the **first 16 bytes** of the device serial number, encoded as UTF-8: + +```typescript +// src/p2p/utils.ts:522 +export const getLockVectorBytes = (data: string): string => { + const encoder = new TextEncoder(); + const encData = encoder.encode(data); + const old_buffer = Buffer.from(encData); + if (encData.length >= 16) return old_buffer.toString("hex"); + // If SN is shorter than 16 chars, zero-pad to 16 bytes + const new_buffer = Buffer.alloc(16); + old_buffer.copy(new_buffer, 0); + return new_buffer.toString("hex"); +}; +``` + +**Example:** +``` +Device SN = "T85D0K1024470204" (17 chars) +IV = "T85D0K102447020" (first 16 chars as ASCII bytes) +IV (hex) = 54 38 35 44 30 4B 31 30 32 34 34 37 30 32 30 34 +``` + +Note: `getLockVectorBytes` returns a **hex string** that is later converted to a Buffer with +`Buffer.from(iv, "hex")`. + +### Time Generation + +```typescript +// src/p2p/utils.ts:905 +export const getSmartLockCurrentTimeInSeconds = function (): number { + return Math.trunc(new Date().getTime() / 1000) | Math.trunc(Math.random() * 100); +}; +``` + +The time value is the current epoch time in seconds with random low bits (0-99) OR'd in. +This same value is sent in `trans.payload.time` so the receiving end can reconstruct the +AES key for decryption. + +### Encryption / Decryption + +```typescript +// src/p2p/utils.ts:609 +export const encryptPayloadData = (data: string | Buffer, key: Buffer, iv: Buffer): Buffer => { + const cipher = createCipheriv("aes-128-cbc", key, iv); + return Buffer.concat([cipher.update(data), cipher.final()]); +}; + +// src/p2p/utils.ts:614 +export const decryptPayloadData = (data: Buffer, key: Buffer, iv: Buffer): Buffer => { + const cipher = createDecipheriv("aes-128-cbc", key, iv); + return Buffer.concat([cipher.update(data), cipher.final()]); +}; +``` + +Standard PKCS#7 padding is used (Node.js default for AES-CBC). + +--- + +## 7. Lock/Unlock Command (TLV Payload) + +The plaintext data inside an encrypted ON_OFF_LOCK frame uses a **TLV (Tag-Length-Value)** +encoding scheme. Tags are sequential starting from `0xA1`. + +### TLV Encoding + +Each TLV entry is: +``` +Tag (1 byte) | Length (1 byte) | Value (N bytes) +``` + +Tags increment sequentially: `0xA1`, `0xA2`, `0xA3`, `0xA4`, `0xA5`, ... + +This is implemented by the `WritePayload` class: + +```typescript +// src/http/utils.ts:385 +export class WritePayload { + private split_byte = -95; // 0xA1 as signed int8 + private data = Buffer.from([]); + + public write(bytes: Buffer): void { + const tmp_data = Buffer.from(bytes); + this.data = Buffer.concat([ + this.data, + Buffer.from([this.split_byte]), // Tag + Buffer.from([tmp_data.length & 255]), // Length + tmp_data, // Value + ]); + this.split_byte += 1; // Next tag: A2, A3, A4, ... + } + + public getData(): Buffer { + return this.data; + } +} +``` + +### ON_OFF_LOCK TLV Fields + +```typescript +// src/http/device.ts:5078 +public static encodeCmdSmartLockUnlock( + adminUserId: string, + lock: boolean, + username: string, + shortUserId: string +): Buffer { + const payload = new WritePayload(); + payload.write(this.getCurrentTimeInSeconds()); // A1: timestamp + payload.write(Buffer.from(adminUserId)); // A2: admin user ID + payload.write(this.getUInt8Buffer(lock ? 0 : 1)); // A3: lock command + payload.write(Buffer.from(username)); // A4: display name + payload.write(Buffer.from(shortUserId, "hex")); // A5: short user ID + return payload.getData(); +} +``` + +### Field Details + +| Tag | Name | Length | Format | Description | +|-----|------|--------|--------|-------------| +| `A1` | Timestamp | 4 | uint32 LE | Current time in seconds (from `getCurrentTimeInSeconds()`) | +| `A2` | Admin User ID | 40 | ASCII string | The `admin_user_id` from the station member data | +| `A3` | Lock Command | 1 | uint8 | `0x00` = **LOCK**, `0x01` = **UNLOCK** | +| `A4` | Username | varies | ASCII string | Display name of the user (e.g., `"nickpape"`) | +| `A5` | Short User ID | varies | Binary (hex-decoded) | The `short_user_id` from station member data, decoded from hex to raw bytes | + +**Note:** The `lock` boolean parameter to `encodeCmdSmartLockUnlock` uses inverted logic: +`lock: true` maps to `0x00` (lock the door), `lock: false` maps to `0x01` (unlock the door). +This matches the Eufy convention where `true` = locked state. + +### Example Plaintext (hex) + +``` +A1 04 +A2 28 +A3 01 01 (unlock) +A4 08 <"nickpape" as ASCII> +A5 04 +``` + +This plaintext is encrypted with AES-128-CBC, then wrapped in the FF09 BLE frame with +flags `0x4023` (encrypted=1, response=0, command=35/ON_OFF_LOCK). + +--- + +## 8. Status Heartbeats + +Locks periodically send **unencrypted** NOTIFY heartbeat frames on the `/res` topic. These +provide battery level and lock state without requiring any command from the app. + +### Heartbeat Frame (Unencrypted) + +``` +Flags: 0x004A (encrypted=0, response=0, command=74/NOTIFY) + +Example hex: FF091100030102004A00A10117A20103BA + +Byte-by-byte: + FF 09 Magic header + 11 00 Length = 17 bytes (LE) + 03 Version = 3 (Smart Lock) + 01 Direction = from device + 02 Data Type = TYPE_2 + 00 4A Flags = 0x004A (NOTIFY, unencrypted) + 00 Return code = 0 (success) + A1 01 17 Tag A1, len 1, value 0x17 = 23 (battery %) + A2 01 03 Tag A2, len 1, value 0x03 = UNLOCKED + BA Checksum (XOR of all preceding bytes) +``` + +### Heartbeat TLV Fields + +| Tag | Name | Length | Values | +|-----|------|--------|--------| +| `A1` | Battery | 1 | Percentage (0-100). Example: `0x17` = 23% | +| `A2` | Lock Status | 1 | `0x03` = **UNLOCKED**, `0x04` = **LOCKED** | + +### Parsing Notes + +- The data section begins with a **return code byte** (value `0x00` for success) before the + TLV entries. When parsing, check if the first byte is less than `0xA0` — if so, skip it + as a return code before processing TLV tags. +- Heartbeat frequency depends on the lock's configuration, but typically arrives every few + minutes and immediately after any state change. + +### Checksum Verification + +``` +FF ^ 09 ^ 11 ^ 00 ^ 03 ^ 01 ^ 02 ^ 00 ^ 4A ^ 00 ^ A1 ^ 01 ^ 17 ^ A2 ^ 01 ^ 03 = BA +``` + +--- + +## 9. Implementation Architecture + +### SecurityMQTTService Class + +The `SecurityMQTTService` class (`src/mqtt/security-mqtt.ts`) encapsulates the entire MQTT +communication layer. It is a `TypedEmitter` that exposes events for lock status and command +responses. + +```typescript +export class SecurityMQTTService extends TypedEmitter { + // Lifecycle + connect(apiBase: string): Promise + close(): void + isConnected(): boolean + + // Lock management + subscribeLock(deviceSN: string): void + publishLockCommand( + userId: string, + deviceSN: string, + adminUserId: string, + shortUserId: string, + nickName: string, + channel: number, + sequence: number, + lock: boolean, + ): Promise +} +``` + +### Events + +```typescript +// src/mqtt/interface.ts +export interface SecurityMQTTServiceEvents { + connect: () => void; + close: () => void; + "lock-status": (deviceSN: string, locked: boolean, battery: number) => void; + "command-response": (deviceSN: string, success: boolean) => void; +} +``` + +### Event Routing Pattern + +The integration follows an event-based routing pattern through three layers: + +``` +Station EufySecurity SecurityMQTTService + | | | + | (lock command requested) | | + | | | + | emit("security mqtt command", | | + | station, deviceSN, | | + | adminUserId, shortUserId, | | + | nickName, channel, seq, lock) | | + | -------------------------------->| | + | | | + | | onSecurityMqttCommand() | + | | calls publishLockCommand() | + | | ---------------------------->| + | | | + | | (MQTT publish)| + | | | + | | emit("lock-status") | + | |<-----------------------------| + | | | + | | (updates device properties | + | | via rawStation property set) | +``` + +**Flow details:** + +1. **Station** (`src/http/station.ts:8464-8483`): When a lock/unlock command is issued for a + T85D0 device, the `Station` class detects `device.isLockWifiT85D0()` and emits the + `"security mqtt command"` event instead of sending a P2P command. + +2. **EufySecurity** (`src/eufysecurity.ts:849-861`): The main `EufySecurity` class listens + for this event on all stations and routes it to `onSecurityMqttCommand()`, which calls + `SecurityMQTTService.publishLockCommand()`. + +3. **SecurityMQTTService** (`src/mqtt/security-mqtt.ts`): Constructs the BLE frame using + existing utility functions, wraps it in the MQTT envelope, and publishes to the broker. + +### Initialization + +`SecurityMQTTService` is initialized during `EufySecurity` startup +(`src/eufysecurity.ts:1286-1287`) only if T85D0 devices are detected in the device list. +It runs the three-step auth chain, connects to the Security MQTT broker, and subscribes to +topics for each known T85D0 lock. + +--- + +## 10. Existing Code Reuse + +A key design principle of this implementation is that it reuses the **same BLE command +construction logic** used by T8506 and T8502 smart locks, which communicate over P2P/Bluetooth. +The only new code is the MQTT transport layer and authentication. + +### Shared Functions + +| Function | File | Line | Purpose | +|----------|------|------|---------| +| `getSmartLockP2PCommand()` | `src/p2p/utils.ts` | 915 | Builds complete BLE command payload (key gen, encryption, FF09 frame) | +| `generateSmartLockAESKey()` | `src/p2p/utils.ts` | 909 | AES-128 key from admin_user_id + timestamp | +| `getSmartLockCurrentTimeInSeconds()` | `src/p2p/utils.ts` | 905 | Epoch seconds with random low bits | +| `getLockVectorBytes()` | `src/p2p/utils.ts` | 522 | IV from device serial number | +| `encryptPayloadData()` | `src/p2p/utils.ts` | 609 | AES-128-CBC encryption | +| `decryptPayloadData()` | `src/p2p/utils.ts` | 614 | AES-128-CBC decryption | +| `Lock.encodeCmdSmartLockUnlock()` | `src/http/device.ts` | 5078 | TLV payload for lock/unlock command | +| `Lock.getCurrentTimeInSeconds()` | `src/http/device.ts` | 4780 | uint32 LE timestamp buffer | +| `Lock.VERSION_CODE_SMART_LOCK` | `src/http/device.ts` | 4533 | Constant `3` — BLE version code | +| `BleCommandFactory` | `src/p2p/ble.ts` | 28 | Builds and parses FF09 BLE frames | +| `BleCommandFactory.parseSmartLock()` | `src/p2p/ble.ts` | 67 | Parses incoming FF09 frames | +| `BleCommandFactory.getSmartLockCommand()` | `src/p2p/ble.ts` | 275 | Serializes BLE frame to bytes | +| `WritePayload` | `src/http/utils.ts` | 385 | TLV builder (A1/A2/A3... tags) | +| `SmartLockBleCommandFunctionType2` | `src/p2p/types.ts` | 1245 | BLE command code enum | +| `SmartLockCommand` | `src/p2p/types.ts` | 1187 | API command code enum | +| `SmartLockP2PCommandPayloadType` | `src/p2p/models.ts` | 313 | Type for the trans payload structure | + +### New Code (MQTT Transport Only) + +| Component | File | Purpose | +|-----------|------|---------| +| `SecurityMQTTService` | `src/mqtt/security-mqtt.ts` | MQTT connection, auth, publish/subscribe | +| `SecurityMQTTServiceEvents` | `src/mqtt/interface.ts` | Event type definitions | +| Station T85D0 routing | `src/http/station.ts:8464-8483` | Emits `"security mqtt command"` for T85D0 | +| EufySecurity routing | `src/eufysecurity.ts:849-861` | Connects Station event to SecurityMQTTService | + +### How `publishLockCommand` Reuses Existing Code + +The `publishLockCommand` method demonstrates the reuse pattern: + +```typescript +// 1. Build the BLE command using the same function used by T8506/T8502 +const command = getSmartLockP2PCommand( + deviceSN, + adminUserId, + SmartLockCommand.ON_OFF_LOCK, // 6018 + channel, + sequence, + Lock.encodeCmdSmartLockUnlock(adminUserId, lock, nickName, shortUserId), + SmartLockFunctionType.TYPE_2, +); + +// 2. Extract the payload that would normally go to P2P +const transPayload: SmartLockP2PCommandPayloadType = JSON.parse(command.payload.value); + +// 3. Wrap it in the MQTT envelope instead of sending via P2P +const trans = { + cmd: transPayload.cmd, // 1940 + mChannel: transPayload.mChannel, // 0 + mValue3: transPayload.mValue3, // 0 + payload: transPayload.payload, // { apiCommand, lock_payload, seq_num, time } +}; +``` + +The only difference from the P2P path is the transport: instead of sending via the P2P +session, the payload is base64-encoded, wrapped in the MQTT JSON envelope, and published +to the MQTT broker. + +--- + +## Appendix A: Complete Lock Command Example + +Below is a step-by-step walkthrough of constructing a lock command (locking the front door). + +### Given Values (placeholders) + +``` +admin_user_id = "ff7c594365c30f5618e1e4b5c34ea1ad70104e84" +device_sn = "T85D0K1024470204" +username = "nickpape" +short_user_id = "abcd1234" (hex, 4 bytes when decoded) +``` + +### 1. Generate Time + +```javascript +time = Math.trunc(Date.now() / 1000) | Math.trunc(Math.random() * 100); +// e.g., 1771171195 +``` + +### 2. Build AES Key + +``` +Last 12 of admin_user_id: "a1ad70104e84" +time as uint32BE: [0x69, 0x91, 0xEC, 0x5B] +AES key (16 bytes): 61 31 61 64 37 30 31 30 34 65 38 34 69 91 EC 5B +``` + +### 3. Build IV + +``` +First 16 chars of device_sn: "T85D0K102447020" +IV (16 bytes): 54 38 35 44 30 4B 31 30 32 34 34 37 30 32 30 34 +``` + +### 4. Build TLV Plaintext + +``` +A1 04 Timestamp +A2 28 <40 ASCII bytes of user_id> Admin user ID +A3 01 00 Lock (0x00 = lock, 0x01 = unlock) +A4 08 <"nickpape"> Username +A5 04 <0xAB 0xCD 0x12 0x34> Short user ID (hex decoded) +``` + +### 5. Encrypt with AES-128-CBC + +```javascript +ciphertext = encryptPayloadData(plaintext, key, iv); +``` + +### 6. Build FF09 Frame + +``` +FF 09 Magic header +XX 00 Length (LE, total frame size) +03 Version = 3 (Smart Lock) +00 Direction = from app +02 Data Type = TYPE_2 +40 23 Flags = 0x4023 (encrypted=1, command=35/ON_OFF_LOCK) + Encrypted TLV +XX XOR checksum +``` + +### 7. Build MQTT Message + +```json +{ + "head": { + "version": "1.0.0.1", + "client_id": "android-eufy_security-ff7c59...04e84-d5d134...securitymqttusankercom", + "sess_id": "a3f1", + "msg_seq": 1, + "seed": "7b3c92a1d4f5e6b8c0a1d2e3f4a5b6c7", + "timestamp": 1771171195, + "cmd_status": 2, + "cmd": 9, + "sign_code": 0 + }, + "payload": "{\"account_id\":\"ff7c59...04e84\",\"device_sn\":\"T85D0K1024470204\",\"trans\":\"\"}" +} +``` + +### 8. Publish + +``` +Topic: cmd/eufy_security/T85D0/T85D0K1024470204/req +QoS: 1 +``` + +--- + +## Appendix B: Extending to Other Lock Types + +If other lock models (beyond T85D0) use the same Security MQTT broker for control, the +following would need to be determined for each model: + +1. **Device type prefix** — Used in topic paths (e.g., `T85D0` in + `cmd/eufy_security/T85D0/{sn}/req`). Other locks may use their own prefix. +2. **BLE protocol version** — The `Version` byte (offset 4) in the FF09 frame. Smart locks + use `3` (`VERSION_CODE_SMART_LOCK`), but newer models may use a different version. +3. **Function type** — `SmartLockFunctionType.TYPE_1` vs `TYPE_2` affects command code + mapping. T85D0 uses TYPE_2. +4. **Encryption scheme** — T85D0 uses AES-128-CBC with the standard key derivation. Some + v12 locks use a different key derivation or ECDH-based keys. +5. **TLV field set** — The TLV tags for lock/unlock are the same across TYPE_2 locks, but + other commands may differ. + +The modular design — shared BLE frame construction, separate MQTT transport — means adding +a new lock type primarily involves updating the device detection logic +(`device.isLockWifiT85D0()` pattern) and potentially adjusting the topic prefix. From 57a92c495d3e0c7ff23e1e8963f554127c207230 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:40:04 -0600 Subject: [PATCH 04/22] Add usesSecurityMqtt() helper and remove hardcoded T85D0 references Replace all transport-related isLockWifiT85D0() checks with a generic usesSecurityMqtt() method on Device. Pass device model string to SecurityMQTTService for topic construction instead of hardcoding "T85D0". --- src/eufysecurity.ts | 19 ++++++++++--------- src/http/device.ts | 8 ++++++++ src/http/station.ts | 6 ++---- src/mqtt/security-mqtt.ts | 30 ++++++++++++++++++------------ 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/eufysecurity.ts b/src/eufysecurity.ts index 31a529ba..e41378d5 100644 --- a/src/eufysecurity.ts +++ b/src/eufysecurity.ts @@ -426,10 +426,9 @@ export class EufySecurity extends TypedEmitter { } private async initSecurityMqtt(email: string, apiBase: string): Promise { - // Check if any T85D0 locks exist before connecting - const hasT85D0 = Object.values(this.devices).some((device) => device.isLockWifiT85D0()); - if (!hasT85D0) { - rootMainLogger.debug("No T85D0 locks found, skipping SecurityMQTT initialization"); + const hasSecurityMqttDevices = Object.values(this.devices).some((device) => device.usesSecurityMqtt()); + if (!hasSecurityMqttDevices) { + rootMainLogger.debug("No security MQTT locks found, skipping SecurityMQTT initialization"); return; } @@ -443,10 +442,9 @@ export class EufySecurity extends TypedEmitter { this.securityMqttService.on("connect", () => { rootMainLogger.info("SecurityMQTT connected"); - // Subscribe all T85D0 locks for (const device of Object.values(this.devices)) { - if (device.isLockWifiT85D0()) { - this.securityMqttService!.subscribeLock(device.getSerial()); + if (device.usesSecurityMqtt()) { + this.securityMqttService!.subscribeLock(device.getSerial(), device.getModel()); } } }); @@ -561,8 +559,8 @@ export class EufySecurity extends TypedEmitter { this.emit("device added", device); if (device.isLock()) this.mqttService.subscribeLock(device.getSerial()); - if (device.isLockWifiT85D0() && this.securityMqttService) { - this.securityMqttService.subscribeLock(device.getSerial()); + if (device.usesSecurityMqtt() && this.securityMqttService) { + this.securityMqttService.subscribeLock(device.getSerial(), device.getModel()); } } else { rootMainLogger.debug(`Device with this serial ${device.getSerial()} exists already and couldn't be added again!`); @@ -2864,9 +2862,12 @@ export class EufySecurity extends TypedEmitter { rootMainLogger.error("SecurityMQTT not connected, cannot send lock command", { deviceSN }); return; } + const device = this.devices[deviceSN]; + const deviceModel = device ? device.getModel() : deviceSN; this.securityMqttService.publishLockCommand( this.api.getPersistentData()?.user_id || adminUserId, deviceSN, + deviceModel, adminUserId, shortUserId, nickName, diff --git a/src/http/device.ts b/src/http/device.ts index ddbfdba9..91f6e355 100644 --- a/src/http/device.ts +++ b/src/http/device.ts @@ -2226,6 +2226,10 @@ export class Device extends TypedEmitter { return DeviceType.LOCK_85D0 == type; } + static usesSecurityMqtt(type: number): boolean { + return Device.isLockWifiT85D0(type); + } + static isLockWifiT8510P(type: number, serialnumber: string): boolean { if ( type == DeviceType.LOCK_WIFI && @@ -2682,6 +2686,10 @@ export class Device extends TypedEmitter { return Device.isLockWifiT85D0(this.rawDevice.device_type); } + public usesSecurityMqtt(): boolean { + return Device.usesSecurityMqtt(this.rawDevice.device_type); + } + public isLockWifiT8510P(): boolean { return Device.isLockWifiT8510P(this.rawDevice.device_type, this.rawDevice.device_sn); } diff --git a/src/http/station.ts b/src/http/station.ts index 023f1179..f4a41403 100644 --- a/src/http/station.ts +++ b/src/http/station.ts @@ -8468,10 +8468,8 @@ export class Station extends TypedEmitter { this._sendLockV12P2PCommand(command, { property: propertyData, }); - } else if (device.isLockWifiT85D0()) { - // T85D0 Smart Lock C30: Uses BLE-over-MQTT protocol (same BLE frames as T8506, - // but sent via security-mqtt broker instead of P2P) - rootHTTPLogger.debug("Station lock device - Using security MQTT for T85D0...", { + } else if (device.usesSecurityMqtt()) { + rootHTTPLogger.debug("Station lock device - Using security MQTT...", { station: this.getSerial(), device: device.getSerial(), value: value, diff --git a/src/mqtt/security-mqtt.ts b/src/mqtt/security-mqtt.ts index 6e1288b0..866db3bd 100644 --- a/src/mqtt/security-mqtt.ts +++ b/src/mqtt/security-mqtt.ts @@ -44,7 +44,7 @@ export class SecurityMQTTService extends TypedEmitter private country: string; private subscribedLocks: Set = new Set(); - private pendingLockSubscriptions: Set = new Set(); + private pendingLockSubscriptions: Map = new Map(); private msgSeq = 1; constructor(email: string, password: string, openudid: string, country = "US") { @@ -219,9 +219,8 @@ export class SecurityMQTTService extends TypedEmitter rootMQTTLogger.info("SecurityMQTT connected successfully"); this.emit("connect"); - // Subscribe any pending locks - for (const deviceSN of this.pendingLockSubscriptions) { - this._subscribeLock(deviceSN); + for (const [deviceSN, deviceModel] of this.pendingLockSubscriptions) { + this._subscribeLock(deviceSN, deviceModel); } this.pendingLockSubscriptions.clear(); }); @@ -244,12 +243,18 @@ export class SecurityMQTTService extends TypedEmitter // ─── Lock Subscriptions ────────────────────────────────────────────────── - private _subscribeLock(deviceSN: string): void { - if (!this.client || this.subscribedLocks.has(deviceSN)) return; + private getMqttTopic(deviceModel: string, deviceSN: string, direction: "req" | "res"): string { + return `cmd/eufy_security/${deviceModel}/${deviceSN}/${direction}`; + } + + private _subscribeLock(deviceSN: string, deviceModel: string): void { + if (!this.client || this.subscribedLocks.has(deviceSN)) { + return; + } const topics = [ - `cmd/eufy_security/T85D0/${deviceSN}/res`, - `cmd/eufy_security/T85D0/${deviceSN}/req`, + this.getMqttTopic(deviceModel, deviceSN, "res"), + this.getMqttTopic(deviceModel, deviceSN, "req"), ]; for (const topic of topics) { @@ -265,11 +270,11 @@ export class SecurityMQTTService extends TypedEmitter this.subscribedLocks.add(deviceSN); } - public subscribeLock(deviceSN: string): void { + public subscribeLock(deviceSN: string, deviceModel: string): void { if (this.connected) { - this._subscribeLock(deviceSN); + this._subscribeLock(deviceSN, deviceModel); } else { - this.pendingLockSubscriptions.add(deviceSN); + this.pendingLockSubscriptions.set(deviceSN, deviceModel); } } @@ -278,6 +283,7 @@ export class SecurityMQTTService extends TypedEmitter public publishLockCommand( userId: string, deviceSN: string, + deviceModel: string, adminUserId: string, shortUserId: string, nickName: string, @@ -342,7 +348,7 @@ export class SecurityMQTTService extends TypedEmitter payload: payload, }); - const topic = `cmd/eufy_security/T85D0/${deviceSN}/req`; + const topic = this.getMqttTopic(deviceModel, deviceSN, "req"); rootMQTTLogger.debug("SecurityMQTT publishing lock command", { topic: topic, deviceSN: deviceSN, From 2b0f73d221dcf8300a459bff6814a412d5602c45 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:46:38 -0600 Subject: [PATCH 05/22] Refactor SecurityMQTTService: interfaces, enums, doc comments, naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major refactor addressing PR review comments: - Replace boolean connected/connecting flags with ConnectionState enum - Add readonly modifiers to constructor-assigned fields - Add explicit type hints to all class properties - Remove section separator comments (────) - Add doc comments to all interfaces, methods, and constants - Extract URLs into static constants - Define typed interfaces for all request/response payloads (EufyHomeLoginRequest, AiotMqttCertRequest, TransportPayload, SecurityMqttMessage, DeviceCommandPayload) - Rename publishLockCommand to lockDevice, remove unused userId param - Extract buildCommandMessage helper for MQTT envelope construction - Extract parseBleFrame helper for FF09 protocol parsing - Extract generateSeed and buildClientId helpers - Replace magic numbers 74/35 with SmartLockBleCommandFunctionType2 enum - Replace magic number 4 with LOCK_STATUS_LOCKED constant - Add named constants for TLV tags and BLE frame header - Rename short variables (trans -> transportData, lp -> lockPayload, cmdCode -> commandCode, etc.) - Use braces for all early returns - Match event naming to existing convention (spaces not hyphens) - Wrap client.end() in try-catch for safety - Add justification comment for battery >= 0 check - Log device-not-found errors instead of silently catching --- src/eufysecurity.ts | 12 +- src/mqtt/interface.ts | 4 +- src/mqtt/security-mqtt.ts | 464 ++++++++++++++++++++++++++------------ 3 files changed, 328 insertions(+), 152 deletions(-) diff --git a/src/eufysecurity.ts b/src/eufysecurity.ts index e41378d5..58105236 100644 --- a/src/eufysecurity.ts +++ b/src/eufysecurity.ts @@ -453,18 +453,21 @@ export class EufySecurity extends TypedEmitter { rootMainLogger.info("SecurityMQTT disconnected"); }); - this.securityMqttService.on("lock-status", (deviceSN, locked, battery) => { + this.securityMqttService.on("lock status", (deviceSN, locked, battery) => { this.getDevice(deviceSN) .then((device) => { device.updateProperty(PropertyName.DeviceLocked, locked); + // Battery is -1 when the heartbeat didn't include a battery TLV tag if (battery >= 0) { device.updateProperty(PropertyName.DeviceBattery, battery); } }) - .catch(() => { /* device not found */ }); + .catch((err) => { + rootMainLogger.debug("SecurityMQTT lock status update failed - device not found", { deviceSN, error: getError(ensureError(err)) }); + }); }); - this.securityMqttService.on("command-response", (deviceSN, success) => { + this.securityMqttService.on("command response", (deviceSN, success) => { rootMainLogger.debug("SecurityMQTT command response", { deviceSN, success }); }); @@ -2864,8 +2867,7 @@ export class EufySecurity extends TypedEmitter { } const device = this.devices[deviceSN]; const deviceModel = device ? device.getModel() : deviceSN; - this.securityMqttService.publishLockCommand( - this.api.getPersistentData()?.user_id || adminUserId, + this.securityMqttService.lockDevice( deviceSN, deviceModel, adminUserId, diff --git a/src/mqtt/interface.ts b/src/mqtt/interface.ts index d8db1387..62382ccb 100644 --- a/src/mqtt/interface.ts +++ b/src/mqtt/interface.ts @@ -9,6 +9,6 @@ export interface MQTTServiceEvents { export interface SecurityMQTTServiceEvents { connect: () => void; close: () => void; - "lock-status": (deviceSN: string, locked: boolean, battery: number) => void; - "command-response": (deviceSN: string, success: boolean) => void; + "lock status": (deviceSN: string, locked: boolean, battery: number) => void; + "command response": (deviceSN: string, success: boolean) => void; } diff --git a/src/mqtt/security-mqtt.ts b/src/mqtt/security-mqtt.ts index 866db3bd..03dfe9a3 100644 --- a/src/mqtt/security-mqtt.ts +++ b/src/mqtt/security-mqtt.ts @@ -9,45 +9,131 @@ import { ensureError } from "../error"; import { getError } from "../utils"; import { SmartLockP2PCommandPayloadType } from "../p2p/models"; import { getSmartLockP2PCommand } from "../p2p/utils"; -import { SmartLockCommand, SmartLockFunctionType, CommandType } from "../p2p/types"; +import { SmartLockCommand, SmartLockFunctionType, SmartLockBleCommandFunctionType2, CommandType } from "../p2p/types"; import { Lock } from "../http/device"; -// ─── Constants ─────────────────────────────────────────────────────────────── - const EUFYHOME_CLIENT_ID = "eufyhome-app"; const EUFYHOME_CLIENT_SECRET = "GQCpr9dSp3uQpsOMgJ4xQ"; -// ─── Types ─────────────────────────────────────────────────────────────────── +const EUFYHOME_LOGIN_URL = "https://home-api.eufylife.com/v1/user/email/login"; +const EUFYHOME_USER_CENTER_URL = "https://home-api.eufylife.com/v1/user/user_center_info"; +const AIOT_MQTT_CERT_URL = "https://aiot-clean-api-pr.eufylife.com/app/devicemanage/get_user_mqtt_info"; + +/** TLV tag for battery level in heartbeat responses. */ +const HEARTBEAT_TLV_TAG_BATTERY = 0xa1; +/** TLV tag for lock status in heartbeat responses. */ +const HEARTBEAT_TLV_TAG_LOCK_STATUS = 0xa2; + +/** Lock status value indicating the lock is locked. */ +const LOCK_STATUS_LOCKED = 4; + +/** BLE frame header magic bytes (FF09 protocol). */ +const BLE_FRAME_HEADER = [0xff, 0x09]; +/** Connection state for the SecurityMQTT service. */ +enum ConnectionState { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", +} + +/** mTLS certificate info returned by the AIOT MQTT endpoint. */ interface MQTTCertInfo { + /** PEM-encoded client certificate for mTLS. */ certificate_pem: string; + /** PEM-encoded private key for mTLS. */ private_key: string; + /** PEM-encoded AWS root CA certificate. */ aws_root_ca1_pem: string; + /** MQTT username (device "thing name" from AWS IoT). */ thing_name: string; + /** MQTT broker endpoint address. */ endpoint_addr: string; } -// ─── SecurityMQTTService ───────────────────────────────────────────────────── +/** Request body for the EufyHome email login endpoint. */ +interface EufyHomeLoginRequest { + client_id: string; + client_secret: string; + email: string; + password: string; +} + +/** Request body for the AIOT MQTT certificate endpoint (empty object). */ +interface AiotMqttCertRequest { + [key: string]: never; +} + +/** Transport payload forwarded from the P2P command builder to the MQTT envelope. */ +interface TransportPayload { + cmd: number; + mChannel: number; + mValue3: number; + payload: unknown; +} +/** MQTT message envelope header sent to the security broker. */ +interface SecurityMqttMessageHead { + version: string; + client_id: string; + sess_id: string; + msg_seq: number; + seed: string; + timestamp: number; + /** 2 = command request. */ + cmd_status: number; + /** 9 = send command to device, 8 = device response. */ + cmd: number; + sign_code: number; +} + +/** Full MQTT message envelope sent to the security broker. */ +interface SecurityMqttMessage { + head: SecurityMqttMessageHead; + payload: string; +} + +/** Device command payload embedded in the MQTT message envelope. */ +interface DeviceCommandPayload { + account_id: string; + device_sn: string; + trans: string; +} + +/** + * Manages BLE-over-MQTT communication with Eufy security locks. + * + * Devices that use this service (e.g. Smart Lock C30 / T85D0) send the same + * BLE command frames as other smart locks (T8506, T8502), but tunneled over + * a dedicated security MQTT broker instead of P2P or Cloud API. + * + * The auth chain is: EufyHome login -> user_center_token -> AIOT mTLS certs -> MQTT connect. + */ export class SecurityMQTTService extends TypedEmitter { private client: mqtt.MqttClient | null = null; - private connected = false; - private connecting = false; + private connectionState: ConnectionState = ConnectionState.DISCONNECTED; private mqttInfo: MQTTCertInfo | null = null; - private userCenterId = ""; - private clientId = ""; + private userCenterId: string = ""; + private clientId: string = ""; - private email: string; - private password: string; - private openudid: string; - private country: string; + private readonly email: string; + private readonly password: string; + private readonly openudid: string; + private readonly country: string; private subscribedLocks: Set = new Set(); private pendingLockSubscriptions: Map = new Map(); - private msgSeq = 1; - - constructor(email: string, password: string, openudid: string, country = "US") { + private msgSeq: number = 1; + + /** + * Creates a new SecurityMQTTService. + * @param email - Eufy account email address. + * @param password - Eufy account password. + * @param openudid - Unique device identifier for the client. + * @param country - Country code for regional API routing (default: "US"). + */ + constructor(email: string, password: string, openudid: string, country: string = "US") { super(); this.email = email; this.password = password; @@ -55,8 +141,6 @@ export class SecurityMQTTService extends TypedEmitter this.country = country; } - // ─── Auth Chain ────────────────────────────────────────────────────────── - private httpRequest( url: string, method: string, @@ -86,34 +170,43 @@ export class SecurityMQTTService extends TypedEmitter }); req.on("error", reject); - if (body) req.write(body); + if (body) { + req.write(body); + } req.end(); }); } + /** + * Authenticates with EufyHome and retrieves mTLS certificates for the MQTT broker. + * + * Steps: + * 1. EufyHome email login to get an access_token. + * 2. Exchange access_token for a user_center_token. + * 3. Use user_center_token to retrieve mTLS certificates from the AIOT endpoint. + */ private async authenticate(): Promise { - // Step 1: EufyHome login rootMQTTLogger.debug("SecurityMQTT auth step 1: EufyHome login..."); + const loginBody: EufyHomeLoginRequest = { + client_id: EUFYHOME_CLIENT_ID, + client_secret: EUFYHOME_CLIENT_SECRET, + email: this.email, + password: this.password, + }; const loginRes = await this.httpRequest( - "https://home-api.eufylife.com/v1/user/email/login", + EUFYHOME_LOGIN_URL, "POST", { "Content-Type": "application/json", category: "Home" }, - JSON.stringify({ - client_id: EUFYHOME_CLIENT_ID, - client_secret: EUFYHOME_CLIENT_SECRET, - email: this.email, - password: this.password, - }) + JSON.stringify(loginBody), ); if (!loginRes.data.access_token) { throw new Error(`EufyHome login failed: ${JSON.stringify(loginRes.data)}`); } rootMQTTLogger.debug("SecurityMQTT auth step 1: OK"); - // Step 2: User center token rootMQTTLogger.debug("SecurityMQTT auth step 2: user_center_token..."); - const ucRes = await this.httpRequest( - "https://home-api.eufylife.com/v1/user/user_center_info", + const userCenterRes = await this.httpRequest( + EUFYHOME_USER_CENTER_URL, "GET", { "Content-Type": "application/json", @@ -121,21 +214,21 @@ export class SecurityMQTTService extends TypedEmitter token: loginRes.data.access_token, } ); - if (!ucRes.data.user_center_token) { - throw new Error(`user_center_info failed: ${JSON.stringify(ucRes.data)}`); + if (!userCenterRes.data.user_center_token) { + throw new Error(`user_center_info failed: ${JSON.stringify(userCenterRes.data)}`); } - this.userCenterId = ucRes.data.user_center_id; + this.userCenterId = userCenterRes.data.user_center_id; rootMQTTLogger.debug("SecurityMQTT auth step 2: OK", { userCenterId: this.userCenterId }); - // Step 3: MQTT certificates rootMQTTLogger.debug("SecurityMQTT auth step 3: MQTT certs..."); const gtoken = createHash("md5").update(this.userCenterId).digest("hex"); - const mqttRes = await this.httpRequest( - "https://aiot-clean-api-pr.eufylife.com/app/devicemanage/get_user_mqtt_info", + const certBody: AiotMqttCertRequest = {}; + const mqttCertRes = await this.httpRequest( + AIOT_MQTT_CERT_URL, "POST", { "Content-Type": "application/json", - "X-Auth-Token": ucRes.data.user_center_token, + "X-Auth-Token": userCenterRes.data.user_center_token, GToken: gtoken, "User-Agent": "EufySecurity-Android-4.6.0-1630", category: "eufy_security", @@ -147,20 +240,18 @@ export class SecurityMQTTService extends TypedEmitter "Model-type": "PHONE", timezone: "America/New_York", }, - "{}" + JSON.stringify(certBody), ); - if (mqttRes.data.code !== 0) { - throw new Error(`MQTT certs failed: ${JSON.stringify(mqttRes.data)}`); + if (mqttCertRes.data.code !== 0) { + throw new Error(`MQTT certs failed: ${JSON.stringify(mqttCertRes.data)}`); } - this.mqttInfo = mqttRes.data.data as MQTTCertInfo; + this.mqttInfo = mqttCertRes.data.data as MQTTCertInfo; rootMQTTLogger.debug("SecurityMQTT auth step 3: OK", { thingName: this.mqttInfo.thing_name, endpointAddr: this.mqttInfo.endpoint_addr, }); } - // ─── Broker URL ────────────────────────────────────────────────────────── - private getSecurityBrokerHost(apiBase: string): string { if (apiBase.includes("-eu.")) { return "security-mqtt-eu.anker.com"; @@ -168,30 +259,49 @@ export class SecurityMQTTService extends TypedEmitter return "security-mqtt-us.anker.com"; } - // ─── Connect ───────────────────────────────────────────────────────────── + /** + * Builds the MQTT client ID from the broker host, user center ID, and openudid. + * + * The format matches the official EufySecurity Android app's client ID pattern: + * `android-eufy_security-{userCenterId}-{openudid}{hostWithoutDots}` + */ + private buildClientId(host: string): string { + const hostNoPunctuation = host.replace(/[.\-]/g, ""); + return `android-eufy_security-${this.userCenterId}-${this.openudid}${hostNoPunctuation}`; + } + /** + * Connects to the Eufy security MQTT broker. + * + * Performs the full auth chain (EufyHome login -> user center token -> mTLS certs), + * then establishes an MQTTS connection. Any locks queued via `subscribeLock()` before + * connection will be subscribed once the connection is established. + * + * @param apiBase - The Eufy API base URL, used to determine the regional broker. + */ public async connect(apiBase: string): Promise { - if (this.connected || this.connecting) return; + if (this.connectionState !== ConnectionState.DISCONNECTED) { + return; + } - this.connecting = true; + this.connectionState = ConnectionState.CONNECTING; try { await this.authenticate(); } catch (err) { - this.connecting = false; + this.connectionState = ConnectionState.DISCONNECTED; const error = ensureError(err); rootMQTTLogger.error("SecurityMQTT authentication failed", { error: getError(error) }); throw error; } if (!this.mqttInfo) { - this.connecting = false; + this.connectionState = ConnectionState.DISCONNECTED; throw new Error("SecurityMQTT: No MQTT certificates after authentication"); } const host = this.getSecurityBrokerHost(apiBase); - const endpointNoDashes = host.replace(/[.\-]/g, ""); - this.clientId = `android-eufy_security-${this.userCenterId}-${this.openudid}${endpointNoDashes}`; + this.clientId = this.buildClientId(host); rootMQTTLogger.info(`SecurityMQTT connecting to ${host}:8883`, { clientId: this.clientId, @@ -214,8 +324,7 @@ export class SecurityMQTTService extends TypedEmitter }); this.client.on("connect", () => { - this.connected = true; - this.connecting = false; + this.connectionState = ConnectionState.CONNECTED; rootMQTTLogger.info("SecurityMQTT connected successfully"); this.emit("connect"); @@ -226,13 +335,13 @@ export class SecurityMQTTService extends TypedEmitter }); this.client.on("close", () => { - this.connected = false; + this.connectionState = ConnectionState.DISCONNECTED; rootMQTTLogger.info("SecurityMQTT connection closed"); this.emit("close"); }); this.client.on("error", (error) => { - this.connecting = false; + this.connectionState = ConnectionState.DISCONNECTED; rootMQTTLogger.error("SecurityMQTT error", { error: getError(error) }); }); @@ -241,8 +350,6 @@ export class SecurityMQTTService extends TypedEmitter }); } - // ─── Lock Subscriptions ────────────────────────────────────────────────── - private getMqttTopic(deviceModel: string, deviceSN: string, direction: "req" | "res"): string { return `cmd/eufy_security/${deviceModel}/${deviceSN}/${direction}`; } @@ -271,17 +378,79 @@ export class SecurityMQTTService extends TypedEmitter } public subscribeLock(deviceSN: string, deviceModel: string): void { - if (this.connected) { + if (this.connectionState === ConnectionState.CONNECTED) { this._subscribeLock(deviceSN, deviceModel); } else { this.pendingLockSubscriptions.set(deviceSN, deviceModel); } } - // ─── Command Publishing ────────────────────────────────────────────────── + /** Generates a random hex seed string for the MQTT message envelope. */ + private generateSeed(): string { + return Array.from({ length: 16 }, () => + Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + ).join(""); + } + + /** + * Builds the MQTT message envelope for sending a command to a lock device. + * + * The message format follows the Eufy security MQTT protocol: + * - `head`: Contains session metadata, sequence number, and command type (cmd=9 for request). + * - `payload`: JSON-encoded string containing the device SN, account ID, and base64-encoded + * transport payload (which wraps the BLE command frame). + */ + private buildCommandMessage(deviceSN: string, transPayload: SmartLockP2PCommandPayloadType): SecurityMqttMessage { + const sessionId = Math.random().toString(16).substring(2, 6); + const timestamp = Math.trunc(Date.now() / 1000); + + const transportPayload: TransportPayload = { + cmd: transPayload.cmd, + mChannel: transPayload.mChannel, + mValue3: transPayload.mValue3, + payload: transPayload.payload, + }; + const transportBase64 = Buffer.from(JSON.stringify(transportPayload)).toString("base64"); + + const devicePayload: DeviceCommandPayload = { + account_id: transPayload.account_id, + device_sn: deviceSN, + trans: transportBase64, + }; + + return { + head: { + version: "1.0.0.1", + client_id: this.clientId, + sess_id: sessionId, + msg_seq: this.msgSeq++, + seed: this.generateSeed(), + timestamp: timestamp, + cmd_status: 2, + cmd: 9, + sign_code: 0, + }, + payload: JSON.stringify(devicePayload), + }; + } - public publishLockCommand( - userId: string, + /** + * Sends a lock or unlock command to a device via the security MQTT broker. + * + * Builds a BLE command frame using the shared smart lock command builder, wraps it + * in the security MQTT envelope, and publishes to the device's request topic. + * + * @param deviceSN - Serial number of the target lock device. + * @param deviceModel - Model identifier used in the MQTT topic (e.g. "T85D0"). + * @param adminUserId - Admin user ID for the lock command. + * @param shortUserId - Short user ID for the lock command. + * @param nickName - User nickname included in the lock command. + * @param channel - BLE channel number. + * @param sequence - Lock command sequence number. + * @param lock - true to lock, false to unlock. + * @returns true if the command was published successfully, false otherwise. + */ + public lockDevice( deviceSN: string, deviceModel: string, adminUserId: string, @@ -292,13 +461,12 @@ export class SecurityMQTTService extends TypedEmitter lock: boolean, ): Promise { return new Promise((resolve) => { - if (!this.client || !this.connected) { + if (!this.client || this.connectionState !== ConnectionState.CONNECTED) { rootMQTTLogger.error("SecurityMQTT not connected, cannot send lock command"); resolve(false); return; } - // Reuse the existing smart lock command builder const command = getSmartLockP2PCommand( deviceSN, adminUserId, @@ -309,44 +477,8 @@ export class SecurityMQTTService extends TypedEmitter SmartLockFunctionType.TYPE_2, ); - // Extract the trans payload from the P2P command const transPayload: SmartLockP2PCommandPayloadType = JSON.parse(command.payload.value); - - // Build MQTT message envelope - const sessId = Math.random().toString(16).substring(2, 6); - const seed = Array.from({ length: 16 }, () => - Math.floor(Math.random() * 256).toString(16).padStart(2, "0") - ).join(""); - const now = Math.trunc(Date.now() / 1000); - - const trans = { - cmd: transPayload.cmd, - mChannel: transPayload.mChannel, - mValue3: transPayload.mValue3, - payload: transPayload.payload, - }; - const transB64 = Buffer.from(JSON.stringify(trans)).toString("base64"); - - const payload = JSON.stringify({ - account_id: transPayload.account_id, - device_sn: deviceSN, - trans: transB64, - }); - - const message = JSON.stringify({ - head: { - version: "1.0.0.1", - client_id: this.clientId, - sess_id: sessId, - msg_seq: this.msgSeq++, - seed: seed, - timestamp: now, - cmd_status: 2, - cmd: 9, - sign_code: 0, - }, - payload: payload, - }); + const message = this.buildCommandMessage(deviceSN, transPayload); const topic = this.getMqttTopic(deviceModel, deviceSN, "req"); rootMQTTLogger.debug("SecurityMQTT publishing lock command", { @@ -355,7 +487,7 @@ export class SecurityMQTTService extends TypedEmitter lock: lock, }); - this.client.publish(topic, message, { qos: 1 }, (err) => { + this.client.publish(topic, JSON.stringify(message), { qos: 1 }, (err) => { if (err) { rootMQTTLogger.error("SecurityMQTT publish failed", { error: getError(err) }); resolve(false); @@ -370,56 +502,88 @@ export class SecurityMQTTService extends TypedEmitter }); } - // ─── Message Handling ──────────────────────────────────────────────────── + /** + * Parses a BLE frame from the FF09 protocol. + * + * The frame format is: [0xFF, 0x09, ...header, flags(2 bytes), ...data, checksum]. + * Flags encode: bit 14 = encrypted, bit 11 = response, bits 0-10 = command code. + * + * @returns Parsed frame fields, or null if the buffer is not a valid FF09 frame. + */ + private parseBleFrame(buffer: Buffer): { isEncrypted: boolean; isResponse: boolean; commandCode: number; data: Buffer } | null { + if (buffer.length < 10 || buffer[0] !== BLE_FRAME_HEADER[0] || buffer[1] !== BLE_FRAME_HEADER[1]) { + return null; + } + + const flags = buffer.readUInt16BE(7); + return { + isEncrypted: !!(flags & (1 << 14)), + isResponse: !!(flags & (1 << 11)), + commandCode: flags & 0x7ff, + data: buffer.subarray(9, buffer.length - 1), + }; + } + /** + * Handles incoming MQTT messages from the security broker. + * + * Only processes messages on `/res` topics (device responses). Messages on `/req` topics + * are commands sent by this or other clients and are ignored. Responses with cmd=8 in the + * head indicate device-originated messages containing BLE frames. + */ private handleMessage(topic: string, message: Buffer): void { try { const parsed = JSON.parse(message.toString()); - // Only process device responses (cmd=8) on /res topics - if (!topic.endsWith("/res")) return; + // Only process messages on /res topics (device responses) + if (!topic.endsWith("/res")) { + return; + } - if (typeof parsed.payload !== "string") return; + if (typeof parsed.payload !== "string") { + return; + } const payload = JSON.parse(parsed.payload); - if (!payload.trans) return; - - const trans = JSON.parse(Buffer.from(payload.trans, "base64").toString("utf8")); - if (trans.cmd !== CommandType.CMD_TRANSFER_PAYLOAD) return; + if (!payload.trans) { + return; + } - const lp = trans.payload; - if (!lp || !lp.lock_payload) return; + const transportData = JSON.parse(Buffer.from(payload.trans, "base64").toString("utf8")); + // CMD_TRANSFER_PAYLOAD indicates a forwarded BLE command/response + if (transportData.cmd !== CommandType.CMD_TRANSFER_PAYLOAD) { + return; + } - const deviceSN = lp.dev_sn || payload.device_sn; - const buf = Buffer.from(lp.lock_payload, "hex"); + const lockPayload = transportData.payload; + if (!lockPayload || !lockPayload.lock_payload) { + return; + } - // Parse FF09 BLE frame - if (buf.length < 10 || buf[0] !== 0xff || buf[1] !== 0x09) return; + const deviceSN = lockPayload.dev_sn || payload.device_sn; + const bleBuffer = Buffer.from(lockPayload.lock_payload, "hex"); - const flags = buf.readUInt16BE(7); - const isEncrypted = !!(flags & (1 << 14)); - const isResponse = !!(flags & (1 << 11)); - const cmdCode = flags & 0x7ff; - const data = buf.subarray(9, buf.length - 1); + const frame = this.parseBleFrame(bleBuffer); + if (!frame) { + return; + } rootMQTTLogger.debug("SecurityMQTT received BLE frame", { deviceSN: deviceSN, - flags: `0x${flags.toString(16)}`, - encrypted: isEncrypted, - response: isResponse, - cmdCode: cmdCode, + encrypted: frame.isEncrypted, + response: frame.isResponse, + commandCode: frame.commandCode, }); - if (!isEncrypted && cmdCode === 74) { - // NOTIFY heartbeat — unencrypted TLV with battery and lock status - this.parseHeartbeat(deviceSN, data); - } else if (isResponse && cmdCode === 35) { - // ON_OFF_LOCK response — command acknowledgment - // First byte after encrypted data decryption is return code (0 = success) + if (!frame.isEncrypted && frame.commandCode === SmartLockBleCommandFunctionType2.NOTIFY) { + // Heartbeat notification — unencrypted TLV containing battery level and lock status + this.parseHeartbeat(deviceSN, frame.data); + } else if (frame.isResponse && frame.commandCode === SmartLockBleCommandFunctionType2.ON_OFF_LOCK) { + // Lock/unlock command acknowledgment from the device rootMQTTLogger.info("SecurityMQTT received lock command response", { deviceSN: deviceSN, - cmdCode: cmdCode, + commandCode: frame.commandCode, }); - this.emit("command-response", deviceSN, true); + this.emit("command response", deviceSN, true); } } catch (err) { const error = ensureError(err); @@ -427,10 +591,16 @@ export class SecurityMQTTService extends TypedEmitter } } + /** + * Parses a heartbeat TLV payload to extract battery level and lock status. + * + * The TLV format uses tag 0xA1 for battery percentage and tag 0xA2 for lock status. + * A leading byte below 0xA0 is a return code and is skipped. + */ private parseHeartbeat(deviceSN: string, data: Buffer): void { let offset = 0; - // Skip return code byte if present + // Skip return code byte if present (return codes are below 0xA0) if (data.length > 0 && data[0] < 0xa0) { offset = 1; } @@ -440,42 +610,46 @@ export class SecurityMQTTService extends TypedEmitter while (offset + 2 <= data.length) { const tag = data[offset]; - const len = data[offset + 1]; - if (offset + 2 + len > data.length) break; + const length = data[offset + 1]; + if (offset + 2 + length > data.length) { + break; + } - if (tag === 0xa1 && len === 1) { + if (tag === HEARTBEAT_TLV_TAG_BATTERY && length === 1) { battery = data[offset + 2]; - } else if (tag === 0xa2 && len === 1) { + } else if (tag === HEARTBEAT_TLV_TAG_LOCK_STATUS && length === 1) { lockStatus = data[offset + 2]; } - offset += 2 + len; + offset += 2 + length; } if (lockStatus !== -1) { - const locked = lockStatus === 4; // 3=unlocked, 4=locked + const locked = lockStatus === LOCK_STATUS_LOCKED; rootMQTTLogger.info("SecurityMQTT heartbeat", { deviceSN: deviceSN, locked: locked, battery: battery, rawStatus: lockStatus, }); - this.emit("lock-status", deviceSN, locked, battery); + this.emit("lock status", deviceSN, locked, battery); } } - // ─── Lifecycle ─────────────────────────────────────────────────────────── - public isConnected(): boolean { - return this.connected; + return this.connectionState === ConnectionState.CONNECTED; } public close(): void { if (this.client) { - this.client.end(true); + try { + this.client.end(true); + } catch (err) { + const error = ensureError(err); + rootMQTTLogger.error("SecurityMQTT close error", { error: getError(error) }); + } this.client = null; - this.connected = false; - this.connecting = false; + this.connectionState = ConnectionState.DISCONNECTED; } } } From f318f967f96162e3af44c0ca44d45a642507d87b Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:52:27 -0600 Subject: [PATCH 06/22] Add unit tests for SecurityMQTTService protocol parsing Tests cover the happy paths for: - BLE frame parsing (FF09 protocol, NOTIFY/ON_OFF_LOCK commands) - Heartbeat TLV parsing (battery level, lock status, return code skipping) - MQTT topic construction - Client ID building - Seed generation - Connection state and close safety --- src/mqtt/security-mqtt.test.ts | 178 +++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/mqtt/security-mqtt.test.ts diff --git a/src/mqtt/security-mqtt.test.ts b/src/mqtt/security-mqtt.test.ts new file mode 100644 index 00000000..1ef74124 --- /dev/null +++ b/src/mqtt/security-mqtt.test.ts @@ -0,0 +1,178 @@ +import { SecurityMQTTService } from "./security-mqtt"; + +// Access private methods for testing via any cast +type TestableService = SecurityMQTTService & { + parseBleFrame(buffer: Buffer): { isEncrypted: boolean; isResponse: boolean; commandCode: number; data: Buffer } | null; + parseHeartbeat(deviceSN: string, data: Buffer): void; + buildClientId(host: string): string; + generateSeed(): string; + getMqttTopic(deviceModel: string, deviceSN: string, direction: "req" | "res"): string; +}; + +function createService(): TestableService { + return new SecurityMQTTService("test@test.com", "password", "test-udid", "US") as TestableService; +} + +describe("SecurityMQTTService", () => { + describe("parseBleFrame", () => { + const service = createService(); + + it("returns null for buffer shorter than 10 bytes", () => { + const buf = Buffer.from([0xff, 0x09, 0x00, 0x00, 0x00]); + expect(service.parseBleFrame(buf)).toBeNull(); + }); + + it("returns null for buffer without FF09 header", () => { + const buf = Buffer.alloc(12); + buf[0] = 0xaa; + buf[1] = 0xbb; + expect(service.parseBleFrame(buf)).toBeNull(); + }); + + it("parses an unencrypted NOTIFY frame (commandCode=74)", () => { + // Build a minimal FF09 frame: [FF, 09, 5 header bytes, flags(2), data..., checksum] + const buf = Buffer.alloc(12); + buf[0] = 0xff; + buf[1] = 0x09; + // flags at bytes 7-8: commandCode=74 (0x4A), no encrypted, no response + buf.writeUInt16BE(74, 7); // 0x004A + buf[9] = 0xa1; // TLV data + buf[10] = 0x01; + buf[11] = 0x00; // checksum byte + + const result = service.parseBleFrame(buf); + expect(result).not.toBeNull(); + expect(result!.isEncrypted).toBe(false); + expect(result!.isResponse).toBe(false); + expect(result!.commandCode).toBe(74); + expect(result!.data.length).toBe(2); // bytes 9..10 (excluding last checksum byte) + }); + + it("parses an encrypted response ON_OFF_LOCK frame (commandCode=35)", () => { + const buf = Buffer.alloc(11); + buf[0] = 0xff; + buf[1] = 0x09; + // flags: bit 14 (encrypted) | bit 11 (response) | 35 + // bit 14 = 0x4000, bit 11 = 0x0800, 35 = 0x0023 + const flags = 0x4000 | 0x0800 | 35; + buf.writeUInt16BE(flags, 7); + buf[9] = 0x00; // data + buf[10] = 0x00; // checksum + + const result = service.parseBleFrame(buf); + expect(result).not.toBeNull(); + expect(result!.isEncrypted).toBe(true); + expect(result!.isResponse).toBe(true); + expect(result!.commandCode).toBe(35); + }); + }); + + describe("parseHeartbeat", () => { + it("emits lock-status with battery and locked state", () => { + const service = createService(); + const emitted: { deviceSN: string; locked: boolean; battery: number }[] = []; + service.on("lock status", (deviceSN, locked, battery) => { + emitted.push({ deviceSN, locked, battery }); + }); + + // TLV: [0xA1, 0x01, 85, 0xA2, 0x01, 0x04] = battery=85, lockStatus=4 (locked) + const data = Buffer.from([0xa1, 0x01, 85, 0xa2, 0x01, 0x04]); + service.parseHeartbeat("TEST_SN_001", data); + + expect(emitted).toHaveLength(1); + expect(emitted[0].deviceSN).toBe("TEST_SN_001"); + expect(emitted[0].locked).toBe(true); + expect(emitted[0].battery).toBe(85); + }); + + it("emits unlocked state when lockStatus is 3", () => { + const service = createService(); + const emitted: { locked: boolean; battery: number }[] = []; + service.on("lock status", (_sn, locked, battery) => { + emitted.push({ locked, battery }); + }); + + const data = Buffer.from([0xa1, 0x01, 50, 0xa2, 0x01, 0x03]); + service.parseHeartbeat("TEST_SN_002", data); + + expect(emitted).toHaveLength(1); + expect(emitted[0].locked).toBe(false); + expect(emitted[0].battery).toBe(50); + }); + + it("skips return code byte when present", () => { + const service = createService(); + const emitted: { locked: boolean; battery: number }[] = []; + service.on("lock status", (_sn, locked, battery) => { + emitted.push({ locked, battery }); + }); + + // Leading 0x00 is a return code (below 0xA0), should be skipped + const data = Buffer.from([0x00, 0xa1, 0x01, 100, 0xa2, 0x01, 0x04]); + service.parseHeartbeat("TEST_SN_003", data); + + expect(emitted).toHaveLength(1); + expect(emitted[0].battery).toBe(100); + expect(emitted[0].locked).toBe(true); + }); + + it("does not emit when no lock status TLV is present", () => { + const service = createService(); + let emitCount = 0; + service.on("lock status", () => { emitCount++; }); + + // Only battery tag, no lock status + const data = Buffer.from([0xa1, 0x01, 80]); + service.parseHeartbeat("TEST_SN_004", data); + + expect(emitCount).toBe(0); + }); + }); + + describe("getMqttTopic", () => { + it("builds the correct request topic", () => { + const service = createService(); + expect(service.getMqttTopic("T85D0", "SN123", "req")) + .toBe("cmd/eufy_security/T85D0/SN123/req"); + }); + + it("builds the correct response topic", () => { + const service = createService(); + expect(service.getMqttTopic("T85D0", "SN123", "res")) + .toBe("cmd/eufy_security/T85D0/SN123/res"); + }); + }); + + describe("buildClientId", () => { + it("builds client ID matching Android app pattern", () => { + const service = createService(); + // Set userCenterId via any cast since it's private + (service as any).userCenterId = "user123"; + const result = service.buildClientId("security-mqtt-us.anker.com"); + expect(result).toBe("android-eufy_security-user123-test-udidsecuritymqttusankercom"); + }); + }); + + describe("generateSeed", () => { + it("generates a 32-character hex string", () => { + const service = createService(); + const seed = service.generateSeed(); + expect(seed).toHaveLength(32); + expect(seed).toMatch(/^[0-9a-f]{32}$/); + }); + }); + + describe("isConnected", () => { + it("returns false when not connected", () => { + const service = createService(); + expect(service.isConnected()).toBe(false); + }); + }); + + describe("close", () => { + it("does not throw when called before connect", () => { + const service = createService(); + expect(() => service.close()).not.toThrow(); + }); + }); +}); From 7501d668e6f21f4c508181f7ac82ec08a9ffeff5 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:19:27 -0600 Subject: [PATCH 07/22] Extract BleLockProtocol, add cmd/cmd_status enums, split authenticate, reorder class - Extract BLE frame parsing into BleLockProtocol class (ble-lock-protocol.ts) - Add SecurityMqttCmd and SecurityMqttCmdStatus enums for message envelope fields - Split authenticate() into authenticateEufyHome(), fetchUserCenterToken(), fetchMqttCertificates() - Reorder class: public API first, private methods grouped by concern - Rename _subscribeLock to subscribeToLockTopics (no _ prefix convention) - Make generateSeed a private static method - Add JSDoc to pendingLockSubscriptions Map - Remove AiotMqttCertRequest interface (empty object doesn't need a type) - Update tests to use BleLockProtocol directly, add isHeartbeat/isLockCommandResponse tests --- src/mqtt/ble-lock-protocol.ts | 125 +++++++ src/mqtt/security-mqtt.test.ts | 131 ++++---- src/mqtt/security-mqtt.ts | 597 ++++++++++++++++----------------- 3 files changed, 480 insertions(+), 373 deletions(-) create mode 100644 src/mqtt/ble-lock-protocol.ts diff --git a/src/mqtt/ble-lock-protocol.ts b/src/mqtt/ble-lock-protocol.ts new file mode 100644 index 00000000..9c9ba991 --- /dev/null +++ b/src/mqtt/ble-lock-protocol.ts @@ -0,0 +1,125 @@ +import { SmartLockBleCommandFunctionType2 } from "../p2p/types"; + +/** BLE frame header magic bytes (FF09 protocol). */ +const BLE_FRAME_HEADER_BYTE_0 = 0xff; +const BLE_FRAME_HEADER_BYTE_1 = 0x09; +const BLE_FRAME_MIN_LENGTH = 10; + +/** TLV tag for battery level in heartbeat responses. */ +const HEARTBEAT_TLV_TAG_BATTERY = 0xa1; +/** TLV tag for lock status in heartbeat responses. */ +const HEARTBEAT_TLV_TAG_LOCK_STATUS = 0xa2; + +/** Lock status value indicating the lock is locked. */ +const LOCK_STATUS_LOCKED = 4; + +/** Parsed result from a BLE FF09 frame. */ +export interface BleFrame { + isEncrypted: boolean; + isResponse: boolean; + commandCode: number; + data: Buffer; +} + +/** Parsed heartbeat data extracted from a NOTIFY TLV payload. */ +export interface HeartbeatData { + /** Battery percentage (0-100), or -1 if not present in the TLV. */ + battery: number; + /** Whether the lock is in the locked state. */ + locked: boolean; + /** Raw lock status byte from the TLV. */ + rawLockStatus: number; +} + +/** + * Handles BLE command frame parsing for the FF09 smart lock protocol. + * + * This is the same wire format used by T8506/T8502 smart locks over Bluetooth, + * but here the frames are transported over MQTT instead of P2P. + * + * Frame format: [0xFF, 0x09, ...header(5 bytes), flags(2 bytes), ...data, checksum(1 byte)] + * Flags: bit 14 = encrypted, bit 11 = response, bits 0-10 = command code + */ +export class BleLockProtocol { + /** + * Parses a BLE frame from the FF09 protocol. + * + * @returns Parsed frame fields, or null if the buffer is not a valid FF09 frame. + */ + static parseBleFrame(buffer: Buffer): BleFrame | null { + if (buffer.length < BLE_FRAME_MIN_LENGTH || buffer[0] !== BLE_FRAME_HEADER_BYTE_0 || buffer[1] !== BLE_FRAME_HEADER_BYTE_1) { + return null; + } + + const flags = buffer.readUInt16BE(7); + return { + isEncrypted: !!(flags & (1 << 14)), + isResponse: !!(flags & (1 << 11)), + commandCode: flags & 0x7ff, + data: buffer.subarray(9, buffer.length - 1), + }; + } + + /** + * Checks whether a parsed BLE frame is a heartbeat notification. + * + * Heartbeats are unencrypted NOTIFY frames containing TLV-encoded + * battery level and lock status. + */ + static isHeartbeat(frame: BleFrame): boolean { + return !frame.isEncrypted && frame.commandCode === SmartLockBleCommandFunctionType2.NOTIFY; + } + + /** + * Checks whether a parsed BLE frame is a lock/unlock command acknowledgment. + */ + static isLockCommandResponse(frame: BleFrame): boolean { + return frame.isResponse && frame.commandCode === SmartLockBleCommandFunctionType2.ON_OFF_LOCK; + } + + /** + * Parses a heartbeat TLV payload to extract battery level and lock status. + * + * The TLV format uses tag 0xA1 for battery percentage and tag 0xA2 for lock status. + * A leading byte below 0xA0 is a return code and is skipped. + * + * @returns Parsed heartbeat data, or null if no lock status TLV was found. + */ + static parseHeartbeat(data: Buffer): HeartbeatData | null { + let offset = 0; + + // Skip return code byte if present (return codes are below 0xA0) + if (data.length > 0 && data[0] < 0xa0) { + offset = 1; + } + + let battery = -1; + let lockStatus = -1; + + while (offset + 2 <= data.length) { + const tag = data[offset]; + const length = data[offset + 1]; + if (offset + 2 + length > data.length) { + break; + } + + if (tag === HEARTBEAT_TLV_TAG_BATTERY && length === 1) { + battery = data[offset + 2]; + } else if (tag === HEARTBEAT_TLV_TAG_LOCK_STATUS && length === 1) { + lockStatus = data[offset + 2]; + } + + offset += 2 + length; + } + + if (lockStatus === -1) { + return null; + } + + return { + battery, + locked: lockStatus === LOCK_STATUS_LOCKED, + rawLockStatus: lockStatus, + }; + } +} diff --git a/src/mqtt/security-mqtt.test.ts b/src/mqtt/security-mqtt.test.ts index 1ef74124..37fc13df 100644 --- a/src/mqtt/security-mqtt.test.ts +++ b/src/mqtt/security-mqtt.test.ts @@ -1,32 +1,24 @@ import { SecurityMQTTService } from "./security-mqtt"; +import { BleLockProtocol } from "./ble-lock-protocol"; -// Access private methods for testing via any cast -type TestableService = SecurityMQTTService & { - parseBleFrame(buffer: Buffer): { isEncrypted: boolean; isResponse: boolean; commandCode: number; data: Buffer } | null; - parseHeartbeat(deviceSN: string, data: Buffer): void; - buildClientId(host: string): string; - generateSeed(): string; - getMqttTopic(deviceModel: string, deviceSN: string, direction: "req" | "res"): string; -}; - -function createService(): TestableService { - return new SecurityMQTTService("test@test.com", "password", "test-udid", "US") as TestableService; +/* eslint-disable @typescript-eslint/no-explicit-any */ + +function createService(): any { + return new SecurityMQTTService("test@test.com", "password", "test-udid", "US"); } -describe("SecurityMQTTService", () => { +describe("BleLockProtocol", () => { describe("parseBleFrame", () => { - const service = createService(); - it("returns null for buffer shorter than 10 bytes", () => { const buf = Buffer.from([0xff, 0x09, 0x00, 0x00, 0x00]); - expect(service.parseBleFrame(buf)).toBeNull(); + expect(BleLockProtocol.parseBleFrame(buf)).toBeNull(); }); it("returns null for buffer without FF09 header", () => { const buf = Buffer.alloc(12); buf[0] = 0xaa; buf[1] = 0xbb; - expect(service.parseBleFrame(buf)).toBeNull(); + expect(BleLockProtocol.parseBleFrame(buf)).toBeNull(); }); it("parses an unencrypted NOTIFY frame (commandCode=74)", () => { @@ -40,7 +32,7 @@ describe("SecurityMQTTService", () => { buf[10] = 0x01; buf[11] = 0x00; // checksum byte - const result = service.parseBleFrame(buf); + const result = BleLockProtocol.parseBleFrame(buf); expect(result).not.toBeNull(); expect(result!.isEncrypted).toBe(false); expect(result!.isResponse).toBe(false); @@ -59,7 +51,7 @@ describe("SecurityMQTTService", () => { buf[9] = 0x00; // data buf[10] = 0x00; // checksum - const result = service.parseBleFrame(buf); + const result = BleLockProtocol.parseBleFrame(buf); expect(result).not.toBeNull(); expect(result!.isEncrypted).toBe(true); expect(result!.isResponse).toBe(true); @@ -67,68 +59,88 @@ describe("SecurityMQTTService", () => { }); }); - describe("parseHeartbeat", () => { - it("emits lock-status with battery and locked state", () => { - const service = createService(); - const emitted: { deviceSN: string; locked: boolean; battery: number }[] = []; - service.on("lock status", (deviceSN, locked, battery) => { - emitted.push({ deviceSN, locked, battery }); - }); + describe("isHeartbeat", () => { + it("returns true for unencrypted NOTIFY frame", () => { + expect(BleLockProtocol.isHeartbeat({ + isEncrypted: false, + isResponse: false, + commandCode: 74, // SmartLockBleCommandFunctionType2.NOTIFY + data: Buffer.alloc(0), + })).toBe(true); + }); + + it("returns false for encrypted NOTIFY frame", () => { + expect(BleLockProtocol.isHeartbeat({ + isEncrypted: true, + isResponse: false, + commandCode: 74, + data: Buffer.alloc(0), + })).toBe(false); + }); + }); + + describe("isLockCommandResponse", () => { + it("returns true for response ON_OFF_LOCK frame", () => { + expect(BleLockProtocol.isLockCommandResponse({ + isEncrypted: false, + isResponse: true, + commandCode: 35, // SmartLockBleCommandFunctionType2.ON_OFF_LOCK + data: Buffer.alloc(0), + })).toBe(true); + }); + + it("returns false for non-response frame", () => { + expect(BleLockProtocol.isLockCommandResponse({ + isEncrypted: false, + isResponse: false, + commandCode: 35, + data: Buffer.alloc(0), + })).toBe(false); + }); + }); + describe("parseHeartbeat", () => { + it("parses battery and locked state", () => { // TLV: [0xA1, 0x01, 85, 0xA2, 0x01, 0x04] = battery=85, lockStatus=4 (locked) const data = Buffer.from([0xa1, 0x01, 85, 0xa2, 0x01, 0x04]); - service.parseHeartbeat("TEST_SN_001", data); + const result = BleLockProtocol.parseHeartbeat(data); - expect(emitted).toHaveLength(1); - expect(emitted[0].deviceSN).toBe("TEST_SN_001"); - expect(emitted[0].locked).toBe(true); - expect(emitted[0].battery).toBe(85); + expect(result).not.toBeNull(); + expect(result!.battery).toBe(85); + expect(result!.locked).toBe(true); + expect(result!.rawLockStatus).toBe(4); }); - it("emits unlocked state when lockStatus is 3", () => { - const service = createService(); - const emitted: { locked: boolean; battery: number }[] = []; - service.on("lock status", (_sn, locked, battery) => { - emitted.push({ locked, battery }); - }); - + it("parses unlocked state when lockStatus is 3", () => { const data = Buffer.from([0xa1, 0x01, 50, 0xa2, 0x01, 0x03]); - service.parseHeartbeat("TEST_SN_002", data); + const result = BleLockProtocol.parseHeartbeat(data); - expect(emitted).toHaveLength(1); - expect(emitted[0].locked).toBe(false); - expect(emitted[0].battery).toBe(50); + expect(result).not.toBeNull(); + expect(result!.locked).toBe(false); + expect(result!.battery).toBe(50); }); it("skips return code byte when present", () => { - const service = createService(); - const emitted: { locked: boolean; battery: number }[] = []; - service.on("lock status", (_sn, locked, battery) => { - emitted.push({ locked, battery }); - }); - // Leading 0x00 is a return code (below 0xA0), should be skipped const data = Buffer.from([0x00, 0xa1, 0x01, 100, 0xa2, 0x01, 0x04]); - service.parseHeartbeat("TEST_SN_003", data); + const result = BleLockProtocol.parseHeartbeat(data); - expect(emitted).toHaveLength(1); - expect(emitted[0].battery).toBe(100); - expect(emitted[0].locked).toBe(true); + expect(result).not.toBeNull(); + expect(result!.battery).toBe(100); + expect(result!.locked).toBe(true); }); - it("does not emit when no lock status TLV is present", () => { - const service = createService(); - let emitCount = 0; - service.on("lock status", () => { emitCount++; }); - + it("returns null when no lock status TLV is present", () => { // Only battery tag, no lock status const data = Buffer.from([0xa1, 0x01, 80]); - service.parseHeartbeat("TEST_SN_004", data); + const result = BleLockProtocol.parseHeartbeat(data); - expect(emitCount).toBe(0); + expect(result).toBeNull(); }); }); +}); +describe("SecurityMQTTService", () => { describe("getMqttTopic", () => { it("builds the correct request topic", () => { const service = createService(); @@ -155,8 +167,7 @@ describe("SecurityMQTTService", () => { describe("generateSeed", () => { it("generates a 32-character hex string", () => { - const service = createService(); - const seed = service.generateSeed(); + const seed = (SecurityMQTTService as any).generateSeed(); expect(seed).toHaveLength(32); expect(seed).toMatch(/^[0-9a-f]{32}$/); }); diff --git a/src/mqtt/security-mqtt.ts b/src/mqtt/security-mqtt.ts index 03dfe9a3..faeb0ce3 100644 --- a/src/mqtt/security-mqtt.ts +++ b/src/mqtt/security-mqtt.ts @@ -4,12 +4,13 @@ import { createHash } from "crypto"; import { TypedEmitter } from "tiny-typed-emitter"; import { SecurityMQTTServiceEvents } from "./interface"; +import { BleLockProtocol } from "./ble-lock-protocol"; import { rootMQTTLogger } from "../logging"; import { ensureError } from "../error"; import { getError } from "../utils"; import { SmartLockP2PCommandPayloadType } from "../p2p/models"; import { getSmartLockP2PCommand } from "../p2p/utils"; -import { SmartLockCommand, SmartLockFunctionType, SmartLockBleCommandFunctionType2, CommandType } from "../p2p/types"; +import { SmartLockCommand, SmartLockFunctionType, CommandType } from "../p2p/types"; import { Lock } from "../http/device"; const EUFYHOME_CLIENT_ID = "eufyhome-app"; @@ -19,17 +20,6 @@ const EUFYHOME_LOGIN_URL = "https://home-api.eufylife.com/v1/user/email/login"; const EUFYHOME_USER_CENTER_URL = "https://home-api.eufylife.com/v1/user/user_center_info"; const AIOT_MQTT_CERT_URL = "https://aiot-clean-api-pr.eufylife.com/app/devicemanage/get_user_mqtt_info"; -/** TLV tag for battery level in heartbeat responses. */ -const HEARTBEAT_TLV_TAG_BATTERY = 0xa1; -/** TLV tag for lock status in heartbeat responses. */ -const HEARTBEAT_TLV_TAG_LOCK_STATUS = 0xa2; - -/** Lock status value indicating the lock is locked. */ -const LOCK_STATUS_LOCKED = 4; - -/** BLE frame header magic bytes (FF09 protocol). */ -const BLE_FRAME_HEADER = [0xff, 0x09]; - /** Connection state for the SecurityMQTT service. */ enum ConnectionState { DISCONNECTED = "disconnected", @@ -37,6 +27,20 @@ enum ConnectionState { CONNECTED = "connected", } +/** Command type in the security MQTT message envelope. */ +enum SecurityMqttCmd { + /** Response from device. */ + DEVICE_RESPONSE = 8, + /** Command sent to device. */ + SEND_COMMAND = 9, +} + +/** Command status in the security MQTT message envelope. */ +enum SecurityMqttCmdStatus { + /** Command request. */ + REQUEST = 2, +} + /** mTLS certificate info returned by the AIOT MQTT endpoint. */ interface MQTTCertInfo { /** PEM-encoded client certificate for mTLS. */ @@ -59,11 +63,6 @@ interface EufyHomeLoginRequest { password: string; } -/** Request body for the AIOT MQTT certificate endpoint (empty object). */ -interface AiotMqttCertRequest { - [key: string]: never; -} - /** Transport payload forwarded from the P2P command builder to the MQTT envelope. */ interface TransportPayload { cmd: number; @@ -80,10 +79,10 @@ interface SecurityMqttMessageHead { msg_seq: number; seed: string; timestamp: number; - /** 2 = command request. */ - cmd_status: number; - /** 9 = send command to device, 8 = device response. */ - cmd: number; + /** Command status (e.g. REQUEST = 2). */ + cmd_status: SecurityMqttCmdStatus; + /** Command type (e.g. SEND_COMMAND = 9, DEVICE_RESPONSE = 8). */ + cmd: SecurityMqttCmd; sign_code: number; } @@ -123,7 +122,8 @@ export class SecurityMQTTService extends TypedEmitter private readonly country: string; private subscribedLocks: Set = new Set(); - private pendingLockSubscriptions: Map = new Map(); + /** Maps device serial number to device model for locks awaiting MQTT subscription. */ + private pendingLockSubscriptions = new Map(); private msgSeq: number = 1; /** @@ -141,134 +141,7 @@ export class SecurityMQTTService extends TypedEmitter this.country = country; } - private httpRequest( - url: string, - method: string, - headers: Record, - body?: string - ): Promise<{ status: number; data: any }> { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const reqOpts: https.RequestOptions = { - hostname: urlObj.hostname, - port: urlObj.port || 443, - path: urlObj.pathname + urlObj.search, - method: method, - headers: headers, - }; - - const req = https.request(reqOpts, (res) => { - let data = ""; - res.on("data", (chunk: string) => (data += chunk)); - res.on("end", () => { - try { - resolve({ status: res.statusCode || 0, data: JSON.parse(data) }); - } catch { - resolve({ status: res.statusCode || 0, data: data }); - } - }); - }); - - req.on("error", reject); - if (body) { - req.write(body); - } - req.end(); - }); - } - - /** - * Authenticates with EufyHome and retrieves mTLS certificates for the MQTT broker. - * - * Steps: - * 1. EufyHome email login to get an access_token. - * 2. Exchange access_token for a user_center_token. - * 3. Use user_center_token to retrieve mTLS certificates from the AIOT endpoint. - */ - private async authenticate(): Promise { - rootMQTTLogger.debug("SecurityMQTT auth step 1: EufyHome login..."); - const loginBody: EufyHomeLoginRequest = { - client_id: EUFYHOME_CLIENT_ID, - client_secret: EUFYHOME_CLIENT_SECRET, - email: this.email, - password: this.password, - }; - const loginRes = await this.httpRequest( - EUFYHOME_LOGIN_URL, - "POST", - { "Content-Type": "application/json", category: "Home" }, - JSON.stringify(loginBody), - ); - if (!loginRes.data.access_token) { - throw new Error(`EufyHome login failed: ${JSON.stringify(loginRes.data)}`); - } - rootMQTTLogger.debug("SecurityMQTT auth step 1: OK"); - - rootMQTTLogger.debug("SecurityMQTT auth step 2: user_center_token..."); - const userCenterRes = await this.httpRequest( - EUFYHOME_USER_CENTER_URL, - "GET", - { - "Content-Type": "application/json", - category: "Home", - token: loginRes.data.access_token, - } - ); - if (!userCenterRes.data.user_center_token) { - throw new Error(`user_center_info failed: ${JSON.stringify(userCenterRes.data)}`); - } - this.userCenterId = userCenterRes.data.user_center_id; - rootMQTTLogger.debug("SecurityMQTT auth step 2: OK", { userCenterId: this.userCenterId }); - - rootMQTTLogger.debug("SecurityMQTT auth step 3: MQTT certs..."); - const gtoken = createHash("md5").update(this.userCenterId).digest("hex"); - const certBody: AiotMqttCertRequest = {}; - const mqttCertRes = await this.httpRequest( - AIOT_MQTT_CERT_URL, - "POST", - { - "Content-Type": "application/json", - "X-Auth-Token": userCenterRes.data.user_center_token, - GToken: gtoken, - "User-Agent": "EufySecurity-Android-4.6.0-1630", - category: "eufy_security", - "App-name": "eufy_security", - openudid: this.openudid, - language: "en", - country: this.country, - "Os-version": "Android", - "Model-type": "PHONE", - timezone: "America/New_York", - }, - JSON.stringify(certBody), - ); - if (mqttCertRes.data.code !== 0) { - throw new Error(`MQTT certs failed: ${JSON.stringify(mqttCertRes.data)}`); - } - this.mqttInfo = mqttCertRes.data.data as MQTTCertInfo; - rootMQTTLogger.debug("SecurityMQTT auth step 3: OK", { - thingName: this.mqttInfo.thing_name, - endpointAddr: this.mqttInfo.endpoint_addr, - }); - } - - private getSecurityBrokerHost(apiBase: string): string { - if (apiBase.includes("-eu.")) { - return "security-mqtt-eu.anker.com"; - } - return "security-mqtt-us.anker.com"; - } - - /** - * Builds the MQTT client ID from the broker host, user center ID, and openudid. - * - * The format matches the official EufySecurity Android app's client ID pattern: - * `android-eufy_security-{userCenterId}-{openudid}{hostWithoutDots}` - */ - private buildClientId(host: string): string { - const hostNoPunctuation = host.replace(/[.\-]/g, ""); - return `android-eufy_security-${this.userCenterId}-${this.openudid}${hostNoPunctuation}`; - } + // ─── Public API ────────────────────────────────────────────────────────── /** * Connects to the Eufy security MQTT broker. @@ -329,7 +202,7 @@ export class SecurityMQTTService extends TypedEmitter this.emit("connect"); for (const [deviceSN, deviceModel] of this.pendingLockSubscriptions) { - this._subscribeLock(deviceSN, deviceModel); + this.subscribeToLockTopics(deviceSN, deviceModel); } this.pendingLockSubscriptions.clear(); }); @@ -350,90 +223,15 @@ export class SecurityMQTTService extends TypedEmitter }); } - private getMqttTopic(deviceModel: string, deviceSN: string, direction: "req" | "res"): string { - return `cmd/eufy_security/${deviceModel}/${deviceSN}/${direction}`; - } - - private _subscribeLock(deviceSN: string, deviceModel: string): void { - if (!this.client || this.subscribedLocks.has(deviceSN)) { - return; - } - - const topics = [ - this.getMqttTopic(deviceModel, deviceSN, "res"), - this.getMqttTopic(deviceModel, deviceSN, "req"), - ]; - - for (const topic of topics) { - this.client.subscribe(topic, { qos: 1 }, (err) => { - if (err) { - rootMQTTLogger.error(`SecurityMQTT subscribe failed: ${topic}`, { error: getError(err) }); - } else { - rootMQTTLogger.info(`SecurityMQTT subscribed: ${topic}`); - } - }); - } - - this.subscribedLocks.add(deviceSN); - } - + /** Queues or immediately subscribes to MQTT topics for a lock device. */ public subscribeLock(deviceSN: string, deviceModel: string): void { if (this.connectionState === ConnectionState.CONNECTED) { - this._subscribeLock(deviceSN, deviceModel); + this.subscribeToLockTopics(deviceSN, deviceModel); } else { this.pendingLockSubscriptions.set(deviceSN, deviceModel); } } - /** Generates a random hex seed string for the MQTT message envelope. */ - private generateSeed(): string { - return Array.from({ length: 16 }, () => - Math.floor(Math.random() * 256).toString(16).padStart(2, "0") - ).join(""); - } - - /** - * Builds the MQTT message envelope for sending a command to a lock device. - * - * The message format follows the Eufy security MQTT protocol: - * - `head`: Contains session metadata, sequence number, and command type (cmd=9 for request). - * - `payload`: JSON-encoded string containing the device SN, account ID, and base64-encoded - * transport payload (which wraps the BLE command frame). - */ - private buildCommandMessage(deviceSN: string, transPayload: SmartLockP2PCommandPayloadType): SecurityMqttMessage { - const sessionId = Math.random().toString(16).substring(2, 6); - const timestamp = Math.trunc(Date.now() / 1000); - - const transportPayload: TransportPayload = { - cmd: transPayload.cmd, - mChannel: transPayload.mChannel, - mValue3: transPayload.mValue3, - payload: transPayload.payload, - }; - const transportBase64 = Buffer.from(JSON.stringify(transportPayload)).toString("base64"); - - const devicePayload: DeviceCommandPayload = { - account_id: transPayload.account_id, - device_sn: deviceSN, - trans: transportBase64, - }; - - return { - head: { - version: "1.0.0.1", - client_id: this.clientId, - sess_id: sessionId, - msg_seq: this.msgSeq++, - seed: this.generateSeed(), - timestamp: timestamp, - cmd_status: 2, - cmd: 9, - sign_code: 0, - }, - payload: JSON.stringify(devicePayload), - }; - } - /** * Sends a lock or unlock command to a device via the security MQTT broker. * @@ -502,40 +300,269 @@ export class SecurityMQTTService extends TypedEmitter }); } + public isConnected(): boolean { + return this.connectionState === ConnectionState.CONNECTED; + } + + public close(): void { + if (this.client) { + try { + this.client.end(true); + } catch (err) { + const error = ensureError(err); + rootMQTTLogger.error("SecurityMQTT close error", { error: getError(error) }); + } + this.client = null; + this.connectionState = ConnectionState.DISCONNECTED; + } + } + + // ─── Private: Authentication ───────────────────────────────────────────── + /** - * Parses a BLE frame from the FF09 protocol. + * Authenticates with EufyHome and retrieves mTLS certificates for the MQTT broker. * - * The frame format is: [0xFF, 0x09, ...header, flags(2 bytes), ...data, checksum]. - * Flags encode: bit 14 = encrypted, bit 11 = response, bits 0-10 = command code. + * Steps: + * 1. EufyHome email login to get an access_token. + * 2. Exchange access_token for a user_center_token. + * 3. Use user_center_token to retrieve mTLS certificates from the AIOT endpoint. + */ + private async authenticate(): Promise { + const accessToken = await this.authenticateEufyHome(); + const userCenterToken = await this.fetchUserCenterToken(accessToken); + await this.fetchMqttCertificates(userCenterToken); + } + + /** Step 1: Logs in to the EufyHome API and returns an access token. */ + private async authenticateEufyHome(): Promise { + rootMQTTLogger.debug("SecurityMQTT auth step 1: EufyHome login..."); + const loginBody: EufyHomeLoginRequest = { + client_id: EUFYHOME_CLIENT_ID, + client_secret: EUFYHOME_CLIENT_SECRET, + email: this.email, + password: this.password, + }; + const loginRes = await this.httpRequest( + EUFYHOME_LOGIN_URL, + "POST", + { "Content-Type": "application/json", category: "Home" }, + JSON.stringify(loginBody), + ); + if (!loginRes.data.access_token) { + throw new Error(`EufyHome login failed: ${JSON.stringify(loginRes.data)}`); + } + rootMQTTLogger.debug("SecurityMQTT auth step 1: OK"); + return loginRes.data.access_token; + } + + /** Step 2: Exchanges the access token for a user center token and user center ID. */ + private async fetchUserCenterToken(accessToken: string): Promise { + rootMQTTLogger.debug("SecurityMQTT auth step 2: user_center_token..."); + const userCenterRes = await this.httpRequest( + EUFYHOME_USER_CENTER_URL, + "GET", + { + "Content-Type": "application/json", + category: "Home", + token: accessToken, + } + ); + if (!userCenterRes.data.user_center_token) { + throw new Error(`user_center_info failed: ${JSON.stringify(userCenterRes.data)}`); + } + this.userCenterId = userCenterRes.data.user_center_id; + rootMQTTLogger.debug("SecurityMQTT auth step 2: OK", { userCenterId: this.userCenterId }); + return userCenterRes.data.user_center_token; + } + + /** Step 3: Uses the user center token to retrieve mTLS certificates from the AIOT endpoint. */ + private async fetchMqttCertificates(userCenterToken: string): Promise { + rootMQTTLogger.debug("SecurityMQTT auth step 3: MQTT certs..."); + const gtoken = createHash("md5").update(this.userCenterId).digest("hex"); + const mqttCertRes = await this.httpRequest( + AIOT_MQTT_CERT_URL, + "POST", + { + "Content-Type": "application/json", + "X-Auth-Token": userCenterToken, + GToken: gtoken, + "User-Agent": "EufySecurity-Android-4.6.0-1630", + category: "eufy_security", + "App-name": "eufy_security", + openudid: this.openudid, + language: "en", + country: this.country, + "Os-version": "Android", + "Model-type": "PHONE", + timezone: "America/New_York", + }, + JSON.stringify({}), + ); + if (mqttCertRes.data.code !== 0) { + throw new Error(`MQTT certs failed: ${JSON.stringify(mqttCertRes.data)}`); + } + this.mqttInfo = mqttCertRes.data.data as MQTTCertInfo; + rootMQTTLogger.debug("SecurityMQTT auth step 3: OK", { + thingName: this.mqttInfo.thing_name, + endpointAddr: this.mqttInfo.endpoint_addr, + }); + } + + /** + * Makes an HTTPS request and returns the parsed response. + * + * Uses Node's built-in `https` module rather than a third-party library to avoid + * adding dependencies. The EufyHome API is a separate service from the main Eufy + * security API (which uses the `got` library in src/http/api.ts). + */ + private httpRequest( + url: string, + method: string, + headers: Record, + body?: string + ): Promise<{ status: number; data: any }> { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const reqOpts: https.RequestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || 443, + path: urlObj.pathname + urlObj.search, + method: method, + headers: headers, + }; + + const req = https.request(reqOpts, (res) => { + let data = ""; + res.on("data", (chunk: string) => (data += chunk)); + res.on("end", () => { + try { + resolve({ status: res.statusCode || 0, data: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode || 0, data: data }); + } + }); + }); + + req.on("error", reject); + if (body) { + req.write(body); + } + req.end(); + }); + } + + // ─── Private: MQTT Helpers ─────────────────────────────────────────────── + + private getSecurityBrokerHost(apiBase: string): string { + if (apiBase.includes("-eu.")) { + return "security-mqtt-eu.anker.com"; + } + return "security-mqtt-us.anker.com"; + } + + /** + * Builds the MQTT client ID from the broker host, user center ID, and openudid. * - * @returns Parsed frame fields, or null if the buffer is not a valid FF09 frame. + * The format matches the official EufySecurity Android app's client ID pattern: + * `android-eufy_security-{userCenterId}-{openudid}{hostWithoutDots}` */ - private parseBleFrame(buffer: Buffer): { isEncrypted: boolean; isResponse: boolean; commandCode: number; data: Buffer } | null { - if (buffer.length < 10 || buffer[0] !== BLE_FRAME_HEADER[0] || buffer[1] !== BLE_FRAME_HEADER[1]) { - return null; + private buildClientId(host: string): string { + const hostNoPunctuation = host.replace(/[.\-]/g, ""); + return `android-eufy_security-${this.userCenterId}-${this.openudid}${hostNoPunctuation}`; + } + + private getMqttTopic(deviceModel: string, deviceSN: string, direction: "req" | "res"): string { + return `cmd/eufy_security/${deviceModel}/${deviceSN}/${direction}`; + } + + /** Subscribes to request and response MQTT topics for a lock device. */ + private subscribeToLockTopics(deviceSN: string, deviceModel: string): void { + if (!this.client || this.subscribedLocks.has(deviceSN)) { + return; } - const flags = buffer.readUInt16BE(7); + const topics = [ + this.getMqttTopic(deviceModel, deviceSN, "res"), + this.getMqttTopic(deviceModel, deviceSN, "req"), + ]; + + for (const topic of topics) { + this.client.subscribe(topic, { qos: 1 }, (err) => { + if (err) { + rootMQTTLogger.error(`SecurityMQTT subscribe failed: ${topic}`, { error: getError(err) }); + } else { + rootMQTTLogger.info(`SecurityMQTT subscribed: ${topic}`); + } + }); + } + + this.subscribedLocks.add(deviceSN); + } + + /** Generates a random 32-character hex string for the MQTT message seed. */ + private static generateSeed(): string { + return Array.from({ length: 16 }, () => + Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + ).join(""); + } + + // ─── Private: Command Building ─────────────────────────────────────────── + + /** + * Builds the MQTT message envelope for sending a command to a lock device. + * + * The message format follows the Eufy security MQTT protocol: + * - `head`: Contains session metadata, sequence number, and command type. + * - `payload`: JSON-encoded string containing the device SN, account ID, and base64-encoded + * transport payload (which wraps the BLE command frame). + */ + private buildCommandMessage(deviceSN: string, transPayload: SmartLockP2PCommandPayloadType): SecurityMqttMessage { + const sessionId = Math.random().toString(16).substring(2, 6); + const timestamp = Math.trunc(Date.now() / 1000); + + const transportPayload: TransportPayload = { + cmd: transPayload.cmd, + mChannel: transPayload.mChannel, + mValue3: transPayload.mValue3, + payload: transPayload.payload, + }; + const transportBase64 = Buffer.from(JSON.stringify(transportPayload)).toString("base64"); + + const devicePayload: DeviceCommandPayload = { + account_id: transPayload.account_id, + device_sn: deviceSN, + trans: transportBase64, + }; + return { - isEncrypted: !!(flags & (1 << 14)), - isResponse: !!(flags & (1 << 11)), - commandCode: flags & 0x7ff, - data: buffer.subarray(9, buffer.length - 1), + head: { + version: "1.0.0.1", + client_id: this.clientId, + sess_id: sessionId, + msg_seq: this.msgSeq++, + seed: SecurityMQTTService.generateSeed(), + timestamp: timestamp, + cmd_status: SecurityMqttCmdStatus.REQUEST, + cmd: SecurityMqttCmd.SEND_COMMAND, + sign_code: 0, + }, + payload: JSON.stringify(devicePayload), }; } + // ─── Private: Message Handling ─────────────────────────────────────────── + /** * Handles incoming MQTT messages from the security broker. * - * Only processes messages on `/res` topics (device responses). Messages on `/req` topics - * are commands sent by this or other clients and are ignored. Responses with cmd=8 in the - * head indicate device-originated messages containing BLE frames. + * Only processes messages on `/res` topics (device responses). Responses containing + * CMD_TRANSFER_PAYLOAD transport data carry BLE frames from the lock device, which + * are parsed by {@link BleLockProtocol}. */ private handleMessage(topic: string, message: Buffer): void { try { const parsed = JSON.parse(message.toString()); - // Only process messages on /res topics (device responses) if (!topic.endsWith("/res")) { return; } @@ -549,7 +576,6 @@ export class SecurityMQTTService extends TypedEmitter } const transportData = JSON.parse(Buffer.from(payload.trans, "base64").toString("utf8")); - // CMD_TRANSFER_PAYLOAD indicates a forwarded BLE command/response if (transportData.cmd !== CommandType.CMD_TRANSFER_PAYLOAD) { return; } @@ -562,7 +588,7 @@ export class SecurityMQTTService extends TypedEmitter const deviceSN = lockPayload.dev_sn || payload.device_sn; const bleBuffer = Buffer.from(lockPayload.lock_payload, "hex"); - const frame = this.parseBleFrame(bleBuffer); + const frame = BleLockProtocol.parseBleFrame(bleBuffer); if (!frame) { return; } @@ -574,11 +600,18 @@ export class SecurityMQTTService extends TypedEmitter commandCode: frame.commandCode, }); - if (!frame.isEncrypted && frame.commandCode === SmartLockBleCommandFunctionType2.NOTIFY) { - // Heartbeat notification — unencrypted TLV containing battery level and lock status - this.parseHeartbeat(deviceSN, frame.data); - } else if (frame.isResponse && frame.commandCode === SmartLockBleCommandFunctionType2.ON_OFF_LOCK) { - // Lock/unlock command acknowledgment from the device + if (BleLockProtocol.isHeartbeat(frame)) { + const heartbeat = BleLockProtocol.parseHeartbeat(frame.data); + if (heartbeat) { + rootMQTTLogger.info("SecurityMQTT heartbeat", { + deviceSN: deviceSN, + locked: heartbeat.locked, + battery: heartbeat.battery, + rawStatus: heartbeat.rawLockStatus, + }); + this.emit("lock status", deviceSN, heartbeat.locked, heartbeat.battery); + } + } else if (BleLockProtocol.isLockCommandResponse(frame)) { rootMQTTLogger.info("SecurityMQTT received lock command response", { deviceSN: deviceSN, commandCode: frame.commandCode, @@ -590,66 +623,4 @@ export class SecurityMQTTService extends TypedEmitter rootMQTTLogger.error("SecurityMQTT message parse error", { error: getError(error) }); } } - - /** - * Parses a heartbeat TLV payload to extract battery level and lock status. - * - * The TLV format uses tag 0xA1 for battery percentage and tag 0xA2 for lock status. - * A leading byte below 0xA0 is a return code and is skipped. - */ - private parseHeartbeat(deviceSN: string, data: Buffer): void { - let offset = 0; - - // Skip return code byte if present (return codes are below 0xA0) - if (data.length > 0 && data[0] < 0xa0) { - offset = 1; - } - - let battery = -1; - let lockStatus = -1; - - while (offset + 2 <= data.length) { - const tag = data[offset]; - const length = data[offset + 1]; - if (offset + 2 + length > data.length) { - break; - } - - if (tag === HEARTBEAT_TLV_TAG_BATTERY && length === 1) { - battery = data[offset + 2]; - } else if (tag === HEARTBEAT_TLV_TAG_LOCK_STATUS && length === 1) { - lockStatus = data[offset + 2]; - } - - offset += 2 + length; - } - - if (lockStatus !== -1) { - const locked = lockStatus === LOCK_STATUS_LOCKED; - rootMQTTLogger.info("SecurityMQTT heartbeat", { - deviceSN: deviceSN, - locked: locked, - battery: battery, - rawStatus: lockStatus, - }); - this.emit("lock status", deviceSN, locked, battery); - } - } - - public isConnected(): boolean { - return this.connectionState === ConnectionState.CONNECTED; - } - - public close(): void { - if (this.client) { - try { - this.client.end(true); - } catch (err) { - const error = ensureError(err); - rootMQTTLogger.error("SecurityMQTT close error", { error: getError(error) }); - } - this.client = null; - this.connectionState = ConnectionState.DISCONNECTED; - } - } } From fd7ec467f6573fc5acb0763528ada82773fc09bf Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:58:03 -0600 Subject: [PATCH 08/22] Add T85D0 to P2P property update and user password conditional checks T85D0 shares the same P2P command handling as T8506/T8502 for property updates and user management (only lock/unlock is routed via MQTT). --- src/eufysecurity.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/eufysecurity.ts b/src/eufysecurity.ts index 58105236..41cdc77c 100644 --- a/src/eufysecurity.ts +++ b/src/eufysecurity.ts @@ -2430,7 +2430,8 @@ export class EufySecurity extends TypedEmitter { !device.isLockWifiT8502() && !device.isLockWifiT8510P() && !device.isLockWifiT8520P() && - !device.isLockWifiT85L0()) || + !device.isLockWifiT85L0() && + !device.isLockWifiT85D0()) || (result.customData !== undefined && result.customData.property !== undefined && device.isSmartSafe() && @@ -2441,7 +2442,8 @@ export class EufySecurity extends TypedEmitter { device.isLockWifiT8502() || device.isLockWifiT8510P() || device.isLockWifiT8520P() || - device.isLockWifiT85L0()) && + device.isLockWifiT85L0() || + device.isLockWifiT85D0()) && result.command_type !== CommandType.CMD_DOORLOCK_SET_PUSH_MODE) ) { if (device.hasProperty(result.customData.property.name)) { @@ -3368,7 +3370,8 @@ export class EufySecurity extends TypedEmitter { device.isLockWifiT8510P() || device.isLockWifiT8520P() || device.isLockWifiT8531() || - device.isLockWifiT85L0()) && + device.isLockWifiT85L0() || + device.isLockWifiT85D0()) && user.password_list.length > 0 ) { for (const entry of user.password_list) { From c01e193165c9539903d5a9651f99566fca833db2 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:30:46 -0600 Subject: [PATCH 09/22] Move protocol spec to dev-docs, add Smart Lock C30 to supported devices - Move t85d0-smart-lock-protocol.md from docs/ to dev-docs/ since the docs/ directory is a Docsify user-facing site, not for protocol specs - Add Smart Lock C30 (T85D0) to supported_devices.md with wrench status --- {docs => dev-docs}/t85d0-smart-lock-protocol.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {docs => dev-docs}/t85d0-smart-lock-protocol.md (100%) diff --git a/docs/t85d0-smart-lock-protocol.md b/dev-docs/t85d0-smart-lock-protocol.md similarity index 100% rename from docs/t85d0-smart-lock-protocol.md rename to dev-docs/t85d0-smart-lock-protocol.md From 8de92667d6f83965dccd148c5c65a047f20a4966 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:36:15 -0600 Subject: [PATCH 10/22] Add Smart Lock C30 (T85D0) product image to docs Cropped and resized to match existing convention (75x75 small, 320x320 large). --- docs/_media/smartlock_c30_t85d0_large.jpg | Bin 0 -> 11871 bytes docs/_media/smartlock_c30_t85d0_small.jpg | Bin 0 -> 1511 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/_media/smartlock_c30_t85d0_large.jpg create mode 100644 docs/_media/smartlock_c30_t85d0_small.jpg diff --git a/docs/_media/smartlock_c30_t85d0_large.jpg b/docs/_media/smartlock_c30_t85d0_large.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9e87c2f926495439cd62f7bf34d3645bf1003327 GIT binary patch literal 11871 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 Date: Wed, 18 Feb 2026 00:59:19 -0600 Subject: [PATCH 11/22] Add related issues research for MQTT lock support --- dev-docs/related-issues.md | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 dev-docs/related-issues.md diff --git a/dev-docs/related-issues.md b/dev-docs/related-issues.md new file mode 100644 index 00000000..376f3ab9 --- /dev/null +++ b/dev-docs/related-issues.md @@ -0,0 +1,77 @@ +# Issues Addressable by the Security MQTT Approach + +Research into open issues across `bropat/eufy-security-client`, `fuatakgun/eufy_security`, and `bropat/eufy-security-ws` that could be addressed by the MQTT-based lock control introduced in this PR. + +--- + +## Table 1 — Issues + +### Direct T85D0 / Smart Lock C30 Issues + +| Repo | Issue | Summary | +|------|-------|---------| +| bropat/eufy-security-client | [#617](https://github.com/bropat/eufy-security-client/issues/617) | T85D0 support request — empty commands array, no features. Nick-pape's original research issue | +| bropat/eufy-security-client | [#733](https://github.com/bropat/eufy-security-client/issues/733) | T85D0 support request — labeled "need hardware or access" | +| bropat/eufy-security-client | [#654](https://github.com/bropat/eufy-security-client/issues/654) | C30/T85D0 support — user offered $50 AUD bounty | +| bropat/eufy-security-client | [#575](https://github.com/bropat/eufy-security-client/issues/575) | T85D0 lock addition — wants lock status + battery | +| bropat/eufy-security-client | [#574](https://github.com/bropat/eufy-security-client/issues/574) | C30 Smart Lock support request | +| bropat/eufy-security-client | [#556](https://github.com/bropat/eufy-security-client/issues/556) | C30 lock support — community +1s | +| bropat/eufy-security-client | [PR #709](https://github.com/bropat/eufy-security-client/pull/709) | Adds C30 + E330 — battery/status works but **C30 lock/unlock missing** because no MQTT support | +| fuatakgun/eufy_security | [#1381](https://github.com/fuatakgun/eufy_security/issues/1381) | T85D0 has no working entities, only debug sensors | +| fuatakgun/eufy_security | [#1394](https://github.com/fuatakgun/eufy_security/issues/1394) | T85D0 — ws addon pulls data but nothing translates to HA entities | +| fuatakgun/eufy_security | [#1254](https://github.com/fuatakgun/eufy_security/issues/1254) | T85D0 — device type 202, debug properties only, no commands | +| fuatakgun/eufy_security | [#1227](https://github.com/fuatakgun/eufy_security/issues/1227) | C30 detected but only "Debug" sensors, no lock/unlock | +| fuatakgun/eufy_security | [#1365](https://github.com/fuatakgun/eufy_security/issues/1365) | Proposes all T85* models share base lock config | +| bropat/eufy-security-ws | [#389](https://github.com/bropat/eufy-security-ws/issues/389) | T85D0 lock/unlock request — redirected to client repo | + +### C33 / T85L0 — Key MQTT Confirmation Issue + +| Repo | Issue | Summary | +|------|-------|---------| +| bropat/eufy-security-client | [#526](https://github.com/bropat/eufy-security-client/issues/526) | **KEY ISSUE** — bropat confirmed C33 does NOT use P2P, only MQTTS (`security-mqtt-us.anker.com`). Nick-pape posted MQTT breakthrough here | +| bropat/eufy-security-client | [#581](https://github.com/bropat/eufy-security-client/issues/581) | T85L0/C33 — large community interest, many +1s, only debug entities | +| bropat/eufy-security-client | [#641](https://github.com/bropat/eufy-security-client/issues/641) | C33 WiFi lock — entities visible but none functional | +| bropat/eufy-security-client | [PR #528](https://github.com/bropat/eufy-security-client/pull/528) | Draft PR adding C33 type — incomplete, no MQTT control | +| fuatakgun/eufy_security | [#1346](https://github.com/fuatakgun/eufy_security/issues/1346) | C33 lock support request | +| fuatakgun/eufy_security | [#1182](https://github.com/fuatakgun/eufy_security/issues/1182) | T85L0 detected but not working | +| fuatakgun/eufy_security | [#1220](https://github.com/fuatakgun/eufy_security/issues/1220) | T85L0/C33 support request | +| bropat/eufy-security-ws | [#432](https://github.com/bropat/eufy-security-ws/issues/432) | T85L0 shows up in ws but no lock entity in HA | + +### Other T85* Locks Likely Needing MQTT + +| Repo | Issue | Summary | +|------|-------|---------| +| bropat/eufy-security-client | [#578](https://github.com/bropat/eufy-security-client/issues/578) | **C34 / T85D2** — shows in HA, no lock entities | +| bropat/eufy-security-client | [#572](https://github.com/bropat/eufy-security-client/issues/572) | **E31 / T85F0** — fingerprint lock not working | +| bropat/eufy-security-client | [#597](https://github.com/bropat/eufy-security-client/issues/597) | **E30 / T85F1** — API shows `p2p_did: ""`, `dsk_key: ""` (confirms no P2P) | +| bropat/eufy-security-client | [#652](https://github.com/bropat/eufy-security-client/issues/652) | **E30** — device_type 206, empty p2p_did/dsk_key | +| fuatakgun/eufy_security | [#1338](https://github.com/fuatakgun/eufy_security/issues/1338) | **E31 / T85F0** — only debug entities, no controls | +| bropat/eufy-security-client | [#644](https://github.com/bropat/eufy-security-client/issues/644) | **E31 WiFi Lock** — only debug entities | +| fuatakgun/eufy_security | [#1432](https://github.com/fuatakgun/eufy_security/issues/1432) | **S3 Max / T85V0** — extensive community debugging, fork with fixes | +| fuatakgun/eufy_security | [#1389](https://github.com/fuatakgun/eufy_security/issues/1389) | **FamiLock S3 / T85V0** — P2P mode camera request | + +### Possibly Related (C210 MQTT?) + +| Repo | Issue | Summary | +|------|-------|---------| +| bropat/eufy-security-client | [#618](https://github.com/bropat/eufy-security-client/issues/618) | C210 lock/unlock fails — `lock.lock()` not available, discussion suggests it may also need MQTT | + +--- + +## Table 2 — Locks + +| Lock | Model | DeviceType | Transport | Issue Links | Notes | +|------|-------|-----------|-----------|-------------|-------| +| **Smart Lock C30** | T85D0 | 202 | **Security MQTT** | [client#617](https://github.com/bropat/eufy-security-client/issues/617), [client#733](https://github.com/bropat/eufy-security-client/issues/733), [client#654](https://github.com/bropat/eufy-security-client/issues/654), [client#575](https://github.com/bropat/eufy-security-client/issues/575), [client#574](https://github.com/bropat/eufy-security-client/issues/574), [client#556](https://github.com/bropat/eufy-security-client/issues/556), [PR#709](https://github.com/bropat/eufy-security-client/pull/709), [ha#1381](https://github.com/fuatakgun/eufy_security/issues/1381), [ha#1394](https://github.com/fuatakgun/eufy_security/issues/1394), [ha#1254](https://github.com/fuatakgun/eufy_security/issues/1254), [ha#1227](https://github.com/fuatakgun/eufy_security/issues/1227), [ha#1365](https://github.com/fuatakgun/eufy_security/issues/1365) | **This PR directly solves this.** Same BLE protocol as T8506/T8502 but over MQTT. Verified working on 2 locks. | +| **Smart Lock C33** | T85L0 | ? | **Security MQTT** | [client#526](https://github.com/bropat/eufy-security-client/issues/526), [client#581](https://github.com/bropat/eufy-security-client/issues/581), [client#641](https://github.com/bropat/eufy-security-client/issues/641), [PR#528](https://github.com/bropat/eufy-security-client/pull/528), [ha#1346](https://github.com/fuatakgun/eufy_security/issues/1346), [ha#1182](https://github.com/fuatakgun/eufy_security/issues/1182), [ha#1220](https://github.com/fuatakgun/eufy_security/issues/1220), [ws#432](https://github.com/bropat/eufy-security-ws/issues/432) | **bropat confirmed MQTT-only** in #526. Lever-style lock. Highest community demand after C30. Likely same MQTT broker, needs topic prefix + type verification. | +| **Smart Lock C34** | T85D2 | ? | **Likely MQTT** | [client#578](https://github.com/bropat/eufy-security-client/issues/578) | Shows in HA, no lock entities. Same T85D* family as C30 — very likely same MQTT transport. | +| **Smart Lock E30** | T85F1 | 206 | **Likely MQTT** | [client#597](https://github.com/bropat/eufy-security-client/issues/597), [client#652](https://github.com/bropat/eufy-security-client/issues/652) | API confirms `p2p_did: ""` and `dsk_key: ""` — no P2P possible. Almost certainly MQTT. | +| **Smart Lock E31** | T85F0 | ? | **Likely MQTT** | [client#572](https://github.com/bropat/eufy-security-client/issues/572), [client#644](https://github.com/bropat/eufy-security-client/issues/644), [ha#1338](https://github.com/fuatakgun/eufy_security/issues/1338) | WiFi fingerprint lock. Only debug entities. T85F* family likely MQTT. | +| **FamiLock S3 Max** | T85V0 | ? | **Likely MQTT + P2P camera** | [ha#1432](https://github.com/fuatakgun/eufy_security/issues/1432), [ha#1389](https://github.com/fuatakgun/eufy_security/issues/1389) | Smart lock + doorbell camera combo. Lock control may need MQTT; camera uses P2P. Community fork with partial fixes exists. | +| **Smart Lock C210** | T8502 | 180 | P2P (but broken?) | [client#618](https://github.com/bropat/eufy-security-client/issues/618) | Supposedly P2P-supported but `lock.lock()` unavailable for some users. May have firmware variants needing MQTT? | + +--- + +## Summary + +This PR directly resolves **~13 open issues** for the T85D0/C30. The same MQTT architecture could extend to **5 more lock models** (C33, C34, E30, E31, S3 Max) covering **~15+ additional open issues** — primarily by updating `usesSecurityMqtt()` and adjusting the MQTT topic prefix per model. The C33 (T85L0) is the most confirmed candidate since bropat explicitly stated it uses the same `security-mqtt-us.anker.com` broker. From b5b922d455e6fc3ee5ff405fa79f9af872d8eb3f Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:01:05 -0600 Subject: [PATCH 12/22] Enable TLS certificate verification for security MQTT broker The broker certificate validates against the aws_root_ca1_pem CA returned by the AIOT endpoint. Verified with live connection to security-mqtt-us.anker.com:8883. --- dev-docs/t85d0-smart-lock-protocol.md | 2 +- src/mqtt/security-mqtt.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-docs/t85d0-smart-lock-protocol.md b/dev-docs/t85d0-smart-lock-protocol.md index 33897f5b..e13b8d39 100644 --- a/dev-docs/t85d0-smart-lock-protocol.md +++ b/dev-docs/t85d0-smart-lock-protocol.md @@ -292,7 +292,7 @@ The Security broker is an **EMQX5** instance running on AWS (us-east-2): | **Client Certificate** | `certificate_pem` from Step 3 | | **Client Private Key** | `private_key` from Step 3 | | **CA Certificate** | `aws_root_ca1_pem` from Step 3 | -| **TLS Verify** | `false` (EMQX5 uses a different CA than the one returned) | +| **TLS Verify** | `true` (broker certificate validates against `aws_root_ca1_pem`) | | **Username** | `{thing_name}` from Step 3 (e.g., `{user_center_id}-eufy_home`) | | **Password** | Empty | | **Client ID** | `android-eufy_security-{user_center_id}-{openudid}{broker_host_no_dots_dashes}` | diff --git a/src/mqtt/security-mqtt.ts b/src/mqtt/security-mqtt.ts index faeb0ce3..b5f11c88 100644 --- a/src/mqtt/security-mqtt.ts +++ b/src/mqtt/security-mqtt.ts @@ -190,7 +190,7 @@ export class SecurityMQTTService extends TypedEmitter cert: this.mqttInfo.certificate_pem, key: this.mqttInfo.private_key, ca: this.mqttInfo.aws_root_ca1_pem, - rejectUnauthorized: false, + rejectUnauthorized: true, clean: true, keepalive: 60, connectTimeout: 30000, From 2b3bc8de0930f642070997861909af06a36d3d6b Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:15:30 -0600 Subject: [PATCH 13/22] Remove related-issues.md (moved to PR description) --- dev-docs/related-issues.md | 77 -------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 dev-docs/related-issues.md diff --git a/dev-docs/related-issues.md b/dev-docs/related-issues.md deleted file mode 100644 index 376f3ab9..00000000 --- a/dev-docs/related-issues.md +++ /dev/null @@ -1,77 +0,0 @@ -# Issues Addressable by the Security MQTT Approach - -Research into open issues across `bropat/eufy-security-client`, `fuatakgun/eufy_security`, and `bropat/eufy-security-ws` that could be addressed by the MQTT-based lock control introduced in this PR. - ---- - -## Table 1 — Issues - -### Direct T85D0 / Smart Lock C30 Issues - -| Repo | Issue | Summary | -|------|-------|---------| -| bropat/eufy-security-client | [#617](https://github.com/bropat/eufy-security-client/issues/617) | T85D0 support request — empty commands array, no features. Nick-pape's original research issue | -| bropat/eufy-security-client | [#733](https://github.com/bropat/eufy-security-client/issues/733) | T85D0 support request — labeled "need hardware or access" | -| bropat/eufy-security-client | [#654](https://github.com/bropat/eufy-security-client/issues/654) | C30/T85D0 support — user offered $50 AUD bounty | -| bropat/eufy-security-client | [#575](https://github.com/bropat/eufy-security-client/issues/575) | T85D0 lock addition — wants lock status + battery | -| bropat/eufy-security-client | [#574](https://github.com/bropat/eufy-security-client/issues/574) | C30 Smart Lock support request | -| bropat/eufy-security-client | [#556](https://github.com/bropat/eufy-security-client/issues/556) | C30 lock support — community +1s | -| bropat/eufy-security-client | [PR #709](https://github.com/bropat/eufy-security-client/pull/709) | Adds C30 + E330 — battery/status works but **C30 lock/unlock missing** because no MQTT support | -| fuatakgun/eufy_security | [#1381](https://github.com/fuatakgun/eufy_security/issues/1381) | T85D0 has no working entities, only debug sensors | -| fuatakgun/eufy_security | [#1394](https://github.com/fuatakgun/eufy_security/issues/1394) | T85D0 — ws addon pulls data but nothing translates to HA entities | -| fuatakgun/eufy_security | [#1254](https://github.com/fuatakgun/eufy_security/issues/1254) | T85D0 — device type 202, debug properties only, no commands | -| fuatakgun/eufy_security | [#1227](https://github.com/fuatakgun/eufy_security/issues/1227) | C30 detected but only "Debug" sensors, no lock/unlock | -| fuatakgun/eufy_security | [#1365](https://github.com/fuatakgun/eufy_security/issues/1365) | Proposes all T85* models share base lock config | -| bropat/eufy-security-ws | [#389](https://github.com/bropat/eufy-security-ws/issues/389) | T85D0 lock/unlock request — redirected to client repo | - -### C33 / T85L0 — Key MQTT Confirmation Issue - -| Repo | Issue | Summary | -|------|-------|---------| -| bropat/eufy-security-client | [#526](https://github.com/bropat/eufy-security-client/issues/526) | **KEY ISSUE** — bropat confirmed C33 does NOT use P2P, only MQTTS (`security-mqtt-us.anker.com`). Nick-pape posted MQTT breakthrough here | -| bropat/eufy-security-client | [#581](https://github.com/bropat/eufy-security-client/issues/581) | T85L0/C33 — large community interest, many +1s, only debug entities | -| bropat/eufy-security-client | [#641](https://github.com/bropat/eufy-security-client/issues/641) | C33 WiFi lock — entities visible but none functional | -| bropat/eufy-security-client | [PR #528](https://github.com/bropat/eufy-security-client/pull/528) | Draft PR adding C33 type — incomplete, no MQTT control | -| fuatakgun/eufy_security | [#1346](https://github.com/fuatakgun/eufy_security/issues/1346) | C33 lock support request | -| fuatakgun/eufy_security | [#1182](https://github.com/fuatakgun/eufy_security/issues/1182) | T85L0 detected but not working | -| fuatakgun/eufy_security | [#1220](https://github.com/fuatakgun/eufy_security/issues/1220) | T85L0/C33 support request | -| bropat/eufy-security-ws | [#432](https://github.com/bropat/eufy-security-ws/issues/432) | T85L0 shows up in ws but no lock entity in HA | - -### Other T85* Locks Likely Needing MQTT - -| Repo | Issue | Summary | -|------|-------|---------| -| bropat/eufy-security-client | [#578](https://github.com/bropat/eufy-security-client/issues/578) | **C34 / T85D2** — shows in HA, no lock entities | -| bropat/eufy-security-client | [#572](https://github.com/bropat/eufy-security-client/issues/572) | **E31 / T85F0** — fingerprint lock not working | -| bropat/eufy-security-client | [#597](https://github.com/bropat/eufy-security-client/issues/597) | **E30 / T85F1** — API shows `p2p_did: ""`, `dsk_key: ""` (confirms no P2P) | -| bropat/eufy-security-client | [#652](https://github.com/bropat/eufy-security-client/issues/652) | **E30** — device_type 206, empty p2p_did/dsk_key | -| fuatakgun/eufy_security | [#1338](https://github.com/fuatakgun/eufy_security/issues/1338) | **E31 / T85F0** — only debug entities, no controls | -| bropat/eufy-security-client | [#644](https://github.com/bropat/eufy-security-client/issues/644) | **E31 WiFi Lock** — only debug entities | -| fuatakgun/eufy_security | [#1432](https://github.com/fuatakgun/eufy_security/issues/1432) | **S3 Max / T85V0** — extensive community debugging, fork with fixes | -| fuatakgun/eufy_security | [#1389](https://github.com/fuatakgun/eufy_security/issues/1389) | **FamiLock S3 / T85V0** — P2P mode camera request | - -### Possibly Related (C210 MQTT?) - -| Repo | Issue | Summary | -|------|-------|---------| -| bropat/eufy-security-client | [#618](https://github.com/bropat/eufy-security-client/issues/618) | C210 lock/unlock fails — `lock.lock()` not available, discussion suggests it may also need MQTT | - ---- - -## Table 2 — Locks - -| Lock | Model | DeviceType | Transport | Issue Links | Notes | -|------|-------|-----------|-----------|-------------|-------| -| **Smart Lock C30** | T85D0 | 202 | **Security MQTT** | [client#617](https://github.com/bropat/eufy-security-client/issues/617), [client#733](https://github.com/bropat/eufy-security-client/issues/733), [client#654](https://github.com/bropat/eufy-security-client/issues/654), [client#575](https://github.com/bropat/eufy-security-client/issues/575), [client#574](https://github.com/bropat/eufy-security-client/issues/574), [client#556](https://github.com/bropat/eufy-security-client/issues/556), [PR#709](https://github.com/bropat/eufy-security-client/pull/709), [ha#1381](https://github.com/fuatakgun/eufy_security/issues/1381), [ha#1394](https://github.com/fuatakgun/eufy_security/issues/1394), [ha#1254](https://github.com/fuatakgun/eufy_security/issues/1254), [ha#1227](https://github.com/fuatakgun/eufy_security/issues/1227), [ha#1365](https://github.com/fuatakgun/eufy_security/issues/1365) | **This PR directly solves this.** Same BLE protocol as T8506/T8502 but over MQTT. Verified working on 2 locks. | -| **Smart Lock C33** | T85L0 | ? | **Security MQTT** | [client#526](https://github.com/bropat/eufy-security-client/issues/526), [client#581](https://github.com/bropat/eufy-security-client/issues/581), [client#641](https://github.com/bropat/eufy-security-client/issues/641), [PR#528](https://github.com/bropat/eufy-security-client/pull/528), [ha#1346](https://github.com/fuatakgun/eufy_security/issues/1346), [ha#1182](https://github.com/fuatakgun/eufy_security/issues/1182), [ha#1220](https://github.com/fuatakgun/eufy_security/issues/1220), [ws#432](https://github.com/bropat/eufy-security-ws/issues/432) | **bropat confirmed MQTT-only** in #526. Lever-style lock. Highest community demand after C30. Likely same MQTT broker, needs topic prefix + type verification. | -| **Smart Lock C34** | T85D2 | ? | **Likely MQTT** | [client#578](https://github.com/bropat/eufy-security-client/issues/578) | Shows in HA, no lock entities. Same T85D* family as C30 — very likely same MQTT transport. | -| **Smart Lock E30** | T85F1 | 206 | **Likely MQTT** | [client#597](https://github.com/bropat/eufy-security-client/issues/597), [client#652](https://github.com/bropat/eufy-security-client/issues/652) | API confirms `p2p_did: ""` and `dsk_key: ""` — no P2P possible. Almost certainly MQTT. | -| **Smart Lock E31** | T85F0 | ? | **Likely MQTT** | [client#572](https://github.com/bropat/eufy-security-client/issues/572), [client#644](https://github.com/bropat/eufy-security-client/issues/644), [ha#1338](https://github.com/fuatakgun/eufy_security/issues/1338) | WiFi fingerprint lock. Only debug entities. T85F* family likely MQTT. | -| **FamiLock S3 Max** | T85V0 | ? | **Likely MQTT + P2P camera** | [ha#1432](https://github.com/fuatakgun/eufy_security/issues/1432), [ha#1389](https://github.com/fuatakgun/eufy_security/issues/1389) | Smart lock + doorbell camera combo. Lock control may need MQTT; camera uses P2P. Community fork with partial fixes exists. | -| **Smart Lock C210** | T8502 | 180 | P2P (but broken?) | [client#618](https://github.com/bropat/eufy-security-client/issues/618) | Supposedly P2P-supported but `lock.lock()` unavailable for some users. May have firmware variants needing MQTT? | - ---- - -## Summary - -This PR directly resolves **~13 open issues** for the T85D0/C30. The same MQTT architecture could extend to **5 more lock models** (C33, C34, E30, E31, S3 Max) covering **~15+ additional open issues** — primarily by updating `usesSecurityMqtt()` and adjusting the MQTT topic prefix per model. The C33 (T85L0) is the most confirmed candidate since bropat explicitly stated it uses the same `security-mqtt-us.anker.com` broker. From 96c6d0ac0f18f2e753096535ba85e82ec8ce19e1 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:19:36 -0600 Subject: [PATCH 14/22] Replace text-art diagrams with GitHub Mermaid in protocol spec Converts architecture, auth flow, and event routing diagrams to Mermaid syntax for better rendering on GitHub. Byte layout tables kept as text since Mermaid doesn't handle bit-level layouts well. --- dev-docs/t85d0-smart-lock-protocol.md | 146 +++++++++----------------- 1 file changed, 52 insertions(+), 94 deletions(-) diff --git a/dev-docs/t85d0-smart-lock-protocol.md b/dev-docs/t85d0-smart-lock-protocol.md index e13b8d39..e63982ce 100644 --- a/dev-docs/t85d0-smart-lock-protocol.md +++ b/dev-docs/t85d0-smart-lock-protocol.md @@ -43,37 +43,24 @@ authentication chain required to obtain MQTT connection credentials. ### High-Level Architecture -``` - Home Assistant - | - eufy-security-client - | - SecurityMQTTService - | - ┌────────────────┼────────────────┐ - | | | - Auth Chain MQTT Publish MQTT Subscribe - | | | - ┌─────────┴─────────┐ | | - | EufyHome API | | | - | (3-step auth) | | | - └─────────┬─────────┘ | | - | | | - mTLS Certs | | - | | | - └────────┐ | | - v v v - ┌──────────────────────────┐ - | security-mqtt-us.anker.com| - | (EMQX5 Broker) | - └──────────┬───────────────┘ - | - Wi-Fi / Cloud - | - ┌───────┴───────┐ - | T85D0 Lock | - | (via Wi-Fi) | - └───────────────┘ +```mermaid +flowchart TD + HA[Home Assistant] --> Client[eufy-security-client] + Client --> SMQTT[SecurityMQTTService] + + SMQTT --> Auth[Auth Chain] + SMQTT --> Pub[MQTT Publish] + SMQTT --> Sub[MQTT Subscribe] + + Auth --> EufyAPI["EufyHome API\n(3-step auth)"] + EufyAPI --> Certs[mTLS Certs] + + Certs --> Broker + Pub --> Broker + Sub --> Broker + + Broker["security-mqtt-us.anker.com\n(EMQX5 Broker)"] --> WiFi[Wi-Fi / Cloud] + WiFi --> Lock["T85D0 Lock\n(via Wi-Fi)"] ``` --- @@ -208,47 +195,23 @@ timezone: America/New_York ### Authentication Flow Diagram -``` -User Credentials (email + password) - | - v - ┌──────────────────────────────────────────┐ - | Step 1: POST home-api.eufylife.com | - | /v1/user/email/login | - | | - | client_id: "eufyhome-app" | - | client_secret: "GQCpr9dSp3uQpsOMgJ4xQ" | - | | - | Returns: access_token | - └──────────────┬───────────────────────────┘ - | - v - ┌──────────────────────────────────────────┐ - | Step 2: GET home-api.eufylife.com | - | /v1/user/user_center_info | - | | - | Header: token: | - | | - | Returns: user_center_token (AIOT token!) | - | user_center_id | - └──────────────┬───────────────────────────┘ - | - v - ┌──────────────────────────────────────────┐ - | Step 3: POST aiot-clean-api-pr | - | .eufylife.com/app/devicemanage/ | - | get_user_mqtt_info | - | | - | Header: X-Auth-Token: | - | Header: GToken: MD5(user_center_id) | - | | - | Returns: certificate_pem, private_key, | - | aws_root_ca1_pem, thing_name, | - | endpoint_addr | - └──────────────┬───────────────────────────┘ - | - v - mTLS credentials ready +```mermaid +flowchart TD + Creds["User Credentials\n(email + password)"] + + Creds --> Step1 + + Step1["**Step 1:** POST home-api.eufylife.com\n/v1/user/email/login\n\nclient_id: eufyhome-app\nclient_secret: GQCpr9dSp3uQpsOMgJ4xQ\n\nReturns: **access_token**"] + + Step1 --> Step2 + + Step2["**Step 2:** GET home-api.eufylife.com\n/v1/user/user_center_info\n\nHeader: token: access_token\n\nReturns: **user_center_token** (AIOT token!)\nuser_center_id"] + + Step2 --> Step3 + + Step3["**Step 3:** POST aiot-clean-api-pr\n.eufylife.com/app/devicemanage/\nget_user_mqtt_info\n\nHeader: X-Auth-Token: user_center_token\nHeader: GToken: MD5(user_center_id)\n\nReturns: certificate_pem, private_key,\naws_root_ca1_pem, thing_name,\nendpoint_addr"] + + Step3 --> Ready["mTLS credentials ready"] ``` ### What Does NOT Work as an AIOT Token @@ -834,28 +797,23 @@ export interface SecurityMQTTServiceEvents { The integration follows an event-based routing pattern through three layers: -``` -Station EufySecurity SecurityMQTTService - | | | - | (lock command requested) | | - | | | - | emit("security mqtt command", | | - | station, deviceSN, | | - | adminUserId, shortUserId, | | - | nickName, channel, seq, lock) | | - | -------------------------------->| | - | | | - | | onSecurityMqttCommand() | - | | calls publishLockCommand() | - | | ---------------------------->| - | | | - | | (MQTT publish)| - | | | - | | emit("lock-status") | - | |<-----------------------------| - | | | - | | (updates device properties | - | | via rawStation property set) | +```mermaid +sequenceDiagram + participant Station + participant EufySecurity + participant SecurityMQTTService + + Note over Station: Lock command requested + + Station->>EufySecurity: emit("security mqtt command",
station, deviceSN, adminUserId,
shortUserId, nickName, channel, seq, lock) + + EufySecurity->>SecurityMQTTService: onSecurityMqttCommand()
calls publishLockCommand() + + Note over SecurityMQTTService: MQTT publish + + SecurityMQTTService->>EufySecurity: emit("lock-status") + + Note over EufySecurity: Updates device properties
via rawStation property set ``` **Flow details:** From 2d26445c6c7cea93f77397526192dd7c3e1af527 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:22:21 -0600 Subject: [PATCH 15/22] Fix Mermaid diagrams: use
for line breaks, convert auth flow to sequence diagram --- dev-docs/t85d0-smart-lock-protocol.md | 29 ++++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/dev-docs/t85d0-smart-lock-protocol.md b/dev-docs/t85d0-smart-lock-protocol.md index e63982ce..79fbb262 100644 --- a/dev-docs/t85d0-smart-lock-protocol.md +++ b/dev-docs/t85d0-smart-lock-protocol.md @@ -52,15 +52,15 @@ flowchart TD SMQTT --> Pub[MQTT Publish] SMQTT --> Sub[MQTT Subscribe] - Auth --> EufyAPI["EufyHome API\n(3-step auth)"] + Auth --> EufyAPI["EufyHome API
(3-step auth)"] EufyAPI --> Certs[mTLS Certs] Certs --> Broker Pub --> Broker Sub --> Broker - Broker["security-mqtt-us.anker.com\n(EMQX5 Broker)"] --> WiFi[Wi-Fi / Cloud] - WiFi --> Lock["T85D0 Lock\n(via Wi-Fi)"] + Broker["security-mqtt-us.anker.com
(EMQX5 Broker)"] --> WiFi[Wi-Fi / Cloud] + WiFi --> Lock["T85D0 Lock
(via Wi-Fi)"] ``` --- @@ -196,22 +196,23 @@ timezone: America/New_York ### Authentication Flow Diagram ```mermaid -flowchart TD - Creds["User Credentials\n(email + password)"] - - Creds --> Step1 - - Step1["**Step 1:** POST home-api.eufylife.com\n/v1/user/email/login\n\nclient_id: eufyhome-app\nclient_secret: GQCpr9dSp3uQpsOMgJ4xQ\n\nReturns: **access_token**"] +sequenceDiagram + participant Client as eufy-security-client + participant EufyHome as home-api.eufylife.com + participant AIOT as aiot-clean-api-pr.eufylife.com - Step1 --> Step2 + Note over Client: User credentials (email + password) - Step2["**Step 2:** GET home-api.eufylife.com\n/v1/user/user_center_info\n\nHeader: token: access_token\n\nReturns: **user_center_token** (AIOT token!)\nuser_center_id"] + Client->>EufyHome: Step 1: POST /v1/user/email/login
client_id: eufyhome-app
client_secret: GQCpr9dSp3uQpsOMgJ4xQ + EufyHome-->>Client: access_token - Step2 --> Step3 + Client->>EufyHome: Step 2: GET /v1/user/user_center_info
Header — token: access_token + EufyHome-->>Client: user_center_token (AIOT token!)
user_center_id - Step3["**Step 3:** POST aiot-clean-api-pr\n.eufylife.com/app/devicemanage/\nget_user_mqtt_info\n\nHeader: X-Auth-Token: user_center_token\nHeader: GToken: MD5(user_center_id)\n\nReturns: certificate_pem, private_key,\naws_root_ca1_pem, thing_name,\nendpoint_addr"] + Client->>AIOT: Step 3: POST /app/devicemanage/get_user_mqtt_info
Header — X-Auth-Token: user_center_token
Header — GToken: MD5(user_center_id) + AIOT-->>Client: certificate_pem, private_key,
aws_root_ca1_pem, thing_name,
endpoint_addr - Step3 --> Ready["mTLS credentials ready"] + Note over Client: mTLS credentials ready ``` ### What Does NOT Work as an AIOT Token From 09a532e221ec20f960ee3fea67a70c2bba342b43 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:00:49 -0600 Subject: [PATCH 16/22] Eliminate EufyHome login from SecurityMQTTService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AIOT cert endpoint accepts the Security API's auth_token directly, so the 3-step EufyHome auth chain (login → access_token → user_center_token) is unnecessary. Refactor SecurityMQTTService to accept auth_token + user_id from HTTPApi instead of raw email/password. - Delete EufyHome constants, login interface, and 2 auth methods - Change constructor: (email, password, ...) → (authToken, userId, ...) - Collapse authenticate() to a single AIOT cert fetch - Update EufySecurity.initSecurityMqtt() to pull creds from this.api - Trim protocol doc: rewrite auth section, remove sections documenting pre-existing BLE/encryption code --- dev-docs/t85d0-smart-lock-protocol.md | 587 ++------------------------ src/eufysecurity.ts | 15 +- src/mqtt/security-mqtt.test.ts | 6 +- src/mqtt/security-mqtt.ts | 105 +---- 4 files changed, 70 insertions(+), 643 deletions(-) diff --git a/dev-docs/t85d0-smart-lock-protocol.md b/dev-docs/t85d0-smart-lock-protocol.md index 79fbb262..ba4390b5 100644 --- a/dev-docs/t85d0-smart-lock-protocol.md +++ b/dev-docs/t85d0-smart-lock-protocol.md @@ -10,12 +10,8 @@ 2. [Authentication Chain](#2-authentication-chain) 3. [MQTT Connection](#3-mqtt-connection) 4. [MQTT Message Envelope](#4-mqtt-message-envelope) -5. [BLE-over-MQTT Protocol (FF09 Frames)](#5-ble-over-mqtt-protocol-ff09-frames) -6. [AES-128-CBC Encryption](#6-aes-128-cbc-encryption) -7. [Lock/Unlock Command (TLV Payload)](#7-lockunlock-command-tlv-payload) -8. [Status Heartbeats](#8-status-heartbeats) -9. [Implementation Architecture](#9-implementation-architecture) -10. [Existing Code Reuse](#10-existing-code-reuse) +5. [Status Heartbeats](#5-status-heartbeats) +6. [Implementation Architecture](#6-implementation-architecture) --- @@ -52,8 +48,8 @@ flowchart TD SMQTT --> Pub[MQTT Publish] SMQTT --> Sub[MQTT Subscribe] - Auth --> EufyAPI["EufyHome API
(3-step auth)"] - EufyAPI --> Certs[mTLS Certs] + Auth --> AIOT["AIOT cert endpoint
(Security API auth_token)"] + AIOT --> Certs[mTLS Certs] Certs --> Broker Pub --> Broker @@ -68,90 +64,18 @@ flowchart TD ## 2. Authentication Chain The MQTT broker requires mutual TLS (mTLS) authentication with X.509 client certificates. -These certificates are obtained through a **three-step authentication chain** that begins with -the **EufyHome API** (not the Security API). +These certificates are obtained from the **AIOT certificate endpoint** using the existing +Security API `auth_token` and `user_id` — no additional login is required. -The Security API's `cloud_token` (from `security-app.eufylife.com`) does **NOT** work on the -AIOT API — it returns `{"code": 26002, "msg": "token not exist"}`. Only the -`user_center_token` from the EufyHome API is accepted. - -### Step 1: EufyHome Login - -**Endpoint:** `POST https://home-api.eufylife.com/v1/user/email/login` - -**Headers:** -``` -Content-Type: application/json -category: Home -``` - -**Request Body:** -```json -{ - "client_id": "eufyhome-app", - "client_secret": "GQCpr9dSp3uQpsOMgJ4xQ", - "email": "", - "password": "" -} -``` - -**Response (200 OK):** -```json -{ - "res_code": 0, - "access_token": "", - "user_id": "643e0e4c-c8de-4c20-9d0e-96f0c5fa7b7a", - "email": "user@example.com", - "nick_name": "User Name", - "ab": "US" -} -``` - -**Notes:** -- `res_code: 0` indicates success at this step. -- The `access_token` is a EufyHome session token — it is NOT the AIOT token and will NOT - work directly on the AIOT API. -- The `user_id` is the EufyHome user ID in UUID format. It is distinct from the Security - API `user_id`. - -### Step 2: Get User Center Token - -**Endpoint:** `GET https://home-api.eufylife.com/v1/user/user_center_info` - -**Headers:** -``` -Content-Type: application/json -category: Home -token: -``` - -**Response (200 OK):** -```json -{ - "res_code": 1, - "user_center_token": "<48_char_hex_string>", - "user_center_id": "<40_char_hex_string>" -} -``` - -**Critical details:** -- `res_code: 1` is **success** at this endpoint (unlike Step 1 where `0` is success). -- `user_center_token` is the **AIOT authentication token**. This is the value that the - encrypted `AccountDelegate.k()` method returns in the Android app's ijiami-protected code. -- `user_center_id` is **identical** to the Security API `user_id` (the 40-character hex - hash). This shared identifier links the two authentication systems. - -### Step 3: Get MQTT Certificates +### Get MQTT Certificates **Endpoint:** `POST https://aiot-clean-api-pr.eufylife.com/app/devicemanage/get_user_mqtt_info` -**Method:** Must be POST (GET returns nothing useful). - **Headers:** ``` Content-Type: application/json -X-Auth-Token: -GToken: +X-Auth-Token: +GToken: User-Agent: EufySecurity-Android-4.6.0-1630 category: eufy_security App-name: eufy_security @@ -171,7 +95,7 @@ timezone: America/New_York "code": 0, "msg": "success!", "data": { - "thing_name": "-eufy_home", + "thing_name": "-eufy_home", "endpoint_addr": "aiot-mqtt-us.anker.com", "certificate_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", "private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", @@ -183,47 +107,44 @@ timezone: America/New_York ``` **Notes:** -- `GToken` is the MD5 hex digest of the `user_center_id` string. -- `thing_name` follows the pattern `{user_center_id}-eufy_home`. +- `GToken` is the MD5 hex digest of the `user_id` string (the 40-char hex hash from the Security API). +- `thing_name` follows the pattern `{user_id}-eufy_home`. - `endpoint_addr` is the **AIOT** MQTT broker hostname. However, locks are NOT on this broker — see Section 3 for the correct broker. - `aws_root_ca1_pem` is actually the **Go Daddy Class 2 CA** certificate despite the field name suggesting Amazon Root CA. This appears to be a legacy field name from an infrastructure migration. - Certificates are valid for approximately 10 years. -- The `pkcs12` field contains the same certificate and key as a PKCS#12 bundle. ### Authentication Flow Diagram ```mermaid sequenceDiagram participant Client as eufy-security-client - participant EufyHome as home-api.eufylife.com + participant SecAPI as security-app.eufylife.com participant AIOT as aiot-clean-api-pr.eufylife.com - Note over Client: User credentials (email + password) - - Client->>EufyHome: Step 1: POST /v1/user/email/login
client_id: eufyhome-app
client_secret: GQCpr9dSp3uQpsOMgJ4xQ - EufyHome-->>Client: access_token - - Client->>EufyHome: Step 2: GET /v1/user/user_center_info
Header — token: access_token - EufyHome-->>Client: user_center_token (AIOT token!)
user_center_id + Note over Client,SecAPI: Existing login (already done by HTTPApi) + Client->>SecAPI: POST /v2/login/sec + SecAPI-->>Client: auth_token + user_id - Client->>AIOT: Step 3: POST /app/devicemanage/get_user_mqtt_info
Header — X-Auth-Token: user_center_token
Header — GToken: MD5(user_center_id) - AIOT-->>Client: certificate_pem, private_key,
aws_root_ca1_pem, thing_name,
endpoint_addr + Note over Client,AIOT: MQTT cert fetch (SecurityMQTTService) + Client->>AIOT: POST /app/devicemanage/get_user_mqtt_info
X-Auth-Token: auth_token
GToken: MD5(user_id) + AIOT-->>Client: certificate_pem, private_key,
aws_root_ca1_pem, thing_name Note over Client: mTLS credentials ready ``` -### What Does NOT Work as an AIOT Token - -| Token Type | Source | AIOT API Response | -|------------|--------|-------------------| -| Security `cloud_token` | `security-app.eufylife.com` | `{"code": 26002, "msg": "token not exist"}` | -| EufyHome `access_token` | Step 1 | `{"code": 26003, "msg": "token error"}` | -| Tuya `sid` session | `px.tuyaus.com` | `{"code": 26003, "msg": "token error"}` | - -Only the `user_center_token` from Step 2 is accepted. +> **Implementation note:** The Android APK uses a longer auth path. Its `SecurityMqttManager` +> goes: EufyHome login (`home-api.eufylife.com`) -> `access_token` -> `user_center_token` -> +> AIOT certs (3 steps). During testing we discovered the AIOT cert endpoint accepts any valid +> Eufy auth token for the user, not just the `user_center_token`. Since eufy-security-client +> already has the Security API `auth_token` + `user_id` from its existing login, we skip the +> EufyHome chain entirely. +> +> Note: the `cloud_token` (a stored/persistent token) does **not** work — only the fresh +> `auth_token` from `login_sec` is accepted. This distinction led to the initial incorrect +> conclusion during APK reverse engineering that a separate EufyHome login was required. --- @@ -427,284 +348,7 @@ actual command or response data. --- -## 5. BLE-over-MQTT Protocol (FF09 Frames) - -The `lock_payload` field in the trans object contains a hex-encoded BLE frame. This is the -same frame format used for Bluetooth communication with T8506/T8502 smart locks, tunneled -through MQTT. - -### FF09 Frame Byte Layout - -``` -Offset Size Field Description -────── ──── ───── ─────────── -0 1 Magic[0] Always 0xFF -1 1 Magic[1] Always 0x09 -2 2 Length (LE) Total frame length (header + data + checksum) -4 1 Version 3 = VERSION_CODE_SMART_LOCK -5 1 Direction 0x00 = from app, 0x01 = from device -6 1 Data Type 2 = SmartLockFunctionType.TYPE_2 -7 2 Flags (BE) Bit field encoding (see below) -9 N Data Ciphertext (if encrypted) or plaintext TLV -9+N 1 Checksum XOR of all preceding bytes (offsets 0 through 8+N) -``` - -### Flags Field (uint16 big-endian at offset 7) - -``` -Bit 15 14 13-12 11 10-0 - ┌──┐ ┌──┐ ┌────┐ ┌──┐ ┌──────────┐ - |P | |E | |Rsvd| |R | |Command | - └──┘ └──┘ └────┘ └──┘ └──────────┘ - -P (bit 15): Partial — 1 = multi-part frame (fragmented BLE packet) -E (bit 14): Encrypted — 1 = data is AES-128-CBC encrypted - (bits 13-12): Reserved / unused -R (bit 11): Response — 1 = response from lock, 0 = command to lock -Command (bits 10-0): SmartLockBleCommandFunctionType2 code -``` - -### Example Flag Values - -| Flags (hex) | Binary | P | E | R | Command | Meaning | -|-------------|--------|---|---|---|---------|---------| -| `0x004A` | `0000 0000 0100 1010` | 0 | 0 | 0 | 74 | Unencrypted NOTIFY heartbeat | -| `0x4023` | `0100 0000 0010 0011` | 0 | 1 | 0 | 35 | Encrypted ON_OFF_LOCK command | -| `0x4823` | `0100 1000 0010 0011` | 0 | 1 | 1 | 35 | Encrypted ON_OFF_LOCK response | -| `0x4022` | `0100 0000 0010 0010` | 0 | 1 | 0 | 34 | Encrypted QUERY_STATUS command | -| `0x4822` | `0100 1000 0010 0010` | 0 | 1 | 1 | 34 | Encrypted QUERY_STATUS response | - -### SmartLockBleCommandFunctionType2 Codes - -These are the BLE command codes carried in bits 10-0 of the flags field, and their -corresponding API command numbers used in `trans.payload.apiCommand`: - -| BLE Code | API Command | Enum Name | Purpose | -|----------|-------------|-----------|---------| -| 33 | 6017 | `CALIBRATE_LOCK` | Lock calibration | -| 34 | 6012 | `QUERY_STATUS_IN_LOCK` | Query battery and lock state | -| **35** | **6018** | **`ON_OFF_LOCK`** | **Lock or unlock** | -| 36 | 6002 | `ADD_PW` | Add password | -| 37 | 146 | `UPDATE_USER_TIME` | Update user time | -| 38 | 6014 | `UPDATE_PW` | Update password | -| 39 | 6009 | `QUERY_PW` | Query passwords | -| 40 | 6003 | `ADD_FINGER` | Add fingerprint | -| 41 | 115 | `CANCEL_ADD_FINGER` | Cancel fingerprint enrollment | -| 42 | 6006 | `DELETE_FINGER` | Delete fingerprint | -| 43 | 119 | `UPDATE_FINGER_NAME` | Rename fingerprint | -| 44 | 6007 | `QUERY_ALL_USERS` | List all users | -| 45 | 6004 | `DELETE_USER` | Delete user | -| 46 | 6022 | `GET_FINGER_PW_USAGE` | Fingerprint/password usage stats | -| 48 | 104 | `WIFI_SCAN` | Wi-Fi scan | -| 49 | 105 | `WIFI_LIST` | Wi-Fi network list | -| 50 | 106 | `WIFI_CONNECT` | Wi-Fi connect | -| 51 | 107 | `ACTIVATE_DEVICE` | Device activation | -| 52 | 6015 | `SET_LOCK_PARAM` | Set lock parameters | -| 53 | 6016 | `GET_LOCK_PARAM` | Get lock parameters | -| 56 | — | `GET_LOCK_EVENT` | Get lock event log | -| 61 | — | `PULL_BLE` | BLE pull | -| 74 | 142 | `NOTIFY` | Status heartbeat (unencrypted) | - -### Checksum Calculation - -The checksum byte is the XOR of all bytes from offset 0 through the last data byte -(everything except the checksum byte itself): - -```typescript -function generateChecksum(data: Buffer): number { - let checksum = 0; - for (let i = 0; i < data.length; i++) { - checksum ^= data[i]; - } - return checksum & 0xFF; -} -``` - ---- - -## 6. AES-128-CBC Encryption - -Encrypted BLE frames (flags bit 14 = 1) use **AES-128-CBC** encryption. The key and IV are -derived from known values — no key exchange or DSK is required. - -### Key Derivation (16 bytes) - -The AES key is constructed by concatenating: -1. The **last 12 ASCII characters** of the `admin_user_id` (12 bytes) -2. The **current time** as a uint32 big-endian value (4 bytes) - -```typescript -// src/p2p/utils.ts:909 -export const generateSmartLockAESKey = (adminUserId: string, time: number): Buffer => { - const buffer = Buffer.allocUnsafe(4); - buffer.writeUint32BE(time); - return Buffer.concat([ - Buffer.from(adminUserId.substring(adminUserId.length - 12)), - buffer - ]); -}; -``` - -**Example:** -``` -admin_user_id = "ff7c594365c30f5618e1e4b5c34ea1ad70104e84" (40 chars) -Last 12 chars = "a1ad70104e84" (substring from index 28) -time = 1771171195 (epoch seconds with random low bits) - -Key (hex) = 61 31 61 64 37 30 31 30 34 65 38 34 69 91 EC 5B - |--- "a1ad70104e84" as ASCII ------| |-- time BE --| -``` - -### IV Derivation (16 bytes) - -The IV is the **first 16 bytes** of the device serial number, encoded as UTF-8: - -```typescript -// src/p2p/utils.ts:522 -export const getLockVectorBytes = (data: string): string => { - const encoder = new TextEncoder(); - const encData = encoder.encode(data); - const old_buffer = Buffer.from(encData); - if (encData.length >= 16) return old_buffer.toString("hex"); - // If SN is shorter than 16 chars, zero-pad to 16 bytes - const new_buffer = Buffer.alloc(16); - old_buffer.copy(new_buffer, 0); - return new_buffer.toString("hex"); -}; -``` - -**Example:** -``` -Device SN = "T85D0K1024470204" (17 chars) -IV = "T85D0K102447020" (first 16 chars as ASCII bytes) -IV (hex) = 54 38 35 44 30 4B 31 30 32 34 34 37 30 32 30 34 -``` - -Note: `getLockVectorBytes` returns a **hex string** that is later converted to a Buffer with -`Buffer.from(iv, "hex")`. - -### Time Generation - -```typescript -// src/p2p/utils.ts:905 -export const getSmartLockCurrentTimeInSeconds = function (): number { - return Math.trunc(new Date().getTime() / 1000) | Math.trunc(Math.random() * 100); -}; -``` - -The time value is the current epoch time in seconds with random low bits (0-99) OR'd in. -This same value is sent in `trans.payload.time` so the receiving end can reconstruct the -AES key for decryption. - -### Encryption / Decryption - -```typescript -// src/p2p/utils.ts:609 -export const encryptPayloadData = (data: string | Buffer, key: Buffer, iv: Buffer): Buffer => { - const cipher = createCipheriv("aes-128-cbc", key, iv); - return Buffer.concat([cipher.update(data), cipher.final()]); -}; - -// src/p2p/utils.ts:614 -export const decryptPayloadData = (data: Buffer, key: Buffer, iv: Buffer): Buffer => { - const cipher = createDecipheriv("aes-128-cbc", key, iv); - return Buffer.concat([cipher.update(data), cipher.final()]); -}; -``` - -Standard PKCS#7 padding is used (Node.js default for AES-CBC). - ---- - -## 7. Lock/Unlock Command (TLV Payload) - -The plaintext data inside an encrypted ON_OFF_LOCK frame uses a **TLV (Tag-Length-Value)** -encoding scheme. Tags are sequential starting from `0xA1`. - -### TLV Encoding - -Each TLV entry is: -``` -Tag (1 byte) | Length (1 byte) | Value (N bytes) -``` - -Tags increment sequentially: `0xA1`, `0xA2`, `0xA3`, `0xA4`, `0xA5`, ... - -This is implemented by the `WritePayload` class: - -```typescript -// src/http/utils.ts:385 -export class WritePayload { - private split_byte = -95; // 0xA1 as signed int8 - private data = Buffer.from([]); - - public write(bytes: Buffer): void { - const tmp_data = Buffer.from(bytes); - this.data = Buffer.concat([ - this.data, - Buffer.from([this.split_byte]), // Tag - Buffer.from([tmp_data.length & 255]), // Length - tmp_data, // Value - ]); - this.split_byte += 1; // Next tag: A2, A3, A4, ... - } - - public getData(): Buffer { - return this.data; - } -} -``` - -### ON_OFF_LOCK TLV Fields - -```typescript -// src/http/device.ts:5078 -public static encodeCmdSmartLockUnlock( - adminUserId: string, - lock: boolean, - username: string, - shortUserId: string -): Buffer { - const payload = new WritePayload(); - payload.write(this.getCurrentTimeInSeconds()); // A1: timestamp - payload.write(Buffer.from(adminUserId)); // A2: admin user ID - payload.write(this.getUInt8Buffer(lock ? 0 : 1)); // A3: lock command - payload.write(Buffer.from(username)); // A4: display name - payload.write(Buffer.from(shortUserId, "hex")); // A5: short user ID - return payload.getData(); -} -``` - -### Field Details - -| Tag | Name | Length | Format | Description | -|-----|------|--------|--------|-------------| -| `A1` | Timestamp | 4 | uint32 LE | Current time in seconds (from `getCurrentTimeInSeconds()`) | -| `A2` | Admin User ID | 40 | ASCII string | The `admin_user_id` from the station member data | -| `A3` | Lock Command | 1 | uint8 | `0x00` = **LOCK**, `0x01` = **UNLOCK** | -| `A4` | Username | varies | ASCII string | Display name of the user (e.g., `"nickpape"`) | -| `A5` | Short User ID | varies | Binary (hex-decoded) | The `short_user_id` from station member data, decoded from hex to raw bytes | - -**Note:** The `lock` boolean parameter to `encodeCmdSmartLockUnlock` uses inverted logic: -`lock: true` maps to `0x00` (lock the door), `lock: false` maps to `0x01` (unlock the door). -This matches the Eufy convention where `true` = locked state. - -### Example Plaintext (hex) - -``` -A1 04 -A2 28 -A3 01 01 (unlock) -A4 08 <"nickpape" as ASCII> -A5 04 -``` - -This plaintext is encrypted with AES-128-CBC, then wrapped in the FF09 BLE frame with -flags `0x4023` (encrypted=1, response=0, command=35/ON_OFF_LOCK). - ---- - -## 8. Status Heartbeats +## 5. Status Heartbeats Locks periodically send **unencrypted** NOTIFY heartbeat frames on the `/res` topic. These provide battery level and lock state without requiring any command from the app. @@ -752,7 +396,7 @@ FF ^ 09 ^ 11 ^ 00 ^ 03 ^ 01 ^ 02 ^ 00 ^ 4A ^ 00 ^ A1 ^ 01 ^ 17 ^ A2 ^ 01 ^ 03 = --- -## 9. Implementation Architecture +## 6. Implementation Architecture ### SecurityMQTTService Class @@ -834,174 +478,21 @@ sequenceDiagram `SecurityMQTTService` is initialized during `EufySecurity` startup (`src/eufysecurity.ts:1286-1287`) only if T85D0 devices are detected in the device list. -It runs the three-step auth chain, connects to the Security MQTT broker, and subscribes to -topics for each known T85D0 lock. - ---- - -## 10. Existing Code Reuse - -A key design principle of this implementation is that it reuses the **same BLE command -construction logic** used by T8506 and T8502 smart locks, which communicate over P2P/Bluetooth. -The only new code is the MQTT transport layer and authentication. - -### Shared Functions - -| Function | File | Line | Purpose | -|----------|------|------|---------| -| `getSmartLockP2PCommand()` | `src/p2p/utils.ts` | 915 | Builds complete BLE command payload (key gen, encryption, FF09 frame) | -| `generateSmartLockAESKey()` | `src/p2p/utils.ts` | 909 | AES-128 key from admin_user_id + timestamp | -| `getSmartLockCurrentTimeInSeconds()` | `src/p2p/utils.ts` | 905 | Epoch seconds with random low bits | -| `getLockVectorBytes()` | `src/p2p/utils.ts` | 522 | IV from device serial number | -| `encryptPayloadData()` | `src/p2p/utils.ts` | 609 | AES-128-CBC encryption | -| `decryptPayloadData()` | `src/p2p/utils.ts` | 614 | AES-128-CBC decryption | -| `Lock.encodeCmdSmartLockUnlock()` | `src/http/device.ts` | 5078 | TLV payload for lock/unlock command | -| `Lock.getCurrentTimeInSeconds()` | `src/http/device.ts` | 4780 | uint32 LE timestamp buffer | -| `Lock.VERSION_CODE_SMART_LOCK` | `src/http/device.ts` | 4533 | Constant `3` — BLE version code | -| `BleCommandFactory` | `src/p2p/ble.ts` | 28 | Builds and parses FF09 BLE frames | -| `BleCommandFactory.parseSmartLock()` | `src/p2p/ble.ts` | 67 | Parses incoming FF09 frames | -| `BleCommandFactory.getSmartLockCommand()` | `src/p2p/ble.ts` | 275 | Serializes BLE frame to bytes | -| `WritePayload` | `src/http/utils.ts` | 385 | TLV builder (A1/A2/A3... tags) | -| `SmartLockBleCommandFunctionType2` | `src/p2p/types.ts` | 1245 | BLE command code enum | -| `SmartLockCommand` | `src/p2p/types.ts` | 1187 | API command code enum | -| `SmartLockP2PCommandPayloadType` | `src/p2p/models.ts` | 313 | Type for the trans payload structure | - -### New Code (MQTT Transport Only) - -| Component | File | Purpose | -|-----------|------|---------| -| `SecurityMQTTService` | `src/mqtt/security-mqtt.ts` | MQTT connection, auth, publish/subscribe | -| `SecurityMQTTServiceEvents` | `src/mqtt/interface.ts` | Event type definitions | -| Station T85D0 routing | `src/http/station.ts:8464-8483` | Emits `"security mqtt command"` for T85D0 | -| EufySecurity routing | `src/eufysecurity.ts:849-861` | Connects Station event to SecurityMQTTService | - -### How `publishLockCommand` Reuses Existing Code - -The `publishLockCommand` method demonstrates the reuse pattern: - -```typescript -// 1. Build the BLE command using the same function used by T8506/T8502 -const command = getSmartLockP2PCommand( - deviceSN, - adminUserId, - SmartLockCommand.ON_OFF_LOCK, // 6018 - channel, - sequence, - Lock.encodeCmdSmartLockUnlock(adminUserId, lock, nickName, shortUserId), - SmartLockFunctionType.TYPE_2, -); - -// 2. Extract the payload that would normally go to P2P -const transPayload: SmartLockP2PCommandPayloadType = JSON.parse(command.payload.value); - -// 3. Wrap it in the MQTT envelope instead of sending via P2P -const trans = { - cmd: transPayload.cmd, // 1940 - mChannel: transPayload.mChannel, // 0 - mValue3: transPayload.mValue3, // 0 - payload: transPayload.payload, // { apiCommand, lock_payload, seq_num, time } -}; -``` - -The only difference from the P2P path is the transport: instead of sending via the P2P -session, the payload is base64-encoded, wrapped in the MQTT JSON envelope, and published -to the MQTT broker. +It fetches mTLS certs using the existing Security API `auth_token`, connects to the Security +MQTT broker, and subscribes to topics for each known T85D0 lock. --- -## Appendix A: Complete Lock Command Example - -Below is a step-by-step walkthrough of constructing a lock command (locking the front door). +### BLE Protocol Reuse -### Given Values (placeholders) - -``` -admin_user_id = "ff7c594365c30f5618e1e4b5c34ea1ad70104e84" -device_sn = "T85D0K1024470204" -username = "nickpape" -short_user_id = "abcd1234" (hex, 4 bytes when decoded) -``` - -### 1. Generate Time - -```javascript -time = Math.trunc(Date.now() / 1000) | Math.trunc(Math.random() * 100); -// e.g., 1771171195 -``` - -### 2. Build AES Key - -``` -Last 12 of admin_user_id: "a1ad70104e84" -time as uint32BE: [0x69, 0x91, 0xEC, 0x5B] -AES key (16 bytes): 61 31 61 64 37 30 31 30 34 65 38 34 69 91 EC 5B -``` - -### 3. Build IV - -``` -First 16 chars of device_sn: "T85D0K102447020" -IV (16 bytes): 54 38 35 44 30 4B 31 30 32 34 34 37 30 32 30 34 -``` - -### 4. Build TLV Plaintext - -``` -A1 04 Timestamp -A2 28 <40 ASCII bytes of user_id> Admin user ID -A3 01 00 Lock (0x00 = lock, 0x01 = unlock) -A4 08 <"nickpape"> Username -A5 04 <0xAB 0xCD 0x12 0x34> Short user ID (hex decoded) -``` - -### 5. Encrypt with AES-128-CBC - -```javascript -ciphertext = encryptPayloadData(plaintext, key, iv); -``` - -### 6. Build FF09 Frame - -``` -FF 09 Magic header -XX 00 Length (LE, total frame size) -03 Version = 3 (Smart Lock) -00 Direction = from app -02 Data Type = TYPE_2 -40 23 Flags = 0x4023 (encrypted=1, command=35/ON_OFF_LOCK) - Encrypted TLV -XX XOR checksum -``` - -### 7. Build MQTT Message - -```json -{ - "head": { - "version": "1.0.0.1", - "client_id": "android-eufy_security-ff7c59...04e84-d5d134...securitymqttusankercom", - "sess_id": "a3f1", - "msg_seq": 1, - "seed": "7b3c92a1d4f5e6b8c0a1d2e3f4a5b6c7", - "timestamp": 1771171195, - "cmd_status": 2, - "cmd": 9, - "sign_code": 0 - }, - "payload": "{\"account_id\":\"ff7c59...04e84\",\"device_sn\":\"T85D0K1024470204\",\"trans\":\"\"}" -} -``` - -### 8. Publish - -``` -Topic: cmd/eufy_security/T85D0/T85D0K1024470204/req -QoS: 1 -``` +The BLE command protocol (FF09 frames, AES-128-CBC encryption, TLV payloads) is **identical** +to the T8506/T8502 smart lock protocol already implemented in `src/p2p/ble.ts` and +`src/p2p/utils.ts`. The only new code in this PR is the MQTT transport layer — all BLE frame +construction, encryption, and command encoding is reused from the existing codebase. --- -## Appendix B: Extending to Other Lock Types +## Appendix: Extending to Other Lock Types If other lock models (beyond T85D0) use the same Security MQTT broker for control, the following would need to be determined for each model: diff --git a/src/eufysecurity.ts b/src/eufysecurity.ts index 41cdc77c..5ccd1376 100644 --- a/src/eufysecurity.ts +++ b/src/eufysecurity.ts @@ -425,17 +425,24 @@ export class EufySecurity extends TypedEmitter { }); } - private async initSecurityMqtt(email: string, apiBase: string): Promise { + private async initSecurityMqtt(apiBase: string): Promise { const hasSecurityMqttDevices = Object.values(this.devices).some((device) => device.usesSecurityMqtt()); if (!hasSecurityMqttDevices) { rootMainLogger.debug("No security MQTT locks found, skipping SecurityMQTT initialization"); return; } + const authToken = this.api.getToken(); + const userId = this.api.getPersistentData()?.user_id; + if (!authToken || !userId) { + rootMainLogger.error("SecurityMQTT initialization failed: missing auth_token or user_id"); + return; + } + try { this.securityMqttService = new SecurityMQTTService( - email, - this.config.password, + authToken, + userId, this.persistentData.openudid, this.config.country || "US", ); @@ -1285,7 +1292,7 @@ export class EufySecurity extends TypedEmitter { this.mqttService.connect(loginData.user_id, this.persistentData.openudid, this.api.getAPIBase(), loginData.email); // Initialize SecurityMQTTService for T85D0 locks (BLE-over-MQTT protocol) - this.initSecurityMqtt(loginData.email, this.api.getAPIBase()); + this.initSecurityMqtt(this.api.getAPIBase()); } else { rootMainLogger.warn("No login data recevied to initialize MQTT connection..."); } diff --git a/src/mqtt/security-mqtt.test.ts b/src/mqtt/security-mqtt.test.ts index 37fc13df..2d0d81b8 100644 --- a/src/mqtt/security-mqtt.test.ts +++ b/src/mqtt/security-mqtt.test.ts @@ -4,7 +4,7 @@ import { BleLockProtocol } from "./ble-lock-protocol"; /* eslint-disable @typescript-eslint/no-explicit-any */ function createService(): any { - return new SecurityMQTTService("test@test.com", "password", "test-udid", "US"); + return new SecurityMQTTService("test-auth-token", "test-user-id", "test-udid", "US"); } describe("BleLockProtocol", () => { @@ -158,8 +158,8 @@ describe("SecurityMQTTService", () => { describe("buildClientId", () => { it("builds client ID matching Android app pattern", () => { const service = createService(); - // Set userCenterId via any cast since it's private - (service as any).userCenterId = "user123"; + // Set userId via any cast since it's private + (service as any).userId = "user123"; const result = service.buildClientId("security-mqtt-us.anker.com"); expect(result).toBe("android-eufy_security-user123-test-udidsecuritymqttusankercom"); }); diff --git a/src/mqtt/security-mqtt.ts b/src/mqtt/security-mqtt.ts index b5f11c88..b45090a6 100644 --- a/src/mqtt/security-mqtt.ts +++ b/src/mqtt/security-mqtt.ts @@ -13,11 +13,6 @@ import { getSmartLockP2PCommand } from "../p2p/utils"; import { SmartLockCommand, SmartLockFunctionType, CommandType } from "../p2p/types"; import { Lock } from "../http/device"; -const EUFYHOME_CLIENT_ID = "eufyhome-app"; -const EUFYHOME_CLIENT_SECRET = "GQCpr9dSp3uQpsOMgJ4xQ"; - -const EUFYHOME_LOGIN_URL = "https://home-api.eufylife.com/v1/user/email/login"; -const EUFYHOME_USER_CENTER_URL = "https://home-api.eufylife.com/v1/user/user_center_info"; const AIOT_MQTT_CERT_URL = "https://aiot-clean-api-pr.eufylife.com/app/devicemanage/get_user_mqtt_info"; /** Connection state for the SecurityMQTT service. */ @@ -55,14 +50,6 @@ interface MQTTCertInfo { endpoint_addr: string; } -/** Request body for the EufyHome email login endpoint. */ -interface EufyHomeLoginRequest { - client_id: string; - client_secret: string; - email: string; - password: string; -} - /** Transport payload forwarded from the P2P command builder to the MQTT envelope. */ interface TransportPayload { cmd: number; @@ -106,18 +93,17 @@ interface DeviceCommandPayload { * BLE command frames as other smart locks (T8506, T8502), but tunneled over * a dedicated security MQTT broker instead of P2P or Cloud API. * - * The auth chain is: EufyHome login -> user_center_token -> AIOT mTLS certs -> MQTT connect. + * The auth chain is: Security API auth_token -> AIOT mTLS certs -> MQTT connect. */ export class SecurityMQTTService extends TypedEmitter { private client: mqtt.MqttClient | null = null; private connectionState: ConnectionState = ConnectionState.DISCONNECTED; private mqttInfo: MQTTCertInfo | null = null; - private userCenterId: string = ""; private clientId: string = ""; - private readonly email: string; - private readonly password: string; + private readonly authToken: string; + private readonly userId: string; private readonly openudid: string; private readonly country: string; @@ -128,15 +114,15 @@ export class SecurityMQTTService extends TypedEmitter /** * Creates a new SecurityMQTTService. - * @param email - Eufy account email address. - * @param password - Eufy account password. + * @param authToken - Security API auth_token (from login_sec). + * @param userId - Security API user_id (hex string, same as user_center_id). * @param openudid - Unique device identifier for the client. * @param country - Country code for regional API routing (default: "US"). */ - constructor(email: string, password: string, openudid: string, country: string = "US") { + constructor(authToken: string, userId: string, openudid: string, country: string = "US") { super(); - this.email = email; - this.password = password; + this.authToken = authToken; + this.userId = userId; this.openudid = openudid; this.country = country; } @@ -146,8 +132,8 @@ export class SecurityMQTTService extends TypedEmitter /** * Connects to the Eufy security MQTT broker. * - * Performs the full auth chain (EufyHome login -> user center token -> mTLS certs), - * then establishes an MQTTS connection. Any locks queued via `subscribeLock()` before + * Fetches mTLS certificates using the Security API auth_token, then establishes an + * MQTTS connection. Any locks queued via `subscribeLock()` before * connection will be subscribed once the connection is established. * * @param apiBase - The Eufy API base URL, used to determine the regional broker. @@ -319,72 +305,16 @@ export class SecurityMQTTService extends TypedEmitter // ─── Private: Authentication ───────────────────────────────────────────── - /** - * Authenticates with EufyHome and retrieves mTLS certificates for the MQTT broker. - * - * Steps: - * 1. EufyHome email login to get an access_token. - * 2. Exchange access_token for a user_center_token. - * 3. Use user_center_token to retrieve mTLS certificates from the AIOT endpoint. - */ + /** Retrieves mTLS certificates from the AIOT endpoint using the Security API auth_token. */ private async authenticate(): Promise { - const accessToken = await this.authenticateEufyHome(); - const userCenterToken = await this.fetchUserCenterToken(accessToken); - await this.fetchMqttCertificates(userCenterToken); - } - - /** Step 1: Logs in to the EufyHome API and returns an access token. */ - private async authenticateEufyHome(): Promise { - rootMQTTLogger.debug("SecurityMQTT auth step 1: EufyHome login..."); - const loginBody: EufyHomeLoginRequest = { - client_id: EUFYHOME_CLIENT_ID, - client_secret: EUFYHOME_CLIENT_SECRET, - email: this.email, - password: this.password, - }; - const loginRes = await this.httpRequest( - EUFYHOME_LOGIN_URL, - "POST", - { "Content-Type": "application/json", category: "Home" }, - JSON.stringify(loginBody), - ); - if (!loginRes.data.access_token) { - throw new Error(`EufyHome login failed: ${JSON.stringify(loginRes.data)}`); - } - rootMQTTLogger.debug("SecurityMQTT auth step 1: OK"); - return loginRes.data.access_token; - } - - /** Step 2: Exchanges the access token for a user center token and user center ID. */ - private async fetchUserCenterToken(accessToken: string): Promise { - rootMQTTLogger.debug("SecurityMQTT auth step 2: user_center_token..."); - const userCenterRes = await this.httpRequest( - EUFYHOME_USER_CENTER_URL, - "GET", - { - "Content-Type": "application/json", - category: "Home", - token: accessToken, - } - ); - if (!userCenterRes.data.user_center_token) { - throw new Error(`user_center_info failed: ${JSON.stringify(userCenterRes.data)}`); - } - this.userCenterId = userCenterRes.data.user_center_id; - rootMQTTLogger.debug("SecurityMQTT auth step 2: OK", { userCenterId: this.userCenterId }); - return userCenterRes.data.user_center_token; - } - - /** Step 3: Uses the user center token to retrieve mTLS certificates from the AIOT endpoint. */ - private async fetchMqttCertificates(userCenterToken: string): Promise { - rootMQTTLogger.debug("SecurityMQTT auth step 3: MQTT certs..."); - const gtoken = createHash("md5").update(this.userCenterId).digest("hex"); + rootMQTTLogger.debug("SecurityMQTT fetching MQTT certs..."); + const gtoken = createHash("md5").update(this.userId).digest("hex"); const mqttCertRes = await this.httpRequest( AIOT_MQTT_CERT_URL, "POST", { "Content-Type": "application/json", - "X-Auth-Token": userCenterToken, + "X-Auth-Token": this.authToken, GToken: gtoken, "User-Agent": "EufySecurity-Android-4.6.0-1630", category: "eufy_security", @@ -402,7 +332,7 @@ export class SecurityMQTTService extends TypedEmitter throw new Error(`MQTT certs failed: ${JSON.stringify(mqttCertRes.data)}`); } this.mqttInfo = mqttCertRes.data.data as MQTTCertInfo; - rootMQTTLogger.debug("SecurityMQTT auth step 3: OK", { + rootMQTTLogger.debug("SecurityMQTT certs OK", { thingName: this.mqttInfo.thing_name, endpointAddr: this.mqttInfo.endpoint_addr, }); @@ -412,8 +342,7 @@ export class SecurityMQTTService extends TypedEmitter * Makes an HTTPS request and returns the parsed response. * * Uses Node's built-in `https` module rather than a third-party library to avoid - * adding dependencies. The EufyHome API is a separate service from the main Eufy - * security API (which uses the `got` library in src/http/api.ts). + * coupling to HTTPApi internals. Only used for the AIOT cert fetch. */ private httpRequest( url: string, @@ -468,7 +397,7 @@ export class SecurityMQTTService extends TypedEmitter */ private buildClientId(host: string): string { const hostNoPunctuation = host.replace(/[.\-]/g, ""); - return `android-eufy_security-${this.userCenterId}-${this.openudid}${hostNoPunctuation}`; + return `android-eufy_security-${this.userId}-${this.openudid}${hostNoPunctuation}`; } private getMqttTopic(deviceModel: string, deviceSN: string, direction: "req" | "res"): string { From 727e4264a4fb09168b0447eca1334b165d929249 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:11:33 -0600 Subject: [PATCH 17/22] Address PR review: remove section dividers, use @remarks tag --- src/mqtt/security-mqtt.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/mqtt/security-mqtt.ts b/src/mqtt/security-mqtt.ts index b45090a6..a632108d 100644 --- a/src/mqtt/security-mqtt.ts +++ b/src/mqtt/security-mqtt.ts @@ -93,7 +93,8 @@ interface DeviceCommandPayload { * BLE command frames as other smart locks (T8506, T8502), but tunneled over * a dedicated security MQTT broker instead of P2P or Cloud API. * - * The auth chain is: Security API auth_token -> AIOT mTLS certs -> MQTT connect. + * @remarks + * Auth chain: Security API auth_token -> AIOT mTLS certs -> MQTT connect. */ export class SecurityMQTTService extends TypedEmitter { private client: mqtt.MqttClient | null = null; @@ -127,8 +128,6 @@ export class SecurityMQTTService extends TypedEmitter this.country = country; } - // ─── Public API ────────────────────────────────────────────────────────── - /** * Connects to the Eufy security MQTT broker. * @@ -303,8 +302,6 @@ export class SecurityMQTTService extends TypedEmitter } } - // ─── Private: Authentication ───────────────────────────────────────────── - /** Retrieves mTLS certificates from the AIOT endpoint using the Security API auth_token. */ private async authenticate(): Promise { rootMQTTLogger.debug("SecurityMQTT fetching MQTT certs..."); @@ -380,8 +377,6 @@ export class SecurityMQTTService extends TypedEmitter }); } - // ─── Private: MQTT Helpers ─────────────────────────────────────────────── - private getSecurityBrokerHost(apiBase: string): string { if (apiBase.includes("-eu.")) { return "security-mqtt-eu.anker.com"; @@ -435,8 +430,6 @@ export class SecurityMQTTService extends TypedEmitter ).join(""); } - // ─── Private: Command Building ─────────────────────────────────────────── - /** * Builds the MQTT message envelope for sending a command to a lock device. * @@ -479,8 +472,6 @@ export class SecurityMQTTService extends TypedEmitter }; } - // ─── Private: Message Handling ─────────────────────────────────────────── - /** * Handles incoming MQTT messages from the security broker. * From 6c51b10f581a980198a23bc4e2b61232fc7c8963 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:40:57 -0600 Subject: [PATCH 18/22] Fix duplicate isLockWifiT85D0 from rebase --- src/http/device.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/http/device.ts b/src/http/device.ts index 91f6e355..977f714a 100644 --- a/src/http/device.ts +++ b/src/http/device.ts @@ -2222,10 +2222,6 @@ export class Device extends TypedEmitter { return DeviceType.LOCK_8502 == type; } - static isLockWifiT85D0(type: number): boolean { - return DeviceType.LOCK_85D0 == type; - } - static usesSecurityMqtt(type: number): boolean { return Device.isLockWifiT85D0(type); } From 2de9c5d7926829ed85a7114b0b935fae32004977 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:41:41 -0600 Subject: [PATCH 19/22] Remove duplicate T85D0 images (upstream already has them) --- docs/_media/smartlock_c30_t85d0_large.jpg | Bin 11871 -> 0 bytes docs/_media/smartlock_c30_t85d0_small.jpg | Bin 1511 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/_media/smartlock_c30_t85d0_large.jpg delete mode 100644 docs/_media/smartlock_c30_t85d0_small.jpg diff --git a/docs/_media/smartlock_c30_t85d0_large.jpg b/docs/_media/smartlock_c30_t85d0_large.jpg deleted file mode 100644 index 9e87c2f926495439cd62f7bf34d3645bf1003327..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11871 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 Date: Tue, 19 May 2026 16:57:25 -0500 Subject: [PATCH 20/22] style: prettier-format SecurityMQTT and BLE lock protocol files --- src/mqtt/ble-lock-protocol.ts | 6 +++- src/mqtt/security-mqtt.test.ts | 62 +++++++++++++++++++--------------- src/mqtt/security-mqtt.ts | 15 ++++---- 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/src/mqtt/ble-lock-protocol.ts b/src/mqtt/ble-lock-protocol.ts index 9c9ba991..720ca6a1 100644 --- a/src/mqtt/ble-lock-protocol.ts +++ b/src/mqtt/ble-lock-protocol.ts @@ -47,7 +47,11 @@ export class BleLockProtocol { * @returns Parsed frame fields, or null if the buffer is not a valid FF09 frame. */ static parseBleFrame(buffer: Buffer): BleFrame | null { - if (buffer.length < BLE_FRAME_MIN_LENGTH || buffer[0] !== BLE_FRAME_HEADER_BYTE_0 || buffer[1] !== BLE_FRAME_HEADER_BYTE_1) { + if ( + buffer.length < BLE_FRAME_MIN_LENGTH || + buffer[0] !== BLE_FRAME_HEADER_BYTE_0 || + buffer[1] !== BLE_FRAME_HEADER_BYTE_1 + ) { return null; } diff --git a/src/mqtt/security-mqtt.test.ts b/src/mqtt/security-mqtt.test.ts index 2d0d81b8..bb2437ee 100644 --- a/src/mqtt/security-mqtt.test.ts +++ b/src/mqtt/security-mqtt.test.ts @@ -61,41 +61,49 @@ describe("BleLockProtocol", () => { describe("isHeartbeat", () => { it("returns true for unencrypted NOTIFY frame", () => { - expect(BleLockProtocol.isHeartbeat({ - isEncrypted: false, - isResponse: false, - commandCode: 74, // SmartLockBleCommandFunctionType2.NOTIFY - data: Buffer.alloc(0), - })).toBe(true); + expect( + BleLockProtocol.isHeartbeat({ + isEncrypted: false, + isResponse: false, + commandCode: 74, // SmartLockBleCommandFunctionType2.NOTIFY + data: Buffer.alloc(0), + }) + ).toBe(true); }); it("returns false for encrypted NOTIFY frame", () => { - expect(BleLockProtocol.isHeartbeat({ - isEncrypted: true, - isResponse: false, - commandCode: 74, - data: Buffer.alloc(0), - })).toBe(false); + expect( + BleLockProtocol.isHeartbeat({ + isEncrypted: true, + isResponse: false, + commandCode: 74, + data: Buffer.alloc(0), + }) + ).toBe(false); }); }); describe("isLockCommandResponse", () => { it("returns true for response ON_OFF_LOCK frame", () => { - expect(BleLockProtocol.isLockCommandResponse({ - isEncrypted: false, - isResponse: true, - commandCode: 35, // SmartLockBleCommandFunctionType2.ON_OFF_LOCK - data: Buffer.alloc(0), - })).toBe(true); + expect( + BleLockProtocol.isLockCommandResponse({ + isEncrypted: false, + isResponse: true, + commandCode: 35, // SmartLockBleCommandFunctionType2.ON_OFF_LOCK + data: Buffer.alloc(0), + }) + ).toBe(true); }); it("returns false for non-response frame", () => { - expect(BleLockProtocol.isLockCommandResponse({ - isEncrypted: false, - isResponse: false, - commandCode: 35, - data: Buffer.alloc(0), - })).toBe(false); + expect( + BleLockProtocol.isLockCommandResponse({ + isEncrypted: false, + isResponse: false, + commandCode: 35, + data: Buffer.alloc(0), + }) + ).toBe(false); }); }); @@ -144,14 +152,12 @@ describe("SecurityMQTTService", () => { describe("getMqttTopic", () => { it("builds the correct request topic", () => { const service = createService(); - expect(service.getMqttTopic("T85D0", "SN123", "req")) - .toBe("cmd/eufy_security/T85D0/SN123/req"); + expect(service.getMqttTopic("T85D0", "SN123", "req")).toBe("cmd/eufy_security/T85D0/SN123/req"); }); it("builds the correct response topic", () => { const service = createService(); - expect(service.getMqttTopic("T85D0", "SN123", "res")) - .toBe("cmd/eufy_security/T85D0/SN123/res"); + expect(service.getMqttTopic("T85D0", "SN123", "res")).toBe("cmd/eufy_security/T85D0/SN123/res"); }); }); diff --git a/src/mqtt/security-mqtt.ts b/src/mqtt/security-mqtt.ts index a632108d..f2133400 100644 --- a/src/mqtt/security-mqtt.ts +++ b/src/mqtt/security-mqtt.ts @@ -241,7 +241,7 @@ export class SecurityMQTTService extends TypedEmitter nickName: string, channel: number, sequence: number, - lock: boolean, + lock: boolean ): Promise { return new Promise((resolve) => { if (!this.client || this.connectionState !== ConnectionState.CONNECTED) { @@ -257,7 +257,7 @@ export class SecurityMQTTService extends TypedEmitter channel, sequence, Lock.encodeCmdSmartLockUnlock(adminUserId, lock, nickName, shortUserId), - SmartLockFunctionType.TYPE_2, + SmartLockFunctionType.TYPE_2 ); const transPayload: SmartLockP2PCommandPayloadType = JSON.parse(command.payload.value); @@ -323,7 +323,7 @@ export class SecurityMQTTService extends TypedEmitter "Model-type": "PHONE", timezone: "America/New_York", }, - JSON.stringify({}), + JSON.stringify({}) ); if (mqttCertRes.data.code !== 0) { throw new Error(`MQTT certs failed: ${JSON.stringify(mqttCertRes.data)}`); @@ -405,10 +405,7 @@ export class SecurityMQTTService extends TypedEmitter return; } - const topics = [ - this.getMqttTopic(deviceModel, deviceSN, "res"), - this.getMqttTopic(deviceModel, deviceSN, "req"), - ]; + const topics = [this.getMqttTopic(deviceModel, deviceSN, "res"), this.getMqttTopic(deviceModel, deviceSN, "req")]; for (const topic of topics) { this.client.subscribe(topic, { qos: 1 }, (err) => { @@ -426,7 +423,9 @@ export class SecurityMQTTService extends TypedEmitter /** Generates a random 32-character hex string for the MQTT message seed. */ private static generateSeed(): string { return Array.from({ length: 16 }, () => - Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0") ).join(""); } From 5530c6b213221f1894da9baee6f58b48bd1e2767 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Wed, 20 May 2026 07:16:10 -0500 Subject: [PATCH 21/22] Route T85L0 Smart Lock through the security-MQTT path T85L0 is already registered as a smart lock everywhere else (device type, command chains, property/user handling); usesSecurityMqtt() was the only gate still scoped to T85D0 only. Suggested by @jhongturney on #797. --- src/http/device.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http/device.ts b/src/http/device.ts index 3d86db7f..9f9a1dd0 100644 --- a/src/http/device.ts +++ b/src/http/device.ts @@ -2270,7 +2270,7 @@ export class Device extends TypedEmitter { } static usesSecurityMqtt(type: number): boolean { - return Device.isLockWifiT85D0(type); + return Device.isLockWifiT85D0(type) || Device.isLockWifiT85L0(type); } static isLockWifiT8510P(type: number, serialnumber: string): boolean { From 1187cf64b201922b99ae360693455505bce2aa09 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Thu, 21 May 2026 15:55:16 -0500 Subject: [PATCH 22/22] style: prettier-format p2p/utils.ts (lowercase hex literal) Pre-existing prettier nit on upstream develop (0xFFFF -> 0xffff); fixing it here so this PR's format check goes green. --- src/p2p/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/p2p/utils.ts b/src/p2p/utils.ts index 2d2844aa..854759d1 100644 --- a/src/p2p/utils.ts +++ b/src/p2p/utils.ts @@ -694,7 +694,7 @@ export const buildTalkbackAudioFrameHeader = (audioData: Buffer, channel = 0): B const unknown1 = Buffer.alloc(1); const audioType = Buffer.alloc(1); const audioSeq = Buffer.allocUnsafe(2); - audioSeq.writeUInt16LE(_talkbackSeq & 0xFFFF); + audioSeq.writeUInt16LE(_talkbackSeq & 0xffff); _talkbackSeq++; const audioTimestamp = Buffer.alloc(8); audioTimestamp.writeBigUInt64LE(BigInt(_talkbackTimestamp));