Metadata-Version: 2.5
Name: sofabaton-x-server
Version: 0.2.0
Summary: REST + WebSocket server over the sofabaton-x library for Sofabaton X1 / X1S / X2 hubs
Project-URL: Homepage, https://github.com/m3tac0de/home-assistant-sofabaton-x1s/tree/main/sofabaton-x-server
Project-URL: Issues, https://github.com/m3tac0de/home-assistant-sofabaton-x1s/issues
Author: m3tac0de
License: MIT
Keywords: home-automation,openapi,rest,sofabaton,universal-remote,websocket,x1,x1s,x2
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Requires-Python: >=3.11
Requires-Dist: fastapi>=0.115
Requires-Dist: sofabaton-x<0.3,>=0.2
Requires-Dist: uvicorn[standard]>=0.30
Provides-Extra: dev
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: websockets>=12; extra == 'dev'
Description-Content-Type: text/markdown

# sofabaton-x-server

> **0.2.0 is the first release.** The API is versioned (`api 1`) and the
> OpenAPI document is committed; before 1.0 a minor release may still
> change the surface, and the release notes say when it does.

REST + WebSocket server over the [sofabaton-x](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x/README.md)
library for **Sofabaton X1 / X1S / X2** hubs, with a built-in management UI
and web remote. Register hubs, browse commands, try the remote and inspect
events in a browser. Automation platforms (Homey, Hubitat, openHAB, …)
connect to the same HTTP/WebSocket API; the server manages the hub
connections and persistence.

The current version is **0.2.0**, built against sofabaton-x 0.2.x. It
covers hub discovery and management, reads and control, the event
stream, button events, configuration editing, IR payloads, backup /
restore / erase, a web remote and a control panel.

**Building your first integration? Start with
[your first integration](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x-server/docs/getting-started.md).**
Let users configure hubs in the control panel. Your client can focus on
activity switches, command actions and remote-button automations, with
links to management and the web remote. Discovery wizards and configuration
editors are optional; a runnable starter and a Hubitat example show the path.

> Unofficial; not affiliated with or endorsed by Sofabaton.

