This is a development version of the documentation. Content may change without notice.
Voke Documentation
PLC / Devices

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

FieldPurposeUsed by
tsPublish time (Unix ms)Replay-window check; nonce deduplication TTL
timestampSample/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, sig

HMAC parts, joined with |:

plantId | ts | nonce | canonicalJsonStringify(stripped_body)

Where:

  • plantId is the plant's UUID (same as {plantId} in the topic).
  • ts is the numeric millisecond value as a string (e.g. "1713540000000").
  • nonce is the value of the n field.
  • canonicalJsonStringify recursively 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 devices array. If your PLC buffers data, publish one envelope per buffered sample with the correct timestamp for each.
  • raw is an unsigned 32-bit integer (04294967295). Do not send negative values or values above 0xFFFFFFFF.
  • values entries should be numeric. The schema also accepts numeric strings, booleans, and null. Numeric strings are coerced, booleans become 1 or 0, and null is 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} in values is ignored — use raw with 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.
  • type values 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: 11001 inverter frame → PV1, INV1, BACKUP, BAT1
  • DevType: 13001 grid-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.


On this page