A direct telecommand is a telecommand sent by hand, outside any procedure: from the CLI
(stellar send), from the target page of the web console, or by
POST /v1/tc. The executor runs it as an implicit one-step procedure, with the verification the
catalogue declares for it: requires, then ACK 1 from the driver, ACK 2 from the gateway and
ACK 3 from verify. The states and acknowledgements are described in
Telecommand Lifecycle.
The current values of a target — the latest sample of each of its measures — are read the same
way, once or as a live feed: stellar watch, the web console, or the values API.
Sending a telecommand#
stellar send sim-1 'tcu[TCU1].ping'
stellar send sim-1 'tcu[TCU1].set_anode_voltage' 'voltage=100 V'
stellar send sim-1 'tcu[TCU1].set_mode' mode=SAFE_MODE
stellar send flatsat-1 'tcu[TCU2].ping' --link direct
stellar send psu-lab-2 psu.set_voltage voltage="28 V"stellar send submits the telecommand, prints its identifier, then follows its events until a
final state, one line per event:
01JB2Y1C8ZP1N2B8H3M4K5Q6R7 submitted to sim-1
2026-10-02T10:14:40.120Z PENDING
2026-10-02T10:14:40.131Z ENCODED
2026-10-02T10:14:40.142Z SENT
2026-10-02T10:14:40.371Z VERIFIEDIt exits with code 0 when the telecommand ends VERIFIED or COMPLETE, and 1 on any other
final state (REJECTED, ENCODE_FAILED, SEND_FAILED, VERIFY_FAILED, VERIFY_TIMEOUT) or
when the API refuses the submission. An ECHO event is printed with conforming or
NOT conforming; it is not a state.
Writing the telecommand#
| Form | Meaning |
|---|---|
ping | The telecommand alone, when its name is unique in the platform |
tcu.ping | component.telecommand, when several components have a telecommand of that name |
tcu[TCU1].ping | With the instance of a multi-instance component |
The instance can also be given with --instance TCU1; an instance written in the telecommand
wins.
The standard link component is addressed the same way: 'link[nominal].unlock' sends the COP-1
directive of the nominal link (see COP-1 and the Link Component).
Writing the arguments#
Arguments are always named, name=value, one per shell word:
| Value | Read as |
|---|---|
true, false | Boolean |
A plain number (10, 2.5) | Number without unit, in the unit of the argument |
| Anything else | Text: a quantity with its unit (100 V, 10 V/s), an enum value (SAFE_MODE), hexadecimal bytes |
Quote a quantity so that the shell keeps its space: 'voltage=100 V' or voltage="100 V".
The MCS converts each value into the unit of the argument (0.1 kV becomes 100 V), checks it
against its range, and fills missing arguments with their default. An argument without a
value nor a default, an unknown argument or a value out of range refuses the telecommand before
anything is sent.
Options#
| Option | Default | Meaning |
|---|---|---|
--instance <instance> | — | Instance of a multi-instance component, when not written in the telecommand |
--link <link> | the default link | Link of the target to send through (direct) |
--environment <env> | — | Environment of the telecommand, which selects the parameters of the link |
--api <url> | http://localhost:8080 (STELLAR_API) | API of the MCS |
--as <user> | $USER | Identity declared to the API |
--role <roles> | STELLAR_ROLE | Roles declared with it, comma-separated |
--token <jwt> | STELLAR_TOKEN | OIDC token, preferred to the declared identity |
--api-ca <pem> | system roots (STELLAR_API_CA) | CA certificates of an https:// API |
Links and components#
A link may carry only some components of the catalogue (components: [tcu]). A telecommand of
another component sent through that link is refused (tc::link-component). The default link
always carries the whole catalogue.
Targets held by a run#
A run holds a lease on each of its targets (see Leases and Crash Recovery). While
it does, a direct telecommand to that target is refused with 409 tc::target-held, naming the
runs that hold it. One exception: under a shared lease (a run that only reads values and sends
telecommands with changes_state: false), a direct telecommand with changes_state: false is
accepted.
The API: POST /v1/tc#
The CLI and the web console go through the API. A client can do the same:
curl -s -X POST http://localhost:8080/v1/tc \
-H 'Content-Type: application/json' -H 'X-Stellar-User: alice' \
-d '{"target": "sim-1", "telecommand": "tcu.set_anode_voltage", "instance": "TCU1",
"args": {"voltage": "100 V"}}'| Field | Required | Meaning |
|---|---|---|
target | yes | Target, as named in the topology |
telecommand | yes | Its name alone when unique in the platform, else component.name |
instance | no | Instance of a multi-instance component, or file identifier for files |
args | no | Arguments by name: "100 V", enum values, booleans, numbers, hexadecimal bytes |
link | no | Link, when not the default one |
environment | no | Environment, which selects the parameters of the link |
id | no | Identifier chosen by the client (a ULID), to make the submission idempotent |
Unknown fields are refused. The reply is 202 Accepted:
{"id": "01JB2Y1C8ZP1N2B8H3M4K5Q6R7", "duplicate": false}The API resolves the request against the current configuration, then publishes the semantic
telecommand on stellar.tc.submit.<target> (stream TC_COMMANDS) with the identifier as
Nats-Msg-Id, the configuration revision as Stellar-Config and the caller as Stellar-User.
The executor records the caller in the PENDING event.
Idempotency#
Without id, the API draws a new ULID. With id, a client that retries after a network error
submits the same telecommand again under the same identifier: JetStream deduplicates it, and
the reply says "duplicate": true. Nothing is sent twice.
Errors#
| Status | Code | Cause |
|---|---|---|
| 400 | api::invalid-body, api::invalid-id | Malformed body, or id is not a ULID |
| 404 | tc::unknown-target | No such target in the topology (with a suggestion) |
| 409 | tc::target-held | A run holds the target |
| 422 | tc::unknown-telecommand, tc::ambiguous-telecommand | No such telecommand, or its name is not unique: write component.name |
| 422 | tc::missing-instance, tc::unexpected-instance, tc::unknown-instance | Instance missing, superfluous or unknown |
| 422 | tc::missing-argument, tc::unexpected-argument, tc::invalid-argument, tc::out-of-range | Arguments |
| 422 | tc::unknown-link, tc::link-component | Link unknown, or not carrying the component |
| 422 | tc::unknown-environment, tc::environment-required | Environment unknown, or required |
| 422 | tc::missing-catalogue | The catalogue of the target is not in the snapshot |
| 503 | api::unavailable | NATS is unavailable |
A 422 reply lists every error found, each with its help when there is one. See
Error Codes.
Following a telecommand#
GET /v1/tc/{target}/{id}/events upgrades to a WebSocket. It replays the events already
recorded in TC_EVENTS, follows the new ones, and closes after a final state. Each message is a
telecommand event:
{"tc": "01JB2Y1C8ZP1N2B8H3M4K5Q6R7", "state": "SENT", "at": "2026-10-02T10:14:40.142Z"}| Field | Meaning |
|---|---|
tc | Telecommand identifier |
state | PENDING, REJECTED, ENCODED, ENCODE_FAILED, SENT, SEND_FAILED, VERIFIED, VERIFY_FAILED, VERIFY_TIMEOUT, COMPLETE, or ECHO |
at | Time of the transition |
detail | Explanation: failed precondition, error of the driver or the gateway |
conforming | For ECHO: whether the echo matches the telecommand sent |
telecommand | For PENDING: {component, instance, telecommand} |
Opening the socket after the telecommand ended still gives its whole history. See WebSocket Streams.
Current values#
The compute stage keeps the latest sample of every measure and derived measure of a target in the current value table (see Measures and Current Values).
stellar watch#
stellar watch sim-1 # every measure of the target
stellar watch sim-1 'tcu[TCU1].anode_voltage' # one instance
stellar watch sim-1 tcu.responding # every instance of tcu
stellar watch sim-1 responding # that measure of any component
stellar watch sim-1 'tcu[TCU1].mode' --until STANDBY --timeout 30It prints the current values, then every update, one line per sample: the time of the sample, the measure and its value.
2026-10-02T10:14:41.000Z tcu[TCU1].anode_voltage = 42.5| Option | Meaning |
|---|---|
measures… | Measures to show; every measure without |
--until <value> | Stop with code 0 as soon as a watched value equals this: true, STANDBY, 28.0 (JSON when it parses, else text) |
--timeout <seconds> | Give up with code 1 after this many seconds |
--until and --timeout make stellar watch usable in scripts: wait for a mode, a flag, a
value. The comparison is exact on the JSON value of the sample: 28.0 does not match 28.1.
The values API#
GET /v1/targets/{target}/values returns the current values of a target, by key order:
{
"values": [
{
"component": "tcu",
"instance": "TCU1",
"measure": "anode_voltage",
"sample": {
"value": 100.2,
"raw": 20040,
"time": "2026-10-02T10:14:41Z",
"ground_time": "2026-10-02T10:14:41.012Z",
"link": "nominal",
"delivery": "realtime"
}
}
]
}| Sample field | Meaning |
|---|---|
value | Physical value: number in the unit of the measure, boolean, enum value, hexadecimal bytes; null when a temporal derived measure becomes unknown |
raw | Raw value, for a measure calibrated by the MCS |
time | Time of the sample: on-board time when known, else ground reception time |
ground_time | Ground reception time, reference of freshness (max_age) |
link | Link the frame came through |
delivery | realtime, or deferred for telemetry stored on board and dumped later |
GET /v1/targets/{target}/values/watch?measures=… upgrades to a WebSocket: it sends the current
values, then each update of the table, one value per message. measures is a comma-separated
list of selectors in the three forms above (tcu[TCU1].responding,psu.voltage); every measure
without. An invalid selector answers 400 api::invalid-measure.
A sample older than the known value never replaces it: the table always shows the most recent state, even while a file of deferred telemetry is being decoded.
See also#
- Telecommand Lifecycle: states, acknowledgements and timeouts.
- Telecommands and Verification:
requires,verify,changes_state,hazardous. - API reference: telecommands and values.