Skip to main content
Updates are handled by the f_ota/ package plus f_lib/firmware_ota.py and f_lib/firmware_rollback.py. On current firmware (v5.0.3, release_code 5.0.3 / release_id 339 / device_type_id 1 in f_ota/system.py) every update runs over station-mode WiFi. The device-as-hotspot path (f_ota/hotspot.py) is compiled in but imported by nothing and refuses to run — see WiFi OTA.
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.
The ESP32 emulator runs this exchange, checks everything the server says, and stops before writing a slot.

An OTA is two boots, not one

This is the single most important structural fact, and earlier revisions of this page had it wrong. Nothing that triggers an OTA downloads anything. A trigger only writes a parameter file and reboots; the download happens on the next boot, from a different entry point.

The perform.ota handoff

project_main.perform_ota() (project_main.dis:446) reads the file, deletes it unconditionally (so it is one-shot, whether or not it parsed), fills in defaults and calls f_ota.main.start_ota:
Every remaining key of the file is forwarded as a keyword argument, and f_ota.main.start_ota applies each one with setattr(cfg, k, v) only if hasattr(cfg, k) — so unknown keys are silently dropped (f_ota_main.dis:1221). Recognised keys are therefore exactly the attributes of f_ota.config.Config (see OTA session config). The BLE app sends these same fields: ble_manager.handle_ota() unpacks <bBbbbh plus two length-prefixed strings and returns {'ota_branch', 'ota_cmd', 'version', 'endpoint_id'} (ble_manager.dis:5727). project_main.force_ota(branch='totem_compass/pre_alpha', version='latest', **kw) is the REPL equivalent — note the pre_alpha branch default (project_main.dis:386). It fills in ota_cmd=3 and is_verbose=True if absent, writes perform.ota and calls machine.soft_reset() (project_main.dis:911).
perform.ota is written with f_lib.file_mgr.save_obj, a plain open(path, 'w') + json.dump with no temp-file/rename (f_lib_file_mgr.dis:1202). A power loss mid-write leaves a truncated file; rebuild_obj then returns False and the next boot falls back to the defaults above rather than failing.

Trigger sources

Command IDs

f_ota.main.ota_mgr(cmd) is the dispatcher, and the cmd number decides what kind of update runs (f_ota_main.dis:1076): Defaults differ by entry point: f_ota.main.start_ota(cmd=1, **kwargs) (f_ota_main.dis:162), ota_daemon.start_ota(cmd=1, …), but project_main.perform_ota() uses d.get('ota_cmd', 4) — so the SOS/BLE path with no explicit command performs a firmware update.

Package formats

The package type follows cmd, not the update method. cfg.update_method is a different thing entirely: 1 means “use cfg.ota_url as given”, 2 means “ask the API where to look” — start_ota sets it to 2 whenever cfg.endpoint_id and cfg.version (f_ota_main.dis:1221). cfg.save_to defaults to 'next' (f_ota_config.dis Config.__init__), and start_ota strips / from it and raises OtaErr('You cannot save downloads to root directory', ErrCode.param_invalid) if it ends up empty. get_release_contents() rmtrees the directory if it exists, else mkdirs it (f_ota_install_ota.dis:404). If the expected member is absent the update aborts: .tgz package not found in repo, cannot perform OTA / .bin package not found in repo, cannot perform OTA (ErrCode.not_found). contents.json is a JSON array of filenames. The pick is a fixed 4-character slice comparison, not str.endswith (f_ota_install_ota.dis:1191):
so only 4-character extensions (.bin, .tgz) can ever match, and the first match wins.

OTA server contract (WiFi path)

Plain HTTP against http://api.totemportal.com (API_ENDPOINT, f_ota_system.dis) and whatever host the release points at. There is no API key, client certificate, HMAC or image signature anywhere on this path, so a server you control can push firmware to a device you own (see Integrity). {MAC} below is f_lib.bitwise.get_mac_addr() = ''.join('{:02x}'.format(b) for b in machine.unique_id()) — 12 lower-case hex characters, no separators (f_lib_bitwise.dis:158). Earlier revisions of this page said upper-case; that was wrong.
1

Device polls for a release (only when update_method == 2)

POST http://api.totemportal.com/devices/{MAC}/ota, Content-Type: application/json, body of exactly seven keys (get_release_from_api, f_ota_install_ota.dis:672):
  • version is cfg.version, the version being asked for ('latest' unless perform.ota said otherwise) — not the running release code.
  • release_id is syst.release_id or 0 → 339 on v5.0.3, 335 on v5.0.2.
  • device_type_id is syst.device_type_id → 1.
  • gnss_time is cfg.gnss_time or 0, but lat and lon are emitted as raw cfg.lat / cfg.lon and stay null when there is no fix. The function computes or 0 fallbacks for lat/lon into locals and then never uses them — dead code.
