Quick start
Publish your first PLC snapshot and see decoded telemetry land in Voke in ~15 minutes.
This guide walks you through connecting a plant to Voke over the PASSWORD listener (port 8885). It is the fastest path to working telemetry because it needs no certificates or JWT keys — just a username and password provisioned via Voke admin.
By the end you will:
- Publish a signed snapshot envelope with two sub-devices.
- Verify decoded readings in the admin SPA and via REST.
- Subscribe to commands and send ACKs.
Prerequisites
- A running Voke instance — either the cloud deployment or
bun run docker:uplocally. - A plant row created in Voke admin with at least one sub-device that has a
DeviceTemplateattached. See Plants and Device templates if you need to set these up first. - Python 3.10+.
pip install paho-mqttStep 1: Choose an auth method
Three listeners are available. This quick-start uses PASSWORD for simplicity.
| Method | Best for |
|---|---|
| PASSWORD | Local dev, industrial controllers on a trusted LAN |
| JWT | Managed devices with pre-provisioned key pairs |
| mTLS | Production — strongest identity guarantee |
See Auth methods for a full comparison.
Step 2: Provision a credential
Password credentials for devices are managed by Voke, not by hand. The admin SPA
writes each plant's username / password pair into the Mosquitto password file via
MqttPasswordService.
Option A — Admin SPA
- Open Voke admin → Plants → select your plant.
- Go to the Security tab.
- Generate (or set) a PASSWORD credential. Copy the password — it is shown only once.
Option B — REST API
# Provision the PASSWORD credential for a plant. Returns a JSON body with
# the generated username (plc-<first 8 hex>) + password — shown once.
curl -X POST https://api.voke.lovinka.com/api/v1/plants/{plantId}/provision \
-H "Content-Type: application/json" \
-H "Cookie: <your session cookie>" \
-d '{"method": "PASSWORD"}'Note your plantId (UUID) and the generated password. You will use both below.
Step 3: Copy the HMAC helper
Save the following as hmac_sign.py in your working directory. It mirrors
packages/shared/src/utils/hmac.ts byte-for-byte so signatures computed here
will pass Voke's server-side verification.
# hmac_sign.py
"""Canonical HMAC-SHA256 helpers for PLC message signing.
Mirrors packages/shared/src/utils/hmac.ts exactly. If you change one side,
change both and re-check the known-value tests.
Key facts (verified against the TypeScript source):
- Separator: "|" (pipe, U+007C)
- Telemetry: deviceId | ts | nonce | canonical_json_stringify(data)
- Command: deviceId | cmdId | ts | action | canonical_json_stringify(payload)
- Ack: deviceId | cmdId | ts | status | nonce
- Alarm: deviceId | ts | nonce | ev | alarmId | code | sev
The telemetry payload is the message body dict with ts/n/sig stripped out,
serialised with sorted keys (recursive) and no spaces — matching
canonicalJsonStringify() in hmac.ts.
Dependencies: Python 3.11+ standard library only.
"""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import Any
# ─── Canonical JSON ───────────────────────────────────────────────────────────
def canonical_json_stringify(obj: Any) -> str: # noqa: ANN401
"""Recursively serialise *obj* with sorted keys and no spaces."""
if obj is None:
return "null"
if isinstance(obj, bool):
return "true" if obj else "false"
if isinstance(obj, (int, float)):
return json.dumps(obj)
if isinstance(obj, str):
return json.dumps(obj)
if isinstance(obj, list):
return "[" + ",".join(canonical_json_stringify(item) for item in obj) + "]"
if isinstance(obj, dict):
sorted_keys = sorted(obj.keys())
parts = [
json.dumps(k) + ":" + canonical_json_stringify(obj[k])
for k in sorted_keys
]
return "{" + ",".join(parts) + "}"
return json.dumps(obj)
# ─── Core primitives ─────────────────────────────────────────────────────────
_SEP = "|"
def compute_signature(parts: list[str], secret: str) -> str:
message = _SEP.join(parts).encode("utf-8")
return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
def verify_signature(parts: list[str], secret: str, signature: str) -> bool:
expected = compute_signature(parts, secret)
if len(expected) != len(signature):
return False
return hmac.compare_digest(expected, signature)
# ─── Domain signers ───────────────────────────────────────────────────────────
def sign_telemetry(device_id, ts, nonce, data, secret):
"""Sign: deviceId | ts | nonce | canonicalJsonStringify(data)"""
return compute_signature(
[device_id, str(ts), nonce, canonical_json_stringify(data)],
secret,
)
def sign_command_ack(device_id, cmd_id, ts, status, nonce, secret):
"""Sign: deviceId | cmdId | ts | status | nonce"""
return compute_signature([device_id, cmd_id, str(ts), status, nonce], secret)The full version with all domain signers and a self-test is in
apps/docs/content/examples/plc/hmac_sign.py in the repository.
Step 4: Connect with PASSWORD auth
All Mosquitto listeners use server-side TLS. For local dev, point CA_CERT at the
Voke CA certificate generated by scripts/pki/. For the cloud deployment use the
system trust store (client.tls_set() with no arguments).
# quick_connect.py
import paho.mqtt.client as mqtt
# ── Configuration ──────────────────────────────────────────────────────────────
HOST = "mqtt.voke.lovinka.com" # or "localhost" for local dev
PORT = 8885 # PASSWORD listener — same port prod and dev
PLANT_ID = "your-plant-uuid" # UUID from Voke admin
MQTT_PASSWORD = "your-password" # generated in Step 2
CA_CERT = None # set to "/path/to/ca.crt" for local dev
# ── Client setup ──────────────────────────────────────────────────────────────
client = mqtt.Client(client_id=PLANT_ID, protocol=mqtt.MQTTv5)
client.username_pw_set(PLANT_ID, MQTT_PASSWORD)
if CA_CERT:
# Local dev: trust the Voke CA explicitly
client.tls_set(ca_certs=CA_CERT)
else:
# Production: use system trust store
client.tls_set()
client.connect(HOST, PORT, keepalive=60)Step 5: Publish a snapshot
Save this as publish_snapshot.py alongside hmac_sign.py. Set PLANT_ID,
MQTT_PASSWORD, and HMAC_SECRET to match your plant.
# publish_snapshot.py
import json
import secrets
import time
import paho.mqtt.client as mqtt
from hmac_sign import sign_telemetry
PLANT_ID = "REPLACE-WITH-YOUR-PLANT-ID"
TELEMETRY_TOPIC = f"cpi/{PLANT_ID}/telemetry"
# HMAC secret provisioned via Voke admin → Plant → Security.
# PASSWORD devices may omit signing if your ACL policy permits;
# mTLS and JWT devices must sign.
HMAC_SECRET = "REPLACE-WITH-YOUR-DEVICE-SECRET"
def build_snapshot_body() -> dict:
"""Build the inner payload — the part that gets HMAC-signed.
Keys inside ``devices`` entries must match your DeviceTemplate:
- Cabinet ``raw``: unsigned 32-bit int; each bit maps to a binary signal.
- Meter/inverter/battery ``values``: field names mapped to numeric signals via template.field.
externalId conventions from seed data:
Cabinets → R1, R2, … (binary signals)
Meters → GRID, E1, … (numeric signals)
Inverters → INV1, PV1, M1, … (numeric signals — match whatever is enrolled)
Batteries → BAT1, … (numeric signals)
"""
return {
# ISO-8601 timestamp (optional — Voke falls back to the envelope ``ts``).
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"devices": [
{
# Cabinet R1: bits 0 and 2 are high → 0b00000101 = 5
"externalId": "R1",
"type": "CABINET",
"raw": 5,
},
{
# Meter M1: field names must match template signal.field
"externalId": "M1",
"type": "METER",
"values": {
"activePowerKw": 12.4,
"voltageV": 231.7,
},
},
{
# Battery BAT1: same numeric values contract
"externalId": "BAT1",
"type": "BATTERY",
"values": {
"stateOfChargePct": 61.2,
"batteryPowerW": -820,
},
},
],
}
def publish_snapshot(client: mqtt.Client) -> None:
ts = int(time.time() * 1000) # Unix milliseconds
nonce = secrets.token_hex(8) # 16 hex chars; must be unique per message
body = build_snapshot_body()
sig = sign_telemetry(PLANT_ID, ts, nonce, body, HMAC_SECRET)
# Final envelope: flat object — envelope fields + body fields merged.
message = {"ts": ts, "n": nonce, "sig": sig, **body}
payload = json.dumps(message, separators=(",", ":"))
client.publish(TELEMETRY_TOPIC, payload, qos=1)
print(f"[snapshot] published: {json.dumps(message, indent=2)}")
def on_connect(client, _userdata, _flags, rc, _props=None):
if rc == 0:
print("[connect] connected")
publish_snapshot(client)
else:
print(f"[connect] failed: rc={rc}")
if __name__ == "__main__":
client = mqtt.Client(client_id=PLANT_ID, protocol=mqtt.MQTTv5)
client.username_pw_set(PLANT_ID, MQTT_PASSWORD)
client.tls_set() # or tls_set(ca_certs="/path/to/ca.crt") for local dev
client.on_connect = on_connect
client.connect("mqtt.voke.lovinka.com", 8885, keepalive=60)
client.loop_forever()What the envelope looks like on the wire
{
"ts": 1713540000000,
"n": "0a1b2c3d4e5f6789",
"sig": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
"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}}
]
}The envelope is flat — ts, n, sig, timestamp, and devices are all top-level
keys. There is no nested payload wrapper.
ts is Unix milliseconds. n is a random nonce (at least 8 hex chars). sig is the
HMAC-SHA256 hex digest of deviceId | ts | nonce | canonicalJsonStringify(body) where
body is everything except ts, n, and sig.
Step 6: Verify the data landed
Admin SPA
- Open Voke admin → Plants → select your plant.
- Click the Devices tab.
- Sub-devices
R1andM1auto-register on first snapshot if theirexternalIdvalues appear in aDeviceTemplateattached to the plant. - Signal cards display the decoded readings from the most recent snapshot.
REST API
# Query sub-device telemetry for the last hour
curl "https://api.voke.lovinka.com/api/v1/telemetry/sub-device\
?plantId=YOUR_PLANT_ID\
&subDeviceExternalId=M1\
&metric=activePowerKw\
&from=2026-04-19T13:00:00Z\
&to=2026-04-19T14:00:00Z" \
-H "Cookie: <your session cookie>"Step 7: Subscribe to commands
Save this as subscribe_commands.py. Run it in a separate terminal while your plant
is connected — Voke will push commands here when triggered from the admin SPA or
POST /api/v1/commands.
# subscribe_commands.py
import json
import secrets
import time
from typing import Any
import paho.mqtt.client as mqtt
from hmac_sign import sign_command_ack, verify_signature, canonical_json_stringify
PLANT_ID = "REPLACE-WITH-YOUR-PLANT-ID"
COMMAND_TOPIC = f"cpi/{PLANT_ID}/command"
ACK_TOPIC = f"cpi/{PLANT_ID}/ack"
HMAC_SECRET = "REPLACE-WITH-YOUR-DEVICE-SECRET"
def verify_command_sig(cmd: dict[str, Any]) -> bool:
"""Verify Voke's signature on an inbound PlcCommand.
Part order: deviceId | cmdId | ts | action | canonicalJsonStringify(payload)
"""
sig = cmd.get("sig", "")
if not sig:
return False
parts = [
PLANT_ID,
str(cmd["cmdId"]),
str(cmd["ts"]),
str(cmd["type"]),
canonical_json_stringify(cmd.get("p", {})),
]
return verify_signature(parts, HMAC_SECRET, sig)
def send_ack(client, cmd_id, status, *, err=None, msg=None):
ts = int(time.time() * 1000)
nonce = secrets.token_hex(8)
sig = sign_command_ack(PLANT_ID, cmd_id, ts, status, nonce, HMAC_SECRET)
ack: dict[str, Any] = {"cmdId": cmd_id, "st": status, "ts": ts, "n": nonce, "sig": sig}
if err:
ack["err"] = err
if msg:
ack["msg"] = msg
client.publish(ACK_TOPIC, json.dumps(ack, separators=(",", ":")), qos=1)
print(f"[cmd] ACK sent: cmdId={cmd_id} st={status}")
def on_message(client, _userdata, msg):
try:
cmd = json.loads(msg.payload.decode())
except json.JSONDecodeError:
print(f"[cmd] malformed payload on {msg.topic}")
return
cmd_id = cmd.get("cmdId")
cmd_type = cmd.get("type")
print(f"[cmd] received: cmdId={cmd_id} type={cmd_type}")
if not cmd_id or not cmd_type:
return
if HMAC_SECRET and not verify_command_sig(cmd):
print(f"[cmd] invalid signature on cmdId={cmd_id} — discarding")
return
# Acknowledge receipt immediately.
send_ack(client, cmd_id, "RECEIVED")
# Replace with real hardware dispatch; send COMPLETED or FAILED when done.
send_ack(client, cmd_id, "COMPLETED")
def on_connect(client, _userdata, _flags, rc, _props=None):
if rc == 0:
client.subscribe(COMMAND_TOPIC, qos=1)
client.on_message = on_message
print(f"[cmd] subscribed to {COMMAND_TOPIC}")
if __name__ == "__main__":
MQTT_PASSWORD = "REPLACE-WITH-YOUR-MQTT-PASSWORD"
client = mqtt.Client(client_id=PLANT_ID, protocol=mqtt.MQTTv5)
client.username_pw_set(PLANT_ID, MQTT_PASSWORD)
client.tls_set()
client.on_connect = on_connect
client.connect("mqtt.voke.lovinka.com", 8885, keepalive=60)
client.loop_forever()Send a test command from the admin SPA
- Voke admin → Plants → your plant → Commands tab.
- Select a command type (e.g.
GET_INFO) and submit. - Watch the terminal running
subscribe_commands.py— it prints the received command and the ACKs it sends back.
Next steps
- Auth methods — set up mTLS or JWT for production.
- Snapshot envelope — full field reference, optional fields, and signing rules.
- Sub-device contract — how bitmasks and field dicts map to DeviceTemplate signals.