# 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

```mermaid
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.

| File | What it holds |
| --- | --- |
| `kaos/net/src/net_ao.c` | The 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.h` | The 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.c` | The MQTT 5 session of the channel and the ring in which the driver keeps the messages that arrive |
| `kaos/console/src/console_ao.c` | `src_allows()`, the one place that holds the policy of the sources, and the `screenshot dst=http` order |
| `kaos/log/src/log_ao.c` | The copy of channel replies into the ring |
| `klog/src/klog.c` | The 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.

| Step | Request | Answer |
| --- | --- | --- |
| 1 | `POST https://api.kode.diy/kodes/challenge` with body `{}` | A `nonce` valid for 120 s, 1 to 200 characters of letters, digits, `.`, `_` and `-` |
| 2 | The C5 signs `kode-auth/v1`, a newline and the nonce | An ECDSA P-256 over SHA-256 signature in DER, at most 72 bytes |
| 3 | `POST https://api.kode.diy/kodes/connect` with `{"cert":"...","nonce":"...","signature":"..."}`, certificate and signature in base64 | The session: `serial`, `brokerUrl`, `username`, `clientId`, `token`, the three topics and optionally `shots` |
| 4 | MQTT 5 over TLS to `brokerUrl` with that `clientId`, that `username` and the `token` as password, asking for a persistent session | The connection |

- The `cert` is the unit's certificate only: the first `unit_len` bytes of what the C5 returns.
- The token is a JWT valid for 24 h, at most 511 characters. The board renews the session 82800 s (23 h) after the connect (`CONFIG_KODEOS_CHANNEL_RENEW_S`) and does not read the expiry. The 23 h and the waits between tries run in real time, so they keep counting while the board sleeps. A renewal or a retry that falls due wakes the radio side for a moment, the channel does its work, and the board rests again with the screen dark (`sdk/kaos/net`).
- The firmware hard-codes one address, the root of the api. The broker, the client id and the topics come from the answer and are used as given. An answer is refused whole unless all eight fields are present and fit, the broker is `mqtts://`, the serial is this unit's and no topic holds an MQTT wildcard.
- `shots` is optional: a full `https://` prefix ending in `/`, at most 128 bytes, with no space or control character. A session without a valid one is a good session and uploads are refused.
- Back-off after a failure starts at 5 s and doubles to 10 min with about 25 % jitter. The terminal states are `no_identity` and `refused`.

| Topic | Direction | Carries |
| --- | --- | --- |
| In topic, for example `kode/<serial>/cmd/in` | To the board | One console line with no line ending. QoS 1, never retained |
| Out topic, for example `kode/<serial>/cmd/out` | From the board | One reply line per message. QoS 0 |
| Status topic, for example `kode/<serial>/status` | From the board | The 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.

| Part | Value |
| --- | --- |
| Protocol | MQTT 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 |
| Session | No clean start: the broker resumes the session it filed under the `clientId` of the connect answer |
| Expiry | The connect asks the broker to keep the session for `chan_keep` after the connection ends |
| In topic | Subscribed at QoS 1, so the broker keeps a message it did not deliver and sends again one the board did not acknowledge |
| Answers | Sent on the out topic at QoS 0, and not kept |

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

| Word | Seconds asked |
| --- | --- |
| `1h` | 3600 |
| `6h` | 21600 |
| `1d` (default) | 86400 |
| `3d` | 259200 |
| `7d` | 604800 |
| `never` | 4294967295, the MQTT 5 value for no expiry |

- The age counts the absence, not each message. A board away for less than the age gets everything the broker kept. A board away for longer finds the session gone, with every message in it.
- A change is stored at once (`kset/chan_keep`, owner AO_Net) and used at the next connect, a renewal included. The channel is not reconnected for it. The log writes one notice: `channel: keep the session <age> (stored); the next connect asks for it`.
- When the store refuses the write, the new age applies until the next reset. The log writes a warning and the answer reads `stored=no`. After a reset the stored age applies.
- `channel_info` shows `keep=` (the age the connect of the live session asked for, `-` while the state is not `up`) and `session=`. The board shows what it asked. It does not read the age the broker grants, so a broker with a lower maximum keeps the session for that maximum while `keep=` still shows the age asked.
- `session=resumed` means the broker kept the session, so the commands sent meanwhile are on their way. `session=new` means it held nothing for this Kode Dot. The log says the same once per connect: `channel: session resumed` or `channel: new session (nothing kept for this Kode)`.

