kodeOS internals

Kode channel

How the owner of a Kode Dot sends console commands over the internet, how the board joins, how commands sent while it is away wait, and what a command may do.

What it is

The Kode channel lets the account that owns a Kode Dot send console commands to it over the internet and read the answers. The board joins the channel by itself whenever it has Wi-Fi. A command arrives as one console line in an MQTT message, and each line of the answer leaves as one message on another topic.

The channel is a fourth way into the console, next to the cable, the charging base and Bluetooth. A line from the channel goes through the same parser and the same command table, tagged with the source KODE_CONSOLE_SRC_NET. It shows nothing on the screen and changes nothing on the LED. The cloud side is run by Kode, and this page describes what the board does and what it asks of the cloud.

The session is persistent. A command sent while the board is off, asleep without a network or out of range waits at the broker, and the board runs it, in the order it was sent, when it connects again. How long the broker keeps it is the setting chan_keep.

The channel is built into any firmware that has Wi-Fi, MQTT and the console (CONFIG_KODEOS_CHANNEL, default on), kodeOS and SDK apps alike. The presence message names the running firmware.

How it is connected

sequenceDiagram
autonumber
box "On the Kode Dot"
  participant N as AO_Net
  participant C as C5 identity
end
box "Kode cloud"
  participant A as api.kode.diy
  participant B as MQTT broker
end
N->>A: POST /kodes/challenge
A-->>N: nonce
N->>C: sign kode-auth/v1 and nonce
C-->>N: ECDSA signature
N->>A: POST /kodes/connect, cert, nonce, signature
A-->>N: broker, topics, token
N->>B: MQTT 5 over TLS
Joining the channel

AO_Net owns the channel. In order, it:

  1. reads the certificate from the C5;
  2. asks the api for a challenge;
  3. has the C5 sign it with the key of the unit;
  4. posts the certificate, the nonce and the signature to /kodes/connect;
  5. opens an MQTT 5 session to the broker the answer names, asking the broker to keep it while the board is away.

The key never leaves the C5.

The Wi-Fi is the door and the stored setting chan is the switch: the board joins when it has an address and the one MQTT client is free, and leaves when it loses the address. With chan off, channel_info reads off. When a client of your own holds the MQTT client, the state is waiting with the reason mqtt_busy, and the board joins at the next address.

AO_Log copies replies into a ring, and AO_Net drains it. The console tags each reply with the source of its command through kode_log_reply_src(). AO_Log writes every line to the cable and Bluetooth, and a line tagged for the channel also goes, without its newline, into a ring in PSRAM (32 KB). AO_Net publishes one message per line on the out topic. A ! log line has no tag and never goes out.

An app that uses kode_mqtt_* itself sets CONFIG_KODEOS_CHANNEL=n. Otherwise mqtt_up, mqtt_down, mqtt_pub, mqtt_sub and mqtt_unsub answer busy while the channel holds the client. Launching an app ends the session of kodeOS, and before a deliberate restart the board leaves cleanly, so the Kode Dot reads offline at once.

FileWhat it holds
kaos/net/src/net_ao.cThe life of the channel: join, renewal, back-off, the incoming line, the queue of commands, chan_keep, the fast mode, the drain of the answers and the screen upload
kaos/net/src/net_channel.c and net_channel.hThe text of the requests, a bounded reader of the api's JSON answers, the retry verdict and the ring. No QP, no network
kdrv/radio/src/kdrv_net.cThe MQTT 5 session of the channel and the ring in which the driver keeps the messages that arrive
kaos/console/src/console_ao.csrc_allows(), the one place that holds the policy of the sources, and the screenshot dst=http order
kaos/log/src/log_ao.cThe copy of channel replies into the ring
klog/src/klog.cThe source tag on a reply

Specifications

What the board asks of the cloud

The board needs an identity: the C5 holds the key of the unit and the certificate that the factory CA signed. A unit whose C5 has no certificate reports it once and stays off the channel.

StepRequestAnswer
1POST https://api.kode.diy/kodes/challenge with body {}A nonce valid for 120 s, 1 to 200 characters of letters, digits, ., _ and -
2The C5 signs kode-auth/v1, a newline and the nonceAn ECDSA P-256 over SHA-256 signature in DER, at most 72 bytes
3POST https://api.kode.diy/kodes/connect with {"cert":"...","nonce":"...","signature":"..."}, certificate and signature in base64The session: serial, brokerUrl, username, clientId, token, the three topics and optionally shots
4MQTT 5 over TLS to brokerUrl with that clientId, that username and the token as password, asking for a persistent sessionThe connection
TopicDirectionCarries
In topic, for example kode/<serial>/cmd/inTo the boardOne console line with no line ending. QoS 1, never retained
Out topic, for example kode/<serial>/cmd/outFrom the boardOne reply line per message. QoS 0
Status topic, for example kode/<serial>/statusFrom the boardThe presence message, retained