On RequestErr it logs Rest Error: {} and returns False; on any other exception Cannot reach server and returns False. Neither aborts the OTA — get_release_details then falls back to the on-device URL.
2

Server returns the release inside a 'body' object

The reply is parsed by f_lib.requests.rest, which requires 200 <= status < 300 and JSON that is not empty-ish (Endpoint unavailable: {} / Response payload unparsable, f_lib_requests.dis:1696). The device then existence-checks six keys inside resp['body']:
get_release_from_api returns True only when both endpoint and release_code were present. Any other shape (including flat top-level keys) is ignored silently.
3

Fallback: parse the on-device URL

If the poll did not return True, get_release_details falls back to parse_ota_url(cfg.ota_url) (f_ota_main.dis:1172): strip /, split on /, and if there are exactly 5 parts take parts[3] as the product and parts[4] as the branch. cfg.release_code is then set to the requested version. With the default http://datapeak-developer.s3.us-east-1.amazonaws.com/totem_compass/totem that yields totem_compass / totem. Both halves failing is not an error here — wifi_update only raises OTA URL or release ID must be specified when update_method == 1 and cfg.ota_url is empty.With cfg.is_verbose the five banner lines print: Product:.......{}, Branch:........{}, Release Code:..{}, Release ID:....{}, OTA URL:.......{}.
4

Device fetches the package index

GET {cfg.ota_url}/{cfg.version}/contents.json, with ?uid={cfg.uid} appended when not cfg.is_cached (get_release_contents, f_ota_install_ota.dis:404).cfg.uid = rand_key(length=8) — 8 characters drawn from the 62-character alphabet A–Za–z0–9 as char_str[urandom(1)[0] % 62] (f_lib_helpers.dis). It is a cache-buster seeded from os.urandom, not derived from the MAC or the clock. (The % 62 gives a slight bias toward the first eight characters; irrelevant for its purpose.) cfg.is_cached defaults to True, so the parameter is normally absent; it is cleared when cfg.version == 'dev' or when a trigger passes is_cached=False (f_ota_main.dis:1221).validate_resp accepts any 200 <= status < 300, else OtaErr('Could not access: {}', ErrCode.not_found). Empty/unparsable JSON → contents.json syntax err, cannot parse (ErrCode.param_invalid).
5

Device downloads and installs

The chosen filename is appended to the same base: {cfg.ota_url}/{cfg.version}/{filename} (_get_endpoints, which simply returns [url_base + '/' + name, dest_dir + '/' + name]), logged as Firmware URL: {}.The version the device thinks it is installing is parsed from the whole URL:
A name without _v produces garbage with no exception. If it equals syst.release_code and not cfg.is_forced, the device logs Desired release already installed on device — and then downloads and flashes it anyway; the check has no early return (f_ota_install_ota.dis:1017).
6

Device reboots and reports

After a successful install the OTA callback posts POST http://api.totemportal.com/devices/{MAC}/ota?updated with exactly ten keys (record_release, ota_callback.dis:974):
sourced from config.boots, cfg.branch, config.age, syst.device_type_id, cfg.product, cfg.release_code, cfg.release_id, config.lat, config.lon, config.last_gnss_unix. If the reply carries body.device_id and syst.device_id is still None, it is stored and system.json is rewritten. A custom server need only accept it with 2xx.

The install path in detail

wifi_update(cmd) (f_ota_main.dis:620):
  1. update_method == 1 and no cfg.ota_urlOtaErr('OTA URL or release ID must be specified', ErrCode.param_missing).
  2. networks = get_known_networks(); hostname = '{}_{}'.format(cfg.hostname, cfg.mac[-4:]) (cfg.hostname defaults to 'mytotem').
  3. ota_status.step_id = 2; await prep_wlan().
  4. priority_nw = user_cfg.wifi_ssid or None.
  5. await local_wifi.connect_to_known(networks=…, hostname=…, priority_nw=…); ConnErr is re-raised as OtaErr('{} | code: {}', ErrCode.conn_failed).
  6. Connected to WiFi; cfg.rec_time('Connected to WiFi'); ota_status.step_id = 4.
  7. get_release_details(cfg.endpoint_id, cfg.version) then install_preview_update() or install_firmware_update().
  8. ota_status.step_id = 6.
