From b03e5f6ce8414b5de47f8d80556908001d506d6b Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Thu, 15 Oct 2026 10:43:24 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20boxes=20in=20the=20Screens=20list=20?= =?UTF-8?q?=E2=80=94=20status=20and=20remote=20care=20(MAD-811)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A paired display that is a Syncphony box sends its bridge's /v1/info up its room socket ("box.status"): version, name, temperature and throttling, uptime, the last nightly update. The server keeps the latest per display in memory and tracks whether each display is connected; GET /rooms/{id}/displays now has `online` and `box`. - displays.updated tells the room when a screen comes, goes, or its box reports something the list shows. - POST .../displays/{id}/reload and .../restart, for whoever may manage the screen (owner, pairer, admin). The command goes out on that display's own topic only; /tv passes it to boxd (/v1/reload, /v1/reboot) or reloads itself. Restart is box-only. - The big screen dialog shows "Syncphony Box 1.3 · online · 52 °C", warnings (running hot / throttled, update failed, box software older than MinBoxVersion 0.2.0), Reload and a tap-twice Restart box, and links to the new docs/box.md. - ADR 0016 records the socket report, remote care and the minimum box version. OpenAPI spec updated and regenerated. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 189 ++++++++- docs/adr/0016-syncphony-box.md | 44 ++- docs/box.md | 56 +++ server/internal/httpapi/api.gen.go | 365 +++++++++++++++++- server/internal/httpapi/boxes.go | 303 +++++++++++++++ .../internal/httpapi/boxes_internal_test.go | 66 ++++ server/internal/httpapi/boxes_test.go | 201 ++++++++++ server/internal/httpapi/displays.go | 9 +- server/internal/httpapi/realtime.go | 58 ++- server/internal/httpapi/server.go | 4 + server/internal/realtime/realtime.go | 11 + web/src/api/schema.gen.ts | 190 ++++++++- web/src/components/room/big-screen-dialog.tsx | 135 +++++-- web/src/lib/box-status.test.ts | 25 ++ web/src/lib/box.test.ts | 63 ++- web/src/lib/box.ts | 63 ++- web/src/lib/displays.ts | 34 ++ web/src/lib/room-socket.ts | 25 +- 18 files changed, 1774 insertions(+), 67 deletions(-) create mode 100644 docs/box.md create mode 100644 server/internal/httpapi/boxes.go create mode 100644 server/internal/httpapi/boxes_internal_test.go create mode 100644 server/internal/httpapi/boxes_test.go create mode 100644 web/src/lib/box-status.test.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index 63389b7..1af8e5e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2562,6 +2562,56 @@ paths: default: $ref: "#/components/responses/Error" + /rooms/{roomId}/displays/{displayId}/reload: + parameters: + - $ref: "#/components/parameters/RoomId" + - name: displayId + in: path + required: true + schema: + type: string + post: + operationId: reloadDisplay + summary: Reload a display's page + description: | + Remote care from the Screens list (ADR 0016): the server tells that + display, and only that display, over its room socket, to reload. A + Syncphony box restarts its browser through its bridge; any other + screen reloads the page. `display_offline` if the display isn't + connected right now. The room's owner, whoever paired it, or an + admin. + tags: [displays] + responses: + "202": + description: Sent to the display + default: + $ref: "#/components/responses/Error" + + /rooms/{roomId}/displays/{displayId}/restart: + parameters: + - $ref: "#/components/parameters/RoomId" + - name: displayId + in: path + required: true + schema: + type: string + post: + operationId: restartDisplay + summary: Restart a Syncphony box + description: | + Reboots a Syncphony box (ADR 0016): the server tells that display, + and only that display, over its room socket, and its page asks the + box's bridge to reboot. Only for a box that says it can + (`not_a_box` otherwise: a plain screen only reloads), and only + while it's connected (`display_offline`). The room's owner, + whoever paired it, or an admin. + tags: [displays] + responses: + "202": + description: Sent to the box + default: + $ref: "#/components/responses/Error" + /rooms/{roomId}/displays/pairing/{code}: parameters: - $ref: "#/components/parameters/RoomId" @@ -7320,7 +7370,7 @@ components: Display: type: object - required: [id, roomId, name, audio, createdAt, lastSeenAt] + required: [id, roomId, name, audio, createdAt, lastSeenAt, online] properties: id: type: string @@ -7340,6 +7390,126 @@ components: lastSeenAt: type: string format: date-time + online: + type: boolean + description: Its room socket is connected right now. + box: + $ref: "#/components/schemas/BoxStatus" + + BoxStatus: + type: object + description: | + What a Syncphony box last reported about itself on its room socket + (ADR 0016), from its bridge's `/v1/info`. Kept in the server's + memory only: a box reports again whenever it connects. Absent for + a screen that isn't a box, or a box that hasn't reported since the + server started. + required: [version, capabilities, outdated, reportedAt] + properties: + version: + type: string + description: The box software's version, like `v1.3.0`. + name: + type: string + description: What the box calls itself (its `syncphony.txt` name). + capabilities: + type: array + items: + type: string + description: What its bridge can do, like `reboot`, `reload`, `cec`. + temperatureC: + type: number + format: double + throttled: + type: boolean + description: The board is slowing itself down right now (heat or power). + underVoltage: + type: boolean + description: The power supply is too weak right now. + throttleOccurred: + type: boolean + description: It has throttled or been under-voltage since it booted. + uptimeSeconds: + type: integer + format: int64 + update: + $ref: "#/components/schemas/BoxUpdate" + outdated: + type: boolean + description: | + Its software is older than this server expects (the server's + minimum box version, in ADR 0016). Unknown versions (a dev + build) aren't outdated. + reportedAt: + type: string + format: date-time + + BoxUpdate: + type: object + description: The box's last nightly update. + required: [at, result] + properties: + at: + type: string + format: date-time + result: + type: string + enum: [updated, current, failed] + error: + type: string + description: Why it failed, when it did. + + BoxStatusReport: + type: object + description: | + Sent by a paired display that is a Syncphony box, up its room + socket, as `{"type": "box.status", "data": BoxStatusReport}`: its + bridge's `/v1/info`, as is. Fields the server doesn't know are + ignored. + required: [version] + properties: + version: + type: string + maxLength: 64 + name: + type: string + maxLength: 80 + capabilities: + type: array + maxItems: 16 + items: + type: string + maxLength: 32 + temperatureC: + type: number + format: double + throttled: + type: boolean + underVoltage: + type: boolean + throttleOccurred: + type: boolean + uptimeSeconds: + type: integer + format: int64 + update: + $ref: "#/components/schemas/BoxUpdate" + + DisplayCommand: + type: object + description: A command for one display, from someone caring for it remotely. + required: [action] + properties: + action: + type: string + enum: [reload, restart] + + DisplaysChanged: + type: object + required: [roomId] + properties: + roomId: + type: string PairDisplayRequest: type: object @@ -7941,8 +8111,8 @@ components: type: object description: | A message on the room WebSocket, `GET /ws/rooms/{roomId}?since=`. - The socket needs the session cookie and is server-push only: send - changes over the REST API. + The socket needs the session cookie and is server-push only (but for + a box's status, below): send changes over the REST API. `data` by `type`: - `hello`: RoomHello. Always first. @@ -7972,11 +8142,22 @@ components: done. Sent after hello for each that's up. - `chat.message`: ChatMessage. Someone sent a message to the room. - `chat.deleted`: ChatMessageDeleted. A message was deleted. + - `displays.updated`: DisplaysChanged. One of the room's displays + connected, went away, or reported something new about its box: + fetch the displays again. + - `display.command`: DisplayCommand. To one paired display only: + reload, or (a box) restart. See reloadDisplay and restartDisplay. A paired display connects with its display cookie instead, to its own room only. So does a signed-in user with `display=1`: either way the connection doesn't count as being in the room. + The one message a client may send: a paired display that is a + Syncphony box sends `{"type": "box.status", "data": BoxStatusReport}` + when it connects and whenever its status changes (ADR 0016). The + server keeps the latest per display, for the room's Screens list. + Anything else, and anything from other connections, is ignored. + Close code 1013 (try again later) means the client fell behind and missed events: reconnect with `since` set to the last version seen. Close code 4001 means the session ended; sign in again. Close code @@ -7988,7 +8169,7 @@ components: properties: type: type: string - enum: [hello, queue.updated, nowplaying.updated, playback.notice, member.joined, member.left, link.status, room.updated, room.deleted, reaction.sent, hearts.updated, night.ended, guests.updated, members.updated, game.round, game.scores, game.queue, chat.message, chat.deleted] + enum: [hello, queue.updated, nowplaying.updated, playback.notice, member.joined, member.left, link.status, room.updated, room.deleted, reaction.sent, hearts.updated, night.ended, guests.updated, members.updated, game.round, game.scores, game.queue, chat.message, chat.deleted, displays.updated, display.command] version: type: integer format: int64 diff --git a/docs/adr/0016-syncphony-box.md b/docs/adr/0016-syncphony-box.md index 0bb5f54..edf6087 100644 --- a/docs/adr/0016-syncphony-box.md +++ b/docs/adr/0016-syncphony-box.md @@ -24,13 +24,53 @@ When the `box` query parameter is present, `/tv` runs in kiosk mode. All of the The page talks to `boxd` at **`http://127.0.0.1:8099`**, paths under `/v1`, JSON bodies. Loopback counts as a secure origin, so an HTTPS page may call it without a mixed-content error. Every call is optional and fails silently: if `/v1/info` doesn't answer, the page behaves like a normal `/tv` — pairing, playback and the speaker never depend on `boxd`. -- **`GET /v1/info`** → `{version, name, capabilities: ["cec", "reboot", ...]}`. What the box is and what it can do; the page probes it when it starts (and keeps trying alongside heartbeats until it answers). +- **`GET /v1/info`** → `{version, name, capabilities: ["cec", "reboot", ...]}`. What the box is and what it can do, plus, when the box has them, its thermals, uptime and last update (see stage 4 below). The page probes it when it starts, keeps trying alongside heartbeats until it answers, and then asks again every minute. - **`POST /v1/events`** ← `{type: "playing" | "paused" | "idle" | "paired" | "unpaired", roomId?}`. Sent when the room's playback changes (playing, paused, or nothing queued) and when the display is paired or unpaired. The box uses `playing` for CEC — switch the TV on and to this input when music starts — and all of them for health. Consecutive repeats aren't sent. - **`POST /v1/heartbeat`** every 30 s while the page is healthy. If heartbeats stop while the server's `/healthz` answers, `boxd`'s watchdog restarts Chromium (that policy is the box repo's, stage 3). -- **`POST /v1/reboot`** and **`POST /v1/reload`** reboot the box or reload its page. They're for remote care from the room's Screens list (stage 4): the server tells the page over its room socket, and the page relays. Nothing calls them before that. +- **`POST /v1/reboot`** and **`POST /v1/reload`** reboot the box or reload its page. They're for remote care from the room's Screens list (stage 4, below): the server tells the page over its room socket, and the page relays. `boxd` listens on loopback only, checks every request's `Origin` against the configured server's origin, and answers CORS preflights — including the Private Network Access preflight Chromium sends before an HTTPS page posts to loopback — with `Access-Control-Allow-Origin: ` and `Access-Control-Allow-Private-Network: true`. Requests from any other origin are refused. The bridge is versioned (`/v1`) so the page (which updates with the server) and the box (which updates with the image) can drift briefly without breaking each other. +### Boxes in the Screens list (MAD-811, stage 4) + +- **The box reports itself on its room socket.** The room socket stays + server-push for everyone else, but a paired display may send one + message: `{"type": "box.status", "data": }`. + `/tv` on a box sends it when the socket opens and whenever `/v1/info` + says something new (the page asks every minute). The fields are + `version`, `name`, `capabilities`, and, when the box has them, + `temperatureC`, `throttled`, `underVoltage`, `throttleOccurred`, + `uptimeSeconds`, and `update: {at, result, error?}` (the box's last + nightly update). The server keeps the latest report per display **in + memory only** (no migration): a box reports again whenever it + connects. It accepts at most one report every 5 s, and messages up to + 8 KiB. +- **Online** means the display has a room socket open right now. The + room hears `displays.updated` when a display comes or goes, or when a + box's report changes something the Screens list shows (its version, its + update result, or whether it's hot, throttled or under-voltage, but not + every degree), and an open Screens list refetches `GET .../displays`. + Each display there now has `online` and, for a box, `box` + (BoxStatus). +- **Remote care.** `POST /rooms/{roomId}/displays/{displayId}/reload` and + `.../restart` are for whoever may manage the screen (the room's owner, + whoever paired it, or an admin, the same rule as unpairing). The server + publishes `display.command` (`{action: "reload" | "restart"}`) on that + display's own topic, which only that display's connections subscribe + to. `/tv` passes it on to `boxd` (`POST /v1/reload` restarts the + browser, `POST /v1/reboot` reboots). A plain screen, or a box whose + `boxd` doesn't answer, reloads the page itself. Restart is only for a + box that listed `reboot` among its capabilities (`not_a_box` + otherwise). Either needs the display connected (`display_offline`). +- **The minimum box version.** The server expects box software of at + least **0.2.0** (`MinBoxVersion` in `server/internal/httpapi/boxes.go`; + this line and that constant change together). An older box shows + "box software older than this server expects". A version the server + can't read (a dev build) never warns. +- The Screens list's other warnings come from the report: *running hot / + throttled* (80 °C or more, throttled now, or under-voltage) and *update + failed*. + ## Consequences - Pairing gains a body: `POST /display/pairing` accepts `kind` and `suggestedName`, and the room-scoped lookup tells the pairing phone what it's about to pair. Nothing is stored: both live on the pending pairing in memory, and the display itself is unchanged. diff --git a/docs/box.md b/docs/box.md new file mode 100644 index 0000000..617cb29 --- /dev/null +++ b/docs/box.md @@ -0,0 +1,56 @@ +# The Syncphony box + +A Syncphony box is a Raspberry Pi 4 or 5 plugged into a TV that becomes a +room's **big screen and its speaker**, with nothing to click: it boots +straight into the room, plays by itself, survives power cuts, and updates +itself overnight. It's a kiosk for your Syncphony server and doesn't run a +server of its own. + +The box has its own repository: +**[madeofpendletonwool/syncphony-box](https://github.com/madeofpendletonwool/syncphony-box)**. +Download an image from its +**[releases](https://github.com/madeofpendletonwool/syncphony-box/releases)**, +or use Raspberry Pi Imager with the box's listing (below). + +## Make one + +1. In [Raspberry Pi Imager](https://www.raspberrypi.com/software/), open + **App Options → Content Repository → Use custom URL** and paste + `https://madeofpendletonwool.github.io/syncphony-box/imager/os_list.json`. +2. Choose your Pi, then **Syncphony Box**, then the SD card. When Imager + offers to customize, set a username and password and your Wi-Fi (skip + it if the box will be on a cable). Write the card. +3. Put the card in the Pi, plug it into the TV, and power it on. +4. The TV shows a QR code. Scan it with a phone on the same network and + type this server's address. (No Wi-Fi set? The box opens its own + `Syncphony-Box-XXXX` network first, so you can give it yours.) + +## Pair it with a room + +The TV now shows a pairing code. In the room, open the **big screen** +dialog and type the code in. Because it's a box, the dialog already +suggests its name and turns **Play the audio on it too** on. Pair, and +the box plays the room by itself from then on, through reboots and power +cuts. + +## Look after it from your phone + +The box shows up in the room's **Paired screens** like any screen, with +more to say: + +- **"Syncphony Box 1.3 · online · 52 °C"**: its software version, whether + it's connected, and its temperature. +- **Warnings**: *running hot / throttled* (it needs airflow, or a better + power supply), *update failed* (it keeps running what it had and tries + again the next night), and *box software older than this server + expects* (it should update overnight; if it doesn't, reflash it). +- **Reload** restarts the box's browser. **Restart box** reboots it, and + it asks for a second tap. Both are for the room's owner, whoever paired + the screen, or an admin. A screen that isn't a box only has Reload. + +The box updates itself every night around 04:00 (never while music +plays). More on hardware, the config file and troubleshooting is in the +[box repository's README](https://github.com/madeofpendletonwool/syncphony-box#readme). + +How the page and the box talk is +[ADR 0016](adr/0016-syncphony-box.md). diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index 0a9a5de..4e0336a 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -180,6 +180,27 @@ func (e BeginDisplayPairingRequestKind) Valid() bool { } } +// Defines values for BoxUpdateResult. +const ( + Current BoxUpdateResult = "current" + Failed BoxUpdateResult = "failed" + Updated BoxUpdateResult = "updated" +) + +// Valid indicates whether the value is a known member of the BoxUpdateResult enum. +func (e BoxUpdateResult) Valid() bool { + switch e { + case Current: + return true + case Failed: + return true + case Updated: + return true + default: + return false + } +} + // Defines values for DiscoveryFeedbackRequestKind. const ( DiscoveryFeedbackRequestKindDown DiscoveryFeedbackRequestKind = "down" @@ -330,6 +351,24 @@ func (e DismissDiscoverySuggestionRequestKind) Valid() bool { } } +// Defines values for DisplayCommandAction. +const ( + Reload DisplayCommandAction = "reload" + Restart DisplayCommandAction = "restart" +) + +// Valid indicates whether the value is a known member of the DisplayCommandAction enum. +func (e DisplayCommandAction) Valid() bool { + switch e { + case Reload: + return true + case Restart: + return true + default: + return false + } +} + // Defines values for DisplayPairingInfoKind. const ( DisplayPairingInfoKindBox DisplayPairingInfoKind = "box" @@ -1111,6 +1150,8 @@ func (e RoomAutopilotAdventure) Valid() bool { const ( RoomEventTypeChatDeleted RoomEventType = "chat.deleted" RoomEventTypeChatMessage RoomEventType = "chat.message" + RoomEventTypeDisplayCommand RoomEventType = "display.command" + RoomEventTypeDisplaysUpdated RoomEventType = "displays.updated" RoomEventTypeGameQueue RoomEventType = "game.queue" RoomEventTypeGameRound RoomEventType = "game.round" RoomEventTypeGameScores RoomEventType = "game.scores" @@ -1137,6 +1178,10 @@ func (e RoomEventType) Valid() bool { return true case RoomEventTypeChatMessage: return true + case RoomEventTypeDisplayCommand: + return true + case RoomEventTypeDisplaysUpdated: + return true case RoomEventTypeGameQueue: return true case RoomEventTypeGameRound: @@ -2157,6 +2202,72 @@ type BlockDiscoveryArtistRequest struct { Name string `json:"name"` } +// BoxStatus What a Syncphony box last reported about itself on its room socket +// (ADR 0016), from its bridge's `/v1/info`. Kept in the server's +// memory only: a box reports again whenever it connects. Absent for +// a screen that isn't a box, or a box that hasn't reported since the +// server started. +type BoxStatus struct { + // Capabilities What its bridge can do, like `reboot`, `reload`, `cec`. + Capabilities []string `json:"capabilities"` + + // Name What the box calls itself (its `syncphony.txt` name). + Name *string `json:"name,omitempty"` + + // Outdated Its software is older than this server expects (the server's + // minimum box version, in ADR 0016). Unknown versions (a dev + // build) aren't outdated. + Outdated bool `json:"outdated"` + ReportedAt time.Time `json:"reportedAt"` + TemperatureC *float64 `json:"temperatureC,omitempty"` + + // ThrottleOccurred It has throttled or been under-voltage since it booted. + ThrottleOccurred *bool `json:"throttleOccurred,omitempty"` + + // Throttled The board is slowing itself down right now (heat or power). + Throttled *bool `json:"throttled,omitempty"` + + // UnderVoltage The power supply is too weak right now. + UnderVoltage *bool `json:"underVoltage,omitempty"` + + // Update The box's last nightly update. + Update *BoxUpdate `json:"update,omitempty"` + UptimeSeconds *int64 `json:"uptimeSeconds,omitempty"` + + // Version The box software's version, like `v1.3.0`. + Version string `json:"version"` +} + +// BoxStatusReport Sent by a paired display that is a Syncphony box, up its room +// socket, as `{"type": "box.status", "data": BoxStatusReport}`: its +// bridge's `/v1/info`, as is. Fields the server doesn't know are +// ignored. +type BoxStatusReport struct { + Capabilities *[]string `json:"capabilities,omitempty"` + Name *string `json:"name,omitempty"` + TemperatureC *float64 `json:"temperatureC,omitempty"` + ThrottleOccurred *bool `json:"throttleOccurred,omitempty"` + Throttled *bool `json:"throttled,omitempty"` + UnderVoltage *bool `json:"underVoltage,omitempty"` + + // Update The box's last nightly update. + Update *BoxUpdate `json:"update,omitempty"` + UptimeSeconds *int64 `json:"uptimeSeconds,omitempty"` + Version string `json:"version"` +} + +// BoxUpdate The box's last nightly update. +type BoxUpdate struct { + At time.Time `json:"at"` + + // Error Why it failed, when it did. + Error *string `json:"error,omitempty"` + Result BoxUpdateResult `json:"result"` +} + +// BoxUpdateResult defines model for BoxUpdate.Result. +type BoxUpdateResult string + // Ceremony defines model for Ceremony. type Ceremony struct { // CeremonyId Pass back to the matching finish endpoint within 5 minutes. @@ -2686,17 +2797,35 @@ type DismissDiscoverySuggestionRequestKind string // Display defines model for Display. type Display struct { // Audio It may play the room's audio, as the speaker. - Audio bool `json:"audio"` - CreatedAt time.Time `json:"createdAt"` - Id string `json:"id"` - LastSeenAt time.Time `json:"lastSeenAt"` - Name string `json:"name"` + Audio bool `json:"audio"` + + // Box What a Syncphony box last reported about itself on its room socket + // (ADR 0016), from its bridge's `/v1/info`. Kept in the server's + // memory only: a box reports again whenever it connects. Absent for + // a screen that isn't a box, or a box that hasn't reported since the + // server started. + Box *BoxStatus `json:"box,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Id string `json:"id"` + LastSeenAt time.Time `json:"lastSeenAt"` + Name string `json:"name"` + + // Online Its room socket is connected right now. + Online bool `json:"online"` // PairedBy The user who paired it, if they're still around. PairedBy *string `json:"pairedBy,omitempty"` RoomId string `json:"roomId"` } +// DisplayCommand A command for one display, from someone caring for it remotely. +type DisplayCommand struct { + Action DisplayCommandAction `json:"action"` +} + +// DisplayCommandAction defines model for DisplayCommand.Action. +type DisplayCommandAction string + // DisplayMe defines model for DisplayMe. type DisplayMe struct { Display Display `json:"display"` @@ -2734,6 +2863,11 @@ type DisplayPairingStatus struct { // DisplayPairingStatusStatus defines model for DisplayPairingStatus.Status. type DisplayPairingStatusStatus string +// DisplaysChanged defines model for DisplaysChanged. +type DisplaysChanged struct { + RoomId string `json:"roomId"` +} + // Error defines model for Error. type Error struct { // Code Stable, machine-readable error code. @@ -4239,8 +4373,8 @@ type RoomAutopilot struct { type RoomAutopilotAdventure string // RoomEvent A message on the room WebSocket, `GET /ws/rooms/{roomId}?since=`. -// The socket needs the session cookie and is server-push only: send -// changes over the REST API. +// The socket needs the session cookie and is server-push only (but for +// a box's status, below): send changes over the REST API. // // `data` by `type`: // - `hello`: RoomHello. Always first. @@ -4270,11 +4404,22 @@ type RoomAutopilotAdventure string // done. Sent after hello for each that's up. // - `chat.message`: ChatMessage. Someone sent a message to the room. // - `chat.deleted`: ChatMessageDeleted. A message was deleted. +// - `displays.updated`: DisplaysChanged. One of the room's displays +// connected, went away, or reported something new about its box: +// fetch the displays again. +// - `display.command`: DisplayCommand. To one paired display only: +// reload, or (a box) restart. See reloadDisplay and restartDisplay. // // A paired display connects with its display cookie instead, to its // own room only. So does a signed-in user with `display=1`: either way // the connection doesn't count as being in the room. // +// The one message a client may send: a paired display that is a +// Syncphony box sends `{"type": "box.status", "data": BoxStatusReport}` +// when it connects and whenever its status changes (ADR 0016). The +// server keeps the latest per display, for the room's Screens list. +// Anything else, and anything from other connections, is ignored. +// // Close code 1013 (try again later) means the client fell behind and // missed events: reconnect with `since` set to the last version seen. // Close code 4001 means the session ended; sign in again. Close code @@ -6109,6 +6254,12 @@ type ServerInterface interface { // UpdateDisplay Turn a display's audio on or off // (PATCH /rooms/{roomId}/displays/{displayId}) UpdateDisplay(w http.ResponseWriter, r *http.Request, roomId RoomId, displayId string) + // ReloadDisplay Reload a display's page + // (POST /rooms/{roomId}/displays/{displayId}/reload) + ReloadDisplay(w http.ResponseWriter, r *http.Request, roomId RoomId, displayId string) + // RestartDisplay Restart a Syncphony box + // (POST /rooms/{roomId}/displays/{displayId}/restart) + RestartDisplay(w http.ResponseWriter, r *http.Request, roomId RoomId, displayId string) // GetGameClip A clip of a song, for a round // (GET /rooms/{roomId}/games/clips/{clipId}) GetGameClip(w http.ResponseWriter, r *http.Request, roomId RoomId, clipId string) @@ -9771,6 +9922,76 @@ func (siw *ServerInterfaceWrapper) UpdateDisplay(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// ReloadDisplay operation middleware +func (siw *ServerInterfaceWrapper) ReloadDisplay(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "roomId" ------------- + var roomId RoomId + + err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err}) + return + } + + // ------------- Path parameter "displayId" ------------- + var displayId string + + err = runtime.BindStyledParameterWithOptions("simple", "displayId", r.PathValue("displayId"), &displayId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "displayId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReloadDisplay(w, r, roomId, displayId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RestartDisplay operation middleware +func (siw *ServerInterfaceWrapper) RestartDisplay(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "roomId" ------------- + var roomId RoomId + + err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err}) + return + } + + // ------------- Path parameter "displayId" ------------- + var displayId string + + err = runtime.BindStyledParameterWithOptions("simple", "displayId", r.PathValue("displayId"), &displayId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "displayId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RestartDisplay(w, r, roomId, displayId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetGameClip operation middleware func (siw *ServerInterfaceWrapper) GetGameClip(w http.ResponseWriter, r *http.Request) { @@ -12293,6 +12514,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/displays", wrapper.PairDisplay) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/rooms/{roomId}/displays/{displayId}", wrapper.UnpairDisplay) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/rooms/{roomId}/displays/{displayId}", wrapper.UpdateDisplay) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/displays/{displayId}/reload", wrapper.ReloadDisplay) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/displays/{displayId}/restart", wrapper.RestartDisplay) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/displays/pairing/{code}", wrapper.LookupDisplayPairing) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/display/pairing", wrapper.PollDisplayPairing) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/display/pairing", wrapper.BeginDisplayPairing) @@ -17635,6 +17858,74 @@ func (response UpdateDisplaydefaultJSONResponse) VisitUpdateDisplayResponse(w ht return err } +type ReloadDisplayRequestObject struct { + RoomId RoomId `json:"roomId"` + DisplayId string `json:"displayId"` +} + +type ReloadDisplayResponseObject interface { + VisitReloadDisplayResponse(w http.ResponseWriter) error +} + +type ReloadDisplay202Response struct { +} + +func (response ReloadDisplay202Response) VisitReloadDisplayResponse(w http.ResponseWriter) error { + w.WriteHeader(202) + return nil +} + +type ReloadDisplaydefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ReloadDisplaydefaultJSONResponse) VisitReloadDisplayResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type RestartDisplayRequestObject struct { + RoomId RoomId `json:"roomId"` + DisplayId string `json:"displayId"` +} + +type RestartDisplayResponseObject interface { + VisitRestartDisplayResponse(w http.ResponseWriter) error +} + +type RestartDisplay202Response struct { +} + +func (response RestartDisplay202Response) VisitRestartDisplayResponse(w http.ResponseWriter) error { + w.WriteHeader(202) + return nil +} + +type RestartDisplaydefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response RestartDisplaydefaultJSONResponse) VisitRestartDisplayResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + type GetGameClipRequestObject struct { RoomId RoomId `json:"roomId"` ClipId string `json:"clipId"` @@ -20736,6 +21027,12 @@ type StrictServerInterface interface { // UpdateDisplay Turn a display's audio on or off // (PATCH /rooms/{roomId}/displays/{displayId}) UpdateDisplay(ctx context.Context, request UpdateDisplayRequestObject) (UpdateDisplayResponseObject, error) + // ReloadDisplay Reload a display's page + // (POST /rooms/{roomId}/displays/{displayId}/reload) + ReloadDisplay(ctx context.Context, request ReloadDisplayRequestObject) (ReloadDisplayResponseObject, error) + // RestartDisplay Restart a Syncphony box + // (POST /rooms/{roomId}/displays/{displayId}/restart) + RestartDisplay(ctx context.Context, request RestartDisplayRequestObject) (RestartDisplayResponseObject, error) // GetGameClip A clip of a song, for a round // (GET /rooms/{roomId}/games/clips/{clipId}) GetGameClip(ctx context.Context, request GetGameClipRequestObject) (GetGameClipResponseObject, error) @@ -24690,6 +24987,60 @@ func (sh *strictHandler) UpdateDisplay(w http.ResponseWriter, r *http.Request, r } } +// ReloadDisplay operation middleware +func (sh *strictHandler) ReloadDisplay(w http.ResponseWriter, r *http.Request, roomId RoomId, displayId string) { + var request ReloadDisplayRequestObject + + request.RoomId = roomId + request.DisplayId = displayId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ReloadDisplay(ctx, request.(ReloadDisplayRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ReloadDisplay") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ReloadDisplayResponseObject); ok { + if err := validResponse.VisitReloadDisplayResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// RestartDisplay operation middleware +func (sh *strictHandler) RestartDisplay(w http.ResponseWriter, r *http.Request, roomId RoomId, displayId string) { + var request RestartDisplayRequestObject + + request.RoomId = roomId + request.DisplayId = displayId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.RestartDisplay(ctx, request.(RestartDisplayRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RestartDisplay") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(RestartDisplayResponseObject); ok { + if err := validResponse.VisitRestartDisplayResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetGameClip operation middleware func (sh *strictHandler) GetGameClip(w http.ResponseWriter, r *http.Request, roomId RoomId, clipId string) { var request GetGameClipRequestObject diff --git a/server/internal/httpapi/boxes.go b/server/internal/httpapi/boxes.go new file mode 100644 index 0000000..de32525 --- /dev/null +++ b/server/internal/httpapi/boxes.go @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/realtime" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// Boxes in the Screens list (MAD-811, ADR 0016): a Syncphony box reports +// what its bridge says about it (version, temperature, uptime, the last +// update) up its room socket, and the room's owner can reload or restart +// it from their phone. Everything here lives in memory: a box reports +// again whenever it connects, so nothing needs storing. + +// MinBoxVersion is the oldest box software this server expects. A box +// older than this shows "box software older than this server expects" +// in the Screens list. ADR 0016 records it; raise both together when the +// server starts relying on something newer in the bridge. +const MinBoxVersion = "0.2.0" + +// Remote care errors. +var ( + ErrDisplayOffline = errors.New("that screen isn't connected right now") + ErrNotABox = errors.New("only a Syncphony box can be restarted; this screen can only reload") +) + +// boxReportEvery is the most often a display's box.status counts: a +// chattier box is ignored in between. +const boxReportEvery = 5 * time.Second + +// boxReportLimit caps one message up the socket. +const boxReportLimit = 8 << 10 + +// boxRegistry is what the server knows about its displays right now: +// whether each is connected, and what each box last reported. +type boxRegistry struct { + mu sync.Mutex + m map[string]*displayLive +} + +type displayLive struct { + conns int + report *BoxStatusReport + at time.Time +} + +func (b *boxRegistry) get(id string) *displayLive { + if b.m == nil { + b.m = map[string]*displayLive{} + } + l := b.m[id] + if l == nil { + l = &displayLive{} + b.m[id] = l + } + return l +} + +// connect records a display connection and reports whether it's the +// display's first (it just came online). +func (b *boxRegistry) connect(id string) bool { + b.mu.Lock() + defer b.mu.Unlock() + l := b.get(id) + l.conns++ + return l.conns == 1 +} + +// disconnect records a closed connection and reports whether it was the +// display's last (it just went offline). The box's last report stays, so +// the Screens list can still say what it was. +func (b *boxRegistry) disconnect(id string) bool { + b.mu.Lock() + defer b.mu.Unlock() + l := b.m[id] + if l == nil || l.conns == 0 { + return false + } + l.conns-- + return l.conns == 0 +} + +// report stores a box's status. It reports whether the report counted +// (not too soon after the last), and whether anything the Screens list +// shows changed enough to tell the room. +func (b *boxRegistry) report(id string, r BoxStatusReport, now time.Time) (stored, changed bool) { + b.mu.Lock() + defer b.mu.Unlock() + l := b.get(id) + if l.report != nil && now.Sub(l.at) < boxReportEvery { + return false, false + } + changed = l.report == nil || boxSummary(*l.report) != boxSummary(r) + l.report, l.at = &r, now + return true, changed +} + +// forget drops an unpaired display. +func (b *boxRegistry) forget(id string) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.m, id) +} + +// live returns a display's connection state and last box report. +func (b *boxRegistry) live(id string) (online bool, report *BoxStatusReport, at time.Time) { + b.mu.Lock() + defer b.mu.Unlock() + l := b.m[id] + if l == nil { + return false, nil, time.Time{} + } + return l.conns > 0, l.report, l.at +} + +// boxSummary is what the Screens list would show differently: a change +// in it is worth a displays.updated. The temperature only counts by +// whether it's hot, so a box warming by a degree doesn't ping the room. +func boxSummary(r BoxStatusReport) string { + hot := r.TemperatureC != nil && *r.TemperatureC >= hotC + upd := "" + if r.Update != nil { + upd = r.Update.At.String() + "/" + string(r.Update.Result) + } + return fmt.Sprintf("%s|%v|%v|%v|%s", r.Version, hot, r.Throttled != nil && *r.Throttled, r.UnderVoltage != nil && *r.UnderVoltage, upd) +} + +// hotC is when a box counts as running hot: the Pi firmware's soft +// throttling limit. +const hotC = 80.0 + +// validBoxReport checks a report from a socket: bounded, and an update +// result the API knows. An over-long update error is cut down, not +// refused: it's the most useful part of a failed update. +func validBoxReport(r *BoxStatusReport) bool { + if r.Version == "" || len(r.Version) > 64 || (r.Name != nil && len(*r.Name) > 80) { + return false + } + if r.Capabilities != nil { + if len(*r.Capabilities) > 16 { + return false + } + for _, c := range *r.Capabilities { + if len(c) > 32 { + return false + } + } + } + if r.Update != nil { + if !r.Update.Result.Valid() { + return false + } + if r.Update.Error != nil && len(*r.Update.Error) > 400 { + e := (*r.Update.Error)[:400] + r.Update.Error = &e + } + } + return true +} + +// boxOutdated reports whether a box's version is older than MinBoxVersion. +// Versions it can't read (a dev build) aren't outdated. +func boxOutdated(version string) bool { + have, ok := parseBoxVersion(version) + if !ok { + return false + } + want, _ := parseBoxVersion(MinBoxVersion) + for i := range have { + if have[i] != want[i] { + return have[i] < want[i] + } + } + return false +} + +// parseBoxVersion reads the box's git-describe versions: v1.3.0, +// 1.3.0, v1.3.0-4-gabc1234, v1.3.0-dirty. +func parseBoxVersion(v string) ([3]int, bool) { + var out [3]int + v = strings.TrimPrefix(v, "v") + if i := strings.IndexAny(v, "-+~"); i >= 0 { + v = v[:i] + } + parts := strings.Split(v, ".") + if len(parts) != 3 { + return out, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return out, false + } + out[i] = n + } + return out, true +} + +// toBoxStatus is a stored report in API form. +func toBoxStatus(r BoxStatusReport, at time.Time) *BoxStatus { + out := &BoxStatus{ + Version: r.Version, Name: r.Name, Capabilities: []string{}, + TemperatureC: r.TemperatureC, Throttled: r.Throttled, UnderVoltage: r.UnderVoltage, + ThrottleOccurred: r.ThrottleOccurred, UptimeSeconds: r.UptimeSeconds, Update: r.Update, + Outdated: boxOutdated(r.Version), ReportedAt: at.UTC(), + } + if r.Capabilities != nil { + out.Capabilities = *r.Capabilities + } + return out +} + +// toDisplay is a display in API form, with what's known about it live. +func (s *Server) toDisplay(d store.Display) Display { + out := toDisplay(d) + online, report, at := s.boxes.live(d.ID) + out.Online = online + if report != nil { + out.Box = toBoxStatus(*report, at) + } + return out +} + +// displayMessage is a message up a paired display's socket. +type displayMessage struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// handleDisplayMessage takes one message from a paired display's socket. +// The only one there is: a box's status. Anything else is ignored. +func (s *Server) handleDisplayMessage(roomID, displayID string, raw []byte) { + var m displayMessage + if err := json.Unmarshal(raw, &m); err != nil || m.Type != "box.status" { + return + } + var r BoxStatusReport + if err := json.Unmarshal(m.Data, &r); err != nil || !validBoxReport(&r) { + return + } + if stored, changed := s.boxes.report(displayID, r, time.Now()); stored && changed { + s.displaysChanged(roomID) + } +} + +// displaysChanged tells the room its displays changed. +func (s *Server) displaysChanged(roomID string) { + s.Bus.Publish(realtime.RoomTopic(roomID), realtime.Event{Type: realtime.DisplaysUpdated, Data: DisplaysChanged{RoomId: roomID}}) +} + +// sendDisplayCommand sends a command to one display's connections. +func (s *Server) sendDisplayCommand(displayID string, action DisplayCommandAction) { + s.Bus.Publish(realtime.DisplayTopic(displayID), realtime.Event{Type: realtime.DisplayCommand, Data: DisplayCommand{Action: action}}) +} + +// ReloadDisplay reloads one display's page, on its socket only. +func (s *Server) ReloadDisplay(ctx context.Context, req ReloadDisplayRequestObject) (ReloadDisplayResponseObject, error) { + d, err := s.manageableDisplay(ctx, req.RoomId, req.DisplayId) + if err != nil { + return nil, err + } + if online, _, _ := s.boxes.live(d.ID); !online { + return nil, ErrDisplayOffline + } + s.sendDisplayCommand(d.ID, Reload) + return ReloadDisplay202Response{}, nil +} + +// RestartDisplay reboots a Syncphony box, on its socket only. +func (s *Server) RestartDisplay(ctx context.Context, req RestartDisplayRequestObject) (RestartDisplayResponseObject, error) { + d, err := s.manageableDisplay(ctx, req.RoomId, req.DisplayId) + if err != nil { + return nil, err + } + online, report, _ := s.boxes.live(d.ID) + if report == nil || report.Capabilities == nil || !contains(*report.Capabilities, "reboot") { + return nil, ErrNotABox + } + if !online { + return nil, ErrDisplayOffline + } + s.sendDisplayCommand(d.ID, Restart) + return RestartDisplay202Response{}, nil +} + +func contains(xs []string, x string) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} diff --git a/server/internal/httpapi/boxes_internal_test.go b/server/internal/httpapi/boxes_internal_test.go new file mode 100644 index 0000000..c205534 --- /dev/null +++ b/server/internal/httpapi/boxes_internal_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi + +import ( + "testing" + "time" +) + +func TestBoxOutdated(t *testing.T) { + for v, want := range map[string]bool{ + "v0.1.0": true, + "0.1.9": true, + "v0.2.0": false, + "v0.2.0-4-gabc1234": false, + "v0.1.0-18-g3d24587": true, + "v1.0.0-dirty": false, + "v0.10.0": false, + "dev": false, // unknown: not outdated + "abc1234": false, + } { + if got := boxOutdated(v); got != want { + t.Errorf("boxOutdated(%q) = %v, want %v", v, got, want) + } + } +} + +func TestBoxReportsAreRateLimitedAndSummarized(t *testing.T) { + var b boxRegistry + now := time.Now() + temp := func(c float64) *float64 { return &c } + r := BoxStatusReport{Version: "v1.0.0", TemperatureC: temp(50)} + if stored, changed := b.report("d1", r, now); !stored || !changed { + t.Fatal("first report") + } + // Too soon: ignored. + if stored, _ := b.report("d1", BoxStatusReport{Version: "v9"}, now.Add(time.Second)); stored { + t.Fatal("a report inside boxReportEvery counted") + } + // A degree warmer: stored, but nothing the room needs to hear. + r.TemperatureC = temp(51) + if stored, changed := b.report("d1", r, now.Add(boxReportEvery)); !stored || changed { + t.Fatalf("warmer: stored=%v changed=%v", stored, changed) + } + // Hot: worth telling. + r.TemperatureC = temp(81) + if _, changed := b.report("d1", r, now.Add(2*boxReportEvery)); !changed { + t.Fatal("running hot didn't count as a change") + } +} + +func TestValidBoxReportCutsLongErrors(t *testing.T) { + long := make([]byte, 1000) + for i := range long { + long[i] = 'x' + } + e := string(long) + r := BoxStatusReport{Version: "v1", Update: &BoxUpdate{At: time.Now(), Result: Failed, Error: &e}} + if !validBoxReport(&r) || len(*r.Update.Error) != 400 { + t.Fatalf("long error: valid=%v len=%d", validBoxReport(&r), len(*r.Update.Error)) + } + bad := BoxStatusReport{Version: "v1", Update: &BoxUpdate{At: time.Now(), Result: "exploded"}} + if validBoxReport(&bad) { + t.Fatal("unknown update result accepted") + } +} diff --git a/server/internal/httpapi/boxes_test.go b/server/internal/httpapi/boxes_test.go new file mode 100644 index 0000000..59db552 --- /dev/null +++ b/server/internal/httpapi/boxes_test.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/coder/websocket/wsjson" + + "github.com/madeofpendletonwool/syncphony/server/internal/httpapi" +) + +// report sends a box's status up a paired display's socket, the way /tv +// on a box does (ADR 0016). +func (s *socket) report(data map[string]any) { + s.t.Helper() + ctx, cancel := context.WithTimeout(s.t.Context(), 5*time.Second) + defer cancel() + if err := wsjson.Write(ctx, s.c, map[string]any{"type": "box.status", "data": data}); err != nil { + s.t.Fatalf("sending box status: %v", err) + } +} + +// displayByName finds a room's display in the Screens list. +func displayByName(t *testing.T, c *client, roomID, id string) httpapi.Display { + t.Helper() + var ds []httpapi.Display + c.want(http.StatusOK, "GET", "/rooms/"+roomID+"/displays", nil).decode(t, &ds) + for _, d := range ds { + if d.Id == id { + return d + } + } + t.Fatalf("display %s not listed: %+v", id, ds) + return httpapi.Display{} +} + +func TestBoxStatusInTheScreensList(t *testing.T) { + e := newEnv(t) + alice := e.admin() + room := e.room(t, me(t, alice).Id) + tv, d := pairTVAudio(t, e, alice, room.ID, true) + + // Paired but not connected: offline, and nothing known about a box. + if got := displayByName(t, alice, room.ID, d.Id); got.Online || got.Box != nil { + t.Fatalf("before connecting: %+v", got) + } + + a := alice.mustDial(room.ID, "") + a.expect("hello", nil) + sock := tv.mustDial(room.ID, "") + sock.expect("hello", nil) + // Coming online tells the room. + a.await("displays.updated", nil) + if got := displayByName(t, alice, room.ID, d.Id); !got.Online { + t.Fatalf("connected display isn't online: %+v", got) + } + + sock.report(map[string]any{ + "version": "v0.1.0", "name": "Living room TV", "capabilities": []string{"reboot", "reload", "cec"}, + "temperatureC": 52.3, "throttled": false, "uptimeSeconds": 3600, + "update": map[string]any{"at": "2026-10-16T04:03:00Z", "result": "failed", "error": "apt-get update: exit status 100"}, + "somethingNew": true, // fields the server doesn't know are ignored + }) + var changed httpapi.DisplaysChanged + a.await("displays.updated", &changed) + if changed.RoomId != room.ID { + t.Fatalf("displays.updated: %+v", changed) + } + got := displayByName(t, alice, room.ID, d.Id) + b := got.Box + if b == nil || b.Version != "v0.1.0" || b.TemperatureC == nil || *b.TemperatureC != 52.3 || + b.UptimeSeconds == nil || *b.UptimeSeconds != 3600 || b.Update == nil || b.Update.Result != httpapi.Failed { + t.Fatalf("box status: %+v", b) + } + // Older than this server expects. + if !b.Outdated { + t.Fatalf("v0.1.0 should be outdated against %s", httpapi.MinBoxVersion) + } + + // Garbage and unknown messages are ignored, and don't break the socket. + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + _ = wsjson.Write(ctx, sock.c, map[string]any{"type": "box.status", "data": map[string]any{"version": ""}}) + _ = wsjson.Write(ctx, sock.c, map[string]any{"type": "something.else", "data": 1}) + if got := displayByName(t, alice, room.ID, d.Id); got.Box == nil || got.Box.Version != "v0.1.0" { + t.Fatalf("a bad report replaced a good one: %+v", got.Box) + } + + // Going away: offline, the last report kept. + _ = sock.c.CloseNow() + a.await("displays.updated", nil) + if got := displayByName(t, alice, room.ID, d.Id); got.Online || got.Box == nil { + t.Fatalf("after disconnecting: %+v", got) + } +} + +func TestReloadAndRestartReachOnlyThatDisplay(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + room := e.room(t, me(t, alice).Id) + other := e.room(t, me(t, alice).Id) + + boxTV, boxD := pairTVAudio(t, e, alice, room.ID, true) + plainTV, plainD := pairTVAudio(t, e, alice, room.ID, false) + otherTV, _ := pairTVAudio(t, e, alice, other.ID, false) + + boxSock := boxTV.mustDial(room.ID, "") + boxSock.expect("hello", nil) + plainSock := plainTV.mustDial(room.ID, "") + plainSock.expect("hello", nil) + otherSock := otherTV.mustDial(other.ID, "") + otherSock.expect("hello", nil) + member := alice.mustDial(room.ID, "") + member.expect("hello", nil) + boxSock.report(map[string]any{"version": "v1.0.0", "capabilities": []string{"reboot", "reload"}}) + // Wait until the server has the report. + for i := 0; ; i++ { + if d := displayByName(t, alice, room.ID, boxD.Id); d.Box != nil { + break + } + if i > 50 { + t.Fatal("box report never arrived") + } + time.Sleep(20 * time.Millisecond) + } + + path := func(id, action string) string { return "/rooms/" + room.ID + "/displays/" + id + "/" + action } + + // Only someone who may manage the screens: bob is in the room, but + // isn't its owner and didn't pair them. + for _, action := range []string{"reload", "restart"} { + if r := bob.do("POST", path(boxD.Id, action), nil); r.status != http.StatusForbidden { + t.Fatalf("bob %s: %d", action, r.status) + } + } + // A plain screen only reloads. + if r := alice.do("POST", path(plainD.Id, "restart"), nil); r.status != http.StatusConflict || r.code() != "not_a_box" { + t.Fatalf("restarting a plain screen: %d %s", r.status, r.code()) + } + // Not through another room. + if r := alice.do("POST", "/rooms/"+other.ID+"/displays/"+boxD.Id+"/reload", nil); r.status != http.StatusNotFound { + t.Fatalf("reload through another room: %d", r.status) + } + + alice.want(http.StatusAccepted, "POST", path(boxD.Id, "restart"), nil) + alice.want(http.StatusAccepted, "POST", path(plainD.Id, "reload"), nil) + + var cmd httpapi.DisplayCommand + boxSock.await("display.command", &cmd) + if cmd.Action != httpapi.Restart { + t.Fatalf("box got %+v", cmd) + } + // The plain screen's first command is its own reload, not the box's restart. + plainSock.await("display.command", &cmd) + if cmd.Action != httpapi.Reload { + t.Fatalf("plain screen got %+v", cmd) + } + + // Nobody else saw a command: the member's socket and the other room's + // display get the next room event without one in between. + bob.want(http.StatusAccepted, "POST", "/rooms/"+room.ID+"/reactions", httpapi.ReactionRequest{Emoji: "🔥"}) + for { + ev := member.next() + if ev.Type == "display.command" { + t.Fatal("a member's socket got a display command") + } + if ev.Type == "reaction.sent" { + break + } + } + alice.want(http.StatusAccepted, "POST", "/rooms/"+other.ID+"/reactions", httpapi.ReactionRequest{Emoji: "🎉"}) + for { + ev := otherSock.next() + if ev.Type == "display.command" { + t.Fatal("another room's display got a command") + } + if ev.Type == "reaction.sent" { + break + } + } + + // Offline: nothing to send to. + _ = plainSock.c.CloseNow() + for i := 0; ; i++ { + if d := displayByName(t, alice, room.ID, plainD.Id); !d.Online { + break + } + if i > 50 { + t.Fatal("plain screen never went offline") + } + time.Sleep(20 * time.Millisecond) + } + if r := alice.do("POST", path(plainD.Id, "reload"), nil); r.status != http.StatusConflict || r.code() != "display_offline" { + t.Fatalf("reloading an offline screen: %d %s", r.status, r.code()) + } +} diff --git a/server/internal/httpapi/displays.go b/server/internal/httpapi/displays.go index 0775dfb..a3d5fc9 100644 --- a/server/internal/httpapi/displays.go +++ b/server/internal/httpapi/displays.go @@ -277,7 +277,7 @@ func (s *Server) GetDisplay(ctx context.Context, _ GetDisplayRequestObject) (Get if err != nil { return nil, err } - return GetDisplay200JSONResponse{Display: toDisplay(d.Display), Room: toRoom(room)}, nil + return GetDisplay200JSONResponse{Display: s.toDisplay(d.Display), Room: toRoom(room)}, nil } // LeaveDisplay unpairs this device. @@ -301,7 +301,7 @@ func (s *Server) ListDisplays(ctx context.Context, req ListDisplaysRequestObject } out := make(ListDisplays200JSONResponse, len(ds)) for i, d := range ds { - out[i] = toDisplay(d) + out[i] = s.toDisplay(d) } return out, nil } @@ -320,7 +320,7 @@ func (s *Server) PairDisplay(ctx context.Context, req PairDisplayRequestObject) if err != nil { return nil, err } - return PairDisplay201JSONResponse(toDisplay(d)), nil + return PairDisplay201JSONResponse(s.toDisplay(d)), nil } // manageableDisplay returns one of a room's displays, if the caller may @@ -359,7 +359,7 @@ func (s *Server) UpdateDisplay(ctx context.Context, req UpdateDisplayRequestObje return nil, err } } - return UpdateDisplay200JSONResponse(toDisplay(d)), nil + return UpdateDisplay200JSONResponse(s.toDisplay(d)), nil } // UnpairDisplay removes a display: the room's owner, whoever paired it, @@ -372,6 +372,7 @@ func (s *Server) UnpairDisplay(ctx context.Context, req UnpairDisplayRequestObje if err := s.Auth.UnpairDisplay(ctx, d.ID); err != nil { return nil, err } + s.boxes.forget(d.ID) if err := s.Playback.Drop(ctx, d.RoomID, d.ID); err != nil { return nil, err } diff --git a/server/internal/httpapi/realtime.go b/server/internal/httpapi/realtime.go index 6e5674e..69f82be 100644 --- a/server/internal/httpapi/realtime.go +++ b/server/internal/httpapi/realtime.go @@ -68,7 +68,7 @@ func (s *Server) RoomSocket() http.Handler { writeError(w, r, auth.ErrForbidden) return } - rc.token, rc.paired, rc.display = c.Value, true, true + rc.token, rc.paired, rc.display, rc.displayID = c.Value, true, true, d.Display.ID } else { writeError(w, r, err) return @@ -123,6 +123,10 @@ type roomConn struct { room store.Room // sent is the last queue version the client has. sent int64 + // displayID is the paired display's ID: its own topic carries the + // commands meant for it alone (reload, restart), and it may send its + // box's status up the socket (ADR 0016). + displayID string // device is the client's device ID, if it said; player is the room's // speaker, as of the last playback state sent. The speaker sees what // a game round hides: its lock screen shows the song anyway. @@ -138,18 +142,39 @@ func (rc *roomConn) speaker() bool { func (rc *roomConn) serve(ctx context.Context) { s, roomID, userID := rc.s, rc.room.ID, rc.user.ID - // Server push only: reading just handles pings and close frames, and - // ctx ends when the client goes away. - ctx = rc.c.CloseRead(ctx) + if rc.paired { + // A paired display may send its box's status; anything else up + // the socket is ignored. ctx ends when the client goes away. + ctx = rc.readDisplay(ctx) + } else { + // Server push only: reading just handles pings and close frames, + // and ctx ends when the client goes away. + ctx = rc.c.CloseRead(ctx) + } // Subscribe before reading snapshots, so nothing falls in between. topics := []string{realtime.RoomTopic(roomID)} - if !rc.paired { + if rc.paired { + topics = append(topics, realtime.DisplayTopic(rc.displayID)) + } else { topics = append(topics, realtime.UserTopic(userID)) } sub := s.Bus.Subscribe(topics...) defer sub.Close() + // A paired display is online while any of its connections is open: + // the room's Screens list shows it. + if rc.paired { + if s.boxes.connect(rc.displayID) { + s.displaysChanged(roomID) + } + defer func() { + if s.boxes.disconnect(rc.displayID) { + s.displaysChanged(roomID) + } + }() + } + // A big screen watches the room without being in it: it doesn't // count toward who's here, or skip votes. if !rc.display { @@ -362,6 +387,10 @@ func (rc *roomConn) send(ctx context.Context, e realtime.Event) error { data = d case rooms.MembersChanged: data = RoomMembersChanged{RoomId: d.RoomID, UserId: d.UserID, Change: RoomMembersChangedChange(d.Change)} + case DisplaysChanged: + data = d + case DisplayCommand: + data = d default: slog.Error("realtime: no API form for event", "type", e.Type, "data", e.Data) return nil @@ -382,6 +411,25 @@ func (rc *roomConn) write(ctx context.Context, typ string, version int64, data a return wsjson.Write(ctx, rc.c, envelope{Type: typ, Version: version, Data: data}) } +// readDisplay reads a paired display's messages until the connection +// ends: its box's status, handed to the server. The returned context ends +// with the connection, like CloseRead's. +func (rc *roomConn) readDisplay(ctx context.Context) context.Context { + ctx, cancel := context.WithCancel(ctx) + rc.c.SetReadLimit(boxReportLimit) + go func() { + defer cancel() + for { + _, msg, err := rc.c.Read(ctx) + if err != nil { + return + } + rc.s.handleDisplayMessage(rc.room.ID, rc.displayID, msg) + } + }() + return ctx +} + // fail closes the connection after an unexpected error. func (rc *roomConn) fail(err error) { if websocket.CloseStatus(err) == -1 && !errors.Is(err, context.Canceled) { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 6292aa3..9b6c68d 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -119,6 +119,8 @@ type Server struct { reactions userLimiter chats userLimiter + // boxes is what the displays connected right now say about themselves. + boxes boxRegistry playlists playlistCache } @@ -437,6 +439,8 @@ var errorCodes = []struct { {ErrGuestsOff, http.StatusForbidden, "guests_off"}, {ErrDisplayNoAudio, http.StatusForbidden, "forbidden"}, {ErrDisplayOrphan, http.StatusForbidden, "forbidden"}, + {ErrDisplayOffline, http.StatusConflict, "display_offline"}, + {ErrNotABox, http.StatusConflict, "not_a_box"}, {nights.ErrNotFound, http.StatusNotFound, "not_found"}, {nights.ErrForbidden, http.StatusForbidden, "forbidden"}, {nights.ErrNotHost, http.StatusForbidden, "forbidden"}, diff --git a/server/internal/realtime/realtime.go b/server/internal/realtime/realtime.go index 5597ed0..cc6c8e0 100644 --- a/server/internal/realtime/realtime.go +++ b/server/internal/realtime/realtime.go @@ -60,6 +60,13 @@ const ( ChatMessage = "chat.message" // ChatMessageDeleted carries a chat message that was deleted. ChatMessageDeleted = "chat.deleted" + // DisplaysUpdated says one of a room's displays connected, went away, + // or reported something new about its box (ADR 0016). It carries the + // room's ID. + DisplaysUpdated = "displays.updated" + // DisplayCommand carries a command for one display (reload, restart), + // on that display's own topic. + DisplayCommand = "display.command" ) // Event is something that happened. Data holds domain values (store rows, @@ -77,6 +84,10 @@ func RoomTopic(roomID string) string { return "room:" + roomID } // UserTopic is the topic for events for one user, wherever they are. func UserTopic(userID string) string { return "user:" + userID } +// DisplayTopic is the topic for events for one paired display: only its +// own connections subscribe. +func DisplayTopic(displayID string) string { return "display:" + displayID } + // Bus delivers events to subscribers of a topic. type Bus interface { // Publish delivers e to the topic's current subscribers. It never diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index a3df3a0..f81f789 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -1981,6 +1981,62 @@ export interface paths { patch: operations["updateDisplay"]; trace?: never; }; + "/rooms/{roomId}/displays/{displayId}/reload": { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + displayId: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reload a display's page + * @description Remote care from the Screens list (ADR 0016): the server tells that + * display, and only that display, over its room socket, to reload. A + * Syncphony box restarts its browser through its bridge; any other + * screen reloads the page. `display_offline` if the display isn't + * connected right now. The room's owner, whoever paired it, or an + * admin. + */ + post: operations["reloadDisplay"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/rooms/{roomId}/displays/{displayId}/restart": { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + displayId: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Restart a Syncphony box + * @description Reboots a Syncphony box (ADR 0016): the server tells that display, + * and only that display, over its room socket, and its page asks the + * box's bridge to reboot. Only for a box that says it can + * (`not_a_box` otherwise: a plain screen only reloads), and only + * while it's connected (`display_offline`). The room's owner, + * whoever paired it, or an admin. + */ + post: operations["restartDisplay"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/rooms/{roomId}/displays/pairing/{code}": { parameters: { query?: never; @@ -5070,6 +5126,79 @@ export interface components { createdAt: string; /** Format: date-time */ lastSeenAt: string; + /** @description Its room socket is connected right now. */ + online: boolean; + box?: components["schemas"]["BoxStatus"]; + }; + /** + * @description What a Syncphony box last reported about itself on its room socket + * (ADR 0016), from its bridge's `/v1/info`. Kept in the server's + * memory only: a box reports again whenever it connects. Absent for + * a screen that isn't a box, or a box that hasn't reported since the + * server started. + */ + BoxStatus: { + /** @description The box software's version, like `v1.3.0`. */ + version: string; + /** @description What the box calls itself (its `syncphony.txt` name). */ + name?: string; + /** @description What its bridge can do, like `reboot`, `reload`, `cec`. */ + capabilities: string[]; + /** Format: double */ + temperatureC?: number; + /** @description The board is slowing itself down right now (heat or power). */ + throttled?: boolean; + /** @description The power supply is too weak right now. */ + underVoltage?: boolean; + /** @description It has throttled or been under-voltage since it booted. */ + throttleOccurred?: boolean; + /** Format: int64 */ + uptimeSeconds?: number; + update?: components["schemas"]["BoxUpdate"]; + /** + * @description Its software is older than this server expects (the server's + * minimum box version, in ADR 0016). Unknown versions (a dev + * build) aren't outdated. + */ + outdated: boolean; + /** Format: date-time */ + reportedAt: string; + }; + /** @description The box's last nightly update. */ + BoxUpdate: { + /** Format: date-time */ + at: string; + /** @enum {string} */ + result: "updated" | "current" | "failed"; + /** @description Why it failed, when it did. */ + error?: string; + }; + /** + * @description Sent by a paired display that is a Syncphony box, up its room + * socket, as `{"type": "box.status", "data": BoxStatusReport}`: its + * bridge's `/v1/info`, as is. Fields the server doesn't know are + * ignored. + */ + BoxStatusReport: { + version: string; + name?: string; + capabilities?: string[]; + /** Format: double */ + temperatureC?: number; + throttled?: boolean; + underVoltage?: boolean; + throttleOccurred?: boolean; + /** Format: int64 */ + uptimeSeconds?: number; + update?: components["schemas"]["BoxUpdate"]; + }; + /** @description A command for one display, from someone caring for it remotely. */ + DisplayCommand: { + /** @enum {string} */ + action: "reload" | "restart"; + }; + DisplaysChanged: { + roomId: string; }; PairDisplayRequest: { /** @description The code the display shows. Case, spaces and dashes don't matter. */ @@ -5374,8 +5503,8 @@ export interface components { }; /** * @description A message on the room WebSocket, `GET /ws/rooms/{roomId}?since=`. - * The socket needs the session cookie and is server-push only: send - * changes over the REST API. + * The socket needs the session cookie and is server-push only (but for + * a box's status, below): send changes over the REST API. * * `data` by `type`: * - `hello`: RoomHello. Always first. @@ -5405,11 +5534,22 @@ export interface components { * done. Sent after hello for each that's up. * - `chat.message`: ChatMessage. Someone sent a message to the room. * - `chat.deleted`: ChatMessageDeleted. A message was deleted. + * - `displays.updated`: DisplaysChanged. One of the room's displays + * connected, went away, or reported something new about its box: + * fetch the displays again. + * - `display.command`: DisplayCommand. To one paired display only: + * reload, or (a box) restart. See reloadDisplay and restartDisplay. * * A paired display connects with its display cookie instead, to its * own room only. So does a signed-in user with `display=1`: either way * the connection doesn't count as being in the room. * + * The one message a client may send: a paired display that is a + * Syncphony box sends `{"type": "box.status", "data": BoxStatusReport}` + * when it connects and whenever its status changes (ADR 0016). The + * server keeps the latest per display, for the room's Screens list. + * Anything else, and anything from other connections, is ignored. + * * Close code 1013 (try again later) means the client fell behind and * missed events: reconnect with `since` set to the last version seen. * Close code 4001 means the session ended; sign in again. Close code @@ -5420,7 +5560,7 @@ export interface components { */ RoomEvent: { /** @enum {string} */ - type: "hello" | "queue.updated" | "nowplaying.updated" | "playback.notice" | "member.joined" | "member.left" | "link.status" | "room.updated" | "room.deleted" | "reaction.sent" | "hearts.updated" | "night.ended" | "guests.updated" | "members.updated" | "game.round" | "game.scores" | "game.queue" | "chat.message" | "chat.deleted"; + type: "hello" | "queue.updated" | "nowplaying.updated" | "playback.notice" | "member.joined" | "member.left" | "link.status" | "room.updated" | "room.deleted" | "reaction.sent" | "hearts.updated" | "night.ended" | "guests.updated" | "members.updated" | "game.round" | "game.scores" | "game.queue" | "chat.message" | "chat.deleted" | "displays.updated" | "display.command"; /** * Format: int64 * @description Queue version, on `queue.updated` only. @@ -8920,6 +9060,50 @@ export interface operations { default: components["responses"]["Error"]; }; }; + reloadDisplay: { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + displayId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sent to the display */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; + restartDisplay: { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + displayId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sent to the box */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; lookupDisplayPairing: { parameters: { query?: never; diff --git a/web/src/components/room/big-screen-dialog.tsx b/web/src/components/room/big-screen-dialog.tsx index 2fb1498..3c5b3e2 100644 --- a/web/src/components/room/big-screen-dialog.tsx +++ b/web/src/components/room/big-screen-dialog.tsx @@ -1,33 +1,51 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { MonitorPlay, Speaker, Trash2, X } from 'lucide-react' +import { CircleAlert, ExternalLink, MonitorPlay, Power, RotateCw, Speaker, Trash2, X } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { Dialog } from 'radix-ui' import { useEffect, useRef, useState, type FormEvent } from 'react' import { errorMessage } from '@/api/errors' +import { ConfirmButton } from '@/components/confirm-button' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { BigScreenLook } from './big-screen-look' import { useMe } from '@/lib/auth' -import { displayPairingLookupQuery, displaysQuery, pairDisplay, setDisplayAudio, unpairDisplay } from '@/lib/displays' +import { + boxSummary, + boxWarnings, + displayPairingLookupQuery, + displaysQuery, + pairDisplay, + reloadDisplay, + restartDisplay, + setDisplayAudio, + unpairDisplay, +} from '@/lib/displays' import { easeOutExpo } from '@/lib/motion' import { can } from '@/lib/playback' import type { Room } from '@/lib/room' import { relativeTime } from '@/lib/time' import { toast } from '@/lib/toast' +/** What a Syncphony box is and how to make one (docs/box.md). */ +const BOX_DOCS = 'https://github.com/madeofpendletonwool/Syncphony/blob/main/docs/box.md' + /** * Pairs a TV, projector or spare tablet with the room: open /tv on it, * type in the code it shows, and choose whether it plays the room's audio * too. A box (ADR 0016) says so while it waits, so the defaults fit it: * its name, and audio on — the choices here are still final. Lists the - * room's screens, turns their audio on and off, and unpairs them. + * room's screens, turns their audio on and off, and unpairs them. A box + * shows what it reported (version, temperature, warnings), and whoever may + * manage a screen can reload it, or restart a box, from here (MAD-811). */ export function BigScreenDialog({ room, open, onOpenChange }: { room: Room; open: boolean; onOpenChange: (open: boolean) => void }) { const me = useMe() const queryClient = useQueryClient() - const displays = useQuery({ ...displaysQuery(room.id), enabled: open }) + // Polled while open, so a box's temperature stays current; comings and + // goings arrive on the room socket (displays.updated). + const displays = useQuery({ ...displaysQuery(room.id), enabled: open, refetchInterval: open ? 30_000 : false }) const [code, setCode] = useState('') const [name, setName] = useState('') const [audio, setAudio] = useState(false) @@ -75,6 +93,13 @@ export function BigScreenDialog({ room, open, onOpenChange }: { room: Room; open onSuccess: () => void queryClient.invalidateQueries({ queryKey: displaysQuery(room.id).queryKey }), onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), }) + const care = useMutation({ + mutationFn: ({ id, action }: { id: string; name: string; action: 'reload' | 'restart' }) => + action === 'restart' ? restartDisplay(room.id, id) : reloadDisplay(room.id, id), + onSuccess: (_, { name, action }) => + toast({ message: action === 'restart' ? `${name} is restarting. It'll be back in about a minute.` : `${name} is reloading` }), + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) const submit = (e: FormEvent) => { e.preventDefault() if (code.trim()) pair.mutate() @@ -170,6 +195,16 @@ export function BigScreenDialog({ room, open, onOpenChange }: { room: Room; open Or show it on this device + + Make a dedicated box for this TV + + + {(displays.data?.length ?? 0) > 0 && ( @@ -178,36 +213,74 @@ export function BigScreenDialog({ room, open, onOpenChange }: { room: Room; open
    {displays.data?.map((d) => { const mayManage = me.role === 'admin' || room.ownerId === me.id || d.pairedBy === me.id + const warnings = d.box ? boxWarnings(d.box) : [] + const canRestart = !!d.box?.capabilities.includes('reboot') return ( -
  • - {d.audio ? ( - - ) : ( - - )} -
    -

    {d.name}

    -

    - {d.audio ? 'Screen and audio' : 'Screen only'} · Paired {relativeTime(d.createdAt)} -

    +
  • +
    + {d.audio ? ( + + ) : ( + + )} +
    +

    {d.name}

    +

    + {d.box + ? boxSummary(d.box, d.online) + : `${d.audio ? 'Screen and audio' : 'Screen only'} · ${d.online ? 'online' : `Paired ${relativeTime(d.createdAt)}`}`} +

    +
    + {mayManage && ( + !toggleAudio.isPending && toggleAudio.mutate({ id: d.id, on })} + label={`Play the audio on ${d.name}`} + /> + )} + {mayManage && ( + + )}
    - {mayManage && ( - !toggleAudio.isPending && toggleAudio.mutate({ id: d.id, on })} - label={`Play the audio on ${d.name}`} - /> + {warnings.length > 0 && ( +
      + {warnings.map((w) => ( +
    • + + {w} +
    • + ))} +
    )} - {mayManage && ( - + {mayManage && d.online && ( +
    + + {canRestart && ( + } + pending={care.isPending && care.variables?.id === d.id && care.variables.action === 'restart'} + onConfirm={() => care.mutate({ id: d.id, name: d.name, action: 'restart' })} + /> + )} +
    )}
  • ) diff --git a/web/src/lib/box-status.test.ts b/web/src/lib/box-status.test.ts new file mode 100644 index 0000000..cb8d7ba --- /dev/null +++ b/web/src/lib/box-status.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { boxSummary, boxWarnings, type BoxStatus } from './displays' + +const base: BoxStatus = { version: 'v1.3.0', capabilities: ['reboot', 'reload'], outdated: false, reportedAt: '2026-10-16T04:00:00Z' } + +describe('the Screens list on a box', () => { + it('sums a box up in one line', () => { + expect(boxSummary({ ...base, temperatureC: 52.4 }, true)).toBe('Syncphony Box 1.3 · online · 52 °C') + expect(boxSummary({ ...base, version: 'v1.3.2-4-gabc1234' }, true)).toBe('Syncphony Box 1.3.2 · online') + // An offline box's last temperature is stale: leave it out. + expect(boxSummary({ ...base, temperatureC: 52 }, false)).toBe('Syncphony Box 1.3 · offline') + }) + + it('warns about heat, a failed update and old software', () => { + expect(boxWarnings(base)).toEqual([]) + expect(boxWarnings({ ...base, temperatureC: 81 })).toEqual(['Running hot / throttled']) + expect(boxWarnings({ ...base, throttled: true })).toEqual(['Running hot / throttled']) + expect(boxWarnings({ ...base, underVoltage: true })).toEqual(['Power supply too weak']) + expect( + boxWarnings({ ...base, outdated: true, update: { at: '2026-10-16T04:03:00Z', result: 'failed', error: 'no network' } }), + ).toEqual(['Update failed', 'Box software older than this server expects']) + // A sticky "throttled since boot" alone isn't worth a warning. + expect(boxWarnings({ ...base, throttleOccurred: true })).toEqual([]) + }) +}) diff --git a/web/src/lib/box.test.ts b/web/src/lib/box.test.ts index d2607ff..0e7db78 100644 --- a/web/src/lib/box.test.ts +++ b/web/src/lib/box.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { box, boxName, boxVersion, isBox } from './box' +import { box, boxName, boxVersion, isBox, runDisplayCommand } from './box' describe('kiosk mode', () => { it('knows it when the URL says box', () => { @@ -45,10 +45,28 @@ describe('BoxBridge', () => { await vi.advanceTimersByTimeAsync(0) expect(box.info).toEqual({ version: '1.3.0', name: 'Living room TV', capabilities: ['cec', 'reboot'] }) await vi.advanceTimersByTimeAsync(60_000) - expect(urls('/v1/info')).toHaveLength(1) + // Once it answered: again every minute, for fresh status. + expect(urls('/v1/info')).toHaveLength(2) expect(urls('/v1/heartbeat')).toHaveLength(3) }) + it('tells listeners when the info changes, and only then', async () => { + let temp = 50 + fetchMock.mockImplementation(async (url) => + String(url).endsWith('/v1/info') + ? new Response(JSON.stringify({ version: '1.3.0', name: 'TV', capabilities: [], temperatureC: temp }), { status: 200 }) + : new Response(null, { status: 202 }), + ) + const seen: number[] = [] + const off = box.onInfo((i) => seen.push(i.temperatureC ?? 0)) + box.start() + await vi.advanceTimersByTimeAsync(60_000) + temp = 61 + await vi.advanceTimersByTimeAsync(60_000) + off() + expect(seen).toEqual([50, 61]) + }) + it('sends each playback change once, and the events carry the room', () => { fetchMock.mockResolvedValue(new Response(null, { status: 202 })) box.start() @@ -88,3 +106,44 @@ describe('BoxBridge', () => { expect(fetchMock).not.toHaveBeenCalled() }) }) + +describe('runDisplayCommand', () => { + const fetchMock = vi.fn() + const original = location.search + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + }) + afterEach(() => { + history.replaceState(null, '', original || '/') + vi.unstubAllGlobals() + }) + const posted = () => fetchMock.mock.calls.map(([url]) => String(url)) + + it('reloads a plain screen itself, and never asks a bridge', async () => { + history.replaceState(null, '', '/tv') + const reload = vi.fn() + await runDisplayCommand({ action: 'reload' }, reload) + await runDisplayCommand({ action: 'restart' }, reload) + expect(reload).toHaveBeenCalledTimes(1) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('has a box reload through boxd, and restart reboot it', async () => { + history.replaceState(null, '', '/tv?box=v1.0.0') + fetchMock.mockResolvedValue(new Response(null, { status: 202 })) + const reload = vi.fn() + await runDisplayCommand({ action: 'reload' }, reload) + await runDisplayCommand({ action: 'restart' }, reload) + expect(posted()).toEqual(['http://127.0.0.1:8099/v1/reload', 'http://127.0.0.1:8099/v1/reboot']) + expect(reload).not.toHaveBeenCalled() + }) + + it('reloads the page when boxd does not take it', async () => { + history.replaceState(null, '', '/tv?box=v1.0.0') + fetchMock.mockRejectedValue(new Error('connection refused')) + const reload = vi.fn() + await runDisplayCommand({ action: 'reload' }, reload) + expect(reload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/lib/box.ts b/web/src/lib/box.ts index 2764074..7cc1fb9 100644 --- a/web/src/lib/box.ts +++ b/web/src/lib/box.ts @@ -7,14 +7,28 @@ const BOX_URL = 'http://127.0.0.1:8099' const HEARTBEAT_EVERY = 30_000 +// Once boxd answered, /v1/info is asked again this often (every other +// heartbeat): the temperature and the last update change, and the room's +// Screens list shows them (MAD-811). +const INFO_EVERY_BEATS = 2 export type BoxEvent = 'playing' | 'paused' | 'idle' | 'paired' | 'unpaired' -/** What boxd says about the box, once it answered /v1/info. */ +/** + * What boxd says about the box, once it answered /v1/info. The page sends + * it, as is, up its room socket for the Screens list (BoxStatusReport in + * the API; fields past the first three may be absent). + */ export type BoxInfo = { version: string name: string capabilities: string[] + temperatureC?: number + throttled?: boolean + underVoltage?: boolean + throttleOccurred?: boolean + uptimeSeconds?: number + update?: { at: string; result: 'updated' | 'current' | 'failed'; error?: string } } /** The box software's version, from ?box= — present means this page runs on a box. */ @@ -36,6 +50,8 @@ export function boxName(search = location.search) { class BoxBridge { private timer?: ReturnType + private beats = 0 + private listeners = new Set<(info: BoxInfo) => void>() // The last event sent, so repeats (re-renders, StrictMode) stay quiet. private last?: { type: BoxEvent; roomId?: string } /** What boxd said about itself, once it answered. */ @@ -46,8 +62,9 @@ class BoxBridge { if (this.timer) return this.timer = setInterval(() => { this.beat() - // boxd may come up after the page: keep asking until it answers. - if (!this.info) void this.probe() + // boxd may come up after the page: keep asking until it answers, + // then now and then for fresh status. + if (!this.info || ++this.beats % INFO_EVERY_BEATS === 0) void this.probe() }, HEARTBEAT_EVERY) void this.probe() this.beat() @@ -58,6 +75,13 @@ class BoxBridge { this.timer = undefined this.info = undefined this.last = undefined + this.beats = 0 + } + + /** Calls back with boxd's info whenever it answers with something new. */ + onInfo(cb: (info: BoxInfo) => void) { + this.listeners.add(cb) + return () => void this.listeners.delete(cb) } /** The room's playback changed: what the box uses for CEC and health. */ @@ -75,13 +99,13 @@ class BoxBridge { this.event('unpaired') } - /** Remote care (ADR 0016, stage 4): reboot the box, or reload its page. */ + /** Remote care (ADR 0016, stage 4): reboot the box, or reload its page. Whether boxd took it. */ async reboot() { - await this.post('/v1/reboot') + return this.post('/v1/reboot') } async reload() { - await this.post('/v1/reload') + return this.post('/v1/reload') } private event(type: BoxEvent, roomId?: string) { @@ -99,7 +123,11 @@ class BoxBridge { private async probe() { try { const r = await fetch(BOX_URL + '/v1/info') - if (r.ok) this.info = (await r.json()) as BoxInfo + if (!r.ok) return + const info = (await r.json()) as BoxInfo + if (JSON.stringify(info) === JSON.stringify(this.info)) return + this.info = info + this.listeners.forEach((cb) => cb(info)) } catch { // No boxd, or it's busy: a normal /tv. } @@ -107,15 +135,34 @@ class BoxBridge { private async post(path: string, body?: object) { try { - await fetch(BOX_URL + path, { + const r = await fetch(BOX_URL + path, { method: 'POST', headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, }) + return r.ok } catch { // boxd is allowed to be missing. + return false } } } export const box = new BoxBridge() + +export type DisplayCommand = { action: 'reload' | 'restart' } + +/** + * A command for this screen from the room's Screens list (MAD-811). A box + * reloads by restarting its browser through boxd (a clean slate), and + * restarts by rebooting; if boxd doesn't take a reload, or this isn't a + * box, the page reloads itself. + */ +export async function runDisplayCommand(cmd: DisplayCommand, reloadPage = () => location.reload()) { + if (cmd.action === 'restart') { + if (isBox()) await box.reboot() + return + } + if (isBox() && (await box.reload())) return + reloadPage() +} diff --git a/web/src/lib/displays.ts b/web/src/lib/displays.ts index 1031f1f..fbd81e0 100644 --- a/web/src/lib/displays.ts +++ b/web/src/lib/displays.ts @@ -70,3 +70,37 @@ export function leaveDisplay() { export function formatPairingCode(code: string) { return code.length === 6 ? `${code.slice(0, 3)} ${code.slice(3)}` : code } + +/** Remote care (MAD-811): reloads a screen's page; a box restarts its browser. */ +export function reloadDisplay(roomId: string, displayId: string) { + return unwrap(api.POST('/rooms/{roomId}/displays/{displayId}/reload', { params: { path: { roomId, displayId } } })) +} + +/** Reboots a Syncphony box. */ +export function restartDisplay(roomId: string, displayId: string) { + return unwrap(api.POST('/rooms/{roomId}/displays/{displayId}/restart', { params: { path: { roomId, displayId } } })) +} + +export type BoxStatus = components['schemas']['BoxStatus'] + +/** A box warms past this and the Screens list says it runs hot (the Pi's soft limit). */ +const HOT_C = 80 + +/** What's worth warning about on a box, worst first. */ +export function boxWarnings(b: BoxStatus): string[] { + const out: string[] = [] + if (b.throttled || b.underVoltage || (b.temperatureC ?? 0) >= HOT_C) { + out.push(b.underVoltage ? 'Power supply too weak' : 'Running hot / throttled') + } + if (b.update?.result === 'failed') out.push('Update failed') + if (b.outdated) out.push('Box software older than this server expects') + return out +} + +/** "Syncphony Box 1.3 · online · 52 °C" */ +export function boxSummary(b: BoxStatus, online: boolean) { + const v = b.version.replace(/^v/, '').replace(/-.*$/, '').replace(/\.0$/, '') + const parts = [`Syncphony Box ${v}`, online ? 'online' : 'offline'] + if (online && b.temperatureC !== undefined) parts.push(`${Math.round(b.temperatureC)} °C`) + return parts.join(' · ') +} diff --git a/web/src/lib/room-socket.ts b/web/src/lib/room-socket.ts index 3b522fa..b5c8271 100644 --- a/web/src/lib/room-socket.ts +++ b/web/src/lib/room-socket.ts @@ -2,10 +2,11 @@ import { useQueryClient, type QueryClient } from '@tanstack/react-query' import { useEffect, useRef } from 'react' import type { components } from '@/api/schema.gen' import { invitesQuery, membersQuery } from './access' +import { box, isBox, runDisplayCommand, type BoxInfo, type DisplayCommand } from './box' import { chatQuery, dropChatMessage, putChatMessage, type ChatMessage } from './chat' import { meQuery } from './auth' import { syncServerClock } from './clock' -import { displayMeQuery } from './displays' +import { displayMeQuery, displaysQuery } from './displays' import { playRoundClips } from './game-clips' import { gameRoundQuery, gameScoresQuery, type GameRound, type GameScores } from './games' import { guestPassQuery, guestsQuery } from './guests' @@ -80,6 +81,11 @@ export function useRoomSocket(roomId: string | undefined, { display = false, onS const url = `${proto}://${location.host}/ws/rooms/${encodeURIComponent(roomId)}${qs ? `?${qs}` : ''}` const sock = new WebSocket(url) ws = sock + // A box tells the room what it is, for the Screens list (MAD-811): + // as soon as it's connected, and whenever boxd says something new. + sock.onopen = () => { + if (reportBox && box.info) sendBoxStatus(sock, box.info) + } sock.onmessage = (e) => { attempt = 0 handle(queryClient, roomId, JSON.parse(e.data as string) as RoomEvent) @@ -116,10 +122,13 @@ export function useRoomSocket(roomId: string | undefined, { display = false, onS } document.addEventListener('visibilitychange', wake) window.addEventListener('online', wake) + const reportBox = display && isBox() + const offInfo = reportBox ? box.onInfo((info) => ws && sendBoxStatus(ws, info)) : undefined connect() return () => { closed = true + offInfo?.() clearTimeout(retry) document.removeEventListener('visibilitychange', wake) window.removeEventListener('online', wake) @@ -128,6 +137,11 @@ export function useRoomSocket(roomId: string | undefined, { display = false, onS }, [roomId, display, device, queryClient]) } +/** A box's status, up the socket (BoxStatusReport in the API). */ +function sendBoxStatus(sock: WebSocket, info: BoxInfo) { + if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ type: 'box.status', data: info })) +} + function handle(queryClient: QueryClient, roomId: string, ev: RoomEvent) { switch (ev.type) { case 'hello': { @@ -238,6 +252,15 @@ function handle(queryClient: QueryClient, roomId: string, ev: RoomEvent) { } break } + case 'displays.updated': + // A screen came or went, or a box said something new: an open + // Screens list refetches. + void queryClient.invalidateQueries({ queryKey: displaysQuery(roomId).queryKey }) + break + case 'display.command': + // Only ever sent to this display: reload, or (a box) restart. + void runDisplayCommand(ev.data as DisplayCommand) + break case 'link.status': { const link = ev.data as ServiceLink queryClient.setQueryData(linksQuery.queryKey, (ls) => ls?.map((l) => (l.id === link.id ? link : l)))