The session keepalive is 60 s. After connecting, the board follows the in topic at QoS 1 and publishes {"online":true,"app":"<project name>","version":"<version>"} retained on the status topic. The project name of kodeOS is kodeos.

The last will is the retained {"online":false}. A clean stop fires no will, so the board publishes the same message itself before any stop that is on purpose.

A publisher must not retain a message on the in topic. The broker replays it at each subscription, so a retained app_run would reset the board into the same command for ever. The board refuses a retained payload.

The line that arrives

The board refuses a payload that is retained, cut by the driver, 240 bytes or longer (KODE_CONSOLE_LINE_MAX), empty, or that holds a CR, an LF or a NUL. A line feed at the end counts. A refusal runs nothing, logs a warning and answers >err code=3 msg=line on the out topic, or msg=retained for a retained payload. A good line joins the queue of the channel, and the queue gives it to the console in its turn. When the queue is full, the answer is >err code=4 msg=busy. Strings are quoted as everywhere: app_run name="snake".

Commands sent while the Kode Dot is away

A board that is off, asleep without a network or out of range misses what is sent to the in topic. The session of the channel is persistent, so the broker keeps those messages and delivers them when the board connects again. A power-on after OFF, a reboot, the return of Wi-Fi, a reconnect to the broker and the renewal of the session are all such a connect.

PartValue
ProtocolMQTT 5 on the channel's client. The client of your own (mqtt_up, the kode_mqtt_* calls) stays on MQTT 3.1.1 with a clean session and QoS 0
SessionNo clean start: the broker resumes the session it filed under the clientId of the connect answer
ExpiryThe connect asks the broker to keep the session for chan_keep after the connection ends
In topicSubscribed at QoS 1, so the broker keeps a message it did not deliver and sends again one the board did not acknowledge
AnswersSent on the out topic at QoS 0, and not kept

config key="chan_keep" value=<age> sets how long the broker keeps the session:

WordSeconds asked
1h3600
6h21600
1d (default)86400
3d259200
7d604800
never4294967295, the MQTT 5 value for no expiry

Order and pace

The commands the broker kept arrive in a burst, and the console takes one command at a time. The channel holds the burst and hands the console one line at a time.

RuleValue
QueueAO_Net holds up to 32 lines (KODE_CHAN_QUEUE_MAX), the one that runs included, in PSRAM
OrderArrival order. The next line goes when the console answers the one that runs with >ok or >err. A line the console refuses ends with its >err. A line that is blank once trimmed ends with no answer, and the queue goes on
BoundAfter 30 s (KODE_CHAN_LINE_BOUND_MS) without an answer, AO_Net gives up on the line and the next goes. A reset, sleep or a stuck verb cannot hold the queue. A verb that runs longer (cp, a dual-band scan, install) is passed at the bound and still runs to its end
Late endEach line is offered with a sequence number and the console returns the number of the line that ended. The late end of a line that was given up does not end the line that took its place
Busy consoleThe console does not refuse a line of the channel. It hands the line back and the queue offers it again every 100 ms. The log writes one warning per episode. A handed-back line is not input, so it neither wakes the board nor restarts the idle time. The try the console takes does
Full queueA line that finds 32 lines is answered >err code=4 msg=busy on the out topic and the log writes channel: command refused, queue full. The line does not run
Refusals aheadA refused payload (>err code=3) and a full queue are answered when the line arrives, before the answers of the lines already queued. A terminator carries no name, so a sender that pairs terminators with lines by order must not take an early refusal for the answer of its first line
Connection dropThe lines wait while the channel is joining, waiting or replacing its client, and go once it is up. A line goes only while the channel can carry its answer back
Channel told to goconfig key="chan" value=off and the leave before a restart or a jump empty the queue and the messages the driver still holds. One notice: channel: halted; <n> command(s) that had not run are dropped
Network lostwifi_down, a loss of Wi-Fi and the board going OFF end the session without telling the channel to go. The lines wait and run in order after the next connect
A queued commandRuns as a live one: source NET, the same exclusions, answers on the out topic. A queued power_off switches the Kode Dot off again

