Skip to main content
The navigation stack turns GNSS position, IMU orientation, and magnetic heading into a “point me to my friend / to the meetup” compass. It spans compass.py, compassing.py, ubx_gnss.py, imu_fusion_auto.py, mag_wmm_data.py, task_mag_declination.py, nav_helpers.py, nav_logger.py, and peer_helpers.py.
Evidence convention. Everything below is marked confirmed (read directly from the v5.0.3 frozen bytecode, with a file.dis:line citation), inferred (decoded against an external spec such as the u-blox interface description), or not recoverable (the code is not in the frozen modules). Two dependencies — madgwick and c_stats — have no .mpy in the firmware image, so they are native C modules and their internals are not recoverable.

GNSS (u-blox)

The receiver is a u-blox module spoken to over the UBX binary protocol on a UART. UBLOX_MSG_PREFIX is a dict from the 2-byte class/ID pair to a name, and UBLOX_MSG_SIZE gives the expected total framed length (header 6 + payload + checksum 2) for the messages whose size is fixed — all confirmed at ubx_gnss.dis:365-394: calc_ubx_checksum is the standard UBX 8-bit Fletcher pair (CK_A/CK_B), confirmed at ubx_gnss.dis:2661-2690. Frames that do not start B5 62 are dropped; the b'$G' NMEA prefix appears in the module’s constant table purely so NMEA lines can be recognised and discarded.

The parser only runs once the device is bonded

UbxGnss.parser returns immediately unless modes.evt_bonded.is_set() (ubx_gnss.dis:1599-1610, present in v5.0.2 too). An unbonded totem streams UBX from the receiver but parses none of it, so it has no position, no time-to-first-lock and no declination until at least one bond exists. Confirmed.

Which NAV-PVT fields are actually used

The payload is unpacked with '<IHBBBBBBIiBBBBiiiiIIiiiiiIIHHBBBBihH' (ubx_gnss.dis:2023), which is struct.calcsize 92 and matches the u-blox NAV-PVT layout field for field. Only a subset of the 36 fields is read (confirmed, ubx_gnss.dis:2025-2160):
gnss_data.p_acc and the pAcc: {:.2f} log field are misnamed in the firmware: the value read is NAV-PVT’s hAcc (horizontal accuracy), not pAcc. The accumulators around it are named honestly — h_acc_min, h_acc_max, h_acc_sum — and the phone fallback log line calls it Compass hAcc. Confirmed, ubx_gnss.dis:2062-2095.
NAV-PVT does carry headMot (index 24), headVeh (34) and magDec (35); the firmware simply never reads them. Course-over-ground is computed from successive fixes instead — see Heading output.

Fix acceptance, quality tiers and smoothing

All confirmed in parse_ubx (ubx_gnss.dis:2154-2560):
  1. Plausibility warning. speedDist = Δt × gSpeed / 1000 is compared against coordDist = get_distance(new, previous_raw). If the position moved more than 10 m and more than twice as far as the reported speed allows, it logs GNSS Validation | speedDist: {:.3f} | coordDist: {}. This is a warning only — the fix is not rejected.
  2. Year gate. year < 2024 → return without using the fix.
  3. Accuracy gate. hAcc > MIN_LOCK_ACCURACY (15 m, project_data.dis:518) → GNSS accuracy too poor to use: {:.1f} meters | Sat count: {} | GNSS Time: {}, solution_id = 0, and the phone fallback is attempted.
  4. Quality tier — gnss_data.solution_id: 1 when hAcc ≤ 3.5, 2 when hAcc ≤ 15, 0 otherwise.
  5. Position smoothing. gnss_data.lat_q / lon_q are CSmoothQ(3) — a 3-sample rolling mean (project_data.dis:1536-1546; CSmoothQ itself comes from the native c_stats module, so only the constructor argument and the put/avg/reset method names are recoverable). A strong fix (hAcc ≤ 3.5) calls queue_reset() and then uses the raw coordinates; anything weaker is averaged over the queue before being published as gnss_data.location and mirrored into config.lat / config.lon.
  6. Derived values. speed = round(gSpeed / 277.8) → km/h (277.8 mm/s = 1 km/h). odometer accumulates round(gSpeed × Δt_seconds) and is therefore in millimetres.
  7. Time to first lock. On the first accepted fix, modes.gnss_ttfl = seconds since modes.booted, modes.is_take_snapshot = True, and Time to first lock: {} is logged.

Phone GNSS fallback

When the on-board fix is rejected, the paired phone’s position is used instead — but only if all of these hold (confirmed, ubx_gnss.dis:2288-2350):
  • gnss_data.phone_last_lock is set and modes.evt_rtc_ready is set,
  • gnss_data.phone_h_acc > 0,
  • the phone fix is younger than 16 000 ms, and
  • phone_h_acc < MIN_LOCK_ACCURACY (15 m).
