Files
wadamesh/deploy/apps/gpscompass/1.0/gpscompass.lua
T
Christopher Van Hoose 6cc0c1e56e Make the WMM block generated, and the tests reviewable
The declination model landed as 4.7 KB of constants pasted into a Lua app,
generated by a script that lived in out/ -- which is gitignored, holds firmware
bins, and is where the app's own "Regenerate:" comment pointed. So the pointer
dangled for anyone who cloned the repo, and nobody but me could answer the
first fair question a reviewer would ask about that block of magic numbers:
where did it come from, and how do I know it is right.

  scripts/wmm/       WMM.COF + NOAA's 100 official test values (both upstream
                     and unmodified), the float64 reference, the generator,
                     verify.py, and a README covering provenance, regeneration
                     and how to move to WMM2030.
  scripts/lua-harness/  the host harness, with run.sh so it is one command.

Neither goes in test/: that is PlatformIO's directory and a harness with a
main.c would be swept into `pio test`. scripts/ already holds this repo's dev
tooling, test_companion_serial.py included.

The block in the app is now genuinely generated rather than hand-pasted:

    scripts/wmm/gen_lua.py --update <app>    rewrite it
    scripts/wmm/gen_lua.py --check  <app>    fail, with a diff, if it drifted

--check catches coefficients updated without regenerating, or a block edited by
hand. The generator owns the `local declination / do ... end` wrapper too, and
that is the point: the tables are named G/H/GD/HD, gpscompass uses a global H
for the screen height, and an unscoped `local H` silently ate it. Hand-wrapping
is how that happened, so hand-wrapping is now not a step.

Verification, all reproducible from a clean clone:
  scripts/wmm/verify.py            100 NOAA values, worst D error 0.005 deg
  scripts/lua-harness/run.sh       10 scenarios, incl. the generated Lua in
                                   the device's own LUA_32BITS interpreter --
                                   0.0002 deg vs NOAA, worst tick 12k of 100k

Also refreshes the LUA_APPS.md paragraph, which still advertised the O and F
keys that were removed and quoted harness numbers from before tilt
compensation.
2026-08-22 13:30:36 -04:00

1284 lines
54 KiB
Lua

