diff --git a/README.md b/README.md index f49f67b3..c75159d8 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Updating to a newer version usually does not require erasing flash unless the re | [Settings Screen](./docs/solo_features/settings_screen/settings_screen.md) | All settings sections with values and interactions | | [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) | Lock/unlock sequence, lock screen, auto-lock | | [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater | +| [Solo UI framework](./docs/design/solo_ui_framework.md) | **Developer guide** — the reusable building blocks (screens, lists, popups, mini-icons, geo/persistence helpers) and how to add a new feature | ### Upstream MeshCore diff --git a/docs/design/solo_ui_framework.md b/docs/design/solo_ui_framework.md new file mode 100644 index 00000000..ef3abbd9 --- /dev/null +++ b/docs/design/solo_ui_framework.md @@ -0,0 +1,339 @@ +# Solo UI framework — a guide for adding features + +[Go back](../../README.md) + +This is a developer guide to the reusable building blocks behind the +`companion_radio` **solo** firmware UI (the `ui-new` screens). It is not a +user manual — for what each screen *does*, see [solo_features](../solo_features/). +The goal here is so that adding a new screen or feature means *wiring together +existing helpers*, not reinventing list scrolling, text wrapping, or persistence. + +Everything below lives under `examples/companion_radio/` unless a path says +otherwise. The screen fragments (`ui-new/*.h`) are all `#include`d, in order, +into one translation unit (`ui-new/UITask.cpp`) — so a `static inline` helper in +an earlier header is visible to later ones. Header-include order in `UITask.cpp` +therefore matters; new screens go near the others. + +> **Single-TU only.** These fragments compile *only* as part of `UITask.cpp`. +> Some define external-linkage symbols at file scope (e.g. +> `NearbyScreen::FILTER_LABELS`), so including a fragment from a second `.cpp` +> is a duplicate-symbol link error. Anything genuinely shared across TUs must +> live in a real header (`icons.h`, `GeoUtils.h`, `DisplayDriver.h`), not a +> screen fragment. + +--- + +## 1. The screen model + +Every screen implements `UIScreen` (`src/helpers/ui/UIScreen.h`): + +```cpp +class UIScreen { +public: + virtual int render(DisplayDriver& display) = 0; // returns ms until the next render + virtual bool handleInput(char c) { return false; } + virtual void poll() { } + virtual void onShow() { } // reset per-visit state +}; +``` + +- **`render()`** draws one frame and returns *how long until it wants to be + drawn again*, in milliseconds. Return a big number (`2000`) for a static + screen, a small one (`50`–`200`) while something animates or a popup is open. + This return value is the main lever for the e-ink cost/latency trade-off — see + §9. `UITask` owns `startFrame()`/`endFrame()`; **`render()` must not call them.** +- **`handleInput(c)`** gets one key (`KEY_*`, see §7). Return `true` if consumed. +- **`poll()`** runs every loop tick regardless of focus — rare, for background + housekeeping (e.g. the shutdown button). +- **`onShow()`** is called by `setCurrScreen()` every time the screen becomes + current — override it to reset per-visit state (`_sel = 0`, `_dirty = false`, + sub-views). Default no-op for screens that keep state across visits. Because + it's invoked centrally, a navigator can't forget to reset on show. + +### Wiring a screen into UITask + +1. Add a `UIScreen* my_screen;` member in `UITask.h` (near the others). +2. Construct it in `UITask::begin()` (`UITask.cpp`): + `my_screen = new MyScreen(this, …);` +3. Add a navigator — usually just the one line (the cast-free `onShow()` runs + inside `setCurrScreen`): + ```cpp + void UITask::gotoMyScreen() { setCurrScreen(my_screen); } + ``` + Only screens needing a *parameter* at entry add a typed call after it (e.g. + `gotoRingtoneEditor` → `selectSlot(slot)`, `gotoMapScreen` → `showMapView()`). +4. Reach it from somewhere — usually a row in `ToolsScreen.h` (add an `Action` + enum value, a row in the right section table, and a `dispatch()` case). + +That's the whole contract. Steps 1, 3 and 4 are compiler-checked (a mismatch +won't link); only a forgotten step 2 can slip through — every screen pointer is +nullptr-initialised in `UITask.h` and `setCurrScreen()` bails on null, so a +missed `new` is an inert no-op rather than a null deref. + +The constructor takes `UITask* task` plus whatever it needs (`NodePrefs*`, +`KeyboardWidget*`, …); the task back-pointer is how a screen calls shared +services (`_task->showAlert(...)`, `_task->waypoints()`, …). + +--- + +## 2. Layout metrics & text (DisplayDriver) + +`DisplayDriver` (`src/helpers/ui/DisplayDriver.h`) abstracts OLED vs e-ink and, +crucially, font scale: landscape e-ink renders text at 2×, so **never hard-code +pixel sizes** — derive everything from these: + +| Call | Meaning | +| --- | --- | +| `getLineHeight()` | pixel rows per text line (8 at 1×, 16 at 2×) | +| `lineStep()` | row pitch = line height + gap; use for row `y` stepping | +| `getCharWidth()` / `getTextWidth(s)` | advance width; `getTextWidth` is font-accurate | +| `headerH()` / `listStart()` | title-bar height / first content row `y` | +| `listVisible(itemH)` | how many rows fit below the header | +| `valCol()` | conventional x for a right-hand value column | +| `width()` / `height()` | panel size in px | +| `isEink()` | true only on landscape e-ink; branch on this, not on pixel counts | + +Drawing helpers (all clip/measure for you): + +- `drawCenteredHeader(title)` — plain centered title + separator. +- `drawInvertedHeader(label)` — filled title bar (used by detail views). +- `drawSelectionRow(x, y, w, h, sel)` — the highlight bar behind a list row. +- `drawTextEllipsized(x, y, max_w, str)` — truncates with `…`; **use this for + any user string** (names, labels) so long/UTF-8 text can't overrun. +- `drawTextCentered(mid_x, y, str)`. +- `translateUTF8ToBlocks(dst, src, n)` — map UTF-8 to the panel's glyph set for + *display only*. Never run text through it before sending it over the air or + storing it (it is lossy) — see the reply-prefix note in §5. + +--- + +## 3. Lists — `drawList` + +`drawList` (`ui-new/icons.h`) is the workhorse for any scrolling list. It +computes the visible window from font metrics, keeps `sel` in view, reserves the +scrollbar column, draws each visible row through your callback, and draws the +indicator: + +```cpp +drawList(display, count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { + drawRowSelection(display, y, sel, reserve); // canonical highlight bar + display.drawTextEllipsized(2, y, display.width() - reserve - 4, items[idx].name); +}); +``` + +`reserve` is the width the scrollbar took (0 when the list fits) — subtract it +from any right-aligned content so nothing slides under the indicator. The row +callback owns its own selection bar: `drawRowSelection(d, y, sel, reserve)` +(`ui-new/icons.h`) draws the standard one (full row minus reserve, one pixel +short); call `display.drawSelectionRow()` directly only when a row needs a +non-standard geometry (full-width, custom height). For the +fold-in-place pattern (sections that expand/collapse) use `AccordionList` +instead (`ui-new/AccordionList.h`) — same idea, two callbacks (header + item). + +Standalone scroll indicators (`drawScrollIndicator`, `…Px`) and the reserve +calculator (`scrollIndicatorReserve`) are exposed for hand-laid lists. + +--- + +## 4. Reusable components + +| Component | Header | Use for | +| --- | --- | --- | +| `PopupMenu` | `PopupMenu.h` | a modal action menu over any screen | +| `AccordionList` | `AccordionList.h` | collapsible sectioned lists (Tools, Settings) | +| `KeyboardWidget` | `KeyboardWidget.h` | on-screen text entry | +| `DigitEditor` | `DigitEditor.h` | scroll-edit one number, digit by digit | +| `FullscreenMsgView` | `FullscreenMsgView.h` | scrollable full-message reader + word wrap | +| `NavView` | `NavView.h` | bearing/distance/ETA "navigate to a point" view | + +All follow the same shape: a `begin(...)` to open, an `active` flag, a +`handleInput(c)` returning a small `Result` enum, and a `render()`/`draw()`. +Typical embedding: + +```cpp +if (_menu.active) { // popup eats input while open + auto r = _menu.handleInput(c); + if (r == PopupMenu::SELECTED) runAction(_menu.selectedIndex()); + return true; +} +... +_menu.begin("Options", 6); // open it +_menu.addItem("Navigate"); _menu.addItem("Ping"); +_menu.active = true; +``` + +`KeyboardWidget` additionally supports **placeholders** (`{loc}`, `{time}`, +sensor tokens) via `addPlaceholder()` / `clearPlaceholders()`; the shared +`kbAddSensorPlaceholders()` (`ui-new/SensorPlaceholders.h`) adds only the tokens +the board's sensors actually provide. Expand them with `expandMsg()` at send time. + +`FullscreenMsgView::wrapLines()` is a standalone pixel-accurate word-wrapper +(O(n), variable-width-font aware) reusable by any multi-line layout; it writes +into the shared `s_wrap_trans` / `s_wrap_lines` scratch (single-threaded render, +never held across a yield — see §9). + +--- + +## 5. Domain helpers + +**Geo (`GeoUtils.h`, namespace `geo`, all pure/header-inline):** + +- `haversineKm(lat1,lon1,lat2,lon2)`, `bearingDeg(...)`, `bearingCardinal(deg)`. +- `fmtDist(buf,n,km,imperial)` — "850m"/"2.3km" or feet/miles. +- `fmtAgeShort(buf,n,now,ts)` — compact "12s"/"5m"/"3h"/"2d" tag, "" for unknown. + **This is the one age formatter** — don't reimplement the s/m/h ladder. +- `parseLatLon(text, lat, lon, label?, n?)` — pull a `lat,lon` out of message + text; reads the `[WAY]` label if tagged. +- `parseLocShare(text, lat, lon)` — true only for an explicit `[LOC]` share. + +Coordinates are **int32 degrees × 1e6** everywhere (GPS, contacts, trail, +prefs). The message tags are `LOCATION_MSG_TAG` (`[LOC]`, the sender's own live +position) and `WAYPOINT_MSG_TAG` (`[WAY]`, a saved point to share); both stay +human-readable on clients that don't know them. + +**State stores:** `TrailStore` (`Trail.h`, GPS breadcrumb ring + GPX export), +`LiveTrackStore` (`LiveTrack.h`, RAM table of others' `[LOC]` positions, expiring), +`WaypointStore` (`Waypoint.h`, persisted saved points). Reach them via the task +(`_task->trail()`, `_task->liveTrack()`, `_task->waypoints()`). + +**Message reply prefix:** `msgReplyBody(text, nick?, n?)` (`FullscreenMsgView.h`) +parses a leading `@[nick] ` reply marker, returning the body and optionally the +addressee. Use it instead of re-scanning for `@[`. The stored/sent prefix is raw +UTF-8 (it goes over the air) — never transliterate it. + +--- + +## 6. Mini-icons & the status bar + +Small glyphs are authored as ASCII art and packed at compile time +(`ui-new/icons.h`): + +```cpp +MINI_ICON(ICON_FOO, 5, + packRow("..#.."), + packRow(".###."), + packRow("#####")); +``` + +Draw with `miniIconDraw(display, x, topY, ICON_FOO)` (auto-scaled & centered), +`miniIconDrawTop` (exact placement), or the boxed/slot variants +(`drawBoxedIcon` = lit when active, `drawSlotIcon` = plain). Bigger page glyphs +use `BIG_ICON` / `bigIconDraw`. The home status bar composes these right-to-left +with a `blinkOn()` cadence for "leave it on and forget" broadcasts (auto-advert, +Live Share, trail, repeater) — follow that pattern when adding an indicator: +always shown on e-ink, blinking on OLED. + +--- + +## 7. Input + +Keys arrive as the `KEY_*` codes in `UIScreen.h`: `KEY_UP/DOWN/LEFT/RIGHT`, +`KEY_ENTER`, `KEY_CANCEL`, `KEY_CONTEXT_MENU` (the "Hold Enter" menu key). + +Use the **prev/next convention** for value changes so the rotary encoder and the +D-pad agree: `keyIsPrev(c)` (LEFT or encoder-prev) and `keyIsNext(c)` (RIGHT or +encoder-next). `KEY_CANCEL` and `KEY_CONTEXT_MENU` stay screen-specific. Joystick +rotation is handled upstream (`rotateJoystickKey`) — screens see already-rotated +keys. + +--- + +## 8. Persistence + +Device settings live in one `NodePrefs` struct (`NodePrefs.h`), saved via +`the_mesh.savePrefs()` and loaded by `DataStore.cpp`. Rules when adding a field: + +- **Append only**, and bump `NodePrefs::SCHEMA_SENTINEL`. Serialization is + binary-positional, so order is the on-disk format; never insert in the middle. +- Add a matching `rd(...)` in `DataStore::loadPrefsInt()` and a `file.write(...)` + in `savePrefs()`, in the same position, and **clamp on load** (an upgrader's + file lacks the field and reads stray bytes — clamp to a sane default). Saves + are atomic (temp-file + rename), so a crash mid-save can't corrupt settings. + +**The `_dirty` convention:** a multi-field editor screen mutates `_node_prefs` +live for instant feedback but only persists once, on exit, gated by a `_dirty` +flag — so LEFT/RIGHT value-cycling doesn't thrash flash. Set `_dirty = true` at +each edit site, then on the exit path call `_task->savePrefsIfDirty(_dirty)` +(`UITask`) — it saves once *iff* dirty and clears the flag, so every screen's +save-on-exit reads the same and the "did we touch flash?" decision lives in one +place. A one-shot action from a popup (no exit hook) calls `the_mesh.savePrefs()` +immediately. Follow whichever matches your screen. + +**The shared "active target"** (Locator/Nav destination) is set through +`UITask::setTarget()` (defines it), `setTargetNow()` (defines + saves + toast), +or `clearTarget()` — one definition used by the Locator screen, the map, and the +Nearby/Waypoints "Set as target" actions. Resolve a person's current position +with `resolvePersonPos()` (live `[LOC]` share, else last-advertised fix). + +--- + +## 9. Conventions & gotchas + +- **Single-threaded render.** Rendering and input run on one thread, so shared + static scratch (`s_wrap_*`) is safe *as long as it's never held across a + yield*. Don't add scratch that outlives one `render()`. +- **e-ink blocks.** `endFrame()` on e-ink stalls the main loop for hundreds of + ms. Keep `render()`'s return value honest so the panel isn't redrawn more than + needed, and don't depend on `loop()` cadence for timing that must be exact + (the ringtone player moved to a hardware timer for this reason). +- **Reference cleanup.** Anything that remembers a contact by pubkey (favourite + slot, Locator/Live-Share target, per-contact mute/melody) or a channel by + index must drop that reference when the entity goes away — hook + `UITask::onContactRemoved()` / `onChannelRemoved()`. New per-contact or + per-channel state should clear there too. +- **Toasts:** `_task->showAlert("msg", duration_ms)` overlays a transient banner + over any screen; no redraw plumbing needed. +- **Strings:** always `strncpy`+NUL or `snprintf`; treat every name/label as + untrusted-length and render through `drawTextEllipsized`. + +--- + +## 10. Worked example — a new Tools screen + +```cpp +// ui-new/MyToolScreen.h — included by UITask.cpp near the other screens +#pragma once +#include "icons.h" // drawList + mini-icons +#include "../NodePrefs.h" + +class MyToolScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + int _sel = 0, _scroll = 0; + bool _dirty = false; + static const int ROWS = 3; +public: + MyToolScreen(UITask* t, NodePrefs* p) : _task(t), _prefs(p) {} + void onShow() override { _sel = 0; _scroll = 0; _dirty = false; } + + int render(DisplayDriver& d) override { + d.setTextSize(1); + d.drawCenteredHeader("MY TOOL"); + drawList(d, ROWS, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { + drawRowSelection(d, y, sel, reserve); + d.setCursor(4, y); + d.print(i == 0 ? "Alpha" : i == 1 ? "Bravo" : "Charlie"); + }); + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL) { + _task->savePrefsIfDirty(_dirty); // saves once iff dirty, then clears + _task->gotoToolsScreen(); + return true; + } + if (c == KEY_UP) { _sel = (_sel + ROWS - 1) % ROWS; return true; } + if (c == KEY_DOWN) { _sel = (_sel + 1) % ROWS; return true; } + if (keyIsPrev(c) || keyIsNext(c) || c == KEY_ENTER) { + /* mutate _prefs…, set _dirty = true */ return true; + } + return false; + } +}; +``` + +Then: `#include "MyToolScreen.h"` in `UITask.cpp`, add the member + constructor + +`gotoMyToolScreen()` (§1), and add a row in `ToolsScreen.h`. Done — scrolling, +the scrollbar, font scaling, e-ink pacing and persistence batching all come from +the framework. diff --git a/docs/solo_features/clock_screen/clock_screen.md b/docs/solo_features/clock_screen/clock_screen.md index 06c54502..a4df91d9 100644 --- a/docs/solo_features/clock_screen/clock_screen.md +++ b/docs/solo_features/clock_screen/clock_screen.md @@ -59,3 +59,33 @@ Sensor fields show `--` when the sensor is not connected or has no data. **Hold Enter** (or press the **Context menu** key) on the Clock page to open the Dashboard Config screen, where each of the three field slots can be cycled with **LEFT/RIGHT**. + +--- + +### Clock tools — Alarm, Timer, Stopwatch + +**Press Enter** (short press) on the Clock page to open **Clock Tools**, a small menu with three time utilities. **Cancel** backs out one level (tool → menu → home). + +#### Alarm + +A single one-shot wake alarm. Rows: **Hour**, **Minute** and **Armed**. **Enter** on Hour or Minute opens the digit editor (LEFT/RIGHT moves between the tens/units, UP/DOWN changes the digit); **Enter** on Armed toggles ON/OFF. The configured time is shown next to the **Alarm** menu row when armed, and the setting persists across reboots. + +While an alarm is armed a bell icon signals it in two places: the top-left corner of the **Clock page** itself, and the **top status bar** of the other home pages (the status bar is hidden on the Clock page, which is why the clock face carries its own indicator). The bell is icon-only — the exact alarm time is on the **Alarm** row inside Clock Tools. + +The alarm is scheduled as an absolute fire instant, so it is **robust to clock re-syncs** — the mesh (every inbound packet), the companion app, GPS and the CLI can all jump the device clock at any moment. A correction that moves the clock a little still fires at the right wall-clock time; a jump that skips over the alarm time still fires (late). After firing once, the alarm disarms itself. + +The alarm only fires while the device is **awake** (it keeps running with the display off or locked). It cannot wake the device from a full **Shutdown** (the CPU and RAM are powered down), and needs a valid time source — it stays pending until the clock is synced. + +#### Timer (countdown) + +A large **HH:MM:SS** readout with one digit underlined. **LEFT/RIGHT** moves the cursor one digit at a time, **Up/Down** changes the digit under it (minute/second tens cap at 5, hours at 23), and **Enter** starts the countdown. While running it shows **H:MM:SS** — **Enter** stops it, **Cancel** returns to the menu and leaves it counting. When it reaches zero the device rings, even if you have navigated to another screen. + +#### Stopwatch + +**Enter** starts/stops; **Up/Down** resets when stopped; **Cancel** returns to the menu and leaves it running. + +#### Ringing + +When the alarm or timer fires the device plays a melody (overriding mute) and shows an alert. **Any key** silences it; otherwise it stops on its own after a minute. + +> **E-ink note:** the live timer/stopwatch readouts would thrash a slow e-paper panel if redrawn every second, so on e-ink they refresh only coarsely (and immediately on any key press). The underlying timing is exact regardless, and the countdown's buzzer always fires on time. diff --git a/docs/solo_features/favourites_dial/favourites_dial.md b/docs/solo_features/favourites_dial/favourites_dial.md index e71b2815..6f636aff 100644 --- a/docs/solo_features/favourites_dial/favourites_dial.md +++ b/docs/solo_features/favourites_dial/favourites_dial.md @@ -29,7 +29,7 @@ Navigate tiles with **UP / DOWN / LEFT / RIGHT**. Pressing a directional key at Filled tiles show an unread message count in the top-right corner when there are unread DMs from that contact. The contact name is ellipsized to make room for the badge. -If a pinned contact has been removed from the contacts list, the tile shows `(gone)` until the slot is reassigned. +If a pinned contact is removed from the contacts list — explicitly, or auto-evicted to make room when the table is full — its slot is freed automatically and goes back to an empty `+` tile. --- diff --git a/docs/solo_features/message_screen/message_screen.md b/docs/solo_features/message_screen/message_screen.md index 31362f2f..c025ca5b 100644 --- a/docs/solo_features/message_screen/message_screen.md +++ b/docs/solo_features/message_screen/message_screen.md @@ -40,6 +40,19 @@ Sensor placeholders appear automatically in the placeholder picker when the corr --- +### Rooms — logging in + +Posting to a **room server** requires a login handshake first, so the device can log in on its own — no phone app needed. The first time you press **Enter** on a room, a password prompt opens automatically; type the room's password and press the **✓** key (leave it empty and submit for open / no-password rooms). + +- **Passwords are remembered across reboots.** After a successful login the password is saved on the device, so picking that room again — even after a power cycle — logs back in silently without re-prompting. +- **A wrong or changed password self-heals.** If a saved password stops working (e.g. the server's password was changed), the failed login forgets it, so the next **Enter** prompts you to type a new one. +- **Re-login any time** with **Hold Enter** on the room → **Login…** (see the room context menu below) — useful to switch to a new password without waiting for a failure. +- Passwords set from the **phone app** are saved on the device too, so it can post to that room standalone after a reboot. + +> The on-screen keyboard is limited to ASCII (letters, digits and common symbols); accented characters such as `ą`/`ę` can't be typed on the device. A password containing them can still be set from the phone app — the device stores and replays it byte-for-byte. + +--- + ### Message history | OLED | E-Ink | @@ -92,6 +105,12 @@ A location is any `lat,lon` pair in the text — exactly what the `{loc}` placeh When **Pin to dial** is selected, a slot picker opens (Slot 1–6 showing current occupant name or "empty"). Choosing a slot that already holds another contact moves the new contact there. +In the **Rooms** list the context menu instead offers a single item: + +| Item | Action | +| ------- | ---------------------------------------------------------------------------- | +| Login… | Opens the password prompt to (re-)log in to this room (see Rooms — logging in) | + --- ### Context menu — channel list diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index dbb6a483..e901f209 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -104,9 +104,10 @@ Cycle views with **LEFT / RIGHT**: | Item | Action | | --------------------- | --------------------------------------------------- | -| Start / Stop tracking | Begin or end a recording session | +| Start / Stop tracking | Begin or end a recording session. If **GPS is off**, choosing Start asks **"GPS is off — Enable GPS & start"** so a session can't silently run with nothing to record | | Mark here | Drop a waypoint at the current GPS fix (see below) | | Waypoints… | Open the waypoint list / navigation / add-by-coords | +| Track back | Retrace the recorded route back to its start (needs ≥2 points; see below) | | Share my pos | Send your current position as a one-shot `[LOC]` message — pick a contact or channel (see **Live Share**) | | Trail file… | Open the file submenu (below) | | Settings… | Open the settings submenu (below) | @@ -127,6 +128,7 @@ Cycle views with **LEFT / RIGHT**: | ---------- | --------- | ------------------------------------------------------- | | Min dist | always | Sample gate, 4 levels — metric: 5/10/25/100 m, imperial: 15/30/75/300 ft | | Auto-pause | always | Off / 1 / 2 / 5 min — auto-freeze the trail after a stop, resume on movement (see below) | +| Mark avg | always | Off / 5 / 10 / 30 s — GPS averaging for **Mark here** (see Waypoints below) | | Readout | Summary view | Summary shows Speed or Pace (in the global unit system) | | Grid | Map view | Toggle scale grid on the map | @@ -134,12 +136,18 @@ Cycle views with **LEFT / RIGHT**: **Auto-pause** — when set, a recording trail automatically **pauses** after the device has stayed within ~15 m of one spot for the chosen delay: the elapsed timer and point sampling both freeze, and the map line breaks across the idle gap. It **resumes on its own** as soon as you move again. This keeps a stop (a break, a meal, parking) out of your distance and average-speed stats without you having to remember to stop and restart tracking. A paused trail is still "on" (the **G** marker keeps blinking) — the Summary **Status** row shows `paused`. The stop is detected with its own coarse movement gate, independent of **Min dist**, so GPS jitter while you're parked doesn't keep it awake. +### Track back + +**Hold Enter → Track back** retraces the trail you just recorded, back to where you started — useful for returning the same way in poor visibility or unfamiliar ground. It reuses the navigation view (distance + two absolute bearings; see *Waypoints › Navigating*), but instead of a single fixed target it walks the recorded breadcrumbs in reverse: it snaps onto the route at the **nearest recorded point**, guides you to it, then automatically advances to the next earlier point as you reach each one (within ~20 m). The header shows how many points remain (`Back: 12 pt`), reading `Trail start` on the final leg; arriving there shows `Back at start` and exits. **Cancel** leaves track-back at any time. It needs a trail with at least two points and a GPS fix; it doesn't require tracking to still be running. + ### Waypoints A waypoint is a saved spot — your car, camp, a water source — that you can navigate back to later. Waypoints are **independent of the trail**: they live in their own flash file (`/waypoints`), survive a reboot, and are **not** cleared by *Reset trail*. Up to 16 can be stored — the Waypoints list header shows how many are in use (e.g. `WAYPOINTS 3/16`). **Dropping a waypoint** — **Hold Enter → Mark here**. This captures the current GPS fix and opens the on-screen keyboard for a short label (up to 11 characters — e.g. `CAR`, `CAMP`, `H2O`). Leaving it blank auto-names it `WP1`, `WP2`, … Marking works whether or not the trail is being recorded; it needs a GPS fix (otherwise it reports *No GPS fix*). +**GPS averaging** — with **Settings → Mark avg** set (5 / 10 / 30 s), *Mark here* doesn't snapshot a single fix; it samples the GPS once a second for that window and stores the **mean** position, for a steadier mark than one instantaneous reading (handy for a precise spot — a cache, a car, a trailhead). A short screen shows the time left and the sample count while it runs; **Cancel** aborts. When the window closes it opens the label keyboard as usual. With **Mark avg = Off** (the default) marking is instant. + **Adding by coordinates** — open **Hold Enter → Waypoints** and select the **+ Add by coords** row (always the last entry in the list). This creates a waypoint without being there — no GPS fix required (handy for a meeting point or a spot read off a map). It opens a small form with three editable rows plus **Save**: | OLED | E-Ink | @@ -253,7 +261,7 @@ The tool holds both directions of sharing in one flat list. Navigate with **UP/D -A single **geofence** that beeps and shows an alert when you cross **into** or **out of** a radius. The target can be a **saved waypoint** (a fixed place — "tell me when I'm back at camp") or a **live contact** (a person sharing their position via Live Share — "alert me when my friend gets near / falls behind"). A waypoint target is a **snapshot** (coordinate + label copied), so it keeps working even if you later edit or delete that waypoint; a contact target follows the person's latest shared position. +A single **geofence** that beeps and shows an alert when you cross **into** or **out of** a radius. The target can be a **saved waypoint** (a fixed place — "tell me when I'm back at camp") or a **live contact** (a person sharing their position via Live Share — "alert me when my friend gets near / falls behind"). A waypoint target is a **snapshot** (coordinate + label copied), so it keeps working even if you later edit that waypoint; a contact target follows the person's latest shared position. **Deleting** the target's waypoint, or the target contact being removed from the contacts list, clears the Locator target back to `none` instead of leaving it pointed at something that's gone. Navigate with **UP/DOWN**, change a value with **LEFT/RIGHT** (or **Enter**); **Cancel/Back** saves and returns to Tools. diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index f2c9e2c9..9ec3b482 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -47,6 +47,10 @@ public: // A repeater rebroadcast of one of our channel sends was heard (seq from // lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker. virtual void onChannelRelayed(uint32_t seq) { (void)seq; } + // Result of an on-device-UI-triggered MyMesh::sendRoomLogin() arrived. + // pub_key is the contact's key prefix (>=4 bytes valid); permissions is the + // room/repeater ACL byte (only meaningful when success is true). + virtual void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { (void)pub_key; (void)success; (void)permissions; } // True only when a BLE central is actually bonded/connected. On a dual // (BLE+USB) interface hasConnection() is always true (USB counts), so use // this for BLE-specific UI like the pairing-PIN prompt. @@ -61,7 +65,7 @@ public: virtual void msgRead(int msgcount) = 0; virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; - virtual void addChannelMsg(uint8_t channel_idx, const char* text) {} + virtual void addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) {} virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) {} // A node shared its current position via a [LOC] message. pub_key is the // sender's key prefix for a verified DM share, or null for a channel share @@ -69,5 +73,16 @@ public: virtual void onSharedLocation(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) {} + // A contact is gone — removed explicitly (companion app / CLI command) or + // silently auto-evicted to make room when the contact table is full. Lets + // UI state that references contacts by pubkey (favourite slots, the + // Locator/Live Share target) drop a reference that would otherwise dangle. + // Default no-op. + virtual void onContactRemoved(const uint8_t* pub_key) {} + // A channel slot was cleared (companion app set it to an empty secret). + // Drop any setting that referenced it by index — otherwise a new channel + // added later at the same slot would silently inherit the old one's bot/ + // share target or notification melody. Default no-op. + virtual void onChannelRemoved(uint8_t channel_idx) {} virtual void loop() = 0; }; diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 4126b5f9..b8a5ad7e 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -176,6 +176,10 @@ File DataStore::openWrite(const char* filename) { return ::openWrite(_fs, filename); } +bool DataStore::commitFile(const char* tmp_path, const char* final_path) { + return commitTempFile(_fs, tmp_path, final_path); +} + bool DataStore::removeFile(const char* filename) { return _fs->remove(filename); } @@ -414,6 +418,16 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no rd(&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); rd(_prefs.locator_key, sizeof(_prefs.locator_key)); if (_prefs.locator_target_kind > 1) _prefs.locator_target_kind = 0; + // → 0xC0DE0016: GPS-averaging duration for waypoint marking. + rd(&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx)); + if (_prefs.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) _prefs.gps_avg_idx = 0; + // → 0xC0DE0017: one-shot alarm clock (local time-of-day + armed flag). + rd(&_prefs.alarm_on, sizeof(_prefs.alarm_on)); + rd(&_prefs.alarm_hour, sizeof(_prefs.alarm_hour)); + rd(&_prefs.alarm_min, sizeof(_prefs.alarm_min)); + if (_prefs.alarm_on > 1) _prefs.alarm_on = 0; + if (_prefs.alarm_hour > 23) _prefs.alarm_hour = 0; + if (_prefs.alarm_min > 59) _prefs.alarm_min = 0; // Pre-0x10 files leave stray sentinel bytes here, same as a never-configured // device. Either way there's no valid saved profile, so default to a profile // in the same band as the companion's own network (_prefs.freq, already read @@ -614,6 +628,10 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.locator_beeper, sizeof(_prefs.locator_beeper)); file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key)); + file.write((uint8_t *)&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx)); + file.write((uint8_t *)&_prefs.alarm_on, sizeof(_prefs.alarm_on)); + file.write((uint8_t *)&_prefs.alarm_hour, sizeof(_prefs.alarm_hour)); + file.write((uint8_t *)&_prefs.alarm_min, sizeof(_prefs.alarm_min)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good @@ -728,16 +746,24 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn } void DataStore::loadChannels(DataStoreHost* host) { - File file = openRead(_getContactsChannelsFS(), "/channels2"); + FILESYSTEM* fs = _getContactsChannelsFS(); + File file = openRead(fs, "/channels3"); if (file) { + // /channels3: the leading 4-byte field's first byte is the channel's + // original slot index (see saveChannels()) — load it back into that + // exact slot. The old /channels2 format instead reassigned indices + // 0,1,2… sequentially on every load, which silently shifted every + // later channel down a slot once an earlier one was removed — anything + // that remembers a channel by index (Live Share's target, the bot's + // channel, per-channel melody) would then point at the wrong channel + // after the next reboot. bool full = false; - uint8_t channel_idx = 0; uint8_t skipped = 0; while (!full) { ChannelDetails ch; - uint8_t unused[4]; + uint8_t hdr[4]; - bool success = (file.read(unused, 4) == 4); + bool success = (file.read(hdr, 4) == 4); success = success && (file.read((uint8_t *)ch.name, 32) == 32); success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32); @@ -760,44 +786,71 @@ void DataStore::loadChannels(DataStoreHost* host) { // as a C string regardless of how the file was written. ch.name[31] = '\0'; - if (host->onChannelLoaded(channel_idx, ch)) { - channel_idx++; - } else { - full = true; - } + if (!host->onChannelLoaded(hdr[0], ch)) full = true; } file.close(); if (skipped > 0) { MESH_DEBUG_PRINTLN("loadChannels: skipped %u corrupted/empty channel entr%s", (unsigned)skipped, skipped == 1 ? "y" : "ies"); } + return; + } + + // One-time migration from the old /channels2 format (sequential index, + // reassigned on every load — the bug /channels3 above replaces). Loads + // with that old semantics once, then resaves as /channels3 so this + // fallback is never hit again on this device. + file = openRead(fs, "/channels2"); + if (file) { + bool full = false; + uint8_t channel_idx = 0; + while (!full) { + ChannelDetails ch; + uint8_t unused[4]; + + bool success = (file.read(unused, 4) == 4); + success = success && (file.read((uint8_t *)ch.name, 32) == 32); + success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32); + if (!success) break; // EOF + + bool secret_empty = true; + for (int b = 0; b < 32; b++) if (ch.channel.secret[b] != 0) { secret_empty = false; break; } + if (secret_empty) continue; + ch.name[31] = '\0'; + + if (host->onChannelLoaded(channel_idx, ch)) channel_idx++; + else full = true; + } + file.close(); + saveChannels(host); // write /channels3 so the migration runs only once } } void DataStore::saveChannels(DataStoreHost* host) { FILESYSTEM* fs = _getContactsChannelsFS(); // Same atomic temp-then-rename pattern as saveContacts() — never truncate the - // live /channels2 before the new copy is fully written. - File file = ::openWrite(fs, "/channels2.tmp"); + // live /channels3 before the new copy is fully written. + File file = ::openWrite(fs, "/channels3.tmp"); if (!file) return; bool ok = true; uint8_t channel_idx = 0; ChannelDetails ch; - uint8_t unused[4]; - memset(unused, 0, 4); while (host->getChannelForSave(channel_idx, ch)) { - channel_idx++; + uint8_t idx = channel_idx++; // getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip // the unused ones (all-zero secret) rather than writing all 40 — otherwise - // the file is always ~2.7 KB and wears the flash needlessly. loadChannels() - // already compacts empty entries on read, so the loaded result is identical. + // the file is always ~2.7 KB and wears the flash needlessly. Unlike the old + // /channels2 format, loadChannels() no longer compacts: the slot index + // travels with the record (hdr[0] below) so a removed channel just leaves + // a hole instead of shifting every later index down a slot. bool empty = true; for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; } if (empty) continue; - bool success = (file.write(unused, 4) == 4); + uint8_t hdr[4] = { idx, 0, 0, 0 }; + bool success = (file.write(hdr, 4) == 4); success = success && (file.write((uint8_t *)ch.name, 32) == 32); success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32); if (!success) { ok = false; break; } // write failed @@ -805,9 +858,9 @@ void DataStore::saveChannels(DataStoreHost* host) { file.close(); if (ok) { - commitTempFile(fs, "/channels2.tmp", "/channels2"); + commitTempFile(fs, "/channels3.tmp", "/channels3"); } else { - fs->remove("/channels2.tmp"); // keep the previous good /channels2 + fs->remove("/channels3.tmp"); // keep the previous good /channels3 } } diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index b789e68f..13579f0f 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -46,6 +46,11 @@ public: File openRead(const char* filename); File openRead(FILESYSTEM* fs, const char* filename); File openWrite(const char* filename); + // Atomically replace final_path with a fully-written temp file (see + // openWrite()). Use for small custom records that want the same crash-safety + // as contacts/channels: write everything to a .tmp, then commit. Returns + // false if the swap fails (the previous good file is left untouched). + bool commitFile(const char* tmp_path, const char* final_path); bool removeFile(const char* filename); bool removeFile(FILESYSTEM* fs, const char* filename); uint32_t getStorageUsedKb() const; diff --git a/examples/companion_radio/LiveTrack.h b/examples/companion_radio/LiveTrack.h index 3fe45e21..78e71e6b 100644 --- a/examples/companion_radio/LiveTrack.h +++ b/examples/companion_radio/LiveTrack.h @@ -51,14 +51,9 @@ public: } // Drop entries not refreshed within EXPIRY_SECS. `now` is RTC epoch seconds. - // Guarded against now < ts (RTC stepped backwards) so a clock fix can't wipe - // the table. void expire(uint32_t now) { - for (int i = 0; i < CAPACITY; i++) { - if (_e[i].used && now > _e[i].ts && (now - _e[i].ts) > EXPIRY_SECS) { - _e[i].used = false; - } - } + for (int i = 0; i < CAPACITY; i++) + if (_e[i].used && expired(_e[i], now)) _e[i].used = false; } void clear() { for (int i = 0; i < CAPACITY; i++) _e[i].used = false; } @@ -66,9 +61,7 @@ public: // Slot-wise access (caller skips inactive slots via isActive()). const Entry& slotAt(int i) const { return _e[i]; } bool isActive(int i, uint32_t now) const { - const Entry& e = _e[i]; - if (!e.used) return false; - return !(now > e.ts && (now - e.ts) > EXPIRY_SECS); + return _e[i].used && !expired(_e[i], now); } // Number of currently non-expired entries. @@ -94,6 +87,13 @@ public: private: Entry _e[CAPACITY] = {}; + // An entry is stale once EXPIRY_SECS have passed since its last update. + // Guarded against now < ts (RTC stepped backwards) so a clock fix can't + // mass-expire the table. + static bool expired(const Entry& e, uint32_t now) { + return now > e.ts && (now - e.ts) > EXPIRY_SECS; + } + // Match an existing entry: verified shares by key, channel shares by name. int find(const uint8_t* key, const char* name, bool verified) const { for (int i = 0; i < CAPACITY; i++) { diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 01fcef48..d0f33f6c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -352,6 +352,7 @@ uint8_t MyMesh::getAutoAddMaxHops() const { void MyMesh::onContactOverwrite(const uint8_t* pub_key) { _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); // delete from storage + if (_ui) _ui->onContactRemoved(pub_key); // same cleanup as an explicit CMD_REMOVE_CONTACT if (_serial->isConnected()) { out_frame[0] = PUSH_CODE_CONTACT_DELETED; memcpy(&out_frame[1], pub_key, PUB_KEY_SIZE); @@ -720,7 +721,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe _serial->writeFrame(frame, 1); } #ifdef DISPLAY_CLASS - if (_ui) _ui->addChannelMsg(channel_idx, text); + if (_ui) _ui->addChannelMsg(channel_idx, text, timestamp); if (_ui) _ui->notify(UIEventType::channelMessage); const char *channel_name = "Unknown"; ChannelDetails channel_details; @@ -845,11 +846,13 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, pending_login = 0; int i = 0; + bool login_ok = false; if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS; out_frame[i++] = 0; // legacy: is_admin = false memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix + login_ok = true; } else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16; if (keep_alive_secs > 0) { @@ -863,13 +866,40 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, i += 4; // NEW: include server timestamp out_frame[i++] = data[7]; // NEW (v7): ACL permissions out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL + login_ok = true; } else { out_frame[i++] = PUSH_CODE_LOGIN_FAIL; out_frame[i++] = 0; // reserved memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix } + // Persist app/USB-entered room passwords too, so the device can later post + // to that room standalone (after reboot, no phone) without re-prompting -- + // same store the on-device login path uses. Rooms only; a failed login + // forgets any stale saved password, mirroring onRoomLoginResult(). + if (contact.type == ADV_TYPE_ROOM) { + if (login_ok) saveRoomPassword(contact.id.pub_key, pending_login_pw); + else forgetRoomPassword(contact.id.pub_key); + } _serial->writeFrame(out_frame, i); + } else if (ui_pending_login && memcmp(&ui_pending_login, contact.id.pub_key, 4) == 0) { // check for on-device UI login response + ui_pending_login = 0; + + bool success; + uint8_t permissions = 0; + if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response + success = true; + } else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response + uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16; + if (keep_alive_secs > 0) { + startConnection(contact, keep_alive_secs); + } + success = true; + permissions = data[7]; // ACL permissions + } else { + success = false; + } + _ui->onRoomLoginResult(contact.id.pub_key, success, permissions); } else if (len > 4 && // check for status response pending_status && memcmp(&pending_status, contact.id.pub_key, 4) == 0 // legacy matching scheme @@ -910,6 +940,104 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, } } +#define ROOM_PW_FILE "/room_pw" +#define ROOM_PW_TMP "/room_pw.tmp" +#define MAX_SAVED_ROOM_PASSWORDS 16 + +namespace { + struct RoomPwRec { + uint8_t key[4]; // pub-key prefix + char pw[16]; // up to 15 chars + NUL + }; +} + +bool MyMesh::getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len) { + File f = _store->openRead(ROOM_PW_FILE); + if (!f) return false; + + RoomPwRec rec; + bool found = false; + while (f.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) { + if (memcmp(rec.key, pub_key, 4) == 0) { + strncpy(out_password, rec.pw, max_len - 1); + out_password[max_len - 1] = 0; + found = true; + break; + } + } + f.close(); + return found; +} + +bool MyMesh::saveRoomPassword(const uint8_t* pub_key, const char* password) { + // The table is tiny (<= MAX_SAVED_ROOM_PASSWORDS * 20 bytes), so just load + // it whole, update/append/evict in RAM, then rewrite -- simpler and just + // as crash-safe as a record seek given how rarely this runs (once per new + // room login). + RoomPwRec recs[MAX_SAVED_ROOM_PASSWORDS]; + int count = 0; + File rf = _store->openRead(ROOM_PW_FILE); + if (rf) { + RoomPwRec rec; + while (count < MAX_SAVED_ROOM_PASSWORDS && rf.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) { + if (memcmp(rec.key, pub_key, 4) != 0) { // drop stale entry for this key -- replaced below + recs[count++] = rec; + } + } + rf.close(); + } + + RoomPwRec new_rec; + memcpy(new_rec.key, pub_key, 4); + strncpy(new_rec.pw, password, sizeof(new_rec.pw) - 1); + new_rec.pw[sizeof(new_rec.pw) - 1] = 0; + + if (count < MAX_SAVED_ROOM_PASSWORDS) { + recs[count++] = new_rec; + } else { // table full and not already present -- evict oldest (front) + memmove(&recs[0], &recs[1], sizeof(RoomPwRec) * (MAX_SAVED_ROOM_PASSWORDS - 1)); + recs[MAX_SAVED_ROOM_PASSWORDS - 1] = new_rec; + } + + // Write to a temp file and atomically swap it over /room_pw, so an + // interrupted save leaves the previous good table intact rather than a + // truncated mix (mirrors how contacts/channels are persisted). + File wf = _store->openWrite(ROOM_PW_TMP); + if (!wf) return false; + size_t want = sizeof(RoomPwRec) * count; + bool ok = (wf.write((uint8_t *)recs, want) == want); + wf.close(); + if (!ok) { _store->removeFile(ROOM_PW_TMP); return false; } // keep previous good file + return _store->commitFile(ROOM_PW_TMP, ROOM_PW_FILE); +} + +void MyMesh::forgetRoomPassword(const uint8_t* pub_key) { + RoomPwRec recs[MAX_SAVED_ROOM_PASSWORDS]; + int count = 0; + File rf = _store->openRead(ROOM_PW_FILE); + if (!rf) return; + + RoomPwRec rec; + bool removed = false; + while (count < MAX_SAVED_ROOM_PASSWORDS && rf.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) { + if (memcmp(rec.key, pub_key, 4) == 0) { + removed = true; + } else { + recs[count++] = rec; + } + } + rf.close(); + if (!removed) return; // nothing to do, avoid a pointless rewrite + + File wf = _store->openWrite(ROOM_PW_TMP); + if (!wf) return; + size_t want = sizeof(RoomPwRec) * count; + bool ok = (wf.write((uint8_t *)recs, want) == want); + wf.close(); + if (!ok) { _store->removeFile(ROOM_PW_TMP); return; } // keep previous good file + _store->commitFile(ROOM_PW_TMP, ROOM_PW_FILE); +} + bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) { if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 4) { uint32_t tag; @@ -1537,6 +1665,11 @@ void MyMesh::startInterface(BaseSerialInterface &serial) { serial.enable(); } +static bool isAllZero(const uint8_t* buf, size_t n) { + for (size_t i = 0; i < n; i++) if (buf[i]) return false; + return true; +} + void MyMesh::handleCmdFrame(size_t len) { if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection app_target_ver = cmd_frame[1]; // which version of protocol does app understand @@ -1823,6 +1956,8 @@ void MyMesh::handleCmdFrame(size_t len) { ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient && removeContact(*recipient)) { _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); + forgetRoomPassword(pub_key); // drop any saved room login -- useless without the contact + if (_ui) _ui->onContactRemoved(pub_key); // drop any favourite slot / Locator / Live Share target pointed at it dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); } else { @@ -2068,6 +2203,8 @@ void MyMesh::handleCmdFrame(size_t len) { } else { clearPendingReqs(); memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse() + strncpy(pending_login_pw, password, sizeof(pending_login_pw) - 1); // saved on success if it's a room + pending_login_pw[sizeof(pending_login_pw) - 1] = 0; out_frame[0] = RESP_CODE_SENT; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; memcpy(&out_frame[2], &pending_login, 4); @@ -2246,6 +2383,12 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(channel.channel.secret, &cmd_frame[2 + 32], 32); // 256-bit key if (setChannel(channel_idx, channel)) { saveChannels(); + // An all-zero secret is this codebase's "empty slot" sentinel (same + // check loadChannels()/saveChannels() use) -- the app just cleared this + // channel, so drop anything that referenced it by index, the same way + // onContactRemoved() does for contacts. + if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret))) + _ui->onChannelRemoved(channel_idx); writeOKFrame(); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx @@ -2258,6 +2401,8 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // 128-bit key if (setChannel(channel_idx, channel)) { saveChannels(); + if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret))) + _ui->onChannelRemoved(channel_idx); writeOKFrame(); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e8db12d5..da9c5c82 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -208,10 +208,30 @@ private: bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } void clearPendingReqs() { - pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; + pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = ui_pending_login = 0; } public: + // On-device UI login to a room/repeater contact — no phone app required. + // The room server's ACL grants permission per-identity (self_id), not per + // command source, so this reuses the same sendLogin() the BLE CMD_SEND_LOGIN + // path uses; the async result lands in onContactResponse() and is pushed to + // the UI via AbstractUITask::onRoomLoginResult(). + bool sendRoomLogin(const ContactInfo& contact, const char* password) { + uint32_t est_timeout; + if (sendLogin(contact, password, est_timeout) == MSG_SEND_FAILED) return false; + clearPendingReqs(); + memcpy(&ui_pending_login, contact.id.pub_key, 4); // match this in onContactResponse() + return true; + } + + // On-device-saved room/repeater login passwords, persisted to flash (own + // small file, independent of /contacts3) so a room that's already been + // logged into doesn't need its password retyped after reboot. + bool saveRoomPassword(const uint8_t* pub_key, const char* password); + bool getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len); + void forgetRoomPassword(const uint8_t* pub_key); + void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } void saveRTCTime() { _store->saveRTCTime(); } DataStore* getDataStore() const { return _store; } @@ -299,6 +319,8 @@ private: DataStore* _store; NodePrefs _prefs; uint32_t pending_login; + uint32_t ui_pending_login; // like pending_login, but triggered by on-device UI instead of BLE/USB app + char pending_login_pw[16]; // password of the in-flight app/USB login, persisted on success for ADV_TYPE_ROOM (see saveRoomPassword) uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ uint32_t pending_req; // pending _BINARY_REQ diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 45b131b6..972392a5 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -87,7 +87,7 @@ struct NodePrefs { // persisted to file uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible uint8_t bot_enabled; // 0=disabled, 1=DM bot active (responds to all DMs) uint8_t bot_channel_enabled; // 0=disabled, 1=channel bot active for bot_channel_idx - uint8_t bot_channel_idx; // channel index for channel bot + uint8_t bot_channel_idx; // channel index for channel bot [del→onChannelRemoved] char bot_trigger[64]; // DM trigger phrase (case-insensitive contains; "*" = any DM) char bot_reply_dm[140]; // auto-reply text for DM char bot_reply_ch[140]; // auto-reply text for channel @@ -100,7 +100,7 @@ struct NodePrefs { // persisted to file uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on static const int DM_NOTIF_TABLE_MAX = 16; - DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes + DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes [del→onContactRemoved] uint8_t dashboard_fields[3]; // 0=None,1=Batt V,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes,10=Msgs,11=Batt % uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds // Second melody slot (same packing as ringtone_*) @@ -114,12 +114,12 @@ struct NodePrefs { // persisted to file // Advert sound filter: 0=all adverts, 1=zero-hop only uint8_t advert_sound_scope; // Per-channel melody override (2 bitmasks, 1 bit per channel) - uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody + uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody [del→onChannelRemoved] uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set) // Per-DM melody table struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2 static const int DM_MELODY_TABLE_MAX = 16; - DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; + DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; // [del→onContactRemoved] uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap) uint8_t display_rotation; // 0-3; only used on e-ink displays // Home screen page order: each byte = HomePageBit + 1. 0 terminates the list. @@ -138,7 +138,7 @@ struct NodePrefs { // persisted to file // Layout transposes between landscape (3×2) and portrait (2×3). static const uint8_t FAVOURITES_COUNT = 6; static const uint8_t FAVOURITE_PREFIX_LEN = 6; - uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; + uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; // [del→onContactRemoved] // GPS trail cadence. Logging on/off is a runtime state (Tools › Trail), // not a persisted preference. @@ -229,8 +229,8 @@ struct NodePrefs { // persisted to file // Configured from the Map (Trail screen) "Live share" menu. uint8_t loc_share_enabled; // 0=off (default), 1=auto-sharing on uint8_t loc_share_target_type; // 0=channel, 1=DM contact - uint8_t loc_share_channel_idx; // target channel index (when target_type==0) - uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1) + uint8_t loc_share_channel_idx; // target channel index (when target_type==0) [del→onChannelRemoved] + uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1) [del→onContactRemoved] uint8_t loc_share_move_idx; // movement gate level (index into locShareMoveMeters) uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs) uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs) @@ -251,7 +251,7 @@ struct NodePrefs { // persisted to file // re-reads the latest [LOC] position each evaluation (keyed by pubkey prefix), // so the geofence follows a moving person ("alert when my friend is near"). uint8_t locator_target_kind; // 0=waypoint (static), 1=live contact - uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1 + uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1 [del→onContactRemoved] // Trail auto-pause — when tracking, automatically freeze the trail (timer + // sampling) after the device has sat still for this long, and resume on the @@ -264,6 +264,24 @@ struct NodePrefs { // persisted to file // discrete arrive/leave alert (locator_mode). uint8_t locator_beeper; // 0=off (default), 1=on + // GPS averaging for waypoint marking — when set, "Mark here" samples the GPS + // fix for this many seconds and stores the mean position, for a more accurate + // mark than a single instantaneous fix. 0 = off (instant mark, the default). + // Index into gpsAvgSecs(). [Tools › Trail › Settings › Mark avg] + uint8_t gps_avg_idx; + + // Alarm clock — a single one-shot wake alarm, configured from the Clock page + // (Enter › Alarm). Stored as a local time-of-day; the actual fire instant is + // (re)computed as an absolute time in tickBackground(), which is what makes it + // robust to RTC re-syncs: the mesh (every inbound packet), the companion app, + // GPS and the CLI can all jump getCurrentTime() at any moment, but an absolute + // target instant stays correct across small corrections and still fires (late) + // if the clock jumps over it. Fires once, then alarm_on clears. The minutnik + // (countdown) and stoper (stopwatch) are runtime-only and not persisted. + uint8_t alarm_on; // 0=off (default), 1=armed (one-shot) + uint8_t alarm_hour; // 0-23, local time + uint8_t alarm_min; // 0-59 + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; @@ -295,6 +313,17 @@ struct NodePrefs { // persisted to file return L[m < LOCATOR_MODE_COUNT ? m : 0]; } + // GPS-averaging durations for waypoint marking (seconds). 0 = off (instant). + static const uint8_t GPS_AVG_COUNT = 4; + static uint16_t gpsAvgSecs(uint8_t idx) { + static const uint16_t S[GPS_AVG_COUNT] = { 0, 5, 10, 30 }; + return S[idx < GPS_AVG_COUNT ? idx : 0]; + } + static const char* gpsAvgLabel(uint8_t idx) { + static const char* L[GPS_AVG_COUNT] = { "Off", "5s", "10s", "30s" }; + return L[idx < GPS_AVG_COUNT ? idx : 0]; + } + // Trail auto-pause delays (seconds). 0 = off. static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4; static uint16_t trailAutoPauseSecs(uint8_t idx) { @@ -315,7 +344,7 @@ struct NodePrefs { // persisted to file // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0015; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0017; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage @@ -385,6 +414,23 @@ struct NodePrefs { // persisted to file } }; +// ── Serialization tripwire ─────────────────────────────────────────────────── +// NodePrefs is written/read field-by-field, in order, by DataStore::savePrefs() +// and loadPrefsInt(); the on-disk format IS the struct's field layout. There is +// no automatic check that those two hand-written sequences match the struct, so +// a forgotten read/write silently misaligns EVERY field after it. +// +// This assert is the manual checkpoint. Changing a data member changes sizeof +// and trips it. When it trips, do ALL of the following, then update the number: +// 1. add the field's rd(...) in DataStore::loadPrefsInt(), in struct order +// 2. add the field's write(...) in DataStore::savePrefs(), in struct order +// 3. clamp it on load (an upgrader's file lacks it → stray bytes) +// 4. bump SCHEMA_SENTINEL's low byte +// (Padding can also shift sizeof; a "false" trip just means re-check + rebump.) +static_assert(sizeof(NodePrefs) == 2488, + "NodePrefs layout changed — sync DataStore save/load + clamp, bump " + "SCHEMA_SENTINEL, then update this size (see steps above)."); + // Bounds for a usable repeater radio profile. The frequency range is passed in // by the caller from RadioLibWrapper::getFreqBounds() — the radio chip's own // validated range — so a profile that passes here is guaranteed to actually take diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index f6c0a286..87e032fd 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -20,7 +20,7 @@ class AutoAdvertScreen : public UIScreen { public: AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _dirty = false; } + void onShow() override { _dirty = false; } int render(DisplayDriver& display) override { display.setTextSize(1); @@ -51,7 +51,7 @@ public: bool handleInput(char c) override { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 2b153a90..cbc9c975 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -42,7 +42,7 @@ class BotScreen : public UIScreen { public: BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {} - void enter() { + void onShow() override { _sel = 0; _scroll = 0; _kb_field = -1; @@ -75,7 +75,7 @@ public: "Trigger Ch", "Reply Ch", "Commands", "Quiet from", "Quiet to" }; drawList(display, ITEM_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(labels[i]); display.setCursor(val_x, y); @@ -141,7 +141,7 @@ public: } if (cancel) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/ClockToolsScreen.h b/examples/companion_radio/ui-new/ClockToolsScreen.h new file mode 100644 index 00000000..f6d80d4b --- /dev/null +++ b/examples/companion_radio/ui-new/ClockToolsScreen.h @@ -0,0 +1,298 @@ +#pragma once +// Clock tools — Alarm, Countdown timer (minutnik) and Stopwatch (stoper). +// Entered with Enter on the home CLOCK page. A small top menu picks one of the +// three tools; Cancel backs out a level (tool → menu → home). +// +// This screen is pure UI. The time-critical machinery lives in UITask, which +// drives it every loop regardless of the current screen: +// • Alarm — UITask schedules an absolute fire instant from NodePrefs' +// alarm_hour/min (robust to RTC re-syncs) and rings. Here we just edit the +// persisted fields and call onAlarmChanged() to re-schedule. +// • Timer — UITask owns the running countdown (startTimer / stopTimer / +// isTimerRunning / timerRemainingMs). It rings even when off-screen. +// • Stopwatch — purely a display utility with no background action, so its +// millis() state lives here; it keeps counting while you're elsewhere. +// +// Numeric fields (alarm hour/minute, timer h/m/s) are edited with the shared +// framework DigitEditor (digit-by-digit: LEFT/RIGHT cursor, UP/DOWN +/- the +// place, min/max enforced) — the same widget Settings/Repeater use for Freq, +// rather than a hand-rolled wrap. +// +// E-INK: the running timer/stopwatch readouts would thrash a slow e-paper panel +// if redrawn every second, so on e-ink the live readout refreshes only coarsely +// (and immediately on any key); the millis() timing underneath is unaffected. +// Included by UITask.cpp after the other screen fragments. + +#include "../NodePrefs.h" +#include "icons.h" // drawList / drawRowSelection +#include "DigitEditor.h" // shared digit-by-digit numeric editor + +class ClockToolsScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + enum View : uint8_t { V_MENU, V_ALARM, V_TIMER, V_STOPWATCH }; + uint8_t _view = V_MENU; + int _sel = 0, _scroll = 0; // shared list cursor + bool _alarm_dirty = false; // unsaved edits to alarm_* (persist on exit) + + // Countdown config (the duration to start; the running countdown lives in UITask). + uint8_t _timer_h = 0, _timer_m = 5, _timer_s = 0; + uint8_t _timer_cur = 0; // digit cursor over HH:MM:SS, 0..5 (skips the colons) + + // Stopwatch (millis — display-only, no background action). + bool _sw_running = false; + uint32_t _sw_start_ms = 0; + uint32_t _sw_accum_ms = 0; + + // Shared numeric editor (alarm hour/minute) + which field it targets. The + // timer uses its own continuous digit cursor (per-place caps a single + // DigitEditor can't express), so it isn't listed here. + enum EditKind : uint8_t { EK_NONE, EK_A_HOUR, EK_A_MIN }; + DigitEditor _editor; + EditKind _edit_kind = EK_NONE; + + // E-ink: coarse refresh for the live readouts (millis underneath is exact). + static int liveTickMs(DisplayDriver& d, int oled_ms) { return d.isEink() ? 30000 : oled_ms; } + + static void fmtClock(char* b, int n, int hh, int mm) { snprintf(b, n, "%02d:%02d", hh, mm); } + + // millis → "H:MM:SS" (the countdown always shows hours). + static void fmtHMS(char* b, int n, uint32_t ms) { + uint32_t s = ms / 1000, h = s / 3600; s %= 3600; + uint32_t m = s / 60; s %= 60; + snprintf(b, n, "%lu:%02lu:%02lu", (unsigned long)h, (unsigned long)m, (unsigned long)s); + } + + // Stopwatch readout: "M:SS.t" while under an hour (tenths only on a fast + // panel), "H:MM:SS" once it rolls past an hour. + static void fmtStopwatch(char* b, int n, uint32_t ms, bool tenths) { + if (ms >= 3600000UL) { fmtHMS(b, n, ms); return; } + uint32_t total_s = ms / 1000, m = total_s / 60, s = total_s % 60; + if (tenths) snprintf(b, n, "%lu:%02lu.%lu", (unsigned long)m, (unsigned long)s, + (unsigned long)((ms % 1000) / 100)); + else snprintf(b, n, "%lu:%02lu", (unsigned long)m, (unsigned long)s); + } + + uint32_t stopwatchMs() const { + return _sw_accum_ms + (_sw_running ? (millis() - _sw_start_ms) : 0); + } + + // Step the digit under the timer cursor by dir (+1/−1), wrapping within that + // place and keeping each field valid: minute/second tens 0-5, hours 0-23. + void stepTimerDigit(int dir) { + uint8_t* f; int tens_max; bool is_hours = false; + if (_timer_cur < 2) { f = &_timer_h; tens_max = 2; is_hours = true; } + else if (_timer_cur < 4) { f = &_timer_m; tens_max = 5; } + else { f = &_timer_s; tens_max = 5; } + int t = *f / 10, u = *f % 10; + if (_timer_cur % 2 == 0) { // tens place + t = (t + dir + (tens_max + 1)) % (tens_max + 1); + if (is_hours && t == 2 && u > 3) u = 3; // 2X hours can't exceed 23 + } else { // units place + int umax = (is_hours && t == 2) ? 3 : 9; + u = (u + dir + (umax + 1)) % (umax + 1); + } + *f = (uint8_t)(t * 10 + u); + } + + // Open the DigitEditor on a numeric field (2 integer digits, no decimals). + void editField(EditKind kind, uint8_t value, uint8_t vmax) { + _edit_kind = kind; + _editor.begin((float)value, 0.0f, (float)vmax, 2, 0); + } + + // Feed a key to the open editor, write the (live) value back to its field, and + // close the editor when DigitEditor reports DONE/CANCELLED. + bool feedEditor(char c) { + DigitEditor::Result r = _editor.handleInput(c); + uint8_t v = (uint8_t)(_editor.value + 0.5f); + switch (_edit_kind) { + case EK_A_HOUR: _prefs->alarm_hour = v; _alarm_dirty = true; _task->onAlarmChanged(); break; + case EK_A_MIN: _prefs->alarm_min = v; _alarm_dirty = true; _task->onAlarmChanged(); break; + default: break; + } + if (r != DigitEditor::NONE) _edit_kind = EK_NONE; // editor closed + return true; + } + + // Draw a row's value column: the live editor when this row is the one being + // edited, otherwise the static text. + void drawValue(DisplayDriver& d, int y, int valx, int reserve, bool sel, + EditKind mykind, const char* text) { + if (sel && _editor.active && _edit_kind == mykind) _editor.render(d, valx, y); + else d.drawTextEllipsized(valx, y, d.width() - valx - reserve, text); + } + + // ── Sub-renders ─────────────────────────────────────────────────────────── + int renderMenu(DisplayDriver& d) { + d.drawCenteredHeader("CLOCK TOOLS"); + static const char* items[3] = { "Alarm", "Timer", "Stopwatch" }; + if (_sel > 2) _sel = 2; + drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { + drawRowSelection(d, y, sel, reserve); + d.setCursor(4, y); + d.print(items[i]); + if (i == 0 && _prefs && _prefs->alarm_on) { // show the armed alarm time + char b[8]; fmtClock(b, sizeof(b), _prefs->alarm_hour, _prefs->alarm_min); + int vx = d.width() - d.getTextWidth(b) - reserve - 2; + d.setCursor(vx, y); d.print(b); + } + }); + return 60000; + } + + int renderAlarm(DisplayDriver& d) { + d.drawCenteredHeader("ALARM"); + if (!_prefs) return 60000; + const char* rows[3] = { "Hour", "Minute", "Armed" }; + if (_sel > 2) _sel = 2; + const int valx = d.width() / 2 + 6; + drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { + drawRowSelection(d, y, sel, reserve); + d.setCursor(4, y); d.print(rows[i]); + char b[6]; + if (i == 0) snprintf(b, sizeof(b), "%02u", _prefs->alarm_hour); + else if (i == 1) snprintf(b, sizeof(b), "%02u", _prefs->alarm_min); + else snprintf(b, sizeof(b), "%s", _prefs->alarm_on ? "ON" : "OFF"); + EditKind k = (i == 0) ? EK_A_HOUR : (i == 1) ? EK_A_MIN : EK_NONE; + drawValue(d, y, valx, reserve, sel, k, b); + }); + return _editor.active ? 50 : 60000; + } + + int renderTimer(DisplayDriver& d) { + d.drawCenteredHeader("TIMER"); + const int cx = d.width() / 2; + if (_task->isTimerRunning()) { + char buf[16]; + fmtHMS(buf, sizeof(buf), _task->timerRemainingMs()); + d.setTextSize(2); + d.drawTextCentered(cx, d.listStart() + 4, buf); + d.setTextSize(1); + d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Ent=stop"); + return liveTickMs(d, 1000); + } + // Setting mode: big HH:MM:SS with the digit under the cursor underlined. + // LEFT/RIGHT move the cursor one digit, UP/DOWN change it, Enter starts. + char buf[12]; + snprintf(buf, sizeof(buf), "%02u:%02u:%02u", _timer_h, _timer_m, _timer_s); + d.setTextSize(2); + const int ty = d.listStart() + 2; + d.drawTextCentered(cx, ty, buf); + const int cw = d.getCharWidth(); // size-2 cell width + const int lh2 = d.getLineHeight(); + const int sx = cx - d.getTextWidth(buf) / 2; + const int char_idx = _timer_cur + (_timer_cur / 2); // skip the two colons + d.fillRect(sx + char_idx * cw, ty + lh2, cw - 1, 2); + d.setTextSize(1); + d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Up/Dn=set Ent=start"); + return 60000; + } + + int renderStopwatch(DisplayDriver& d) { + d.drawCenteredHeader("STOPWATCH"); + const int cx = d.width() / 2; + char buf[16]; + bool tenths = !d.isEink() && _sw_running; // tenths only on a fast panel + fmtStopwatch(buf, sizeof(buf), stopwatchMs(), tenths); + d.setTextSize(2); + d.drawTextCentered(cx, d.listStart() + 4, buf); + d.setTextSize(1); + const char* hint = _sw_running ? "Ent=stop" : "Ent=start Dn=reset"; + d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, hint); + return _sw_running ? liveTickMs(d, 100) : 60000; + } + + // ── Input per view ──────────────────────────────────────────────────────── + bool inputMenu(char c) { + if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } + if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; } + if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; } + if (c == KEY_ENTER) { + _view = (_sel == 0) ? V_ALARM : (_sel == 1) ? V_TIMER : V_STOPWATCH; + _sel = 0; _scroll = 0; + return true; + } + return true; + } + + bool inputAlarm(char c) { + if (!_prefs) { if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; } return true; } + if (_editor.active) return feedEditor(c); + if (c == KEY_CANCEL) { + _task->savePrefsIfDirty(_alarm_dirty); // persist alarm fields only if edited + _task->onAlarmChanged(); // re-schedule from the new time + _view = V_MENU; _sel = 0; _scroll = 0; + return true; + } + if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; } + if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; } + if (c == KEY_ENTER) { + if (_sel == 0) editField(EK_A_HOUR, _prefs->alarm_hour, 23); + else if (_sel == 1) editField(EK_A_MIN, _prefs->alarm_min, 59); + else { _prefs->alarm_on ^= 1; _alarm_dirty = true; _task->onAlarmChanged(); } + return true; + } + return true; + } + + bool inputTimer(char c) { + if (_task->isTimerRunning()) { + if (c == KEY_ENTER) { _task->stopTimer(); return true; } // stop/cancel countdown + if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps counting in background + return true; // any other key just forces a refresh (e-ink) + } + if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } + if (keyIsPrev(c)) { _timer_cur = (_timer_cur + 5) % 6; return true; } // cursor ← + if (keyIsNext(c)) { _timer_cur = (_timer_cur + 1) % 6; return true; } // cursor → + if (c == KEY_UP) { stepTimerDigit(+1); return true; } + if (c == KEY_DOWN) { stepTimerDigit(-1); return true; } + if (c == KEY_ENTER) { + uint32_t dur = (((uint32_t)_timer_h * 60 + _timer_m) * 60 + _timer_s) * 1000UL; + if (dur == 0) { _task->showAlert("Set a duration", 1000); return true; } + _task->startTimer(dur); + } + return true; + } + + bool inputStopwatch(char c) { + if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps running in background + if (c == KEY_ENTER) { + if (_sw_running) { _sw_accum_ms += millis() - _sw_start_ms; _sw_running = false; } + else { _sw_start_ms = millis(); _sw_running = true; } + return true; + } + if ((c == KEY_UP || c == KEY_DOWN) && !_sw_running) { _sw_accum_ms = 0; return true; } + return true; // other keys just refresh the readout + } + +public: + ClockToolsScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void onShow() override { + _view = V_MENU; _sel = 0; _scroll = 0; + _timer_cur = 0; + _editor.active = false; _edit_kind = EK_NONE; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + switch (_view) { + case V_ALARM: return renderAlarm(display); + case V_TIMER: return renderTimer(display); + case V_STOPWATCH: return renderStopwatch(display); + default: return renderMenu(display); + } + } + + bool handleInput(char c) override { + switch (_view) { + case V_ALARM: return inputAlarm(c); + case V_TIMER: return inputTimer(c); + case V_STOPWATCH: return inputStopwatch(c); + default: return inputMenu(c); + } + } +}; diff --git a/examples/companion_radio/ui-new/CompassScreen.h b/examples/companion_radio/ui-new/CompassScreen.h index dcadb5af..c37ea150 100644 --- a/examples/companion_radio/ui-new/CompassScreen.h +++ b/examples/companion_radio/ui-new/CompassScreen.h @@ -27,7 +27,7 @@ class CompassScreen : public UIScreen { public: CompassScreen(UITask* task) : _task(task) {} - void enter() {} + void onShow() override {} int render(DisplayDriver& display) override { display.setTextSize(1); diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h index a370f05e..e2f307ae 100644 --- a/examples/companion_radio/ui-new/DashboardConfigScreen.h +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -37,7 +37,7 @@ class DashboardConfigScreen : public UIScreen { public: DashboardConfigScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _sel = 0; _dirty = false; } + void onShow() override { _sel = 0; _dirty = false; } int render(DisplayDriver& display) override { display.setTextSize(1); @@ -65,7 +65,7 @@ public: bool handleInput(char c) override { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoHomeScreen(); return true; } diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 4390a0f9..ed84dd9c 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -10,12 +10,35 @@ static const int FS_CHARS_MAX = 80; // max bytes per wrapped line // fullscreen message view and the history list are never laid out in the same // frame (opening the fullscreen view early-returns before the list loop runs), // so one static buffer serves both. This keeps ~1.5 KB of line buffers off the -// render-call stack (see CODE_REVIEW.md "Render-path stack peak") at the cost of -// a fixed RAM allocation. Only used inside render()/wrap helpers — never across -// a yield, so the single instance is safe. +// render-call stack at the cost of a fixed RAM allocation. Only used inside +// render()/wrap helpers — never across a yield, so the single instance is safe. static char s_wrap_trans[512]; static char s_wrap_lines[12][FS_CHARS_MAX]; +// Parse a leading "@[nick] " reply prefix. Returns the message body that +// follows it (and any leading whitespace); when nick/nick_n are supplied, +// fills nick with the addressee, or "" when there's no prefix. One parser for +// both the history list (body only — see QuickMsgScreen::skipReplyPrefix) and +// the fullscreen view (which also shows the "To:" nick). +static inline const char* msgReplyBody(const char* text, char* nick = nullptr, int nick_n = 0) { + if (nick && nick_n > 0) nick[0] = '\0'; + const char* body = text; + if (text[0] == '@' && text[1] == '[') { + const char* close = strchr(text + 2, ']'); + if (close && close[1] == ' ' && close[2]) { + if (nick && nick_n > 0) { + int len = (int)(close - text) - 2; + if (len > nick_n - 1) len = nick_n - 1; + memcpy(nick, text + 2, len); + nick[len] = '\0'; + } + body = close + 2; + } + } + while (*body == '\n' || *body == '\r' || *body == ' ') body++; + return body; +} + struct FullscreenMsgView { int scroll; bool active; @@ -80,19 +103,9 @@ struct FullscreenMsgView { const int lineH = display.getLineHeight(); const int max_px = display.width() - 6; - // detect @recipient at start of message - char to_nick[32] = ""; - const char* body = text; - if (text[0] == '@' && text[1] == '[') { - const char* close = strchr(text + 2, ']'); - if (close && close[1] == ' ' && close[2]) { - int len = (int)(close - text) - 2; - if (len >= (int)sizeof(to_nick)) len = sizeof(to_nick) - 1; - memcpy(to_nick, text + 2, len); - to_nick[len] = '\0'; - body = close + 2; - } - } + // "@[nick] " reply prefix → "To:" header + body (shared parser). + char to_nick[32]; + const char* body = msgReplyBody(text, to_nick, sizeof(to_nick)); const int cw = display.getCharWidth(); const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2); @@ -143,8 +156,8 @@ struct FullscreenMsgView { Result handleInput(char c) { if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; } if (c == KEY_DOWN) { if (scroll < _max_scroll) scroll++; return NONE; } - if (c == KEY_LEFT) return NEXT; - if (c == KEY_RIGHT) return PREV; + if (keyIsPrev(c)) return NEXT; // page between messages (encoder too) + if (keyIsNext(c)) return PREV; if (c == KEY_CONTEXT_MENU) return REPLY; if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE; return NONE; diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index 83b33878..726b3757 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -36,7 +36,7 @@ class LiveShareScreen : public UIScreen { public: LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _dirty = false; _sel = 0; _scroll = 0; } + void onShow() override { _dirty = false; _sel = 0; _scroll = 0; } // Resolve the configured target's display name (channel name or contact name). void currentTargetName(char* buf, int n) { @@ -92,7 +92,7 @@ public: const int valx = display.width() / 2 + 6; drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { Row r = rows(i); - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(4, y); display.print(r.label); char val[24]; @@ -161,7 +161,7 @@ public: bool handleInput(char c) override { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) { the_mesh.savePrefs(); _dirty = false; } + _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/LocatorScreen.h b/examples/companion_radio/ui-new/LocatorScreen.h index b6dd5112..0cf7fba0 100644 --- a/examples/companion_radio/ui-new/LocatorScreen.h +++ b/examples/companion_radio/ui-new/LocatorScreen.h @@ -72,7 +72,7 @@ class LocatorScreen : public UIScreen { public: LocatorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _dirty = false; _sel = 0; _scroll = 0; _picking = false; } + void onShow() override { _dirty = false; _sel = 0; _scroll = 0; _picking = false; } void valueLabel(Kind k, char* buf, int n) { switch (k) { @@ -116,7 +116,7 @@ public: const int valx = display.width() / 2 + 6; drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { Row r = rows(i); - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(4, y); display.print(r.label); char val[24]; @@ -265,7 +265,7 @@ public: display.drawCenteredHeader("PICK TARGET"); uint32_t now = rtc_clock.getCurrentTime(); drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); const Target& t = _targets[i]; char row[36]; if (t.kind != 1) { @@ -293,7 +293,7 @@ public: return true; } if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) { the_mesh.savePrefs(); _dirty = false; } // engine re-seeded per edit + _task->savePrefsIfDirty(_dirty); // engine re-seeded per edit _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/MessageHistory.h b/examples/companion_radio/ui-new/MessageHistory.h new file mode 100644 index 00000000..1bdb5cb4 --- /dev/null +++ b/examples/companion_radio/ui-new/MessageHistory.h @@ -0,0 +1,324 @@ +#pragma once +// Message history store for QuickMsgScreen: two RAM ring buffers (channel + DM) +// with their per-entry delivery state (channel "relayed into mesh" echo, DM +// end-to-end ACK + auto-resend) and the per-channel unread counters. Pure +// storage + queries — no UI/phase state lives here. The screen keeps selection, +// scroll, the unread "viewing session" bookkeeping, the room-login table, and +// all rendering, and reaches entries through the accessors below. +// +// Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h. AckState, +// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the +// phase machine in QuickMsgScreen keeps referring to them unqualified. + +// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to +// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no +// recipient ACK exists for floods), so a missing echo is NOT shown as failure. +enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL }; + +// History text holds a full received message. Channel messages carry the sender +// embedded as "Name: body" in the payload, so a message can be up to the +// over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, otherwise +// long messages (and Polish text, where each accented char is two UTF-8 bytes) +// get their tail clipped. +static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; + +struct ChHistEntry { + uint8_t ch_idx; + char text[MSG_TEXT_BUF]; + uint32_t timestamp; + uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods) + uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed() +}; + +struct DmHistEntry { + uint8_t prefix[4]; + uint8_t outgoing; + char text[MSG_TEXT_BUF]; + uint32_t timestamp; + uint8_t ack_status; // AckState; meaningful only when outgoing + uint32_t ack_tag; // expected_ack CRC to match against onMsgAck() + uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive + // Sender-perspective message timestamp: the send timestamp for outgoing + // (reused verbatim on resend so the recipient treats it as a retry), or the + // sender_timestamp for incoming (used to dedup retried copies). 0 = unknown. + uint32_t msg_ts; + uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1 + uint8_t resends_left; // remaining auto-resends before the marker shows ✗ +}; + +class MessageHistory { +public: + // Shared ring for all channels combined; each slot carries a full MSG_TEXT_BUF, + // so this ring dominates RAM — kept modest to leave heap headroom (see + // DM_HIST_MAX). The DM ring is likewise bounded. + static const int CH_HIST_MAX = 48; + static const int DM_HIST_MAX = 32; + + MessageHistory() + : _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0) { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + // ── Channel ring ────────────────────────────────────────────────────────── + + // Append a channel message. `viewing` = the user is currently in this + // channel's history (so it isn't counted unread). `timestamp` is the sender's + // own send time (0 = unknown — use receipt time). Returns the ring position + // (an opaque handle for armChannelRelay / chAtPos), or -1 if rejected. + int addChannelMsg(uint8_t ch_idx, const char* text, bool viewing, uint32_t timestamp = 0) { + // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 + // and was cast to uint8_t → 255). Storing such an entry would burn a ring + // slot for a message that no visible channel can ever surface. + if (ch_idx >= MAX_GROUP_CHANNELS) return -1; + int pos; + if (_hist_count < CH_HIST_MAX) { + pos = (_hist_head + _hist_count) % CH_HIST_MAX; + _hist_count++; + } else { + pos = _hist_head; + // Evicting the oldest entry — drop its share of the unread counter so + // the badge can't claim a message the ring no longer holds. + uint8_t evicted = _hist[pos].ch_idx; + if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) { + _ch_unread[evicted]--; + } + _hist_head = (_hist_head + 1) % CH_HIST_MAX; + } + _hist[pos].ch_idx = ch_idx; + _hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime(); + strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); + _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + _hist[pos].relay_status = ACK_NONE; + _hist[pos].relay_seq = 0; + + if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; + return pos; + } + + // count history entries for a specific channel + int histCountForChannel(int ch_idx) const { + int n = 0; + for (int i = 0; i < _hist_count; i++) { + if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; + } + return n; + } + + // get ring-buffer position of j-th history entry for channel (newest first) + int histEntryForChannel(int ch_idx, int j) const { + int n = 0; + for (int i = _hist_count - 1; i >= 0; i--) { + int pos = (_hist_head + i) % CH_HIST_MAX; + if (_hist[pos].ch_idx == (uint8_t)ch_idx) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + // Called when a repeater echo of one of our channel sends is heard. + void markChannelRelayed(uint32_t seq) { + if (seq == 0) return; + for (int i = 0; i < _hist_count; i++) { + ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX]; + if (e.relay_status == ACK_PENDING && e.relay_seq == seq) { + e.relay_status = ACK_OK; + return; + } + } + } + + // Arm the "relayed into mesh" marker on a just-sent entry (pos from + // addChannelMsg) — MyMesh tracked the flood it originated and will report a + // heard repeater echo by seq. + void armChannelRelay(int pos, uint32_t seq) { + if (pos < 0 || pos >= CH_HIST_MAX) return; + _hist[pos].relay_status = ACK_PENDING; + _hist[pos].relay_seq = seq; + } + + ChHistEntry& chAtPos(int pos) { return _hist[pos]; } + const ChHistEntry& chAtPos(int pos) const { return _hist[pos]; } + + // ── Per-channel unread counters ───────────────────────────────────────────── + uint8_t chUnread(int ch) const { + return (ch >= 0 && ch < MAX_GROUP_CHANNELS) ? _ch_unread[ch] : 0; + } + void setChUnread(int ch, uint8_t v) { + if (ch >= 0 && ch < MAX_GROUP_CHANNELS) _ch_unread[ch] = v; + } + void clearAllChannelUnread() { memset(_ch_unread, 0, sizeof(_ch_unread)); } + int getTotalChannelUnread() const { + int total = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; + return total; + } + + // ── DM ring ───────────────────────────────────────────────────────────────── + + // ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by + // ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming). + // msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp + // for incoming); resends = remaining auto-resends for an outgoing pending DM. + void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, + uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, + uint32_t msg_ts = 0, uint8_t resends = 0) { + int pos; + if (_dm_hist_count < DM_HIST_MAX) { + pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; + _dm_hist_count++; + } else { + pos = _dm_hist_head; + _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; + } + memcpy(_dm_hist[pos].prefix, pub_key, 4); + _dm_hist[pos].outgoing = outgoing ? 1 : 0; + // Prefer the sender's own timestamp — a room-sync replay or an + // offline-queued message held by a repeater can arrive long after it was + // actually sent, so "now" would mislabel every backlog message as fresh. + // Fall back to receipt time only when the sender's timestamp is unknown. + _dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime(); + strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); + _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; + _dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE; + _dm_hist[pos].ack_tag = ack_tag; + _dm_hist[pos].ack_deadline_ms = ack_deadline_ms; + _dm_hist[pos].msg_ts = msg_ts; + _dm_hist[pos].attempt = 0; + _dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0; + } + + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, + uint32_t sender_timestamp = 0) { + // Drop retried copies of an incoming DM: a resend reuses the sender's + // timestamp and text but carries a fresh packet hash, so the mesh dup-filter + // lets it through. Match on prefix + sender_timestamp + text to suppress it. + if (!outgoing && sender_timestamp != 0) { + for (int i = 0; i < _dm_hist_count; i++) { + const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (!e.outgoing && e.msg_ts == sender_timestamp && + memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0) + return; // duplicate retry — already in history + } + } + storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0); + } + + int dmHistCountForContact(const uint8_t* prefix) const { + int n = 0; + for (int i = 0; i < _dm_hist_count; i++) + if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; + return n; + } + + int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + // Effective status for display. A pending ACK only reads as failed once its + // deadline has passed AND no auto-resends remain — while resends_left > 0 the + // entry stays pending (tickDmResends() retries / finalises it). Safety net for + // when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL. + AckState dmEffectiveStatus(const DmHistEntry& e) const { + if (e.ack_status == ACK_PENDING && e.resends_left == 0 && + (int32_t)(millis() - e.ack_deadline_ms) >= 0) + return ACK_FAIL; + return (AckState)e.ack_status; + } + + // Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv). + // Marks the matching pending outgoing DM as delivered. + void markDmDelivered(uint32_t ack_crc) { + if (ack_crc == 0) return; + for (int i = 0; i < _dm_hist_count; i++) { + DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) { + e.ack_status = ACK_OK; + return; + } + } + } + + // Periodic resend driver for outgoing DMs whose ACK deadline lapsed with no + // ACK: resend with the next attempt# (reusing the original timestamp so the + // recipient dedups) while resends remain, else mark it failed (✗). + void tickDmResends() { + uint32_t now = millis(); + for (int i = 0; i < _dm_hist_count; i++) { + DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (!e.outgoing || e.ack_status != ACK_PENDING) continue; + if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting + if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; } + ContactInfo c; + if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; } + uint32_t expected_ack = 0, est_timeout = 0; + uint8_t next_attempt = e.attempt + 1; + if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text, + expected_ack, est_timeout) > 0 && expected_ack) { + e.attempt = next_attempt; + e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC + e.ack_deadline_ms = now + est_timeout + 4000; + e.resends_left--; + } else { + e.ack_status = ACK_FAIL; // couldn't compose/send — give up + } + } + } + + // Recent DM contacts, newest first, deduped. Resolves the 4-byte _dm_hist + // prefix to a 6-byte pub_key prefix by walking the contact list once per + // unique sender. Returns the number filled. + int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const { + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + const uint8_t* p4 = _dm_hist[pos].prefix; + // Skip if already collected. + bool dup = false; + for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; } + if (dup) continue; + // Find a real contact whose pub_key starts with this 4-byte prefix. + for (int idx = 0; ; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (memcmp(c.id.pub_key, p4, 4) == 0) { + memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN); + n++; + break; + } + } + } + return n; + } + + DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; } + const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; } + +private: + // Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry). + bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const { + int total = the_mesh.getNumContacts(); + for (int i = 0; i < total; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(i, c)) continue; + if (memcmp(c.id.pub_key, prefix, 4) == 0) { out = c; return true; } + } + return false; + } + + ChHistEntry _hist[CH_HIST_MAX]; + int _hist_head, _hist_count; + uint8_t _ch_unread[MAX_GROUP_CHANNELS]; + + DmHistEntry _dm_hist[DM_HIST_MAX]; + int _dm_hist_head, _dm_hist_count; +}; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 57a503ff..45d0d00f 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -1,12 +1,7 @@ #pragma once -#include #include "../GeoUtils.h" #include "NavView.h" -#ifndef M_PI - #define M_PI 3.14159265358979323846 -#endif - // ── Nearby Nodes ────────────────────────────────────────────────────────────── // One list / detail / action-menu interaction path over two sources: // SRC_STORED — contacts known to the mesh (distance / bearing / last-heard) @@ -111,14 +106,13 @@ class NearbyScreen : public UIScreen { bool useImperial() const { return _task && _task->useImperial(); } + // Full "X ago" form for the detail view, built on the shared short-age tag + // so there's one bucket ladder (see geo::fmtAgeShort). static void fmtAge(char* buf, int n, uint32_t lastmod) { - uint32_t now = rtc_clock.getCurrentTime(); - if (now < lastmod || lastmod == 0) { snprintf(buf, n, "unknown"); return; } - uint32_t age = now - lastmod; - if (age < 60) snprintf(buf, n, "%us ago", age); - else if (age < 3600) snprintf(buf, n, "%um ago", age / 60); - else if (age < 86400) snprintf(buf, n, "%uh ago", age / 3600); - else snprintf(buf, n, ">1d ago"); + char s[8]; + geo::fmtAgeShort(s, sizeof(s), rtc_clock.getCurrentTime(), lastmod); + if (!s[0]) { snprintf(buf, n, "unknown"); return; } + snprintf(buf, n, "%s ago", s); } static const char* typeName(uint8_t t) { @@ -593,7 +587,7 @@ public: _sort_label[0] = '\0'; } - void enter() { + void onShow() override { _sel = _scroll = 0; _detail = false; _nav = false; @@ -692,7 +686,7 @@ public: drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { const Entry& e = _entries[idx]; - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); char filt[32]; int tx = 2; @@ -715,20 +709,13 @@ public: if (_source == SRC_SCAN) { snprintf(right, sizeof(right), "%d", (int)e.rssi); } else if (_sort == SORT_TIME) { - uint32_t now = rtc_clock.getCurrentTime(); - if (e.lastmod == 0 || now < e.lastmod) snprintf(right, sizeof(right), "?"); - else { - uint32_t age = now - e.lastmod; - if (age < 60) snprintf(right, sizeof(right), "%us", age); - else if (age < 3600) snprintf(right, sizeof(right), "%um", age / 60); - else snprintf(right, sizeof(right), "%uh", age / 3600); - } + geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod); + if (!right[0]) snprintf(right, sizeof(right), "?"); // unknown / RTC not synced } else { if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial()); else strncpy(right, "?GPS", sizeof(right)); } - display.setCursor(display.width() - display.getTextWidth(right) - 2 - reserve, y); - display.print(right); + display.drawTextRightAlign(display.width() - reserve - 2, y, right); }); } @@ -791,8 +778,8 @@ public: _detail_refresh_ms = millis(); return true; } - if (c == KEY_LEFT) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; } - if (c == KEY_RIGHT) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; } + if (keyIsPrev(c)) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; } + if (keyIsNext(c)) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; } return false; } }; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 5bfcd323..08911c86 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -20,12 +20,12 @@ class QuickMsgScreen : public UIScreen { uint16_t _sorted[MAX_CONTACTS]; ContactInfo _sel_contact; bool _room_mode; // true = picking a room server, false = picking a DM contact + bool _login_mode; // true while KEYBOARD is collecting a room-login password // CHANNEL_PICK int _channel_sel, _channel_scroll; int _num_channels; uint8_t _channel_indices[MAX_GROUP_CHANNELS]; - uint8_t _ch_unread[MAX_GROUP_CHANNELS]; int _sel_channel_idx; bool _sending_to_channel; @@ -40,17 +40,12 @@ class QuickMsgScreen : public UIScreen { int _active_msgs[QUICK_MSGS_MAX]; int _active_msg_count; - // CHANNEL_HIST + // CHANNEL_HIST — selection + the unread "viewing session" bookkeeping. The + // history ring itself and the per-channel unread counters live in _history. int _hist_sel, _hist_scroll; FullscreenMsgView _fs; - int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST + int _unread_at_entry; // channel unread count when entering CHANNEL_HIST int _viewing_max_seen; // highest _hist_sel reached in current session - // Shared ring for all channels combined. addChannelMsg() decrements the - // matching _ch_unread when an entry is evicted so the badge can't claim - // unread messages that no longer exist in the ring. Each slot carries a full - // MSG_TEXT_BUF, so this ring dominates the screen's RAM footprint — kept - // modest to leave heap headroom (see DM_HIST_MAX). - static const int CH_HIST_MAX = 48; // KEYBOARD KeyboardWidget* _kb; @@ -89,48 +84,15 @@ class QuickMsgScreen : public UIScreen { // auto-share target (channel / DM) instead of composing a message. bool _pick_target = false; - // History text holds a full received message. Channel messages carry the - // sender embedded as "Name: body" in the payload, so a message can be up to - // the over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, - // otherwise long messages (and Polish text, where each accented char is two - // UTF-8 bytes) get their tail clipped. - static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; + // The message-history rings (channel + DM), their per-entry delivery state and + // per-channel unread counters live in this store (see MessageHistory.h). The + // phase machine below keeps only the view state — selection, scroll, the + // fullscreen readers — and reaches entries through _history's accessors. The + // shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file- + // scope, so they're still referred to unqualified throughout this screen. + MessageHistory _history; - // Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to - // the recipient). Channel: only a "relayed into mesh" echo from a repeater (no - // recipient ACK exists for floods), so a missing echo is NOT shown as failure. - enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL }; - - struct ChHistEntry { - uint8_t ch_idx; - char text[MSG_TEXT_BUF]; - uint32_t timestamp; - uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods) - uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed() - }; - ChHistEntry _hist[CH_HIST_MAX]; - int _hist_head, _hist_count; - - // DM_HIST - struct DmHistEntry { - uint8_t prefix[4]; - uint8_t outgoing; - char text[MSG_TEXT_BUF]; - uint32_t timestamp; - uint8_t ack_status; // AckState; meaningful only when outgoing - uint32_t ack_tag; // expected_ack CRC to match against onMsgAck() - uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive - // Sender-perspective message timestamp: the send timestamp for outgoing - // (reused verbatim on resend so the recipient treats it as a retry), or the - // sender_timestamp for incoming (used to dedup retried copies). 0 = unknown. - uint32_t msg_ts; - uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1 - uint8_t resends_left; // remaining auto-resends before the marker shows ✗ - }; - // Each slot carries a full MSG_TEXT_BUF; kept modest to bound heap use. - static const int DM_HIST_MAX = 32; - DmHistEntry _dm_hist[DM_HIST_MAX]; - int _dm_hist_head, _dm_hist_count; + // DM_HIST view state (the ring itself is in _history). int _dm_hist_sel, _dm_hist_scroll; FullscreenMsgView _dm_fs; @@ -208,15 +170,9 @@ class QuickMsgScreen : public UIScreen { return r; } - // Strip "@[nick] " reply prefix from a message body for compact list display. - static const char* skipReplyPrefix(const char* text) { - if (text[0] == '@' && text[1] == '[') { - const char* close = strchr(text + 2, ']'); - if (close && close[1] == ' ' && close[2]) text = close + 2; - } - while (*text == '\n' || *text == '\r' || *text == ' ') text++; - return text; - } + // Strip the "@[nick] " reply prefix for compact list display (body only). + // Shares the one parser with the fullscreen view — see msgReplyBody(). + static const char* skipReplyPrefix(const char* text) { return msgReplyBody(text); } // Split a DM-history entry into the name to show as the author and the body to // show beneath it. Room servers carry many guests, so incoming room posts are @@ -257,6 +213,19 @@ class QuickMsgScreen : public UIScreen { return true; } + // Build "@[nick] " into _reply_prefix for a reply to a DM/room post, raw + // (UTF-8) so it goes out over the air intact — like buildChannelReplyPrefix, + // and unlike the old per-site code which ran the name through the lossy + // display transliterator. Addresses the same author the history shows + // (dmDisplayParts): in a room every post is stored "Author: text", so the + // addressee is that author, not the room server's own name (_sel_contact.name); + // a plain 1:1 DM uses the contact name. Caller ensures the post is incoming. + void buildDmReplyPrefix(const DmHistEntry& e) { + char nick[32]; + dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, _sel_contact.name, nick, sizeof(nick)); + snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", nick); + } + void startReply(bool to_channel) { _sending_to_channel = to_channel; _reply_mode = true; @@ -336,38 +305,6 @@ class QuickMsgScreen : public UIScreen { } } - - static void fmtMsgAge(char* buf, int n, uint32_t timestamp, uint32_t now) { - if (timestamp == 0 || now < timestamp) { buf[0] = '\0'; return; } - uint32_t age = now - timestamp; - if (age < 60) snprintf(buf, n, "%us", age); - else if (age < 3600) snprintf(buf, n, "%um", age / 60); - else if (age < 86400) snprintf(buf, n, "%uh", age / 3600); - else snprintf(buf, n, ">1d"); - } - - // count history entries for a specific channel - int histCountForChannel(int ch_idx) const { - int n = 0; - for (int i = 0; i < _hist_count; i++) { - if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; - } - return n; - } - - // get ring-buffer position of j-th history entry for channel (newest first) - int histEntryForChannel(int ch_idx, int j) const { - int n = 0; - for (int i = _hist_count - 1; i >= 0; i--) { - int pos = (_hist_head + i) % CH_HIST_MAX; - if (_hist[pos].ch_idx == (uint8_t)ch_idx) { - if (n == j) return pos; - n++; - } - } - return -1; - } - // Delivery marker, drawn with the current ink colour and auto-scaled to the // font (see icons.h). Pending = a row of dots, one per send (so it grows with // each auto-resend); delivered = ✓; failed = ✗; ACK_NONE = nothing. @@ -380,62 +317,32 @@ class QuickMsgScreen : public UIScreen { } } - // ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by - // ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming). - // msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp - // for incoming); resends = remaining auto-resends for an outgoing pending DM. - void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, - uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, - uint32_t msg_ts = 0, uint8_t resends = 0) { - int pos; - if (_dm_hist_count < DM_HIST_MAX) { - pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; - _dm_hist_count++; + // Selection frame for one history message box, shared by the DM/room and + // channel history lists. Selected = solid fill; unselected = outline with a + // filled header strip. Leaves the ink DARK (for the sender row drawn next). + static void drawHistRowFrame(DisplayDriver& d, int y, int bh, int reserve, int lh, bool sel) { + d.setColor(DisplayDriver::LIGHT); + if (sel) { + d.fillRect(0, y, d.width() - reserve, bh); } else { - pos = _dm_hist_head; - _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; + d.drawRect(0, y, d.width() - reserve, bh); + d.fillRect(1, y + 1, d.width() - 2 - reserve, lh); } - memcpy(_dm_hist[pos].prefix, pub_key, 4); - _dm_hist[pos].outgoing = outgoing ? 1 : 0; - _dm_hist[pos].timestamp = rtc_clock.getCurrentTime(); - strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); - _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; - _dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE; - _dm_hist[pos].ack_tag = ack_tag; - _dm_hist[pos].ack_deadline_ms = ack_deadline_ms; - _dm_hist[pos].msg_ts = msg_ts; - _dm_hist[pos].attempt = 0; - _dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0; + d.setColor(DisplayDriver::DARK); } - // Effective status for display. A pending ACK only reads as failed once its - // deadline has passed AND no auto-resends remain — while resends_left > 0 the - // entry stays pending (tickDmResends() retries / finalises it). Safety net for - // when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL. - AckState dmEffectiveStatus(const DmHistEntry& e) const { - if (e.ack_status == ACK_PENDING && e.resends_left == 0 && - (int32_t)(millis() - e.ack_deadline_ms) >= 0) - return ACK_FAIL; - return (AckState)e.ack_status; - } - - int dmHistCountForContact(const uint8_t* prefix) const { - int n = 0; - for (int i = 0; i < _dm_hist_count; i++) - if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; - return n; - } - - int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest - int n = 0; - for (int i = _dm_hist_count - 1; i >= 0; i--) { - int pos = (_dm_hist_head + i) % DM_HIST_MAX; - if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { - if (n == j) return pos; - n++; - } - } - return -1; + // Bottom-left "[+ send]" compose button, shared by both history lists: + // bordered when idle, inverted (filled) when selected. Always reset to LIGHT + // first so it stays visible regardless of the ink the message loop left. + static void drawComposeButton(DisplayDriver& d, int cby, int lh, bool sel) { + const char* ctxt = "[+ send]"; + int ctw = d.getTextWidth(ctxt); + d.setColor(DisplayDriver::LIGHT); + if (sel) { d.fillRect(0, cby - 1, ctw + 4, lh + 2); d.setColor(DisplayDriver::DARK); } + else d.drawRect(0, cby - 1, ctw + 4, lh + 2); + d.setCursor(2, cby); + d.print(ctxt); + d.setColor(DisplayDriver::LIGHT); } void afterSend(bool ok, const char* msg) { @@ -450,21 +357,18 @@ class QuickMsgScreen : public UIScreen { int pos = addChannelMsg(_sel_channel_idx, entry); // Arm the "relayed into mesh" marker on this exact entry — MyMesh tracked // the flood it just originated and reports a heard repeater echo by seq. - if (pos >= 0) { - _hist[pos].relay_status = ACK_PENDING; - _hist[pos].relay_seq = the_mesh.lastChannelRelaySeq(); - } + if (pos >= 0) _history.armChannelRelay(pos, the_mesh.lastChannelRelaySeq()); // After inserting sent msg at index 0, the unread index range is stale. // User is active in this channel — treat as fully read. - _ch_unread[_sel_channel_idx] = 0; + _history.setChUnread(_sel_channel_idx, 0); _unread_at_entry = 0; _viewing_max_seen = 0; _task->showAlert("Sent!", 600); } else if (ok) { NodePrefs* np = _task->getNodePrefs(); uint8_t resends = np ? np->dm_resend_count : 0; - storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, - _last_ack_deadline_ms, _last_send_ts, resends); + _history.storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, + _last_ack_deadline_ms, _last_send_ts, resends); _dm_hist_sel = 0; _dm_hist_scroll = 0; _phase = DM_HIST; @@ -519,7 +423,7 @@ class QuickMsgScreen : public UIScreen { for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; if (!show_all && !(c.flags & 0x01)) continue; - counts[_num_contacts] = dmHistCountForContact(c.id.pub_key); + counts[_num_contacts] = _history.dmHistCountForContact(c.id.pub_key); _sorted[_num_contacts++] = i; } // Sort by message count descending; contacts with no messages keep original order. @@ -548,256 +452,188 @@ class QuickMsgScreen : public UIScreen { } // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on - uint8_t chNotifState(uint8_t ch_idx) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - uint64_t mask = 1ULL << ch_idx; - if (!(p->ch_notif_override & mask)) return 0; - return (p->ch_notif_muted & mask) ? 1 : 2; + // Per-contact/channel notification + melody overrides share two storage + // shapes, so the eight accessors below are thin wrappers over two primitives: + // + // • Channels — a 3-state packed into a pair of channel-index bitmasks: a + // "presence" mask (is there an override at all) + a "variant" mask. The two + // uses disagree on which state the variant bit means, so the caller passes + // the state value that corresponds to variant-set (v_set). + // • DMs — a small {prefix[4], value} table: find by 4-byte prefix, update or + // clear (clear frees the slot), else insert into the first free slot, else + // overwrite slot 0. value 0 == "no override" == empty slot. + + static uint8_t maskPairGet(uint64_t presence, uint64_t variant, uint8_t idx, + uint8_t v_set, uint8_t v_clr) { + uint64_t m = 1ULL << idx; + if (!(presence & m)) return 0; + return (variant & m) ? v_set : v_clr; + } + static void maskPairSet(uint64_t& presence, uint64_t& variant, uint8_t idx, + uint8_t state, uint8_t v_set) { + uint64_t m = 1ULL << idx; + if (state == 0) { presence &= ~m; variant &= ~m; return; } + presence |= m; + if (state == v_set) variant |= m; else variant &= ~m; } + template + static uint8_t prefTableGet(const Entry* tbl, int n, const uint8_t* pub_key, + uint8_t Entry::* val) { + for (int i = 0; i < n; i++) + if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) return tbl[i].*val; + return 0; + } + template + static void prefTableSet(Entry* tbl, int n, const uint8_t* pub_key, + uint8_t Entry::* val, uint8_t v) { + for (int i = 0; i < n; i++) + if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) { + if (v == 0) memset(&tbl[i], 0, sizeof(tbl[i])); else tbl[i].*val = v; + return; + } + if (v == 0) return; + for (int i = 0; i < n; i++) + if (tbl[i].*val == 0) { memcpy(tbl[i].prefix, pub_key, 4); tbl[i].*val = v; return; } + memcpy(tbl[0].prefix, pub_key, 4); tbl[0].*val = v; // table full — overwrite slot 0 + } + + // Channel notif: state 1 = muted (variant bit set), 2 = force-on (variant clear). + uint8_t chNotifState(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + return p ? maskPairGet(p->ch_notif_override, p->ch_notif_muted, ch_idx, 1, 2) : 0; + } void setChNotifState(uint8_t ch_idx, uint8_t state) { NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - uint64_t mask = 1ULL << ch_idx; - if (state == 0) { - p->ch_notif_override &= ~mask; - p->ch_notif_muted &= ~mask; - } else if (state == 1) { - p->ch_notif_override |= mask; - p->ch_notif_muted |= mask; - } else { - p->ch_notif_override |= mask; - p->ch_notif_muted &= ~mask; - } + if (p) maskPairSet(p->ch_notif_override, p->ch_notif_muted, ch_idx, state, 1); } uint8_t dmNotifState(const uint8_t* pub_key) const { NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) - if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) - return p->dm_notif[i].state; - return 0; + return p ? prefTableGet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, + &NodePrefs::DmNotifEntry::state) : 0; } - void setDmNotifState(const uint8_t* pub_key, uint8_t state) { NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { - if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { - if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } - else p->dm_notif[i].state = state; - return; - } - } - if (state == 0) return; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { - if (p->dm_notif[i].state == 0) { - memcpy(p->dm_notif[i].prefix, pub_key, 4); - p->dm_notif[i].state = state; - return; - } - } - // table full — overwrite slot 0 - memcpy(p->dm_notif[0].prefix, pub_key, 4); - p->dm_notif[0].state = state; + if (p) prefTableSet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, + &NodePrefs::DmNotifEntry::state, state); } + // Channel melody: slot 1 = melody 1 (variant bit clear), 2 = melody 2 (set). uint8_t chNotifMelody(uint8_t ch_idx) const { NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - uint64_t mask = 1ULL << ch_idx; - if (!(p->ch_notif_melody_set & mask)) return 0; - return (p->ch_notif_melody_2 & mask) ? 2 : 1; + return p ? maskPairGet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, 2, 1) : 0; } - void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - uint64_t mask = 1ULL << ch_idx; - if (slot == 0) { - p->ch_notif_melody_set &= ~mask; - p->ch_notif_melody_2 &= ~mask; - } else if (slot == 1) { - p->ch_notif_melody_set |= mask; - p->ch_notif_melody_2 &= ~mask; - } else { - p->ch_notif_melody_set |= mask; - p->ch_notif_melody_2 |= mask; - } + if (p) maskPairSet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, slot, 2); } uint8_t dmMelodySlot(const uint8_t* pub_key) const { NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) - if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) - return p->dm_melody[i].slot; - return 0; + return p ? prefTableGet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, + &NodePrefs::DmMelodyEntry::slot) : 0; } - void setDmMelody(const uint8_t* pub_key, uint8_t slot) { NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { - if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { - if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); - else p->dm_melody[i].slot = slot; - return; - } - } - if (slot == 0) return; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { - if (p->dm_melody[i].slot == 0) { - memcpy(p->dm_melody[i].prefix, pub_key, 4); - p->dm_melody[i].slot = slot; - return; - } - } - memcpy(p->dm_melody[0].prefix, pub_key, 4); - p->dm_melody[0].slot = slot; + if (p) prefTableSet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, + &NodePrefs::DmMelodyEntry::slot, slot); } public: QuickMsgScreen(UITask* task, KeyboardWidget* kb) : _task(task), _kb(kb), _phase(MODE_SELECT), _mode_sel(0), - _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), + _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), _login_mode(false), _channel_sel(0), _channel_scroll(0), _num_channels(0), _sel_channel_idx(0), _sending_to_channel(false), _msg_sel(0), _msg_scroll(0), _active_msg_count(0), _hist_sel(0), _hist_scroll(0), _unread_at_entry(0), _viewing_max_seen(0), - _hist_head(0), _hist_count(0), - _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), _ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false) { - memset(_ch_unread, 0, sizeof(_ch_unread)); + // The history rings + per-channel unread counters init in MessageHistory. } - // Returns the ring position the message was stored at, or -1 if rejected, so + // Public entry points (routed from MyMesh / the bot via UITask) — thin + // forwarders to the history store. addChannelMsg computes the "viewing" flag + // (a phase-machine fact the store can't see) and returns the ring position so // the outgoing path can attach a relay seq to that exact entry. - int addChannelMsg(uint8_t ch_idx, const char* text) { - // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 - // and was cast to uint8_t → 255). Storing such an entry would burn a ring - // slot for a message that no visible channel can ever surface. - if (ch_idx >= MAX_GROUP_CHANNELS) return -1; - int pos; - if (_hist_count < CH_HIST_MAX) { - pos = (_hist_head + _hist_count) % CH_HIST_MAX; - _hist_count++; - } else { - pos = _hist_head; - // Evicting the oldest entry — drop its share of the unread counter so - // the badge can't claim a message the ring no longer holds. - uint8_t evicted = _hist[pos].ch_idx; - if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) { - _ch_unread[evicted]--; - } - _hist_head = (_hist_head + 1) % CH_HIST_MAX; - } - _hist[pos].ch_idx = ch_idx; - _hist[pos].timestamp = rtc_clock.getCurrentTime(); - strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); - _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; - _hist[pos].relay_status = ACK_NONE; - _hist[pos].relay_seq = 0; - + int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) { bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); - if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; - return pos; + return _history.addChannelMsg(ch_idx, text, viewing, timestamp); } - - // Called when a repeater echo of one of our channel sends is heard. - void markChannelRelayed(uint32_t seq) { - if (seq == 0) return; - for (int i = 0; i < _hist_count; i++) { - ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX]; - if (e.relay_status == ACK_PENDING && e.relay_seq == seq) { - e.relay_status = ACK_OK; - return; - } - } - } - + void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); } void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) { - // Drop retried copies of an incoming DM: a resend reuses the sender's - // timestamp and text but carries a fresh packet hash, so the mesh dup-filter - // lets it through. Match on prefix + sender_timestamp + text to suppress it. - if (!outgoing && sender_timestamp != 0) { - for (int i = 0; i < _dm_hist_count; i++) { - const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (!e.outgoing && e.msg_ts == sender_timestamp && - memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0) - return; // duplicate retry — already in history - } - } - storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0); + _history.addDMMsg(pub_key, outgoing, text, sender_timestamp); } + void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); } - // Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv). - // Marks the matching pending outgoing DM as delivered. - void markDmDelivered(uint32_t ack_crc) { - if (ack_crc == 0) return; - for (int i = 0; i < _dm_hist_count; i++) { - DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) { - e.ack_status = ACK_OK; - return; - } - } - } + // Rooms successfully logged in to this power-on session. RAM-only — the + // server's ACL (see ClientACL) is the real permission store and survives + // reboot on its own, but the device has no way to query it, so this is just + // a local memo to skip re-prompting for a password already entered this + // session when the same room is picked again. + static const int ROOM_LOGIN_TABLE_SIZE = 8; + uint8_t _room_login_prefix[ROOM_LOGIN_TABLE_SIZE][4]; + int _room_login_head = 0, _room_login_count = 0; - // Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry). - bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const { - int total = the_mesh.getNumContacts(); - for (int i = 0; i < total; i++) { - ContactInfo c; - if (the_mesh.getContactByIdx(i, c) && memcmp(c.id.pub_key, prefix, 4) == 0) { - out = c; - return true; - } - } + bool isRoomLoggedIn(const uint8_t* pub_key) const { + for (int i = 0; i < _room_login_count; i++) + if (memcmp(_room_login_prefix[i], pub_key, 4) == 0) return true; return false; } - // Background tick (called every UI loop, regardless of the active screen) that - // drives auto-resend of on-device DMs. When a pending DM passes its deadline - // with no ACK: resend with the next attempt# (reusing the original timestamp so - // the recipient dedups) while resends remain, else mark it failed (✗). - void tickDmResends() { - uint32_t now = millis(); - for (int i = 0; i < _dm_hist_count; i++) { - DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (!e.outgoing || e.ack_status != ACK_PENDING) continue; - if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting - if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; } - ContactInfo c; - if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; } - uint32_t expected_ack = 0, est_timeout = 0; - uint8_t next_attempt = e.attempt + 1; - if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text, - expected_ack, est_timeout) > 0 && expected_ack) { - e.attempt = next_attempt; - e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC - e.ack_deadline_ms = now + est_timeout + 4000; - e.resends_left--; - } else { - e.ack_status = ACK_FAIL; // couldn't compose/send — give up - } + void markRoomLoggedIn(const uint8_t* pub_key) { + if (isRoomLoggedIn(pub_key)) return; + int pos; + if (_room_login_count < ROOM_LOGIN_TABLE_SIZE) { + pos = (_room_login_head + _room_login_count) % ROOM_LOGIN_TABLE_SIZE; + _room_login_count++; + } else { + pos = _room_login_head; + _room_login_head = (_room_login_head + 1) % ROOM_LOGIN_TABLE_SIZE; } + memcpy(_room_login_prefix[pos], pub_key, 4); } + // Password of the room-login attempt currently in flight -- set right + // before sendRoomLogin(), read back in onRoomLoginResult() so a successful + // attempt can be persisted (see MyMesh::saveRoomPassword()). + char _login_pw[16]; + + void startRoomLogin(const char* password) { + strncpy(_login_pw, password, sizeof(_login_pw) - 1); + _login_pw[sizeof(_login_pw) - 1] = 0; + bool sent = the_mesh.sendRoomLogin(_sel_contact, password); + _task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); + } + + // Result of an on-device sendRoomLogin() (MyMesh::onContactResponse(), routed + // via AbstractUITask::onRoomLoginResult()). Surfaces as a transient alert. + void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { + (void)permissions; + if (success) { + markRoomLoggedIn(pub_key); + the_mesh.saveRoomPassword(pub_key, _login_pw); + } else { + // Saved password (if any) no longer works -- forget it so the next + // ENTER on this room falls back to a manual prompt instead of + // silently retrying the same bad password forever. + the_mesh.forgetRoomPassword(pub_key); + } + _task->showAlert(success ? "Login OK" : "Login failed", 1200); + } + + // Background tick (called every UI loop, regardless of the active screen) that + // drives auto-resend of on-device DMs — forwarded to the history store. + void tickDmResends() { _history.tickDmResends(); } + int getDMUnreadTotal() const { return _task->getDMUnreadTotal(); } - int getTotalChannelUnread() const { - int total = 0; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; - return total; - } + int getTotalChannelUnread() const { return _history.getTotalChannelUnread(); } void markReadAlert(int n) { char msg[32]; @@ -805,10 +641,11 @@ public: _task->showAlert(msg, 800); } - void clearAllChannelUnread() { - memset(_ch_unread, 0, sizeof(_ch_unread)); - } + void clearAllChannelUnread() { _history.clearAllChannelUnread(); } + // Update the viewing-session unread bookkeeping (UI state) and push the + // resulting count down into the store. The ring lives in _history, but this + // "what has the user seen on screen" logic is pure phase-machine state. void updateChannelUnread() { if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. @@ -820,7 +657,7 @@ public: if (seen_to > _viewing_max_seen) _viewing_max_seen = seen_to; // Each step down from 0 sees one more message; seen count = max_seen + 1. int remaining = _unread_at_entry - (_viewing_max_seen + 1); - _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); + _history.setChUnread(_sel_channel_idx, (uint8_t)(remaining > 0 ? remaining : 0)); } void reset() { @@ -832,6 +669,7 @@ public: _sending_to_channel = false; _room_mode = false; + _login_mode = false; buildContactList(); buildChannelList(); @@ -846,30 +684,9 @@ public: _viewing_max_seen = 0; } - // Recent DM contacts, newest first, deduped. Resolves the 4-byte - // _dm_hist prefix to a 6-byte pub_key prefix by walking the contact - // list once per unique sender. Returns the number filled. + // Recent DM contacts, newest first, deduped (forwarded to the history store). int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const { - int n = 0; - for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) { - int pos = (_dm_hist_head + i) % DM_HIST_MAX; - const uint8_t* p4 = _dm_hist[pos].prefix; - // Skip if already collected. - bool dup = false; - for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; } - if (dup) continue; - // Find a real contact whose pub_key starts with this 4-byte prefix. - for (int idx = 0; ; idx++) { - ContactInfo c; - if (!the_mesh.getContactByIdx(idx, c)) break; - if (memcmp(c.id.pub_key, p4, 4) == 0) { - memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN); - n++; - break; - } - } - } - return n; + return _history.getRecentDMContacts(out, max); } // Jump straight into a contact's DM history (used by the Favourites dial). @@ -984,7 +801,7 @@ public: drawList(display, _num_contacts, _contact_sel, _contact_scroll, [&](int list_idx, int y, bool sel, int reserve) { int mesh_idx = _sorted[list_idx]; - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); ContactInfo c; if (the_mesh.getContactByIdx(mesh_idx, c)) { @@ -1017,10 +834,10 @@ public: } drawList(display, _num_channels, _channel_sel, _channel_scroll, [&](int list_idx, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); ChannelDetails ch; if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { - uint8_t unread = _ch_unread[_channel_indices[list_idx]]; + uint8_t unread = _history.chUnread(_channel_indices[list_idx]); char badge[5] = ""; int bw = 0; if (unread > 0) { @@ -1044,21 +861,21 @@ public: display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); if (_dm_fs.active && _dm_hist_sel >= 0) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - const DmHistEntry& e = _dm_hist[ring_pos]; + const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); int ret = _dm_fs.render(display, sender, body, _dm_hist_sel < dm_count - 1, _dm_hist_sel > 0); if (e.outgoing) { // delivery marker in the (inverted) header bar display.setColor(DisplayDriver::DARK); drawAckGlyph(display, 2 + display.getTextWidth(sender) + 3, 1, - dmEffectiveStatus(e), e.attempt + 1); + _history.dmEffectiveStatus(e), e.attempt + 1); display.setColor(DisplayDriver::LIGHT); } if (_ctx_menu.active) _ctx_menu.render(display); @@ -1076,7 +893,7 @@ public: display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, lh + 1, display.width(), display.sepH()); - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); uint32_t now_ts = rtc_clock.getCurrentTime(); bool is_room = (_sel_contact.type == ADV_TYPE_ROOM); @@ -1091,8 +908,8 @@ public: HistScroll hs = computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll, hist_start_y, cby, lh, [&](int idx) -> const char* { - int rp = dmHistEntryForContact(_sel_contact.id.pub_key, idx); - return rp >= 0 ? _dm_hist[rp].text : nullptr; + int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); + return rp >= 0 ? _history.dmAtPos(rp).text : nullptr; }); int reserve = hs.reserve; { @@ -1101,10 +918,10 @@ public: for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) { int bh = fixed_bh; if (portrait_expand) { - int rp = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); + int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); if (rp >= 0) { char hsb[33]; - const char* hbody = dmDisplayParts(_dm_hist[rp], is_room, filtered_name, hsb, sizeof(hsb)); + const char* hbody = dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)); display.translateUTF8ToBlocks(s_wrap_trans, hbody, sizeof(s_wrap_trans)); int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8); bh = (1 + (nl > 0 ? nl : 1)) * lh + 1; @@ -1122,31 +939,22 @@ public: int y = box_ys[i]; int bh = box_hs[i]; - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, item); if (ring_pos < 0) continue; - const DmHistEntry& e = _dm_hist[ring_pos]; + const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; - char age[6]; fmtMsgAge(age, sizeof(age), e.timestamp, now_ts); + char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width() - reserve, bh); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width() - reserve, bh); - display.fillRect(1, y + 1, display.width() - 2 - reserve, lh); - display.setColor(DisplayDriver::DARK); - } + drawHistRowFrame(display, y, bh, reserve, lh, sel); display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); if (e.outgoing) { // delivery marker after "Me" int gx = 3 + display.getTextWidth(sender) + 3; - drawAckGlyph(display, gx, y + 1, dmEffectiveStatus(e), e.attempt + 1); + drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1); } if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); } if (!sel) display.setColor(DisplayDriver::LIGHT); @@ -1170,26 +978,16 @@ public: drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px, hs.total_px, hs.view_px, hs.scroll_px); - bool compose_sel = (_dm_hist_sel == -1); - const char* ctxt = "[+ send]"; - int ctw = display.getTextWidth(ctxt); - int cbx = 1; - if (compose_sel) { - display.fillRect(cbx, cby - 1, ctw + 4, lh + 1); - display.setColor(DisplayDriver::DARK); - } - display.setCursor(cbx + 2, cby); - display.print(ctxt); - display.setColor(DisplayDriver::LIGHT); + drawComposeButton(display, cby, lh, _dm_hist_sel == -1); if (_ctx_menu.active) _ctx_menu.render(display); return dm_count > 0 ? 500 : 2000; } else if (_phase == CHANNEL_HIST) { if (_fs.active && _hist_sel >= 0) { - int fs_hist_count = histCountForChannel(_sel_channel_idx); - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int fs_hist_count = _history.histCountForChannel(_sel_channel_idx); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) { - const char* ftext = _hist[ring_pos].text; + const char* ftext = _history.chAtPos(ring_pos).text; const char* fsep = strstr(ftext, ": "); char fsender[33], fmsg[MSG_TEXT_BUF]; if (fsep) { @@ -1204,7 +1002,7 @@ public: _hist_sel < fs_hist_count - 1, _hist_sel > 0); // Channels: ✓ only once a repeater echo confirms relay (see list view). - if (strcmp(fsender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) { + if (strcmp(fsender, "Me") == 0 && _history.chAtPos(ring_pos).relay_status == ACK_OK) { display.setColor(DisplayDriver::DARK); drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK); display.setColor(DisplayDriver::LIGHT); @@ -1225,7 +1023,7 @@ public: display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, lh + 1, display.width(), display.sepH()); - int ch_hist_count = histCountForChannel(_sel_channel_idx); + int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); uint32_t now_ts = rtc_clock.getCurrentTime(); // Portrait e-ink (height > width): variable-height boxes that show the full @@ -1237,9 +1035,9 @@ public: HistScroll hs = computeHistScroll(display, portrait_expand, ch_hist_count, _hist_scroll, hist_start_y, cby, lh, [&](int idx) -> const char* { - int rp = histEntryForChannel(_sel_channel_idx, idx); + int rp = _history.histEntryForChannel(_sel_channel_idx, idx); if (rp < 0) return nullptr; - const char* t = _hist[rp].text; + const char* t = _history.chAtPos(rp).text; const char* s = strstr(t, ": "); return s ? s + 2 : t; }); @@ -1250,9 +1048,9 @@ public: for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) { int bh = fixed_bh; if (portrait_expand) { - int rp = histEntryForChannel(_sel_channel_idx, _hist_scroll + ii); + int rp = _history.histEntryForChannel(_sel_channel_idx, _hist_scroll + ii); if (rp >= 0) { - const char* rtext = _hist[rp].text; + const char* rtext = _history.chAtPos(rp).text; const char* rsep = strstr(rtext, ": "); const char* rbody = rsep ? rsep + 2 : rtext; display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(rbody), sizeof(s_wrap_trans)); @@ -1274,10 +1072,10 @@ public: int y = box_ys[i]; int bh = box_hs[i]; - int ring_pos = histEntryForChannel(_sel_channel_idx, item); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, item); if (ring_pos < 0) continue; - const char* text = _hist[ring_pos].text; + const char* text = _history.chAtPos(ring_pos).text; const char* sep = strstr(text, ": "); char sender[33], msg_part[MSG_TEXT_BUF]; if (sep) { @@ -1291,24 +1089,15 @@ public: msg_part[sizeof(msg_part) - 1] = '\0'; } - char age[6]; fmtMsgAge(age, sizeof(age), _hist[ring_pos].timestamp, now_ts); + char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _history.chAtPos(ring_pos).timestamp); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width() - reserve, bh); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width() - reserve, bh); - display.fillRect(1, y + 1, display.width() - 2 - reserve, lh); - display.setColor(DisplayDriver::DARK); - } + drawHistRowFrame(display, y, bh, reserve, lh, sel); display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); // Channels have no recipient ACK — only show ✓ once a repeater echo // confirms the send was relayed into the mesh; otherwise no marker // (absence is normal, not a failure). - if (strcmp(sender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) { + if (strcmp(sender, "Me") == 0 && _history.chAtPos(ring_pos).relay_status == ACK_OK) { int gx = 3 + display.getTextWidth(sender) + 3; drawAckGlyph(display, gx, y + 1, ACK_OK); } @@ -1333,22 +1122,7 @@ public: drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px, hs.total_px, hs.view_px, hs.scroll_px); - // small compose button (bottom-left, always bordered, inverted when selected) - bool compose_sel = (_hist_sel == -1); - const char* ctxt = "[+ send]"; - int ctw = display.getTextWidth(ctxt); - int cbx = 1; - if (compose_sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(cbx - 1, cby - 1, ctw + 4, lh + 2); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(cbx - 1, cby - 1, ctw + 4, lh + 2); - } - display.setCursor(cbx + 1, cby); - display.print(ctxt); - display.setColor(DisplayDriver::LIGHT); + drawComposeButton(display, cby, lh, _hist_sel == -1); if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == KEYBOARD) { @@ -1375,7 +1149,7 @@ public: int total_msg_items = 1 + _active_msg_count; drawList(display, total_msg_items, _msg_sel, _msg_scroll, [&](int idx, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); if (idx == 0) { display.setCursor(2, y); @@ -1443,10 +1217,21 @@ public: } else if (_phase == CONTACT_PICK) { // Context menu consumes all input while open if (_ctx_menu.active) { + if (_room_mode) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_contacts > 0) { + if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { + _login_mode = true; + _kb->begin("", 15); // room/repeater password: max 15 chars + _phase = KEYBOARD; + } + } + return true; + } // LEFT/RIGHT cycle Notif/Melody in-place (menu stays open). if (!_pin_picker_active && _num_contacts > 0) { - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = keyIsPrev(c); + bool right = keyIsNext(c); if (left || right) { static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; static const char* ML[] = { "global", "M1", "M2" }; @@ -1533,9 +1318,7 @@ public: // sel == 1 (Notif) and sel == 2 (Melody): already cycled via LEFT/RIGHT; ENTER just closes. } } - if (res != PopupMenu::NONE && _ctx_dirty) { - the_mesh.savePrefs(); _ctx_dirty = false; - } + if (res != PopupMenu::NONE) _task->savePrefsIfDirty(_ctx_dirty); return true; } if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } @@ -1545,6 +1328,22 @@ public: if (c == KEY_ENTER && _num_contacts > 0) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { if (_pick_target) { commitPickTargetDM(_sel_contact); return true; } + if (_room_mode && !isRoomLoggedIn(_sel_contact.id.pub_key)) { + // Posting to a room requires a login handshake first (even with a + // blank password) — go straight to the password prompt instead of + // a history view that would silently fail to send. + char saved_pw[sizeof(_login_pw)]; + if (the_mesh.getRoomPassword(_sel_contact.id.pub_key, saved_pw, sizeof(saved_pw))) { + // Logged in to this room before, on an earlier boot -- retry + // with the remembered password instead of prompting again. + startRoomLogin(saved_pw); + } else { + _login_mode = true; + _kb->begin("", 15); // room/repeater password: max 15 chars + _phase = KEYBOARD; + } + return true; + } _task->clearDMUnread(_sel_contact.id.pub_key); _dm_hist_sel = -1; _dm_hist_scroll = 0; @@ -1554,6 +1353,11 @@ public: } return true; } + if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && _room_mode) { + _ctx_menu.begin("Room options", 1); + _ctx_menu.addItem("Login..."); + return true; + } if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; ContactInfo ci; @@ -1580,8 +1384,8 @@ public: if (_ctx_menu.active) { // LEFT/RIGHT cycle Notif/Melody/Fav in-place (menu stays open). if (_num_channels > 0) { - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = keyIsPrev(c); + bool right = keyIsNext(c); if (left || right) { static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; static const char* ML[] = { "global", "M1", "M2" }; @@ -1618,15 +1422,13 @@ public: uint8_t ch_idx = _channel_indices[_channel_sel]; int sel = _ctx_menu.selectedIndex(); if (sel == 0) { - int cleared = (int)_ch_unread[ch_idx]; - _ch_unread[ch_idx] = 0; + int cleared = (int)_history.chUnread(ch_idx); + _history.setChUnread(ch_idx, 0); markReadAlert(cleared); } // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. } - if (res != PopupMenu::NONE && _ctx_dirty) { - the_mesh.savePrefs(); _ctx_dirty = false; - } + if (res != PopupMenu::NONE) _task->savePrefsIfDirty(_ctx_dirty); return true; } if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } @@ -1636,8 +1438,8 @@ public: if (c == KEY_ENTER && _num_channels > 0) { _sel_channel_idx = _channel_indices[_channel_sel]; if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; } - int hc = histCountForChannel(_sel_channel_idx); - _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; + int hc = _history.histCountForChannel(_sel_channel_idx); + _unread_at_entry = (int)_history.chUnread(_sel_channel_idx); _hist_scroll = 0; _hist_sel = hc > 0 ? 0 : -1; _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; @@ -1670,7 +1472,7 @@ public: } } else if (_phase == DM_HIST) { - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); if (_dm_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); @@ -1689,14 +1491,11 @@ public: } else if (res == FullscreenMsgView::CLOSE) { _dm_fs.active = false; } else if (res == FullscreenMsgView::REPLY) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - bool reply_ok = !_dm_hist[ring_pos].outgoing; - if (reply_ok) { - char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname)); - snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname); - } - buildFsMenu(_dm_hist[ring_pos].text, reply_ok); + bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; + if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); + buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok); } } return true; @@ -1748,20 +1547,17 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - bool reply_ok = !_dm_hist[ring_pos].outgoing; - if (reply_ok) { - char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname)); - snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname); - } - buildFsMenu(_dm_hist[ring_pos].text, reply_ok); + bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; + if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); + buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok); } return true; } } else if (_phase == CHANNEL_HIST) { - int ch_hist_count = histCountForChannel(_sel_channel_idx); + int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); if (_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); @@ -1780,9 +1576,9 @@ public: } else if (res == FullscreenMsgView::CLOSE) { _fs.active = false; } else if (res == FullscreenMsgView::REPLY) { - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) - buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text)); + buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text)); } return true; } @@ -1823,14 +1619,28 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) { - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) - buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text)); + buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text)); return true; } } else if (_phase == KEYBOARD) { auto res = _kb->handleInput(c); + if (_login_mode) { + if (res == KeyboardWidget::CANCELLED) { + _login_mode = false; + _phase = CONTACT_PICK; + } else if (res == KeyboardWidget::DONE) { + // Blank password is valid (guest/no-password rooms) — unlike normal + // message text, an empty submit here is a deliberate "log in with no + // password" attempt, so it isn't suppressed like an empty message is. + _login_mode = false; + startRoomLogin(_kb->buf); + _phase = CONTACT_PICK; + } + return true; + } if (res == KeyboardWidget::CANCELLED) { if (_share_mode) { _share_mode = false; _task->gotoHomeScreen(); } else { _phase = MSG_PICK; } diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index 3e546634..5b4682bb 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -128,7 +128,7 @@ class RepeaterScreen : public UIScreen { public: RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {} - void enter() { + void onShow() override { _dirty = false; _sel = 0; _scroll = 0; _picker.menu.active = false; _editor.freq.active = false; _picker.saving = false; _picker.deleting = false; @@ -143,39 +143,21 @@ public: display.setColor(DisplayDriver::LIGHT); display.drawCenteredHeader("REPEATER"); - const int item_h = display.lineStep(); - const int start_y = display.listStart(); - int visible = display.listVisible(item_h); - if (visible < 1) visible = 1; - // Config only — live forwarding stats live on Tools › Diagnostics. - int total = _item_count; - if (_sel < _scroll) _scroll = _sel; - if (_sel >= _scroll + visible) _scroll = _sel - visible + 1; - int max_scroll = total - visible; - if (max_scroll < 0) max_scroll = 0; - if (_scroll > max_scroll) _scroll = max_scroll; - if (_scroll < 0) _scroll = 0; - - const int reserve = scrollIndicatorReserve(display, total, visible); - for (int i = 0; i < visible && (_scroll + i) < total; i++) { - int row = _scroll + i; - int y = start_y + i * item_h; - char val[16]; - bool sel = (row == _sel); + drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) { int item = _items[row]; - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(itemLabel(item)); if (item == IT_RFREQ && sel && _editor.active()) { _editor.render(display, display.valCol(), y); } else { + char val[16]; itemValue(item, p, val, sizeof(val)); display.drawTextRightAlign(display.width() - reserve - 2, y, val); } display.setColor(DisplayDriver::LIGHT); - } - drawScrollIndicator(display, start_y, visible * item_h, total, visible, _scroll); + }); display.setColor(DisplayDriver::LIGHT); if (_picker.menu.active) _picker.menu.render(display); return (_picker.menu.active || _editor.active()) ? 50 : 500; @@ -229,7 +211,7 @@ public: } if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 8b9de323..37f71043 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -57,7 +57,9 @@ class RingtoneEditorScreen : public UIScreen { public: RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _slot(0) {} - void enter(int slot = 0) { + // Load a melody slot for editing. Called by gotoRingtoneEditor() right after + // setCurrScreen() (whose onShow() can't carry the slot argument). + void selectSlot(int slot = 0) { _slot = (slot == 1) ? 1 : 0; bool s2 = (_slot == 1); _bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5) @@ -164,8 +166,8 @@ public: bool handleInput(char c) override { bool up = (c == KEY_UP); bool down = (c == KEY_DOWN); - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = keyIsPrev(c); + bool right = keyIsNext(c); bool enter = (c == KEY_ENTER); bool menu_key = (c == KEY_CONTEXT_MENU); bool cancel = (c == KEY_CANCEL); @@ -197,7 +199,7 @@ public: break; case MI_SWITCH: _task->stopMelody(); - this->enter(1 - _slot); + this->selectSlot(1 - _slot); break; case MI_DURATION: break; // already handled by LEFT/RIGHT case MI_BPM: break; // already handled by LEFT/RIGHT diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 09fbc3b8..b8322acf 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -422,7 +422,7 @@ class SettingsScreen : public UIScreen { void renderItem(DisplayDriver& display, int item, int y, bool sel) { NodePrefs* p = _task->getNodePrefs(); - display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, _reserve); display.setCursor(2, y); @@ -651,7 +651,7 @@ public: } - void markClean() { + void onShow() override { _dirty = false; resetList(); _editor.freq.active = false; @@ -671,7 +671,7 @@ public: [&](int sec, int y, bool sel, int reserve, bool collapsed) { _reserve = reserve; display.setColor(DisplayDriver::LIGHT); - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(collapsed ? "+" : "-"); display.print(" "); @@ -754,7 +754,7 @@ public: } if (c == KEY_CANCEL) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoHomeScreen(); return true; } diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index a066d3b4..9f15a27a 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -58,7 +58,7 @@ public: ToolsScreen(UITask* task) : _task(task) {} // Open folded at the section list each time Tools is entered from Home. - void enter() { + void onShow() override { static uint8_t sizes[SECTION_COUNT]; for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count; _acc.begin(sizes, SECTION_COUNT); @@ -75,7 +75,7 @@ public: _acc.render(display, // Section header: "[+/-] Name" [&](int sec, int y, bool sel, int reserve, bool collapsed) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(collapsed ? "+" : "-"); const int icon_x = 2 + cw + 2; @@ -85,7 +85,7 @@ public: }, // Item: indented " Label" [&](int sec, int item, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); const int icon_x = 2 + cw + 2; // align item icons under the header icon // drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon); // icons disabled for now, don't fit visually display.setCursor(icon_x + g, y); diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index d2ca5b27..3b967e41 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -72,14 +72,14 @@ class TrailScreen : public UIScreen { // (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so // the labels live in member buffers that get refreshed in openActionMenu() // and after every LEFT/RIGHT cycle. - enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS, - ACT_SHARE_NOW }; + enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_MARK_AVG, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS, + ACT_SHARE_NOW, ACT_TRACKBACK }; // The action popup is multi-level: a short main menu, plus "Trail file…" and // "Settings…" submenus. _menu_level tracks which is open so input is routed // correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter). // Live-share *config* lives in its own tool (Tools › Live Share); the map only // keeps the one-shot "Share my pos" action. - enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS }; + enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS, ML_CONFIRM_GPS }; PopupMenu _action_menu; uint8_t _menu_level = ML_MAIN; uint8_t _act_map[16]; // max rows on any one level; pushAction guards the cap @@ -88,6 +88,7 @@ class TrailScreen : public UIScreen { char _act_units_label[24]; char _act_grid_label[16]; char _act_autopause_label[24]; + char _act_mark_avg_label[24]; char _act_toggle_label[20]; static const int SUMMARY_ITEM_COUNT = 5; @@ -96,7 +97,7 @@ public: TrailScreen(UITask* task, TrailStore* store) : _task(task), _store(store), _wp(task, store) {} - void enter() { + void onShow() override { _view = V_SUMMARY; _summary_scroll = 0; _list_scroll = 0; @@ -107,9 +108,10 @@ public: _wp.reset(); } - // Enter straight into the Map view (used by the Home "Map" page). Remembers - // that we came from Home so KEY_CANCEL returns there, not to Tools. - void enterMap() { enter(); _view = V_MAP; _return_home = true; } + // Switch straight into the Map view (used by the Home "Map" page). Layered on + // top of onShow()'s reset by gotoMapScreen(); remembers we came from Home so + // KEY_CANCEL returns there, not to Tools. + void showMapView() { _view = V_MAP; _return_home = true; } int render(DisplayDriver& display) override { display.setTextSize(1); @@ -135,6 +137,10 @@ public: return _store->isActive() ? 1000 : 5000; } + // Forward the loop tick to the waypoint UI so GPS averaging (Mark avg) can + // sample independently of the render cadence (slow on e-ink). + void poll() override { _wp.poll(); } + bool handleInput(char c) override { // Waypoint management UI consumes all input while active. if (_wp.active()) return _wp.handleInput(c); @@ -143,8 +149,8 @@ public: if (_action_menu.active) { // LEFT/RIGHT cycles the focused value — only meaningful in the Settings // submenu; the popup stays open so the user can keep tapping. - if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) { - int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1; + if (keyIsPrev(c) || keyIsNext(c)) { + int dir = keyIsNext(c) ? 1 : -1; int idx = _action_menu.selectedIndex(); if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS) cycleSetting((ActionId)_act_map[idx], dir); @@ -152,6 +158,16 @@ public: } auto res = _action_menu.handleInput(c); if (res == PopupMenu::SELECTED) { + // GPS-off confirmation popup: rows aren't ActionIds, route by level. + if (_menu_level == ML_CONFIRM_GPS) { + if (_action_menu.selectedIndex() == 0) { // "Enable GPS & start" + _task->toggleGPS(); // off → on (persists, shows GPS alert) + _store->setActive(true); + _task->showAlert("GPS on, tracking started", 1200); + } + _menu_level = ML_MAIN; // popup already closed by handleInput() + return true; + } int sel = _action_menu.selectedIndex(); ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE; switch (act) { @@ -161,35 +177,46 @@ public: case ACT_MIN_DIST: case ACT_UNITS: case ACT_GRID: + case ACT_MARK_AVG: case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true; case ACT_SHARE_NOW: shareMyLocationNow(); break; - case ACT_TOGGLE: handleToggle(); break; + case ACT_TOGGLE: + // Starting a trail with GPS switched off logs nothing and just + // sits on "Waiting for GPS fix" forever -- prompt to enable it + // first (only on boards that actually have a toggleable GPS). + if (!_store->isActive() && _task->hasGPS() && !_task->getGPSState()) { + buildGpsConfirmMenu(); + return true; + } + handleToggle(); + break; case ACT_MARK: _wp.markHere(); break; case ACT_WAYPOINTS: _wp.openList(); break; + case ACT_TRACKBACK: _wp.startTrackBack(); break; case ACT_SAVE: handleSave(); break; case ACT_LOAD: handleLoad(); break; case ACT_RESET: handleReset(); break; case ACT_EXPORT: handleExport(); break; case ACT_EXPORT_SAVED: handleExportSaved(); break; } - if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; } + _task->savePrefsIfDirty(_cfg_dirty); } else if (res == PopupMenu::CANCELLED) { - if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; } + _task->savePrefsIfDirty(_cfg_dirty); if (_menu_level != ML_MAIN) buildMainMenu(); // Cancel backs out of a submenu } return true; } if (c == KEY_CANCEL) { - if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; } + _task->savePrefsIfDirty(_cfg_dirty); if (_return_home) _task->gotoHomeScreen(); else _task->gotoToolsScreen(); return true; } if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; } - if (c == KEY_LEFT || c == KEY_PREV) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; } - if (c == KEY_RIGHT || c == KEY_NEXT) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; } + if (keyIsPrev(c)) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; } + if (keyIsNext(c)) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; } if (_view == V_SUMMARY && c == KEY_UP) { _summary_scroll = (_summary_scroll > 0) ? _summary_scroll - 1 : _summary_max_scroll; return true; @@ -298,6 +325,8 @@ private: "Grid: %s", _map_grid ? "ON" : "OFF"); snprintf(_act_autopause_label, sizeof(_act_autopause_label), "Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0)); + snprintf(_act_mark_avg_label, sizeof(_act_mark_avg_label), + "Mark avg: %s", NodePrefs::gpsAvgLabel(p ? p->gps_avg_idx : 0)); snprintf(_act_toggle_label, sizeof(_act_toggle_label), "%s tracking", _store->isActive() ? "Stop" : "Start"); } @@ -325,11 +354,23 @@ private: // "Waypoints" opens the nav list — always available; it hosts the list, // backtrack (Trail-start row) and the "+ Add by coords" entry. pushAction(ACT_WAYPOINTS, "Waypoints..."); + // Track back retraces the recorded route to its start; needs ≥2 points. + if (_store->count() >= 2) pushAction(ACT_TRACKBACK, "Track back"); pushAction(ACT_SHARE_NOW, "Share my pos"); if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file..."); pushAction(ACT_SETTINGS, "Settings..."); } + // Confirmation shown when "Start tracking" is chosen with GPS off. Rows are + // plain (not ActionIds); handled by _menu_level in the SELECTED branch. + void buildGpsConfirmMenu() { + _menu_level = ML_CONFIRM_GPS; + _act_count = 0; + _action_menu.begin("GPS is off", 2); + _action_menu.addItem("Enable GPS & start"); + _action_menu.addItem("Cancel"); + } + // Trail-file submenu — only the operations that make sense right now. void buildFileMenu() { _menu_level = ML_FILE; @@ -352,6 +393,7 @@ private: _action_menu.begin("Settings", 4); pushAction(ACT_MIN_DIST, _act_min_dist_label); pushAction(ACT_AUTOPAUSE, _act_autopause_label); + pushAction(ACT_MARK_AVG, _act_mark_avg_label); if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label); if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label); } @@ -363,6 +405,7 @@ private: if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; } if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } + if (act == ACT_MARK_AVG && p){ cycleMarkAvg(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } return false; } @@ -405,6 +448,13 @@ private: : (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT; p->trail_autopause_idx = idx; } + void cycleMarkAvg(NodePrefs* p, int dir) { + uint8_t idx = p->gps_avg_idx; + if (idx >= NodePrefs::GPS_AVG_COUNT) idx = 0; + idx = (dir > 0) ? (idx + 1) % NodePrefs::GPS_AVG_COUNT + : (idx + NodePrefs::GPS_AVG_COUNT - 1) % NodePrefs::GPS_AVG_COUNT; + p->gps_avg_idx = idx; + } // One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages // screen, where the user picks a DM or channel recipient. (Auto live-share diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ab46426d..5e5a28b7 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -116,9 +116,21 @@ public: static const int QUICK_MSGS_MAX = 10; +// ── Screen fragments — included into THIS translation unit only ─────────────── +// These headers are not standalone: they are compiled solely as part of +// UITask.cpp, in the order below. Two consequences a new screen must respect: +// • Order matters. A `static inline` helper (drawList, msgReplyBody, geo::…) +// or a shared scratch buffer (FullscreenMsgView's s_wrap_*) is only visible +// to fragments included *after* the one that defines it. Add new screens +// after their dependencies. +// • Single-TU only. Some fragments define external-linkage symbols at file +// scope (e.g. NearbyScreen::FILTER_LABELS), so including any of them from a +// second .cpp is a duplicate-symbol link error. Keep them UITask-internal; +// anything genuinely shareable belongs in a real header (icons.h, GeoUtils.h). #include "FullscreenMsgView.h" #include "SensorPlaceholders.h" #include "SettingsScreen.h" +#include "MessageHistory.h" // RAM history rings (DM + channel) used by QuickMsgScreen #include "QuickMsgScreen.h" // ── Custom screens (separate files to ease upstream merges) ─────────────────── @@ -134,6 +146,7 @@ static const int QUICK_MSGS_MAX = 10; #include "DiagnosticsScreen.h" #include "RepeaterScreen.h" #include "ToolsScreen.h" +#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter) #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3200 @@ -462,6 +475,13 @@ class HomeScreen : public UIScreen { } #endif + // Alarm armed — a persistent status cue (like mute), always shown (no blink). + if (_node_prefs && _node_prefs->alarm_on) { + int alX = battLeftX - ind - ind_gap; + drawBoxedIcon(display, alX, ind, ind_h, ICON_ALARM); + battLeftX = alX; + } + // BT connection indicator (left of muted/battery icons) int leftmostX = battLeftX; if (_task->isSerialEnabled()) { @@ -791,6 +811,16 @@ public: snprintf(buf, sizeof(buf),"%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); display.drawTextCentered(display.width() / 2, date_y, buf); + // Alarm armed: a small bell in the top-left corner. The status bar (and + // its bell) is hidden on this page, so signal the armed alarm here. Just + // the glyph — no time text — so it stays clear of the centred clock + // digits (which can reach the corner when seconds are shown), matching + // the icon-only status-bar indicator. The exact time is in Clock Tools. + if (_node_prefs && _node_prefs->alarm_on) { + display.setColor(DisplayDriver::LIGHT); + miniIconDrawTop(display, 0, 0, ICON_ALARM); + } + int sep_y = date_y + lh + 1; int dash0 = sep_y + display.sepH() + 2; display.fillRect(0, sep_y, display.width(), display.sepH()); @@ -1372,6 +1402,10 @@ public: _shutdown_init = true; // need to wait for button to be released return true; } + if (c == KEY_ENTER && _page == HomePage::CLOCK) { + _task->gotoClockTools(); // Alarm / Timer / Stopwatch + return true; + } if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) { _task->gotoDashboardConfig(); return true; @@ -1470,6 +1504,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no compass_screen = new CompassScreen(this); diag_screen = new DiagnosticsScreen(this); repeater_screen = new RepeaterScreen(this); + clock_tools = new ClockToolsScreen(this, node_prefs); applyBrightness(); applyFont(); applyRotation(); @@ -1477,74 +1512,110 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no setCurrScreen(splash); } -void UITask::gotoSettingsScreen() { - ((SettingsScreen*)settings)->markClean(); - setCurrScreen(settings); +// onShow() is invoked by setCurrScreen(), so most navigators are just that. +void UITask::gotoSettingsScreen() { setCurrScreen(settings); } +void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); } +void UITask::gotoBotScreen() { setCurrScreen(bot_screen); } +void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); } +void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); } +void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); } +void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); } +void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); } +void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); } +void UITask::gotoClockTools() { setCurrScreen(clock_tools); } +void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } + +void UITask::wakeForAlarm() { + if (_display != NULL) _display->turnOn(); + _next_refresh = 0; // draw the alert overlay immediately } -void UITask::gotoToolsScreen() { - ((ToolsScreen*)tools_screen)->enter(); - setCurrScreen(tools_screen); +// ── Clock tools engine (alarm / countdown / ring) ─────────────────────────── +// Lives here, not in ClockToolsScreen, so it fires regardless of the current +// screen. The melody overrides mute (playMelody → buzzer.playForced); the ring +// auto-stops after CLOCK_RING_MS if no key dismisses it (see UITask::loop). +static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p"; +static const uint32_t CLOCK_RING_MS = 60000; +static const uint32_t CLOCK_ALARM_CATCHUP_SECS = 6 * 3600; // fire late up to 6 h, else reschedule + +// Next absolute wall instant matching alarm_hour:alarm_min in local time, +// strictly after now_wall (an alarm set to the current minute waits a day). +uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const { + int tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; + int64_t now_local = (int64_t)now_wall + (int64_t)tz * 3600; + time_t t = (time_t)now_local; + struct tm* ti = gmtime(&t); + int64_t sod = ti->tm_hour * 3600 + ti->tm_min * 60 + ti->tm_sec; // secs since local midnight + int64_t target = (now_local - sod) + + (int64_t)_node_prefs->alarm_hour * 3600 + (int64_t)_node_prefs->alarm_min * 60; + if (target <= now_local) target += 86400; + return (uint32_t)(target - (int64_t)tz * 3600); } +void UITask::fireClockAlert(const char* label) { + snprintf(_ring_label, sizeof(_ring_label), "%s", label); + _ringing = true; + _ring_until_ms = millis() + CLOCK_RING_MS; + wakeForAlarm(); + showAlert(label, CLOCK_RING_MS); + playMelody(CLOCK_ALARM_MELODY); +} + +void UITask::evaluateAlarm() { + if (!_node_prefs || !_node_prefs->alarm_on) return; + uint32_t now_ms = millis(); + if (now_ms - _alarm_check_ms < 500) return; // ~2 Hz is plenty for a minute alarm + _alarm_check_ms = now_ms; + uint32_t now_wall = rtc_clock.getCurrentTime(); + if (now_wall < 1000000000UL) return; // need a real time sync first + if (_alarm_next_fire == 0) _alarm_next_fire = computeAlarmNextFire(now_wall); + if (now_wall < _alarm_next_fire) return; + if (now_wall - _alarm_next_fire < CLOCK_ALARM_CATCHUP_SECS) { + char lbl[20]; + snprintf(lbl, sizeof(lbl), "Alarm %02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min); + _node_prefs->alarm_on = 0; // one-shot + bool dirty = true; savePrefsIfDirty(dirty); + _alarm_next_fire = 0; + fireClockAlert(lbl); + } else { + // Clock jumped implausibly far past the target — reschedule rather than + // ringing absurdly late. + _alarm_next_fire = computeAlarmNextFire(now_wall); + } +} + +void UITask::tickClockTools() { + uint32_t now_ms = millis(); + // Ring maintenance: repeat the melody until dismissed or the window elapses. + if (_ringing) { + if (now_ms >= _ring_until_ms) { stopMelody(); _ringing = false; clearAlert(); } + else if (!isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY); + } + // Countdown timer (millis — sync-immune). + if (_timer_running && now_ms >= _timer_deadline_ms) { + _timer_running = false; + fireClockAlert("Timer done"); + } + // Alarm (wall clock — absolute schedule for sync robustness). + evaluateAlarm(); +} + +// Ringtone takes a slot argument that onShow() can't carry — pass it after the +// reset (setCurrScreen's onShow runs first, then this layers the slot on top). void UITask::gotoRingtoneEditor(int slot) { - ((RingtoneEditorScreen*)ringtone_edit)->enter(slot); setCurrScreen(ringtone_edit); + ((RingtoneEditorScreen*)ringtone_edit)->selectSlot(slot); } -void UITask::gotoBotScreen() { - ((BotScreen*)bot_screen)->enter(); - setCurrScreen(bot_screen); -} - -void UITask::gotoNearbyScreen() { - ((NearbyScreen*)nearby_screen)->enter(); - setCurrScreen(nearby_screen); -} - -void UITask::gotoDashboardConfig() { - ((DashboardConfigScreen*)dashboard_config)->enter(); - setCurrScreen(dashboard_config); -} - -void UITask::gotoTrailScreen() { - ((TrailScreen*)trail_screen)->enter(); - setCurrScreen(trail_screen); -} - +// Map is a sub-view variant of the Trail screen: reset via onShow(), then +// switch into the map view. void UITask::gotoMapScreen() { - ((TrailScreen*)trail_screen)->enterMap(); setCurrScreen(trail_screen); + ((TrailScreen*)trail_screen)->showMapView(); } -void UITask::gotoCompassScreen() { - ((CompassScreen*)compass_screen)->enter(); - setCurrScreen(compass_screen); -} - -void UITask::gotoDiagnosticsScreen() { - setCurrScreen(diag_screen); -} - -void UITask::gotoRepeaterScreen() { - ((RepeaterScreen*)repeater_screen)->enter(); - setCurrScreen(repeater_screen); -} - -void UITask::gotoLiveShareScreen() { - ((LiveShareScreen*)live_share_screen)->enter(); - setCurrScreen(live_share_screen); -} - -void UITask::gotoLocatorScreen() { - ((LocatorScreen*)locator_screen)->enter(); - setCurrScreen(locator_screen); -} - -void UITask::gotoAutoAdvertScreen() { - ((AutoAdvertScreen*)auto_advert_screen)->enter(); - setCurrScreen(auto_advert_screen); -} +void UITask::gotoLocatorScreen() { setCurrScreen(locator_screen); } +void UITask::gotoAutoAdvertScreen() { setCurrScreen(auto_advert_screen); } // Public method to handle ping result callback void UITask::handlePingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) { @@ -1643,9 +1714,9 @@ int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max); } -void UITask::addChannelMsg(uint8_t channel_idx, const char* text) { +void UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp) { _last_notif_ch_idx = (int)channel_idx; - ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text); + ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text, timestamp); } int UITask::getChannelUnreadCount() const { @@ -1660,6 +1731,15 @@ void UITask::onChannelRelayed(uint32_t seq) { ((QuickMsgScreen*)quick_msg)->markChannelRelayed(seq); } +void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { + ((QuickMsgScreen*)quick_msg)->onRoomLoginResult(pub_key, success, permissions); + // Unlike the keypress-driven showAlert() calls elsewhere, this fires from a + // background mesh response with no keypress to schedule a redraw — without + // forcing one, the alert's short expiry can lapse before the next scheduled + // refresh ever draws it. + _next_refresh = 0; +} + void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp) { ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp); } @@ -1783,10 +1863,23 @@ void UITask::userLedHandler() { } void UITask::setCurrScreen(UIScreen* c) { + // Fail safe on a null target: a screen pointer left uninitialised (member + // declared + navigator wired, but the `new XScreen()` line forgotten in + // begin()) stays nullptr thanks to the in-class initialisers. Bail here so + // that mistake is an inert no-op instead of a null deref in render()/poll(). + if (!c) return; curr = c; + c->onShow(); // central per-visit reset hook (see UIScreen::onShow) _next_refresh = 100; } +bool UITask::savePrefsIfDirty(bool& dirty) { + if (!dirty) return false; + the_mesh.savePrefs(); + dirty = false; + return true; +} + /* hardware-agnostic pre-shutdown activity should be done here */ @@ -2015,6 +2108,14 @@ void UITask::loop() { } #endif + // A ringing alarm/timer is dismissed by ANY key, even when locked or on another + // screen — and the queued keys are swallowed so they don't also act on the view. + if (_kq_head != _kq_tail && isRinging()) { + dismissRing(); + _kq_head = _kq_tail = 0; + _next_refresh = 0; + } + if (_kq_head != _kq_tail) { if (!_locked && curr) { // Apply the whole queued burst, then redraw once — N taps captured during @@ -2048,6 +2149,10 @@ void UITask::loop() { if (curr) curr->poll(); + // Alarm + countdown run regardless of the current screen / display state, so + // they're driven here (not via the current screen's poll()). + tickClockTools(); + if (_display != NULL && _display->isOn()) { if (_locked && millis() > _lock_wake_until) { _display->turnOff(); @@ -2157,7 +2262,7 @@ void UITask::loop() { _auto_off = millis() + AUTO_OFF_MILLIS; } #endif - if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) { + if (!_locked && autoOffMillis() > 0 && millis() > _auto_off && !isRinging()) { _display->turnOff(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power @@ -2410,6 +2515,85 @@ void UITask::clearTarget() { resetLocator(); } +void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) { + if (!_node_prefs || !_node_prefs->locator_has_target || _node_prefs->locator_target_kind != 0) return; + if (_node_prefs->locator_lat_1e6 != lat_1e6 || _node_prefs->locator_lon_1e6 != lon_1e6) return; + clearTarget(); + the_mesh.savePrefs(); +} + +// CONTRACT: every NodePrefs field that keys on a contact pubkey/prefix is +// cleared here, so a removed contact can't leave a dangling reference. If you +// add such a field, add its cleanup below (and mark the field in NodePrefs.h). +// Currently covered: favourite_contacts, locator_key, loc_share_dm_prefix, +// dm_notif[], dm_melody[]. Called for both explicit removal and silent +// auto-eviction (see MyMesh CMD_REMOVE_CONTACT / onContactOverwrite). +void UITask::onContactRemoved(const uint8_t* pub_key) { + if (!_node_prefs || !pub_key) return; + bool changed = false; + + int slot = findFavouriteSlot(pub_key); + if (slot >= 0) { clearFavouriteSlot(slot); changed = true; } + + if (_node_prefs->locator_has_target && _node_prefs->locator_target_kind == 1 + && memcmp(_node_prefs->locator_key, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + clearTarget(); + changed = true; + } + // Fail closed rather than guess a new recipient: a contact target that's + // gone just turns auto-share off, it doesn't fall back to some other target. + if (_node_prefs->loc_share_target_type == 1 + && memcmp(_node_prefs->loc_share_dm_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + _node_prefs->loc_share_enabled = 0; + changed = true; + } + // Per-contact mute/melody overrides — only 16 slots shared across every + // contact, so an orphaned entry isn't just stale, it can eventually starve + // new overrides for contacts that still exist. Keyed by a 4-byte prefix + // (narrower than the 6-byte one above), so compare only that many bytes. + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (_node_prefs->dm_notif[i].state && memcmp(_node_prefs->dm_notif[i].prefix, pub_key, 4) == 0) { + memset(&_node_prefs->dm_notif[i], 0, sizeof(_node_prefs->dm_notif[i])); + changed = true; + } + } + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (_node_prefs->dm_melody[i].slot && memcmp(_node_prefs->dm_melody[i].prefix, pub_key, 4) == 0) { + memset(&_node_prefs->dm_melody[i], 0, sizeof(_node_prefs->dm_melody[i])); + changed = true; + } + } + + if (changed) the_mesh.savePrefs(); +} + +// CONTRACT: every NodePrefs field that keys on a channel index is cleared here, +// so a channel re-added at a freed slot can't inherit the old one's settings. +// If you add such a field, add its cleanup below (and mark it in NodePrefs.h). +// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*. +void UITask::onChannelRemoved(uint8_t channel_idx) { + if (!_node_prefs) return; + bool changed = false; + + if (_node_prefs->bot_channel_enabled && _node_prefs->bot_channel_idx == channel_idx) { + _node_prefs->bot_channel_enabled = 0; + changed = true; + } + // Fail closed, same policy as onContactRemoved()'s Live Share case. + if (_node_prefs->loc_share_target_type == 0 && _node_prefs->loc_share_channel_idx == channel_idx) { + _node_prefs->loc_share_enabled = 0; + changed = true; + } + uint64_t mask = 1ULL << channel_idx; + if (_node_prefs->ch_notif_melody_set & mask) { + _node_prefs->ch_notif_melody_set &= ~mask; + _node_prefs->ch_notif_melody_2 &= ~mask; + changed = true; + } + + if (changed) the_mesh.savePrefs(); +} + // Homing beeper: while armed with a target and inside the radius, emit a short // tick whose interval shrinks linearly with distance — slow at the edge, rapid // near the centre. Polls distance a few times a second; silent outside the @@ -2627,6 +2811,16 @@ bool UITask::getGPSState() { return false; } +bool UITask::hasGPS() { + if (_sensors != NULL) { + int num = _sensors->getNumSettings(); + for (int i = 0; i < num; i++) { + if (strcmp(_sensors->getSettingName(i), "gps") == 0) return true; + } + } + return false; +} + void UITask::toggleGPS() { if (_sensors != NULL) { // toggle GPS on/off diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index b2b7f131..8bf140da 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -68,23 +68,29 @@ class UITask : public AbstractUITask { unsigned long _analogue_pin_read_millis = millis(); #endif - UIScreen* splash; - UIScreen* home; - UIScreen* settings; - UIScreen* quick_msg; - UIScreen* tools_screen; - UIScreen* ringtone_edit; - UIScreen* bot_screen; - UIScreen* nearby_screen; - UIScreen* dashboard_config; - UIScreen* auto_advert_screen; - UIScreen* live_share_screen; - UIScreen* locator_screen; - UIScreen* trail_screen; - UIScreen* compass_screen; - UIScreen* diag_screen; - UIScreen* repeater_screen; - UIScreen* curr; + // Registering a new screen touches 4 sites: (1) the member below, (2) the + // `new XScreen()` in begin(), (3) the gotoXScreen() declaration further down, + // (4) its one-line definition in UITask.cpp. Sites 1/3/4 are compile-checked; + // only a forgotten (2) can slip through — the nullptr initialisers here turn + // that into an inert no-op (see UITask::setCurrScreen) rather than a crash. + UIScreen* splash = nullptr; + UIScreen* home = nullptr; + UIScreen* settings = nullptr; + UIScreen* quick_msg = nullptr; + UIScreen* tools_screen = nullptr; + UIScreen* ringtone_edit = nullptr; + UIScreen* bot_screen = nullptr; + UIScreen* nearby_screen = nullptr; + UIScreen* dashboard_config = nullptr; + UIScreen* auto_advert_screen = nullptr; + UIScreen* live_share_screen = nullptr; + UIScreen* locator_screen = nullptr; + UIScreen* trail_screen = nullptr; + UIScreen* compass_screen = nullptr; + UIScreen* diag_screen = nullptr; + UIScreen* repeater_screen = nullptr; + UIScreen* clock_tools = nullptr; + UIScreen* curr = nullptr; CayenneLPP _dash_lpp; TrailStore _trail; WaypointStore _waypoints; @@ -121,6 +127,25 @@ class UITask : public AbstractUITask { void fireLocator(bool arrived); void locatorProximityBeeper(); + // Clock tools engine — owned here (not by ClockToolsScreen) so the one-shot + // alarm and the countdown timer fire every loop regardless of the current + // screen / display state. ClockToolsScreen is pure UI over this state. The + // alarm is scheduled as an ABSOLUTE wall instant, recomputed from the stored + // time-of-day, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump the + // clock) — small corrections still fire on time, a jump over the target still + // fires (late). See evaluateAlarm(). Timer + ring are millis-based. + uint32_t _alarm_next_fire = 0; // unix; 0 = (re)compute lazily once time is valid + uint32_t _alarm_check_ms = 0; // throttle the wall-clock read to ~2 Hz + bool _timer_running = false; + uint32_t _timer_deadline_ms = 0; + bool _ringing = false; + uint32_t _ring_until_ms = 0; + char _ring_label[20] = {0}; + uint32_t computeAlarmNextFire(uint32_t now_wall) const; + void evaluateAlarm(); // alarm scheduling + fire detection + void fireClockAlert(const char* label); // wake + alert + melody + start ring + void tickClockTools(); // driven from loop(): ring + timer + alarm + // Course-over-ground ring — a heading source independent of trail recording. // Filled from the same periodic GPS poll regardless of _trail.isActive(). // Heading = bearing across the window (oldest→newest) once the cumulative @@ -218,6 +243,22 @@ public: // Unset the active target (locator_has_target = 0). Distinct from setTarget() // because there's no "kind" for nothing — clearing is its own operation. void clearTarget(); + // One-shot: if the active target is exactly this waypoint, clear it and + // persist immediately (setTargetNow()'s save-on-the-spot policy) — called + // from waypoint deletion so the Locator can't keep pointing at a spot + // that no longer exists. + void clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6); + // A contact was removed (companion app / CLI): drop any UI reference to its + // pubkey that would otherwise dangle — a pinned favourite slot, the Locator + // target if it was this contact, the Live Share target if it was this + // contact (auto-share turns off rather than guessing a new recipient), and + // any per-contact mute/melody override (those tables have only 16 shared + // slots, so an orphan isn't just stale — it can starve other contacts). + void onContactRemoved(const uint8_t* pub_key) override; + // A channel slot was cleared: turn off anything armed against it by index + // (the bot's channel, Live Share's channel target) and drop its per-channel + // melody bit, so a future channel re-added at the same slot starts clean. + void onChannelRemoved(uint8_t channel_idx) override; // Resolve a person target (6-byte pubkey prefix) to a current position: // prefers an active [LOC] live share, falls back to their last-advertised // GPS fix. Returns false when neither is known. Optional live/ts report @@ -235,6 +276,24 @@ public: void gotoCompassScreen(); void gotoDiagnosticsScreen(); void gotoRepeaterScreen(); + void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page) + // Wake the display for an alarm/timer ring (force an immediate refresh). + void wakeForAlarm(); + // Clear any active alert overlay early (alarm dismiss). + void clearAlert() { _alert_expiry = 0; } + // Clock tools engine API — ClockToolsScreen drives these; the engine itself + // runs in tickClockTools() from loop() so it fires regardless of the screen. + void onAlarmChanged() { _alarm_next_fire = 0; } // re-schedule after an alarm edit + void startTimer(uint32_t duration_ms) { _timer_running = true; _timer_deadline_ms = millis() + duration_ms; } + void stopTimer() { _timer_running = false; } + bool isTimerRunning() const { return _timer_running; } + uint32_t timerRemainingMs() const { + if (!_timer_running) return 0; + uint32_t now = millis(); + return (now >= _timer_deadline_ms) ? 0 : (_timer_deadline_ms - now); + } + bool isRinging() const { return _ringing; } + void dismissRing() { stopMelody(); _ringing = false; clearAlert(); } TrailStore& trail() { return _trail; } WaypointStore& waypoints() { return _waypoints; } LiveTrackStore& liveTrack() { return _livetrack; } @@ -256,10 +315,11 @@ public: void stopMelody(); bool isMelodyPlaying(); void showAlert(const char* text, int duration_millis); - void addChannelMsg(uint8_t channel_idx, const char* text) override; + void addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) override; void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) override; void onMsgAck(uint32_t ack_crc) override; void onChannelRelayed(uint32_t seq) override; + void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override; int getDMUnreadTotal() const; int getMsgCount() const { return _msgcount; } int getChannelUnreadCount() const; @@ -333,6 +393,7 @@ public: void cycleBuzzerMode(); // ON → OFF → Auto → ON int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto bool getGPSState(); + bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off) void toggleGPS(); void applyBrightness(); void setBrightnessLevel(uint8_t level); @@ -343,6 +404,11 @@ public: void applyPowerSave(); // hardware duty-cycle RX on/off from prefs void applyApc(); // Adaptive Power Control on/off from prefs void applyRadioParams(); // freq/bw/sf/cr from prefs (radio preset change) + // Save-on-exit helper for the screen `_dirty` pattern: persists NodePrefs once + // only if `dirty`, then clears the flag. Standardises the screens' exit paths + // (some used to leave the flag set, relying on onShow() to reset it) and keeps + // the "did we touch flash?" answer in one place. Returns whether it saved. + bool savePrefsIfDirty(bool& dirty); void applyFont(); void applyRotation(); void applyFullRefreshInterval(); diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index ab66f71f..9c10f976 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -20,11 +20,29 @@ class WaypointsView { TrailStore* _store; // Sub-modes layered over the trail views. OFF = the component is dormant. - enum Mode { OFF, LIST, NAV, ADD }; + enum Mode { OFF, LIST, NAV, ADD, AVG, TRACKBACK }; uint8_t _mode = OFF; int _sel = 0; int _scroll = 0; + // Track-back (Tools › Trail › Track back): retrace the recorded trail in + // reverse using NavView. _tb_idx is the current target breadcrumb; it walks + // down to 0 (the start) as each is reached within TB_ARRIVE_M. + static const int TB_ARRIVE_M = 20; + int _tb_idx = 0; + navview::EtaTracker _tb_eta; + + // GPS averaging (Tools › Trail › Settings › Mark avg). When enabled, markHere() + // accumulates fixes for gps_avg_idx seconds and marks the mean position — a + // steadier mark than one instantaneous fix. Sampling runs in poll() on a 1 s + // gate, independent of (slow, on e-ink) redraws. int64 sums: 30 samples × + // ~180e6 overflows int32. + long long _avg_sum_lat = 0, _avg_sum_lon = 0; + uint32_t _avg_n = 0; // fixes accumulated so far + uint32_t _avg_end_ms = 0; // millis() when the averaging window closes + uint32_t _avg_next_ms = 0; // millis() of the next sample + uint16_t _avg_total_s = 0; // configured window length (for the readout) + PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint bool _kb_active = false; int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index @@ -99,6 +117,17 @@ class WaypointsView { _kb_active = true; } + // Capture a mark position and open the keyboard for its label. Shared by the + // instant "Mark here" and the end of GPS averaging. _mode goes OFF: the + // keyboard takes over the screen, and there's no sub-view to return to once + // the label is committed (active() then tracks _kb_active alone). + void beginLabel(int32_t lat, int32_t lon) { + _mode = OFF; + _mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime(); + _kb_rename_idx = -1; + openKb("", WAYPOINT_LABEL_LEN - 1); + } + // Commit a mark-here / rename label from the keyboard. void commitLabel(const char* buf) { if (_kb_rename_idx >= 0) { @@ -144,6 +173,23 @@ class WaypointsView { } } + // GPS-averaging progress screen. Sampling itself happens in poll(); this only + // reports remaining time and the running sample count. + void renderAvg(DisplayDriver& display) { + display.setColor(DisplayDriver::LIGHT); + display.drawCenteredHeader("AVERAGING GPS"); + const int top = display.listStart(); + const int step = display.lineStep(); + int remain = (int)((int32_t)(_avg_end_ms - millis()) / 1000); + if (remain < 0) remain = 0; + char line[28]; + snprintf(line, sizeof(line), "%ds left (of %us)", remain, (unsigned)_avg_total_s); + display.setCursor(2, top); display.print(line); + snprintf(line, sizeof(line), "Samples: %u", (unsigned)_avg_n); + display.setCursor(2, top + step); display.print(line); + display.setCursor(2, top + 2 * step); display.print("Cancel to abort"); + } + void renderWpList(DisplayDriver& display) { display.setColor(DisplayDriver::LIGHT); char title[24]; @@ -153,12 +199,10 @@ class WaypointsView { int n = wpListCount(); int total = n + 1; // final row = "+ Add by coords" - const int step = display.lineStep(); - int32_t mylat, mylon; bool have = ownPos(mylat, mylon); drawList(display, total, _sel, _scroll, [&](int row, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, step - 1, sel); + drawRowSelection(display, y, sel, reserve); if (row == n) { // the synthetic "Add" row display.setCursor(2, y); display.print("+ Add by coords"); @@ -189,6 +233,20 @@ class WaypointsView { navview::draw(display, have, mylat, mylon, tlat, tlon, label, cogv, cog, useImperial()); } + // Navigate to the current track-back breadcrumb. The header doubles as the + // progress readout: "Trail start" on the last leg, else points still to go. + void renderTrackBack(DisplayDriver& display) { + if (_tb_idx < 0 || _tb_idx >= _store->count()) { _mode = OFF; return; } + const TrailPoint& t = _store->at(_tb_idx); + int32_t mylat, mylon; bool have = ownPos(mylat, mylon); + int cog; bool cogv = _task->currentCourse(cog); + char label[20]; + if (_tb_idx == 0) snprintf(label, sizeof(label), "Trail start"); + else snprintf(label, sizeof(label), "Back: %d pt", _tb_idx); + navview::draw(display, have, mylat, mylon, t.lat_1e6, t.lon_1e6, + label, cogv, cog, useImperial(), &_tb_eta); + } + // Resolve a combined-list row to a nav target. Row 0 is the trail start when // a trail exists; the rest are saved waypoints. bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) { @@ -222,9 +280,70 @@ public: int32_t lat, lon; if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; } if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; } - _mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime(); - _kb_rename_idx = -1; - openKb("", WAYPOINT_LABEL_LEN - 1); + NodePrefs* p = _task->getNodePrefs(); + uint8_t avg_idx = p ? p->gps_avg_idx : 0; + if (avg_idx == 0) { beginLabel(lat, lon); return; } // instant mark (default) + // Averaging: seed with the current fix, then sample for N more seconds. + _avg_total_s = NodePrefs::gpsAvgSecs(avg_idx); + _avg_sum_lat = lat; _avg_sum_lon = lon; _avg_n = 1; + uint32_t now = millis(); + _avg_end_ms = now + (uint32_t)_avg_total_s * 1000; + _avg_next_ms = now + 1000; + _mode = AVG; + } + + // Retrace the recorded trail back to its start. Snaps onto the route at the + // nearest recorded point, then NavView guides to each earlier breadcrumb in + // turn (poll() advances the target) until the start is reached. + void startTrackBack() { + if (_store->count() < 2) { _task->showAlert("No trail", 1000); return; } + int idx = _store->count() - 1; // default: the newest end of the trail + int32_t lat, lon; + if (ownPos(lat, lon)) { // else snap to the nearest recorded point + float best = 1e30f; + for (int i = 0; i < _store->count(); i++) { + float d = geo::haversineKm(lat, lon, _store->at(i).lat_1e6, _store->at(i).lon_1e6); + if (d < best) { best = d; idx = i; } + } + } + _tb_idx = idx; + _tb_eta.reset(); + _mode = TRACKBACK; + } + + // Driven from TrailScreen::poll() so the position-tracking sub-modes tick on + // the main loop, not on the (slow on e-ink) render cadence. + void poll() { + if (_mode == AVG) pollAvg(); + else if (_mode == TRACKBACK) pollTrackBack(); + } + + // Accumulate GPS fixes while averaging; mark the mean when the window closes. + void pollAvg() { + uint32_t now = millis(); + if ((int32_t)(now - _avg_next_ms) >= 0) { + int32_t lat, lon; + if (ownPos(lat, lon)) { _avg_sum_lat += lat; _avg_sum_lon += lon; _avg_n++; } + _avg_next_ms = now + 1000; + } + if ((int32_t)(now - _avg_end_ms) >= 0) { // window closed → mark the mean + if (_avg_n == 0) { _task->showAlert("No GPS fix", 1000); _mode = OFF; return; } + int32_t mlat = (int32_t)(_avg_sum_lat / (long long)_avg_n); + int32_t mlon = (int32_t)(_avg_sum_lon / (long long)_avg_n); + beginLabel(mlat, mlon); // opens the label keyboard + } + } + + // Advance the track-back target when the current breadcrumb is reached; once + // at the start (index 0) and within range, announce arrival and exit. + void pollTrackBack() { + int32_t lat, lon; + if (!ownPos(lat, lon)) return; + float d_m = geo::haversineKm(lat, lon, _store->at(_tb_idx).lat_1e6, + _store->at(_tb_idx).lon_1e6) * 1000.0f; + if (d_m > (float)TB_ARRIVE_M) return; + if (_tb_idx > 0) { _tb_idx--; _tb_eta.reset(); } + else { _task->showAlert("Back at start", 1500); _mode = OFF; } } // Only called while active(). @@ -233,6 +352,8 @@ public: display.setColor(DisplayDriver::LIGHT); if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen if (_mode == ADD) { renderAddForm(display); return 1000; } + if (_mode == AVG) { renderAvg(display); return display.isEink() ? 1000 : 300; } + if (_mode == TRACKBACK) { renderTrackBack(display); return 1000; } if (_mode == NAV) { renderWpNav(display); return 1000; } renderWpList(display); // LIST if (_ctx.active) _ctx.render(display); @@ -269,6 +390,8 @@ public: } } else if (sel == 1) { // Delete if (wi >= 0 && wi < _task->waypoints().count()) { + const Waypoint& w = _task->waypoints().at(wi); + _task->clearTargetIfWaypoint(w.lat_1e6, w.lon_1e6); // don't leave the Locator pointed at a deleted spot _task->waypoints().remove(wi); _task->saveWaypoints(); if (_sel >= wpListCount()) _sel = wpListCount() - 1; @@ -293,6 +416,12 @@ public: return true; } + // Averaging window — any cancel aborts the mark; otherwise just wait it out. + if (_mode == AVG) { + if (c == KEY_CANCEL) _mode = OFF; + return true; + } + // ADD form: scroll-edit lat/lon (magnitude) + hemisphere toggle + label. if (_mode == ADD) { // The digit editor, while open, consumes all input (UP/DOWN change the @@ -307,7 +436,7 @@ public: if (c == KEY_CANCEL) { _mode = OFF; return true; } if (c == KEY_UP) { _add_sel = (_add_sel > 0) ? _add_sel - 1 : 3; return true; } if (c == KEY_DOWN) { _add_sel = (_add_sel < 3) ? _add_sel + 1 : 0; return true; } - if (c == KEY_LEFT || c == KEY_RIGHT) { + if (keyIsPrev(c) || keyIsNext(c)) { if (_add_sel == 0) _add_lat_neg = !_add_lat_neg; // N <-> S else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W return true; @@ -328,6 +457,13 @@ public: return true; } + // Track-back — launched from the action menu, so Cancel returns to the + // trail views (not the waypoint list). + if (_mode == TRACKBACK) { + if (c == KEY_CANCEL) _mode = OFF; + return true; + } + // List (row 0 is the synthetic "Trail start" when a trail exists; the final // row is "+ Add by coords"). Cancel returns control to the trail views. int n = wpListCount(); diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index e3cbb315..d343be29 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -150,6 +150,14 @@ MINI_ICON(ICON_ADVERT, 6, // broadcast mast + radiating waves (auto-advert) packRow(".####."), packRow(".####.")); +MINI_ICON(ICON_ALARM, 5, // bell — an alarm is armed + packRow("..#.."), + packRow(".###."), + packRow(".###."), + packRow(".###."), + packRow("#####"), + packRow("..#..")); + MINI_ICON(ICON_TRAIL, 6, // map pin / location marker (GPS trail logging) packRow(".####."), packRow("######"), @@ -499,6 +507,16 @@ inline int drawList(DisplayDriver& d, int total, int sel, int& scroll, RenderRow return visible; } +// Canonical selection bar for a drawList() row: spans the row width minus the +// scroll-indicator `reserve`, one pixel short of the row height, anchored one +// pixel above `y` (the row's text baseline-top). Call as the first line of a +// row callback, then draw content over it. Captures the geometry every list row +// repeated by hand; rows that intentionally differ (full-width, custom height) +// still call display.drawSelectionRow() directly. +inline void drawRowSelection(DisplayDriver& d, int y, bool sel, int reserve) { + d.drawSelectionRow(0, y - 1, d.width() - reserve, d.lineStep() - 1, sel); +} + // ── Big ASCII-art icons (skeleton, not yet used) ───────────────────────────── // Same authoring idea as the mini-icons but for full page glyphs up to 32 px // wide: one uint32_t per row. The existing XBM bitmaps below (logo/bluetooth/…) diff --git a/release-notes.md b/release-notes.md index 467d0037..d6a43596 100644 --- a/release-notes.md +++ b/release-notes.md @@ -10,6 +10,7 @@ - **Trail auto-pause** — recording **freezes on stops** (banking elapsed time and breaking the map line across the idle gap) and **resumes on movement** without ending the session; the home-screen blink keeps going while paused. - **Collapsible Tools** — tools are grouped into fold-in-place **Location / Comms / System** sections, the same model as Settings (Tools always opens folded to the section list), and the home carousel now uses **page-indicator icons** instead of dots. - **Waypoint coordinate editor** — add a waypoint by scroll-editing its latitude/longitude digit by digit. +- **On-device room login with saved passwords** — log in to a room server straight from the device (no phone app): pick the room and the password prompt appears automatically (a blank password works for open rooms), or re-login any time via the room's **context-menu "Login…"**. The password is **remembered across reboots**, so a room you've used before logs back in without retyping; a failed login (e.g. the server's password changed) forgets the stale password so the next attempt prompts again. Room passwords entered via the **phone app** are saved on the device too, so it can post to that room standalone after a reboot. Saved passwords are written with the same atomic, crash-safe persistence as contacts and channels. ### Fixes @@ -20,6 +21,7 @@ - **Nearby Nodes** — live `[LOC]` senders now respect the type filter and sort by their shared position; the distance-sorted list refreshes so live shares bubble to the top. - **Map** — live contacts are labelled before waypoints, so a person's name shows rather than a nearby waypoint's. - **GPS status icon** is hidden when GPS is turned off in Settings, instead of sitting there empty. +- **Trail — start with GPS off** now prompts **"GPS is off — Enable GPS & start"** instead of silently starting a session that shows "Waiting for GPS fix" forever and records nothing. - Null-guarded the Locator target picker and clamped the loc-share channel index on load. ### Under the hood diff --git a/src/helpers/ui/UIScreen.h b/src/helpers/ui/UIScreen.h index bdde8e04..db2793b0 100644 --- a/src/helpers/ui/UIScreen.h +++ b/src/helpers/ui/UIScreen.h @@ -57,5 +57,10 @@ public: virtual int render(DisplayDriver& display) =0; // return value is number of millis until next render virtual bool handleInput(char c) { return false; } virtual void poll() { } + // Called by UITask::setCurrScreen() each time this screen becomes current — + // the place to reset per-visit state (selection, dirty flag, sub-views). + // Default no-op for screens that keep state across visits. Because it's + // invoked centrally, a new screen can't "forget" to be reset on show. + virtual void onShow() { } };