Skip to content

Releases: rhshourav/PeekESP

PeekESP v1.2.1

Choose a tag to compare

@rhshourav rhshourav released this 12 Oct 02:33
3f07146

PeekESP v1.2.1

A physical system-metrics dashboard. An ESP32 with a 1.14" display shows live CPU, RAM, storage, temperature and network throughput for machines anywhere on the internet — no port forward, no VPN, no account.

No ESP32? The new Android app and its home-screen widget show the same readings on your phone, with or without a display.

By rhshourav · MIT


Downloads

winget install rhshourav.PeekESP

Or take what you need directly:

File What it is
PeekESP.exe The tray app. Double-click it — this is the one most people want on Windows
peek-agent.exe Headless, for a service or scheduled task
PeekESP-1.2.1-win-x64.zip Both of the above plus the licence. This is what winget installs
PeekESP.apk Android app and home-screen widget. Android 8.1 or newer
peekesp_1.2.1_all.deb Debian, Ubuntu, Raspberry Pi OS, DietPi
PeekESP-merged.bin Firmware for the board — python tools/flash.py
winget-manifests-1.2.1.zip Only needed to submit this version to winget

Not verified

The device-side work in this release has not run on hardware. Standby, the clock, the corrected battery reading, the redesigned settings page, the multi-network join and the commands all compile; that is what can be said.

The charging threshold is still inferred — 4.15 V — because there is no charge-status pin to check it against. The voltage is on screen beside the percentage so it can be corrected against a real board.


Known limits

The free tier is a request budget, not a device count. At a 5-second interval each agent costs 17,280 requests/day and each display 17,280, against Cloudflare's 100,000. A display with two machines is ~52k and fits; four is ~86k and is close. peekesp set interval 15 divides it by three. A phone or widget that polls counts like another display, so count it in.

The Android app is installed from the APK for now. It is not on Google Play yet, so it will not update itself; install the next release's PeekESP.apk over this one. Android also decides how often a widget may refresh, so it updates far less often than a display. [TODO: actual widget refresh interval.]

There is no over-the-air update. The huge_app scheme has one application slot, and the image no longer fits a two-slot layout. Updating the firmware means a cable.

Temperature on Windows is an ACPI zone unless LibreHardwareMonitor is running. Real reading, real place, not the CPU die.

2.4 GHz only, WPA2. An ESP32 has no 5 GHz radio.

Re-flashing keeps the pairing code. It lives in NVS and survives a firmware update. python tools/flash.py --erase gets a new one, and so does the button on the settings page.

An ESP32 cannot join a Tailscale network. Tailscale is WireGuard plus a control plane, and none of that has an embedded client. The relay exists because of this.


Thanks

LVGL, TFT_eSPI, ArduinoJson, and LilyGO for the board.

# PeekESP v1.2.1

A physical system-metrics dashboard. An ESP32 with a 1.14" display shows live
CPU, RAM, storage, temperature and network throughput for machines anywhere on
the internet — no port forward, no VPN, no account.

No ESP32? The new Android app and its home-screen widget show the same
readings on your phone, with or without a display.

By rhshourav· MIT


Downloads

winget install rhshourav.PeekESP

Or take what you need directly:

File What it is
PeekESP.exe The tray app. Double-click it — this is the one most people want on Windows
peek-agent.exe Headless, for a service or scheduled task
PeekESP-1.2.1-win-x64.zip Both of the above plus the licence. This is what winget installs
PeekESP.apk Android app and home-screen widget. Android 8.1 or newer
peekesp_1.2.1_all.deb Debian, Ubuntu, Raspberry Pi OS, DietPi
PeekESP-merged.bin Firmware for the board — python tools/flash.py
winget-manifests-1.2.1.zip Only needed to submit this version to winget

Linux, one line:

curl -fsSL https://raw.githubusercontent.com/rhshourav/PeekESP/main/dietpi/install.sh | sudo sh

Android: download PeekESP.apk from this page and open it. It is not on Google
Play yet — that listing is coming — so Android will ask you to allow Install
unknown apps
for your browser or file manager the first time. Switch that
permission off again afterwards.

