Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
daf2f22
Add Smart Lock C30 (T85D0) device support
nick-pape Feb 14, 2026
253782c
Add MQTT-based lock control for T85D0 Smart Lock C30
nick-pape Feb 15, 2026
c65c149
Add T85D0 Smart Lock C30 protocol specification
nick-pape Feb 15, 2026
57a92c4
Add usesSecurityMqtt() helper and remove hardcoded T85D0 references
nick-pape Feb 18, 2026
2b0f73d
Refactor SecurityMQTTService: interfaces, enums, doc comments, naming
nick-pape Feb 18, 2026
f318f96
Add unit tests for SecurityMQTTService protocol parsing
nick-pape Feb 18, 2026
7501d66
Extract BleLockProtocol, add cmd/cmd_status enums, split authenticate…
nick-pape Feb 18, 2026
fd7ec46
Add T85D0 to P2P property update and user password conditional checks
nick-pape Feb 18, 2026
c01e193
Move protocol spec to dev-docs, add Smart Lock C30 to supported devices
nick-pape Feb 18, 2026
8de9266
Add Smart Lock C30 (T85D0) product image to docs
nick-pape Feb 18, 2026
45e3be7
Add related issues research for MQTT lock support
nick-pape Feb 18, 2026
b5b922d
Enable TLS certificate verification for security MQTT broker
nick-pape Feb 18, 2026
2b3bc8d
Remove related-issues.md (moved to PR description)
nick-pape Feb 18, 2026
96c6d0a
Replace text-art diagrams with GitHub Mermaid in protocol spec
nick-pape Feb 18, 2026
2d26445
Fix Mermaid diagrams: use <br/> for line breaks, convert auth flow to…
nick-pape Feb 18, 2026
09a532e
Eliminate EufyHome login from SecurityMQTTService
nick-pape Feb 18, 2026
727e426
Address PR review: remove section dividers, use @remarks tag
nick-pape Feb 24, 2026
6c51b10
Fix duplicate isLockWifiT85D0 from rebase
nick-pape Feb 24, 2026
2de9c5d
Remove duplicate T85D0 images (upstream already has them)
nick-pape Feb 24, 2026
8aa8a83
Merge remote-tracking branch 'upstream/develop' into feat/t85d0-smart…
nick-pape May 19, 2026
bdd350e
style: prettier-format SecurityMQTT and BLE lock protocol files
nick-pape May 19, 2026
5530c6b
Route T85L0 Smart Lock through the security-MQTT path
nick-pape May 20, 2026
1187cf6
style: prettier-format p2p/utils.ts (lowercase hex literal)
nick-pape May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
513 changes: 513 additions & 0 deletions dev-docs/t85d0-smart-lock-protocol.md

Large diffs are not rendered by default.

134 changes: 130 additions & 4 deletions src/eufysecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import {
MotionZone,
CrossTrackingGroupEntry,
} from "./p2p/interfaces";
import { CommandResult, StorageInfoBodyHB3 } from "./p2p/models";
import { CommandResult, PropertyData, StorageInfoBodyHB3 } from "./p2p/models";
import {
generateSerialnumber,
generateUDID,
Expand Down Expand Up @@ -110,6 +110,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 {
Expand Down Expand Up @@ -140,6 +141,7 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {

private pushService!: PushNotificationService;
private mqttService!: MQTTService;
private securityMqttService: SecurityMQTTService | null = null;
private pushCloudRegistered = false;
private pushCloudChecked = false;
private persistentFile!: string;
Expand Down Expand Up @@ -424,6 +426,69 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
});
}

private async initSecurityMqtt(apiBase: string): Promise<void> {
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(
authToken,
userId,
this.persistentData.openudid,
this.config.country || "US"
);

this.securityMqttService.on("connect", () => {
rootMainLogger.info("SecurityMQTT connected");
for (const device of Object.values(this.devices)) {
if (device.usesSecurityMqtt()) {
this.securityMqttService!.subscribeLock(device.getSerial(), device.getModel());
}
}
});

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);
// Battery is -1 when the heartbeat didn't include a battery TLV tag
if (battery >= 0) {
device.updateProperty(PropertyName.DeviceBattery, battery);
}
})
.catch((err) => {
rootMainLogger.debug("SecurityMQTT lock status update failed - device not found", {
deviceSN,
error: getError(ensureError(err)),
});
});
});

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" &&
Expand Down Expand Up @@ -508,6 +573,9 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
this.emit("device added", device);

