|
Bogdan Timofte
authored
2 weeks ago
|
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.
|