The Windows executables are unsigned, so SmartScreen warns the first time:
More info → Run anyway. The .sha256 beside the zip lets you check the file
is the one that was built. Why it is unsigned, and what fixing it
costs.


New in 1.2.1

  • Android support — PeekESP.apk, an app plus a home-screen widget. See below.
  • The repository has moved from shouravx to rhshourav, the original
    account. Both accounts belong to the same person, MD Shourav Hossain. Every
    reference to shouravx has been changed to rhshourav.
  • Some minor UI changes.

Android app

The app lets you use PeekESP from your phone, with or without an ESP32. It
reads the same relay as every other client, so a phone, a desk display and Home
Assistant can all watch the same machines at once.

  • Join an existing network — type the code your device or agents already
    use, e.g. K7M2-P4QX-9R. Dashes and case are ignored.
  • Create a new network — the app generates a code. Give that same code to
    each agent (sudo peekesp pair CODE on Linux, Settings → Pair a device in
    the Windows app). [TODO: say whether an ESP32 can adopt an app-made code, and how.]
  • Home-screen widget — long-press the home screen → Widgets → PeekESP.
    It shows CPU and RAM, storage, temperature, network throughput, uptime and link
    status, depending on what the machine reports.

Needs Android 8.1 or newer. Every machine run with the same code appears in the
app, up to six, exactly as on the display.

Update checks, and commands from the desktop

The relay is talking to the device anyway, so it now carries the latest release
tag with the reply and the device raises a banner when it is behind. The
alternative — every device polling api.github.com — is a second host, a
second certificate authority in an image with no room, and a second request per
device per interval against the budget that is the actual limit here.

The tray app checks for its own updates on a six-hour floor, and can send the
device a short, closed list of commands: reboot, standby, wake, refresh,
identify (flashes the screen, so you can tell two boards apart), jump to a
page, and set the backlight.

A command is left at the relay and collected on the next poll, so there is
still nothing listening anywhere. It is delivered at most once and dropped
after five minutes. Nothing in that vocabulary changes configuration and
everything in it is undone by pressing a button on the device — configuration
is what the settings page is for, behind its own password.

Packaging and CI

  • peekesp_1.2.1_all.deb — sudo apt install ./peekesp_1.2.1_all.deb.
    Built with dpkg-deb alone, no container and no sponsor.
  • packaging/aur/PKGBUILD for Arch.
  • The release is built by CI from the tag: the Linux package is installed
    and its files checked, the Windows agent is run and its JSON parsed, the tag
    is checked against the VERSION file, and every asset is named rather than
    counted. It stops at a draft — publishing is a person clicking publish.
  • The firmware compiles on every push, under --warnings all, with a size
    ceiling and a check that the committed image is not older than the sketch.

A landing page

rhshourav.github.io/PeekESP — photographs
of the real device, what it does, and how to flash it.


What's in the box

Firmware — LVGL dashboard on a LilyGO TTGO T-Display. Two arcs, a bar, a
temperature and throughput panel, a clock and a power page. Values sweep to new
readings over 500 ms rather than snapping. Swiping between machines slides the
whole dashboard body as one object, with values swapped at the midpoint while
nothing is visible.

Android app — a phone-based way to view the same data, with a home-screen
widget. Join an existing network or create a new one from a code; the ESP32 is
optional.

Cloudflare Worker relay — the hosts push, the device polls, both only ever
dial out. Three modes on one deployment: paired (no secrets at all), private
(two tokens), shared (named streams). 86 automated tests.

Agents — Linux and Windows, both standard-library only. The Windows build
adds a tray app with a settings window and a headless peek-agent.exe.

Prebuilt firmware — firmware/PeekESP-merged.bin. Flashing needs no
Arduino IDE, no ESP32 core and no libraries; only esptool.


Architecture

Two pinned FreeRTOS tasks sharing nothing but a mutex-guarded struct. Core 0
does WiFi, NTP, the blocking HTTPS request and the battery ADC. Core 1 runs
lv_timer_handler() and never opens a socket, so a slow link cannot drop a
frame. The ADC moved off the render path in this...

Read more

PeekESP v1.2.0

Choose a tag to compare

@github-actions github-actions released this 07 Sep 14:23