It then logs Using Phone's GNSS location | phone hAcc: {} | Compass hAcc: {}, sets is_phone_gnss_used = 1, overwrites p_acc with phone_h_acc, publishes location = (phone_lat, phone_lon) and sets modes.evt_gnss_location.

Receiver configuration (CFG-VALSET)

set_config(option) writes one of four embedded CFG-VALSET (06 8A) frames (ubx_gnss.dis:784-880). Every frame’s length field and Fletcher checksum verify, so the bytes are confirmed; the key→name mapping is inferred from the u-blox interface description. Decoded key/value pairs of option 1: Enabling Dev GNSS Printout writes a 14-byte CFG-VALSET that sets both unidentified CFG-MSGOUT items (0x2091035A, 0x2091038C) to 1 — they are developer-only diagnostic messages, gated on config.is_dev_gnss. After any config write, set_config re-inits the UART to 115200 if it is not already there. UbxGnss.start writes option 1, and only if the baud had to change logs Saving GNSS config to BBR for faster reboots, waits 500 ms and writes option 4 (the BBR layer), then launches load_assist_now (ubx_gnss.dis:1297-1345). Confirmed. modify_nav_rate(mpm) writes a one-key CFG-VALSET setting CFG-MSGOUT-UBX_NAV_PVT_UART1 to 4 for mpm == 15 or 1 for mpm == 60, and logs GNSS Rate changed to: {} MPM (ubx_gnss.dis:897-955). With CFG-RATE-MEAS at 1000 ms that is one NAV-PVT every 4 s or every 1 s — i.e. MPM is literally messages per minute, corroborated by parser setting gnss_data.msg_interval_ms to 4000 for 15 MPM and 1000 for 60 MPM (ubx_gnss.dis:1717-1750). The rate is chosen in the once-a-minute branch of Compass.backend_checks (compass.dis:3161-3230, confirmed): 60 MPM by default, dropping to 15 MPM when the totem is held vertical and the RTC is already set, or when it is horizontal but gnss_data.is_sat_signal has never been true. The change is only pushed once modes.uptime_sec > 15.

Receiver lifecycle & auto-reset

Three long-standing claims about this area were wrong:
  • Only the hot start is ever used. reset_cold_start and reset_warm_start are defined in ubx_gnss.py and referenced from nowhere else in the 94 frozen modules. The single call site is tasks.launch(ubx_conn.reset_hot_start) at compass.dis:2988-2992.
  • Nothing is power-cycled. Auto-Reset GNSS fires when gnss_data.p_acc is set, ticks_diff(now, gnss_data.last_strong_lock) > 300000 (5 min), p_acc > 30 m and gnss_data.last_gnss_reset is None — so at most once per boot. reset_hot_start stamps last_gnss_reset, writes the 12-byte hot-start CFG-RST frame and sleeps 500 ms (ubx_gnss.dis:725-755). There is no power rail toggle and no re-initialisation.
  • hotcold.bin is not a GNSS cache. The name appears only in data_upload_v2 as one of the log files the device can upload (data_upload_v2.dis:660, 934). No frozen module writes it and ubx_gnss never mentions it.

AssistNow (A-GNSS)

To shorten time to first fix the device feeds u-blox AssistNow (MGA) data to the receiver from the on-device filesystem. load_assist_now (ubx_gnss.dis:956-1233) is fully confirmed:
  1. Waits for modes.evt_rtc_ready, logs Starting Assist Now ingestion.
  2. Logs Checking VFS for assist now files and enumerates get_files(dir_path='', prefix='agnss_'). Each candidate must contain _ and .; the substring after the last _ and before the . is parsed as an expiry unix timestamp. Files whose expiry is in the past are logged as Deleting invalid A-GNSS file: {} and os.removed; the newest still-valid file wins.
  3. If no valid file survives, or if modes.evt_gnss_location is already set (A-GNSS file not needed. Location already known), it returns without sending anything.
  4. Otherwise Loading AssistNow from: {}, then the file is streamed in 250-byte chunks, ceil(size / 250) of them. Each chunk clears assist_ack_received, writes, and awaits the MGA-ACK; up to 3 attempts per chunk with a 50 ms gap (Sending AssistNow chunk {}/{} ({} bytes), attempt {}, Chunk {} acknowledged successfully).
  5. On completion: AssistNow transfer completed successfully: {} chunks sent. Any exception logs Error during AssistNow transfer and returns False.
MGA-ACK handling in parse_ubx unpacks '<BBBB4B' and logs either Assistance used by GNSS - InfoCode: {} or Assistance NOT used by GNSS - InfoCode: {} before setting assist_ack_received either way (ubx_gnss.dis:1975-2018).
assist-now-b.bin is not the file the GNSS driver reads. That string exists only in two error messages inside f_ble_chunking.download_vfs (f_ble_chunking.dis:718, 887), and that function actually writes to self.file_name, whatever the transfer named it. The GNSS driver only ever looks for agnss_* files with an expiry timestamp in the name. No frozen module renames one into the other, so the link between the BLE-delivered blob and the agnss_* naming convention is not recoverable from the frozen bytecode.
Delivery of assistance data over BLE/mesh is covered by the message-format subsystem, not the GNSS driver.

