Add Secure MQTT-based lock control for Smart Lock C30 (T85D0)#797
Add Secure MQTT-based lock control for Smart Lock C30 (T85D0)#797nick-pape wants to merge 23 commits into
Conversation
|
@max246 @bropat - let me know what you think! Over the weekend I finally got fed up with not being able to automate my door locks that I've had over a year... Figured I'd put that new Claude subscription to the test. Was a real challenge to get the protocol (even with Claude) -- we tried a bunch of stuff: MITM, rooting an emulator, etc. By the end the only way to solve was to decompile the APK, which is semi-obfuscated. I don't really know Android much (wrote like 2 non-production apps in the past) so that would have taken a very long time to do myself. You can't imagine how happy I was to finally have a script that controlled the lock, haha! I know this was a pending item blocking a bunch of locks, so huge extra benefit :) Feel free to edit. I added a spec document, which we can tighten or toss as needed. |
|
@nick-pape I don't think it's a good practice to use the EufyHome (or a Anker) API to login. That will be phased out. Eufy has a new Eufy Mega API which we should use for this. Having the Eufy-security and Eufy-home authentication in 1 library is not what we want. It's better to have 1 login. |
|
@martijnpoppen Ack. Went this route mostly because... that's what the APK is doing. I was able to test this out using the auth flows from the existing Security API. Apparently the auth_token from there works with the AIOT certficate endpoint. Would these changes be sufficient? nick-pape#3 . (Haven't cleaned this up yet) |
|
@nick-pape yes nice! Exactly what I was looking for :) |
|
@martijnpoppen any suggestions for how to go about testing this for other locks? I assume it should be pretty safe, since only my T85D0 is enabled in |
|
@nick-pape no sorry i don't know. Never tested a Eufy lock |
|
@nick-pape I've some people on homebridge-eufysecurity who are willing to test. Can you change the PR to push to the |
|
@martijnpoppen, @max246 |
|
@lenoxys repointed at develop branch |
I think is good question... Probably is possible just need to work out how to tag develop and don't make it look like the latest version. Not too family with npm publish command line, happy to have suggestions if you have any ideas. We have a docker-dev that builds the GitHub branch to test it out. Maybe you can take inspiration from that? |
Will push you a PR for that. |
4993c32 to
38bcfc2
Compare
b84a4bb to
d097ef6
Compare
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 bropat#617, closes bropat#654
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.
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
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".
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
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
…, 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<deviceSN, deviceModel> - Remove AiotMqttCertRequest interface (empty object doesn't need a type) - Update tests to use BleLockProtocol directly, add isHeartbeat/isLockCommandResponse tests
T85D0 shares the same P2P command handling as T8506/T8502 for property updates and user management (only lock/unlock is routed via MQTT).
- 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
Cropped and resized to match existing convention (75x75 small, 320x320 large).
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.
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.
… sequence diagram
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
d097ef6 to
2de9c5d
Compare
|
@nick-pape to my knowledge, without this PR, the C30 lock will not have lock/unlock capabilities correct? |
|
@askchrisn that's correct. |
…-lock-c30 # Conflicts: # src/eufysecurity.ts # src/http/interfaces.ts # src/http/station.ts # src/p2p/session.ts
|
Merged the latest @lenoxys @max246 — caught up and ready for another look. @lenoxys, are your homebridge folks still up for testing the C30? (This comment and the conflict resolution were done by Claude Code on Nick's behalf.) |
| } | ||
|
|
||
| static usesSecurityMqtt(type: number): boolean { | ||
| return Device.isLockWifiT85D0(type); |
There was a problem hiding this comment.
| return Device.isLockWifiT85D0(type); | |
| return Device.isLockWifiT85D0(type) || Device.isLockWifiT85L0(type); |
If you could adjust this line to cover the T85L0 device I'm pretty sure it would work as-is. I'd be happy to test with my device, but I'm not entirely sure I can just overwrite the package.json entry for this dependency in my homebridge-eufy-security plugin folder. Maybe? Is the test package hosted somewhere public?
There was a problem hiding this comment.
Thanks @jhongturney, applied it. T85L0 was already set up as a smart lock everywhere else in the code, so usesSecurityMqtt() was the only place it was missing.
I don't have a T85L0 myself, so it'd be great if you could confirm it works on yours. The way I test without touching package.json: run eufy-security-ws as a Home Assistant add-on, npm run build, copy the build/ folder into the add-on's node_modules/eufy-security-client/build, and restart. Then trigger a lock/unlock from Home Assistant and watch the add-on logs for the SecurityMQTT command going out and the heartbeat coming back. Happy to share exact commands if it helps.
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 bropat#797.
|
@nick-pape You might want/need to BTW, I was able to build locally and copy/overwrite the files on my RPi, everything looks good after a HomeBridge restart (I'm not using HA, as you are). Unfortunately I won't be able to fully test until Monday, I think, but I'll post an update on the functionality of my T85L0 as soon as I'm able to. |
Pre-existing prettier nit on upstream develop (0xFFFF -> 0xffff); fixing it here so this PR's format check goes green.
|
Good catch on the CI. For the record, that failing file ( And thank you for building and testing on your RPi / Homebridge. Really glad it's looking good so far. No rush at all, I'll watch for your T85L0 functional update Monday. |
| 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)) }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
| 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.
|
@nick-pape as soon its merged in develop I will make a beta announcement for my users plugin! |
|
I will get a phone soon to ensure we reverse the whole SMQTT and chck what we can pick from this PR |
Summary
SecurityMQTTServiceclass (src/mqtt/security-mqtt.ts) — manages the BLE-over-MQTT connection tosecurity-mqtt-*.anker.comfor T85D0 lock controlBleLockProtocolclass (src/mqtt/ble-lock-protocol.ts) — parses FF09 BLE frames, heartbeat TLV payloads, and command responses (same wire format as T8506/T8502 but over MQTT)SecurityMqttCmd/SecurityMqttCmdStatusenums for the MQTT message envelopeisLockWifiT85D0()and genericusesSecurityMqtt()helper (extensible to future MQTT-based locks)eufysecurity.ts: SecurityMQTT lifecycle (connect/disconnect), topic subscription per device, lock/unlock command dispatch, heartbeat-based battery + lock status updatesSecurityMQTTServiceaccepts the existingauth_tokenanduser_idfrom HTTPApi. The AIOT cert endpoint accepts the Security API token directly, so no separate EufyHome login is needed.rejectUnauthorized: true— verified the broker cert chains to the provided AWS root CA)dev-docs/(can be removed if preferred)Security MQTT Protocol
The T85D0 Smart Lock C30 does not support P2P or Cloud API for lock control (P2P fails with error 20028, no DSK provisioning). Instead, it uses a dedicated security MQTT broker (
security-mqtt-{region}.anker.com:8883) as its control channel. This is the same issue identified in #709, where lock status worked but lock/unlock did not.Authentication reuses the existing Security API credentials —
SecurityMQTTServicetakesauth_tokenanduser_idfromHTTPApi(viagetToken()andgetPersistentData().user_id), computes aGToken(MD5 of user_id), and fetches mTLS certificates from the AIOT endpoint (aiot-clean-api-pr.eufylife.com/app/devicemanage/get_user_mqtt_info). No separate login is introduced.The mTLS certs authenticate the MQTTS connection. Once connected, the client subscribes to device-specific topics (
cmd/eufy_security/T85D0/{deviceSN}/reqand/res) and sends BLE command frames — the same FF09 format with AES-128-CBC encryption used by T8506/T8502 over Bluetooth — tunneled through MQTT messages.The lock publishes periodic heartbeat messages containing battery level and lock state as TLV fields (tag 0xA1 = battery, 0xA2 = lock status). These provide real-time status updates without polling. Lock/unlock commands receive acknowledgments via response frames (commandCode 35 = ON_OFF_LOCK).
Testing
T85D0K1024470204,T85D0K10244822EB)eufy-security-wsadd-onRelated Issues
Resolved (T85D0 / Smart Lock C30):
Potentially resolved (same MQTT architecture, untested without hardware):
security-mqttbroker)p2p_did/dsk_key)