PeekESP v1.2.0

A physical system-metrics dashboard. An ESP32 with a 1.14" display shows live
CPU, RAM, storage, temperature and network throughput for machines anywhere on
the internet — no port forward, no VPN, no account.

By shouravx · MIT


Downloads

winget install shouravx.PeekESP

Or take what you need directly:

File What it is
PeekESP.exe The tray app. Double-click it — this is the one most people want
peek-agent.exe Headless, for a service or scheduled task
PeekESP-1.2.0-win-x64.zip Both of the above plus the licence. This is what winget installs
peekesp_1.2.0_all.deb Debian, Ubuntu, Raspberry Pi OS, DietPi
PeekESP-merged.bin Firmware for the board — python tools/flash.py
winget-manifests-1.2.0.zip Only needed to submit this version to winget

Linux, one line:

curl -fsSL https://raw.githubusercontent.com/shouravx/PeekESP/main/dietpi/install.sh | sudo sh

The Windows executables are unsigned, so SmartScreen warns the first time:
More info → Run anyway. The .sha256 beside the zip lets you check the file
is the one that was built. Why it is unsigned, and what fixing it
costs.


New in 1.2.0

Sleeping no longer means unplugging it

1.1.0 slept with esp_deep_sleep_start() and woke on a GPIO. Two things were
wrong with that, and both were found on hardware.

The wake pin was GPIO 0, a strapping pin. Deep-sleep wake is a reset, and
the ESP32 reads GPIO 0 at reset to decide whether to run your sketch or the
serial bootloader — so holding down the wake button did the one thing that
guarantees it will not come back. Pressing harder made it worse.

The second is that deep sleep is a reset even when it works. Waking meant
rejoining WiFi, re-syncing NTP and re-polling: several seconds of a blank
screen before anything appeared, and the page you were on was gone.

There is no deep sleep now. Standby turns off the panel and the backlight,
drops the poll to once a minute, and leaves RAM and both tasks alive. Pressing
the button brings back the same page with the same values, immediately. The
cost is honest: standby draws more than deep sleep would. For a board on a desk
with a cable, resuming where you left off is worth more than the microamps.

A clock, with the date and the right time zone

The last release synced NTP and never showed the time. There is a clock page
now: a large 12-hour time with an AM/PM marker, the day and date under it, and
a seconds ring around the edge.

The time zone is a setting, in minutes, so the places that are not on a whole
hour work — Dhaka at UTC+6, Kathmandu at +5:45, Adelaide at +9:30. The label
beside it is yours to type.

There were briefly two clock faces, one of which showed only the time. A screen
in a swipe carousel that shows less than the one before it is a screen you
swipe past, so it is gone.

It says when a machine has stopped reporting

A dashboard that shows the last known numbers forever is worse than a blank
one: a dead machine looks like a healthy one whose figures happen not to be
moving. That is the failure this project exists to catch, so it was the wrong
one to have.

Each machine now carries the age of its reading, and after five polls bring
nothing newer it is drawn as OFFLINE with how long it has been silent.

The age is computed from the relay's own timestamp plus the time since the
display last managed a successful fetch. Without that second term a display
that has lost WiFi shows every machine as permanently fresh — which is exactly
backwards, because at that moment it knows nothing at all.

The freshness redraw runs on its own one-second timer rather than inside the
"new data arrived" path. In 1.1.0 the ages only advanced when new data came in,
and no new data was the thing being reported.

The battery reading stopped lying

It reported 100 % on the charger and 20 % a second after unplugging. The
charging threshold was 4.32 V, but a real charger holds the cell at about
4.2 V, so the condition never fired and the charger's own voltage was being
shown as state of charge.

The threshold is 4.15 V with hysteresis, readings are smoothed across samples,
and while something external is holding the cell up the percentage is
suppressed rather than invented — because on this board it cannot be known.

Honest limit, unchanged: the T-Display exposes no charge-status pin. A cell
being topped up at 3.9 V reads exactly like one discharging at 3.9 V.

Settings worth opening

The configuration page was one long form. It is now a proper page — grouped
sections, live validation, a WiFi scan that fills the field, and a dark theme
that matches the device.

