You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
-**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`.
-**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.
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.
4
4
5
5
If you're building a new Techposts device (RidgeSync, future products), this is the doc to design against.
|**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
+
7
14
---
8
15
9
16
## Topology models
10
17
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).
12
21
13
22
### A. Standalone hub (mains-powered, always-on)
14
23
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.
16
27
17
28
Examples: **AmbiSense** (radar + LED, mains via USB or 5 V PSU), **mains-powered RidgeSync** (door lock with permanent power).
18
29
@@ -25,7 +36,9 @@ Examples: **AmbiSense** (radar + LED, mains via USB or 5 V PSU), **mains-powered
25
36
26
37
### B. Hub + TX (battery sensors via gateway)
27
38
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.
29
42
30
43
Examples: **TankSync** (one mains hub, multiple battery-powered tank sensors), **future battery-powered RidgeSync** if shipped as a multi-door kit.
31
44
@@ -96,16 +109,22 @@ You may **also** advertise a product-specific service (e.g. `_ambisense._http._t
96
109
97
110
### `GET /api/v1/info`
98
111
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).
100
113
101
114
```json
102
115
{
103
-
"schema_version": "1.0",
116
+
"schema_version": "1.1",
104
117
"manufacturer": "SmartGhar",
105
118
"product": "ambisense",
106
119
"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",
108
126
"hub_id": "ambisense_d83bda3506f0",
127
+
"hub_name": "ambisense-06f0",
109
128
"unique_id": "ambisense_d83bda3506f0",
110
129
"host": "ambisense-06f0",
111
130
"ip": "192.168.1.42",
@@ -116,7 +135,17 @@ Hub identity + diagnostics. Polled every 30 s by the integration.
116
135
}
117
136
```
118
137
119
-
Required keys: `schema_version`, `manufacturer`, `product`, `model`, `fw_version`, `hub_id`. Everything else is best-effort but recommended.
|`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. |
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.
`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.
186
255
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.
191
257
192
-
### Frame shape
258
+
### Frame: `event` (on state change)
193
259
194
260
```json
195
261
{
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 }
200
265
}
201
266
```
202
267
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.
| 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 |
206
278
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
208
280
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).
210
282
211
283
---
212
284
@@ -384,16 +456,18 @@ Commands like `unlock`, `add_fingerprint` would arrive via `PUT /api/v1/devices/
384
456
Before claiming smartghar-compat:
385
457
386
458
-[ ] 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)
389
461
-[ ]`PUT /api/v1/devices/{id}` round-trips a config change (verify via subsequent GET)
390
462
-[ ]`POST /api/v1/hub/identify` makes the LED visibly flash
391
463
-[ ]`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
393
466
-[ ] Auth-gated endpoints reject unauthenticated writes with 401
394
467
-[ ] Pulling the power and bringing the device back: integration reconnects + entities go available
395
468
-[ ] 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)
397
471
398
472
---
399
473
@@ -402,11 +476,19 @@ Before claiming smartghar-compat:
402
476
| What | Where |
403
477
|---|---|
404
478
| mDNS service registration |[`netmgr.c:bring_up_mdns()`](../firmware/components/netmgr/netmgr.c)|
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.
0 commit comments