Home bus, energy bus, shared contract, adapters, historian worker, measurement and counter ingestion contracts, consolidated reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -0,0 +1,5 @@ |
||
| 1 |
+.DS_Store |
|
| 2 |
+*.swp |
|
| 3 |
+*.swo |
|
| 4 |
+*~ |
|
| 5 |
+tdb_ingestion |
|
@@ -0,0 +1,275 @@ |
||
| 1 |
+# Home MQTT Semantic Bus |
|
| 2 |
+ |
|
| 3 |
+## Overview |
|
| 4 |
+ |
|
| 5 |
+This project defines the architecture and conventions used to build a semantic MQTT bus for a heterogeneous home infrastructure. |
|
| 6 |
+ |
|
| 7 |
+The environment includes multiple device ecosystems and protocols such as Zigbee, custom ESP firmware, network telemetry, energy systems, and HomeKit integrations. These systems publish data using incompatible topic structures and payload formats. |
|
| 8 |
+ |
|
| 9 |
+The purpose of this repository is to define a canonical internal structure that allows all telemetry, events, and states to be normalized and consumed by multiple systems. |
|
| 10 |
+ |
|
| 11 |
+The MQTT bus acts as the central integration layer between devices and higher-level services. |
|
| 12 |
+ |
|
| 13 |
+Primary documents: |
|
| 14 |
+ |
|
| 15 |
+- `consolidated_spec.md`: consolidated reference linking all specs, decisions, and end-to-end traces |
|
| 16 |
+- `mqtt_contract.md`: shared transport, payload, metadata, and historian rules |
|
| 17 |
+- `sys_bus.md`: operational namespace for adapters, workers, and infrastructure components |
|
| 18 |
+- `home_bus.md`: room-centric semantic bus contract |
|
| 19 |
+- `energy_bus.md`: electrical telemetry bus contract |
|
| 20 |
+- `addapters.md`: adapter responsibilities and normalization rules |
|
| 21 |
+- `adapter_implementation_examples.md`: practical Node-RED adapter patterns, flow integration guidance, and known failure modes |
|
| 22 |
+- `historian_worker.md`: historian worker responsibilities for consuming buses and writing to PostgreSQL |
|
| 23 |
+- `tdb_ingestion/mqtt_ingestion_api.md`: PostgreSQL historian ingestion API contract for numeric and boolean measurements |
|
| 24 |
+- `tdb_ingestion/counter_ingestion_api.md`: counter ingestion API contract (stabilized, not yet implemented) |
|
| 25 |
+ |
|
| 26 |
+--- |
|
| 27 |
+ |
|
| 28 |
+## Architectural Model |
|
| 29 |
+ |
|
| 30 |
+The architecture separates five fundamental layers: |
|
| 31 |
+ |
|
| 32 |
+Device Layer |
|
| 33 |
+ |
|
| 34 |
+Devices publish telemetry using vendor-specific protocols and topic structures. |
|
| 35 |
+ |
|
| 36 |
+Examples: |
|
| 37 |
+ |
|
| 38 |
+- Zigbee2MQTT |
|
| 39 |
+- Tasmota |
|
| 40 |
+- ESP firmware |
|
| 41 |
+- SNMP |
|
| 42 |
+- MikroTik APIs |
|
| 43 |
+- Modbus energy meters |
|
| 44 |
+ |
|
| 45 |
+Protocol Adapter Layer |
|
| 46 |
+ |
|
| 47 |
+Adapters translate vendor-specific topics and payloads into canonical MQTT bus contracts. |
|
| 48 |
+ |
|
| 49 |
+Adapters perform only normalization and protocol translation. |
|
| 50 |
+ |
|
| 51 |
+They must not implement automation logic, aggregation, or persistence. |
|
| 52 |
+ |
|
| 53 |
+MQTT Semantic Bus |
|
| 54 |
+ |
|
| 55 |
+The canonical model is implemented as multiple semantic buses (for example `home`, `energy`, `network`), each with a strict domain contract. |
|
| 56 |
+ |
|
| 57 |
+All higher-level services consume data from this layer. |
|
| 58 |
+ |
|
| 59 |
+The bus is intentionally lightweight: canonical publications must remain minimal, MQTT-ready messages rather than rich adapter envelopes. |
|
| 60 |
+ |
|
| 61 |
+Historian Worker Layer |
|
| 62 |
+ |
|
| 63 |
+Historian persistence is handled by a worker that subscribes to canonical bus topics and writes them into PostgreSQL using the historian ingestion API. |
|
| 64 |
+ |
|
| 65 |
+Consumer Layer |
|
| 66 |
+ |
|
| 67 |
+Multiple systems consume the bus simultaneously: |
|
| 68 |
+ |
|
| 69 |
+- HomeKit integration |
|
| 70 |
+- Historian (time-series storage) |
|
| 71 |
+- Aggregators |
|
| 72 |
+- Automation logic |
|
| 73 |
+- Dashboards and monitoring |
|
| 74 |
+ |
|
| 75 |
+Pipeline: |
|
| 76 |
+ |
|
| 77 |
+Device -> Protocol Adapter -> MQTT Bus -> Historian Worker / Other Consumers |
|
| 78 |
+ |
|
| 79 |
+--- |
|
| 80 |
+ |
|
| 81 |
+## The Standardization Problem |
|
| 82 |
+ |
|
| 83 |
+IoT ecosystems lack a common telemetry model. |
|
| 84 |
+ |
|
| 85 |
+Different devices publish data using incompatible conventions: |
|
| 86 |
+ |
|
| 87 |
+- inconsistent topic hierarchies |
|
| 88 |
+- different payload formats (numeric, text, JSON) |
|
| 89 |
+- different naming schemes |
|
| 90 |
+- missing timestamps |
|
| 91 |
+- device-specific semantics |
|
| 92 |
+ |
|
| 93 |
+This lack of standardization creates several problems: |
|
| 94 |
+ |
|
| 95 |
+- difficult automation |
|
| 96 |
+- complex integrations |
|
| 97 |
+- duplicated parsing logic |
|
| 98 |
+- unreliable historical analysis |
|
| 99 |
+ |
|
| 100 |
+The semantic MQTT bus solves this by enforcing strict internal addressing contracts per bus. |
|
| 101 |
+ |
|
| 102 |
+Adapters isolate vendor inconsistencies and expose normalized data to the rest of the system. |
|
| 103 |
+ |
|
| 104 |
+--- |
|
| 105 |
+ |
|
| 106 |
+## Shared Contract Baseline (v1) |
|
| 107 |
+ |
|
| 108 |
+Each bus defines its own topic grammar, but all buses inherit the same shared contract from `mqtt_contract.md`. |
|
| 109 |
+ |
|
| 110 |
+The shared contract defines: |
|
| 111 |
+ |
|
| 112 |
+- the common stream taxonomy (`value`, `last`, `set`, `meta`, `availability`) |
|
| 113 |
+- payload profiles (`scalar` and `envelope`) |
|
| 114 |
+- retained metadata structure |
|
| 115 |
+- time semantics (`observed_at`, `published_at`, `ingested_at`) |
|
| 116 |
+- quality states |
|
| 117 |
+- operational topics under `<site>/sys/...` with detailed rules in `sys_bus.md` |
|
| 118 |
+- historian defaults |
|
| 119 |
+ |
|
| 120 |
+Semantic categories such as `sample`, `state`, and `event` are carried by `meta.historian.mode`, not by introducing separate live stream names in v1. |
|
| 121 |
+ |
|
| 122 |
+This keeps ingestion simple and predictable while allowing low-overhead Node-RED flows. |
|
| 123 |
+ |
|
| 124 |
+--- |
|
| 125 |
+ |
|
| 126 |
+## Node-RED Translation Constraints |
|
| 127 |
+ |
|
| 128 |
+Protocol translation is implemented in Node-RED. |
|
| 129 |
+ |
|
| 130 |
+To keep flow cost low and determinism high: |
|
| 131 |
+ |
|
| 132 |
+- keep topic shapes stable and predictable |
|
| 133 |
+- avoid expensive JSON transforms in high-rate paths |
|
| 134 |
+- publish repeated metadata on retained `meta` topics |
|
| 135 |
+- publish canonical MQTT-ready messages as early as possible after normalization |
|
| 136 |
+- keep hot-path messages minimal at publish time: `topic`, `payload`, and stream-policy QoS/retain only |
|
| 137 |
+- do not carry adapter-internal normalization structures on forwarded `msg` objects |
|
| 138 |
+- delete temporary adapter fields before MQTT publish |
|
| 139 |
+- do not use semantic bus topics as a debugging channel |
|
| 140 |
+- use reusable normalization subflows and centralized mapping tables |
|
| 141 |
+- avoid broad `#` subscriptions on high-volume paths |
|
| 142 |
+ |
|
| 143 |
+These constraints are reflected in each bus specification. |
|
| 144 |
+ |
|
| 145 |
+--- |
|
| 146 |
+ |
|
| 147 |
+## Operational Separation |
|
| 148 |
+ |
|
| 149 |
+The new broker is treated as a clean semantic boundary. |
|
| 150 |
+ |
|
| 151 |
+Production-facing legacy topics may continue to exist temporarily, but adapters should normalize data into the new broker namespace without leaking old topic structures into the canonical contract. |
|
| 152 |
+ |
|
| 153 |
+The target split is: |
|
| 154 |
+ |
|
| 155 |
+- legacy broker and vendor topics remain compatibility surfaces |
|
| 156 |
+- the new broker hosts the semantic buses and adapter operational topics |
|
| 157 |
+- historian and future consumers subscribe only to canonical topics |
|
| 158 |
+ |
|
| 159 |
+--- |
|
| 160 |
+ |
|
| 161 |
+## Historian Integration |
|
| 162 |
+ |
|
| 163 |
+One of the primary consumers of the bus is the historian. |
|
| 164 |
+ |
|
| 165 |
+The historian records time-series measurements for long-term analysis. |
|
| 166 |
+ |
|
| 167 |
+Typical use cases include: |
|
| 168 |
+ |
|
| 169 |
+- temperature history |
|
| 170 |
+- energy production and consumption |
|
| 171 |
+- network traffic metrics |
|
| 172 |
+- device performance monitoring |
|
| 173 |
+ |
|
| 174 |
+The historian does not communicate directly with devices. |
|
| 175 |
+ |
|
| 176 |
+Instead, it subscribes to normalized bus topics. |
|
| 177 |
+ |
|
| 178 |
+Current ingestion modeling is split in two: |
|
| 179 |
+ |
|
| 180 |
+- numeric and boolean measurements or states go through `tdb_ingestion/mqtt_ingestion_api.md` |
|
| 181 |
+- cumulative counters such as `energy_total` follow the separate contract in `tdb_ingestion/counter_ingestion_api.md` (stabilized, not yet implemented) |
|
| 182 |
+ |
|
| 183 |
+Example subscriptions: |
|
| 184 |
+ |
|
| 185 |
+- `+/home/+/+/+/value` |
|
| 186 |
+- `+/energy/+/+/+/value` |
|
| 187 |
+ |
|
| 188 |
+This architecture ensures that historical data remains consistent even when devices or protocols change. |
|
| 189 |
+ |
|
| 190 |
+--- |
|
| 191 |
+ |
|
| 192 |
+## Project Goals |
|
| 193 |
+ |
|
| 194 |
+The project aims to achieve the following objectives: |
|
| 195 |
+ |
|
| 196 |
+1. Define a stable MQTT semantic architecture for home infrastructure. |
|
| 197 |
+2. Decouple device protocols from automation and monitoring systems. |
|
| 198 |
+3. Enable multiple independent consumers of telemetry data. |
|
| 199 |
+4. Provide consistent topic contracts across heterogeneous systems. |
|
| 200 |
+5. Support scalable integration of additional device ecosystems. |
|
| 201 |
+6. Enable long-term historical analysis of telemetry. |
|
| 202 |
+7. Simplify integration with HomeKit and other user interfaces. |
|
| 203 |
+8. Make historian ingestion generic enough to reuse across buses. |
|
| 204 |
+9. Keep room for future buses without reworking existing consumers. |
|
| 205 |
+ |
|
| 206 |
+--- |
|
| 207 |
+ |
|
| 208 |
+## Core Concepts |
|
| 209 |
+ |
|
| 210 |
+Adapters |
|
| 211 |
+ |
|
| 212 |
+Components that translate between systems and canonical bus contracts. |
|
| 213 |
+ |
|
| 214 |
+In practice there are two useful classes: |
|
| 215 |
+ |
|
| 216 |
+- ingress adapters: vendor/protocol topics -> canonical bus topics |
|
| 217 |
+- consumer adapters: canonical bus topics -> downstream consumer models such as HomeKit |
|
| 218 |
+ |
|
| 219 |
+Buses |
|
| 220 |
+ |
|
| 221 |
+Domain-specific normalized telemetry spaces (for example `home`, `energy`, `network`). |
|
| 222 |
+ |
|
| 223 |
+Streams |
|
| 224 |
+ |
|
| 225 |
+Named data flows associated with a capability or metric (`value`, `last`, `set`, `meta`, `availability`). |
|
| 226 |
+ |
|
| 227 |
+Semantic interpretation such as `sample`, `state`, or `event` is carried by retained `meta`, especially `meta.historian.mode`. |
|
| 228 |
+ |
|
| 229 |
+Consumers |
|
| 230 |
+ |
|
| 231 |
+Systems that subscribe to the bus and process the data. |
|
| 232 |
+ |
|
| 233 |
+--- |
|
| 234 |
+ |
|
| 235 |
+## Design Principles |
|
| 236 |
+ |
|
| 237 |
+Protocol isolation |
|
| 238 |
+ |
|
| 239 |
+Device ecosystems must not leak their internal topic structure into the system. |
|
| 240 |
+ |
|
| 241 |
+Contract-driven addressing |
|
| 242 |
+ |
|
| 243 |
+All normalized telemetry must follow explicit per-bus topic contracts. |
|
| 244 |
+ |
|
| 245 |
+Loose coupling |
|
| 246 |
+ |
|
| 247 |
+Consumers must not depend on specific device implementations. |
|
| 248 |
+ |
|
| 249 |
+Extensibility |
|
| 250 |
+ |
|
| 251 |
+New buses, locations, devices, and metrics must be easy to integrate. |
|
| 252 |
+ |
|
| 253 |
+Observability |
|
| 254 |
+ |
|
| 255 |
+All telemetry should be recordable by a historian. |
|
| 256 |
+ |
|
| 257 |
+Node-RED efficiency |
|
| 258 |
+ |
|
| 259 |
+Topic and payload design should minimize transformation overhead in Node-RED. |
|
| 260 |
+ |
|
| 261 |
+The MQTT semantic bus is therefore optimized as a low-memory, low-CPU event bus for constrained accessories, SBCs, thin VMs, and high-rate Node-RED flows. |
|
| 262 |
+ |
|
| 263 |
+--- |
|
| 264 |
+ |
|
| 265 |
+## Status |
|
| 266 |
+ |
|
| 267 |
+The system is currently being deployed with a new MQTT broker running on: |
|
| 268 |
+ |
|
| 269 |
+`192.168.2.101` |
|
| 270 |
+ |
|
| 271 |
+The legacy broker at: |
|
| 272 |
+ |
|
| 273 |
+`192.168.2.133` |
|
| 274 |
+ |
|
| 275 |
+will be progressively phased out while Node-RED adapters migrate traffic into the canonical bus. |
|
@@ -0,0 +1,322 @@ |
||
| 1 |
+# Adapter Implementation Examples |
|
| 2 |
+ |
|
| 3 |
+## Purpose |
|
| 4 |
+ |
|
| 5 |
+This document captures practical implementation conventions that proved useful while building the `ZG-204ZV` adapters: |
|
| 6 |
+ |
|
| 7 |
+- Zigbee2MQTT -> HomeBus adapter |
|
| 8 |
+- HomeBus -> HomeKit adapter |
|
| 9 |
+ |
|
| 10 |
+It complements `addapters.md`. |
|
| 11 |
+ |
|
| 12 |
+`addapters.md` defines the normative adapter role and contract boundaries. |
|
| 13 |
+ |
|
| 14 |
+This document focuses on practical Node-RED integration patterns, cold-start behavior, flow topology, and known failure modes. |
|
| 15 |
+ |
|
| 16 |
+--- |
|
| 17 |
+ |
|
| 18 |
+## Two Distinct Adapter Roles |
|
| 19 |
+ |
|
| 20 |
+In practice, two different adapter classes appear in the system: |
|
| 21 |
+ |
|
| 22 |
+1. ingress adapters |
|
| 23 |
+ |
|
| 24 |
+These consume vendor topics and publish canonical bus topics. |
|
| 25 |
+ |
|
| 26 |
+Example: |
|
| 27 |
+ |
|
| 28 |
+`zigbee2mqtt/ZG-204ZV/...` -> `vad/home/...` |
|
| 29 |
+ |
|
| 30 |
+2. consumer adapters |
|
| 31 |
+ |
|
| 32 |
+These consume canonical bus topics and project them into another consumer model. |
|
| 33 |
+ |
|
| 34 |
+Example: |
|
| 35 |
+ |
|
| 36 |
+`vad/home/...` -> HomeKit |
|
| 37 |
+ |
|
| 38 |
+Rule: |
|
| 39 |
+ |
|
| 40 |
+- keep ingress normalization separate from consumer-specific projection |
|
| 41 |
+- do not mix HomeKit, dashboard, or automation constraints into the ingress adapter |
|
| 42 |
+- treat HomeKit adapters as bus consumers, not as protocol translators |
|
| 43 |
+ |
|
| 44 |
+Pipeline: |
|
| 45 |
+ |
|
| 46 |
+Device -> Ingress Adapter -> MQTT Bus -> Consumer Adapter -> Consumer System |
|
| 47 |
+ |
|
| 48 |
+--- |
|
| 49 |
+ |
|
| 50 |
+## Recommended Node-RED Flow Topology |
|
| 51 |
+ |
|
| 52 |
+### Ingress Adapter Pattern |
|
| 53 |
+ |
|
| 54 |
+Recommended structure: |
|
| 55 |
+ |
|
| 56 |
+1. dynamic or static `mqtt in` on vendor broker |
|
| 57 |
+2. adapter normalization node |
|
| 58 |
+3. `mqtt out` on canonical broker |
|
| 59 |
+4. optional debug nodes on raw input and normalized output |
|
| 60 |
+ |
|
| 61 |
+If the adapter controls vendor subscription dynamically: |
|
| 62 |
+ |
|
| 63 |
+- reserve one output for `mqtt in` control messages |
|
| 64 |
+- reserve one output for MQTT-ready publish messages |
|
| 65 |
+ |
|
| 66 |
+Practical convention: |
|
| 67 |
+ |
|
| 68 |
+- when an adapter has multiple semantic outputs, keep the `mqtt in` control output as the last output |
|
| 69 |
+- this keeps semantic outputs stable and makes flow rewiring easier |
|
| 70 |
+ |
|
| 71 |
+### Consumer Adapter Pattern |
|
| 72 |
+ |
|
| 73 |
+Recommended structure: |
|
| 74 |
+ |
|
| 75 |
+1. dynamic `mqtt in` on canonical broker |
|
| 76 |
+2. consumer adapter |
|
| 77 |
+3. consumer-specific nodes or services |
|
| 78 |
+4. optional debug nodes for `last`, `value`, and control messages |
|
| 79 |
+ |
|
| 80 |
+Typical example: |
|
| 81 |
+ |
|
| 82 |
+- subscribe to `.../last` and `.../value` |
|
| 83 |
+- use retained `last` for cold start |
|
| 84 |
+- continue on live `value` |
|
| 85 |
+- optionally unsubscribe from `.../last` after bootstrap completes |
|
| 86 |
+ |
|
| 87 |
+--- |
|
| 88 |
+ |
|
| 89 |
+## Cold-Start Pattern with `last` |
|
| 90 |
+ |
|
| 91 |
+For stateful consumers such as HomeKit, a practical pattern is: |
|
| 92 |
+ |
|
| 93 |
+1. subscribe to `.../last` |
|
| 94 |
+2. subscribe to `.../value` |
|
| 95 |
+3. initialize consumer state from retained `last` |
|
| 96 |
+4. keep consuming live `value` |
|
| 97 |
+5. optionally unsubscribe from `.../last` after bootstrap completes |
|
| 98 |
+ |
|
| 99 |
+Example: |
|
| 100 |
+ |
|
| 101 |
+- subscribe `vad/home/balcon/+/south/last` |
|
| 102 |
+- subscribe `vad/home/balcon/+/south/value` |
|
| 103 |
+- wait until all required capabilities are satisfied |
|
| 104 |
+- unsubscribe `vad/home/balcon/+/south/last` |
|
| 105 |
+ |
|
| 106 |
+Bootstrap completion SHOULD be defined explicitly. |
|
| 107 |
+ |
|
| 108 |
+Good rules: |
|
| 109 |
+ |
|
| 110 |
+- all required capabilities have arrived from either `last` or `value` |
|
| 111 |
+- or the consumer has entered a known good state for its required capability set |
|
| 112 |
+ |
|
| 113 |
+Bad rule: |
|
| 114 |
+ |
|
| 115 |
+- unsubscribe on first live message only |
|
| 116 |
+ |
|
| 117 |
+That rule is incomplete because a single live message does not imply that the consumer has received all required initial state. |
|
| 118 |
+ |
|
| 119 |
+--- |
|
| 120 |
+ |
|
| 121 |
+## Dynamic `mqtt in` Control Messages |
|
| 122 |
+ |
|
| 123 |
+For Node-RED dynamic `mqtt in`, control messages SHOULD remain simple. |
|
| 124 |
+ |
|
| 125 |
+Preferred forms: |
|
| 126 |
+ |
|
| 127 |
+```json |
|
| 128 |
+{
|
|
| 129 |
+ "action": "subscribe", |
|
| 130 |
+ "topic": "vad/home/balcon/+/south/last", |
|
| 131 |
+ "qos": 2, |
|
| 132 |
+ "rh": 0, |
|
| 133 |
+ "rap": true |
|
| 134 |
+} |
|
| 135 |
+``` |
|
| 136 |
+ |
|
| 137 |
+```json |
|
| 138 |
+{
|
|
| 139 |
+ "action": "unsubscribe", |
|
| 140 |
+ "topic": "vad/home/balcon/+/south/last" |
|
| 141 |
+} |
|
| 142 |
+``` |
|
| 143 |
+ |
|
| 144 |
+Practical recommendation: |
|
| 145 |
+ |
|
| 146 |
+- prefer one control message per topic |
|
| 147 |
+- do not rely on a single multi-topic control message unless runtime behavior is already verified in the target Node-RED version |
|
| 148 |
+ |
|
| 149 |
+This keeps behavior easier to debug and makes sidebar traces clearer. |
|
| 150 |
+ |
|
| 151 |
+--- |
|
| 152 |
+ |
|
| 153 |
+## Dedicated MQTT Session Requirement for Retained Bootstrap |
|
| 154 |
+ |
|
| 155 |
+This was a critical lesson from the HomeKit consumer adapter. |
|
| 156 |
+ |
|
| 157 |
+If a dynamic consumer depends on retained `last` as its cold-start mechanism, it SHOULD use its own MQTT client session. |
|
| 158 |
+ |
|
| 159 |
+In Node-RED terms: |
|
| 160 |
+ |
|
| 161 |
+- give that dynamic `mqtt in` its own `mqtt-broker` config node |
|
| 162 |
+- do not share the same broker config with unrelated static or dynamic subscribers when deterministic bootstrap matters |
|
| 163 |
+ |
|
| 164 |
+Reason: |
|
| 165 |
+ |
|
| 166 |
+- broker config nodes in Node-RED represent shared MQTT client sessions |
|
| 167 |
+- retained replay behavior is tied to subscribe operations in that session |
|
| 168 |
+- when static and dynamic subscribers share the same session, retained bootstrap can become non-deterministic from the perspective of one consumer |
|
| 169 |
+ |
|
| 170 |
+Observed failure mode: |
|
| 171 |
+ |
|
| 172 |
+- a static debug subscriber on `.../last` receives retained messages |
|
| 173 |
+- a dynamic consumer on the same broker config subscribes later |
|
| 174 |
+- the consumer does not reliably observe the cold-start retained replay it expects |
|
| 175 |
+- later live updates re-publish `last`, creating the false impression that only live traffic works |
|
| 176 |
+ |
|
| 177 |
+Rule: |
|
| 178 |
+ |
|
| 179 |
+- consumers that require deterministic retained bootstrap SHOULD have a dedicated broker config |
|
| 180 |
+ |
|
| 181 |
+This is especially important for: |
|
| 182 |
+ |
|
| 183 |
+- HomeKit adapters |
|
| 184 |
+- dashboard cold-start consumers |
|
| 185 |
+- control nodes that reconstruct state from retained `last` |
|
| 186 |
+ |
|
| 187 |
+It is usually not necessary for: |
|
| 188 |
+ |
|
| 189 |
+- simple live-only debug subscribers |
|
| 190 |
+- low-value telemetry observers that do not depend on retained bootstrap semantics |
|
| 191 |
+ |
|
| 192 |
+--- |
|
| 193 |
+ |
|
| 194 |
+## Practical Semantics: Raw vs Derived State |
|
| 195 |
+ |
|
| 196 |
+Adapters must be careful not to publish device-generated derived semantics as if they were raw truth. |
|
| 197 |
+ |
|
| 198 |
+Example from `ZG-204ZV`: |
|
| 199 |
+ |
|
| 200 |
+- raw Zigbee payload contains `presence` |
|
| 201 |
+- with `fading_time = 0`, that field is effectively raw motion detection |
|
| 202 |
+- canonical output should therefore be `motion/value` |
|
| 203 |
+- canonical `presence` should remain reserved for held or higher-level derived occupancy semantics |
|
| 204 |
+ |
|
| 205 |
+Rule: |
|
| 206 |
+ |
|
| 207 |
+- when a device field is operationally a raw signal, publish it as the raw canonical capability |
|
| 208 |
+- keep higher-level semantics for fusion, aggregation, or dedicated higher-level adapters |
|
| 209 |
+ |
|
| 210 |
+This prevents single-device vendor quirks from leaking into the semantic bus. |
|
| 211 |
+ |
|
| 212 |
+--- |
|
| 213 |
+ |
|
| 214 |
+## Status, Errors, and Flow Observability |
|
| 215 |
+ |
|
| 216 |
+Adapter nodes SHOULD expose useful runtime status in the Node-RED editor. |
|
| 217 |
+ |
|
| 218 |
+Useful status content: |
|
| 219 |
+ |
|
| 220 |
+- detected devices count |
|
| 221 |
+- processed input count |
|
| 222 |
+- translated output count |
|
| 223 |
+- invalid topic count |
|
| 224 |
+- invalid payload count |
|
| 225 |
+- dead-letter count |
|
| 226 |
+ |
|
| 227 |
+Adapters SHOULD also surface problems through: |
|
| 228 |
+ |
|
| 229 |
+- `node.warn` for malformed input and validation failures |
|
| 230 |
+- `node.error` for internal exceptions |
|
| 231 |
+- `<site>/sys/adapter/<adapter_id>/error` |
|
| 232 |
+- `<site>/sys/adapter/<adapter_id>/dlq` |
|
| 233 |
+- `<site>/sys/adapter/<adapter_id>/stats` |
|
| 234 |
+ |
|
| 235 |
+Rule: |
|
| 236 |
+ |
|
| 237 |
+- errors should be visible both in operational topics and in Node-RED flow messages |
|
| 238 |
+ |
|
| 239 |
+That combination makes debugging much faster than relying on only one channel. |
|
| 240 |
+ |
|
| 241 |
+--- |
|
| 242 |
+ |
|
| 243 |
+## Recommended Debug Setup During Implementation |
|
| 244 |
+ |
|
| 245 |
+While implementing a new adapter, use three debug perspectives: |
|
| 246 |
+ |
|
| 247 |
+1. raw ingress debug |
|
| 248 |
+ |
|
| 249 |
+What the source `mqtt in` actually receives. |
|
| 250 |
+ |
|
| 251 |
+2. control-path debug |
|
| 252 |
+ |
|
| 253 |
+What control messages are sent to a dynamic `mqtt in`. |
|
| 254 |
+ |
|
| 255 |
+3. semantic output debug |
|
| 256 |
+ |
|
| 257 |
+What canonical bus publications are emitted. |
|
| 258 |
+ |
|
| 259 |
+For consumers using `last` bootstrap, it is useful to have: |
|
| 260 |
+ |
|
| 261 |
+- one static debug subscriber on `.../last` |
|
| 262 |
+- one static debug subscriber on `.../value` |
|
| 263 |
+- one debug node on the dynamic `mqtt in` output |
|
| 264 |
+- one debug node on control messages |
|
| 265 |
+ |
|
| 266 |
+This makes it possible to distinguish: |
|
| 267 |
+ |
|
| 268 |
+- no retained message exists |
|
| 269 |
+- retained exists but the dynamic subscription did not activate |
|
| 270 |
+- retained exists but the consumer path is using the wrong MQTT session |
|
| 271 |
+- parsing or capability mapping is discarding the message |
|
| 272 |
+ |
|
| 273 |
+--- |
|
| 274 |
+ |
|
| 275 |
+## Example Integration: `ZG-204ZV` |
|
| 276 |
+ |
|
| 277 |
+### Ingress Adapter |
|
| 278 |
+ |
|
| 279 |
+Source: |
|
| 280 |
+ |
|
| 281 |
+`zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>` |
|
| 282 |
+ |
|
| 283 |
+Output: |
|
| 284 |
+ |
|
| 285 |
+- `.../value` for live semantic samples |
|
| 286 |
+- `.../last` retained for latest timestamped sample |
|
| 287 |
+- `.../meta` retained |
|
| 288 |
+- `.../availability` retained |
|
| 289 |
+- `.../sys/adapter/...` for operational topics |
|
| 290 |
+ |
|
| 291 |
+### Consumer Adapter |
|
| 292 |
+ |
|
| 293 |
+Source: |
|
| 294 |
+ |
|
| 295 |
+- `vad/home/<location>/<capability>/<device_id>/last` |
|
| 296 |
+- `vad/home/<location>/<capability>/<device_id>/value` |
|
| 297 |
+ |
|
| 298 |
+Output: |
|
| 299 |
+ |
|
| 300 |
+- HomeKit service updates |
|
| 301 |
+- dynamic `mqtt in` control for bootstrap subscribe/unsubscribe |
|
| 302 |
+ |
|
| 303 |
+Lessons learned: |
|
| 304 |
+ |
|
| 305 |
+- occupancy may need to be synthesized locally from `motion` and a fading timer |
|
| 306 |
+- numeric values may arrive as strings and should be normalized defensively |
|
| 307 |
+- bootstrap completeness must be defined per required capability set |
|
| 308 |
+- deterministic `last` bootstrap requires a dedicated MQTT session for the dynamic consumer |
|
| 309 |
+ |
|
| 310 |
+--- |
|
| 311 |
+ |
|
| 312 |
+## Summary Rules |
|
| 313 |
+ |
|
| 314 |
+- ingress adapters and consumer adapters are different roles and should remain separate |
|
| 315 |
+- prefer one control message per dynamic subscription topic |
|
| 316 |
+- keep dynamic `mqtt in` control output as the last output when practical |
|
| 317 |
+- define bootstrap completeness explicitly |
|
| 318 |
+- do not unsubscribe from `last` on first live message alone |
|
| 319 |
+- use dedicated MQTT broker config nodes for dynamic consumers that depend on retained bootstrap |
|
| 320 |
+- expose operational errors both in `sys` and in Node-RED flow messages |
|
| 321 |
+- treat raw device-generated signals as raw semantics, not as already-derived business meaning |
|
| 322 |
+ |
|
@@ -0,0 +1,868 @@ |
||
| 1 |
+# Adapter |
|
| 2 |
+ |
|
| 3 |
+## Definition |
|
| 4 |
+ |
|
| 5 |
+An **adapter** is a software component that translates data between an external system or device and the internal MQTT semantic bus used in the infrastructure. |
|
| 6 |
+ |
|
| 7 |
+The primary role of the adapter is to **normalize heterogeneous protocols, topic structures, and payload formats** into the canonical structure used by the system. |
|
| 8 |
+ |
|
| 9 |
+Adapters do not implement business logic, automation rules, aggregation, or storage. Their responsibility is strictly limited to protocol translation and semantic normalization. |
|
| 10 |
+ |
|
| 11 |
+Shared payload, metadata, quality, and operational namespace rules are defined in `mqtt_contract.md` and `sys_bus.md`. |
|
| 12 |
+ |
|
| 13 |
+Practical Node-RED conventions and worked examples are documented in `adapter_implementation_examples.md`. |
|
| 14 |
+ |
|
| 15 |
+--- |
|
| 16 |
+ |
|
| 17 |
+## Purpose |
|
| 18 |
+ |
|
| 19 |
+In a heterogeneous environment (Zigbee, Tasmota, SNMP, Modbus, MikroTik APIs, custom firmware, etc.), devices publish data using incompatible conventions: |
|
| 20 |
+ |
|
| 21 |
+- different topic hierarchies |
|
| 22 |
+- different payload formats (numeric, text, JSON) |
|
| 23 |
+- inconsistent naming |
|
| 24 |
+- missing timestamps |
|
| 25 |
+- device‑specific semantics |
|
| 26 |
+ |
|
| 27 |
+Adapters isolate these differences and expose a **stable internal MQTT bus contract**. |
|
| 28 |
+ |
|
| 29 |
+--- |
|
| 30 |
+ |
|
| 31 |
+## Architectural Position |
|
| 32 |
+ |
|
| 33 |
+Adapters operate at the ingress boundary of the system. |
|
| 34 |
+ |
|
| 35 |
+Pipeline: |
|
| 36 |
+ |
|
| 37 |
+Device / External System |
|
| 38 |
+ ↓ |
|
| 39 |
+Vendor / Protocol Topics |
|
| 40 |
+ ↓ |
|
| 41 |
+Adapter |
|
| 42 |
+ ↓ |
|
| 43 |
+Canonical MQTT Bus |
|
| 44 |
+ ↓ |
|
| 45 |
+Consumers (HomeKit, historian, automation, analytics) |
|
| 46 |
+ |
|
| 47 |
+--- |
|
| 48 |
+ |
|
| 49 |
+## Responsibilities |
|
| 50 |
+ |
|
| 51 |
+An adapter may perform the following transformations: |
|
| 52 |
+ |
|
| 53 |
+1. Topic translation to bus contracts |
|
| 54 |
+ |
|
| 55 |
+Example: |
|
| 56 |
+ |
|
| 57 |
+zigbee2mqtt/bedroom_sensor/temperature |
|
| 58 |
+ ↓ |
|
| 59 |
+vad/home/bedroom/temperature/bedroom-sensor/value |
|
| 60 |
+ |
|
| 61 |
+2. Payload normalization |
|
| 62 |
+ |
|
| 63 |
+Examples: |
|
| 64 |
+ |
|
| 65 |
+"23.4" → 23.4 |
|
| 66 |
+ |
|
| 67 |
+{"temperature":23.4}
|
|
| 68 |
+ ↓ |
|
| 69 |
+23.4 |
|
| 70 |
+ |
|
| 71 |
+3. Timestamp handling |
|
| 72 |
+ |
|
| 73 |
+If the device provides an observation timestamp, the adapter must preserve it. |
|
| 74 |
+ |
|
| 75 |
+If the device does not provide timestamps, the adapter may attach ingestion time. |
|
| 76 |
+ |
|
| 77 |
+4. Unit normalization |
|
| 78 |
+ |
|
| 79 |
+Example: |
|
| 80 |
+ |
|
| 81 |
+°F → °C |
|
| 82 |
+ |
|
| 83 |
+5. Identity normalization |
|
| 84 |
+ |
|
| 85 |
+Adapters map vendor identifiers to canonical IDs used by bus contracts. |
|
| 86 |
+ |
|
| 87 |
+6. Stream mapping |
|
| 88 |
+ |
|
| 89 |
+Adapters must route data to valid streams (`value`, `last`, `set`, `meta`, `availability`) according to bus rules. Legacy `state` and `event` topics remain compatibility-only during migration and SHOULD NOT be introduced by new adapters. |
|
| 90 |
+ |
|
| 91 |
+7. Historian policy projection |
|
| 92 |
+ |
|
| 93 |
+Adapters should publish enough retained `meta` for historian workers to ingest canonical topics without knowing vendor semantics. |
|
| 94 |
+ |
|
| 95 |
+--- |
|
| 96 |
+ |
|
| 97 |
+## Internal Models vs MQTT Output |
|
| 98 |
+ |
|
| 99 |
+Adapters MAY construct richer internal objects during normalization. |
|
| 100 |
+ |
|
| 101 |
+Example internal normalization shape: |
|
| 102 |
+ |
|
| 103 |
+```json |
|
| 104 |
+{
|
|
| 105 |
+ "sourcePayload": {
|
|
| 106 |
+ "temperature": 23.6, |
|
| 107 |
+ "battery": 91 |
|
| 108 |
+ }, |
|
| 109 |
+ "normalizedLocation": "bedroom", |
|
| 110 |
+ "capability": "temperature", |
|
| 111 |
+ "deviceId": "bedroom-sensor", |
|
| 112 |
+ "stream": "value" |
|
| 113 |
+} |
|
| 114 |
+``` |
|
| 115 |
+ |
|
| 116 |
+Rule: |
|
| 117 |
+ |
|
| 118 |
+- these structures MUST be ephemeral and limited to the normalization stage |
|
| 119 |
+- they MUST remain local to the normalization stage and MUST NOT be attached to the `msg` object that continues through the hot-path pipeline |
|
| 120 |
+- once normalization is complete, the adapter MUST publish MQTT-ready messages as early as possible |
|
| 121 |
+- before publishing to MQTT, the adapter MUST reduce the message to the canonical MQTT-ready form |
|
| 122 |
+- consumers on the semantic bus MUST NOT need to understand adapter-specific fields |
|
| 123 |
+ |
|
| 124 |
+At the publish boundary, the message SHOULD contain only: |
|
| 125 |
+ |
|
| 126 |
+- `msg.topic` |
|
| 127 |
+- `msg.payload` |
|
| 128 |
+- `msg.qos` when QoS is not configured statically on the MQTT node |
|
| 129 |
+- `msg.retain` when retain is not configured statically on the MQTT node |
|
| 130 |
+ |
|
| 131 |
+Adapters MUST NOT publish rich internal envelopes such as: |
|
| 132 |
+ |
|
| 133 |
+```json |
|
| 134 |
+{
|
|
| 135 |
+ "topic": "vad/home/bedroom/temperature/bedroom-sensor/value", |
|
| 136 |
+ "payload": 23.6, |
|
| 137 |
+ "mapping": {
|
|
| 138 |
+ "source_field": "temperature" |
|
| 139 |
+ }, |
|
| 140 |
+ "normalizedBus": {
|
|
| 141 |
+ "bus": "home", |
|
| 142 |
+ "stream": "value" |
|
| 143 |
+ }, |
|
| 144 |
+ "sourcePayload": {
|
|
| 145 |
+ "temperature": 23.6, |
|
| 146 |
+ "battery": 91 |
|
| 147 |
+ }, |
|
| 148 |
+ "internalContext": {
|
|
| 149 |
+ "location": "bedroom" |
|
| 150 |
+ } |
|
| 151 |
+} |
|
| 152 |
+``` |
|
| 153 |
+ |
|
| 154 |
+Those fields may exist inside adapter logic, but MUST be removed before publish. |
|
| 155 |
+ |
|
| 156 |
+--- |
|
| 157 |
+ |
|
| 158 |
+## Fan-Out Pattern |
|
| 159 |
+ |
|
| 160 |
+Many ingress protocols emit a single inbound payload containing multiple metrics. |
|
| 161 |
+ |
|
| 162 |
+Examples: |
|
| 163 |
+ |
|
| 164 |
+- Zigbee2MQTT |
|
| 165 |
+- Modbus pollers |
|
| 166 |
+- SNMP collectors |
|
| 167 |
+ |
|
| 168 |
+Adapters SHOULD normalize those payloads using fan-out: |
|
| 169 |
+ |
|
| 170 |
+1 inbound message |
|
| 171 |
+ ↓ |
|
| 172 |
+multiple canonical MQTT messages |
|
| 173 |
+ |
|
| 174 |
+Example inbound payload: |
|
| 175 |
+ |
|
| 176 |
+```json |
|
| 177 |
+{
|
|
| 178 |
+ "temperature": 23.4, |
|
| 179 |
+ "humidity": 41 |
|
| 180 |
+} |
|
| 181 |
+``` |
|
| 182 |
+ |
|
| 183 |
+Canonical adapter output: |
|
| 184 |
+ |
|
| 185 |
+- `vad/home/bedroom/temperature/bedroom-sensor/value` -> `23.4` |
|
| 186 |
+- `vad/home/bedroom/humidity/bedroom-sensor/value` -> `41` |
|
| 187 |
+ |
|
| 188 |
+Rule: |
|
| 189 |
+ |
|
| 190 |
+- each emitted message MUST be independent and minimal |
|
| 191 |
+- fan-out SHOULD produce canonical MQTT-ready outputs directly rather than forwarding one rich `msg` object through multiple downstream stages |
|
| 192 |
+- metadata for each emitted metric belongs on the corresponding retained `meta` topic |
|
| 193 |
+ |
|
| 194 |
+--- |
|
| 195 |
+ |
|
| 196 |
+## Multi‑Bus Capability Projection |
|
| 197 |
+ |
|
| 198 |
+Some physical devices expose multiple capabilities that belong to **different semantic domains**. In such cases an adapter may project different aspects of the same device onto different buses. |
|
| 199 |
+ |
|
| 200 |
+A common example is a **smart socket (smart plug)** which provides both: |
|
| 201 |
+ |
|
| 202 |
+- a controllable power switch |
|
| 203 |
+- energy measurement (power or accumulated energy) |
|
| 204 |
+ |
|
| 205 |
+These capabilities belong to different semantic models: |
|
| 206 |
+ |
|
| 207 |
+- switching is part of the **home automation model** |
|
| 208 |
+- energy measurement is part of the **energy telemetry model** |
|
| 209 |
+ |
|
| 210 |
+An adapter may therefore publish different streams derived from the same device to different buses. |
|
| 211 |
+ |
|
| 212 |
+Example: |
|
| 213 |
+ |
|
| 214 |
+Home bus (control semantics): |
|
| 215 |
+ |
|
| 216 |
+vad/home/living-room/power/tv/value |
|
| 217 |
+vad/home/living-room/power/tv/set |
|
| 218 |
+ |
|
| 219 |
+Energy bus (load telemetry): |
|
| 220 |
+ |
|
| 221 |
+vad/energy/load/living-room-entertainment/active_power/value |
|
| 222 |
+vad/energy/load/living-room-entertainment/energy_total/value |
|
| 223 |
+ |
|
| 224 |
+In this situation the adapter performs a **capability projection**, exposing each capability in the semantic domain where it belongs. |
|
| 225 |
+ |
|
| 226 |
+This approach prevents the spatial model of the home (`home` bus) from becoming coupled to the electrical topology represented by the `energy` bus, while still allowing a single physical device to participate in both domains. |
|
| 227 |
+ |
|
| 228 |
+Rule: |
|
| 229 |
+ |
|
| 230 |
+- projection across buses is allowed |
|
| 231 |
+- semantic duplication inside one bus should be avoided |
|
| 232 |
+- the same source field must not be published to multiple semantic meanings without an explicit reason |
|
| 233 |
+ |
|
| 234 |
+--- |
|
| 235 |
+ |
|
| 236 |
+## Explicit Non‑Responsibilities |
|
| 237 |
+ |
|
| 238 |
+Adapters must NOT: |
|
| 239 |
+ |
|
| 240 |
+- implement HomeKit logic |
|
| 241 |
+- implement automation rules |
|
| 242 |
+- aggregate multiple sensors |
|
| 243 |
+- perform anomaly detection |
|
| 244 |
+- store historical data |
|
| 245 |
+ |
|
| 246 |
+These functions belong to other components in the system. |
|
| 247 |
+ |
|
| 248 |
+--- |
|
| 249 |
+ |
|
| 250 |
+## Node-RED Execution Guidelines |
|
| 251 |
+ |
|
| 252 |
+Because adapters are implemented in Node-RED, the following constraints apply: |
|
| 253 |
+ |
|
| 254 |
+- Node-RED hot paths are not optimized for large per-message object graphs |
|
| 255 |
+- prefer deterministic `change`/`switch` mapping before custom `function` logic |
|
| 256 |
+- centralize topic and metric mapping in reusable subflows |
|
| 257 |
+- minimize per-message allocations on hot paths |
|
| 258 |
+- avoid large nested objects and rich per-message envelopes |
|
| 259 |
+- avoid heavy per-message JSON transform on high-rate telemetry |
|
| 260 |
+- use scalar payload on hot `value` streams and publish metadata on retained `meta` |
|
| 261 |
+- use retained `last` for cold-start samples that need timestamp/freshness evaluation |
|
| 262 |
+- keep live `value` streams lightweight and deduplicated where appropriate |
|
| 263 |
+ |
|
| 264 |
+--- |
|
| 265 |
+ |
|
| 266 |
+## Consumer Adapters and Dynamic Subscriptions |
|
| 267 |
+ |
|
| 268 |
+Not all adapters are ingress adapters. |
|
| 269 |
+ |
|
| 270 |
+In practice, the system also includes consumer adapters that subscribe to canonical bus topics and project them into a downstream model such as HomeKit. |
|
| 271 |
+ |
|
| 272 |
+Examples: |
|
| 273 |
+ |
|
| 274 |
+- Device -> Protocol Adapter -> MQTT Bus |
|
| 275 |
+- MQTT Bus -> HomeKit Adapter -> HomeKit |
|
| 276 |
+ |
|
| 277 |
+Consumer adapters SHOULD follow these rules: |
|
| 278 |
+ |
|
| 279 |
+- consume only canonical bus topics |
|
| 280 |
+- keep consumer-specific logic out of the ingress adapter |
|
| 281 |
+- use retained `last` for bootstrap when startup state matters |
|
| 282 |
+- continue on live `value` after bootstrap |
|
| 283 |
+- unsubscribe from `last` only after bootstrap completeness is explicitly satisfied |
|
| 284 |
+ |
|
| 285 |
+Practical recommendation: |
|
| 286 |
+ |
|
| 287 |
+- if a Node-RED adapter node has a dedicated output for controlling a dynamic `mqtt in`, keep that control output as the last output when possible |
|
| 288 |
+ |
|
| 289 |
+This keeps semantic outputs stable and reduces rewiring churn. |
|
| 290 |
+ |
|
| 291 |
+--- |
|
| 292 |
+ |
|
| 293 |
+## Dedicated MQTT Session Rule for Retained Bootstrap |
|
| 294 |
+ |
|
| 295 |
+If a consumer adapter depends on retained `last` for deterministic cold start, it SHOULD use a dedicated MQTT client session. |
|
| 296 |
+ |
|
| 297 |
+In Node-RED this means: |
|
| 298 |
+ |
|
| 299 |
+- create a dedicated `mqtt-broker` config node for that dynamic `mqtt in` |
|
| 300 |
+- do not share the same broker config with unrelated static or dynamic subscribers when retained bootstrap must be isolated |
|
| 301 |
+ |
|
| 302 |
+Reason: |
|
| 303 |
+ |
|
| 304 |
+- broker config nodes represent shared MQTT client sessions |
|
| 305 |
+- retained replay behavior is coupled to subscribe operations inside that session |
|
| 306 |
+- shared sessions make lifecycle-sensitive bootstrap behavior difficult to reason about |
|
| 307 |
+ |
|
| 308 |
+Observed failure mode: |
|
| 309 |
+ |
|
| 310 |
+- a static subscriber receives retained `last` |
|
| 311 |
+- a dynamic consumer using the same broker config subscribes later |
|
| 312 |
+- the consumer does not observe retained bootstrap deterministically |
|
| 313 |
+- later live updates republish `last`, masking the real issue |
|
| 314 |
+ |
|
| 315 |
+Rule: |
|
| 316 |
+ |
|
| 317 |
+- live-only telemetry consumers MAY share a broker config |
|
| 318 |
+- consumers that rely on retained bootstrap SHOULD NOT |
|
| 319 |
+ |
|
| 320 |
+--- |
|
| 321 |
+ |
|
| 322 |
+## Dynamic `mqtt in` Control Recommendations |
|
| 323 |
+ |
|
| 324 |
+For Node-RED dynamic `mqtt in`, adapter control messages SHOULD remain simple and explicit. |
|
| 325 |
+ |
|
| 326 |
+Preferred pattern: |
|
| 327 |
+ |
|
| 328 |
+- one `subscribe` message per topic |
|
| 329 |
+- one `unsubscribe` message per topic |
|
| 330 |
+ |
|
| 331 |
+Example: |
|
| 332 |
+ |
|
| 333 |
+```json |
|
| 334 |
+{
|
|
| 335 |
+ "action": "subscribe", |
|
| 336 |
+ "topic": "vad/home/balcon/+/south/last", |
|
| 337 |
+ "qos": 2, |
|
| 338 |
+ "rh": 0, |
|
| 339 |
+ "rap": true |
|
| 340 |
+} |
|
| 341 |
+``` |
|
| 342 |
+ |
|
| 343 |
+```json |
|
| 344 |
+{
|
|
| 345 |
+ "action": "subscribe", |
|
| 346 |
+ "topic": "vad/home/balcon/+/south/value", |
|
| 347 |
+ "qos": 2, |
|
| 348 |
+ "rh": 0, |
|
| 349 |
+ "rap": true |
|
| 350 |
+} |
|
| 351 |
+``` |
|
| 352 |
+ |
|
| 353 |
+Recommendation: |
|
| 354 |
+ |
|
| 355 |
+- prefer separate control messages over multi-topic control payloads unless the exact runtime behavior has been verified on the target Node-RED version |
|
| 356 |
+ |
|
| 357 |
+See `adapter_implementation_examples.md` for the full flow pattern and debugging guidance. |
|
| 358 |
+- allow `last` to update whenever the latest observation timestamp changes, even if the scalar value is unchanged |
|
| 359 |
+- publish MQTT-ready messages as early as possible once normalization is complete |
|
| 360 |
+- keep temporary normalization structures in local variables or node-local context, not on forwarded `msg` objects |
|
| 361 |
+- delete temporary normalization fields before the MQTT publish node |
|
| 362 |
+- keep MQTT subscriptions narrow (avoid global `#` on hot pipelines) |
|
| 363 |
+- include error routing for malformed input and unknown mapping cases |
|
| 364 |
+ |
|
| 365 |
+Hot-path rule: |
|
| 366 |
+ |
|
| 367 |
+- the semantic bus is not a debugging channel |
|
| 368 |
+- adapters should emit MQTT-ready messages as the semantic boundary |
|
| 369 |
+- adapter internals belong in transient Node-RED state or under operational `sys` topics, not on bus payloads |
|
| 370 |
+ |
|
| 371 |
+Recommended final publish stage: |
|
| 372 |
+ |
|
| 373 |
+```javascript |
|
| 374 |
+return {
|
|
| 375 |
+ topic: normalizedTopic, |
|
| 376 |
+ payload: normalizedValue |
|
| 377 |
+}; |
|
| 378 |
+``` |
|
| 379 |
+ |
|
| 380 |
+If an existing `msg` object must be reused: |
|
| 381 |
+ |
|
| 382 |
+```javascript |
|
| 383 |
+msg.topic = normalizedTopic; |
|
| 384 |
+msg.payload = normalizedValue; |
|
| 385 |
+ |
|
| 386 |
+delete msg.internal; |
|
| 387 |
+delete msg.internalContext; |
|
| 388 |
+delete msg.mapping; |
|
| 389 |
+delete msg.normalizedBus; |
|
| 390 |
+delete msg.sourcePayload; |
|
| 391 |
+ |
|
| 392 |
+return msg; |
|
| 393 |
+``` |
|
| 394 |
+ |
|
| 395 |
+Operational requirement: |
|
| 396 |
+ |
|
| 397 |
+- malformed or unmappable messages SHOULD be published to `<site>/sys/adapter/<adapter_id>/dlq` |
|
| 398 |
+- adapter faults SHOULD be published to `<site>/sys/adapter/<adapter_id>/error` |
|
| 399 |
+- adapter liveness SHOULD be exposed on `<site>/sys/adapter/<adapter_id>/availability` |
|
| 400 |
+- adapter diagnostics and counters SHOULD be published to `<site>/sys/adapter/<adapter_id>/stats` |
|
| 401 |
+ |
|
| 402 |
+See `sys_bus.md` for the shared contract of these operational topics. |
|
| 403 |
+ |
|
| 404 |
+Operational recommendation: |
|
| 405 |
+ |
|
| 406 |
+- one ingress flow per protocol |
|
| 407 |
+- one shared normalization subflow per bus contract |
|
| 408 |
+- one egress flow for publish, with QoS/retain set per stream policy |
|
| 409 |
+ |
|
| 410 |
+ |
|
| 411 |
+## Mapping Registry |
|
| 412 |
+ |
|
| 413 |
+Adapters should be driven by declarative mapping data wherever possible. |
|
| 414 |
+ |
|
| 415 |
+Recommended mapping fields: |
|
| 416 |
+ |
|
| 417 |
+- `source_system` |
|
| 418 |
+- `source_topic_match` |
|
| 419 |
+- `source_field` |
|
| 420 |
+- `target_bus` |
|
| 421 |
+- `target_location` or `target_entity_id` |
|
| 422 |
+- `target_capability` or `target_metric` |
|
| 423 |
+- `target_device_id` |
|
| 424 |
+- `stream` |
|
| 425 |
+- `payload_profile` |
|
| 426 |
+- `unit` |
|
| 427 |
+- `historian_enabled` |
|
| 428 |
+- `historian_mode` |
|
| 429 |
+ |
|
| 430 |
+Example mapping entry for a Zigbee room sensor: |
|
| 431 |
+ |
|
| 432 |
+```json |
|
| 433 |
+{
|
|
| 434 |
+ "source_system": "zigbee2mqtt", |
|
| 435 |
+ "source_topic_match": "zigbee2mqtt/bedroom_sensor", |
|
| 436 |
+ "source_field": "temperature", |
|
| 437 |
+ "target_bus": "home", |
|
| 438 |
+ "target_location": "bedroom", |
|
| 439 |
+ "target_capability": "temperature", |
|
| 440 |
+ "target_device_id": "bedroom-sensor", |
|
| 441 |
+ "stream": "value", |
|
| 442 |
+ "payload_profile": "scalar", |
|
| 443 |
+ "unit": "C", |
|
| 444 |
+ "historian_enabled": true, |
|
| 445 |
+ "historian_mode": "sample" |
|
| 446 |
+} |
|
| 447 |
+``` |
|
| 448 |
+ |
|
| 449 |
+This keeps normalization logic deterministic and reviewable. |
|
| 450 |
+ |
|
| 451 |
+--- |
|
| 452 |
+ |
|
| 453 |
+## Types of Adapters |
|
| 454 |
+ |
|
| 455 |
+Adapters are typically organized by protocol or subsystem. |
|
| 456 |
+ |
|
| 457 |
+Examples: |
|
| 458 |
+ |
|
| 459 |
+zigbee_adapter |
|
| 460 |
+ |
|
| 461 |
+Transforms Zigbee2MQTT topics into the canonical bus structure. |
|
| 462 |
+ |
|
| 463 |
+network_adapter |
|
| 464 |
+ |
|
| 465 |
+Transforms SNMP / router telemetry into the network bus. |
|
| 466 |
+ |
|
| 467 |
+energy_adapter |
|
| 468 |
+ |
|
| 469 |
+Normalizes inverter, meter, and battery telemetry. |
|
| 470 |
+ |
|
| 471 |
+vehicle_adapter |
|
| 472 |
+ |
|
| 473 |
+Normalizes EV charger and vehicle telemetry. |
|
| 474 |
+ |
|
| 475 |
+ |
|
| 476 |
+## Zigbee2MQTT Adapter Guidance |
|
| 477 |
+ |
|
| 478 |
+Zigbee2MQTT is expected to be one of the first major ingress sources for the new broker, so its projection rules should be explicit. |
|
| 479 |
+ |
|
| 480 |
+Source shape: |
|
| 481 |
+ |
|
| 482 |
+- base telemetry topic: `zigbee2mqtt/<friendly_name>` |
|
| 483 |
+- availability topic: `zigbee2mqtt/<friendly_name>/availability` |
|
| 484 |
+- payload: JSON object with one or more capability fields |
|
| 485 |
+ |
|
| 486 |
+Normalization rules: |
|
| 487 |
+ |
|
| 488 |
+- one inbound Z2M JSON payload may fan out into multiple canonical MQTT publications |
|
| 489 |
+- friendly names SHOULD follow the deterministic naming convention defined below so canonical IDs and locations can be derived directly from the topic path |
|
| 490 |
+- vendor names and IEEE addresses MUST be preserved in `meta.source_ref`, not exposed in canonical topic paths |
|
| 491 |
+- measurements, booleans, enums, and transition-like signals map to live `value` |
|
| 492 |
+- adapters SHOULD use `meta.historian.mode` to label whether a `value` stream is semantically a `sample`, `state`, or `event` |
|
| 493 |
+- adapters SHOULD deduplicate hot `value` publications when the semantic value did not change |
|
| 494 |
+- adapters SHOULD update retained `last` whenever the latest observed timestamp changes, even if the scalar value is unchanged |
|
| 495 |
+ |
|
| 496 |
+Recommended initial Z2M field mapping: |
|
| 497 |
+ |
|
| 498 |
+- `temperature` -> `home/.../temperature/.../value` |
|
| 499 |
+- `humidity` -> `home/.../humidity/.../value` |
|
| 500 |
+- `pressure` -> `home/.../pressure/.../value` |
|
| 501 |
+- `illuminance` -> `home/.../illuminance/.../value` |
|
| 502 |
+- `contact` -> `home/.../contact/.../value` |
|
| 503 |
+- `occupancy` from PIR devices -> `home/.../motion/.../value` |
|
| 504 |
+- `presence` from mmWave devices with `fading_time=0` -> `home/.../motion/.../value` |
|
| 505 |
+- `battery` -> `home/.../battery/.../value` |
|
| 506 |
+- `state` on smart plugs or switches -> `home/.../power/.../value` |
|
| 507 |
+- `power`, `energy`, `voltage`, `current` on smart plugs -> `energy/.../.../.../value` |
|
| 508 |
+- `action` -> `home/.../button/.../value` |
|
| 509 |
+ |
|
| 510 |
+mmWave presence handling: |
|
| 511 |
+ |
|
| 512 |
+- if a mmWave device emits `presence` while `fading_time=0`, adapters SHOULD treat it as raw motion detection and publish `motion`, not `presence` |
|
| 513 |
+- device-level `presence` SHOULD usually be derived above the adapter layer through fusion or higher-level logic |
|
| 514 |
+- adapters MAY publish `presence/.../value` directly only when the device is intentionally configured to expose held presence semantics, for example with non-zero `fading_time` |
|
| 515 |
+ |
|
| 516 |
+Fields that SHOULD NOT go to `home` by default: |
|
| 517 |
+ |
|
| 518 |
+- `linkquality` |
|
| 519 |
+- transport diagnostics |
|
| 520 |
+- adapter-local counters |
|
| 521 |
+ |
|
| 522 |
+Those belong on `sys` or a future `network` bus. |
|
| 523 |
+ |
|
| 524 |
+## Zigbee2MQTT Topic Structure for Deterministic Adapter Translation |
|
| 525 |
+ |
|
| 526 |
+In Zigbee2MQTT the primary telemetry topic structure is: |
|
| 527 |
+ |
|
| 528 |
+`zigbee2mqtt/<friendly_name>` |
|
| 529 |
+ |
|
| 530 |
+The MQTT prefix is controlled by the Zigbee2MQTT `base_topic` configuration parameter. |
|
| 531 |
+ |
|
| 532 |
+Default: |
|
| 533 |
+ |
|
| 534 |
+`zigbee2mqtt` |
|
| 535 |
+ |
|
| 536 |
+Zigbee2MQTT allows the `/` character inside `friendly_name`, which means the MQTT topic hierarchy can be controlled by the device name itself. |
|
| 537 |
+ |
|
| 538 |
+Example: |
|
| 539 |
+ |
|
| 540 |
+- friendly name: `kitchen/floor_light` |
|
| 541 |
+- resulting topic: `zigbee2mqtt/kitchen/floor_light` |
|
| 542 |
+ |
|
| 543 |
+This project uses that feature intentionally to encode semantic information directly into the Zigbee2MQTT topic path. |
|
| 544 |
+ |
|
| 545 |
+For Zigbee devices in this repository, the `friendly_name` MUST use the following structure: |
|
| 546 |
+ |
|
| 547 |
+`<device_type>/<site>/<location>/<device_id>` |
|
| 548 |
+ |
|
| 549 |
+Example: |
|
| 550 |
+ |
|
| 551 |
+- friendly name: `ZG-204ZV/vad/balcon/south` |
|
| 552 |
+- resulting topic: `zigbee2mqtt/ZG-204ZV/vad/balcon/south` |
|
| 553 |
+ |
|
| 554 |
+Segment meaning: |
|
| 555 |
+ |
|
| 556 |
+- `device_type`: hardware model or device class |
|
| 557 |
+- `site`: canonical site identifier |
|
| 558 |
+- `location`: canonical room/location identifier |
|
| 559 |
+- `device_id`: logical endpoint identifier within the location |
|
| 560 |
+ |
|
| 561 |
+### Adapter Translation Rationale |
|
| 562 |
+ |
|
| 563 |
+Structuring Zigbee2MQTT topics this way allows adapters to perform deterministic translation without lookup tables. |
|
| 564 |
+ |
|
| 565 |
+Example inbound topic: |
|
| 566 |
+ |
|
| 567 |
+`zigbee2mqtt/ZG-204ZV/vad/balcon/south` |
|
| 568 |
+ |
|
| 569 |
+Example payload: |
|
| 570 |
+ |
|
| 571 |
+```json |
|
| 572 |
+{ "illuminance": 704 }
|
|
| 573 |
+``` |
|
| 574 |
+ |
|
| 575 |
+Adapter output: |
|
| 576 |
+ |
|
| 577 |
+`vad/home/balcon/illuminance/south/value` |
|
| 578 |
+ |
|
| 579 |
+The adapter only needs to split the topic path and map payload fields to capabilities. |
|
| 580 |
+ |
|
| 581 |
+This aligns directly with the home bus contract defined in `home_bus.md`: |
|
| 582 |
+ |
|
| 583 |
+`<site>/home/<location>/<capability>/<device_id>/<stream>` |
|
| 584 |
+ |
|
| 585 |
+Example: |
|
| 586 |
+ |
|
| 587 |
+`vad/home/balcon/illuminance/south/value` |
|
| 588 |
+ |
|
| 589 |
+The Zigbee2MQTT topic structure intentionally mirrors the dimensions required by the semantic home bus grammar. |
|
| 590 |
+ |
|
| 591 |
+### Naming Rules |
|
| 592 |
+ |
|
| 593 |
+- `device_type` should correspond to the hardware model when possible |
|
| 594 |
+- `location` must match canonical location identifiers used by the home bus |
|
| 595 |
+- `device_id` must be unique within the location |
|
| 596 |
+- identifiers must follow MQTT naming rules defined in `mqtt_contract.md` |
|
| 597 |
+- identifiers should use lowercase kebab-case where possible |
|
| 598 |
+ |
|
| 599 |
+This approach removes location mapping tables from adapters, enables deterministic topic parsing, simplifies Node-RED flows, and allows wildcard subscriptions by device type. |
|
| 600 |
+ |
|
| 601 |
+Useful subscriptions: |
|
| 602 |
+ |
|
| 603 |
+- `zigbee2mqtt/+/+/+/+` |
|
| 604 |
+- `zigbee2mqtt/ZG-204ZV/#` |
|
| 605 |
+ |
|
| 606 |
+## Adapter Auto-Provisioning from Source Topics |
|
| 607 |
+ |
|
| 608 |
+The purpose of this section is to define how adapters automatically derive canonical MQTT bus topics from source topics without requiring a static mapping table. |
|
| 609 |
+ |
|
| 610 |
+The design goal is to keep adapters deterministic and lightweight while allowing configuration overrides for exceptional cases. |
|
| 611 |
+ |
|
| 612 |
+### Principle |
|
| 613 |
+ |
|
| 614 |
+Adapters SHOULD attempt to derive semantic dimensions directly from the inbound topic structure. |
|
| 615 |
+ |
|
| 616 |
+For Zigbee2MQTT the expected inbound topic format is: |
|
| 617 |
+ |
|
| 618 |
+`zigbee2mqtt/<device_type>/<site>/<location>/<device_id>` |
|
| 619 |
+ |
|
| 620 |
+Adapters MUST parse the topic segments and attempt to infer: |
|
| 621 |
+ |
|
| 622 |
+- source system |
|
| 623 |
+- device type |
|
| 624 |
+- site |
|
| 625 |
+- location |
|
| 626 |
+- device identifier |
|
| 627 |
+ |
|
| 628 |
+These values are then used to construct canonical bus topics. |
|
| 629 |
+ |
|
| 630 |
+Example inbound topic: |
|
| 631 |
+ |
|
| 632 |
+`zigbee2mqtt/ZG-204ZV/vad/balcon/south` |
|
| 633 |
+ |
|
| 634 |
+Parsed dimensions: |
|
| 635 |
+ |
|
| 636 |
+- `source = zigbee2mqtt` |
|
| 637 |
+- `device_type = ZG-204ZV` |
|
| 638 |
+- `site = vad` |
|
| 639 |
+- `location = balcon` |
|
| 640 |
+- `device_id = south` |
|
| 641 |
+ |
|
| 642 |
+Example inbound payload: |
|
| 643 |
+ |
|
| 644 |
+```json |
|
| 645 |
+{ "illuminance": 704 }
|
|
| 646 |
+``` |
|
| 647 |
+ |
|
| 648 |
+Adapter output: |
|
| 649 |
+ |
|
| 650 |
+`vad/home/balcon/illuminance/south/value` |
|
| 651 |
+ |
|
| 652 |
+### Provisioning Algorithm |
|
| 653 |
+ |
|
| 654 |
+Adapters SHOULD follow the following processing order: |
|
| 655 |
+ |
|
| 656 |
+1. Parse topic segments. |
|
| 657 |
+2. Attempt direct semantic mapping. |
|
| 658 |
+3. Apply configuration overrides. |
|
| 659 |
+4. Apply default values if required fields are missing. |
|
| 660 |
+ |
|
| 661 |
+This ensures deterministic behavior while allowing controlled deviations. |
|
| 662 |
+ |
|
| 663 |
+### Configuration Overrides |
|
| 664 |
+ |
|
| 665 |
+Adapters MUST support a configuration object allowing explicit overrides. |
|
| 666 |
+ |
|
| 667 |
+Overrides allow correcting cases where the inbound topic does not match the canonical semantic model. |
|
| 668 |
+ |
|
| 669 |
+Example override configuration: |
|
| 670 |
+ |
|
| 671 |
+```json |
|
| 672 |
+{
|
|
| 673 |
+ "location_map": {
|
|
| 674 |
+ "balcony": "balcon" |
|
| 675 |
+ }, |
|
| 676 |
+ "bus_override": {
|
|
| 677 |
+ "power": "energy" |
|
| 678 |
+ }, |
|
| 679 |
+ "device_id_map": {
|
|
| 680 |
+ "south": "radar-south" |
|
| 681 |
+ } |
|
| 682 |
+} |
|
| 683 |
+``` |
|
| 684 |
+ |
|
| 685 |
+Override categories: |
|
| 686 |
+ |
|
| 687 |
+- `location_map` |
|
| 688 |
+ Maps inbound location identifiers to canonical location identifiers. |
|
| 689 |
+- `bus_override` |
|
| 690 |
+ Overrides which semantic bus a capability should be published to. |
|
| 691 |
+- `device_id_map` |
|
| 692 |
+ Renames device identifiers when canonical IDs differ from source IDs. |
|
| 693 |
+ |
|
| 694 |
+### Default Values |
|
| 695 |
+ |
|
| 696 |
+If a semantic dimension cannot be derived from the topic and no override exists, adapters MUST fall back to defaults. |
|
| 697 |
+ |
|
| 698 |
+Recommended defaults: |
|
| 699 |
+ |
|
| 700 |
+- `site = configured adapter site id` |
|
| 701 |
+- `bus = home` |
|
| 702 |
+- `location = unknown` |
|
| 703 |
+- `device_id = device_type` |
|
| 704 |
+- `stream = value` |
|
| 705 |
+ |
|
| 706 |
+Example fallback result: |
|
| 707 |
+ |
|
| 708 |
+`vad/home/unknown/temperature/ZG-204ZV/value` |
|
| 709 |
+ |
|
| 710 |
+These defaults ensure the adapter continues operating even when topic information is incomplete. |
|
| 711 |
+ |
|
| 712 |
+### Node-RED Implementation Guidance |
|
| 713 |
+ |
|
| 714 |
+Adapters implemented in Node-RED SHOULD: |
|
| 715 |
+ |
|
| 716 |
+- parse topic segments using deterministic split logic |
|
| 717 |
+- apply overrides via a centralized configuration object |
|
| 718 |
+- avoid dynamic lookup tables in hot paths |
|
| 719 |
+- emit canonical MQTT topics as early as possible |
|
| 720 |
+- publish malformed or unmappable inputs to: |
|
| 721 |
+ |
|
| 722 |
+`<site>/sys/adapter/<adapter_id>/dlq` |
|
| 723 |
+ |
|
| 724 |
+Adapter errors SHOULD be published to: |
|
| 725 |
+ |
|
| 726 |
+`<site>/sys/adapter/<adapter_id>/error` |
|
| 727 |
+ |
|
| 728 |
+This keeps semantic bus traffic deterministic while still exposing operational diagnostics. |
|
| 729 |
+ |
|
| 730 |
+### Architectural Rationale |
|
| 731 |
+ |
|
| 732 |
+Automatic provisioning from topic structure provides several advantages: |
|
| 733 |
+ |
|
| 734 |
+- eliminates large static mapping tables |
|
| 735 |
+- allows new devices to appear without manual configuration |
|
| 736 |
+- keeps Node-RED adapter logic simple |
|
| 737 |
+- keeps canonical bus structure predictable |
|
| 738 |
+- allows targeted overrides only when necessary |
|
| 739 |
+ |
|
| 740 |
+This approach maintains the principle that adapters perform normalization rather than interpretation. |
|
| 741 |
+ |
|
| 742 |
+ |
|
| 743 |
+## Historian Testability |
|
| 744 |
+ |
|
| 745 |
+Adapters should support a replay-friendly execution model so historian testing does not depend on live device timing. |
|
| 746 |
+ |
|
| 747 |
+Recommended modes: |
|
| 748 |
+ |
|
| 749 |
+- live consume from vendor topics |
|
| 750 |
+- replay recorded vendor fixtures |
|
| 751 |
+- synthetic generate canonical samples |
|
| 752 |
+ |
|
| 753 |
+For historian bootstrap, adapters SHOULD be able to emit: |
|
| 754 |
+ |
|
| 755 |
+- retained `last` for streams that must support cold start from the bus |
|
| 756 |
+- retained `meta` |
|
| 757 |
+- retained `availability` |
|
| 758 |
+- realistic live `value` traffic together with retained `last` |
|
| 759 |
+- dual projection for devices such as smart plugs |
|
| 760 |
+ |
|
| 761 |
+--- |
|
| 762 |
+ |
|
| 763 |
+## Relationship with HomeKit |
|
| 764 |
+ |
|
| 765 |
+HomeKit integration is implemented through a **separate adapter layer** that consumes the canonical MQTT bus. |
|
| 766 |
+ |
|
| 767 |
+Pipeline: |
|
| 768 |
+ |
|
| 769 |
+Device → Protocol Adapter → MQTT Bus → HomeKit Adapter → HomeKit |
|
| 770 |
+ |
|
| 771 |
+This separation prevents HomeKit constraints from leaking into protocol translation logic. |
|
| 772 |
+ |
|
| 773 |
+--- |
|
| 774 |
+ |
|
| 775 |
+## Design Principles |
|
| 776 |
+ |
|
| 777 |
+Adapters should follow several key principles: |
|
| 778 |
+ |
|
| 779 |
+1. Deterministic behavior |
|
| 780 |
+ |
|
| 781 |
+The same input must always produce the same output. |
|
| 782 |
+ |
|
| 783 |
+2. Stateless processing |
|
| 784 |
+ |
|
| 785 |
+Adapters should avoid maintaining internal state unless strictly required. |
|
| 786 |
+ |
|
| 787 |
+3. Minimal transformation |
|
| 788 |
+ |
|
| 789 |
+Adapters should only normalize data, not reinterpret it. |
|
| 790 |
+ |
|
| 791 |
+4. Idempotent operation |
|
| 792 |
+ |
|
| 793 |
+Repeated messages should not produce inconsistent system state. |
|
| 794 |
+ |
|
| 795 |
+5. Contract-first mapping |
|
| 796 |
+ |
|
| 797 |
+Adapters must validate outgoing topics against the target bus grammar. |
|
| 798 |
+ |
|
| 799 |
+6. Low-overhead runtime |
|
| 800 |
+ |
|
| 801 |
+Adapter behavior should minimize CPU and memory cost in Node-RED hot paths. |
|
| 802 |
+ |
|
| 803 |
+7. Operational transparency |
|
| 804 |
+ |
|
| 805 |
+Adapter failures must be observable without inspecting raw vendor topics. |
|
| 806 |
+ |
|
| 807 |
+--- |
|
| 808 |
+ |
|
| 809 |
+## Example |
|
| 810 |
+ |
|
| 811 |
+Vendor message: |
|
| 812 |
+ |
|
| 813 |
+Topic: |
|
| 814 |
+ |
|
| 815 |
+zigbee2mqtt/bedroom_sensor |
|
| 816 |
+ |
|
| 817 |
+Payload: |
|
| 818 |
+ |
|
| 819 |
+{
|
|
| 820 |
+ "temperature": 23.4, |
|
| 821 |
+ "humidity": 41 |
|
| 822 |
+} |
|
| 823 |
+ |
|
| 824 |
+Adapter output: |
|
| 825 |
+ |
|
| 826 |
+Topic: |
|
| 827 |
+ |
|
| 828 |
+vad/home/bedroom/temperature/bedroom-sensor/value |
|
| 829 |
+ |
|
| 830 |
+Payload: |
|
| 831 |
+ |
|
| 832 |
+23.4 |
|
| 833 |
+ |
|
| 834 |
+and |
|
| 835 |
+ |
|
| 836 |
+Topic: |
|
| 837 |
+ |
|
| 838 |
+vad/home/bedroom/humidity/bedroom-sensor/value |
|
| 839 |
+ |
|
| 840 |
+Payload: |
|
| 841 |
+ |
|
| 842 |
+41 |
|
| 843 |
+ |
|
| 844 |
+The final published bus message should be MQTT-ready and minimal, for example: |
|
| 845 |
+ |
|
| 846 |
+Topic: |
|
| 847 |
+ |
|
| 848 |
+vad/home/balcon/illuminance/radar-south/value |
|
| 849 |
+ |
|
| 850 |
+Payload: |
|
| 851 |
+ |
|
| 852 |
+1315 |
|
| 853 |
+ |
|
| 854 |
+It should NOT include diagnostic wrappers, mapping objects, or source payload copies. |
|
| 855 |
+ |
|
| 856 |
+If the same source is a smart plug, additional output may also be emitted on the `energy` bus: |
|
| 857 |
+ |
|
| 858 |
+Topic: |
|
| 859 |
+ |
|
| 860 |
+vad/energy/load/bedroom-heater/active_power/value |
|
| 861 |
+ |
|
| 862 |
+Payload: |
|
| 863 |
+ |
|
| 864 |
+126.8 |
|
| 865 |
+ |
|
| 866 |
+--- |
|
| 867 |
+ |
|
| 868 |
+This abstraction allows the rest of the system to operate independently of the device ecosystem and vendor protocols. |
|
@@ -0,0 +1,693 @@ |
||
| 1 |
+# Consolidated Bus & Data Collection Specification |
|
| 2 |
+ |
|
| 3 |
+Data: 2026-03-20 |
|
| 4 |
+ |
|
| 5 |
+Acest document consolidează specificațiile existente într-o referință unică pentru busuri, colectare de date și conformitate cu cerințele historian-ului. Nu înlocuiește documentele sursă, ci le leagă și stabilește deciziile rămase deschise. |
|
| 6 |
+ |
|
| 7 |
+Documentele sursă rămân canonice. Acest document este o hartă de navigare și un registru de decizii. |
|
| 8 |
+ |
|
| 9 |
+--- |
|
| 10 |
+ |
|
| 11 |
+## 1. Arhitectura Pipeline-ului |
|
| 12 |
+ |
|
| 13 |
+``` |
|
| 14 |
+Device / External System |
|
| 15 |
+ ↓ |
|
| 16 |
+Protocol Adapter (Node-RED) |
|
| 17 |
+ ↓ |
|
| 18 |
+Canonical MQTT Bus (home | energy) |
|
| 19 |
+ ↓ |
|
| 20 |
+Historian Worker |
|
| 21 |
+ ↓ |
|
| 22 |
+telemetry.ingest_measurement(...) ← measurement path (implementat) |
|
| 23 |
+telemetry.ingest_counter(...) ← counter path (contract stabilizat, neimplementat) |
|
| 24 |
+ ↓ |
|
| 25 |
+PostgreSQL Historian |
|
| 26 |
+``` |
|
| 27 |
+ |
|
| 28 |
+Documente relevante: |
|
| 29 |
+- `README.md` — arhitectura generală |
|
| 30 |
+- `addapters.md` — responsabilitățile adapterelor |
|
| 31 |
+- `historian_worker.md` — responsabilitățile worker-ului |
|
| 32 |
+ |
|
| 33 |
+--- |
|
| 34 |
+ |
|
| 35 |
+## 2. Contractul MQTT Partajat |
|
| 36 |
+ |
|
| 37 |
+Definit în `mqtt_contract.md`. Toate busurile moștenesc aceste reguli. |
|
| 38 |
+ |
|
| 39 |
+### 2.1 Namespace |
|
| 40 |
+ |
|
| 41 |
+- Busuri semantice: `<site>/<bus>/...` |
|
| 42 |
+- Namespace operațional: `<site>/sys/...` |
|
| 43 |
+- `<site>` = site-id stabil, kebab-case (ex: `vad`) |
|
| 44 |
+- Busuri v1: `home`, `energy` |
|
| 45 |
+ |
|
| 46 |
+### 2.2 Stream-uri Canonice |
|
| 47 |
+ |
|
| 48 |
+| Stream | Scop | Retain | QoS | |
|
| 49 |
+|---|---|---|---| |
|
| 50 |
+| `value` | sample live hot-path | false | 1 | |
|
| 51 |
+| `last` | ultimul sample cu timestamp, pentru cold-start | true | 1 | |
|
| 52 |
+| `set` | comandă/request | false | 1 | |
|
| 53 |
+| `meta` | metadata statică sau slow-changing | true | 1 | |
|
| 54 |
+| `availability` | online/offline | true (+ LWT) | 1 | |
|
| 55 |
+ |
|
| 56 |
+Reguli: |
|
| 57 |
+- adaptoarele emit date live doar pe `value` |
|
| 58 |
+- adaptoarele deduplică `value` când valoarea semantică nu s-a schimbat |
|
| 59 |
+- adaptoarele actualizează `last` (retained) la fiecare schimbare de timestamp |
|
| 60 |
+- `state` și `event` sunt legacy, nu se introduc de adaptoare noi |
|
| 61 |
+- `set` nu se reține niciodată |
|
| 62 |
+- historian-ul ingestează doar `value`, ignoră `last`, `set`, `meta`, `availability` |
|
| 63 |
+ |
|
| 64 |
+### 2.3 Payload Profiles |
|
| 65 |
+ |
|
| 66 |
+**Profile A — Scalar (default pentru hot paths):** |
|
| 67 |
+ |
|
| 68 |
+``` |
|
| 69 |
+23.6 |
|
| 70 |
+41 |
|
| 71 |
+true |
|
| 72 |
+on |
|
| 73 |
+``` |
|
| 74 |
+ |
|
| 75 |
+- metadata pe `meta` separat (retained) |
|
| 76 |
+- `observed_at` = ingestion time (acceptabil pentru Phase 1) |
|
| 77 |
+ |
|
| 78 |
+**Profile B — Envelope JSON (opțional):** |
|
| 79 |
+ |
|
| 80 |
+```json |
|
| 81 |
+{
|
|
| 82 |
+ "value": 23.6, |
|
| 83 |
+ "unit": "C", |
|
| 84 |
+ "observed_at": "2026-03-08T10:15:12Z", |
|
| 85 |
+ "quality": "good" |
|
| 86 |
+} |
|
| 87 |
+``` |
|
| 88 |
+ |
|
| 89 |
+- câmpuri opționale: `published_at`, `source_seq`, `annotations` |
|
| 90 |
+- utilizat când fidelitatea timestamp-ului contează sau când quality trebuie propagat per-sample |
|
| 91 |
+ |
|
| 92 |
+### 2.4 Meta Contract |
|
| 93 |
+ |
|
| 94 |
+Fiecare topic retained `meta` descrie familia sibling de `value`/`last`. |
|
| 95 |
+ |
|
| 96 |
+Formă minimă recomandată: |
|
| 97 |
+ |
|
| 98 |
+```json |
|
| 99 |
+{
|
|
| 100 |
+ "schema_ref": "mqbus.home.v1", |
|
| 101 |
+ "payload_profile": "scalar", |
|
| 102 |
+ "data_type": "number", |
|
| 103 |
+ "unit": "C", |
|
| 104 |
+ "adapter_id": "z2m-main", |
|
| 105 |
+ "source": "zigbee2mqtt", |
|
| 106 |
+ "source_ref": "0x00158d0008aa1111", |
|
| 107 |
+ "source_topic": "zigbee2mqtt/SENSOR/vad/bedroom/bedroom-sensor", |
|
| 108 |
+ "precision": 0.1, |
|
| 109 |
+ "historian": {
|
|
| 110 |
+ "enabled": true, |
|
| 111 |
+ "mode": "sample" |
|
| 112 |
+ } |
|
| 113 |
+} |
|
| 114 |
+``` |
|
| 115 |
+ |
|
| 116 |
+Câmpuri historian: |
|
| 117 |
+- `historian.enabled`: boolean |
|
| 118 |
+- `historian.mode`: `sample` | `state` | `event` | `ignore` |
|
| 119 |
+- `historian.retention_class`: opțional (`short`, `default`, `long`) |
|
| 120 |
+- `historian.sample_period_hint_s`: opțional |
|
| 121 |
+ |
|
| 122 |
+### 2.5 Time Semantics |
|
| 123 |
+ |
|
| 124 |
+Trei timestamp-uri distincte: |
|
| 125 |
+ |
|
| 126 |
+| Timestamp | Semnificație | |
|
| 127 |
+|---|---| |
|
| 128 |
+| `observed_at` | când sursa a observat/măsurat valoarea | |
|
| 129 |
+| `published_at` | când adapterul a publicat mesajul | |
|
| 130 |
+| `ingested_at` | când worker-ul a procesat mesajul | |
|
| 131 |
+ |
|
| 132 |
+Reguli: |
|
| 133 |
+- dacă sursa are timestamp, adapterul îl păstrează ca `observed_at` |
|
| 134 |
+- dacă nu, adapterul omite `observed_at`; worker-ul folosește `ingested_at` |
|
| 135 |
+- adaptoarele nu fabrică timestamp-uri |
|
| 136 |
+- dacă adapterul estimează timpul, folosește Profile B cu `quality=estimated` |
|
| 137 |
+ |
|
| 138 |
+### 2.6 Quality Model |
|
| 139 |
+ |
|
| 140 |
+Valori: `good`, `estimated`, `degraded`, `stale`, `invalid` |
|
| 141 |
+ |
|
| 142 |
+- `invalid` nu se emite pe bus semantic; merge pe `sys/.../error` |
|
| 143 |
+- `quality` se omite doar dacă `good` este implicit |
|
| 144 |
+ |
|
| 145 |
+--- |
|
| 146 |
+ |
|
| 147 |
+## 3. Home Bus |
|
| 148 |
+ |
|
| 149 |
+Definit în `home_bus.md`. Bus room-centric pentru senzori, control, automatizare. |
|
| 150 |
+ |
|
| 151 |
+### 3.1 Topic Grammar |
|
| 152 |
+ |
|
| 153 |
+``` |
|
| 154 |
+<site>/home/<location>/<capability>/<device_id>/<stream> |
|
| 155 |
+``` |
|
| 156 |
+ |
|
| 157 |
+Exemple: |
|
| 158 |
+- `vad/home/bedroom/temperature/bedroom-sensor/value` |
|
| 159 |
+- `vad/home/living-room/motion/radar-south/value` |
|
| 160 |
+- `vad/home/kitchen/light/ceiling-switch/set` |
|
| 161 |
+ |
|
| 162 |
+### 3.2 Capability Catalog |
|
| 163 |
+ |
|
| 164 |
+**Environmental:** `temperature`, `humidity`, `pressure`, `illuminance`, `co2`, `voc`, `pm25`, `pm10` |
|
| 165 |
+ |
|
| 166 |
+**Presence/Safety:** `motion`, `presence`, `contact`, `water_leak`, `smoke`, `gas`, `tamper` |
|
| 167 |
+ |
|
| 168 |
+**Control:** `light`, `power`, `lock`, `cover_position`, `thermostat_mode`, `target_temperature`, `fan_mode`, `button` |
|
| 169 |
+ |
|
| 170 |
+**Device health:** `battery`, `battery_low` |
|
| 171 |
+ |
|
| 172 |
+Reguli: |
|
| 173 |
+- `power` pe `home` doar pentru control semantics (on/off) |
|
| 174 |
+- metrici electrice (`active_power`, `energy_total`) pe `energy` |
|
| 175 |
+- `linkquality` nu pe `home` (merge pe `sys` sau viitor `network`) |
|
| 176 |
+- `presence` = stare derivată de nivel mai înalt; detecția brută mmWave cu `fading_time=0` se publică ca `motion` |
|
| 177 |
+ |
|
| 178 |
+### 3.3 Historian Mapping |
|
| 179 |
+ |
|
| 180 |
+``` |
|
| 181 |
+metric_name = <capability> |
|
| 182 |
+device_id = <location>.<device_id> |
|
| 183 |
+``` |
|
| 184 |
+ |
|
| 185 |
+Exemplu: |
|
| 186 |
+- Topic: `vad/home/bedroom/temperature/bedroom-sensor/value` |
|
| 187 |
+- `metric_name = temperature` |
|
| 188 |
+- `device_id = bedroom.bedroom-sensor` |
|
| 189 |
+- `value = 23.4` (scalar) |
|
| 190 |
+- `observed_at = ingested_at` (Profile A fallback) |
|
| 191 |
+ |
|
| 192 |
+--- |
|
| 193 |
+ |
|
| 194 |
+## 4. Energy Bus |
|
| 195 |
+ |
|
| 196 |
+Definit în `energy_bus.md`. Bus pentru topologia electrică: producție, stocare, grid, load. |
|
| 197 |
+ |
|
| 198 |
+### 4.1 Topic Grammar |
|
| 199 |
+ |
|
| 200 |
+``` |
|
| 201 |
+<site>/energy/<entity_type>/<entity_id>/<metric>/<stream> |
|
| 202 |
+``` |
|
| 203 |
+ |
|
| 204 |
+Entity types: `source`, `storage`, `grid`, `load`, `transfer` |
|
| 205 |
+ |
|
| 206 |
+Exemple: |
|
| 207 |
+- `vad/energy/source/pv-roof-1/active_power/value` |
|
| 208 |
+- `vad/energy/storage/battery-main/soc/value` |
|
| 209 |
+- `vad/energy/grid/main-meter/import_power/value` |
|
| 210 |
+- `vad/energy/load/living-room-tv/active_power/value` |
|
| 211 |
+ |
|
| 212 |
+### 4.2 Metric Classes |
|
| 213 |
+ |
|
| 214 |
+**Measurement-style** (compatibile cu `ingest_measurement()` acum): |
|
| 215 |
+ |
|
| 216 |
+`active_power`, `voltage`, `current`, `frequency`, `soc`, `charge_power`, `discharge_power` |
|
| 217 |
+ |
|
| 218 |
+**Counter-style** (pe bus, dar NU prin measurement API — urmează `ingest_counter()`): |
|
| 219 |
+ |
|
| 220 |
+`energy_total`, `import_energy_total`, `export_energy_total` |
|
| 221 |
+ |
|
| 222 |
+### 4.3 Units Canonice |
|
| 223 |
+ |
|
| 224 |
+| Metric | Unit | |
|
| 225 |
+|---|---| |
|
| 226 |
+| power | `W` | |
|
| 227 |
+| energy | `Wh` sau `kWh` | |
|
| 228 |
+| voltage | `V` | |
|
| 229 |
+| current | `A` | |
|
| 230 |
+| frequency | `Hz` | |
|
| 231 |
+| state of charge | `%` | |
|
| 232 |
+ |
|
| 233 |
+### 4.4 Historian Mapping |
|
| 234 |
+ |
|
| 235 |
+``` |
|
| 236 |
+metric_name = <metric> |
|
| 237 |
+device_id = <entity_type>.<entity_id> |
|
| 238 |
+``` |
|
| 239 |
+ |
|
| 240 |
+Exemplu: |
|
| 241 |
+- Topic: `vad/energy/storage/battery-main/soc/value` |
|
| 242 |
+- `metric_name = soc` |
|
| 243 |
+- `device_id = storage.battery-main` |
|
| 244 |
+ |
|
| 245 |
+### 4.5 Ownership Rule |
|
| 246 |
+ |
|
| 247 |
+- Dacă valoarea e pentru control utilizator într-o cameră → `home` |
|
| 248 |
+- Dacă valoarea e pentru contabilitate electrică → `energy` |
|
| 249 |
+- Un device fizic poate proiecta pe ambele busuri (smart plug: `power` pe `home`, `active_power` pe `energy`) |
|
| 250 |
+ |
|
| 251 |
+--- |
|
| 252 |
+ |
|
| 253 |
+## 5. Namespace Operațional (sys) |
|
| 254 |
+ |
|
| 255 |
+Definit în `sys_bus.md`. Nu este un bus semantic. |
|
| 256 |
+ |
|
| 257 |
+### 5.1 Topic Grammar |
|
| 258 |
+ |
|
| 259 |
+``` |
|
| 260 |
+<site>/sys/<producer_kind>/<instance_id>/<stream> |
|
| 261 |
+``` |
|
| 262 |
+ |
|
| 263 |
+Producer kinds v1: `adapter`, `historian` |
|
| 264 |
+ |
|
| 265 |
+### 5.2 Stream-uri Operaționale |
|
| 266 |
+ |
|
| 267 |
+| Stream | Scop | Retain | |
|
| 268 |
+|---|---|---| |
|
| 269 |
+| `availability` | liveness-ul componentei | true + LWT | |
|
| 270 |
+| `stats` | contori operaționali, snapshot periodic | true | |
|
| 271 |
+| `error` | erori structurate pentru operator | false | |
|
| 272 |
+| `dlq` | mesaje dead-letter cu context | false | |
|
| 273 |
+ |
|
| 274 |
+Exemple: |
|
| 275 |
+- `vad/sys/adapter/z2m-main/availability` → `online` |
|
| 276 |
+- `vad/sys/historian/main/error` → `{"code":"unknown_metric",...}`
|
|
| 277 |
+ |
|
| 278 |
+--- |
|
| 279 |
+ |
|
| 280 |
+## 6. Adaptoare |
|
| 281 |
+ |
|
| 282 |
+Definit în `addapters.md` și `adapter_implementation_examples.md`. |
|
| 283 |
+ |
|
| 284 |
+### 6.1 Responsabilități |
|
| 285 |
+ |
|
| 286 |
+1. Traducere topic-uri din format vendor în bus canonic |
|
| 287 |
+2. Normalizare payload (JSON → scalar, conversie unități) |
|
| 288 |
+3. Păstrare timestamp sursă (dacă există) |
|
| 289 |
+4. Fan-out: 1 mesaj inbound → N publicațiuni canonice |
|
| 290 |
+5. Proiecție multi-bus (smart plug → `home` + `energy`) |
|
| 291 |
+6. Publicare `meta` retained înaintea traficului live |
|
| 292 |
+7. Publicare `availability` retained + LWT |
|
| 293 |
+8. Routing erori pe `sys/.../error` și `sys/.../dlq` |
|
| 294 |
+ |
|
| 295 |
+### 6.2 NON-Responsabilități |
|
| 296 |
+ |
|
| 297 |
+- Nu implementează logica HomeKit |
|
| 298 |
+- Nu implementează reguli de automatizare |
|
| 299 |
+- Nu agregă senzori |
|
| 300 |
+- Nu stochează date istorice |
|
| 301 |
+- Nu repară mesaje malformed (le expune pe `sys`) |
|
| 302 |
+ |
|
| 303 |
+### 6.3 Publish Boundary |
|
| 304 |
+ |
|
| 305 |
+La publicare, mesajul conține DOAR: |
|
| 306 |
+ |
|
| 307 |
+```javascript |
|
| 308 |
+{ topic: normalizedTopic, payload: normalizedValue }
|
|
| 309 |
+``` |
|
| 310 |
+ |
|
| 311 |
+Structurile interne de normalizare se șterg înainte de publish. |
|
| 312 |
+ |
|
| 313 |
+### 6.4 Z2M Topic Convention |
|
| 314 |
+ |
|
| 315 |
+Friendly name: `<device_type>/<site>/<location>/<device_id>` |
|
| 316 |
+ |
|
| 317 |
+Exemplu: |
|
| 318 |
+- Z2M: `zigbee2mqtt/ZG-204ZV/vad/balcon/south` |
|
| 319 |
+- Canonical: `vad/home/balcon/illuminance/south/value` |
|
| 320 |
+ |
|
| 321 |
+Permite traducere deterministă fără lookup tables. |
|
| 322 |
+ |
|
| 323 |
+### 6.5 Z2M Field Mapping |
|
| 324 |
+ |
|
| 325 |
+| Z2M field | Bus | Canonical capability/metric | |
|
| 326 |
+|---|---|---| |
|
| 327 |
+| `temperature` | home | `temperature` | |
|
| 328 |
+| `humidity` | home | `humidity` | |
|
| 329 |
+| `pressure` | home | `pressure` | |
|
| 330 |
+| `illuminance` | home | `illuminance` | |
|
| 331 |
+| `contact` | home | `contact` | |
|
| 332 |
+| `occupancy` (PIR) | home | `motion` | |
|
| 333 |
+| `presence` (mmWave, `fading_time=0`) | home | `motion` | |
|
| 334 |
+| `battery` | home | `battery` | |
|
| 335 |
+| `state` (plug/switch) | home | `power` | |
|
| 336 |
+| `action` (remote) | home | `button` | |
|
| 337 |
+| `power` (smart plug) | energy | `active_power` | |
|
| 338 |
+| `voltage` (smart plug) | energy | `voltage` | |
|
| 339 |
+| `current` (smart plug) | energy | `current` | |
|
| 340 |
+| `energy` (smart plug) | energy | `energy_total` | |
|
| 341 |
+ |
|
| 342 |
+### 6.6 Mapping Registry |
|
| 343 |
+ |
|
| 344 |
+Adapoarele folosesc configurație declarativă. Câmpuri recomandate: |
|
| 345 |
+ |
|
| 346 |
+`source_system`, `source_topic_match`, `source_field`, `target_bus`, `target_location` / `target_entity_id`, `target_capability` / `target_metric`, `target_device_id`, `stream`, `payload_profile`, `unit`, `historian_enabled`, `historian_mode` |
|
| 347 |
+ |
|
| 348 |
+### 6.7 Auto-Provisioning |
|
| 349 |
+ |
|
| 350 |
+Adapoarele derivă dimensiunile semantice din structura topic-ului Z2M. Override-uri se aplică doar pentru excepții. Default-uri: `bus=home`, `location=unknown`, `stream=value`. |
|
| 351 |
+ |
|
| 352 |
+--- |
|
| 353 |
+ |
|
| 354 |
+## 7. Historian Worker |
|
| 355 |
+ |
|
| 356 |
+Definit în `historian_worker.md`. |
|
| 357 |
+ |
|
| 358 |
+### 7.1 Subscription Model |
|
| 359 |
+ |
|
| 360 |
+``` |
|
| 361 |
++/home/+/+/+/value ← ingestie primară |
|
| 362 |
++/energy/+/+/+/value ← ingestie primară |
|
| 363 |
++/home/+/+/+/meta ← cache local |
|
| 364 |
++/energy/+/+/+/meta ← cache local |
|
| 365 |
++/home/+/+/+/last ← ignorat pentru ingestie |
|
| 366 |
++/energy/+/+/+/last ← ignorat pentru ingestie |
|
| 367 |
+``` |
|
| 368 |
+ |
|
| 369 |
+### 7.2 Topic → Historian Mapping (sumar) |
|
| 370 |
+ |
|
| 371 |
+| Bus | `metric_name` | `device_id` | |
|
| 372 |
+|---|---|---| |
|
| 373 |
+| `home` | `<capability>` | `<location>.<device_id>` | |
|
| 374 |
+| `energy` | `<metric>` | `<entity_type>.<entity_id>` | |
|
| 375 |
+ |
|
| 376 |
+### 7.3 Payload Handling |
|
| 377 |
+ |
|
| 378 |
+**Scalar (Profile A):** |
|
| 379 |
+- `value` = payload parsat ca number/boolean |
|
| 380 |
+- `observed_at` = ingestion time |
|
| 381 |
+- `unit` din meta cache |
|
| 382 |
+ |
|
| 383 |
+**Envelope (Profile B):** |
|
| 384 |
+- `value` = `payload.value` |
|
| 385 |
+- `observed_at` = `payload.observed_at` sau ingestion time |
|
| 386 |
+- `unit` = `payload.unit` sau meta cache |
|
| 387 |
+ |
|
| 388 |
+### 7.4 Type Routing |
|
| 389 |
+ |
|
| 390 |
+| Tip payload | Overload DB | |
|
| 391 |
+|---|---| |
|
| 392 |
+| numeric | `ingest_measurement(..., value::double precision, ...)` | |
|
| 393 |
+| boolean | `ingest_measurement(..., value::boolean, ...)` | |
|
| 394 |
+| string enum | SKIP (nu se ingestează fără encoding policy explicit) | |
|
| 395 |
+| counter cumulative | SKIP measurement path; rutare pe counter pipeline | |
|
| 396 |
+ |
|
| 397 |
+### 7.5 Ordering |
|
| 398 |
+ |
|
| 399 |
+- Serializare scrieri per `(metric_name, device_id)` — obligatoriu |
|
| 400 |
+- Paralelism acceptabil între device-uri/metrici diferite |
|
| 401 |
+- Retry-urile păstrează ordinea pe stream |
|
| 402 |
+ |
|
| 403 |
+### 7.6 Error Handling |
|
| 404 |
+ |
|
| 405 |
+**Erori permanente** (log + DLQ, nu retry): |
|
| 406 |
+- topic incompatibil cu grammar-ul busului |
|
| 407 |
+- tip payload incompatibil |
|
| 408 |
+- metric necunoscut |
|
| 409 |
+- out-of-order measurement |
|
| 410 |
+- valoare respinsă de policy |
|
| 411 |
+ |
|
| 412 |
+**Erori tranziente** (retry cu ordine): |
|
| 413 |
+- PostgreSQL indisponibil |
|
| 414 |
+- deconectare rețea |
|
| 415 |
+- deconectare broker temporară |
|
| 416 |
+ |
|
| 417 |
+### 7.7 Meta Cache |
|
| 418 |
+ |
|
| 419 |
+- Cheie: stem topic (fără ultimul segment stream) |
|
| 420 |
+- Câmpuri cache: `payload_profile`, `data_type`, `unit`, `historian.enabled`, `historian.mode`, `schema_ref`, `adapter_id`, `source`, `source_ref` |
|
| 421 |
+- Missing meta = mod degradat, nu stop |
|
| 422 |
+ |
|
| 423 |
+### 7.8 Deployment |
|
| 424 |
+ |
|
| 425 |
+Model recomandat inițial: un singur worker per site consumând `home` + `energy`. |
|
| 426 |
+ |
|
| 427 |
+--- |
|
| 428 |
+ |
|
| 429 |
+## 8. Measurement Ingestion API |
|
| 430 |
+ |
|
| 431 |
+Definit în `tdb_ingestion/mqtt_ingestion_api.md`. Implementat și funcțional. |
|
| 432 |
+ |
|
| 433 |
+### 8.1 Semnătură |
|
| 434 |
+ |
|
| 435 |
+```sql |
|
| 436 |
+-- Numeric |
|
| 437 |
+SELECT * FROM telemetry.ingest_measurement( |
|
| 438 |
+ p_metric_name => $1, |
|
| 439 |
+ p_device_id => $2, |
|
| 440 |
+ p_value => $3::double precision, |
|
| 441 |
+ p_observed_at => $4::timestamptz |
|
| 442 |
+); |
|
| 443 |
+ |
|
| 444 |
+-- Boolean |
|
| 445 |
+SELECT * FROM telemetry.ingest_measurement( |
|
| 446 |
+ p_metric_name => $1, |
|
| 447 |
+ p_device_id => $2, |
|
| 448 |
+ p_value => $3::boolean, |
|
| 449 |
+ p_observed_at => $4::timestamptz |
|
| 450 |
+); |
|
| 451 |
+``` |
|
| 452 |
+ |
|
| 453 |
+### 8.2 Comportament Automat |
|
| 454 |
+ |
|
| 455 |
+Gestionat de PostgreSQL, worker-ul nu reimplementează: |
|
| 456 |
+ |
|
| 457 |
+- Auto-provisioning device (`ensure_device()`) |
|
| 458 |
+- Append-only enforcement (watermark per `metric_name, device_id`) |
|
| 459 |
+- Policy lookup din `telemetry.metric_policies` |
|
| 460 |
+- Gap detection la sosirea următorului measurement |
|
| 461 |
+- Segment splitting (value change, NULL, policy change, gap) |
|
| 462 |
+- Tail NULL simulation la query time |
|
| 463 |
+ |
|
| 464 |
+### 8.3 Response Actions |
|
| 465 |
+ |
|
| 466 |
+`opened`, `opened_null`, `extended`, `extended_null`, `split`, `null_to_value`, `value_to_null`, `gap_split`, `gap_to_null` |
|
| 467 |
+ |
|
| 468 |
+Worker-ul le loghează, nu branșează logica pe ele. |
|
| 469 |
+ |
|
| 470 |
+### 8.4 Idempotency |
|
| 471 |
+ |
|
| 472 |
+Limitare curentă: `ingest_measurement()` nu acceptă `idempotency_key`. Replay după network failure ambiguă poate produce eroare out-of-order. Worker-ul tratează aceasta ca duplicate operațional. |
|
| 473 |
+ |
|
| 474 |
+### 8.5 Advisory Locking |
|
| 475 |
+ |
|
| 476 |
+- Un advisory lock per `(metric_name, device_id)` pe tranzacție |
|
| 477 |
+- Implementat ca wait (nu NOWAIT) |
|
| 478 |
+- Recomandare: writer affinity per `(metric_name, device_id)` via routing determinist |
|
| 479 |
+ |
|
| 480 |
+--- |
|
| 481 |
+ |
|
| 482 |
+## 9. Counter Ingestion API |
|
| 483 |
+ |
|
| 484 |
+Definit în `tdb_ingestion/counter_ingestion_api.md`. Contractul este acum materializat în repo și reprezintă ținta de implementare pentru worker și backend. |
|
| 485 |
+ |
|
| 486 |
+### 9.1 Semnătură |
|
| 487 |
+ |
|
| 488 |
+```sql |
|
| 489 |
+SELECT * FROM telemetry.ingest_counter( |
|
| 490 |
+ p_metric_name => $1, |
|
| 491 |
+ p_device_id => $2, |
|
| 492 |
+ p_counter_value => $3::numeric, |
|
| 493 |
+ p_observed_at => $4::timestamptz, |
|
| 494 |
+ p_source_sequence => $5, -- nullable |
|
| 495 |
+ p_idempotency_key => $6, -- nullable |
|
| 496 |
+ p_snapshot_id => $7 -- nullable |
|
| 497 |
+); |
|
| 498 |
+``` |
|
| 499 |
+ |
|
| 500 |
+### 9.2 Reguli Core |
|
| 501 |
+ |
|
| 502 |
+- Append-only, ordonat per `(metric_name, device_id)` |
|
| 503 |
+- `counter_value` nu acceptă NULL; dacă sursa nu are valoare, observația se omite |
|
| 504 |
+- Gap-uri derivate la query time din freshness, nu scrieri sintetice NULL |
|
| 505 |
+- Scăderea valorii = boundary semantic (`reset_boundary`, `rollover_boundary`, `invalid_drop`) |
|
| 506 |
+- Delta și rate nu traversează boundary-uri de reset/rollover |
|
| 507 |
+- Worker-ul rutează aceste metrici pe `ingest_counter()`, nu pe `ingest_measurement()` |
|
| 508 |
+ |
|
| 509 |
+### 9.3 Stream Identity |
|
| 510 |
+ |
|
| 511 |
+``` |
|
| 512 |
+stream = (metric_name, device_id) |
|
| 513 |
+``` |
|
| 514 |
+ |
|
| 515 |
+Nu există `stream_id` suplimentar. Ordering, freshness, replay metadata — toate per `(metric_name, device_id)`. |
|
| 516 |
+ |
|
| 517 |
+### 9.4 Reliability Levels |
|
| 518 |
+ |
|
| 519 |
+| Nivel | Cerințe | Garanții | |
|
| 520 |
+|---|---|---| |
|
| 521 |
+| `degraded / at-most-once` | fără `source_sequence`, fără `idempotency_key` | retry nesigur | |
|
| 522 |
+| `recommended / at-least-once` | `source_sequence` sau `idempotency_key` | replay detectabil | |
|
| 523 |
+| `stronger replay safety` | ambele | deduplicare explicită | |
|
| 524 |
+ |
|
| 525 |
+### 9.5 Reporting Modes |
|
| 526 |
+ |
|
| 527 |
+| Mode | Semantică | |
|
| 528 |
+|---|---| |
|
| 529 |
+| `periodic` | cadență așteptată; lipsa update-ului = gap | |
|
| 530 |
+| `on_change` | publicare la schimbare; tăcerea nu = delta 0 | |
|
| 531 |
+| `hybrid` | on_change + heartbeat periodic | |
|
| 532 |
+ |
|
| 533 |
+### 9.6 Freshness Defaults |
|
| 534 |
+ |
|
| 535 |
+| Reporting mode | Input | Default `stale_after_s` | |
|
| 536 |
+|---|---|---| |
|
| 537 |
+| `periodic` | `expected_interval_s` | `expected_interval_s * 2` | |
|
| 538 |
+| `on_change` | `heartbeat_interval_s` | `heartbeat_interval_s * 2` | |
|
| 539 |
+| `hybrid` | `heartbeat_interval_s` | `heartbeat_interval_s * 2` | |
|
| 540 |
+ |
|
| 541 |
+`stale_after_s` explicit în policy overridează default-ul. |
|
| 542 |
+ |
|
| 543 |
+--- |
|
| 544 |
+ |
|
| 545 |
+## 10. Decizii Luate |
|
| 546 |
+ |
|
| 547 |
+### 10.1 Closed for Phase 1 |
|
| 548 |
+ |
|
| 549 |
+Aceste decizii sunt stabile și se poate implementa pe baza lor. |
|
| 550 |
+ |
|
| 551 |
+| # | Decizie | Status | |
|
| 552 |
+|---|---|---| |
|
| 553 |
+| 1 | Measurement path: numeric + boolean prin `ingest_measurement()` | Acceptat, implementat | |
|
| 554 |
+| 2 | Counter path: separat de measurement, prin `ingest_counter()` | Contract stabilizat, neimplementat | |
|
| 555 |
+| 3 | `last` nu se ingestează ca time-series | Închis | |
|
| 556 |
+| 4 | Ordering per `(metric_name, device_id)` obligatoriu | Închis | |
|
| 557 |
+| 5 | Missing source timestamp → fallback la `ingested_at` | Închis | |
|
| 558 |
+| 6 | Missing `meta` = mod degradat, nu hard stop | Închis | |
|
| 559 |
+| 7 | String enum states: nu se ingestează fără encoding policy | Închis | |
|
| 560 |
+| 8 | Counter `stream_id = (metric_name, device_id)` — fără ID suplimentar | Închis | |
|
| 561 |
+| 9 | Counter: NULL never, gaps derivate la query time | Închis | |
|
| 562 |
+| 10 | Counter: un model comun cu profile de domeniu, nu separare energy/traffic | Închis | |
|
| 563 |
+| 11 | Permanent errors → DLQ + log, nu retry | Închis | |
|
| 564 |
+| 12 | Transient errors → retry cu ordine păstrată | Închis | |
|
| 565 |
+ |
|
| 566 |
+### 10.2 Decizii Luate Acum (în acest document) |
|
| 567 |
+ |
|
| 568 |
+| # | Decizie | Alegere | Rațiune | |
|
| 569 |
+|---|---|---|---| |
|
| 570 |
+| D1 | Snapshot pe bus vs poller→worker | **Snapshot rămâne pe boundary-ul poller→worker.** Bus-ul semantic păstrează publicații per-stream. | Busul semantic e optimizat pentru publicații lightweight per-topic. Snapshot-ul e un artefact de colectare, nu o semantică de bus. Dacă mai târziu se dovedește necesar, se poate evolua contractul explicit. | |
|
| 571 |
+| D2 | Freshness defaults | **`stale_after_s = expected_interval * 2`** (conform cu draft-ul existent) | Multiplicatorul 2x din draft e suficient de conservator. Override explicit per policy rămâne disponibil. | |
|
| 572 |
+| D3 | Profile energy/traffic counter | **Amânat până la date reale.** | Nu avem network bus; profilele de traffic nu sunt relevante acum. Energy counter profile se definesc când implementăm `ingest_counter()`. | |
|
| 573 |
+ |
|
| 574 |
+--- |
|
| 575 |
+ |
|
| 576 |
+## 11. Puncte Deschise (non-blocante) |
|
| 577 |
+ |
|
| 578 |
+Aceste puncte nu blochează Phase 1 dar vor necesita atenție: |
|
| 579 |
+ |
|
| 580 |
+| # | Punct | Când devine relevant | |
|
| 581 |
+|---|---|---| |
|
| 582 |
+| O1 | `idempotency_key` pe measurement API | Când avem nevoie de replay sigur pe measurement path | |
|
| 583 |
+| O2 | Encoding policy pentru string enum states | Când vrem să persistăm HVAC modes sau alte enums | |
|
| 584 |
+| O3 | Forma fizică a reset/rollover boundary în counter storage | La implementarea `ingest_counter()` | |
|
| 585 |
+| O4 | Batch SQL pentru counter ingestion | Când volumul de counters justifică optimizarea | |
|
| 586 |
+| O5 | Completitudine snapshot (cum se declară explicit) | Când se adaugă surse bulk polling | |
|
| 587 |
+ |
|
| 588 |
+--- |
|
| 589 |
+ |
|
| 590 |
+## 12. Testing & Validation |
|
| 591 |
+ |
|
| 592 |
+### 12.1 Prima Matrice de Test |
|
| 593 |
+ |
|
| 594 |
+| # | Device | Capabilities | Bus | Historian | |
|
| 595 |
+|---|---|---|---|---| |
|
| 596 |
+| 1 | Room sensor | `temperature`, `humidity`, `battery` | home | Da (numeric) | |
|
| 597 |
+| 2 | Door sensor | `contact`, `battery` | home | Da (boolean + numeric) | |
|
| 598 |
+| 3 | Smart plug | `power` (control) | home | Nu (string enum) | |
|
| 599 |
+| 3 | Smart plug | `active_power`, `voltage`, `current` | energy | Da (numeric) | |
|
| 600 |
+| 3 | Smart plug | `energy_total` | energy | Nu (counter path) | |
|
| 601 |
+| 4 | Remote | `button` / `action` | home | Nu (event, skip default) | |
|
| 602 |
+ |
|
| 603 |
+### 12.2 Acceptance Criteria Phase 1 |
|
| 604 |
+ |
|
| 605 |
+1. Historian stochează samples `home` + `energy` (measurement) fără parsing vendor topics |
|
| 606 |
+2. `value` topics ingestabile doar cu topic parsing + opțional `meta` cache |
|
| 607 |
+3. Smart plug proiectat pe ambele busuri fără ambiguitate |
|
| 608 |
+4. `meta.historian.mode` respectat (sample/state/event/ignore) |
|
| 609 |
+5. Missing timestamps nu blochează ingestia |
|
| 610 |
+6. Missing `meta` degradează, nu blochează |
|
| 611 |
+7. Erori adapter pe `sys`, nu pe bus semantic |
|
| 612 |
+8. Counter totals coexistă pe bus fără a fi confundate cu measurement path |
|
| 613 |
+ |
|
| 614 |
+--- |
|
| 615 |
+ |
|
| 616 |
+## 13. Hartă de Navigare a Documentelor |
|
| 617 |
+ |
|
| 618 |
+| Document | Ce conține | Când îl citești | |
|
| 619 |
+|---|---|---| |
|
| 620 |
+| `mqtt_contract.md` | Contract partajat: payload, meta, time, quality, delivery | Când construiești orice componentă care publică sau consumă de pe bus | |
|
| 621 |
+| `home_bus.md` | Topic grammar, capabilities, historian mapping home | Când lucrezi pe adaptoare home sau pe historian worker | |
|
| 622 |
+| `energy_bus.md` | Topic grammar, entity types, metrics, historian mapping energy | Când lucrezi pe surse de energie | |
|
| 623 |
+| `sys_bus.md` | Namespace operațional, stream-uri sys | Când adaugi observabilitate la adaptoare sau worker | |
|
| 624 |
+| `addapters.md` | Responsabilități adapter, fan-out, multi-bus, Z2M convention | Când construiești un adapter | |
|
| 625 |
+| `adapter_implementation_examples.md` | Patterns Node-RED, cold-start, failure modes | Când implementezi concret în Node-RED | |
|
| 626 |
+| `historian_worker.md` | Subscription, mapping, payload handling, ordering, errors | Când construiești historian worker-ul | |
|
| 627 |
+| `tdb_ingestion/mqtt_ingestion_api.md` | API measurement implementat, error handling, ordering | Când scrii cod de ingestie measurement | |
|
| 628 |
+| `tdb_ingestion/counter_ingestion_api.md` | Contract counter stabilizat (neimplementat) | Când implementezi counter ingestion | |
|
| 629 |
+ |
|
| 630 |
+--- |
|
| 631 |
+ |
|
| 632 |
+## 14. End-to-End Trace: De la Device la PostgreSQL |
|
| 633 |
+ |
|
| 634 |
+### Exemplu 1: Temperatură cameră (measurement, Profile A) |
|
| 635 |
+ |
|
| 636 |
+``` |
|
| 637 |
+[Z2M Device] zigbee2mqtt/SENSOR/vad/bedroom/bedroom-sensor |
|
| 638 |
+ payload: {"temperature": 23.4, "humidity": 41, "battery": 87}
|
|
| 639 |
+ |
|
| 640 |
+[Adapter] fan-out → 3 publicațiuni: |
|
| 641 |
+ vad/home/bedroom/temperature/bedroom-sensor/value → 23.4 |
|
| 642 |
+ vad/home/bedroom/humidity/bedroom-sensor/value → 41 |
|
| 643 |
+ vad/home/bedroom/battery/bedroom-sensor/value → 87 |
|
| 644 |
+ |
|
| 645 |
+[Worker] topic parse: |
|
| 646 |
+ metric_name = "temperature" |
|
| 647 |
+ device_id = "bedroom.bedroom-sensor" |
|
| 648 |
+ value = 23.4 |
|
| 649 |
+ observed_at = ingested_at (Profile A, no source timestamp) |
|
| 650 |
+ |
|
| 651 |
+[PostgreSQL] |
|
| 652 |
+ SELECT * FROM telemetry.ingest_measurement( |
|
| 653 |
+ 'temperature', 'bedroom.bedroom-sensor', |
|
| 654 |
+ 23.4::double precision, now() |
|
| 655 |
+ ); |
|
| 656 |
+ → action: extended |
|
| 657 |
+``` |
|
| 658 |
+ |
|
| 659 |
+### Exemplu 2: Smart plug dual projection (measurement + counter) |
|
| 660 |
+ |
|
| 661 |
+``` |
|
| 662 |
+[Z2M Device] zigbee2mqtt/PLUG/vad/living-room/tv-plug |
|
| 663 |
+ payload: {"state":"on", "power":126.8, "energy":4.72, "voltage":229.4, "current":0.58}
|
|
| 664 |
+ |
|
| 665 |
+[Adapter] fan-out → 5 publicațiuni pe 2 busuri: |
|
| 666 |
+ vad/home/living-room/power/tv-plug/value → "on" (home, string enum, skip historian) |
|
| 667 |
+ vad/energy/load/living-room-tv/active_power/value → 126.8 (energy, measurement) |
|
| 668 |
+ vad/energy/load/living-room-tv/voltage/value → 229.4 (energy, measurement) |
|
| 669 |
+ vad/energy/load/living-room-tv/current/value → 0.58 (energy, measurement) |
|
| 670 |
+ vad/energy/load/living-room-tv/energy_total/value → 4.72 (energy, counter — skip measurement path) |
|
| 671 |
+ |
|
| 672 |
+[Worker] |
|
| 673 |
+ active_power → ingest_measurement('active_power', 'load.living-room-tv', 126.8, now()) ✓
|
|
| 674 |
+ voltage → ingest_measurement('voltage', 'load.living-room-tv', 229.4, now()) ✓
|
|
| 675 |
+ current → ingest_measurement('current', 'load.living-room-tv', 0.58, now()) ✓
|
|
| 676 |
+ energy_total → skip measurement path, route to counter pipeline ⏳ |
|
| 677 |
+ "on" → skip (string enum, no encoding policy) ⊘ |
|
| 678 |
+``` |
|
| 679 |
+ |
|
| 680 |
+### Exemplu 3: Contact sensor (boolean measurement) |
|
| 681 |
+ |
|
| 682 |
+``` |
|
| 683 |
+[Z2M Device] zigbee2mqtt/SNZB-04P/vad/entrance/front-door |
|
| 684 |
+ payload: {"contact": false, "battery": 94}
|
|
| 685 |
+ |
|
| 686 |
+[Adapter] fan-out → 2 publicațiuni: |
|
| 687 |
+ vad/home/entrance/contact/front-door/value → false |
|
| 688 |
+ vad/home/entrance/battery/front-door/value → 94 |
|
| 689 |
+ |
|
| 690 |
+[Worker] |
|
| 691 |
+ contact → ingest_measurement('contact', 'entrance.front-door', false, now()) ✓
|
|
| 692 |
+ battery → ingest_measurement('battery', 'entrance.front-door', 94, now()) ✓
|
|
| 693 |
+``` |
|
@@ -0,0 +1,317 @@ |
||
| 1 |
+# Energy Bus |
|
| 2 |
+ |
|
| 3 |
+## Purpose |
|
| 4 |
+ |
|
| 5 |
+The `energy` bus exposes telemetry for the electrical energy domain: production, storage, grid exchange, and electrical load measurement points. |
|
| 6 |
+ |
|
| 7 |
+This bus is used for energy accounting and historian ingestion, not for user-facing room automation semantics. |
|
| 8 |
+ |
|
| 9 |
+Shared payload, metadata, time, and operational rules are defined in `mqtt_contract.md`. |
|
| 10 |
+ |
|
| 11 |
+Typical systems connected to this bus include: |
|
| 12 |
+ |
|
| 13 |
+- photovoltaic inverters |
|
| 14 |
+- battery systems (BMS and inverter side) |
|
| 15 |
+- UPS systems |
|
| 16 |
+- generators |
|
| 17 |
+- grid meters |
|
| 18 |
+- smart plugs used as electrical measurement points |
|
| 19 |
+ |
|
| 20 |
+ |
|
| 21 |
+## Scope and Ownership |
|
| 22 |
+ |
|
| 23 |
+The `energy` bus owns telemetry that describes electrical topology and electrical flows. |
|
| 24 |
+ |
|
| 25 |
+The `home` bus owns room-centric control semantics. |
|
| 26 |
+ |
|
| 27 |
+Examples: |
|
| 28 |
+ |
|
| 29 |
+- `vad/home/living-room/power/tv/value` is home automation state |
|
| 30 |
+- `vad/energy/load/living-room-entertainment/active_power/value` is electrical load telemetry |
|
| 31 |
+ |
|
| 32 |
+Rule: |
|
| 33 |
+ |
|
| 34 |
+- if the value is for user control in a room context, publish on `home` |
|
| 35 |
+- if the value is for electrical accounting, publish on `energy` |
|
| 36 |
+ |
|
| 37 |
+ |
|
| 38 |
+## Normative Topic Contract (v1) |
|
| 39 |
+ |
|
| 40 |
+All energy topics MUST follow: |
|
| 41 |
+ |
|
| 42 |
+`<site>/energy/<entity_type>/<entity_id>/<metric>/<stream>` |
|
| 43 |
+ |
|
| 44 |
+Where: |
|
| 45 |
+ |
|
| 46 |
+- `<site>`: deployment/site id, for example `vad` |
|
| 47 |
+- `<entity_type>`: one of `source`, `storage`, `grid`, `load`, `transfer` |
|
| 48 |
+- `<entity_id>`: stable kebab-case identifier (no spaces) |
|
| 49 |
+- `<metric>`: canonical metric name in snake_case |
|
| 50 |
+- `<stream>`: one of `value`, `last`, `set`, `meta`, `availability` |
|
| 51 |
+ |
|
| 52 |
+Examples: |
|
| 53 |
+ |
|
| 54 |
+- `vad/energy/source/pv-roof-1/active_power/value` |
|
| 55 |
+- `vad/energy/storage/battery-main/soc/value` |
|
| 56 |
+- `vad/energy/grid/main-meter/import_power/value` |
|
| 57 |
+- `vad/energy/load/living-room-entertainment/active_power/value` |
|
| 58 |
+- `vad/energy/transfer/pv-to-battery/power/value` |
|
| 59 |
+ |
|
| 60 |
+ |
|
| 61 |
+## Stream Semantics |
|
| 62 |
+ |
|
| 63 |
+- `value`: live semantic sample, whether it represents a numeric measurement, a durable state, or a transition-like signal |
|
| 64 |
+- `last`: retained last-known timestamped sample for startup/bootstrap decisions |
|
| 65 |
+- `meta`: static or slowly changing metadata |
|
| 66 |
+- `availability`: online/offline status |
|
| 67 |
+- `set`: write/request command topic (only where command is supported) |
|
| 68 |
+ |
|
| 69 |
+Rules: |
|
| 70 |
+ |
|
| 71 |
+- adapters SHOULD emit live hot-path data only on `value` |
|
| 72 |
+- adapters SHOULD deduplicate repeated `value` samples when the semantic value did not change |
|
| 73 |
+- adapters SHOULD publish retained `last` for the latest known timestamped sample |
|
| 74 |
+- legacy `state` and `event` topics SHOULD be treated as compatibility-only during migration and SHOULD NOT be introduced by new adapters |
|
| 75 |
+ |
|
| 76 |
+Command safety: |
|
| 77 |
+ |
|
| 78 |
+- `set` topics MUST NOT be retained |
|
| 79 |
+- command handlers SHOULD confirm via `value` |
|
| 80 |
+- payload profile and time semantics follow `mqtt_contract.md` |
|
| 81 |
+ |
|
| 82 |
+ |
|
| 83 |
+## Payload Contract |
|
| 84 |
+ |
|
| 85 |
+To optimize Node-RED flow cost, two payload profiles are defined. |
|
| 86 |
+ |
|
| 87 |
+### Profile A: Hot Path Scalar (recommended) |
|
| 88 |
+ |
|
| 89 |
+For high-frequency telemetry, `value` payload SHOULD be scalar (`number` or `boolean`). |
|
| 90 |
+ |
|
| 91 |
+Example: |
|
| 92 |
+ |
|
| 93 |
+- Topic: `vad/energy/source/pv-roof-1/active_power/value` |
|
| 94 |
+- Payload: `3245.7` |
|
| 95 |
+ |
|
| 96 |
+Metadata is published separately on retained `meta`. |
|
| 97 |
+ |
|
| 98 |
+Example: |
|
| 99 |
+ |
|
| 100 |
+- Topic: `vad/energy/source/pv-roof-1/active_power/meta` |
|
| 101 |
+- Payload: |
|
| 102 |
+ |
|
| 103 |
+```json |
|
| 104 |
+{
|
|
| 105 |
+ "unit": "W", |
|
| 106 |
+ "description": "PV inverter AC active power", |
|
| 107 |
+ "source": "modbus_adapter", |
|
| 108 |
+ "precision": 0.1 |
|
| 109 |
+} |
|
| 110 |
+``` |
|
| 111 |
+ |
|
| 112 |
+### Profile B: Envelope JSON (optional) |
|
| 113 |
+ |
|
| 114 |
+When observation time or quality must travel with each point: |
|
| 115 |
+ |
|
| 116 |
+```json |
|
| 117 |
+{
|
|
| 118 |
+ "value": 3245.7, |
|
| 119 |
+ "unit": "W", |
|
| 120 |
+ "observed_at": "2026-03-08T10:15:12Z", |
|
| 121 |
+ "quality": "good" |
|
| 122 |
+} |
|
| 123 |
+``` |
|
| 124 |
+ |
|
| 125 |
+Recommendation: |
|
| 126 |
+ |
|
| 127 |
+- prefer Profile A for hot telemetry paths |
|
| 128 |
+- use Profile B for low-rate or quality-critical streams |
|
| 129 |
+- use Profile B when source timestamp fidelity matters |
|
| 130 |
+- use `last` with Profile B and `observed_at` when startup decisions require freshness evaluation |
|
| 131 |
+- do not repeat metadata or adapter internals in `value` payloads |
|
| 132 |
+- keep Profile B envelopes small and canonical |
|
| 133 |
+ |
|
| 134 |
+ |
|
| 135 |
+## Time Semantics |
|
| 136 |
+ |
|
| 137 |
+- `observed_at` means device observation time, not broker receive time |
|
| 138 |
+- if device timestamp exists, adapter MUST preserve it |
|
| 139 |
+- if missing, adapter MAY use ingestion timestamp and mark degraded quality |
|
| 140 |
+ |
|
| 141 |
+ |
|
| 142 |
+## Units and Naming |
|
| 143 |
+ |
|
| 144 |
+Canonical units SHOULD follow SI conventions: |
|
| 145 |
+ |
|
| 146 |
+- power: `W` |
|
| 147 |
+- energy: `Wh` or `kWh` |
|
| 148 |
+- voltage: `V` |
|
| 149 |
+- current: `A` |
|
| 150 |
+- frequency: `Hz` |
|
| 151 |
+- state of charge: `%` |
|
| 152 |
+ |
|
| 153 |
+Naming rules: |
|
| 154 |
+ |
|
| 155 |
+- `entity_id`: kebab-case (example `battery-main`) |
|
| 156 |
+- `metric`: snake_case (example `active_power`, `import_energy_total`) |
|
| 157 |
+ |
|
| 158 |
+ |
|
| 159 |
+## Metric Classes |
|
| 160 |
+ |
|
| 161 |
+The `energy` bus may carry both measurement-style metrics and counter-style metrics. |
|
| 162 |
+ |
|
| 163 |
+Measurement-style examples: |
|
| 164 |
+ |
|
| 165 |
+- `active_power` |
|
| 166 |
+- `voltage` |
|
| 167 |
+- `current` |
|
| 168 |
+- `frequency` |
|
| 169 |
+- `soc` |
|
| 170 |
+- `charge_power` |
|
| 171 |
+- `discharge_power` |
|
| 172 |
+ |
|
| 173 |
+Counter-style cumulative examples: |
|
| 174 |
+ |
|
| 175 |
+- `energy_total` |
|
| 176 |
+- `import_energy_total` |
|
| 177 |
+- `export_energy_total` |
|
| 178 |
+ |
|
| 179 |
+ |
|
| 180 |
+## MQTT Delivery Policy |
|
| 181 |
+ |
|
| 182 |
+Default policy: |
|
| 183 |
+ |
|
| 184 |
+- `value`: QoS 1, retain false |
|
| 185 |
+- `last`: QoS 1, retain true |
|
| 186 |
+- `meta`: QoS 1, retain true |
|
| 187 |
+- `availability`: QoS 1, retain true (use LWT where available) |
|
| 188 |
+- `set`: QoS 1, retain false |
|
| 189 |
+ |
|
| 190 |
+Cold-start rule: |
|
| 191 |
+ |
|
| 192 |
+- startup consumers SHOULD subscribe to retained `last` for the latest known measurement |
|
| 193 |
+- `last` SHOULD include `observed_at` so consumers can reject stale measurements |
|
| 194 |
+- consumers MAY unsubscribe from `last` after bootstrap and continue on live `value` |
|
| 195 |
+ |
|
| 196 |
+If uncertain, choose QoS 1. |
|
| 197 |
+ |
|
| 198 |
+ |
|
| 199 |
+## Node-RED Implementation Optimizations |
|
| 200 |
+ |
|
| 201 |
+Because translation is implemented in Node-RED, the contract is optimized for low-overhead flows. |
|
| 202 |
+ |
|
| 203 |
+Guidelines: |
|
| 204 |
+ |
|
| 205 |
+- keep canonical topic depth fixed to 6 segments after `<site>` to simplify wildcard routing |
|
| 206 |
+- avoid per-message heavy JSON transforms on high-rate streams |
|
| 207 |
+- split metadata to retained `meta` topics to avoid repeated payload bloat |
|
| 208 |
+- publish canonical MQTT-ready messages as early as possible after normalization |
|
| 209 |
+- emit MQTT-ready messages only at the publish boundary |
|
| 210 |
+- do not carry adapter-internal normalization structures on forwarded `msg` objects |
|
| 211 |
+- discard temporary normalization fields before publish |
|
| 212 |
+- keep high-rate `value` streams extremely lightweight |
|
| 213 |
+- centralize mapping tables in one `function` or `change` node set (device id, metric id, unit) |
|
| 214 |
+- use one normalization subflow reused per adapter/protocol |
|
| 215 |
+- avoid broad `#` subscriptions in hot paths; use specific wildcard patterns |
|
| 216 |
+ |
|
| 217 |
+Suggested Node-RED routing subscriptions: |
|
| 218 |
+ |
|
| 219 |
+- `+/energy/+/+/+/value` |
|
| 220 |
+- `+/energy/+/+/+/last` |
|
| 221 |
+- `+/energy/+/+/+/meta` |
|
| 222 |
+- `+/energy/+/+/+/availability` |
|
| 223 |
+ |
|
| 224 |
+ |
|
| 225 |
+## Energy Flow Semantics |
|
| 226 |
+ |
|
| 227 |
+### Production |
|
| 228 |
+ |
|
| 229 |
+Energy generated by a source, usually under `entity_type=source`. |
|
| 230 |
+ |
|
| 231 |
+Examples: |
|
| 232 |
+ |
|
| 233 |
+- `active_power` |
|
| 234 |
+- `energy_total` |
|
| 235 |
+ |
|
| 236 |
+### Storage |
|
| 237 |
+ |
|
| 238 |
+Energy held in storage, under `entity_type=storage`. |
|
| 239 |
+ |
|
| 240 |
+Examples: |
|
| 241 |
+ |
|
| 242 |
+- `soc` |
|
| 243 |
+- `charge_power` |
|
| 244 |
+- `discharge_power` |
|
| 245 |
+ |
|
| 246 |
+### Grid |
|
| 247 |
+ |
|
| 248 |
+Import/export metrics at connection points, under `entity_type=grid`. |
|
| 249 |
+ |
|
| 250 |
+Examples: |
|
| 251 |
+ |
|
| 252 |
+- `import_power` |
|
| 253 |
+- `export_power` |
|
| 254 |
+- `import_energy_total` |
|
| 255 |
+- `export_energy_total` |
|
| 256 |
+ |
|
| 257 |
+### Load |
|
| 258 |
+ |
|
| 259 |
+Electrical load measurement points, under `entity_type=load`. |
|
| 260 |
+ |
|
| 261 |
+Examples: |
|
| 262 |
+ |
|
| 263 |
+- `active_power` |
|
| 264 |
+- `energy_total` |
|
| 265 |
+ |
|
| 266 |
+Note: this is electrical telemetry semantics, not room-control semantics. |
|
| 267 |
+ |
|
| 268 |
+### Transfer |
|
| 269 |
+ |
|
| 270 |
+Flow between subsystems, under `entity_type=transfer`. |
|
| 271 |
+ |
|
| 272 |
+Examples: |
|
| 273 |
+ |
|
| 274 |
+- `pv-to-battery` |
|
| 275 |
+- `battery-to-home` |
|
| 276 |
+ |
|
| 277 |
+ |
|
| 278 |
+## Historian Relationship |
|
| 279 |
+ |
|
| 280 |
+The `energy` bus is a primary source for historian ingestion, but not all energy metrics use the same persistence path. |
|
| 281 |
+ |
|
| 282 |
+Measurement-style metrics such as `active_power`, `voltage`, `current`, `frequency`, `soc`, `charge_power`, and `discharge_power` are compatible with `tdb_ingestion/mqtt_ingestion_api.md`. |
|
| 283 |
+ |
|
| 284 |
+For those metrics, workers should map each incoming message to: |
|
| 285 |
+ |
|
| 286 |
+- `metric_name` |
|
| 287 |
+- `device_id` |
|
| 288 |
+- `value` |
|
| 289 |
+- `observed_at` |
|
| 290 |
+ |
|
| 291 |
+`device_id` recommendation: |
|
| 292 |
+ |
|
| 293 |
+- `<entity_type>.<entity_id>` |
|
| 294 |
+ |
|
| 295 |
+Historian defaults: |
|
| 296 |
+ |
|
| 297 |
+- `value` streams SHOULD be ingested by default for measurement-style metrics |
|
| 298 |
+- `last` streams SHOULD NOT be ingested as normal telemetry samples |
|
| 299 |
+- `meta.historian.mode` SHOULD describe whether a `value` stream represents `sample`, `state`, or `event` semantics |
|
| 300 |
+- when Profile A scalar is used, `observed_at` will usually fall back to ingestion time |
|
| 301 |
+- enum-like state values may need explicit encoding before they can be written through the current PostgreSQL historian API |
|
| 302 |
+- counter-style totals such as `energy_total`, `import_energy_total`, and `export_energy_total` SHOULD remain on the `energy` bus but SHOULD follow the separate contract in `tdb_ingestion/counter_ingestion_api.md` rather than being forced through the current measurement API |
|
| 303 |
+ |
|
| 304 |
+Example: |
|
| 305 |
+ |
|
| 306 |
+- Topic: `vad/energy/storage/battery-main/soc/value` |
|
| 307 |
+- `metric_name = soc` |
|
| 308 |
+- `device_id = storage.battery-main` |
|
| 309 |
+ |
|
| 310 |
+ |
|
| 311 |
+## Design Principles |
|
| 312 |
+ |
|
| 313 |
+- keep semantic ownership clear between buses |
|
| 314 |
+- make energy accounting reconstructable from `energy` topics |
|
| 315 |
+- optimize for deterministic adapter behavior |
|
| 316 |
+- optimize for low-cost Node-RED translation on high-frequency streams |
|
| 317 |
+- keep contract stable and extensible |
|
@@ -0,0 +1,429 @@ |
||
| 1 |
+# Historian Bus Worker |
|
| 2 |
+ |
|
| 3 |
+## Definition |
|
| 4 |
+ |
|
| 5 |
+A historian worker is a consumer component that subscribes to one or more canonical semantic MQTT buses and writes normalized measurements into the PostgreSQL telemetry historian. |
|
| 6 |
+ |
|
| 7 |
+Its role is the inverse of an adapter: |
|
| 8 |
+ |
|
| 9 |
+- adapters translate vendor-specific inputs into canonical bus topics |
|
| 10 |
+- historian workers translate canonical bus topics into historian API calls |
|
| 11 |
+ |
|
| 12 |
+The worker is a consumer of the semantic contract, not an owner of it. |
|
| 13 |
+ |
|
| 14 |
+--- |
|
| 15 |
+ |
|
| 16 |
+## Purpose |
|
| 17 |
+ |
|
| 18 |
+The worker exists to decouple historian persistence from: |
|
| 19 |
+ |
|
| 20 |
+- vendor topic structures |
|
| 21 |
+- protocol-specific payload formats |
|
| 22 |
+- source-specific device identities |
|
| 23 |
+- source-side timing quirks |
|
| 24 |
+ |
|
| 25 |
+This keeps the historian integration generic enough to ingest any bus that follows the shared MQTT contract. |
|
| 26 |
+ |
|
| 27 |
+--- |
|
| 28 |
+ |
|
| 29 |
+## Architectural Position |
|
| 30 |
+ |
|
| 31 |
+The historian worker operates at the egress side of the semantic bus. |
|
| 32 |
+ |
|
| 33 |
+Pipeline: |
|
| 34 |
+ |
|
| 35 |
+Device / External System |
|
| 36 |
+ ↓ |
|
| 37 |
+Protocol Adapter |
|
| 38 |
+ ↓ |
|
| 39 |
+Canonical MQTT Bus |
|
| 40 |
+ ↓ |
|
| 41 |
+Historian Worker |
|
| 42 |
+ ↓ |
|
| 43 |
+`telemetry.ingest_measurement(...)` |
|
| 44 |
+ ↓ |
|
| 45 |
+Historian tables |
|
| 46 |
+ |
|
| 47 |
+The worker consumes canonical MQTT only. |
|
| 48 |
+ |
|
| 49 |
+It MUST NOT subscribe to raw vendor topics such as `zigbee2mqtt/...` for normal historian ingestion. |
|
| 50 |
+ |
|
| 51 |
+--- |
|
| 52 |
+ |
|
| 53 |
+## Shared Inputs |
|
| 54 |
+ |
|
| 55 |
+The worker consumes data defined by: |
|
| 56 |
+ |
|
| 57 |
+- `mqtt_contract.md` |
|
| 58 |
+- bus-specific contracts such as `home_bus.md` and `energy_bus.md` |
|
| 59 |
+- `tdb_ingestion/mqtt_ingestion_api.md` |
|
| 60 |
+- `tdb_ingestion/counter_ingestion_api.md` when cumulative counter metrics are present on the bus |
|
| 61 |
+ |
|
| 62 |
+The worker should treat the semantic bus as the source of truth for topic grammar and stream meaning. |
|
| 63 |
+ |
|
| 64 |
+The database API is the source of truth for persistence behavior. |
|
| 65 |
+ |
|
| 66 |
+Boundary decisions are recorded in `consolidated_spec.md` §10. |
|
| 67 |
+ |
|
| 68 |
+--- |
|
| 69 |
+ |
|
| 70 |
+## Responsibilities |
|
| 71 |
+ |
|
| 72 |
+The historian worker is responsible for: |
|
| 73 |
+ |
|
| 74 |
+1. Topic subscription |
|
| 75 |
+ |
|
| 76 |
+Subscribe to canonical topics for the target buses. |
|
| 77 |
+ |
|
| 78 |
+2. Topic parsing |
|
| 79 |
+ |
|
| 80 |
+Extract `metric_name`, `device_id`, `stream`, and bus-specific dimensions from the topic. |
|
| 81 |
+ |
|
| 82 |
+3. Payload decoding |
|
| 83 |
+ |
|
| 84 |
+Decode scalar payloads and Profile B envelopes according to `mqtt_contract.md`. |
|
| 85 |
+ |
|
| 86 |
+4. Meta caching |
|
| 87 |
+ |
|
| 88 |
+Consume retained `meta` topics and keep a local cache keyed by topic stem. |
|
| 89 |
+ |
|
| 90 |
+5. Historian policy enforcement |
|
| 91 |
+ |
|
| 92 |
+Respect `meta.historian.enabled` and `meta.historian.mode` when deciding what should be persisted. |
|
| 93 |
+ |
|
| 94 |
+6. Type selection |
|
| 95 |
+ |
|
| 96 |
+Choose the numeric or boolean PostgreSQL overload correctly for measurement-style metrics. |
|
| 97 |
+ |
|
| 98 |
+7. Timestamp resolution |
|
| 99 |
+ |
|
| 100 |
+Use `observed_at` from the envelope when present, otherwise fall back to ingestion time. |
|
| 101 |
+ |
|
| 102 |
+8. Per-stream ordering |
|
| 103 |
+ |
|
| 104 |
+Preserve source order for the same `(metric_name, device_id)` path when sending measurements to PostgreSQL. |
|
| 105 |
+ |
|
| 106 |
+9. Error handling |
|
| 107 |
+ |
|
| 108 |
+Separate permanent message errors from transient infrastructure failures. |
|
| 109 |
+ |
|
| 110 |
+10. Observability |
|
| 111 |
+ |
|
| 112 |
+Emit worker health, counters, and persistence failures on operational topics. |
|
| 113 |
+ |
|
| 114 |
+--- |
|
| 115 |
+ |
|
| 116 |
+## Explicit Non-Responsibilities |
|
| 117 |
+ |
|
| 118 |
+The historian worker must NOT: |
|
| 119 |
+ |
|
| 120 |
+- parse vendor-specific topics as part of the normal pipeline |
|
| 121 |
+- redefine semantic topic contracts |
|
| 122 |
+- invent new device identities outside the bus contract |
|
| 123 |
+- perform automation logic |
|
| 124 |
+- aggregate unrelated sensors into synthetic measurements |
|
| 125 |
+- reimplement historian segment logic already handled in PostgreSQL |
|
| 126 |
+- repair malformed semantic messages silently |
|
| 127 |
+ |
|
| 128 |
+If semantic messages are invalid, the worker should surface them operationally and skip persistence. |
|
| 129 |
+ |
|
| 130 |
+--- |
|
| 131 |
+ |
|
| 132 |
+## Subscription Model |
|
| 133 |
+ |
|
| 134 |
+Recommended subscriptions per site: |
|
| 135 |
+ |
|
| 136 |
+- `+/home/+/+/+/value` |
|
| 137 |
+- `+/home/+/+/+/last` |
|
| 138 |
+- `+/energy/+/+/+/value` |
|
| 139 |
+- `+/energy/+/+/+/last` |
|
| 140 |
+- `+/home/+/+/+/meta` |
|
| 141 |
+- `+/energy/+/+/+/meta` |
|
| 142 |
+ |
|
| 143 |
+Optional observability subscriptions: |
|
| 144 |
+ |
|
| 145 |
+- `+/home/+/+/+/availability` |
|
| 146 |
+- `+/energy/+/+/+/availability` |
|
| 147 |
+- `+/sys/adapter/+/availability` |
|
| 148 |
+- `+/sys/adapter/+/error` |
|
| 149 |
+ |
|
| 150 |
+Rules: |
|
| 151 |
+ |
|
| 152 |
+- `meta` SHOULD be subscribed and cached before relying on enrichment |
|
| 153 |
+- `set` topics MUST be ignored by the historian worker |
|
| 154 |
+- `last` topics MUST be ignored for normal time-series ingestion |
|
| 155 |
+- `availability` SHOULD NOT be persisted as telemetry samples unless a separate policy explicitly requires it |
|
| 156 |
+ |
|
| 157 |
+--- |
|
| 158 |
+ |
|
| 159 |
+## Topic-to-Historian Mapping |
|
| 160 |
+ |
|
| 161 |
+### Home Bus |
|
| 162 |
+ |
|
| 163 |
+Topic: |
|
| 164 |
+ |
|
| 165 |
+`<site>/home/<location>/<capability>/<device_id>/<stream>` |
|
| 166 |
+ |
|
| 167 |
+Mapped fields: |
|
| 168 |
+ |
|
| 169 |
+- `metric_name = <capability>` |
|
| 170 |
+- `device_id = <location>.<device_id>` |
|
| 171 |
+ |
|
| 172 |
+### Energy Bus |
|
| 173 |
+ |
|
| 174 |
+Topic: |
|
| 175 |
+ |
|
| 176 |
+`<site>/energy/<entity_type>/<entity_id>/<metric>/<stream>` |
|
| 177 |
+ |
|
| 178 |
+Mapped fields: |
|
| 179 |
+ |
|
| 180 |
+- `metric_name = <metric>` |
|
| 181 |
+- `device_id = <entity_type>.<entity_id>` |
|
| 182 |
+ |
|
| 183 |
+The worker MUST NOT require database-side topic parsing. |
|
| 184 |
+ |
|
| 185 |
+--- |
|
| 186 |
+ |
|
| 187 |
+## Payload Handling |
|
| 188 |
+ |
|
| 189 |
+### Scalar Payload |
|
| 190 |
+ |
|
| 191 |
+If the stream uses Profile A: |
|
| 192 |
+ |
|
| 193 |
+- parse the scalar payload as number, boolean, or enum string |
|
| 194 |
+- resolve `unit` from cached retained `meta` |
|
| 195 |
+- set `observed_at` to ingestion time unless source time is available out of band |
|
| 196 |
+ |
|
| 197 |
+### Envelope Payload |
|
| 198 |
+ |
|
| 199 |
+If the stream uses Profile B: |
|
| 200 |
+ |
|
| 201 |
+- read `value` |
|
| 202 |
+- use `observed_at` when present |
|
| 203 |
+- use `unit` from payload, falling back to cached `meta.unit` |
|
| 204 |
+- use `quality` when present for logs or side metrics |
|
| 205 |
+ |
|
| 206 |
+### Type Compatibility Rule |
|
| 207 |
+ |
|
| 208 |
+The current PostgreSQL historian API directly supports: |
|
| 209 |
+ |
|
| 210 |
+- numeric values |
|
| 211 |
+- boolean values |
|
| 212 |
+ |
|
| 213 |
+String enum states are allowed on the semantic bus, but the worker SHOULD skip them unless there is an explicit encoding policy. |
|
| 214 |
+ |
|
| 215 |
+Examples: |
|
| 216 |
+ |
|
| 217 |
+- `open` / `closed` may be mapped to boolean if a metric policy expects that |
|
| 218 |
+- `heat` / `cool` / `off` MUST NOT be guessed into arbitrary numeric values |
|
| 219 |
+ |
|
| 220 |
+### Counter Metric Boundary |
|
| 221 |
+ |
|
| 222 |
+Counter-style cumulative metrics such as `energy_total`, `import_energy_total`, `export_energy_total`, `rx_bytes_total`, or `tx_packets_total` are valid semantic bus values, but they are not covered by the current measurement ingestion API. |
|
| 223 |
+ |
|
| 224 |
+Rules: |
|
| 225 |
+ |
|
| 226 |
+- the worker MUST NOT force cumulative counters through `telemetry.ingest_measurement(...)` just because the payload is numeric |
|
| 227 |
+- the worker SHOULD route them to the separate counter ingestion path defined in `tdb_ingestion/counter_ingestion_api.md` |
|
| 228 |
+- if the counter path is temporarily unavailable, the worker SHOULD skip them explicitly and expose that through operational stats or DLQ |
|
| 229 |
+- measurement-style metrics such as `active_power`, `voltage`, `current`, `temperature`, and `soc` continue through the existing measurement API |
|
| 230 |
+ |
|
| 231 |
+--- |
|
| 232 |
+ |
|
| 233 |
+## Stream Policy |
|
| 234 |
+ |
|
| 235 |
+Default persistence policy: |
|
| 236 |
+ |
|
| 237 |
+- ingest `value` by default |
|
| 238 |
+- ignore `last` |
|
| 239 |
+- use `meta.historian.mode` to interpret whether a `value` stream represents `sample`, `state`, or `event` semantics |
|
| 240 |
+- ignore `set` |
|
| 241 |
+- never treat `meta` itself as a measurement |
|
| 242 |
+ |
|
| 243 |
+Additional rules: |
|
| 244 |
+ |
|
| 245 |
+- if `meta.historian.enabled=false`, the worker MUST skip persistence |
|
| 246 |
+- if `meta` is missing, the worker MAY continue with degraded defaults |
|
| 247 |
+- missing `meta` MUST NOT block ingestion of otherwise valid numeric or boolean `value` samples |
|
| 248 |
+ |
|
| 249 |
+--- |
|
| 250 |
+ |
|
| 251 |
+## Ordering and Delivery |
|
| 252 |
+ |
|
| 253 |
+Ordering is critical because PostgreSQL enforces append-only writes per `(metric_name, device_id)`. |
|
| 254 |
+ |
|
| 255 |
+Rules: |
|
| 256 |
+ |
|
| 257 |
+- the worker MUST serialize writes for the same `(metric_name, device_id)` |
|
| 258 |
+- retries MUST preserve order |
|
| 259 |
+- the worker SHOULD partition concurrency by `(metric_name, device_id)` or an equivalent stable shard |
|
| 260 |
+- out-of-order database errors SHOULD be treated as permanent message errors unless the worker can prove replay ordering was preserved |
|
| 261 |
+ |
|
| 262 |
+Operational implication: |
|
| 263 |
+ |
|
| 264 |
+- broad parallelism is acceptable across distinct devices or metrics |
|
| 265 |
+- unordered fan-out for the same logical stream is not acceptable |
|
| 266 |
+ |
|
| 267 |
+--- |
|
| 268 |
+ |
|
| 269 |
+## Meta Cache |
|
| 270 |
+ |
|
| 271 |
+The worker should maintain a retained metadata cache. |
|
| 272 |
+ |
|
| 273 |
+Recommended cache key: |
|
| 274 |
+ |
|
| 275 |
+- topic stem without the final stream segment |
|
| 276 |
+ |
|
| 277 |
+Example: |
|
| 278 |
+ |
|
| 279 |
+- topic: `vad/home/bedroom/temperature/bedroom-sensor/meta` |
|
| 280 |
+- cache key: `vad/home/bedroom/temperature/bedroom-sensor` |
|
| 281 |
+ |
|
| 282 |
+Recommended cached fields: |
|
| 283 |
+ |
|
| 284 |
+- `payload_profile` |
|
| 285 |
+- `data_type` |
|
| 286 |
+- `unit` |
|
| 287 |
+- `historian.enabled` |
|
| 288 |
+- `historian.mode` |
|
| 289 |
+- `schema_ref` |
|
| 290 |
+- `adapter_id` |
|
| 291 |
+- `source` |
|
| 292 |
+- `source_ref` |
|
| 293 |
+ |
|
| 294 |
+Rules: |
|
| 295 |
+ |
|
| 296 |
+- cached `meta` SHOULD be replaced atomically when newer retained data arrives |
|
| 297 |
+- worker startup SHOULD tolerate receiving live `value` traffic before the corresponding retained `meta` |
|
| 298 |
+- if `meta` is deleted, the cache entry SHOULD be removed as well |
|
| 299 |
+ |
|
| 300 |
+--- |
|
| 301 |
+ |
|
| 302 |
+## Database Interaction |
|
| 303 |
+ |
|
| 304 |
+The worker writes measurement-style samples through `telemetry.ingest_measurement(...)` as defined in `tdb_ingestion/mqtt_ingestion_api.md`. |
|
| 305 |
+ |
|
| 306 |
+Rules: |
|
| 307 |
+ |
|
| 308 |
+- use the numeric overload for numeric values |
|
| 309 |
+- use the boolean overload for boolean values |
|
| 310 |
+- type `NULL` explicitly if unknown values are ever supported by bus policy |
|
| 311 |
+- log the returned `action` for observability |
|
| 312 |
+- do not duplicate historian logic already implemented in PostgreSQL |
|
| 313 |
+- do not send counter-style cumulative totals through this API; those follow the separate contract in `tdb_ingestion/counter_ingestion_api.md` |
|
| 314 |
+ |
|
| 315 |
+The worker should assume the database is responsible for: |
|
| 316 |
+ |
|
| 317 |
+- deduplication |
|
| 318 |
+- gap detection |
|
| 319 |
+- segment splitting |
|
| 320 |
+- policy boundary handling |
|
| 321 |
+ |
|
| 322 |
+--- |
|
| 323 |
+ |
|
| 324 |
+## Operational Topics |
|
| 325 |
+ |
|
| 326 |
+Shared operational namespace rules are defined in `sys_bus.md`. |
|
| 327 |
+ |
|
| 328 |
+The worker should expose its own operational topics under: |
|
| 329 |
+ |
|
| 330 |
+- `<site>/sys/historian/<worker_id>/availability` |
|
| 331 |
+- `<site>/sys/historian/<worker_id>/stats` |
|
| 332 |
+- `<site>/sys/historian/<worker_id>/error` |
|
| 333 |
+- `<site>/sys/historian/<worker_id>/dlq` |
|
| 334 |
+ |
|
| 335 |
+Recommended uses: |
|
| 336 |
+ |
|
| 337 |
+- `availability`: retained online/offline state |
|
| 338 |
+- `stats`: low-rate counters such as ingested messages, skipped samples, and retry counts |
|
| 339 |
+- `error`: structured infrastructure or persistence failures |
|
| 340 |
+- `dlq`: semantic messages rejected by the worker |
|
| 341 |
+ |
|
| 342 |
+This keeps historian worker observability separate from adapter observability. |
|
| 343 |
+ |
|
| 344 |
+--- |
|
| 345 |
+ |
|
| 346 |
+## Failure Handling |
|
| 347 |
+ |
|
| 348 |
+Permanent message failures include: |
|
| 349 |
+ |
|
| 350 |
+- topic does not match a supported bus grammar |
|
| 351 |
+- payload type cannot be mapped to the target historian type |
|
| 352 |
+- unknown metric in PostgreSQL |
|
| 353 |
+- out-of-order measurement |
|
| 354 |
+- value violates metric policy |
|
| 355 |
+ |
|
| 356 |
+Transient failures include: |
|
| 357 |
+ |
|
| 358 |
+- PostgreSQL unavailable |
|
| 359 |
+- network interruption between worker and database |
|
| 360 |
+- temporary broker disconnect |
|
| 361 |
+ |
|
| 362 |
+Rules: |
|
| 363 |
+ |
|
| 364 |
+- permanent message failures SHOULD be logged and dead-lettered |
|
| 365 |
+- transient failures SHOULD be retried without reordering per stream |
|
| 366 |
+- ambiguous retries that later return out-of-order errors SHOULD be logged as likely duplicates |
|
| 367 |
+ |
|
| 368 |
+--- |
|
| 369 |
+ |
|
| 370 |
+## Deployment Model |
|
| 371 |
+ |
|
| 372 |
+A historian worker may be deployed in one of the following ways: |
|
| 373 |
+ |
|
| 374 |
+- one worker for all buses |
|
| 375 |
+- one worker per bus |
|
| 376 |
+- one worker per site and bus |
|
| 377 |
+ |
|
| 378 |
+Recommended starting model: |
|
| 379 |
+ |
|
| 380 |
+- one worker per site consuming both `home` and `energy` |
|
| 381 |
+ |
|
| 382 |
+This keeps the first implementation simple while preserving the option to split workers later for throughput or failure-domain reasons. |
|
| 383 |
+ |
|
| 384 |
+--- |
|
| 385 |
+ |
|
| 386 |
+## Relationship with Adapters |
|
| 387 |
+ |
|
| 388 |
+Adapters and historian workers are symmetrical integration roles. |
|
| 389 |
+ |
|
| 390 |
+Adapter direction: |
|
| 391 |
+ |
|
| 392 |
+- external protocol -> canonical bus |
|
| 393 |
+ |
|
| 394 |
+Historian worker direction: |
|
| 395 |
+ |
|
| 396 |
+- canonical bus -> historian API |
|
| 397 |
+ |
|
| 398 |
+Both should be: |
|
| 399 |
+ |
|
| 400 |
+- contract-driven |
|
| 401 |
+- deterministic |
|
| 402 |
+- observable |
|
| 403 |
+- easy to replay in tests |
|
| 404 |
+ |
|
| 405 |
+Neither should contain business automation logic. |
|
| 406 |
+ |
|
| 407 |
+--- |
|
| 408 |
+ |
|
| 409 |
+## Design Principles |
|
| 410 |
+ |
|
| 411 |
+1. Consume canonical topics only |
|
| 412 |
+ |
|
| 413 |
+The worker should depend on semantic contracts, not vendor payloads. |
|
| 414 |
+ |
|
| 415 |
+2. Keep persistence generic |
|
| 416 |
+ |
|
| 417 |
+The same worker model should work for `home`, `energy`, and future buses. |
|
| 418 |
+ |
|
| 419 |
+3. Preserve ordering |
|
| 420 |
+ |
|
| 421 |
+Correct historian writes matter more than maximum ingest parallelism. |
|
| 422 |
+ |
|
| 423 |
+4. Fail visibly |
|
| 424 |
+ |
|
| 425 |
+Bad messages and persistence problems must be observable on operational topics. |
|
| 426 |
+ |
|
| 427 |
+5. Reuse shared contract rules |
|
| 428 |
+ |
|
| 429 |
+The worker should not invent alternate payload or metadata semantics. |
|
@@ -0,0 +1,310 @@ |
||
| 1 |
+# Home Bus |
|
| 2 |
+ |
|
| 3 |
+## Purpose |
|
| 4 |
+ |
|
| 5 |
+The `home` bus exposes room-centric and user-facing telemetry, state, and control topics for the living environment. |
|
| 6 |
+ |
|
| 7 |
+Typical systems connected to this bus include: |
|
| 8 |
+ |
|
| 9 |
+- environmental sensors |
|
| 10 |
+- lighting and switches |
|
| 11 |
+- motion/contact/presence sensors |
|
| 12 |
+- thermostats and climate devices |
|
| 13 |
+- smart sockets for on/off control semantics |
|
| 14 |
+ |
|
| 15 |
+The `home` bus models living-space behavior, not electrical topology. |
|
| 16 |
+ |
|
| 17 |
+This document defines the `home`-specific topic grammar and semantic ownership. |
|
| 18 |
+ |
|
| 19 |
+Shared rules for payload profiles, metadata, time semantics, quality, and operational topics are defined in `mqtt_contract.md`. |
|
| 20 |
+ |
|
| 21 |
+ |
|
| 22 |
+## Scope and Ownership |
|
| 23 |
+ |
|
| 24 |
+The `home` bus owns room and automation semantics. |
|
| 25 |
+ |
|
| 26 |
+The `energy` bus owns electrical accounting semantics. |
|
| 27 |
+ |
|
| 28 |
+Examples: |
|
| 29 |
+ |
|
| 30 |
+- `vad/home/living-room/power/tv/value` is room control state |
|
| 31 |
+- `vad/home/living-room/power/tv/set` is room control command |
|
| 32 |
+- `vad/energy/load/living-room-entertainment/active_power/value` is electrical telemetry |
|
| 33 |
+ |
|
| 34 |
+Rule: |
|
| 35 |
+ |
|
| 36 |
+- publish user-oriented control/state on `home` |
|
| 37 |
+- publish electrical measurement and accounting on `energy` |
|
| 38 |
+ |
|
| 39 |
+ |
|
| 40 |
+## Normative Topic Contract (v1) |
|
| 41 |
+ |
|
| 42 |
+All home topics MUST follow: |
|
| 43 |
+ |
|
| 44 |
+`<site>/home/<location>/<capability>/<device_id>/<stream>` |
|
| 45 |
+ |
|
| 46 |
+Where: |
|
| 47 |
+ |
|
| 48 |
+- `<site>`: deployment/site id, for example `vad` |
|
| 49 |
+- `<location>`: normalized area identifier in kebab-case |
|
| 50 |
+- `<capability>`: semantic capability in snake_case |
|
| 51 |
+- `<device_id>`: stable device/entity identifier in kebab-case |
|
| 52 |
+- `<stream>`: one of `value`, `last`, `set`, `meta`, `availability` |
|
| 53 |
+ |
|
| 54 |
+Examples: |
|
| 55 |
+ |
|
| 56 |
+- `vad/home/bedroom/temperature/bedroom-sensor/value` |
|
| 57 |
+- `vad/home/living-room/motion/radar-south/value` |
|
| 58 |
+- `vad/home/kitchen/light/ceiling-switch/value` |
|
| 59 |
+- `vad/home/living-room/power/tv/set` |
|
| 60 |
+ |
|
| 61 |
+ |
|
| 62 |
+## Canonical Identity Model |
|
| 63 |
+ |
|
| 64 |
+The `home` bus is built around stable room semantics. |
|
| 65 |
+ |
|
| 66 |
+Rules: |
|
| 67 |
+ |
|
| 68 |
+- `<location>` MUST describe the living-space area, not the vendor room name if the two differ |
|
| 69 |
+- `<device_id>` MUST identify the logical endpoint exposed to consumers |
|
| 70 |
+- one physical device MAY publish multiple capabilities under the same `<device_id>` |
|
| 71 |
+- replacing a physical Zigbee sensor SHOULD keep the same canonical `<device_id>` when the logical endpoint remains the same |
|
| 72 |
+ |
|
| 73 |
+Examples: |
|
| 74 |
+ |
|
| 75 |
+- `bedroom-sensor` is a logical endpoint |
|
| 76 |
+- `0x00158d0008aa1111` is a vendor identity and belongs in `meta.source_ref`, not in the topic path |
|
| 77 |
+ |
|
| 78 |
+This keeps HomeKit, historian, and automations insulated from vendor identity churn. |
|
| 79 |
+ |
|
| 80 |
+ |
|
| 81 |
+## Stream Semantics |
|
| 82 |
+ |
|
| 83 |
+- `value`: live semantic sample, whether measurement, held state, or transition notification |
|
| 84 |
+- `last`: retained last-known timestamped sample for startup/bootstrap decisions |
|
| 85 |
+- `set`: command/request topic for controllable capabilities |
|
| 86 |
+- `meta`: static or slowly-changing metadata |
|
| 87 |
+- `availability`: online/offline state |
|
| 88 |
+ |
|
| 89 |
+Rules: |
|
| 90 |
+ |
|
| 91 |
+- adapters SHOULD emit live hot-path data only on `value` |
|
| 92 |
+- adapters SHOULD deduplicate repeated `value` samples when the semantic value did not change |
|
| 93 |
+- adapters SHOULD publish retained `last` for the latest known timestamped sample |
|
| 94 |
+- legacy `state` and `event` topics SHOULD be treated as compatibility-only during migration |
|
| 95 |
+ |
|
| 96 |
+Command safety: |
|
| 97 |
+ |
|
| 98 |
+- `set` MUST NOT be retained |
|
| 99 |
+- stateful devices SHOULD acknowledge commands via `value` |
|
| 100 |
+- payload profile and time semantics follow `mqtt_contract.md` |
|
| 101 |
+ |
|
| 102 |
+ |
|
| 103 |
+## Payload Contract |
|
| 104 |
+ |
|
| 105 |
+To keep Node-RED flows efficient, two payload profiles are allowed. |
|
| 106 |
+ |
|
| 107 |
+### Profile A: Hot Path Scalar (recommended) |
|
| 108 |
+ |
|
| 109 |
+For frequent updates, payload SHOULD be scalar. |
|
| 110 |
+ |
|
| 111 |
+Examples: |
|
| 112 |
+ |
|
| 113 |
+- `vad/home/bedroom/temperature/bedroom-sensor/value` -> `23.6` |
|
| 114 |
+- `vad/home/living-room/motion/radar-south/value` -> `true` |
|
| 115 |
+ |
|
| 116 |
+Metadata is published separately on retained `meta`. |
|
| 117 |
+ |
|
| 118 |
+Example: |
|
| 119 |
+ |
|
| 120 |
+- Topic: `vad/home/bedroom/temperature/bedroom-sensor/meta` |
|
| 121 |
+- Payload: |
|
| 122 |
+ |
|
| 123 |
+```json |
|
| 124 |
+{
|
|
| 125 |
+ "unit": "C", |
|
| 126 |
+ "source": "zigbee_adapter", |
|
| 127 |
+ "precision": 0.1 |
|
| 128 |
+} |
|
| 129 |
+``` |
|
| 130 |
+ |
|
| 131 |
+### Profile B: Envelope JSON (optional) |
|
| 132 |
+ |
|
| 133 |
+For streams that need inline quality/timestamp: |
|
| 134 |
+ |
|
| 135 |
+```json |
|
| 136 |
+{
|
|
| 137 |
+ "value": 23.6, |
|
| 138 |
+ "unit": "C", |
|
| 139 |
+ "observed_at": "2026-03-08T10:15:12Z", |
|
| 140 |
+ "quality": "good" |
|
| 141 |
+} |
|
| 142 |
+``` |
|
| 143 |
+ |
|
| 144 |
+Recommendation: |
|
| 145 |
+ |
|
| 146 |
+- prefer Profile A on high-frequency paths |
|
| 147 |
+- use Profile B where source quality/time must travel with each sample |
|
| 148 |
+- if exact source timestamp must be preserved, use Profile B |
|
| 149 |
+- use `last` with Profile B and `observed_at` when control consumers need a startup sample with freshness information |
|
| 150 |
+- do not repeat metadata or adapter internals in `value` payloads |
|
| 151 |
+- keep Profile B envelopes small and canonical |
|
| 152 |
+ |
|
| 153 |
+ |
|
| 154 |
+## Capability Catalog (initial) |
|
| 155 |
+ |
|
| 156 |
+Environmental capabilities: |
|
| 157 |
+ |
|
| 158 |
+- `temperature` |
|
| 159 |
+- `humidity` |
|
| 160 |
+- `pressure` |
|
| 161 |
+- `illuminance` |
|
| 162 |
+- `co2` |
|
| 163 |
+- `voc` |
|
| 164 |
+- `pm25` |
|
| 165 |
+- `pm10` |
|
| 166 |
+ |
|
| 167 |
+Presence and safety capabilities: |
|
| 168 |
+ |
|
| 169 |
+- `motion` |
|
| 170 |
+- `presence` |
|
| 171 |
+- `contact` |
|
| 172 |
+- `water_leak` |
|
| 173 |
+- `smoke` |
|
| 174 |
+- `gas` |
|
| 175 |
+- `tamper` |
|
| 176 |
+ |
|
| 177 |
+Control and user-facing capabilities: |
|
| 178 |
+ |
|
| 179 |
+- `light` |
|
| 180 |
+- `power` |
|
| 181 |
+- `lock` |
|
| 182 |
+- `cover_position` |
|
| 183 |
+- `thermostat_mode` |
|
| 184 |
+- `target_temperature` |
|
| 185 |
+- `fan_mode` |
|
| 186 |
+- `button` |
|
| 187 |
+ |
|
| 188 |
+Device health capabilities that are still user-relevant: |
|
| 189 |
+ |
|
| 190 |
+- `battery` |
|
| 191 |
+- `battery_low` |
|
| 192 |
+ |
|
| 193 |
+Rules: |
|
| 194 |
+ |
|
| 195 |
+- use `power` on `home` only for control semantics |
|
| 196 |
+- use electrical telemetry such as `active_power` and cumulative counters such as `energy_total` on `energy` |
|
| 197 |
+- radio diagnostics such as `linkquality` SHOULD NOT go on `home` by default |
|
| 198 |
+- `presence` SHOULD normally be a higher-level derived state, not a raw single-device detection |
|
| 199 |
+- raw mmWave detection, including Zigbee `presence` with `fading_time=0`, SHOULD be published as `motion` |
|
| 200 |
+ |
|
| 201 |
+ |
|
| 202 |
+## MQTT Delivery Policy |
|
| 203 |
+ |
|
| 204 |
+Default policy: |
|
| 205 |
+ |
|
| 206 |
+- `value`: QoS 1, retain false |
|
| 207 |
+- `last`: QoS 1, retain true |
|
| 208 |
+- `set`: QoS 1, retain false |
|
| 209 |
+- `meta`: QoS 1, retain true |
|
| 210 |
+- `availability`: QoS 1, retain true (LWT preferred) |
|
| 211 |
+ |
|
| 212 |
+Cold-start rule: |
|
| 213 |
+ |
|
| 214 |
+- control consumers such as thermostats SHOULD subscribe to retained `last` to obtain the latest known sample at startup |
|
| 215 |
+- `last` SHOULD include `observed_at` so staleness can be evaluated immediately after subscribe |
|
| 216 |
+- consumers MAY unsubscribe from `last` after bootstrap and continue consuming lightweight live updates on `value` |
|
| 217 |
+- consumers that depend on deterministic retained bootstrap SHOULD use a dedicated MQTT client session instead of sharing the same broker config with unrelated subscribers |
|
| 218 |
+ |
|
| 219 |
+If uncertain, choose QoS 1. |
|
| 220 |
+ |
|
| 221 |
+ |
|
| 222 |
+## Node-RED Implementation Optimizations |
|
| 223 |
+ |
|
| 224 |
+Translation is done in Node-RED, so the contract is optimized for low processing overhead. |
|
| 225 |
+ |
|
| 226 |
+Guidelines: |
|
| 227 |
+ |
|
| 228 |
+- keep topic shape stable to simplify wildcard routing and switch nodes |
|
| 229 |
+- avoid unnecessary JSON parse/build in high-rate pipelines |
|
| 230 |
+- publish device metadata on retained `meta` to avoid payload repetition |
|
| 231 |
+- publish canonical MQTT-ready messages as early as possible after normalization |
|
| 232 |
+- emit MQTT-ready messages only at the publish boundary |
|
| 233 |
+- do not carry adapter-internal normalization structures on forwarded `msg` objects |
|
| 234 |
+- discard temporary normalization fields before publish |
|
| 235 |
+- keep `value` streams extremely lightweight |
|
| 236 |
+- centralize mapping rules (location, capability, device_id) in reusable subflows |
|
| 237 |
+- keep one ingress subflow per protocol and one normalization subflow shared by all |
|
| 238 |
+- avoid broad `#` subscriptions in hot paths |
|
| 239 |
+ |
|
| 240 |
+Suggested Node-RED subscriptions: |
|
| 241 |
+ |
|
| 242 |
+- `+/home/+/+/+/value` |
|
| 243 |
+- `+/home/+/+/+/last` |
|
| 244 |
+- `+/home/+/+/+/set` |
|
| 245 |
+ |
|
| 246 |
+ |
|
| 247 |
+## Interaction with Other Buses |
|
| 248 |
+ |
|
| 249 |
+Cross-bus correlations are implemented by consumers, not by changing bus semantics. |
|
| 250 |
+ |
|
| 251 |
+Examples: |
|
| 252 |
+ |
|
| 253 |
+- combine `home` presence with `energy` load telemetry for occupancy-aware energy decisions |
|
| 254 |
+- combine `home` climate data with `network` device state for diagnostics |
|
| 255 |
+ |
|
| 256 |
+ |
|
| 257 |
+## Historian Relationship |
|
| 258 |
+ |
|
| 259 |
+The `home` bus is a major historian source. |
|
| 260 |
+ |
|
| 261 |
+For ingestion worker compatibility, each message should map to: |
|
| 262 |
+ |
|
| 263 |
+- `metric_name` |
|
| 264 |
+- `device_id` |
|
| 265 |
+- `value` |
|
| 266 |
+- `observed_at` |
|
| 267 |
+ |
|
| 268 |
+`device_id` recommendation: |
|
| 269 |
+ |
|
| 270 |
+- `<location>.<device_id>` |
|
| 271 |
+ |
|
| 272 |
+Historian defaults: |
|
| 273 |
+ |
|
| 274 |
+- `value` streams SHOULD be ingested by default |
|
| 275 |
+- `last` streams SHOULD NOT be ingested as normal telemetry samples |
|
| 276 |
+- `meta.historian.mode` SHOULD describe whether a `value` stream represents `sample`, `state`, or `event` semantics |
|
| 277 |
+- when Profile A scalar is used, `observed_at` will usually fall back to ingestion time |
|
| 278 |
+- string enum states are semantically valid, but the current PostgreSQL ingestion API directly supports only numeric and boolean samples |
|
| 279 |
+ |
|
| 280 |
+Example: |
|
| 281 |
+ |
|
| 282 |
+- Topic: `vad/home/bedroom/temperature/bedroom-sensor/value` |
|
| 283 |
+- `metric_name = temperature` |
|
| 284 |
+- `device_id = bedroom.bedroom-sensor` |
|
| 285 |
+ |
|
| 286 |
+Zigbee2MQTT projection examples: |
|
| 287 |
+ |
|
| 288 |
+- `zigbee2mqtt/bedroom_sensor.temperature` -> `vad/home/bedroom/temperature/bedroom-sensor/value` |
|
| 289 |
+- `zigbee2mqtt/front_door_contact.contact` -> `vad/home/entrance/contact/front-door/value` |
|
| 290 |
+- `zigbee2mqtt/bedside_remote.action` -> `vad/home/bedroom/button/bedside-remote/value` |
|
| 291 |
+ |
|
| 292 |
+ |
|
| 293 |
+## Relationship with HomeKit |
|
| 294 |
+ |
|
| 295 |
+The `home` bus is a primary source for HomeKit adapters. |
|
| 296 |
+ |
|
| 297 |
+HomeKit integration remains a consumer layer concern: |
|
| 298 |
+ |
|
| 299 |
+- Device -> Protocol Adapter -> MQTT Bus -> HomeKit Adapter -> HomeKit |
|
| 300 |
+ |
|
| 301 |
+The `home` bus contract SHOULD remain independent from HomeKit-specific modeling constraints. |
|
| 302 |
+ |
|
| 303 |
+ |
|
| 304 |
+## Design Principles |
|
| 305 |
+ |
|
| 306 |
+- keep room-centric semantics explicit and stable |
|
| 307 |
+- separate control semantics (`home`) from electrical accounting (`energy`) |
|
| 308 |
+- optimize for deterministic, low-cost Node-RED translation |
|
| 309 |
+- keep topic grammar strict and payload overhead low |
|
| 310 |
+- keep canonical IDs independent from vendor identifiers |
|
@@ -0,0 +1,420 @@ |
||
| 1 |
+# MQTT Shared Contract |
|
| 2 |
+ |
|
| 3 |
+## Purpose |
|
| 4 |
+ |
|
| 5 |
+This document defines the shared contract baseline for all semantic MQTT buses in this repository. |
|
| 6 |
+ |
|
| 7 |
+It exists to keep adapters, historian ingestion, and future buses aligned on the same transport and payload rules. |
|
| 8 |
+ |
|
| 9 |
+Bus-specific documents such as `home_bus.md` and `energy_bus.md` define their own topic grammar, but they inherit the rules from this shared contract. |
|
| 10 |
+ |
|
| 11 |
+The operational namespace under `<site>/sys/...` is specified in more detail by `sys_bus.md`. |
|
| 12 |
+ |
|
| 13 |
+--- |
|
| 14 |
+ |
|
| 15 |
+## Namespace Model |
|
| 16 |
+ |
|
| 17 |
+Two top-level namespaces are reserved: |
|
| 18 |
+ |
|
| 19 |
+- semantic bus namespace: `<site>/<bus>/...` |
|
| 20 |
+- operational namespace: `<site>/sys/...` |
|
| 21 |
+ |
|
| 22 |
+Examples: |
|
| 23 |
+ |
|
| 24 |
+- `vad/home/bedroom/temperature/bedroom-sensor/value` |
|
| 25 |
+- `vad/energy/load/living-room-tv/active_power/value` |
|
| 26 |
+- `vad/sys/adapter/z2m-main/error` |
|
| 27 |
+ |
|
| 28 |
+Rules: |
|
| 29 |
+ |
|
| 30 |
+- `<site>` MUST be stable and lowercase kebab-case |
|
| 31 |
+- `<bus>` MUST be a reserved bus identifier such as `home`, `energy`, `network`, `compute`, `vehicle` |
|
| 32 |
+- `sys` is reserved for operational topics and is not a semantic bus; see `sys_bus.md` |
|
| 33 |
+- topic path versioning is intentionally not used in v1 to keep wildcard subscriptions simple |
|
| 34 |
+ |
|
| 35 |
+Versioning rule: |
|
| 36 |
+ |
|
| 37 |
+- breaking contract changes MUST be expressed via document version and `schema_ref` |
|
| 38 |
+- existing topic paths MUST remain stable for v1 consumers |
|
| 39 |
+ |
|
| 40 |
+--- |
|
| 41 |
+ |
|
| 42 |
+## Shared Streams |
|
| 43 |
+ |
|
| 44 |
+All semantic buses use the same stream taxonomy: |
|
| 45 |
+ |
|
| 46 |
+- `value`: live hot-path semantic sample, whether it represents a measurement, durable state, or transition notification |
|
| 47 |
+- `last`: retained last-known timestamped sample used for cold start and freshness evaluation |
|
| 48 |
+- `set`: command/request topic |
|
| 49 |
+- `meta`: retained metadata for the sibling topic family |
|
| 50 |
+- `availability`: online/offline or degraded health signal |
|
| 51 |
+ |
|
| 52 |
+Rules: |
|
| 53 |
+ |
|
| 54 |
+- `set` MUST NOT be retained |
|
| 55 |
+- `last` SHOULD be retained |
|
| 56 |
+- `meta` SHOULD be retained |
|
| 57 |
+- `availability` SHOULD be retained and SHOULD use LWT when supported |
|
| 58 |
+- buses MUST NOT invent ad-hoc stream names for v1 |
|
| 59 |
+- adapters SHOULD publish live semantic data on `value`, not split it across separate `state` and `event` streams |
|
| 60 |
+- adapters SHOULD deduplicate hot `value` publications when consecutive samples are semantically identical |
|
| 61 |
+- adapters SHOULD update retained `last` whenever the latest observed sample timestamp changes |
|
| 62 |
+- legacy `state` and `event` topics SHOULD be treated as compatibility-only during migration and SHOULD NOT be introduced by new adapters |
|
| 63 |
+ |
|
| 64 |
+If a future need appears for diagnostics, replay, dead-letter handling, or adapter metrics, it MUST be modeled under `<site>/sys/...`, not by extending semantic bus streams. |
|
| 65 |
+ |
|
| 66 |
+--- |
|
| 67 |
+ |
|
| 68 |
+## Topic Naming Rules |
|
| 69 |
+ |
|
| 70 |
+Common naming rules: |
|
| 71 |
+ |
|
| 72 |
+- identifiers representing physical or logical entities SHOULD use kebab-case |
|
| 73 |
+- capability and metric names SHOULD use snake_case |
|
| 74 |
+- topic segments MUST be ASCII lowercase |
|
| 75 |
+- spaces MUST NOT appear in canonical topics |
|
| 76 |
+- vendor-native identifiers MUST NOT leak into semantic topics unless they are the chosen canonical identifier |
|
| 77 |
+ |
|
| 78 |
+Identity rules: |
|
| 79 |
+ |
|
| 80 |
+- canonical IDs MUST be stable across adapter rewrites |
|
| 81 |
+- replacing a physical sensor SHOULD NOT force a canonical ID change if the logical endpoint remains the same |
|
| 82 |
+- vendor IDs SHOULD be carried in `meta.source_ref`, not in the semantic topic path |
|
| 83 |
+ |
|
| 84 |
+--- |
|
| 85 |
+ |
|
| 86 |
+## Lightweight Bus Requirement |
|
| 87 |
+ |
|
| 88 |
+The semantic MQTT bus is a high-efficiency event bus. |
|
| 89 |
+ |
|
| 90 |
+It is NOT: |
|
| 91 |
+ |
|
| 92 |
+- a debugging interface |
|
| 93 |
+- a transport for rich adapter envelopes |
|
| 94 |
+- a place to repeat internal mapping state on every sample |
|
| 95 |
+ |
|
| 96 |
+Normative rule: |
|
| 97 |
+ |
|
| 98 |
+- semantic bus publications MUST remain minimal and MQTT-ready |
|
| 99 |
+- the canonical publication boundary is the MQTT topic plus payload, with QoS and retain determined by stream policy |
|
| 100 |
+- adapters MAY build richer internal normalization objects, but those objects MUST be discarded before publish |
|
| 101 |
+- adapter-specific fields such as mapping tables, vendor payload snapshots, or internal processing context MUST NOT travel on semantic bus hot paths |
|
| 102 |
+ |
|
| 103 |
+Canonical publication form: |
|
| 104 |
+ |
|
| 105 |
+- topic: `<site>/<bus>/...` |
|
| 106 |
+- payload: scalar or small JSON envelope |
|
| 107 |
+- qos: `0` or `1` |
|
| 108 |
+- retain: according to stream policy |
|
| 109 |
+ |
|
| 110 |
+Discouraged adapter-side publish shape: |
|
| 111 |
+ |
|
| 112 |
+```json |
|
| 113 |
+{
|
|
| 114 |
+ "topic": "vad/home/bedroom/temperature/bedroom-sensor/value", |
|
| 115 |
+ "payload": 23.6, |
|
| 116 |
+ "homeBus": {
|
|
| 117 |
+ "location": "bedroom" |
|
| 118 |
+ }, |
|
| 119 |
+ "z2mPayload": {
|
|
| 120 |
+ "temperature": 23.6, |
|
| 121 |
+ "battery": 91 |
|
| 122 |
+ }, |
|
| 123 |
+ "mapping": {
|
|
| 124 |
+ "source_field": "temperature" |
|
| 125 |
+ } |
|
| 126 |
+} |
|
| 127 |
+``` |
|
| 128 |
+ |
|
| 129 |
+Those structures may exist inside normalization logic, but MUST be stripped before the MQTT publish boundary. |
|
| 130 |
+ |
|
| 131 |
+Reason: |
|
| 132 |
+ |
|
| 133 |
+- the architecture prioritizes low CPU usage, low memory footprint, predictable Node-RED execution, and compatibility with constrained IoT accessories |
|
| 134 |
+- large message objects increase memory pressure |
|
| 135 |
+- large nested objects increase garbage collection cost in Node-RED |
|
| 136 |
+- constrained accessories, SBCs, and thin VMs benefit from structurally simple bus traffic |
|
| 137 |
+ |
|
| 138 |
+--- |
|
| 139 |
+ |
|
| 140 |
+## Payload Profiles |
|
| 141 |
+ |
|
| 142 |
+Two payload profiles are supported across all buses. |
|
| 143 |
+ |
|
| 144 |
+### Profile A: Scalar Payload |
|
| 145 |
+ |
|
| 146 |
+This is the default profile for hot paths. |
|
| 147 |
+ |
|
| 148 |
+Examples: |
|
| 149 |
+ |
|
| 150 |
+- `23.6` |
|
| 151 |
+- `41` |
|
| 152 |
+- `true` |
|
| 153 |
+- `on` |
|
| 154 |
+ |
|
| 155 |
+Profile A requirements: |
|
| 156 |
+ |
|
| 157 |
+- the payload MUST be a scalar: number, boolean, or short string enum |
|
| 158 |
+- units and metadata MUST be published on retained `meta` |
|
| 159 |
+- if exact source observation time matters, Profile A MUST NOT be used unless broker receive time is acceptable |
|
| 160 |
+- Profile A SHOULD be the default for high-rate telemetry and hot `value` streams |
|
| 161 |
+- Profile A is the preferred format for live `value` streams consumed by lightweight clients |
|
| 162 |
+ |
|
| 163 |
+Profile A historian rule: |
|
| 164 |
+ |
|
| 165 |
+- historian workers SHOULD use ingestion time as `observed_at` unless an equivalent timestamp is provided out of band |
|
| 166 |
+ |
|
| 167 |
+### Profile B: Envelope JSON |
|
| 168 |
+ |
|
| 169 |
+This profile is used when timestamp, quality, or extra annotations must travel with each sample. |
|
| 170 |
+ |
|
| 171 |
+Canonical shape: |
|
| 172 |
+ |
|
| 173 |
+```json |
|
| 174 |
+{
|
|
| 175 |
+ "value": 23.6, |
|
| 176 |
+ "unit": "C", |
|
| 177 |
+ "observed_at": "2026-03-08T10:15:12Z", |
|
| 178 |
+ "quality": "good" |
|
| 179 |
+} |
|
| 180 |
+``` |
|
| 181 |
+ |
|
| 182 |
+Optional fields: |
|
| 183 |
+ |
|
| 184 |
+- `published_at`: adapter publish time |
|
| 185 |
+- `source_seq`: source-side monotonic counter or sequence id |
|
| 186 |
+- `annotations`: free-form object for low-rate streams |
|
| 187 |
+ |
|
| 188 |
+Profile B requirements: |
|
| 189 |
+ |
|
| 190 |
+- `value` is REQUIRED |
|
| 191 |
+- `observed_at` SHOULD be included when the source provides a timestamp |
|
| 192 |
+- `quality` SHOULD be included if the adapter had to estimate or degrade data |
|
| 193 |
+- `unit` MAY be omitted for unitless, boolean, or enum values |
|
| 194 |
+ |
|
| 195 |
+Use Profile B when: |
|
| 196 |
+ |
|
| 197 |
+- source timestamp must be preserved |
|
| 198 |
+- historian ordering must follow source time, not broker receive time |
|
| 199 |
+- per-sample quality matters |
|
| 200 |
+- the stream is low-rate enough that JSON overhead is acceptable |
|
| 201 |
+- a retained `last` sample is used for startup decisions and consumers must evaluate whether it is still usable |
|
| 202 |
+ |
|
| 203 |
+Profile B restriction: |
|
| 204 |
+ |
|
| 205 |
+- adapters SHOULD avoid envelope JSON on high-rate streams unless there is no acceptable scalar alternative |
|
| 206 |
+- Profile B MUST remain a small canonical envelope and MUST NOT be extended into a general-purpose transport for adapter internals |
|
| 207 |
+- repeated metadata belongs on retained `meta`, not inside every `value` sample |
|
| 208 |
+ |
|
| 209 |
+--- |
|
| 210 |
+ |
|
| 211 |
+## Meta Contract |
|
| 212 |
+ |
|
| 213 |
+Each retained `meta` topic describes the sibling `value` and `last` stream family. |
|
| 214 |
+ |
|
| 215 |
+Minimum recommended shape: |
|
| 216 |
+ |
|
| 217 |
+```json |
|
| 218 |
+{
|
|
| 219 |
+ "schema_ref": "mqbus.home.v1", |
|
| 220 |
+ "payload_profile": "scalar", |
|
| 221 |
+ "data_type": "number", |
|
| 222 |
+ "unit": "C", |
|
| 223 |
+ "adapter_id": "z2m-main", |
|
| 224 |
+ "source": "zigbee2mqtt", |
|
| 225 |
+ "source_ref": "0x00158d0008aa1111", |
|
| 226 |
+ "source_topic": "zigbee2mqtt/bedroom_sensor", |
|
| 227 |
+ "precision": 0.1, |
|
| 228 |
+ "historian": {
|
|
| 229 |
+ "enabled": true, |
|
| 230 |
+ "mode": "sample" |
|
| 231 |
+ } |
|
| 232 |
+} |
|
| 233 |
+``` |
|
| 234 |
+ |
|
| 235 |
+Recommended fields: |
|
| 236 |
+ |
|
| 237 |
+- `schema_ref`: stable schema identifier such as `mqbus.home.v1` |
|
| 238 |
+- `payload_profile`: `scalar` or `envelope` |
|
| 239 |
+- `data_type`: `number`, `boolean`, `string`, or `json` |
|
| 240 |
+- `unit`: canonical engineering unit when applicable |
|
| 241 |
+- `adapter_id`: canonical adapter instance id |
|
| 242 |
+- `source`: source system such as `zigbee2mqtt`, `modbus`, `snmp` |
|
| 243 |
+- `source_ref`: vendor or physical device identifier |
|
| 244 |
+- `source_topic`: original inbound topic or equivalent source path |
|
| 245 |
+- `precision`: numeric precision hint |
|
| 246 |
+- `display_name`: human-readable label |
|
| 247 |
+- `tags`: optional list for analytics and discovery |
|
| 248 |
+- `historian`: ingestion policy object |
|
| 249 |
+ |
|
| 250 |
+Historian metadata contract: |
|
| 251 |
+ |
|
| 252 |
+- `historian.enabled`: boolean |
|
| 253 |
+- `historian.mode`: one of `sample`, `state`, `event`, `ignore` |
|
| 254 |
+- `historian.retention_class`: optional storage class such as `short`, `default`, `long` |
|
| 255 |
+- `historian.sample_period_hint_s`: optional expected cadence |
|
| 256 |
+ |
|
| 257 |
+Rules: |
|
| 258 |
+ |
|
| 259 |
+- `meta` SHOULD be published before live `value` traffic for new streams |
|
| 260 |
+- `meta` updates MUST remain backward-compatible for existing consumers during v1 |
|
| 261 |
+- consumers MUST tolerate missing `meta` and continue with degraded defaults |
|
| 262 |
+- repeated descriptive metadata MUST be published on retained `meta`, not repeated on each hot-path `value` publication |
|
| 263 |
+ |
|
| 264 |
+Example: |
|
| 265 |
+ |
|
| 266 |
+- `vad/home/bedroom/temperature/bedroom-sensor/meta` -> `{"unit":"C","precision":0.1,"adapter_id":"z2m-main"}`
|
|
| 267 |
+- `vad/home/bedroom/temperature/bedroom-sensor/value` -> `23.6` |
|
| 268 |
+ |
|
| 269 |
+--- |
|
| 270 |
+ |
|
| 271 |
+## Time Semantics |
|
| 272 |
+ |
|
| 273 |
+The following timestamps are distinct: |
|
| 274 |
+ |
|
| 275 |
+- `observed_at`: when the source system observed or measured the value |
|
| 276 |
+- `published_at`: when the adapter normalized and published the message |
|
| 277 |
+- `ingested_at`: when the downstream worker processed the message |
|
| 278 |
+ |
|
| 279 |
+Rules: |
|
| 280 |
+ |
|
| 281 |
+- if the source provides a trustworthy timestamp, adapters SHOULD preserve it as `observed_at` |
|
| 282 |
+- if the source does not provide a timestamp, adapters MAY omit `observed_at` |
|
| 283 |
+- if `observed_at` is omitted, historian workers SHOULD use `ingested_at` |
|
| 284 |
+- adapters MUST NOT fabricate source time and mark it as fully trustworthy |
|
| 285 |
+- if an adapter estimates time, it SHOULD use Profile B with `quality=estimated` |
|
| 286 |
+ |
|
| 287 |
+This rule is the key tradeoff between low-overhead scalar payloads and strict time fidelity. |
|
| 288 |
+ |
|
| 289 |
+--- |
|
| 290 |
+ |
|
| 291 |
+## Quality Model |
|
| 292 |
+ |
|
| 293 |
+The following quality values are recommended: |
|
| 294 |
+ |
|
| 295 |
+- `good`: trusted value from source |
|
| 296 |
+- `estimated`: value or timestamp estimated by adapter |
|
| 297 |
+- `degraded`: source known to be unstable or partially invalid |
|
| 298 |
+- `stale`: source not updated within expected cadence |
|
| 299 |
+- `invalid`: malformed or failed validation |
|
| 300 |
+ |
|
| 301 |
+Rules: |
|
| 302 |
+ |
|
| 303 |
+- omit `quality` only when `good` is implied |
|
| 304 |
+- `invalid` payloads SHOULD NOT be emitted on semantic bus topics |
|
| 305 |
+- invalid or unmappable messages SHOULD be routed to operational error topics under `sys` |
|
| 306 |
+ |
|
| 307 |
+--- |
|
| 308 |
+ |
|
| 309 |
+## Delivery Policy |
|
| 310 |
+ |
|
| 311 |
+Shared defaults: |
|
| 312 |
+ |
|
| 313 |
+- `value`: QoS 1, retain false unless a bus-specific contract explicitly requires otherwise |
|
| 314 |
+- `last`: QoS 1, retain true |
|
| 315 |
+- `set`: QoS 1, retain false |
|
| 316 |
+- `meta`: QoS 1, retain true |
|
| 317 |
+- `availability`: QoS 1, retain true |
|
| 318 |
+ |
|
| 319 |
+Additional rules: |
|
| 320 |
+ |
|
| 321 |
+- `value` is the live stream and SHOULD remain lightweight |
|
| 322 |
+- `last` is the cold-start bootstrap mechanism for latest known measurements on the semantic bus |
|
| 323 |
+- `last` SHOULD use Profile B and include `observed_at` |
|
| 324 |
+- consumers MUST treat a retained `last` sample as the latest known observation, not as proof of freshness |
|
| 325 |
+- if freshness matters, consumers SHOULD evaluate `observed_at`, `availability`, and expected cadence from `meta` |
|
| 326 |
+- adapters MAY deduplicate `value` publications, but `last` SHOULD be updated whenever the latest observed sample timestamp changes |
|
| 327 |
+- command acknowledgements SHOULD be emitted on normalized `value`, not by retaining `set` |
|
| 328 |
+- late joiners MUST be able to reconstruct stream meaning and last known sample from retained `meta`, retained `last`, and retained `availability` |
|
| 329 |
+- consumers that require deterministic retained bootstrap SHOULD use a dedicated MQTT client session rather than sharing a session with unrelated subscribers on the same broker config |
|
| 330 |
+ |
|
| 331 |
+--- |
|
| 332 |
+ |
|
| 333 |
+## Command Envelope Guidance |
|
| 334 |
+ |
|
| 335 |
+Simple `set` commands may use scalar payloads: |
|
| 336 |
+ |
|
| 337 |
+- `on` |
|
| 338 |
+- `off` |
|
| 339 |
+- `21.5` |
|
| 340 |
+ |
|
| 341 |
+If correlation or richer semantics are required, a JSON envelope is allowed: |
|
| 342 |
+ |
|
| 343 |
+```json |
|
| 344 |
+{
|
|
| 345 |
+ "value": "on", |
|
| 346 |
+ "request_id": "01HRN8KZQ2D7P0S4M6B4CJ3M8Y", |
|
| 347 |
+ "requested_at": "2026-03-08T10:20:00Z" |
|
| 348 |
+} |
|
| 349 |
+``` |
|
| 350 |
+ |
|
| 351 |
+Rules: |
|
| 352 |
+ |
|
| 353 |
+- command topics MUST remain bus-specific and capability-specific |
|
| 354 |
+- acknowledgements SHOULD be published separately on normalized `value` |
|
| 355 |
+- adapters SHOULD avoid command-side business logic |
|
| 356 |
+ |
|
| 357 |
+--- |
|
| 358 |
+ |
|
| 359 |
+## Operational Namespace |
|
| 360 |
+ |
|
| 361 |
+Operational topics are for adapter health, replay control, and malformed input handling. |
|
| 362 |
+ |
|
| 363 |
+Detailed operational namespace rules are defined in `sys_bus.md`. |
|
| 364 |
+ |
|
| 365 |
+Recommended topics: |
|
| 366 |
+ |
|
| 367 |
+- `<site>/sys/adapter/<adapter_id>/availability` |
|
| 368 |
+- `<site>/sys/adapter/<adapter_id>/stats` |
|
| 369 |
+- `<site>/sys/adapter/<adapter_id>/error` |
|
| 370 |
+- `<site>/sys/adapter/<adapter_id>/dlq` |
|
| 371 |
+ |
|
| 372 |
+Recommended uses: |
|
| 373 |
+ |
|
| 374 |
+- `availability`: retained adapter liveness |
|
| 375 |
+- `stats`: low-rate counters such as published points or dropped messages |
|
| 376 |
+- `error`: structured adapter errors that deserve operator attention |
|
| 377 |
+- `dlq`: dead-letter payloads for messages that could not be normalized |
|
| 378 |
+ |
|
| 379 |
+This keeps operational concerns separate from semantic buses and avoids polluting historian input. |
|
| 380 |
+ |
|
| 381 |
+Debugging, replay diagnostics, and adapter-internal observability MUST be published under `<site>/sys/...`, not embedded into semantic bus payloads. |
|
| 382 |
+ |
|
| 383 |
+--- |
|
| 384 |
+ |
|
| 385 |
+## Retained Message Lifecycle |
|
| 386 |
+ |
|
| 387 |
+Retained topics are part of the contract and require explicit lifecycle handling. |
|
| 388 |
+ |
|
| 389 |
+Rules: |
|
| 390 |
+ |
|
| 391 |
+- `meta`, `last`, and `availability` SHOULD be retained when they represent current truth |
|
| 392 |
+- when a retained topic must be deleted, publish a zero-byte retained message to the same topic |
|
| 393 |
+- adapters SHOULD clear retained topics when an entity is decommissioned or renamed |
|
| 394 |
+- consumers MUST tolerate retained data arriving before or after live traffic |
|
| 395 |
+ |
|
| 396 |
+--- |
|
| 397 |
+ |
|
| 398 |
+## Historian Ingestion Defaults |
|
| 399 |
+ |
|
| 400 |
+Historian workers SHOULD apply the following defaults: |
|
| 401 |
+ |
|
| 402 |
+- ingest `value` streams by default |
|
| 403 |
+- interpret `meta.historian.mode` as the semantic category of the `value` stream, for example `sample`, `state`, or `event` |
|
| 404 |
+- ignore `last`, `set`, `meta`, and `availability` as time-series samples |
|
| 405 |
+ |
|
| 406 |
+Current PostgreSQL historian compatibility: |
|
| 407 |
+ |
|
| 408 |
+- numeric and boolean samples are directly compatible with `tdb_ingestion/mqtt_ingestion_api.md` |
|
| 409 |
+- string enum states are valid on the semantic bus, but SHOULD stay out of historian until an explicit encoding policy exists |
|
| 410 |
+- counter-style cumulative metrics such as `energy_total`, `*_bytes_total`, and `*_packets_total` are valid bus metrics, but the current measurement API does not define their storage semantics; see `tdb_ingestion/counter_ingestion_api.md` |
|
| 411 |
+- if enum state ingestion is needed, the worker MUST map it to an agreed numeric or boolean representation before calling PostgreSQL |
|
| 412 |
+ |
|
| 413 |
+Default field mapping: |
|
| 414 |
+ |
|
| 415 |
+- `value` from payload or envelope |
|
| 416 |
+- `observed_at` from envelope if present, otherwise ingestion time |
|
| 417 |
+- `unit` from envelope if present, otherwise cached retained `meta.unit` |
|
| 418 |
+- `quality` from envelope if present, otherwise `good` |
|
| 419 |
+ |
|
| 420 |
+This allows historian workers to stay generic while bus contracts remain strict. |
|
@@ -0,0 +1,176 @@ |
||
| 1 |
+# Sys Operational Namespace |
|
| 2 |
+ |
|
| 3 |
+## Purpose |
|
| 4 |
+ |
|
| 5 |
+This document defines the shared operational MQTT namespace under `<site>/sys/...`. |
|
| 6 |
+ |
|
| 7 |
+It is used for adapter, worker, bridge, and infrastructure observability. |
|
| 8 |
+ |
|
| 9 |
+`sys` is not a semantic bus. It exists to expose component health, counters, errors, and rejected messages without polluting domain buses such as `home` or `energy`. |
|
| 10 |
+ |
|
| 11 |
+--- |
|
| 12 |
+ |
|
| 13 |
+## Namespace Model |
|
| 14 |
+ |
|
| 15 |
+Canonical shape: |
|
| 16 |
+ |
|
| 17 |
+`<site>/sys/<producer_kind>/<instance_id>/<stream>` |
|
| 18 |
+ |
|
| 19 |
+Examples: |
|
| 20 |
+ |
|
| 21 |
+- `vad/sys/adapter/z2m-zg-204zv/availability` |
|
| 22 |
+- `vad/sys/adapter/z2m-zg-204zv/stats` |
|
| 23 |
+- `vad/sys/historian/main/error` |
|
| 24 |
+- `vad/sys/historian/main/dlq` |
|
| 25 |
+ |
|
| 26 |
+Rules: |
|
| 27 |
+ |
|
| 28 |
+- `<site>` MUST be stable lowercase kebab-case |
|
| 29 |
+- `<producer_kind>` identifies the emitting software component, not a device capability |
|
| 30 |
+- `<instance_id>` MUST identify a stable logical instance of that component |
|
| 31 |
+- `sys` MUST NOT be used for room, device, or capability telemetry |
|
| 32 |
+- semantic topics such as `<site>/home/...` and `<site>/energy/...` MUST remain separate from `sys` |
|
| 33 |
+ |
|
| 34 |
+Common `producer_kind` values in v1: |
|
| 35 |
+ |
|
| 36 |
+- `adapter` |
|
| 37 |
+- `historian` |
|
| 38 |
+ |
|
| 39 |
+Additional producer kinds MAY be introduced later, but they SHOULD follow the same operational topic model. |
|
| 40 |
+ |
|
| 41 |
+--- |
|
| 42 |
+ |
|
| 43 |
+## Streams |
|
| 44 |
+ |
|
| 45 |
+The `sys` namespace uses operational streams, not the semantic stream taxonomy from domain buses. |
|
| 46 |
+ |
|
| 47 |
+Supported v1 streams: |
|
| 48 |
+ |
|
| 49 |
+- `availability` |
|
| 50 |
+- `stats` |
|
| 51 |
+- `error` |
|
| 52 |
+- `dlq` |
|
| 53 |
+ |
|
| 54 |
+### `availability` |
|
| 55 |
+ |
|
| 56 |
+Meaning: |
|
| 57 |
+ |
|
| 58 |
+- liveness of the emitting component instance |
|
| 59 |
+- whether the adapter or worker itself is online |
|
| 60 |
+ |
|
| 61 |
+Typical payloads: |
|
| 62 |
+ |
|
| 63 |
+- `online` |
|
| 64 |
+- `offline` |
|
| 65 |
+- optionally `degraded` for components that expose a degraded mode |
|
| 66 |
+ |
|
| 67 |
+Policy: |
|
| 68 |
+ |
|
| 69 |
+- QoS 1 |
|
| 70 |
+- retain true |
|
| 71 |
+- SHOULD use LWT when supported by the MQTT client |
|
| 72 |
+ |
|
| 73 |
+### `stats` |
|
| 74 |
+ |
|
| 75 |
+Meaning: |
|
| 76 |
+ |
|
| 77 |
+- low-rate counters or snapshots describing the current operational state of the component |
|
| 78 |
+ |
|
| 79 |
+Typical payload shape: |
|
| 80 |
+ |
|
| 81 |
+```json |
|
| 82 |
+{
|
|
| 83 |
+ "processed_inputs": 1824, |
|
| 84 |
+ "translated_messages": 9241, |
|
| 85 |
+ "errors": 2, |
|
| 86 |
+ "dlq": 1 |
|
| 87 |
+} |
|
| 88 |
+``` |
|
| 89 |
+ |
|
| 90 |
+Policy: |
|
| 91 |
+ |
|
| 92 |
+- QoS 1 |
|
| 93 |
+- retain true when the message represents the latest snapshot |
|
| 94 |
+- publish at a low rate, not on every hot-path sample |
|
| 95 |
+ |
|
| 96 |
+### `error` |
|
| 97 |
+ |
|
| 98 |
+Meaning: |
|
| 99 |
+ |
|
| 100 |
+- structured operator-visible errors |
|
| 101 |
+- faults that deserve attention but do not require embedding diagnostics into semantic bus payloads |
|
| 102 |
+ |
|
| 103 |
+Typical payload shape: |
|
| 104 |
+ |
|
| 105 |
+```json |
|
| 106 |
+{
|
|
| 107 |
+ "code": "invalid_topic", |
|
| 108 |
+ "reason": "Topic must start with zigbee2mqtt/ZG-204ZV", |
|
| 109 |
+ "source_topic": "zigbee2mqtt/bad/topic", |
|
| 110 |
+ "adapter_id": "z2m-zg-204zv" |
|
| 111 |
+} |
|
| 112 |
+``` |
|
| 113 |
+ |
|
| 114 |
+Policy: |
|
| 115 |
+ |
|
| 116 |
+- QoS 1 |
|
| 117 |
+- retain false by default |
|
| 118 |
+- SHOULD remain structured and compact |
|
| 119 |
+ |
|
| 120 |
+### `dlq` |
|
| 121 |
+ |
|
| 122 |
+Meaning: |
|
| 123 |
+ |
|
| 124 |
+- dead-letter payloads for messages that could not be normalized or safely processed |
|
| 125 |
+ |
|
| 126 |
+Typical payload shape: |
|
| 127 |
+ |
|
| 128 |
+```json |
|
| 129 |
+{
|
|
| 130 |
+ "code": "payload_not_object", |
|
| 131 |
+ "source_topic": "zigbee2mqtt/ZG-204ZV/vad/balcon/south", |
|
| 132 |
+ "payload": "offline" |
|
| 133 |
+} |
|
| 134 |
+``` |
|
| 135 |
+ |
|
| 136 |
+Policy: |
|
| 137 |
+ |
|
| 138 |
+- QoS 1 |
|
| 139 |
+- retain false |
|
| 140 |
+- SHOULD carry enough context to reproduce or inspect the rejected message |
|
| 141 |
+ |
|
| 142 |
+--- |
|
| 143 |
+ |
|
| 144 |
+## Availability Semantics |
|
| 145 |
+ |
|
| 146 |
+Operational availability and semantic availability are different signals. |
|
| 147 |
+ |
|
| 148 |
+Examples: |
|
| 149 |
+ |
|
| 150 |
+- `vad/sys/adapter/z2m-zg-204zv/availability` means the adapter is running |
|
| 151 |
+- `vad/home/balcon/motion/south/availability` means the canonical endpoint is available to consumers |
|
| 152 |
+ |
|
| 153 |
+These topics are complementary, not duplicates. |
|
| 154 |
+ |
|
| 155 |
+Rules: |
|
| 156 |
+ |
|
| 157 |
+- `sys/.../availability` describes component health |
|
| 158 |
+- semantic `.../availability` describes endpoint availability on a domain bus |
|
| 159 |
+ |
|
| 160 |
+--- |
|
| 161 |
+ |
|
| 162 |
+## Consumer Guidance |
|
| 163 |
+ |
|
| 164 |
+- historians SHOULD NOT treat `sys` topics as normal semantic measurements by default |
|
| 165 |
+- dashboards, alerting, and operator tooling SHOULD subscribe to `sys` |
|
| 166 |
+- malformed input, normalization failures, and adapter faults SHOULD be routed to `sys`, not embedded into semantic payloads |
|
| 167 |
+- consumers SHOULD expect `stats` to be snapshots and `error`/`dlq` to be transient diagnostics |
|
| 168 |
+ |
|
| 169 |
+--- |
|
| 170 |
+ |
|
| 171 |
+## Relationship to Other Documents |
|
| 172 |
+ |
|
| 173 |
+- shared transport and payload rules: `mqtt_contract.md` |
|
| 174 |
+- adapter operational responsibilities: `addapters.md` |
|
| 175 |
+- historian worker operational topics: `historian_worker.md` |
|
| 176 |
+- semantic endpoint contracts: `home_bus.md`, `energy_bus.md` |
|