# AO_Net

The network of a Kode Dot: ping, HTTP, UDP and MQTT as jobs on two workers, the download loan, the screen upload and the Kode channel host.

## What it is

Once a Kode Dot has joined a Wi-Fi network, **AO_Net** lets the console and your app reach other computers. It pings a host, fetches a web page over HTTP or HTTPS, sends a UDP datagram and keeps an MQTT client connected to a broker. It also streams a downloaded file to whoever installs it, uploads a picture of the screen to the cloud, and runs the Kode channel, the session through which the account owner sends console commands.

lwIP runs on the P4, so sockets, HTTP, MQTT and ICMP never use the C5's RPC channel. The C5 only carries frames. That is why AO_Net is not part of `sdk/kaos/radio`: a ping can run while a 38 s Wi-Fi scan runs, and neither waits for the other. A socket is memory in the P4, not a part of the board, so **AO_Net owns no resource**. It keeps one piece of state, the MQTT client.

Nothing in AO_Net blocks. A name lookup, the start of a TLS connection and a ping of four echoes all run as jobs on a worker task. The Kode channel is described on `internals/kode-channel`; this page covers only how AO_Net hosts it.

AO_Net also takes part in the board's rest (`sdk/kaos/lowpower`). Asleep, the channel and an MQTT client of yours stay connected, and AO_Net tells the rest when it next has work, so the board wakes for it.

## How it is connected

```mermaid
flowchart LR
  req["Console, kode_mqtt_*()<br/>AO_Radio, AO_Display"]
  radio["AO_Radio"]
  subgraph p4["On the P4"]
    ao["AO_Net<br/>priority 2"] -- "kdrv_net_run()" --> w1["net_worker"]
    ao -- "stream jobs" --> w2["net_stream"]
  end
  req -- "NET_*, MQTT_*_REQ_SIG" --> ao
  radio -- "WIFI_STATE_SIG" --> ao
  w1 -- "lwIP, esp-mqtt, TLS" --> net["Network"]
  ao -- "MQTT_MSG_SIG,<br/>NET_*_SIG" --> subs["Subscribers"]
  class ao,radio ao
  class net ext
  class subs app
```

*AO_Net, its workers and the network*

### Priority and options

AO_Net runs at priority 2 (`KODE_PRIO_NET`), the lowest of any Active Object, below AO_Gui at 3. Network I/O is the slowest work in the system, so at this level it can only delay itself, and its workers can block on a socket as long as the network takes.

That is load-bearing: every kodeOS subscriber to `MQTT_MSG_SIG` outranks AO_Net, so a subscriber preempts it and handles each message before the next is published, and a chatty broker cannot fill a subscriber's queue.

Priority 1 (`KODE_PRIO_USER_LOW`) is the one level below, reserved for an app's own AO. An app that puts a subscriber there, or that blocks, needs a queue deep enough for its broker's bursts. The queue is 16 events (`KQP_AO_QUEUE_LEN`) and the stack 4096 bytes. `kode_init()` starts it after AO_Radio.

The Kconfig options below decide what is compiled. All default to on.

| Option | Compiles | Depends on |
| --- | --- | --- |
| `KODEOS_NET` | AO_Net | `KODEOS_WIFI` |
| `KODEOS_MQTT` | The MQTT half | - |
| `KODEOS_CHANNEL` | The Kode channel | `KODEOS_MQTT`, `KODEOS_CONSOLE` |
| `KODEOS_SHOT_HTTP` | The screen upload | The channel, `KODEOS_MIRROR` |
| `KODE_LOWPOWER` | AO_Net's part in the rest: the real-time waits, the radio wake and the `mqtt` check | - |

The channel's renewal time is `KODEOS_CHANNEL_RENEW_S`, 82800 s.

### States

```mermaid
stateDiagram-v2
  [*] --> Net_offline: subscribe WIFI_STATE_SIG, start workers, ask for the state
  Net_offline --> Net_online: WIFI_STATE_SIG got-ip, ip4 not 0
  Net_online --> Net_offline: any other WIFI_STATE_SIG, stop MQTT
```

*AO_Net states*

`Net_root` is the parent. It answers the queries in both states, because "not connected" is a valid answer: `NET_INFO_REQ_SIG`, `MQTT_STATE_REQ_SIG` and `CHANNEL_STATE_REQ_SIG`. It also handles worker outcomes, since a job submitted online can finish after the network has gone.

### Start-up

The initial transition takes the job storage from PSRAM, starts `net_worker` and `net_stream` with `kqp_worker_start()` (12288 bytes of stack each, in PSRAM, so the TLS handshake costs no internal RAM) and installs the MQTT callback. If the storage or `net_worker` cannot be had, the network services stay down; without `net_stream`, transfers are refused. Then it **asks** for the Wi-Fi state, since AO_Radio may have had an address for a while.