New in it:

  • Five WiFi networks, tried in order. A board that travels between a desk
    and a bench no longer needs reflashing.
  • A new pairing code on demand, which is how you revoke one that has been
    shared or shown in a photograph.
  • Time zone offset and label.
  • A switch to turn the web UI off entirely. It is a listening socket with a
    password on it; if you do not use it, it should not be there.

It also works. In 1.1.0 the page answered 404 to everything, because every
server.on() was registered inside a task that had already returned.

Update checks, and commands from the desktop

The relay is talking to the device anyway, so it now carries the latest release
tag with the reply and the device raises a banner when it is behind. The
alternative — every device polling api.github.com — is a second host, a
second certificate authority in an image with no room, and a second request per
device per interval against the budget that is the actual limit here.

The tray app checks for its own updates on a six-hour floor, and can send the
device a short, closed list of commands: reboot, standby, wake, refresh,
identify (flashes the screen, so you can tell two boards apart), jump to a
page, and set the backlight.

A command is left at the relay and collected on the next poll, so there is
still nothing listening anywhere. It is delivered at most once and dropped
after five minutes. Nothing in that vocabulary changes configuration and
everything in it is undone by pressing a button on the device — configuration
is what the settings page is for, behind its own password.

Packaging and CI

  • peekesp_1.2.0_all.deb — sudo apt install ./peekesp_1.2.0_all.deb.
    Built with dpkg-deb alone, no container and no sponsor.
  • packaging/aur/PKGBUILD for Arch.
  • The release is built by CI from the tag: the Linux package is installed
    and its files checked, the Windows agent is run and its JSON parsed, the tag
    is checked against the VERSION file, and every asset is named rather than
    counted. It stops at a draft — publishing is a person clicking publish.
  • The firmware compiles on every push, under --warnings all, with a size
    ceiling and a check that the committed image is not older than the sketch.

A landing page

shouravx.github.io/PeekESP — photographs
of the real device, what it does, and how to flash it.


What's in the box

Firmware — LVGL dashboard on a LilyGO TTGO T-Display. Two arcs, a bar, a
temperature and throughput panel, a clock and a power page. Values sweep to new
readings over 500 ms rather than snapping. Swiping between machines slides the
whole dashboard body as one object, with values swapped at the midpoint while
nothing is visible.

Cloudflare Worker relay — the hosts push, the device polls, both only ever
dial out. Three modes on one deployment: paired (no secrets at all), private
(two tokens), shared (named streams). 86 automated tests.

Agents — Linux and Windows, both standard-library only. The Windows build
adds a tray app with a settings window and a headless peek-agent.exe.

Prebuilt firmware — firmware/PeekESP-merged.bin. Flashing needs no
Arduino IDE, no ESP32 core and no libraries; only esptool.


Architecture

Two pinned FreeRTOS tasks sharing nothing but a mutex-guarded struct. Core 0
does WiFi, NTP, the blocking HTTPS request and the battery ADC. Core 1 runs
lv_timer_handler() and never opens a socket, so a slow link cannot drop a
frame. The ADC moved off the render path in this release: sampling it inside
the LVGL loop meant a battery reading and an animation frame competing for the
same core.

The pairing derivation is the same three lines in four languages:

stream = SHA-256("peek-stream:" + CODE)  first 16 hex
push   = SHA-256("peek-push:"   + CODE)  first 48 hex
read   = SHA-256("peek-read:"   + CODE)  first 48 hex

C++ on the device, Python in the Windows app, Python again in the Linux agent,
JavaScript in the Worker — all pinned against vectors generated independently
by openssl. A drift between any two would mean a machine pushing to a stream
the device never reads, with every request still looking perfectly valid from
both ends.


Verified

Firmware compiles clean under --warnings all, 43 % of a 3 MB partition
Worker 86 unit tests, plus live checks against the real deployment
Multi-device three machines under one code, proven live end to end
Agents Windows snapshot checked against PowerShell; Linux run in CI
Packaging the .deb is installed and inspected in CI; manifests pass winget validate

Not verified

The device-side work in this release has not run on hardware. Standby, the
clock, the corrected battery reading, the redesigned settings page, the
multi-network join and the commands all compile; that is what can be said.