IMU fusion

imu_fusion_auto.mpy is byte-identical between v5.0.2 and v5.0.3 (md5 f6508f8d…) and is the sole fusion module in v5.0.3, imported by compass, compassing, ble_manager, espnow_conn_v2, nav_logger and debugger. v5.0.2 also froze an older imu_fusion.py — an unused Madgwick Fusion/Cal implementation that nothing imported — which v5.0.3 removes. The orientation filter itself lives in a native madgwick module (madgwick.reset() and a 14-argument update taking accel, gyro, mag, the 3-element bias, dt and beta, returning a quaternion — imu_fusion_auto.dis:3207-3252; madgwick.reset() at :2662 and :2959). There is no madgwick.mpy in the image, so the filter’s maths is not recoverable — but the caller identifies the algorithm: the gain is built as sqrt(3/4) × radians(gyro_error_deg), which is Madgwick’s own published beta formula. See Sensor fusion decoded. Module constants (confirmed, imu_fusion_auto.dis:311-322):
  • Adaptive beta. When is_adaptive_beta is set, the filter gain is multiplied by _BETA_MIN_SCALE (0.3) while the gyro magnitude is ≤ 10 °/s, ramps linearly back toward 1.0 as it rises, and is unscaled above 50 °/s (imu_fusion_auto.dis:3155-3185). Confirmed, including the ramp denominator — a literal 40 assigned at :3025.
  • Calibration eligibility. A sample only counts toward 3D calibration when the gyro magnitude is < 280 °/s and the accel deviation is < _CAL3D_MAX_ACCEL_DEV (imu_fusion_auto.dis:3140-3152).
  • Shake detection. _SHAKE_ACCEL_DEV, _shake_count, _shake_start_ms drive a manual magnetometer bias reset (Manual bias reset triggered via shake pattern, is_shake_reset).
  • Tilt/orientation feeds the 2D↔3D compass mode switch and the orientation events below.

”Navigation ready” requires a full 360° spin

_spin_deg integrates the gyro’s yaw component projected through the current rotation matrix. Once abs(_spin_deg) >= 360 and _cal3d_count is non-zero, the module sets _cal3d_ready = True and fusion.is_nav_ready = True (an instance attribute, not a modes flag) and logs 3D cal navigation-ready | cals={} | n={} | spin={:.0f}deg (imu_fusion_auto.dis:3481-3535, log string at :3522). Confirmed. So is_nav_ready is not a logging flag — it means the wearer has physically rotated the totem through a complete turn since the last 3D calibration. A completed 2D calibration sets the same flag without any spin integration — see Which bias wins.

Sensor fusion decoded

The 4 392-line imu_fusion_auto.dis breaks down into a sample path, a single filter call, an orientation state machine and two independent magnetometer-calibration paths. This section is a line-by-line decode of all four; what is still missing is listed under What is not recovered.

The IMU is an ICM-20948 on I2C

Compass.start_fusion is the only place the inertial hardware is built (confirmed, compass.dis:6514-6648):
There is no c_icm20948_mag.mpy in the image, so it is a native C driver: the register writes, output data rate, full-scale ranges and the magnetometer’s own conversion cadence are not recoverable.
The six floats in compass.py’s constant table — 1.064422876, 0.5324427477, 0.3160999346, 0.006519494999999999, 0.01682035088, 0.01879789 — are not filter gains. They are two hard-coded zero-offset vectors subtracted from every raw sample (confirmed, compass.dis:6615-6633, each stored into a closure cell that read_coro then reads):The units follow from how _update uses the corrected values: the accelerometer magnitude is compared against 1.0 (imu_fusion_auto.dis:3100-3131), the gyro magnitude against 280 / 50 / 15 / 10 (:3141, :3169, :3440, :3166) and the magnetometer magnitude against mag_gate_min = 10 and mag_gate_max = 320 µT (:562-568, :4086-4101).
The closure start_fusion installs as fusion.read_coro (confirmed, compass.dis:6649-6708):
read_coro_cal — the alternate reader Fusion.calibrate awaits — is set to None in __init__ and no frozen module ever assigns it (imu_fusion_auto.dis:540, :2860, :2882), so Fusion.calibrate is dead code in v5.0.3. Confirmed.

The filter is Madgwick, and this is its gain

