> ## Documentation Index
> Fetch the complete documentation index at: https://totem-cb8b3887.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# WiFi (OTA updates)

> The third 2.4 GHz role: station-mode WiFi used to pull firmware/preview updates over HTTP.

WiFi exists on this device for one job: **pulling over-the-air updates**. It is never
active during normal navigation. The code lives in `f_lib/wifi.py`, `f_lib/requests.py`,
`f_lib/firmware_ota.py` and the `f_ota/` package, and it only runs on a boot that follows
an OTA trigger — see [an OTA is two boots](/subsystems/ota#an-ota-is-two-boots-not-one).

<Note>
  Everything on this page was read out of the frozen MicroPython bytecode in
  `re/v5.0.3/mpy/*.dis`; citations are `file.dis:line`. Claims that could **not** be read
  directly are marked **inferred** or **not recoverable**.
</Note>

## What actually starts a WiFi OTA

<Warning>
  Earlier revisions of this page claimed the device "opens a client WebSocket to the API
  host and can be told to update over it." **That is wrong.** The only WebSocket code in
  the image is a *server* in `f_ota/hotspot.py` — the device's own soft-AP web UI — and that
  module is imported by nothing in v5.0.3 (`grep f_ota.hotspot` matches only itself). There
  is no WebSocket client, no persistent connection to `api.totemportal.com`, and no polling
  loop: the device only talks to the API *during* an update it was already told to perform.
</Warning>

A trigger (SOS triple tap, the phone app over BLE, an ESP-NOW/demi-god broadcast, or the
REPL) writes a `perform.ota` parameter file and soft-resets. The next boot notices the
soft reset plus the file and calls `f_ota.main.start_ota`, which is where WiFi first comes
up. The full sequence, the `cmd` numbers and the `perform.ota` schema are documented under
[OTA & rollback](/subsystems/ota).

### Battery gate

OTA is battery-gated at the **trigger**, before any reboot:

```python theme={null}
if not modes.evt_battery_charged.is_set():
    log.warn('Battery too low for OTA update')
    return
```

(`compass.dis:6904`.) This is an `asyncio.Event` flag maintained by the battery monitor,
**not** a voltage or percentage compared inline — the threshold that sets and clears the
event lives in the power subsystem, not here. Once the reboot has happened there is no
further battery check on the download path.

## Choosing and joining a network

### Known networks

`f_ota.config` hard-codes two credentials (`f_ota_config.dis`, `PERM_NETWORKS`):

| SSID          | Password    |
| ------------- | ----------- |
| `magiceye`    | `magiceye`  |
| `totemupdate` | `totem1234` |

`get_known_networks()` (`f_ota_config.dis`) merges them with runtime state:

```python theme={null}
d = PERM_NETWORKS                      # NOTE: bound, not copied
if cfg.networks:
    for ssid in cfg.networks:
        if ssid not in d:
            d[ssid] = cfg.networks[ssid]
if user_cfg.wifi_ssid and user_cfg.wifi_ssid not in d:
    d[user_cfg.wifi_ssid] = user_cfg.wifi_key
return d
```

Two things follow. First, the hard-coded pair always wins over anything supplied at
runtime with the same SSID. Second, because `d` **is** the module-level `PERM_NETWORKS`
dict rather than a copy, every call permanently accumulates the caller's networks into it
for the life of the process.

`cfg.networks` is whatever a trigger put in `perform.ota` under `networks`;
`user_cfg.wifi_ssid` / `wifi_key` come from `user-config.json`, the file the (now dead)
hotspot web UI wrote.

<Warning>
  `totemupdate` / `totem1234` is **not** legacy. It is in `PERM_NETWORKS`, so the current
  station-mode OTA path will join it on every update attempt if it is in range. Anything
  broadcasting that SSID with that password, in range of a device that has been told to
  update, gets to answer its HTTP requests.
</Warning>

### `prep_wlan()` and the connect loop

`prep_wlan()` (`f_ota_main.dis:437`) logs
`Preparing WLAN protocols for update (v2)...` and then:

```python theme={null}
await local_wifi.disconnect(power_off=True)
local_wifi.wlan.active(True)
await asyncio.sleep_ms(300)
local_wifi.protocol = 7          # 802.11 b/g/n
local_wifi.pm = 0                # power-save off
local_wifi.wlan.config(protocol=7)
local_wifi.wlan.config(pm=0)
```

ending with `WLAN prep completed`. Despite the `(v2)` in that string, `f_ota/main.py`
imports `ConnErr, WiFi, CLIENT` from **`f_lib.wifi`**, not `f_lib/wifi_v2.py`
(`f_ota_main.dis:162`). `WiFi.__init__` defaults to `protocol=7, pm=1` and sets
`self.reconnects = 3` (`f_lib_wifi.dis:234`).

`WiFi.connect_to_known(networks, hostname, max_n=10, attempts=2, priority_nw=None)`
(`f_lib_wifi.dis:550`, defaults at `:183`):

1. Empty `networks` → `ConnErr('No network creds were provided', ErrCode.invalid_auth)`.
2. Up to **`max_n = 10`** scan rounds, each `await asyncio.sleep_ms(200)` then
   `get_nearby_networks()`, stopping as soon as anything is seen. Still nothing →
   `ConnErr('No networks within range', ErrCode.conn_failed)`.
3. Intersect the scan with `networks`. Nothing in common →
   `ConnErr('Known-network not found within range', ErrCode.conn_failed)`.
4. If `priority_nw` (i.e. `user_cfg.wifi_ssid`) is among the matches it wins; otherwise
   the first match in scan order.
5. **`attempts = 2`** tries of `asyncio.wait_for(self.connect(ssid, key, hostname), 15)`,
   printing `Connecting to: {} | Attempt {} of {}...`. A timeout prints
   `Timed out connecting to WiFi, trying again...`.
6. `if not wlan.isconnected(): raise ConnErr('Unable to establish connection with {}', ErrCode.conn_failed)`.

The retry branch assigns a backoff value of `30` to a local that is never read again — the
`wait_for` timeout is the literal `15` on both attempts. Dead code.

`wifi_update` passes `hostname = '{}_{}'.format(cfg.hostname, cfg.mac[-4:])`, e.g.
`mytotem_a1b2` (`cfg.hostname` defaults to `'mytotem'`).

Any `ConnErr` is re-raised by `wifi_update` as
`OtaErr('{} | code: {}'.format(e.msg, e.code), ErrCode.conn_failed)`, which aborts the
update and — with `cfg.is_reboot` defaulting to `True` — reboots the device.

## The HTTP exchange

Two update kinds share one path, distinguished by the `cmd` number the trigger supplied,
**not** by `cfg.update_method`:

| `cmd`    | Log string                            | Package                                                       |
| -------- | ------------------------------------- | ------------------------------------------------------------- |
| `1`      | `Performing Preview update via WiFi`  | `.tgz` gzip+tar, unpacked over the filesystem root            |
| `3`, `4` | `Performing Firmware update via WiFi` | `.bin` ESP-IDF app image, streamed into the inactive OTA slot |

`cfg.update_method` selects where the *URL* comes from: `1` = use `cfg.ota_url` as given,
`2` = ask the API first. `start_ota` sets it to `2` whenever `cfg.endpoint_id and
cfg.version` are both set, which the standard `perform.ota` defaults guarantee
(`endpoint_id = 2`, `version = 'latest'`).

The full request and response shapes — the seven-key release poll, the `resp['body']`
release object, `contents.json`, and the ten-key `?updated` completion report — are
documented once, under
[OTA server contract](/subsystems/ota#ota-server-contract-wifi-path). In summary:

```text theme={null}
POST http://api.totemportal.com/devices/{mac}/ota          -> {"body": {"endpoint": …, "release_code": …, …}}
GET  {endpoint}/{release_code}/contents.json[?uid=XXXXXXXX] -> ["firmware_v5.0.3.bin", …]
GET  {endpoint}/{release_code}/{picked filename}             -> streamed to flash
POST http://api.totemportal.com/devices/{mac}/ota?updated   -> (2xx)
```

`{mac}` is `get_mac_addr()` = `''.join('{:02x}'.format(b) for b in machine.unique_id())`,
i.e. 12 **lower-case** hex characters with no separators (`f_lib_bitwise.dis:158`).
Earlier revisions said upper-case; that was wrong.

`f_lib/requests.py` speaks **HTTP/1.0** with `Connection: close`
(`b'%s /%s HTTP/1.0\r\n'`, `f_lib_requests.dis`). It can do TLS — port 443 is in its
scheme table — but every Totem host literal in the image is `http://`.

## Transport security

<Warning>
  Both hops are cleartext HTTP with no authentication, and the firmware image carries no
  app-level hash or signature check on the WiFi path — the only verification is that the
  number of bytes written equals the server's own `Content-Length`
  (`Received {} bytes (expected {}).`, `f_lib_firmware_ota.dis:639`). Anything that can
  answer for `api.totemportal.com`, or for whatever host the API's `endpoint` field names,
  or that can stand up a `totemupdate` / `magiceye` access point near a device that has been
  told to update, can flash arbitrary code onto it. See
  [OTA integrity](/subsystems/ota#integrity).
</Warning>

## LED feedback

The `ota_callback` task drives the ring and Touch Crystal for the whole update
(`ota_callback.dis:497`). Earlier revisions of this page said no firmware string bound a
colour to an OTA state, and concluded the mapping was unconfirmed. That was wrong — the
mapping is in bytecode, not in strings:

| State                              | Indication                                                                                                                                               |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Searching for / connecting to WiFi | **white** comet chasing around the 60-pixel ring, 50 ms per step                                                                                         |
| Connected                          | ring cleared                                                                                                                                             |
| Downloading                        | ring fills progressively with `PROGRESS_RGB = dim_leds(colors.white, 0.3)` — a **white progress ring**                                                   |
| Failure (`ota_status.code == 3`)   | ring flashes `ERROR_RGB = dim_leds(colors.orange, GLOBAL_BRT)` twice (200 ms on / 200 ms off / 400 ms on), then goes dark — the **orange failure blink** |
| Throughout                         | Touch Crystal twinkles magenta → violet → blue (`(200,0,200) → (100,0,200) → (0,0,200)`)                                                                 |

`colors.orange = (255,128,0)` and `GLOBAL_BRT = 0.6` (`project_data.dis:1902`, `:515`).
There is no *pink* OTA state: the "can't join" animation uses `colors.white`, same as the
progress ring. Full detail in
[LED feedback during an OTA](/subsystems/ota#led-feedback-during-an-ota).

## The two things called "hotspot"

These are unrelated and earlier revisions conflated them.

### 1. `totemupdate`, a network the device joins — still live

A phone personal hotspot (or any AP) named `totemupdate` with the key `totem1234`,
on 2.4 GHz. The device joins it in **station** mode via `connect_to_known`, exactly like
any other entry in `get_known_networks()`. Documented above; not deprecated.

### 2. `f_ota/hotspot.py`, the device as an access point — dead code

Historically the device could become the AP and serve a web UI to a phone. The module is
still compiled in but nothing imports it, and `ota_mgr` refuses the command that used to
reach it:

```python theme={null}
elif cmd == 2:
    raise OtaErr('OTA Hotspot has been sunset', ErrCode.operation_failed)
```

(`f_ota_main.dis:1076`.) For the record, what it did (`f_ota_hotspot.dis:601`):

| Parameter    | Value                                                                                                                                                     |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Soft-AP SSID | `'{}_{}'.format(cfg.hostname, cfg.mac[-4:])`, e.g. `mytotem_a1b2`                                                                                         |
| Soft-AP key  | `totem1234`                                                                                                                                               |
| `ifconfig`   | `('10.9.8.7', '255.255.255.0', '192.168.0.1', '8.8.8.8')`                                                                                                 |
| Served from  | `m_assets/web/`, over `HTTP/1.0 200 OK` / `404 Not Found`                                                                                                 |
| Upgrade      | RFC 6455 handshake (`Sec-WebSocket-Key`, the `258EAFA5-…` GUID, `HTTP/1.1 101 Switching Protocol`)                                                        |
| Wrote        | `user-config.json` (`WiFi Network Saved`) and `perform.ota`                                                                                               |
| Log lines    | `Starting server...`, `Hotspot launched: {}`, `WebSocket Init Request...`, `WebSocket Connection initiated...`, `Server max inactivity period reached...` |

It also set `ota_status.step_id = 3` — the only assignment of that value anywhere, which
is why the LED callback's `step_id >= 3` test reads oddly on the surviving path.

## WiFi capabilities present

`f_lib/wifi.py` exposes exactly seven methods (`f_lib_wifi.dis:183`): `__init__`,
`connect`, `connect_to_known`, `disconnect(power_off=True)`, `get_nearby_networks`,
`power_on`, `scan(max_n=10)`. `scan` powers the interface on, calls `wlan.scan()` and
sorts the result in reverse (signal-strongest first); `get_nearby_networks` turns that
into the `nearby_networks` list `connect_to_known` intersects against.

<Note>
  The string `WLAN settings changed to (pwr, proto, txpower) ({}, {})` lives in
  `espnow_conn_v2.dis:2735`, not in the WiFi driver — it belongs to the ESP-NOW radio
  manager, which owns the same 2.4 GHz interface during normal operation. Earlier revisions
  of this page attributed it to the WiFi stack.
</Note>

The radio is shared with BLE and ESP-NOW, which is why `prep_wlan()` tears it down with
`disconnect(power_off=True)` and brings it back up with power save disabled before an
update, and why `stop_ota` disconnects again on the way out.
