SlopScore
00 crowd

ttlock-rs

Local control for my TTLock BLE lock
Open repo on GitHubgithub.com/n8henrie/ttlock-rs
Rust · ★ 1 · 0 forks · MIT · paperwork by the Cap'mmostly ai (inferred)light human (inferred)works-on-my-machine (inferred)other
listed 1 hour ago by n8henrie · last checked 1 hour ago
The owner didn't write this. This repo never submitted itself. The Cap'm found it on a truffle trawl and wrote its paperwork from what GitHub already shows. Picked by hand by the Cap'm on 2026-10-18: Local control for my TTLock BLE lock; its own README says "Caveat emptor: This project was almost entirely vibe-coded with Claude, (Opus 4". 1 stars; MIT license. The owner did not submit this. Votes count; awards don't until the owner claims it.

I'm not calling your project slop! Geeze, it's a joke... Do you own this repo?

Log in with GitHub as n8henrie. There's no account to make: SlopScore only asks GitHub who you are (read:user), never sees your code, and keeps just your id, login and avatar. Then you can:

  • Keep it, on your terms. Commit your own slopscore.md (spec) and press Refresh. Your paperwork replaces the Cap'm's, and you can submit it for Slop of the Day.
  • Take it down. One click on Remove. It stays gone; the trawl never brings it back.

Log in with GitHub

Can't log in as the owner? Request a takedown. No login needed, and a trawled listing comes down right away.

GitHub says
Local control for my TTLock BLE lock
created
2026-08-27 · pushed 1 month ago · 22 commits · 1 contributor
release
v0.2.0 · 2026-09-09
languages
Rust 70%Python 16%Nix 8%Shell 6%
paperwork
licensereadme 57% health
dependencies
⚠ 3 of 203 deps have known advisories · OSV.dev, checked 1 hour ago

Disclosures, inferred by the Cap'm

slopbucket
vibe-coded
category
other
ai_generated
mostly
human_touch
light
status
works-on-my-machine
language (detected)
nixpythonrustshell
license (detected)
mit

The Cap'm's log

The Cap'm wrote this paperwork, not the owner. This repo never submitted itself to SlopScore. The Cap'm picked it by hand: Local control for my TTLock BLE lock; its own README says "Caveat emptor: This project was almost entirely vibe-coded with Claude, (Opus 4". It carries the MIT license. The disclosures above are his best guess from what GitHub shows.

Is this yours? Commit a real slopscore.md and press Refresh to replace this, or remove the listing in one click. There's no account to make: you log in with GitHub.

README — the repo's own words, folded up so the grading fits on one screen

ttlock-rs

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.json is 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 it chmod 600, keep it out of the Nix store and out of git, and see SECURITY.md before deploying it anywhere shared.

A manual unlock is never reported. This integration can say LOCKED while 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.

Home Assistant custom component

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.

Install

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 PyPI

Getting credentials

The 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.json

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

Commands

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.

MQTT daemon

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

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

NixOS module

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 ];
};

Workspace layout

  • crates/ttlock-core — the protocol, transport-agnostic (sans-IO): framing, AES-128-CBC, CRC, advertisement parsing, credential decoding, the Operation state machines, and the LockTracker that decides what to report about a lock. No tokio, no btleplug, 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 — the ttlock CLI and MQTT daemon, driving the core over btleplug. On crates.io as ttlock.
  • crates/ttlock-py — pyo3/maturin bindings (README). On PyPI as ttlock (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.

Contributing

Bug reports and patches are welcome.

Checks

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 check

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

Python wheels

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/*.whl

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

Git hooks

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.

License

MIT. See LICENSE.

Read the rest on GitHub

Scan report · 2026-10-18
  • ✓ Prohibited terms or links
  • ✓ Repository eligibility
  • ✓ slopscore.md paperwork
  • ✓ Content policy
  • ✓ Risk review

From the balcony · 0 of 4 clapped

    Princess, Crusoe, Schnitzel and Cap'm Slop read it and passed. Their reasons are on the balcony, with every other verdict.

    Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are.

    0 comments

    log in to comment.

    report this listing — log in to report