watchdeck
A “Stream Deck” for the homelab. The repo has three parts:
firmware/holds the ESP-IDF firmware for the CrowPanel Advanced 10.1“ ESP32-P4 display.app/holds the Tauri desktop app that talks to the device.site/holds this book, built with mdBook and published to GitHub Pages.
Wi-Fi setup
The board joins a 2.4 GHz network through its ESP32-C6 co-processor. It reads credentials once at boot from one of two places:
| Source | Where | Change it by |
|---|---|---|
| SD card | wifi.txt at the root of the TF card | Editing the file, then pressing reset |
| Compiled in | firmware/sdkconfig.defaults.local | Rebuilding and reflashing |
A valid wifi.txt takes priority. If the card has no wifi.txt, or the file is invalid, the board uses the compiled-in credentials. If neither source has an SSID, the board doesn’t connect.
Option 1: wifi.txt on the SD card
Create a plain text file named wifi.txt at the top level of the card. Line 1 is the SSID and line 2 is the password:
MyNetwork
my-password
- Don’t use quotes or escaping. Every character on the line is used as-is.
- Windows (CRLF) and Unix (LF) line endings both work.
- The SSID can be up to 32 characters and the password up to 64. A longer line makes the whole file invalid.
- For an open network, leave out the password line.
- A blank first line means “don’t connect”. The board won’t fall back to the compiled-in credentials.
You can edit the file without removing the card. Plug a USB-C cable into the board’s USB 2.0 port, which is not the serial port. The card then shows up as a USB drive with a tf-data partition. Edit wifi.txt, eject the drive, and press reset.
Always eject before unplugging. The board acknowledges a USB write before it reaches the card, so unplugging early can lose data.
Option 2: compiled-in credentials
Create firmware/sdkconfig.defaults.local. Git ignores this file.
CONFIG_WATCHDECK_WIFI_SSID="MyNetwork"
CONFIG_WATCHDECK_WIFI_PASSWORD="my-password"
- Keep the double quotes.
- Put a backslash before any
"or\. For example,pa"ss\wordbecomes"pa\"ss\\word". - For an open network, set the password to
"".
Then delete the generated config and flash:
rm firmware/sdkconfig
mise run fw:flash -- -p /dev/ttyUSB0
The rm step is required. The defaults files only fill in settings that are missing from firmware/sdkconfig, so an old config keeps the old values.
Checking the connection
The screen’s Wi-Fi line shows the progress:
| Screen text | Meaning |
|---|---|
connecting to MyNetwork (source: sd) | Credentials came from wifi.txt |
connecting to MyNetwork (source: config) | Credentials came from the firmware |
connected 192.168.0.56 (watchdeck.local) | Joined the network |
no Wi-Fi credentials | Neither source has an SSID |
invalid Wi-Fi credentials | The compiled-in SSID or password is too long |
Once the board is connected, it answers as watchdeck.local on the local network:
ping watchdeck.local
If the board can’t find the network, it keeps retrying every few seconds. The serial log (mise run fw:monitor -- -p /dev/ttyUSB0) shows the reason.
Security
wifi.txtholds the password in plain text. Anyone with the card, or with the USB drive port, can read it.- Compiled-in credentials stay out of git, but anyone who dumps the board’s flash can read them.
Buttons and actions
The desktop app reads the button grid, and what each button does, from config.toml. HTTP actions can take header values from secrets.toml, which keeps tokens out of config.toml. Both files live in ~/.config/dev.rackrot.watchdeck/.
On its first run the app writes a commented config.toml with one button, Hello, which shows a desktop notification.
Example
version = 1
[layout]
cols = 5
rows = 3
[[button]]
id = "plex"
pos = [0, 0]
label = "Restart Plex"
bg = "#e65100"
action = { kind = "shell", argv = ["ssh", "nas", "docker", "restart", "plex"], timeout_s = 60 }
[[button]]
id = "disk"
pos = [1, 0]
label = "Disk"
action = { kind = "shell", argv = ["sh", "-c", "df -h / | tail -n 1"] }
tile = { every_s = 30, source = { kind = "shell", argv = ["sh", "-c", "df --output=pcent / | tail -n 1"] } }
[[button]]
id = "office-light"
pos = [2, 0]
label = "Office"
[button.action]
kind = "http"
method = "POST"
url = "https://ha.lan:8123/api/services/light/toggle"
body = '{"entity_id":"light.office"}'
headers = { "Content-Type" = "application/json" }
secret_headers = { Authorization = "ha" }
[button.tile]
every_s = 15
json_pointer = "/state"
[button.tile.source]
kind = "http"
method = "GET"
url = "https://ha.lan:8123/api/states/light.office"
secret_headers = { Authorization = "ha" }
The Disk button shows how full / is, and the Office button shows whether the light is on or off; see Tiles. The last button needs a secret named ha; see Secrets.
Changing the config
Save the file and the change applies within about a second. The app checks config.toml and secrets.toml every half second. A paired device redraws its grid on the same connection, without reconnecting. An edit that changes only actions applies from the next press and leaves the screen alone.
A version that breaks any rule on this page doesn’t apply. The last good config stays live, and the app logs every problem it found:
[2026-10-11T02:14:49Z ERROR watchdeck::config_rejected] /home/you/.config/dev.rackrot.watchdeck/config.toml not applied, keeping the live config: button "disk": pos [5, 0] is outside the 5x3 grid
- If the file breaks a rule when the app starts, the device shows an empty grid until you fix it.
- Deleting the file while the app runs keeps the last good config. The next Save in the window recreates the file, and the next start writes the default again.
Saving from the window
The Layout page writes config.toml for you. A save rewrites the whole file from the config on screen, so it does not keep your comments (the file starts with a note saying so). A save is refused, and nothing is written, if the file changed on disk since the page loaded it.
If the file on disk breaks a rule, the page says so and offers Replace config.toml. It asks you to confirm what it will write, then saves the config on screen over the file. If the file was already broken when the app started, that config is an empty grid, not your file. The text it replaces is kept in the config folder as config.toml.bak.<UTC time>, for example config.toml.bak.20261011T021449Z. The newest 5 are kept, and each save makes a new one, so an earlier backup is never overwritten. Copy anything you want to keep out of the folder.
Grid
| Key | Rule |
|---|---|
version | 1 |
layout.cols | 1 to 8 |
layout.rows | 1 to 5 |
Buttons
Each [[button]] table is one cell.
| Key | Required | Rule |
|---|---|---|
id | yes | 1 to 63 letters, digits, _, - or ., unique |
pos | yes | [column, row], counted from 0 at the top left. Inside the grid, one button per cell |
label | yes | Printable ASCII, at most 47 characters. The device’s fonts have no other characters |
bg | no | Background as #rrggbb. Default #1e88e5 |
fg | no | Label color as #rrggbb. Default #ffffff |
icon | no | A picture in the icons/ folder, such as "3f2a9c1d0e44.png"; see Icons |
action | yes | A shell or HTTP action, below |
tile | no | Makes the button show a live value; see Tiles |
Unknown keys are errors, so a misspelled key is caught rather than ignored.
Icons
A button can show a picture above its label. The picture is a PNG, JPEG or WebP file in ~/.config/dev.rackrot.watchdeck/icons/, and icon names the file:
[[button]]
id = "plex"
pos = [0, 0]
label = "Plex"
icon = "plex.png"
action = { kind = "shell", argv = ["ssh", "nas", "docker", "restart", "plex"] }
To add one, either copy the file into icons/ yourself (make the folder if it’s missing) and name it in config.toml, or import it, which copies it there under a name made from its content, such as 3f2a9c1d0e44.png. The config editor’s Import button does the import. Its icon picker lists the files in icons/ that the app would accept (a valid name, a regular file inside the folder, and a readable PNG, JPEG or WebP), and reads the folder again after an import.
iconis a plain file name: 1 to 63 letters, digits,_,-or., not starting with a dot. It can’t point outsideicons/.- Only PNG, JPEG and WebP are read. The kind comes from the file’s contents, not its name. SVG isn’t supported.
- A file must be a regular file of at most 2 MB, and the picture at most 4096 × 4096 pixels. Bigger ones are refused, not scaled.
- Transparency is kept, so a PNG or WebP with a see-through background shows the button’s color behind it.
- The app shrinks a picture that is bigger than its box, keeping its shape, and leaves a smaller one as it is. It never scales a picture up. A JPEG is turned upright by its EXIF orientation first.
- The box is a square above the label. Its side is what the button has left after 4 px of padding on each side, a 2 px gap and the label, and it never exceeds 256 px or the button’s width less the padding. The label gets 2 lines (35 px each), or 1 line where 2 lines would leave the icon under 24 px. A longer label ends in dots. So the box is 256 px on 1 row, 110 on 2 rows, 44 on 3 rows (the default 5×3 grid, labels up to 2 lines), 46 on 4 rows and 26 on 5 rows (8×5, labels of 1 line). On a single row of 4 or more columns the button’s width limits it instead, for example 238 px on 4 columns.
- A WebP’s declared sizes are checked before it is decoded, because the decoder would trust them. The app reads the headers (the canvas, each animation frame and the picture data) and refuses a picture over 4096 pixels on a side, one whose headers disagree, or one that breaks off.
- Buttons that name the same file share one picture on the device.
- A missing, unreadable or invalid file holds back the whole config, like any other broken rule, and the app logs which button and file.
- Adding or removing a file in
icons/, or changing one a button names (its modified time or size), reloads the config once the change has held for two polls. That covers replacing a picture in place, and a file that is still being copied is read again when it finishes. A different picture gets a new id, so the device fetches it and drops the old one.
Shell actions
| Key | Required | Rule |
|---|---|---|
kind | yes | "shell" |
argv | yes | The program, then its arguments. Not empty |
timeout_s | no | 1 to 600 seconds. Default 10 |
argvruns directly, without a shell, so|,>,*and$HOMEhave no special meaning. For shell syntax, run a shell yourself:["sh", "-c", "df -h / | tail -n 1"].- The command runs in your home directory, with the app’s environment and stdin closed.
- When the app starts at login, its environment may lack your shell’s
PATHadditions andSSH_AUTH_SOCK. Use absolute paths for programs outside the systemPATH, and an SSH key that works without the agent. - A button runs once at a time. A press while it runs gets “already running”. At most 8 actions run at once, and a press beyond that gets “busy”.
- On timeout the app sends SIGTERM to the command and every process it started, then SIGKILL 2 s later. A process that leaves the group, for example through
setsid, escapes this. - A command that starts a background job finishes when it exits, and the job keeps running. The job’s output pipes close, so redirect them:
["sh", "-c", "backup.sh >/tmp/backup.log 2>&1 &"].
HTTP actions
| Key | Required | Rule |
|---|---|---|
kind | yes | "http" |
method | yes | GET, HEAD, POST, PUT, PATCH or DELETE |
url | yes | http:// or https://, with a host |
body | no | Sent as written, at most 64 KB. No Content-Type is added; set one in headers |
headers | no | Header name → value, sent as written, e.g. { "Content-Type" = "application/json" }. Values are printable ASCII |
secret_headers | no | Header name → secret name, e.g. { Authorization = "ha" } |
insecure_http | no | true lets secret_headers go over http:// to another machine. Default false |
timeout_s | no | 1 to 600 seconds for the whole exchange. Default 10 |
- A header name may appear only once across
headersandsecret_headers, ignoring case. - Redirects aren’t followed, so a 3xx reply is a failure.
- Proxy settings are ignored, so a request goes only to the host in its URL. Over
https://, only a server that proves it is that host gets it. - HTTPS checks the server’s certificate against the system’s trusted roots. Self-signed certificates aren’t supported yet.
- The app reads at most 64 KB of the reply.
Secrets over plain http
The app refuses a button whose secret_headers would go over http:// to another machine. Over plain http, anyone on the network path can read the header. So can anyone who answers for the host name first, which is easy on a LAN, above all for .local names. Use https://, or a URL on this machine (localhost, 127.0.0.1 or [::1]), which needs no setting.
To send them anyway, set insecure_http = true on the action. The app then logs a warning whenever a config with that button goes live.
Tiles
A tile puts a live value on a button, such as a load average, a disk percentage or whether a service is up. Pressing the button still runs its action. The tile has its own source, which can be any shell or HTTP action:
| Key | Required | Rule |
|---|---|---|
every_s | yes | Seconds between polls, 2 to 86400 |
json_pointer | no | An RFC 6901 pointer such as /state or /attributes/brightness. It must start with / |
source | yes | A shell or HTTP action, with the keys above. Its timeout_s applies to each poll. A source that sends secret headers over plain http needs insecure_http, as for any action |
When it polls
- Only while a device is connected. With no device there are no polls and no traffic. When the first device connects, every tile polls at once. When the last one leaves, polling stops and a poll under way is cancelled.
- A tile never starts a poll less than 2 s after its previous poll began, whatever asks for one: a press, a device that connects and drops again, or a short
every_s. The app remembers when each tile last polled across those stops and starts. Editing a tile is the exception and polls at once. - Each tile has one poll at a time. The next waits
every_sseconds after the previous one ends, so a slow source stretches the interval instead of piling up. - A press of the button polls its tile again once the press has finished, because the press probably changed what it shows, and once the 2 s above are up. A press that is refused (“already running”, “busy”) does not.
- Polls share the 8 action slots with presses, but at most 6 tile polls run at once, so a press always finds 2 slots free. A poll that gets no slot is skipped and tried again after
every_s. After 3 skipped polls in a row the tile showsstalein red, instead of an old value that may no longer be true, until a poll runs. A poll and a press of the same button don’t block each other. - Editing a tile restarts it with a poll at once, and the first value from the new source replaces what the old one showed. A device never gets a value that the old source produced once the app has applied the edit.
What it shows
- A good poll (exit 0, or a 2xx reply) shows its value. Without
json_pointerthat is the first line of output that isn’t blank. With it, the output is read as JSON and the value is the string, number or boolean at the pointer. The pointer reads the output as it came, so keys with non-ASCII characters match. A missing pointer, anull, an object or an array, or output that isn’t JSON, is a bad poll. A good poll with no value showsok. - A bad poll shows what went wrong: for a shell source its first line of output if it printed one, so a check can print
DOWNand exit 1, and otherwise the summary (exit 3,HTTP 503,timed out after 10 s). For an HTTP source it is always the summary, never the reply. - The text is one line of printable ASCII, cut at 64 characters. Other characters show as
?. The device shows what fits and ends a longer value in dots. - A shell source’s output is its stdout followed by its stderr, and the app reads at most the last 64 KB of it. So a
json_pointerover a command that writes progress to stderr, such ascurl’s progress meter, finds the output isn’t JSON. Usecurl -s, or2>/dev/null. An HTTP reply is read up to 64 KB, and a JSON body longer than that doesn’t parse. - Secrets in a reply show as
<secret>, as for any HTTP action. That includes a secret the reply escaped (\/or\u0041) which the pointer then decodes: the app blanks the picked value against the secrets the request used, before it goes to the device or the log.
How the device draws it
- The label shrinks to one line and the value goes under it.
- A tile is grey until its first value arrives, then keeps the button’s
bgwhile polls are good and turns red while they are bad. If the app goes away, the grid greys out with the rest of the screen. - A button with an icon and a tile on a grid of 4 or 5 rows has room for the icon and the value only, so its label is hidden. On 1 to 3 rows it keeps its label.
- The app logs a line at info level whenever a tile’s value changes, and when polling starts and stops.
Secrets
secrets.toml holds one name = "value" line per secret:
ha = "Bearer paste-your-token-here"
- The file must have mode 0600, or the app won’t read it and logs the fix:
chmod 600 ~/.config/dev.rackrot.watchdeck/secrets.toml. - A value goes out as an HTTP header, so it must be printable ASCII on one line, up to 4096 bytes. The app won’t read a file with any other value, and the error names the secret.
- A button that names a missing secret holds back the whole config. Adding the secret applies it.
- The Secrets page can remove a secret, after a confirmation. It refuses while the running config still names the secret in a button’s action or a tile’s source, and the message lists the buttons. Change those buttons first, then remove it.
- Secret values never appear in logs or error messages. Errors name the secret, and a syntax error in
secrets.tomlgives only its line number. - On the device, exact copies of a value in a reply show as
<secret>, even one cut short by the 64 KB limit. An encoded copy, such as base64 or URL-encoded, isn’t caught. - The file is plain text. Anything running as your user can read it.
Results
Every press shows a result on the device: the button’s label with OK or Failed, a one-line summary with the time taken, and the end of the output.
The config editor’s Test button runs a button’s saved action the same way, with the same limits and result text, and shows the result in the editor. No press result is sent to any device; a button with a tile polls it again, as a press does. It is off while the form differs from the saved button.
| Outcome | Summary |
|---|---|
| The command exited | exit 0 is OK, any other code fails |
| The command ran out of time | timed out after 60 s |
| A signal killed the command | killed by signal 9 |
| The program wasn’t found | could not start ssh: No such file or directory (os error 2) |
| The server answered | HTTP 200; only a 2xx status is OK |
| The request failed | request failed: …, without the URL |
- The output is the last 1 KB of stdout followed by stderr, or of the HTTP body. Characters other than printable ASCII show as
?. - An OK result closes after 4 s and a failure after 10 s. A tap closes it sooner.
Error reporting
The app can report its own errors and panics to Sentry. It is off unless config.toml has a [sentry] table. Without one the app makes no Sentry client and no network call, even if SENTRY_DSN is set in the environment.
[sentry]
dsn = "https://<key>@<host>/<project>"
pii = false
| Key | Required | Rule |
|---|---|---|
dsn | yes | A Sentry DSN. It must be https, except for localhost and other loopback addresses. The app ignores SENTRY_DSN and SSL_VERIFY and always verifies the certificate |
pii | no | Default false. true turns off the scrubbing below |
-
The app reads
[sentry]once, at startup. After you edit it, restart the app. It logs a warning once if it sees the change. -
An event is sent for each
ERRORlog line and each panic of the app, with the app version aswatchdeck@<version>, anddevelopmentorproductionas the environment. Warnings and info lines are never events. -
With
[sentry], the window also reports its uncaught errors, unhandled rejections and a few handled failures, as events taggedcomponent=uiwith the page URL removed. They pass the same scrubbing and limits. Without it the window loads no Sentry code. -
The
config.toml not appliederror, which names your button ids and labels, is never sent. -
With
pii = false, each event is scrubbed before it leaves the machine, in this order:- Values from
secrets.tomlare removed. If that file can’t be read, the event is dropped. - Your home directory becomes
~. - The hostname becomes
<host>. - IPv4 and IPv6 addresses become
<ip>. - Device ids (
wd-and hex digits) become<device>. - Text inside double quotes is emptied.
This covers messages, exception text, stack frame paths, breadcrumbs, tags, extra data and the log context (the source file path and key-values of the log call). The event has no user and no server name.
- Values from
-
With
pii = true, events keep their text as logged, andWARNlines are also kept as breadcrumbs on later events. -
At most 30 events are sent per hour, and an identical event (same message and build) is sent once per 10 minutes.
-
Turn on “Prevent Storing of IP Addresses” and the data scrubber in the Sentry project as well.
-
The window keeps
[sentry]when you save the layout from it.
Security
- The device sends only a button id. Nothing it sends reaches a command line, URL, header or body.
- Anyone who can edit
config.tomlcan run commands as you, so keep it as private as your shell startup files.
Device protocol
This page defines how the device and the desktop daemon talk. Both sides implement it, and the daemon’s test fixtures use these exact examples.
Status: protocol v1. Sessions run over TLS (wss://) pinned to the daemon’s certificate, and every session needs the token the device made at pairing. See plans/desktop-app-v1.md for the full design.
Ports
| Port | Transport | Open |
|---|---|---|
| 47820 | wss | Whenever the daemon runs |
| 47821 | plain ws | Only while the desktop’s pairing dialog is open, for at most 120 s |
Discovery
The daemon advertises itself over mDNS:
| Field | Value |
|---|---|
| Service | _watchdeck._tcp |
| Port | 47820 |
| TXT | v=1, fp=<16 hex>, and pair=47821 only while pairing is open |
fp is the first 16 hex digits of SHA-256 over the daemon’s certificate PEM, byte for byte.
- A paired device keeps the instances whose
fpmatches the certificate it pinned at pairing. It tries their IPv4 addresses in order, its own subnet first, and moves to the next at once when one fails. It connects towss://<addr>:47820/. - An unpaired device lists up to 4 instances that carry
v=1and apairport, and looks again every 4 s. The user taps one to pair.
Transport security
- The daemon serves
wsson 47820 only. Plainwsis refused. - The certificate is self-signed P-256, created on the daemon’s first run and stored with its key in the daemon’s config directory.
- The device trusts exactly the certificate it received at pairing (a byte-for-byte pin) and checks neither its name nor its dates.
- The
fpin mDNS only selects which instance to try. Any LAN peer can advertise it, so the pin is what authenticates the daemon. - The device shows “daemon identity changed” when instances are visible but none carry the pinned
fp, and “wrong cert from daemon” when an instance with the rightfppresents another certificate.
Pairing
Pairing runs over plain ws on 47821, so anyone on the LAN can read it. Nothing secret crosses it. The device makes a random 32-byte token and sends only its SHA-256 (commit). A commit/reveal exchange then gives both sides the same 6-digit code. The device shows the code, the user types it into the desktop app, and then the user confirms on the device that the app shows the pairing.
The daemon commits to its nonce before it sees the device’s, and reveals it only after. Neither side can steer the code, so a man in the middle gets a 1 in 10^6 chance per attempt that the desktop accepts the typed code, and one wrong guess closes the window. That check protects the desktop only. The device can’t see whether the code matched, and pair_result is unauthenticated, so anyone on the pairing socket can send ok:true. The device therefore saves nothing until the user confirms on it that the desktop app says “Paired <device>”. The device’s side relies on the user checking the desktop before tapping Yes.
device daemon (dialog open)
|--- pair_req ----------------->| within 10 s of the upgrade
|<-- pair_offer ----------------|
|--- pair_nonce --------------->| within 10 s
|<-- pair_reveal ---------------| both sides derive the code
| (the user types the code into the desktop app)
|<-- pair_result ---------------| then close
| (on ok:true the device asks: does the app say "Paired <device>"?
| Yes saves the pairing and connects; No or 60 s saves nothing)
Binary fields are lowercase hex: commit and c_host are 64 digits, n_dev and n_host 32.
pair_req (device → daemon)
{"t":"pair_req","v":1,"device":"wd-000000000001","commit":"630dcd2966c4336691125448bbb25b4ff412a49c732db2c8abc1b8581bd710dd"}
commit is SHA-256(token). device has the same form as in hello.
pair_offer (daemon → device)
{"t":"pair_offer","name":"buck-desktop","cert":"-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----\n","c_host":"816b9e7c25d559c5766755b3bbb36654ad451e080ffa93694a793d6eed41f40a"}
| Field | Meaning |
|---|---|
name | The daemon host’s name, for display only |
cert | The daemon’s certificate PEM, which the device will pin. At most 2047 bytes |
c_host | SHA-256(n_host), where n_host is 16 random bytes |
The device refuses a cert that isn’t exactly one PEM certificate that mbedTLS parses.
pair_nonce (device → daemon)
{"t":"pair_nonce","n_dev":"202122232425262728292a2b2c2d2e2f"}
n_dev is 16 random bytes.
pair_reveal (daemon → device)
{"t":"pair_reveal","n_host":"303132333435363738393a3b3c3d3e3f"}
The device aborts unless SHA-256(n_host) equals c_host.
The code
The code is BE_u32(SHA-256(enc)[0..4]) mod 10^6, zero-padded to 6 digits. enc joins these fields in order, each as a u16-BE length and then its bytes:
"watchdeck-pair-v1"devicecommit, as 32 raw bytescert, the PEM bytesn_dev, as 16 raw bytesn_host, as 16 raw bytes
name stays out of enc, because the device shows it but pins only cert.
Test vector, matching the examples above:
| Input | Value |
|---|---|
| token | bytes 00 to 1f |
device | wd-000000000001 |
cert | -----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----\n |
n_dev | bytes 20 to 2f |
n_host | bytes 30 to 3f |
| code | 333724 |
pair_result (daemon → device)
{"t":"pair_result","ok":true}
ok is true only when the typed code matched and the daemon stored SHA-256(token) for device. The daemon closes after sending it.
On ok:true the device keeps its token, the daemon’s name and the certificate in RAM, and asks the user whether the desktop app says “Paired <device>”. Yes stores them in NVS and connects over wss. No, or no answer within 60 s, wipes them and shows “pairing cancelled”.
The daemon stores its side before the user answers, so a No leaves a token hash on the desktop that nobody can use, since only the device ever held the token. The device’s question screen tells the user to Forget the device on the desktop after a No. Nothing syncs this automatically.
Pairing rules
- The listener serves one device at a time.
- An attempt that fails before the code leaves the window open for another. A step slower than 10 s is closed. Before the code, a malformed or out-of-order message gets
errorwithbad_request, and a wrongvgetsversion, then a close. - Once the daemon sends
pair_reveal, the device must stay silent untilpair_result. Any message from it, or a disconnect, ends the window withpair_resultok:false. - The user gets one guess per window. A wrong code gets
pair_resultwithok:false, stores nothing, and closes the window. - Cancelling the dialog or reaching the 120 s limit closes the listener. A device waiting on the code gets
pair_resultwithok:false. - Pairing a device again replaces its token, sends its live session
errorwithunauthorized, and closes it.
Connection
- The daemon is the WebSocket server, and the device is the client.
- Messages are JSON in text frames. Every message has a string field
tthat names its type. - Each message is a single frame of at most 16 KB in either direction, on both ports. The receiver closes the connection on anything larger.
- The one exception is an icon frame, a binary frame from the daemon to the device of at most 262,168 bytes. The device never sends binary frames, and the daemon answers one with
errorbad_requestand a close. - Both sides ignore unknown fields. An unknown
tis logged and ignored. - Liveness uses WebSocket ping frames. The device pings every 10 s and drops the link after 25 s without a pong. The daemon drops a session that has been silent for over 30 s.
- If the link drops, the device reconnects:
- It backs off 1, 2, 4, 8, 16 and then 30 s (cap), each with ±20% jitter.
- It re-runs mDNS discovery before every attempt, so a changed desktop IP is picked up.
- Getting an IP or receiving a
welcomeresets the backoff.
Session
device daemon
|--- hello ---------------->| within 5 s of connecting
|<-- welcome ---------------| or error, then close
|<-- layout ----------------| and again whenever the grid changes
|--- layout_ack ----------->| once the grid is built
|--- icon_get ------------->| for icons the device doesn't have
|<-- icon frame (binary) ---| one per icon
|--- press ---------------->|
|<-- result ----------------| exactly one per press
|--- report --------------->| a crash or reset from an earlier boot, if the device has one
|<-- report_ack ------------| after the daemon logs it
- Presses run concurrently. Results arrive in the order the actions finish, and pings, presses and layouts keep flowing while an action runs.
- A device that authenticates with the same
deviceid replaces its old session. Ahellothat fails authentication never touches the live session, so a copied id can’t push the real device off. - Forgetting a device on the desktop deletes its token hash, sends its live session
errorwithunauthorized, and closes it. - Forget pairing on the device erases only the device’s copy. The desktop keeps listing the device until it’s forgotten there too.
Messages
hello (device → daemon)
{"t":"hello","v":1,"device":"wd-e8f60ae276ab","token":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","fw":"0.2.0","screen":[1024,600]}
| Field | Type | Meaning |
|---|---|---|
v | int | Protocol version. The daemon accepts only 1 |
device | string | wd- plus the device’s Wi-Fi MAC as 12 lowercase hex digits |
token | string | The device’s 32-byte token from pairing, as 64 lowercase hex digits |
fw | string | Firmware version |
screen | [int, int] | Screen width and height in pixels |
The daemon hashes token and compares the result in constant time with the hash it stored for device. A missing, malformed or wrong token and an unknown device all get the same error, then a close:
{"t":"error","code":"unauthorized","msg":"unauthorized"}
welcome (daemon → device)
{"t":"welcome","v":1,"name":"buck-desktop"}
name is the daemon host’s name, which the device can show.
error (daemon → device)
{"t":"error","code":"version","msg":"unsupported protocol version 2"}
The daemon closes the connection after sending it. code is one of version, bad_request or unauthorized. A device that gets unauthorized over wss erases its pairing and goes back to listing daemons open for pairing.
layout (daemon → device)
{"t":"layout","rev":1,"cols":5,"rows":3,"buttons":[
{"id":"hello","col":0,"row":0,"label":"Hello","bg":2001125,"fg":16777215,"icon":"d95bd7f461bd7bc39755b97d11f8a270"},
{"id":"plain","col":1,"row":0,"label":"Plain","bg":0,"fg":16777215},
{"id":"nas-load","col":2,"row":0,"label":"NAS load","bg":2001125,"fg":16777215,"tile":true}
]}
| Field | Type | Meaning |
|---|---|---|
rev | int | 1 for the session’s first layout, then one more for each layout pushed on it |
cols, rows | int | Grid size, 1 to 8 columns by 1 to 5 rows |
buttons[].id | string | Stable button id, used in press. At most 63 bytes |
buttons[].col, row | int | Zero-based cell |
buttons[].label | string | Printable ASCII, at most 47 bytes |
buttons[].bg, fg | int | Colors as 0xRRGGBB integers |
buttons[].icon | string | Optional. The icon’s id as 32 lowercase hex digits. Absent for a button without one. A layout with any other value, null included, is refused |
buttons[].tile | bool | Optional, default false. true for a button that shows a live value, which arrives as tile messages. Any other type is refused |
The daemon sends layout after welcome. When the user’s config changes the grid, a button, a label, a color or a position, the daemon pushes a new layout on the same session, and the device replaces its grid without reconnecting. An edit that changes only actions sends nothing. Empty cells have no button. A layout that turns a tile on or off for a button counts as a change to a button.
The layout carries only icon ids. The device keeps the icons it has, and asks for the rest with icon_get. A button without its icon yet shows its label alone and gains the picture when the frame arrives.
layout_ack (device → daemon)
{"t":"layout_ack","rev":2}
| Field | Type | Meaning |
|---|---|---|
rev | int | The rev of the layout whose grid the device has built |
The device sends it for every layout, after it has built the grid and sent its icon_get. Icons still on the way don’t hold it back, because a button shows its label until its picture arrives. The daemon tells the desktop window that the device applied the layout.
- The daemon logs and ignores an ack for a
revit never sent on this session (0 or above the latest), and one no newer than the latest ack it already took. - A
revthat is not a non-negative integer of at most 32 bits is refused like any malformed message:errorbad_requestand a close. - A daemon from before this message ignores it as an unknown
t. A board from before it never sends one, and the daemon then reports only that it sent the layout. Addinglayout_ackdoesn’t changev.
icon_get (device → daemon)
{"t":"icon_get","ids":["9f86d081884c7d659a2feaa0c55ad015","d95bd7f461bd7bc39755b97d11f8a270"]}
| Field | Type | Meaning |
|---|---|---|
ids | [string] | Icon ids from the current layout that the device lacks. At most 40, each exactly 32 lowercase hex digits |
The daemon answers each id that the session’s current layout names with one icon frame, in the order asked, and sends nothing for any other id. A repeated id gets one frame, since the daemon drops duplicates. A list that is too long or has a malformed id gets error bad_request and a close.
The device sends one icon_get per layout, listing every distinct id it doesn’t hold, and sends none when it holds them all. It keeps the icons of the layout on screen only. A layout that drops an icon frees it, and a link that drops and returns keeps the rest, so a reconnect to an unchanged layout fetches nothing.
Icon frames
An icon is a binary WebSocket frame from the daemon:
offset size field
0 4 "WDI1"
4 16 id
20 2 width, u16 LE
22 2 height, u16 LE
24 w*h*4 pixels, row by row, from the top left
- Pixels are LVGL’s ARGB8888: the bytes B, G, R, A for each pixel, with straight (not premultiplied) alpha.
- The
idis the first 16 bytes of SHA-256 over the frame from byte 20 on: width, height and pixels. Equal pictures share an id. - Width and height are each 1 to 256, and the frame is exactly 24 + w·h·4 bytes, so a frame is at most 262,168 bytes.
- The device checks, in this order, that the frame has a header, that it starts with
WDI1, that width and height are in range, that its length is exactly right, and that the hash matchesid. It also checks that it asked for thatid. A frame that fails any check is dropped and logged, and the session goes on. The device never reads a pixel before the length check passes.
Test vector, a 2×1 icon of one opaque red pixel and one fully transparent pixel:
| Field | Value |
|---|---|
| pixels | 00 00 ff ff 00 00 00 00 |
id | d95bd7f461bd7bc39755b97d11f8a270 |
| frame | 57 44 49 31, the 16 id bytes, 02 00 01 00, then the 8 pixel bytes: 32 bytes in all |
The daemon sizes every icon to fit its button, with room left for the label. See Icons for the box and how pictures are scaled.
press (device → daemon)
{"t":"press","btn":"hello","seq":17}
seq increases with each press from that device. The daemon echoes it back in result.
result (daemon → device)
{"t":"result","btn":"hello","seq":17,"ok":true,"code":0,"summary":"exit 0","text":"","ms":12}
| Field | Type | Meaning |
|---|---|---|
btn | string | The button id from the press |
ok | bool | Exit 0 for a shell action, a 2xx status for an HTTP action |
code | int | The exit code or HTTP status, or -1 when there is neither |
summary | string | One line, at most 96 bytes, on what happened |
text | string | The last 1 KB of output: stdout then stderr, or the HTTP body. Printable ASCII and \n only. Often empty |
ms | int | How long the action ran. 0 when nothing ran |
The daemon answers every press with exactly one result, including when nothing runs. Typical summaries:
summary | ok | code |
|---|---|---|
exit 0, exit 3 | exit 0 only | The exit code |
killed by signal 9 | false | -1 |
timed out after 30 s | false | -1 |
could not start ssh: … | false | -1 |
HTTP 200, HTTP 503, HTTP 302, redirect not followed | 2xx only | The status |
request failed: … | false | -1 |
unknown button, already running, busy: 8 actions are running | false | -1 |
The device shows the result in a modal over the grid: the button’s label with OK or Failed, the summary and time, and the text. It closes at a tap, after 4 s when ok, or after 10 s when not. A result whose seq the device didn’t send is ignored. summary arrived with S4, so a device that finds none shows the code instead.
tile (daemon → device)
{"t":"tile","btn":"nas-load","text":"0.42","state":"ok"}
| Field | Type | Meaning |
|---|---|---|
btn | string | The id of a button whose layout entry has tile: true |
text | string | The tile’s value: one line of printable ASCII, at most 64 characters |
state | string | ok or bad. A device shows any other value, or none, as unknown |
The daemon polls each tile only while at least one device has a session, and sends a tile when a tile’s value or state differs from what it last sent on that session. So:
- A session gets the current value of every tile that has one right after its
layout, and again after each laterlayout, because a device rebuilds its grid for each layout and the new buttons start blank. - A tile that hasn’t answered yet has sent nothing. The device shows it as unknown until its first
tile. - A tile whose polls couldn’t run (no action slot free, 3 times in a row) sends
badwith the textstale. A device needs nothing special for it. - The daemon never sends a value that a tile’s source produced before the config edit it has applied.
- A
tilefor an unknown button, or a button withouttile, is ignored. - A session that the daemon has revoked or replaced gets no more
tilemessages. See Config for what the value and the state mean.
The device updates the button’s text and color in place and doesn’t rebuild the grid.
report (device → daemon)
{"t":"report","id":"a1b2c3d4","kind":"crash","reason":"panic","task":"link","pc":"0x4ff01234","ra":"0x4ff05678","cause":"LoadAccessFault","addr":"0x00000000","elf":"0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0","same_build":true}
| Field | Type | Meaning |
|---|---|---|
id | string | 8 lowercase hex digits that name this report in report_ack |
kind | string | crash for a coredump from an earlier boot, or reset for an abnormal reset with no dump |
reason | string | panic, int_wdt, task_wdt, wdt or brownout |
task | string | The task that was running, or empty. Printable ASCII, at most 16 characters |
pc, ra | string | The faulting program counter and the return address, each 0x and 8 lowercase hex digits |
cause | string | The fault’s name, such as LoadAccessFault. 1 to 32 printable ASCII characters |
addr | string | The faulting address (mtval), in the same form as pc |
elf | string | SHA-256 of the crashed firmware’s ELF, as 64 lowercase hex digits. With the pc it finds the source line using riscv32-esp-elf-addr2line |
same_build | bool | Optional, default false. true when the crashed firmware is the one now running, so the fw in hello names it |
pc, ra, cause, addr, elf and same_build are required for crash and ignored for reset, which has no dump to read them from.
- The device sends its report after
welcomeand again on every session until it gets the matchingreport_ack. It keeps the report until then. - The daemon checks every field above, and a report counts toward the session’s limit of two whether it passes or not.
- A report with a valid
idthat fails a check, or whose fields have the wrong JSON type, is rejected. The daemon logs the failed field without the report’s values, sendsreport_ackso the device erases it, and sends nothing to Sentry. - A report without a valid
id, or one that arrives after the session’s second, is logged and dropped with noreport_ack. - Either way the session goes on.
- A report with a valid
- The daemon writes one
warnlog line per accepted report, whether or not it sends the report to Sentry, and then sendsreport_ack. It sends the ack even when it is not configured to report to Sentry, or when it keeps the report out of Sentry to stay under its limits (one report per device,elf,pcandreasonper hour, and 10 device reports an hour in all). - A daemon from before this message ignores it as an unknown
tand never acks, so the device keeps the report for a newer daemon. Addingreportandreport_ackdoesn’t changev.
report_ack (daemon → device)
{"t":"report_ack","id":"a1b2c3d4"}
id is the id of an accepted report. The device then erases the dump.
Device Spec: CrowPanel Advanced 10.1“ ESP32-P4 HMI
Elecrow CrowPanel Advanced 10.1-inch ESP32-P4 HMI AI display, 1024×600 IPS touch screen, Wi-Fi 6.
Source: Elecrow product page, retrieved 2026-10-10. Values are transcribed as published. See Source discrepancies for apparent errors on the vendor page.
Main Chip (ESP32-P4NRW32)
| Item | Value |
|---|---|
| CPU/SoC | ESP32-P4 |
| HP core | RISC-V 32-bit dual-core, up to 400 MHz |
| LP core | RISC-V 32-bit single-core, up to 40 MHz |
| System memory | 768 KB L2MEM (HP), 32 KB SRAM (LP), 8 KB TCM, 32 MB PSRAM |
| ROM | 128 KB (HP), 16 KB (LP) |
| Flash | 16 MB |
| Development environment | ESP-IDF, Arduino IDE |
Screen
| Item | Value |
|---|---|
| Size | 10.1 in |
| Resolution | 1024×600 |
| Panel | IPS |
| Touch | Capacitive, single / 5-point |
| Viewing angle | 178° |
| Brightness | 400 cd/m² (typ.) |
| Color depth | 16.7M (8-bit) |
Wireless (onboard antenna)
| Item | Value |
|---|---|
| Wi-Fi | 2.4 GHz Wi-Fi 6, 802.11 b/g/n |
| Bluetooth | Bluetooth 5.3 and BLE |
| Optional | Zigbee, LoRa, nRF2401, Matter, Thread, Wi-Fi HaLow |
Interfaces and Functions
- Interfaces: USB 2.0, UART, I2C, GPIO female headers, SD card holder, battery socket, speaker jack, camera header, module female headers, etc.
- Functions: audio amplifier, battery charge management, USB-to-UART, microphones, dual speakers, etc.
Speaker Connector
| Item | Value |
|---|---|
| Model | BX-PH 2.0-2PWT |
| Pins | 1×2P |
| Pitch | 2 mm |
| Dimensions (L×W×H) | 8 × 8.5 × 5.6 mm |
| Mounting | SMT right angle |
Buttons and LEDs
| Control | Behavior |
|---|---|
| Reset button | Press to reset the device |
| Boot button | “Press and hold the power button to burn the program” (as published) |
| Power button | Switch on/off |
| PWR LED | Power on/off indication |
| CHG LED | Lithium battery charging status, low battery state |
Physical and Power
| Item | Value |
|---|---|
| Mounting | Mounting holes all around (M3, 3.2 mm), embedded, shell assembly |
| Operating temperature | -20 to 70 °C |
| Storage temperature | -30 to 80 °C |
| Power input | 5 V / 2 A, via USB or UART terminal |
| Dimensions | 248 × 147 mm |
Board Connectors
| Pin | Description | Connector |
|---|---|---|
| SPK | Audio output to speakers. Mainboard has a power amplifier. | PH2.0-2P |
| PWR | Power LED | — |
| RST | Reset button | — |
| BOOT | — | — |
| UART0-OUT | Serial link to logic modules (serial comms, printing) | HY2.0-4P |
| UART1-OUT | Serial link to logic modules (serial comms, printing) | HY2.0-4P |
| UART1-IN | — | HY2.0-4P |
| I2C-OUT | MCU-to-peripheral communication | HY2.0-4P |
| BAT | Lithium battery, with charging circuit | PH2.0-2P |
Optional Wireless Modules
ESP32-H2 Module
Pinout:
| Pin | Direction | Note |
|---|---|---|
| U1RXD | Input | Serial port 0 receive pin (as published) |
| U1TXD | Output | Serial port 0 receive pin (as published) |
| GPIO12 | I/O | GPIO |
| GPIO13 | I/O | GPIO |
| GPIO14 | I/O | GPIO |
| 3V3 | — | Power supply |
| GND | — | Ground reference |
| GPIO1 | I/O | GPIO |
| GPIO0 | I/O | GPIO |
| GPIO2 | I/O | GPIO |
| GPIO22 | I/O | GPIO |
| GPIO10 | I/O | GPIO |
| GPIO11 | I/O | GPIO |
| BOOT | — | Hold, then tap RST to enter flashing mode |
| RST | — | Restarts the program; also used when flashing |
Chip:
| Item | Value |
|---|---|
| Model | ESP32-H2FH4 |
| Flash | 4 MB Quad SPI |
| SRAM | 320 KB |
| ROM | 128 KB |
| LP memory | 4 KB |
| Ambient temperature | -40 to 105 °C |
| Voltage | 3.3 V |
| BLE channel center frequency | 2402–2480 MHz |
| RF TX power | -24.0 to 20.0 dBm |
Patch antenna (2.4–2.5 GHz):
| Item | Value |
|---|---|
| Peak gain | 4.33 dBi |
| Average gain | 4.0 dBi |
| Gain range | 3.59–4.33 dBi |
| Peak efficiency | 62.5% |
| Average efficiency | 57.5% |
| Efficiency range | 51.3–62.5% |
nRF2401 Module
Pinout:
| Pin | Direction | Note |
|---|---|---|
| CE | Input | Module control |
| SCK | Output | SPI |
| MISO | Input | SPI |
| MOSI | Output | SPI |
| 3V3 | — | Power supply |
| GND | — | Ground reference |
| CSN | Input | SPI chip select |
| IRQ | Input | Interrupt, active low |
Chip (nRF24L01+):
- Worldwide 2.4 GHz ISM band
- 250 kbps, 1 Mbps, 2 Mbps air data rates
- 11.3 mA TX at 0 dBm
- 13.5 mA RX at 2 Mbps
- 900 nA power down
- On-chip voltage regulator
- 1.9–3.6 V supply
Patch antenna (2.4–2.5 GHz):
| Item | Value |
|---|---|
| Peak gain | 3.74 dBi |
| Average gain | 3.66 dBi |
| Gain range | 3.45–3.74 dBi |
| Peak efficiency | 58.9% |
| Average efficiency | 55.9% |
| Efficiency range | 53.0–58.9% |
SX1262 (LoRa) Module
Pinout:
| Pin | Direction | Note |
|---|---|---|
| DIO1 | I/O | Configurable GPIO |
| SCK | Input | SPI |
| MISO | Input | SPI |
| MOSI | Output | SPI |
| 3V3 | — | Power supply |
| GND | — | Ground reference |
| NRESET | Output | Chip reset, active low |
| DIO2 | Input | RF switch: low = RX, high = TX |
| BUSY | Output | Status indication |
| NSS | Input | SPI chip select |
| DIO3 | I/O | Configurable GPIO |
Antenna:
| Item | Value |
|---|---|
| Gain | 3.5 dBi |
| VSWR | ≤ 1.9 |
| Input impedance | 50 Ω |
| Polarization | Vertical |
| Lightning protection | DC ground |
| Cable | RF1.13, 10 cm |
| Connector | IPEX-1 |
| Weight | 0.01 kg |
| Operating temperature | -40 to 85 °C |
Source Discrepancies
The vendor page contains apparent errors. They are kept verbatim above and should be checked against the schematic before relying on them.
- ESP32-H2 U1RXD/U1TXD. Both are described as “Serial port 0 receiving pin”, though the names indicate UART1 and U1TXD is an output.
- Boot button. The description mentions the power button. The H2 module’s BOOT description (hold BOOT, tap RST) is the usual ESP flashing sequence.
- Pin directions. Several SPI and control pin directions (e.g. nRF2401 SCK as output, SX1262 NRESET and BUSY) look stated from inconsistent perspectives (host vs. module).
- Wi-Fi. “Wi-Fi 6” is listed alongside only 802.11 b/g/n; Wi-Fi 6 is 802.11ax.