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

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:

  1. Parse the JSON payload.
  2. Extract sig and set it aside — do not include it in the signed data.
  3. Compute canonicalJsonStringify(cmd.p): sort object keys lexicographically at every nesting level, no whitespace between tokens.
  4. Assemble the part list: [plantId, cmd.cmdId, str(cmd.ts), cmd.type, canonical_p].
  5. Join with | (pipe, U+007C) — byte-exact, no padding.
  6. Compute HMAC-SHA256 over the joined string using the plant's provisioned secret; hex-encode the digest.
  7. Compare against cmd.sig using a constant-time comparison (prevents timing side channels).
  8. 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:

  1. ACK receipt immediately — send a RECEIVED ACK so Voke knows the command landed.
  2. Dispatch to hardware — translate type and p to the appropriate hardware call.
  3. ACK completion — send COMPLETED when the operation finishes, or FAILED if it does not.
    For long-running commands (CHARGE, SCHEDULE) you may send periodic IN_PROGRESS ACKs while the operation is underway.
  4. Keep cmdId — every ACK for the same command must carry the same cmdId.

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 | nonce

Full 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

SituationAction
Signature mismatch on inbound commandDrop 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 payloadACK FAILED with err: 'INVALID_PARAMS', explain in msg.
p parameters fail validationACK FAILED with err: 'INVALID_PARAMS'.
Hardware fault during executionACK FAILED with the most specific PlcAckErrorCode.
Safety interlock triggeredACK FAILED with err: 'SAFETY_OVERRIDE', add detail in msg.
Operation still running at timeoutSend 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}/commands

The 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

On this page