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):
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 inparse_ubx (ubx_gnss.dis:2154-2560):
- Plausibility warning.
speedDist = Δt × gSpeed / 1000is compared againstcoordDist = 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 logsGNSS Validation | speedDist: {:.3f} | coordDist: {}. This is a warning only — the fix is not rejected. - Year gate.
year < 2024→ return without using the fix. - 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. - Quality tier —
gnss_data.solution_id:1whenhAcc ≤ 3.5,2whenhAcc ≤ 15,0otherwise. - Position smoothing.
gnss_data.lat_q/lon_qareCSmoothQ(3)— a 3-sample rolling mean (project_data.dis:1536-1546;CSmoothQitself comes from the nativec_statsmodule, so only the constructor argument and theput/avg/resetmethod names are recoverable). A strong fix (hAcc ≤ 3.5) callsqueue_reset()and then uses the raw coordinates; anything weaker is averaged over the queue before being published asgnss_data.locationand mirrored intoconfig.lat/config.lon. - Derived values.
speed = round(gSpeed / 277.8)→ km/h (277.8 mm/s = 1 km/h).odometeraccumulatesround(gSpeed × Δt_seconds)and is therefore in millimetres. - Time to first lock. On the first accepted fix,
modes.gnss_ttfl= seconds sincemodes.booted,modes.is_take_snapshot = True, andTime 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_lockis set andmodes.evt_rtc_readyis 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).
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.
Nav rate throttling
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
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:
- Waits for
modes.evt_rtc_ready, logsStarting Assist Now ingestion. - Logs
Checking VFS for assist now filesand enumeratesget_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 asDeleting invalid A-GNSS file: {}andos.removed; the newest still-valid file wins. - If no valid file survives, or if
modes.evt_gnss_locationis already set (A-GNSS file not needed. Location already known), it returns without sending anything. - Otherwise
Loading AssistNow from: {}, then the file is streamed in 250-byte chunks,ceil(size / 250)of them. Each chunk clearsassist_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). - On completion:
AssistNow transfer completed successfully: {} chunks sent. Any exception logsError during AssistNow transferand returnsFalse.
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).
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_betais 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 literal40assigned 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_msdrive 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-lineimu_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 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):
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
Withq = (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
Everyfusion.<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):
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
Whileis_shake_reset is set and the totem is horizontal (confirmed,
imu_fusion_auto.dis:3812-3906):
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:
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 asmagbias_3dtoo only if no 3D fit has ever succeeded; - stamps
_last_cal_2d = ticks_ms(), clears_boot_bias, setsbias_src = 1andis_nav_ready = True; - calls
_refresh_active()(which persists toconfig.magbias, sinceis_persistdefaults toTrue) and_finish_calibration(); - sets
auto_cal_mag = Falseand schedules_auto_cal_toggleto turn it back on 1 000 ms later; - writes
config.last_mag_2d_cal = rtc.unix(precision=1), or0if 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:
- Seed. Centre starts at the per-axis bounding-box midpoint; the centroid is kept separately.
- Sphere fit. Three refinement iterations. Each computes the mean radius from the
current centre, then moves the centre by the mean of
(r - r̄)/rscaled radius vectors — the standard iterative least-squares sphere centre. - Quality.
stdis the RMS deviation of the sample radii from the mean radius. A 3×3 covariance matrixCis accumulated about the centroid, and eight power iterations recover the dominant eigenvector oftrace·I − C— equivalently the eigenvector ofC’s smallest eigenvalue, i.e. the normal of the plane the samples lie closest to.covis thensqrt(vᵀCv) / r̄. - 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 previousmagbias_3dvalue is kept for that direction. The log line then carries(planar).
imu_fusion_auto.dis:2218-2245):
_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):
_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_biasis pending and the RTC is ready, it restoresconfig.magbiasas the 2D bias — but only ifconfig.last_mag_2d_calis less than 1 200 s old — back-dating_last_cal_2dby that age, settingbias_src = 1andis_nav_ready = True, and logging2D 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 — theBias handoff 2D->3D (...)line. That is_switch_to_3d’s only call site in the whole image, soexpiredis 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):
-
Awaits
evt_gnss_locationandevt_rtc_ready, sleeps 20 s, then awaits both again. -
Logs
Setting magnetic declination. Needsgnss_data.gnss_dateandgnss_data.location, else returns. -
Builds the decimal year as
year + round(month / 12, 2). -
Skips the recomputation when a previous result exists and the device is both
within 1000 m of
config.dec_lat/dec_lonand less than 2 419 200 s (28 days) pastconfig.dec_unix: -
Otherwise it records
gc.mem_alloc(), importstask_mag_declination.get_declination, calls it, and in afinallydeletes bothsys.modules['task_mag_declination']andsys.modules['mag_wmm_data']beforegc.collect(). It then logsMag declination memory used: {} | Declination: {}. -
On success it applies
fusion.mag_offset = declination × -1, mirrors it intoconfig.mag_offset, storesconfig.dec_lat/dec_lon/dec_unix, and setsmodes.is_save_config. Failures logMagnetic declination errorand leave the old offset in place.
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.
nav_helpers.get_heading_mot() is course-over-ground, not a NAV-PVT field
(confirmed, nav_helpers.dis:53-89):
-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
InCompassing.start_rotation (confirmed, compassing.dis:706-760):
config.is_persistent_northsimply keeps a white pixel on magnetic-corrected north lit at all times. It is also reported to the app in a packed flags byte alongsideis_compass_lock(ble_core.dis:678-690).get_peer_px(mac, north_diff)returnsNonewhenpeer.directionisNone; otherwiseget_dial_px(mac, get_bearing(peer.direction, offset=-north_diff)), whereget_bearing(h, o) = (h + o) % 360(confirmed,compassing.dis:455-488,1347-1358). Sinceoffset = -(360 - avg_azimuth), the bearing handed toget_dial_pxis(direction + avg_azimuth) % 360; the sign is then reversed again byget_dial_px’s mirrored ring mapping (px = abs(px - 60)), so the two cancel.get_dial_px(id, deg)mapspx = 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 againstself._cache:abs(prev - px) <= 30→px = round((prev + px) / 2), elsepx = 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
Peer staleness & proximity display
peer_helpers.is_peer_stale(peer, now) (confirmed, peer_helpers.dis:534-636):
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):
is_unknown = 1 when the
accuracy check concludes you are standing on them.
Navigation logging
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:
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):
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.