The charging threshold ...

Read more

PeekESP v1.1.0

Choose a tag to compare

@github-actions github-actions released this 04 Sep 16:09

PeekESP v1.1.0

A physical system-metrics dashboard. An ESP32 with a 1.14" display shows live
CPU, RAM, storage, temperature and network throughput for machines anywhere on
the internet — no port forward, no VPN, no account.

By shouravx · MIT


New in 1.1.0

One code, several machines

A pairing code identifies you, not a machine. Run the agent on a Windows box,
a Mac and a DietPi with the same code and all three appear on the display; the
left button swipes between them. Six per code.

Before this they overwrote a single slot in turn, which on the display looked
exactly like one flapping agent — the worst possible failure for a monitor,
because it is indistinguishable from a real problem with the machine you were
watching.

Swiping costs nothing. One poll already carried every machine, so a display
makes the same number of requests whether it shows one or six.

Install on Linux in one line

curl -fsSL https://raw.githubusercontent.com/shouravx/PeekESP/main/dietpi/install.sh | sudo sh

Asks for the pairing code, derives the relay URL, stream and push token
locally, installs a hardened systemd service, and waits to confirm the
machine is actually pushing
before saying it worked. A rejected push says so,
and says the usual cause is the wrong code.

Then everything is managed with a peekesp command — status, logs -f,
test, pair, set interval 15, update, uninstall. The unit passes no
flags at all; settings live in a config file, so changing the poll interval
rewrites one line rather than regenerating a unit.

peekesp status answers the question that actually matters, which is not
whether a service is running: a rejected push leaves the unit perfectly active
while nothing reaches the display.

Storage is every disk

It reported the system drive only. A machine with a full 240 GB SSD and two
mostly empty 1 TB disks read 95 % when it was 57 % full — the honest number was
on the drives nobody asked about. Both agents now sum every real disk, and the
device shows STORAGE 906G FREE beside the bar.

Temperature on Windows

This had never worked, and not for the reason previously stated. The WMI
fallback referenced subprocess in a module that never imported it, so every
attempt raised NameError into a bare except Exception and came back as
"this machine has no temperature sensor".

With that fixed there is a source that needs nothing installed and no
administrator: the thermal-zone performance counter. It reports an ACPI zone
rather than the CPU die, so it reads cooler than LibreHardwareMonitor would for
the same machine — which the docs now say plainly, because an unexplained 28 °C
on a busy laptop looks like a bug. LibreHardwareMonitor still comes first when
it is running.

Battery, and a power screen

The last page of the swipe carousel shows the board's own cell — charge,
voltage, and whether something is holding it up — and underneath it, the
battery of the machine you were just looking at.

It does not appear on its own. An earlier build raised it whenever the charge
state changed, which on a board resting near the threshold meant every couple
of seconds, over the top of whatever you were reading. There is hysteresis now.

Honest limit: the T-Display exposes no charge-status pin, so state is inferred
from voltage. A cell being topped up at 3.9 V reads exactly like one
discharging at 3.9 V.

The board can be woken again

Holding the right button sleeps it. GPIO 0 cannot be the wake pin — it is a
strapping pin, and deep-sleep wake is a reset, so held low across it the ESP32
comes up in the serial bootloader instead of running the sketch. The symptom is
a board that sleeps and will not come back, and pressing harder or longer makes
it worse because holding it low is the trigger. Wake is GPIO 35 now, so the
button that sleeps it is the one that wakes it.

Windows packaging

package.py builds, zips both executables, writes the SHA-256 beside it and
generates winget manifests with the hash already in them, cross-checked against
the archive. The build passes --noupx, because UPX-packed binaries are one of
the strongest heuristics antivirus engines have.

The executables are unsigned, so SmartScreen warns the first time. More
info → Run anyway.
SIGNING.md explains what a
self-signed certificate does not fix — it is trusted by exactly one machine —
and what the real options cost.


What's in the box

Firmware — LVGL dashboard on a LilyGO TTGO T-Display. Two arcs, a bar, a
temperature and throughput panel, and a power page. Values sweep to new
readings over 500 ms rather than snapping. Swiping between machines slides the
whole dashboard body as one object, with values swapped at the midpoint while
nothing is visible.

