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.Where the power code lives
device_power.py does not hold the power state machine — it only contains
shut_down_tasks, restore_ble_secrets_vfs and save_ble_secrets
(device_power.dis:203, the module’s children list). The state machine is spread over:
Power hardware
All pins below are confirmed fromCompass.__init__ (compass.dis:1275–1445).
Cell voltage is read through a fixed linear calibration (confirmed,
compass.dis:1446):
self.volt_smooth = CSmoothQ(5)
(compass.dis:1306), refilled once per backend_checks pass. backend_checks defaults to
interval_ms=3000 (compass.dis:1105), so modes.batt_volts is a ~15-second rolling
average.
Battery percentage
peripherals.get_batt_pct(max_volts, cur_volts) (peripherals.dis:296) converts volts to a
percentage against a piecewise-linear table stored as a single 232-byte bytes constant in
the module’s obj_table. The bytes are 58 little-endian (millivolts, raw) uint16
pairs, descending from (4120, 605) to (3190, 0) (confirmed — decoded from the
literal at peripherals.dis:313).
The whole function, recovered from bytecode:
- The
>= 4120 → 100and< 3190 → 0clamps are not gated on a learned maximum. A device that has never learned one still reads 100% at 4.12 V and 0% below 3.19 V. - The rescale window is strict on both ends (
3190 < max_mv < 4120), and because the divisormax_mv − 3190is always smaller than the numerator’s930, a learned maximum can only push the reported percentage up, never down.
The curve
Representative points from the 58-entry table, with the percentage the algorithm returns for exactly that voltage and no learned maximum:
Spacing is 10–20 mV from 4.12 V down to 3.50 V and then widens sharply (3.47, 3.43, 3.36,
3.28, 3.19) — the last 8% of the gauge covers 310 mV of real cell voltage.
v5.0.3 recalibration
Confirmed by decoding both tables. v5.0.2’s constant is 332 bytes / 83 pairs, topping out at(4380, 1000); v5.0.3’s is 232 bytes / 58 pairs, topping out at (4120, 605). The
lower 58 pairs are byte-identical between the two versions — v5.0.3 simply deleted the 25
entries above 4.12 V and renormalised against the new top raw value.
Battery % therefore reads noticeably higher on v5.0.3 and reaches 100% at a normal full
charge.
Learning the maximum
config.batt_max_volts starts at 0 (project_data.dis:1283), which is falsy, so a fresh
device applies no rescale at all. It is written from exactly one place — one_min_coro,
under if modes.is_charging (compass.dis:9596–9668) — and always as peak minus
0.04 V:
modes.batt_volts_max is the running volt_smooth.max() captured while charging
(compass.dis:2758). Branch A fires immediately when the new peak is at or above the
stored learned maximum; branch B needs five consecutive one-minute passes with no rise.
Both log lines (Max volts updated A: {} / Max volts updated B: {}) exist only here.
Battery health (power_level)
modes.power_level defaults to 0 (project_data.dis:782). backend_checks recomputes it
every pass and calls change_power_level only when it differs
(compass.dis:2760–2890, confirmed):
Three things this corrects or adds:
- Level 1 is dead.
change_power_levelimplements apower_level == 1branch, butbackend_checksonly ever produces 0 or 2, so nothing assigns it. - The ladder runs while charging too. Only the
< 3.15 Vcutoff sits in the not-charging branch; the health ladder, the touch sensitivity switch and the watchdog timeout adjustment all run on both paths (the charging branch’sJUMP 36skips only the cutoff). - The 3.45–3.65 V band is sticky. Once degraded, the device stays at level 2 until it climbs past 3.49 V, which prevents flapping around 3.45 V.
What each level does
change_power_level (compass.dis:6751), confirmed:
Level 0 — healthy
Sets
modes.evt_battery_usable. If evt_battery_charged was clear, stops the
low_battery task, restores the crystal (crystal_default() when vertical, or
init_touch(True) when horizontal and touch is off), then sets
evt_battery_charged.Level 2 — degraded
Clears
evt_battery_charged and evt_battery_usable, turns the crystal off, waits
10 ms, disables the Touch Crystal (init_touch(False)), launches
leds.crystal.breathe_v2 as task low_battery in red at brt=0.3, and sets
modes.is_save_config = True.evt_battery_charged is the gate the rest of the firmware actually reads. Confirmed
consumers:
This resolves the old “OTA gate: numeric threshold is not present in the binary” note. The
OTA threshold is recoverable — it is indirect. OTA requires health level 0, which means
batt_volts >= 3.65 V, or > 3.49 V if the device was already degraded.
Boot-time battery check
Compass.start samples the battery before the main loop and, if
modes.batt_volts < 3.45, waits on modes.anim_boot_batt_pct and then forces
change_power_level(power_level=2) (compass.dis:5494–5512, confirmed) — a device
booted flat comes up already in the degraded state.
Power modes (eco / normal)
Compass.change_power_mode(power_mode) (compass.dis:8446, confirmed):
Leaving eco mode accumulates
modes.eco_mode_sec from modes.eco_mode_start, so total
eco-mode time is tracked across a session. The function logs
Changing power mode from {} to: {}.
The power button’s single tap (Compass.toggle_brightness, compass.dis:8386) simply
flips between the two: it reads modes.is_eco_mode, XORs to the other mode, and calls
change_power_mode. It is a no-op while modes.is_power_down is set or the battery is
degraded.
config.power_mode defaults to 0 (project_data.dis:1376) — a value
change_power_mode rejects — so the persisted mode is only meaningful once the user has
toggled brightness at least once.
Shutdown and reboot
Compass.shut_down(method) (compass.dis:5256) is the single entry point and is
idempotent — it returns immediately if modes.is_power_down is already set. It persists
fusion.mag_offset into config.mag_offset, sets config.is_silent_boot when
modes.is_silent_reboot, disables touch, and then calls
device_power.shut_down_tasks(method=method).
device_power.shut_down_tasks(method=0, is_save_config=True) (device_power.dis:203,
confirmed) sets the BOOT_SHUTDOWN watchdog condition, bumps config.boots (and
config.boots_failed when modes.hw_err_code is set), saves BLE secrets unless
method == 2, stops the whole task list, powers ESP-NOW off, waits 50 ms and then:
For
1 and 2 it first turns the crystal and ring off (unless modes.is_silent_reboot)
and waits 100 ms so the LEDs are visibly dark before the reset.
Method 0 drives the same GPIO 4 line that the power button reads. The firmware is
electrically asserting the button line; that an external latch or PMIC interprets a
sustained low as “cut power” is inferred — the bytecode only shows the pin being driven
low, and nothing after it runs.
Callers, all confirmed:
power_control is wired to parser.cb_power_ctrl (compass.dis:1370), so a peer or the
app can reboot or power off the device remotely.
Reset cause at boot
project_main reads machine.reset_cause() once into _reset_cause and logs
Reset Cause: {} (project_main.dis:327–336). Two branches depend on it
(confirmed):
_reset_cause == 5(soft reset) →WDT.delete()immediately at import time._reset_cause == 3(watchdog reset) → the boot animation is skipped; the ring is force-powered on but not coloured (project_main.dis:656).
Sleep
The device uses light sleep only. A “~12 h for v5.0” endurance figure is not evidenced in the binary (it comes from the v5.0 release notes); the only12-hour literal,
[activation_check] Activation doesn't start for 12hrs, concerns promo-activation timing
(see Navigation), not battery endurance.
Light sleep
espnow_conn_v2 imports from machine import lightsleep (espnow_conn_v2.dis:641) and
calls it inside smart_sleep(sleep_duration_ms, speed_id) (:3556). smart_sleep returns
without sleeping when sleep_duration_ms < 10.
The decision to sleep is made in communicate_v2 (confirmed, espnow_conn_v2.dis:3400–3455):
After waking,
smart_sleep adds the elapsed time to modes.dev_total_lightsleep_ms and
modes.lightsleep_ms, and re-activates the WLAN driver (self.wlan.active(True)) unless a
WLAN kick is in progress.
The Awoke from lightsleep | Slept for: {} of {} | Total Sleep: {} | sleep duty: {:.3f}
line is emitted only when modes.is_timing_debug is set (espnow_conn_v2.dis:4156) —
it is a debug counter readout, not a normal-operation log.
Deep sleep is not used in this build
Confirmed by absence: no module in the 94 frozen modules callsmachine.deepsleep().
The only occurrence of the word is in a stock MicroPython boot.py template string inside
inisetup.dis.
Three artefacts previously read as evidence of deep sleep, and what they actually are:
Awake from Deep Sleep | {} | Free mem: {}does exist, but it is a bareprint()inproject_main(project_main.dis:1296) reachable only whenboot_mode == 2(thempy_dev.dev_commsdeveloper boot path) and_reset_cause == 4(DEEPSLEEP_RESET). Since nothing ever enters deep sleep, that reset cause never occurs.esp32.wake_on_ext0(pin=self.pin, level=esp32.WAKEUP_ALL_LOW)is called on every button press (button.dis,pressed), together withesp32.gpio_deep_sleep_hold(False). These arm a deep-sleep wake source that is never used — vestigial code from an earlier power design (inferred).f_lib/rtc_mem.pyis real, and what it stores is not sleep state. It has six importers, not the three an earlier revision of this page claimed as confirmed:ble_core,ble_manageranddevice_power(BLE bonding secrets, category 1),project_data(the category 2 device snapshot),project_main(which callsreload()at boot and reads the category 0 reboot-into-service mailbox), andf_lib/firmware_ota, which imports it and never uses it (confirmed — counted fromIMPORT_NAME f_lib.rtc_memacross all 94 modules). See f_lib for the frame format and the three categories.
Watchdog
wdt_manager.py builds a single module-level singleton, wdt_mgr = WdtManager()
(wdt_manager.dis:128). Everything below is confirmed from that module.
_timeout_ms = 2000, conditions = [True, False, False, False]
(so BOOT_POWER_ON is already set before anything runs), blockers = [0, 0, 0], and
_feed_loop is launched as task wdt_feed_loop.
WDT.delete() and WDT.status() are called as class methods. Stock MicroPython’s
machine.WDT has neither, so this is a Totem-patched WDT implementation (inferred from
the call shape).
Who sets what
Timeout follows battery health
backend_checks recomputes the timeout on every pass (compass.dis:2860, confirmed):
Recovery
A separate
ble_handoff_watchdog guards BLE TX-ownership handoff between the phone app and
the ESP, reclaiming TX priority when a stall is detected.
Scheduled BLE off-time (v5.0.3)
compass.ble_schedule_mgr turns BLE off on a schedule. In v5.0.3 it:
- waits for the BLE outbox to empty before a scheduled disconnect for at most 20 s
(
BLE outbox not empty after {} ms (App never confirmed the connection?) - disconnecting anyway), and skips that wait entirely whenble_conn.is_stalled; - also waits for the BLE log uploader to go idle
(
Waiting on BLE log upload to finish before scheduled disconnect); - after two or more consecutive hung links (
stall_count >= 2) keeps BLE off for 90 s instead of 30 s and skips the fast-reconnect window (BLE hung {} links in a row - staying off {} ms without fast-reconnect bursts); - force-stops BLE whenever
evt_battery_chargedis clear (Battery too low for BLE).
30000 / 90000 / 20000 literals are confirmed at compass.dis:5752–5757.
start_ble now ignores a reconnect request when BLE is already active
([Compass.start_ble] BLE already active; reconnect request ignored).
Touch Crystal
Capacitive input viatouch_button_v2.py, which wraps a custom native driver imported in
Python as c_cap_touch (IMPORT_NAME / STORE_NAME c_cap_touch), built on the ESP-IDF
touch_pad API (touch_pad_init failed, etc.) — not the stock MicroPython touch module.
The Touch Crystal is a separate input from the two physical buttons below. (v5.0.2 also
froze an io_mgr.py, but it contained only import io and had no importer; it is removed
in v5.0.3.)
Gestures
The driver reports an event id indexingTOUCH_EVENTS (confirmed, the tuple literal in
touch_button_v2.dis:115):
TouchButtonV2 carries a matching callback per gesture: cb_single_tap, cb_double_tap,
cb_triple_tap, cb_multi_tap, cb_short_hold, cb_long_hold, cb_ex_long_hold.
Hold thresholds default to (confirmed, touch_button_v2.dis, __init__):
Pairing is a touch-crystal long hold of exactly 1200 ms.
Enable sequence
TouchButtonV2.start() (confirmed) waits on modes.anim_boot_batt_pct, sleeps 500 ms,
and then holds off until at least 5500 ms have elapsed since modes.booted, logging
Booted: {} ago, waiting: {} before enabling touch for the remainder. It then calls
c_cap_touch.enable(27, self.on_touch), registers self.on_release as the hold-release
callback, turns auto-tune on, and re-applies low-battery mode if modes.batt_volts is
already at or below BATT_POOR_TOUCH.
Low-battery sensitivity
Module constants (confirmed,touch_button_v2.dis:171–176):
backend_checks drives the switch with ±0.04 V of hysteresis, and only while the driver is
running (compass.dis:2820–2855):
- on when
batt_volts <= 3.55and low-battery mode is not already active; - off when
batt_volts > 3.59(BATT_POOR_TOUCH + 0.04) and it is.
min(max(sens * 1.35, 0.25), 0.5), disables auto-tune and applies the pinned value,
logging [touch] Low battery mode ON | base_sens: {:.3f} → adjusted: {:.3f}. Turning it
off re-enables auto-tune, calls c_cap_touch.calibrate() and logs the single-value form
[touch] Low battery mode OFF | sens: {:.3f}.
Note that at battery health level 2 the Touch Crystal is switched off entirely by
change_power_level, so low-battery sensitivity only matters in the band between 3.55 V
and the level-2 threshold.
Native parameter validation bounds
These are not recoverable from the frozen bytecode —c_cap_touch is a native C
module. They come from the firmware image’s read-only strings
(re/v5.0.3/rodata_strings.txt:50–65) and are validation bounds, not defaults; the
defaults are compiled-in C constants and remain unrecovered.
The driver additionally enforces ordering:
short_hold_ms < long_hold_ms < ex_long_hold_ms
(short_hold_ms must be less than long_hold_ms, long_hold_ms must be greater than short_hold_ms, ex_long_hold_ms must be greater than long_hold_ms). The Python side calls
get_/set_ accessors for sensitivity, auto_tune, auto_tune_multiplier,
multi_tap_window, sample_interval and each hold threshold, plus calibrate,
get_baseline, get_threshold, enable, disable, is_enabled, read and
set_hold_release_callback.
Physical buttons (power & SOS)
button.AsyncButton drives both physical buttons. The constructor signature is
AsyncButton(pin, hold_ms=300, long_hold_ms=10000, wake=None) (confirmed,
button.dis, class body), and it opens the pin as Pin(pin, Pin.IN, Pin.PULL_UP) — the
buttons are active-low.
The wiring in Compass.__init__ is confirmed at compass.dis:1323–1444 and is
identical in v5.0.2 and v5.0.3:
cb_long_hold is a (callable, task_name) tuple, not a bare callable — it defaults to
(None, None) and is launched as tasks.launch(cb_long_hold[0], task_name=cb_long_hold[1]).
So the SOS alert is started by holding the SOS button, and a triple tap of the SOS
button starts an OTA update. On debug builds only (log._print_lvl == 1), start_ble
rewires the SOS single tap to dev_trigger_upload (compass.dis:6217), which requests a
BLE log upload and then falls through to sos_mute.
Gesture timing
Confirmed frombutton.dis:
Because the long hold is anchored to the press,
sw_sos’s 10 000 ms long hold fires 10 s
after the button first went down — 9.1 s after start_sos already ran at
hold_ms + 100 ms.
v5.0.3 debounce change
Confirmed by diffing the two modules. v5.0.2 haddebounce_ms = 50 and a debounce()
coroutine that set enabled = False, slept 50 ms and set it back
(v5.0.2/mpy/button.dis:513). v5.0.3 deletes that coroutine and instead uses
debounce_ms = 30 with an _edge_ms timestamp lockout tested at the top of the IRQ handler
changed and refreshed in both pressed and released. The effect — more reliable
single/double/triple-tap detection — is inferred.
SOS
sos_timing is a generator in f_lib/generators.py (next to breathe_effect); it paces
the SOS indication (inferred) rather than defining the trigger gesture.
Factory reset
Holdingsw_sos while the device boots triggers a factory reset (confirmed,
Compass.compass_reset, compass.dis:1602–1720):
compass_reset(is_init=True)readsself.sw_sos.pin.value()directly. If it is low (pressed), it logsChecking for factory resetand fills the ring.- It then extinguishes one ring LED per 20 ms for 60 iterations — a ~1.2 s progress wipe across the 60-pixel ring. Releasing the button at any point aborts.
- If the button survived all 60 steps, it logs
Perform a factory reset!, takes theVFS_WRITEwatchdog block, deletes every.binin/, removesuser-config.json, rewrites a defaultconfig.json, and callsshut_down_tasks(method=1, is_save_config=False)— a hard reset with no config save. - Otherwise the ring and crystal are turned off and
modes.evt_led_override_offis set.
The SOS triple tap calls
start_ota. Firmware updates run over direct
WiFi OTA (Performing Firmware update via WiFi, is_wifi_ota).
The older hotspot OTA path is still sunset in v5.0.3 (OTA Hotspot has been sunset),
so it is not the active update path despite f_ota/hotspot.py still being present.Vibe Mode (sound-reactive LEDs)
new_vibe.py (“Vibe Mode”, version string newvibe-1.10; v5.0.2: newvibe-1.9) is a
microphone-driven, sound-reactive ring LED effect (confirmed): it samples the
microphone ADC on GPIO 36 (mic.read_u16, new_vibe.dis:478) and writes the halo ring’s
pixel buffer (ring.buf) directly. It is not magnetometer calibration — earlier
revisions of this page called it “passive magnetic calibration”, which was wrong for both
v5.0.2 and v5.0.3.
compass.vibe_mode launches new_vibe.new_vibe as a task still named 'sound_react';
v5.0.3 removed the dead in-compass sound_react / set_buff code. The coroutine first
awaits modes.evt_non_eco_mode.wait() and then modes.evt_orien_vertical.wait(), and
thereafter re-checks a list of six events — evt_orien_vertical, evt_non_eco_mode,
evt_bonded, anim_gnss_search, evt_peer_mgmt_ended, evt_led_override_off
(confirmed, new_vibe.dis:160–192). So Vibe Mode never runs in eco mode, and never
while the compass is horizontal.
v5.0.3 tuning changes:
With the
_DBG telemetry gone, the v5.0.2 NEWVIBE … and #NEWVIBE-REC … log lines and
the task started, waiting on enable events / resumed after pause / task cancelled
strings no longer exist in v5.0.3. The magnetometer bias handoff and its gap_xy residual
(Bias handoff 2D->3D ({}) | gap_xy={:.1f}uT, 2D governs, gap_xy={:.1f}uT) belong to the
fusion engine (imu_fusion_auto), unrelated to Vibe Mode — see
Navigation.
Temperature sensor
ds18x20.py + onewire.py provide a DS18B20 1-Wire temperature driver (_search_rom,
DS18X20). Confirmed by absence: no module in the build imports ds18x20 — the string
appears only inside ds18x20.dis itself. The driver is frozen into the image and
instantiable from the REPL, but nothing in the firmware ever constructs it, and there is no
runtime-log evidence (no °C / celsius / temperature: {} string) of a sensor being
polled. Any use for temperature compensation or environment reporting is therefore dead
code in v5.0.3, not an active subsystem.