Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

SourceWhereChange it by
SD cardwifi.txt at the root of the TF cardEditing the file, then pressing reset
Compiled infirmware/sdkconfig.defaults.localRebuilding 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\word becomes "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 textMeaning
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 credentialsNeither source has an SSID
invalid Wi-Fi credentialsThe 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.txt holds 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

KeyRule
version1
layout.cols1 to 8
layout.rows1 to 5

Buttons

Each [[button]] table is one cell.

KeyRequiredRule
idyes1 to 63 letters, digits, _, - or ., unique
posyes[column, row], counted from 0 at the top left. Inside the grid, one button per cell
labelyesPrintable ASCII, at most 47 characters. The device’s fonts have no other characters
bgnoBackground as #rrggbb. Default #1e88e5
fgnoLabel color as #rrggbb. Default #ffffff
iconnoA picture in the icons/ folder, such as "3f2a9c1d0e44.png"; see Icons
actionyesA shell or HTTP action, below
tilenoMakes 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.

  • icon is a plain file name: 1 to 63 letters, digits, _, - or ., not starting with a dot. It can’t point outside icons/.
  • 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

KeyRequiredRule
kindyes"shell"
argvyesThe program, then its arguments. Not empty
timeout_sno1 to 600 seconds. Default 10
  • argv runs directly, without a shell, so |, >, * and $HOME have 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 PATH additions and SSH_AUTH_SOCK. Use absolute paths for programs outside the system PATH, 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

KeyRequiredRule
kindyes"http"
methodyesGET, HEAD, POST, PUT, PATCH or DELETE
urlyeshttp:// or https://, with a host
bodynoSent as written, at most 64 KB. No Content-Type is added; set one in headers
headersnoHeader name → value, sent as written, e.g. { "Content-Type" = "application/json" }. Values are printable ASCII
secret_headersnoHeader name → secret name, e.g. { Authorization = "ha" }
insecure_httpnotrue lets secret_headers go over http:// to another machine. Default false
timeout_sno1 to 600 seconds for the whole exchange. Default 10
  • A header name may appear only once across headers and secret_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:

KeyRequiredRule
every_syesSeconds between polls, 2 to 86400
json_pointernoAn RFC 6901 pointer such as /state or /attributes/brightness. It must start with /
sourceyesA 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_s seconds 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 shows stale in 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_pointer that 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, a null, an object or an array, or output that isn’t JSON, is a bad poll. A good poll with no value shows ok.
  • 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 DOWN and 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_pointer over a command that writes progress to stderr, such as curl’s progress meter, finds the output isn’t JSON. Use curl -s, or 2>/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 bg while 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.toml gives 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.

OutcomeSummary
The command exitedexit 0 is OK, any other code fails
The command ran out of timetimed out after 60 s
A signal killed the commandkilled by signal 9
The program wasn’t foundcould not start ssh: No such file or directory (os error 2)
The server answeredHTTP 200; only a 2xx status is OK
The request failedrequest 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
KeyRequiredRule
dsnyesA 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
piinoDefault 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 ERROR log line and each panic of the app, with the app version as watchdeck@<version>, and development or production as 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 tagged component=ui with the page URL removed. They pass the same scrubbing and limits. Without it the window loads no Sentry code.

  • The config.toml not applied error, 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:

    1. Values from secrets.toml are removed. If that file can’t be read, the event is dropped.
    2. Your home directory becomes ~.
    3. The hostname becomes <host>.
    4. IPv4 and IPv6 addresses become <ip>.
    5. Device ids (wd- and hex digits) become <device>.
    6. 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.

  • With pii = true, events keep their text as logged, and WARN lines 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.toml can 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

PortTransportOpen
47820wssWhenever the daemon runs
47821plain wsOnly while the desktop’s pairing dialog is open, for at most 120 s

Discovery

The daemon advertises itself over mDNS:

FieldValue
Service_watchdeck._tcp
Port47820
TXTv=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 fp matches 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 to wss://<addr>:47820/.
  • An unpaired device lists up to 4 instances that carry v=1 and a pair port, and looks again every 4 s. The user taps one to pair.

Transport security

  • The daemon serves wss on 47820 only. Plain ws is 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 fp in 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 right fp presents 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"}
FieldMeaning
nameThe daemon host’s name, for display only
certThe daemon’s certificate PEM, which the device will pin. At most 2047 bytes
c_hostSHA-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:

  1. "watchdeck-pair-v1"
  2. device
  3. commit, as 32 raw bytes
  4. cert, the PEM bytes
  5. n_dev, as 16 raw bytes
  6. n_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:

