Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

87 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gatekeeper-bot

Based on missytake/doorbot -- thanks to missytake for the Delta Chat bot skeleton. Webxdc app reuses the layout from deltachat-bot/webxdcbot. Substantial portions of the code and documentation were co-authored with Anthropic Claude under the repo owner's direction -- see Attribution at the end of this README for details.

A Raspberry Pi-hosted bridge that operates an eQ-3 Eqiva Smart Lock from chat. A small Python daemon (delta-door-bot.py) listens on a Delta Chat account. Two control surfaces share one backend:

  • Text commands -- send /lock / /unlock / /open / /status from an allowed chat.
  • Gatekeeper webxdc app -- full lock control inside the chat: closed-lock / open-lock / door buttons, live status icon, "Last update HH:MM" line. Tagged internally as app: "gatekeeper".

A disabled Quick-Lock app (built artefact under apps-disabled/) is still in the tree and can be re-enabled by moving its source + .xdc back under apps/. A previously-shipped Quick-Unlock app was retired for safety -- a stray tap on a phone home screen should not be enough to open a door.

Both apps speak the same protocol; the bot doesn't behave differently per app (the app tag is only logged for debugging).

All paths converge on send-command.sh, which calls keyblepy over BLE. State pushes flow back from the bot to every active app instance whenever the lock changes -- whether the change came from an app, a text command, or another allowed chat.

   your phone -- Delta Chat -+--> delta-door-bot.py --> send-command.sh
                             |          |                     |
   webxdc apps (in chat) ----+          |          keyblepy -- BlueZ -- Pi BLE --+
                                        v                                        |
                              systemd (deltabot)                                 v
                                                                          Eqiva Smart Lock

All runtime configuration and secrets live in a single .env file at the repo root (see .env.example). Scripts source .env on startup; delta-door-bot.py reads env vars via os.environ. The active webxdc app is built into apps/<id>.xdc (tracked artifact -- see Building the apps). The bot auto-discovers them on each /apps call.


1. Prerequisites

Hardware -- reference deployment

The project is developed and tested on:

  • Host -- Raspberry Pi Zero 2 W (ARMv8, 512 MB RAM).
  • OS -- Raspberry Pi OS (Debian 12 Bookworm), 64-bit. Python 3.11+.
  • Bluetooth -- Realtek RTL8761BU-based USB dongle with an external antenna (USB ID 0bda:a729, BD 8A:88:4B:C2:9C:B9, advertised as "Bluetooth 5.3 Radio"), on the powered USB hub described under Networking. The onboard Broadcom BCM43430A1 radio (hci0) also works and is used as a fallback/verification adapter, but its integrated antenna only reaches short line-of-sight; the external-antenna dongle is the reliable path for locks more than a couple of metres away or through walls.
  • Networking -- wired ethernet via a Realtek RTL8152 USB adapter (0bda:8152 -> eth0) is the primary link since 2026-07-19; the onboard WiFi (wlan0) stays up as a dormant fallback. The USB-ethernet adapter and the BLE dongle share a powered micro-USB OTG hub, since the Pi Zero 2 W has only one USB data port.
  • Locks -- Eqiva eQ-3 Smart Lock (this project covers two: a gate lock and an indoor lock).

Physical proximity still matters: for reliable GATT operation target RSSI ≥ -85 dBm. With the external-antenna dongle we see -65 to -75 dBm through two interior walls at ~8 m; both locks reach the bot consistently. The onboard adapter was sufficient for one of the two locks at ~3 m line-of-sight but not for the gate lock at ~8 m.

Other configurations should work in principle -- anything Linux, recent BlueZ (bluetoothctl present), Python 3.11+. The BLE layer is managed by bluepy; see section 2 for the version note.

Known Bluetooth-stack pitfall on this host

Realtek RTL8761-series dongles on Pi-class USB ports sometimes fail their initial firmware download at boot with RTL: download fw command failed (-110) in dmesg. The adapter presents but has BD address 00:00:00:00:00:00 and stays DOWN. Without intervention all BLE operations then fail until a physical replug.

The bundled bt-adapter-wait.service (see section 6.2) mitigates this: at boot, before bluetooth.service, it detects a wedged USB adapter and reloads the btusb kernel module up to three times to coax the firmware through. It's a per-host install (one for the whole Pi, shared by both bot instances) -- see the installer install-bt-wait-service.sh. If the adapter can't be recovered in software after all retries, the service logs that fact and bluetooth.service still starts; the operator then replugs the dongle physically. In our deployment the automatic recovery succeeds most of the time.

Other requirements

  • A Delta Chat account for the bot. Create it once interactively via deltabot-cli init (see deltabot-cli). The account's SQLite state ends up under ~/.config/<BOT_NAME>/ (the value you set for BOT_NAME in .env; defaults to gatekeeper if unset).
  • The lock's QR/setup card (paper insert that came with the Eqiva lock). It encodes the MAC, card-key, and serial; you feed the whole string to register-user.sh as QR_DATA.