Fusion.__init__ derives both gains from Madgwick’s published formula beta = sqrt(3/4) · gyroMeasError (confirmed, imu_fusion_auto.dis:495-520): That the gain is literally sqrt(3/4) times a gyro measurement error in rad/s is a positive identification of Madgwick’s gradient-descent MARG filter, not just an echo of the module’s name. The quaternion integration itself still lives in the native madgwick module and remains not recoverable; what is recovered here is its full calling convention (confirmed, imu_fusion_auto.dis:3205-3252):
dt is ticks_diff(ticks_us(), prev_us) / 1_000_000, or 0.0001 on the first pass (:3186-3204). The quaternion is seeded to [1.0, 0.0, 0.0, 0.0] before the loop (:3013-3017) and madgwick.reset() is called from Fusion.start (:2959) and from _reset_bias when is_reset_fusion is set (:2662). Which gain is used, per sample (confirmed, imu_fusion_auto.dis:3155-3185):
The ramp denominator, flagged as inferred in earlier revisions, is a plain 40 assigned once before the loop and read exactly once here (:3025, :3182) — so the ramp reaches 1.0 precisely at 50 °/s and the gain is left unscaled above it. Confirmed. is_dirty is not set inside this module. espnow_conn_v2.communicate_v2 writes fusion.is_dirty = modes.evt_orien_vertical.is_set() on every communication cycle (confirmed, espnow_conn_v2.dis:2958-2967), so the gyro-error model doubles whenever the totem is held up and the filter chases the magnetometer harder.

Euler angles out of the quaternion

With q = (q0, q1, q2, q3), _update computes four angles per cycle (confirmed, imu_fusion_auto.dis:3253-3435): heading_tilt_compensated_deg is that last value wrapped as (h + 360) % 360 (:3429-3435), and heading_deg is the yaw wrapped the same way (:3536-3545).
heading_tilt_compensated is a different angle from heading — the two are computed from different rotation-matrix elements, and only heading feeds avg_azimuth. Which body axis the tilt-compensated variant measures heading about cannot be settled without the native madgwick module’s quaternion convention, so the geometric interpretation is not recoverable; the formula above is confirmed literally.

What downstream code actually reads

Every fusion.<attr> access in the other 93 frozen modules (confirmed, by exhaustive search for LOAD_GLOBAL fusion — each one is immediately followed by an attribute access, so the list is complete): heading_tilt_compensated, heading_tilt_compensated_deg, pitch, roll, is_nav_ready, peer_azimuth, bias_force, is_flash_3d, is_shake_reset, is_adaptive_beta, mag_gate_min, mag_gate_max, auto_cal_mag and cb_on_cal are never read or written by any other frozen module — they are internal state or developer hooks. Likewise Fusion.reset_bias, cb_reset_bias, cb_toggle_bias, force_bias and calibrate, and the module-level is_clockwise and toggle_mag_state, have no call site outside imu_fusion_auto.py. Confirmed. compassing.start_rotation renders nothing at all until the first heading exists: it returns immediately when modes.is_fusion_err is set (compassing.dis:657) and skips the frame body while fusion.heading is falsy (compassing.dis:688).

The spin integrator

_spin_deg is the totem’s accumulated rotation about the gravity vector, not about a body axis (confirmed, imu_fusion_auto.dis:3436-3535):
The third row of the rotation matrix is the vertical axis expressed in body coordinates, so the dot product with the gyro vector is the true yaw rate however the totem is tilted. The 15 °/s deadband stops gyro noise integrating into a false “spin complete”.

Orientation is a two-state machine with hysteresis

_update tracks a candidate state, a committed state and two timers (confirmed, imu_fusion_auto.dis:3606-3810). Orientation id 1 is vertical, 2 is horizontal: If neither condition holds the candidate is simply left unchanged — there is no third state and no else branch. A candidate is only committed when two separate hold times have elapsed, indexed by the state (confirmed, imu_fusion_auto.dis:3018-3021, :3712-3740): On commit the module clears one modes event and sets the other (modes.evt_orien_vertical / modes.evt_orien_horizontal), writes self.orientation, banks the elapsed time into sec_vertical or sec_horizontal, and — only when going horizontal — calls _auto_cal_reset(), so every time the totem is laid down the 2D calibration run starts from empty buckets. It then stops the opposite task and launches cb_orientation_change as orient_vert or orient_horiz with orientation_id=<state>. compass installs Compass.orientation_change as that callback (compass.dis:1349-1350). The whole block is skipped while fusion.is_orientation_lock is set. compass sets it only in restore_wdt_reboot, alongside forcing evt_orien_vertical, and clears it 3 000 ms later from unlock_orientation (confirmed, compass.dis:9443, :9484).

Shake-to-reset

While is_shake_reset is set and the totem is horizontal (confirmed, imu_fusion_auto.dis:3812-3906):
So: three violent samples within 2 s. is_shake_reset defaults to False and nothing turns it on, so this path is dormant in shipped firmware. Confirmed.

2D calibration: six heading buckets, min/max hard-iron fit