[Starter guide](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x-server/docs/getting-started.md) · [Run](#run) · [Control panel](#control-panel) · [Web remote](#web-remote) · [Settings](#settings) · [API](#api) · [Jobs](#jobs) ·
[Writes](#writes) · [Recovery](#recovery-and-retention) ·
[Button events](#button-events) · [Development](#development)

## Run

**Run one server for all your hubs.** Register each hub in its control
panel; all hubs share the same server URL and WebSocket endpoint.

**Close the official Sofabaton app on all phones/tablets before initial
setup.** A hub connected directly to the app stops advertising, so the
server cannot discover it. Keep the app closed until the hub is registered
and you have tested control. Disable any existing proxy for that hub first.

Install from PyPI (Python 3.11+; the library comes with it):

```
python -m pip install "sofabaton-x-server>=0.2,<0.3"
sofabaton-x-server
```

From a checkout, install both packages from the repository root instead:
`python -m pip install . ./sofabaton-x-server`.

Open the control panel at `http://<server>:8480/` (it lives at `/ui/`).
Use `localhost` when browsing on the server host. Its Hubs view lists the
hubs advertised on the LAN with an Add button, takes an address by hand,
and enables, disables and removes hubs later (see
[Control panel](#control-panel)). If the hub is missing, make sure the app
is fully closed and scan again. Keep the data directory (default `./data`)
across restarts. `--hub <physical IP>` is an alternative for seeding the
first startup, not for adding hubs to an existing data directory.

After setup, the app can connect through the proxy; the server then observes
the session but refuses control commands until the app disconnects.

The server must sit on the same network segment as the phones running
the official app (mDNS and UDP broadcast); in Docker that means host
networking on a Linux host. Ports on the host: TCP 8200 (hub connect-
back, shared by all hubs), UDP 8102 (app discovery), UDP 5353 (mDNS),
and the API port.

### Docker

Build from the repository root (both distributions come from one repo)
or use the compose file next to this README:

```
docker build -f sofabaton-x-server/Dockerfile -t sofabaton-x-server .
docker run -d --name sofabaton-x-server --network host -v ./data:/data \
  sofabaton-x-server
```

```
cd sofabaton-x-server && docker compose up -d
```

The supplied Compose file uses Linux **host networking** so mDNS, the
app's UDP broadcast and the hub's TCP dial-back can reach the LAN interface.
Docker Desktop compatibility with this project's discovery and dial-back
requirements is unverified; this is a Linux deployment recipe.

Callback devices also need the hub to reach the separate HTTP callback
listener (TCP 8060 by default). A bridge deployment would need a reachable
`SOFABATON_CALLBACK_HOST` and callback-port publication, as well as working
discovery and hub dial-back; those two callback settings alone are not a
complete bridge-network deployment recipe.

`GET /api/v1/hubs/{hub_id}/callback-device` reports `target`, the address
already written to that device, and `effective_destination`, the address a
new deploy would use with the current settings. These can differ after a
settings change. `GET /api/v1/server` reports callback listener state, not
`effective_destination`. `/data` holds `hubs.json`, `server.json`, one
`state-<hub_id>.json` cache document per hub, and the apply records described
below.

### Behind a reverse proxy (TLS)

Terminate TLS in a reverse proxy; that is where certificates are
manageable. Three things to set on the server, then a snippet per proxy.

- `--advertise-url https://sofabaton.home.example`: what clients must
  use. Published in the mDNS TXT record as `base_url` and as the OpenAPI
  document's server URL, so discovery and generated clients both point
  at the proxy.
- `--trusted-proxy 127.0.0.1` (or the proxy's address): honours
  `X-Forwarded-*` from that source, so control-call logs show the real
  client and redirects keep the public scheme.
- `--bind 127.0.0.1` when the proxy runs on the same host, so plain
  HTTP is not also reachable directly.

Caddy (WebSocket upgrade is automatic):

```
sofabaton.home.example {
    reverse_proxy 127.0.0.1:8480
}
```

nginx:

```
location / {
    proxy_pass         http://127.0.0.1:8480;
    proxy_http_version 1.1;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    # /api/v1/events is a WebSocket:
    proxy_set_header   Upgrade           $http_upgrade;
    proxy_set_header   Connection        "upgrade";
    proxy_read_timeout 3600s;
}
```

Mounting under a prefix (`https://home.example/sofabaton/`): configure the
proxy to strip `/sofabaton` when forwarding, add `--root-path /sofabaton`,
and set `--advertise-url https://home.example/sofabaton`. The API then lives
at `https://home.example/sofabaton/api/v1`; do not include `/api/v1` in
`--advertise-url`.

Exposing the server beyond the LAN through a proxy means the **proxy
must add authentication** (forward-auth or basic auth): the server has
none in v1. Configure authentication for both HTTP requests and WebSocket
upgrades, and ensure your clients support the proxy's authentication method.

## Settings

Defaults, then `server.json` in the data directory, then environment
variables, then flags; each layer overrides the one before.

| flag | environment | default | meaning |
| --- | --- | --- | --- |
| `--bind` | `SOFABATON_BIND` | `0.0.0.0` | address to listen on |
| `--port` | `SOFABATON_PORT` | `8480` | API port |
| `--data-dir` | `SOFABATON_DATA_DIR` | `./data` | `server.json`, `hubs.json` |
| `--hub HOST` (repeatable) | `SOFABATON_HUBS=a,b` | none | hubs registered on first start, only when `hubs.json` does not exist |
| `--advertise-url` | `SOFABATON_ADVERTISE_URL` | none | public base URL behind a reverse proxy; published as mDNS TXT `base_url` and as the OpenAPI `servers[0].url` |
| `--root-path` | `SOFABATON_ROOT_PATH` | none | path prefix a reverse proxy mounts the API under |
| `--trusted-proxy ADDR` (repeatable) | `SOFABATON_TRUSTED_PROXIES=a,b` | none | sources whose `X-Forwarded-*` headers are honoured |
| `--tls-cert` / `--tls-key` | `SOFABATON_TLS_CERT` / `_KEY` | none | bring your own certificate (a reverse proxy is the usual way) |
| `--callback-host` | `SOFABATON_CALLBACK_HOST` | routed local IP per hub | IPv4 address the hubs call back on for callback devices (see Button events); set the host's LAN address inside a container on a bridge network |
| `--callback-port` | `SOFABATON_CALLBACK_PORT` | `8060` | port of the callback listener; the X1 can call no other |
| no flag (`server.json`: `apply_keep`) | `SOFABATON_APPLY_KEEP` | `20` | retained terminal apply records per hub, including stopped/cancelled ones |
| `--log-level` | `SOFABATON_LOG_LEVEL` | `info` | |

For example, save this as `server.json` in the selected data directory:

```json
{
  "port": 8480,
  "callback_port": 8060,
  "initial_hubs": ["192.168.1.50"],
  "apply_keep": 20,
  "log_level": "info"
}
```

Choose that directory with `--data-dir` or `SOFABATON_DATA_DIR` before the
file is loaded. An existing empty `hubs.json` is respected: seed hubs are
not re-added. `--print-settings` prints effective settings and exits.

## Security

**No authentication in v1.** The server is a LAN service in the same
class as the hub protocol it fronts: anyone who can reach the port can
read the catalogs, send commands and change the hub's
configuration, including `POST /hubs/{id}/erase` and a replacing
restore. Do not expose it beyond your LAN; never through NAT or an
internet-facing reverse proxy without the proxy adding authentication.
Control calls are logged with the caller's address. Configuration writes
run as jobs whose records name the operation; transient control and IR
play calls return their acceptance immediately.

## Control panel

Open `<server base URL>/ui/`. The root `/` and legacy `/harness` redirect
there. The sidebar lists registered hubs and their state.

| View | What users can do |
| --- | --- |
| **Hubs** | Add discovered hubs or enter an address; inspect status; enable, disable, retry a failed start or remove a registration. Removal also forgets its cached state and remote layout. |
| **Catalog** | Browse devices, activities, commands, buttons, macros and favorites with their IDs. Fetch missing detail or explicitly refresh one entity or the whole hub. It does not edit configuration. |
| **Remote** | Control the selected hub and edit its saved remote layout. |
| **API** | Select an OpenAPI operation or enter a method/path, send a request, inspect the response and follow a returned job. `{hub_id}` uses the selected hub. |
| **Events** | Inspect the live WebSocket stream, filter by hub/text and identify callback presses. It reconnects after a server restart. |

There is no dedicated callback-device, binding or full-configuration editor
in 0.2.0. Use the starter's setup command or the API view for those writes;
deployed callback commands can also be assigned in the official app.

The panel supports light and dark themes. Like the API, it has no built-in
authentication: anyone who can open it can control and change the hub.
The UI itself is outside the API contract. See [Development](#development)
for rebuilding its bundled assets.

## Web remote

The server serves the Sofabaton remote card as a page of its own at
`/ui/remote/`. The root `/` opens the control panel. It is the same card the Home
Assistant integration ships, talking to this server's API instead of
Home Assistant, so a household without Home Assistant gets a phone,
tablet or wall-panel remote by opening a URL. Bookmark it, add it to a
phone's home screen (it ships a web manifest), or frame it from a
dashboard that can show a URL (Hubitat, openHAB, Node-RED dashboards,
Home Assistant's own iframe card).

The page needs the hub in the URL: `/ui/remote/?hub=<hub id>`, the id
as `GET /hubs` lists it. Without it, or with an unknown id, the page
lists the registered hubs as links. Optional parameters: `lang=<bcp47>`
(the card's language; the browser's by default), `device=<device id>`
(open in device mode on that device), `zoom=<factor>` for a wall panel,
and `theme=light|dark` to pin a theme (the system setting by default).
The page carries the Home Assistant default palette, so it looks like
the card on a default Home Assistant dashboard; other themes are not
available outside Home Assistant.

**Configuration.** The card's layout (which key groups show, their
order, device mode, shortcuts, custom favourites, hold-to-repeat, key
style) is a per-hub JSON document the server stores:

```text
GET    /hubs/{id}/ui/remote-card        the document (null until one is stored)
PUT    /hubs/{id}/ui/remote-card        {"document": {...}} replaces it (64 KB max)
DELETE /hubs/{id}/ui/remote-card        back to the card's defaults
```

The document holds the same keys as the Home Assistant card's YAML,
minus `entity`, `theme` and Home Assistant actions (custom favourites
that call a Home Assistant action are dropped; those that name a hub
command stay). The control panel's Remote view (`/ui/`) shows the
remote next to an editor for this document and applies a saved document
to the remote at once; a Home Assistant user can paste the card's YAML
converted to JSON. The page never stores anything in the browser.

**Icons.** The page bundles the icons the card itself uses plus a set of
common `mdi:` names for favourites and shortcuts; an icon outside that
set renders as a neutral dot.

**Exposure.** The server has no authentication, and this page is the
first thing a household will want to reach from a phone. Keep it on the
LAN, or put the server behind a reverse proxy that authenticates (see
[Behind a reverse proxy](#behind-a-reverse-proxy-tls)); do not
port-forward it. The page and its assets are outside the API contract
(not in the OpenAPI document); the configuration document routes are in
it.

## API

For a minimal client, use hub selection, status, control and events as
described in the [starter guide](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x-server/docs/getting-started.md).
The sections below are a reference for optional features as well as the
core API; implementing the full surface is not required.

`GET /api/v1/server` identifies the server. The OpenAPI document is at
`/api/v1/openapi.json` (interactive docs at `/api/v1/docs`). Every
operation has a stable `operationId`, and public models are named
components. Job results and editable entity tables contain open objects;
clients must interpret them according to the operation. Errors are one shape,
`Problem` (`type`, `title`, `status`, `detail`, `hub_id`, `mode`).

Paths beginning `/hubs`, `/server` or `/events` below are relative to
`/api/v1`. `{id}` and `{hub_id}` both mean the registered hub ID, not a
device or activity ID. An ellipsis (`...`) abbreviates the preceding
hub/entity path; it is not an executable URL.
For an executable refresh/preview/apply workflow, see
[the integration guide](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x-server/docs/platform-integration.md#8-complete-edit-workflow).

Catalog reads (`.../activities`, `.../devices`, `.../devices/{did}/commands`,
`.../entities/{eid}/buttons`, `.../activities/{aid}/macros` and `/favorites`)
serve cached data when available and fetch missing detail when needed;
an uncached read therefore requires control mode. `GET .../devices?refresh=true`
re-reads the device list. `GET .../devices/{did}/power-state` is the one
read that always goes to the hub: it re-reads the list and returns that
device's power byte (`0` / `1`, `null` when the row has no parseable
record, `504` when the hub never answers), which is what a remote UI needs
before it fires a power toggle. `Button` rows carry the hub's long-press
pair (`long_press_device_id` / `long_press_command_id`, both `null` when
the button has none); send the pair like any other command.

## Snapshot

`GET /api/v1/hubs/{id}/snapshot` is the hub's structural configuration
(devices, activities, commands, bindings, macros, favorites; no IR
payloads) projected from the library's cache with **no hub traffic**.
`snapshot_id` identifies the configuration content and is stable when that
content survives a restart. Send this revision, quoted, as `If-Match` when
editing. The response's HTTP `ETag` is an opaque cache validator: retain it
verbatim for `If-None-Match`, which returns 304 only when the whole
representation is unchanged. Do not assume the ETag and configuration
revision are equal; provenance (`fetched_at`, `complete`, `editable`)
changes the ETag without changing the configuration revision, so a poll
that gets 200 with the same `snapshot_id` is a provenance change.
Every entity carries `complete`,
`editable` and `fetched_at`. These describe the server's copy, not the
hub: the hub can be edited outside this server at any time (the vendor
app, another client) and does not say so, so the server gives no
freshness verdict. Show `fetched_at` and offer a refresh; the
`app_state` event with `connected: false` (a vendor-app session through
the proxy just ended) is one good moment for that offer.

The library reads initial catalogs automatically when the hub connects.
`POST /hubs/{id}/snapshot/refresh` with
`{"device_id": 5}` or `{"activity_id": 101}` re-reads one entity; an
empty body re-reads the whole hub, which can take tens of seconds to
minutes depending on its configuration. Treat it as a user action.
Detailed refreshes are explicit; backups and write reconciliation also
read hub data. The server saves the library's state document on
`snapshot_changed` and when stopping a hub, and imports it before starting
the hub. This preserves previously fetched detail and its completeness
flags. A partial cache remains partial; absent or unreadable state starts
cold. Check `editable` before editing rather than assuming a restart made
the snapshot complete.

| Document | Use |
| --- | --- |
| Snapshot | Cached structural configuration; edit a copy and sync. It is not restorable. |
| Full backup bundle | Configuration plus command payloads; retain the whole bundle for restore. |
| State document | Opaque library cache persisted by the server; do not edit or submit it to restore. |
| Job | In-memory progress and outcome of one operation; lost on restart. |
| Apply record | Persistent documents, item outcomes and ID mappings for a document write; recovery and retention limits apply below. |

## Jobs

Anything that holds the hub for more than a moment answers `202` with a
job record: structural refresh, configuration writes, learn, backup,
restore and erase. Follow
it on the event stream (`job_event` messages carry the full record:
`status`, the last `progress`, the `result` or a `Problem` in `error`)
or poll `GET /hubs/{id}/jobs/{job_id}`; `GET /hubs/{id}/jobs` lists
recent ones. One job runs per hub at a time (`409 hub_job_running`). Reads
are not rejected merely because a job runs, but a read that needs hub
traffic can wait or fail; keep the hub idle during IR learning.
Check the job's `cancellable` field before requesting cancellation with
`DELETE /hubs/{id}/jobs/{job_id}`:

| Operation | Cancellable | Stopping point |
| --- | --- | --- |
| Whole-hub refresh | yes | after the entity in flight |
| Single-entity refresh | no | runs to completion |
| IR learn | yes | ends the capture wait |
| Whole-document `sync_hub` / `resume_apply` | yes | after draining the item in flight |
| Row edits, intents, callback writes, backup, restore, erase | no | runs to completion |

Cancellation can remain pending while the current entity/item finishes.
Repeating the request while cancellation is pending changes nothing. Wait
for terminal status before another operation; disable/remove is refused
while a job holds the hub. A graceful stop requests cancellation of
cancellable work and waits for non-cancellable writes, with a bounded drain
timeout. It does not guarantee completion after an abrupt process exit.

`202` means accepted, not successful. Terminal states are `done`, `failed`
and `cancelled`. On failure inspect both `error` and `result`, which may
describe partial changes. Job records are in memory and only recent ones
are retained; after a server restart, reconcile against a fresh snapshot
rather than assuming a lost job succeeded or failed.

## Writes

Two shapes, both jobs:

- **Intents** say what to change and the server derives the edit from
  the current snapshot: `POST .../activities/{aid}/rename`, `PUT
  .../activities/{aid}/buttons/{button}` (a code or a `ButtonName`
  alias such as `VOL_UP`, with an optional long press) and `DELETE` on
  the same path, `POST` / `DELETE` / `PUT .../favorites[/order]`,
  `POST .../devices/{did}/rename`, `POST .../commands/{cid}/rename`,
  `PUT .../devices/{did}/idle-behavior`; and the whole-entity ones:
  `POST /hubs/{id}/devices` (empty device of a class the hub can create), `POST
  /hubs/{id}/activities`, `DELETE /hubs/{id}/devices/{did}`, `DELETE /hubs/{id}/activities/{aid}`,
  `PUT /hubs/{id}/devices/order`, `PUT /hubs/{id}/activities/order`, `PUT /hubs/{id}/name`.
  `If-Match` is optional and honours the snapshot's `snapshot_id` revision.
- **Row edits** for an editor that works on the document: change one
  `activities[]` or `devices[]` element of the snapshot, preview with
  `POST .../plan`, then `PUT` it back with `If-Match` (required: `428`
  without it, `412` when the snapshot moved). Only the named entity may
  differ from the snapshot (`422 out_of_scope`).

`If-Match` compares the cached configuration revision. Sync-based row edits
and intents also re-read the target before writing, but compare only
device bindings/macros and activity bindings/macros/favorites, with
normalization exceptions. Names, payloads and device-head fields are not
fully compared. A detected difference fails with `sync_failed` at
`stale_check`. These routes use the library's `strict=False` default, so an
unreadable or incomplete preflight can allow the write to proceed. There is
no REST strict-mode option for row edits. Whole-document sync requires
complete live reads, but uses the same limited comparison tables.
Whole-entity operations (create, delete, reorder, hub rename, restore) use their own validation;
they do not all perform this live baseline comparison. Configuration writes
are refused up front while an app holds the hub (`409 hub_busy`).

The `device_class` on create is a protocol class, not an appliance category
such as TV or receiver:

| Hub | Creatable classes |
| --- | --- |
| X1 | `ir`, `wifi_roku`, `wifi_hue`, `wifi_sonos` |
| X1S | X1 classes plus `wifi_ip` |
| X2 | X1S classes plus `wifi_mqtt` |

Payloads and device fields must match the class/model. This table describes
implemented create support; see the [bench notes](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/docs/protocol/live-hub-testing.md)
for which workflows have been tested on hardware.

## Whole-document writes

An editor that changes many things at once puts the whole edited
snapshot back: `PUT /api/v1/hubs/{id}/snapshot` with the document
`GET /hubs/{id}/snapshot` returned, edited, and the quoted `snapshot_id` in `If-Match`
(required). New devices and activities carry a negative placeholder id
of the client's choosing (every reference to them uses the same negative
id; the hub assigns the real one and the result's `id_map` says which);
a removed entity must be removed from every activity in the same
document; array order is display order. `POST /hubs/{id}/snapshot/plan` previews
the ordered items without writing and performs structural validation
shared with `PUT` (`422 dangling_reference` / `out_of_scope` /
`invalid_request`, `409 entity_not_editable` / `snapshot_incomplete`).

A successful preview does not validate every command's wire encoding or
guarantee hub acceptance. The [integration guide](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x-server/docs/platform-integration.md#editing-the-whole-document)
shows the distinct REST payload and document `restore_data` formats.

The `PUT` answers `202` with a cancellable `sync_hub` job. The server
requires live reads of affected entities before the first write, then runs
items in order in one batch. Requested remote-sync triggers and snapshot
notifications are coalesced: at most one explicit trigger when required;
an unchanged document can finish without a change event. Follow the job's
terminal status to determine completion.

Apply records are saved under `data/applies/<hub_id>/` after each item and
when a created ID becomes known. Read them with `GET /hubs/{id}/applies`
or `GET /hubs/{id}/applies/{apply_id}`; `DELETE` on the latter forgets the
record, not the hub changes.

### Recovery and retention

| Outcome | Job status | Apply status | Next step |
| --- | --- | --- | --- |
| All items completed | `done` | `success` | adopt the resulting snapshot |
| Partial, uncertain or refused item | `failed`, error `apply_stopped` | `stopped` | inspect item outcomes and hub state before recovery |
| Cancellation drained by the apply runner | `cancelled` | `cancelled` | inspect the drained item's outcome before recovery |
| Abrupt server restart | in-memory job lost | last persisted status, possibly `queued`/`running` | refresh and reconcile; no automatic restart recovery |

`POST /hubs/{id}/applies/{apply_id}/resume` accepts stopped or cancelled
records. **Resume is not currently duplicate-safe for uncertain creates**:
lost acknowledgements/readback can cause a create to repeat, and a crash
checkpoint can omit an in-flight write. Do not automatically resume these
cases. Preserve the record, refresh and inspect the hub, and construct a
new edit from that reconciled state when the intended changes are clear.
Records left `queued` or `running` by an abrupt restart are not reconciled
on startup and the resume endpoint rejects them. See the
[library limitations](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x/README.md#current-document-write-limitations).

An `Idempotency-Key` can make a repeated `PUT` of the same document return
the existing job view (`200`), but **control and `If-Match` checks run before
key lookup**. The original retry can therefore return `412` after the first
write changes the revision. Check jobs and apply records after a timeout;
do not blindly resubmit the old document with a newer revision. Once those
checks pass, reusing a key with a different document returns
`409 apply_key_reused`. Keys cease to protect against repeated submission
when their apply record is deleted or pruned.

`apply_keep` (default 20) limits terminal records per hub: **`success`,
`stopped` and `cancelled` all count**, so even a resumable record can be
pruned. `queued`/`running` records are not automatically pruned. Export any
record needed for diagnosis before deleting it or allowing retention to
remove it.

## IR payloads, backup, restore

A code in any format your platform has (`{"pronto": ...}`,
`{"descriptor": "P:NEC1 D:4 S:5 F:21"}`, `{"timings_us": [...],
"carrier_hz": 38000}`, or the hub's own `{"hex": ...}`) can be fired
once with `POST /hubs/{id}/play`, saved as a new command with `POST
.../devices/{did}/commands`, or written over an existing one with `PUT
.../commands/{cid}/payload`; `GET .../commands/{cid}/payload` reads what
the hub holds. `POST /hubs/{id}/learn` arms the hub's receiver and
returns the captured code as the job result.

`POST /hubs/{id}/backup` returns a full, restorable bundle in the completed
job's `result.bundle` (minutes; keep that whole bundle as a file).
`POST /hubs/{id}/restore` with
`{"bundle": ..., "replace": true}` erases first and then writes the
bundle back. The bundle and its entity references are validated before erase.
With `replace` omitted or false, restore is additive and assigns new ids.
Structural snapshots and backups made with `include_blobs: false` are not
restorable. Keep the complete full-backup bundle, not just its job header.

`POST /hubs/{id}/erase` and a replacing restore are whole-hub destructive
operations. Device/activity deletion and payload replacement can also remove
existing configuration. A failed restore is not rolled back: inspect its
result (`failed_at`, restored counts, `device_id_map`, `snapshot_id`) and the
current snapshot before recovery. Automatically retrying an additive restore
can create duplicates. If a write request times out, check the hub's jobs
before submitting it again.

## Button events

The hubs never report presses of IR or Bluetooth commands, but a Wifi
device's commands call an address when pressed. The server turns that
into button events: it deploys a **callback device** on a hub, a managed
Wifi device whose commands call the server's own listener, and relays
every press to your platform.

```
POST /hubs/{id}/callback-device        {"name": "Server", "slots": [{"label": "Play"}, {"label": "Pause"}]}
GET  /hubs/{id}/callback-device        the record: device_id, labels, target, stale, effective_destination
PUT  /hubs/{id}/callback-device        rename slots or change the power / input hooks in place (a job)
DELETE /hubs/{id}/callback-device      remove it from the hub and forget it (409 while activities reference it; ?force=true)
POST /hubs/{id}/callback-device/redeploy   deploy a stale one again from its stored spec
GET  /hubs/{id}/presses?after=<seq>    the catch-up view of the press stream
GET  /server/callback-listener         the listener's state; POST .../retry tries to bind it now
```

`PUT /hubs/{id}/callback-device` replaces the **complete desired spec**.
Omitted slots become defaults; omitted power/input hooks are cleared. To
rename safely, copy `name`, `slots`, `power_on_slot`, `power_off_slot` and
`input_slots` from the GET response's `spec`, change the intended fields,
and PUT all five back. Preserved IDs and generic bindings do not imply
preservation of omitted spec fields. The integration guide includes a
[copy-and-edit example](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x-server/docs/platform-integration.md#10-button-events).

Hook slots are one-based (`1..10`); the callback URL uses a zero-based
index (`0..9`). The `press.slot` field is one-based when resolved.

Every deploy writes all ten slots (unnamed ones are `Button n`), each as
a short and a long press record: command ids `1..10` and `11..20`. Bind
them like any command with the generic routes (`PUT
/hubs/{id}/activities/{aid}/buttons/{button}`, favorites, activity membership); an
in-place update never touches those bindings. On the X1S and X2,
`power_on_slot` / `power_off_slot` fire when an activity powers on or
off and `input_slots` are offered as activity-start inputs; the X1
ignores both (its firmware fires one power and one input callback per
transition regardless) and always calls port 8060.

Presses arrive as `press` messages on `/events` and in `GET
/hubs/{id}/presses`. Both carry the same `seq`, a counter of this server
instance; de-duplicate across the two channels by it, and after a
reconnect or a `dropped` message fetch `?after=<last seq you saw>`.
`expired: true` means presses newer than that were already evicted from
the ring (100 per hub); accept the gap. The `hello` message and `GET
/server` carry an `instance_id`: when it changes the server restarted,
the ring is empty and the sequence started over. `resolution` says how a
press matched the record: `deployed`, `stale` (the record is flagged
stale, see below), `unknown_slot`, `unknown_device`; nothing is dropped.

The listener is a separate plain-HTTP port (8060 by default, the same
default as the Home Assistant integration and Emulated Roku, so only
one of them can own it on a host). It runs while any hub has a callback
device, accepts only the hub's own address (or the forwarded client when
the peer is a `--trusted-proxy`) and answers every request at once; the
hub retries anything it dislikes. A port in use is not fatal: the deploy
still succeeds, `callback_listener_failed` is announced, `GET
/server/callback-listener` shows the error and the next retry, the
server keeps retrying with backoff, and `POST
/server/callback-listener/retry` tries at once.

Failures split two ways. An immediate `409` is something the record
alone decides: `callback_device_exists`, `callback_device_stale`,
`callback_device_not_stale`, `callback_device_referenced` (the detail
names the activities and reference kinds), `callback_port_x1`. Anything
that needs the hub happens inside the accepted job and fails it with a
coded error: `callback_update_declined` (a record's label matches
neither what was deployed nor what you asked, so the device was edited
elsewhere; or the planner refused the diff; nothing was written) and
`callback_update_failed` (the hub rejected a step; the next update with
the same spec resumes).

If the device disappears from the hub (deleted in the Sofabaton app, an
erase), the record is marked `stale` (`callback_device_stale` server
event), presses that still arrive are tagged `resolution: "stale"`, and
`redeploy` creates it again from the stored spec. The server verifies
identity (brand, name and the callback path inside the first record)
before it clears the flag on its own. Every create, update and delete
writes its intent to `hubs.json` before the hub is touched; at boot and
before every deploy the server reconciles it, and a device it created
but forgot (a crash before the save, a lost data directory) is adopted
by that same identity check instead of being created twice
(`adopted: true` on the record).

## Discovery

The physical hub stops advertising while the official app is connected
directly to it. Close the app before discovery and initial registration;
repeated scans cannot find a hub that is not advertising. A previously
seen entry can remain in the discovery table, so `present: false` does not
by itself mean the hub is offline. Use registered hub status for availability.

The server browses for hubs for as long as it runs and keeps a table of
what it has seen: `GET /api/v1/discovery/hubs` lists physical hubs
(`key` is the MAC when the advertisement carries one, else the host),
whether each is currently advertised (`present`), and the configured
hub it matches (`registered_hub_id`) if any. `POST /api/v1/discovery/scan`
with `{"timeout": 5}` listens for that long and returns the table, for
platforms that want a synchronous answer. Advertisements from this
server's own proxies are recognised and left out. New and vanished hubs
arrive on the event stream as `hub_discovered` / `hub_lost`.

To register a discovered hub, `POST /api/v1/hubs` with the entry's
`config` object. A record from your platform's own mDNS stack works the
same way: pass `host`, and `mac`, `name`, `txt` and `hub_version` when
you have them; filter out advertisements carrying `HA_PROXY=1` (they
are proxies, and the server refuses them with a pointer to the hub they
front).

The server advertises itself as `_sofabaton-x._tcp.local.` with TXT
`version`, `api`, `path`, `hubs` (count) and, when `--advertise-url` is
set, `base_url`. Use `base_url` when present, otherwise
`http://<SRV host>:<SRV port>`, as the **server base URL**. Append `path` to
obtain the **API root** for hand-written calls. Generated clients use the
server base URL because OpenAPI operation paths already include `/api/v1`.
Preserve a reverse-proxy prefix and avoid appending `/api/v1` twice.

## Events (WebSocket)

`ws://<server>:8480/api/v1/events` streams every hub's events on one
connection; add `?hub_id=<id>` (repeatable) to narrow it. Messages are
JSON objects discriminated by `type`:

| type | payload |
| --- | --- |
| `hello` | once on connect: `server_version`, `api_version`, `instance_id`, `hubs` (`hub_id`, `enabled`) |
| `hub_event` | `hub_id` and the library `event` (`seq`, `kind`, `payload`): `activity_changed`, `activity_list_updated`, `hub_state`, `app_state`, `status_changed`, `catalog_ready`, `snapshot_changed`, `ota` |
| `server_event` | `hub_id` and `kind`: hub lifecycle/discovery events (`hub_added`, `hub_removed`, `hub_enabled`, `hub_disabled`, `hub_rekeyed`, `hub_discovered`, `hub_lost`) and callback events (`callback_device_stale`, `callback_device_restored`, `callback_listener_started`, `callback_listener_failed`) |
| `job_event` | `hub_id` and the full `job` record on every transition: queued, running, each progress report, done / failed / cancelled |
| `press` | a button press the hub delivered to the callback listener: `seq` (the server-instance press sequence, shared with `GET /hubs/{id}/presses`), `hub_id`, `device_id`, `command_id`, `slot`, `label`, `press_type` (`short` / `long`), `resolution`, `transport`, `source`, `received_at` (see Button events) |
| `dropped` | `count` of older messages discarded because this client fell behind; sent before the next message that gets through |

`hub_event.event.seq` is the library's per-proxy counter, passed through
untouched. A gap means events were lost in a bounded queue; either the
library's consumer or the WebSocket client can fall behind. `press.seq` is
a separate server-instance-wide counter shared with press history; use
`(instance_id, seq)` to de-duplicate presses.
`hub_rekeyed` is the one to watch after registering by host: the id
becomes the hub's MAC once its banner is read. Disabling a hub is
announced by `hub_disabled` alone (its proxy is gone before any link
event could be relayed); enabling it creates a new proxy whose
`hub_event.event.seq` starts over. A hub transport reconnect alone does not
reset that proxy counter. On reconnect, a `dropped` message or a sequence gap,
re-read the hub list, status, relevant snapshots and outstanding jobs;
hub and job events have no replay history. Presses have the bounded
catch-up history described above. Inbound text is ignored.
The message types are published as components in the OpenAPI document
(`WsHello`, `WsHubEvent`, `WsServerEvent`, `WsJobEvent`, `WsPress`, `WsDropped`)
for generators.

Writing a platform integration? Start with
[docs/platform-integration.md](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/sofabaton-x-server/docs/platform-integration.md): finding
the server and the hubs, the endpoint rule, the error table, the event
stream, pairing with registered hubs, and optional snapshot, job and editing
flows.

## Development

The web remote (`src/sofabaton_server/ui/remote/`) and the control
panel (`src/sofabaton_server/ui/panel/`) are built from the repository's
frontend sources (`npm run build:remote-web` and
`npm run build:server-panel` at the repository root; the bundles are
committed and the frontend CI checks them for drift), so a server change
never needs a frontend toolchain, and a card or panel change ships with
the next server release.

From the repository root, with the library importable (the tests alias
the in-tree library automatically):

```
python -m pip install . ./sofabaton-x-server
python -m pip install -r sofabaton-x-server/openapi-toolchain.txt pytest httpx
python -m pytest sofabaton-x-server/tests -q
```

`openapi.json` is the committed contract; a test fails when the running
app's document differs. After an API change:

```
python -m pip install -e ./sofabaton-x-server
python -m pip install -r sofabaton-x-server/openapi-toolchain.txt
python -m sofabaton_server.openapi
```

The toolchain file pins the FastAPI and pydantic versions the document
is generated with; CI installs the same set before the drift test, so a
framework's own wording (the 422 description changed between FastAPI
releases, for instance) never shows up as API drift.

The codegen smoke (also in CI) checks generation and type-checks the sample
client:

```
npx -y openapi-typescript@7 sofabaton-x-server/openapi.json -o sofabaton-x-server/codegen-smoke/schema.d.ts
npx tsc --noEmit -p sofabaton-x-server/codegen-smoke/tsconfig.json
```

Unit tests and schema checks do not establish live hub compatibility.
The [live-hub testing notes](https://github.com/m3tac0de/home-assistant-sofabaton-x1s/blob/main/docs/protocol/live-hub-testing.md) record
hardware coverage; the document-write bench covers library operations on
X1/X1S, with X2 and the corresponding server-route bench still pending.

To release: set `__version__` in `src/sofabaton_server/__init__.py`, update
the documentation, and push the tag `sofabaton-x-server-vX.Y.Z`.
The release workflow re-runs the tests, checks the tag against the
version and publishes to PyPI; a compatible `sofabaton-x` version must be
on PyPI first (see the repository's CONTRIBUTING).