InputValue
tokenbytes 00 to 1f
devicewd-000000000001
cert-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----\n
n_devbytes 20 to 2f
n_hostbytes 30 to 3f
code333724

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 error with bad_request, and a wrong v gets version, then a close.
  • Once the daemon sends pair_reveal, the device must stay silent until pair_result. Any message from it, or a disconnect, ends the window with pair_result ok:false.
  • The user gets one guess per window. A wrong code gets pair_result with ok: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_result with ok:false.
  • Pairing a device again replaces its token, sends its live session error with unauthorized, 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 t that 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 error bad_request and a close.
  • Both sides ignore unknown fields. An unknown t is 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:
    1. It backs off 1, 2, 4, 8, 16 and then 30 s (cap), each with ±20% jitter.
    2. It re-runs mDNS discovery before every attempt, so a changed desktop IP is picked up.
    3. Getting an IP or receiving a welcome resets 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 device id replaces its old session. A hello that 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 error with unauthorized, 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]}
FieldTypeMeaning
vintProtocol version. The daemon accepts only 1
devicestringwd- plus the device’s Wi-Fi MAC as 12 lowercase hex digits
tokenstringThe device’s 32-byte token from pairing, as 64 lowercase hex digits
fwstringFirmware 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}
]}
FieldTypeMeaning
revint1 for the session’s first layout, then one more for each layout pushed on it
cols, rowsintGrid size, 1 to 8 columns by 1 to 5 rows
buttons[].idstringStable button id, used in press. At most 63 bytes
buttons[].col, rowintZero-based cell
buttons[].labelstringPrintable ASCII, at most 47 bytes
buttons[].bg, fgintColors as 0xRRGGBB integers
buttons[].iconstringOptional. 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[].tileboolOptional, 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}
FieldTypeMeaning
revintThe 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 rev it never sent on this session (0 or above the latest), and one no newer than the latest ack it already took.
  • A rev that is not a non-negative integer of at most 32 bits is refused like any malformed message: error bad_request and 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. Adding layout_ack doesn’t change v.

icon_get (device → daemon)

{"t":"icon_get","ids":["9f86d081884c7d659a2feaa0c55ad015","d95bd7f461bd7bc39755b97d11f8a270"]}
FieldTypeMeaning
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 id is 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 matches id. It also checks that it asked for that id. 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:

FieldValue
pixels00 00 ff ff 00 00 00 00
idd95bd7f461bd7bc39755b97d11f8a270
frame57 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}
FieldTypeMeaning
btnstringThe button id from the press
okboolExit 0 for a shell action, a 2xx status for an HTTP action
codeintThe exit code or HTTP status, or -1 when there is neither
summarystringOne line, at most 96 bytes, on what happened
textstringThe last 1 KB of output: stdout then stderr, or the HTTP body. Printable ASCII and \n only. Often empty
msintHow long the action ran. 0 when nothing ran

The daemon answers every press with exactly one result, including when nothing runs. Typical summaries:

summaryokcode
exit 0, exit 3exit 0 onlyThe exit code
killed by signal 9false-1
timed out after 30 sfalse-1
could not start ssh: …false-1
HTTP 200, HTTP 503, HTTP 302, redirect not followed2xx onlyThe status
request failed: …false-1
unknown button, already running, busy: 8 actions are runningfalse-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"}
FieldTypeMeaning
btnstringThe id of a button whose layout entry has tile: true
textstringThe tile’s value: one line of printable ASCII, at most 64 characters
statestringok 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 later layout, 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 bad with the text stale. 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 tile for an unknown button, or a button without tile, is ignored.
  • A session that the daemon has revoked or replaced gets no more tile messages. 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}
FieldTypeMeaning
idstring8 lowercase hex digits that name this report in report_ack
kindstringcrash for a coredump from an earlier boot, or reset for an abnormal reset with no dump
reasonstringpanic, int_wdt, task_wdt, wdt or brownout
taskstringThe task that was running, or empty. Printable ASCII, at most 16 characters
pc, rastringThe faulting program counter and the return address, each 0x and 8 lowercase hex digits
causestringThe fault’s name, such as LoadAccessFault. 1 to 32 printable ASCII characters
addrstringThe faulting address (mtval), in the same form as pc
elfstringSHA-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_buildboolOptional, 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 welcome and again on every session until it gets the matching report_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 id that fails a check, or whose fields have the wrong JSON type, is rejected. The daemon logs the failed field without the report’s values, sends report_ack so 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 no report_ack.
    • Either way the session goes on.
  • The daemon writes one warn log line per accepted report, whether or not it sends the report to Sentry, and then sends report_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, pc and reason per hour, and 10 device reports an hour in all).
  • A daemon from before this message ignores it as an unknown t and never acks, so the device keeps the report for a newer daemon. Adding report and report_ack doesn’t change v.

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)