The 2D path runs only while the totem is horizontal and only after it has been settled in that pose for 200 ms (confirmed, imu_fusion_auto.dis:3908-3999). _auto_cal_reset builds six 60°-wide heading buckets — markers 0, 60, 120, 180, 240, 300 — each a Cal(max_len=5), which is a list subclass whose put appends and drops the oldest past the limit (confirmed, imu_fusion_auto.dis:708-764 and the Cal class at :346-407). Per sample:
get_rotation_dir (imu_fusion_auto.dis:4304-4347) returns 0 for “no movement”, and otherwise 1 or 2. The monotonic cases are cur > prev → 2, else 1; the two wrap cases are 0 after 300 → 1 and 300 after 0 → 2. Confirmed literally. Note that the wrap cases are the mirror of the monotonic ones — 300 → 0 is a forward sweep yet returns the same code as a backward one. Whether that is deliberate or a sign slip is not recoverable; its only effect is to make one sweep direction spuriously “reverse” at the seam and discard the run. _auto_cal_record is the fit itself (confirmed, imu_fusion_auto.dis:785-947): it takes the per-axis minimum and maximum across all 30 retained samples (six buckets × five) and returns their midpoint — a textbook hard-iron bounding-box centre:
The spin field in that log line carries the literal text xy-only when a 3D calibration already exists, and an empty string otherwise (:896-910). _apply_2d (imu_fusion_auto.dis:948-1011) then:
  • stores the fit as magbias_2d, and as magbias_3d too only if no 3D fit has ever succeeded;
  • stamps _last_cal_2d = ticks_ms(), clears _boot_bias, sets bias_src = 1 and is_nav_ready = True;
  • calls _refresh_active() (which persists to config.magbias, since is_persist defaults to True) and _finish_calibration();
  • sets auto_cal_mag = False and schedules _auto_cal_toggle to turn it back on 1 000 ms later;
  • writes config.last_mag_2d_cal = rtc.unix(precision=1), or 0 if the RTC is not ready.

3D calibration: iterative sphere fit with a planarity guard

The 3D path runs only while the totem is vertical (confirmed, imu_fusion_auto.dis:4000-4277). A sample is admitted through four gates, each with its own rejection counter: Accepted samples go into a 60-deep FIFO. Once it holds 12 or more and _cal3d_variance() — the mean squared distance from the sample centroid — reaches 80 (an RMS spread of ≈ 8.9 µT), _cal3d_record() runs the fit (imu_fusion_auto.dis:4240-4277). _cal3d_record (confirmed, imu_fusion_auto.dis:1470-2411) is four passes:
  1. Seed. Centre starts at the per-axis bounding-box midpoint; the centroid is kept separately.
  2. Sphere fit. Three refinement iterations. Each computes the mean radius from the current centre, then moves the centre by the mean of (r - r̄)/r scaled radius vectors — the standard iterative least-squares sphere centre.
  3. Quality. std is the RMS deviation of the sample radii from the mean radius. A 3×3 covariance matrix C is accumulated about the centroid, and eight power iterations recover the dominant eigenvector of trace·I − C — equivalently the eigenvector of C’s smallest eigenvalue, i.e. the normal of the plane the samples lie closest to. cov is then sqrt(vᵀCv) / r̄.
  4. Planarity guard. When cov < _CAL3D_PLANAR_RATIO (0.2) the samples are effectively coplanar, so the fit is under-determined along that normal: the new centre’s component along it is projected out and the previous magbias_3d value is kept for that direction. The log line then carries (planar).
Acceptance (confirmed, imu_fusion_auto.dis:2218-2245):
On acceptance: the first ever fit tightens the gates to _cal3d_min_dist = 13 and _cal3d_sample_ms = 200; _cal3d_count increments; a non-planar fit learns the interference gate as mean_radius × 0.7 … mean_radius × 1.3 (from _CAL3D_FIELD_TOL); magbias_3d is stored; bias_src becomes 2 unless a fresh 2D fit still governs; and the three rejection counters reset. is_flash_3d (default False, never set) would blink the ring teal. _finish_calibration is shared by both paths (confirmed, imu_fusion_auto.dis:2412-2447): _is_test_bias = False, modes.last_mag_cal = ticks_ms(), modes.evt_calibrated.set(), modes.is_mag_cal_needed = False, and config.last_mag_cal = rtc.unix(precision=1) when the RTC is ready. The counterpart lives in Compass.backend_checks, which sets modes.is_mag_cal_needed = True once the last calibration is more than 1 800 s old, or when there has never been one and uptime exceeds 1 800 s (confirmed, compass.dis:3262-3335); ble_manager reports the flag to the app (ble_manager.dis:6217).

Which bias wins

