Files
SamandClaude Sonnet 4.6 2929467a75 fix(apps): ProTreck 1.2 — address reviewer feedback on PR #648
apps.json: one protreck row, ver=1.2, requires=sdk_ext (no seed: false)

protreck.lua:
- get_heading(): compute from raw compass x/y via atan(c.y,-c.x); remove
  c.hdg (no such field) and GPS course fallback (M9-only)
- tap detection: record press={x,y} on "down", act on "up" only when
  distance <= 12 px — swipes no longer trigger waypoint/chrono/timer/alarm
- alarm clock: use sys.datetime() (local time) instead of now_unix()+decompose()
- wada.timer.after: schedule_alarm() fires alarms while screen sleeps, rescheduled daily
- key events: left/right = tab nav, up/down mirror swipe actions
- canvas capped at 480×480 (Tanmatsu compatibility)

harness.lua:
- timer.after mock added
- protreck_tdeck fixture: h=240→196 (T-Deck actual resolution)
- protreck_chrono / protreck_timer / protreck_compass: send down+up tap pairs
- protreck_compass: compass mock returns raw {x,y,z} (not {hdg})
- protreck_deniv: alt_seq reordered so step 9 (first on_tick sample) is the
  max value, matching the GPS call order through draw_status_bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 19:04:14 +02:00

