Commands (receipt)
How PLC devices receive signed commands from Voke, verify them, execute, and acknowledge — including failure modes and target routing.
Voke publishes a signed PlcCommand envelope to cpi/{plantId}/command whenever an operator or automated rule dispatches a command to a plant. The PLC verifies the signature, executes the requested operation, and publishes a signed PlcAck to cpi/{plantId}/ack so Voke can track completion.
Commands are not retained. A plant that is offline at publish time will miss the message. After reconnecting, poll GET /api/v1/plants/{plantId}/commands to discover commands that arrived while offline.
Command envelope
Voke sends a compact JSON object with every command:
// packages/shared/src/types/plc-protocol.ts
interface PlcCommand {
cmdId: string; // server-assigned UUID — correlation key for the ACK
ts: number; // server publish time, Unix milliseconds (13 digits)
type: PlcCommandType; // e.g. 'CHARGE', 'DISCHARGE', 'SET_SOC_LIMITS', …
p: Record<string, unknown>; // command payload — shape depends on type
sig: string; // HMAC-SHA256 hex — Voke signs with the plant's secret
}PlcCommandType includes: SET_OVERFLOW, CHARGE, DISCHARGE, HOLD, CHARGE_ONLY, DISCHARGE_ONLY, CONTINUOUS_CHARGE, DEFAULT, SET_SOC_LIMITS, CANCEL_ALL, SCHEDULE, SET_TELEMETRY_INTERVAL, SET_DEFAULTS, GET_INFO.
All field names are intentionally compact. p is the payload, sig is the signature. Field widths are kept small because PLCs typically operate under an 8 KB MQTT message limit.
Verifying the command signature
Voke signs every command with the plant's HMAC secret so PLCs can detect broker-injected or replayed messages.
Part order (from computeCommandSignature in packages/shared/src/utils/hmac.ts):
deviceId | cmdId | ts | action | canonicalJsonStringify(p)The parameter Voke passes in position 4 is called action in the signing helper — it maps to cmd.type in the envelope.
Verification steps:
- Parse the JSON payload.
- Extract
sigand set it aside — do not include it in the signed data. - Compute
canonicalJsonStringify(cmd.p): sort object keys lexicographically at every nesting level, no whitespace between tokens. - Assemble the part list:
[plantId, cmd.cmdId, str(cmd.ts), cmd.type, canonical_p]. - Join with
|(pipe, U+007C) — byte-exact, no padding. - Compute HMAC-SHA256 over the joined string using the plant's provisioned secret; hex-encode the digest.
- Compare against
cmd.sigusing a constant-time comparison (prevents timing side channels). - If the comparison fails, drop the message silently — do not execute, do not ACK. Voke will surface the miss via command-log timeout monitoring.
# From apps/docs/content/examples/plc/subscribe_commands.py
def verify_command_sig(cmd: dict[str, Any]) -> bool:
"""Verify Voke's signature on an inbound PlcCommand.
Part order (from computeCommandSignature in hmac.ts):
deviceId | cmdId | ts | action | canonicalJsonStringify(payload)
"""
sig = cmd.get("sig", "")
if not sig:
return False # unsigned — reject or allow per your security policy
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)For mTLS devices on a private network segment, signature verification is optional — the mutual TLS handshake already authenticates both parties. For PASSWORD or JWT listener devices on shared infrastructure, always verify before executing.
Executing the command
After successful verification:
- ACK receipt immediately — send a
RECEIVEDACK so Voke knows the command landed. - Dispatch to hardware — translate
typeandpto the appropriate hardware call. - ACK completion — send
COMPLETEDwhen the operation finishes, orFAILEDif it does not.
For long-running commands (CHARGE,SCHEDULE) you may send periodicIN_PROGRESSACKs while the operation is underway. - Keep
cmdId— every ACK for the same command must carry the samecmdId.
Publishing the ACK
Publish to cpi/{plantId}/ack with QoS 1:
// packages/shared/src/types/plc-protocol.ts
interface PlcAck {
cmdId: string; // same UUID as the command being acknowledged
st: PlcAckStatus; // RECEIVED | IN_PROGRESS | COMPLETED | FAILED
ts: number; // ACK publish time, Unix milliseconds
n: string; // nonce — 8+ hex chars, unique per ACK message
err?: PlcAckErrorCode; // present when st === 'FAILED'
msg?: string; // optional human-readable detail
sig?: string; // HMAC-SHA256 hex (recommended for PASSWORD/JWT devices)
}
type PlcAckStatus = 'RECEIVED' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
type PlcAckErrorCode =
| 'INVALID_TYPE'
| 'INVALID_PARAMS'
| 'NOT_SUPPORTED'
| 'BATTERY_UNAVAILABLE'
| 'INVERTER_FAULT'
| 'SOC_LIMIT_REACHED'
| 'POWER_LIMIT_EXCEEDED'
| 'TIMEOUT'
| 'SAFETY_OVERRIDE'
| 'INTERNAL_ERROR';ACK signature part order (from computeAckSignature in hmac.ts):
deviceId | cmdId | ts | status | nonceFull ACK sender from subscribe_commands.py:
def send_ack(
client: mqtt.Client,
cmd_id: str,
status: str,
*,
err: str | None = None,
msg: str | None = None,
) -> None:
"""Publish a signed ACK for the given command ID.
*status* must be one of: RECEIVED | IN_PROGRESS | COMPLETED | FAILED
*err* must be a PlcAckErrorCode string when status is FAILED.
"""
ts = int(time.time() * 1000) # Unix milliseconds
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 is not None:
ack["err"] = err
if msg is not None:
ack["msg"] = msg
client.publish(ACK_TOPIC, json.dumps(ack, separators=(",", ":")), qos=1)Sub-device routing
Some commands carry an optional target sub-device, identified in the payload by the operator-assigned externalId (e.g. "R1", "M1"). Check cmd.p for a target key:
target = cmd.get("p", {}).get("target")
if target:
route_to_sub_device(target, cmd["type"], cmd["p"])
else:
route_to_plant(cmd["type"], cmd["p"])If the firmware does not recognise the externalId, ACK with FAILED and include INVALID_PARAMS as the error code:
send_ack(client, cmd_id, "FAILED", err="INVALID_PARAMS",
msg=f"Unknown sub-device target: {target}")Failure modes
| Situation | Action |
|---|---|
| Signature mismatch on inbound command | Drop silently — no ACK, no execution. Voke surfaces the miss via command-log. |
Unknown type (not in PlcCommandType) | ACK FAILED with err: 'INVALID_TYPE'. |
| Unknown sub-device target in payload | ACK FAILED with err: 'INVALID_PARAMS', explain in msg. |
p parameters fail validation | ACK FAILED with err: 'INVALID_PARAMS'. |
| Hardware fault during execution | ACK FAILED with the most specific PlcAckErrorCode. |
| Safety interlock triggered | ACK FAILED with err: 'SAFETY_OVERRIDE', add detail in msg. |
| Operation still running at timeout | Send IN_PROGRESS periodically; eventually COMPLETED or FAILED. |
Never leave a command unacknowledged indefinitely. Voke marks commands as timed-out after a configurable window. If your firmware restarts mid-execution, re-read command history via REST on reconnect and send the appropriate terminal ACK.
Catching up after offline periods
Commands are published once to MQTT and are not retained. After reconnecting:
GET /api/v1/plants/{plantId}/commandsThe response is a paginated list of CommandLog entries. Filter for status SENT or IN_PROGRESS — these are commands that landed without a terminal ACK and likely missed the plant.
See also
- HMAC signing — canonical part orders and Python helper
- Topics — full MQTT topic reference
Sub-device contract
How PLCs derive snapshot payloads from DeviceTemplate signal definitions — binary bitmask mapping and numeric field mapping with worked examples.
Alarms upstream
How PLC devices raise and resolve alarms on the cpi/{plantId}/alarm topic — envelope shape, RAISE/RESOLVE lifecycle, severity levels, and HMAC signing.