Snapshot envelope
Wire format for sub-device telemetry — the flat JSON envelope that carries per-device bitmasks and numeric values on the telemetry topic.
A snapshot envelope is a single MQTT message published to cpi/{plantId}/telemetry that carries the current state of every sub-device in one atomic burst. Voke detects the snapshot format by the presence of a top-level devices array and routes the message to SubDeviceTelemetryService for template-driven decoding.
Wire shape
The envelope is a flat JSON object. All fields except timestamp and devices are envelope metadata; timestamp and devices form the signable body.
// packages/shared/src/schemas/sub-device.ts — TelemetryEnvelopeSchema
interface SnapshotEnvelope {
ts: number; // Unix milliseconds — publish time, used for replay-window checks
n: string; // nonce, 8+ hex characters, must be unique per message
sig: string; // HMAC-SHA256 hex; see Signing rule below (optional for PASSWORD)
timestamp?: string | number; // ISO 8601 UTC string OR epoch ms number — sample/observation time (used for persistence)
devices: SnapshotDevice[];
}
interface SnapshotDevice {
externalId: string; // short operator-assigned ID: R1, R2, E1, M1, …
type: 'CABINET' | 'METER' | 'INVERTER' | 'BATTERY'; // SubDeviceType enum values
raw?: number; // CABINET only: uint32 bitmask for binary signals
values?: Record<string, number | string | boolean | null>; // METER/INVERTER/BATTERY only
}The schema is discriminated by type: CABINET entries send raw; METER, INVERTER, and BATTERY entries send values. Do not mix raw and values in one entry unless the schema is extended in a future protocol version.
timestamp accepts either an ISO 8601 UTC string or an epoch-millisecond number — the number form is provided for partners working directly with raw PLC clocks, while ISO 8601 is preferred for human-readable logs.
Concrete example
{
"ts": 1713540000000,
"n": "0a1b2c3d4e5f6789",
"sig": "3d8f2a1c...",
"timestamp": "2026-04-19T14:00:00Z",
"devices": [
{
"externalId": "R1",
"type": "CABINET",
"raw": 5
},
{
"externalId": "M1",
"type": "METER",
"values": {
"activePowerKw": 12.4,
"voltageV": 231.7
}
},
{
"externalId": "BAT1",
"type": "BATTERY",
"values": {
"stateOfChargePct": 61.2,
"batteryPowerW": -820
}
}
]
}Cabinet R1 sends raw: 5 (binary 0b00000101) — bit 0 and bit 2 are set, all others are clear. Meter M1 and battery BAT1 send numeric readings. All entries are decoded server-side using the sub-device's assigned DeviceTemplate.
ts vs timestamp
| Field | Purpose | Used by |
|---|---|---|
ts | Publish time (Unix ms) | Replay-window check; nonce deduplication TTL |
timestamp | Sample/observation time (ISO 8601) | Persistence row timestamp; what partners see in queries |
For live data, ts and timestamp are within a few hundred milliseconds of each other.
For buffered publishes (e.g. catching up after a cellular outage), timestamp is earlier than ts. Voke stores the timestamp value — callers querying historical telemetry see the original observation time, not the late-publish time.
If timestamp is omitted, Voke falls back to deriving the observation time from ts.
Signing rule
The signature covers the message body with envelope fields stripped out. The server applies the same strip-and-verify pattern:
stripped body = { timestamp, devices } // everything except ts, n, sigHMAC parts, joined with |:
plantId | ts | nonce | canonicalJsonStringify(stripped_body)Where:
plantIdis the plant's UUID (same as{plantId}in the topic).tsis the numeric millisecond value as a string (e.g."1713540000000").nonceis the value of thenfield.canonicalJsonStringifyrecursively sorts object keys and serialises with no spaces — see HMAC signing for the exact algorithm.
# From apps/docs/content/examples/plc/hmac_sign.py
body = {k: v for k, v in raw.items() if k not in ("ts", "n", "nonce", "sig")}
sig = sign_telemetry(plant_id, raw["ts"], raw["n"], body, secret)PASSWORD devices may operate without signing (sig is optional for that listener). mTLS and JWT devices should always include sig — Voke's ingestor logs a warning on unsigned payloads from those listeners and may enforce the requirement in a future version.
Auto-discovery
When a snapshot device entry contains an externalId that Voke has never seen for this plant, Voke automatically creates a placeholder SubDevice row. No manual provisioning is needed to start receiving data.
The placeholder appears in Voke admin with status ONLINE because it was observed in the incoming snapshot. Operators link a DeviceTemplate to it to start signal decoding. Until a template is linked, the raw payload is received but no decoded sub-device telemetry rows are written.
Constraints and rules
- One snapshot = one moment. Do not pack readings from different timestamps into a single
devicesarray. If your PLC buffers data, publish one envelope per buffered sample with the correcttimestampfor each. rawis an unsigned 32-bit integer (0–4294967295). Do not send negative values or values above0xFFFFFFFF.valuesentries should be numeric. The schema also accepts numeric strings, booleans, andnull. Numeric strings are coerced, booleans become1or0, andnullis skipped. Prefer sending numbers and omit a key entirely when a reading is unavailable.- Do not send decoded signal names. Voke decodes server-side from the template. Sending
{"mainContactor": true}invaluesis ignored — userawwith the appropriate bitmask for binary signals. - Nonce must be unique per message. Generate at least 8 hex characters (64 bits of entropy) per publish. Voke tracks nonces in Redis with a 10-minute TTL to reject replays.
typevalues are uppercase. Use"CABINET","METER","INVERTER", or"BATTERY"— not lowercase variants.
Solinteg compatibility decoder
Some Solinteg X3-Hybrid plants still publish a legacy flat JSON payload instead of a native devices[] snapshot. Voke detects those frames and converts them into a synthetic snapshot before calling the same sub-device ingest path:
DevType: 11001inverter frame →PV1,INV1,BACKUP,BAT1DevType: 13001grid-meter frame →GRID- legacy all-in-one frame with both PV and battery keys →
PV1,INV1,GRID,BACKUP,BAT1
This bridge is a compatibility layer for existing PLC firmware. New PLC integrations should publish the native snapshot envelope directly.
Cross-links
- Sub-device contract — how DeviceTemplate signals map to
rawbits andvaluesfields - HMAC signing — canonical signing algorithm and known-value tests
- Concepts / Signals — signal kinds, stores, and the
featuredflag