Skip to content

Commit 23383de

Browse files
author
Ravi Singh
committed
feat(contract+webui): schema 1.1 — topology + /api/v1/stream WS push
Adds two backwards-compatible additions to the smartghar wire contract, designed so future Techposts products (RidgeSync, PowerSync) plug into the HA integration without per-product branching. 1. info.topology — "standalone" (AmbiSense) | "hub" (TankSync). The integration's subdevice_device_info() helper reads this to decide whether sub-device entities collapse onto the hub's HA card or render as separate cards with via_device. Fixes the bug where AmbiSense showed as TWO devices in HA (hub + synthetic presence sensor) instead of one. 2. info.stream + /api/v1/stream WS — separate fd pool from /api/live so the PWA (20 Hz dashboard) and the HA integration (3 s protocol-shaped snapshots) don't collide. Frame kinds: hello on connect, snapshot every 3 s, event reserved for future use (locks, gas). Idle early-out skips JSON build when no clients are subscribed — zero allocator pressure on idle hubs. Schema bumped 1.0 → 1.1. Old integrations ignore the new fields and stay on polling — backwards compat preserved. Documented in docs/SMARTGHAR-PROTOCOL.md (full spec) and docs/DECISIONS.md D-009 (topology) + D-010 (split WS endpoints).
1 parent b2a3c65 commit 23383de

3 files changed

Lines changed: 289 additions & 35 deletions

File tree

