Remote Pi Client
Audience: Org Admin, Deployment Administrator, Developer, Support Related: Overview · Eink Rendering · Raspberry Pi · Overview · Displays
Remote Pi Client mode links a Raspberry Pi display client to a Blackcap server. The client polls for state and preview/transport artifacts, validates the server-prepared content, atomically stores the last known-good frame, pushes it to the attached panel, and acknowledges physical display completion.
Client mode suspends normal server pushes to the local panel. Let’s Cook uses faster polling and a separate server-side watcher when enabled; deployments that disable the watcher can still start a session but should warn that remote updates will be delayed.
Client-mode behavior
A Raspberry Pi can remain a full Blackcap server while temporarily allowing a different Blackcap server—normally GCP—to control its attached Waveshare 13.3-inch Type K e-ink panel.
This is a display content mode, not a device runtime mode. The Pi application, SQLite database, Admin UI, scheduled jobs, and server services remain running.
Pi server remains online
├─ Local content (Menu, Recipe, Meal Plan, Let’s Cook)
│ Local Blackcap owns the attached panel
└─ Client content
Paired remote Blackcap owns the attached panel
Local hardware pushes are blocked
Supported panel
The first supported client panel preset is:
- Waveshare 13.3-inch e-Paper HAT (K)
- 960 × 680
- four-level grayscale: white, black, and two gray levels
- full-refresh remote preview updates
Both attached local_eink displays and newly created remote_pi_client targets default to this grayscale profile.
Attached display restrictions
Blackcap permits only one active attached local e-ink display. It can be created only when:
- Blackcap identifies the host as a Raspberry Pi;
- the active organization is the Default organization; and
- no other active
local_einkdisplay exists.
The UI hides the type when it is unavailable, the display service independently enforces the rule, and migration 200 adds database uniqueness protection.
Assigning Client content
Use Tools → Displays → Assign Content → Client for the attached local e-ink display.
Client can only be the immediate current content assignment. It cannot be scheduled or configured as default content. Entering Client:
- ends any active Let’s Cook session on the display;
- ends the current local content state rather than pausing it;
- blocks all local server hardware writes;
- keeps local schedules stored but marks them Suspended — display is in Client mode;
- removes suspended schedules from the compact Upcoming summary;
- leaves the last physical e-ink frame unchanged until a valid remote preview is displayed.
Scheduled windows that expire while Client is active are completed and are not replayed later. Future or still-applicable schedules remain stored. Assigning normal content again immediately returns hardware ownership to the local server and pushes the newly selected content.
Linking the attached display
After assigning Client, open Display Details and enter:
- the remote Blackcap URL, defaulting to
https://blackcap.microwavepie.com; and - the eight-character pairing code generated for a Remote Raspberry Pi Client display on that server.
HTTPS is required outside development. Pairing returns a durable bc_client_... token, which Blackcap encrypts inside the local display settings. The UI never displays that token.
The link remains saved when the display returns to local content. Assigning Client later reconnects immediately without another code. Relink keeps the existing working relationship until the new pairing succeeds. Unlink removes the local relationship and best-effort revokes the old token on the remote server; the display remains in Client until other content is assigned.
Only one remote relationship is active locally at a time.
Always-running client service
On a Pi with attached e-ink, tools/deploy_pi.sh installs and enables:
blackcap-client.service
The process remains running at boot. Every five seconds while inactive it checks the local attached display through Blackcap’s service/database layer:
- local content active → sleep;
- Client active but not linked → sleep;
- Client active and linked → poll the remote Blackcap server.
- Normal content uses the configured pull interval, 30 seconds by default.
- An active Let’s Cook assignment temporarily uses a 5-second pull interval; the normal interval returns automatically when the session ends and default content is restored.
No server service or cron job is disabled when Client is selected.
When a changed remote preview is available, the service:
- prefers the server-generated
blackcap-gray4-planes-v1transport artifact; - validates its dimensions, compressed payload, and SHA-256 checksum;
- falls back to the panel-ready PNG only when the transport is unavailable;
- calls
push_eink_image.py --client-owner --four-gray --gray4-transport ...while holding the normal display lock; - bulk-writes the two controller RAM planes and reports the physically displayed version through heartbeat metadata.
The server retains the normal PNG for Admin and receiver surfaces. A standalone client-only Pi does not download that PNG when the controller transport is available. A Pi that is also running the Blackcap server downloads both artifacts so its local Admin UI retains the preview image.
For a Waveshare 13.3-inch Type K client, display-targeted renderers produce exact 960 × 680 grayscale PNGs on the server. Let’s Cook creates its canvas directly in Pillow L mode and maps antialiased pixels to the four panel values (0, 128, 192, 255). The server then converts those pixels into the exact 0x24 and 0x26 controller bitplanes and publishes them atomically beside the PNG.
The client no longer decodes PNG pixels or packs the Waveshare two-bit buffer on the normal transport path. The hardware helper validates/decompresses the prepacked planes, initializes the controller, performs two bulk SPI writes, runs the full e-ink waveform, and puts the panel to sleep. The PNG conversion and local plane-packing paths remain as compatibility fallbacks.
The client owner flag is accepted only while the attached display is in Client. Local server pushes are accepted only while it is not in Client. This provides exclusive panel ownership without stopping the Pi server.
For active Let’s Cook sessions assigned to a remote_pi_client, each durable step, ingredient, render-mode, timer-state, or completion action regenerates the server preview immediately with no server-side debounce. blackcap_lets_cook_timer_refresh.service remains a two-second retry/race-recovery watcher rather than the normal delivery path. Attached local e-ink displays retain their separate roughly ten-second server-side coalescing behavior.
The client continues to coalesce physical panel work naturally: while the 23–24 second full waveform is running, later server actions replace the remote preview artifact. Immediately after a Let’s Cook physical refresh completes, the client checks /api/display-client/state again without waiting another five seconds. If a newer artifact arrived during the waveform, the client downloads and displays the latest image; otherwise it resumes the normal five-second active-session interval. This immediate post-refresh check is Let’s Cook-only.
The state API uses one artifact-backed generation timestamp and advertises separate PNG and panel-transport versions. The controller transport version is authoritative for capable clients, while the preview URL uses the matching PNG version for cache busting. This removes the earlier stale timestamp mismatch and still detects changes that occur within one database timestamp second.
See remote-pi-client.md for the measured timing breakdown and client/server processing boundary.
When Let’s Cook ends through Admin, Mobile, Kiosk, voice, or automatic timed completion, GCP assigns and renders the remote display’s configured default content. The next state response returns the normal pull interval.
If the remote server is unreachable, the link is missing, the preview is invalid, or the hardware update fails, Blackcap does not push an error or blank screen. E-ink retains the last successful frame and Display Details shows the latest diagnostic error.
Display Details diagnostics
The local attached display shows:
- local content/Client ownership state;
- linked or unlinked status;
- remote URL and remote display ID;
- client service status;
- last successful poll;
- last physical panel update;
- last error;
- the last remote preview downloaded locally;
- Link, Relink, and Unlink actions.
The remote remote_pi_client Display Details page also shows heartbeat metadata confirming the last preview physically displayed by the client.
The normal client status progression is:
Waiting for Link → Linked → Remote Displayed
↘ Client Error
A successful later poll or panel update clears an earlier transient Client Error. Last Poll and Physical Update intentionally include seconds because those values distinguish individual polling and hardware-update cycles. Normal polling is 30 seconds by default; active Let’s Cook polling is 5 seconds.
When the locally attached panel is known to be showing a downloaded remote preview, Home, Displays, and Display Details use that preview as the current image and overlay a 🌐 Remote badge on the image itself.
Display Busy means a local-server or remote-client process is actively writing to the physical panel. Client mode, waiting for a link, ordinary polling, and the existence of a stale lock file do not make the display busy.
Backup, restore, and diagnostic copies
Organization backups can contain encrypted or hashed display-client state. Restore behavior is explicit:
- Leave Preserve this installation’s existing display-client links cleared for another Pi/server, a copied environment, or a cross-environment restore. Restored client credentials are removed, transient client jobs are discarded, and a restored Client display returns to Waiting for Link.
- Select the option only for an in-place restore on the same Blackcap installation. Blackcap sanitizes the restored client state first, then reapplies that installation’s current live encrypted/hashed link state to matching restored display/client records. Stale credentials embedded in the backup are never activated directly.
- The same rule applies to SQLite platform restores because those backups replace the application database. PostgreSQL platform backups do not contain the database, so restoring their application files does not replace current display-client rows.
- Diagnostic clones and configuration copies strip token/credential fields and never receive a live attached-client credential.
Do not use a platform backup as a shortcut for cloning a linked Pi. Leave preservation cleared and relink the destination deliberately.
Remote client API
The paired client uses:
POST /api/display-client/pairGET /api/display-client/stateGET /api/display-client/previewPOST /api/display-client/heartbeatPOST /api/display-client/unlink
State responses no longer put the durable token in the preview URL; the client uses the bearer-token header for all protected requests.
Standalone client compatibility
tools/blackcap_client_service.py still supports a dedicated client installation using /etc/blackcap/client.json. Run the script without --local-attached for that topology; the repository systemd unit is intentionally configured for a Pi that also runs the Blackcap server and therefore includes --local-attached. In standalone mode the client also normalizes and physically displays changed previews by default. Set hardware_renderer to false only for diagnostics on a machine without the Waveshare panel.
Repository-local client credential filenames are ignored by Git, but /etc/blackcap/client.json with mode 0600 is the preferred standalone location.
Operations
Check the service with:
sudo systemctl status blackcap-client.service --no-pager -l
sudo journalctl -u blackcap-client.service -n 120 --no-pager
sudo journalctl -u blackcap-client.service -f
The monthly deep-clean task detects Client ownership and exits successfully without touching the panel. The Let’s Cook timer watcher and all normal render paths also honor Client ownership.
Dedicated client-only schedule profile
A separate client-only Pi can still install only its local e-ink maintenance schedule:
cd /home/pi/Blackcap-Pi
sudo env \
BLACKCAP_SCHEDULE_MODE=pi-client \
BLACKCAP_SCHEDULE_APP_DIR=/home/pi/Blackcap-Pi \
BLACKCAP_SCHEDULE_VENV_DIR=/home/pi/inky_env \
BLACKCAP_SCHEDULE_USER=pi \
BLACKCAP_ENABLE_EINK_DEEP_CLEAN=1 \
BLACKCAP_ENABLE_LETS_COOK_EINK_WATCHER=0 \
bash ./tools/install_blackcap_scheduled_operations.sh
The full Pi-server arrangement described above continues using the normal pi schedule profile; it does not switch schedules when the attached display enters Client content.
Troubleshooting
Service does not stay running
sudo systemctl status blackcap-client.service --no-pager -l
sudo journalctl -u blackcap-client.service -n 200 --no-pager
The attached-server unit must start tools/blackcap_client_service.py --local-attached. It does not require a command-line server URL because the URL and encrypted token come from the local display link.
Pairing succeeds but no preview downloads
- Confirm the attached display is currently assigned Client.
- Confirm the remote display has a generated preview.
- Check that the remote URL is reachable from the Pi and its TLS certificate matches the hostname.
- Confirm the remote client token has not been revoked.
- Check for
Downloaded remote display previewandDisplayed remote preview on attached e-inksuccess messages inblackcap-client.service; their absence means the state response did not advertise a new preview version or the client could not complete the download.
Preview downloads but the panel does not change
Check the client log for transport validation, PNG compatibility conversion, display-lock, SPI/GPIO, or Waveshare-driver errors. Successful messages include artifact=gray4_transport, download_prepare_seconds=..., physical_refresh_seconds=..., and a detailed E-ink stages line covering input decode, local plane packing, lock wait, driver import, module initialization, controller initialization, each SPI plane, waveform wait, panel sleep, helper total, and subprocess overhead. The last valid artifact remains on disk and the panel retains the last successful frame after a failed update.
Display Busy remains visible
The banner should exist only during an active physical write. Blackcap removes malformed, dead-process, and reused-PID lock files. Check both services if a legitimate writer is still active:
sudo journalctl -u inky_admin.service -n 120 --no-pager
sudo journalctl -u blackcap-client.service -n 120 --no-pager
Client token was revoked
The client reports a relink-required error. Generate a new pairing code on the remote display and use Relink on the Pi display. A failed relink leaves the old working relationship untouched; a revoked old token cannot be reused.
TLS or hostname errors
Production linking requires HTTPS. Use the hostname covered by the server certificate. Insecure HTTP is accepted only in Blackcap development mode.
Physical-panel deployment checks
After client-mode changes, verify these behaviors on the actual Pi:
- reboot while Client is active and confirm automatic reconnection;
- restart the remote server and confirm the panel retains its image, then reconnects;
- switch Client → local content → Client and confirm the saved link is reused;
- attempt a failed Relink and confirm the existing working link is retained;
- Unlink while Client remains active and confirm the physical image is unchanged;
- allow a local scheduled window to expire during Client and confirm it is not replayed;
- confirm an unchanged preview does not trigger another full panel refresh;
- confirm one changed remote preview produces exactly one four-level grayscale refresh.
Transport contract
Blackcap preserves a PNG preview for every display because Admin and receiver surfaces need a standard image. For a Waveshare 13.3-inch Type K target, Blackcap additionally creates a device-specific transport artifact after the PNG is finalized and before the new display generation is advertised. Remote clients download it; attached local e-ink paths consume the same artifact directly.
See eink-rendering.md for all local/remote content paths and atomic publication details.
Format
Encoding: blackcap-gray4-planes-v1
The artifact contains:
- an 8-byte magic/version value;
- 960 × 680 dimensions;
- the per-plane byte count;
- compressed payload length;
- SHA-256 of the uncompressed planes;
- zlib-compressed command 0x24 and command 0x26 RAM planes.
Each uncompressed plane is 81,600 bytes. The client validates the header, dimensions, lengths, decompression, and checksum before replacing its last known-good artifact.
Pixel mapping
The four server-rendered grayscale values map to the controller planes as follows:
| PNG gray | Meaning | 0x24 | 0x26 |
|---|---|---|---|
| 255 | White | 0 | 0 |
| 192 | Light gray | 1 | 0 |
| 128 | Dark gray | 0 | 1 |
| 0 | Black | 1 | 1 |
Pixels are packed MSB first, matching Waveshare's official Type K Python driver.
Client and local behavior
For structured Type K content, a standalone client normally downloads the controller-plane transport and skips PNG repacking. A Pi acting as both a Blackcap server and remote client may download both files and verify that the transport reconstructs to the exact same four-level image as the PNG before using the transport for hardware.
Photographic Menu content defaults to an exact one-bit PNG. No gray4 transport is published for that generation, and the client uses the normal black/white panel path. If the organization explicitly selects experimental native_four_gray, the client downloads the PNG and uses the installed Waveshare getbuffer_4Gray() / display_4Gray() path in automatic mode.
If a required artifact is absent or invalid, Blackcap retains the last valid panel artifact and frame after the failure.
Hardware push
Structured Meal Plan, Recipe, and Let’s Cook content uses Blackcap's optimized controller-plane transport in automatic mode. It sends the prepared 0x24 and 0x26 planes with the driver's bulk operation and does not perform a mandatory white pre-clear.
The environment override applies only to native four-gray content:
BLACKCAP_EINK_GRAY4_PUSH_MODE=auto # default; structured content bulk, experimental native Menu official
BLACKCAP_EINK_GRAY4_PUSH_MODE=bulk # diagnostic: force optimized gray4 transport
BLACKCAP_EINK_GRAY4_PUSH_MODE=official # diagnostic: force installed driver for all gray4 content
The official path is slower because the Waveshare Python method translates and writes the frame byte by byte. One-bit Menu modes ignore this override. Detailed stage timings, the imported driver module path, the four-gray LUT fingerprint, selected panel mode, and artifact comparison hashes are written to blackcap-client.service.
Type K Menu strategy
Photographic Menu content now defaults to the organization-scoped crisp_bw strategy. The server publishes an exact one-bit PNG, does not publish a gray4 transport for that generation, and the client uses the normal fast black/white panel path. perceptual_four_tone provides a patterned black/white alternative, while native_four_gray remains experimental. See eink-rendering.md. Structured Meal Plan, Recipe, and Let’s Cook gray4 updates retain the fast bulk transport without a mandatory white pre-clear.
Let’s Cook delivery timing
Observed behavior
The July 15 client log separates three different sources of delay:
- Initial Let’s Cook discovery — the client was still using its normal 30-second interval while the display showed meal-plan content. It polled at 11:32:36, the server artifact was generated at approximately 11:32:40, and the client did not pull Let’s Cook state until 11:33:12. A client cannot switch to the active-session five-second interval until it first discovers the session.
- Physical panel refresh — successful downloads generally completed in about one second, while full Waveshare Type K refreshes consistently consumed roughly 23–24 seconds.
- Post-refresh sleep — after a full Let’s Cook refresh completed, the previous loop waited the normal active-session interval before checking whether another preview had arrived. The log repeatedly shows the next state pull about five to six seconds after the physical refresh message.
The full e-ink waveform is therefore the dominant unavoidable delay. Server preview debounce and the extra post-refresh sleep were avoidable.
Updated delivery model
Remote Raspberry Pi Client
A durable Let’s Cook action now follows this path:
controller action
→ durable Let’s Cook state update
→ immediate server preview render (no server debounce)
→ native Type K `L`/gray4 artifact generation on the server
→ artifact-backed preview version
→ client poll/download
→ attached e-ink full refresh
→ immediate Let’s Cook-only state recheck
The two-second remote preview watcher remains active only for retry and concurrent-state recovery. It is no longer the normal preview-generation path.
Attached local e-ink owned by the server
The existing local debounce remains unchanged:
controller action
→ durable pending marker
→ roughly ten-second coalescing watcher
→ full e-ink refresh
This prevents rapid local actions from launching back-to-back hardware refreshes.
Display-targeted Let’s Cook rendering
Let’s Cook resolves the destination display profile before creating its canvas. For the supported Waveshare 13.3-inch Type K panel—both attached local_eink and remote_pi_client targets—the renderer starts directly with a 960 × 680 Pillow L canvas instead of creating an RGB image and converting it afterward. Hosted receivers and kiosks retain RGB rendering.
The native Type K artifact has:
- exact 960 × 680 resolution;
- Pillow mode
Lgrayscale; - only the four driver-supported values:
0,128,192, and255; - a normal PNG encoding ready for the client hardware helper.
Text antialiasing can create intermediate grayscale samples even on an L canvas, so the renderer performs one final L-to-four-native-level point mapping before atomically publishing the PNG. It does not perform RGB conversion or resizing. The compatibility finalizer remains only as a no-op validator for native Let’s Cook artifacts and as a safety fallback for older/other content paths.
A conforming client download no longer performs:
- EXIF transpose;
- RGB-to-grayscale conversion;
- Lanczos resize/crop;
- per-pixel four-level quantization;
- optimized PNG re-encoding;
- a second full PNG verification decode before the helper starts.
The client performs a lightweight PNG header/profile check, atomically installs the file, and invokes the hardware helper. A compatibility conversion remains available for older servers that do not advertise a panel-ready profile.
Server-prepared controller transport
The server now keeps two related artifacts for a remote Type K display:
- the normal PNG used by Admin and receiver surfaces;
- a compressed
blackcap-gray4-planes-v1file containing the already packed 0x24 and 0x26 controller RAM planes.
The plane mapping is equivalent to Waveshare's official getbuffer_4Gray() plus display_4Gray() implementation. The custom artifact includes dimensions, plane length, compressed length, and a SHA-256 checksum. It is validated before atomic publication on the server and again before atomic installation on the Pi.
A standalone client-only Pi downloads only the transport file. A Pi that also runs Blackcap downloads the PNG as well for its local Admin UI.
Bulk Waveshare transfer
Waveshare's Python display_4Gray() implementation translates the packed image into two 81,600-byte planes and calls send_data() once for every output byte. That results in 163,200 Python SPI calls and chip-select toggles. Blackcap now sends the exact same two planes using two send_data2() bulk writes.
The panel still requires:
- driver import and GPIO/SPI module initialization;
- Type K four-gray controller initialization and LUT loading;
- one bulk transfer for command 0x24;
- one bulk transfer for command 0x26;
- the physical four-gray waveform and busy wait;
- panel deep sleep and hardware cleanup.
The server has removed PNG decode and plane packing from the normal client path. The client helper retains local PNG-to-plane generation only as an older-server fallback.
The panel is still put to sleep after every full refresh. Waveshare recommends sleep or power-off after refresh to avoid leaving the panel in a high-voltage state. Keeping the controller awake is therefore not enabled until stage timing proves that the remaining initialization cost justifies a separate, carefully bounded design.
Detailed timing
Every hardware refresh emits an E-ink stages line with:
input_decode_seconds— transport decompression/checksum or PNG decode;plane_pack_seconds— zero for server transport, nonzero only for PNG fallback;lock_wait_seconds;driver_import_seconds;module_init_seconds— Waveshare EPD construction and GPIO/SPI setup;controller_init_seconds— reset, four-gray initialization, and LUT setup;spi_plane_24_seconds;spi_plane_26_seconds;waveform_seconds— the visible panel transition and busy wait;panel_sleep_seconds;helper_total_seconds;subprocess_overhead_secondsin the parent client log.
This separates the approximately five-second panel specification from Python process, initialization, transfer, and sleep time.
True five-second active polling cadence
The client now schedules the next Let’s Cook poll relative to the start of the previous poll. Request and state-processing time is subtracted from the sleep. A five-second setting therefore produces approximately five seconds between poll starts instead of five seconds plus request duration. The immediate post-refresh recheck remains unchanged.
Post-refresh recheck
After a physical refresh whose downloaded state was lets_cook, the client performs the next state request immediately rather than sleeping five seconds.
- If actions arrived during the 23–24 second waveform, the latest artifact is downloaded and displayed next.
- Multiple actions during the waveform are naturally coalesced into that latest artifact.
- If no newer artifact exists, the client resumes the five-second Let’s Cook interval.
- Non-Let’s-Cook content retains its normal configured polling behavior.
Remaining initial-session consideration
The first transition from normal content into Let’s Cook can still wait for the display’s normal client interval, which defaults to 30 seconds. Reducing that initial discovery window requires either configuring a shorter normal interval, polling every five seconds while Client content owns the panel, or adding a push/long-poll transport. This implementation does not broaden the normal-content polling policy; the immediate post-refresh check is Let’s Cook-only.
Diagnostic log fields
Successful client messages now include:
artifact=gray4_transport
server_advertised_panel_ready=true
download_prepare_seconds=<seconds>
physical_refresh_seconds=<seconds>
subprocess_overhead_seconds=<seconds>
The next line provides the detailed helper stage timing described above.
After a Let’s Cook physical refresh, the client also logs:
Let’s Cook e-ink refresh complete; checking the server again immediately.
These fields make server/download overhead distinguishable from the physical panel waveform.
First-frame commit behavior
Failure observed
The remote client correctly changed from current_meal_plan to lets_cook and switched to five-second polling, but every state response kept the prior meal-plan preview generation. Because the version never changed, the Pi correctly did not download or display another artifact.
Root cause
Two behaviors combined to hide the failure:
- The immutable preview-publication layer introduced after the bulk-transfer optimization could fail before
displays.last_preview_updated_atwas advanced. - Inline display jobs returned an
{ok: false}result when rendering or publication failed, butqueue_display_content_push()discarded that result. The Let’s Cook assignment therefore became current even though the first frame was never committed.
The client was not at fault. It was receiving the old committed meal-plan generation while the current assignment already said lets_cook.
Corrected flow
The first-frame path is intentionally simple again:
- Let’s Cook atomically saves the display-scoped panel-ready PNG.
- For a supported Type K target, Blackcap generates and validates the matching
.gray4controller transport. - Only after preparation completes, Blackcap updates the display preview path and a new microsecond UTC generation token in the database.
- The remote client versions both PNG and controller transport from that database token.
- An inline first-frame job that reports failure now raises immediately. It can no longer silently leave the display in Let’s Cook mode with the previous preview.
_push_lets_cook()requires a committed generation result and logs the committed generation and transport status.
The controller transport and bulk SPI performance optimizations remain unchanged.
Expected server log
A successful first frame includes a line similar to:
Let’s Cook preview committed: org=... display=... session=... generation=... transport=True path=...
If rendering or publication fails, the Let’s Cook start request now reports the actual failure instead of appearing successful.
Expected client log
On the next poll after starting Let’s Cook, the preview timestamp/version changes and the client logs a download followed by the optimized physical refresh:
Preview URL: /api/display-client/preview?t=<new generation>
Downloaded panel transport ...
Downloaded remote display artifact mode=lets_cook ... artifact=gray4_transport...
Displayed remote preview on attached e-ink ...