bias_src is 1 for the 2D fit and 2 for the 3D fit, and bias_force overrides it. _refresh_active(is_persist=True) resolves the pair into the magbias actually handed to madgwick.update (confirmed, imu_fusion_auto.dis:1012-1067):
So once a 3D fit exists the 2D source contributes only X and Y — the Z component always comes from the 3D fit. That is what _gap_xy() measures: the XY distance between the two biases (imu_fusion_auto.dis:1092-1134), reported as gap_xy=...uT in the handoff and override log lines. A 2D fit is only authoritative for 20 minutes (1200000 ms in _is_2d_active, imu_fusion_auto.dis:1068-1091; the same 1 200 s appears in seconds form in _bias_tick and in the src=2D (...s left) telemetry, :1220, :4153). _bias_tick, called once per cycle while the totem is vertical, performs the handoff (confirmed, imu_fusion_auto.dis:1177-1318):
  • If _boot_bias is pending and the RTC is ready, it restores config.magbias as the 2D bias — but only if config.last_mag_2d_cal is less than 1 200 s old — back-dating _last_cal_2d by that age, setting bias_src = 1 and is_nav_ready = True, and logging 2D bias restored from config | age=...s | cals=....
  • Otherwise, if bias_src == 1, the totem is vertical and at least one 3D fit exists, it calls _switch_to_3d('expired') as soon as the 20 minutes lapse — the Bias handoff 2D->3D (...) line. That is _switch_to_3d’s only call site in the whole image, so expired is the only reason string the handoff ever logs (imu_fusion_auto.dis:1312-1313). Confirmed.
set_boot_bias (imu_fusion_auto.dis:2732-2759) is what Compass.start calls with tuple(config.magbias) before fusion.start() (compass.dis:5585-5597): it loads the stored tuple into magbias_3d, arms _boot_bias only when config.last_mag_2d_cal is non-zero, sets bias_src = 2 and refreshes without persisting.

Bias resets

_reset_bias(is_persist=True, is_reset_fusion=False, is_random=True) (confirmed, imu_fusion_auto.dis:2579-2693; the public reset_bias wrapper defaults all three to True and logs Manual magbias reset (button/external) first) clears magbias_2d, _last_cal_2d, _boot_bias, _field_min/_field_max, _cal3d_count, is_nav_ready, _cal3d_ready and _spin_deg, restores the loose gates (9 µT / 100 ms), sets _is_test_bias = True, and sets magbias_3d to either (0, 0, 0) or a random vector. _random_bias (imu_fusion_auto.dis:2448-2578) rejects candidates until one has a magnitude between 25 and 75 µT, lies at least 25 µT away in XY from the previous bias, and — when the previous bias is non-trivial — sits more than 45° away from it in direction (_RESET_BIAS_MAX_COS). It is a test fixture, which is why it also arms _is_test_bias, the flag that waives the 3D acceptance check on bias-magnitude change.

What modes.is_fusion_err means

The flag is set in exactly two places, both meaning the IMU stopped answering (it is never cleared anywhere in the frozen modules, so it is one-way until reboot): Its one consumer is Compassing.start_rotation, which returns immediately when it is set (compassing.dis:657), so a dead IMU leaves the ring blank rather than pointing wrongly. Confirmed.

What is not recovered

Magnetic compass & calibration

Calibration

The 2D→3D “bias handoff” hands governance from the flat-plane fit back to the 3D fit when the 2D fit expires, 20 minutes after it was taken: Bias handoff 2D->3D ({}) | gap_xy={:.1f}uT | 2D age={}s | cals={}. The full mechanism — both fits, the gates that feed them and the rule that decides which one is active — is decoded under Which bias wins. All of this calibration telemetry lives in imu_fusion_auto; it is unrelated to Vibe Mode (new_vibe.py, a sound-reactive LED effect — see Power, input & sensors). The --- Magnetic Calibration Samples --- string cited in earlier revisions existed only in the unused v5.0.2 imu_fusion.py and is gone in v5.0.3. config.magbias defaults to the tuple (30.675, 21.6, -3.825); the negative third element is confirmed by an explicit UNARY_OP __neg__.

Declination (World Magnetic Model)

mag_wmm_data.py contains exactly one name, WMM_2025: a flat tuple of 90 six-element tuples (n, m, g, h, ġ, ḣ) covering degree and order 1…12 — the complete WMM main-field plus secular-variation coefficient set (90 = Σ(n+1) for n = 1…12). The first entry is (1, 0, -29351.8, 0.0, 12.0, 0.0), which is WMM2025’s published g₁⁰ and its secular variation, so the model is WMM 2025 (valid 2025–2030). Confirmed, mag_wmm_data.dis:6 (constant table) and :22-779 (the tuple build). task_mag_declination.py is a straight port of the NOAA geomag spherical-harmonic routine. get_declination(lat, lon, year=2025.1, alt=0) clamps year to ≤ 2030.0, builds the normalised coefficient tables with _load_coefficients(maxord=12) and evaluates _calculate(..., epoch=2025.0, maxord=12, ...). The WGS84 semi-axes 6378.137 and 6356.7523142 km and the geomagnetic reference radius 6371.2 km are local constants in _calculate (confirmed, task_mag_declination.dis:211-215) — they are not in mag_wmm_data, as earlier revisions of this page claimed. Both the calculation and its caller free the tables aggressively: _load_coefficients ends with del sys.modules['tasks.mag_wmm_data'] in a finally, and get_declination deletes its six working tables and calls gc.collect() twice.

