Skip to main content
The ESP32 emulator models the battery curve, the power modes, the sleep accounting and the watchdog’s blocker rule; its touch section models the gestures below.
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 from Compass.__init__ (compass.dis:1275–1445). Cell voltage is read through a fixed linear calibration (confirmed, compass.dis:1446):
Readings are smoothed through a 5-deep queue, 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:
Two consequences worth stating plainly:
  • The >= 4120 → 100 and < 3190 → 0 clamps 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 divisor max_mv − 3190 is always smaller than the numerator’s 930, 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_level implements a power_level == 1 branch, but backend_checks only ever produces 0 or 2, so nothing assigns it.
  • The ladder runs while charging too. Only the < 3.15 V cutoff 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’s JUMP 36 skips 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 only 12-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 calls machine.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 bare print() in project_main (project_main.dis:1296) reachable only when boot_mode == 2 (the mpy_dev.dev_comms developer 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 with esp32.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.py is 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_manager and device_power (BLE bonding secrets, category 1), project_data (the category 2 device snapshot), project_main (which calls reload() at boot and reads the category 0 reboot-into-service mailbox), and f_lib/firmware_ota, which imports it and never uses it (confirmed — counted from IMPORT_NAME f_lib.rtc_mem across 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.
State at construction: _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):
A degraded battery doubles the watchdog window to 4 s, giving the slower, brown-out-prone device more slack before a reset.

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 when ble_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_charged is clear (Battery too low for BLE).
The 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 via touch_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 indexing TOUCH_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.55 and low-battery mode is not already active;
  • off when batt_volts > 3.59 (BATT_POOR_TOUCH + 0.04) and it is.
Turning it on stores the current sensitivity, computes 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 from button.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 had debounce_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

Holding sw_sos while the device boots triggers a factory reset (confirmed, Compass.compass_reset, compass.dis:1602–1720):
  1. compass_reset(is_init=True) reads self.sw_sos.pin.value() directly. If it is low (pressed), it logs Checking for factory reset and fills the ring.
  2. 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.
  3. If the button survived all 60 steps, it logs Perform a factory reset!, takes the VFS_WRITE watchdog block, deletes every .bin in /, removes user-config.json, rewrites a default config.json, and calls shut_down_tasks(method=1, is_save_config=False) — a hard reset with no config save.
  4. Otherwise the ring and crystal are turned off and modes.evt_led_override_off is 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.