ItemValue
CPU/SoCESP32-P4
HP coreRISC-V 32-bit dual-core, up to 400 MHz
LP coreRISC-V 32-bit single-core, up to 40 MHz
System memory768 KB L2MEM (HP), 32 KB SRAM (LP), 8 KB TCM, 32 MB PSRAM
ROM128 KB (HP), 16 KB (LP)
Flash16 MB
Development environmentESP-IDF, Arduino IDE

Screen

ItemValue
Size10.1 in
Resolution1024×600
PanelIPS
TouchCapacitive, single / 5-point
Viewing angle178°
Brightness400 cd/m² (typ.)
Color depth16.7M (8-bit)

Wireless (onboard antenna)

ItemValue
Wi-Fi2.4 GHz Wi-Fi 6, 802.11 b/g/n
BluetoothBluetooth 5.3 and BLE
OptionalZigbee, 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

ItemValue
ModelBX-PH 2.0-2PWT
Pins1×2P
Pitch2 mm
Dimensions (L×W×H)8 × 8.5 × 5.6 mm
MountingSMT right angle

Buttons and LEDs

ControlBehavior
Reset buttonPress to reset the device
Boot button“Press and hold the power button to burn the program” (as published)
Power buttonSwitch on/off
PWR LEDPower on/off indication
CHG LEDLithium battery charging status, low battery state

Physical and Power

ItemValue
MountingMounting holes all around (M3, 3.2 mm), embedded, shell assembly
Operating temperature-20 to 70 °C
Storage temperature-30 to 80 °C
Power input5 V / 2 A, via USB or UART terminal
Dimensions248 × 147 mm

Board Connectors

PinDescriptionConnector
SPKAudio output to speakers. Mainboard has a power amplifier.PH2.0-2P
PWRPower LED—
RSTReset button—
BOOT——
UART0-OUTSerial link to logic modules (serial comms, printing)HY2.0-4P
UART1-OUTSerial link to logic modules (serial comms, printing)HY2.0-4P
UART1-IN—HY2.0-4P
I2C-OUTMCU-to-peripheral communicationHY2.0-4P
BATLithium battery, with charging circuitPH2.0-2P

Optional Wireless Modules

ESP32-H2 Module

Pinout:

PinDirectionNote
U1RXDInputSerial port 0 receive pin (as published)
U1TXDOutputSerial port 0 receive pin (as published)
GPIO12I/OGPIO
GPIO13I/OGPIO
GPIO14I/OGPIO
3V3—Power supply
GND—Ground reference
GPIO1I/OGPIO
GPIO0I/OGPIO
GPIO2I/OGPIO
GPIO22I/OGPIO
GPIO10I/OGPIO
GPIO11I/OGPIO
BOOT—Hold, then tap RST to enter flashing mode
RST—Restarts the program; also used when flashing

Chip:

ItemValue
ModelESP32-H2FH4
Flash4 MB Quad SPI
SRAM320 KB
ROM128 KB
LP memory4 KB
Ambient temperature-40 to 105 °C
Voltage3.3 V
BLE channel center frequency2402–2480 MHz
RF TX power-24.0 to 20.0 dBm

Patch antenna (2.4–2.5 GHz):

ItemValue
Peak gain4.33 dBi
Average gain4.0 dBi
Gain range3.59–4.33 dBi
Peak efficiency62.5%
Average efficiency57.5%
Efficiency range51.3–62.5%

nRF2401 Module

Pinout:

PinDirectionNote
CEInputModule control
SCKOutputSPI
MISOInputSPI
MOSIOutputSPI
3V3—Power supply
GND—Ground reference
CSNInputSPI chip select
IRQInputInterrupt, 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):

ItemValue
Peak gain3.74 dBi
Average gain3.66 dBi
Gain range3.45–3.74 dBi
Peak efficiency58.9%
Average efficiency55.9%
Efficiency range53.0–58.9%

SX1262 (LoRa) Module

Pinout:

PinDirectionNote
DIO1I/OConfigurable GPIO
SCKInputSPI
MISOInputSPI
MOSIOutputSPI
3V3—Power supply
GND—Ground reference
NRESETOutputChip reset, active low
DIO2InputRF switch: low = RX, high = TX
BUSYOutputStatus indication
NSSInputSPI chip select
DIO3I/OConfigurable GPIO

Antenna:

ItemValue
Gain3.5 dBi
VSWR≤ 1.9
Input impedance50 Ω
PolarizationVertical
Lightning protectionDC ground
CableRF1.13, 10 cm
ConnectorIPEX-1
Weight0.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.