When declination is recomputed

Compass.set_declination (confirmed, compass.dis:7430-7697):
  1. Awaits evt_gnss_location and evt_rtc_ready, sleeps 20 s, then awaits both again.
  2. Logs Setting magnetic declination. Needs gnss_data.gnss_date and gnss_data.location, else returns.
  3. Builds the decimal year as year + round(month / 12, 2).
  4. Skips the recomputation when a previous result exists and the device is both within 1000 m of config.dec_lat/dec_lon and less than 2 419 200 s (28 days) past config.dec_unix:
  5. Otherwise it records gc.mem_alloc(), imports task_mag_declination.get_declination, calls it, and in a finally deletes both sys.modules['task_mag_declination'] and sys.modules['mag_wmm_data'] before gc.collect(). It then logs Mag declination memory used: {} | Declination: {}.
  6. On success it applies fusion.mag_offset = declination × -1, mirrors it into config.mag_offset, stores config.dec_lat/dec_lon/dec_unix, and sets modes.is_save_config. Failures log Magnetic declination error and leave the old offset in place.
The negation matters: mag_offset is added to the raw tilt-compensated heading, so the stored offset is the negative of the WMM declination.

Heading output

Fusion._update produces, each cycle (confirmed, imu_fusion_auto.dis:3420-3600): avg_azimuth is averaged from heading_deg — the yaw angle — not from heading_tilt_compensated_deg. It is also the only Fusion output the rest of the firmware consumes; see What downstream code actually reads.
The wrap branch of avg_azimuth does not do what it looks like. The bytecode is (confirmed, imu_fusion_auto.dis:3556-3597):
Because a is rebound before max is evaluated, and the rebound a is always ≥ 360 while b is always < 360, max(a, b) returns a. The average then collapses to a % 360, i.e. min(previous, current) — the smaller of the two headings, not their circular mean. The author almost certainly meant a, b = min(a, b) + 360, max(a, b) as a single tuple assignment; MicroPython would have emitted a ROT_TWO for that, and there is none.In practice the effect is small: at 25 Hz consecutive headings differ by a fraction of a degree, so across the 0°/360° seam the output snaps to the smaller value instead of interpolating — a sub-degree error. It only becomes large if the heading jumps more than 180° between frames.
nav_helpers.get_heading_mot() is course-over-ground, not a NAV-PVT field (confirmed, nav_helpers.dis:53-89):
The odometer is in millimetres, so the gate is 10 m travelled, and the sentinel for “unknown” is -1, not None. nav_helpers.update_peer_location(peer) is what turns a peer’s coordinates into something the ring can draw: when the peer has lat/lon and our own gnss_data.location is set and not (0, 0), it sets peer.direction = get_azimuth(my_lat, my_lon, peer_lat, peer_lon) and peer.distance = get_distance(peer_lat, peer_lon, my_lat, my_lon) (confirmed, nav_helpers.dis:90-143). get_azimuth and get_distance come from the native c_stats module, so their formulas are not recoverable.

From heading to ring pixels

