Long-running things (an agent working overnight, a render, a trading bot) keep running only while a human says “I am still here”. Every N minutes you press the side button of the M5 Cardputer ADV. The press produces a signed, timestamped pulse. If the host stops seeing valid pulses, it stops the process and writes down why.
The signature matters: the key lives on the device and only a physical button press uses it, so the process being watched cannot extend its own leash.
40 MIN, 1H 20M, 45 SEC. Mono, big, with an orange accent (same look as the signer firmware).4:12 counting down), and says SILENT when time is over.SENT. The keyboard does nothing: it cannot produce a pulse.signer). The host tells it the interval and the time.You need PlatformIO.
cd firmware/deadhand
pio run # build only
pio run -t upload --upload-port /dev/cu.usbmodemXXXX # flash
This replaces whatever firmware is on the board (for example signer). Both can be re-flashed any time; each keeps its own key in its own NVS namespace, so flashing one does not delete the other’s key, but they are different keys and trusted separately.
Opening the serial port resets the board, and holding the side button during a reset enters the ROM bootloader (blank screen). Do not hold the button while connecting over USB.
The device generates an ed25519 key on first boot. Tell the host to trust it, once:
tools/deadhand/deadhand pair --source usb
It prints the key fingerprint. Compare it with the FP on the device screen (bottom line) and answer y. After this the host accepts pulses only from this key.
Edit src/config.h, rebuild, flash:
| Setting | Meaning |
|---|---|
DH_BRIGHTNESS |
screen brightness, 0..255 |
DH_SOUND, DH_VOLUME |
sound on/off, volume |
DH_WARN_AT_S |
seconds-left list at which it beeps (0 = at expiry) |
DH_WARN_COLOR_S |
when the plate turns orange |
DH_GRACE_BEEP_S |
blip period during grace (0 = off) |
DH_BLE_NAME, DH_DEFAULT_ID |
BLE name; default label inside pulses |
The interval and grace are not in the firmware: you set them per run on the host (--interval 40m --grace 5m), and the device signs them into every pulse, so the host and the device always agree.
A pulse is a signcheck v1 message: kind = pulse, choice = alive, id = the run label, and text = ctr=<n> int=<seconds> grace=<seconds>. ctr is a counter stored in the device flash and increased on every press, so an old pulse can never be replayed. The signed iat is host-synchronised device time; a pulse is valid for 10 minutes after it is made, which only matters for very slow links.
One JSON object per line.
{"cmd":"pubkey"} -> {"pubkey":"<b64>","fp":"<16 hex>","alg":"ed25519"}{"cmd":"hello","now":<unix>,"interval":2400,"grace":300,"id":"deadhand","arm":true} -> {"ready":"deadhand v0",...}. arm restarts the countdown (the host sets it on start only, not on reconnect).{"msg":{...},"sig":"<b64>","fp":"..."}Before the first hello the device shows NO HOST and a button press does nothing.
Prototype. The secret key sits in plain flash (ESP32 NVS), no flash encryption or secure boot. Someone with the device and a USB cable can extract it. The device does not store pulses: a press while nothing is listening is lost.