Skip to content

Add Secure MQTT-based lock control for Smart Lock C30 (T85D0)#797

Open
nick-pape wants to merge 23 commits into
bropat:developfrom
nick-pape:feat/t85d0-smart-lock-c30
Open

Add Secure MQTT-based lock control for Smart Lock C30 (T85D0)#797
nick-pape wants to merge 23 commits into
bropat:developfrom
nick-pape:feat/t85d0-smart-lock-c30

Conversation

@nick-pape

@nick-pape nick-pape commented Feb 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New SecurityMQTTService class (src/mqtt/security-mqtt.ts) — manages the BLE-over-MQTT connection to security-mqtt-*.anker.com for T85D0 lock control
  • BleLockProtocol class (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 / SecurityMqttCmdStatus enums for the MQTT message envelope
  • Device detection: isLockWifiT85D0() and generic usesSecurityMqtt() helper (extensible to future MQTT-based locks)
  • Integration in eufysecurity.ts: SecurityMQTT lifecycle (connect/disconnect), topic subscription per device, lock/unlock command dispatch, heartbeat-based battery + lock status updates
  • Added T85D0 to P2P conditional checks for property updates and user password management (user management still goes through P2P)
  • No second login requiredSecurityMQTTService accepts the existing auth_token and user_id from HTTPApi. The AIOT cert endpoint accepts the Security API token directly, so no separate EufyHome login is needed.
  • TLS certificate verification enabled (rejectUnauthorized: true — verified the broker cert chains to the provided AWS root CA)
  • 18 unit tests covering frame parsing, heartbeat decoding, topic construction, client ID generation
  • Supported devices doc entry + product image
  • Protocol specification in 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 — SecurityMQTTService takes auth_token and user_id from HTTPApi (via getToken() and getPersistentData().user_id), computes a GToken (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.

Implementation note: The Android APK uses a longer auth path (EufyHome login → access_tokenuser_center_token → AIOT certs via SecurityMqttManager). We discovered during testing that 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 a valid auth_token from login_sec, we skip the EufyHome chain entirely. The user_id from the Security API is identical to user_center_id from EufyHome.

The mTLS certs authenticate the MQTTS connection. Once connected, the client subscribes to device-specific topics (cmd/eufy_security/T85D0/{deviceSN}/req and /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

  • Tested on real hardware: 2x Smart Lock C30 devices (T85D0K1024470204, T85D0K10244822EB)
  • Deployed to Home Assistant via the eufy-security-ws add-on
  • Verified end-to-end: auth chain, MQTT connection with TLS verification, topic subscription, lock/unlock commands, heartbeat parsing (battery + lock state), command acknowledgments

Related Issues

Resolved (T85D0 / Smart Lock C30):

Potentially resolved (same MQTT architecture, untested without hardware):

Comment thread src/mqtt/security-mqtt.ts Outdated
Comment thread src/mqtt/security-mqtt.ts
@nick-pape nick-pape changed the title Add MQTT-based lock control for Smart Lock C30 (T85D0) Add Secure MQTT-based lock control for Smart Lock C30 (T85D0) Feb 18, 2026
@nick-pape

Copy link
Copy Markdown
Contributor Author

@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.

Comment thread dev-docs/t85d0-smart-lock-protocol.md Outdated
@martijnpoppen

Copy link
Copy Markdown
Collaborator

@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.

@nick-pape

Copy link
Copy Markdown
Contributor Author

@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)

@martijnpoppen

Copy link
Copy Markdown
Collaborator

@nick-pape yes nice! Exactly what I was looking for :)

@nick-pape

Copy link
Copy Markdown
Contributor Author

@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 device.usesSecurityMqtt()

@martijnpoppen

Copy link
Copy Markdown
Collaborator

@nick-pape no sorry i don't know. Never tested a Eufy lock

@lenoxys

lenoxys commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

@nick-pape I've some people on homebridge-eufysecurity who are willing to test. Can you change the PR to push to the develop branch ?

@lenoxys

lenoxys commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

@martijnpoppen, @max246
btw, is that possible to auto build develop in npmjs under develop tag when a new PR is merged in develop branch? It will ease the test.

@nick-pape nick-pape changed the base branch from master to develop February 19, 2026 15:53
@nick-pape

Copy link
Copy Markdown
Contributor Author

@lenoxys repointed at develop branch

@max246

max246 commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator

@martijnpoppen, @max246 btw, is that possible to auto build develop in npmjs under develop tag when a new PR is merged in develop branch? It will ease the test.

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?

@lenoxys

lenoxys commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

@martijnpoppen, @max246 btw, is that possible to auto build develop in npmjs under develop tag when a new PR is merged in develop branch? It will ease the test.

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.

@nick-pape

Copy link
Copy Markdown
Contributor Author

@lenoxys @max246 -- rebased onto develop and re-tested. Mind taking another look?

@nick-pape nick-pape force-pushed the feat/t85d0-smart-lock-c30 branch from b84a4bb to d097ef6 Compare February 24, 2026 06:24
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.
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
@askchrisn

Copy link
Copy Markdown

@nick-pape to my knowledge, without this PR, the C30 lock will not have lock/unlock capabilities correct?
I will just fork this then and do some testing

@nick-pape

Copy link
Copy Markdown
Contributor Author

@askchrisn that's correct.

…-lock-c30

# Conflicts:
#	src/eufysecurity.ts
#	src/http/interfaces.ts
#	src/http/station.ts
#	src/p2p/session.ts
@nick-pape

Copy link
Copy Markdown
Contributor Author

Merged the latest develop and resolved the conflicts with the newer lock work that landed since (T85P0 / T85V0) — T85D0 now sits alongside those in the lock command paths. Build is green, all 320 tests pass, and I re-verified lock + unlock end-to-end on a live C30 via Home Assistant (confirmed both directions in the security-MQTT command/response + heartbeat).

@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.)

Comment thread src/http/device.ts Outdated
}

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

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
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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.
@jhongturney

Copy link
Copy Markdown

@nick-pape You might want/need to prettier-ify that last file failing in the CI check, otherwise this might not get looked at.

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.
@nick-pape

Copy link
Copy Markdown
Contributor Author

Good catch on the CI. For the record, that failing file (src/p2p/utils.ts) is a pre-existing prettier nit in upstream develop (a 0xFFFF literal prettier wants lowercased), not something this PR touched, and develop's own format check is red for the same reason. But you're right that a red check doesn't help it get attention, so I went ahead and prettified it anyway. Should be green now.

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.

Comment thread src/eufysecurity.ts
Comment on lines +2906 to +2929
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)) });
});
}

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.

@lenoxys

lenoxys commented May 27, 2026

Copy link
Copy Markdown
Contributor

@nick-pape as soon its merged in develop I will make a beta announcement for my users plugin!

@max246

max246 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

I will get a phone soon to ensure we reverse the whole SMQTT and chck what we can pick from this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support MQTTS devices like C33 (T85L0) and C30 (T85D0)

6 participants