The messages in the driver

esp-mqtt acknowledges a QoS 1 message itself, when its data callback returns, and calls the callback inside its API lock. The callback therefore copies the message into a ring in PSRAM and returns at once. It never waits for AO_Net, which can be blocked in a publish under the same lock. AO_Net empties the ring on a notice.

PartValue
Ring48 messages (KDRV_MQTT_RX_RING). A resume burst of the queue's 32 fits with room to spare
Receive windowThe connect asks the broker for at most 4 unacknowledged messages in flight (KDRV_MQTT_RECEIVE_MAX). The board acknowledges on receipt, so this bounds what is on the wire. A slow consumer is not held back by it
Full ringThe message is dropped after the broker has its acknowledgement. The driver logs a warning, then one in 64, and counts it. Nothing is answered on the out topic. net_info prints the count as rx_dropped= since boot

Lost on a reset, and run twice

What the broker must do

These are asks of the cloud side.

While the board is ASLEEP its session stays up, so a command arrives as it always did and wakes the board when the console takes the line (sdk/kaos/lowpower). The persistent session matters across OFF, a reboot and a lost connection.

Three sources, one console

src_allows() holds the whole policy. Every other command runs from every port.

Kind of commandCommandsCable and baseBluetoothChannel
Factoryident_csr, ident_cert, ident_lock, c5_wipeRunsmsg=cableRuns
Payload around the lineinstall with src="terminal", file_put, file_get, screenshot dst=console, mirror_startRunsRunsmsg=net
Cable onlyinstall dst=c5 from any sourceRunsmsg=cablemsg=net

The channel carries lines, not bytes. screenshot dst=file and screenshot dst=http answer in a few lines and run from every port. Commands with many ordinary records (help, ps, tasks, test component=all) run from the channel.

A fourth source, SCHED, is not a port. It is a line that AO_Scheduler runs at a cron time (sdk/kaos/scheduler). The policy treats it as it treats the channel, and also refuses cron to it (>err code=5 msg=sched). Its replies go to the log as notices.

The channel has no allow-list. A command the cable runs runs here, with the exceptions above. Whoever holds the account of the owner, or one of its session tokens of 15 minutes, can:

After that the board stays off the network until it is given Wi-Fi again by cable or Bluetooth.

cron is open, and what it leaves outlasts the session. The verb cron (internals/command-set) is accepted from the channel like every other verb, and the entry it leaves is stored. The entry runs later, with no session open and nobody at the board, and it can run every verb a SCHED line may run. Whoever can publish on the in topic can leave work for the future. List the entries with cron and remove one with cron name="<name>" when=off.

The board trusts the topic: a line on the in topic of its live session runs, and what may publish there is the rule of the cloud.

A command can end its own session: wifi_down, wifi_leave, radio_down and config key="chan" value=off. A command that resets the board answers >ok before the reset and the board tries to publish it, but it may not arrive. The lines queued behind it are lost with the reset.

How the answers go out

RuleValue
TaggingConsole.cmd_src holds the source from the moment the console accepts a line until the command closes. The tag travels in LogEvt.src
CopyAO_Log copies only lines tagged for the channel into the ring. A reply to a cable or Bluetooth command never goes in. A channel command is also answered on the cable and Bluetooth
Log linesNever. A command whose result lives only in a ! line answers a remote owner with a bare >ok, so such a command gives its result as >| records
One command at a timeThe queue gives the console one line at a time. A line that finds the console busy with a command of another port waits and is offered again every 100 ms. A function that answers in records holds the console for its job: assets_commit up to 120 s and assets_update up to 240 s, and the queue waits that long. The only >err code=4 msg=busy the channel sends for a command is the answer to a line that finds the queue full
Lost linesCounted in lost of channel_info: a full ring, a failed publish, no session, or an event pool with no block

Replies keep going out on the old client while a renewal asks the api, and wait in the ring from the moment the old client is told to stop.

The fast mode

The Wi-Fi radio of the C5 sleeps between beacons by default (WIFI_PS_MIN_MODEM). A frame sent to a sleeping station is lost at first and the broker's TCP retransmits it, so a command reaching an idle Kode Dot arrives 3 to 6 s late. With power save off a round trip takes about 0.1 s and the board draws about 27 mA more (an average of 107 mA became 134 mA on the bench).