#### 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.

| Rule | Value |
| --- | --- |
| Queue | AO_Net holds up to 32 lines (`KODE_CHAN_QUEUE_MAX`), the one that runs included, in PSRAM |
| Order | Arrival 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 |
| Bound | After 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 end | Each 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 console | The 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 queue | A 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 ahead | A 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 drop | The 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 go | `config 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 lost | `wifi_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 command | Runs 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.

| Part | Value |
| --- | --- |
| Ring | 48 messages (`KDRV_MQTT_RX_RING`). A resume burst of the queue's 32 fits with room to spare |
| Receive window | The 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 ring | The 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

- **The queue and the ring are RAM.** A reset in the middle of a burst drops the lines still in them, and the broker does not deliver them again because it has their acknowledgement. A queued `app_run`, an `install` or a panic resets the board. A queued `power_off` ends the run. Every line behind them is lost, and the board logs nothing about it at the next boot.
- **Delivery is at least once.** If the connection drops after the board received a message and before the acknowledgement reached the broker, the broker delivers it again and the command can run twice. The board has no key to tell the second delivery from a new command. Send only commands that are safe to repeat, or accept the risk.

#### What the broker must do

These are asks of the cloud side.

- Accept MQTT 5 from the Kode Dot. The channel does not fall back to 3.1.1.
- Honour the session expiry that the board asks for. The broker's maximum must be at least 7 days (604800 s) for `7d`, and `never` asks for 4294967295 s.
- Publish to the in topic at QoS 1. A broker may or may not queue QoS 0 for an offline session, depending on its configuration.
- Return the same `clientId` for a Kode Dot each time. A different id starts a session with nothing in it, and the commands waiting under the earlier id are never delivered.
- Keep a burst small. The queue holds 32 lines and the ring 48 messages. Lines past 32 that fit in the ring are answered `busy`, so the sender sees them. Messages past what the ring holds before AO_Net empties it are dropped on the board, acknowledged and silent.
- Set the limit of the broker's own queue for a session (`max_mqueue_len` in EMQX) and say what a sender can expect when a Kode Dot has been away past it. The board cannot tell that a message was dropped, or which.
- Never retain a command.
- Keep a command well under 1 KB. The board does not change esp-mqtt's receive buffer, which defaults to 1024 bytes. **TBD**: what the broker does with a larger packet for this client is not measured.

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 command | Commands | Cable and base | Bluetooth | Channel |
| --- | --- | --- | --- | --- |
| Factory | `ident_csr`, `ident_cert`, `ident_lock`, `c5_wipe` | Runs | `msg=cable` | Runs |
| Payload around the line | `install` with `src="terminal"`, `file_put`, `file_get`, `screenshot dst=console`, `mirror_start` | Runs | Runs | `msg=net` |
| Cable only | `install dst=c5` from any source | Runs | `msg=cable` | `msg=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:

- reflash kodeOS from a URL;
- erase apps;
- close the identity for good with `ident_lock confirm=on`;
- forget the networks and BLE keys with `c5_wipe confirm=on` or `wifi_forget`.

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

| Rule | Value |
| --- | --- |
| Tagging | `Console.cmd_src` holds the source from the moment the console accepts a line until the command closes. The tag travels in `LogEvt.src` |
| Copy | AO_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 lines | Never. 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 time | The 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 lines | Counted 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 first good line while the mode is off asks AO_Radio for no power save. That first line of a burst is still slow.
- Each good line, and each frame of a screen upload stream, restarts a hold of 60 s (`CHAN_FAST_HOLD_MS`). A refused payload does not count.
- The mode ends 60 s after the last line, or at once when the channel is left. `wifi_ps` set to `off` keeps the station out of power save at all times.
- The log writes one notice when it turns on and one when it turns off.

### 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.

```text
screenshot dst=http token="<token>" [fps=<1..10>] [secs=<1..300>]
screenshot dst=http stop=on
```

| Case | Answer |
| --- | --- |
| One picture taken (any 2xx, the api answers 201) | Record `screenshot dst=http bytes id seq`, then `>ok` |
| A stream accepted | Record `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:

- It runs in AO_Net, so the console is free and the owner can drive the board with `btn`, `tap` and `touch` while it shows the glass.
- A frame starts when its time has come and the last `PUT` is done, so a slow link lowers the rate and frames never queue.
- The api refuses frames closer than 100 ms. One TLS connection stays open between frames.
- Three network errors in a row end the stream, a 429 drops that frame, and a new valid order replaces a running stream.
- It ends with one of these words in a notice: `time`, `expired` (404), `refused`, `channel`, `network`, `replaced`, `display` or `stopped`.

What the upload keeps safe and what it costs:

- The board never follows a redirect and sends only to `https://`.
- It never logs the token, the link or the JWT (the HTTP client's log is held at info during a `PUT`, whatever `config key="log"` says).
- It wipes its copies of the token, the URL and the bearer when the upload ends. The connection of a stream keeps its URL until the stream closes, and the last picture stays in a PSRAM block until the next `PUT`.
- The `PUT` takes about 64 KB of PSRAM on first use and a second TLS session beside the MQTT one. Nothing new is taken from internal RAM.

### channel_info

`channel_info` answers one record and `>ok`.

```text
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
```

| Field | Meaning |
| --- | --- |
| `channel` | `off`, `no_net`, `no_identity`, `joining`, `up`, `waiting` or `refused` |
| `serial`, `broker` | In quotes, or `-` when unknown. The broker is filled last and keeps at least 18 characters, quotes included, at the widest counters |
| `since_s` | Seconds in this state |
| `why` | One word such as `broker_lost`, `broker_refused`, `mqtt_busy` or the api's `error` word, or `-` |
| `next_s` | Seconds to the next try while `waiting`, `-` otherwise |
| `lost` | Reply lines that did not leave since boot |
| `fast` | `on` while the fast mode holds |
| `shot`, `sent`, `fps_got`, `left_s` | The upload: idle, one or stream, frames the api took, the rate reached and seconds left |
| `keep` | The 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` |
| `session` | What the broker said at that connect: `resumed`, `new`, or `-` while the state is not `up` |

## Gotchas

> **WARNING**
>
> **A line feed at the end of a command is a refusal.** A publisher that appends a newline gets `>err code=3 msg=line`. Send the line with no ending, not retained.

> **WARNING**
>
> **A command can lose its own answer, a queued command can be lost, and a command can run twice.** The `>ok` of `wifi_down`, `radio_down` or `config key="chan" value=off` may never reach the out topic, because it ends the session that carries it. The queue and the driver's ring are RAM and the broker has already been told that it delivered the messages, so a command that resets the board takes the lines behind it. A connection that drops before the broker has an acknowledgement makes it deliver the message again. Send commands that are safe to repeat, and read the answers.

> **NOTE**
>
> **The retained status is not proof of presence.** When a session ends without a clean stop, the owner sees `{"online":false}` only when the keepalive runs out, about 90 s. A deliberate restart or a stop by AO_Net publishes it first. A renewal does not, since it reconnects at once.

> **NOTE**
>
> **A broker drop starts a wait, not a redial.** The state becomes `waiting` with `broker_lost` on the back-off, and the join starts again with a new token. AO_Net compares the driver and the channel every 15 s, so a driver with no client while the channel says `up` ends the same way.

## 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

- [`components/kaos/net`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/net): `net_ao.c` runs the channel and `net_channel.c` reads the api's answers.
- [`components/kaos/console`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/console): `src_allows()` and the screen upload order.
- [`components/kdrv/radio`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/radio): `kdrv_net.c` holds the HTTPS and MQTT client the channel uses.