2. Installation

2.1 System packages

sudo apt install -y bluez bluez-firmware pi-bluetooth python3-venv git
sudo systemctl enable --now hciuart          # brings up onboard hci0
sudo systemctl enable --now bluetooth

2.2 Clone the repo

git clone <this repo's URL> gatekeeper-bot
cd gatekeeper-bot
git submodule update --init --recursive       # pulls keyblepy

2.3 Python venv and dependencies

python3 -m venv venv
source venv/bin/activate
pip install bluepy transitions pycryptodome \
            deltachat2 deltabot-cli deltachat-rpc-server

On bluepy versions: the off-the-shelf bluepy 1.3.0 wheel from PyPI works for everything we need -- including registration and the encrypted fast path -- and is what the Pi deployment uses. We ran both flows end-to-end against an Eqiva eQ-3 SmartLock with the PyPI wheel and saw no functional difference vs a self-compiled bluepy from upstream master. No source build is required.

Minor note: PyPI 1.3.0 lacks connect(timeout=...) -- keyblepy detects this and falls back to the 3-arg signature (the connect still happens; only --connect-timeout is silently ignored). If you specifically need that flag honoured at the BLE layer, you can install bluepy editable from a newer checkout (git clone https://github.com/IanHarvey/bluepy.git ../bluepy-src && pip install -e ../bluepy-src) -- but the Pi deployment does not, and has had no issues.


3. Configuration

cp .env.example .env
$EDITOR .env

.env fields (see .env.example for the template):

variable meaning
LOCK_MAC Lock BLE MAC, e.g. 00:CA:FF:EE:DE:AD. Obtain via sudo hcitool lescan; the lock advertises as KEY-BLE.
USER_ID Numeric slot on the lock (0-254). Output of register-user.sh -- do not set before running it. The lock auto-assigns this; register-user.sh prints the chosen value, which you paste here. The Eqiva mobile app shows the registered user as e.g. "User 4" -- that number is your USER_ID.
USER_KEY 32 hex chars (16 bytes) -- your shared secret with the lock. Also an output of register-user.sh; the script generates a fresh random value, registers it with the lock, and prints it. Keep private.
QR_DATA The full QR string from the lock's setup card, format M<12-hex-MAC>K<32-hex-card-key><10-char-serial>. Only needed for register-user.sh.
USER_NAME A label shown in the Eqiva mobile app for this user.
ADAPTER_MAC BD address of the BLE adapter to use (e.g. 8A:88:4B:C2:9C:B9 for a USB dongle). Resolved to hciN at runtime so HCI renumbering across reboots is harmless. Leave blank to default to the built-in UART adapter. Find yours with hciconfig -a.
SEC_LEVEL low (unencrypted, works on any lock) or medium (LE-encrypted; requires a BlueZ bond -- see section 5). If the bond is lost, send-command.sh automatically retries with low and prints a warning.
CONNECT_TIMEOUT Seconds to wait for the BLE connection; 75 is a safe default.
TIMEOUT Overall wall-clock timeout per command. 90 default.
ALLOWED_CHATS Comma-separated Delta Chat chat-ids that may operate the lock. Gates both text commands (/lock, /unlock, /open, /status) and the webxdc app -- chats in this list automatically receive the app and may use either path. /id is the only command that bypasses this check (so you can discover chat ids during setup). Empty = nobody. See section 6.
DOOR_NAME Display name shown as the heading inside the webxdc app (e.g. "Front Gate"). Pushed silently to every active app instance on startup. Default: Door.
HELP_MESSAGE Optional override for the bot's help text (multi-line supported). Empty = a sensible English default. Put your contact info / localized aliases / extra commands here.
LOG_LEVEL Bot log level passed to deltabot-cli as --logging. One of trace / debug / info / warning / error. Default info. Use debug while stabilising a change -- the log_event hook (every raw Delta Chat core event) logs at DEBUG, so only debug surfaces it. Flip back to info (or remove the line) once the journal gets too chatty.

4. Register the Pi as a lock user

The Eqiva lock ships with a factory card-key (encoded in the QR card) that authenticates administrative operations. You use it once to inject your own user-key into an empty user slot.

4.1 Put the lock into registration mode

Press and hold the "open" button on the lock for about 3 seconds, until its LED turns orange. That's the signal that the lock will accept a new user registration on the next BLE connection. The registration window lasts about 30 seconds; if you miss it, repeat the press.

4.2 Run register-user.sh and copy its output into .env

register-user.sh does all the credential work for you:

  • generates a fresh random 16-byte user-key,
  • asks the lock to auto-assign a free slot (you don't pick one),
  • on success, prints both the assigned USER_ID and the new USER_KEY in a copy-pasteable block.

You do not need to set USER_ID or USER_KEY in .env before running the script -- registration creates them. (LOCK_MAC, QR_DATA, ADAPTER_MAC, USER_NAME do still need to be set.)

# With the lock's LED orange (3-second "open" press), run:
./register-user.sh                # quiet (summary only)
./register-user.sh -v             # also streams keyblepy's debug log live

On success the script prints, e.g.:

============================================================
Registration successful.

To activate, set the following two lines in
/home/pi/gatekeeper-km/.env

    USER_ID=2
    USER_KEY=c4360e78beaf524c4e6af66dec48e11d

Then restart the bot service, e.g.
    sudo systemctl restart deltabot-gatekeeper-km.service
============================================================

Paste those two lines into .env and restart the service. No persistent log file is written: the keyblepy --verbose stream re-echoes the newly-generated --user-key on the command line, and a captured log would be a credential leak. The output is held in shell memory only for the duration of the run; on failure it is dumped to stderr so you can still diagnose (without the success credentials surviving disk).

How the lock signals successful registration: the lock emits a short beep and the orange LED stops blinking, and the Eqiva mobile app (next time it syncs) shows the new user as "User N" matching the assigned USER_ID.

If neither the beep nor the LED change happens within ~30 s after running register-user.sh, registration failed -- typically because the lock exited registration mode before the BLE handshake completed, or the auth tag was rejected. The script exits non-zero and does not print credentials (so you can't paste a half-baked entry into .env). In quiet mode the captured keyblepy output is dumped to stderr for debugging; in -v mode you've already seen it live. Re-press the button (3 s, orange LED) and try again.

Confirm with the printed credentials in place:

./send-command.sh status
# device status = {'lock_status': 'UNLOCKED', ...}

4.3 Notes

  • User slots on Eqiva locks are finite (roughly 8-10). If all slots are taken, registration is rejected; revoke a user from the Eqiva mobile app first to free a slot.
  • Revoking a user: do it from the Eqiva mobile app (admin device). Pick the user and tap Delete. The slot becomes free for re-registration.
  • If you specifically need to register into a chosen slot rather than letting the lock pick, call keyblepy/keyble.py directly with --user-id N. register-user.sh always uses auto-assign.

5. (Recommended) BLE bond for the encrypted fast path

Without a BLE-level bond, each lock operation takes ~45 s on the Pi: the lock asks for SMP pairing post-connect, the bypass stack doesn't respond, and the lock waits about 40 s before accepting GATT writes. With a bond stored in BlueZ and SEC_LEVEL=medium in .env, operations drop to 6-8 s.

  1. Put the lock in pairing mode (same 3-second "open" button press, wait for the orange LED).

  2. Run sudo bluetoothctl and execute:

    select <your-controller-MAC>      # e.g. the MAC shown by `hciconfig hci1`
    power on
    agent NoInputNoOutput
    default-agent
    scan le
    # wait ~5s for "KEY-BLE" to appear
    pair <LOCK_MAC>
    trust <LOCK_MAC>
    info <LOCK_MAC>                    # should print Paired: yes / Bonded: yes
    exit
    
  3. Confirm .env has SEC_LEVEL=medium, then time a command:

    time ./send-command.sh status       # ~7 s instead of ~45 s

If the bond is ever evicted (the lock has a finite bond table; bonding more peers may displace yours), commands slow to ~45 s again. Recover with sudo bluetoothctl -- remove <LOCK_MAC> and repeat the ceremony.

The bond does not block other peers (the Eqiva mobile app, another Pi) -- the lock keeps one bond entry per peer.


6. Run as a systemd service

Use the bundled install-systemd-unit.sh script rather than copying the unit file by hand -- it handles the per-bot parameterisation (service name, description, working dir) and the required setcap step in one shot.

6.1 Installing

cd /home/pi/gatekeeper-bot       # or wherever this bot's clone lives
$EDITOR .env                     # set BOT_NAME, DOOR_NAME, etc.
sudo ./install-systemd-unit.sh   # interactive: confirms overwrite + start

The script:

  1. Reads BOT_NAME and DOOR_NAME from .env in the current directory.
  2. Renders systemd-unit/deltabot.service.template into /etc/systemd/system/deltabot-<BOT_NAME>.service.
  3. Runs setcap cap_net_raw,cap_net_admin+eip on the venv's bluepy-helper so the bot can open raw HCI sockets as an unprivileged user.
  4. systemctl daemon-reload, then offers to enable --now the service.

For a two-bot deployment you'd run the installer from each bot's directory:

cd /home/pi/gatekeeper-km     && sudo ./install-systemd-unit.sh -y
cd /home/pi/gatekeeper-hoftor && sudo ./install-systemd-unit.sh -y

BLE adapter recovery privileges (one per host)

setcap lets the bot open raw HCI sockets, but it does not let it recover a wedged adapter -- killing a stale root-owned bluepy-helper, stopping a competing bluetoothd scan, and resetting the HCI device all need more. Grant exactly those, and nothing else:

sudo ./install-ble-sudoers.sh          # grants $SUDO_USER; --user NAME to override
sudo ./install-ble-sudoers.sh --dry-run   # inspect first
sudo ./install-ble-sudoers.sh --uninstall

This is a per-host install, not per-bot. It renders sudoers.d/gatekeeper-ble.template into /etc/sudoers.d/gatekeeper-ble with host-resolved binary paths, validates it with visudo -c before installing (and reverts if the directory fails validation afterwards -- a malformed file there can break sudo for everyone).

Each granted entry is a complete command line, so the service user gets hciconfig hciN reset but not hciconfig hciN down, killall -9 bluepy-helper but not killall aimed anywhere else, and no shell.

It is optional. cleanup_ble probes for the privilege on every call and skips recovery when it is missing, logging one warning per operation; lock commands work either way. Every sudo call is -n (never prompts, so a service can't hang on a password) and wrapped in timeout. You can uninstall the grant, revoke the host's blanket sudo, or remove the sudo binary entirely without breaking the bots.

Useful flags:

  • -y / --yes -- non-interactive; overwrite existing unit and enable+start without prompting.
  • --skip-setcap -- skip the setcap step (e.g. you applied it manually or you're deploying to a host without bluepy).
  • --dry-run -- print the rendered unit to stdout and exit; does not touch /etc/systemd/system/ or file capabilities.

The installer is idempotent: re-running against an up-to-date target is a no-op beyond re-applying setcap. If the live unit has diverged from what the installer would render, you see a diff and a confirmation prompt before anything is overwritten.

6.2 The unit template

The template at systemd-unit/deltabot.service.template is what the installer fills in:

[Unit]
Description=Gatekeeper Bot - @DESCRIPTION@
After=network.target bluetooth.service

[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=@WORKING_DIR@
ExecStart=@WORKING_DIR@/start-gatekeeper-bot.sh
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Runs as an unprivileged user (pi). BLE raw-HCI access needs CAP_NET_RAW + CAP_NET_ADMIN; rather than grant the whole bot those capabilities, install-systemd-unit.sh grants them file-bound to the single helper binary that actually opens the raw socket (venv/lib/python*/site-packages/bluepy/bluepy-helper). The capability is lost on pip install --force-reinstall bluepy, so re-run the installer after rebuilding the venv.

One known behavioural compromise: the adapter-wedging recovery path in lib/common.sh:cleanup_ble (killall bluepy-helper, hciconfig reset) still needs root and silently no-ops under pi. If the adapter actually wedges, the operator recovers manually with sudo ./send-command.sh status once (the same script self-heals under root). For a home deployment this has been rare; an industrial setup might prefer to keep User=root.

6.3 After editing .env or code

sudo systemctl restart deltabot-<BOT_NAME>

Or re-run the installer (which detects that the service is active and restarts it if the unit file changed).

6.4 Uninstalling

sudo systemctl disable --now deltabot-<BOT_NAME>
sudo rm /etc/systemd/system/deltabot-<BOT_NAME>.service
sudo systemctl daemon-reload

6.5 Boot-time USB Bluetooth recovery (bt-adapter-wait.service)

Realtek RTL8761 dongles on Pi-class USB ports sometimes fail their initial firmware download at boot (RTL: download fw command failed (-110) in dmesg), leaving the adapter with BD address 00:00:00:00:00:00 and status DOWN. Without intervention every BLE operation then fails until a physical replug. See section 1 ("Known Bluetooth-stack pitfall") for background.

The bundled bt-adapter-wait.service runs once per boot, before bluetooth.service, and reloads the btusb kernel module up to three times to recover a wedged USB adapter. It's a host-wide install (one service for the whole Pi, not per-bot) so you run the installer once:

cd /home/pi/gatekeeper-bot   # from any bot clone on the host
sudo ./install-bt-wait-service.sh

The installer copies systemd-unit/bt-adapter-wait.sh to /usr/local/sbin/bt-adapter-wait and systemd-unit/bt-adapter-wait.service to /etc/systemd/system/bt-adapter-wait.service, then enables the unit. Running the installer from a second bot clone is a safe no-op beyond re-copying identical files.

Verify after a reboot:

journalctl -u bt-adapter-wait -b
# expect one of:
#   "USB BT adapter(s) ready at boot; no recovery needed"
#   "USB BT adapter recovered after N reload(s)"
#   "USB BT adapter still wedged after 3 attempts -- physical replug required"

The third message means this particular boot lost the race; replug the dongle and the bonds persist under /var/lib/bluetooth/ so no re-pairing is needed.

Uninstall:

sudo ./install-bt-wait-service.sh --uninstall

7. Operating the lock

7.1 From the command line (on the Pi)

./send-command.sh status    # prints: device status = {'lock_status': 'LOCKED', ...}
./send-command.sh lock      # engages the bolt
./send-command.sh unlock    # retracts the bolt
./send-command.sh open      # fully retracts (mode-dependent)

Exit codes:

code meaning
0 success
2 both attempts failed (lock unreachable or command rejected)
3 BLE adapter busy (another operation in progress — try again)
4 BLE adapter not found (dongle unplugged? wrong ADAPTER_MAC?)

send-command.sh is self-healing: it kills stale bluepy-helper processes, resets the HCI adapter, and stops any active bluetoothd scan before each attempt. If the first attempt fails (e.g. bond evicted after a battery swap), it automatically retries with SEC_LEVEL=low and prints a warning to stderr:

⚠ Bond may be lost — retrying without pairing (slow). Run pair-lock.sh to re-establish.

Concurrent callers are serialized via flock (one BLE operation at a time per adapter, on /tmp/gatekeeper-ble/hci<N>.lock). A second caller waits up to BLE_LOCK_WAIT_SECONDS (default 20 s) -- long enough to absorb one concurrent request, which is the normal case when both doors are tapped at once -- and only then gives up with exit code 3.

The per-lock post-op cooldown is waited out before the flock is taken, so one lock's blackout window never blocks the other lock's bot. Do not move cooldown_check back inside the lock.

An invalid command argument exits 2 with a usage line without touching the adapter:

$ ./send-command.sh statsu
Usage: send-command.sh status|lock|unlock|open
Unknown command: statsu

7.1.1 Re-establishing the BLE bond

If send-command.sh reports a lost bond, run the interactive pairing guide:

./pair-lock.sh

It prompts you to put the lock in pairing mode (3-second button press, orange LED), scans for the lock, pairs and trusts it on the configured adapter, and verifies with a status probe. See README §5 for background on bonding.

7.2 From Delta Chat

  1. Make sure the service is running (section 6).

  2. From your own Delta Chat, message the bot's account (QR invite after deltabot-cli init). Send /id -- the bot replies with the chat's numeric id.

  3. Stop the service, add that id to ALLOWED_CHATS in .env, restart:

    sudo systemctl stop deltabot
    $EDITOR .env           # ALLOWED_CHATS=14,12
    sudo systemctl start deltabot
  4. From the allowed chat:

    command effect
    /status current lock state
    /lock / /zu engage the bolt
    /unlock / /auf retract the bolt only (latch still engaged)
    /open / /oeffnen retract bolt and latch (door swings open)
    /apps (re)deliver every apps/*.xdc to this chat. Always sends fresh copies and deletes prior tracked copies for all members (so late-joining members also get the app). Also retracts any app whose artefact has been removed from apps/ (e.g. moved to apps-disabled/). Currently delivers Gatekeeper.
    /id show this chat's id (always works, no permission)
    anything else help text

    Reactions: hourglass on receipt, checkmark on completion, cross if the message is older than 60 s (replay-protection). Webxdc button presses use a client-embedded ts and a 45 s window.

7.3 From the webxdc apps

/apps drops the active app(s) in the chat. Currently only the Gatekeeper app is active; Quick-Lock is disabled but still buildable.

Gatekeeper (apps/gatekeeper.xdc)

Full lock control. Tap the app message; three buttons appear over a small colourful house drawing:

button sends to bot runs
closed-lock lock send-command.sh lock
open-lock open send-command.sh open
door (centre) status send-command.sh status

While a command is pending the door button glows yellow and the status text shows Door: locking… / opening… / checking…. The icon returns to the actual state when the bot's response arrives (or to Door: timeout (no response) after 60 s). A small "Last update HH:MM" line (24h local time) below the status shows when the bot last confirmed the state.

Quick-Lock (apps-disabled/quick-lock.xdc -- currently disabled)

Disabled by default. Both the source (apps-disabled/quick-lock/) and the built .xdc live under apps-disabled/, so the bot's apps/*.xdc glob does not pick it up and /apps will not deliver it. To re-enable: rebuild it first (./build-xdc.sh apps-disabled/quick-lock), then move the source directory and the freshly built .xdc under apps/. The build output path follows the source path, so nothing else needs changing -- but the rebuild is not optional, since a disabled app's artifact goes stale silently while the protocol moves on.

Priority: "after touching this app the lock is closed." Opening the app immediately sends lock to the bot. The starting visual is orange ("Closing…") so it reads as an explicit "working on it" signal, transitioning to red ("Closed") once the bot confirms the lock. There is no open direction here -- a tap on a non-closed slider only retries the lock command. The chat-message preview icon is the red "Closed" image (the target state).

State updates

The status icons / labels in both apps update from silent webxdc status messages the bot pushes back -- so every member of every allowed chat sees the same current lock state in real time. The app heading shows DOOR_NAME from .env. The bot pushes that name to each app on delivery; clients see it once they open the app.

The door state shown is always the last state the lock confirmed, and "Last update HH:MM" is when it confirmed it. If a command fails to reach the lock at all (adapter busy, out of range, dongle missing) the icon does not change -- an amber line appears above it instead:

⚠ lock did not respond at 14:07 — state below may be stale

That advisory clears on the next successful operation. The reasoning: a failed status probe tells you the bot could not talk to the lock, not that the door moved. Replacing a known-good "Locked" with an error icon threw away the more useful of the two facts. (Quick-lock, whose whole job is one-tap locking, instead falls back to its green "not confirmed locked" visual with the reason as its label -- there, "failed" and "not locked" call for the same action from the user.)

Audit trail

Every state-changing app command produces a single concise chat line {icon} {DOOR_NAME} {actor} in the originating chat:

🟢 Hoftor Matthias       (opened)
🔒 Hoftor Matthias       (locked)
❌ Hoftor Matthias (lock failed)

status requests don't produce an audit line (read-only; the apps auto-request it on open / refresh). Text-driven commands keep their original output (device locked etc.) -- the user's own /lock message already identifies them. The originating app id (gatekeeper or quick-lock) is logged at INFO level for debugging but is not shown to chat members.

The icon pair is 🔒 (closed padlock) / 🟢 (green circle) rather than the 🔒/🔓 padlock pair -- the two padlocks render near- identically at chat-line size on many stacks, and a padlock-vs- circle contrast is unambiguous at a glance.

Automatic retry and fallback

send-command.sh handles retries internally: if the first attempt fails (configured SEC_LEVEL), it retries once with SEC_LEVEL=low (unbonded fallback). A warning is printed to stderr if the fallback is used. The bot relays stderr to the chat for text commands. See §7.1 for details and exit codes.


8. Troubleshooting

symptom likely cause / fix
./send-command.sh status takes ~45 s No BLE bond (or bond evicted). Run ./pair-lock.sh to re-establish (see §7.1.1).
./send-command.sh exits with code 2 Both attempts failed. Lock out of range, phone app connected, or battery dead. Check and retry.
./send-command.sh exits with code 3 Another BLE operation in progress. Wait a moment and try again.
./send-command.sh exits with code 4 BLE adapter not found. Check ADAPTER_MAC in .env and that the dongle is plugged in.
⚠ Bond may be lost warning in chat The bonded fast path failed; send-command.sh fell back to SEC_LEVEL=low. Run ./pair-lock.sh.
Bot reacts with cross to every command Message older than 60 s (text) / 45 s (webxdc). Check network latency or clock skew on the Pi.
Bot replies "permission denied" Chat id not in ALLOWED_CHATS. Use /id, edit .env, restart the service.
MAC mismatch on received frame; dropping in --verbose User-key mismatch between .env and the lock -- re-register or double-check the hex string.
Failed to connect to peripheral ... addr type: public Lock-side issue: someone else is connected, or the lock is advertising slowly. Retry.
After reboot, hci0 is DOWN sudo systemctl enable hciuart (section 2.1). Or sudo hciconfig hci0 up.
DeltaChat replies delayed by minutes SMTP rate-limit on the account's provider. Check journalctl -u deltabot for rate-limited until.
App icon stays "Unknown" after restart Bot couldn't reach the lock to seed the state. Check journalctl -u deltabot for the startup status probe line; tap the door button (sends status) to retry on demand.
App buttons do nothing Chat not in ALLOWED_CHATS, or the bot can't see your webxdc status updates. Check journalctl -u deltabot for app cmd from chat N lines.

Link-quality monitoring (retired 2026-07-19)

A tools/ble-probe.sh script + 30-min systemd timer used to sample send-command.sh status against every lock over each adapter and log a CSV row per probe (timestamp, antenna/adapter/lock labels, return code, duration, base64 stderr), summarized by ble-probe-stats.sh. It ran connection-based rather than passive-RSSI because Eqiva locks don't advertise when idle, so only a real connect-and-disconnect measures whether the lock answers and how fast.

It was retired once enough data had been collected: the external 8A:88 dongle is the strongest radio, 8C:68 a weaker spare, and the onboard BCM43430A1 too marginal to depend on. The scripts and units were removed; the accumulated /home/pi/ble-probe/probe.log is kept on the Pi, and the tooling is recoverable from git history if a future antenna or adapter change warrants re-measuring.


9. Security notes

  • .env contains your lock credentials and is gitignored. Never commit it. Back it up to encrypted storage if you care about disaster recovery.
  • The user-key is a shared secret. If it leaks, revoke the user via the Eqiva app, generate a new key, and re-register (section 4).
  • The card-key embedded in QR_DATA is the lock's factory admin secret and cannot be rotated without a factory reset. Treat the QR card and register-user.sh as high-value.
  • The DeltaChat account credentials live in ~/.config/<BOT_NAME>/ (see section 10 for the exact path; keyed by BOT_NAME in .env so two bots on the same host keep separate databases). That directory is protected only by filesystem permissions on the Pi.
  • Only chats listed in ALLOWED_CHATS can trigger lock operations. This single allow-list gates both text commands (/lock, /unlock, /open, /status) and the webxdc app: chats in this list automatically receive the app and any chat member can tap its buttons. /id is the only command that bypasses the check (so you can discover chat ids during setup).
  • Chat ids are private per your Delta Chat account but treat them as low-sensitivity -- anyone who learns an allowed id and can send messages to your bot account can operate the lock.
  • The bot whitelists the four lock-operation tokens (lock, unlock, open, status) before passing them to send-command.sh. The webxdc payload's text field is matched against this whitelist; anything else is logged and dropped, so a malicious app payload can't inject arbitrary shell arguments.
  • Webxdc status updates the bot pushes back to apps use empty info, so the Delta Chat client renders them silently. They are still visible to every member of the chat (the app screen reflects them) -- do not rely on them as a private channel.
  • App-instance ids cached in ~/.config/<BOT_NAME>/app_msgids.json reveal which chats currently host the app but contain no credentials.

10. Layout

gatekeeper-bot/
|-- README.md                    (this file)
|-- .env                         (your secrets, gitignored)
|-- .env.example                 (template)
|-- delta-door-bot.py            (DeltaChat listener)
|-- start-gatekeeper-bot.sh      (service entrypoint)
|-- send-command.sh              (one-shot CLI wrapper around keyblepy, with retry + flock)
|-- pair-lock.sh                 (interactive BLE bond guide -- run when bond is lost)
|-- register-user.sh             (one-shot registration wrapper)
|-- install-systemd-unit.sh      (renders systemd-unit/*.template and
|                                 installs deltabot-<BOT_NAME>.service)
|-- install-bt-wait-service.sh   (host-wide installer for the USB-BT
|                                 firmware-reload service; one-time)
|-- keyblepy/                    (Python KeyBLE implementation, submodule)
|   \-- README.md                (protocol-level docs, Python API)
|-- apps/                        (live webxdc app -- source + built artifact)
|   |-- gatekeeper.xdc           (built artifact, tracked)
|   \-- gatekeeper/              (full lock-control app source)
|       |-- index.html main.js main.css
|       \-- public/              (icon, manifest)
|-- apps-disabled/               (apps built but NOT served by /apps)
|   |-- quick-lock.xdc           (built artifact, tracked)
|   \-- quick-lock/              (one-tap-lock app source)
|       |-- index.html main.js main.css
|       \-- public/              (slider images, icon, manifest)
|-- sudoers.d/
|   \-- gatekeeper-ble.template  (narrow BLE-recovery grant; rendered
|                                  by install-ble-sudoers.sh)
|-- systemd-unit/
|   |-- deltabot.service.template    (parameterised; rendered by
|   |                                 install-systemd-unit.sh)
|   |-- bt-adapter-wait.service      (host-wide; boot-time USB BT
|   |                                 firmware-reload service)
|   \-- bt-adapter-wait.sh           (the retry logic called by
|                                     bt-adapter-wait.service)
\-- venv/                        (Python venv, gitignored)

Persistent state

Lives outside this tree, on the root filesystem — survives reboots. The directory is keyed by BOT_NAME (from .env), so two bots on the same host keep their state separate. Under the bundled systemd unit the bot runs as pi, so the directory is /home/pi/.config/<BOT_NAME>/.

  • ~/.config/<BOT_NAME>/ -- Delta Chat account database (SQLite). Protected only by filesystem permissions; back up if you care about disaster recovery. See section 9 (Security model) for the trust implications.
  • ~/.config/<BOT_NAME>/app_msgids.json -- {chat_id: {app_id: msgid}} map the bot uses to find prior copies of each app in each chat so /apps can delete the old message for all members after sending a fresh one, and so the bot can push silent state updates to the current instance. Atomic write (tmp + os.replace). Safe to delete: next /apps in each chat reseeds it. The bot self- migrates the older {chat_id: [msgid, …]} shape on startup by dropping legacy entries (logs "dropping legacy app_msgids entries for chats [..]; run /apps in each chat to re-seed") and rewriting the file clean.

Non-persistent (deliberately in-memory, re-derived on boot)

  • Last-known lock state (locked / unlocked / unknown) and battery-low flag -- re-derived by the startup status probe in _on_start (send-command.sh status).
  • keyblepy BLE session state (session nonces, security counters) -- freshly negotiated per BLE connection; the lock discards them too.
  • BLE flock file /tmp/gatekeeper-ble/hci<N>.lock and the per-lock cooldown stamps /tmp/gatekeeper-ble/lock-<MAC>.lastop -- wiped on reboot, recreated on first use.

For a deeper dive into the BLE protocol, encryption layout, and the bugs that got fixed in keyblepy, see keyblepy/README.md.


11. Building the webxdc apps

You only need to rebuild if you change something under an app's source directory. The repo ships pre-built artifacts (apps/gatekeeper.xdc, apps-disabled/quick-lock.xdc) so a fresh deploy doesn't require Node.js on the Pi.

11.1 Prerequisites

  • zip and unzip. That is the whole toolchain -- a .xdc is just a zip with a particular layout, and the apps are vanilla JS with no imports, so the earlier vite setup earned nothing and was removed along with each app's package.json and node_modules/.

11.2 Build

./build-xdc.sh apps/gatekeeper            # -> apps/gatekeeper.xdc
./build-xdc.sh apps-disabled/quick-lock   # -> apps-disabled/quick-lock.xdc

The output path derives from the source path (<srcdir>/../<name>.xdc), so an app moving between apps/ and apps-disabled/ needs no config change. The live app lands inside the bot's apps/*.xdc glob and is served by /apps; the disabled one lands outside it.

Always verify the artifact matches its source before shipping. Nothing rebuilds a disabled app automatically, and a stale artifact looks identical from the outside:

diff <(unzip -p apps/gatekeeper.xdc main.js) apps/gatekeeper/main.js

apps-disabled/quick-lock.xdc was three months stale this way -- a vite-era bundle predating the ts replay field, so re-enabling it would have shipped an app whose every command the bot silently dropped.

11.3 What the apps share

  • Both send the same protocol to the bot: {request: {name, text: "lock"|"open"|"status", app: "<id>"}}. The bot whitelists text against {lock, unlock, open, status} and uses app only for log lines.
  • Both listen for these payload types from the bot:
    • {response: {text: "locked"|"unlocked"|"unknown", ts, battery_low, last_error}} -- updates the visual state. text is the last confirmed door state; last_error (null, or {code, ts}) is a separate advisory about the last command.
    • {config: {door_name: "..."}} -- sets the heading. Pushed when an app is sent (/apps) and on bot startup for every known instance.
    • {ack: "<command>"} -- the bot accepted the command, before the BLE round-trip. {progress: "retrying"} -- the bot is waiting out a cooldown or running its low-sec retry; apps treat it as proof of life and restart their local give-up timer.
  • All bot-side state updates use empty info so they're silent in the chat.

After rebuilding any app, commit the updated .xdc -- deploys are a plain git pull.

11.4 Adding a new app

The bot discovers apps by scanning apps/*.xdc -- there is no hard-coded list. To add a new app:

  1. Create apps/<id>/ containing index.html, main.js, main.css and public/manifest.toml (plus any assets under public/).
  2. Implement the same protocol: send {request: {name, text, app: "<id>", ts}} -- the ts is mandatory, requests without it are dropped -- and listen for {response}, {config}, optionally {ack} and {progress}.
  3. ./build-xdc.sh apps/<id>. The resulting apps/<id>.xdc is picked up automatically the next time /apps runs (no bot restart needed if the file appears between two /apps calls; for new chats the bot also picks it up at startup).

11.5 Tests

Both suites are stdlib-only and run on any machine with the repo checked out -- no lock, no adapter, no venv, no network:

python3 test_delta_door_bot.py   # bot logic
./test_cooldown.sh               # BLE cooldown gate (stubbed clock)

test_delta_door_bot.py stubs the deltachat/appdirs imports and loads the bot via importlib (its filename has a dash). It covers the keyblepy output parsers, _apply_result's state folding (notably that a failed command never overwrites a confirmed door state), the COOLDOWN_WAIT / RETRYING_LOW_SEC marker protocol, the replay and clock-skew windows, and the app-tracking bookkeeping. test_cooldown.sh sources lib/common.sh with date and sleep stubbed and asserts each of the four cooldown bands plus the COOLDOWN_WAIT marker contract.

Run both before committing anything that touches the bot or the BLE scripts.


12. Attribution

Substantial portions of this repository -- both the Python bot and the webxdc apps -- were co-authored with Anthropic Claude (the claude-opus-4-7 model family) working interactively with the repo owner. Every design decision was reviewed, approved, and directed by a human; Claude generated code, rewrote text, and produced commits based on that direction. The audit trail is in the git history: commits that Claude helped author carry a Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> trailer.

Per Anthropic's Commercial Terms, ownership of model output is assigned to the customer; Anthropic does not claim copyright over the generated code.

No attached licence (deliberate)

This repository does not include a LICENSE file. The reason is upstream: the project builds on multiple external components (the missytake/doorbot skeleton, the deltachat-bot/webxdcbot layout, the keyblepy submodule, bluepy, deltachat-core-rust, various npm dependencies) whose licensing the repo owner has not individually audited. Asserting a clean licence on the combined work would imply a guarantee about those inputs the repo owner is not in a position to make.

If you want to reuse code from here in your own project, please open an issue and ask -- the repo owner is happy to grant specific permission for specific reuse, and can check the upstream constraints for you at that point. Pull requests are welcome; by submitting one you agree that your contribution may be used under whatever terms the repo eventually adopts.

About

A delta bot that allows to operate an eqiva Eq3 lock using DeltaChat

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages