Local control of my TTLock BLE door lock, providing a command-line tool, an MQTT daemon, and a home-assistant custom component.
Caveat emptor: This project was almost entirely vibe-coded with Claude, (Opus 4.7, 4.8, 5, and Fable). In spite of this, I generally do not care for LLM-generated or LLM-assisted contributions. Please divulge LLM involvement in any communication or code, and note that issues, PR, or other contributions may (or may not) be closed on this basis alone, with or without additional feedback from me.
Further, please note that the TTLock apparently CANNOT report manual locks and unlocks (by hand or by key), therefore reported lock state will often be incorrect. This is a huge disappointment for me, if you know of a solution here please reach out.
I've been tinkering on this project on-and-off since 2021, more seriously for the last year or so. A few months ago a project with similar goals was released, and it likely deserves some attention from anyone that has ended up here first: https://github.com/roquerodrigo/ha-ttlock-ble It is also LLM-assisted, features Home Assistant integration, and seems to be seeing frequent updates.
Finally a note on obtaining your credentials: my approach was to install the official TTLock app on my MacBook.
After signing in and ensuring that I could toggle the lock with the official app, I found the database at ~/Library/Group Containers/group.tongtongsuo.app/sciener.sqlite and used this to find the required secrets.
This process has been mostly automated in the CLI tool's import-credentials command as detailed below.
LLM-generated content below.
Local control of a paired TTLock Bluetooth lock — no vendor cloud, no internet. A Home Assistant integration, an MQTT bridge, a CLI, and the sans-IO protocol library underneath them, in Rust with Python bindings.
lockData.jsonis a key to your door. It holds the lock's AES key and admin passcode in plaintext, because the protocol needs them that way. Anyone who can read that file can open the lock. Keep itchmod 600, keep it out of the Nix store and out of git, and seeSECURITY.mdbefore deploying it anywhere shared.
A manual unlock is never reported. This integration can say
LOCKEDwhile your door stands open. Measured over ~95 minutes and ~1400 advertisements: the lock detects the bolt arriving in the locked position and nothing else. Bluetooth commands appear in about 8 seconds. A manual lock appears eventually (4–10 minutes). A manual unlock — thumbturn or key, it makes no difference — never appears at all. The connected status query (0x14) is worse: on the tested lock it returns a constant regardless of bolt position. This is the firmware, not this project; the other open-source implementation behaves the same way. The lock's own operate log is no help either: walked to the present, it records every electronic operation and not one mechanical one. Both consumers therefore mark the entity as having an assumed state. Treat it as "last known", and do not build a security automation on it. See §7a of the design notes for the measurements.
custom_components/ttlock_ble is a native integration that needs no MQTT broker: it tracks state and battery passively from advertisements over Home Assistant's own Bluetooth stack, and connects on demand to lock/unlock.
It is built on the ttlock Python bindings.
See the component README for setup, credentials, and the NixOS wiring.
Prebuilt CLI binaries for Linux (x86-64, aarch64) and macOS (Apple silicon) are attached to each release. The Linux builds are dynamically linked against a recent glibc; on an older distro, build from source or use the Nix package. Intel Macs are not built.
cargo install ttlock # CLI, from crates.io
pip install ttlock # Python bindings, from PyPIThe lock's credentials live in the Sciener/TTLock phone app's local database.
import-credentials reads it strictly read-only and converts the stored keys:
ttlock import-credentials --db sciener.sqlite > lockData.json
chmod 600 lockData.jsonThe database defaults to the app's Group Containers copy under $HOME; copy it out or pass --db.
sample-lockData.json shows the format if you would rather write it by hand.
| Command | |
|---|---|
import-credentials |
Convert the Sciener app's sqlite database into lockData.json. |
scan |
Scan for TTLock-like devices and show advertisement-derived data. |
listen |
Connect and reassemble notification frames without sending commands. |
status |
Connect and query the lock state. |
logs |
Read the lock's operate log — its audit trail of who opened the door, when, and how. |
timings |
Timing breakdown: discovery, connect, and the encrypted round-trip. |
lock / unlock |
Actuate the lock. |
daemon |
Long-lived MQTT bridge (below). |
ttlock scan --seconds 10
ttlock status --file lockData.json
ttlock unlock --file lockData.json
ttlock logs --file lockData.json --format text | tail # the most recent
ttlock logs --file lockData.json | jq -r '[.at, .operation] | @tsv'logs reads the lock's operate log: who opened the door, when, and how.
It is an audit trail and not a state source — the lock records operations it performs and never bolt movement it observes, so a thumbturn or a key leaves no trace either way.
Reading is non-destructive and repeatable, so --from costs round trips and nothing else — but it is one round trip per record and the log can hold hundreds going back months, so a full read takes minutes.
Records stream as they arrive, so --limit, a pipe into head, or Ctrl-C all work.
A keyboard password unlock record contains the code that was typed, and the command prints it, warning and pausing five seconds first; --no-warn skips the pause.
Treat the output as a credential.
ttlock daemon bridges one lock to any MQTT broker.
It publishes Home Assistant discovery for a lock entity and a battery sensor, but nothing about it is HA-specific — the state, command, and availability topics are plain MQTT and work with anything.
ttlock daemon --file lockData.json --mqtt-host 192.0.2.10It holds one continuous passive BLE scan and reports state and battery from each advertisement as it arrives, interrupting the scan only to actuate on a LOCK/UNLOCK command.
Reported state is always backed by evidence: a command publishes only LOCKING/UNLOCKING until the lock acknowledges it or an advertisement confirms the bolt position.
Broker settings also come from TTLOCK_MQTT_HOST, TTLOCK_MQTT_PORT, TTLOCK_MQTT_USERNAME, and TTLOCK_MQTT_PASSWORD; prefer these over --mqtt-password, which is visible in ps.
--discovery-prefix and --base-topic move the topics, --connect-attempts helps on a weak link, and -v/-vv/RUST_LOG control logging.
ttlock daemon --help covers the rest, and docs/protocol-and-design.md explains why the defaults are what they are.
module.nix (exported as nixosModules.default) runs the MQTT daemon under systemd.
Secrets are referenced by path and read at runtime — never put lock credentials or broker passwords in the Nix store, where they are world readable.
Both options take a sops-nix secret path directly:
{
inputs.ttlock.url = "github:n8henrie/ttlock-rs";
# ... ttlock.nixosModules.default in your modules list ...
services.ttlock = {
enable = true;
lockDataFile = config.sops.secrets.ttlock-lockdata.path;
mqtt = {
host = "10.0.0.5";
# An EnvironmentFile holding TTLOCK_MQTT_USERNAME / TTLOCK_MQTT_PASSWORD,
# so the password never reaches the store or the process command line.
credentialsFile = config.sops.secrets.ttlock-mqtt.path;
};
connectAttempts = 8; # raise on a weak link
};
}The service runs as root because BlueZ denies D-Bus access to unprivileged callers; the unit is otherwise heavily sandboxed.
Packages are ttlock, ttlock-python, and ttlock-ble-component (Linux only), exported as flake outputs, an overlay, and a plain packages.nix for non-flake users.
To run Home Assistant with the component:
services.home-assistant = {
enable = true;
extraComponents = [ "bluetooth" ];
customComponents = [ ttlock.packages.${pkgs.system}.ttlock-ble-component ];
};crates/ttlock-core— the protocol, transport-agnostic (sans-IO): framing, AES-128-CBC, CRC, advertisement parsing, credential decoding, theOperationstate machines, and theLockTrackerthat decides what to report about a lock. Notokio, nobtleplug, so it backs the CLI, the Python bindings, and the HA component alike — all three report from the same tracker rather than each reimplementing the rules.crates/ttlock— thettlockCLI and MQTT daemon, driving the core overbtleplug. On crates.io asttlock.crates/ttlock-py— pyo3/maturin bindings (README). On PyPI asttlock(import ttlock).custom_components/ttlock_ble— the Home Assistant integration.
All of them share one version, [workspace.package] version in Cargo.toml; ./scripts/check-versions.sh asserts the places that cannot derive it agree.
docs/protocol-and-design.md documents the reverse-engineered protocol, which parts of it are confirmed versus inferred, and why the library is shaped the way it is.
Read it before changing anything protocol-level.
Bug reports and patches are welcome.
scripts/lint.sh runs every static check: cargo fmt, clippy at pedantic, ruff check, ruff format, ty, statix, deadnix, nixfmt, and the repository's own version and secret scans.
CI runs exactly this script, so a green run locally means a green lint job.
./scripts/lint.sh # report every failure, not just the first
./scripts/lint.sh --fix # apply what the formatters can fix, then checkIt re-enters the Nix dev shell on its own if the tools are not already on PATH, so it works from a bare shell and always uses the toolchain the flake pins rather than whatever cargo happens to be installed.
The root pyproject.toml configures both ruff and ty, so neither falls through to a config file in your home directory — without it, two people get different results from the same source tree.
nix flake check is deliberately not part of it — that builds every package and runs the NixOS VM test, which is a CI job rather than something to wait for before a commit.
Linux wheels are built through Nix rather than a manylinux container:
nix develop --command ./scripts/build-wheel.sh # writes dist/
nix develop --command ./scripts/audit-wheel.sh dist/*.whlThe wheel must run on ordinary distributions, not just on the machine that built it, and asking maturin for --compatibility manylinux_2_28 does not achieve that on its own — the option labels and audits the wheel, it cannot relink it.
The offending symbols (pthread_setspecific@GLIBC_2.34 and friends) come from ordinary Rust std, so no dependency pruning removes them.
--zig links against an older glibc, which is the actual fix; Nix pins the versions of rust, maturin and zig, and audit-wheel.sh checks the resulting ELF rather than trusting the filename.
Linux builds are native per architecture — zig is here for the glibc version, not to emulate a CPU.
The macOS wheel is deliberately not built under Nix. Doing so links it against /nix/store/…/libiconv.2.dylib by absolute path, so it imports on the builder and nowhere else; audit-wheel.sh checks Mach-O objects for exactly that.
Hooks live in .githooks/, so they are version controlled and shared rather than living in each person's .git/hooks.
Enable them once per clone:
git config core.hooksPath .githooks.githooks/pre-commit runs every executable in .githooks/pre-commit.d/ in name order — currently just 10-lint, which calls scripts/lint.sh.
To add another check, drop an executable file in that directory.
Every hook runs even if an earlier one fails, and the commit is blocked if any did, so one commit attempt shows the whole list.
chmod -x disables a hook without deleting it.
For a different hook type, copy .githooks/pre-commit to e.g. .githooks/pre-push and create .githooks/pre-push.d/; the dispatcher works out which directory to run from its own name.
Two things to know about core.hooksPath: it needs git 2.9 or newer, and it replaces the hooks directory wholesale, so anything already in .git/hooks stops running until you move it into .githooks/.
The hook checks the working tree rather than the staged content, so with a partially staged change it reports on files as they are on disk.
Bypass it with git commit --no-verify.
This project was built with heavy LLM assistance, and I am not going to pretend otherwise. That does not oblige me to review LLM output from anyone else: I may close LLM-generated issues and pull requests for no reason other than being LLM-generated, at my sole discretion. If you use a model, understand and vouch for what you are submitting.
MIT. See LICENSE.
0 comments
log in to comment.