### Offline

**Offline refuses every request with an answer.** A caller that gets silence cannot tell "no network" from "still working". Most refusals log `no network; <verb> needs an address - 'wifi_join' first` and publish the terminator the verb closes on:

| Request | Refusal |
| --- | --- |
| `NET_PING_REQ_SIG` | `NET_PING_END_SIG` with `ok=false` |
| `NET_HTTP_REQ_SIG` | `NET_HTTP_DONE_SIG` with `rc=KODE_DENIED` |
| `NET_UDP_REQ_SIG` | `NET_UDP_DONE_SIG` with `rc=KODE_DENIED`, `sent=0` |
| `NET_TEST_REQ_SIG` | `TEST_RESULT_SIG` named `net`, `ok=false` |
| `MQTT_UP_REQ_SIG` | `err = KDRV_MQTT_ERR_NO_NET`, then `MQTT_STATE_SIG` |
| `MQTT_PUB/SUB/UNSUB_REQ_SIG` | A log line, then `MQTT_STATE_SIG`, which closes the verb |
| `NET_STREAM_OPEN_REQ_SIG` | `NET_STREAM_OPEN_RSP_SIG` to `who` with `KODE_DENIED` and `medium = KDRV_MEDIUM_COUNT` |

A stream read, return or close that arrives right after the network went is handled with no harm.

### Going online and offline

Entering `Net_online` sets `online`, logs `online as <ip>`, starts the time client and starts the channel. Leaving it closes an open transfer, because a stuck `sopen` would refuse every later open with `a transfer is already open` for the rest of the boot. Any `WIFI_STATE_SIG` other than got-ip logs `the network is gone`, stops MQTT, ends the channel and goes offline. A got-ip with a non-zero address is the same network and is handled in place.

### Jobs and the busy refusal

There are six job types (`kdrv_net_job_t`): `PING`, `HTTP_GET`, `HTTP_POST`, `UDP_SEND`, `MQTT_START` and `MQTT_STOP`. `net_submit()` copies a `kdrv_net_req_t` into storage in PSRAM and hands it to `net_worker`. `me->busy` is set at the submit and cleared only by the `NET_JOB_SIG` whose sequence number matches.

Every online request checks `busy` before it builds the job, and a refusal publishes its own terminator, so a busy refusal and a no-network refusal look the same to a waiting console.

AO_Net keeps one job in flight on each worker, so the download of a file never waits behind a long POST of the channel, and the POST never waits behind the download. Every job has a one-shot guard, `NET_JOB_GUARD_SIG`, of 60 s (`KODE_NET_GUARD_MS`). If it fires the job is still with the worker, `busy` stays up, and AO_Net answers whoever waits with `KODE_TIMEOUT`; the late done event answers nobody twice. A `MQTT_START` or `MQTT_STOP` waits for nobody.

The worker waits for a pool block when the pool is dry, so a dry pool delays a done event and never loses it.

### Publishing answers

The driver keeps the results of the last job and a new job overwrites them, so AO_Net reads them between the done event and the next submit. A ping publishes one `NET_PING_ENTRY_SIG` per echo (`seq`, `time_ms`, `ttl`, `ok`) and then `NET_PING_END_SIG` (`ip4`, `tx`, `rx`, `min_ms`, `avg_ms`, `max_ms`, `ok`). AO_Net takes the terminator from the pool first, because `net_ping` and `http_get` disarm the console watchdog, so a lost terminator would leave the console busy for good; a short pool gives a short listing.

HTTP publishes up to 8 `NET_HTTP_DATA_SIG` records (`KODE_NET_HTTP_CHUNKS`) of 192 bytes each (`KODE_NET_HTTP_CHUNK`: 191 characters and a terminator), with every non-visible byte rewritten to `.`, then `NET_HTTP_DONE_SIG` (`status`, `content_length`, `body_len`, `kept`, `truncated`, `rc`).

The self-test sends **one echo to the gateway**, since a ping outside the building would test someone else's network, and passes on `ok && rx > 0`. Every field of a pooled event is written explicitly, because a pool block holds whatever the last event left in it.

### MQTT

AO_Net tracks its own phase apart from the driver's state, because the driver knows whether a socket is connected and the phase knows whether anyone asked for one.

```mermaid
stateDiagram-v2
  MQTT_IDLE --> MQTT_STARTING: MQTT_UP_REQ, job accepted
  MQTT_STARTING --> MQTT_LIVE: connected
  MQTT_STARTING --> MQTT_IDLE: start failed
  MQTT_STARTING --> MQTT_STOPPING: 15 s, down, or network gone
  MQTT_LIVE --> MQTT_STOPPING: down, or network gone
  MQTT_STOPPING --> MQTT_IDLE: MQTT_STOP_DONE
```

