DEVELOPER SURFACE · REST + WS · v1 STABLE
API & Developer Experience
The WB-1 control plane is a documented REST + WebSocket surface with a declarative YAML rule DSL. Bring your own client; the dashboard is just one of them.
The Wireless Bobulator is not a closed box. Every capability the WB-1 exposes is reachable over a documented HTTP control plane for config and state, a WebSocket stream for live events, and a declarative YAML rule DSL for behaviour. The built-in dashboard has no private channel — it is just another client of the same v1 API, and anything it does, your code can do too.
If a unit is on your desk, begin with the 15-minute quickstart. For complete rule syntax, use the Rule DSL reference.
Bring your own client
The control plane splits along two axes. REST (http://bobulator.local/api/v1/)
owns configuration and state: read a device, list channels, PUT a rule set,
page through the audit log. It is request/response, cacheable, and idempotent
where it should be. The WebSocket endpoint owns the live event stream: sensor
readings, rule firings, radio-health transitions, and connection state, pushed
as they happen with no polling.
There is no capability gap between the dashboard and the API. The dashboard authenticates with the same bearer tokens, hits the same routes, and subscribes to the same event stream you would. If you would rather drive the WB-1 from a shell script, a Prometheus exporter, or your own control panel, nothing stops you — the surface is the product, not an afterthought bolted onto it. Every route is documented with request and response schemas, and every field means exactly one thing.
Hello, world
Subscribe to the live event stream and print the next five sensor events:
# WebSocket: tail the live event stream, stop after 5 events
websocat ws://bobulator.local/api/v1/events \
-H "Authorization: Bearer $WB_TOKEN" | head -5
# REST: same data, one snapshot, no stream
curl -s http://bobulator.local/api/v1/events?limit=5 \
-H "Authorization: Bearer $WB_TOKEN" | jq '.data[]'
The WebSocket line opens a connection, the server begins pushing newline-delimited
JSON immediately, and head -5 closes the pipe after the fifth frame — no
subscription handshake, no client library. First frame typically lands within
40msstream latency, sensor edge to client frame
of the sensor edge. The REST variant returns the same
records as a bounded snapshot when you want a poll instead of a stream. Each
frame is a fully-formed Event:
{
"id": "evt_01HZ8Q3M2K",
"type": "sensor.reading",
"device": "wb1-court3",
"channel": "shot_clock",
"value": 0,
"unit": "s",
"zone": "court.3",
"ts": "2026-07-07T19:42:11.204Z"
}
A rule, annotated
Behaviour is declarative. A rule is a when block (conditions), a then block
(actions), and a priority that breaks ties when two rules match the same cycle.
Here is the period-end rule from the sports-facilities
deployment:
rule "court-end-of-period":
priority: 80
when:
- timer.court.3.shot_clock == 0
- timer.court.3.game_clock == 0
then:
- scoreboard.court.3.flash("PERIOD", 3s)
- light.court.3.scene("celebration") # warm pulse, 4s
- audio.zone.court.3.buzzer(loud)
- light.court.4..8.hold() # neighbours unaffected
Read it top to bottom. priority: 80 places this rule above routine ambient
rules but below safety overrides — higher numbers win, and the engine resolves
the whole matched set on one cycle rather than racing. The when block is an
implicit AND: both the shot clock and the game clock must read zero, so the rule
fires on a period end but not on a shot-clock violation. The then block lists
actions in declaration order; the light.court.4..8.hold() wildcard explicitly
pins the seven neighbouring courts so the celebration stays zone-scoped.
Rules hot-reload. PUT a new rule set and the engine swaps it in on the next
cycle with no dropped events and no reboot — the running state survives the
reload. Across a fleet, the same PUT /api/v1/rules pushes the document to
every device that owns the addressed zones, so you write the rule once and it
lands everywhere the wildcard resolves:
{
"status": 200,
"applied": 8,
"rev": "rules_01HZ8R",
"reloaded_at": "2026-07-07T19:42:12.010Z"
}
Conventions
Errors use standard HTTP status codes, and the JSON body always carries a
machine-readable code and a human-readable message:
| Status | Meaning |
|---|---|
400 | Malformed request — bad JSON or missing required field |
401 | Missing or invalid bearer token |
404 | No such resource (device, rule, zone) |
409 | Conflict — revision mismatch on a concurrent write |
422 | Well-formed but semantically invalid (rule references an unknown channel) |
429 | Rate limited — retry after the Retry-After header |
Mutating requests accept an Idempotency-Key header. Send the same key on a
retry and the WB-1 returns the original result instead of applying the change
twice — safe to replay after a dropped connection. Rate limits are enforced
per token at 60req/minper token, burst 120
, and a 429 includes the window reset in
Retry-After. List endpoints are cursor-based: pass ?cursor= with the
next_cursor from the previous page and keep paging until it comes back null.
Request bodies are capped at 256KBmax JSON payload per request
; larger rule sets should be split across
zones.
Schemas
Six resource types make up the v1 surface:
- Device — a single WB-1 puck: identity, firmware revision, radio state, and the zones it owns.
- Channel — one of the twelve logical I/O lanes on a device (shot clock, lighting zone, occupancy, PA trigger).
- Rule — a
when/then/prioritydocument in the YAML DSL, addressed by zone. - Event — an immutable timestamped record on the stream: sensor reading, rule firing, or state transition.
- Zone — a routing scope (
court.3,court.4..8) that rules and actions target; the unit of isolation. - AuditEntry — an append-only log line recording who changed what config, when, and from which token.
Versioning
v1.0 is stable. The v1 path prefix is a contract: fields do not change type,
routes do not disappear, and semantics stay put for the life of the major
version. Breaking changes — a removed field, a renamed route, a changed default
— ship only under a new v2.x prefix, and only with 12 months of advance
notice so you have room to migrate. Additive changes are non-breaking and
unversioned: a new optional field or a new event type can land in v1 at any
time, so write clients that ignore fields they do not recognise.
Status
The WB-1 publishes an unauthenticated health endpoint, GET /api/v1/status,
returning uptime and per-radio health so you can wire it straight into a
monitoring status page at http://bobulator.local/api/v1/status:
{
"status": "ok",
"uptime_s": 1892344,
"radio": { "2g4": "healthy", "sub_ghz": "healthy" },
"version": "1.0.3"
}
We answer design questions in the field journal.