feat(ui-core): status history, trail profile and calendar math, shared

The history of the noise floor, battery, traffic and SNR, a trail's height
profile (with a sampler for trails read point by point off the card) and a
month's calendar, moved out of ui-new so ui-lvgl can draw the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-02 23:26:29 +02:00
co-authored by Claude Opus 5.5
parent 13d099cf55
commit 699d6b6ae0
3 changed files with 182 additions and 0 deletions
@@ -0,0 +1,44 @@
#pragma once
// A month's calendar, shared by both UIs: its length, the column (0 = Monday)
// its 1st falls in, how many weeks it spans, and the days left in it the
// alarm goes off on. ui-new draws it on e-ink (CalendarView.h), ui-lvgl in
// Clock > Calendar.
#include <stdint.h>
#include <time.h>
namespace calmath {
inline int daysIn(int year, int mon) { // mon 0..11, year as in tm (since 1900)
static const uint8_t D[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
const int y = year + 1900;
const bool leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
return D[mon] + (mon == 1 && leap ? 1 : 0);
}
// The column (0 = Monday) the 1st falls in.
inline int firstCol(const struct tm& t) {
const int wday1 = ((t.tm_wday - (t.tm_mday - 1)) % 7 + 7) % 7; // 0 = Sunday
return (wday1 + 6) % 7;
}
inline int weeks(const struct tm& t) { return (firstCol(t) + daysIn(t.tm_year, t.tm_mon) + 6) / 7; }
// The days left this month the alarm goes off on, bit (day - 1) each: the
// repeat days (`mask`: bit per tm_wday), or a one-shot's next (today or
// tomorrow). `t` is the local time now.
inline uint32_t alarmDays(const struct tm& t, bool on, uint8_t mask, int hour, int min) {
if (!on) return 0;
const int n = daysIn(t.tm_year, t.tm_mon);
const bool passed = t.tm_hour * 60 + t.tm_min >= hour * 60 + min;
uint32_t days = 0;
for (int day = t.tm_mday; day <= n; day++) {
if (day == t.tm_mday && passed) continue;
const int wday = (t.tm_wday + day - t.tm_mday) % 7;
if (mask ? (mask & (1 << wday)) : true) {
days |= 1UL << (day - 1);
if (!mask) break; // one-shot: only the next
}
}
return days;
}
} // namespace calmath
@@ -0,0 +1,53 @@
#pragma once
// The Status history, shared by both UIs: the noise floor, the battery and
// the mesh traffic, one sample every SAMPLE_MS into a ring of N, and the SNR
// of each packet heard (the last 32). ui-new draws it on its Status tabs,
// ui-lvgl in Diagnostics > History. The UI loop calls tick() on every pass
// with the readings; it keeps its own pace.
#include <stdint.h>
namespace uicore {
// A ring of the last N samples, oldest first.
template <int N>
struct History {
int16_t v[N];
uint8_t n = 0, head = 0;
void push(int16_t x) { v[head] = x; head = (head + 1) % N; if (n < N) n++; }
int16_t at(int i) const { return v[(head + N - n + i) % N]; } // 0 = oldest
static constexpr int size() { return N; }
};
template <int N, unsigned long SAMPLE_MS>
struct StatusHistory {
static constexpr int SAMPLES = N;
static constexpr unsigned long INTERVAL_MS = SAMPLE_MS;
History<N> noise; // dBm
History<N> batt; // mV
History<N> traffic; // packets received in each interval
History<32> snr; // dB x4, a sample a packet
uint32_t version = 0; // counts every change, for a view to see one
// `noise_dbm` 0 = no reading (the sample is skipped); `total_rx` counts
// every packet received since boot; `last_snr` is the last packet's.
void tick(unsigned long now, int noise_dbm, int batt_mv, uint32_t total_rx, float last_snr) {
if (total_rx != _snr_rx) { snr.push((int16_t)(last_snr * 4)); _snr_rx = total_rx; version++; }
if (_next && (long)(now - _next) < 0) return;
_next = now + SAMPLE_MS;
if (noise_dbm) noise.push((int16_t)noise_dbm);
batt.push((int16_t)batt_mv);
if (_last_rx || total_rx) traffic.push((int16_t)(total_rx - _last_rx > 30000 ? 30000 : total_rx - _last_rx));
_last_rx = total_rx;
version++;
}
// After the packet counters are reset: the next interval counts from 0.
void countersReset() { _last_rx = 0; _snr_rx = 0; }
private:
unsigned long _next = 0;
uint32_t _last_rx = 0, _snr_rx = 0;
};
} // namespace uicore
@@ -0,0 +1,85 @@
#pragma once
// A trail's height profile, shared by both UIs: the lowest and highest
// altitude, the climb (rises past 3 m of GPS jitter), and a walk over the
// points that carry an altitude with the distance covered to each. Duck-typed
// over any store with count() / at(i) whose points have lat_1e6, lon_1e6,
// flags and alt_m (TrailStore, a saved trail).
#include "../Trail.h"
namespace trailprofile {
struct Stats {
bool any = false; // some point has an altitude
int lo = 0, hi = 0; // metres
int gain = 0; // the climb, metres
// One altitude more, in order.
void add(int a) {
if (!any) { lo = hi = a; any = true; }
if (a < lo) lo = a;
if (a > hi) hi = a;
if (_last == TRAIL_ALT_NONE || a < _last) _last = a;
else if (a - _last >= 3) { gain += a - _last; _last = a; }
}
private:
int _last = TRAIL_ALT_NONE;
};
template <class Store>
Stats stats(const Store& s) {
Stats st;
for (int i = 0; i < s.count(); i++)
if (s.at(i).alt_m != TRAIL_ALT_NONE) st.add(s.at(i).alt_m);
return st;
}
// fn(metres from the start, altitude) for each point with an altitude, in
// order. A segment start adds no distance (a gap in the recording).
template <class Store, class Fn>
void walk(const Store& s, Fn fn) {
float cum = 0;
for (int i = 0; i < s.count(); i++) {
const auto& p = s.at(i);
if (i > 0 && !(p.flags & TRAIL_FLAG_SEG_START))
cum += TrailStore::haversineMeters(s.at(i - 1).lat_1e6, s.at(i - 1).lon_1e6, p.lat_1e6, p.lon_1e6);
if (p.alt_m != TRAIL_ALT_NONE) fn(cum, (int)p.alt_m);
}
}
// The profile of a trail too long to keep (a file read point by point): at
// most K samples, the stats of all of them. Fed point by point; when full,
// every other sample goes and only every second one after is kept, so the
// samples stay spread over the whole trail.
struct Sampler {
static const int K = 128;
float dist[K]; // metres from the start
int16_t alt[K];
int n = 0;
float total = 0; // the trail's length, metres
Stats st;
void feed(const TrailPoint& p) {
if (_have && !(p.flags & TRAIL_FLAG_SEG_START))
total += TrailStore::haversineMeters(_prev_lat, _prev_lon, p.lat_1e6, p.lon_1e6);
_have = true; _prev_lat = p.lat_1e6; _prev_lon = p.lon_1e6;
if (p.alt_m == TRAIL_ALT_NONE) return;
st.add(p.alt_m);
if (_skip++ % _stride) return;
if (n == K) { // full: keep every other, take every second from now on
for (int i = 0; i < K / 2; i++) { dist[i] = dist[2 * i]; alt[i] = alt[2 * i]; }
n = K / 2;
_stride *= 2;
_skip = 1;
}
dist[n] = total; alt[n] = p.alt_m; n++;
}
template <class Store>
void feedAll(const Store& s) { for (int i = 0; i < s.count(); i++) feed(s.at(i)); }
private:
bool _have = false;
int32_t _prev_lat = 0, _prev_lon = 0;
int _stride = 1, _skip = 0;
};
} // namespace trailprofile