*The MQTT phases*

A stop that AO_Net could not submit leaves the phase alone, otherwise `mqtt_down` would answer "already down" over a running client. `mqtt_up` with a client existing answers the current state and no error. The connect deadline is `NET_TMO_SIG`, 15 s (`KODE_MQTT_CONNECT_MS`).

A publish, a subscribe and an unsubscribe do not go through the worker: they run on AO_Net's own thread, and a publish is written to the socket at once with `esp_mqtt_client_publish()` and never stored in the outbox, which takes one message per second and drops silently what is older than 30 s.

The error `kdrv_mqtt_err_t` is `NONE`, `NO_NET`, `REFUSED`, `TIMEOUT` or `BUSY`; `BUSY` is the refusal of `mqtt_up` and `mqtt_down` while the Kode channel holds the one client, printed as `>err code=4 msg=busy`.

### Inbound messages

A broker on the internet sets the rate of this stream. esp-mqtt calls `net_on_mqtt()` on a task it owns, inside its API lock, and AO_Net can be blocked in a publish under the same lock. Nothing on that path may wait for AO_Net, so a message does not travel to AO_Net as an event.

1. **The ring.** The driver copies the message into a ring of 48 messages in PSRAM (`KDRV_MQTT_RX_RING`) and returns at once. The return is also what makes esp-mqtt acknowledge a QoS 1 message. A message that finds the ring full is dropped, with a warning and a count.
2. **The notice.** The driver calls `net_on_mqtt()` when the ring goes from empty to holding a message, and again when a notice is owed: one was refused, or AO_Net asked for one with `kdrv_net_mqtt_rx_owe()`. The call carries no message. It posts the static event `NET_MQTT_RX_SIG` with `QACTIVE_POST_X` and a margin of 4 free slots (`NET_MQTT_MARGIN`), so a flooding broker cannot fill the queue and `QACTIVE_POST` never asserts. The call returns whether the post was taken.
3. **The drain.** On `NET_MQTT_RX_SIG`, `net_rx_drain()` takes messages with `kdrv_net_mqtt_rx_take()`, at most one ring's worth per step, into a scratch buffer in PSRAM, and handles each whole before it takes the next. When the ring still holds messages AO_Net posts the notice to itself. If that post is refused it calls `kdrv_net_mqtt_rx_owe()`, and the next message to arrive sends the notice. A refused notice delays messages and does not lose them. `chan_on_watch()` also empties the ring every 15 s, but only while the client of the channel serves.
4. **Where a message goes.** The driver tags a message of the channel's client (`kdrv_mqtt_msg_t.channel`) when it arrives. Such a message is a command: it joins the channel's queue and AO_Net never republishes it. The tag is used and not the topic, because the session that names the topic is wiped when the channel ends and the ring outlives it. Every other message is copied into an `MqttMsgEvt` and republished as `MQTT_MSG_SIG` from AO_Net's own thread, so only AO_Net publishes MQTT messages. A topic or payload longer than the event carries arrives shortened and says so: `topic_cut`, `data_cut`, and `retained` for a broker replay.

Link events (`CONNECTED`, `DISCONNECTED`, `ERROR`) are not in the ring. `net_on_mqtt()` copies each into `NET_MQTT_LINK_SIG` and posts it with a margin of 0, because losing one costs a command its answer or leaves the channel believing in a dead session. `CONNECTED` carries `session_present`, the broker's flag that it resumed the session.

### Hosting the Kode channel

The channel is a sequence inside `Net_online`, not a state of its own: a phase `cp` says which answer AO_Net waits for, and one timer `ctmo` means a guard on the C5, a wait after a failure or the renewal. The setting `chan` (`kset/chan`, boolean, default on) is the switch.

AO_Net asks AO_Radio for the unit's certificate and a signature (`ID_PART_SIG`, `ID_END_SIG`, `ID_SIGN_RSP_SIG`), posts to `https://api.kode.diy/kodes/challenge` and `/kodes/connect` as `HTTP_POST` jobs, starts the MQTT client with the broker the answer names, and follows the `in` topic.

Waits after a failure back off from 5 s, doubling to 10 min, scaled to 75 to 125 % with `khal_random()`. The token lives 24 h and is renewed after 23 h.

The session asks for MQTT 5, no clean start, the age `chan_keep` as its expiry and QoS 1 on the `in` topic, so the broker keeps the commands sent while the board is away. On a connect AO_Net follows the topic again, publishes the presence again, logs `channel: session resumed` or `channel: new session (nothing kept for this Kode)` from the broker's `session_present`, and starts handing the queued commands to the console.

