Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 185 additions & 4 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -7941,8 +8111,8 @@ components:
type: object
description: |
A message on the room WebSocket, `GET /ws/rooms/{roomId}?since=<version>`.
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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
44 changes: 42 additions & 2 deletions docs/adr/0016-syncphony-box.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <that 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": <the bridge's /v1/info, as is>}`.
`/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.
Expand Down
56 changes: 56 additions & 0 deletions docs/box.md
Original file line number Diff line number Diff line change
@@ -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).
Loading
Loading