docs/DECISIONS.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,36 @@ Format borrowed from [ADR](https://adr.github.io) (Architecture Decision Records
118118

119119
---
120120

121+
## D-009 — Topology declared in contract, rendered by integration
122+
123+
- **Date:** 2026-05-07 (v6.2.0-alpha.4 / smartghar v0.7.1)
124+
- **Status:** Locked
125+
- **Context:** v0.7.0 of the smartghar HA integration was modeled around TankSync's hub-with-children topology — sub-device entities always rendered as their own HA device with `via_device` linking back to the hub. Worked for TankSync (each tank IS a separate physical TX node). Wrong for AmbiSense (the C3 IS the radar; splitting them into "AmbiSense Hub" + "Presence Sensor" produced two HA cards for what's physically one box). The integration needed to know the difference *without* hardcoding per-product branches.
126+
- **Decision:** Add `info.topology` to the wire contract — `"standalone"` or `"hub"`. Integration ships two helpers in [`device_info.py`](../../smartghar-homeassistant/custom_components/smartghar/device_info.py): `hub_device_info()` for hub-level entities, `subdevice_device_info()` for kind-specific entities. The latter checks `topology` and either returns the hub's DeviceInfo verbatim (collapse, standalone) or builds a child DeviceInfo with `via_device` (split, hub).
127+
- **Alternatives considered:**
128+
- **Branch on `info.product` in the integration** (e.g. `if product == "ambisense": collapse else: split`). Rejected — every new product would need integration-side code; doesn't scale.
129+
- **Drop the synthetic sub-device from AmbiSense firmware entirely** (move presence state into `info.presence`). Rejected — would have required changing the data shape, the integration's per-kind entity dispatch, and the wire contract simultaneously. Topology-flag approach is purely additive.
130+
- **Per-entity-class detection** (every entity reads `info.topology` directly). Rejected — duplicates the check across 12+ entity classes; the helper centralises it.
131+
- **Why this won:** the firmware *knows* its own topology; declaring it in the contract makes the integration product-agnostic. Adding RidgeSync (mains-powered standalone lock) or PowerSync (multi-clamp hub) requires zero integration-side branching — they just declare their topology and the renderer does the right thing.
132+
- **See also:** [`SMARTGHAR-PROTOCOL.md` Topology models section](SMARTGHAR-PROTOCOL.md#topology-models); helper source in `smartghar-homeassistant/custom_components/smartghar/device_info.py`.
133+
134+
---
135+
136+
## D-010 — Two WebSocket endpoints: `/api/live` (PWA) + `/api/v1/stream` (smartghar)
137+
138+
- **Date:** 2026-05-07 (v6.2.0-alpha.4)
139+
- **Status:** Locked
140+
- **Context:** Existing `/api/live` WS broadcasts at 20 Hz with rich live-data fields (raw distance, heap, RSSI, occupancy, sparkline-friendly). Tightly coupled to the AmbiSense PWA's dashboard rendering — changing its shape breaks the UI. The smartghar HA integration wants a completely different shape (protocol-shaped frames: `{kind, hub:{}, devices:[]}`), a different cadence (3 s), and a stable cross-product schema. One endpoint can't serve both consumers cleanly.
141+
- **Decision:** Run both. `/api/live` keeps its PWA-shaped 20 Hz frames and its consumer (the dashboard). `/api/v1/stream` emits smartghar protocol frames (`hello` / `snapshot` / `event`) at 3 s for the HA integration. Separate fd pools (`MAX_WS_CLIENTS` + `MAX_V1_WS_CLIENTS` = 4 each), separate broadcast tasks, both with idle early-out so neither does cJSON work when no one's listening.
142+
- **Alternatives considered:**
143+
- **One WS endpoint + version-negotiate the frame shape.** Rejected — coupling release cadences of the PWA and the integration via a shared WS contract; first sub-protocol mismatch ships breakage to both consumers at once.
144+
- **Drop `/api/live`, port the PWA to consume `/api/v1/stream`.** Rejected for now — PWA needs sub-second updates; the protocol's 3 s cadence is wrong for it. Could revisit if we ever change the PWA to a polling architecture.
145+
- **HTTP SSE for the integration instead of WS.** Rejected — the integration code path already uses aiohttp's `ws_connect`; SSE would be a parallel infrastructure path with no clear benefit.
146+
- **Why this won:** the two consumers genuinely have different needs (PWA = high-frequency raw, integration = protocol-shaped events). Separating them by URI keeps each evolution independent. The cost is ~4 KB stack + 16 bytes for the second fd table — trivial.
147+
- **See also:** [`webui.c:handle_v1_stream` + `v1_stream_broadcast_task`](../firmware/components/webui/webui.c); SMARTGHAR-PROTOCOL.md WebSocket section.
148+
149+
---
150+
121151
## How to add a new entry
122152

123153
When a decision rises to "this affects how the codebase is organised" or "this constrains future product design," document it here:

docs/SMARTGHAR-PROTOCOL.md

Lines changed: 110 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
1-
# SmartGhar device protocol (v1.0)
1+
# SmartGhar device protocol (v1.1)
22

33
This document is the **wire contract** every Techposts IoT device implements to be discovered and operated by the [smartghar Home Assistant integration](https://github.com/Techposts/smartghar-homeassistant). It also describes the integration-side dispatch model for adding a new product.
44

55
If you're building a new Techposts device (RidgeSync, future products), this is the doc to design against.
66

7+
### Versions
8+
9+
| Schema | Integration | Firmware | Notable |
10+
|---|---|---|---|
11+
| 1.0 | smartghar v0.6.x – v0.7.0 | TankSync hub firmware, AmbiSense ≤ v6.2.0-alpha.3 | Initial release. Hub topology only, polling-only. |
12+
| **1.1** | **smartghar v0.7.1+** | **AmbiSense ≥ v6.2.0-alpha.4** | Adds `info.topology`, `info.stream`, real-time WS push channel, `event` frame kind. **All additions are backwards-compatible** — older integrations ignore unknown fields and stay on polling. |
13+
714
---
815

916
## Topology models
1017

11-
The protocol supports two device topologies. Both speak the same wire contract — the difference is whether `/api/v1/devices` returns one entry or many.
18+
The protocol supports two device topologies. Both speak the same wire contract — they differ in (a) whether `/api/v1/devices` returns one entry or many, and (b) how the HA integration renders them in the device registry.
19+
20+
Schema 1.1 makes the topology **explicit** via `info.topology` (`"standalone"` or `"hub"`). The integration reads this to decide whether sub-device entities collapse onto the hub's HA device card (standalone) or appear as separate cards linked via `via_device` (hub).
1221

1322
### A. Standalone hub (mains-powered, always-on)
1423

15-
Single ESP32 doing everything. The device IS its own hub; it presents a single virtual sub-device representing its own function.
24+
Single ESP32 doing everything. The device IS its own hub. It declares `topology: "standalone"` and presents a single virtual sub-device that maps onto its physical function.
25+
26+
**HA rendering**: the integration's `subdevice_device_info()` helper detects `standalone` and **collapses sub-device entities onto the hub's HA device entry** — no `via_device` link, no second card in Settings → Devices. The user sees one device with all entities under it. This is the right UX when the hub *is* the sensor/actuator.
1627

1728
Examples: **AmbiSense** (radar + LED, mains via USB or 5 V PSU), **mains-powered RidgeSync** (door lock with permanent power).
1829

@@ -25,7 +36,9 @@ Examples: **AmbiSense** (radar + LED, mains via USB or 5 V PSU), **mains-powered
2536

2637
### B. Hub + TX (battery sensors via gateway)
2738

28-
Always-on hub ESP32 acts as a gateway for battery-powered TX nodes that talk to it over short-range RF (ESP-NOW, LoRa, BLE). Each TX node appears as a sub-device in the hub's `/api/v1/devices` array.
39+
Always-on hub ESP32 acts as a gateway for battery-powered TX nodes that talk to it over short-range RF (ESP-NOW, LoRa, BLE). It declares `topology: "hub"`. Each TX node appears as a sub-device in the hub's `/api/v1/devices` array.
40+
41+
**HA rendering**: each sub-device renders as **its own HA device** with `via_device` pointing at the hub. The user sees one card per physical TX node — appropriate because each is a real hardware device with its own battery, RSSI, name, location.
2942

3043
Examples: **TankSync** (one mains hub, multiple battery-powered tank sensors), **future battery-powered RidgeSync** if shipped as a multi-door kit.
3144

@@ -96,16 +109,22 @@ You may **also** advertise a product-specific service (e.g. `_ambisense._http._t
96109
97110
### `GET /api/v1/info`
98111
99-
Hub identity + diagnostics. Polled every 30 s by the integration.
112+
Hub identity + diagnostics. Polled every 30 s by the integration as a baseline, even when WS push is connected (catches static-field changes + acts as recovery channel).
100113
101114
```json
102115
{
103-
"schema_version": "1.0",
116+
"schema_version": "1.1",
104117
"manufacturer": "SmartGhar",
105118
"product": "ambisense",
106119
"model": "AmbiSense v6",
107-
"fw_version": "v6.2.0-alpha.2",
120+
"topology": "standalone",
121+
"stream": {
122+
"ws_path": "/api/v1/stream",
123+
"frame_period_ms": 3000
124+
},
125+
"fw_version": "v6.2.0-alpha.4",
108126
"hub_id": "ambisense_d83bda3506f0",
127+
"hub_name": "ambisense-06f0",
109128
"unique_id": "ambisense_d83bda3506f0",
110129
"host": "ambisense-06f0",
111130
"ip": "192.168.1.42",
@@ -116,7 +135,17 @@ Hub identity + diagnostics. Polled every 30 s by the integration.
116135
}
117136
```
118137

119-
Required keys: `schema_version`, `manufacturer`, `product`, `model`, `fw_version`, `hub_id`. Everything else is best-effort but recommended.
138+
Required keys (schema 1.1): `schema_version`, `manufacturer`, `product`, `model`, `fw_version`, `hub_id`, `hub_name`, `topology`. Recommended: `stream`, `host`, `ip`, `capabilities`. Everything else is best-effort.
139+
140+
Field reference:
141+
142+
| Field | Type | Required | Notes |
143+
|---|---|---|---|
144+
| `topology` | `"standalone"` \| `"hub"` | yes (1.1+) | Drives HA device-registry rendering. See Topology models above. |
145+
| `stream.ws_path` | string | recommended | Where to subscribe for real-time push. Almost always `/api/v1/stream`. |
146+
| `stream.frame_period_ms` | integer | recommended | Self-declared snapshot cadence. Picks the integration's reconnect/timeout heuristics. AmbiSense = 3000, TankSync hub firmware will set 30000 once it adopts 1.1. |
147+
| `hub_name` | string | yes (1.1+) | What HA shows as the device-registry name. Convention: same as `host` (e.g. `ambisense-06f0`). Without it the integration crashes — no fallback in 1.1. |
148+
| `capabilities` | string[] | optional | Free-form tags. Future use: integration could conditionally enable entity sets based on capabilities. Not load-bearing today. |
120149

121150
### `GET /api/v1/devices`
122151

@@ -182,31 +211,74 @@ Optional. Reserved for future OTA-via-integration flow. AmbiSense v6.2 returns 5
182211

183212
## WebSocket — `/api/v1/stream`
184213

185-
Real-time push. Integration opens one WS per discovered device after `/api/v1/info` succeeds.
214+
Real-time push. Integration opens one WS per hub immediately after `/api/v1/info` returns. The 30 s HTTP polling continues in parallel as a fallback + static-field refresh channel.
215+
216+
### Connection lifecycle
217+
218+
1. Integration opens `ws://<host>/api/v1/stream` (path comes from `info.stream.ws_path`)
219+
2. Firmware sends a **hello** frame within ~100 ms of accept
220+
3. Firmware then emits **snapshot** frames every `info.stream.frame_period_ms` (3 s for AmbiSense)
221+
4. Firmware MAY emit **event** frames at any time between snapshots for state changes that shouldn't wait for the next tick
222+
5. aiohttp keepalive: integration pings every 20 s; firmware's `httpd_ws_send_frame_async` returns error if the socket is dead → firmware drops the fd; integration reconnects with backoff 2 s → 60 s
223+
224+
### Frame: `hello` (sent once on connect)
225+
226+
```json
227+
{
228+
"kind": "hello",
229+
"schema_version": "1.1",
230+
"hub_id": "ambisense_d83bda3506f0"
231+
}
232+
```
233+
234+
Schema validation only — integration logs a warning if the hub announces a major version it doesn't speak (1.x vs 2.x). Doesn't drop the connection in 1.x.
235+
236+
### Frame: `snapshot` (periodic)
237+
238+
```json
239+
{
240+
"kind": "snapshot",
241+
"hub": {
242+
"uptime_s": 12345,
243+
"wifi_rssi": -45,
244+
"ota_available": null
245+
},
246+
"devices": [
247+
{ "kind": "presence", "id": 0, "name": "Presence Sensor",
248+
"state": { "occupied": true, "nearest_cm": 80, ... },
249+
"config": { ... } }
250+
]
251+
}
252+
```
253+
254+
`hub` carries only **dynamic** fields (uptime, RSSI, OTA-available flag). Static fields (`hub_id`, `fw_version`, `topology`, `product`, `ota.current`, `ota.channel`) come from polled `/api/v1/info` and are not duplicated on every tick — saves bandwidth on small frames.
186255

187-
### Cadence
188-
- Snapshot frame every **~3 seconds** containing the latest devices array
189-
- Heartbeat every **20 seconds** (empty `{}` or `{"hb": true}`) to keep the connection warm
190-
- Reconnect with exponential backoff (2 s → 60 s) if dropped
256+
`devices` is the same shape as `GET /api/v1/devices` so the integration's snapshot-merge logic is identical between push and poll paths.
191257

192-
### Frame shape
258+
### Frame: `event` (on state change)
193259

194260
```json
195261
{
196-
"type": "snapshot",
197-
"ts": 1234567890,
198-
"info": { ...same shape as /api/v1/info... },
199-
"devices": [ ...same shape as /api/v1/devices... ]
262+
"kind": "event",
263+
"device_id": 0,
264+
"state": { "occupied": true }
200265
}
201266
```
202267

203-
The integration uses this for live entity updates without burning HTTP bandwidth.
268+
Optional, but **strongly recommended** for event-driven kinds (locks, gas, doors). The integration applies the partial `state` delta to the existing device entry and triggers an immediate entity refresh — no waiting for next snapshot. Use cases: lock-opened, gas-leak-detected, door-bell-pressed. AmbiSense doesn't emit `event` frames yet (snapshot at 3 Hz is fine for occupancy), but RidgeSync will.
269+
270+
### Cadence picking guide
204271

205-
### Implementation note
272+
| Device behaviour | snapshot `frame_period_ms` | emit `event`? |
273+
|---|---|---|
274+
| Steady-state telemetry (tank level) | 30000 | no |
275+
| Sub-second physical motion (presence) | 3000 | no — snapshot is fast enough |
276+
| Event-driven (lock, doorbell, gas) | 30000 | **yes** — events are the real-time channel |
277+
| Mixed steady + bursty (power: live W + cumulative kWh) | 5000 | yes for trip events |
206278

207-
If you don't have time to implement a separate WS endpoint, the integration silently falls back to HTTP polling on `/api/v1/info` + `/api/v1/devices` every 30 s. This is acceptable for telemetry that doesn't need sub-second updates (e.g. tank levels). It's NOT acceptable for occupancy/door events (5 s+ stale state would cause user-visible automation lag).
279+
### Skipping WS
208280

209-
For new mains-powered products: implement the WS. For battery-powered products that wake briefly to send a packet: skip the WS, lean on the hub's WS for any sub-device the hub knows about.
281+
If the firmware can't ship WS yet, omit `info.stream` entirely. The integration falls back to 30 s polling — acceptable for tank levels and other slow telemetry, NOT acceptable for occupancy or door events (5 s+ stale state = visible automation lag).
210282

211283
---
212284

@@ -384,16 +456,18 @@ Commands like `unlock`, `add_fingerprint` would arrive via `PUT /api/v1/devices/
384456
Before claiming smartghar-compat:
385457

386458
- [ ] mDNS service `_smartghar._tcp` advertises with all 5 required TXT records
387-
- [ ] `GET /api/v1/info` returns 200 with all required keys + plausible values
388-
- [ ] `GET /api/v1/devices` returns a non-empty array with the right `kind` per sub-device
459+
- [ ] `GET /api/v1/info` returns 200 with all required keys + plausible values, including `topology` and (if WS is implemented) `stream`
460+
- [ ] `GET /api/v1/devices` returns the right `kind` per sub-device (or `[]` for standalone hubs that surface state through `info` only)
389461
- [ ] `PUT /api/v1/devices/{id}` round-trips a config change (verify via subsequent GET)
390462
- [ ] `POST /api/v1/hub/identify` makes the LED visibly flash
391463
- [ ] `POST /api/v1/hub/reboot` causes a clean reboot
392-
- [ ] WS `/api/v1/stream` emits at least one snapshot frame within 5 s of connect, then heartbeats every 20 s
464+
- [ ] WS `/api/v1/stream` sends `hello` within 100 ms of connect, then `snapshot` at the declared `frame_period_ms`. Test with `websocat ws://<host>/api/v1/stream` from a laptop.
465+
- [ ] If WS is implemented, the broadcast task **skips JSON build when no clients are connected** (early-out check) — verify via heap-stable idle behaviour
393466
- [ ] Auth-gated endpoints reject unauthenticated writes with 401
394467
- [ ] Pulling the power and bringing the device back: integration reconnects + entities go available
395468
- [ ] Add the kind to `smartghar-homeassistant/custom_components/smartghar/const.py` + entity builders
396-
- [ ] Bench test: install the updated integration in HA, watch the device auto-discover, confirm entities populate
469+
- [ ] In integration, choose the right device_info helper: `hub_device_info()` for hub-level entities, `subdevice_device_info()` for kind-specific entities (it auto-collapses based on `topology`)
470+
- [ ] Bench test: install the updated integration in HA, watch the device auto-discover, confirm entities populate as ONE device (standalone) or one-per-TX (hub)
397471

398472
---
399473

@@ -402,11 +476,19 @@ Before claiming smartghar-compat:
402476
| What | Where |
403477
|---|---|
404478
| mDNS service registration | [`netmgr.c:bring_up_mdns()`](../firmware/components/netmgr/netmgr.c) |
405-
| `/api/v1/info` handler | [`webui.c:handle_v1_info()`](../firmware/components/webui/webui.c) |
479+
| `/api/v1/info` handler (with topology + stream) | [`webui.c:handle_v1_info()`](../firmware/components/webui/webui.c) |
406480
| `/api/v1/devices` handler | [`webui.c:handle_v1_devices()`](../firmware/components/webui/webui.c) |
407481
| `build_presence_device()` device builder | [`webui.c`](../firmware/components/webui/webui.c) |
408482
| `/api/v1/devices/0` PUT handler | [`webui.c:handle_v1_devices_put()`](../firmware/components/webui/webui.c) |
409483
| `/api/v1/hub/identify` | [`webui.c:handle_v1_hub_identify()`](../firmware/components/webui/webui.c) |
410484
| `/api/v1/hub/reboot` | [`webui.c:handle_v1_hub_reboot()`](../firmware/components/webui/webui.c) |
485+
| `/api/v1/stream` WS handler + broadcast task | [`webui.c:handle_v1_stream()` + `v1_stream_broadcast_task()`](../firmware/components/webui/webui.c) |
486+
487+
### Integration-side counterparts (smartghar-homeassistant v0.7.1+)
411488

412-
WS `/api/v1/stream` is **not yet implemented** in v6.2.0-alpha.2 — falls back to polling per the spec. Will land in v6.2.0-alpha.3 along with the integration-side `kind: "presence"` PR.
489+
| What | Where |
490+
|---|---|
491+
| Topology-aware DeviceInfo helpers | `custom_components/smartghar/device_info.py` (`hub_device_info`, `subdevice_device_info`) |
492+
| WS frame dispatch (hello / snapshot / event) | `coordinator.py:_handle_ws_msg`, `_apply_snapshot`, `_apply_event` |
493+
| Per-product model name dispatch | `const.py:hub_model_for_product()` |
494+
| zeroconf config_flow + manual IP fallback | `config_flow.py` |

0 commit comments

Comments
 (0)