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/:idwith{ "canSendCommands": true }; a caller without the permission, or a target that is not ROOT, gets403 commands_forbidden(a caller who cannot edit that user at all gets403 forbiddenfirst). 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 astype.commands_updated.
In the demo data, root@linter.test holds the permission.
What the device receives
| Property | Value |
|---|---|
| Topic | commands/<deviceId> (the MAC, lowercase, without separators) |
| QoS | 1 (at least once) |
| Retain | No |
| Payload | Exactly 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" }| Field | Meaning |
|---|---|
commandId | The 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 field | Optional. 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
| Status | In the web | Meaning |
|---|---|---|
PENDING | Queued | Waiting for the ingest. It can still be cancelled. |
SENDING | Sending | Taken by the ingest and being published. |
SENT | Sent | The broker accepted it (PUBACK). Waiting for the device’s ack. |
ACKED | Confirmed | The device answered "status": "ok". |
REJECTED | Rejected by the device | The device answered "status": "error". |
FAILED | Failed | It could not be published (see below). |
EXPIRED | Expired | It was still queued when its expiry time passed. It was never published. |
CANCELLED | Cancelled | A 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
ttlSecondsafter 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:
| Field | Meaning |
|---|---|
name, description | Per language; English is required. |
format | json or text. |
payload | The packet with placeholders, up to 16 KB. A json template must be valid JSON. |
params | Up to 20 parameters (below). |
ttlSeconds | Expiry, 30 to 86400 (default 600). |
enabled | Disabled 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:
| Placeholder | Value |
|---|---|
{{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 becomesnull). 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.
| Endpoint | Use |
|---|---|
GET /v1/devices/:id/command-templates | Enabled templates of the device’s type: { templates } |
POST /v1/devices/:id/commands/preview | Render the exact packet without queuing it: { topic, format, payload, bytes }. The id appears as <commandId>. |
POST /v1/devices/:id/commands | Queue the command: 201 with the command |
GET /v1/devices/:id/commands?limit=20&offset=0 | History, newest first: { items, total } (limit up to 100) |
POST /v1/devices/:id/commands/:commandId/cancel | Cancel a command that is still queued |
PUT /v1/device-models/:id/command-templates | Replace 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.
# Templatecurl -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 packetcurl -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).
| Status | code | When |
|---|---|---|
403 | commands_forbidden | No permission, not ROOT, or an API token |
404 | not_found | Unknown device or command |
409 | command_not_cancellable | Cancelling a command that is no longer queued |
422 | command_template_not_found | Unknown or disabled template |
422 | command_param_invalid | A parameter is missing, of the wrong type, out of range or not one of its options |
422 | command_payload_invalid | Invalid JSON, unknown placeholder, empty packet, or a free packet without format |
422 | command_payload_too_large | The rendered packet is over 16 KB |
422 | command_template_invalid | PUT of templates: the first problem is in detail |
429 | commands_queue_full | 20 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):
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' -vConfirm one from another terminal (another client id):
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 mqttfrom paho.mqtt.enums import CallbackAPIVersion
BROKER = "mqtt.linter-iot-stg.icent.digital"MAC = "a4cf12bb73d2" # deviceId: lowercase MAC without separatorsCOMMANDS = 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_connectclient.on_message = on_messageclient.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,commandIdincluded; - publish
{"commandId": "…", "status": "ok"}on another device’scommands/<deviceId>/ackand 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-ingestatopic read telemetry/#topic read commands/+/acktopic write commands/#
# Every device: only its own topics (CN = MAC)pattern write telemetry/+/%upattern read commands/%upattern write commands/%u/ackUse 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
| Variable | Default | Meaning |
|---|---|---|
COMMANDS_ENABLED | true | false stops publishing commands; queued commands neither go out nor expire until it is re-enabled (acks are still recorded). |
COMMANDS_POLL_SECONDS | 2 | How often the queue is checked. |
COMMAND_PUBLISH_TIMEOUT | 10 | Seconds 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).