The screen upload

The channel cannot carry a picture, but it carries the order for one. screenshot dst=http makes the board encode the glass as a JPEG (quality 80, KSHOT_QUALITY) and upload it with one HTTPS PUT. It needs KODEOS_SHOT_HTTP (default on), which needs the channel and the mirror's codec. The buffer holds 64 KB (KSHOT_JPEG_CAP) and a larger frame is not sent.

  1. The owner's session asks the api for a link: POST /kodes/<serial>/shots. The body {} asks for one picture, and {"mode":"stream","seconds":60} for a stream. The answer holds the picture id, a token and the console line. A link for one picture lives 2 minutes, for a stream the seconds chosen, at most 5 minutes.
  2. The owner sends that line to the Kode Dot on the in topic: screenshot dst=http token="<token>". The api issues tokens of 66 characters and the board accepts up to 128.
  3. The board uploads to the shots prefix plus the token, with the JWT of the session as bearer. The api accepts the upload only with the link and the token of this Kode Dot.
  4. The owner reads the picture from the api: GET /kodes/<serial>/shots/<id>.

The board never takes an address from a line. It takes the token, and the prefix comes from the connect answer, which arrived over TLS after the C5 signed the challenge.

screenshot dst=http token="<token>" [fps=<1..10>] [secs=<1..300>]
screenshot dst=http stop=on
CaseAnswer
One picture taken (any 2xx, the api answers 201)Record screenshot dst=http bytes id seq, then >ok
A stream acceptedRecord screenshot dst=http mode=stream fps secs, then >ok at once
Stop, a stream was running>ok
Stop, none running>err code=5 msg=stream
The api refused (401, 404, 409, 413, 415, a redirect, any non-2xx)Record screenshot dst=http result=refused http=<code>, then >err code=5 msg=http
No answer: TLS, socket or timeout>err code=6 msg=http
Codec busy, display silent for 2 s, an upload under way, worker taken, or no codec, framebuffer or PSRAM>err code=4 msg=busy
Frame over 64 KB>err code=5 msg=size
Channel down, no valid shots, or the link and token do not fit the URL buffer>err code=5 msg=channel
Missing token, bad token>err code=2 msg=token, >err code=3 msg=token
fps, secs or name with the wrong destination, or stop with any other key>err code=3 with the key

A picture waits at most 20 s for its answer, and a late answer is dropped. A stream is not waited for. fps and secs make a stream and the missing one takes its default of 2 frames a second or 60 s.

A stream behaves like this:

What the upload keeps safe and what it costs:

channel_info

channel_info answers one record and >ok.

channel=up serial="KODE<serial>" broker="mqtts://..." since_s=312 why=- next_s=- lost=0 fast=on shot=stream sent=118 fps_got=3.9 left_s=30 keep=86400 session=resumed
FieldMeaning
channeloff, no_net, no_identity, joining, up, waiting or refused
serial, brokerIn quotes, or - when unknown. The broker is filled last and keeps at least 18 characters, quotes included, at the widest counters
since_sSeconds in this state
whyOne word such as broker_lost, broker_refused, mqtt_busy or the api's error word, or -
next_sSeconds to the next try while waiting, - otherwise
lostReply lines that did not leave since boot
faston while the fast mode holds
shot, sent, fps_got, left_sThe upload: idle, one or stream, frames the api took, the rate reached and seconds left
keepThe session expiry in seconds that the connect of the live session asked for, never when it asked for none, - while the state is not up
sessionWhat the broker said at that connect: resumed, new, or - while the state is not up

Gotchas

Example code

Check the channel from the console, then switch it off and on.

    1. Join Wi-Fi: python3 tools/kcli.py -p PORT cmd --gap 10 'wifi_auto'.
    2. Run python3 tools/kcli.py -p PORT cmd 'channel_info'. A joined board reads channel=up, and since_s counts seconds in the state.
    3. Run python3 tools/kcli.py -p PORT cmd 'config key="chan" value=off'. A second channel_info reads channel=off.
    4. Run config key="chan" value=on. The board joins again as soon as the MQTT client is free.
    5. Run config key="chan_keep" value=7d. The log says that the next connect asks for it. With the channel up, channel_info keeps keep=86400 until the channel connects again, and then reads keep=604800.

TBD: the cloud side of the channel (which accounts and tokens may publish on the in topic) is not described here; Kode's ecosystem documentation settles it.

In the SDK