prep_wlan() is Preparing WLAN protocols for update (v2)...await local_wifi.disconnect(power_off=True)wlan.active(True)sleep_ms(300)protocol = 7, pm = 0, wlan.config(protocol=7), wlan.config(pm=0)WLAN prep completed (f_ota_main.dis:437). Despite the (v2) in the string, f_ota/main.py imports ConnErr, WiFi, CLIENT from f_lib.wifi, not f_lib.wifi_v2 (f_ota_main.dis:162).

Writing the firmware image

install_firmware_update hands off to a background task and polls (f_ota_install_ota.dis:1017):
f_lib.firmware_ota.from_file (f_lib_firmware_ota.dis:1212) opens the URL, requires status exactly 200 (HTTP Error: {}), records resp.content_length into ota_status.download_size, and streams through BlockDevWriter.write_from_stream: a memoryview(bytearray(device.blocksize)) buffer, asyncio.sleep_ms(10) every 10th block, ota_status.downloaded_bytes = written, ota_status.progress = round(written / download_size * 100). On close it calls part.set_boot() and prints OTA Partition '{}' updated successfully. then Will boot from '{}' partition on next boot.; if the boot partition does not read back as expected it prints Failed to set {} as the next boot partition. and sets ota_status.code = 3. The preview path uses f_lib.requests.Downloader.file(url, dest, pause_ms=20) instead — a 160-byte read buffer, \rDownloaded: {} of {} bytes | {}% complete progress, wrapped in asyncio.wait_for(..., 90) and followed by a while dl.pct != 100 poll (10 ms steps) that bails after 100 s (Update took too long to download / Firmware update took too long).

Install flow

Status, errors and steps

OtaStatus (f_ota/config.py, f_ota_config.dis) has twelve fields, not four: step_id is only ever assigned in f_ota/config.py, f_ota/main.py and the dead f_ota/hotspot.py (grep -n 'STORE_ATTR step_id' *.dis → five sites): 1 at construction, 2 before WLAN prep, 4 after connecting, 6 after the installer returns, 7 after a successful stop_ota. The value 3 is set only by the sunset hotspot server, which is why the LED callback’s step_id >= 3 test looks off-by-one on the surviving path. OtaErr(msg, code) is an Exception subclass carrying msg and code (f_ota_config.dis). The ErrCode members referenced on this path are param_missing, param_invalid, not_found, conn_failed, timeout, operation_failed, invalid_auth. OTA_ACTIVE = 2 is a WdtConditions watchdog state (wdt_manager.py), not an OtaStatus member; OTA_MIN / OTA_MAX belong to rollback.

OTA session config

There are two unrelated classes named Config. f_ota.config.Config is the ephemeral OTA session state described here. project_data.Config is the persisted device configuration serialised to config.json. They share no fields and no code.
f_ota.config.cfg is a module-level singleton whose defaults are (f_ota_config.dis, Config.__init__): attempt_count=0, callback=None, enable_gc=False, endpoint_id=None, freq_hz=None, gnss_time=None, hostname='mytotem', is_await_cb=False, is_blocking=False, is_cached=True, is_del_vfs=True, is_forced=False, is_graceful=False, is_reboot=True, is_verbose=False, lat=None, lon=None, mac=None, networks={}, ota_url=None, max_retries=0, save_to='next', uid=None, update_method=1, version='latest', branch='', product='', release_code='', release_id=None, plus private _start_time / _time used by rec_time() and the show_time() timing table. Importing f_ota/main.py also sets cfg.freq_hz = machine.freq(), cfg.mac = get_mac_addr(), builds local_wifi = WiFi(mode=CLIENT) and loads user-config.json into user_cfg (f_ota_main.dis:162). cfg.is_graceful reads backwards from its name: stop_ota sets it to True whenever ota_status.progress != 100, i.e. it marks a handled failure, and start_ota returns not cfg.is_graceful (f_ota_main.dis:800, :1221).

Retry, cleanup and reboot

