ScreenSquire is digital-signage software for Raspberry Pi with the Pi Signage Control Windows app. Each Display Pi is the source of truth for its media, playlist, tournament board, and timer, so the TV keeps running when the Controller laptop is off.
agent/ FastAPI service and kiosk pages that run on each Display Pi
windows-app/ .NET 8 WPF control app for the shared store Windows login
signage-core/ Testable controller, pairing, signing, and discovery logic
pi-setup/ Installer and USB provisioning scripts for a real Pi
Each Display Pi has a stable device ID and a unique 8-digit Recovery PIN. The PIN is printed once during USB provisioning and belongs on the bottom of that Pi's case. A Display Pi trusts exactly one Controller laptop at a time.
- Pairing requires the correct Recovery PIN and a USB connection at
10.55.0.1; five bad PIN attempts block pairing for 60 seconds. - Pairing or Ownership recovery creates a new per-Pi controller secret and invalidates the previous laptop immediately.
- The Windows app stores controller secrets encrypted with Windows DPAPI for the current Windows user. Daily use on the shared store login is passwordless after Store onboarding.
- Every state-changing request is HMAC-SHA256 signed over the controller ID, durable monotonic counter, HTTP method, exact path/query, and entity hash. Replayed counters are rejected.
- Pairing is the only unsigned state change. It is USB-only and PIN-gated. Prepare for delivery is both USB-only and signed.
- Status, playlists, dashboard state, kiosk pages, media display, and other read-only traffic remain public so the TV can render without a login. HTTP traffic is not encrypted; use only the intended USB/store LAN.
- There is no legacy insecure control mode.
Requires the .NET 8 SDK (or Visual Studio 2022) for development.
cd windows-app
dotnet run
# Ship one self-contained executable; the client does not need .NET installed.
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
# bin\Release\net8.0-windows\win-x64\publish\PiSignageControl.exeThe publish output is two files: PiSignageControl.exe and vncviewer.exe
beside it. The app resolves the viewer from its own directory, so keep them
together. See THIRD-PARTY.md for the viewer's GPLv2 terms.
A local build reports version 0.0.0, which the self-updater treats as "a
developer built this, leave it alone". Only executables built by the release
workflow carry a real version and update themselves. See Releases below.
The executable embeds the complete approved agent bundle:
main.py, trust.py, control_auth.py, delivery_reset.py, and static/**.
When a paired Pi is out of date, Update Pi software sends that authenticated
bundle and confirms the new version after the agent restarts.
For VM/WSL development, enter its address manually (for example,
localhost:8080) because mDNS discovery generally does not cross WSL2 NAT.
Mutation controls remain unavailable until the development agent has been
paired; production pairing is intentionally restricted to the USB subnet.
Use a disposable data directory so development cannot alter real Pi state:
cd agent
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
export SIGNAGE_DATA="$(mktemp -d)"
python trust.py init --data-dir "$SIGNAGE_DATA"
python main.pyRecord the printed RECOVERY_PIN if the pairing flow will be tested. Open
http://localhost:8080/ for the TV view. Public GET endpoints can be inspected
directly; use the Windows app for signed mutations.
These steps are performed on every new Pi before shipment:
- Copy this repository to
~/pi-signageand runcd ~/pi-signage/pi-setup && bash install.sh. - Run
bash provision-usb.sh. Record the one-timeRECOVERY_PINoutput and put that exact 8-digit PIN on a durable bottom-case sticker. Re-running the script does not reveal or replace the existing PIN. - Reboot, connect the Pi to the builder laptop with a USB-C data cable, open Set up a new Pi, enter the PIN, and pair it.
- Join the builder's Wi-Fi through the wizard. Confirm the saved Pi has the same stable device identity over USB and Wi-Fi.
- Over Wi-Fi, test media upload/playback, playlists, tournament boards, timer start/pause/extend, kiosk control, and an agent update.
- Reconnect USB, select the saved Pi, and run Prepare for delivery. Read
the warning, confirm the correct physical Pi, and type
PREPARE. - Confirm the Pi disappears from the builder device list and that the old builder credential can no longer send Wi-Fi or other mutations.
Prepare for delivery removes media, playlist contents, dashboard/tournament state, timer state, temporary name, every NetworkManager Wi-Fi profile, and controller trust. It preserves installed software, USB provisioning, the stable device ID, and the Recovery PIN verifier. If Pi-side reset fails, builder credentials are retained so the reset can be repaired and retried. If the Pi reset succeeds but local cleanup reports residue, the Pi is still delivery ready; remove the reported local residue before reusing the builder laptop.
Use the store's shared Windows login:
- Install or open
PiSignageControl.exe. - Connect the Display Pi with a USB-C data cable and click Set up a new Pi.
- Enter the 8-digit PIN from the bottom label.
- Select or type the store Wi-Fi name, enter its password, and click Connect to WiFi.
- Confirm the success message shows a Wi-Fi address, unplug USB, and connect to the saved Pi. Verify it is the same stable device identity.
After this one-time flow, normal store operations are passwordless on that Windows login. Do not pair the Pi from a second laptop merely to provide convenient access: pairing transfers ownership and revokes the first laptop.
If the Controller laptop or its Windows profile is replaced:
- Install/open the app on the replacement laptop or profile.
- Connect one Display Pi at a time by USB data cable.
- Open Set up a new Pi, enter that Pi's bottom-label PIN and the store Wi-Fi details.
- Confirm the warning that pairing the replacement will remove the previous laptop's access.
- Confirm the old laptop receives HTTP 401 for a fresh signed mutation and the replacement laptop succeeds.
- Repeat for every Display Pi; each has its own PIN and controller secret.
The PIN does not grant remote ownership by itself. Physical USB access is also required.
Use attended Windows Quick Assist for normal remote help. The store employee starts Quick Assist, enters the builder's support code, and explicitly approves screen sharing/control for that session. End Quick Assist when work is done. Do not install unattended remote-access software, copy controller secrets, or pair the builder laptop to a production Pi.
| Access | Method | Path | Purpose |
|---|---|---|---|
| Public read/display | GET | /, /dashboard, /media/* |
TV content |
| Public read | GET | /api/status, /api/playlist, /api/media, /api/dashboard, /api/wifi/status, /api/kiosk |
Current state |
| USB read | GET | /api/pair/status |
Stable identity and current pairing state |
| USB + PIN bootstrap | POST | /api/pair |
Establish or replace the Controller laptop |
| Signed control | POST/PUT/DELETE | /api/dashboard, /api/kiosk, /api/name, /api/playlist, /api/media*, /api/show-now, /api/next, /api/update, /api/youtube/control, /api/spotify/control |
Change Display Pi state |
| USB + signed control | POST | /api/wifi, /api/prepare-delivery |
Set store Wi-Fi or remove ownership only over the physical USB link |
The agent advertises _pisign._tcp.local over mDNS with its name and port. The
app verifies the stable device ID returned by /api/status; names, IP addresses,
DHCP changes, and non-default ports do not define ownership.
Software reaches a store in two hops, and only the first one involves GitHub:
tag vYYYY.MM.DD.N ──► GitHub Release ──► Controller laptop ──► Display Pis
(.github/workflows/ (updates itself) (signed push from
release.yml) the paired laptop)
Display Pis never contact GitHub. A Pi trusts exactly one Controller laptop
and accepts software only over the HMAC-signed /api/update channel, so putting
an internet download in that path would trade a verified signature for plain
transport trust. The laptop stays the only thing that talks to the internet.
To cut a release, push a tag matching vYYYY.MM.DD.N — zero-padded, the same
CalVer scheme as the agent's AGENT_VERSION. The workflow re-runs the full test
suite (a tag can be cut from any commit), publishes the self-contained exe with
the version injected from the tag, verifies that both executables are present
and that the embedded FileVersion matches, and attaches three assets:
PiSignageControl.exe, vncviewer.exe, and SHA256SUMS.txt.
Use the workflow_dispatch trigger with dry_run to exercise the whole build
and verification path without publishing anything.
The client does nothing. Within six hours the laptop notices the release,
downloads it, checks it against SHA256SUMS.txt, confirms the new build's
embedded version, and starts it once with --selftest to prove it runs at all.
Only then is it staged, and it is swapped in the next time the app is opened. A
banner offers a Restart now button for anyone who does not want to wait, but
nothing is ever required. Every failure is silent and simply retried later.
Install the app somewhere the signed-in user can write —
%LocalAppData%\Programs\PiSignage\ is the intended location. From
Program Files it cannot replace its own executable, and it will say so instead
of downloading 150 MB to find out.
Display Pis are brought up to date separately, during the nightly quiet window described under Agent updates.
Rolling back a bad app build: the previous executable is kept as
PiSignageControl.exe.old next to the current one until the new build has
started successfully once. Renaming it back restores the previous version.
SHA256SUMS.txt comes from the same origin as the executables, so it protects
against corrupted or truncated downloads — not against a compromised origin.
SECURITY.md states the full trust boundary.
Display Pis update themselves overnight. While the control app is open it checks every 15 minutes whether the local time is between 03:00 and 05:00, and if so pushes the bundled agent to every saved Pi that is reachable and out of date. Nobody has to click anything, and no TV goes dark during trading hours.
A Pi that is switched off at 03:00 does not end the night: the sweep is only recorded as finished once nothing is left worth retrying, so a Pi powered on at 03:40 is still caught before the window shuts. One that stays off all night waits for the next window. Update Pi software in Device setup remains available as an override for when it cannot wait, and the result of the last automatic sweep is shown there — that is the only place it is ever reported, since the sweep runs with nobody watching.
The window opens at 03:00 so a push completes before the Pi's own
pisignage-kiosk-restart.timer fires at 04:00; otherwise the TV would blink
twice. A Pi that is saved but has never been paired with this laptop is reported
and skipped rather than retried, because retrying cannot create a credential.
For developers, deploy-agent.ps1 sends the same complete signed bundle used by
the app. It reads saved device identities and DPAPI-protected credentials from
the current Windows user's Pi Signage data.
.\deploy-agent.ps1
.\deploy-agent.ps1 -Hosts 192.168.0.58,pisignage2.local
.\deploy-agent.ps1 -WhatIfTargets without a saved pairing credential are rejected before bundling or
network activity. Updates are limited to 20 MB compressed and uncompressed,
bind the uploaded bytes to the signature, reject unexpected/archive-traversal
paths, compile every Python module before installation, and replace the managed
bundle transactionally with rollback on installation failure. Changes to
requirements.txt, Pi setup scripts, systemd units, or the OS still require a
builder-managed deployment.
agent\.venv\Scripts\python.exe -m pytest agent -q
dotnet test PiSignage.slnx
dotnet build PiSignage.slnx -c Release
dotnet list PiSignage.slnx package --vulnerable --include-transitiveThe Python suite is configured to use test data outside agent/data. Treat
real-Pi testing as a release gate in addition to these automated checks.
Automated release evidence recorded on 2026-07-25:
- Python: 127 passed; the runtime
agent/data/dashboard.jsonhash and timestamp were unchanged. The only warning was the accepted deprecation from the pinned Starlette test-client stack. - .NET: 123 passed.
- Release build: succeeded with zero warnings and zero errors.
- NuGet advisory scan: no known vulnerable direct or transitive packages in
signage-core,PiSignageControl, orsignage-core.Testsfrom the configured package sources. The test project uses xUnit 2.9.3 to avoid the vulnerable legacyNETStandard.Librarydependency chain. - Real-Pi builder acceptance and store-laptop simulation: pending hardware execution.
Do not mark these items passed without a reachable test Pi and observed results. Record Pi device ID, agent version, test date, operator, laptop/profile, and evidence links with the release record.
Builder acceptance:
- Initialize the Pi; photograph the durable label and verify its PIN matches the one-time provisioning output.
- Pair the builder laptop over USB and record the stable device ID.
- Join builder Wi-Fi and verify
/api/statusreports that same device ID over the reported Wi-Fi address. - Over Wi-Fi: upload one image and one video, save/play a playlist, capture a tournament board, start/pause/extend the timer, toggle kiosk off/on, and install an authenticated agent update.
- Send an unsigned mutation (for example
POST /api/kiosk) and record HTTP 401. - Replay the exact headers and bytes of one accepted signed request and record HTTP 409.
- On USB, submit five wrong 8-digit PINs and record HTTP 429 with a
Retry-Aftervalue no greater than 60 seconds; verify the correct PIN is blocked until that interval expires. - Run Prepare for delivery over USB and verify media, playlist, dashboard, timer, temporary name, all Wi-Fi profiles, and builder control are gone.
- Verify installed software, USB provisioning, stable device ID, and the correct Recovery PIN still work.
Store-laptop simulation (second Windows profile or second laptop):
- Pair the delivery-ready Pi over USB using its label PIN.
- Join a different Wi-Fi network and verify the stable device ID is unchanged.
- Close and reopen the app; verify all daily operations work without a password prompt.
- Verify a fresh builder-laptop mutation receives HTTP 401.
- Pair once more from a third/replacement profile after accepting the replacement warning; verify the previous store profile receives HTTP 401 and the replacement succeeds.
The Spotify window in the app saves links (tracks, albums, playlists, podcast episodes, shows, artists) and plays them on one TV through Spotify's embedded player. The player renders as a centered card, with pause/resume and ±10 s seek from the app. There is no volume control — Spotify's embed API doesn't offer one; use the TV or amp remote.
- Without a Spotify login on the Pi, tracks play 30-second previews. To get full tracks, open Remote control once, sign into open.spotify.com in the kiosk browser, and close the session — the kiosk profile is persistent, so the login survives restarts.
- Full-track playback also needs Widevine DRM in the Pi's Chromium build. If it's missing, playback falls back to previews even when signed in.
- When a track or playlist finishes, the TV returns to its normal rotation within about 12 seconds. Artist pages have no defined end and stay up until "Back to playlist" is clicked.
- URL items use an iframe. Sites that deny framing will not display.
- Videos are muted to satisfy browser autoplay rules.
- USB NCM requires Windows 11's native NCM driver and a data-capable cable. RNDIS and serial fallbacks are not implemented.
- Controller authentication provides integrity, ownership, and replay protection, not transport confidentiality.