Files
SamandClaude Sonnet 4.6 06f9c6e549 feat(apps): ProTreck 1.0 — outdoor watch Lua app (Astro/Chrono/Timer/Alti)
Sun/Moon rise+set from GPS lat/lon + date (Spencer 1971), moon phase 0-29.
Chronometer with lap times, countdown timer with alarm beep and preset
cycling, altimeter with GPS alt_m, min/max persistence and trend graph.
Swipe left/right to switch tabs. 4 tabs fit any screen size.

test(harness): add 7 ProTreck scenarios (tdeck, v4, no_gps, chrono,
timer, alti, cost). ALL OK 7/7.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-02 22:52:51 +02:00

492 lines
17 KiB
Lua

-- ProTreck — wadamesh outdoor watch: Astro · Chrono · Timer · Altimeter
-- Swipe left/right to change tab
-- ASTRO: sunrise/sunset + moon phase from GPS fix
-- CHRONO: tap start/stop, swipe-up lap, swipe-down reset
-- TIMER: tap start/stop, swipe-up cycle preset, swipe-down reset
-- ALTI: GPS altitude with min/max and trend graph; swipe-down resets min/max
-- 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", "CHRONO", "TIMER", "ALTI" }
local TAB_H = 18 -- tab bar height (px)
local HIST_N = 60 -- altimeter ring-buffer size
-- ── State ─────────────────────────────────────────────────────────────────
local tab = 1
local cv, W, H
-- Astro
local astro_t0 = 0 -- last refresh (millis)
local astro_sr, astro_ss -- sunrise / sunset (UTC fractional hours), or nil
local astro_phase = 0 -- moon phase 0-29
local astro_lat, astro_lon, astro_yday, astro_year
-- Chrono
local chr_run = false
local chr_t0 = 0 -- millis at last start
local chr_acc = 0 -- accumulated ms before last start
local chr_laps = {} -- list of "MM:SS.cs" strings
-- Timer
local TMR_PRESETS = { 30, 60, 120, 300, 600, 1800, 3600 }
local tmr_pi = 4 -- preset index (default 300 s)
local tmr_secs = 300
local tmr_end = 0 -- millis when alarm fires; 0 = not running
local tmr_done = false
-- Altimeter
local alti_min = 99999
local alti_max = -99999
local alti_hist = {}
local alti_hi = 0 -- ring-buffer write head
-- ── 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
-- minimal fallback (good enough for rough yday on harness without os.date)
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)
-- Gregorian: days since epoch 1970-01-01
local y = 1970; local d = days
while true do
local ylen = ((y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0) and 366 or 365
if d < ylen then break end
d = d - ylen; y = y + 1
end
return { year = y, 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
-- ── Sun / moon algorithms ─────────────────────────────────────────────────
local PI = math.pi
local RAD = PI / 180
local function sun_utc(lat, lon, yday)
-- Spencer 1971 simplified; returns rise_utc, set_utc (fractional hours) or nil,nil
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) -- minutes
local decl = RAD * 23.45 * math.sin(B)
local cha = -math.tan(lat * RAD) * math.tan(decl)
if cha < -1 or cha > 1 then return nil, nil end
local ha = math.acos(cha) / RAD -- degrees
local noon = 12 - lon / 15 - eqt / 60
return noon - ha / 15, noon + ha / 15
end
local function moon_phase_num(t)
-- days since known new moon 2000-01-06 (JD 2451549.5) → phase 0-29
local days = t / 86400 - 10957 -- 10957 = days from 1970-01-01 to 2000-01-01
local cycle = 29.53058867
return math.floor((days - 6.0) % cycle)
end
local MOON_NAMES = {
[0]="New Moon", [1]="New Moon",
[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 Moon",
}
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)
astro_lat = gps.lat
astro_lon = gps.lon
astro_yday = d.yday or 182
astro_year = d.year or 2024
astro_sr, astro_ss = sun_utc(gps.lat, gps.lon, astro_yday)
astro_phase = moon_phase_num(t)
end
end
-- ── Draw helpers ──────────────────────────────────────────────────────────
local function tx(x) return math.floor(x) end
local function ty(y) return math.floor(y) end
local function label(x, y, s, col, sz)
cv:text(tx(x), ty(y), tostring(s), col, sz)
end
local function head(x, y, s, col)
label(x, y, s, col or C.sub, 11)
end
local function big(x, y, s, col, sz)
label(x, y, s, col or C.text, sz or 24)
return #s * (sz and math.floor(sz * 0.6) or 14) -- rough width estimate
end
-- ── ASTRO tab ─────────────────────────────────────────────────────────────
local function draw_astro(cw, ch)
local t = now_unix()
local d = t and decompose(t) or nil
-- Date header
if d then
local ds = string.format("%04d-%02d-%02d", d.year or 0, d.month or 0, d.day or 0)
head(4, 4, ds, C.sub)
local ts2 = string.format("%02d:%02d:%02d UTC", d.hour or 0, d.min or 0, d.sec or 0)
head(cw - tx(#ts2 * 6 + 4), 4, ts2, C.sub)
end
local y = 22
-- Sun section
cv:rect(4, y, cw - 8, 1, C.sub, true, 0)
y = y + 6
head(4, y, "SUN", C.accent)
y = y + 14
if astro_sr then
local rh = math.floor(astro_sr)
local rm = math.floor((astro_sr - rh) * 60 + 0.5)
local sh = math.floor(astro_ss)
local sm = math.floor((astro_ss - sh) * 60 + 0.5)
label(8, y, string.format("Rise %02d:%02d", rh, rm), C.text, 13)
label(8, y + 16, string.format("Set %02d:%02d", sh, sm), C.text, 13)
elseif astro_lat then
label(8, y, "Polar day / night", C.sub, 12)
y = y - 16
else
label(8, y, "Need GPS fix", C.bad, 12)
y = y - 16
end
y = y + 40
-- Moon section
cv:rect(4, y, cw - 8, 1, C.sub, true, 0)
y = y + 6
head(4, y, "MOON", C.accent)
y = y + 14
if astro_lat and t then
local name = MOON_NAMES[astro_phase] or "?"
label(8, y, string.format("Phase %d / 29", astro_phase), C.text, 13)
label(8, y + 16, name, C.sub, 12)
-- draw a simple moon disc glyph (filled = new/full, half = quarters)
local mx = math.floor(cw * 3 / 4)
local my = ty(y + 8)
local r = 10
cv:circle(mx, my, r, C.sub, false, 1)
if astro_phase < 3 or astro_phase > 27 then
cv:circle(mx, my, r - 2, C.sub, true, 0) -- new moon: dark
elseif astro_phase < 15 then
cv:circle(mx, my, r - 2, C.accent, true, 0) -- waxing: right half lit
elseif astro_phase < 17 then
cv:circle(mx, my, r - 2, C.good, true, 0) -- full moon
else
cv:circle(mx, my, r - 2, C.accent, true, 0) -- waning: left half lit
end
else
label(8, y, "Need GPS + time", C.bad, 12)
end
-- GPS status row at bottom
local gps = sys.gps()
if gps then
label(4, ch - 16, string.format("GPS %d sats %.4f,%.4f", gps.sats, gps.lat, gps.lon), C.sub, 10)
else
label(4, ch - 16, "GPS: no fix", C.bad, 10)
end
end
-- ── CHRONO tab ────────────────────────────────────────────────────────────
local function chr_elapsed()
if chr_run then
return chr_acc + (sys.millis() - chr_t0)
else
return chr_acc
end
end
local function draw_chrono(cw, ch)
local ms = chr_elapsed()
local ts = fmt_hms(ms)
-- big time
local tw = tx(#ts * 14)
local tx2 = math.max(4, math.floor((cw - tw) / 2))
cv:text(tx2, 20, ts, chr_run and C.good or C.text, 22)
-- status
local stat = chr_run and "RUNNING" or (chr_acc > 0 and "STOPPED" or "READY")
head(math.floor((cw - #stat * 6) / 2), 52, stat, chr_run and C.good or C.sub)
-- hints
head(4, 66, "tap: start/stop up: lap down: reset", C.sub)
-- lap list (last 6)
local start_lap = math.max(1, #chr_laps - 5)
local ly = 84
for i = start_lap, #chr_laps do
local col = i == #chr_laps and C.text or C.sub
label(4, ly, string.format("Lap %d", i), col, 12)
label(44, ly, chr_laps[i], col, 12)
ly = ly + 14
if ly > ch - 20 then break end
end
end
-- ── TIMER tab ─────────────────────────────────────────────────────────────
local function tmr_remaining()
if tmr_end == 0 then return tmr_secs * 1000 end
local r = tmr_end - sys.millis()
return r < 0 and 0 or r
end
local function draw_timer(cw, ch)
local rem = tmr_remaining()
local secs = math.floor(rem / 1000)
local ts = fmt_mm_ss(secs)
local col = tmr_done and C.bad or (tmr_end ~= 0 and C.good or C.text)
local tw = tx(#ts * 16)
local tx2 = math.max(4, math.floor((cw - tw) / 2))
cv:text(tx2, 20, ts, col, 26)
-- progress bar
local total_ms = tmr_secs * 1000
local pct = (total_ms > 0) and math.floor((total_ms - rem) * (cw - 8) / total_ms) or 0
if pct > 0 then
cv:rect(4, 58, pct, 4, tmr_done and C.bad or C.accent, true, 0)
end
cv:rect(4, 58, cw - 8, 4, 0x1a1f26, false, 0)
-- preset
local pre_s = string.format("Preset: %s", fmt_mm_ss(tmr_secs))
head(4, 70, pre_s, C.sub)
head(4, 84, "tap: start/stop up: cycle preset down: reset", C.sub)
if tmr_done then
local ax = math.floor((cw - 60) / 2)
cv:rect(ax, 100, 60, 22, 0x1a1f26, true, 4)
label(ax + 8, 104, "ALARM!", C.bad, 16)
end
end
-- ── ALTI tab ──────────────────────────────────────────────────────────────
local function draw_alti(cw, ch)
local gps = sys.gps()
local alt = gps and gps.alt_m or nil
-- big altitude
local ats = alt and string.format("%d m", math.floor(alt)) or "--- m"
local tw = tx(#ats * 14)
cv:text(math.max(4, math.floor((cw - tw) / 2)), 10, ats,
alt and C.text or C.sub, 24)
-- min / max
local mn_s = alti_min < 99999 and string.format("%d m", math.floor(alti_min)) or "---"
local mx_s = alti_max > -99999 and string.format("%d m", math.floor(alti_max)) or "---"
head(4, 44, "MIN")
label(4, 56, mn_s, C.sub, 13)
head(math.floor(cw / 2) + 4, 44, "MAX")
label(math.floor(cw / 2) + 4, 56, mx_s, C.sub, 13)
-- GPS info
if gps then
label(4, 76, string.format("Sats %d %d km/h", gps.sats, math.floor(gps.speed_kmh or 0)),
gps.sats >= 4 and C.good or C.bad, 11)
else
label(4, 76, "No GPS fix", C.bad, 11)
end
-- trend graph
local n = #alti_hist
if n > 2 then
local gy = 92
local gh = ch - gy - 20
local gw = cw - 8
cv:rect(4, gy, gw, gh, 0x1a1f26, true, 2)
-- find range
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
-- draw line as series of small rects
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)
local lx = math.min(x1, x2)
local lw = math.max(1, math.abs(x2 - x1))
local ly = math.min(y1, y2)
local lh = math.max(1, math.abs(y2 - y1))
cv:rect(lx, ly, lw, lh, C.accent, true, 0)
end
-- axis labels
head(6, gy + 2, math.floor(mx2) .. "m", C.sub)
head(6, gy + gh - 12, math.floor(mn2) .. "m", C.sub)
end
head(4, ch - 14, "down: reset min/max", C.sub)
end
-- ── Tab bar ───────────────────────────────────────────────────────────────
local function draw_tab_bar()
local tw = math.floor(W / #TAB)
for i, name in ipairs(TAB) do
local tx2 = (i - 1) * tw
if i == tab then
cv:rect(tx2, H - TAB_H, tw, TAB_H, C.accent, true, 0)
cv:text(math.floor(tx2 + (tw - #name * 6) / 2), H - TAB_H + 4, name, 0x000000, 10)
else
cv:text(math.floor(tx2 + (tw - #name * 6) / 2), H - TAB_H + 4, name, C.sub, 10)
end
end
end
-- ── Full redraw ───────────────────────────────────────────────────────────
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_chrono(W, ch)
elseif tab == 3 then draw_timer( W, ch)
elseif tab == 4 then draw_alti( W, ch)
end
draw_tab_bar()
end
-- ── App lifecycle ─────────────────────────────────────────────────────────
function app.on_open(w, h)
W, H = w, h
cv = ui.canvas(w, h)
cv:pos(0, 0)
-- restore persistent state
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]
astro_t0 = 0 -- force an immediate astro refresh
update_astro()
redraw()
tmr.every(500)
end
function app.on_tick(dt)
-- altimeter: sample GPS when on the alti tab
if tab == 4 then
local gps = sys.gps()
if gps and gps.alt_m then
local a = gps.alt_m
alti_hi = (alti_hi % HIST_N) + 1
alti_hist[alti_hi] = a
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
end
end
-- timer alarm
if tmr_end ~= 0 and not tmr_done and sys.millis() >= tmr_end then
tmr_done = true
tmr_end = 0
sys.beep()
end
-- astro refresh (throttled inside update_astro)
update_astro()
redraw()
end
function app.on_input(ev)
if ev.type == "swipe" then
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
if tab == 2 and chr_run then
chr_laps[#chr_laps + 1] = fmt_hms(chr_elapsed())
sys.toast("Lap " .. #chr_laps, 800)
elseif tab == 3 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
elseif d == "down" then
if tab == 2 then
chr_run = false; chr_t0 = 0; chr_acc = 0; chr_laps = {}
elseif tab == 3 then
tmr_end = 0; tmr_done = false
elseif tab == 4 then
alti_min = 99999; alti_max = -99999; alti_hist = {}; alti_hi = 0
store.set("alti_min", alti_min); store.set("alti_max", alti_max)
sys.toast("Alt min/max reset", 1000)
end
end
redraw()
elseif ev.type == "down" then
if tab == 2 then
if chr_run then
chr_acc = chr_elapsed(); chr_run = false
else
chr_t0 = sys.millis(); chr_run = true
end
redraw()
elseif tab == 3 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
end
end
function app.on_close()
chr_run = false
end
return app