stop_ota(cmd) runs in a finally: on every exit (f_ota_main.dis:800), logging Cleanly exiting OTA:
  • On failure (progress != 100): ota_status.code = 3; if ota_status.is_wifi_ota and cfg.max_retries > 0, it decrements max_retries and writes a fresh perform.ota containing {'ota_cmd': cmd, 'max_retries': …, 'attempt_count': cfg.attempt_count + 1, 'version': cfg.version} (plus networks if set), then arranges a soft reset so the next boot retries. cfg.max_retries defaults to 0, so nothing retries unless a trigger asked for it. Attempt: {} of {} is logged at the top of start_ota.
  • On success: OTA completed successfully!, cfg.show_time(), save_obj('system.json', syst), ota_status.step_id = 7.
  • Either way, if cfg.callback and cfg.is_await_cb it waits on ota_status.project_ready, which the LED callback sets when it is done.
  • if cfg.is_del_vfs and ota_status.is_firmware_ota: del_vfs() — and is_del_vfs defaults to True. del_vfs() walks os.ilistdir() at the root, rmtrees every directory and os.removes every *.mpy file (f_ota_main.dis:350). After a firmware update the VFS is deliberately wiped so the new image’s frozen modules take effect.
  • cmd in (1,2,3,4)await local_wifi.disconnect(); stops the ota_callback and garbage_collection tasks; Ending Free mem: {}.
  • if cfg.is_reboot (default True): sleeps 5 s first if the run failed, then ota_reboot(delay_sec=0, is_soft_reset=<retry pending>), which counts down with \rRebooting device in {:2} seconds (ctrl-C to cancel) and calls machine.soft_reset() or machine.reset().
  • Finally restores machine.freq(cfg.freq_hz).

LED feedback during an OTA

ota_callback.ProjectOta.start() runs as the ota_callback task for the whole update (ota_callback.dis:497). Earlier revisions of these pages said no firmware string bound a colour to an OTA state; that was wrong — the mapping is in bytecode, not strings.

Rollback & slot management

The image uses the standard ESP-IDF OTA data partition with two app slots plus a factory image. f_lib/firmware_rollback.py is tiny — its only string literal is OTA rollback unsupported — so the logic below was read from bytecode (f_lib_firmware_rollback.dis):
cancel() is called unconditionally at every boot from the top of ota_daemon.py, before project_main is imported. The new image therefore self-confirms as soon as MicroPython reaches the frozen ota_daemon module — ESP-IDF anti-rollback will not undo a firmware that panics later in project_main. The only rollback that remains is the app-driven cmd == -1 path (Manually rolling back firmware, f_ota_main.dis:1076).
The strings ota data invalid, no current app. Assuming factory, not found otadata, Rollback is not possible… and Running firmware is factory do not appear in any of the 94 frozen modules of v5.0.3 (nor in v5.0.2’s 96). They belong to the native bootloader, not to f_lib/firmware_rollback.py.

Integrity

  • The WiFi firmware path has no cryptographic check at all. The only verification in f_lib/firmware_ota.py is a length comparison in BlockDevWriter.close() (f_lib_firmware_ota.dis:639):
    i.e. bytes written must equal the server’s own Content-Length. There is no SHA-256, no signature, and download_size comes from the same response being validated. Beyond that, integrity rests on the stock ESP-IDF bootloader’s image-hash check at boot (Image hash failed - image is corrupt is a native bootloader string, absent from the frozen bytecode — this wiring is inferred).
  • App-level SHA-256 exists only on the BLE transfer path. f_ble/chunking.py (fed by ota_ble.py) compares a digest supplied in the transfer metadata: File integrity confirmed! / SHA256 hash does NOT match (SHA256 final : {}, SHA256 origin: {}). In v5.0.3 sha256 appears in f_ble_chunking.dis, ota_ble.dis, f_ble_file_upload.dis, f_lib_file_mgr.dis (the last is new in v5.0.3, where gen_file_hash(path, buff=None, yield_every=4) is added) plus unrelated hits in mip and peer_helpers. It appears in neither f_ota_install_ota.dis nor f_lib_firmware_ota.dis.
  • No firmware signature on either path. The ECDSA with SHA256 / ECP_VERIFY_FAILED machinery belongs to the bundled mbedTLS stack and is not wired to the OTA install, so a custom OTA server needs no signing key.
  • The preview (.tgz) path has no integrity check either, and it unpacks over the filesystem root.
Both the release poll and the download run over HTTP with no authentication. Anything that can answer for api.totemportal.com, or for whatever host the API’s endpoint field names, can flash arbitrary code. See WiFi OTA.

Files the OTA path persists

save_obj(path, obj) is json.dump(obj if isinstance(obj, dict) else obj.__dict__, f) into a plain open(path, 'w'); on OSError with errno == 28 it prints No space left on device to save file and returns False (f_lib_file_mgr.dis:1202). rebuild_obj(path, obj=None, is_strict=False) returns False for a missing or unparsable file, returns the raw dict when obj is None, and otherwise setattrs only keys the object already has — so a corrupt file silently leaves defaults in place (f_lib_file_mgr.dis:934).