Merge branch 'main' into fix/eink-button-irq-edge-capture

# Conflicts:
#	examples/companion_radio/ui-new/UITask.cpp
This commit is contained in:
MarekZegare4
2026-06-30 22:13:18 +02:00
35 changed files with 2290 additions and 720 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -59,3 +59,33 @@ Sensor fields show `--` when the sensor is not connected or has no data.
<!-- screenshot pending: Dashboard Config — three field slots cycled with LEFT/RIGHT -->
**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.

View File

@@ -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.
---

View File

@@ -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 16 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

View File

@@ -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
<!-- screenshot pending: Locator screen with a target set (e.g. "@Bob (5m)"), radius/mode/beeper rows -->
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.

View File

@@ -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;
};

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -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++) {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}
};

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -1,12 +1,7 @@
#pragma once
#include <math.h>
#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;
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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: "[+/-] <icon> 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 "<icon> 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);

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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();

View File

@@ -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/…)

View File

@@ -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

View File

@@ -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() { }
};