In Compassing.start_rotation (confirmed, compassing.dis:706-760):
  • config.is_persistent_north simply keeps a white pixel on magnetic-corrected north lit at all times. It is also reported to the app in a packed flags byte alongside is_compass_lock (ble_core.dis:678-690).
  • get_peer_px(mac, north_diff) returns None when peer.direction is None; otherwise get_dial_px(mac, get_bearing(peer.direction, offset=-north_diff)), where get_bearing(h, o) = (h + o) % 360 (confirmed, compassing.dis:455-488, 1347-1358). Since offset = -(360 - avg_azimuth), the bearing handed to get_dial_px is (direction + avg_azimuth) % 360; the sign is then reversed again by get_dial_px’s mirrored ring mapping (px = abs(px - 60)), so the two cancel.
  • get_dial_px(id, deg) maps px = int(deg // 6) onto the 60-pixel ring (RING_PX_COUNT = 60, project_data.dis:522), mirrors it, and then applies a per-id half-step easing filter against self._cache: abs(prev - px) <= 30 → px = round((prev + px) / 2), else px = round((prev + px + 60) / 2) % 60. It is a smoothing filter, not a static arc width.

Compass lock is a button lock, not a heading lock

config.is_compass_lock does not freeze the heading or the ring. Compass.touch_button_cb stores modes.touch_feature = feature, and then, if is_compass_lock is set, logs Compasss Lock Enabled and replaces every touch gesture handler (confirmed, compass.dis:8782-8850):
It is a child-lock: taps and holds stop doing anything except play the “max peers reached” feedback. Compass.toggle_compass_lock flips the flag, logs Turning ON/OFF compass lock, and re-runs touch_button_cb(feature=modes.touch_feature) to install or remove the overrides.

Peer staleness & proximity display

peer_helpers.is_peer_stale(peer, now) (confirmed, peer_helpers.dis:534-636):
So the staleness timeout is distance-scaled (600 s when the distance is unknown, otherwise twice the walking time between 2 and 20 minutes), and the 7200 s figure is separately the threshold for the is_unknown flag. v5.0.2 used 14400 s (4 h) for that flag; v5.0.3 halves it to 7200 s (2 h). compassing.get_peer_led_prox(peer) returns (marker, spread) where marker is Ellipsis for “on top of you, no direction” and False for “draw a directional arc”, and spread is the arc half-width from get_peer_spread: 3 at ≤ 40 m, 2 at ≤ 60 m, 1 at ≤ 100 m, 0 beyond (or with no distance). Confirmed, compassing.dis:1391-1508 (get_peer_spread at :1359-1390):
The v5.0.2 → v5.0.3 change here is precise: v5.0.2 had an extra early return immediately after the no-coordinates branch —
— so a stale peer was always drawn as a directional arc from its last known distance, skipping the RSSI and accuracy logic entirely. v5.0.3 deletes that branch, so stale peers now go through the same proximity path as fresh ones, and gain is_unknown = 1 when the accuracy check concludes you are standing on them. nav_logger.py records per-period navigation telemetry, schedulable remotely: Records are packed into events.bin with '<BiffH4behhBBhHBbb6B4B' (44 bytes) plus a '<6Bbh4B' (13-byte) sub-record per peer — both format strings confirmed at nav_logger.dis:164. Fields include the GNSS message count, h_acc_sum/min/max, p_acc, modes.horizontal_sec, power_level, BLE-active, LED brightness, a pack_flags byte over is_sos/is_charging/is_phone_gnss_used/is_app_conn/is_eco_mode, and minutes since modes.last_mag_cal; the exact index-to-field mapping is inferred from the pack order. check_log_storage (confirmed, nav_logger.dis:1211-1408) runs under a WdtBlockers.LOG_ROTATE guard and does considerably more than a size comparison:
So the normal rotation threshold is 7100 B, the fast threshold is 1024 B, and the “backlog empty” condition is enforced twice: f_ble_file_upload turns modes.is_nav_log_fast_rotate on and off with [Upload] Backlog empty; nav log fast rotation ON / [Upload] Backlog present; nav log fast rotation OFF (f_ble_file_upload.dis:1918-1950), and check_log_storage re-checks the file count itself. Rotated logs are gzipped to events-<unix>.gz, and the backlog is capped at 100 files.

Promo activation (time-limited point of interest)

Compass.activation_check creates a time-limited, geofenced point of interest that the compass then points at like a peer. In v5.0.2 the function existed but was never called; in v5.0.3 backend_checks launches it with tasks.launch(self.activation_check) (compass.dis:1943-1947). Confirmed both ways — activation_check has no LOAD_ATTR reference anywhere in v5.0.2’s compass.dis. The full flow (confirmed, compass.dis:3466-3745):
Decoding the embedded constants: peer_helpers.create_promo_bond(**kw) (confirmed, peer_helpers.dis:262-380) then builds a synthetic Peer — is_poi = 1, animation_id = 1, fixed lat/lon, p_acc = h_acc, peer_name, rgb, a ring pixel from get_next_peer_pixel(), and eat (expire-at unix) — inserts it at config.peers[mac], increments config.poi_count, sets modes.is_promo_bond = True, and logs Creating promo activation bond / [create_promo_bond] Created Bond!. If the MAC is already present it logs [create_promo_bond] Peer already exists and does nothing. Defaults when the caller omits them: mac='A77401000001', poi_name='POI', poi_rgb=(0, 0, 255), h_acc=5. Because the POI is an ordinary Peer with is_poi = 1, is_peer_stale returns 0 for it unconditionally, and get_peer_led_prox takes the directional-arc fast path for it as soon as is_sticky_heading is also set. The v5.0.2 equivalent, never executed, pointed at a different event:

Orientation events

imu_fusion_auto raises the task names orient_horiz / orient_vert, and the shared state is modes.evt_orien_vertical. Confirmed owners of the strings, since earlier revisions of this page lumped them together: modes.evt_orien_vertical is read in ten places across compass.py, including the once-a-minute branch that halves the GNSS nav rate — see Nav rate throttling.

Version deltas at a glance