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:

StatusMeaning
400Malformed request — bad JSON or missing required field
401Missing or invalid bearer token
404No such resource (device, rule, zone)
409Conflict — revision mismatch on a concurrent write
422Well-formed but semantically invalid (rule references an unknown channel)
429Rate 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/priority document 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.

copied!