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.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:
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).
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 followscmd, 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):
.bin, .tgz) can ever match, and the first match wins.
OTA server contract (WiFi path)
Plain HTTP againsthttp://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):versioniscfg.version, the version being asked for ('latest'unlessperform.otasaid otherwise) — not the running release code.release_idissyst.release_id or 0→ 339 on v5.0.3, 335 on v5.0.2.device_type_idissyst.device_type_id→ 1.gnss_timeiscfg.gnss_time or 0, butlatandlonare emitted as rawcfg.lat/cfg.lonand staynullwhen there is no fix. The function computesor 0fallbacks for lat/lon into locals and then never uses them — dead code.
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:
A name without
{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:_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
sourced from
POST http://api.totemportal.com/devices/{MAC}/ota?updated with exactly ten keys
(record_release, ota_callback.dis:974):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):
update_method == 1and nocfg.ota_url→OtaErr('OTA URL or release ID must be specified', ErrCode.param_missing).networks = get_known_networks();hostname = '{}_{}'.format(cfg.hostname, cfg.mac[-4:])(cfg.hostnamedefaults to'mytotem').ota_status.step_id = 2;await prep_wlan().priority_nw = user_cfg.wifi_ssid or None.await local_wifi.connect_to_known(networks=…, hostname=…, priority_nw=…);ConnErris re-raised asOtaErr('{} | code: {}', ErrCode.conn_failed).Connected to WiFi;cfg.rec_time('Connected to WiFi');ota_status.step_id = 4.get_release_details(cfg.endpoint_id, cfg.version)theninstall_preview_update()orinstall_firmware_update().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
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; ifota_status.is_wifi_otaandcfg.max_retries > 0, it decrementsmax_retriesand writes a freshperform.otacontaining{'ota_cmd': cmd, 'max_retries': …, 'attempt_count': cfg.attempt_count + 1, 'version': cfg.version}(plusnetworksif set), then arranges a soft reset so the next boot retries.cfg.max_retriesdefaults to0, so nothing retries unless a trigger asked for it.Attempt: {} of {}is logged at the top ofstart_ota. - On success:
OTA completed successfully!,cfg.show_time(),save_obj('system.json', syst),ota_status.step_id = 7. - Either way, if
cfg.callbackandcfg.is_await_cbit waits onota_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()— andis_del_vfsdefaults toTrue.del_vfs()walksos.ilistdir()at the root,rmtrees every directory andos.removes every*.mpyfile (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 theota_callbackandgarbage_collectiontasks;Ending Free mem: {}.if cfg.is_reboot(defaultTrue): sleeps 5 s first if the run failed, thenota_reboot(delay_sec=0, is_soft_reset=<retry pending>), which counts down with\rRebooting device in {:2} seconds (ctrl-C to cancel)and callsmachine.soft_reset()ormachine.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):
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.pyis a length comparison inBlockDevWriter.close()(f_lib_firmware_ota.dis:639):i.e. bytes written must equal the server’s ownContent-Length. There is no SHA-256, no signature, anddownload_sizecomes 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 corruptis 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 byota_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.3sha256appears inf_ble_chunking.dis,ota_ble.dis,f_ble_file_upload.dis,f_lib_file_mgr.dis(the last is new in v5.0.3, wheregen_file_hash(path, buff=None, yield_every=4)is added) plus unrelated hits inmipandpeer_helpers. It appears in neitherf_ota_install_ota.disnorf_lib_firmware_ota.dis. -
No firmware signature on either path. The
ECDSA with SHA256/ECP_VERIFY_FAILEDmachinery 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.
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).