f_lib/requests.py.
The OTA release poll, the contents.json index fetch, the firmware download and the
completion report are all documented under OTA and
WiFi OTA — but those pages describe the exchange, and assume the
client underneath behaves like a normal HTTP client. This page is the decode of the client
itself, plus f_lib/wifi.py, the module that gets the radio associated before any of it
can run.
Evidence convention on this page. Claims are confirmed when they were read
directly out of the v5.0.3 disassembly (cited as
file.dis:line), inferred when they
are reasoned from strings or structure, and called out explicitly when they are not
recoverable from the frozen bytecode. Line numbers refer to re/v5.0.3/mpy/*.dis.The Python shown below is reconstructed: the source is not in the image. The
operations, their order and the constants are exact; names of local variables that the
bytecode does not carry are ours, and are marked where it matters.
The four importers of
f_lib/requests.py are data_upload_v2, f_lib/firmware_ota.py,
f_ota/install_ota.py and ota_callback (confirmed: IMPORT_NAME f_lib.requests in
exactly those four of the 94 disassemblies). The two importers of f_lib/wifi.py are
f_ota/main.py and f_ota/hotspot.py.
There is a second, unrelated requests/__init__.py in the image (the stock
micropython-lib client). Nothing on any device path imports it — its only importers are
mip/__init__.py and urequests.py (confirmed). Everything below is f_lib/requests.py.
Is there TLS, and is anything validated?
Short answer: there is a TLS path, no first-party URL ever reaches it, and if one did the certificate would not be checked. This confirms — and strengthens — the statement on OTA that the OTA exchange is plain HTTP. Three separate findings, each confirmed:1
The client does implement https
request carries a two-entry scheme table. The default is (80, False)
(f_lib_requests.dis:802); a URL whose scheme is exactly https: triggers a lazy
import ssl (:812) and replaces it with (443, True) (:814). Any other scheme
raises RequestErr('Unsupported HTTP protocol: {}'). So https:// is a supported
scheme, not a rejected one.2
No first-party URL uses it
Every host literal belonging to this product is
http://: http://api.totemportal.com
and http://datapeak-developer.s3.us-east-1.amazonaws.com (plus the
http://datapeak-developer.s3.us-east-1.amazonaws.com/{} template in project_main).
A search of all 94 disassemblies finds https:// in exactly two files, mip/__init__.py
and webrepl.py — stock MicroPython modules, neither of which is on the OTA, upload or
mesh path. The TLS branch is dead in stock firmware.It is not dead code, though: cfg.ota_url is server-supplied (the endpoint field of
the release object) and a trigger file can override it, so an https:// endpoint would
be honoured. See OTA server contract.3
If it were used, nothing would be verified
The wrap is one call, with exactly one positional argument and one keyword
(No (
f_lib_requests.dis:941–:946):cert_reqs, no cadata, no ca_certs, no key/cert. The ssl module it calls is
also frozen into the image (ssl.py, version 0.2.1, a shim over the native tls
module) and its defaults are readable:ssl.dis:253–:303; the defaults tuple (False, None, None, CERT_NONE, None, None, True)
is at ssl.dis:32–:40.) With the call above, cert_reqs stays CERT_NONE and
cadata stays None, so load_verify_locations is never reached and no trust store is
ever loaded. SSLContext.__init__ independently sets self._context.verify_mode = CERT_NONE
on every context it builds (ssl.dis:124–:134).A disassembler footnote you need in order to read
(443, True) above. The .dis
files render that constant as (443, Ellipsis) (f_lib_requests.dis:159). That is a
mapping error in the reconstruction pipeline, not an Ellipsis in the source:
re/tools/mpyrecon.py:106 maps the ROM immediate-object word 0x16 to true and 0x1E
to ellipsis, while MicroPython’s REPR_A immediates are None = 0x06,
False = 0x0E, True = 0x1E (bools are immediates 1 and 3 so that one bit gives the
value). Two corroborations: no genuine True object appears in any obj_table in the
image — the only True substrings are inside log strings — and the other Ellipsis
site, compassing.dis:1423, returns (Ellipsis, 0) from a function whose other returns
are (False, 3), (False, 1) and (False, 0) (compassing.dis:156). Reading
Ellipsis as True is therefore inferred, but the inference is tight. Nothing else
on this page depends on it: the actual TLS decision is made by re-testing the scheme
string, not by reading that flag (see below).f_lib/requests.py
The module body is five imports, three classes and nine module-level functions
(f_lib_requests.dis:160–:265). Its own imports are asyncio, gc, json, socket,
time, micropython.const, f_lib.file_mgr.copyfileobj and f_lib.logger.log/ErrCode.
time, const and copyfileobj are imported and never used.
The public surface (CONFIRMED)
Defaults below are exact — they are theMAKE_FUNCTION_DEFARGS tuples built in the module
body (f_lib_requests.dis:224–:263).
RequestErr and the verb wrappers are the only names imported elsewhere, along with
SocketWrapper, Downloader and rest.
The six verb wrappers take
**kw, not *args. Their scope flags are 3
(f_lib_requests.dis:1596 and the five siblings), and the call they emit passes the local
dict as a keyword pair whose key is MP_OBJ_NULL with a star-args bitmap of 0 — the
MicroPython encoding for **kw. Cross-checked against ssl.dis:121, whose SSLContext.__init__
has flags 4 and emits a bitmap of 1 for a real *args, and against
asyncio_funcs.dis:263 (gather, flags 13). This matters because f_lib/firmware_ota.py
calls get(url, auth=auth) (f_lib_firmware_ota.dis:1176) — which only type-checks under
the **kw reading.The request pipeline
f_lib_requests.dis:718–:970.) Points worth stating, all confirmed:
- HTTP/1.0 with
Connection: close, always (:950,:1038). There is no keep-alive and no connection pool; every request opens and tears down a socket. The response body is therefore delimited by the server closing the connection, not byContent-Length— which is whyDownloadercan read to EOF (below). - The path is whatever followed the third
/, verbatim.url.split('/', 3)(:778), falling back tosplit('/', 2)with an empty path onValueError(:785–:796). No percent-encoding, no normalisation, no rejection of CR/LF. A URL with no path yieldsGET / HTTP/1.0. use_sslis dead. Local 20 is stored at:805and:817and never loaded anywhere in the function. The TLS decision at:936re-testsproto == 'https:'instead. Harmless today, but it means a future edit to the scheme table alone would not change behaviour.headers={}is a shared mutable default, andauthwrites into it.headers['Authorization'] = ...at:771mutates whatever dict was passed — including the module-level default built once at:227. A call that suppliesauthand noheadersleaves theAuthorizationheader in the default dict for every later call that also omitsheaders. On current firmware the onlyauth=caller isf_lib/firmware_ota.py:_open_url, andcfgcarries no credentials, so nothing is leaked in practice — but the mechanism is there.- Basic auth is built as
b'{}:{}'.format(user, pw), base64’d with the trailing newline stripped (:748–:770).str.formaton abytesliteral is a MicroPython extension; this would not run on CPython.
Body and JSON
:1002–:1082.) chunked is decided up front by duck-typing data: iterable but with no
__len__ (:721–:731). A str, bytes, list or dict all have __len__, so only a
generator selects the chunked branch. Note that Content-Length is len(data) — for a
dict that would be the key count, and the later s.write(data) would fail; the module
is safe only because every caller passes an already-serialised string (see
rest).
Status line and headers
request reads the status line with readline(), splits it on whitespace into at most
three parts and requires at least two, else it closes the socket and raises
RequestErr('HTTP error: Bad Status: {}', ErrCode.operation_failed) (:1085–:1112). The
status is int(parts[1]); reason is parts[2].rstrip() when present.
Then a header loop until a blank line, with three special cases (:1130–:1252):
parse_headers is tri-state (:1215–:1248): False skips parsing entirely and leaves
resp.headers as None; True (the default) does the split above; anything else is
called as parse_headers(line, resp_d). No caller in the image passes a callable.
Redirects
:1167–:1212, :1275–:1305.) Four things are true of this, all confirmed:
- The recursive call is not awaited.
requestis a generator function (scope flag1,f_lib_requests.dis:714), and both recursive calls areCALL_FUNCTION 6followed immediately byRETURN_VALUEat:1296and:1305— there is noGET_ITER/YIELD_FROMpair, which is how every otherawaitin this file compiles (compare:856,:1461). So a 3xx response makesawait requests.get(url)evaluate to a coroutine object, not aResponse. The caller’s nextresp.status_coderaisesAttributeError. In other words redirects do not work at all, in either branch. - There is no redirect limit. Nothing counts hops; the depth is bounded only by the fact that the recursion never actually runs.
- The scheme is not re-checked against the original. The
Locationvalue is re-parsed from scratch by the recursiverequest, which acceptshttp:as readily ashttps:. Anhttps://request redirected tohttp://would be downgraded silently — were the recursion awaited. auth,timeoutandparse_headersare dropped on redirect: only six of the nine parameters are forwarded.
l[10:-2] assumes exactly Location: plus one space and a trailing CRLF; a
server that omits the space loses the first character of the URL.
Response
:450–:481.) request fills in status_code, reason, headers and content_length
after construction (:1307–:1341).
Response.read is the odd one. On the first call (_chunked still 0) it prints a
three-column progress banner to stdout and sets _chunk_size = content_length; thereafter
_chunked accumulates the running byte total and each call returns
raw.read(min(size, _chunk_size)), printing a row per chunk (:589–:710). So
_chunked is a counter, not a flag, despite the name, and the _chunk_size == 0 branch
that follows — which sets _chunk_size = len(readline().strip()), the length of the chunk-size
line rather than the hex value it contains — is not a working chunked-transfer decoder. It
is unreachable in practice because request rejects Transfer-Encoding: chunked before a
Response is ever built. The progress rows go to print, not to
the logger, so they appear on the serial console only.
Downloader
:1420–:1593.) Behaviour worth pinning down:
- It reads
resp.rawdirectly, notresp.read()(:1553). The loop runs untilreadintoreturns0— i.e. until the server closes the connection.content_lengthis used only to compute the percentage (:1497–:1520). A response longer than its ownContent-Lengthis written to the file in full; a shorter one simply ends early withpctbelow 100, and thewithblock still exits normally. gc.collect()runs after every 4 KiB block (:1541). On a device this small that is deliberate, and it is why the optionalpause_msyield exists at all.- It strictly requires
200— any other status, including 2xx, raisesRequestErr('Endpoint not found', ErrCode.operation_failed)(:1466–:1477). - The redundant
f.close()after thewithblock (:1573) is harmless.
await asyncio.wait_for(dl.file(url=url, dest=dest, pause_ms=20), 90)
(f_ota_install_ota.dis:583–:597) — a 90-second ceiling on the whole .tgz download,
with a 20 ms yield between blocks. The firmware (.bin) path does not use Downloader; it
uses SocketWrapper (below) and streams into the OTA slot. Both are described from the OTA
side under the install path.
SocketWrapper
Fourteen lines of bytecode (:306–:418): it holds one attribute f, returns it from both
__enter__ and __aenter__, and on exit closes f and — if any of exc_type, exc_val,
exc_tb is set — prints the three of them separated by newlines. It never suppresses the
exception and never logs through the logger.
Its single use is f_lib/firmware_ota.py:_open_url, which returns
SocketWrapper(resp.raw) so that a plain file and an HTTP body can be consumed by the same
with statement (f_lib_firmware_ota.dis:1155–:1211):
split(':', 1)[0] in ('http', 'https') rather than == 'https:'), and that the
Response object itself is dropped — only the raw socket survives, so the response is never
close()d except through the wrapper.
rest, the JSON wrapper
:1700–:1771.) Two details that the OTA pages depend on:
payloadis forwarded asdata, not aspayload(:1722–:1726).request’s ownpayloadbranch — the one that callsjson.dumpsand emitsContent-Type: application/json— is therefore never taken throughrest. Callers must serialise the body themselves and supply the header themselves, and they do:f_ota/install_ota.pybuildsheaders = {'Content-Type': 'application/json'}andpayload = json.dumps({...})before callingrest('POST', url=url, headers=headers, payload=payload)(f_ota_install_ota.dis:751–:805). The seven-key release poll documented under OTA server contract is unaffected — this just explains why the caller does the serialising.resp.json()returningFalseis indistinguishable from a body of literalfalse.Response.jsonusesFalseas its error sentinel (:548–:584) andresttestsbody is False(:1755), so a server answering the JSON documentfalseproducesRequestErr('Response payload unparsable', ErrCode.syntax_err). The same sentinel means an empty or unparsable body and a validfalseare the same thing to every caller.
Timeouts, or the lack of them
This is the weakest part of the module, and it is worth a table because three different timeout mechanisms are in play and two of them are effectively disabled.asyncio.wait_for in this firmware takes seconds — its default sleep parameter is
core.sleep, not core.sleep_ms (asyncio_funcs.dis:63–:68, :139), and the module
exports a separate wait_for_ms for the millisecond form. So timeout=10000 is a ceiling of
roughly two hours and 47 minutes on name resolution. It is inferred, not confirmed,
that 10000 was meant as milliseconds — nothing in the bytecode records intent — but the
value is the only four-digit literal in the file and 10 s is the conventional DNS ceiling.
Note also that the 500 ms sleep runs on the successful iteration too, so every request pays
it, and that _get_addr swallows OSError only — any other exception from getaddrinfo
escapes past the wait_for and out of request untranslated.
The practical effect on the OTA path: with no settimeout, a server that accepts the
connection and then stops sending blocks the whole asyncio loop indefinitely. What actually
recovers the device in that state is the OTA watchdog condition, not this module — see
OTA.
Other corners
OSErrorfrom anywhere in the send/receive block closes the socket and is re-raised, excepterrno == 118(EHOSTUNREACHon ESP-IDF), which becomesRequestErr('Host unreachable', ErrCode.operation_failed)(:1254–:1272). The numeric constant is compared directly and never named; that it isEHOSTUNREACHis inferred.streamis accepted byrequest, forwarded through both redirect branches, and never otherwise read. Dead parameter (confirmed: local 5 is loaded only at:1295and:1304).time,micropython.constandf_lib.file_mgr.copyfileobjare imported at module level (:175–:200) and never referenced.
f_lib/wifi.py
Two names: ConnErr(Exception) with __init__(self, msg='', code=None), and WiFi
(f_lib_wifi.dis:86–:142). The module aliases network.STA_IF to CLIENT and
network.AP_IF to HOTSPOT at import (:104–:117); HOTSPOT is imported only by
f_ota/hotspot.py, which is itself dead — see WiFi OTA.
Which WiFi module the OTA path uses (CONFIRMED)
Bothf_lib/wifi.py and f_lib/wifi_v2.py are in the image and both define a WiFi class
with connect, connect_to_known, disconnect, get_nearby_networks, power_on and
scan. They split by caller, not by version:
So the OTA path runs
f_lib/wifi.py, notwithstanding the
Preparing WLAN protocols for update (v2)... log line, which refers to the second-generation
procedure in f_ota/main.py, not to the module. This corroborates what
OTA already says.
wifi_v2 is out of scope for this page, but one difference is worth recording because it
explains the split: wifi_v2 carries four (pm, protocol) mode tuples — (0, 8), (1, 8),
(0, 7), (1, 7) — a set_mode method that applies them, and an is_conn_wifi that
returns True only when the interface is associated and wlan.config('protocol') < 8
(f_lib_wifi_v2.dis:100, :308–:330, :967). Protocol bit 8 is the long-range mode the
ESP-NOW mesh runs in; f_lib/wifi.py has no concept of it and always
forces protocol 7.
WiFi.__init__ (CONFIRMED, :234–:263)
protocol=7 is 11B | 11G | 11N; pm=1 is WLAN.PM_PERFORMANCE. Both are overridden
before the OTA join: f_ota/main.py:prep_wlan sets local_wifi.protocol = 7 and
local_wifi.pm = 0 and pushes them to the driver (f_ota_main.dis:437–:490), and
connect then sets pm to network.WLAN.PM_NONE regardless (:356–:365).
wlan is declared on the class but assigned on the instance, so the is None guard only
ever fires once per instance, not once per process. The firmware builds exactly one:
local_wifi = WiFi(mode=CLIENT) at f_ota/main.py import time (f_ota_main.dis:294–:298).
WiFi.connect (CONFIRMED, :264–:549)
- The association wait has no timeout of its own — it is a bare
while not isconnected(): await sleep_ms(30)(:392–:404). The 15-second ceiling comes entirely from theasyncio.wait_forinconnect_to_known; anything that callsconnectdirectly gets an unbounded wait. Nothing in the image does. reconnects=3isself.reconnects, set in__init__and never changed (:250,:343). This is the driver-level retry count, distinct from the two application-level attempts below.- The status codes
201,202and1001are compared numerically and never named (:505,:515,:525). That they areSTAT_NO_AP_FOUND,STAT_WRONG_PASSWORDandSTAT_CONNECTINGis inferred from the MicroPython ESP32 port; likewiseerrno == 110asETIMEDOUT(:490). network.hostname(hostname)is a global setting, not per-interface (:366–:372).- The
except asyncio.TimeoutErrorarm insideconnectis unreachable fromconnect_to_known:wait_forcancels the coroutine, and theTimeoutErroris raised in the caller, which has its own handler. It would only fire if something inside thetryawaited its ownwait_for. - The five
Device …:banner lines print unconditionally (:410–:470) — not gated oncfg.is_verbose, unlike the OTA banner.
Whether the join is constrained to a particular security mode is not recoverable here.
connect passes exactly (ssid, pwd) to the native WLAN.connect (:387–:391) and sets
no authmode threshold; whether the ESP-IDF driver applies one by default is a property of
the C layer, which is not in these disassemblies. The stored-network record does carry a
security_algo field elsewhere in the image (ble_manager.dis:473), but f_lib/wifi.py
never reads it — connect_to_known passes only networks[ssid] as the password.WiFi.connect_to_known (CONFIRMED, :550–:726)
Further confirmed behaviour:
- The scan loop is normally a no-op. It breaks immediately if
self.nearby_networksis already populated (:562–:566), andnearby_networkspersists on the instance across calls. A stale list from an earlier scan is reused rather than refreshed. priority_nwonly wins if it is also in range and known. It is matched against the scan results, not againstnetworks(:614–:634). If it is absent, the first scan-ordered match is taken — andscansorts by RSSI descending, so that is the strongest known network, not the first in the caller’s dict.- Attempts do not re-scan and do not try a second network.
nwis chosen once, before the attempt loop; both attempts target the same SSID (:645–:706). - The final check is
self.wlan.isconnected(), not the loop outcome, so a connection that came up by driver auto-reconnect after aTimeoutErrorstill counts as success.
f_ota/main.py calls it as
await local_wifi.connect_to_known(networks=…, hostname='{}_{}'.format(cfg.hostname, cfg.mac[-4:]), priority_nw=user_cfg.wifi_ssid or None)
(f_ota_main.dis:670–:682) — so max_n and attempts stay at their defaults, and every
ConnErr is re-raised as OtaErr('{} | code: {}', ErrCode.conn_failed).
Scanning (CONFIRMED, :785–:930)
x[3]is the RSSI field of MicroPython’s scan tuple, so the sort is strongest first (:907–:913, lambda at:931).self.wlan.scan()is not awaited (:897–:901). It is a blocking driver call; the asyncio loop stalls for its whole duration. This is whyconnect_to_knownsleeps 200 ms before calling it rather than after.- Hidden networks (empty SSID) and SSIDs that are not valid UTF-8 are skipped
(
:812–:848). Duplicates — the same SSID on two bands or two APs — collapse to one entry, so the RSSI ordering is by the strongest sighting. get_nearby_networksreturnsNone; its output is theself.nearby_networksside effect.
Power and teardown (CONFIRMED, :727–:886)
disconnect swallows RuntimeError silently and, with power_off=True (the default),
deactivates the interface even if the disconnect itself failed. connect calls it with
power_off=False so that the radio stays up between the teardown and the new association.
What could not be recovered
Stated plainly, because the absence matters as much as the findings:- Whether the ESP-IDF driver enforces a minimum authmode on
WLAN.connect(ssid, pwd). That is C, not frozen bytecode. - What the native
tlsmodule does onceverify_modeisCERT_NONE— cipher suite selection, protocol version floor, session reuse.ssl.pyis a thin shim; everything below it is compiled into the firmware binary. - Intent. That
timeout=10000was meant as milliseconds, that the missingawaiton the redirect branches is an oversight rather than a deliberate return of a coroutine, and thatuse_ssl/max_n/delaywere meant to be read — all of these are readings of dead or inconsistent code, not recorded facts. The code paths themselves are confirmed; the reasons are not. - Any runtime evidence. Nothing on this page was observed on hardware. The ESP32 emulator exercises the OTA exchange against this client, but the TLS branch, the redirect branches and the DNS retry loop are not covered by it.
- The
f_lib/wifi_v2.pydecode. Only its method list, its four mode tuples andis_conn_wifiwere read, enough to establish that it is not on the OTA path.