Replies leave through a 32 KB PSRAM ring (`KCHAN_OUT_BYTES`) in bursts of 8 lines (`CHAN_OUT_BURST`). While the channel is active, `mqtt_up`, `mqtt_down`, `mqtt_pub`, `mqtt_sub` and `mqtt_unsub` answer `busy` and touch nothing.

#### The setting `chan_keep`

`chan_keep` is AO_Net's second setting: how long the broker keeps the channel's session while the board is away. The words are `1h`, `6h`, `1d`, `3d`, `7d` and `never`, the default is `1d`, and the store is `kset/chan_keep`, a blob of one `uint32_t` in seconds (`never` is `0xFFFFFFFF`, which a `_Static_assert` keeps equal to `KDRV_MQTT_EXPIRY_NEVER`, the MQTT 5 value for no expiry). AO_Net reads it once in its initial transition. A value that is none of the six, or a blob of another size, reads as the default. A set writes the blob, logs one notice and does nothing else: the next connect puts the age into the session, and a renewal is a connect, so a new age reaches the broker within 23 h and sooner after a drop. `channel_info` shows the age of the live session as `keep=`.

#### The queue of commands

The console takes one command at a time, and a Kode Dot that was away comes back to a burst. A command (a message tagged as the channel's) is checked first: a payload that is retained, cut, empty, 240 bytes or longer, or that holds a CR, an LF or a NUL is refused with `>err code=3 msg=line` (`msg=retained` for a retained one). A good one joins a ring of 32 lines in PSRAM (`KODE_CHAN_QUEUE_MAX`), and a line that finds it full is answered `>err code=4 msg=busy` at once.

`chan_q_pump()` gives the console the oldest line when none is out and the channel is up, so the answer can leave. It posts a `ConsoleLineEvt` with source NET and a sequence number, with a margin of 2 slots (`CHAN_LINE_MARGIN`). The line stays in the ring until it ends.

- **The end.** AO_Console calls `net_line_done()` at the terminator of a line of source NET, with the sequence the line arrived with, and AO_Net takes it only when it matches the line that is out. It then removes the line and pumps. Any other end is ignored, which covers a late end of a line the bound gave up on.
- **A busy console.** AO_Console does not refuse a line of source NET. It calls `net_line_busy()` and AO_Net offers the line again after 100 ms (`CHAN_LINE_RETRY_MS`). The same retry follows a refused post.
- **The bound.** The one-shot `lguard` (`NET_CHAN_LINE_SIG`) runs for 30 s (`KODE_CHAN_LINE_BOUND_MS`) on the line that is out. When it fires, AO_Net gives up on the line, removes it and pumps. It is a guard that counts an expiry already queued after a stop, so the bound of a line that ended cannot pass for the bound of the next.
- **Channel told to go.** `config key="chan" value=off` and the leave for a restart or a jump empty the ring, stop the timer and discard what the driver still holds for the channel, with one notice (`channel: halted; <n> command(s) that had not run are dropped`).
- **Network lost.** A lost address (`wifi_down` included) and the OFF round of the rest leave the channel but keep the ring. The lines run in order after the next connect.
- **Reset.** The ring is RAM. A command that resets the board takes the lines behind it. Delivery is at least once, so a command can run twice.

The channel's own states (`CHAN_OFF`, `CHAN_NO_NET`, `CHAN_NO_IDENTITY`, `CHAN_JOINING`, `CHAN_UP`, `CHAN_WAITING`, `CHAN_REFUSED`), its failure words, the fast mode, the renewal and the presence message are on `internals/kode-channel`. `net_leave_request()` makes AO_Net publish the offline presence, stop the client and answer AO_Boot exactly once with `BOOT_NET_LEFT_SIG`, so a deliberate restart does not wait for a keepalive.

### The board's rest

With `CONFIG_KODE_LOWPOWER`, AO_Net joins the rounds of AO_LowPower as `net` (`lowpower_join_radio()`, because it takes part in radio wakes) and tells AO_Radio from its initial transition that it does (`radio_lp_expect_net()`). The full state machine is on `sdk/kaos/lowpower`. This is AO_Net's part.

| Case | What AO_Net does |
| --- | --- |
| ASLEEP | The Kode channel and an MQTT client of yours stay connected on the C5 link, which AO_Radio keeps up. A channel command that arrives is a console line like any other and wakes the board. A message of your client, republished as `MQTT_MSG_SIG`, wakes it too |
| Nothing new starts | A request that would start a job is refused with its own answer, as offline |
| Round for ASLEEP refused as busy | A job or transfer in flight (`net job`, `net transfer`), a screen upload (`screen upload`), a leave for a jump (`net leaving`), a join between steps (`net joining`, because its guards are `QTimeEvt`s that would freeze), the wake time having no event to go in (`net wake`), or the check's dwell (`check dwell`) |
| OFF | Closes the channel and any MQTT session, tells AO_Radio with `radio_net_left()`, and only then answers ready. An OFF round that does not happen leaves the channel to join anew at the next AWAKE. A client of yours is not brought back |

#### The long waits are real time

A `QTimeEvt` does not count while ASLEEP, because the QP tick is stopped. A renewal due in 23 h would wait for 23 h of awake time, the token would lapse and the broker would drop the session. So the two long waits, the renewal (phase `Up`) and the back-off (phase `Wait`), carry a deadline in `cdue_us`, in `khal_time_us()`, which runs through light sleep. `chan_wait()` sets it and `chan_due_arm()` puts the wait where the board is:

- **AWAKE.** `ctmo` is armed for what is left of the deadline.
- **ASLEEP.** Nothing is armed. AO_Net gives AO_LowPower the deadline with `lowpower_wake_at()`, before it answers the round for ASLEEP and again whenever a wait begins in the rest. If the pool has no block to carry the note, the round is answered busy (`net wake`).

AO_LowPower wakes the board at the deadline with the cause `timer`, as a radio wake that does not light the glass. On `LOWPOWER_STATE_SIG` with state RADIO, or with AWAKE after a person used the board, `chan_lp_resume()` arms `ctmo` for the real time left and at least 2 s (`CHAN_LP_SETTLE_MS`), because AO_Radio takes the C5 link out of its rest on the same event. A deadline that passed in the rest acts 2 s after the wake and not a whole renewal later.

Nothing else starts in the rest. An address that arrives while ASLEEP sets `lp_rejoin`, and the join begins 2 s after the wake. A long timer that fires as the board goes to sleep gives the deadline again and does not begin the renewal. A deadline left registered after the channel ended costs one radio wake that finds nothing to do.

#### The radio wake

In a radio wake the tick runs and the lock is held, but the glass, the touch panel and every other owner stay at rest. `net_lp_state()` treats RADIO like AWAKE. AO_Net says it is finished with `lowpower_radio_done()`, once per radio wake, decided by `net_lp_radio_check()` at the start of the wake and at the end of each step of the channel. It is done when nothing is in the way of resting (no job, transfer, screen upload or leave) and the channel is not in use or waits for a time further off than 2 s. A renewal that is due, a back-off that has run out and a join between steps are work. A renewal that succeeds is done at once, and so is a try that fails and waits: a back-off starts at about 5 s. A broker that cannot be reached costs one radio wake per try while the back-off grows to 10 min, and the glass never lights. If the work takes longer than `KODE_LOWPOWER_RADIO_MAX_MS` (60 s), AO_LowPower asks AO_Net to rest anyway.

#### The check of the `mqtt` entry

AO_Scheduler has a built-in entry, `mqtt`, whose work is a look at the sessions in a radio wake. It is the net under the interrupt of the C5: if the interrupt that wakes the P4 for a message is ever lost, the message arrives at the next check. The scheduler tells AO_Net the next time with `net_check_at(due_wall_s)` (`NET_CHECK_AT_SIG`, Unix seconds in UTC, `-1` for none), and AO_Net keeps one time at a time. AO_Net knows whether the C5 link is up from `RADIO_STATE_SIG`.

- **Something must listen.** The check counts only when the C5 link is up, Wi-Fi has an address, and the channel is in use or a client of yours holds the MQTT session. With nothing to listen to there is nothing to wake for, and a radio wake that finds the check due makes it at once.
- **The time is part of the wake.** Before it answers a round for ASLEEP, AO_Net gives `lowpower_wake_at()` the earlier of the channel's deadline and the check's time.
- **The dwell.** In a radio wake with the check due, or in a whole wake that finds it due, AO_Net arms `chk_tmo` (`NET_CHECK_TMO_SIG`) for 3000 ms (`KODE_NET_CHECK_DWELL_MS`) from the beginning of the wake, so that what the C5 or the broker holds can flow in. While it runs, a round for ASLEEP is answered busy (`check dwell`) and the radio wake does not end. A round for OFF is not refused. At the timer the check is made.
- **The board has been awake.** A check found due by a round, or by a repeated AWAKE state, is made at once with no dwell: the sessions were served while the time came.
- **A check made.** AO_Net forgets the time and tells the scheduler with `sched_check_done()`, which answers with the next time.

### The screen upload

`screenshot dst=http token="…"` uploads a picture of the screen. The channel carries lines of 240 bytes, which a picture does not fit, so MQTT carries the order and one HTTPS `PUT` carries the picture.

AO_Console checks the token and posts `NET_SHOT_REQ_SIG`. AO_Net holds the session's `shots` prefix and the JWT, asks AO_Display for a JPEG (`display_shot_request()`, with a 2 s deadline, `KSHOT_DISPLAY_MS`), copies it into the driver's buffer and releases the loan before it submits `KDRV_NET_JOB_HTTP_PUT`. A capture answers one `NET_SHOT_RSP_SIG` result.

An order with `fps` or `secs` is a stream: AO_Net answers at once, runs it alone, marks a frame due on each tick of a one-shot `1000 / fps` ms timer, and starts it only when the last `PUT` is done and the worker is free, so a slow link lowers the rate and builds no backlog. Three network errors in a row end a stream (`KSHOT_STREAM_ERR_MAX`), a 429 drops that frame, and the channel always gets the worker first.

A stream ends with one of the words `time`, `expired`, `refused`, `channel`, `network`, `replaced`, `display` or `stopped`. The URL never comes from the order, only the token, whose characters cannot leave the last path segment.

Neither AO_Net nor `kdrv_net` logs the token, the link or the JWT, and `kdrv_net` holds the `HTTP_CLIENT` log tag at INFO during a `PUT`.

### The download loan

A URL can stand in for a file name when an image is installed, the same conversation as `sdk/kaos/storage`. AO_Net has two 32 KB PSRAM buffers (`NET_STREAM_BUFS`, `NET_STREAM_CHUNK`) and lends them by index, 0 or 1, never by address. The reads are jobs on `net_stream`: the worker fills one buffer while the reader reads the other. A return frees the buffer and asks for the next chunk in the same step.

A close always wins: it forgets the transfer at once, answers a staged open with a refusal and owes the driver a close job, and a new open waits behind it so the driver never sees two transfers. Each job has a guard above the driver's own cap: 20 s plus 5 s of slack for an open or a read, 30 s for a close.

The callers are AO_AppMgr (`install src="http…"`), AO_Radio (a C5 image over HTTP) and AO_Gui (the files of Kody's pack). None has a deadline of its own, which is why the offline state answers an open and does not ignore it.

### The clock

Entering `Net_online` starts the time client, not boot, because without a network it would retry and never reach a server. When a server answers, the callback sets the wall clock and logs one line, the moment where every timestamp in the session changes from milliseconds since boot to dates.

## Specifications

### Signals in

What AO_Net receives: requests, answers from other parts, and its own timers.

| Signal | Event | Direction | Meaning |
| --- | --- | --- | --- |
| `WIFI_STATE_SIG` | `WifiStateEvt` | in, subscribed | Got-ip with a non-zero `ip4` is online |
| `NET_PING_REQ_SIG` | `NetPingReqEvt` | in, posted | `net_ping`; 1 to 10 echoes |
| `NET_HTTP_REQ_SIG` | `NetHttpReqEvt` | in, posted | `http_get`. `HTTP_POST` is submitted only by the channel |
| `NET_UDP_REQ_SIG` | `NetUdpReqEvt` | in, posted | `udp_send`; payload up to 64 bytes from an event |
| `NET_INFO_REQ_SIG`, `NET_TEST_REQ_SIG` | none | in, posted | `net_info`; `test component=net` |
| `MQTT_UP_REQ_SIG` | `MqttUpEvt` | in, posted | Start the client |
| `MQTT_DOWN_REQ_SIG`, `MQTT_STATE_REQ_SIG` | none | in, posted | Stop; republish |
| `MQTT_PUB_REQ_SIG`, `MQTT_SUB_REQ_SIG`, `MQTT_UNSUB_REQ_SIG` | `MqttPubEvt`, `MqttTopicEvt` | in, posted | Send, follow, unfollow |
| `CHANNEL_STATE_REQ_SIG` | none | in, posted | `channel_info`; AO_Gui when the settings screen opens |
| `CONFIG_REQ_SIG` | `KodeConfigReqEvt` | in, subscribed | The keys `chan` and `chan_keep` |
| `RADIO_STATE_SIG` | `RadioStateEvt` | in, subscribed | Whether the C5 link is up, for the `mqtt` check |
| `LOWPOWER_ROUND_SIG`, `LOWPOWER_STATE_SIG` | `LowPowerRoundEvt`, `LowPowerStateEvt` | in, subscribed, with `KODE_LOWPOWER` | A round to rest or to close the sessions, and the state the board went to |
| `NET_LINE_DONE_SIG` | `NetLineDoneEvt` | in, posted by AO_Console | `net_line_done()`: the line of source NET ended, with the sequence of the line |
| `NET_LINE_BUSY_SIG` | none | in, posted by AO_Console | `net_line_busy()`: the console was busy and did not take the line |
| `NET_CHECK_AT_SIG` | none | in, posted by AO_Scheduler, with `KODE_LOWPOWER` | `net_check_at()`: the next time of the `mqtt` check |
| `ID_PART_SIG`, `ID_END_SIG`, `ID_SIGN_RSP_SIG` | | in, posted by AO_Radio | The channel's certificate and signature |
| `NET_STREAM_OPEN/READ/NEXT/CLOSE_REQ_SIG` | `NetStreamOpenEvt` for the open | in, posted | The download loan |
| `NET_SHOT_REQ_SIG`, `NET_SHOT_STOP_SIG` | `NetShotReqEvt`, `NetShotStopEvt` | in, posted | `screenshot dst=http` and its stop |
| `DISPLAY_SHOT_RSP_SIG` | `DisplayShotRspEvt` | in, posted by AO_Display | The JPEG, lent |
| `NET_LEAVE_REQ_SIG` | none | in, posted | `net_leave_request()`, before a restart |
| `NET_JOB_SIG`, `NET_STREAM_JOB_SIG` | `KqpWorkerDoneEvt` | in, from the workers | Job outcomes |
| `NET_JOB_GUARD_SIG`, `NET_STREAM_GUARD_SIG`, `NET_TMO_SIG`, `NET_CHAN_TMO_SIG`, `NET_CHAN_FAST_SIG`, `NET_CHAN_WATCH_SIG`, `NET_CHAN_LINE_SIG`, `NET_CHECK_TMO_SIG`, `NET_SHOT_TICK_SIG`, `NET_SHOT_WAIT_SIG` | none | self, one-shot | Guards, the connect deadline, the channel's wait (real time for the renewal and the back-off), fast-mode hold (60 s), client look (15 s, which also empties the receive ring), the bound on the line the console runs (30 s) or its retry (100 ms), the dwell of the `mqtt` check (3 s), stream pace, display deadline |
| `NET_MQTT_LINK_SIG` (`NetMqttLinkEvt`), `NET_MQTT_RX_SIG` (a static event), `NET_CHAN_OUT_SIG` | | in, private | From esp-mqtt's task (a link event, or the notice that the receive ring holds messages) and from the ring's writer |

### Signals out

What AO_Net publishes or posts back.

| Signal | Event | Direction | Meaning |
| --- | --- | --- | --- |
| `NET_PING_ENTRY_SIG`, `NET_PING_END_SIG` | `NetPingEntryEvt`, `NetPingEndEvt` | out, published | The ping listing |
| `NET_HTTP_DATA_SIG`, `NET_HTTP_DONE_SIG` | `NetHttpDataEvt`, `NetHttpDoneEvt` | out, published | The GET |
| `NET_UDP_DONE_SIG`, `NET_INFO_SIG` | `NetUdpDoneEvt`, `NetInfoEvt` | out, published | `NetInfoEvt`: `ip4`, `gw4`, `mask4`, `dns4` in network byte order, the MQTT state, `online` and `rx_dropped`, the messages the driver dropped since boot because its receive ring was full; answered in every state |
| `MQTT_STATE_SIG` | `MqttStateEvt` | out, published | Where the client stands |
| `MQTT_MSG_SIG` | `MqttMsgEvt`: `topic`, `len`, `topic_cut`, `data_cut`, `retained`, `data` | out, published | A message from the broker |
| `CHANNEL_STATE_SIG` | `ChannelStateEvt` | out, published | State, reason, wait, fast mode, the upload counters, and the session (`keep_s`, the age the connect asked for, and `session`, what the broker said) |
| `CONSOLE_LINE_SIG` | `ConsoleLineEvt` | out, posted to AO_Console | A command of the channel, source NET, one at a time from the queue, with a sequence number |
| `NET_SHOT_RSP_SIG` | `NetShotRspEvt` | out, posted to the asker | Result of a capture or a stream |
| `NET_STREAM_OPEN_RSP_SIG`, `NET_STREAM_DATA_SIG` | `StorageOpenRspEvt`, `StorageDataEvt`: `idx`, `len`, `eof`, `status` | out, posted to the reader | The loan |
| `BOOT_NET_LEFT_SIG` | none | out, posted to AO_Boot | The channel has left; once per `NET_LEAVE_REQ_SIG` |
| `TEST_RESULT_SIG` | named `net` | out, published | The verdict |

### Calls, limits and verbs

| Thing | Value |
| --- | --- |
| Blocking calls | `kode_net_lookup(host, &ip4)`, `kode_net_ping(host, count, &stats)` (count 1 to 10), `kode_http_get(url, buf, cap, &result)`, `kode_udp_send(host, port, data, len)`. Never from an AO; reentrant, each call opens its own socket. `https://` is checked against the Mozilla bundle. A 404 returns `KODE_OK` and `result.status` says 404 |
| Posting calls | `kode_mqtt_connect()`, `kode_mqtt_disconnect()`, `kode_mqtt_publish()`, `kode_mqtt_subscribe()`, `kode_mqtt_unsubscribe()` and `kode_mqtt_connected()`. Safe anywhere. There is deliberately no polling accessor: subscribe an AO to `MQTT_MSG_SIG` |
| Request helpers | `net_ping_request()`, `net_http_get_request()`, `net_udp_send_request()`, `net_info_request()`, `net_test_request()`, `mqtt_*_request()`, `net_leave_request()`, `net_shot_request()`, `net_shot_stop_request()`, `net_stream_*` |
| Size limits | Host 64, URL 80, datagram 64, URI 96, user 33, password 65, topic 80, payload 240 (`KODE_MQTT_PAYLOAD_MAX`, one console line). Receive ring 48 messages (`KDRV_MQTT_RX_RING`), command queue 32 lines (`KODE_CHAN_QUEUE_MAX`). `_Static_assert`s check each against the driver's copy, including 8 x 191 = 1528 (`KDRV_NET_BODY_MAX`) |
| Guards | Job 60 s; connect 15 s; stream open or read 20 s + 5 s; stream close 30 s; screenshot display 2 s; a line out to the console 30 s |
| Stored settings | `kset/chan`, boolean, default on. `kset/chan_keep`, one `uint32_t` of seconds, default 86400 |
| Console | `net_ping host= count=`, `net_info`, `http_get url=`, `udp_send host= port= data=`, `mqtt_up uri= user= pass=`, `mqtt_down`, `mqtt_pub`, `mqtt_sub`, `mqtt_unsub`, `mqtt_info`, `channel_info`, `config key="chan"`, `config key="chan_keep"`, `screenshot dst=http`, `test component=net`. The ICMP verb is `net_ping`; plain `ping` is the console liveness check |

## Gotchas

> **DANGER**
>
> **Never call `kode_net_ping()`, `kode_http_get()`, `kode_net_lookup()` or `kode_udp_send()` from an Active Object.** They block, and a call parked in an AO stalls every priority beneath it. Call them from `app_main()`, a task of your own or a job of a `kode_worker`. An AO uses the request helpers and subscribes to the answer signals.

> **WARNING**
>
> **Your MQTT subscriber loses messages, or the board resets.** Messages arrive in bursts that a broker sets. The driver keeps 48 in a ring and drops the rest with a warning and a count (`rx_dropped=` of `net_info`), but a QP queue that fills in an AO of yours at priority 1 is an assert. Give a blocking subscriber a deeper queue than the default 16.

> **WARNING**
>
> **`mqtt_up` answers `busy`.** While the Kode channel is active it holds the one MQTT client. Set `config key="chan" value=off` first, or do your MQTT work in `no_net`, `no_identity` or `refused`. A bench client never inherits the channel's identity: the session is one-shot and wiped after each start.

> **NOTE**
>
> **A published message is sent at once, not stored.** `kode_mqtt_publish()` writes to the socket before AO_Net's call returns and reports a failure now. The driver does not use the outbox, because one message per second and silent drops after 30 s made reply bursts unusable. A payload over 240 bytes is cut.

> **NOTE**
>
> **A GET that returns `KODE_OK` can still be a 404 or truncated.** `KODE_OK` means the transport worked. Read `result.status`, and `result.truncated` when `body_len` exceeds your buffer. The console's preview shows at most 1528 bytes.

## Example code

This fetches a page over HTTPS and logs the status and the size, once the Kode Dot is online. Join a network first (`wifi_join` on the console). The log level must be `i`: `config key="log" value=i`.

```c
/*
 * http_status.c - 2026-10-28 - from Kode with love
 *
 * Fetches https://api.kode.diy/ with AO_Net's blocking GET and logs the HTTP
 * status and how many body bytes arrived. The Kode Dot must be online. With
 * no network the log shows the failure code instead of a status.
 */
#include "kode.h"

/* Bring the system up, then GET one page every 10 s and report it. */
void app_main(void)
{
    kode_init();

    while (1) {
        char body[128];
        kode_http_result_t result;
        kode_status_t status = kode_http_get("https://api.kode.diy/", body,
                                             sizeof(body), &result);

        if (status == KODE_OK) {
            KLOG_I("http_status", "status %u, %u body bytes",
                   (unsigned)result.status, (unsigned)result.body_len);
        } else {
            KLOG_I("http_status", "no answer, status %d", (int)status);
        }
        kode_delay_ms(10000);
    }
}
```

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. **TBD**: a run on a Kode Dot.

## In the SDK

- [`components/kaos/net`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/net): `net_ao.c` (the AO, MQTT, the loan, the uploads), `net_channel.c` and `net_channel.h` (the channel's pure logic).
- [`kode.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kode/include/kode.h): the `kode_net_*`, `kode_http_get`, `kode_udp_send` and `kode_mqtt_*` calls.
- [`components/kdrv/radio`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/radio): `kdrv_net.c`, the sockets, esp-mqtt and TLS under the jobs.