Cloudflare Worker relay — the hosts push, the device polls, both only ever
dial out. Three modes on one deployment: paired (no secrets at all), private
(two tokens), shared (named streams). 74 automated tests plus 17 live checks
against the real deployment.

Agents — Linux and Windows, both standard-library only. The Windows build
adds a tray app with a settings window and a headless peek-agent.exe.

Prebuilt firmware — firmware/PeekESP-merged.bin. Flashing needs no
Arduino IDE, no ESP32 core and no libraries; only esptool.


Architecture

Two pinned FreeRTOS tasks sharing nothing but a mutex-guarded struct. Core 0
does WiFi, NTP and the blocking HTTPS request. Core 1 runs lv_timer_handler()
and never opens a socket, so a slow link cannot drop a frame.

The pairing derivation is the same three lines in four languages:

stream = SHA-256("peek-stream:" + CODE)  first 16 hex
push   = SHA-256("peek-push:"   + CODE)  first 48 hex
read   = SHA-256("peek-read:"   + CODE)  first 48 hex

C++ on the device, Python in the Windows app, Python again in the Linux agent,
JavaScript in the Worker — all pinned against vectors generated independently
by openssl. A drift between any two would mean a machine pushing to a stream
the device never reads, with every request still looking perfectly valid from
both ends.


Verified

Firmware compiles clean under --warnings all, 39 % of a 3 MB partition
Worker 74 unit tests, plus 17 live checks against the real deployment
Multi-device three machines under one code, proven live end to end
Agents Windows snapshot checked against PowerShell; Linux tested with stubbed /proc
Packaging manifests pass winget validate; zip cross-checked, negative-tested

Not verified

None of the device-side work in this release has run on hardware. It
compiles; that is all that can be said. The swipe, the power page, deep sleep
and the wake button get their first run when you flash it.

The charging threshold is a documented guess — 4.32 V — because there is no
charge-status pin to check it against. The voltage is on screen next to the
percentage so it can be corrected against a real board.

winget install --manifest was not run: it needs administrator rights. The
winget-pkgs pipeline installs on a clean VM, which is the real test.


Known limits

The free tier is a request budget, not a device count. At a 5-second
interval each agent costs 17,280 requests/day and each display 17,280, against
Cloudflare's 100,000. A display with two machines is ~52k and fits; four is
~86k and is close. peekesp set interval 15 divides it by three.

Temperature on Windows is an ACPI zone unless LibreHardwareMonitor is
running. Real reading, real place, not the CPU die.

2.4 GHz only, WPA2. An ESP32 has no 5 GHz radio.

Re-flashing keeps the pairing code. It lives in NVS and survives a firmware
update. python tools/flash.py --erase gets a new one.

An ESP32 cannot join a Tailscale network. Tailscale is WireGuard plus a
control plane, and none of that has an embedded client. The relay exists
because of this.


Thanks

LVGL, TFT_eSPI,
ArduinoJson, and LilyGO for the board.

PeekESP v1.0.0

Choose a tag to compare

@shouravx shouravx released this 04 Sep 09:18

A physical system-metrics dashboard. An ESP32 with a 1.14" display shows live
CPU, RAM, storage, temperature and network throughput for a machine anywhere on
the internet — no port forward, no VPN, no account.

By shouravx · MIT


Setup is one code

python quickstart.py

Builds the Windows app and flashes the board. Then the device shows a code:

K7M2-P4QX-9R

Type it into the app. Done — the relay URL, the stream and both tokens are
derived from that code on both sides, and the relay never sees it.

Nothing else is typed. No account, no token to copy, no port to forward.


What's in the box

Firmware — LVGL dashboard on a LilyGO TTGO T-Display. Two arcs, a bar, a
temperature and throughput panel. Values sweep to new readings over 500 ms
rather than snapping; the gauges run on a 0–1000 range so a 3 % change still
resolves into a visible sweep instead of a staircase. Boot logo, on-device WiFi
setup portal with a QR code to join it, and a status line that names what it is
doing.