if (device.isLock()) this.mqttService.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!`);
}
Expand Down Expand Up @@ -790,6 +858,20 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
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)
);
station.on("hub notify update", (station: Station) => this.onHubNotifyUpdate(station));
this.addStation(station);
station.initialize();
Expand Down Expand Up @@ -1129,6 +1211,10 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {

this.pushService.close();
this.mqttService.close();
if (this.securityMqttService) {
this.securityMqttService.close();
this.securityMqttService = null;
}

Object.values(this.stations).forEach((station) => {
station.close();
Expand Down Expand Up @@ -1228,6 +1314,9 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
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(this.api.getAPIBase());
} else {
rootMainLogger.warn("No login data recevied to initialize MQTT connection...");
}
Expand Down Expand Up @@ -2381,7 +2470,8 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
!device.isLockWifiT8502() &&
!device.isLockWifiT8510P() &&
!device.isLockWifiT8520P() &&
!device.isLockWifiT85L0()) ||
!device.isLockWifiT85L0() &&
!device.isLockWifiT85D0()) ||
(result.customData !== undefined &&
result.customData.property !== undefined &&
device.isSmartSafe() &&
Expand All @@ -2392,7 +2482,8 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
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)) {
Expand Down Expand Up @@ -2803,6 +2894,40 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
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;
}
const device = this.devices[deviceSN];
const deviceModel = device ? device.getModel() : deviceSN;
this.securityMqttService
.lockDevice(deviceSN, deviceModel, 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)) });
});
}
Comment on lines +2906 to +2929

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!this.securityMqttService || !this.securityMqttService.isConnected()) {
rootMainLogger.error("SecurityMQTT not connected, cannot send lock command", { deviceSN });
return;
}
const device = this.devices[deviceSN];
const deviceModel = device ? device.getModel() : deviceSN;
this.securityMqttService
.lockDevice(deviceSN, deviceModel, 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)) });
});
}
this.sendMqttLockCommandWithRetry(deviceSN, adminUserId, shortUserId, nickName, channel, sequence, lock, 0);
}
private sendMqttLockCommandWithRetry(
deviceSN: string,
adminUserId: string,
shortUserId: string,
nickName: string,
channel: number,
sequence: number,
lock: boolean,
attemptNumber: number
): void {
const maxRetries = 3;
const retryDelayMs = Math.min(1000 * Math.pow(2, attemptNumber), 8000); // Exponential backoff: 1s, 2s, 4s, max 8s
if (!this.securityMqttService || !this.securityMqttService.isConnected()) {
if (attemptNumber < maxRetries) {
setTimeout(() => {
this.sendMqttLockCommandWithRetry(
deviceSN,
adminUserId,
shortUserId,
nickName,
channel,
sequence,
lock,
attemptNumber + 1
);
}, retryDelayMs);
} else {
rootMainLogger.error("SecurityMQTT not connected after retries, cannot send lock command", {
deviceSN,
attempts: attemptNumber + 1,
});
}
return;
}
const device = this.devices[deviceSN];
const deviceModel = device ? device.getModel() : deviceSN;
this.securityMqttService
.lockDevice(deviceSN, deviceModel, adminUserId, shortUserId, nickName, channel, sequence, lock)
.then((success) => {
if (success) {
// Refresh cloud data after a delay to allow physical lock to change
setTimeout(() => {
this.refreshCloudData().catch((err) => {
rootMainLogger.error("Failed to refresh cloud data after MQTT command", {
deviceSN,
error: getError(ensureError(err)),
});
});
}, 7000);
} else if (attemptNumber < maxRetries) {
// Retry on publish failure
setTimeout(() => {
this.sendMqttLockCommandWithRetry(
deviceSN,
adminUserId,
shortUserId,
nickName,
channel,
sequence,
lock,
attemptNumber + 1
);
}, retryDelayMs);
} else {
rootMainLogger.error("SecurityMQTT lock command failed after retries", {
deviceSN,
lock,
attempts: attemptNumber + 1,
});
}
})
.catch((err) => {
if (attemptNumber < maxRetries) {
// Retry on error
setTimeout(() => {
this.sendMqttLockCommandWithRetry(
deviceSN,
adminUserId,
shortUserId,
nickName,
channel,
sequence,
lock,
attemptNumber + 1
);
}, retryDelayMs);
} else {
rootMainLogger.error("SecurityMQTT lock command error after retries", {
error: getError(ensureError(err)),
deviceSN,
lock,
attempts: attemptNumber + 1,
});
}
});
}

👋🏻 Testing with my T85L0 is working well, the only problem is the default 10 min delay in status updating. I found that forcing a cloud API refresh after the successful state change command that doesn't adhere to the global API refresh timer worked well when set to 7 seconds (it's not too aggressive). You could just add that bit from this (the 7 second cloud refresh) but I wrapped the commands in some retry behavior as well with some backoff in case anything is temporarily unresponsive.


private onDeviceOpen(device: Device, state: boolean): void {
this.emit("device open", device, state);
}
Expand Down Expand Up @@ -3283,7 +3408,8 @@ export class EufySecurity extends TypedEmitter<EufySecurityEvents> {
device.isLockWifiT8510P() ||
device.isLockWifiT8520P() ||
device.isLockWifiT8531() ||
device.isLockWifiT85L0()) &&
device.isLockWifiT85L0() ||
device.isLockWifiT85D0()) &&
user.password_list.length > 0
) {
for (const entry of user.password_list) {
Expand Down
15 changes: 14 additions & 1 deletion src/http/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2269,6 +2269,10 @@ export class Device extends TypedEmitter<DeviceEvents> {
return DeviceType.LOCK_8502 == type;
}

static usesSecurityMqtt(type: number): boolean {
return Device.isLockWifiT85D0(type) || Device.isLockWifiT85L0(type);
}

static isLockWifiT8510P(type: number, serialnumber: string): boolean {
if (
type == DeviceType.LOCK_WIFI &&
Expand Down Expand Up @@ -2594,7 +2598,8 @@ export class Device extends TypedEmitter<DeviceEvents> {
sn.startsWith("T8504") ||
sn.startsWith("T8506") ||
sn.startsWith("T85L0") ||
sn.startsWith("T8530")
sn.startsWith("T8530") ||
sn.startsWith("T85D0")
);
}

Expand Down Expand Up @@ -2754,6 +2759,14 @@ export class Device extends TypedEmitter<DeviceEvents> {
return Device.isLockWifiT8502(this.rawDevice.device_type);
}

public isLockWifiT85D0(): boolean {
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);
}
Expand Down
13 changes: 12 additions & 1 deletion src/http/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
"hub notify update": (station: Station) => void;
}

Expand Down
Loading
Loading