Skip to content

Device commands

A ROOT user with the commands permission can send a packet to a device from the web application. The API queues the command, the ingest service publishes it on the device’s command topic, and the device confirms it on its ack topic:

web ──► REST API ──► command queue (MongoDB) ──► ingest ──► commands/<deviceId> ──► device
▲ │
└──────── commands/<deviceId>/ack ◄┘

Who can send commands

  • Only ROOT users with the Can send commands to devices permission. It is off by default, and it is read from the database on every request, so revoking it takes effect at once.
  • The permission is granted under Users and permissions (edit the user), by a ROOT who already holds it and only to ROOT users. Through the API it is PATCH /v1/users/:id with { "canSendCommands": true }; a caller without the permission, or a target that is not ROOT, gets 403 commands_forbidden (a caller who cannot edit that user at all gets 403 forbidden first). Likewise, only a holder can change another holder’s email, password or status.
  • API tokens (lit_…) never send commands, even when their owner has the permission: commands need a signed-in session.
  • Every send and cancellation is recorded in the audit log (device.command_sent, device.command_cancelled), and template changes as type.commands_updated.

In the demo data, root@linter.test holds the permission.

What the device receives

PropertyValue
Topiccommands/<deviceId> (the MAC, lowercase, without separators)
QoS1 (at least once)
RetainNo
PayloadExactly the packet shown in the preview, UTF-8, up to 16 KB. JSON is sent compact; text as written. The platform adds no envelope.

Subscribe to the topic with QoS 1, a fixed client id and a persistent session (clean session off): the broker then keeps the commands published while the device is briefly offline and delivers them when it reconnects.

QoS 1 can deliver the same packet more than once. Deduplicate by commandId: keep the ids of the last commands handled and, when one comes back, do not run it again (answer with the same reply).

Confirming a command

The device answers on commands/<deviceId>/ack (QoS 1) with a JSON object:

{ "commandId": "6718a0c2e4b0a1b2c3d4e5f6", "status": "ok" }
{ "commandId": "6718a0c2e4b0a1b2c3d4e5f6", "status": "error", "message": "Valve locked" }
FieldMeaning
commandIdThe 24-hex identifier the command carried (see placeholders). Required.
status"ok" (or absent) → Confirmed. "error" → Rejected by the device. Any other value is ignored.
message and any other fieldOptional. The whole object is stored as the device’s reply (up to 4 KB; a longer reply is kept as truncated text, and one over 64 KB is ignored) and shown in the command history.
  • An ack is only applied on the topic of the device the command was sent to, and only while the command is Sending or Sent. Acks for unknown ids, for another device, or for a command that is already closed are ignored, so repeating an ack is harmless.
  • Without an ack, the command stays Sent.

Statuses

StatusIn the webMeaning
PENDINGQueuedWaiting for the ingest. It can still be cancelled.
SENDINGSendingTaken by the ingest and being published.
SENTSentThe broker accepted it (PUBACK). Waiting for the device’s ack.
ACKEDConfirmedThe device answered "status": "ok".
REJECTEDRejected by the deviceThe device answered "status": "error".
FAILEDFailedIt could not be published (see below).
EXPIREDExpiredIt was still queued when its expiry time passed. It was never published.
CANCELLEDCancelledA user cancelled it while it was queued.

Sent means the broker has the packet, not that the device ran it: only an ack moves a command to Confirmed or Rejected.

Delivery rules

  • The ingest checks the queue every 2 seconds and publishes the oldest commands first, one at a time.
  • Expiry: each command expires ttlSeconds after it is created (the template’s value, 30 to 86400; default 600, that is 10 minutes). Expiry only applies while the command is queued; once published it does not expire. The ingest applies it on each pass, before publishing: while it is stopped or disconnected from the broker, queued commands wait, and the ones whose time has passed end as Expired on its next pass instead of going out.
  • When publishing fails because of the broker connection, the command goes back to the queue and is retried, up to 3 attempts; then it ends as Failed.
  • A command whose publication was interrupted (the connection dropped half way) ends as Failed and is not resent, because a command does not have to be idempotent.
  • A device can have at most 20 commands queued or being sent; more requests get 429 commands_queue_full.

Packets and templates

Free packet

A JSON or text packet written by hand when sending. It can only use the built-in placeholders.

Templates per device type

Predefined commands are configured per device type, under Device types › (type) › Commands, by a ROOT holding the permission. Each template has:

FieldMeaning
name, descriptionPer language; English is required.
formatjson or text.
payloadThe packet with placeholders, up to 16 KB. A json template must be valid JSON.
paramsUp to 20 parameters (below).
ttlSecondsExpiry, 30 to 86400 (default 600).
enabledDisabled templates are not offered.

