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
+1
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 | | [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 | | [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 | | [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 ### Upstream MeshCore
+339
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.
@@ -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 --> <!-- 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**. **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.
@@ -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. 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.
--- ---
@@ -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 ### Message history
| OLED | E-Ink | | 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. 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 ### Context menu — channel list
@@ -104,9 +104,10 @@ Cycle views with **LEFT / RIGHT**:
| Item | Action | | 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) | | Mark here | Drop a waypoint at the current GPS fix (see below) |
| Waypoints… | Open the waypoint list / navigation / add-by-coords | | 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**) | | 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) | | Trail file… | Open the file submenu (below) |
| Settings… | Open the settings 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 | | 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) | | 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) | | Readout | Summary view | Summary shows Speed or Pace (in the global unit system) |
| Grid | Map view | Toggle scale grid on the map | | 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. **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 ### 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`). 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*). **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**: **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 | | 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 --> <!-- 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. Navigate with **UP/DOWN**, change a value with **LEFT/RIGHT** (or **Enter**); **Cancel/Back** saves and returns to Tools.
+16 -1
View File
@@ -47,6 +47,10 @@ public:
// A repeater rebroadcast of one of our channel sends was heard (seq from // A repeater rebroadcast of one of our channel sends was heard (seq from
// lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker. // lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker.
virtual void onChannelRelayed(uint32_t seq) { (void)seq; } 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 // True only when a BLE central is actually bonded/connected. On a dual
// (BLE+USB) interface hasConnection() is always true (USB counts), so use // (BLE+USB) interface hasConnection() is always true (USB counts), so use
// this for BLE-specific UI like the pairing-PIN prompt. // this for BLE-specific UI like the pairing-PIN prompt.
@@ -61,7 +65,7 @@ public:
virtual void msgRead(int msgcount) = 0; 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 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 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) {} 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 // 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 // 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, virtual void onSharedLocation(const uint8_t* pub_key, const char* name,
int32_t lat_1e6, int32_t lon_1e6, int32_t lat_1e6, int32_t lon_1e6,
uint32_t ts, bool verified) {} 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; virtual void loop() = 0;
}; };
+72 -19
View File
@@ -176,6 +176,10 @@ File DataStore::openWrite(const char* filename) {
return ::openWrite(_fs, 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) { bool DataStore::removeFile(const char* filename) {
return _fs->remove(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_target_kind, sizeof(_prefs.locator_target_kind));
rd(_prefs.locator_key, sizeof(_prefs.locator_key)); rd(_prefs.locator_key, sizeof(_prefs.locator_key));
if (_prefs.locator_target_kind > 1) _prefs.locator_target_kind = 0; 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 // 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 // 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 // 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_beeper, sizeof(_prefs.locator_beeper));
file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); 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.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 // 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 // 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) { void DataStore::loadChannels(DataStoreHost* host) {
File file = openRead(_getContactsChannelsFS(), "/channels2"); FILESYSTEM* fs = _getContactsChannelsFS();
File file = openRead(fs, "/channels3");
if (file) { 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; bool full = false;
uint8_t channel_idx = 0;
uint8_t skipped = 0; uint8_t skipped = 0;
while (!full) { while (!full) {
ChannelDetails ch; 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.name, 32) == 32);
success = success && (file.read((uint8_t *)ch.channel.secret, 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. // as a C string regardless of how the file was written.
ch.name[31] = '\0'; ch.name[31] = '\0';
if (host->onChannelLoaded(channel_idx, ch)) { if (!host->onChannelLoaded(hdr[0], ch)) full = true;
channel_idx++;
} else {
full = true;
}
} }
file.close(); file.close();
if (skipped > 0) { if (skipped > 0) {
MESH_DEBUG_PRINTLN("loadChannels: skipped %u corrupted/empty channel entr%s", MESH_DEBUG_PRINTLN("loadChannels: skipped %u corrupted/empty channel entr%s",
(unsigned)skipped, skipped == 1 ? "y" : "ies"); (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) { void DataStore::saveChannels(DataStoreHost* host) {
FILESYSTEM* fs = _getContactsChannelsFS(); FILESYSTEM* fs = _getContactsChannelsFS();
// Same atomic temp-then-rename pattern as saveContacts() — never truncate the // Same atomic temp-then-rename pattern as saveContacts() — never truncate the
// live /channels2 before the new copy is fully written. // live /channels3 before the new copy is fully written.
File file = ::openWrite(fs, "/channels2.tmp"); File file = ::openWrite(fs, "/channels3.tmp");
if (!file) return; if (!file) return;
bool ok = true; bool ok = true;
uint8_t channel_idx = 0; uint8_t channel_idx = 0;
ChannelDetails ch; ChannelDetails ch;
uint8_t unused[4];
memset(unused, 0, 4);
while (host->getChannelForSave(channel_idx, ch)) { while (host->getChannelForSave(channel_idx, ch)) {
channel_idx++; uint8_t idx = channel_idx++;
// getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip // getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip
// the unused ones (all-zero secret) rather than writing all 40 — otherwise // 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() // the file is always ~2.7 KB and wears the flash needlessly. Unlike the old
// already compacts empty entries on read, so the loaded result is identical. // /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; bool empty = true;
for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; } for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; }
if (empty) continue; 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.name, 32) == 32);
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32); success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
if (!success) { ok = false; break; } // write failed if (!success) { ok = false; break; } // write failed
@@ -805,9 +858,9 @@ void DataStore::saveChannels(DataStoreHost* host) {
file.close(); file.close();
if (ok) { if (ok) {
commitTempFile(fs, "/channels2.tmp", "/channels2"); commitTempFile(fs, "/channels3.tmp", "/channels3");
} else { } else {
fs->remove("/channels2.tmp"); // keep the previous good /channels2 fs->remove("/channels3.tmp"); // keep the previous good /channels3
} }
} }
+5
View File
@@ -46,6 +46,11 @@ public:
File openRead(const char* filename); File openRead(const char* filename);
File openRead(FILESYSTEM* fs, const char* filename); File openRead(FILESYSTEM* fs, const char* filename);
File openWrite(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(const char* filename);
bool removeFile(FILESYSTEM* fs, const char* filename); bool removeFile(FILESYSTEM* fs, const char* filename);
uint32_t getStorageUsedKb() const; uint32_t getStorageUsedKb() const;
+10 -10
View File
@@ -51,14 +51,9 @@ public:
} }
// Drop entries not refreshed within EXPIRY_SECS. `now` is RTC epoch seconds. // 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) { void expire(uint32_t now) {
for (int i = 0; i < CAPACITY; i++) { for (int i = 0; i < CAPACITY; i++)
if (_e[i].used && now > _e[i].ts && (now - _e[i].ts) > EXPIRY_SECS) { if (_e[i].used && expired(_e[i], now)) _e[i].used = false;
_e[i].used = false;
}
}
} }
void clear() { for (int i = 0; i < CAPACITY; i++) _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()). // Slot-wise access (caller skips inactive slots via isActive()).
const Entry& slotAt(int i) const { return _e[i]; } const Entry& slotAt(int i) const { return _e[i]; }
bool isActive(int i, uint32_t now) const { bool isActive(int i, uint32_t now) const {
const Entry& e = _e[i]; return _e[i].used && !expired(_e[i], now);
if (!e.used) return false;
return !(now > e.ts && (now - e.ts) > EXPIRY_SECS);
} }
// Number of currently non-expired entries. // Number of currently non-expired entries.
@@ -94,6 +87,13 @@ public:
private: private:
Entry _e[CAPACITY] = {}; 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. // Match an existing entry: verified shares by key, channel shares by name.
int find(const uint8_t* key, const char* name, bool verified) const { int find(const uint8_t* key, const char* name, bool verified) const {
for (int i = 0; i < CAPACITY; i++) { for (int i = 0; i < CAPACITY; i++) {
+146 -1
View File
@@ -352,6 +352,7 @@ uint8_t MyMesh::getAutoAddMaxHops() const {
void MyMesh::onContactOverwrite(const uint8_t* pub_key) { void MyMesh::onContactOverwrite(const uint8_t* pub_key) {
_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); // delete from storage _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()) { if (_serial->isConnected()) {
out_frame[0] = PUSH_CODE_CONTACT_DELETED; out_frame[0] = PUSH_CODE_CONTACT_DELETED;
memcpy(&out_frame[1], pub_key, PUB_KEY_SIZE); 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); _serial->writeFrame(frame, 1);
} }
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_ui) _ui->addChannelMsg(channel_idx, text); if (_ui) _ui->addChannelMsg(channel_idx, text, timestamp);
if (_ui) _ui->notify(UIEventType::channelMessage); if (_ui) _ui->notify(UIEventType::channelMessage);
const char *channel_name = "Unknown"; const char *channel_name = "Unknown";
ChannelDetails channel_details; ChannelDetails channel_details;
@@ -845,11 +846,13 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
pending_login = 0; pending_login = 0;
int i = 0; int i = 0;
bool login_ok = false;
if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response
out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS; out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS;
out_frame[i++] = 0; // legacy: is_admin = false out_frame[i++] = 0; // legacy: is_admin = false
memcpy(&out_frame[i], contact.id.pub_key, 6); memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix i += 6; // pub_key_prefix
login_ok = true;
} else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response } else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16; uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
if (keep_alive_secs > 0) { 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 i += 4; // NEW: include server timestamp
out_frame[i++] = data[7]; // NEW (v7): ACL permissions out_frame[i++] = data[7]; // NEW (v7): ACL permissions
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
login_ok = true;
} else { } else {
out_frame[i++] = PUSH_CODE_LOGIN_FAIL; out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
out_frame[i++] = 0; // reserved out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], contact.id.pub_key, 6); memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix 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); _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 } else if (len > 4 && // check for status response
pending_status && pending_status &&
memcmp(&pending_status, contact.id.pub_key, 4) == 0 // legacy matching scheme 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) { 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) { if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 4) {
uint32_t tag; uint32_t tag;
@@ -1537,6 +1665,11 @@ void MyMesh::startInterface(BaseSerialInterface &serial) {
serial.enable(); 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) { void MyMesh::handleCmdFrame(size_t len) {
if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection 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 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); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient && removeContact(*recipient)) { if (recipient && removeContact(*recipient)) {
_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); _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); dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
writeOKFrame(); writeOKFrame();
} else { } else {
@@ -2068,6 +2203,8 @@ void MyMesh::handleCmdFrame(size_t len) {
} else { } else {
clearPendingReqs(); clearPendingReqs();
memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse() 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[0] = RESP_CODE_SENT;
out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0;
memcpy(&out_frame[2], &pending_login, 4); 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 memcpy(channel.channel.secret, &cmd_frame[2 + 32], 32); // 256-bit key
if (setChannel(channel_idx, channel)) { if (setChannel(channel_idx, channel)) {
saveChannels(); 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(); writeOKFrame();
} else { } else {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx 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 memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // 128-bit key
if (setChannel(channel_idx, channel)) { if (setChannel(channel_idx, channel)) {
saveChannels(); saveChannels();
if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret)))
_ui->onChannelRemoved(channel_idx);
writeOKFrame(); writeOKFrame();
} else { } else {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
+23 -1
View File
@@ -208,10 +208,30 @@ private:
bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); }
void clearPendingReqs() { 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: 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 savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
void saveRTCTime() { _store->saveRTCTime(); } void saveRTCTime() { _store->saveRTCTime(); }
DataStore* getDataStore() const { return _store; } DataStore* getDataStore() const { return _store; }
@@ -299,6 +319,8 @@ private:
DataStore* _store; DataStore* _store;
NodePrefs _prefs; NodePrefs _prefs;
uint32_t pending_login; 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_status;
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
uint32_t pending_req; // pending _BINARY_REQ uint32_t pending_req; // pending _BINARY_REQ
+55 -9
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 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_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_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_trigger[64]; // DM trigger phrase (case-insensitive contains; "*" = any DM)
char bot_reply_dm[140]; // auto-reply text for DM char bot_reply_dm[140]; // auto-reply text for DM
char bot_reply_ch[140]; // auto-reply text for channel 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 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 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; 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 % 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 uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds
// Second melody slot (same packing as ringtone_*) // 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 // Advert sound filter: 0=all adverts, 1=zero-hop only
uint8_t advert_sound_scope; uint8_t advert_sound_scope;
// Per-channel melody override (2 bitmasks, 1 bit per channel) // 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) uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set)
// Per-DM melody table // Per-DM melody table
struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2 struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2
static const int DM_MELODY_TABLE_MAX = 16; 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 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 uint8_t display_rotation; // 0-3; only used on e-ink displays
// Home screen page order: each byte = HomePageBit + 1. 0 terminates the list. // 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). // Layout transposes between landscape (3×2) and portrait (2×3).
static const uint8_t FAVOURITES_COUNT = 6; static const uint8_t FAVOURITES_COUNT = 6;
static const uint8_t FAVOURITE_PREFIX_LEN = 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), // GPS trail cadence. Logging on/off is a runtime state (Tools Trail),
// not a persisted preference. // not a persisted preference.
@@ -229,8 +229,8 @@ struct NodePrefs { // persisted to file
// Configured from the Map (Trail screen) "Live share" menu. // 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_enabled; // 0=off (default), 1=auto-sharing on
uint8_t loc_share_target_type; // 0=channel, 1=DM contact 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_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) 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_move_idx; // movement gate level (index into locShareMoveMeters)
uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs) uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs)
uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs) 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), // 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"). // 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_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 + // Trail auto-pause — when tracking, automatically freeze the trail (timer +
// sampling) after the device has sat still for this long, and resume on the // 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). // discrete arrive/leave alert (locator_mode).
uint8_t locator_beeper; // 0=off (default), 1=on 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 // Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask). // UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4; 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]; 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. // Trail auto-pause delays (seconds). 0 = off.
static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4; static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4;
static uint16_t trailAutoPauseSecs(uint8_t idx) { 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 // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept). // 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. // 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 // 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 // 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 // Bounds for a usable repeater radio profile. The frequency range is passed in
// by the caller from RadioLibWrapper::getFreqBounds() — the radio chip's own // by the caller from RadioLibWrapper::getFreqBounds() — the radio chip's own
// validated range — so a profile that passes here is guaranteed to actually take // validated range — so a profile that passes here is guaranteed to actually take
@@ -20,7 +20,7 @@ class AutoAdvertScreen : public UIScreen {
public: public:
AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() { _dirty = false; } void onShow() override { _dirty = false; }
int render(DisplayDriver& display) override { int render(DisplayDriver& display) override {
display.setTextSize(1); display.setTextSize(1);
@@ -51,7 +51,7 @@ public:
bool handleInput(char c) override { bool handleInput(char c) override {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) the_mesh.savePrefs(); _task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen(); _task->gotoToolsScreen();
return true; return true;
} }
+3 -3
View File
@@ -42,7 +42,7 @@ class BotScreen : public UIScreen {
public: public:
BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {} BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {}
void enter() { void onShow() override {
_sel = 0; _sel = 0;
_scroll = 0; _scroll = 0;
_kb_field = -1; _kb_field = -1;
@@ -75,7 +75,7 @@ public:
"Trigger Ch", "Reply Ch", "Commands", "Trigger Ch", "Reply Ch", "Commands",
"Quiet from", "Quiet to" }; "Quiet from", "Quiet to" };
drawList(display, ITEM_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { 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.setCursor(2, y);
display.print(labels[i]); display.print(labels[i]);
display.setCursor(val_x, y); display.setCursor(val_x, y);
@@ -141,7 +141,7 @@ public:
} }
if (cancel) { if (cancel) {
if (_dirty) the_mesh.savePrefs(); _task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen(); _task->gotoToolsScreen();
return true; return true;
} }
@@ -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);
}
}
};
@@ -27,7 +27,7 @@ class CompassScreen : public UIScreen {
public: public:
CompassScreen(UITask* task) : _task(task) {} CompassScreen(UITask* task) : _task(task) {}
void enter() {} void onShow() override {}
int render(DisplayDriver& display) override { int render(DisplayDriver& display) override {
display.setTextSize(1); display.setTextSize(1);
@@ -37,7 +37,7 @@ class DashboardConfigScreen : public UIScreen {
public: public:
DashboardConfigScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} 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 { int render(DisplayDriver& display) override {
display.setTextSize(1); display.setTextSize(1);
@@ -65,7 +65,7 @@ public:
bool handleInput(char c) override { bool handleInput(char c) override {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) the_mesh.savePrefs(); _task->savePrefsIfDirty(_dirty);
_task->gotoHomeScreen(); _task->gotoHomeScreen();
return true; return true;
} }
@@ -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 // 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), // 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 // 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 // render-call stack at the cost of a fixed RAM allocation. Only used inside
// a fixed RAM allocation. Only used inside render()/wrap helpers — never across // render()/wrap helpers — never across a yield, so the single instance is safe.
// a yield, so the single instance is safe.
static char s_wrap_trans[512]; static char s_wrap_trans[512];
static char s_wrap_lines[12][FS_CHARS_MAX]; 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 { struct FullscreenMsgView {
int scroll; int scroll;
bool active; bool active;
@@ -80,19 +103,9 @@ struct FullscreenMsgView {
const int lineH = display.getLineHeight(); const int lineH = display.getLineHeight();
const int max_px = display.width() - 6; const int max_px = display.width() - 6;
// detect @recipient at start of message // "@[nick] " reply prefix → "To:" header + body (shared parser).
char to_nick[32] = ""; char to_nick[32];
const char* body = text; const char* body = msgReplyBody(text, to_nick, sizeof(to_nick));
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;
}
}
const int cw = display.getCharWidth(); const int cw = display.getCharWidth();
const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2); const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2);
@@ -143,8 +156,8 @@ struct FullscreenMsgView {
Result handleInput(char c) { Result handleInput(char c) {
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; } if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
if (c == KEY_DOWN) { if (scroll < _max_scroll) scroll++; return NONE; } if (c == KEY_DOWN) { if (scroll < _max_scroll) scroll++; return NONE; }
if (c == KEY_LEFT) return NEXT; if (keyIsPrev(c)) return NEXT; // page between messages (encoder too)
if (c == KEY_RIGHT) return PREV; if (keyIsNext(c)) return PREV;
if (c == KEY_CONTEXT_MENU) return REPLY; if (c == KEY_CONTEXT_MENU) return REPLY;
if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE; if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE;
return NONE; return NONE;
@@ -36,7 +36,7 @@ class LiveShareScreen : public UIScreen {
public: public:
LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} 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). // Resolve the configured target's display name (channel name or contact name).
void currentTargetName(char* buf, int n) { void currentTargetName(char* buf, int n) {
@@ -92,7 +92,7 @@ public:
const int valx = display.width() / 2 + 6; const int valx = display.width() / 2 + 6;
drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = rows(i); 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.setCursor(4, y);
display.print(r.label); display.print(r.label);
char val[24]; char val[24];
@@ -161,7 +161,7 @@ public:
bool handleInput(char c) override { bool handleInput(char c) override {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) { the_mesh.savePrefs(); _dirty = false; } _task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen(); _task->gotoToolsScreen();
return true; return true;
} }
@@ -72,7 +72,7 @@ class LocatorScreen : public UIScreen {
public: public:
LocatorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} 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) { void valueLabel(Kind k, char* buf, int n) {
switch (k) { switch (k) {
@@ -116,7 +116,7 @@ public:
const int valx = display.width() / 2 + 6; const int valx = display.width() / 2 + 6;
drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = rows(i); 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.setCursor(4, y);
display.print(r.label); display.print(r.label);
char val[24]; char val[24];
@@ -265,7 +265,7 @@ public:
display.drawCenteredHeader("PICK TARGET"); display.drawCenteredHeader("PICK TARGET");
uint32_t now = rtc_clock.getCurrentTime(); uint32_t now = rtc_clock.getCurrentTime();
drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) { 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]; const Target& t = _targets[i];
char row[36]; char row[36];
if (t.kind != 1) { if (t.kind != 1) {
@@ -293,7 +293,7 @@ public:
return true; return true;
} }
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { 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(); _task->gotoToolsScreen();
return true; return true;
} }
@@ -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;
};
+13 -26
View File
@@ -1,12 +1,7 @@
#pragma once #pragma once
#include <math.h>
#include "../GeoUtils.h" #include "../GeoUtils.h"
#include "NavView.h" #include "NavView.h"
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
// ── Nearby Nodes ────────────────────────────────────────────────────────────── // ── Nearby Nodes ──────────────────────────────────────────────────────────────
// One list / detail / action-menu interaction path over two sources: // One list / detail / action-menu interaction path over two sources:
// SRC_STORED — contacts known to the mesh (distance / bearing / last-heard) // 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(); } 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) { static void fmtAge(char* buf, int n, uint32_t lastmod) {
uint32_t now = rtc_clock.getCurrentTime(); char s[8];
if (now < lastmod || lastmod == 0) { snprintf(buf, n, "unknown"); return; } geo::fmtAgeShort(s, sizeof(s), rtc_clock.getCurrentTime(), lastmod);
uint32_t age = now - lastmod; if (!s[0]) { snprintf(buf, n, "unknown"); return; }
if (age < 60) snprintf(buf, n, "%us ago", age); snprintf(buf, n, "%s ago", s);
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");
} }
static const char* typeName(uint8_t t) { static const char* typeName(uint8_t t) {
@@ -593,7 +587,7 @@ public:
_sort_label[0] = '\0'; _sort_label[0] = '\0';
} }
void enter() { void onShow() override {
_sel = _scroll = 0; _sel = _scroll = 0;
_detail = false; _detail = false;
_nav = false; _nav = false;
@@ -692,7 +686,7 @@ public:
drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
const Entry& e = _entries[idx]; 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]; char filt[32];
int tx = 2; int tx = 2;
@@ -715,20 +709,13 @@ public:
if (_source == SRC_SCAN) { if (_source == SRC_SCAN) {
snprintf(right, sizeof(right), "%d", (int)e.rssi); snprintf(right, sizeof(right), "%d", (int)e.rssi);
} else if (_sort == SORT_TIME) { } else if (_sort == SORT_TIME) {
uint32_t now = rtc_clock.getCurrentTime(); geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod);
if (e.lastmod == 0 || now < e.lastmod) snprintf(right, sizeof(right), "?"); if (!right[0]) snprintf(right, sizeof(right), "?"); // unknown / RTC not synced
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);
}
} else { } else {
if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial()); if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial());
else strncpy(right, "?GPS", sizeof(right)); else strncpy(right, "?GPS", sizeof(right));
} }
display.setCursor(display.width() - display.getTextWidth(right) - 2 - reserve, y); display.drawTextRightAlign(display.width() - reserve - 2, y, right);
display.print(right);
}); });
} }
@@ -791,8 +778,8 @@ public:
_detail_refresh_ms = millis(); _detail_refresh_ms = millis();
return true; return true;
} }
if (c == KEY_LEFT) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; } if (keyIsPrev(c)) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; }
if (c == KEY_RIGHT) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; } if (keyIsNext(c)) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; }
return false; return false;
} }
}; };
File diff suppressed because it is too large Load Diff
@@ -128,7 +128,7 @@ class RepeaterScreen : public UIScreen {
public: public:
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {} 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; _dirty = false; _sel = 0; _scroll = 0;
_picker.menu.active = false; _editor.freq.active = false; _picker.menu.active = false; _editor.freq.active = false;
_picker.saving = false; _picker.deleting = false; _picker.saving = false; _picker.deleting = false;
@@ -143,39 +143,21 @@ public:
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("REPEATER"); 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. // Config only — live forwarding stats live on Tools Diagnostics.
int total = _item_count; drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
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);
int item = _items[row]; 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.setCursor(2, y);
display.print(itemLabel(item)); display.print(itemLabel(item));
if (item == IT_RFREQ && sel && _editor.active()) { if (item == IT_RFREQ && sel && _editor.active()) {
_editor.render(display, display.valCol(), y); _editor.render(display, display.valCol(), y);
} else { } else {
char val[16];
itemValue(item, p, val, sizeof(val)); itemValue(item, p, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val); display.drawTextRightAlign(display.width() - reserve - 2, y, val);
} }
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
} });
drawScrollIndicator(display, start_y, visible * item_h, total, visible, _scroll);
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
if (_picker.menu.active) _picker.menu.render(display); if (_picker.menu.active) _picker.menu.render(display);
return (_picker.menu.active || _editor.active()) ? 50 : 500; return (_picker.menu.active || _editor.active()) ? 50 : 500;
@@ -229,7 +211,7 @@ public:
} }
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) the_mesh.savePrefs(); _task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen(); _task->gotoToolsScreen();
return true; return true;
} }
@@ -57,7 +57,9 @@ class RingtoneEditorScreen : public UIScreen {
public: public:
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _slot(0) {} 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; _slot = (slot == 1) ? 1 : 0;
bool s2 = (_slot == 1); bool s2 = (_slot == 1);
_bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5) _bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5)
@@ -164,8 +166,8 @@ public:
bool handleInput(char c) override { bool handleInput(char c) override {
bool up = (c == KEY_UP); bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN); bool down = (c == KEY_DOWN);
bool left = (c == KEY_LEFT || c == KEY_PREV); bool left = keyIsPrev(c);
bool right = (c == KEY_RIGHT || c == KEY_NEXT); bool right = keyIsNext(c);
bool enter = (c == KEY_ENTER); bool enter = (c == KEY_ENTER);
bool menu_key = (c == KEY_CONTEXT_MENU); bool menu_key = (c == KEY_CONTEXT_MENU);
bool cancel = (c == KEY_CANCEL); bool cancel = (c == KEY_CANCEL);
@@ -197,7 +199,7 @@ public:
break; break;
case MI_SWITCH: case MI_SWITCH:
_task->stopMelody(); _task->stopMelody();
this->enter(1 - _slot); this->selectSlot(1 - _slot);
break; break;
case MI_DURATION: break; // already handled by LEFT/RIGHT case MI_DURATION: break; // already handled by LEFT/RIGHT
case MI_BPM: break; // already handled by LEFT/RIGHT case MI_BPM: break; // already handled by LEFT/RIGHT
@@ -422,7 +422,7 @@ class SettingsScreen : public UIScreen {
void renderItem(DisplayDriver& display, int item, int y, bool sel) { void renderItem(DisplayDriver& display, int item, int y, bool sel) {
NodePrefs* p = _task->getNodePrefs(); 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); display.setCursor(2, y);
@@ -651,7 +651,7 @@ public:
} }
void markClean() { void onShow() override {
_dirty = false; _dirty = false;
resetList(); resetList();
_editor.freq.active = false; _editor.freq.active = false;
@@ -671,7 +671,7 @@ public:
[&](int sec, int y, bool sel, int reserve, bool collapsed) { [&](int sec, int y, bool sel, int reserve, bool collapsed) {
_reserve = reserve; _reserve = reserve;
display.setColor(DisplayDriver::LIGHT); 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.setCursor(2, y);
display.print(collapsed ? "+" : "-"); display.print(collapsed ? "+" : "-");
display.print(" "); display.print(" ");
@@ -754,7 +754,7 @@ public:
} }
if (c == KEY_CANCEL) { if (c == KEY_CANCEL) {
if (_dirty) the_mesh.savePrefs(); _task->savePrefsIfDirty(_dirty);
_task->gotoHomeScreen(); _task->gotoHomeScreen();
return true; return true;
} }
@@ -58,7 +58,7 @@ public:
ToolsScreen(UITask* task) : _task(task) {} ToolsScreen(UITask* task) : _task(task) {}
// Open folded at the section list each time Tools is entered from Home. // Open folded at the section list each time Tools is entered from Home.
void enter() { void onShow() override {
static uint8_t sizes[SECTION_COUNT]; static uint8_t sizes[SECTION_COUNT];
for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count; for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count;
_acc.begin(sizes, SECTION_COUNT); _acc.begin(sizes, SECTION_COUNT);
@@ -75,7 +75,7 @@ public:
_acc.render(display, _acc.render(display,
// Section header: "[+/-] <icon> Name" // Section header: "[+/-] <icon> Name"
[&](int sec, int y, bool sel, int reserve, bool collapsed) { [&](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.setCursor(2, y);
display.print(collapsed ? "+" : "-"); display.print(collapsed ? "+" : "-");
const int icon_x = 2 + cw + 2; const int icon_x = 2 + cw + 2;
@@ -85,7 +85,7 @@ public:
}, },
// Item: indented "<icon> Label" // Item: indented "<icon> Label"
[&](int sec, int item, int y, bool sel, int reserve) { [&](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 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 // drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon); // icons disabled for now, don't fit visually
display.setCursor(icon_x + g, y); display.setCursor(icon_x + g, y);
+65 -15
View File
@@ -72,14 +72,14 @@ class TrailScreen : public UIScreen {
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so // (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
// the labels live in member buffers that get refreshed in openActionMenu() // the labels live in member buffers that get refreshed in openActionMenu()
// and after every LEFT/RIGHT cycle. // 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, 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_SHARE_NOW, ACT_TRACKBACK };
// The action popup is multi-level: a short main menu, plus "Trail file…" and // 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 // "Settings…" submenus. _menu_level tracks which is open so input is routed
// correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter). // 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 // Live-share *config* lives in its own tool (Tools Live Share); the map only
// keeps the one-shot "Share my pos" action. // 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; PopupMenu _action_menu;
uint8_t _menu_level = ML_MAIN; uint8_t _menu_level = ML_MAIN;
uint8_t _act_map[16]; // max rows on any one level; pushAction guards the cap 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_units_label[24];
char _act_grid_label[16]; char _act_grid_label[16];
char _act_autopause_label[24]; char _act_autopause_label[24];
char _act_mark_avg_label[24];
char _act_toggle_label[20]; char _act_toggle_label[20];
static const int SUMMARY_ITEM_COUNT = 5; static const int SUMMARY_ITEM_COUNT = 5;
@@ -96,7 +97,7 @@ public:
TrailScreen(UITask* task, TrailStore* store) TrailScreen(UITask* task, TrailStore* store)
: _task(task), _store(store), _wp(task, store) {} : _task(task), _store(store), _wp(task, store) {}
void enter() { void onShow() override {
_view = V_SUMMARY; _view = V_SUMMARY;
_summary_scroll = 0; _summary_scroll = 0;
_list_scroll = 0; _list_scroll = 0;
@@ -107,9 +108,10 @@ public:
_wp.reset(); _wp.reset();
} }
// Enter straight into the Map view (used by the Home "Map" page). Remembers // Switch straight into the Map view (used by the Home "Map" page). Layered on
// that we came from Home so KEY_CANCEL returns there, not to Tools. // top of onShow()'s reset by gotoMapScreen(); remembers we came from Home so
void enterMap() { enter(); _view = V_MAP; _return_home = true; } // KEY_CANCEL returns there, not to Tools.
void showMapView() { _view = V_MAP; _return_home = true; }
int render(DisplayDriver& display) override { int render(DisplayDriver& display) override {
display.setTextSize(1); display.setTextSize(1);
@@ -135,6 +137,10 @@ public:
return _store->isActive() ? 1000 : 5000; 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 { bool handleInput(char c) override {
// Waypoint management UI consumes all input while active. // Waypoint management UI consumes all input while active.
if (_wp.active()) return _wp.handleInput(c); if (_wp.active()) return _wp.handleInput(c);
@@ -143,8 +149,8 @@ public:
if (_action_menu.active) { if (_action_menu.active) {
// LEFT/RIGHT cycles the focused value — only meaningful in the Settings // LEFT/RIGHT cycles the focused value — only meaningful in the Settings
// submenu; the popup stays open so the user can keep tapping. // submenu; the popup stays open so the user can keep tapping.
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) { if (keyIsPrev(c) || keyIsNext(c)) {
int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1; int dir = keyIsNext(c) ? 1 : -1;
int idx = _action_menu.selectedIndex(); int idx = _action_menu.selectedIndex();
if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS) if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS)
cycleSetting((ActionId)_act_map[idx], dir); cycleSetting((ActionId)_act_map[idx], dir);
@@ -152,6 +158,16 @@ public:
} }
auto res = _action_menu.handleInput(c); auto res = _action_menu.handleInput(c);
if (res == PopupMenu::SELECTED) { 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(); int sel = _action_menu.selectedIndex();
ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE; ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE;
switch (act) { switch (act) {
@@ -161,35 +177,46 @@ public:
case ACT_MIN_DIST: case ACT_MIN_DIST:
case ACT_UNITS: case ACT_UNITS:
case ACT_GRID: case ACT_GRID:
case ACT_MARK_AVG:
case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true; case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
case ACT_SHARE_NOW: shareMyLocationNow(); break; 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_MARK: _wp.markHere(); break;
case ACT_WAYPOINTS: _wp.openList(); break; case ACT_WAYPOINTS: _wp.openList(); break;
case ACT_TRACKBACK: _wp.startTrackBack(); break;
case ACT_SAVE: handleSave(); break; case ACT_SAVE: handleSave(); break;
case ACT_LOAD: handleLoad(); break; case ACT_LOAD: handleLoad(); break;
case ACT_RESET: handleReset(); break; case ACT_RESET: handleReset(); break;
case ACT_EXPORT: handleExport(); break; case ACT_EXPORT: handleExport(); break;
case ACT_EXPORT_SAVED: handleExportSaved(); 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) { } 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 if (_menu_level != ML_MAIN) buildMainMenu(); // Cancel backs out of a submenu
} }
return true; return true;
} }
if (c == KEY_CANCEL) { if (c == KEY_CANCEL) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; } _task->savePrefsIfDirty(_cfg_dirty);
if (_return_home) _task->gotoHomeScreen(); if (_return_home) _task->gotoHomeScreen();
else _task->gotoToolsScreen(); else _task->gotoToolsScreen();
return true; return true;
} }
if (c == KEY_CONTEXT_MENU) { openActionMenu(); 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 (keyIsPrev(c)) { _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 (keyIsNext(c)) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; }
if (_view == V_SUMMARY && c == KEY_UP) { if (_view == V_SUMMARY && c == KEY_UP) {
_summary_scroll = (_summary_scroll > 0) ? _summary_scroll - 1 : _summary_max_scroll; _summary_scroll = (_summary_scroll > 0) ? _summary_scroll - 1 : _summary_max_scroll;
return true; return true;
@@ -298,6 +325,8 @@ private:
"Grid: %s", _map_grid ? "ON" : "OFF"); "Grid: %s", _map_grid ? "ON" : "OFF");
snprintf(_act_autopause_label, sizeof(_act_autopause_label), snprintf(_act_autopause_label, sizeof(_act_autopause_label),
"Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0)); "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), snprintf(_act_toggle_label, sizeof(_act_toggle_label),
"%s tracking", _store->isActive() ? "Stop" : "Start"); "%s tracking", _store->isActive() ? "Stop" : "Start");
} }
@@ -325,11 +354,23 @@ private:
// "Waypoints" opens the nav list — always available; it hosts the list, // "Waypoints" opens the nav list — always available; it hosts the list,
// backtrack (Trail-start row) and the "+ Add by coords" entry. // backtrack (Trail-start row) and the "+ Add by coords" entry.
pushAction(ACT_WAYPOINTS, "Waypoints..."); 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"); pushAction(ACT_SHARE_NOW, "Share my pos");
if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file..."); if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file...");
pushAction(ACT_SETTINGS, "Settings..."); 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. // Trail-file submenu — only the operations that make sense right now.
void buildFileMenu() { void buildFileMenu() {
_menu_level = ML_FILE; _menu_level = ML_FILE;
@@ -352,6 +393,7 @@ private:
_action_menu.begin("Settings", 4); _action_menu.begin("Settings", 4);
pushAction(ACT_MIN_DIST, _act_min_dist_label); pushAction(ACT_MIN_DIST, _act_min_dist_label);
pushAction(ACT_AUTOPAUSE, _act_autopause_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_SUMMARY) pushAction(ACT_UNITS, _act_units_label);
if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_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_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_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_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; return false;
} }
@@ -405,6 +448,13 @@ private:
: (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT; : (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT;
p->trail_autopause_idx = idx; 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 // 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 // screen, where the user picks a DM or channel recipient. (Auto live-share
+253 -59
View File
@@ -116,9 +116,21 @@ public:
static const int QUICK_MSGS_MAX = 10; 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 "FullscreenMsgView.h"
#include "SensorPlaceholders.h" #include "SensorPlaceholders.h"
#include "SettingsScreen.h" #include "SettingsScreen.h"
#include "MessageHistory.h" // RAM history rings (DM + channel) used by QuickMsgScreen
#include "QuickMsgScreen.h" #include "QuickMsgScreen.h"
// ── Custom screens (separate files to ease upstream merges) ─────────────────── // ── Custom screens (separate files to ease upstream merges) ───────────────────
@@ -134,6 +146,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "DiagnosticsScreen.h" #include "DiagnosticsScreen.h"
#include "RepeaterScreen.h" #include "RepeaterScreen.h"
#include "ToolsScreen.h" #include "ToolsScreen.h"
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page Enter)
#ifndef BATT_MIN_MILLIVOLTS #ifndef BATT_MIN_MILLIVOLTS
#define BATT_MIN_MILLIVOLTS 3200 #define BATT_MIN_MILLIVOLTS 3200
@@ -462,6 +475,13 @@ class HomeScreen : public UIScreen {
} }
#endif #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) // BT connection indicator (left of muted/battery icons)
int leftmostX = battLeftX; int leftmostX = battLeftX;
if (_task->isSerialEnabled()) { 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); 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); 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 sep_y = date_y + lh + 1;
int dash0 = sep_y + display.sepH() + 2; int dash0 = sep_y + display.sepH() + 2;
display.fillRect(0, sep_y, display.width(), display.sepH()); 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 _shutdown_init = true; // need to wait for button to be released
return true; return true;
} }
if (c == KEY_ENTER && _page == HomePage::CLOCK) {
_task->gotoClockTools(); // Alarm / Timer / Stopwatch
return true;
}
if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) { if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) {
_task->gotoDashboardConfig(); _task->gotoDashboardConfig();
return true; return true;
@@ -1470,6 +1504,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
compass_screen = new CompassScreen(this); compass_screen = new CompassScreen(this);
diag_screen = new DiagnosticsScreen(this); diag_screen = new DiagnosticsScreen(this);
repeater_screen = new RepeaterScreen(this); repeater_screen = new RepeaterScreen(this);
clock_tools = new ClockToolsScreen(this, node_prefs);
applyBrightness(); applyBrightness();
applyFont(); applyFont();
applyRotation(); applyRotation();
@@ -1477,74 +1512,110 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
setCurrScreen(splash); setCurrScreen(splash);
} }
void UITask::gotoSettingsScreen() { // onShow() is invoked by setCurrScreen(), so most navigators are just that.
((SettingsScreen*)settings)->markClean(); void UITask::gotoSettingsScreen() { setCurrScreen(settings); }
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() { // ── Clock tools engine (alarm / countdown / ring) ───────────────────────────
((ToolsScreen*)tools_screen)->enter(); // Lives here, not in ClockToolsScreen, so it fires regardless of the current
setCurrScreen(tools_screen); // 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) { void UITask::gotoRingtoneEditor(int slot) {
((RingtoneEditorScreen*)ringtone_edit)->enter(slot);
setCurrScreen(ringtone_edit); setCurrScreen(ringtone_edit);
((RingtoneEditorScreen*)ringtone_edit)->selectSlot(slot);
} }
void UITask::gotoBotScreen() { // Map is a sub-view variant of the Trail screen: reset via onShow(), then
((BotScreen*)bot_screen)->enter(); // switch into the map view.
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);
}
void UITask::gotoMapScreen() { void UITask::gotoMapScreen() {
((TrailScreen*)trail_screen)->enterMap();
setCurrScreen(trail_screen); setCurrScreen(trail_screen);
((TrailScreen*)trail_screen)->showMapView();
} }
void UITask::gotoCompassScreen() { void UITask::gotoLocatorScreen() { setCurrScreen(locator_screen); }
((CompassScreen*)compass_screen)->enter(); void UITask::gotoAutoAdvertScreen() { setCurrScreen(auto_advert_screen); }
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);
}
// Public method to handle ping result callback // 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) { 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); 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; _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 { int UITask::getChannelUnreadCount() const {
@@ -1660,6 +1731,15 @@ void UITask::onChannelRelayed(uint32_t seq) {
((QuickMsgScreen*)quick_msg)->markChannelRelayed(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) { 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); ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp);
} }
@@ -1783,10 +1863,23 @@ void UITask::userLedHandler() {
} }
void UITask::setCurrScreen(UIScreen* c) { 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; curr = c;
c->onShow(); // central per-visit reset hook (see UIScreen::onShow)
_next_refresh = 100; _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 hardware-agnostic pre-shutdown activity should be done here
*/ */
@@ -2015,6 +2108,14 @@ void UITask::loop() {
} }
#endif #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 (_kq_head != _kq_tail) {
if (!_locked && curr) { if (!_locked && curr) {
// Apply the whole queued burst, then redraw once — N taps captured during // Apply the whole queued burst, then redraw once — N taps captured during
@@ -2048,6 +2149,10 @@ void UITask::loop() {
if (curr) curr->poll(); 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 (_display != NULL && _display->isOn()) {
if (_locked && millis() > _lock_wake_until) { if (_locked && millis() > _lock_wake_until) {
_display->turnOff(); _display->turnOff();
@@ -2157,7 +2262,7 @@ void UITask::loop() {
_auto_off = millis() + AUTO_OFF_MILLIS; _auto_off = millis() + AUTO_OFF_MILLIS;
} }
#endif #endif
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) { if (!_locked && autoOffMillis() > 0 && millis() > _auto_off && !isRinging()) {
_display->turnOff(); _display->turnOff();
#ifdef PIN_LED #ifdef PIN_LED
digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power
@@ -2410,6 +2515,85 @@ void UITask::clearTarget() {
resetLocator(); 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 // 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 // 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 // near the centre. Polls distance a few times a second; silent outside the
@@ -2627,6 +2811,16 @@ bool UITask::getGPSState() {
return false; 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() { void UITask::toggleGPS() {
if (_sensors != NULL) { if (_sensors != NULL) {
// toggle GPS on/off // toggle GPS on/off
+84 -18
View File
@@ -68,23 +68,29 @@ class UITask : public AbstractUITask {
unsigned long _analogue_pin_read_millis = millis(); unsigned long _analogue_pin_read_millis = millis();
#endif #endif
UIScreen* splash; // Registering a new screen touches 4 sites: (1) the member below, (2) the
UIScreen* home; // `new XScreen()` in begin(), (3) the gotoXScreen() declaration further down,
UIScreen* settings; // (4) its one-line definition in UITask.cpp. Sites 1/3/4 are compile-checked;
UIScreen* quick_msg; // only a forgotten (2) can slip through — the nullptr initialisers here turn
UIScreen* tools_screen; // that into an inert no-op (see UITask::setCurrScreen) rather than a crash.
UIScreen* ringtone_edit; UIScreen* splash = nullptr;
UIScreen* bot_screen; UIScreen* home = nullptr;
UIScreen* nearby_screen; UIScreen* settings = nullptr;
UIScreen* dashboard_config; UIScreen* quick_msg = nullptr;
UIScreen* auto_advert_screen; UIScreen* tools_screen = nullptr;
UIScreen* live_share_screen; UIScreen* ringtone_edit = nullptr;
UIScreen* locator_screen; UIScreen* bot_screen = nullptr;
UIScreen* trail_screen; UIScreen* nearby_screen = nullptr;
UIScreen* compass_screen; UIScreen* dashboard_config = nullptr;
UIScreen* diag_screen; UIScreen* auto_advert_screen = nullptr;
UIScreen* repeater_screen; UIScreen* live_share_screen = nullptr;
UIScreen* curr; 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; CayenneLPP _dash_lpp;
TrailStore _trail; TrailStore _trail;
WaypointStore _waypoints; WaypointStore _waypoints;
@@ -121,6 +127,25 @@ class UITask : public AbstractUITask {
void fireLocator(bool arrived); void fireLocator(bool arrived);
void locatorProximityBeeper(); 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. // Course-over-ground ring — a heading source independent of trail recording.
// Filled from the same periodic GPS poll regardless of _trail.isActive(). // Filled from the same periodic GPS poll regardless of _trail.isActive().
// Heading = bearing across the window (oldest→newest) once the cumulative // 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() // Unset the active target (locator_has_target = 0). Distinct from setTarget()
// because there's no "kind" for nothing — clearing is its own operation. // because there's no "kind" for nothing — clearing is its own operation.
void clearTarget(); 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: // Resolve a person target (6-byte pubkey prefix) to a current position:
// prefers an active [LOC] live share, falls back to their last-advertised // prefers an active [LOC] live share, falls back to their last-advertised
// GPS fix. Returns false when neither is known. Optional live/ts report // GPS fix. Returns false when neither is known. Optional live/ts report
@@ -235,6 +276,24 @@ public:
void gotoCompassScreen(); void gotoCompassScreen();
void gotoDiagnosticsScreen(); void gotoDiagnosticsScreen();
void gotoRepeaterScreen(); 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; } TrailStore& trail() { return _trail; }
WaypointStore& waypoints() { return _waypoints; } WaypointStore& waypoints() { return _waypoints; }
LiveTrackStore& liveTrack() { return _livetrack; } LiveTrackStore& liveTrack() { return _livetrack; }
@@ -256,10 +315,11 @@ public:
void stopMelody(); void stopMelody();
bool isMelodyPlaying(); bool isMelodyPlaying();
void showAlert(const char* text, int duration_millis); 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 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 onMsgAck(uint32_t ack_crc) override;
void onChannelRelayed(uint32_t seq) override; void onChannelRelayed(uint32_t seq) override;
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override;
int getDMUnreadTotal() const; int getDMUnreadTotal() const;
int getMsgCount() const { return _msgcount; } int getMsgCount() const { return _msgcount; }
int getChannelUnreadCount() const; int getChannelUnreadCount() const;
@@ -333,6 +393,7 @@ public:
void cycleBuzzerMode(); // ON → OFF → Auto → ON void cycleBuzzerMode(); // ON → OFF → Auto → ON
int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto
bool getGPSState(); bool getGPSState();
bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off)
void toggleGPS(); void toggleGPS();
void applyBrightness(); void applyBrightness();
void setBrightnessLevel(uint8_t level); void setBrightnessLevel(uint8_t level);
@@ -343,6 +404,11 @@ public:
void applyPowerSave(); // hardware duty-cycle RX on/off from prefs void applyPowerSave(); // hardware duty-cycle RX on/off from prefs
void applyApc(); // Adaptive Power Control on/off from prefs void applyApc(); // Adaptive Power Control on/off from prefs
void applyRadioParams(); // freq/bw/sf/cr from prefs (radio preset change) 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 applyFont();
void applyRotation(); void applyRotation();
void applyFullRefreshInterval(); void applyFullRefreshInterval();
+144 -8
View File
@@ -20,11 +20,29 @@ class WaypointsView {
TrailStore* _store; TrailStore* _store;
// Sub-modes layered over the trail views. OFF = the component is dormant. // 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; uint8_t _mode = OFF;
int _sel = 0; int _sel = 0;
int _scroll = 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 PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint
bool _kb_active = false; bool _kb_active = false;
int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index
@@ -99,6 +117,17 @@ class WaypointsView {
_kb_active = true; _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. // Commit a mark-here / rename label from the keyboard.
void commitLabel(const char* buf) { void commitLabel(const char* buf) {
if (_kb_rename_idx >= 0) { 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) { void renderWpList(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
char title[24]; char title[24];
@@ -153,12 +199,10 @@ class WaypointsView {
int n = wpListCount(); int n = wpListCount();
int total = n + 1; // final row = "+ Add by coords" int total = n + 1; // final row = "+ Add by coords"
const int step = display.lineStep();
int32_t mylat, mylon; bool have = ownPos(mylat, mylon); int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
drawList(display, total, _sel, _scroll, [&](int row, int y, bool sel, int reserve) { 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 if (row == n) { // the synthetic "Add" row
display.setCursor(2, y); display.print("+ Add by coords"); 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()); 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 // Resolve a combined-list row to a nav target. Row 0 is the trail start when
// a trail exists; the rest are saved waypoints. // a trail exists; the rest are saved waypoints.
bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) { bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) {
@@ -222,9 +280,70 @@ public:
int32_t lat, lon; int32_t lat, lon;
if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; } if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 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(); NodePrefs* p = _task->getNodePrefs();
_kb_rename_idx = -1; uint8_t avg_idx = p ? p->gps_avg_idx : 0;
openKb("", WAYPOINT_LABEL_LEN - 1); 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(). // Only called while active().
@@ -233,6 +352,8 @@ public:
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen
if (_mode == ADD) { renderAddForm(display); return 1000; } 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; } if (_mode == NAV) { renderWpNav(display); return 1000; }
renderWpList(display); // LIST renderWpList(display); // LIST
if (_ctx.active) _ctx.render(display); if (_ctx.active) _ctx.render(display);
@@ -269,6 +390,8 @@ public:
} }
} else if (sel == 1) { // Delete } else if (sel == 1) { // Delete
if (wi >= 0 && wi < _task->waypoints().count()) { 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->waypoints().remove(wi);
_task->saveWaypoints(); _task->saveWaypoints();
if (_sel >= wpListCount()) _sel = wpListCount() - 1; if (_sel >= wpListCount()) _sel = wpListCount() - 1;
@@ -293,6 +416,12 @@ public:
return true; 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. // ADD form: scroll-edit lat/lon (magnitude) + hemisphere toggle + label.
if (_mode == ADD) { if (_mode == ADD) {
// The digit editor, while open, consumes all input (UP/DOWN change the // 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_CANCEL) { _mode = OFF; return true; }
if (c == KEY_UP) { _add_sel = (_add_sel > 0) ? _add_sel - 1 : 3; 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_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 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 else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W
return true; return true;
@@ -328,6 +457,13 @@ public:
return true; 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 // 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. // row is "+ Add by coords"). Cancel returns control to the trail views.
int n = wpListCount(); int n = wpListCount();
+18
View File
@@ -150,6 +150,14 @@ MINI_ICON(ICON_ADVERT, 6, // broadcast mast + radiating waves (auto-advert)
packRow(".####."), packRow(".####."),
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) MINI_ICON(ICON_TRAIL, 6, // map pin / location marker (GPS trail logging)
packRow(".####."), packRow(".####."),
packRow("######"), packRow("######"),
@@ -499,6 +507,16 @@ inline int drawList(DisplayDriver& d, int total, int sel, int& scroll, RenderRow
return visible; 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) ───────────────────────────── // ── Big ASCII-art icons (skeleton, not yet used) ─────────────────────────────
// Same authoring idea as the mini-icons but for full page glyphs up to 32 px // 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/…) // wide: one uint32_t per row. The existing XBM bitmaps below (logo/bluetooth/…)
+2
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. - **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. - **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. - **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 ### 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. - **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. - **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. - **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. - Null-guarded the Locator target picker and clamped the loc-share channel index on load.
### Under the hood ### Under the hood
+5
View File
@@ -57,5 +57,10 @@ public:
virtual int render(DisplayDriver& display) =0; // return value is number of millis until next render virtual int render(DisplayDriver& display) =0; // return value is number of millis until next render
virtual bool handleInput(char c) { return false; } virtual bool handleInput(char c) { return false; }
virtual void poll() { } 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() { }
}; };