Make the /display kiosk survive losing its infrastructure, then surrender authority cleanly when it returns. The Phase 5A renderer is unchanged: this builds on its two permanent scene layers, explicit Cache Storage, IndexedDB, Service Worker, timing anchors, and structured errors. While connected the server is authoritative. While disconnected the display becomes a deterministic local continuation of the last authoritative state it persisted, and the moment the server speaks again the local cursor is abandoned rather than reconciled or reported back. A small client evaluator resolves offline playback from the persisted manifest and clock anchor. Catch-up is arithmetic rather than iterative: whole rotations are skipped between eligibility boundaries, so a month offline resolves in milliseconds. Mixed durations, wraparound, [start, end) schedules, and expirations are evaluated locally, and a null expiration stays permanent. Mode off keeps the hidden cursor advancing, pause holds its slide and remainder, static holds until the cached metadata says the asset genuinely expired, and an activation queued for an unknown playlist holds the last good frame instead of continuing into a playlist that is being replaced. Contract tests run this evaluator and the real Phase 3B engine over identical scenarios and assert the same slide, start, and deadline, so the client tracks the server without refactoring it. Cold start restores signage from the Service Worker shell, IndexedDB, and Cache Storage with the entire stack down, keeping the same persistent display identity. Validated display error codes now become Home Assistant incidents through the Phase 4 machinery, keyed per display and condition so a persisting error reports once and recovers once. Arbitrary browser text stays telemetry. Corrects the deployment assumption: the kiosk runs on a separate machine, so production requires a trusted HTTPS origin and localhost is development only. Verifying on a real trusted certificate exposed two production bugs that localhost had hidden — behind a reverse proxy an outage arrives as a 502 rather than a refused connection, and an unbounded shell fetch left a restarting kiosk blank for the proxy's whole connect timeout. The worker now treats a gateway error as an outage and bounds that wait. Verified with 200/200 backend, 20 client unit, and 30 Playwright tests on a trusted HTTPS origin with no browser security flags weakened. Co-Authored-By: Claude Opus 5 <[email protected]>
651 lines
32 KiB
Markdown
651 lines
32 KiB
Markdown
# Announcements v2
|
||
|
||
Announcements is a small Express, React, Socket.IO, MQTT, SQLite, and
|
||
S3-compatible signage application. Version 2 is being built in phases and is
|
||
intended to run beside the existing production version until cutover.
|
||
|
||
## Development
|
||
|
||
The repository is a pnpm workspace with one lockfile. It requires Node.js
|
||
22.13 or newer and pnpm 10.29.3.
|
||
|
||
```sh
|
||
cp .env.example .env
|
||
pnpm install --frozen-lockfile
|
||
pnpm check
|
||
docker compose up --build
|
||
```
|
||
|
||
Only the Nginx web service publishes a host port (5175 by default). The API and
|
||
SeaweedFS S3 endpoint remain private on Compose networks. Replace the example
|
||
S3 credentials before starting the stack.
|
||
|
||
## Library assets
|
||
|
||
`POST /api/assets` accepts multipart uploads in the repeated `file` or `files`
|
||
field. An optional `display_name` (or `displayName`) is accepted for a
|
||
single-file upload. Supported decoded formats are still JPEG, PNG, and WebP;
|
||
extensions and multipart content types are not trusted. Animated, multipage,
|
||
malformed, unsupported, oversized, and excessive-pixel inputs are rejected.
|
||
|
||
Default limits are:
|
||
|
||
- 25 MiB per file (`UPLOAD_MAX_FILE_BYTES`)
|
||
- 20 files per request (`UPLOAD_MAX_FILES`)
|
||
- 40 million decoded pixels per image (`IMAGE_MAX_INPUT_PIXELS`)
|
||
|
||
Nginx accepts up to 512 MiB per request, enough for the default aggregate plus
|
||
multipart overhead. If the backend limits are raised, update
|
||
`client_max_body_size` in `client/nginx.conf` as well. Proxied API requests use
|
||
a ten-minute inactivity timeout so a valid large batch can finish processing.
|
||
|
||
Original bytes are archived unchanged. Content identity is the SHA-256 of
|
||
those bytes, so uploading the same content returns the existing asset even
|
||
when the filename differs. Display-name collisions with different content
|
||
return `409 ASSET_NAME_CONFLICT`. Multi-file uploads validate every new image
|
||
locally before persisting any of them. If a later storage operation fails,
|
||
already completed items remain valid and retrying the request is idempotent.
|
||
|
||
Sharp produces two metadata-free, auto-oriented WebP renditions:
|
||
|
||
- display: inside-fit at most 1920×1080, quality 82
|
||
- thumbnail: inside-fit at most 480×270, quality 72
|
||
|
||
Neither rendition crops or upscales. Transparency is flattened against black.
|
||
Presentation effects such as blur, shadows, darkening, and framing are not
|
||
baked into stored media.
|
||
|
||
## Asset and media API
|
||
|
||
- `GET /api/assets`
|
||
- `POST /api/assets`
|
||
- `GET /api/assets/:assetId`
|
||
- `PATCH /api/assets/:assetId`
|
||
- `GET /api/assets/:assetId/references`
|
||
- `DELETE /api/assets/:assetId`
|
||
- `GET /api/assets/:assetId/original`
|
||
- `GET /media/assets/:assetId/display/:sha256.webp`
|
||
- `GET /media/assets/:assetId/thumbnail/:sha256.webp`
|
||
|
||
PATCH accepts only display name and expiration. A null expiration is permanent.
|
||
Manual deletion refuses playlist, current playback, and static references. It
|
||
marks an asset as deleting before idempotently removing its three objects and
|
||
removes SQLite metadata only after storage succeeds. A failed processing row
|
||
can be inspected, safely deleted, and uploaded again; no automatic retry loop
|
||
is run.
|
||
|
||
Media URLs are derived from SQLite metadata and never accept object keys. The
|
||
display and thumbnail routes stream private S3 objects through the API with a
|
||
one-year immutable cache policy. Original downloads use private, no-store
|
||
caching.
|
||
|
||
## Playlists
|
||
|
||
Playlist and entry metadata is authoritative in SQLite. The API surface is:
|
||
|
||
- `GET /api/playlists`
|
||
- `POST /api/playlists`
|
||
- `GET /api/playlists/:playlistId`
|
||
- `PATCH /api/playlists/:playlistId`
|
||
- `DELETE /api/playlists/:playlistId`
|
||
- `POST /api/playlists/:playlistId/entries`
|
||
- `PATCH /api/playlists/:playlistId/entries/:entryId`
|
||
- `DELETE /api/playlists/:playlistId/entries/:entryId`
|
||
- `PUT /api/playlists/:playlistId/entries/order`
|
||
- `POST /api/playlists/:playlistId/activate`
|
||
|
||
Entry positions are zero-based and contiguous. Reordering requires the complete
|
||
ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a
|
||
playlist, but the same asset can be used independently by any number of other
|
||
playlists. Only ready, unexpired assets can be newly added.
|
||
|
||
Entry duration overrides may be null (inherit the global setting) or 1,000 to
|
||
3,600,000 milliseconds. Presentation overrides are nullable; darkening is an
|
||
inherit/off/on boolean and blur always comes from the global setting. Schedule
|
||
instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric
|
||
UTC offset, and intervals are evaluated as `[start, end)`.
|
||
|
||
Playlist detail responses include effective duration and presentation,
|
||
server-evaluated eligibility, immutable display/thumbnail URLs, and the
|
||
settings revision and evaluation time used. Playlist revisions increment once
|
||
per semantic content change. Exact no-ops and reads do not increment them.
|
||
|
||
Before a playback-relevant playlist, settings, or asset mutation, the playback
|
||
service catches up using the old metadata. The domain transaction then commits
|
||
and playback synchronously reconciles the new values at the mutation instant.
|
||
This prevents a late timer or restart boundary from being rewritten by a later
|
||
edit. If the post-commit reconciliation unexpectedly fails, the API returns an
|
||
error even though the preceding domain commit may already be durable; clients
|
||
must refetch current state before deciding whether to retry.
|
||
|
||
Activation is explicit. A request is held in the existing pending-activation
|
||
state while a playable normal slide is mid-duration; otherwise the target
|
||
playlist is prepared immediately at its first currently playable entry. The
|
||
output-off flag remains only a visual gate, and static hold is preserved while
|
||
the hidden normal playlist changes. The authoritative playback service consumes
|
||
a pending activation at the current slide boundary and never merges the old and
|
||
new playlists.
|
||
|
||
Socket.IO publishes `playlists:changed` as a refetch invalidation after
|
||
committed playlist changes and after settings or referenced-asset edits that
|
||
affect resolved playlist data. Socket.IO is not a mutation API.
|
||
|
||
## Authoritative playback
|
||
|
||
One serialized service owns persisted global playback, timer reconciliation,
|
||
and every playback control path. Its REST surface is:
|
||
|
||
- `GET /api/playback`
|
||
- `POST /api/playback/pause`
|
||
- `POST /api/playback/resume`
|
||
- `POST /api/playback/next`
|
||
- `POST /api/playback/previous`
|
||
- `POST /api/playback/static`
|
||
- `PUT /api/playback/mode`
|
||
- `POST /api/playback/reload`
|
||
|
||
Visual output precedence is mode off (black), a valid static asset, the current
|
||
normal entry, then black when no playable target exists. Mode off is only a
|
||
visual gate: normal playback continues underneath unless it is independently
|
||
paused or held by static mode. Pause and static both preserve the normal cursor
|
||
and remaining duration. Resume clears both static and paused state together and
|
||
continues the normal entry with its preserved remaining time. Next and previous
|
||
wrap across currently playable entries; they change the held entry without
|
||
unpausing when paused and are no-ops while static.
|
||
|
||
Normal slides use persisted absolute start times and deadlines. Chained
|
||
timeouts reconcile from the prior authoritative deadline, including late
|
||
callbacks and restart catch-up, so delays do not accumulate as timing drift.
|
||
The service also arms exact schedule and asset-expiration boundaries. It skips
|
||
disabled, out-of-schedule, expired, and non-ready entries, and safely returns to
|
||
an eligible entry when one becomes available. Pending playlist activation is
|
||
applied at the current normal slide boundary; an empty target playlist still
|
||
becomes active with black normal output.
|
||
|
||
Socket.IO sends `playback:state` immediately on connection and after meaningful
|
||
committed playback changes. The authoritative snapshot includes server time and
|
||
revisions, mode and transport state, active playlist and timeline data, current
|
||
and static targets, crossfade settings, and a display-cache manifest with
|
||
effective duration, presentation, scheduling, expiration, and immutable media
|
||
metadata. Enabled future-scheduled entries remain in the manifest; disabled
|
||
entries and private S3 details do not. Reads and connections do not increment
|
||
the playback revision.
|
||
|
||
Phase 3B exposes this state and behavior through REST and Socket.IO only. MQTT
|
||
command wiring and the browser display renderer remain later phases.
|
||
|
||
Deleting a library asset that is the current static target is still refused by
|
||
the Phase 2 deletion model. That refusal is a known interim integration point,
|
||
not the intended final behavior: deleting the statically held slide is meant to
|
||
exit static safely, return to normal rotation, and only then delete the asset.
|
||
Phase 3B does not depend on the refusal, so the change belongs to the later
|
||
phase that revisits asset lifecycle.
|
||
|
||
## MQTT integration
|
||
|
||
The API connects to `MQTT_URL` when it is configured and stays disabled when it
|
||
is not. Commands are dispatched straight into the authoritative playback
|
||
service, so an MQTT command and its REST equivalent are the same operation with
|
||
the same semantics; no playback logic is duplicated in the integration.
|
||
|
||
With the default `MQTT_TOPIC_PREFIX` of `announcements` the command topics are:
|
||
|
||
| Topic | Payload | QoS |
|
||
| ------------------------- | ---------------------- | --- |
|
||
| `announcements/mode` | `on` or `off` | 1 |
|
||
| `announcements/pause` | ignored | 1 |
|
||
| `announcements/resume` | ignored | 1 |
|
||
| `announcements/reload` | ignored | 1 |
|
||
| `announcements/static` | LibraryAsset UUID | 1 |
|
||
| `announcements/next` | ignored | 0 |
|
||
| `announcements/previous` | ignored | 0 |
|
||
|
||
Mode payloads are trimmed and case-insensitive. `off` is only the visual black
|
||
gate; it never pauses, restarts, or resets the playlist, cursor, static hold, or
|
||
timing. Static accepts only a stable LibraryAsset UUID: URLs, filenames, and S3
|
||
object keys are rejected. The asset must exist, be `ready`, and be unexpired,
|
||
but it does not need to appear in any playlist. A rejected command is logged as
|
||
a structured warning, changes nothing, and publishes no state.
|
||
|
||
`next` and `previous` stay at QoS 0 on purpose. They are not idempotent and
|
||
carry no command ID, so an at-least-once redelivery would silently skip a
|
||
slide. Commands are never retained, and the server never publishes onto a
|
||
command topic.
|
||
|
||
The server publishes retained state on separate topics:
|
||
|
||
| Topic | Payload |
|
||
| --------------------------------- | ---------------------------------- |
|
||
| `announcements/availability` | `online` / `offline` |
|
||
| `announcements/mode/state` | `on` / `off` |
|
||
| `announcements/playback/state` | small versioned JSON snapshot |
|
||
|
||
Availability describes this API's MQTT integration, not a display. A registered
|
||
last will and testament lets the broker publish `offline` when the API
|
||
disappears without a clean disconnect; a planned shutdown retracts it directly.
|
||
The compact playback topic carries mode, transport, active playlist, current
|
||
entry and asset, static asset, and revision. It never carries the Socket.IO
|
||
manifest or any private storage detail.
|
||
|
||
State is published only after a playback mutation has committed, and MQTT
|
||
publication happens after the SQLite commit. A publish failure degrades the
|
||
integration and is never allowed to roll playback back. A broker outage leaves
|
||
the API, HTTP, and playback untouched: the client reconnects on its own, then
|
||
resubscribes and republishes availability and authoritative state.
|
||
|
||
## Home Assistant notifications
|
||
|
||
Announcements sends outgoing generic webhook POSTs to `HA_WEBHOOK_URL`. Home
|
||
Assistant is only the receiver; Announcements never calls Companion directly and
|
||
needs no Home Assistant authentication beyond the configured webhook. The URL
|
||
and `HA_WEBHOOK_SECRET` are configuration, never API or status output.
|
||
|
||
Notifications are durable and deduplicated through `notification_incidents` and
|
||
`notification_outbox`. A condition that starts failing opens one incident and
|
||
queues exactly one failure notification. While it keeps failing only its
|
||
observation time is updated, so a thirty-minute outage still sends one message.
|
||
Recovery closes the incident and queues exactly one recovery notification. If
|
||
the same condition fails again later that is a new episode with its own
|
||
failure/recovery pair. Open incidents and undelivered notifications survive an
|
||
API restart.
|
||
|
||
Reported conditions are `storage.unavailable` / `storage.recovered`, driven by
|
||
the Phase 2 storage health transition rather than individual request failures;
|
||
`display.offline` / `display.recovered` per display client; and
|
||
`playback.no_playable_slides` / `playback.playable` from committed playback
|
||
state. A static override does not suppress the playback condition, because
|
||
holding one asset does not repair an empty playlist. A failed upload opens one
|
||
`asset.processing_failed` warning per asset; an individual failed upload has no
|
||
automatic recovery, so the operator resolves it by deleting or re-uploading.
|
||
|
||
The payload is versioned:
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"notificationId": "stable-delivery-id",
|
||
"incidentId": "stable-incident-id",
|
||
"event": "storage.unavailable",
|
||
"severity": "critical",
|
||
"state": "failure",
|
||
"message": "Announcement storage is unavailable.",
|
||
"timestamp": "2026-08-16T12:00:00.000Z",
|
||
"context": { "conditionKey": "storage", "episode": 1 }
|
||
}
|
||
```
|
||
|
||
Severities are `info`, `warning`, `critical`, and `recovery`. Home Assistant can
|
||
treat them all as ordinary notifications; the field exists so later Ekklesia or
|
||
HA handling can discriminate.
|
||
|
||
Delivery is asynchronous and bounded. A 2xx marks the notification delivered.
|
||
Any other response and any transport failure leaves it pending and retries after
|
||
30 seconds, 1 minute, 5 minutes, 15 minutes, and 30 minutes, then every 30
|
||
minutes. The notification ID is stable across retries so a receiver can
|
||
deduplicate, retries resume after a restart, and a failing webhook never blocks
|
||
playback, MQTT, storage, or heartbeats. When no webhook URL is configured,
|
||
incidents are still recorded but no delivery work is queued, and status reports
|
||
the integration as disabled.
|
||
|
||
An example Home Assistant receiver:
|
||
|
||
```yaml
|
||
automation:
|
||
- alias: Announcements Notifications
|
||
triggers:
|
||
- trigger: webhook
|
||
webhook_id: announcements
|
||
allowed_methods:
|
||
- POST
|
||
local_only: true
|
||
|
||
actions:
|
||
- action: notify.mobile_app_YOUR_DEVICE
|
||
data:
|
||
title: "Announcements"
|
||
message: "{{ trigger.json.message }}"
|
||
```
|
||
|
||
Substitute any notify target; Companion is not required. Announcements cannot
|
||
send a webhook while the Announcements API itself is completely down. That is a
|
||
limitation of self-reporting, not a bug: if server-down alerting is wanted, an
|
||
external monitor such as Home Assistant should poll `/health/live`.
|
||
|
||
## Display telemetry
|
||
|
||
The future `/display` client reports read-only telemetry over the Socket.IO
|
||
event `display:heartbeat`, expected every 10-15 seconds:
|
||
|
||
```json
|
||
{
|
||
"protocolVersion": 1,
|
||
"clientId": "persistent-display-id",
|
||
"outputId": "global",
|
||
"activePlaylistId": "playlist-id",
|
||
"playlistRevision": 12,
|
||
"playbackRevision": 42,
|
||
"currentEntryId": "entry-id",
|
||
"currentAssetId": "asset-id",
|
||
"cachedAssetCount": 10,
|
||
"playableAssetCount": 10,
|
||
"mostRecentError": null
|
||
}
|
||
```
|
||
|
||
Every field is validated and bounded, `mostRecentError` is truncated, and
|
||
malformed telemetry is ignored without creating a display. Heartbeats can never
|
||
mutate playback and never increment a playback or playlist revision. Identity is
|
||
the persistent `clientId` stored in `display_clients`, never a Socket.IO
|
||
connection ID, so a browser reconnect remains the same display.
|
||
|
||
A display is online while heartbeats are arriving and offline once
|
||
`DISPLAY_OFFLINE_THRESHOLD_MS` (45 seconds, about three missed heartbeats) has
|
||
passed since the last one. A closed socket alone never marks a display offline,
|
||
because reconnects and brief network hiccups are normal. Crossing the threshold
|
||
opens one `display.offline` incident that does not repeat while the display
|
||
stays away, and the next valid heartbeat recovers it exactly once.
|
||
|
||
Display history is persisted, so a restart does not erase monitoring. The API
|
||
cannot know what happened to a display while the API itself was down, so it is
|
||
deliberately conservative: nothing is reported offline until a full threshold has
|
||
elapsed since startup. A healthy display that reconnects during that window is
|
||
never reported, a display that is still missing afterwards is reported once, and
|
||
an incident that was already open before the restart is not duplicated.
|
||
|
||
`GET /api/displays` returns each known display with a status derived from
|
||
heartbeat freshness rather than mere database existence, and exposes no socket
|
||
internals. `display:status` is emitted only for meaningful changes — first seen,
|
||
offline, recovered, or a changed error — so steady telemetry creates no realtime
|
||
churn. `mostRecentError` is telemetry only; arbitrary browser strings never
|
||
become Home Assistant notifications. Structured display-side errors such as
|
||
decode failures arrive with the Phase 5 renderer, which will report them through
|
||
this same field and event.
|
||
|
||
## Signage display
|
||
|
||
`/display` is the signage output and `/` is reserved for the future management
|
||
dashboard. Both are served by the same single-page build, so `/display` survives
|
||
direct navigation and refresh through the existing Nginx SPA fallback. The
|
||
display stays a Chromium kiosk pointed at a web application: v2 makes that
|
||
application reliable rather than replacing it with a native client.
|
||
|
||
### Deployment topology
|
||
|
||
The kiosk runs on a different computer from the Announcements host, so the
|
||
localhost secure-context exception is not available in production. A remote
|
||
display **must** use HTTPS:
|
||
|
||
```
|
||
Remote Chromium kiosk
|
||
|
|
||
HTTPS
|
||
|
|
||
v
|
||
Announcements web / reverse proxy
|
||
|
|
||
+-- React /display
|
||
+-- /api
|
||
+-- /socket.io
|
||
+-- /media
|
||
```
|
||
|
||
The reverse proxy terminates TLS; the API and web containers may keep speaking
|
||
plain HTTP behind it. The kiosk browser must trust the certificate — a public
|
||
certificate or an internal CA installed on the kiosk machine both work.
|
||
|
||
Secure-context rules decide what the display can do:
|
||
|
||
| Origin | Status |
|
||
| --- | --- |
|
||
| `https://display.example.org/display` | Production. Full offline capability. |
|
||
| `http://localhost:5175/display` | Development and automated testing only. Secure context, so offline works. |
|
||
| `http://10.0.0.5:5175/display` | Degraded, online only. **Not a valid kiosk configuration.** |
|
||
|
||
Plain LAN HTTP has no Service Worker, no Cache Storage, and no offline startup.
|
||
The display still shows slides while the server is reachable, but it cannot
|
||
survive an outage. It detects this at startup and reports
|
||
`offline_capability_unavailable` through its heartbeat so the limitation is
|
||
visible in `/api/displays` and Home Assistant rather than on screen.
|
||
|
||
Do not work around this with `--ignore-certificate-errors`,
|
||
`--disable-web-security`, or an insecure-origin-as-secure flag. They disable
|
||
the protections the offline design relies on and do not represent a supportable
|
||
deployment.
|
||
|
||
### Kiosk browser requirements
|
||
|
||
The kiosk must use a **persistent** Chromium profile. The Service Worker, Cache
|
||
Storage, and IndexedDB that make offline operation possible all live in that
|
||
profile, and an incognito window, a guest profile, or a throwaway
|
||
`--user-data-dir` discards them on every launch.
|
||
|
||
```sh
|
||
chromium \
|
||
--kiosk \
|
||
--user-data-dir=/var/lib/announcements-kiosk \
|
||
https://display.example.org/display
|
||
```
|
||
|
||
The old v1 kiosk flags that disabled the browser cache are no longer required
|
||
and must be removed at cutover. They existed because v1 reused mutable slide
|
||
URLs; v2 media URLs are content-addressed, so a changed image always arrives
|
||
under a new URL and stale bytes are impossible. Those flags now actively
|
||
interfere with the Service Worker and Cache Storage the display depends on.
|
||
|
||
Clearing the browser's site data deletes the cached slides, the last-known-good
|
||
state, and the display's identity. Nothing is lost permanently: on its next
|
||
connection the display registers as a new client, re-caches the active playlist,
|
||
and resumes. No routine cache management is expected of an operator.
|
||
|
||
### Transition guarantee
|
||
|
||
The renderer owns two permanent full-screen scene layers. Each layer is a
|
||
complete scene — background, optional darkening overlay, and foreground image —
|
||
and neither layer is ever recreated; only its contents and opacity change.
|
||
|
||
A transition prepares the hidden layer completely, decodes the incoming image
|
||
through `Image.decode()` on the element that will actually be composited, then
|
||
raises that layer above the current one at opacity 0 and fades it in. The
|
||
outgoing layer stays fully opaque underneath for the whole transition and is
|
||
never faded out. This is the specific v1 defect that produced black frames: two
|
||
simultaneous fades let the black root show through the composite.
|
||
|
||
If an incoming asset cannot be fetched or decoded, the last good frame simply
|
||
stays on screen. The entry is marked locally unavailable, a structured error is
|
||
recorded for telemetry, and rotation continues past it rather than retrying it
|
||
in a loop. Every asynchronous load and transition carries a generation token, so
|
||
work that completes after authoritative state has moved on is discarded instead
|
||
of overwriting the newer target.
|
||
|
||
While connected, the display schedules the expected next transition locally from
|
||
the authoritative timeline and the cached manifest, so transitions stay smooth
|
||
between packets. The server always wins any disagreement. When an activation is
|
||
pending for a playlist the display does not yet hold, local prediction is
|
||
withheld entirely: holding the current frame a little longer is better than
|
||
briefly showing the wrong slide.
|
||
|
||
### Presentation
|
||
|
||
A slide whose real pixel dimensions are 16:9 within a small tolerance fills the
|
||
frame: no blurred duplicate, no pillarbox, and no foreground shadow. Anything
|
||
else is shown whole and centred with `object-fit: contain`, never cropped, over
|
||
its configured background and with a subtle drop shadow.
|
||
|
||
Backgrounds follow the server's already-resolved effective values. `blurred`
|
||
reuses the same slide image, covered and centred, blurred by the global setting
|
||
(20px by default) and overscanned so the blur cannot reveal an edge. `solid`
|
||
fills with the configured colour and `black` fills with black. Background
|
||
darkening is boolean, resolved by the server from the global default and the
|
||
entry's inherit/off/on override, and applies a fixed overlay of about 0.25 to
|
||
the background only — never to the readable foreground. Blur is applied at
|
||
render time and is never baked into a stored derivative.
|
||
|
||
Static targets have no playlist entry, so they use the global presentation
|
||
defaults the snapshot already carries, through the same renderer and the same
|
||
transition path.
|
||
|
||
`mode=off` is a topmost black gate that covers the output immediately. The
|
||
renderer keeps tracking the timeline, caching, and updating its current target
|
||
underneath, so switching the mode back on reveals the synchronized scene without
|
||
restarting the playlist.
|
||
|
||
### Explicit asset cache
|
||
|
||
Slide bytes are stored explicitly in Cache Storage under
|
||
`announcements-display-assets-v1`, not left to ordinary HTTP caching. The asset
|
||
manager checks the cache, fetches only on a miss, validates the response, stores
|
||
a clone, and returns a blob that is decoded before it is ever presented. Object
|
||
URLs are reference counted, and bytes are never revoked while a scene layer is
|
||
still displaying them.
|
||
|
||
A new manifest never destroys the working one. Incoming assets are fetched and
|
||
validated first and the manifest is promoted once it is usable; one unfetchable
|
||
asset marks only itself unavailable rather than rejecting the whole playlist.
|
||
After promotion, anything not referenced by the promoted manifest, the current
|
||
static target, or a live scene layer is pruned. Quota pressure triggers a prune
|
||
and one retry before a bounded error is reported.
|
||
|
||
### Persistence and the app shell
|
||
|
||
A small IndexedDB database (`announcements-display`, version 1, one object
|
||
store, explicit upgrade handling) holds the persistent client ID, the
|
||
last-known-good manifest with the revisions needed to interpret it, the last
|
||
authoritative timing anchor, mode and transport state, and the most recent
|
||
structured error. Malformed or superseded records are discarded on read so the
|
||
display starts cleanly from the server instead of trusting an unreadable
|
||
snapshot. The client ID is generated once and reused across refresh, reconnect,
|
||
and Chromium restart; a socket ID is never used as display identity.
|
||
|
||
`display-sw.js` caches only the `/display` application shell. Navigations are
|
||
network-first so an online kiosk always receives the newest application, with
|
||
the cached shell as the offline fallback, and content-hashed Vite assets are
|
||
cache-first. The worker explicitly bypasses `/media`, `/api`, `/socket.io`, and
|
||
`/health`: slide bytes belong to the asset manager and live server state must
|
||
never be answered from a cache. It calls `skipWaiting` and `clients.claim`, so a
|
||
kiosk cannot be trapped on a stale version.
|
||
|
||
### Heartbeat and errors
|
||
|
||
The display emits `display:heartbeat` about every 12 seconds with its persistent
|
||
client ID, the active playlist and revisions it is actually rendering, the
|
||
current entry and asset, its true cached and locally playable asset counts, and
|
||
its most recent error. Telemetry is read-only and never mutates playback.
|
||
|
||
Errors are structured internally as `slide_decode_failed`, `asset_fetch_failed`,
|
||
`cache_write_failed`, or `manifest_invalid`, each with the relevant asset or
|
||
entry, a bounded message, and a timestamp. They are sent as bounded JSON, which
|
||
the server's existing string validation already accepts, so no schema migration
|
||
was required; `/api/displays` parses them back into an object. An error clears
|
||
when the failing asset renders successfully or leaves the manifest — never
|
||
merely because time passed.
|
||
|
||
### Offline resilience
|
||
|
||
While connected the server is authoritative. While disconnected the display
|
||
becomes a deterministic local continuation of the last authoritative state it
|
||
persisted. The moment the server speaks again it is authoritative once more:
|
||
the local cursor is abandoned, never reconciled and never reported back.
|
||
Offline playback is resilience, not distributed consensus.
|
||
|
||
Local continuation uses the persisted manifest and timing anchor. Elapsed time
|
||
is derived from the anchor — the authoritative `serverTime` and the local clock
|
||
reading when it was accepted — so a restart estimates where playback should be
|
||
rather than restarting a slide. Catch-up is arithmetic: whole rotations are
|
||
skipped in one step between eligibility boundaries, so a display offline for a
|
||
month resolves its position in milliseconds instead of stepping through every
|
||
missed slide. Mixed durations, wraparound, `[start, end)` schedule boundaries,
|
||
and asset expirations are all evaluated locally from the cached manifest. A null
|
||
expiration stays permanent no matter how long the display has been away.
|
||
|
||
Transport state is preserved exactly. Mode off stays black while the hidden
|
||
cursor keeps advancing; a paused display holds its slide and remainder and never
|
||
advances merely because time passed or the browser restarted; static holds
|
||
indefinitely and releases only when the cached metadata says the asset genuinely
|
||
expired. If the server had queued an activation for a playlist the display does
|
||
not hold, the display holds its last good frame at that boundary rather than
|
||
continuing deeper into a playlist that is being replaced.
|
||
|
||
The distinction between "the playlist is genuinely empty" and "the playlist
|
||
should play but the local bytes are unusable" is preserved: the first is
|
||
correctly black, the second keeps the last good frame and reports an error.
|
||
|
||
This evaluator is a small client-side implementation, deliberately separate from
|
||
the verified Phase 3B engine. Contract tests run both over identical scenarios —
|
||
mixed durations, wraparound, schedule starts and ends, expirations, long
|
||
outages, and empty playlists — and assert they resolve the same slide, start,
|
||
and deadline.
|
||
|
||
### Cold start
|
||
|
||
After one successful online visit the display can start with the entire stack
|
||
unavailable. The Service Worker serves the application shell, IndexedDB
|
||
provides the client ID and last-known-good state, Cache Storage provides the
|
||
slide bytes, and local playback resumes from the persisted anchor. A reconnect
|
||
attempt continues in the background.
|
||
|
||
A display that has never successfully cached anything has nothing trustworthy
|
||
to show, so it stays black. It never invents placeholder content.
|
||
|
||
Because a remote kiosk reaches the origin through a reverse proxy, an outage
|
||
behind that proxy arrives as a 502 rather than a refused connection. The worker
|
||
treats a gateway error as an outage, and bounds how long it waits for the
|
||
network when a usable shell is already cached, so a kiosk restarting during an
|
||
outage reaches its slides quickly instead of waiting out a proxy timeout.
|
||
|
||
### Reconnect
|
||
|
||
On reconnect the authoritative snapshot wins. Required assets are staged and
|
||
decoded first and the current frame is kept until the new one is ready, so
|
||
resynchronization never exposes black and never replays the slides that were
|
||
missed. If the local calculation already agrees with the server, the timing
|
||
anchor is simply re-anchored with no visual churn. A playlist changed during the
|
||
outage is staged and adopted without mixing old and new entries, and the
|
||
superseded playlist's bytes are pruned once the new manifest is promoted.
|
||
|
||
The server's own 45-second heartbeat threshold is unchanged. A display can be
|
||
showing announcements perfectly while disconnected and still be reported as
|
||
`display.offline`, which is the useful distinction: the audience sees slides,
|
||
and the operator still learns that the display lost contact.
|
||
|
||
### Display errors as incidents
|
||
|
||
Validated display error codes become Home Assistant incidents through the
|
||
existing Phase 4 machinery, keyed per display and condition so a persisting
|
||
error is reported once and recovers once:
|
||
|
||
| Code | Severity |
|
||
| --- | --- |
|
||
| `slide_decode_failed` | warning |
|
||
| `asset_fetch_failed` | warning |
|
||
| `cache_write_failed` | warning |
|
||
| `manifest_invalid` | critical |
|
||
| `offline_capability_unavailable` | warning |
|
||
|
||
Arbitrary browser text remains telemetry and never becomes a notification. If
|
||
an error occurs while the display is completely disconnected the server cannot
|
||
learn of it at that instant; the display persists its current error and reports
|
||
it on reconnect. A transient offline error that fully resolves before
|
||
reconnection therefore produces no historical notification. This is a documented
|
||
limitation rather than a browser-side durable event queue.
|
||
|
||
## Storage and health
|
||
|
||
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
|
||
`S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY`.
|
||
Connection timeout, socket-inactivity timeout (`S3_REQUEST_TIMEOUT_MS`),
|
||
attempts, and background probe interval are configurable in `.env.example`.
|
||
Startup verifies or safely creates only the configured bucket.
|
||
|
||
Storage outages do not stop the API or fail process readiness. `/health/ready`
|
||
reports storage as degraded while remaining HTTP 200 when SQLite and the API
|
||
runtime are healthy. `/api/health` and `/api/status` expose cached, redacted
|
||
storage state and recover after a successful probe or storage operation.
|
||
|
||
`/health/live` reports only that the process is running. Readiness stays limited
|
||
to SQLite, migrations, and playback initialization. Every external integration
|
||
degrades the reported status instead of failing readiness, so a broker, webhook
|
||
receiver, or display problem can never put the API into a restart loop.
|
||
`/api/health` and `/api/status` report redacted storage, MQTT, notification,
|
||
playback, and display state. No MQTT password, S3 credential, webhook URL, or
|
||
webhook secret appears in any response.
|