734 lines
30 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ProTreck 1.2 — wadamesh outdoor watch
-- 5 tabs: ASTRO · CMPAS · CHRONO · TIMER · ALTI
-- Permanent status bar on every tab: local time · GPS sats · battery %
--
-- ASTRO : clock, 3 alarms, sun arc + civil twilight, moon disc
-- tap = cycle alarm slot A1/A2/A3 | double-tap = toggle on/off
-- swipe-up/down = ±15 min on selected alarm
-- CMPAS : compass rose (magnetometer or GPS course fallback)
-- tap = set / clear waypoint → bearing + distance shown on rose
-- CHRONO : tap start/stop · swipe-up lap · swipe-down reset
-- TIMER : tap start/stop · swipe-up preset cycle · swipe-down reset
-- ALTI : altitude, min/max, déniv+/−, climb rate m/h, trend graph
-- swipe-down = full reset (min/max + accumulated déniv)
-- Swipe left/right to change tab
-- Contributed by samuelcoustet
local ui, sys, store, tmr = wada.ui, wada.sys, wada.store, wada.timer
local C = ui.colors
local app = {}
-- ── Constants ─────────────────────────────────────────────────────────────
local TAB = { "ASTRO", "CMPAS", "CHRONO", "TIMER", "ALTI" }
local TAB_H = 18
local STATUS_H = 14
local HIST_N = 60
local PI = math.pi
local RAD = PI / 180
local COMPASS_MARKS = { -- {bearing°, label, is_cardinal}
{ 0, "N", true }, { 45, "NE", false },
{ 90, "E", true }, { 135, "SE", false },
{ 180, "S", true }, { 225, "SW", false },
{ 270, "W", true }, { 315, "NW", false },
}
local MOON_NAMES = {
[0]="New",[1]="New",
[2]="Waxing Crescent",[3]="Waxing Crescent",[4]="Waxing Crescent",
[5]="Waxing Crescent",[6]="Waxing Crescent",[7]="Waxing Crescent",
[8]="First Quarter",[9]="First Quarter",
[10]="Waxing Gibbous",[11]="Waxing Gibbous",[12]="Waxing Gibbous",
[13]="Waxing Gibbous",[14]="Waxing Gibbous",
[15]="Full Moon",[16]="Full Moon",
[17]="Waning Gibbous",[18]="Waning Gibbous",[19]="Waning Gibbous",
[20]="Waning Gibbous",[21]="Waning Gibbous",
[22]="Last Quarter",[23]="Last Quarter",
[24]="Waning Crescent",[25]="Waning Crescent",[26]="Waning Crescent",
[27]="Waning Crescent",[28]="Waning Crescent",[29]="New",
}
-- ── Shared display state ───────────────────────────────────────────────────
local tab = 1
local cv, W, H
local press = nil -- pending touch: { x, y } from last "down", cleared on swipe/up
-- ── Time helpers ──────────────────────────────────────────────────────────
local function now_unix()
local ep = sys.epoch and sys.epoch()
if ep then return ep end
if os then return os.time and os.time() end
return nil
end
local function decompose(t)
if os and os.date then return os.date("*t", t) end
local s = t % 86400
local sec = s % 60; s = math.floor(s / 60)
local min2 = s % 60; local hr = math.floor(s / 60)
local days = math.floor(t / 86400)
local y = 1970; local d = days
while true do
local yl = ((y%4==0 and y%100~=0) or y%400==0) and 366 or 365
if d < yl then break end; d = d - yl; y = y + 1
end
return { year=y, month=1, day=1, hour=hr, min=min2, sec=sec, yday=d+1 }
end
local function fmt_hms(ms)
local s = math.floor(ms / 1000)
local cs = math.floor((ms % 1000) / 10)
local m = math.floor(s / 60); s = s % 60
local h = math.floor(m / 60); m = m % 60
if h > 0 then return string.format("%d:%02d:%02d", h, m, s)
else return string.format("%02d:%02d.%02d", m, s, cs) end
end
local function fmt_mm_ss(secs)
local h = math.floor(secs / 3600)
local m = math.floor((secs % 3600) / 60)
local s = secs % 60
if h > 0 then return string.format("%d:%02d:%02d", h, m, s) end
return string.format("%02d:%02d", m, s)
end
local function fmt_utch(fh)
if not fh then return "--:--" end
local h = math.floor(fh) % 24
local m = math.floor((fh - math.floor(fh)) * 60 + 0.5)
if m >= 60 then h = (h+1)%24; m = 0 end
return string.format("%02d:%02d", h, m)
end
-- ── Astronomical algorithms ────────────────────────────────────────────────
-- Generalised: dep_deg=0 → sunrise, -6 → civil twilight, -12 → nautical
local function sun_utc_dep(lat, lon, yday, dep_deg)
local B = RAD * (360/365) * (yday - 81)
local eqt = 9.87*math.sin(2*B) - 7.53*math.cos(B) - 1.5*math.sin(B)
local decl = RAD * 23.45 * math.sin(B)
local lr = lat * RAD; local dr = dep_deg * RAD
local cha = (math.sin(dr) - math.sin(lr)*math.sin(decl)) / (math.cos(lr)*math.cos(decl))
if cha < -1 or cha > 1 then return nil, nil end
local ha = math.acos(cha) / RAD
local noon = 12 - lon/15 - eqt/60
return noon - ha/15, noon + ha/15
end
local function moon_phase_num(t)
return math.floor((t/86400 - 10957 - 6.0) % 29.53058867)
end
local function moonrise_set(phase, sr)
local rise = ((sr or 6) + phase * 24 / 29.5) % 24
return rise, (rise + 12.4) % 24
end
-- ── Navigation helpers ─────────────────────────────────────────────────────
local function bearing_to(la1, lo1, la2, lo2)
local dlon = (lo2-lo1)*RAD; local la1r=la1*RAD; local la2r=la2*RAD
local y = math.sin(dlon)*math.cos(la2r)
local x = math.cos(la1r)*math.sin(la2r) - math.sin(la1r)*math.cos(la2r)*math.cos(dlon)
return (math.atan(y, x)/RAD + 360) % 360
end
local function distance_m(la1, lo1, la2, lo2)
local dlat=(la2-la1)*RAD; local dlon=(lo2-lo1)*RAD
local a = math.sin(dlat/2)^2 + math.cos(la1*RAD)*math.cos(la2*RAD)*math.sin(dlon/2)^2
return 6371000 * 2 * math.atan(math.sqrt(a), math.sqrt(math.max(0, 1-a)))
end
local CARDINALS = {"N","NNE","NE","ENE","E","ESE","SE","SSE",
"S","SSW","SW","WSW","W","WNW","NW","NNW"}
local function cardinal(deg)
return CARDINALS[math.floor(((deg%360)+11.25)/22.5)%16 + 1]
end
-- ── Astro state ────────────────────────────────────────────────────────────
local astro_t0 = 0
local astro_sr, astro_ss -- sunrise/sunset UTC frac hours
local astro_cr, astro_cs -- civil twilight
local astro_mr, astro_ms -- moonrise/set (approx)
local astro_phase = 0
local astro_lat, astro_lon
local astro_tz = 0 -- rough UTC offset for local clock
local function update_astro()
local now = sys.millis()
if now - astro_t0 < 60000 then return end
astro_t0 = now
local t = now_unix()
local gps = sys.gps()
if gps and t then
local d = decompose(t)
local yday = d.yday or 182
astro_lat = gps.lat; astro_lon = gps.lon
astro_tz = math.floor(gps.lon/15 + 0.5)
astro_sr, astro_ss = sun_utc_dep(gps.lat, gps.lon, yday, 0)
astro_cr, astro_cs = sun_utc_dep(gps.lat, gps.lon, yday, -6)
astro_phase = moon_phase_num(t)
astro_mr, astro_ms = moonrise_set(astro_phase, astro_sr)
end
end
-- ── Alarm state ────────────────────────────────────────────────────────────
local alarms = {
{ h=7, m=0, on=0, fire=false },
{ h=8, m=0, on=0, fire=false },
{ h=9, m=0, on=0, fire=false },
}
local alarm_sel = 1
local alarm_prest = 0 -- millis when "down" was received on ASTRO tab
local function alarm_adjust(delta_min)
local al = alarms[alarm_sel]
local tot = al.h*60 + al.m + delta_min
tot = ((tot % (24*60)) + 24*60) % (24*60)
al.h = math.floor(tot/60); al.m = tot % 60
store.set("al"..alarm_sel.."h", al.h)
store.set("al"..alarm_sel.."m", al.m)
end
-- schedule_alarm: one-shot callback that fires via wada.timer.after (works while screen sleeps)
local schedule_alarm -- forward declaration
schedule_alarm = function(i)
local al = alarms[i]
if al.on ~= 1 then return end
local dt = sys.datetime and sys.datetime()
if not dt then return end
local now_min = dt.hour * 60 + dt.min
local al_min = al.h * 60 + al.m
local delta = al_min - now_min
if delta <= 0 then delta = delta + 24*60 end
local ms = (delta * 60 - (dt.sec or 0)) * 1000
tmr.after(math.max(1000, ms), function()
if alarms[i].on == 1 then
sys.beep()
sys.toast(string.format("Alarm A%d %02d:%02d", i, alarms[i].h, alarms[i].m), 4000)
schedule_alarm(i)
end
end)
end
-- ── Compass / waypoint state ───────────────────────────────────────────────
local wpt_lat, wpt_lon = nil, nil
-- ── Chrono state ───────────────────────────────────────────────────────────
local chr_run = false; local chr_t0 = 0; local chr_acc = 0; local chr_laps = {}
local function chr_elapsed()
return chr_run and chr_acc + (sys.millis() - chr_t0) or chr_acc
end
-- ── Timer state ────────────────────────────────────────────────────────────
local TMR_PRESETS = {30,60,120,300,600,1800,3600}
local tmr_pi = 4; local tmr_secs = 300; local tmr_end = 0; local tmr_done = false
local function tmr_rem()
if tmr_end == 0 then return tmr_secs*1000 end
local r = tmr_end - sys.millis(); return r < 0 and 0 or r
end
-- ── Altimeter state ────────────────────────────────────────────────────────
local alti_min = 99999; local alti_max = -99999; local alti_hist = {}
local alti_dplus = 0; local alti_dminus = 0; local alti_prev = nil
-- ── Status bar ────────────────────────────────────────────────────────────
local function draw_status_bar(cw)
cv:rect(0, 0, cw, STATUS_H, 0x0d1520, true, 0)
cv:rect(0, STATUS_H-1, cw, 1, 0x1e2a38, true, 0)
-- local time + UTC offset
local t = now_unix(); local d = t and decompose(t) or nil
if d then
local lh = (d.hour + astro_tz + 24) % 24
cv:text(4, 2, string.format("%02d:%02d", lh, d.min), C.text, 10)
cv:text(44, 2, string.format("UTC%+d", astro_tz), C.sub, 9)
else
cv:text(4, 2, "--:--", C.sub, 10)
end
-- GPS sats
local gps = sys.gps()
local gcol = gps and gps.sats >= 4 and C.good or C.bad
cv:text(math.floor(cw/2)-12, 2, gps and ("G:"..gps.sats) or "G:--", gcol, 10)
-- battery
local bat = sys.battery and sys.battery()
if bat then
local pct = math.floor(bat.pct or 0)
cv:text(cw-28, 2, pct.."%", pct > 20 and C.good or C.bad, 10)
end
end
-- ── Sun arc diagram ────────────────────────────────────────────────────────
local function draw_sun_arc(cx, hy, r, cur_h)
for step = 0, 22 do
local a = PI - step/22*PI
cv:rect(math.floor(cx+(r+3)*math.cos(a)), math.floor(hy-(r+3)*math.sin(a)), 2, 2, 0x1e2d40, true, 0)
end
cv:rect(cx-r-8, hy, r*2+16, 1, C.sub, true, 0) -- horizon
if astro_cr then
cv:rect(cx-r-10, hy-3, 1, 6, 0x4466aa, true, 0) -- civil rise tick
cv:rect(cx+r+ 9, hy-3, 1, 6, 0x4466aa, true, 0) -- civil set tick
end
if astro_sr and astro_ss then
cv:rect(cx-r-3, hy-8, 1, 16, C.good, true, 0) -- rise tick
cv:rect(cx+r+2, hy-8, 1, 16, C.bad, true, 0) -- set tick
if cur_h and cur_h >= astro_sr and cur_h <= astro_ss then
local prog = (cur_h - astro_sr) / (astro_ss - astro_sr)
local a = PI - prog*PI
local sx = math.floor(cx + r*math.cos(a))
local sy = math.floor(hy - r*math.sin(a))
cv:circle(sx, sy, 5, 0xffcc00, true, 0)
cv:circle(sx, sy, 5, 0xff9900, false, 1)
else
cv:circle(cx, hy+9, 4, 0x334455, true, 0) -- below horizon
end
else
cv:circle(cx, hy - math.floor(r/2), 4, 0x445566, true, 0) -- polar
end
end
-- ── Moon phase disc (exact terminator scan-lines) ─────────────────────────
local function draw_moon_disc(cx, cy, r, phase)
local phi = phase / 29.5 * 2 * PI
local drk = 0x1a2433; local lit = 0xdde8f8
local cosp = math.cos(phi)
for yi = -r, r-1, 2 do
local chord = math.floor(math.sqrt(r*r - yi*yi) + 0.5)
if chord > 0 then
cv:rect(cx-chord, cy+yi, chord*2, 2, drk, true, 0)
local term = math.floor(cosp * chord + 0.5)
local lx, lw
if phi <= PI then lx = cx+term; lw = chord-term
else lx = cx-chord; lw = chord-term end
if lw > 0 then cv:rect(lx, cy+yi, lw, 2, lit, true, 0) end
end
end
cv:circle(cx, cy, r, 0x4a5f70, false, 1)
end
-- ── ASTRO tab ─────────────────────────────────────────────────────────────
local function draw_astro(cw, ch)
draw_status_bar(cw)
local t = now_unix(); local d = t and decompose(t) or nil
local cur_h = d and (d.hour + d.min/60 + d.sec/3600) or nil
local SY = STATUS_H + 2
local col_w = math.floor(cw/2)
local cx_r = math.floor(cw*3/4)
local arc_r = math.min(34, math.floor(col_w*0.42))
local moo_r = math.min(20, math.floor(col_w*0.24))
-- ── Alarms row ────────────────────────────────────────────────────────
for i = 1, 3 do
local al = alarms[i]
local sel = i == alarm_sel
local col = al.on==1 and C.good or (sel and C.accent or C.sub)
local ax = 4 + (i-1)*math.floor((cw-8)/3)
cv:text(ax, SY, "A"..i, sel and C.accent or C.sub, 9)
cv:text(ax, SY+10, string.format("%02d:%02d",al.h,al.m), col, 11)
cv:circle(ax+40, SY+14, 3, al.on==1 and C.good or 0x223344, true, 0)
end
local SEP1 = SY + 30
cv:rect(0, SEP1, cw, 1, 0x1e2a38, true, 0)
-- ── Sun ───────────────────────────────────────────────────────────────
local SUN_Y = SEP1 + 4
cv:text(4, SUN_Y, "SUN", C.accent, 11)
if astro_sr then
cv:text(4, SUN_Y+13, "Rise "..fmt_utch(astro_sr), C.text, 11)
cv:text(4, SUN_Y+25, "Set "..fmt_utch(astro_ss), C.text, 11)
if astro_cr then
cv:text(4, SUN_Y+37, "Twl "..fmt_utch(astro_cr), 0x5588cc, 10)
end
elseif astro_lat then cv:text(4, SUN_Y+13, "Polar", C.sub, 11)
else cv:text(4, SUN_Y+13, "No GPS", C.bad, 11) end
local ARC_HY = SUN_Y + 40
draw_sun_arc(cx_r, ARC_HY, arc_r, cur_h)
if astro_sr then
cv:text(cx_r-arc_r-8, ARC_HY+6, fmt_utch(astro_sr), C.good, 9)
cv:text(cx_r+arc_r+2, ARC_HY+6, fmt_utch(astro_ss), C.bad, 9)
end
local SEP2 = ARC_HY + 18
cv:rect(0, SEP2, cw, 1, 0x1e2a38, true, 0)
-- ── Moon ──────────────────────────────────────────────────────────────
local MOO_Y = SEP2 + 4
cv:text(4, MOO_Y, "MOON", C.accent, 11)
if astro_lat and t then
cv:text(4, MOO_Y+13, MOON_NAMES[astro_phase] or "?", C.text, 11)
cv:text(4, MOO_Y+25, "Rise "..fmt_utch(astro_mr), C.sub, 10)
cv:text(4, MOO_Y+36, "Set "..fmt_utch(astro_ms), C.sub, 10)
cv:text(4, MOO_Y+47, "Phase "..astro_phase.."/29", C.sub, 10)
else
cv:text(4, MOO_Y+13, "No GPS+time", C.bad, 11)
end
draw_moon_disc(cx_r, math.floor(MOO_Y+30), moo_r, astro_phase)
-- GPS footer
local gps = sys.gps()
if gps then cv:text(4, ch-12, string.format("%.3f,%.3f %dm", gps.lat, gps.lon, math.floor(gps.alt_m or 0)), C.sub, 10)
else cv:text(4, ch-12, "GPS: no fix", C.bad, 10) end
end
-- ── CMPAS tab ─────────────────────────────────────────────────────────────
local function get_heading()
local c = sys.compass and sys.compass()
if c then
-- raw magnetometer x/y → heading (matches GPS Compass axis mapping: bx=c.y, by=-c.x)
return (math.deg(math.atan(c.y, -c.x)) + 360) % 360
end
return nil
end
local function draw_cmpas(cw, ch)
draw_status_bar(cw)
local hdg = get_heading()
local cx = math.floor(cw/2)
local r = math.min(68, math.floor(math.min(cw, ch-STATUS_H-56)/2) - 4)
local cy = STATUS_H + r + 14
cv:circle(cx, cy, r, 0x151e2a, true, 0)
cv:circle(cx, cy, r, C.sub, false, 1)
cv:circle(cx, cy, r-5, 0x0d1117, true, 0)
if hdg then
-- rotating rose
for _, mk in ipairs(COMPASS_MARKS) do
local sa = (mk[1] - hdg) * RAD
local sins = math.sin(sa); local coss = math.cos(sa)
local tk = mk[3] and 9 or 5
local ox = math.floor(cx + r * sins); local oy = math.floor(cy - r * coss)
local ix = math.floor(cx + (r-tk)* sins); local iy = math.floor(cy - (r-tk)* coss)
local tc = mk[1] == 0 and C.bad or (mk[3] and C.text or C.sub)
cv:line(ix, iy, ox, oy, tc, mk[3] and 2 or 1)
if mk[3] then
local lx = math.floor(cx + (r+10)*sins)
local ly = math.floor(cy - (r+10)*coss)
cv:text(lx-3, ly-5, mk[2], tc, 10)
end
end
-- needle: red toward N, grey toward S
local na = (-hdg)*RAD
local nx = math.floor(cx + (r-6)*math.sin(na)); local ny = math.floor(cy - (r-6)*math.cos(na))
local sx = math.floor(cx - (r/3)*math.sin(na)); local sy = math.floor(cy + (r/3)*math.cos(na))
cv:line(cx, cy, nx, ny, 0xff3333, 2)
cv:line(cx, cy, sx, sy, C.sub, 2)
cv:circle(cx, cy, 4, C.text, true, 0)
cv:rect(cx-2, cy-r-10, 4, 8, C.accent, true, 2) -- fixed course indicator
-- heading readout
local hy2 = cy + r + 8
cv:text(cx-18, hy2, string.format("%d", math.floor(hdg)), C.text, 18)
cv:text(cx+22, hy2+4, cardinal(hdg), C.sub, 12)
-- waypoint line on rose + info
local gps = sys.gps()
if wpt_lat and gps then
local brg = bearing_to(gps.lat, gps.lon, wpt_lat, wpt_lon)
local dist_m = distance_m(gps.lat, gps.lon, wpt_lat, wpt_lon)
local wa = (brg - hdg)*RAD
local wx = math.floor(cx + (r-3)*math.sin(wa))
local wy = math.floor(cy - (r-3)*math.cos(wa))
cv:line(cx, cy, wx, wy, C.accent, 2)
cv:circle(wx, wy, 4, C.accent, true, 0)
local dist_s = dist_m >= 1000
and string.format("WPT %.1fkm / %d° %s", dist_m/1000, math.floor(brg), cardinal(brg))
or string.format("WPT %dm / %d° %s", math.floor(dist_m), math.floor(brg), cardinal(brg))
cv:text(4, ch-24, dist_s, C.accent, 11)
end
else
cv:text(cx-24, cy-8, "No signal", C.sub, 14)
end
local gps = sys.gps()
local hint = wpt_lat and "tap: clear waypoint"
or (gps and "tap: save waypoint here" or "tap: (need GPS)")
cv:text(4, ch-12, hint, C.sub, 10)
end
-- ── CHRONO tab ────────────────────────────────────────────────────────────
local function draw_chrono(cw, ch)
draw_status_bar(cw)
local SY = STATUS_H + 8
local ms = chr_elapsed()
local ts = fmt_hms(ms)
local tw = math.floor(#ts*14)
cv:text(math.max(4, math.floor((cw-tw)/2)), SY, ts, chr_run and C.good or C.text, 22)
local stat = chr_run and "RUNNING" or (chr_acc > 0 and "STOPPED" or "READY")
cv:text(math.floor((cw-#stat*6)/2), SY+34, stat, chr_run and C.good or C.sub, 11)
cv:text(4, SY+48, "tap: start/stop up: lap down: reset", C.sub, 11)
local start_i = math.max(1, #chr_laps-5)
local ly = SY + 66
for i = start_i, #chr_laps do
local col = i==#chr_laps and C.text or C.sub
cv:text(4, ly, string.format("Lap %d", i), col, 12)
cv:text(44, ly, chr_laps[i], col, 12)
ly = ly + 14; if ly > ch-20 then break end
end
end
-- ── TIMER tab ─────────────────────────────────────────────────────────────
local function draw_timer(cw, ch)
draw_status_bar(cw)
local SY = STATUS_H + 8
local rem = tmr_rem()
local ts = fmt_mm_ss(math.floor(rem/1000))
local col = tmr_done and C.bad or (tmr_end~=0 and C.good or C.text)
local tw = math.floor(#ts*16)
cv:text(math.max(4, math.floor((cw-tw)/2)), SY, ts, col, 26)
local total = tmr_secs*1000
local pct = total>0 and math.floor((total-rem)*(cw-8)/total) or 0
if pct > 0 then cv:rect(4, SY+38, pct, 4, tmr_done and C.bad or C.accent, true, 0) end
cv:rect(4, SY+38, cw-8, 4, 0x1a1f26, false, 0)
cv:text(4, SY+50, "Preset: "..fmt_mm_ss(tmr_secs), C.sub, 11)
cv:text(4, SY+64, "tap: start/stop up: preset down: reset", C.sub, 11)
if tmr_done then
local ax = math.floor((cw-60)/2)
cv:rect(ax, SY+80, 60, 22, 0x1a1f26, true, 4)
cv:text(ax+8, SY+84, "ALARM!", C.bad, 16)
end
end
-- ── ALTI tab ──────────────────────────────────────────────────────────────
local function draw_alti(cw, ch)
draw_status_bar(cw)
local SY = STATUS_H + 2
local gps = sys.gps()
local alt = gps and gps.alt_m or nil
local ats = alt and string.format("%d m", math.floor(alt)) or "--- m"
cv:text(math.max(4, math.floor((cw-#ats*14)/2)), SY, ats, alt and C.text or C.sub, 24)
-- min / max
local mn_s = alti_min<99999 and string.format("%dm",math.floor(alti_min)) or "---"
local mx_s = alti_max>-99999 and string.format("%dm",math.floor(alti_max)) or "---"
cv:text(4, SY+32, "MIN", C.sub, 10)
cv:text(4, SY+42, mn_s, C.sub, 12)
cv:text(math.floor(cw/3)+4, SY+32, "MAX", C.sub, 10)
cv:text(math.floor(cw/3)+4, SY+42, mx_s, C.sub, 12)
-- déniv
local dp_s = string.format("+%d -%dm", math.floor(alti_dplus), math.floor(alti_dminus))
cv:text(math.floor(cw*2/3)+4, SY+32, "D+/-", C.sub, 10)
cv:text(math.floor(cw*2/3)+4, SY+42, dp_s, C.sub, 11)
-- climb rate (last 12 samples = 6 s at 500ms tick)
local n = #alti_hist
if n >= 2 then
local from = math.max(1, n-12)
local rate = (alti_hist[n] - alti_hist[from]) * 7200 / (n - from)
local rate_s = string.format("%+d m/h", math.floor(rate))
local rcol = math.abs(rate)<30 and C.sub or (rate>0 and C.good or 0x6699ff)
cv:text(4, SY+58, rate_s, rcol, 12)
if gps then
cv:text(60, SY+58, string.format(" Sats %d %dkm/h", gps.sats,
math.floor(gps.speed_kmh or 0)),
gps.sats>=4 and C.good or C.bad, 11)
end
elseif gps then
cv:text(4, SY+58, string.format("Sats %d", gps.sats), gps.sats>=4 and C.good or C.bad, 11)
end
-- trend graph
if n > 2 then
local gy = SY + 74
local gh = ch - gy - 20
local gw = cw - 8
cv:rect(4, gy, gw, gh, 0x1a1f26, true, 2)
local mn2, mx2 = alti_hist[1], alti_hist[1]
for _, v in ipairs(alti_hist) do if v<mn2 then mn2=v end; if v>mx2 then mx2=v end end
local rng = mx2-mn2; if rng < 1 then rng = 1 end
for i = 2, n do
local x1=math.floor(4+(i-2)*gw/(HIST_N-1)); local x2=math.floor(4+(i-1)*gw/(HIST_N-1))
local y1=math.floor(gy+gh-(alti_hist[i-1]-mn2)*gh/rng)
local y2=math.floor(gy+gh-(alti_hist[i] -mn2)*gh/rng)
cv:rect(math.min(x1,x2), math.min(y1,y2),
math.max(1,math.abs(x2-x1)), math.max(1,math.abs(y2-y1)), C.accent, true, 0)
end
cv:text(6, gy+2, math.floor(mx2).."m", C.sub, 10)
cv:text(6, gy+gh-12, math.floor(mn2).."m", C.sub, 10)
end
cv:text(4, ch-12, "down: reset min/max + deniv", C.sub, 10)
end
-- ── Tab bar + redraw ──────────────────────────────────────────────────────
local function draw_tab_bar()
local tw = math.floor(W / #TAB)
for i, name in ipairs(TAB) do
local tx = (i-1)*tw
if i == tab then
cv:rect(tx, H-TAB_H, tw, TAB_H, C.accent, true, 0)
cv:text(math.floor(tx+(tw-#name*6)/2), H-TAB_H+4, name, 0x000000, 10)
else
cv:text(math.floor(tx+(tw-#name*6)/2), H-TAB_H+4, name, C.sub, 10)
end
end
end
local function redraw()
if not cv then return end
local ch = H - TAB_H
cv:fill(0x0d1117)
if tab==1 then draw_astro( W, ch)
elseif tab==2 then draw_cmpas( W, ch)
elseif tab==3 then draw_chrono(W, ch)
elseif tab==4 then draw_timer( W, ch)
elseif tab==5 then draw_alti( W, ch)
end
draw_tab_bar()
end
-- ── App lifecycle ─────────────────────────────────────────────────────────
function app.on_open(w, h)
W, H = math.min(w, 480), math.min(h, 480)
cv = ui.canvas(W, H); cv:pos(0, 0)
-- restore alarms
for i = 1, 3 do
alarms[i].h = tonumber(store.get("al"..i.."h", alarms[i].h)) or alarms[i].h
alarms[i].m = tonumber(store.get("al"..i.."m", alarms[i].m)) or alarms[i].m
alarms[i].on = tonumber(store.get("al"..i.."on", 0)) or 0
end
-- schedule any alarm that was toggled on in a previous session
for i = 1, 3 do if alarms[i].on == 1 then schedule_alarm(i) end end
alti_min = tonumber(store.get("alti_min", 99999)) or 99999
alti_max = tonumber(store.get("alti_max", -99999)) or -99999
tmr_pi = tonumber(store.get("tmr_pi", 4)) or 4
if tmr_pi < 1 or tmr_pi > #TMR_PRESETS then tmr_pi = 4 end
tmr_secs = TMR_PRESETS[tmr_pi]
-- restore waypoint
local wlat = tonumber(store.get("wpt_lat", ""))
local wlon = tonumber(store.get("wpt_lon", ""))
if wlat and wlon then wpt_lat = wlat; wpt_lon = wlon end
astro_t0 = 0; update_astro(); redraw(); tmr.every(500)
end
function app.on_tick(dt)
-- altimeter sampling (all tabs, not just ALTI, to accumulate déniv while exploring)
local gps = sys.gps()
if gps and gps.alt_m then
local a = gps.alt_m
alti_hist[#alti_hist+1] = a
if #alti_hist > HIST_N then table.remove(alti_hist, 1) end
if a < alti_min then alti_min = a; store.set("alti_min", alti_min) end
if a > alti_max then alti_max = a; store.set("alti_max", alti_max) end
if alti_prev then
local diff = a - alti_prev
if diff > 0.5 then alti_dplus = alti_dplus + diff
elseif diff < -0.5 then alti_dminus = alti_dminus - diff end
end
alti_prev = a
end
-- countdown timer
if tmr_end ~= 0 and not tmr_done and sys.millis() >= tmr_end then
tmr_done = true; tmr_end = 0; sys.beep()
end
-- alarm clock check (local time via sys.datetime; also fired by schedule_alarm while screen sleeps)
local dc = sys.datetime and sys.datetime()
if dc then
for _, al in ipairs(alarms) do
if al.on==1 and dc.hour==al.h and dc.min==al.m then
if not al.fire then
al.fire = true; sys.beep()
sys.toast(string.format("Alarm %02d:%02d", al.h, al.m), 4000)
end
else
al.fire = false
end
end
end
update_astro(); redraw()
end
local function do_swipe_up()
if tab==1 then alarm_adjust(15)
elseif tab==3 and chr_run then
chr_laps[#chr_laps+1] = fmt_hms(chr_elapsed())
sys.toast("Lap "..(#chr_laps), 800)
elseif tab==4 then
tmr_pi = (tmr_pi%#TMR_PRESETS)+1; tmr_secs = TMR_PRESETS[tmr_pi]
store.set("tmr_pi", tmr_pi); tmr_end=0; tmr_done=false
end
end
local function do_swipe_down()
if tab==1 then alarm_adjust(-15)
elseif tab==3 then chr_run=false; chr_t0=0; chr_acc=0; chr_laps={}
elseif tab==4 then tmr_end=0; tmr_done=false
elseif tab==5 then
alti_min=99999; alti_max=-99999; alti_hist={}
alti_dplus=0; alti_dminus=0; alti_prev=nil
store.set("alti_min",alti_min); store.set("alti_max",alti_max)
sys.toast("Alt + déniv reset", 1000)
end
end
function app.on_input(ev)
if ev.type == "swipe" then
press = nil
local d = ev.dir
if d == "left" then tab = (tab % #TAB) + 1
elseif d == "right" then tab = ((tab-2) % #TAB) + 1
elseif d == "up" then do_swipe_up()
elseif d == "down" then do_swipe_down()
end
redraw()
elseif ev.type == "down" then
press = { x = ev.x or 0, y = ev.y or 0 }
if tab == 1 then alarm_prest = sys.millis() end
elseif ev.type == "up" then
local is_tap = false
if press then
local dx = (ev.x or 0) - press.x
local dy = (ev.y or 0) - press.y
is_tap = dx*dx + dy*dy <= 144 -- 12 px radius (matches GPS Compass)
press = nil
end
if tab == 1 and is_tap then
-- short tap = cycle slot; long hold ≥500ms = toggle on/off
local dur = sys.millis() - alarm_prest
if dur >= 500 then
local al = alarms[alarm_sel]
al.on = al.on==1 and 0 or 1
store.set("al"..alarm_sel.."on", al.on)
if al.on == 1 then schedule_alarm(alarm_sel) end
else
alarm_sel = (alarm_sel % 3) + 1
end
alarm_prest = 0
redraw()
elseif tab == 2 and is_tap then
local gps2 = sys.gps()
if wpt_lat then
wpt_lat=nil; wpt_lon=nil
store.set("wpt_lat",""); store.set("wpt_lon","")
sys.toast("Waypoint cleared", 800)
elseif gps2 then
wpt_lat=gps2.lat; wpt_lon=gps2.lon
store.set("wpt_lat", wpt_lat); store.set("wpt_lon", wpt_lon)
sys.toast(string.format("WPT %.4f,%.4f saved", wpt_lat, wpt_lon), 1200)
end
redraw()
elseif tab == 3 and is_tap then
if chr_run then chr_acc=chr_elapsed(); chr_run=false
else chr_t0=sys.millis(); chr_run=true end
redraw()
elseif tab == 4 and is_tap then
if tmr_end~=0 then tmr_end=0; tmr_done=false
elseif tmr_done then tmr_done=false
else tmr_end=sys.millis()+tmr_secs*1000 end
redraw()
end
elseif ev.type == "key" then
local k = ev.key
if k == "left" then tab = ((tab-2) % #TAB) + 1; redraw()
elseif k == "right" then tab = (tab % #TAB) + 1; redraw()
elseif k == "up" then do_swipe_up(); redraw()
elseif k == "down" then do_swipe_down(); redraw()
end
end
end
function app.on_close() chr_run = false end
return app