diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 8271f4e4..b2af7491 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -554,6 +554,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_set_node_name(const char* name) { return 1; } +// Whether adverts carry this node's position (ADVERT_LOC_NONE / _SHARE / +// _PREFS, src/helpers/CommonCLI.h) -- off by default, so peers in a test +// mesh have no distance or direction to one another until it's on. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_set_advert_loc(int policy) { + if (!g_sim_ready) return 0; + NodePrefs* prefs = the_mesh.getNodePrefs(); + if (!prefs) return 0; + prefs->advert_loc_policy = (uint8_t)policy; + return 1; +} + // auto_off_secs (NodePrefs.h) defaults to 15 -- a real device's screen // (and, if auto_lock is also set, the UI itself) turns off/locks 15s // after the last input, real power-saving behaviour a battery-powered diff --git a/examples/companion_radio/ui-new/CalendarView.h b/examples/companion_radio/ui-new/CalendarView.h index e30683e0..091e0cc8 100644 --- a/examples/companion_radio/ui-new/CalendarView.h +++ b/examples/companion_radio/ui-new/CalendarView.h @@ -6,21 +6,13 @@ #include #include #include "InfoKit.h" +#include "../ui-core/CalendarMath.h" namespace calendar { -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; } +using calmath::daysIn; +using calmath::firstCol; +using calmath::weeks; // Its height: the day names, a rule, then a row a week. inline int height(DisplayDriver& d, const struct tm& t) { diff --git a/examples/companion_radio/ui-new/InfoKit.h b/examples/companion_radio/ui-new/InfoKit.h index 89014d85..caa4c445 100644 --- a/examples/companion_radio/ui-new/InfoKit.h +++ b/examples/companion_radio/ui-new/InfoKit.h @@ -9,6 +9,7 @@ #include #include #include "icons.h" +#include "../ui-core/StatusHistory.h" namespace info { @@ -249,12 +250,7 @@ inline void switchRow(DisplayDriver& d, int y, const char* label, bool on, bool // The last N samples of one reading, oldest first, for a history line. template -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 -}; +using History = uicore::History; // A history line in a w x h box: a dotted baseline, the samples scaled // between their own min and max (at least `min_span` apart, so a flat diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index effdba87..ae2539b4 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -440,10 +440,19 @@ class NearbyScreen : public UIScreen, protected NearbyModel { int step = display.lineStep(); if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; char buf[32]; - snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); - display.setCursor(2, hdr); display.print(buf); - snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); - display.setCursor(2, hdr + step); display.print(buf); + // Without a line to spare for the path (the OLED), the position takes one + // row, unlabelled: then there are five rows either way. + const bool one_pos = hdr + step * 5 + display.getLineHeight() > display.height(); + if (one_pos) { + snprintf(buf, sizeof(buf), "%.5f,%.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); + display.drawTextEllipsized(2, hdr, display.width() - 4, buf); + } else { + snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); + display.setCursor(2, hdr); display.print(buf); + snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); + display.setCursor(2, hdr + step); display.print(buf); + } + const int r0 = one_pos ? -1 : 0; // the rows under the position move up one if (e.dist_km >= 0.0f) { char dist[12]; @@ -453,32 +462,35 @@ class NearbyScreen : public UIScreen, protected NearbyModel { } else { snprintf(buf, sizeof(buf), "Dist: no GPS"); } - display.setCursor(2, hdr + step * 2); display.print(buf); + display.setCursor(2, hdr + step * (2 + r0)); display.print(buf); snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type)); - display.setCursor(2, hdr + step * 3); display.print(buf); + display.setCursor(2, hdr + step * (3 + r0)); display.print(buf); char age[16]; fmtAge(age, sizeof(age), e.lastmod); // For a live [LOC] row, label the timestamp as a position share and note // whether the sender's identity is verified (DM) or name-only (channel). if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)"); else snprintf(buf, sizeof(buf), "Seen: %s", age); - display.drawTextEllipsized(2, hdr + step * 4, display.width() - 4, buf); + display.drawTextEllipsized(2, hdr + step * (4 + r0), display.width() - 4, buf); const int W = display.width(), H = display.height(), lh = display.getLineHeight(); - int rows = 5; - // How a message gets there, where there's a line for it (e-ink): a chain - // from you to it, a ring per repeater on the way. + int rows = 5 + r0; + // How a message gets there: a chain from you to it, a ring per repeater + // on the way. ContactInfo ci; - if (hdr + step * 5 + lh <= H && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) { - const int y = hdr + step * 5; + if (hdr + step * rows + lh <= H && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) { + const int y = hdr + step * rows; display.setCursor(2, y); display.print("Path:"); int x = 2 + display.getTextWidth("Path: "); if (ci.out_path_len == 0xFF) { display.print(" flood"); } else { - const int hops = ci.out_path_len & 63, shown = hops > 5 ? 5 : hops; + const int hops = ci.out_path_len & 63; const int r = lh / 4 > 2 ? lh / 4 : 2, cy = y + lh / 2 - 1, gap = r + 3; + // up to 5 rings, as many as leave room for the count after them + const int fit = (W - 2 - x - display.getTextWidth("5 hops")) / (2 * r + 1 + gap) - 2; + const int cap = fit < 5 ? (fit < 1 ? 1 : fit) : 5, shown = hops > cap ? cap : hops; for (int k = 0; k <= shown + 1; k++) { // you and it filled, the hops as rings if (k) for (int dx = x - gap + 1; dx < x; dx += 2) display.fillRect(dx, cy, 1, 1); info::circle(display, x + r, cy, r); @@ -490,12 +502,12 @@ class NearbyScreen : public UIScreen, protected NearbyModel { display.setCursor(x, y); display.print(buf); } - rows = 6; + rows++; } // Where it is, on a compass rose (north up), where the screen has the room // (e-ink): beside the rows on a wide one, under them on a tall one. - if (e.dist_km < 0.0f) return; + if (!Features::IS_EINK || e.dist_km < 0.0f) return; const int below = hdr + step * rows + lh; int r, cx, cy; if (H - below > W / 2) { r = (W < H - below ? W : H - below) / 2 - lh; cx = W / 2; cy = below + lh / 2 + r; } @@ -650,8 +662,8 @@ public: } int item_h = display.lineStep(); - // E-ink sets an arrow towards the node before its distance: one more cell. - const bool arrows = Features::IS_EINK && _source == SRC_STORED && _sort != SORT_TIME; + // An arrow towards the node before its distance: one more cell. + const bool arrows = _source == SRC_STORED && _sort != SORT_TIME; int dist_col = display.width() - display.getCharWidth() * (arrows ? 9 : 7); display.setColor(DisplayDriver::LIGHT); diff --git a/examples/companion_radio/ui-new/StatusScreen.h b/examples/companion_radio/ui-new/StatusScreen.h index 779c111c..74dff98e 100644 --- a/examples/companion_radio/ui-new/StatusScreen.h +++ b/examples/companion_radio/ui-new/StatusScreen.h @@ -48,17 +48,13 @@ private: CayenneLPP _lpp; unsigned long _lpp_at = 0; - // History lines, one sample a minute (32 min). E-ink draws them as charts - // (chartBlock()) over a longer span: a sample every 5 minutes, 8 hours. + // History lines (ui-core/StatusHistory.h), one sample a minute (32 min). + // E-ink draws them as charts (chartBlock()) over a longer span: a sample + // every 5 minutes, 8 hours. Its Radio chart also has the SNR of the last + // 32 packets. static const int HIST_N = Features::IS_EINK ? 96 : 32; - static const unsigned long SAMPLE_MS = Features::IS_EINK ? 300000 : 60000; static constexpr const char* HIST_SPAN = Features::IS_EINK ? "8 h ago" : "32 min"; - unsigned long _next_sample = 0; - uint32_t _last_rx = 0; - info::History _noise, _batt, _traffic; - // The SNR of each packet heard, the last 32 (e-ink's Radio chart), x4 dB. - info::History<32> _snr; - uint32_t _snr_rx = 0; + uicore::StatusHistory _hist; // A chart as a block (e-ink): `title` with the value now on its line, the // chart under it. @@ -124,12 +120,12 @@ private: const int nf = (int)radio_driver.getNoiseFloor(); if (nf) snprintf(a, sizeof(a), "%d dBm", nf); else strcpy(a, "-"); if (Features::IS_EINK) { - chartBlock(d, f, rsv, "Noise", a, _noise, 6, false, HIST_SPAN); + chartBlock(d, f, rsv, "Noise", a, _hist.noise, 6, false, HIST_SPAN); } else if (f.place(d.lineStep())) { // label, the last half hour, the value now info::valueRow(d, f.at, "Noise", a, false, rsv, 1); const int x0 = 1 + d.getTextWidth("Noise") + 6; const int x1 = d.width() - rsv - 2 - d.getTextWidth(a) - 6; - if (x1 - x0 > 12) info::spark(d, x0, f.at, x1 - x0, lh - 1, _noise); + if (x1 - x0 > 12) info::spark(d, x0, f.at, x1 - x0, lh - 1, _hist.noise); } if (f.place(d.lineStep())) { const float snr = radio_driver.getLastSNR(); @@ -138,7 +134,7 @@ private: drawSignalBars(d, d.width() - rsv - 2 - d.getTextWidth(b) - 5, f.at, (int)(snr * 4)); } snprintf(b, sizeof(b), "%.1f dB", radio_driver.getLastSNR()); - if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _snr, 20, false, "32 pkts"); + if (Features::IS_EINK) chartBlock(d, f, rsv, "SNR", b, _hist.snr, 20, false, "32 pkts"); else row(d, f, rsv, "Last SNR", b); } @@ -214,12 +210,12 @@ private: row(d, f, rsv, "Battery", a); row(d, f, rsv, "Source", board.isExternalPowered() ? "USB" : "Battery"); if (Features::IS_EINK) { - chartBlock(d, f, rsv, "Trend", nullptr, _batt, 20, false, HIST_SPAN); + chartBlock(d, f, rsv, "Trend", nullptr, _hist.batt, 20, false, HIST_SPAN); } else if (f.place(d.lineStep())) { d.setCursor(1, f.at); d.print("Trend"); const int x0 = 1 + d.getTextWidth("Trend") + 6; - info::spark(d, x0, f.at, d.width() - rsv - 2 - x0, d.getLineHeight() - 1, _batt, 20); + info::spark(d, x0, f.at, d.width() - rsv - 2 - x0, d.getLineHeight() - 1, _hist.batt, 20); } // Sensors: every reading but the battery (above) and the position (GPS). @@ -259,9 +255,9 @@ private: if (Features::IS_EINK) { // packets per 5 minutes, as bars const int ch = 3 * lh; if (f.place(info::chartH(d, ch) + 2)) - info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _traffic, 4, true, HIST_SPAN, "now"); + info::chart(d, 1, f.at, d.width() - rsv - 3, ch, _hist.traffic, 4, true, HIST_SPAN, "now"); } else if (f.place(lh + 6)) { - info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _traffic, 4); + info::spark(d, 1, f.at, d.width() - rsv - 3, lh + 3, _hist.traffic, 4); } static const uint8_t MSG[] = { PAYLOAD_TYPE_TXT_MSG, PAYLOAD_TYPE_GRP_TXT }; @@ -350,26 +346,12 @@ public: // GPS stays awake while its live tabs are on screen. bool wantsLiveGps() const { return tab() == TAB_GPS || tab() == TAB_SKY; } - // Called from the UI loop on every pass: one sample a minute for the lines. + // Called from the UI loop on every pass; the history keeps its own pace. void sample() { - if (Features::IS_EINK) samplePacket(); - const unsigned long now = millis(); - if (_next_sample && (long)(now - _next_sample) < 0) return; - _next_sample = now + SAMPLE_MS; - const int nf = (int)radio_driver.getNoiseFloor(); - if (nf) _noise.push((int16_t)nf); - _batt.push((int16_t)battMv()); - const uint32_t rx = totalRx(); - if (_last_rx || rx) _traffic.push((int16_t)(rx - _last_rx > 30000 ? 30000 : rx - _last_rx)); - _last_rx = rx; - } - // On every pass: the SNR of a packet just heard (e-ink's Radio chart). - void samplePacket() { - const uint32_t rx = totalRx(); - if (rx != _snr_rx) { _snr.push((int16_t)(radio_driver.getLastSNR() * 4)); _snr_rx = rx; } + _hist.tick(millis(), (int)radio_driver.getNoiseFloor(), battMv(), totalRx(), radio_driver.getLastSNR()); } - const info::History& battHistory() const { return _batt; } + const info::History& battHistory() const { return _hist.batt; } // Contacts heard from in the last hour (cached for 10 s: it walks them all). static int heardLastHour() { @@ -422,7 +404,7 @@ public: auto res = _reset_menu.handleInput(c); if (res == PopupMenu::SELECTED && _reset_menu.selectedIndex() == 0) { diag::resetCounters(); - _last_rx = 0; + _hist.countersReset(); } return true; } diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index e5c2bf56..fc1b4e5e 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -7,6 +7,7 @@ #include "InfoKit.h" #include "../Trail.h" +#include "../ui-core/TrailProfile.h" #include "../GeoUtils.h" #include "GfxUtils.h" #include "icons.h" // scalable mini-icons (map markers, north arrow, grid dots) @@ -51,7 +52,7 @@ class TrailScreen : public UIScreen { UITask* _task; TrailStore* _store; - enum View { V_SUMMARY = 0, V_MAP = 1, V_LIST = 2, V_COUNT }; + enum View { V_SUMMARY = 0, V_MAP = 1, V_PROFILE = 2, V_LIST = 3, V_COUNT }; uint8_t _view = V_SUMMARY; int _summary_scroll = 0; int _list_scroll = 0; @@ -126,14 +127,16 @@ public: // The title bar carries the view dots so the bottom hint row can be // reclaimed for content. - const char* title = (_view == V_MAP) ? (_map_grid ? "Trail map+" : "Trail map") - : (_view == V_LIST) ? "Trail list" - : "Trail"; + const char* title = (_view == V_MAP) ? (_map_grid ? "Trail map+" : "Trail map") + : (_view == V_PROFILE) ? "Trail height" + : (_view == V_LIST) ? "Trail list" + : "Trail"; drawScreenHeader(display, title, (int)_view, (int)V_COUNT); - if (_view == V_MAP) renderMap(display); - else if (_view == V_LIST) renderList(display); - else renderSummary(display); + if (_view == V_MAP) renderMap(display); + else if (_view == V_PROFILE) renderProfileView(display); + else if (_view == V_LIST) renderList(display); + else renderSummary(display); if (_action_menu.active) _action_menu.render(display); return _store->isActive() ? 1000 : 5000; @@ -558,9 +561,16 @@ private: } } - bool hasAltitude() const { - for (int i = 0; i < _store->count(); i++) if (_store->at(i).alt_m != TRAIL_ALT_NONE) return true; - return false; + bool hasAltitude() const { return trailprofile::stats(*_store).any; } + + // The profile as its own view, the whole space under the header. + void renderProfileView(DisplayDriver& d) { + const int top = d.listStart(); + if (!hasAltitude()) { + d.drawTextCentered(d.width() / 2, (top + d.height()) / 2 - d.getLineHeight() / 2, "No altitude yet"); + return; + } + renderProfile(d, 1, top, d.width() - 2, d.height() - 1 - top); } // The height over the distance walked, in x, y, w, h: a framed area, a @@ -568,30 +578,17 @@ private: // point under it. void renderProfile(DisplayDriver& d, int x, int y, int w, int h) { const int lh = d.getLineHeight(), fh = h - lh - 2; // the frame; the caption under it - const int n = _store->count(); - int lo = 32767, hi = -32768, gain = 0, last = TRAIL_ALT_NONE; - for (int i = 0; i < n; i++) { - const int a = _store->at(i).alt_m; - if (a == TRAIL_ALT_NONE) continue; - if (a < lo) lo = a; - if (a > hi) hi = a; - if (last == TRAIL_ALT_NONE || a < last) last = a; // the climb, past 3 m of jitter - else if (a - last >= 3) { gain += a - last; last = a; } - } + const trailprofile::Stats st = trailprofile::stats(*_store); + int lo = st.lo, hi = st.hi; if (hi - lo < 20) { const int mid = (hi + lo) / 2; lo = mid - 10; hi = mid + 10; } const float total = _store->totalDistanceMeters() > 0 ? (float)_store->totalDistanceMeters() : 1.0f; d.drawSoftRect(x, y, w, fh); const int ix = x + 2, iy = y + 2, iw = w - 4, ih = fh - 4; - float cum = 0; int px = -1, py = 0; - for (int i = 0; i < n; i++) { - const TrailPoint& p = _store->at(i); - if (i > 0 && !(p.flags & TRAIL_FLAG_SEG_START)) - cum += TrailStore::haversineMeters(_store->at(i - 1).lat_1e6, _store->at(i - 1).lon_1e6, p.lat_1e6, p.lon_1e6); - if (p.alt_m == TRAIL_ALT_NONE) continue; + trailprofile::walk(*_store, [&](float cum, int alt) { int cx = ix + (int)(cum / total * (iw - 1)); if (cx > ix + iw - 1) cx = ix + iw - 1; - const int cy = iy + ih - 1 - (p.alt_m - lo) * (ih - 1) / (hi - lo); + const int cy = iy + ih - 1 - (alt - lo) * (ih - 1) / (hi - lo); if (px >= 0) { for (int c = px + 1; c <= cx; c++) { // each column: the line, the fill under it const int ly = py + (cy - py) * (c - px) / (cx - px); @@ -600,10 +597,11 @@ private: } } px = cx; py = cy; - } + }); + const int gain = st.gain; char a[16], b[16]; snprintf(a, sizeof(a), "+%d m", gain); - snprintf(b, sizeof(b), "max %d m", hi); + snprintf(b, sizeof(b), "max %d m", st.hi); const int cy = y + fh + 2; miniIconDraw(d, x, cy, ICON_ARROW_NE); d.setCursor(x + ICON_ARROW_NE.w * miniIconScale(d) + 2, cy); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 3f366276..05b01953 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1072,20 +1072,9 @@ public: // The days left this month the alarm goes off on, as calendar::draw()'s // marks: the repeat days, or a one-shot's next (today or tomorrow). uint32_t alarmDays(const struct tm& t) const { - if (!_node_prefs || !_node_prefs->alarm_on) return 0; - const int n = calendar::daysIn(t.tm_year, t.tm_mon); - const uint8_t mask = _node_prefs->alarm_repeat_mask; - const bool passed = t.tm_hour * 60 + t.tm_min >= _node_prefs->alarm_hour * 60 + _node_prefs->alarm_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; + if (!_node_prefs) return 0; + return calmath::alarmDays(t, _node_prefs->alarm_on, _node_prefs->alarm_repeat_mask, + _node_prefs->alarm_hour, _node_prefs->alarm_min); } void drawClockPage(DisplayDriver& d, uint32_t unix_ts) {