A type can have up to 30 templates. Each parameter has a key ([A-Za-z_][A-Za-z0-9_]{0,39}, unique, not a built-in name), a label per language, a type (string, number with optional min/max, boolean, or select with 1 to 50 options), and optionally required and a default.

Placeholders

{{name}} is replaced when the command is created:

PlaceholderValue
{{commandId}}The command’s id: the one the device must send back in its ack.
{{deviceId}}The device’s MAC (a4cf12bb73d2).
{{timestamp}}Creation time, ISO 8601 in UTC (2026-10-25T09:30:00.000Z).
{{<param key>}}The value of that template parameter.

Include {{commandId}} in every packet that the device should confirm; the usual place in JSON is a "commandId" field.

  • json: placeholders go inside JSON strings. A string that is exactly "{{key}}" becomes the typed value (numbers and booleans without quotes; an optional parameter without a value becomes null). Inside a longer string the value is inserted as text. Integers beyond 2^53 are rejected.
  • text: plain substitution; an optional parameter without a value becomes an empty string.

For example, with speed = 40 (number) and boost = false (boolean), this template:

{
"commandId": "{{commandId}}",
"action": "set_speed",
"speed": "{{speed}}",
"boost": "{{boost}}",
"note": "sent at {{timestamp}}"
}

is published as:

{"commandId":"6718a0c2e4b0a1b2c3d4e5f6","action":"set_speed","speed":40,"boost":false,"note":"sent at 2026-10-25T09:30:00.000Z"}

The same command as a text template, SPEED {{speed}} ID {{commandId}}, is published as SPEED 40 ID 6718a0c2e4b0a1b2c3d4e5f6.

API

All routes are under /v1 and need the session of a ROOT user with the permission (Authorization: Bearer eyJ…, from POST /v1/auth/login). :id is the device’s _id as returned by GET /v1/devices, not its MAC.

EndpointUse
GET /v1/devices/:id/command-templatesEnabled templates of the device’s type: { templates }
POST /v1/devices/:id/commands/previewRender the exact packet without queuing it: { topic, format, payload, bytes }. The id appears as <commandId>.
POST /v1/devices/:id/commandsQueue the command: 201 with the command
GET /v1/devices/:id/commands?limit=20&offset=0History, newest first: { items, total } (limit up to 100)
POST /v1/devices/:id/commands/:commandId/cancelCancel a command that is still queued
PUT /v1/device-models/:id/command-templatesReplace the templates of a type: { commandTemplates: [...] }

The preview and the send take the same body: templateId and values for a template, or format and payload for a free packet. ttlSeconds (30 to 86400) overrides the expiry.

Terminal window
# Template
curl -X POST https://api.linter-iot-stg.icent.digital/v1/devices/<id>/commands \
-H "Authorization: Bearer eyJ…" -H "Content-Type: application/json" \
-d '{ "templateId": "set-speed", "values": { "speed": 40, "boost": false } }'
# Free packet
curl -X POST https://api.linter-iot-stg.icent.digital/v1/devices/<id>/commands \
-H "Authorization: Bearer eyJ…" -H "Content-Type: application/json" \
-d '{ "format": "json", "payload": "{\"commandId\":\"{{commandId}}\",\"action\":\"reboot\"}" }'

Each command in the history carries status, payload (exactly what was published), topic, attempts, createdAt, expiresAt, and when they apply sentAt, ackAt, ack (the device’s reply), error, and who sent it.

Errors

The error/code envelope is described in Errors and limits. Validation errors of a command also carry field (the parameter key, templateId, format or payload).

StatuscodeWhen
403commands_forbiddenNo permission, not ROOT, or an API token
404not_foundUnknown device or command
409command_not_cancellableCancelling a command that is no longer queued
422command_template_not_foundUnknown or disabled template
422command_param_invalidA parameter is missing, of the wrong type, out of range or not one of its options
422command_payload_invalidInvalid JSON, unknown placeholder, empty packet, or a free packet without format
422command_payload_too_largeThe rendered packet is over 16 KB
422command_template_invalidPUT of templates: the first problem is in detail
429commands_queue_full20 commands already queued for the device

Testing from the device side

Wait for commands with mosquitto_sub (here with the staging bundle; -c keeps a persistent session, which needs a fixed -i):

Terminal window
mosquitto_sub -h mqtt.linter-iot-stg.icent.digital -p 8883 \
--cafile ca.crt --cert linter-device-001.crt --key linter-device-001.key \
-i a4cf12bb73d2 -c -q 1 -t 'commands/a4cf12bb73d2' -v

Confirm one from another terminal (another client id):

Terminal window
mosquitto_pub -h mqtt.linter-iot-stg.icent.digital -p 8883 \
--cafile ca.crt --cert linter-device-001.crt --key linter-device-001.key \
-q 1 -t 'commands/a4cf12bb73d2/ack' \
-m '{"commandId":"6718a0c2e4b0a1b2c3d4e5f6","status":"ok"}'