-- GPS Compass — heading dial plus the live GPS fix, in the RF Monitor's look.
--
-- Heading comes from the magnetometer where the board has one (ThinkNode M9:
-- QMC6309, via wada.sys.compass()) and falls back to GPS course-over-ground
-- while moving on every other board. The dial turns so the heading sits
-- under the fixed lubber mark at the top; a contact with a known position
-- can be picked as a target and is drawn on the dial with bearing and range.
--
-- The magnetometer is raw: the firmware hands out x/y/z in Gauss in the
-- sensor's own frame, uncalibrated. This app does the rest —
-- * hard-iron calibration: press C (or Cal), turn the device through every
-- orientation for ~20 s, press C again. Offsets persist in wada.store.
-- * orientation: the sensor's axes vs. the screen are not documented for
-- the M9, so the app works it out. Hold the device flat, point the top
-- edge at north and press A. Which way the sensor's Z axis faces decides
-- whether the heading runs clockwise or anticlockwise, and that follows
-- from the sign of the vertical field: Earth's field dips DOWN in the
-- northern hemisphere and UP in the southern one, so with a GPS fix (or
-- the last known position) the app reads the handedness off the sensor
-- itself and only needs the one press for the rest. Both persist.
-- Magnetic declination is not applied: this is a magnetic compass.
local ui, sys, mesh, store, timer = wada.ui, wada.sys, wada.mesh, wada.store, wada.timer
local C = ui.colors
local AMBER = 0xE8A33D
local RING = 0x3A424A -- dial ring + minor ticks
local RING2 = 0x252C33 -- inner ring
local PANEL = C.panel or 0x15181B -- the dial's own surface (older hosts: literal)
local app = {}
local caps, W, H
local landscape
local cv, D, R, CX, CY -- dial canvas, diameter, radius, centre
local dial_x, dial_y = 0, 0 -- canvas position in the body (tap hit-test)
local sats_cv, SATS_W, SATS_H = nil, 52, 8
local TH12, TH14, TH16 = 15, 17, 19 -- font line heights, replaced from ui.text_h
local L = {} -- labels by name
local has_compass = false
local has_accel = false -- QMI8658 present: gravity for tilt
local acc = nil -- last accelerometer sample, g, sensor frame
local cal = nil -- { ox, oy, oz } hard-iron offsets
local calib = nil -- in-progress: { mn = {x,y,z}, mx = {x,y,z}, t0 }
local align = 0 -- degrees added to the raw angle so north reads 000
local align_pending_decl = false -- A was pressed before the model had a fix
local hint_normal = "" -- bottom row text outside diagnostics
-- Magnetic declination: the angle between magnetic north (what the sensor
-- measures) and true north (what every GPS bearing is relative to). Mixing the
-- two offsets every waypoint by exactly this much -- measured on hardware as
-- all contacts sitting 22 degrees west, including ones whose positions were
-- known to be right. East positive, so true = magnetic + decl.
local decl = 0
local decl_src = "none" -- "model" | "stale" | "manual" | "none"
local decl_lat, decl_lon = nil, nil -- where the model was last evaluated
local DECL_REFRESH_KM = 2 -- it moves ~1 deg per 100 km; 2 km is free
local BUILD_YEAR = 2026.6 -- used only when the clock has never been set
local mag_norm = nil -- |B| after offsets, Gauss (sanity check for the user)
local tilt_deg = nil -- how far from level, degrees (nil = unknown)
local weak_field = false -- WMM caution/blackout zone: compass unreliable
local mag_sat = false -- last magnetometer sample was flagged as saturated
local hv_x, hv_y = 0, 0 -- smoothed heading unit vector
local heading = nil -- degrees 0..360, or nil
local src = "none" -- "mag" | "gps" | "none"
local last_mag_ms = 0
local TICK_MS = 100 -- dial update rate
local SMOOTH = 0.5 -- per-tick blend toward the new heading (1 = none)
local targets, target_i = {}, 0 -- contacts with a position; 0 = none
local next_contacts_ms = 0
local CONTACTS_EVERY = 20000 -- positions only change on adverts
-- Units: altitude and speed each carry their own, imperial by default. Up and
-- down move the selection between the two rows; OK (or a tap on the row)
-- switches that row's units.
local UNITS = { alt = true, spd = true } -- true = imperial
local sel = "alt"
local row_hit = {} -- name -> { y, h } in body coordinates
local row_y = {} -- name -> y, so diagnostics can move rows
local col_x0, col_x1 = 0, 0 -- the stats column's horizontal span
local val_x, val_w = 0, 0 -- where the value column starts, and its width
local diag = false -- D: show what the app is actually computing
local diag_m = nil -- last raw sample, for the diagnostic rows
local press = nil -- pending touch: { x, y } from the last "down"
local last_swipe_ms = -100000 -- debounce: LVGL's gesture and the hardware swipe
-- detector can both report one finger swipe
local name_max = 16 -- target-name characters that fit the value column
local compact = false -- narrow column at a big font: short strings
local last_text = {} -- label text cache: LVGL relayout only on change
local last_dial_key, last_sats_key = nil, nil
local CAL_SECS = 20
local CARD = { "N","NNE","NE","ENE","E","ESE","SE","SSE",
"S","SSW","SW","WSW","W","WNW","NW","NNW" }
-- Which north the dial is showing. Bearings computed from coordinates are
-- always true; the dial is only true once a declination is known.
local function href()
return decl_src == "none" and "M" or "T"
end
local function cardinal(deg)
return CARD[(math.floor((deg + 11.25) / 22.5) % 16) + 1]
end
local function norm360(d)
d = d % 360
if d < 0 then d = d + 360 end
return d
end
-- ---------------------------------------------------------------------------
-- Magnetic declination (WMM2025), so the dial and the bearings share a north.
--
-- The magnetometer measures MAGNETIC north; every bearing computed from GPS
-- coordinates is relative to TRUE north. Plotting one against the other offsets
-- every waypoint by the local declination -- measured on hardware as all
-- contacts, including ones whose positions were known to be right, sitting
-- about 22 degrees west of where they belong.
--
-- This is the real World Magnetic Model rather than a lookup table: degree 12,
-- 4.4 KB of coefficients and code, and it reproduces NOAA's own calculator to
-- 0.00005 degrees at seventeen sites worldwide, verified in this exact
-- single-precision interpreter. A grid accurate enough to stay inside a degree
-- would have cost five times the space and still been worse.
--
-- Includes the secular-variation terms, so it is exact across its 2025.0-2030.0
-- window rather than drifting ~0.1 deg/yr from a frozen snapshot. Past expiry
-- it degrades gracefully -- roughly 0.14 deg/yr -- so it stays inside a degree
-- until about 2033 even if nobody reissues it.
--
-- Regenerate or verify: scripts/wmm/README.md. The block below is generated;
-- scripts/wmm/gen_lua.py --check catches it drifting from the coefficients.
-- WMM2025 magnetic declination, degree 12.
-- Data: NOAA/NCEI WMM.COF epoch 2025.0, valid 2025.0-2030.0.
-- Source: https://www.ncei.noaa.gov/sites/default/files/2024-12/WMM2025COF.zip
-- WMM-GEN BEGIN -- generated by scripts/wmm/gen_lua.py; do not edit by hand
-- WMM2025 magnetic declination, degree 12. East positive:
-- true bearing = magnetic bearing + declination
-- Data: NOAA/NCEI WMM.COF epoch 2025.0, valid 2025.0-2030.0.
-- Source: https://www.ncei.noaa.gov/sites/default/files/2024-12/WMM2025COF.zip
-- Everything below is scoped: the tables are named G/H/GD/HD and would
-- otherwise shadow an app's own globals of those names.
local declination
do
local EPOCH,NMAX=2025.0,12
local G={-29351.8,-1410.8,-2556.6,2951.1,1649.3,1361,-2404.1,1243.8,453.6,895,799.5,55.7,-281.1,12.1,
-233.2,368.9,187.2,-138.7,-142,20.9,64.4,63.8,76.9,-115.7,-40.9,14.9,-60.7,79.5,-77,-8.8,59.3,
15.8,2.5,-11.1,14.2,23.2,10.8,-17.5,2,-21.7,16.9,15,-16.8,.9,4.6,7.8,3,-.2,-2.5,-13.1,2.4,8.6,
-8.7,-12.9,-1.3,-6.4,.2,2,-1,-.6,-.9,1.5,.9,-2.7,-3.9,2.9,-1.5,-2.5,2.4,-.6,-.1,-.6,-.1,1.1,-1,
-.2,2.6,-2,-.2,.3,1.2,-1.3,.6,.6,.5,-.1,-.4,-.2,-1.3,-.7,}
local H={4545.4,-3133.6,-815.1,-56.6,237.5,-549.5,278.6,-133.9,212,-375.6,45.4,220.2,-122.9,43,106.1,
-18.4,16.8,48.8,-59.8,10.9,72.7,-48.9,-14.4,-1,23.4,-7.4,-25.1,-2.3,7.1,-12.6,11.4,-9.7,12.7,.7,
-5.2,3.9,-24.8,12.2,8.3,-3.3,-5.2,7.2,-.6,.8,10,3.3,0,2.4,5.3,-9.1,.4,-4.2,-3.8,.9,-9.1,0,2.9,
-.6,.2,.5,-.3,-1.2,-1.7,-2.9,-1.8,-2.3,-1.3,.7,1,-1.4,0,.6,-.1,.8,.1,-1,.1,.2,}
local GD={12,9.7,-11.6,-5.2,-8,-1.3,-4.2,.4,-15.6,-1.6,-2.4,-6,5.6,-7,.6,1.4,0,.6,2.2,.9,-.2,-.4,.9,1.2,
-.9,.3,.9,0,-.1,-.1,.5,-.1,-.8,-.8,.8,-.1,.2,0,.5,-.1,.3,.2,0,.2,0,-.1,.1,.3,-.3,0,.3,-.1,.1,
-.1,.1,0,.1,.1,0,-.3,0,-.1,-.1,0,0,0,0,0,0,0,-.1,0,0,-.1,-.1,-.1,-.1,0,0,0,0,0,0,.1,0,0,0,-.1,0,
-.1,}
local HD={-21.5,-27.7,-12.1,4,-.3,-4.1,-1.1,4.1,1.6,-4.4,-.5,2.2,.4,1.7,1.9,.3,-1.6,-.4,.9,.7,.9,.6,.5,
-.8,0,-1,.6,-.2,-.2,.5,-.4,.4,-.5,-.6,.3,.2,-.3,.3,-.3,.3,.2,-.1,-.2,.4,.1,0,0,-.2,.1,-.1,.1,0,
-.1,.2,0,0,.1,0,.1,0,0,.1,0,0,0,0,0,0,-.1,.1,0,0,0,0,0,0,0,-.1,}
local sqrt,sin,cos,asin,atan=math.sqrt,math.sin,math.cos,math.asin,math.atan
-- Flat (n,m) index = n*(n+1)/2 + m + 1, so every table stays in Lua's array
-- part (no hash lookups in the inner loop).
local OFF={} ; for n=0,NMAX do OFF[n]=n*(n+1)//2 end
local NP=OFF[NMAX]+NMAX+1
-- one-time recursion constants (position independent)
local K,C,E={},{},{}
for n=1,NMAX do
local k=sqrt((2*n-1)/(2*n)); if n==1 then k=k*sqrt(2) end
K[n]=k
for m=0,n-1 do
local d=sqrt(n*n-m*m); local i=OFF[n]+m+1
C[i]=(2*n-1)/d
E[i]=(n>=m+2) and sqrt((n-1)*(n-1)-m*m)/d or 0
end
end
local P,DP={},{}
for i=1,NP do P[i]=0; DP[i]=0 end
local CM,SM={},{}
-- lat,lon in degrees; year is a decimal year (e.g. 2027.6, default = EPOCH).
-- Returns (1) declination in degrees, EAST positive: true = magnetic + decl,
-- (2) horizontal field intensity H in nT.
-- H gives the caller WMM's own error bar for free:
-- sigma_D = sqrt(0.26^2 + (5417/H)^2) degrees, 1-sigma
-- and the official reliability zones: H < 2000 nT is the WMM "Blackout Zone"
-- (a magnetic compass is unusable), 2000 <= H < 6000 nT the "Caution Zone".
function declination(lat,lon,year)
local dt=(year or EPOCH)-EPOCH
if lat>89.99 then lat=89.99 elseif lat<-89.99 then lat=-89.99 end
local phi,lam=lat*0.017453292,lon*0.017453292
local sp,cp=sin(phi),cos(phi)
-- WGS-84 geodetic -> geocentric spherical
local rc=6378.137/sqrt(1-0.006694380*sp*sp)
local p,z=rc*cp,rc*0.993305620*sp
local r=sqrt(p*p+z*z)
local pp=asin(z/r) -- geocentric latitude
local ct,st=sin(pp),cos(pp) -- cos(colatitude), sin(colatitude)
P[1],DP[1]=1,0 -- (0,0)
for n=1,NMAX do
local o,o1=OFF[n],OFF[n-1]
local k=K[n]
local dnn=o1+n -- (n-1,n-1)
P[o+n+1]=k*st*P[dnn]
DP[o+n+1]=k*(st*DP[dnn]+ct*P[dnn])
for m=0,n-1 do
local i,j=o+m+1,o1+m+1
local c,e=C[i],E[i]
local pv=c*ct*P[j]
local dv=c*(ct*DP[j]-st*P[j])
if e~=0 then local h=OFF[n-2]+m+1; pv=pv-e*P[h]; dv=dv-e*DP[h] end
P[i],DP[i]=pv,dv
end
end
for m=0,NMAX do CM[m+1]=cos(m*lam); SM[m+1]=sin(m*lam) end
local ratio=6371.2/r
local X,Y,Z=0,0,0
local pw=ratio*ratio
local gi,hi=0,0
for n=1,NMAX do
pw=pw*ratio
local o=OFF[n]
local np1=n+1
for m=0,n do
gi=gi+1
local i,m1=o+m+1,m+1
local gv=G[gi]+dt*GD[gi]
local cm,sm=CM[m1],SM[m1]
local a
if m>0 then
hi=hi+1
local hv=H[hi]+dt*HD[hi]
a=gv*cm+hv*sm
Y=Y+pw*m*(gv*sm-hv*cm)*P[i]
else
a=gv
end
X=X+pw*a*DP[i]
Z=Z-np1*pw*a*P[i]
end
end
Y=Y/st
local d=pp-phi
local Xg=X*cos(d)-Z*sin(d)
return atan(Y,Xg)*57.29577951, sqrt(Xg*Xg+Y*Y)
end
end -- WMM-GEN END
-- ---------------------------------------------------------------------------
-- persistence
local function load_prefs()
local ox, oy = store.get("cal_ox"), store.get("cal_oy")
local oz, r = store.get("cal_oz"), store.get("cal_r")
-- oz is required for tilt compensation, so a calibration saved by the
-- flat-only version (which had no third offset) is not good enough any more:
-- take it only when the whole set is there.
if type(ox) == "number" and type(oy) == "number" and type(oz) == "number" then
cal = { ox = ox, oy = oy, oz = oz, r = (type(r) == "number" and r > 0) and r or nil }
local bx, by, bz = store.get("acc_bx"), store.get("acc_by"), store.get("acc_bz")
if type(bx) == "number" and type(by) == "number" and type(bz) == "number" then
cal.abx, cal.aby, cal.abz = bx, by, bz
end
end
-- Orientation settings are versioned: the heading formula changed once the
-- M9's axes were measured, so values saved against the old one would push a
-- correct default back off north. Anything older is discarded, not migrated.
if store.get("orient_ver", 0) == 2 then
align = tonumber(store.get("align", 0)) or 0
else
align = 0
store.set("align", nil)
store.set("orient", nil); store.set("flip", nil) -- the even older pair
store.set("orient_ver", 2)
end
local d = store.get("decl")
if type(d) == "number" then
decl, decl_src = d, "stale"
decl_lat, decl_lon = store.get("decl_lat"), store.get("decl_lon")
end
align_pending_decl = store.get("align_pd") and true or false
UNITS.alt = store.get("u_alt", 1) == 1
UNITS.spd = store.get("u_spd", 1) == 1
end
local function save_align()
store.set("align", math.floor(align + 0.5))
store.set("mirror", nil) -- the old hand-flipped handedness; measured now
store.set("orient_ver", 2)
end
local function save_cal()
if cal then
store.set("cal_ox", cal.ox); store.set("cal_oy", cal.oy); store.set("cal_oz", cal.oz)
store.set("cal_r", cal.r)
store.set("acc_bx", cal.abx); store.set("acc_by", cal.aby); store.set("acc_bz", cal.abz)
else
store.set("cal_ox", nil); store.set("cal_oy", nil); store.set("cal_oz", nil)
store.set("cal_r", nil)
store.set("acc_bx", nil); store.set("acc_by", nil); store.set("acc_bz", nil)
end
end
-- ---------------------------------------------------------------------------
-- calibration + heading
--
-- MEASURED FRAMES on the ThinkNode M9 (both by holding known attitudes and
-- logging the raw vectors; neither is documented by anyone, and the only other
-- firmware that touches these parts passes them through unverified):
-- magnetometer +Y = top edge, +X = LEFT edge, +Z = into the screen
-- accelerometer +X = top edge, +Y = RIGHT edge, +Z = into the screen
-- The accelerometer is therefore already in the aerospace body frame (forward,
-- right, down); the magnetometer becomes (fwd, right, down) = (my, -mx, mz).
--
-- WHY TILT MATTERS: at this latitude Earth's field dips about 60 degrees below
-- horizontal, so the vertical component is ~1.6x the horizontal one. A heading
-- taken from two axes assumes the device is level; tip it and some of that
-- large vertical field leaks into the horizontal pair, which is worth roughly
-- 1.5 degrees of heading per degree of tilt. That, not the sensor, is why a
-- hand-held reading wanders.
local function to_body(m)
return m.y - (cal and cal.oy or 0), -- forward (top edge)
-(m.x - (cal and cal.ox or 0)), -- right
m.z - (cal and cal.oz or 0) -- down (into the screen)
end
-- Hard-iron calibration: fit the sphere the samples lie on. Rotating the
-- device sweeps that sphere, whose centre is the offset and whose radius is
-- the true field.
--
-- ROTATE IT IN PLACE. Carrying it around the room while turning it does not
-- just rotate the device, it also TRANSLATES it through the field of the desk,
-- the laptop and anything else ferrous — and that corrupts the fit. Measured
-- here: a centre that moved 0.15 G between hand-tumbled sessions, against a
-- horizontal signal of only 0.26 G. The accelerometer now checks that the
-- device was actually turned every way, which is the part a user cannot see.
local function calib_new()
return { n = 0, o = nil, t0 = sys.millis(),
sx = 0, sy = 0, sz = 0, sxx = 0, syy = 0, szz = 0,
sxy = 0, sxz = 0, syz = 0, sxs = 0, sys_ = 0, szs = 0, ss = 0,
gmn = { 9, 9, 9 }, gmx = { -9, -9, -9 },
-- the same sweep traces a 1 g sphere for the accelerometer, so its
-- own zero-g offset falls out of the same fit for free
an = 0, ao = nil, ax = 0, ay = 0, az = 0, axx = 0, ayy = 0, azz = 0,
axy = 0, axz = 0, ayz = 0, axs = 0, ays = 0, azs = 0, ass = 0 }
end
local function calib_add(m, a)
local c = calib
if not c.o then c.o = { m.x, m.y, m.z } end
local x, y, z = m.x - c.o[1], m.y - c.o[2], m.z - c.o[3]
local s = x * x + y * y + z * z
c.n = c.n + 1
c.sx = c.sx + x; c.sy = c.sy + y; c.sz = c.sz + z
c.sxx = c.sxx + x * x; c.syy = c.syy + y * y; c.szz = c.szz + z * z
c.sxy = c.sxy + x * y; c.sxz = c.sxz + x * z; c.syz = c.syz + y * z
c.sxs = c.sxs + x * s; c.sys_ = c.sys_ + y * s; c.szs = c.szs + z * s
c.ss = c.ss + s
-- coverage, measured by where gravity pointed rather than by where the field
-- went: it is the honest test of "was this thing actually turned over?"
if a then
local g = { a.x, a.y, a.z }
for i = 1, 3 do
if g[i] < c.gmn[i] then c.gmn[i] = g[i] end
if g[i] > c.gmx[i] then c.gmx[i] = g[i] end
end
if not c.ao then c.ao = { a.x, a.y, a.z } end
local ux, uy, uz = a.x - c.ao[1], a.y - c.ao[2], a.z - c.ao[3]
local us = ux * ux + uy * uy + uz * uz
c.an = c.an + 1
c.ax = c.ax + ux; c.ay = c.ay + uy; c.az = c.az + uz
c.axx = c.axx + ux * ux; c.ayy = c.ayy + uy * uy; c.azz = c.azz + uz * uz
c.axy = c.axy + ux * uy; c.axz = c.axz + ux * uz; c.ayz = c.ayz + uy * uz
c.axs = c.axs + ux * us; c.ays = c.ays + uy * us; c.azs = c.azs + uz * us
c.ass = c.ass + us
end
end
local function solve4(M, v)
for col = 1, 4 do
local piv, best = col, math.abs(M[col][col])
for r = col + 1, 4 do
local a = math.abs(M[r][col])
if a > best then piv, best = r, a end
end
if best < 1e-9 then return nil end
if piv ~= col then M[col], M[piv] = M[piv], M[col]; v[col], v[piv] = v[piv], v[col] end
local d = M[col][col]
for r = col + 1, 4 do
local f = M[r][col] / d
if f ~= 0 then
for k = col, 4 do M[r][k] = M[r][k] - f * M[col][k] end
v[r] = v[r] - f * v[col]
end
end
end
local out = {}
for r = 4, 1, -1 do
local acc = v[r]
for k = r + 1, 4 do acc = acc - M[r][k] * out[k] end
out[r] = acc / M[r][r]
end
return out
end
-- Returns { ox, oy, oz, r } or nil plus the reason. Every refusal keeps the
-- previous calibration: silently saving a bad fit is how north broke before.
local function calib_solve()
local c = calib
if c.n < 80 then return nil, "too few readings" end
-- Was it really turned every way? Gravity should have pointed along both
-- ends of each axis at some point. This is what a flat spin fails.
if has_accel and c.gmx[1] > -9 then
local worst, axis = 9, 1
for i = 1, 3 do
local span = c.gmx[i] - c.gmn[i]
if span < worst then worst, axis = span, i end
end
if worst < 0.9 then
return nil, ({ "turn it nose over tail", "turn it side over side",
"turn it face up and down" })[axis]
end
end
local M = {
{ 4 * c.sxx, 4 * c.sxy, 4 * c.sxz, 2 * c.sx },
{ 4 * c.sxy, 4 * c.syy, 4 * c.syz, 2 * c.sy },
{ 4 * c.sxz, 4 * c.syz, 4 * c.szz, 2 * c.sz },
{ 2 * c.sx, 2 * c.sy, 2 * c.sz, c.n },
}
local v = { 2 * c.sxs, 2 * c.sys_, 2 * c.szs, c.ss }
local sol = solve4(M, v)
if not sol then return nil, "turn it in more directions" end
local cx, cy, cz, k = sol[1], sol[2], sol[3], sol[4]
local r2 = k + cx * cx + cy * cy + cz * cz
if r2 <= 0 then return nil, "turn it in more directions" end
local r = math.sqrt(r2)
-- Earth's TOTAL field is 0.25..0.65 G everywhere on the planet.
if r < 0.22 or r > 0.70 then return nil, string.format("field reads %.2f G", r) end
-- Roundness, in closed form from the sums. A fit distorted by carrying the
-- device past nearby metal has a normal-looking radius and a bad residual.
local mean_r2 = (c.ss - 2 * (cx * c.sx + cy * c.sy + cz * c.sz)) / c.n
+ cx * cx + cy * cy + cz * cz
local resid = math.sqrt(math.max(mean_r2 - r * r, 0))
if resid > 0.15 * r then
return nil, string.format("too distorted (%.0f%%) - turn it ON THE SPOT", 100 * resid / r)
end
local out = { ox = c.o[1] + cx, oy = c.o[2] + cy, oz = c.o[3] + cz, r = r }
-- Accelerometer zero-g offset, from the same sweep. A MEMS part is commonly
-- tens of milli-g out of true, and this one reads ~0.08 g on Y lying flat --
-- about 5 degrees of tilt that is not there, which the compensation would
-- then apply to the heading. Only taken when the sphere it fits is close to
-- the 1 g it must be.
if c.an >= 80 then
local AM = {
{ 4 * c.axx, 4 * c.axy, 4 * c.axz, 2 * c.ax },
{ 4 * c.axy, 4 * c.ayy, 4 * c.ayz, 2 * c.ay },
{ 4 * c.axz, 4 * c.ayz, 4 * c.azz, 2 * c.az },
{ 2 * c.ax, 2 * c.ay, 2 * c.az, c.an },
}
local av = { 2 * c.axs, 2 * c.ays, 2 * c.azs, c.ass }
local asol = solve4(AM, av)
if asol then
local acx, acy, acz, ak = asol[1], asol[2], asol[3], asol[4]
local ar2 = ak + acx * acx + acy * acy + acz * acz
if ar2 > 0 then
local ar = math.sqrt(ar2)
if ar > 0.85 and ar < 1.15 then
out.abx = c.ao[1] + acx
out.aby = c.ao[2] + acy
out.abz = c.ao[3] + acz
out.ar = ar
end
end
end
end
return out, r
end
-- Heading. With the accelerometer, the magnetic vector is rotated back into
-- the horizontal plane before the angle is taken, so tipping the device no
-- longer swings the reading (NXP AN4248 / ST AN3192, in the body frame above).
-- Without one, it falls back to the flat-earth two-axis form, which is only
-- honest while the device is held level.
local function mag_heading(m)
local bx, by, bz = to_body(m)
if acc then
-- gravity's DIRECTION in the body frame: the part reads specific force, so
-- the skyward axis reads +1 and down is the negative of that
-- gravity's direction, with the sensor's own zero-g offset removed
local gx = -(acc.x - (cal and cal.abx or 0))
local gy = -(acc.y - (cal and cal.aby or 0))
local gz = -(acc.z - (cal and cal.abz or 0))
local gn = math.sqrt(gx * gx + gy * gy + gz * gz)
if gn > 0.5 then
gx, gy, gz = gx / gn, gy / gn, gz / gn
local pitch = math.atan(-gx, math.sqrt(gy * gy + gz * gz))
local roll = math.atan(gy, gz)
local sp, cp = math.sin(pitch), math.cos(pitch)
local sr, cr = math.sin(roll), math.cos(roll)
local xh = bx * cp + by * sp * sr + bz * sp * cr
local yh = by * cr - bz * sr
tilt_deg = math.deg(math.acos(math.max(-1, math.min(1, gz))))
return norm360(math.deg(math.atan(-yh, xh)) + align + decl)
end
end
tilt_deg = nil
return norm360(math.deg(math.atan(bx, by)) + align + decl)
end
local function smooth_heading(h)
local r = math.rad(h)
local sx, sy = math.sin(r), math.cos(r)
if hv_x == 0 and hv_y == 0 then
hv_x, hv_y = sx, sy
else
hv_x = hv_x + (sx - hv_x) * SMOOTH
hv_y = hv_y + (sy - hv_y) * SMOOTH
end
return norm360(math.deg(math.atan(hv_x, hv_y)))
end
local function update_heading(now)
-- Gravity first: which way is down decides how much of the vertical field is
-- leaking into the horizontal pair, and the field dips ~60 degrees here.
if has_accel then acc = sys.accel() or acc end
local m = has_compass and sys.compass() or nil
if m then
last_mag_ms = now
diag_m = m
mag_sat = m.ovfl == true
if not mag_sat then
if calib then calib_add(m, acc) end
-- total field magnitude: with tilt compensation the heading no longer
-- cares about attitude, so the useful health check is whether the whole
-- vector still has the length the calibration found. A big departure
-- means a magnet nearby or a stale calibration, not a tipped device.
local bx, by, bz = to_body(m)
mag_norm = math.sqrt(bx * bx + by * by + bz * bz)
heading, src = smooth_heading(mag_heading(m)), "mag"
end
return
end
-- No magnetometer (or nothing fresh for a while): GPS course while moving.
if has_compass and (now - last_mag_ms) < 1500 and heading then return end
local g = caps.sdk_ext and sys.gps() or nil
if g and g.course then
heading, src = smooth_heading(g.course), "gps"
else
heading, src = nil, "none"
hv_x, hv_y = 0, 0
end
end
-- ---------------------------------------------------------------------------
-- targets: contacts that have shared a position
local function refresh_contacts()
local list = {}
for _, c in ipairs(mesh.contacts()) do
if (c.lat ~= 0 or c.lon ~= 0) and c.name and c.name ~= "" then
list[#list + 1] = c
end
end
table.sort(list, function(a, b) return a.name < b.name end)
-- keep the same target selected across a refresh when it is still there
local cur = targets[target_i]
targets = list
if cur then
target_i = 0
for i, c in ipairs(list) do
if c.name == cur.name then target_i = i break end
end
elseif target_i > #targets then
target_i = 0
end
end
local function cycle_target(step)
if #targets == 0 then
target_i = 0
sys.toast("No contact has shared a position yet", 1500)
return
end
target_i = (target_i + step) % (#targets + 1)
if target_i < 0 then target_i = target_i + #targets + 1 end
end
-- great-circle distance (m) and initial bearing (deg) from (lat1,lon1) to (lat2,lon2)
local function geo(lat1, lon1, lat2, lon2)
local p1, p2 = math.rad(lat1), math.rad(lat2)
local dl = math.rad(lon2 - lon1)
local a = math.sin((p2 - p1) / 2) ^ 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ^ 2
local dist = 2 * 6371000 * math.atan(math.sqrt(a), math.sqrt(1 - a))
local y = math.sin(dl) * math.cos(p2)
local x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
return dist, norm360(math.deg(math.atan(y, x)))
end
-- range to a target follows the ALTITUDE row's units (one "how far" setting)
-- Recompute only when the fix has actually moved: the model costs ~8k VM
-- instructions, which is 8% of one tick's budget, and declination changes by
-- about a degree per 100 km.
local function update_decl(lat, lon)
if not lat or (lat == 0 and lon == 0) then return end
if decl_src == "model" and decl_lat then
local dist = geo(decl_lat, decl_lon, lat, lon)
if dist < DECL_REFRESH_KM * 1000 then return end
end
local yr = BUILD_YEAR
local dt = sys.datetime and sys.datetime()
if dt and dt.year and dt.year > 2020 then yr = dt.year + (dt.month - 0.5) / 12 end
local d, hfield = declination(lat, lon, yr)
if d then
if align_pending_decl then
-- north was set against TRUE north while the model was blind, so the
-- offset the user made is carrying this declination already
align = align - d
align_pending_decl = false
store.set("align_pd", nil); store.set("align", math.floor(align + 0.5))
end
decl, decl_src = d, "model"
decl_lat, decl_lon = lat, lon
-- keep it across a reboot: a stale value from 50 km away is worth about a
-- degree, where assuming zero is worth the whole declination
store.set("decl", d); store.set("decl_lat", lat); store.set("decl_lon", lon)
-- WMM's own error model: below 6000 nT of horizontal field a magnetic
-- compass is not to be trusted, and below 2000 nT it is useless
weak_field = hfield and hfield < 6000
end
end
local function fmt_dist(m)
if UNITS.alt then
local ft = m * 3.28084
if ft < 1000 then return string.format("%d ft", math.floor(ft + 0.5)) end
local mi = m / 1609.344
if mi < 10 then return string.format("%.2f mi", mi) end
return string.format("%.1f mi", mi)
end
if m < 1000 then return string.format("%d m", math.floor(m + 0.5)) end
if m < 10000 then return string.format("%.2f km", m / 1000) end
return string.format("%.1f km", m / 1000)
end
local function fmt_alt(m)
if UNITS.alt then return string.format("%d ft", math.floor(m * 3.28084 + 0.5)) end
return string.format("%d m", math.floor(m + 0.5))
end
local function fmt_speed(kmh)
if UNITS.spd then return string.format("%.1f mph", kmh * 0.621371) end
return string.format("%.1f km/h", kmh)
end
-- ---------------------------------------------------------------------------
-- drawing
local function set_text(name, text, color)
local l = L[name]
if not l then return end
if last_text[name] ~= text then l:set(text); last_text[name] = text end
if color and last_text[name .. "#"] ~= color then l:color(color); last_text[name .. "#"] = color end
end
local function pt(deg, r)
local a = math.rad(deg)
return math.floor(CX + math.sin(a) * r + 0.5), math.floor(CY - math.cos(a) * r + 0.5)
end
-- Canvas text is left-anchored, so centring means knowing the width. Newer
-- firmware can MEASURE it (wada.ui.text_w, gated on caps().measure); older
-- firmware cannot, and then this estimates ~0.55 x line height per CHARACTER —
-- counting characters, not bytes, since "°" is two bytes in UTF-8 and counting
-- those once pushed the centred heading half a glyph off.
local function text_w(str, lh, size)
if ui.text_w then return ui.text_w(str, size or 12) end
local n = 0
for _ in str:gmatch("[%z\1-\127\194-\244]") do n = n + 1 end
return math.floor(n * lh * 0.55)
end
local function draw_dial(tgt_bearing)
local h = heading or 0
local live = heading ~= nil
-- the page is the firmware's black; the dial sits on its own raised disc so
-- it reads as an instrument rather than as drawing on the page
cv:fill(C.bg)
cv:circle(CX, CY, R, PANEL, true)
-- rings
cv:circle(CX, CY, R, live and RING or RING2, false, 2)
cv:circle(CX, CY, R - 14, RING2, false, 1)
-- graduations: every 10° a minor tick, every 30° a major one, the four
-- cardinals as letters. The whole card rotates by -heading so the current
-- heading sits under the lubber mark.
for deg = 0, 350, 10 do
local a = deg - h
local major = deg % 30 == 0
local cardinalp = deg % 90 == 0
local len = cardinalp and 9 or major and 6 or 3
local x1, y1 = pt(a, R - 3)
local x2, y2 = pt(a, R - 3 - len)
local col = (deg == 0) and C.bad or (major and (live and C.text or C.sub) or RING)
cv:line(x1, y1, x2, y2, col, cardinalp and 2 or 1)
if cardinalp then
local lx, ly = pt(a, R - 14 - math.floor(TH12 * 0.6))
local letter = CARD[math.floor(deg / 90) * 4 + 1]
cv:text(lx - math.floor(TH12 * 0.3), ly - math.floor(TH12 / 2), letter, col, 12)
end
end
-- lubber mark: a small solid triangle pointing in from the top
for i = 0, 6 do
cv:line(CX - 6 + i, i, CX + 6 - i, i, C.accent, 1)
end
cv:line(CX, 0, CX, 9, C.accent, 2)
-- target: dot on the inner ring plus a thin spoke, relative to the card
if tgt_bearing then
local mx, my = pt(tgt_bearing - h, R - 14)
local sx, sy = pt(tgt_bearing - h, R - 26)
cv:line(CX, CY, sx, sy, RING, 1)
cv:circle(mx, my, 4, AMBER, true)
end
-- centre: heading number with the degree sign hanging off its right edge —
-- the DIGITS are what should sit centred, so the number does not appear to
-- shift when the reading crosses 100 or 200
if live then
local digits = string.format("%03d", math.floor(h + 0.5) % 360)
local dw = text_w(digits, TH16, 16)
local dx = CX - math.floor(dw / 2)
cv:text(dx, CY - TH16 + 1, digits, C.text, 16)
cv:text(dx + dw, CY - TH16 + 1, "\194\176", C.sub, 16)
-- T or M rides with the degree sign, outside the centred digits: the point
-- of the number is useless without knowing what it is measured from
local ref = (src == "gps") and "T" or href()
cv:text(dx + dw + text_w("\194\176", TH16, 16), CY - TH16 + 1, ref,
ref == "M" and AMBER or C.sub, 12)
local cd = cardinal(h)
cv:text(CX - math.floor(text_w(cd, TH12, 12) / 2), CY + 3, cd, src == "mag" and C.accent or AMBER, 12)
else
cv:text(CX - math.floor(text_w("--", TH16, 16) / 2), CY - math.floor(TH16 / 2), "--", C.sub, 16)
end
end
-- satellite meter: ten cells, filled in the status colour up to the count
local function draw_sats(n, col)
if not sats_cv then return end
sats_cv:fill(C.bg)
local cw = math.floor((SATS_W - 9) / 10)
for i = 0, 9 do
local x = i * (cw + 1)
if i < n then sats_cv:rect(x, 0, cw, SATS_H, col, true)
else sats_cv:rect(x, 0, cw, SATS_H, PANEL, true) end
end
end
local function refresh(now)
update_heading(now)
-- GPS readout
local g = caps.sdk_ext and sys.gps() or nil
local me_lat, me_lon
local sats_n, sats_col = 0, RING
if g then
me_lat, me_lon = g.lat, g.lon
update_decl(g.lat, g.lon)
sats_n = g.sats or 0
sats_col = sats_n >= 6 and C.good or AMBER
set_text("fix", string.format("%d sats", sats_n), C.text)
set_text("lat", string.format("%.5f", g.lat), C.text)
set_text("lon", string.format("%.5f", g.lon), C.text)
set_text("alt", fmt_alt(g.alt_m or 0), C.text)
if g.speed_kmh then
local s = fmt_speed(g.speed_kmh)
if g.course then s = s .. string.format(" %03d\194\176T", math.floor(g.course + 0.5) % 360) end
set_text("spd", s, C.text)
else
set_text("spd", "--", C.sub)
end
else
local me = mesh.self()
if me and (me.lat ~= 0 or me.lon ~= 0) then
me_lat, me_lon = me.lat, me.lon
update_decl(me.lat, me.lon)
set_text("lat", string.format("%.5f", me.lat), C.sub)
set_text("lon", string.format("%.5f", me.lon), C.sub)
else
set_text("lat", "--", C.sub)
set_text("lon", "--", C.sub)
end
set_text("fix", caps.sdk_ext and "no fix" or "no GPS", C.sub)
set_text("alt", "--", C.sub)
set_text("spd", "--", C.sub)
end
local sk = sats_n .. "|" .. sats_col
if sk ~= last_sats_key then draw_sats(sats_n, sats_col); last_sats_key = sk end
-- heading-source line over the dial (<= 18 glyphs: it spans the dial width)
if calib then
local left = CAL_SECS - math.floor((now - calib.t0) / 1000)
-- coverage, not just a countdown: the span of each axis reaches 2r over a
-- full turn, so this reads ~100% exactly when the sweep is complete
-- coverage measured by how far gravity has swung on its worst axis: 2.0
-- means the device has been fully over in that direction
local worst = 9
for i = 1, 3 do
local span = calib.gmx[i] - calib.gmn[i]
if span < worst then worst = span end
end
local cov = (worst < 8) and math.floor(worst / 1.6 * 100) or 0
set_text("src", string.format("Turn it all ways %ds %d%%", math.max(left, 0), math.min(cov, 99)), AMBER)
elseif mag_sat then
set_text("src", "Field saturated", C.bad)
elseif src == "mag" then
-- Tilt is the accuracy ceiling for a 2-axis compass: the field dips ~60
-- degrees at mid latitudes, so tipping the device swaps vertical field
-- into the horizontal pair and swings the heading. The app cannot correct
-- that without the IMU, but it CAN notice: held level the horizontal
-- magnitude equals the calibrated radius, and tilting shrinks or inflates
-- it. So say when the reading should not be trusted.
local field_off = cal and cal.r and mag_norm and math.abs(mag_norm - cal.r) > 0.25 * cal.r
if not cal then
set_text("src", "Calibrate: press C", AMBER)
elseif field_off then
set_text("src", string.format("Field off (%.2f G)", mag_norm or 0), C.bad)
elseif tilt_deg and tilt_deg > 55 then
-- past ~55 degrees the horizontal projection is small enough that the
-- correction stops being trustworthy; say so rather than lie
set_text("src", "Too steep to read", AMBER)
elseif tilt_deg then
-- Which north the dial is showing, in the room the panel actually has.
-- Amber MAG is a warning: bearings to contacts are TRUE, so while the
-- declination is unknown every waypoint is off by it.
if weak_field then
-- WMM ships a blackout/caution model with its coefficients: under
-- 6000 nT of horizontal field the direction is not worth trusting,
-- whatever the calibration says
set_text("src", "Weak field: heading unreliable", AMBER)
else
set_text("src", string.format("%s tilt %.0f\194\176",
decl_src == "none" and "MAG north" or "TRUE north",
tilt_deg), decl_src == "none" and AMBER or C.good)
end
else
set_text("src", "Hold it level", AMBER)
end
elseif src == "gps" then
set_text("src", "GPS course", AMBER)
elseif has_compass then
set_text("src", "Sensor: no data", C.bad)
else
set_text("src", "GPS when moving", C.sub)
end
-- D: what the app is actually computing, so a wrong heading can be diagnosed
-- from the screen instead of guessed at. Takes over the target rows.
if diag then
local m = diag_m
-- Rows are short on purpose: the value column is ~22 characters at this
-- font, and a longer line wraps onto the row below and overprints it.
-- Diagnostics also take the whole panel width (see diag_layout), which is
-- the key column back.
set_text("tgt", cal and string.format("cal %.2f %.2f %.2f", cal.ox, cal.oy, cal.oz or 0)
or "cal NONE - press C", cal and C.text or C.bad)
set_text("tgt2", m and string.format("mag %.2f %.2f %.2f", m.x, m.y, m.z) or "mag --", C.text)
set_text("rel", acc and string.format("acc %.2f %.2f %.2f", acc.x, acc.y, acc.z) or "acc --", C.text)
local bits = string.format("|B|%.2f", mag_norm or 0)
if m then
-- Vertical field. North of the equator, held flat, this MUST be positive
-- if the magnetometer's +Z points into the screen -- which is what the
-- tilt correction assumes. Negative here means that sign is inverted,
-- and pitching the device would still swing the heading.
local _, _, bz = to_body(m)
bits = bits .. string.format(" d%+.2f", bz)
end
if cal and cal.r then bits = bits .. string.format(" r%.2f", cal.r) end
if tilt_deg then bits = bits .. string.format(" t%d", math.floor(tilt_deg + 0.5)) end
if align ~= 0 then bits = bits .. string.format(" a%d", math.floor(align + 0.5)) end
set_text("seen", bits, AMBER)
-- The declination gets the full-width bottom row: it is what separates the
-- dial's north from every bearing on the panel, and seeing the position it
-- was computed at is how a wrong marker gets traced to a bad fix rather
-- than a bad model.
if decl_src == "none" then
set_text("hint", "no declination yet - bearings are TRUE, dial is MAGNETIC", AMBER)
else
set_text("hint", string.format("decl %+.1f\194\176 %s @ %.2f, %.2f", decl,
decl_src == "model" and "WMM2025" or "stored",
decl_lat or 0, decl_lon or 0), C.sub)
end
draw_dial(nil)
last_dial_key = nil -- keep the dial live while diagnosing
return
end
-- target: name, range + bearing, which way to turn, when it was last heard
local tgt_bearing
local t = targets[target_i]
if t then
set_text("tgt", t.name:sub(1, name_max), AMBER)
if me_lat then
local dist, brg = geo(me_lat, me_lon, t.lat, t.lon)
tgt_bearing = brg
set_text("tgt2", string.format("%s %03d\194\176T %s", fmt_dist(dist), math.floor(brg + 0.5) % 360,
cardinal(brg)), C.text)
if heading then
local rel = norm360(brg - heading)
local turn = rel <= 180 and rel or 360 - rel
local s
if turn <= 6 then s = "ahead"
elseif turn >= 174 then s = "behind"
else s = string.format("%d\194\176 %s", math.floor(turn + 0.5), rel <= 180 and "right" or "left") end
set_text("rel", s, turn <= 6 and C.good or C.text)
else
set_text("rel", "no heading", C.sub)
end
else
set_text("tgt2", compact and "no own position" or "own position unknown", C.sub)
set_text("rel", "", C.sub)
end
local ago = t.ago_s or 0
if ago <= 0 then set_text("seen", "", C.sub)
elseif ago < 60 then set_text("seen", "heard just now", C.sub)
elseif ago < 3600 then set_text("seen", string.format("heard %dm ago", math.floor(ago / 60)), C.sub)
elseif ago < 86400 then set_text("seen", string.format("heard %dh ago", math.floor(ago / 3600)), C.sub)
else set_text("seen", string.format("heard %dd ago", math.floor(ago / 86400)), C.sub) end
else
set_text("tgt", #targets > 0 and string.format("none (%d) <>", #targets) or "none", C.sub)
set_text("tgt2", "", C.sub)
set_text("rel", "", C.sub)
set_text("seen", "", C.sub)
end
-- dial: redraw only when what it shows changed. The T/M reference belongs in
-- the key too: when the first fix lands the heading itself often does not
-- move, and without this the dial would keep claiming MAGNETIC.
local key = string.format("%d|%s|%s|%s|%s|%s", heading and math.floor(heading + 0.5) or -1, src,
tgt_bearing and math.floor(tgt_bearing + 0.5) or "-", tostring(cal ~= nil),
tostring(mag_sat), href())
if key ~= last_dial_key then
draw_dial(tgt_bearing)
last_dial_key = key
end
end
-- ---------------------------------------------------------------------------
-- actions
local function toggle_cal()
if not has_compass then sys.toast("No magnetometer on this board", 1500) return end
if calib then
local fit, r = calib_solve()
if fit then
cal = fit
save_cal()
sys.toast(string.format("Calibrated field %.2f G", r), 2200)
else
sys.toast("Not calibrated: " .. tostring(r), 2800)
end
calib = nil
if sys.keep_awake then sys.keep_awake(false) end
else
-- Hold the screen and the app's tick for the sweep: the default screen
-- timeout is the same 20 s as this calibration, and a blanked screen used
-- to stop the sampling dead half way through and save the partial fit.
if sys.keep_awake then sys.keep_awake(true) end
calib = calib_new()
sys.toast("Turn it every way ON ONE SPOT - do not carry it around", 3000)
end
hv_x, hv_y = 0, 0
end
-- One press does the whole orientation job: hold the device flat with the top
-- edge at north and press A.
-- * handedness — whether the heading runs clockwise or anticlockwise depends
-- on which way the sensor's Z axis faces, and that shows up in the sign of
-- the vertical field: Earth's field dips DOWN north of the magnetic
-- equator and UP south of it. With a position (a fix, or the last one the
-- node knows) the sign of `mag_z` therefore says which way Z points.
-- * offset — whatever angle the sensor reports while pointing north becomes
-- the zero.
-- the selected row's key is drawn in the accent colour so it is obvious which
-- one OK will switch
local function paint_selection()
for _, n in ipairs({ "alt", "spd" }) do
local kl = L["k_" .. n]
if kl then kl:color(n == sel and C.accent or C.sub) end
end
end
local function move_sel(dir)
sel = (sel == "alt") and "spd" or "alt"
paint_selection()
end
local function toggle_units(which)
which = which or sel
UNITS[which] = not UNITS[which]
store.set(which == "alt" and "u_alt" or "u_spd", UNITS[which] and 1 or 0)
sel = which
paint_selection()
last_text[which] = nil -- force the row to re-render now
if which == "alt" then
sys.toast(UNITS.alt and "Altitude and range: feet / miles" or "Altitude and range: metres / km", 1400)
else
sys.toast(UNITS.spd and "Speed: mph" or "Speed: km/h", 1200)
end
end
-- Note on what "north" means here: this is a MAGNETIC compass. Magnetic north
-- and true north differ by the local declination -- about 13 degrees in
-- California, over 15 in parts of the US -- so a dial that disagrees with a
-- phone (which shows true north) by roughly that much is not broken, it is
-- measuring a different north.
--
-- A pressed while pointing at TRUE north folds the local declination into
-- `align` and makes the two agree. That is now its only job: both sensors'
-- axis mappings are measured, so nothing here has to guess at handedness any
-- more (the first version tried to infer it from the dip and had the test
-- inverted, which is what once made the dial turn the wrong way).
local function align_north()
if not has_compass then sys.toast("No magnetometer on this board", 1500) return end
if not cal then sys.toast("Calibrate first: press C", 2000) return end
local m = sys.compass()
if not m or m.ovfl then sys.toast("No usable reading", 1500) return end
align = 0
align = -mag_heading(m) -- whatever it reads now becomes 000
-- mag_heading() already added the declination, so `align` is the residual on
-- top of the model -- mounting error, a stray magnet in the case -- and it
-- stays correct as the model updates. But with no fix yet the declination in
-- there was 0, so `align` quietly swallowed the real one; counting it again
-- when the fix arrives would double it. Remember to take it back out.
align_pending_decl = (decl_src == "none")
store.set("align_pd", align_pending_decl and 1 or nil) -- the store takes numbers, not booleans
save_align()
hv_x, hv_y = 0, 0
sys.toast(align_pending_decl and "North set (magnetic until a fix)" or "North set here", 1600)
end
-- ---------------------------------------------------------------------------
-- layout
local function label(name, x, y, size, color, width)
local l = ui.label("", x, y, size or 12, color or C.text)
if width then l:width(width) end
L[name] = l
end
function app.on_open(w, h)
W, H = w, h
caps = sys.caps()
has_compass = caps.compass == true
has_accel = caps.accel == true
load_prefs()
landscape = w >= h * 1.3
TH12, TH14, TH16 = ui.text_h(12), ui.text_h(14), ui.text_h(16)
-- panel rows: { name, key } — a key/value pair per row, keys in the muted
-- colour at x0, values in the value column. Key-less rows after TGT are the
-- target's detail lines and sit in the value column. The magnetometer
-- status line is not a row: it is centred over the dial. The key hint is
-- not a row either: it sits on the bottom edge of the view.
local rows = {
{ "fix", "FIX" }, { "lat", "LAT" }, { "lon", "LON" }, { "alt", "ALT" }, { "spd", "SPD" },
{ "tgt", "TGT" }, { "tgt2", false }, { "rel", false }, { "seen", false },
}
local gap_before = { tgt = 4 } -- group spacing
local x0, y0, colw, line
local hint_y
if landscape then
-- stats column on the left, dial on the right under its status line, the
-- hint centred along the bottom edge
hint_y = h - TH12 - 3
dial_y = TH12 + 4
D = math.min(hint_y - 4 - dial_y, w - 176)
cv = ui.canvas(D, D)
dial_x = w - D - 2
x0, y0 = 4, 4
colw = dial_x - x0 - 8
line = TH12 + 2
local function total()
local t = 0
for _, r in ipairs(rows) do t = t + line + (gap_before[r[1]] or 0) end
return t
end
local room = hint_y - 4 - y0 -- rows must clear the hint line
while #rows > 6 and total() > room do table.remove(rows) end
if total() > room then line = math.floor(room / #rows) end
else
D = math.min(w - 8, 200)
cv = ui.canvas(D, D)
dial_x, dial_y = math.floor((w - D) / 2), TH12 + 4
x0, y0 = 6, dial_y + D + 6
colw = w - 12
line = TH12 + 2
if caps.touch then ui.scroll(true) end
end
cv:pos(dial_x, dial_y)
R = math.floor(D / 2) - 2
CX, CY = math.floor(D / 2), math.floor(D / 2)
-- magnetometer / heading-source status, centred over the dial
L.src = ui.label("", dial_x, 2, 12, C.sub)
L.src:width(D, "center")
local keyw = math.floor(TH12 * 2.1) -- "LON" at 12 px is ~24 px; leave a gap
local valx = x0 + keyw + 6
local valw = colw - keyw - 6
col_x0, col_x1 = x0, x0 + colw
val_x, val_w = valx, valw
name_max = math.max(8, math.min(18, math.floor((valw - 4) / (TH12 * 0.62))))
-- Montserrat runs ~0.48 x line height per glyph: the status line over the
-- dial is the longest (22 glyphs, ~10.6 x); go compact when the dial is
-- narrower than that
compact = D < TH12 * 10.7
local y = y0
for _, r in ipairs(rows) do
local name, key = r[1], r[2]
y = y + (gap_before[name] or 0)
if key then
local kl = ui.label(key, x0, y, 12, C.sub)
if name == "alt" or name == "spd" or name == "tgt" then L["k_" .. name] = kl end
end
label(name, valx, y, 12, C.text, valw) -- key-less rows line up with the values
row_y[name] = y
if name == "alt" or name == "spd" then row_hit[name] = { y = y, h = line } end
if name == "fix" then
-- The meter sits right after the count. The reserve is sized for the
-- widest text ("99 sats") so the bars hold still as the number changes,
-- but measured properly: text_w's 0.55-per-character estimate is for
-- mixed text, while digits and spaces in Montserrat run nearer 0.39 of
-- the line height, which left an obvious gap.
local sats_x = valx + text_w("99 sats", TH12, 12) + 4
SATS_W = math.max(20, math.min(52, valx + valw - sats_x - 2))
sats_cv = ui.canvas(SATS_W, SATS_H)
sats_cv:pos(sats_x, y + math.floor((TH12 - SATS_H) / 2))
sats_cv:fill(C.bg)
end
y = y + line
end
-- touch boards in portrait: the same actions as buttons (keys may not exist)
if caps.touch and not landscape then
local bw, bh = math.floor((w - 12 - 9) / 4), 30
local by = y + 2
local bx = 6
if has_compass then
ui.button("Cal", bx, by, bw, bh, toggle_cal); bx = bx + bw + 3
ui.button("North", bx, by, bw, bh, align_north); bx = bx + bw + 3
end
ui.button("Target", bx, by, bw, bh, function() cycle_target(1) end)
y = by + bh + 4
end
-- key hint: centred across the whole view on the bottom edge in landscape,
-- below everything else in portrait (where the body scrolls)
L.hint = ui.label("", 0, hint_y or (y + 2), 12, C.sub)
L.hint:width(w, "center")
if caps.keyboard then
-- "A set north" is deliberately not advertised: the axis mapping is
-- measured, so a calibrated device points north on its own. A still works
-- for an unknown board or a stubborn environment.
hint_normal = has_compass and "C calibrate up/down + OK units <> target"
or "up/down + OK units <> target"
elseif caps.touch then
hint_normal = "Tap a row for units, the dial for the next target"
else
hint_normal = "up/down + OK units <> target"
end
set_text("hint", hint_normal, C.sub)
paint_selection()
refresh_contacts()
next_contacts_ms = sys.millis() + CONTACTS_EVERY
refresh(sys.millis())
timer.every(TICK_MS)
end
function app.on_tick(dt)
local now = sys.millis()
if (now - next_contacts_ms) >= 0 then -- wrap-safe: millis is a 32-bit integer here
refresh_contacts()
next_contacts_ms = now + CONTACTS_EVERY
end
if calib and (now - calib.t0) >= CAL_SECS * 1000 then toggle_cal() end
refresh(now)
end
local function in_dial(x, y)
return x >= dial_x and x < dial_x + D and y >= dial_y and y < dial_y + D
end
-- Diagnostics need every pixel of the panel, so the four target rows move out
-- to the key column's left edge and take the full width while it is on. The
-- TGT key label would sit on top of that, so it is blanked and restored.
local function diag_layout(on)
for _, n in ipairs({ "tgt", "tgt2", "rel", "seen" }) do
local l = L[n]
if l then
l:width(on and (col_x1 - col_x0) or val_w)
local x, y = on and col_x0 or val_x, row_y[n]
if y then l:pos(x, y) end
end
last_text[n] = nil -- force a re-render at the new width
end
if L.k_tgt then L.k_tgt:set(on and "" or "TGT") end
end
-- which stats row a press landed on, or nil
local function row_at(x, y)
if x < col_x0 or x > col_x1 then return nil end
for name, r in pairs(row_hit) do
if y >= r.y - 2 and y < r.y + r.h + 2 then return name end
end
return nil
end
function app.on_input(ev)
if ev.type == "swipe" then
press = nil
-- one finger swipe can arrive twice on touch boards (LVGL's gesture and
-- the firmware's own swipe detector both report it): take the first only
local now = sys.millis()
if (now - last_swipe_ms) < 300 then return end
last_swipe_ms = now
-- the M9's d-pad arrives here, not as key events
if ev.dir == "left" then cycle_target(-1)
elseif ev.dir == "right" then cycle_target(1)
elseif ev.dir == "up" or ev.dir == "down" then move_sel(ev.dir)
end
elseif ev.type == "down" then
-- "down" fires at the start of every touch, swipe or scroll drag, so a tap
-- is only recognised on the matching "up" that landed within 12 px. The
-- M9's OK key synthesises down+up at the body centre, which passes too.
press = { x = ev.x or 0, y = ev.y or 0 }
elseif ev.type == "up" then
if press then
local dx, dy = (ev.x or 0) - press.x, (ev.y or 0) - press.y
if dx * dx + dy * dy <= 144 then
if caps.touch then
-- a tap on the ALT or SPD row switches that row's units; on the dial
-- it steps the target
local r = row_at(press.x, press.y)
if r then toggle_units(r)
elseif in_dial(press.x, press.y) then cycle_target(1) end
else
-- no touchscreen: this is the OK key's synthetic press, wherever the
-- host put it — it switches the selected row's units
toggle_units()
end
end
press = nil
end
elseif ev.type == "key" then
local k = ev.key
-- deliberately no "enter" here: the host answers OK with a synthetic
-- down/up pair AND an enter key event, so acting on both would toggle
-- twice and look like nothing happened
if k == "up" or k == "down" then move_sel(k)
elseif k == "d" or k == "D" then
diag = not diag
last_dial_key = nil
diag_layout(diag)
if not diag then set_text("hint", hint_normal, C.sub) end
sys.toast(diag and "Diagnostics on" or "Diagnostics off", 900)
elseif k == "c" or k == "C" then toggle_cal()
elseif k == "a" or k == "A" then align_north()
elseif k == "x" or k == "X" then
if cal then
cal = nil; save_cal()
align = 0; save_align()
sys.toast("Calibration cleared", 1000)
end
end
end
end
function app.on_close()
calib = nil
end
return app