Skip to main content

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 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. 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 · Run · Control panel · Web remote · Settings · API · Jobs · Writes · Recovery · Button events · 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). 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:

{
  "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 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:

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); 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. 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.

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 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 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.

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.

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: 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 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).

Release files for sofabaton-x-server 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sofabaton-x-server 0.2.0
File Size Uploaded
sofabaton_x_server-0.2.0.tar.gz 455.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sofabaton-x-server 0.2.0
File Interpreter ABI Platform
sofabaton_x_server-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 824.2 kB

Release files / sofabaton_x_server-0.2.0.tar.gz

Download URL sofabaton_x_server-0.2.0.tar.gz
Size 455.2 kB
Tags Source
SHA-256 checksum
How to use checksums
c1b0bd6fdab6f841a3bf9d0beba641d98569d1652075a56e33bed3064026ff09
BLAKE2b-256 checksum
How to use checksums
f8a9c121f1ce8bc36c7a8a08d3bc8b37da6407dc720f9c6cfc539ed4080926ba
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.

Transparency log

Release files / sofabaton_x_server-0.2.0-py3-none-any.whl

Download URL sofabaton_x_server-0.2.0-py3-none-any.whl
Size 369.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ca981f5dcb5fade8b42a37b4670200f26ed5374f35fa32d412aced3890582a1e
BLAKE2b-256 checksum
How to use checksums
307f84e5d017ab1ddd2b44384a52cf3985a09812a29b937a0da2ebd273b57325
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.2

2 release files

0.2.1

2 release files

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page