A minimal device loop in Python with paho-mqtt 2.x, for JSON packets with a commandId field: it runs each command once, answers duplicates with the same reply, reports failures as "error" and ignores any other packet. It uses the same certificate files as the examples above:

import json
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
BROKER = "mqtt.linter-iot-stg.icent.digital"
MAC = "a4cf12bb73d2" # deviceId: lowercase MAC without separators
COMMANDS = f"commands/{MAC}"
ACKS = f"commands/{MAC}/ack"
# Replies already sent, by commandId: QoS 1 may deliver the same command twice.
replies: dict[str, dict] = {}
def run(command: dict) -> None:
"""Device-specific: apply the command or raise an exception."""
print("running", command)
def on_connect(client, userdata, flags, reason_code, properties):
client.subscribe(COMMANDS, qos=1)
def on_message(client, userdata, msg):
# An exception here would stop loop_forever(): skip packets that are not
# a JSON object with a commandId (a text template, for example).
try:
command = json.loads(msg.payload)
command_id = command["commandId"]
if not isinstance(command_id, str):
raise TypeError("commandId is not a string")
except (ValueError, KeyError, TypeError):
print("ignored", msg.payload)
return
reply = replies.get(command_id)
if reply is None:
try:
run(command)
reply = {"commandId": command_id, "status": "ok"}
except Exception as e:
reply = {"commandId": command_id, "status": "error", "message": str(e)}
replies[command_id] = reply
if len(replies) > 100:
replies.pop(next(iter(replies)))
client.publish(ACKS, json.dumps(reply), qos=1)
# Fixed client id + persistent session: the broker keeps the commands
# published while the device is offline.
client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=MAC, clean_session=False)
client.tls_set(
ca_certs="ca.crt", certfile="linter-device-001.crt", keyfile="linter-device-001.key"
)
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, 8883)
client.loop_forever()

The repository also includes a device simulator (tools/simulator) that answers every command; see Self-hosted with Docker.

Topic access in production

The broker authenticates every client by its certificate. Without a topic ACL, as in the development and staging config (infra/mosquitto/config/mosquitto.conf), it does not limit which topics a certificate may use, so any valid certificate can:

  • publish any packet on another device’s commands/<deviceId>: a command that never goes through the API, its permission check, its queue limit or the audit log;
  • subscribe to commands/# and read every command, commandId included;
  • publish {"commandId": "…", "status": "ok"} on another device’s commands/<deviceId>/ack and confirm or reject its command. The ingest only applies an ack to the device in its topic, but the sender chooses the topic, so that does not stop a forged ack.

Do not run production without an ACL. The broker of the Docker stack (stack.conf) already enables infra/mosquitto/config/acl; on any other Mosquitto, use the same rules. With use_identity_as_username true (already set) the certificate CN is the MQTT username, so issue each device certificate with its MAC as the CN, written as in the topics (lowercase hex, no separators):

# The ingest service (CN of its certificate)
user linter-ingesta
topic read telemetry/#
topic read commands/+/ack
topic write commands/#
# Every device: only its own topics (CN = MAC)
pattern write telemetry/+/%u
pattern read commands/%u
pattern write commands/%u/ack

Use the CN of the ingest’s own certificate in the user line (linter-ingesta in the Docker stack) and enable the file with acl_file in the broker config. Do not use the client id (%c) for this: clients choose it freely.

A test client that speaks for several devices, such as the simulator, does not match these patterns: the broker drops its messages and only logs them as denied. The stack’s file gives the simulator and the sample linter-device-local certificate their own user blocks with topic write telemetry/#, topic read commands/# and topic write commands/+/ack. They can read every command and answer for any device (but not send commands): keep such certificates for tests.

The stack’s broker also sets max_packet_size 262144: a client that sends a larger packet (telemetry, command or ack) is disconnected.

Mosquitto 2 warns at start when the ACL file is world-readable and says future versions will refuse it. On a native install, make it readable only by the broker’s user (owner mosquitto, mode 0600). In the Docker stack the file is mounted from the repository and must stay readable by the container’s mosquitto user, so that warning shows up in its log.

Ingest settings

VariableDefaultMeaning
COMMANDS_ENABLEDtruefalse stops publishing commands; queued commands neither go out nor expire until it is re-enabled (acks are still recorded).
COMMANDS_POLL_SECONDS2How often the queue is checked.
COMMAND_PUBLISH_TIMEOUT10Seconds to wait for the broker’s PUBACK.

The ingest’s GET /health reports commands_dispatcher: true while commands are being dispatched (or when they are disabled on purpose).