Cloudflare Worker relay — the host pushes, the device polls, both only ever
dial out. Three modes on one deployment: paired (no secrets at all), private
(two tokens), shared (named streams for several people). 50 automated tests.

Agents — Linux (dietpi/peek-agent.py) and Windows, both standard-library
only. The Windows build adds a tray app with a settings window, and a headless
peek-agent.exe for running as a service.

Prebuilt firmware — firmware/PeekESP-merged.bin, 1.25 MB. Flashing needs
no Arduino IDE, no ESP32 core and no libraries; only esptool, about 3 MB.


Architecture

Two pinned FreeRTOS tasks sharing nothing but a mutex-guarded struct. Core 0
does WiFi, NTP and the blocking HTTPS request. Core 1 runs lv_timer_handler()
and never opens a socket, so a slow link cannot drop a frame.

The pairing derivation is the same three lines in three languages:

stream = SHA-256("peek-stream:" + CODE)  first 16 hex
push   = SHA-256("peek-push:"   + CODE)  first 48 hex
read   = SHA-256("peek-read:"   + CODE)  first 48 hex

C++ on the device, Python in the app, JavaScript in the tests — pinned against
vectors generated independently by openssl, because a drift between any two
would mean the PC and the device derive different streams and never meet, with
every request still looking perfectly valid.

Paired streams authenticate by trust-on-first-use. The guarantee is precisely
"once a role is claimed, only that token works" — not "only the right token
was ever possible"
. Squatting an unclaimed stream requires the 16-hex id,
which requires the code.


Verified

Firmware compiles clean, no warnings, 39 % of a 3 MB partition
Worker 50 unit tests, plus 9 live checks against the real deployment
Pairing full round-trip proven end to end on the deployed Worker
Windows app both executables run; settings window driven by smoke tests
CI deploys the Worker on merge, proven with a real change

Not verified

The display was brought up on real hardware late, and several things there have
been exercised only briefly or not at all: the boot splash, the QR setup portal,
the on-device SHA-256 derivation, and the TLS handshake under poor signal. The
device-side code compiles and the parts that have run, worked — but this is a
1.0.0, not a soak-tested release.


Known limits

Two paired devices per Worker. Cloudflare's free tier allows 100,000
requests/day; a paired set costs about 35,000. A third pair exceeds it for
everyone on that deployment. Raising the poll interval from 5 s to 15 s gets
you six.

No temperature on Windows. Windows exposes none without a vendor driver, so
the agent reports -1 and the display shows -- rather than a confident wrong
number.

2.4 GHz only, WPA2. An ESP32 has no 5 GHz radio. A network that does not
appear in the scan list is usually 5 GHz-only or WPA3-only rather than a fault.

Re-flashing keeps the pairing code. It lives in NVS and survives a firmware
update, which is correct and surprising the first time you want a new one. Use
python tools/flash.py --erase.

An ESP32 cannot join a Tailscale network. Tailscale is WireGuard plus a
control plane — node registration, rotating keys, DERP relays — and none of that
has an embedded client. The relay exists because of this.


Notable fixes during development

Several of these were only findable by running the thing, and are worth
recording because each looked like something else:

  • Watchdog reboot loop. Core 0 is the core allowed to block, so the idle-task
    watchdog on it was always contradictory. WebServer::handleClient() waits up
    to 5 s for a client that never finishes its request — exactly what captive
    portal probes do — and that starved IDLE0 at precisely the watchdog timeout.
  • SSIDs were injected into the setup page unescaped. An SSID is chosen by
    whoever owns the access point; a neighbour naming theirs "><script>… had
    script execution on the page where the WiFi password is typed.
  • Cloudflare bans the default Python-urllib user agent with error 1010,
    before the Worker runs. Every push would have failed with an opaque 403 that
    looks exactly like an auth problem.
  • A build that succeeded and produced a broken binary. Excluding email
    from PyInstaller built cleanly and died at launch, because http.server
    imports it.
  • A build that failed and left the previous binary in place, so the test
    afterwards passed against stale code.
  • LV_USE_SPINNER and LV_USE_QRCODE default to 0 in LVGL and fail at
    link time, not compile time.

Thanks

LVGL, TFT_eSPI,
ArduinoJson, and LilyGO for the board.