Appearance
Implement commands
Before you start
Complete Telemetry. Keep the same files and credentials. If you announced SECURITY_TRANSPORT_MTLS, install the device's certificate, key, and CA trust. Leave commands disabled during implementation.
MC sends commands through the bus link to the address you announced on the payload LAN (Connect, step 1). The bus must be adopted in your organization and reach MC over the bus link. A cloud bench bus may not carry commands to your payload; ask Urban Sky which bench to use for this step.
1. Define the command
Save as command-manifest.json:
json
{
"schemaVersion": 1,
"commands": [
{
"name": "sensor.set_sample_period",
"description": "Set the measurement interval in seconds. Read back the applied interval in telemetry.",
"confirm": true,
"params": [
{
"key": "seconds",
"label": "Sample period",
"type": "FIELD_TYPE_NUMBER",
"unit": "s",
"min": 1,
"max": 60,
"required": true
}
]
}
]
}Generate this manifest and your validation from one command registry. Missing required means required; only false allows omission. Enforce integer-only constraints in the device when needed.
confirm: true adds a UI confirmation. It does not replace authentication or hardware interlocks.
2. Implement and test the command server
Serve POST /commands at the announced address and port. Accept JSON, including when telemetry uses protobuf. MC sends:
json
{
"name": "sensor.set_sample_period",
"requestId": "68ca9d81-e40b-4b45-9b29-5a1bba5474f5",
"params": {
"seconds": 5
}
}Your handler must:
- With mTLS, verify MC's certificate before hardware access.
- Parse a bounded JSON body; require a nonempty
requestIdand a known command. - If
targetSerialis present, route to that child or reject it. Never default an unknown target to the gateway. - Validate required parameters, types, enums, ranges, unknown keys, and hardware interlocks.
- Atomically reserve the request ID. Concurrent duplicates with the same body execute once and return the recorded result. Reject the same ID with a different body.
- Execute or durably queue the operation. Record failures and any partial changes.
- Return the outcome and report actual state in telemetry.
Persist request records when deduplication must survive reboot.
Reply within 10 seconds. For longer operations, return HTTP 202 after accepting the work:
json
{
"requestId": "68ca9d81-e40b-4b45-9b29-5a1bba5474f5",
"state": "COMMAND_STATE_ACCEPTED"
}| State | Meaning |
|---|---|
COMMAND_STATE_ACCEPTED | Accepted; not yet complete. |
COMMAND_STATE_APPLIED | Applied successfully. |
COMMAND_STATE_REJECTED | Refused before execution; include reason. |
COMMAND_STATE_FAILED | Execution failed; include reason and partial-state details. |
Always echo the request ID. Empty responses, mismatched IDs, and unspecified states are invalid.
A command that reads a value, for example a getter for device limits, can return it in result. result is any JSON value. MC passes it back to the operator:
json
{
"requestId": "68ca9d81-e40b-4b45-9b29-5a1bba5474f5",
"state": "COMMAND_STATE_APPLIED",
"result": { "min_s": 1, "max_s": 60 }
}For a valid request, HTTP 200 with a rejected/failed acknowledgment preserves the command outcome. Use HTTP errors for authentication or malformed requests; MC treats non-2xx responses as delivery failures.
Report completion through telemetry; there is no completion callback. A timeout can mean the reply was lost after execution—check device state before retrying.
Log each command
Write one log line for each stage of a command. Put MC's requestId in every line. MC and the bus log the same ID, so one ID follows a command from the operator's click to your device's answer. A command with no log line on your device did not reach your device.
Write one JSON object per line:
json
{
"time": "2026-10-19T22:05:39.753Z",
"level": "info",
"message": "payload command answered",
"event": "payload_command_answered",
"request_id": "68ca9d81-e40b-4b45-9b29-5a1bba5474f5",
"command_name": "sensor.set_sample_period",
"param_names": "seconds",
"outcome": "applied",
"duration_ms": 12
}| Event | When |
|---|---|
payload_command_received | After you parse the request, before validation. |
payload_command_refused | You refuse it before execution: auth, body, target, or interlock. |
payload_command_sent | Your driver sends it to the hardware. |
payload_command_device_answered | The hardware answers, fails, or times out. |
payload_command_answered | You send the acknowledgment to MC. |
| Field | Value |
|---|---|
request_id | MC's requestId, unchanged. |
command_name | The command name. |
param_names | Parameter names, comma-separated. Never the values. |
outcome | accepted, applied, rejected, failed, or unknown (it may have run). |
error_code | Your error code, when the outcome is not accepted or applied. |
error | The reason you return, when there is one. |
device_status | The hardware's status code, when it has one. |
duration_ms | Milliseconds from payload_command_received to this line. |
replayed | true when you answer a duplicate requestId from your record. |
Use these exact field names. Do not log credentials, parameter values, addresses, or positions.
Use level info for accepted and applied, and warn for the other outcomes. A payload on a Husky bus sends these lines to the bus. See Send logs and metrics.
Check: unauthorized and invalid requests never reach hardware; concurrent duplicates execute once; driver failures return the correct outcome; one requestId finds every line of its command in your log.
3. Announce the new command
Add the manifest to announce.json and resend it:
bash
jq -e --slurpfile commands command-manifest.json \
'.commandManifest = ($commands[0] // error("Empty command manifest"))' announce.json > announce-next.json &&
curl --fail-with-body --silent --show-error \
-X POST "${MC_URL:?Set MC_URL}/payloads/announce" \
-H "x-device-api-token: ${DEVICE_API_TOKEN:?Set DEVICE_API_TOKEN}" \
-H 'Content-Type: application/json' \
--data-binary @announce-next.json &&
mv announce-next.json announce.jsonExpected: HTTP 200 and the same UUID. The response does not include manifests; inspect command_manifest in the registration read below. Adoption and command enablement remain unchanged.
4. Adopt and enable the device
Your Urban Sky account needs payload read and write permissions in the device's organization. Some commands require higher roles; see permissions.
With Balto:
bash
balto payloads list --bus uskybus-8901fc # your registration: status, commands_enabled
balto payloads adopt ENV-001
balto payloads enable-commands ENV-001
balto payloads enable-commands ENV-001 --off # turn them off again<payload> is the payload's ID or serial. Each command prints JSON. The same actions are in the Balto app, on the bus's Payloads tab.
With the HTTP API, use an operator token:
Inspect the identity, address, manifests, and measurements:
bash
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer ${OPERATOR_TOKEN:?Set OPERATOR_TOKEN}" \
"${MC_URL:?Set MC_URL}/payloads/${PAYLOAD_ID:?Run Connect first}"
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer ${OPERATOR_TOKEN:?Set OPERATOR_TOKEN}" \
"${MC_URL:?Set MC_URL}/payloads/${PAYLOAD_ID:?Run Connect first}/telemetry"The step 5 command must return 403 while the device is discovered. Adopt it:
bash
curl --fail-with-body --silent --show-error \
-X POST "${MC_URL:?Set MC_URL}/payloads/${PAYLOAD_ID:?Run Connect first}/adopt" \
-H "Authorization: Bearer ${OPERATOR_TOKEN:?Set OPERATOR_TOKEN}"Repeat the command while commands_enabled is false; expect 403 again. Then enable it:
bash
curl --fail-with-body --silent --show-error \
-X PUT "${MC_URL:?Set MC_URL}/payloads/${PAYLOAD_ID:?Run Connect first}" \
-H "Authorization: Bearer ${OPERATOR_TOKEN:?Set OPERATOR_TOKEN}" \
-H 'Content-Type: application/json' \
--data '{"commandsEnabled":true}'Check: registration reads status: "adopted" and commands_enabled: true. Write requests use commandsEnabled; read responses use commands_enabled.
5. Send one command and verify its effect
With Balto:
bash
balto payloads send ENV-001 sensor.set_sample_period seconds=5
balto payloads audit ENV-001 --limit 5Numbers and true/false keep their type. Quote a value to send it as a string: code='"0012"'. --request-id ID sends under a request ID you choose, for a retry of the same command. balto payloads audit reads what MC sent, newest first; --request-id finds one command. balto trace ENV-001 --down --follow shows commands as MC sends them.
With the HTTP API:
bash
curl --fail-with-body --silent --show-error \
-X POST "${MC_URL:?Set MC_URL}/payloads/${PAYLOAD_ID:?Run Connect first}/commands" \
-H "Authorization: Bearer ${OPERATOR_TOKEN:?Set OPERATOR_TOKEN}" \
-H 'Content-Type: application/json' \
--data '{"name":"sensor.set_sample_period","params":{"seconds":5}}'MC generates requestId. Repeating this POST without a requestId creates a new command. Do not automatically retry after a timeout.
To retry a command with an unknown outcome as the same command, send the first attempt's ID as requestId (a UUID). A route that removes duplicates returns the first outcome and deduplicated: true. It returns deduplicated: false when it ran the command. If the route cannot tell, the response has no deduplicated field. Your device must still detect duplicates by request ID. You cannot send requestId together with via.
Check requestId, ack.state, and ack.reason. Then read a sample taken after the command: data.values["sensor.sample_period_s"] should report 5. For physical outputs, verify the effect with a measurement.
Find the requestId in your device log (see Log each command). Mission Control operators trace the same ID through MC and the bus. To read the commands that MC sent to the device, newest first, use GET /payloads/:id/command-audit. Add ?requestId= to find one command.
If it fails
403: check adoption, command enablement, and permissions.400: compare names, values, and types with the stored manifest.502: check routing, mTLS, and acknowledgment format in Troubleshooting.- Accepted but unchanged: inspect the driver and measured state before retrying.