mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
Merge branch 'main' into fix/eink-button-irq-edge-capture
# Conflicts: # examples/companion_radio/ui-new/UITask.cpp
This commit is contained in:
@@ -100,6 +100,7 @@ Updating to a newer version usually does not require erasing flash unless the re
|
||||
| [Settings Screen](./docs/solo_features/settings_screen/settings_screen.md) | All settings sections with values and interactions |
|
||||
| [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) | Lock/unlock sequence, lock screen, auto-lock |
|
||||
| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater |
|
||||
| [Solo UI framework](./docs/design/solo_ui_framework.md) | **Developer guide** — the reusable building blocks (screens, lists, popups, mini-icons, geo/persistence helpers) and how to add a new feature |
|
||||
|
||||
### Upstream MeshCore
|
||||
|
||||
|
||||
339
docs/design/solo_ui_framework.md
Normal file
339
docs/design/solo_ui_framework.md
Normal 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 -->
|
||||
|
||||
**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.
|
||||
|
||||
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
|
||||
|
||||
| OLED | E-Ink |
|
||||
@@ -92,6 +105,12 @@ A location is any `lat,lon` pair in the text — exactly what the `{loc}` placeh
|
||||
|
||||
When **Pin to dial** is selected, a slot picker opens (Slot 1–6 showing current occupant name or "empty"). Choosing a slot that already holds another contact moves the new contact there.
|
||||
|
||||
In the **Rooms** list the context menu instead offers a single item:
|
||||
|
||||
| Item | Action |
|
||||
| ------- | ---------------------------------------------------------------------------- |
|
||||
| Login… | Opens the password prompt to (re-)log in to this room (see Rooms — logging in) |
|
||||
|
||||
---
|
||||
|
||||
### Context menu — channel list
|
||||
|
||||
@@ -104,9 +104,10 @@ Cycle views with **LEFT / RIGHT**:
|
||||
|
||||
| Item | Action |
|
||||
| --------------------- | --------------------------------------------------- |
|
||||
| Start / Stop tracking | Begin or end a recording session |
|
||||
| Start / Stop tracking | Begin or end a recording session. If **GPS is off**, choosing Start asks **"GPS is off — Enable GPS & start"** so a session can't silently run with nothing to record |
|
||||
| Mark here | Drop a waypoint at the current GPS fix (see below) |
|
||||
| Waypoints… | Open the waypoint list / navigation / add-by-coords |
|
||||
| Track back | Retrace the recorded route back to its start (needs ≥2 points; see below) |
|
||||
| Share my pos | Send your current position as a one-shot `[LOC]` message — pick a contact or channel (see **Live Share**) |
|
||||
| Trail file… | Open the file submenu (below) |
|
||||
| Settings… | Open the settings submenu (below) |
|
||||
@@ -127,6 +128,7 @@ Cycle views with **LEFT / RIGHT**:
|
||||
| ---------- | --------- | ------------------------------------------------------- |
|
||||
| Min dist | always | Sample gate, 4 levels — metric: 5/10/25/100 m, imperial: 15/30/75/300 ft |
|
||||
| Auto-pause | always | Off / 1 / 2 / 5 min — auto-freeze the trail after a stop, resume on movement (see below) |
|
||||
| Mark avg | always | Off / 5 / 10 / 30 s — GPS averaging for **Mark here** (see Waypoints below) |
|
||||
| Readout | Summary view | Summary shows Speed or Pace (in the global unit system) |
|
||||
| Grid | Map view | Toggle scale grid on the map |
|
||||
|
||||
@@ -134,12 +136,18 @@ Cycle views with **LEFT / RIGHT**:
|
||||
|
||||
**Auto-pause** — when set, a recording trail automatically **pauses** after the device has stayed within ~15 m of one spot for the chosen delay: the elapsed timer and point sampling both freeze, and the map line breaks across the idle gap. It **resumes on its own** as soon as you move again. This keeps a stop (a break, a meal, parking) out of your distance and average-speed stats without you having to remember to stop and restart tracking. A paused trail is still "on" (the **G** marker keeps blinking) — the Summary **Status** row shows `paused`. The stop is detected with its own coarse movement gate, independent of **Min dist**, so GPS jitter while you're parked doesn't keep it awake.
|
||||
|
||||
### Track back
|
||||
|
||||
**Hold Enter → Track back** retraces the trail you just recorded, back to where you started — useful for returning the same way in poor visibility or unfamiliar ground. It reuses the navigation view (distance + two absolute bearings; see *Waypoints › Navigating*), but instead of a single fixed target it walks the recorded breadcrumbs in reverse: it snaps onto the route at the **nearest recorded point**, guides you to it, then automatically advances to the next earlier point as you reach each one (within ~20 m). The header shows how many points remain (`Back: 12 pt`), reading `Trail start` on the final leg; arriving there shows `Back at start` and exits. **Cancel** leaves track-back at any time. It needs a trail with at least two points and a GPS fix; it doesn't require tracking to still be running.
|
||||
|
||||
### Waypoints
|
||||
|
||||
A waypoint is a saved spot — your car, camp, a water source — that you can navigate back to later. Waypoints are **independent of the trail**: they live in their own flash file (`/waypoints`), survive a reboot, and are **not** cleared by *Reset trail*. Up to 16 can be stored — the Waypoints list header shows how many are in use (e.g. `WAYPOINTS 3/16`).
|
||||
|
||||
**Dropping a waypoint** — **Hold Enter → Mark here**. This captures the current GPS fix and opens the on-screen keyboard for a short label (up to 11 characters — e.g. `CAR`, `CAMP`, `H2O`). Leaving it blank auto-names it `WP1`, `WP2`, … Marking works whether or not the trail is being recorded; it needs a GPS fix (otherwise it reports *No GPS fix*).
|
||||
|
||||
**GPS averaging** — with **Settings → Mark avg** set (5 / 10 / 30 s), *Mark here* doesn't snapshot a single fix; it samples the GPS once a second for that window and stores the **mean** position, for a steadier mark than one instantaneous reading (handy for a precise spot — a cache, a car, a trailhead). A short screen shows the time left and the sample count while it runs; **Cancel** aborts. When the window closes it opens the label keyboard as usual. With **Mark avg = Off** (the default) marking is instant.
|
||||
|
||||
**Adding by coordinates** — open **Hold Enter → Waypoints** and select the **+ Add by coords** row (always the last entry in the list). This creates a waypoint without being there — no GPS fix required (handy for a meeting point or a spot read off a map). It opens a small form with three editable rows plus **Save**:
|
||||
|
||||
| OLED | E-Ink |
|
||||
@@ -253,7 +261,7 @@ The tool holds both directions of sharing in one flat list. Navigate with **UP/D
|
||||
|
||||
<!-- screenshot pending: Locator screen with a target set (e.g. "@Bob (5m)"), radius/mode/beeper rows -->
|
||||
|
||||
A single **geofence** that beeps and shows an alert when you cross **into** or **out of** a radius. The target can be a **saved waypoint** (a fixed place — "tell me when I'm back at camp") or a **live contact** (a person sharing their position via Live Share — "alert me when my friend gets near / falls behind"). A waypoint target is a **snapshot** (coordinate + label copied), so it keeps working even if you later edit or delete that waypoint; a contact target follows the person's latest shared position.
|
||||
A single **geofence** that beeps and shows an alert when you cross **into** or **out of** a radius. The target can be a **saved waypoint** (a fixed place — "tell me when I'm back at camp") or a **live contact** (a person sharing their position via Live Share — "alert me when my friend gets near / falls behind"). A waypoint target is a **snapshot** (coordinate + label copied), so it keeps working even if you later edit that waypoint; a contact target follows the person's latest shared position. **Deleting** the target's waypoint, or the target contact being removed from the contacts list, clears the Locator target back to `none` instead of leaving it pointed at something that's gone.
|
||||
|
||||
Navigate with **UP/DOWN**, change a value with **LEFT/RIGHT** (or **Enter**); **Cancel/Back** saves and returns to Tools.
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ public:
|
||||
// A repeater rebroadcast of one of our channel sends was heard (seq from
|
||||
// lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker.
|
||||
virtual void onChannelRelayed(uint32_t seq) { (void)seq; }
|
||||
// Result of an on-device-UI-triggered MyMesh::sendRoomLogin() arrived.
|
||||
// pub_key is the contact's key prefix (>=4 bytes valid); permissions is the
|
||||
// room/repeater ACL byte (only meaningful when success is true).
|
||||
virtual void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { (void)pub_key; (void)success; (void)permissions; }
|
||||
// True only when a BLE central is actually bonded/connected. On a dual
|
||||
// (BLE+USB) interface hasConnection() is always true (USB counts), so use
|
||||
// this for BLE-specific UI like the pairing-PIN prompt.
|
||||
@@ -61,7 +65,7 @@ public:
|
||||
virtual void msgRead(int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) = 0;
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual void addChannelMsg(uint8_t channel_idx, const char* text) {}
|
||||
virtual void addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) {}
|
||||
virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) {}
|
||||
// A node shared its current position via a [LOC] message. pub_key is the
|
||||
// sender's key prefix for a verified DM share, or null for a channel share
|
||||
@@ -69,5 +73,16 @@ public:
|
||||
virtual void onSharedLocation(const uint8_t* pub_key, const char* name,
|
||||
int32_t lat_1e6, int32_t lon_1e6,
|
||||
uint32_t ts, bool verified) {}
|
||||
// A contact is gone — removed explicitly (companion app / CLI command) or
|
||||
// silently auto-evicted to make room when the contact table is full. Lets
|
||||
// UI state that references contacts by pubkey (favourite slots, the
|
||||
// Locator/Live Share target) drop a reference that would otherwise dangle.
|
||||
// Default no-op.
|
||||
virtual void onContactRemoved(const uint8_t* pub_key) {}
|
||||
// A channel slot was cleared (companion app set it to an empty secret).
|
||||
// Drop any setting that referenced it by index — otherwise a new channel
|
||||
// added later at the same slot would silently inherit the old one's bot/
|
||||
// share target or notification melody. Default no-op.
|
||||
virtual void onChannelRemoved(uint8_t channel_idx) {}
|
||||
virtual void loop() = 0;
|
||||
};
|
||||
|
||||
@@ -176,6 +176,10 @@ File DataStore::openWrite(const char* filename) {
|
||||
return ::openWrite(_fs, filename);
|
||||
}
|
||||
|
||||
bool DataStore::commitFile(const char* tmp_path, const char* final_path) {
|
||||
return commitTempFile(_fs, tmp_path, final_path);
|
||||
}
|
||||
|
||||
bool DataStore::removeFile(const char* filename) {
|
||||
return _fs->remove(filename);
|
||||
}
|
||||
@@ -414,6 +418,16 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
rd(&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind));
|
||||
rd(_prefs.locator_key, sizeof(_prefs.locator_key));
|
||||
if (_prefs.locator_target_kind > 1) _prefs.locator_target_kind = 0;
|
||||
// → 0xC0DE0016: GPS-averaging duration for waypoint marking.
|
||||
rd(&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx));
|
||||
if (_prefs.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) _prefs.gps_avg_idx = 0;
|
||||
// → 0xC0DE0017: one-shot alarm clock (local time-of-day + armed flag).
|
||||
rd(&_prefs.alarm_on, sizeof(_prefs.alarm_on));
|
||||
rd(&_prefs.alarm_hour, sizeof(_prefs.alarm_hour));
|
||||
rd(&_prefs.alarm_min, sizeof(_prefs.alarm_min));
|
||||
if (_prefs.alarm_on > 1) _prefs.alarm_on = 0;
|
||||
if (_prefs.alarm_hour > 23) _prefs.alarm_hour = 0;
|
||||
if (_prefs.alarm_min > 59) _prefs.alarm_min = 0;
|
||||
// Pre-0x10 files leave stray sentinel bytes here, same as a never-configured
|
||||
// device. Either way there's no valid saved profile, so default to a profile
|
||||
// in the same band as the companion's own network (_prefs.freq, already read
|
||||
@@ -614,6 +628,10 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.locator_beeper, sizeof(_prefs.locator_beeper));
|
||||
file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind));
|
||||
file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key));
|
||||
file.write((uint8_t *)&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx));
|
||||
file.write((uint8_t *)&_prefs.alarm_on, sizeof(_prefs.alarm_on));
|
||||
file.write((uint8_t *)&_prefs.alarm_hour, sizeof(_prefs.alarm_hour));
|
||||
file.write((uint8_t *)&_prefs.alarm_min, sizeof(_prefs.alarm_min));
|
||||
|
||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
||||
// the one we check: once the flash fills, writes return 0, so a good
|
||||
@@ -728,16 +746,24 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
|
||||
}
|
||||
|
||||
void DataStore::loadChannels(DataStoreHost* host) {
|
||||
File file = openRead(_getContactsChannelsFS(), "/channels2");
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
File file = openRead(fs, "/channels3");
|
||||
if (file) {
|
||||
// /channels3: the leading 4-byte field's first byte is the channel's
|
||||
// original slot index (see saveChannels()) — load it back into that
|
||||
// exact slot. The old /channels2 format instead reassigned indices
|
||||
// 0,1,2… sequentially on every load, which silently shifted every
|
||||
// later channel down a slot once an earlier one was removed — anything
|
||||
// that remembers a channel by index (Live Share's target, the bot's
|
||||
// channel, per-channel melody) would then point at the wrong channel
|
||||
// after the next reboot.
|
||||
bool full = false;
|
||||
uint8_t channel_idx = 0;
|
||||
uint8_t skipped = 0;
|
||||
while (!full) {
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
uint8_t hdr[4];
|
||||
|
||||
bool success = (file.read(unused, 4) == 4);
|
||||
bool success = (file.read(hdr, 4) == 4);
|
||||
success = success && (file.read((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
|
||||
@@ -760,44 +786,71 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
||||
// as a C string regardless of how the file was written.
|
||||
ch.name[31] = '\0';
|
||||
|
||||
if (host->onChannelLoaded(channel_idx, ch)) {
|
||||
channel_idx++;
|
||||
} else {
|
||||
full = true;
|
||||
}
|
||||
if (!host->onChannelLoaded(hdr[0], ch)) full = true;
|
||||
}
|
||||
file.close();
|
||||
if (skipped > 0) {
|
||||
MESH_DEBUG_PRINTLN("loadChannels: skipped %u corrupted/empty channel entr%s",
|
||||
(unsigned)skipped, skipped == 1 ? "y" : "ies");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// One-time migration from the old /channels2 format (sequential index,
|
||||
// reassigned on every load — the bug /channels3 above replaces). Loads
|
||||
// with that old semantics once, then resaves as /channels3 so this
|
||||
// fallback is never hit again on this device.
|
||||
file = openRead(fs, "/channels2");
|
||||
if (file) {
|
||||
bool full = false;
|
||||
uint8_t channel_idx = 0;
|
||||
while (!full) {
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
|
||||
bool success = (file.read(unused, 4) == 4);
|
||||
success = success && (file.read((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
if (!success) break; // EOF
|
||||
|
||||
bool secret_empty = true;
|
||||
for (int b = 0; b < 32; b++) if (ch.channel.secret[b] != 0) { secret_empty = false; break; }
|
||||
if (secret_empty) continue;
|
||||
ch.name[31] = '\0';
|
||||
|
||||
if (host->onChannelLoaded(channel_idx, ch)) channel_idx++;
|
||||
else full = true;
|
||||
}
|
||||
file.close();
|
||||
saveChannels(host); // write /channels3 so the migration runs only once
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::saveChannels(DataStoreHost* host) {
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
// Same atomic temp-then-rename pattern as saveContacts() — never truncate the
|
||||
// live /channels2 before the new copy is fully written.
|
||||
File file = ::openWrite(fs, "/channels2.tmp");
|
||||
// live /channels3 before the new copy is fully written.
|
||||
File file = ::openWrite(fs, "/channels3.tmp");
|
||||
if (!file) return;
|
||||
|
||||
bool ok = true;
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
channel_idx++;
|
||||
uint8_t idx = channel_idx++;
|
||||
// getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip
|
||||
// the unused ones (all-zero secret) rather than writing all 40 — otherwise
|
||||
// the file is always ~2.7 KB and wears the flash needlessly. loadChannels()
|
||||
// already compacts empty entries on read, so the loaded result is identical.
|
||||
// the file is always ~2.7 KB and wears the flash needlessly. Unlike the old
|
||||
// /channels2 format, loadChannels() no longer compacts: the slot index
|
||||
// travels with the record (hdr[0] below) so a removed channel just leaves
|
||||
// a hole instead of shifting every later index down a slot.
|
||||
bool empty = true;
|
||||
for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; }
|
||||
if (empty) continue;
|
||||
|
||||
bool success = (file.write(unused, 4) == 4);
|
||||
uint8_t hdr[4] = { idx, 0, 0, 0 };
|
||||
bool success = (file.write(hdr, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
if (!success) { ok = false; break; } // write failed
|
||||
@@ -805,9 +858,9 @@ void DataStore::saveChannels(DataStoreHost* host) {
|
||||
file.close();
|
||||
|
||||
if (ok) {
|
||||
commitTempFile(fs, "/channels2.tmp", "/channels2");
|
||||
commitTempFile(fs, "/channels3.tmp", "/channels3");
|
||||
} else {
|
||||
fs->remove("/channels2.tmp"); // keep the previous good /channels2
|
||||
fs->remove("/channels3.tmp"); // keep the previous good /channels3
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ public:
|
||||
File openRead(const char* filename);
|
||||
File openRead(FILESYSTEM* fs, const char* filename);
|
||||
File openWrite(const char* filename);
|
||||
// Atomically replace final_path with a fully-written temp file (see
|
||||
// openWrite()). Use for small custom records that want the same crash-safety
|
||||
// as contacts/channels: write everything to a .tmp, then commit. Returns
|
||||
// false if the swap fails (the previous good file is left untouched).
|
||||
bool commitFile(const char* tmp_path, const char* final_path);
|
||||
bool removeFile(const char* filename);
|
||||
bool removeFile(FILESYSTEM* fs, const char* filename);
|
||||
uint32_t getStorageUsedKb() const;
|
||||
|
||||
@@ -51,14 +51,9 @@ public:
|
||||
}
|
||||
|
||||
// Drop entries not refreshed within EXPIRY_SECS. `now` is RTC epoch seconds.
|
||||
// Guarded against now < ts (RTC stepped backwards) so a clock fix can't wipe
|
||||
// the table.
|
||||
void expire(uint32_t now) {
|
||||
for (int i = 0; i < CAPACITY; i++) {
|
||||
if (_e[i].used && now > _e[i].ts && (now - _e[i].ts) > EXPIRY_SECS) {
|
||||
_e[i].used = false;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < CAPACITY; i++)
|
||||
if (_e[i].used && expired(_e[i], now)) _e[i].used = false;
|
||||
}
|
||||
|
||||
void clear() { for (int i = 0; i < CAPACITY; i++) _e[i].used = false; }
|
||||
@@ -66,9 +61,7 @@ public:
|
||||
// Slot-wise access (caller skips inactive slots via isActive()).
|
||||
const Entry& slotAt(int i) const { return _e[i]; }
|
||||
bool isActive(int i, uint32_t now) const {
|
||||
const Entry& e = _e[i];
|
||||
if (!e.used) return false;
|
||||
return !(now > e.ts && (now - e.ts) > EXPIRY_SECS);
|
||||
return _e[i].used && !expired(_e[i], now);
|
||||
}
|
||||
|
||||
// Number of currently non-expired entries.
|
||||
@@ -94,6 +87,13 @@ public:
|
||||
private:
|
||||
Entry _e[CAPACITY] = {};
|
||||
|
||||
// An entry is stale once EXPIRY_SECS have passed since its last update.
|
||||
// Guarded against now < ts (RTC stepped backwards) so a clock fix can't
|
||||
// mass-expire the table.
|
||||
static bool expired(const Entry& e, uint32_t now) {
|
||||
return now > e.ts && (now - e.ts) > EXPIRY_SECS;
|
||||
}
|
||||
|
||||
// Match an existing entry: verified shares by key, channel shares by name.
|
||||
int find(const uint8_t* key, const char* name, bool verified) const {
|
||||
for (int i = 0; i < CAPACITY; i++) {
|
||||
|
||||
@@ -352,6 +352,7 @@ uint8_t MyMesh::getAutoAddMaxHops() const {
|
||||
|
||||
void MyMesh::onContactOverwrite(const uint8_t* pub_key) {
|
||||
_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); // delete from storage
|
||||
if (_ui) _ui->onContactRemoved(pub_key); // same cleanup as an explicit CMD_REMOVE_CONTACT
|
||||
if (_serial->isConnected()) {
|
||||
out_frame[0] = PUSH_CODE_CONTACT_DELETED;
|
||||
memcpy(&out_frame[1], pub_key, PUB_KEY_SIZE);
|
||||
@@ -720,7 +721,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
||||
_serial->writeFrame(frame, 1);
|
||||
}
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_ui) _ui->addChannelMsg(channel_idx, text);
|
||||
if (_ui) _ui->addChannelMsg(channel_idx, text, timestamp);
|
||||
if (_ui) _ui->notify(UIEventType::channelMessage);
|
||||
const char *channel_name = "Unknown";
|
||||
ChannelDetails channel_details;
|
||||
@@ -845,11 +846,13 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
pending_login = 0;
|
||||
|
||||
int i = 0;
|
||||
bool login_ok = false;
|
||||
if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response
|
||||
out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS;
|
||||
out_frame[i++] = 0; // legacy: is_admin = false
|
||||
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
||||
i += 6; // pub_key_prefix
|
||||
login_ok = true;
|
||||
} else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response
|
||||
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
|
||||
if (keep_alive_secs > 0) {
|
||||
@@ -863,13 +866,40 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
i += 4; // NEW: include server timestamp
|
||||
out_frame[i++] = data[7]; // NEW (v7): ACL permissions
|
||||
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
|
||||
login_ok = true;
|
||||
} else {
|
||||
out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
|
||||
out_frame[i++] = 0; // reserved
|
||||
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
||||
i += 6; // pub_key_prefix
|
||||
}
|
||||
// Persist app/USB-entered room passwords too, so the device can later post
|
||||
// to that room standalone (after reboot, no phone) without re-prompting --
|
||||
// same store the on-device login path uses. Rooms only; a failed login
|
||||
// forgets any stale saved password, mirroring onRoomLoginResult().
|
||||
if (contact.type == ADV_TYPE_ROOM) {
|
||||
if (login_ok) saveRoomPassword(contact.id.pub_key, pending_login_pw);
|
||||
else forgetRoomPassword(contact.id.pub_key);
|
||||
}
|
||||
_serial->writeFrame(out_frame, i);
|
||||
} else if (ui_pending_login && memcmp(&ui_pending_login, contact.id.pub_key, 4) == 0) { // check for on-device UI login response
|
||||
ui_pending_login = 0;
|
||||
|
||||
bool success;
|
||||
uint8_t permissions = 0;
|
||||
if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response
|
||||
success = true;
|
||||
} else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response
|
||||
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
|
||||
if (keep_alive_secs > 0) {
|
||||
startConnection(contact, keep_alive_secs);
|
||||
}
|
||||
success = true;
|
||||
permissions = data[7]; // ACL permissions
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
_ui->onRoomLoginResult(contact.id.pub_key, success, permissions);
|
||||
} else if (len > 4 && // check for status response
|
||||
pending_status &&
|
||||
memcmp(&pending_status, contact.id.pub_key, 4) == 0 // legacy matching scheme
|
||||
@@ -910,6 +940,104 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
}
|
||||
}
|
||||
|
||||
#define ROOM_PW_FILE "/room_pw"
|
||||
#define ROOM_PW_TMP "/room_pw.tmp"
|
||||
#define MAX_SAVED_ROOM_PASSWORDS 16
|
||||
|
||||
namespace {
|
||||
struct RoomPwRec {
|
||||
uint8_t key[4]; // pub-key prefix
|
||||
char pw[16]; // up to 15 chars + NUL
|
||||
};
|
||||
}
|
||||
|
||||
bool MyMesh::getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len) {
|
||||
File f = _store->openRead(ROOM_PW_FILE);
|
||||
if (!f) return false;
|
||||
|
||||
RoomPwRec rec;
|
||||
bool found = false;
|
||||
while (f.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) {
|
||||
if (memcmp(rec.key, pub_key, 4) == 0) {
|
||||
strncpy(out_password, rec.pw, max_len - 1);
|
||||
out_password[max_len - 1] = 0;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
return found;
|
||||
}
|
||||
|
||||
bool MyMesh::saveRoomPassword(const uint8_t* pub_key, const char* password) {
|
||||
// The table is tiny (<= MAX_SAVED_ROOM_PASSWORDS * 20 bytes), so just load
|
||||
// it whole, update/append/evict in RAM, then rewrite -- simpler and just
|
||||
// as crash-safe as a record seek given how rarely this runs (once per new
|
||||
// room login).
|
||||
RoomPwRec recs[MAX_SAVED_ROOM_PASSWORDS];
|
||||
int count = 0;
|
||||
File rf = _store->openRead(ROOM_PW_FILE);
|
||||
if (rf) {
|
||||
RoomPwRec rec;
|
||||
while (count < MAX_SAVED_ROOM_PASSWORDS && rf.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) {
|
||||
if (memcmp(rec.key, pub_key, 4) != 0) { // drop stale entry for this key -- replaced below
|
||||
recs[count++] = rec;
|
||||
}
|
||||
}
|
||||
rf.close();
|
||||
}
|
||||
|
||||
RoomPwRec new_rec;
|
||||
memcpy(new_rec.key, pub_key, 4);
|
||||
strncpy(new_rec.pw, password, sizeof(new_rec.pw) - 1);
|
||||
new_rec.pw[sizeof(new_rec.pw) - 1] = 0;
|
||||
|
||||
if (count < MAX_SAVED_ROOM_PASSWORDS) {
|
||||
recs[count++] = new_rec;
|
||||
} else { // table full and not already present -- evict oldest (front)
|
||||
memmove(&recs[0], &recs[1], sizeof(RoomPwRec) * (MAX_SAVED_ROOM_PASSWORDS - 1));
|
||||
recs[MAX_SAVED_ROOM_PASSWORDS - 1] = new_rec;
|
||||
}
|
||||
|
||||
// Write to a temp file and atomically swap it over /room_pw, so an
|
||||
// interrupted save leaves the previous good table intact rather than a
|
||||
// truncated mix (mirrors how contacts/channels are persisted).
|
||||
File wf = _store->openWrite(ROOM_PW_TMP);
|
||||
if (!wf) return false;
|
||||
size_t want = sizeof(RoomPwRec) * count;
|
||||
bool ok = (wf.write((uint8_t *)recs, want) == want);
|
||||
wf.close();
|
||||
if (!ok) { _store->removeFile(ROOM_PW_TMP); return false; } // keep previous good file
|
||||
return _store->commitFile(ROOM_PW_TMP, ROOM_PW_FILE);
|
||||
}
|
||||
|
||||
void MyMesh::forgetRoomPassword(const uint8_t* pub_key) {
|
||||
RoomPwRec recs[MAX_SAVED_ROOM_PASSWORDS];
|
||||
int count = 0;
|
||||
File rf = _store->openRead(ROOM_PW_FILE);
|
||||
if (!rf) return;
|
||||
|
||||
RoomPwRec rec;
|
||||
bool removed = false;
|
||||
while (count < MAX_SAVED_ROOM_PASSWORDS && rf.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) {
|
||||
if (memcmp(rec.key, pub_key, 4) == 0) {
|
||||
removed = true;
|
||||
} else {
|
||||
recs[count++] = rec;
|
||||
}
|
||||
}
|
||||
rf.close();
|
||||
if (!removed) return; // nothing to do, avoid a pointless rewrite
|
||||
|
||||
File wf = _store->openWrite(ROOM_PW_TMP);
|
||||
if (!wf) return;
|
||||
size_t want = sizeof(RoomPwRec) * count;
|
||||
bool ok = (wf.write((uint8_t *)recs, want) == want);
|
||||
wf.close();
|
||||
if (!ok) { _store->removeFile(ROOM_PW_TMP); return; } // keep previous good file
|
||||
_store->commitFile(ROOM_PW_TMP, ROOM_PW_FILE);
|
||||
}
|
||||
|
||||
bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) {
|
||||
if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 4) {
|
||||
uint32_t tag;
|
||||
@@ -1537,6 +1665,11 @@ void MyMesh::startInterface(BaseSerialInterface &serial) {
|
||||
serial.enable();
|
||||
}
|
||||
|
||||
static bool isAllZero(const uint8_t* buf, size_t n) {
|
||||
for (size_t i = 0; i < n; i++) if (buf[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void MyMesh::handleCmdFrame(size_t len) {
|
||||
if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection
|
||||
app_target_ver = cmd_frame[1]; // which version of protocol does app understand
|
||||
@@ -1823,6 +1956,8 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||||
if (recipient && removeContact(*recipient)) {
|
||||
_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE);
|
||||
forgetRoomPassword(pub_key); // drop any saved room login -- useless without the contact
|
||||
if (_ui) _ui->onContactRemoved(pub_key); // drop any favourite slot / Locator / Live Share target pointed at it
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
writeOKFrame();
|
||||
} else {
|
||||
@@ -2068,6 +2203,8 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
} else {
|
||||
clearPendingReqs();
|
||||
memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse()
|
||||
strncpy(pending_login_pw, password, sizeof(pending_login_pw) - 1); // saved on success if it's a room
|
||||
pending_login_pw[sizeof(pending_login_pw) - 1] = 0;
|
||||
out_frame[0] = RESP_CODE_SENT;
|
||||
out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0;
|
||||
memcpy(&out_frame[2], &pending_login, 4);
|
||||
@@ -2246,6 +2383,12 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
memcpy(channel.channel.secret, &cmd_frame[2 + 32], 32); // 256-bit key
|
||||
if (setChannel(channel_idx, channel)) {
|
||||
saveChannels();
|
||||
// An all-zero secret is this codebase's "empty slot" sentinel (same
|
||||
// check loadChannels()/saveChannels() use) -- the app just cleared this
|
||||
// channel, so drop anything that referenced it by index, the same way
|
||||
// onContactRemoved() does for contacts.
|
||||
if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret)))
|
||||
_ui->onChannelRemoved(channel_idx);
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
@@ -2258,6 +2401,8 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // 128-bit key
|
||||
if (setChannel(channel_idx, channel)) {
|
||||
saveChannels();
|
||||
if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret)))
|
||||
_ui->onChannelRemoved(channel_idx);
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
|
||||
@@ -208,10 +208,30 @@ private:
|
||||
bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); }
|
||||
|
||||
void clearPendingReqs() {
|
||||
pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0;
|
||||
pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = ui_pending_login = 0;
|
||||
}
|
||||
|
||||
public:
|
||||
// On-device UI login to a room/repeater contact — no phone app required.
|
||||
// The room server's ACL grants permission per-identity (self_id), not per
|
||||
// command source, so this reuses the same sendLogin() the BLE CMD_SEND_LOGIN
|
||||
// path uses; the async result lands in onContactResponse() and is pushed to
|
||||
// the UI via AbstractUITask::onRoomLoginResult().
|
||||
bool sendRoomLogin(const ContactInfo& contact, const char* password) {
|
||||
uint32_t est_timeout;
|
||||
if (sendLogin(contact, password, est_timeout) == MSG_SEND_FAILED) return false;
|
||||
clearPendingReqs();
|
||||
memcpy(&ui_pending_login, contact.id.pub_key, 4); // match this in onContactResponse()
|
||||
return true;
|
||||
}
|
||||
|
||||
// On-device-saved room/repeater login passwords, persisted to flash (own
|
||||
// small file, independent of /contacts3) so a room that's already been
|
||||
// logged into doesn't need its password retyped after reboot.
|
||||
bool saveRoomPassword(const uint8_t* pub_key, const char* password);
|
||||
bool getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len);
|
||||
void forgetRoomPassword(const uint8_t* pub_key);
|
||||
|
||||
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
|
||||
void saveRTCTime() { _store->saveRTCTime(); }
|
||||
DataStore* getDataStore() const { return _store; }
|
||||
@@ -299,6 +319,8 @@ private:
|
||||
DataStore* _store;
|
||||
NodePrefs _prefs;
|
||||
uint32_t pending_login;
|
||||
uint32_t ui_pending_login; // like pending_login, but triggered by on-device UI instead of BLE/USB app
|
||||
char pending_login_pw[16]; // password of the in-flight app/USB login, persisted on success for ADV_TYPE_ROOM (see saveRoomPassword)
|
||||
uint32_t pending_status;
|
||||
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
|
||||
uint32_t pending_req; // pending _BINARY_REQ
|
||||
|
||||
@@ -87,7 +87,7 @@ struct NodePrefs { // persisted to file
|
||||
uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible
|
||||
uint8_t bot_enabled; // 0=disabled, 1=DM bot active (responds to all DMs)
|
||||
uint8_t bot_channel_enabled; // 0=disabled, 1=channel bot active for bot_channel_idx
|
||||
uint8_t bot_channel_idx; // channel index for channel bot
|
||||
uint8_t bot_channel_idx; // channel index for channel bot [del→onChannelRemoved]
|
||||
char bot_trigger[64]; // DM trigger phrase (case-insensitive contains; "*" = any DM)
|
||||
char bot_reply_dm[140]; // auto-reply text for DM
|
||||
char bot_reply_ch[140]; // auto-reply text for channel
|
||||
@@ -100,7 +100,7 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected
|
||||
struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on
|
||||
static const int DM_NOTIF_TABLE_MAX = 16;
|
||||
DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes
|
||||
DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes [del→onContactRemoved]
|
||||
uint8_t dashboard_fields[3]; // 0=None,1=Batt V,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes,10=Msgs,11=Batt %
|
||||
uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds
|
||||
// Second melody slot (same packing as ringtone_*)
|
||||
@@ -114,12 +114,12 @@ struct NodePrefs { // persisted to file
|
||||
// Advert sound filter: 0=all adverts, 1=zero-hop only
|
||||
uint8_t advert_sound_scope;
|
||||
// Per-channel melody override (2 bitmasks, 1 bit per channel)
|
||||
uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody
|
||||
uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody [del→onChannelRemoved]
|
||||
uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set)
|
||||
// Per-DM melody table
|
||||
struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2
|
||||
static const int DM_MELODY_TABLE_MAX = 16;
|
||||
DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX];
|
||||
DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; // [del→onContactRemoved]
|
||||
uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap)
|
||||
uint8_t display_rotation; // 0-3; only used on e-ink displays
|
||||
// Home screen page order: each byte = HomePageBit + 1. 0 terminates the list.
|
||||
@@ -138,7 +138,7 @@ struct NodePrefs { // persisted to file
|
||||
// Layout transposes between landscape (3×2) and portrait (2×3).
|
||||
static const uint8_t FAVOURITES_COUNT = 6;
|
||||
static const uint8_t FAVOURITE_PREFIX_LEN = 6;
|
||||
uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN];
|
||||
uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; // [del→onContactRemoved]
|
||||
|
||||
// GPS trail cadence. Logging on/off is a runtime state (Tools › Trail),
|
||||
// not a persisted preference.
|
||||
@@ -229,8 +229,8 @@ struct NodePrefs { // persisted to file
|
||||
// Configured from the Map (Trail screen) "Live share" menu.
|
||||
uint8_t loc_share_enabled; // 0=off (default), 1=auto-sharing on
|
||||
uint8_t loc_share_target_type; // 0=channel, 1=DM contact
|
||||
uint8_t loc_share_channel_idx; // target channel index (when target_type==0)
|
||||
uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1)
|
||||
uint8_t loc_share_channel_idx; // target channel index (when target_type==0) [del→onChannelRemoved]
|
||||
uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1) [del→onContactRemoved]
|
||||
uint8_t loc_share_move_idx; // movement gate level (index into locShareMoveMeters)
|
||||
uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs)
|
||||
uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs)
|
||||
@@ -251,7 +251,7 @@ struct NodePrefs { // persisted to file
|
||||
// re-reads the latest [LOC] position each evaluation (keyed by pubkey prefix),
|
||||
// so the geofence follows a moving person ("alert when my friend is near").
|
||||
uint8_t locator_target_kind; // 0=waypoint (static), 1=live contact
|
||||
uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1
|
||||
uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1 [del→onContactRemoved]
|
||||
|
||||
// Trail auto-pause — when tracking, automatically freeze the trail (timer +
|
||||
// sampling) after the device has sat still for this long, and resume on the
|
||||
@@ -264,6 +264,24 @@ struct NodePrefs { // persisted to file
|
||||
// discrete arrive/leave alert (locator_mode).
|
||||
uint8_t locator_beeper; // 0=off (default), 1=on
|
||||
|
||||
// GPS averaging for waypoint marking — when set, "Mark here" samples the GPS
|
||||
// fix for this many seconds and stores the mean position, for a more accurate
|
||||
// mark than a single instantaneous fix. 0 = off (instant mark, the default).
|
||||
// Index into gpsAvgSecs(). [Tools › Trail › Settings › Mark avg]
|
||||
uint8_t gps_avg_idx;
|
||||
|
||||
// Alarm clock — a single one-shot wake alarm, configured from the Clock page
|
||||
// (Enter › Alarm). Stored as a local time-of-day; the actual fire instant is
|
||||
// (re)computed as an absolute time in tickBackground(), which is what makes it
|
||||
// robust to RTC re-syncs: the mesh (every inbound packet), the companion app,
|
||||
// GPS and the CLI can all jump getCurrentTime() at any moment, but an absolute
|
||||
// target instant stays correct across small corrections and still fires (late)
|
||||
// if the clock jumps over it. Fires once, then alarm_on clears. The minutnik
|
||||
// (countdown) and stoper (stopwatch) are runtime-only and not persisted.
|
||||
uint8_t alarm_on; // 0=off (default), 1=armed (one-shot)
|
||||
uint8_t alarm_hour; // 0-23, local time
|
||||
uint8_t alarm_min; // 0-59
|
||||
|
||||
// Single source of truth for the live-share option tables (shared by the Map
|
||||
// UI labels and the auto-send engine in UITask).
|
||||
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
|
||||
@@ -295,6 +313,17 @@ struct NodePrefs { // persisted to file
|
||||
return L[m < LOCATOR_MODE_COUNT ? m : 0];
|
||||
}
|
||||
|
||||
// GPS-averaging durations for waypoint marking (seconds). 0 = off (instant).
|
||||
static const uint8_t GPS_AVG_COUNT = 4;
|
||||
static uint16_t gpsAvgSecs(uint8_t idx) {
|
||||
static const uint16_t S[GPS_AVG_COUNT] = { 0, 5, 10, 30 };
|
||||
return S[idx < GPS_AVG_COUNT ? idx : 0];
|
||||
}
|
||||
static const char* gpsAvgLabel(uint8_t idx) {
|
||||
static const char* L[GPS_AVG_COUNT] = { "Off", "5s", "10s", "30s" };
|
||||
return L[idx < GPS_AVG_COUNT ? idx : 0];
|
||||
}
|
||||
|
||||
// Trail auto-pause delays (seconds). 0 = off.
|
||||
static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4;
|
||||
static uint16_t trailAutoPauseSecs(uint8_t idx) {
|
||||
@@ -315,7 +344,7 @@ struct NodePrefs { // persisted to file
|
||||
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
||||
// older saves are detected on load and skipped (zero-init defaults kept).
|
||||
// High 24 bits identify the file format; low byte is the schema revision.
|
||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0015;
|
||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0017;
|
||||
|
||||
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
||||
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
||||
@@ -385,6 +414,23 @@ struct NodePrefs { // persisted to file
|
||||
}
|
||||
};
|
||||
|
||||
// ── Serialization tripwire ───────────────────────────────────────────────────
|
||||
// NodePrefs is written/read field-by-field, in order, by DataStore::savePrefs()
|
||||
// and loadPrefsInt(); the on-disk format IS the struct's field layout. There is
|
||||
// no automatic check that those two hand-written sequences match the struct, so
|
||||
// a forgotten read/write silently misaligns EVERY field after it.
|
||||
//
|
||||
// This assert is the manual checkpoint. Changing a data member changes sizeof
|
||||
// and trips it. When it trips, do ALL of the following, then update the number:
|
||||
// 1. add the field's rd(...) in DataStore::loadPrefsInt(), in struct order
|
||||
// 2. add the field's write(...) in DataStore::savePrefs(), in struct order
|
||||
// 3. clamp it on load (an upgrader's file lacks it → stray bytes)
|
||||
// 4. bump SCHEMA_SENTINEL's low byte
|
||||
// (Padding can also shift sizeof; a "false" trip just means re-check + rebump.)
|
||||
static_assert(sizeof(NodePrefs) == 2488,
|
||||
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
|
||||
"SCHEMA_SENTINEL, then update this size (see steps above).");
|
||||
|
||||
// Bounds for a usable repeater radio profile. The frequency range is passed in
|
||||
// by the caller from RadioLibWrapper::getFreqBounds() — the radio chip's own
|
||||
// validated range — so a profile that passes here is guaranteed to actually take
|
||||
|
||||
@@ -20,7 +20,7 @@ class AutoAdvertScreen : public UIScreen {
|
||||
public:
|
||||
AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() { _dirty = false; }
|
||||
void onShow() override { _dirty = false; }
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
@@ -51,7 +51,7 @@ public:
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) the_mesh.savePrefs();
|
||||
_task->savePrefsIfDirty(_dirty);
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class BotScreen : public UIScreen {
|
||||
public:
|
||||
BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {}
|
||||
|
||||
void enter() {
|
||||
void onShow() override {
|
||||
_sel = 0;
|
||||
_scroll = 0;
|
||||
_kb_field = -1;
|
||||
@@ -75,7 +75,7 @@ public:
|
||||
"Trigger Ch", "Reply Ch", "Commands",
|
||||
"Quiet from", "Quiet to" };
|
||||
drawList(display, ITEM_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
display.setCursor(2, y);
|
||||
display.print(labels[i]);
|
||||
display.setCursor(val_x, y);
|
||||
@@ -141,7 +141,7 @@ public:
|
||||
}
|
||||
|
||||
if (cancel) {
|
||||
if (_dirty) the_mesh.savePrefs();
|
||||
_task->savePrefsIfDirty(_dirty);
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
298
examples/companion_radio/ui-new/ClockToolsScreen.h
Normal file
298
examples/companion_radio/ui-new/ClockToolsScreen.h
Normal file
@@ -0,0 +1,298 @@
|
||||
#pragma once
|
||||
// Clock tools — Alarm, Countdown timer (minutnik) and Stopwatch (stoper).
|
||||
// Entered with Enter on the home CLOCK page. A small top menu picks one of the
|
||||
// three tools; Cancel backs out a level (tool → menu → home).
|
||||
//
|
||||
// This screen is pure UI. The time-critical machinery lives in UITask, which
|
||||
// drives it every loop regardless of the current screen:
|
||||
// • Alarm — UITask schedules an absolute fire instant from NodePrefs'
|
||||
// alarm_hour/min (robust to RTC re-syncs) and rings. Here we just edit the
|
||||
// persisted fields and call onAlarmChanged() to re-schedule.
|
||||
// • Timer — UITask owns the running countdown (startTimer / stopTimer /
|
||||
// isTimerRunning / timerRemainingMs). It rings even when off-screen.
|
||||
// • Stopwatch — purely a display utility with no background action, so its
|
||||
// millis() state lives here; it keeps counting while you're elsewhere.
|
||||
//
|
||||
// Numeric fields (alarm hour/minute, timer h/m/s) are edited with the shared
|
||||
// framework DigitEditor (digit-by-digit: LEFT/RIGHT cursor, UP/DOWN +/- the
|
||||
// place, min/max enforced) — the same widget Settings/Repeater use for Freq,
|
||||
// rather than a hand-rolled wrap.
|
||||
//
|
||||
// E-INK: the running timer/stopwatch readouts would thrash a slow e-paper panel
|
||||
// if redrawn every second, so on e-ink the live readout refreshes only coarsely
|
||||
// (and immediately on any key); the millis() timing underneath is unaffected.
|
||||
// Included by UITask.cpp after the other screen fragments.
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
#include "icons.h" // drawList / drawRowSelection
|
||||
#include "DigitEditor.h" // shared digit-by-digit numeric editor
|
||||
|
||||
class ClockToolsScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
enum View : uint8_t { V_MENU, V_ALARM, V_TIMER, V_STOPWATCH };
|
||||
uint8_t _view = V_MENU;
|
||||
int _sel = 0, _scroll = 0; // shared list cursor
|
||||
bool _alarm_dirty = false; // unsaved edits to alarm_* (persist on exit)
|
||||
|
||||
// Countdown config (the duration to start; the running countdown lives in UITask).
|
||||
uint8_t _timer_h = 0, _timer_m = 5, _timer_s = 0;
|
||||
uint8_t _timer_cur = 0; // digit cursor over HH:MM:SS, 0..5 (skips the colons)
|
||||
|
||||
// Stopwatch (millis — display-only, no background action).
|
||||
bool _sw_running = false;
|
||||
uint32_t _sw_start_ms = 0;
|
||||
uint32_t _sw_accum_ms = 0;
|
||||
|
||||
// Shared numeric editor (alarm hour/minute) + which field it targets. The
|
||||
// timer uses its own continuous digit cursor (per-place caps a single
|
||||
// DigitEditor can't express), so it isn't listed here.
|
||||
enum EditKind : uint8_t { EK_NONE, EK_A_HOUR, EK_A_MIN };
|
||||
DigitEditor _editor;
|
||||
EditKind _edit_kind = EK_NONE;
|
||||
|
||||
// E-ink: coarse refresh for the live readouts (millis underneath is exact).
|
||||
static int liveTickMs(DisplayDriver& d, int oled_ms) { return d.isEink() ? 30000 : oled_ms; }
|
||||
|
||||
static void fmtClock(char* b, int n, int hh, int mm) { snprintf(b, n, "%02d:%02d", hh, mm); }
|
||||
|
||||
// millis → "H:MM:SS" (the countdown always shows hours).
|
||||
static void fmtHMS(char* b, int n, uint32_t ms) {
|
||||
uint32_t s = ms / 1000, h = s / 3600; s %= 3600;
|
||||
uint32_t m = s / 60; s %= 60;
|
||||
snprintf(b, n, "%lu:%02lu:%02lu", (unsigned long)h, (unsigned long)m, (unsigned long)s);
|
||||
}
|
||||
|
||||
// Stopwatch readout: "M:SS.t" while under an hour (tenths only on a fast
|
||||
// panel), "H:MM:SS" once it rolls past an hour.
|
||||
static void fmtStopwatch(char* b, int n, uint32_t ms, bool tenths) {
|
||||
if (ms >= 3600000UL) { fmtHMS(b, n, ms); return; }
|
||||
uint32_t total_s = ms / 1000, m = total_s / 60, s = total_s % 60;
|
||||
if (tenths) snprintf(b, n, "%lu:%02lu.%lu", (unsigned long)m, (unsigned long)s,
|
||||
(unsigned long)((ms % 1000) / 100));
|
||||
else snprintf(b, n, "%lu:%02lu", (unsigned long)m, (unsigned long)s);
|
||||
}
|
||||
|
||||
uint32_t stopwatchMs() const {
|
||||
return _sw_accum_ms + (_sw_running ? (millis() - _sw_start_ms) : 0);
|
||||
}
|
||||
|
||||
// Step the digit under the timer cursor by dir (+1/−1), wrapping within that
|
||||
// place and keeping each field valid: minute/second tens 0-5, hours 0-23.
|
||||
void stepTimerDigit(int dir) {
|
||||
uint8_t* f; int tens_max; bool is_hours = false;
|
||||
if (_timer_cur < 2) { f = &_timer_h; tens_max = 2; is_hours = true; }
|
||||
else if (_timer_cur < 4) { f = &_timer_m; tens_max = 5; }
|
||||
else { f = &_timer_s; tens_max = 5; }
|
||||
int t = *f / 10, u = *f % 10;
|
||||
if (_timer_cur % 2 == 0) { // tens place
|
||||
t = (t + dir + (tens_max + 1)) % (tens_max + 1);
|
||||
if (is_hours && t == 2 && u > 3) u = 3; // 2X hours can't exceed 23
|
||||
} else { // units place
|
||||
int umax = (is_hours && t == 2) ? 3 : 9;
|
||||
u = (u + dir + (umax + 1)) % (umax + 1);
|
||||
}
|
||||
*f = (uint8_t)(t * 10 + u);
|
||||
}
|
||||
|
||||
// Open the DigitEditor on a numeric field (2 integer digits, no decimals).
|
||||
void editField(EditKind kind, uint8_t value, uint8_t vmax) {
|
||||
_edit_kind = kind;
|
||||
_editor.begin((float)value, 0.0f, (float)vmax, 2, 0);
|
||||
}
|
||||
|
||||
// Feed a key to the open editor, write the (live) value back to its field, and
|
||||
// close the editor when DigitEditor reports DONE/CANCELLED.
|
||||
bool feedEditor(char c) {
|
||||
DigitEditor::Result r = _editor.handleInput(c);
|
||||
uint8_t v = (uint8_t)(_editor.value + 0.5f);
|
||||
switch (_edit_kind) {
|
||||
case EK_A_HOUR: _prefs->alarm_hour = v; _alarm_dirty = true; _task->onAlarmChanged(); break;
|
||||
case EK_A_MIN: _prefs->alarm_min = v; _alarm_dirty = true; _task->onAlarmChanged(); break;
|
||||
default: break;
|
||||
}
|
||||
if (r != DigitEditor::NONE) _edit_kind = EK_NONE; // editor closed
|
||||
return true;
|
||||
}
|
||||
|
||||
// Draw a row's value column: the live editor when this row is the one being
|
||||
// edited, otherwise the static text.
|
||||
void drawValue(DisplayDriver& d, int y, int valx, int reserve, bool sel,
|
||||
EditKind mykind, const char* text) {
|
||||
if (sel && _editor.active && _edit_kind == mykind) _editor.render(d, valx, y);
|
||||
else d.drawTextEllipsized(valx, y, d.width() - valx - reserve, text);
|
||||
}
|
||||
|
||||
// ── Sub-renders ───────────────────────────────────────────────────────────
|
||||
int renderMenu(DisplayDriver& d) {
|
||||
d.drawCenteredHeader("CLOCK TOOLS");
|
||||
static const char* items[3] = { "Alarm", "Timer", "Stopwatch" };
|
||||
if (_sel > 2) _sel = 2;
|
||||
drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
drawRowSelection(d, y, sel, reserve);
|
||||
d.setCursor(4, y);
|
||||
d.print(items[i]);
|
||||
if (i == 0 && _prefs && _prefs->alarm_on) { // show the armed alarm time
|
||||
char b[8]; fmtClock(b, sizeof(b), _prefs->alarm_hour, _prefs->alarm_min);
|
||||
int vx = d.width() - d.getTextWidth(b) - reserve - 2;
|
||||
d.setCursor(vx, y); d.print(b);
|
||||
}
|
||||
});
|
||||
return 60000;
|
||||
}
|
||||
|
||||
int renderAlarm(DisplayDriver& d) {
|
||||
d.drawCenteredHeader("ALARM");
|
||||
if (!_prefs) return 60000;
|
||||
const char* rows[3] = { "Hour", "Minute", "Armed" };
|
||||
if (_sel > 2) _sel = 2;
|
||||
const int valx = d.width() / 2 + 6;
|
||||
drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
drawRowSelection(d, y, sel, reserve);
|
||||
d.setCursor(4, y); d.print(rows[i]);
|
||||
char b[6];
|
||||
if (i == 0) snprintf(b, sizeof(b), "%02u", _prefs->alarm_hour);
|
||||
else if (i == 1) snprintf(b, sizeof(b), "%02u", _prefs->alarm_min);
|
||||
else snprintf(b, sizeof(b), "%s", _prefs->alarm_on ? "ON" : "OFF");
|
||||
EditKind k = (i == 0) ? EK_A_HOUR : (i == 1) ? EK_A_MIN : EK_NONE;
|
||||
drawValue(d, y, valx, reserve, sel, k, b);
|
||||
});
|
||||
return _editor.active ? 50 : 60000;
|
||||
}
|
||||
|
||||
int renderTimer(DisplayDriver& d) {
|
||||
d.drawCenteredHeader("TIMER");
|
||||
const int cx = d.width() / 2;
|
||||
if (_task->isTimerRunning()) {
|
||||
char buf[16];
|
||||
fmtHMS(buf, sizeof(buf), _task->timerRemainingMs());
|
||||
d.setTextSize(2);
|
||||
d.drawTextCentered(cx, d.listStart() + 4, buf);
|
||||
d.setTextSize(1);
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Ent=stop");
|
||||
return liveTickMs(d, 1000);
|
||||
}
|
||||
// Setting mode: big HH:MM:SS with the digit under the cursor underlined.
|
||||
// LEFT/RIGHT move the cursor one digit, UP/DOWN change it, Enter starts.
|
||||
char buf[12];
|
||||
snprintf(buf, sizeof(buf), "%02u:%02u:%02u", _timer_h, _timer_m, _timer_s);
|
||||
d.setTextSize(2);
|
||||
const int ty = d.listStart() + 2;
|
||||
d.drawTextCentered(cx, ty, buf);
|
||||
const int cw = d.getCharWidth(); // size-2 cell width
|
||||
const int lh2 = d.getLineHeight();
|
||||
const int sx = cx - d.getTextWidth(buf) / 2;
|
||||
const int char_idx = _timer_cur + (_timer_cur / 2); // skip the two colons
|
||||
d.fillRect(sx + char_idx * cw, ty + lh2, cw - 1, 2);
|
||||
d.setTextSize(1);
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Up/Dn=set Ent=start");
|
||||
return 60000;
|
||||
}
|
||||
|
||||
int renderStopwatch(DisplayDriver& d) {
|
||||
d.drawCenteredHeader("STOPWATCH");
|
||||
const int cx = d.width() / 2;
|
||||
char buf[16];
|
||||
bool tenths = !d.isEink() && _sw_running; // tenths only on a fast panel
|
||||
fmtStopwatch(buf, sizeof(buf), stopwatchMs(), tenths);
|
||||
d.setTextSize(2);
|
||||
d.drawTextCentered(cx, d.listStart() + 4, buf);
|
||||
d.setTextSize(1);
|
||||
const char* hint = _sw_running ? "Ent=stop" : "Ent=start Dn=reset";
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, hint);
|
||||
return _sw_running ? liveTickMs(d, 100) : 60000;
|
||||
}
|
||||
|
||||
// ── Input per view ────────────────────────────────────────────────────────
|
||||
bool inputMenu(char c) {
|
||||
if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; }
|
||||
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; }
|
||||
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
_view = (_sel == 0) ? V_ALARM : (_sel == 1) ? V_TIMER : V_STOPWATCH;
|
||||
_sel = 0; _scroll = 0;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool inputAlarm(char c) {
|
||||
if (!_prefs) { if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; } return true; }
|
||||
if (_editor.active) return feedEditor(c);
|
||||
if (c == KEY_CANCEL) {
|
||||
_task->savePrefsIfDirty(_alarm_dirty); // persist alarm fields only if edited
|
||||
_task->onAlarmChanged(); // re-schedule from the new time
|
||||
_view = V_MENU; _sel = 0; _scroll = 0;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; }
|
||||
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sel == 0) editField(EK_A_HOUR, _prefs->alarm_hour, 23);
|
||||
else if (_sel == 1) editField(EK_A_MIN, _prefs->alarm_min, 59);
|
||||
else { _prefs->alarm_on ^= 1; _alarm_dirty = true; _task->onAlarmChanged(); }
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool inputTimer(char c) {
|
||||
if (_task->isTimerRunning()) {
|
||||
if (c == KEY_ENTER) { _task->stopTimer(); return true; } // stop/cancel countdown
|
||||
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps counting in background
|
||||
return true; // any other key just forces a refresh (e-ink)
|
||||
}
|
||||
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; }
|
||||
if (keyIsPrev(c)) { _timer_cur = (_timer_cur + 5) % 6; return true; } // cursor ←
|
||||
if (keyIsNext(c)) { _timer_cur = (_timer_cur + 1) % 6; return true; } // cursor →
|
||||
if (c == KEY_UP) { stepTimerDigit(+1); return true; }
|
||||
if (c == KEY_DOWN) { stepTimerDigit(-1); return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
uint32_t dur = (((uint32_t)_timer_h * 60 + _timer_m) * 60 + _timer_s) * 1000UL;
|
||||
if (dur == 0) { _task->showAlert("Set a duration", 1000); return true; }
|
||||
_task->startTimer(dur);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool inputStopwatch(char c) {
|
||||
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps running in background
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sw_running) { _sw_accum_ms += millis() - _sw_start_ms; _sw_running = false; }
|
||||
else { _sw_start_ms = millis(); _sw_running = true; }
|
||||
return true;
|
||||
}
|
||||
if ((c == KEY_UP || c == KEY_DOWN) && !_sw_running) { _sw_accum_ms = 0; return true; }
|
||||
return true; // other keys just refresh the readout
|
||||
}
|
||||
|
||||
public:
|
||||
ClockToolsScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void onShow() override {
|
||||
_view = V_MENU; _sel = 0; _scroll = 0;
|
||||
_timer_cur = 0;
|
||||
_editor.active = false; _edit_kind = EK_NONE;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
switch (_view) {
|
||||
case V_ALARM: return renderAlarm(display);
|
||||
case V_TIMER: return renderTimer(display);
|
||||
case V_STOPWATCH: return renderStopwatch(display);
|
||||
default: return renderMenu(display);
|
||||
}
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
switch (_view) {
|
||||
case V_ALARM: return inputAlarm(c);
|
||||
case V_TIMER: return inputTimer(c);
|
||||
case V_STOPWATCH: return inputStopwatch(c);
|
||||
default: return inputMenu(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -27,7 +27,7 @@ class CompassScreen : public UIScreen {
|
||||
|
||||
public:
|
||||
CompassScreen(UITask* task) : _task(task) {}
|
||||
void enter() {}
|
||||
void onShow() override {}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
|
||||
@@ -37,7 +37,7 @@ class DashboardConfigScreen : public UIScreen {
|
||||
public:
|
||||
DashboardConfigScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() { _sel = 0; _dirty = false; }
|
||||
void onShow() override { _sel = 0; _dirty = false; }
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
@@ -65,7 +65,7 @@ public:
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) the_mesh.savePrefs();
|
||||
_task->savePrefsIfDirty(_dirty);
|
||||
_task->gotoHomeScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,35 @@ static const int FS_CHARS_MAX = 80; // max bytes per wrapped line
|
||||
// fullscreen message view and the history list are never laid out in the same
|
||||
// frame (opening the fullscreen view early-returns before the list loop runs),
|
||||
// so one static buffer serves both. This keeps ~1.5 KB of line buffers off the
|
||||
// render-call stack (see CODE_REVIEW.md "Render-path stack peak") at the cost of
|
||||
// a fixed RAM allocation. Only used inside render()/wrap helpers — never across
|
||||
// a yield, so the single instance is safe.
|
||||
// render-call stack at the cost of a fixed RAM allocation. Only used inside
|
||||
// render()/wrap helpers — never across a yield, so the single instance is safe.
|
||||
static char s_wrap_trans[512];
|
||||
static char s_wrap_lines[12][FS_CHARS_MAX];
|
||||
|
||||
// Parse a leading "@[nick] " reply prefix. Returns the message body that
|
||||
// follows it (and any leading whitespace); when nick/nick_n are supplied,
|
||||
// fills nick with the addressee, or "" when there's no prefix. One parser for
|
||||
// both the history list (body only — see QuickMsgScreen::skipReplyPrefix) and
|
||||
// the fullscreen view (which also shows the "To:" nick).
|
||||
static inline const char* msgReplyBody(const char* text, char* nick = nullptr, int nick_n = 0) {
|
||||
if (nick && nick_n > 0) nick[0] = '\0';
|
||||
const char* body = text;
|
||||
if (text[0] == '@' && text[1] == '[') {
|
||||
const char* close = strchr(text + 2, ']');
|
||||
if (close && close[1] == ' ' && close[2]) {
|
||||
if (nick && nick_n > 0) {
|
||||
int len = (int)(close - text) - 2;
|
||||
if (len > nick_n - 1) len = nick_n - 1;
|
||||
memcpy(nick, text + 2, len);
|
||||
nick[len] = '\0';
|
||||
}
|
||||
body = close + 2;
|
||||
}
|
||||
}
|
||||
while (*body == '\n' || *body == '\r' || *body == ' ') body++;
|
||||
return body;
|
||||
}
|
||||
|
||||
struct FullscreenMsgView {
|
||||
int scroll;
|
||||
bool active;
|
||||
@@ -80,19 +103,9 @@ struct FullscreenMsgView {
|
||||
const int lineH = display.getLineHeight();
|
||||
const int max_px = display.width() - 6;
|
||||
|
||||
// detect @recipient at start of message
|
||||
char to_nick[32] = "";
|
||||
const char* body = text;
|
||||
if (text[0] == '@' && text[1] == '[') {
|
||||
const char* close = strchr(text + 2, ']');
|
||||
if (close && close[1] == ' ' && close[2]) {
|
||||
int len = (int)(close - text) - 2;
|
||||
if (len >= (int)sizeof(to_nick)) len = sizeof(to_nick) - 1;
|
||||
memcpy(to_nick, text + 2, len);
|
||||
to_nick[len] = '\0';
|
||||
body = close + 2;
|
||||
}
|
||||
}
|
||||
// "@[nick] " reply prefix → "To:" header + body (shared parser).
|
||||
char to_nick[32];
|
||||
const char* body = msgReplyBody(text, to_nick, sizeof(to_nick));
|
||||
|
||||
const int cw = display.getCharWidth();
|
||||
const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2);
|
||||
@@ -143,8 +156,8 @@ struct FullscreenMsgView {
|
||||
Result handleInput(char c) {
|
||||
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
|
||||
if (c == KEY_DOWN) { if (scroll < _max_scroll) scroll++; return NONE; }
|
||||
if (c == KEY_LEFT) return NEXT;
|
||||
if (c == KEY_RIGHT) return PREV;
|
||||
if (keyIsPrev(c)) return NEXT; // page between messages (encoder too)
|
||||
if (keyIsNext(c)) return PREV;
|
||||
if (c == KEY_CONTEXT_MENU) return REPLY;
|
||||
if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE;
|
||||
return NONE;
|
||||
|
||||
@@ -36,7 +36,7 @@ class LiveShareScreen : public UIScreen {
|
||||
public:
|
||||
LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() { _dirty = false; _sel = 0; _scroll = 0; }
|
||||
void onShow() override { _dirty = false; _sel = 0; _scroll = 0; }
|
||||
|
||||
// Resolve the configured target's display name (channel name or contact name).
|
||||
void currentTargetName(char* buf, int n) {
|
||||
@@ -92,7 +92,7 @@ public:
|
||||
const int valx = display.width() / 2 + 6;
|
||||
drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
Row r = rows(i);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
display.setCursor(4, y);
|
||||
display.print(r.label);
|
||||
char val[24];
|
||||
@@ -161,7 +161,7 @@ public:
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) { the_mesh.savePrefs(); _dirty = false; }
|
||||
_task->savePrefsIfDirty(_dirty);
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class LocatorScreen : public UIScreen {
|
||||
public:
|
||||
LocatorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() { _dirty = false; _sel = 0; _scroll = 0; _picking = false; }
|
||||
void onShow() override { _dirty = false; _sel = 0; _scroll = 0; _picking = false; }
|
||||
|
||||
void valueLabel(Kind k, char* buf, int n) {
|
||||
switch (k) {
|
||||
@@ -116,7 +116,7 @@ public:
|
||||
const int valx = display.width() / 2 + 6;
|
||||
drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
Row r = rows(i);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
display.setCursor(4, y);
|
||||
display.print(r.label);
|
||||
char val[24];
|
||||
@@ -265,7 +265,7 @@ public:
|
||||
display.drawCenteredHeader("PICK TARGET");
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
const Target& t = _targets[i];
|
||||
char row[36];
|
||||
if (t.kind != 1) {
|
||||
@@ -293,7 +293,7 @@ public:
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) { the_mesh.savePrefs(); _dirty = false; } // engine re-seeded per edit
|
||||
_task->savePrefsIfDirty(_dirty); // engine re-seeded per edit
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
324
examples/companion_radio/ui-new/MessageHistory.h
Normal file
324
examples/companion_radio/ui-new/MessageHistory.h
Normal file
@@ -0,0 +1,324 @@
|
||||
#pragma once
|
||||
// Message history store for QuickMsgScreen: two RAM ring buffers (channel + DM)
|
||||
// with their per-entry delivery state (channel "relayed into mesh" echo, DM
|
||||
// end-to-end ACK + auto-resend) and the per-channel unread counters. Pure
|
||||
// storage + queries — no UI/phase state lives here. The screen keeps selection,
|
||||
// scroll, the unread "viewing session" bookkeeping, the room-login table, and
|
||||
// all rendering, and reaches entries through the accessors below.
|
||||
//
|
||||
// Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h. AckState,
|
||||
// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the
|
||||
// phase machine in QuickMsgScreen keeps referring to them unqualified.
|
||||
|
||||
// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to
|
||||
// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no
|
||||
// recipient ACK exists for floods), so a missing echo is NOT shown as failure.
|
||||
enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL };
|
||||
|
||||
// History text holds a full received message. Channel messages carry the sender
|
||||
// embedded as "Name: body" in the payload, so a message can be up to the
|
||||
// over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, otherwise
|
||||
// long messages (and Polish text, where each accented char is two UTF-8 bytes)
|
||||
// get their tail clipped.
|
||||
static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1;
|
||||
|
||||
struct ChHistEntry {
|
||||
uint8_t ch_idx;
|
||||
char text[MSG_TEXT_BUF];
|
||||
uint32_t timestamp;
|
||||
uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods)
|
||||
uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed()
|
||||
};
|
||||
|
||||
struct DmHistEntry {
|
||||
uint8_t prefix[4];
|
||||
uint8_t outgoing;
|
||||
char text[MSG_TEXT_BUF];
|
||||
uint32_t timestamp;
|
||||
uint8_t ack_status; // AckState; meaningful only when outgoing
|
||||
uint32_t ack_tag; // expected_ack CRC to match against onMsgAck()
|
||||
uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive
|
||||
// Sender-perspective message timestamp: the send timestamp for outgoing
|
||||
// (reused verbatim on resend so the recipient treats it as a retry), or the
|
||||
// sender_timestamp for incoming (used to dedup retried copies). 0 = unknown.
|
||||
uint32_t msg_ts;
|
||||
uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1
|
||||
uint8_t resends_left; // remaining auto-resends before the marker shows ✗
|
||||
};
|
||||
|
||||
class MessageHistory {
|
||||
public:
|
||||
// Shared ring for all channels combined; each slot carries a full MSG_TEXT_BUF,
|
||||
// so this ring dominates RAM — kept modest to leave heap headroom (see
|
||||
// DM_HIST_MAX). The DM ring is likewise bounded.
|
||||
static const int CH_HIST_MAX = 48;
|
||||
static const int DM_HIST_MAX = 32;
|
||||
|
||||
MessageHistory()
|
||||
: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0) {
|
||||
memset(_ch_unread, 0, sizeof(_ch_unread));
|
||||
}
|
||||
|
||||
// ── Channel ring ──────────────────────────────────────────────────────────
|
||||
|
||||
// Append a channel message. `viewing` = the user is currently in this
|
||||
// channel's history (so it isn't counted unread). `timestamp` is the sender's
|
||||
// own send time (0 = unknown — use receipt time). Returns the ring position
|
||||
// (an opaque handle for armChannelRelay / chAtPos), or -1 if rejected.
|
||||
int addChannelMsg(uint8_t ch_idx, const char* text, bool viewing, uint32_t timestamp = 0) {
|
||||
// Guard against bogus channel indices (e.g. findChannelIdx() returned -1
|
||||
// and was cast to uint8_t → 255). Storing such an entry would burn a ring
|
||||
// slot for a message that no visible channel can ever surface.
|
||||
if (ch_idx >= MAX_GROUP_CHANNELS) return -1;
|
||||
int pos;
|
||||
if (_hist_count < CH_HIST_MAX) {
|
||||
pos = (_hist_head + _hist_count) % CH_HIST_MAX;
|
||||
_hist_count++;
|
||||
} else {
|
||||
pos = _hist_head;
|
||||
// Evicting the oldest entry — drop its share of the unread counter so
|
||||
// the badge can't claim a message the ring no longer holds.
|
||||
uint8_t evicted = _hist[pos].ch_idx;
|
||||
if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) {
|
||||
_ch_unread[evicted]--;
|
||||
}
|
||||
_hist_head = (_hist_head + 1) % CH_HIST_MAX;
|
||||
}
|
||||
_hist[pos].ch_idx = ch_idx;
|
||||
_hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime();
|
||||
strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1);
|
||||
_hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0';
|
||||
_hist[pos].relay_status = ACK_NONE;
|
||||
_hist[pos].relay_seq = 0;
|
||||
|
||||
if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++;
|
||||
return pos;
|
||||
}
|
||||
|
||||
// count history entries for a specific channel
|
||||
int histCountForChannel(int ch_idx) const {
|
||||
int n = 0;
|
||||
for (int i = 0; i < _hist_count; i++) {
|
||||
if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// get ring-buffer position of j-th history entry for channel (newest first)
|
||||
int histEntryForChannel(int ch_idx, int j) const {
|
||||
int n = 0;
|
||||
for (int i = _hist_count - 1; i >= 0; i--) {
|
||||
int pos = (_hist_head + i) % CH_HIST_MAX;
|
||||
if (_hist[pos].ch_idx == (uint8_t)ch_idx) {
|
||||
if (n == j) return pos;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Called when a repeater echo of one of our channel sends is heard.
|
||||
void markChannelRelayed(uint32_t seq) {
|
||||
if (seq == 0) return;
|
||||
for (int i = 0; i < _hist_count; i++) {
|
||||
ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX];
|
||||
if (e.relay_status == ACK_PENDING && e.relay_seq == seq) {
|
||||
e.relay_status = ACK_OK;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arm the "relayed into mesh" marker on a just-sent entry (pos from
|
||||
// addChannelMsg) — MyMesh tracked the flood it originated and will report a
|
||||
// heard repeater echo by seq.
|
||||
void armChannelRelay(int pos, uint32_t seq) {
|
||||
if (pos < 0 || pos >= CH_HIST_MAX) return;
|
||||
_hist[pos].relay_status = ACK_PENDING;
|
||||
_hist[pos].relay_seq = seq;
|
||||
}
|
||||
|
||||
ChHistEntry& chAtPos(int pos) { return _hist[pos]; }
|
||||
const ChHistEntry& chAtPos(int pos) const { return _hist[pos]; }
|
||||
|
||||
// ── Per-channel unread counters ─────────────────────────────────────────────
|
||||
uint8_t chUnread(int ch) const {
|
||||
return (ch >= 0 && ch < MAX_GROUP_CHANNELS) ? _ch_unread[ch] : 0;
|
||||
}
|
||||
void setChUnread(int ch, uint8_t v) {
|
||||
if (ch >= 0 && ch < MAX_GROUP_CHANNELS) _ch_unread[ch] = v;
|
||||
}
|
||||
void clearAllChannelUnread() { memset(_ch_unread, 0, sizeof(_ch_unread)); }
|
||||
int getTotalChannelUnread() const {
|
||||
int total = 0;
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i];
|
||||
return total;
|
||||
}
|
||||
|
||||
// ── DM ring ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by
|
||||
// ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming).
|
||||
// msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp
|
||||
// for incoming); resends = remaining auto-resends for an outgoing pending DM.
|
||||
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
|
||||
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0,
|
||||
uint32_t msg_ts = 0, uint8_t resends = 0) {
|
||||
int pos;
|
||||
if (_dm_hist_count < DM_HIST_MAX) {
|
||||
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
|
||||
_dm_hist_count++;
|
||||
} else {
|
||||
pos = _dm_hist_head;
|
||||
_dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX;
|
||||
}
|
||||
memcpy(_dm_hist[pos].prefix, pub_key, 4);
|
||||
_dm_hist[pos].outgoing = outgoing ? 1 : 0;
|
||||
// Prefer the sender's own timestamp — a room-sync replay or an
|
||||
// offline-queued message held by a repeater can arrive long after it was
|
||||
// actually sent, so "now" would mislabel every backlog message as fresh.
|
||||
// Fall back to receipt time only when the sender's timestamp is unknown.
|
||||
_dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime();
|
||||
strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1);
|
||||
_dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0';
|
||||
_dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE;
|
||||
_dm_hist[pos].ack_tag = ack_tag;
|
||||
_dm_hist[pos].ack_deadline_ms = ack_deadline_ms;
|
||||
_dm_hist[pos].msg_ts = msg_ts;
|
||||
_dm_hist[pos].attempt = 0;
|
||||
_dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0;
|
||||
}
|
||||
|
||||
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
|
||||
uint32_t sender_timestamp = 0) {
|
||||
// Drop retried copies of an incoming DM: a resend reuses the sender's
|
||||
// timestamp and text but carries a fresh packet hash, so the mesh dup-filter
|
||||
// lets it through. Match on prefix + sender_timestamp + text to suppress it.
|
||||
if (!outgoing && sender_timestamp != 0) {
|
||||
for (int i = 0; i < _dm_hist_count; i++) {
|
||||
const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||
if (!e.outgoing && e.msg_ts == sender_timestamp &&
|
||||
memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0)
|
||||
return; // duplicate retry — already in history
|
||||
}
|
||||
}
|
||||
storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0);
|
||||
}
|
||||
|
||||
int dmHistCountForContact(const uint8_t* prefix) const {
|
||||
int n = 0;
|
||||
for (int i = 0; i < _dm_hist_count; i++)
|
||||
if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest
|
||||
int n = 0;
|
||||
for (int i = _dm_hist_count - 1; i >= 0; i--) {
|
||||
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
|
||||
if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) {
|
||||
if (n == j) return pos;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Effective status for display. A pending ACK only reads as failed once its
|
||||
// deadline has passed AND no auto-resends remain — while resends_left > 0 the
|
||||
// entry stays pending (tickDmResends() retries / finalises it). Safety net for
|
||||
// when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL.
|
||||
AckState dmEffectiveStatus(const DmHistEntry& e) const {
|
||||
if (e.ack_status == ACK_PENDING && e.resends_left == 0 &&
|
||||
(int32_t)(millis() - e.ack_deadline_ms) >= 0)
|
||||
return ACK_FAIL;
|
||||
return (AckState)e.ack_status;
|
||||
}
|
||||
|
||||
// Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv).
|
||||
// Marks the matching pending outgoing DM as delivered.
|
||||
void markDmDelivered(uint32_t ack_crc) {
|
||||
if (ack_crc == 0) return;
|
||||
for (int i = 0; i < _dm_hist_count; i++) {
|
||||
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||
if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) {
|
||||
e.ack_status = ACK_OK;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic resend driver for outgoing DMs whose ACK deadline lapsed with no
|
||||
// ACK: resend with the next attempt# (reusing the original timestamp so the
|
||||
// recipient dedups) while resends remain, else mark it failed (✗).
|
||||
void tickDmResends() {
|
||||
uint32_t now = millis();
|
||||
for (int i = 0; i < _dm_hist_count; i++) {
|
||||
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||
if (!e.outgoing || e.ack_status != ACK_PENDING) continue;
|
||||
if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting
|
||||
if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; }
|
||||
ContactInfo c;
|
||||
if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; }
|
||||
uint32_t expected_ack = 0, est_timeout = 0;
|
||||
uint8_t next_attempt = e.attempt + 1;
|
||||
if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text,
|
||||
expected_ack, est_timeout) > 0 && expected_ack) {
|
||||
e.attempt = next_attempt;
|
||||
e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC
|
||||
e.ack_deadline_ms = now + est_timeout + 4000;
|
||||
e.resends_left--;
|
||||
} else {
|
||||
e.ack_status = ACK_FAIL; // couldn't compose/send — give up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recent DM contacts, newest first, deduped. Resolves the 4-byte _dm_hist
|
||||
// prefix to a 6-byte pub_key prefix by walking the contact list once per
|
||||
// unique sender. Returns the number filled.
|
||||
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
|
||||
int n = 0;
|
||||
for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) {
|
||||
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
|
||||
const uint8_t* p4 = _dm_hist[pos].prefix;
|
||||
// Skip if already collected.
|
||||
bool dup = false;
|
||||
for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; }
|
||||
if (dup) continue;
|
||||
// Find a real contact whose pub_key starts with this 4-byte prefix.
|
||||
for (int idx = 0; ; idx++) {
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(idx, c)) break;
|
||||
if (memcmp(c.id.pub_key, p4, 4) == 0) {
|
||||
memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
n++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; }
|
||||
const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; }
|
||||
|
||||
private:
|
||||
// Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry).
|
||||
bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const {
|
||||
int total = the_mesh.getNumContacts();
|
||||
for (int i = 0; i < total; i++) {
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(i, c)) continue;
|
||||
if (memcmp(c.id.pub_key, prefix, 4) == 0) { out = c; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ChHistEntry _hist[CH_HIST_MAX];
|
||||
int _hist_head, _hist_count;
|
||||
uint8_t _ch_unread[MAX_GROUP_CHANNELS];
|
||||
|
||||
DmHistEntry _dm_hist[DM_HIST_MAX];
|
||||
int _dm_hist_head, _dm_hist_count;
|
||||
};
|
||||
@@ -1,12 +1,7 @@
|
||||
#pragma once
|
||||
#include <math.h>
|
||||
#include "../GeoUtils.h"
|
||||
#include "NavView.h"
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
// ── Nearby Nodes ──────────────────────────────────────────────────────────────
|
||||
// One list / detail / action-menu interaction path over two sources:
|
||||
// SRC_STORED — contacts known to the mesh (distance / bearing / last-heard)
|
||||
@@ -111,14 +106,13 @@ class NearbyScreen : public UIScreen {
|
||||
|
||||
bool useImperial() const { return _task && _task->useImperial(); }
|
||||
|
||||
// Full "X ago" form for the detail view, built on the shared short-age tag
|
||||
// so there's one bucket ladder (see geo::fmtAgeShort).
|
||||
static void fmtAge(char* buf, int n, uint32_t lastmod) {
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
if (now < lastmod || lastmod == 0) { snprintf(buf, n, "unknown"); return; }
|
||||
uint32_t age = now - lastmod;
|
||||
if (age < 60) snprintf(buf, n, "%us ago", age);
|
||||
else if (age < 3600) snprintf(buf, n, "%um ago", age / 60);
|
||||
else if (age < 86400) snprintf(buf, n, "%uh ago", age / 3600);
|
||||
else snprintf(buf, n, ">1d ago");
|
||||
char s[8];
|
||||
geo::fmtAgeShort(s, sizeof(s), rtc_clock.getCurrentTime(), lastmod);
|
||||
if (!s[0]) { snprintf(buf, n, "unknown"); return; }
|
||||
snprintf(buf, n, "%s ago", s);
|
||||
}
|
||||
|
||||
static const char* typeName(uint8_t t) {
|
||||
@@ -593,7 +587,7 @@ public:
|
||||
_sort_label[0] = '\0';
|
||||
}
|
||||
|
||||
void enter() {
|
||||
void onShow() override {
|
||||
_sel = _scroll = 0;
|
||||
_detail = false;
|
||||
_nav = false;
|
||||
@@ -692,7 +686,7 @@ public:
|
||||
drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
|
||||
const Entry& e = _entries[idx];
|
||||
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
|
||||
char filt[32];
|
||||
int tx = 2;
|
||||
@@ -715,20 +709,13 @@ public:
|
||||
if (_source == SRC_SCAN) {
|
||||
snprintf(right, sizeof(right), "%d", (int)e.rssi);
|
||||
} else if (_sort == SORT_TIME) {
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
if (e.lastmod == 0 || now < e.lastmod) snprintf(right, sizeof(right), "?");
|
||||
else {
|
||||
uint32_t age = now - e.lastmod;
|
||||
if (age < 60) snprintf(right, sizeof(right), "%us", age);
|
||||
else if (age < 3600) snprintf(right, sizeof(right), "%um", age / 60);
|
||||
else snprintf(right, sizeof(right), "%uh", age / 3600);
|
||||
}
|
||||
geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod);
|
||||
if (!right[0]) snprintf(right, sizeof(right), "?"); // unknown / RTC not synced
|
||||
} else {
|
||||
if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial());
|
||||
else strncpy(right, "?GPS", sizeof(right));
|
||||
}
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2 - reserve, y);
|
||||
display.print(right);
|
||||
display.drawTextRightAlign(display.width() - reserve - 2, y, right);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -791,8 +778,8 @@ public:
|
||||
_detail_refresh_ms = millis();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_LEFT) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; }
|
||||
if (c == KEY_RIGHT) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; }
|
||||
if (keyIsPrev(c)) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; }
|
||||
if (keyIsNext(c)) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; }
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -128,7 +128,7 @@ class RepeaterScreen : public UIScreen {
|
||||
public:
|
||||
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
|
||||
|
||||
void enter() {
|
||||
void onShow() override {
|
||||
_dirty = false; _sel = 0; _scroll = 0;
|
||||
_picker.menu.active = false; _editor.freq.active = false;
|
||||
_picker.saving = false; _picker.deleting = false;
|
||||
@@ -143,39 +143,21 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawCenteredHeader("REPEATER");
|
||||
|
||||
const int item_h = display.lineStep();
|
||||
const int start_y = display.listStart();
|
||||
int visible = display.listVisible(item_h);
|
||||
if (visible < 1) visible = 1;
|
||||
|
||||
// Config only — live forwarding stats live on Tools › Diagnostics.
|
||||
int total = _item_count;
|
||||
if (_sel < _scroll) _scroll = _sel;
|
||||
if (_sel >= _scroll + visible) _scroll = _sel - visible + 1;
|
||||
int max_scroll = total - visible;
|
||||
if (max_scroll < 0) max_scroll = 0;
|
||||
if (_scroll > max_scroll) _scroll = max_scroll;
|
||||
if (_scroll < 0) _scroll = 0;
|
||||
|
||||
const int reserve = scrollIndicatorReserve(display, total, visible);
|
||||
for (int i = 0; i < visible && (_scroll + i) < total; i++) {
|
||||
int row = _scroll + i;
|
||||
int y = start_y + i * item_h;
|
||||
char val[16];
|
||||
bool sel = (row == _sel);
|
||||
drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
|
||||
int item = _items[row];
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
display.setCursor(2, y);
|
||||
display.print(itemLabel(item));
|
||||
if (item == IT_RFREQ && sel && _editor.active()) {
|
||||
_editor.render(display, display.valCol(), y);
|
||||
} else {
|
||||
char val[16];
|
||||
itemValue(item, p, val, sizeof(val));
|
||||
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
drawScrollIndicator(display, start_y, visible * item_h, total, visible, _scroll);
|
||||
});
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (_picker.menu.active) _picker.menu.render(display);
|
||||
return (_picker.menu.active || _editor.active()) ? 50 : 500;
|
||||
@@ -229,7 +211,7 @@ public:
|
||||
}
|
||||
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) the_mesh.savePrefs();
|
||||
_task->savePrefsIfDirty(_dirty);
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ class RingtoneEditorScreen : public UIScreen {
|
||||
public:
|
||||
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _slot(0) {}
|
||||
|
||||
void enter(int slot = 0) {
|
||||
// Load a melody slot for editing. Called by gotoRingtoneEditor() right after
|
||||
// setCurrScreen() (whose onShow() can't carry the slot argument).
|
||||
void selectSlot(int slot = 0) {
|
||||
_slot = (slot == 1) ? 1 : 0;
|
||||
bool s2 = (_slot == 1);
|
||||
_bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5)
|
||||
@@ -164,8 +166,8 @@ public:
|
||||
bool handleInput(char c) override {
|
||||
bool up = (c == KEY_UP);
|
||||
bool down = (c == KEY_DOWN);
|
||||
bool left = (c == KEY_LEFT || c == KEY_PREV);
|
||||
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
|
||||
bool left = keyIsPrev(c);
|
||||
bool right = keyIsNext(c);
|
||||
bool enter = (c == KEY_ENTER);
|
||||
bool menu_key = (c == KEY_CONTEXT_MENU);
|
||||
bool cancel = (c == KEY_CANCEL);
|
||||
@@ -197,7 +199,7 @@ public:
|
||||
break;
|
||||
case MI_SWITCH:
|
||||
_task->stopMelody();
|
||||
this->enter(1 - _slot);
|
||||
this->selectSlot(1 - _slot);
|
||||
break;
|
||||
case MI_DURATION: break; // already handled by LEFT/RIGHT
|
||||
case MI_BPM: break; // already handled by LEFT/RIGHT
|
||||
|
||||
@@ -422,7 +422,7 @@ class SettingsScreen : public UIScreen {
|
||||
void renderItem(DisplayDriver& display, int item, int y, bool sel) {
|
||||
NodePrefs* p = _task->getNodePrefs();
|
||||
|
||||
display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel);
|
||||
drawRowSelection(display, y, sel, _reserve);
|
||||
|
||||
display.setCursor(2, y);
|
||||
|
||||
@@ -651,7 +651,7 @@ public:
|
||||
}
|
||||
|
||||
|
||||
void markClean() {
|
||||
void onShow() override {
|
||||
_dirty = false;
|
||||
resetList();
|
||||
_editor.freq.active = false;
|
||||
@@ -671,7 +671,7 @@ public:
|
||||
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
|
||||
_reserve = reserve;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
display.setCursor(2, y);
|
||||
display.print(collapsed ? "+" : "-");
|
||||
display.print(" ");
|
||||
@@ -754,7 +754,7 @@ public:
|
||||
}
|
||||
|
||||
if (c == KEY_CANCEL) {
|
||||
if (_dirty) the_mesh.savePrefs();
|
||||
_task->savePrefsIfDirty(_dirty);
|
||||
_task->gotoHomeScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public:
|
||||
ToolsScreen(UITask* task) : _task(task) {}
|
||||
|
||||
// Open folded at the section list each time Tools is entered from Home.
|
||||
void enter() {
|
||||
void onShow() override {
|
||||
static uint8_t sizes[SECTION_COUNT];
|
||||
for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count;
|
||||
_acc.begin(sizes, SECTION_COUNT);
|
||||
@@ -75,7 +75,7 @@ public:
|
||||
_acc.render(display,
|
||||
// Section header: "[+/-] <icon> Name"
|
||||
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
display.setCursor(2, y);
|
||||
display.print(collapsed ? "+" : "-");
|
||||
const int icon_x = 2 + cw + 2;
|
||||
@@ -85,7 +85,7 @@ public:
|
||||
},
|
||||
// Item: indented "<icon> Label"
|
||||
[&](int sec, int item, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
const int icon_x = 2 + cw + 2; // align item icons under the header icon
|
||||
// drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon); // icons disabled for now, don't fit visually
|
||||
display.setCursor(icon_x + g, y);
|
||||
|
||||
@@ -72,14 +72,14 @@ class TrailScreen : public UIScreen {
|
||||
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
|
||||
// the labels live in member buffers that get refreshed in openActionMenu()
|
||||
// and after every LEFT/RIGHT cycle.
|
||||
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS,
|
||||
ACT_SHARE_NOW };
|
||||
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_MARK_AVG, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS,
|
||||
ACT_SHARE_NOW, ACT_TRACKBACK };
|
||||
// The action popup is multi-level: a short main menu, plus "Trail file…" and
|
||||
// "Settings…" submenus. _menu_level tracks which is open so input is routed
|
||||
// correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter).
|
||||
// Live-share *config* lives in its own tool (Tools › Live Share); the map only
|
||||
// keeps the one-shot "Share my pos" action.
|
||||
enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS };
|
||||
enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS, ML_CONFIRM_GPS };
|
||||
PopupMenu _action_menu;
|
||||
uint8_t _menu_level = ML_MAIN;
|
||||
uint8_t _act_map[16]; // max rows on any one level; pushAction guards the cap
|
||||
@@ -88,6 +88,7 @@ class TrailScreen : public UIScreen {
|
||||
char _act_units_label[24];
|
||||
char _act_grid_label[16];
|
||||
char _act_autopause_label[24];
|
||||
char _act_mark_avg_label[24];
|
||||
char _act_toggle_label[20];
|
||||
|
||||
static const int SUMMARY_ITEM_COUNT = 5;
|
||||
@@ -96,7 +97,7 @@ public:
|
||||
TrailScreen(UITask* task, TrailStore* store)
|
||||
: _task(task), _store(store), _wp(task, store) {}
|
||||
|
||||
void enter() {
|
||||
void onShow() override {
|
||||
_view = V_SUMMARY;
|
||||
_summary_scroll = 0;
|
||||
_list_scroll = 0;
|
||||
@@ -107,9 +108,10 @@ public:
|
||||
_wp.reset();
|
||||
}
|
||||
|
||||
// Enter straight into the Map view (used by the Home "Map" page). Remembers
|
||||
// that we came from Home so KEY_CANCEL returns there, not to Tools.
|
||||
void enterMap() { enter(); _view = V_MAP; _return_home = true; }
|
||||
// Switch straight into the Map view (used by the Home "Map" page). Layered on
|
||||
// top of onShow()'s reset by gotoMapScreen(); remembers we came from Home so
|
||||
// KEY_CANCEL returns there, not to Tools.
|
||||
void showMapView() { _view = V_MAP; _return_home = true; }
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
@@ -135,6 +137,10 @@ public:
|
||||
return _store->isActive() ? 1000 : 5000;
|
||||
}
|
||||
|
||||
// Forward the loop tick to the waypoint UI so GPS averaging (Mark avg) can
|
||||
// sample independently of the render cadence (slow on e-ink).
|
||||
void poll() override { _wp.poll(); }
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// Waypoint management UI consumes all input while active.
|
||||
if (_wp.active()) return _wp.handleInput(c);
|
||||
@@ -143,8 +149,8 @@ public:
|
||||
if (_action_menu.active) {
|
||||
// LEFT/RIGHT cycles the focused value — only meaningful in the Settings
|
||||
// submenu; the popup stays open so the user can keep tapping.
|
||||
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) {
|
||||
int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1;
|
||||
if (keyIsPrev(c) || keyIsNext(c)) {
|
||||
int dir = keyIsNext(c) ? 1 : -1;
|
||||
int idx = _action_menu.selectedIndex();
|
||||
if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS)
|
||||
cycleSetting((ActionId)_act_map[idx], dir);
|
||||
@@ -152,6 +158,16 @@ public:
|
||||
}
|
||||
auto res = _action_menu.handleInput(c);
|
||||
if (res == PopupMenu::SELECTED) {
|
||||
// GPS-off confirmation popup: rows aren't ActionIds, route by level.
|
||||
if (_menu_level == ML_CONFIRM_GPS) {
|
||||
if (_action_menu.selectedIndex() == 0) { // "Enable GPS & start"
|
||||
_task->toggleGPS(); // off → on (persists, shows GPS alert)
|
||||
_store->setActive(true);
|
||||
_task->showAlert("GPS on, tracking started", 1200);
|
||||
}
|
||||
_menu_level = ML_MAIN; // popup already closed by handleInput()
|
||||
return true;
|
||||
}
|
||||
int sel = _action_menu.selectedIndex();
|
||||
ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE;
|
||||
switch (act) {
|
||||
@@ -161,35 +177,46 @@ public:
|
||||
case ACT_MIN_DIST:
|
||||
case ACT_UNITS:
|
||||
case ACT_GRID:
|
||||
case ACT_MARK_AVG:
|
||||
case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
|
||||
case ACT_SHARE_NOW: shareMyLocationNow(); break;
|
||||
case ACT_TOGGLE: handleToggle(); break;
|
||||
case ACT_TOGGLE:
|
||||
// Starting a trail with GPS switched off logs nothing and just
|
||||
// sits on "Waiting for GPS fix" forever -- prompt to enable it
|
||||
// first (only on boards that actually have a toggleable GPS).
|
||||
if (!_store->isActive() && _task->hasGPS() && !_task->getGPSState()) {
|
||||
buildGpsConfirmMenu();
|
||||
return true;
|
||||
}
|
||||
handleToggle();
|
||||
break;
|
||||
case ACT_MARK: _wp.markHere(); break;
|
||||
case ACT_WAYPOINTS: _wp.openList(); break;
|
||||
case ACT_TRACKBACK: _wp.startTrackBack(); break;
|
||||
case ACT_SAVE: handleSave(); break;
|
||||
case ACT_LOAD: handleLoad(); break;
|
||||
case ACT_RESET: handleReset(); break;
|
||||
case ACT_EXPORT: handleExport(); break;
|
||||
case ACT_EXPORT_SAVED: handleExportSaved(); break;
|
||||
}
|
||||
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
|
||||
_task->savePrefsIfDirty(_cfg_dirty);
|
||||
} else if (res == PopupMenu::CANCELLED) {
|
||||
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
|
||||
_task->savePrefsIfDirty(_cfg_dirty);
|
||||
if (_menu_level != ML_MAIN) buildMainMenu(); // Cancel backs out of a submenu
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c == KEY_CANCEL) {
|
||||
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
|
||||
_task->savePrefsIfDirty(_cfg_dirty);
|
||||
if (_return_home) _task->gotoHomeScreen();
|
||||
else _task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
|
||||
|
||||
if (c == KEY_LEFT || c == KEY_PREV) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; }
|
||||
if (c == KEY_RIGHT || c == KEY_NEXT) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; }
|
||||
if (keyIsPrev(c)) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; }
|
||||
if (keyIsNext(c)) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; }
|
||||
if (_view == V_SUMMARY && c == KEY_UP) {
|
||||
_summary_scroll = (_summary_scroll > 0) ? _summary_scroll - 1 : _summary_max_scroll;
|
||||
return true;
|
||||
@@ -298,6 +325,8 @@ private:
|
||||
"Grid: %s", _map_grid ? "ON" : "OFF");
|
||||
snprintf(_act_autopause_label, sizeof(_act_autopause_label),
|
||||
"Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0));
|
||||
snprintf(_act_mark_avg_label, sizeof(_act_mark_avg_label),
|
||||
"Mark avg: %s", NodePrefs::gpsAvgLabel(p ? p->gps_avg_idx : 0));
|
||||
snprintf(_act_toggle_label, sizeof(_act_toggle_label),
|
||||
"%s tracking", _store->isActive() ? "Stop" : "Start");
|
||||
}
|
||||
@@ -325,11 +354,23 @@ private:
|
||||
// "Waypoints" opens the nav list — always available; it hosts the list,
|
||||
// backtrack (Trail-start row) and the "+ Add by coords" entry.
|
||||
pushAction(ACT_WAYPOINTS, "Waypoints...");
|
||||
// Track back retraces the recorded route to its start; needs ≥2 points.
|
||||
if (_store->count() >= 2) pushAction(ACT_TRACKBACK, "Track back");
|
||||
pushAction(ACT_SHARE_NOW, "Share my pos");
|
||||
if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file...");
|
||||
pushAction(ACT_SETTINGS, "Settings...");
|
||||
}
|
||||
|
||||
// Confirmation shown when "Start tracking" is chosen with GPS off. Rows are
|
||||
// plain (not ActionIds); handled by _menu_level in the SELECTED branch.
|
||||
void buildGpsConfirmMenu() {
|
||||
_menu_level = ML_CONFIRM_GPS;
|
||||
_act_count = 0;
|
||||
_action_menu.begin("GPS is off", 2);
|
||||
_action_menu.addItem("Enable GPS & start");
|
||||
_action_menu.addItem("Cancel");
|
||||
}
|
||||
|
||||
// Trail-file submenu — only the operations that make sense right now.
|
||||
void buildFileMenu() {
|
||||
_menu_level = ML_FILE;
|
||||
@@ -352,6 +393,7 @@ private:
|
||||
_action_menu.begin("Settings", 4);
|
||||
pushAction(ACT_MIN_DIST, _act_min_dist_label);
|
||||
pushAction(ACT_AUTOPAUSE, _act_autopause_label);
|
||||
pushAction(ACT_MARK_AVG, _act_mark_avg_label);
|
||||
if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label);
|
||||
if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label);
|
||||
}
|
||||
@@ -363,6 +405,7 @@ private:
|
||||
if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; }
|
||||
if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
if (act == ACT_MARK_AVG && p){ cycleMarkAvg(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -405,6 +448,13 @@ private:
|
||||
: (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT;
|
||||
p->trail_autopause_idx = idx;
|
||||
}
|
||||
void cycleMarkAvg(NodePrefs* p, int dir) {
|
||||
uint8_t idx = p->gps_avg_idx;
|
||||
if (idx >= NodePrefs::GPS_AVG_COUNT) idx = 0;
|
||||
idx = (dir > 0) ? (idx + 1) % NodePrefs::GPS_AVG_COUNT
|
||||
: (idx + NodePrefs::GPS_AVG_COUNT - 1) % NodePrefs::GPS_AVG_COUNT;
|
||||
p->gps_avg_idx = idx;
|
||||
}
|
||||
|
||||
// One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages
|
||||
// screen, where the user picks a DM or channel recipient. (Auto live-share
|
||||
|
||||
@@ -116,9 +116,21 @@ public:
|
||||
static const int QUICK_MSGS_MAX = 10;
|
||||
|
||||
|
||||
// ── Screen fragments — included into THIS translation unit only ───────────────
|
||||
// These headers are not standalone: they are compiled solely as part of
|
||||
// UITask.cpp, in the order below. Two consequences a new screen must respect:
|
||||
// • Order matters. A `static inline` helper (drawList, msgReplyBody, geo::…)
|
||||
// or a shared scratch buffer (FullscreenMsgView's s_wrap_*) is only visible
|
||||
// to fragments included *after* the one that defines it. Add new screens
|
||||
// after their dependencies.
|
||||
// • Single-TU only. Some fragments define external-linkage symbols at file
|
||||
// scope (e.g. NearbyScreen::FILTER_LABELS), so including any of them from a
|
||||
// second .cpp is a duplicate-symbol link error. Keep them UITask-internal;
|
||||
// anything genuinely shareable belongs in a real header (icons.h, GeoUtils.h).
|
||||
#include "FullscreenMsgView.h"
|
||||
#include "SensorPlaceholders.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "MessageHistory.h" // RAM history rings (DM + channel) used by QuickMsgScreen
|
||||
#include "QuickMsgScreen.h"
|
||||
|
||||
// ── Custom screens (separate files to ease upstream merges) ───────────────────
|
||||
@@ -134,6 +146,7 @@ static const int QUICK_MSGS_MAX = 10;
|
||||
#include "DiagnosticsScreen.h"
|
||||
#include "RepeaterScreen.h"
|
||||
#include "ToolsScreen.h"
|
||||
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter)
|
||||
|
||||
#ifndef BATT_MIN_MILLIVOLTS
|
||||
#define BATT_MIN_MILLIVOLTS 3200
|
||||
@@ -462,6 +475,13 @@ class HomeScreen : public UIScreen {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Alarm armed — a persistent status cue (like mute), always shown (no blink).
|
||||
if (_node_prefs && _node_prefs->alarm_on) {
|
||||
int alX = battLeftX - ind - ind_gap;
|
||||
drawBoxedIcon(display, alX, ind, ind_h, ICON_ALARM);
|
||||
battLeftX = alX;
|
||||
}
|
||||
|
||||
// BT connection indicator (left of muted/battery icons)
|
||||
int leftmostX = battLeftX;
|
||||
if (_task->isSerialEnabled()) {
|
||||
@@ -791,6 +811,16 @@ public:
|
||||
snprintf(buf, sizeof(buf),"%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year);
|
||||
display.drawTextCentered(display.width() / 2, date_y, buf);
|
||||
|
||||
// Alarm armed: a small bell in the top-left corner. The status bar (and
|
||||
// its bell) is hidden on this page, so signal the armed alarm here. Just
|
||||
// the glyph — no time text — so it stays clear of the centred clock
|
||||
// digits (which can reach the corner when seconds are shown), matching
|
||||
// the icon-only status-bar indicator. The exact time is in Clock Tools.
|
||||
if (_node_prefs && _node_prefs->alarm_on) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
miniIconDrawTop(display, 0, 0, ICON_ALARM);
|
||||
}
|
||||
|
||||
int sep_y = date_y + lh + 1;
|
||||
int dash0 = sep_y + display.sepH() + 2;
|
||||
display.fillRect(0, sep_y, display.width(), display.sepH());
|
||||
@@ -1372,6 +1402,10 @@ public:
|
||||
_shutdown_init = true; // need to wait for button to be released
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::CLOCK) {
|
||||
_task->gotoClockTools(); // Alarm / Timer / Stopwatch
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) {
|
||||
_task->gotoDashboardConfig();
|
||||
return true;
|
||||
@@ -1470,6 +1504,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
compass_screen = new CompassScreen(this);
|
||||
diag_screen = new DiagnosticsScreen(this);
|
||||
repeater_screen = new RepeaterScreen(this);
|
||||
clock_tools = new ClockToolsScreen(this, node_prefs);
|
||||
applyBrightness();
|
||||
applyFont();
|
||||
applyRotation();
|
||||
@@ -1477,74 +1512,110 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
void UITask::gotoSettingsScreen() {
|
||||
((SettingsScreen*)settings)->markClean();
|
||||
setCurrScreen(settings);
|
||||
// onShow() is invoked by setCurrScreen(), so most navigators are just that.
|
||||
void UITask::gotoSettingsScreen() { setCurrScreen(settings); }
|
||||
void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); }
|
||||
void UITask::gotoBotScreen() { setCurrScreen(bot_screen); }
|
||||
void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); }
|
||||
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
|
||||
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
|
||||
void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
|
||||
void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); }
|
||||
void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); }
|
||||
void UITask::gotoClockTools() { setCurrScreen(clock_tools); }
|
||||
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
|
||||
|
||||
void UITask::wakeForAlarm() {
|
||||
if (_display != NULL) _display->turnOn();
|
||||
_next_refresh = 0; // draw the alert overlay immediately
|
||||
}
|
||||
|
||||
void UITask::gotoToolsScreen() {
|
||||
((ToolsScreen*)tools_screen)->enter();
|
||||
setCurrScreen(tools_screen);
|
||||
// ── Clock tools engine (alarm / countdown / ring) ───────────────────────────
|
||||
// Lives here, not in ClockToolsScreen, so it fires regardless of the current
|
||||
// screen. The melody overrides mute (playMelody → buzzer.playForced); the ring
|
||||
// auto-stops after CLOCK_RING_MS if no key dismisses it (see UITask::loop).
|
||||
static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p";
|
||||
static const uint32_t CLOCK_RING_MS = 60000;
|
||||
static const uint32_t CLOCK_ALARM_CATCHUP_SECS = 6 * 3600; // fire late up to 6 h, else reschedule
|
||||
|
||||
// Next absolute wall instant matching alarm_hour:alarm_min in local time,
|
||||
// strictly after now_wall (an alarm set to the current minute waits a day).
|
||||
uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const {
|
||||
int tz = _node_prefs ? _node_prefs->tz_offset_hours : 0;
|
||||
int64_t now_local = (int64_t)now_wall + (int64_t)tz * 3600;
|
||||
time_t t = (time_t)now_local;
|
||||
struct tm* ti = gmtime(&t);
|
||||
int64_t sod = ti->tm_hour * 3600 + ti->tm_min * 60 + ti->tm_sec; // secs since local midnight
|
||||
int64_t target = (now_local - sod)
|
||||
+ (int64_t)_node_prefs->alarm_hour * 3600 + (int64_t)_node_prefs->alarm_min * 60;
|
||||
if (target <= now_local) target += 86400;
|
||||
return (uint32_t)(target - (int64_t)tz * 3600);
|
||||
}
|
||||
|
||||
void UITask::fireClockAlert(const char* label) {
|
||||
snprintf(_ring_label, sizeof(_ring_label), "%s", label);
|
||||
_ringing = true;
|
||||
_ring_until_ms = millis() + CLOCK_RING_MS;
|
||||
wakeForAlarm();
|
||||
showAlert(label, CLOCK_RING_MS);
|
||||
playMelody(CLOCK_ALARM_MELODY);
|
||||
}
|
||||
|
||||
void UITask::evaluateAlarm() {
|
||||
if (!_node_prefs || !_node_prefs->alarm_on) return;
|
||||
uint32_t now_ms = millis();
|
||||
if (now_ms - _alarm_check_ms < 500) return; // ~2 Hz is plenty for a minute alarm
|
||||
_alarm_check_ms = now_ms;
|
||||
uint32_t now_wall = rtc_clock.getCurrentTime();
|
||||
if (now_wall < 1000000000UL) return; // need a real time sync first
|
||||
if (_alarm_next_fire == 0) _alarm_next_fire = computeAlarmNextFire(now_wall);
|
||||
if (now_wall < _alarm_next_fire) return;
|
||||
if (now_wall - _alarm_next_fire < CLOCK_ALARM_CATCHUP_SECS) {
|
||||
char lbl[20];
|
||||
snprintf(lbl, sizeof(lbl), "Alarm %02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min);
|
||||
_node_prefs->alarm_on = 0; // one-shot
|
||||
bool dirty = true; savePrefsIfDirty(dirty);
|
||||
_alarm_next_fire = 0;
|
||||
fireClockAlert(lbl);
|
||||
} else {
|
||||
// Clock jumped implausibly far past the target — reschedule rather than
|
||||
// ringing absurdly late.
|
||||
_alarm_next_fire = computeAlarmNextFire(now_wall);
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::tickClockTools() {
|
||||
uint32_t now_ms = millis();
|
||||
// Ring maintenance: repeat the melody until dismissed or the window elapses.
|
||||
if (_ringing) {
|
||||
if (now_ms >= _ring_until_ms) { stopMelody(); _ringing = false; clearAlert(); }
|
||||
else if (!isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY);
|
||||
}
|
||||
// Countdown timer (millis — sync-immune).
|
||||
if (_timer_running && now_ms >= _timer_deadline_ms) {
|
||||
_timer_running = false;
|
||||
fireClockAlert("Timer done");
|
||||
}
|
||||
// Alarm (wall clock — absolute schedule for sync robustness).
|
||||
evaluateAlarm();
|
||||
}
|
||||
|
||||
// Ringtone takes a slot argument that onShow() can't carry — pass it after the
|
||||
// reset (setCurrScreen's onShow runs first, then this layers the slot on top).
|
||||
void UITask::gotoRingtoneEditor(int slot) {
|
||||
((RingtoneEditorScreen*)ringtone_edit)->enter(slot);
|
||||
setCurrScreen(ringtone_edit);
|
||||
((RingtoneEditorScreen*)ringtone_edit)->selectSlot(slot);
|
||||
}
|
||||
|
||||
void UITask::gotoBotScreen() {
|
||||
((BotScreen*)bot_screen)->enter();
|
||||
setCurrScreen(bot_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoNearbyScreen() {
|
||||
((NearbyScreen*)nearby_screen)->enter();
|
||||
setCurrScreen(nearby_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoDashboardConfig() {
|
||||
((DashboardConfigScreen*)dashboard_config)->enter();
|
||||
setCurrScreen(dashboard_config);
|
||||
}
|
||||
|
||||
void UITask::gotoTrailScreen() {
|
||||
((TrailScreen*)trail_screen)->enter();
|
||||
setCurrScreen(trail_screen);
|
||||
}
|
||||
|
||||
// Map is a sub-view variant of the Trail screen: reset via onShow(), then
|
||||
// switch into the map view.
|
||||
void UITask::gotoMapScreen() {
|
||||
((TrailScreen*)trail_screen)->enterMap();
|
||||
setCurrScreen(trail_screen);
|
||||
((TrailScreen*)trail_screen)->showMapView();
|
||||
}
|
||||
|
||||
void UITask::gotoCompassScreen() {
|
||||
((CompassScreen*)compass_screen)->enter();
|
||||
setCurrScreen(compass_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoDiagnosticsScreen() {
|
||||
setCurrScreen(diag_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoRepeaterScreen() {
|
||||
((RepeaterScreen*)repeater_screen)->enter();
|
||||
setCurrScreen(repeater_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoLiveShareScreen() {
|
||||
((LiveShareScreen*)live_share_screen)->enter();
|
||||
setCurrScreen(live_share_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoLocatorScreen() {
|
||||
((LocatorScreen*)locator_screen)->enter();
|
||||
setCurrScreen(locator_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoAutoAdvertScreen() {
|
||||
((AutoAdvertScreen*)auto_advert_screen)->enter();
|
||||
setCurrScreen(auto_advert_screen);
|
||||
}
|
||||
void UITask::gotoLocatorScreen() { setCurrScreen(locator_screen); }
|
||||
void UITask::gotoAutoAdvertScreen() { setCurrScreen(auto_advert_screen); }
|
||||
|
||||
// Public method to handle ping result callback
|
||||
void UITask::handlePingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) {
|
||||
@@ -1643,9 +1714,9 @@ int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN],
|
||||
return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max);
|
||||
}
|
||||
|
||||
void UITask::addChannelMsg(uint8_t channel_idx, const char* text) {
|
||||
void UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp) {
|
||||
_last_notif_ch_idx = (int)channel_idx;
|
||||
((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text);
|
||||
((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text, timestamp);
|
||||
}
|
||||
|
||||
int UITask::getChannelUnreadCount() const {
|
||||
@@ -1660,6 +1731,15 @@ void UITask::onChannelRelayed(uint32_t seq) {
|
||||
((QuickMsgScreen*)quick_msg)->markChannelRelayed(seq);
|
||||
}
|
||||
|
||||
void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
|
||||
((QuickMsgScreen*)quick_msg)->onRoomLoginResult(pub_key, success, permissions);
|
||||
// Unlike the keypress-driven showAlert() calls elsewhere, this fires from a
|
||||
// background mesh response with no keypress to schedule a redraw — without
|
||||
// forcing one, the alert's short expiry can lapse before the next scheduled
|
||||
// refresh ever draws it.
|
||||
_next_refresh = 0;
|
||||
}
|
||||
|
||||
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp) {
|
||||
((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp);
|
||||
}
|
||||
@@ -1783,10 +1863,23 @@ void UITask::userLedHandler() {
|
||||
}
|
||||
|
||||
void UITask::setCurrScreen(UIScreen* c) {
|
||||
// Fail safe on a null target: a screen pointer left uninitialised (member
|
||||
// declared + navigator wired, but the `new XScreen()` line forgotten in
|
||||
// begin()) stays nullptr thanks to the in-class initialisers. Bail here so
|
||||
// that mistake is an inert no-op instead of a null deref in render()/poll().
|
||||
if (!c) return;
|
||||
curr = c;
|
||||
c->onShow(); // central per-visit reset hook (see UIScreen::onShow)
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
bool UITask::savePrefsIfDirty(bool& dirty) {
|
||||
if (!dirty) return false;
|
||||
the_mesh.savePrefs();
|
||||
dirty = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
hardware-agnostic pre-shutdown activity should be done here
|
||||
*/
|
||||
@@ -2015,6 +2108,14 @@ void UITask::loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// A ringing alarm/timer is dismissed by ANY key, even when locked or on another
|
||||
// screen — and the queued keys are swallowed so they don't also act on the view.
|
||||
if (_kq_head != _kq_tail && isRinging()) {
|
||||
dismissRing();
|
||||
_kq_head = _kq_tail = 0;
|
||||
_next_refresh = 0;
|
||||
}
|
||||
|
||||
if (_kq_head != _kq_tail) {
|
||||
if (!_locked && curr) {
|
||||
// Apply the whole queued burst, then redraw once — N taps captured during
|
||||
@@ -2048,6 +2149,10 @@ void UITask::loop() {
|
||||
|
||||
if (curr) curr->poll();
|
||||
|
||||
// Alarm + countdown run regardless of the current screen / display state, so
|
||||
// they're driven here (not via the current screen's poll()).
|
||||
tickClockTools();
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
if (_locked && millis() > _lock_wake_until) {
|
||||
_display->turnOff();
|
||||
@@ -2157,7 +2262,7 @@ void UITask::loop() {
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
}
|
||||
#endif
|
||||
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) {
|
||||
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off && !isRinging()) {
|
||||
_display->turnOff();
|
||||
#ifdef PIN_LED
|
||||
digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power
|
||||
@@ -2410,6 +2515,85 @@ void UITask::clearTarget() {
|
||||
resetLocator();
|
||||
}
|
||||
|
||||
void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) {
|
||||
if (!_node_prefs || !_node_prefs->locator_has_target || _node_prefs->locator_target_kind != 0) return;
|
||||
if (_node_prefs->locator_lat_1e6 != lat_1e6 || _node_prefs->locator_lon_1e6 != lon_1e6) return;
|
||||
clearTarget();
|
||||
the_mesh.savePrefs();
|
||||
}
|
||||
|
||||
// CONTRACT: every NodePrefs field that keys on a contact pubkey/prefix is
|
||||
// cleared here, so a removed contact can't leave a dangling reference. If you
|
||||
// add such a field, add its cleanup below (and mark the field in NodePrefs.h).
|
||||
// Currently covered: favourite_contacts, locator_key, loc_share_dm_prefix,
|
||||
// dm_notif[], dm_melody[]. Called for both explicit removal and silent
|
||||
// auto-eviction (see MyMesh CMD_REMOVE_CONTACT / onContactOverwrite).
|
||||
void UITask::onContactRemoved(const uint8_t* pub_key) {
|
||||
if (!_node_prefs || !pub_key) return;
|
||||
bool changed = false;
|
||||
|
||||
int slot = findFavouriteSlot(pub_key);
|
||||
if (slot >= 0) { clearFavouriteSlot(slot); changed = true; }
|
||||
|
||||
if (_node_prefs->locator_has_target && _node_prefs->locator_target_kind == 1
|
||||
&& memcmp(_node_prefs->locator_key, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
|
||||
clearTarget();
|
||||
changed = true;
|
||||
}
|
||||
// Fail closed rather than guess a new recipient: a contact target that's
|
||||
// gone just turns auto-share off, it doesn't fall back to some other target.
|
||||
if (_node_prefs->loc_share_target_type == 1
|
||||
&& memcmp(_node_prefs->loc_share_dm_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
|
||||
_node_prefs->loc_share_enabled = 0;
|
||||
changed = true;
|
||||
}
|
||||
// Per-contact mute/melody overrides — only 16 slots shared across every
|
||||
// contact, so an orphaned entry isn't just stale, it can eventually starve
|
||||
// new overrides for contacts that still exist. Keyed by a 4-byte prefix
|
||||
// (narrower than the 6-byte one above), so compare only that many bytes.
|
||||
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
|
||||
if (_node_prefs->dm_notif[i].state && memcmp(_node_prefs->dm_notif[i].prefix, pub_key, 4) == 0) {
|
||||
memset(&_node_prefs->dm_notif[i], 0, sizeof(_node_prefs->dm_notif[i]));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
|
||||
if (_node_prefs->dm_melody[i].slot && memcmp(_node_prefs->dm_melody[i].prefix, pub_key, 4) == 0) {
|
||||
memset(&_node_prefs->dm_melody[i], 0, sizeof(_node_prefs->dm_melody[i]));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) the_mesh.savePrefs();
|
||||
}
|
||||
|
||||
// CONTRACT: every NodePrefs field that keys on a channel index is cleared here,
|
||||
// so a channel re-added at a freed slot can't inherit the old one's settings.
|
||||
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
|
||||
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*.
|
||||
void UITask::onChannelRemoved(uint8_t channel_idx) {
|
||||
if (!_node_prefs) return;
|
||||
bool changed = false;
|
||||
|
||||
if (_node_prefs->bot_channel_enabled && _node_prefs->bot_channel_idx == channel_idx) {
|
||||
_node_prefs->bot_channel_enabled = 0;
|
||||
changed = true;
|
||||
}
|
||||
// Fail closed, same policy as onContactRemoved()'s Live Share case.
|
||||
if (_node_prefs->loc_share_target_type == 0 && _node_prefs->loc_share_channel_idx == channel_idx) {
|
||||
_node_prefs->loc_share_enabled = 0;
|
||||
changed = true;
|
||||
}
|
||||
uint64_t mask = 1ULL << channel_idx;
|
||||
if (_node_prefs->ch_notif_melody_set & mask) {
|
||||
_node_prefs->ch_notif_melody_set &= ~mask;
|
||||
_node_prefs->ch_notif_melody_2 &= ~mask;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) the_mesh.savePrefs();
|
||||
}
|
||||
|
||||
// Homing beeper: while armed with a target and inside the radius, emit a short
|
||||
// tick whose interval shrinks linearly with distance — slow at the edge, rapid
|
||||
// near the centre. Polls distance a few times a second; silent outside the
|
||||
@@ -2627,6 +2811,16 @@ bool UITask::getGPSState() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UITask::hasGPS() {
|
||||
if (_sensors != NULL) {
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void UITask::toggleGPS() {
|
||||
if (_sensors != NULL) {
|
||||
// toggle GPS on/off
|
||||
|
||||
@@ -68,23 +68,29 @@ class UITask : public AbstractUITask {
|
||||
unsigned long _analogue_pin_read_millis = millis();
|
||||
#endif
|
||||
|
||||
UIScreen* splash;
|
||||
UIScreen* home;
|
||||
UIScreen* settings;
|
||||
UIScreen* quick_msg;
|
||||
UIScreen* tools_screen;
|
||||
UIScreen* ringtone_edit;
|
||||
UIScreen* bot_screen;
|
||||
UIScreen* nearby_screen;
|
||||
UIScreen* dashboard_config;
|
||||
UIScreen* auto_advert_screen;
|
||||
UIScreen* live_share_screen;
|
||||
UIScreen* locator_screen;
|
||||
UIScreen* trail_screen;
|
||||
UIScreen* compass_screen;
|
||||
UIScreen* diag_screen;
|
||||
UIScreen* repeater_screen;
|
||||
UIScreen* curr;
|
||||
// Registering a new screen touches 4 sites: (1) the member below, (2) the
|
||||
// `new XScreen()` in begin(), (3) the gotoXScreen() declaration further down,
|
||||
// (4) its one-line definition in UITask.cpp. Sites 1/3/4 are compile-checked;
|
||||
// only a forgotten (2) can slip through — the nullptr initialisers here turn
|
||||
// that into an inert no-op (see UITask::setCurrScreen) rather than a crash.
|
||||
UIScreen* splash = nullptr;
|
||||
UIScreen* home = nullptr;
|
||||
UIScreen* settings = nullptr;
|
||||
UIScreen* quick_msg = nullptr;
|
||||
UIScreen* tools_screen = nullptr;
|
||||
UIScreen* ringtone_edit = nullptr;
|
||||
UIScreen* bot_screen = nullptr;
|
||||
UIScreen* nearby_screen = nullptr;
|
||||
UIScreen* dashboard_config = nullptr;
|
||||
UIScreen* auto_advert_screen = nullptr;
|
||||
UIScreen* live_share_screen = nullptr;
|
||||
UIScreen* locator_screen = nullptr;
|
||||
UIScreen* trail_screen = nullptr;
|
||||
UIScreen* compass_screen = nullptr;
|
||||
UIScreen* diag_screen = nullptr;
|
||||
UIScreen* repeater_screen = nullptr;
|
||||
UIScreen* clock_tools = nullptr;
|
||||
UIScreen* curr = nullptr;
|
||||
CayenneLPP _dash_lpp;
|
||||
TrailStore _trail;
|
||||
WaypointStore _waypoints;
|
||||
@@ -121,6 +127,25 @@ class UITask : public AbstractUITask {
|
||||
void fireLocator(bool arrived);
|
||||
void locatorProximityBeeper();
|
||||
|
||||
// Clock tools engine — owned here (not by ClockToolsScreen) so the one-shot
|
||||
// alarm and the countdown timer fire every loop regardless of the current
|
||||
// screen / display state. ClockToolsScreen is pure UI over this state. The
|
||||
// alarm is scheduled as an ABSOLUTE wall instant, recomputed from the stored
|
||||
// time-of-day, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump the
|
||||
// clock) — small corrections still fire on time, a jump over the target still
|
||||
// fires (late). See evaluateAlarm(). Timer + ring are millis-based.
|
||||
uint32_t _alarm_next_fire = 0; // unix; 0 = (re)compute lazily once time is valid
|
||||
uint32_t _alarm_check_ms = 0; // throttle the wall-clock read to ~2 Hz
|
||||
bool _timer_running = false;
|
||||
uint32_t _timer_deadline_ms = 0;
|
||||
bool _ringing = false;
|
||||
uint32_t _ring_until_ms = 0;
|
||||
char _ring_label[20] = {0};
|
||||
uint32_t computeAlarmNextFire(uint32_t now_wall) const;
|
||||
void evaluateAlarm(); // alarm scheduling + fire detection
|
||||
void fireClockAlert(const char* label); // wake + alert + melody + start ring
|
||||
void tickClockTools(); // driven from loop(): ring + timer + alarm
|
||||
|
||||
// Course-over-ground ring — a heading source independent of trail recording.
|
||||
// Filled from the same periodic GPS poll regardless of _trail.isActive().
|
||||
// Heading = bearing across the window (oldest→newest) once the cumulative
|
||||
@@ -218,6 +243,22 @@ public:
|
||||
// Unset the active target (locator_has_target = 0). Distinct from setTarget()
|
||||
// because there's no "kind" for nothing — clearing is its own operation.
|
||||
void clearTarget();
|
||||
// One-shot: if the active target is exactly this waypoint, clear it and
|
||||
// persist immediately (setTargetNow()'s save-on-the-spot policy) — called
|
||||
// from waypoint deletion so the Locator can't keep pointing at a spot
|
||||
// that no longer exists.
|
||||
void clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6);
|
||||
// A contact was removed (companion app / CLI): drop any UI reference to its
|
||||
// pubkey that would otherwise dangle — a pinned favourite slot, the Locator
|
||||
// target if it was this contact, the Live Share target if it was this
|
||||
// contact (auto-share turns off rather than guessing a new recipient), and
|
||||
// any per-contact mute/melody override (those tables have only 16 shared
|
||||
// slots, so an orphan isn't just stale — it can starve other contacts).
|
||||
void onContactRemoved(const uint8_t* pub_key) override;
|
||||
// A channel slot was cleared: turn off anything armed against it by index
|
||||
// (the bot's channel, Live Share's channel target) and drop its per-channel
|
||||
// melody bit, so a future channel re-added at the same slot starts clean.
|
||||
void onChannelRemoved(uint8_t channel_idx) override;
|
||||
// Resolve a person target (6-byte pubkey prefix) to a current position:
|
||||
// prefers an active [LOC] live share, falls back to their last-advertised
|
||||
// GPS fix. Returns false when neither is known. Optional live/ts report
|
||||
@@ -235,6 +276,24 @@ public:
|
||||
void gotoCompassScreen();
|
||||
void gotoDiagnosticsScreen();
|
||||
void gotoRepeaterScreen();
|
||||
void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page)
|
||||
// Wake the display for an alarm/timer ring (force an immediate refresh).
|
||||
void wakeForAlarm();
|
||||
// Clear any active alert overlay early (alarm dismiss).
|
||||
void clearAlert() { _alert_expiry = 0; }
|
||||
// Clock tools engine API — ClockToolsScreen drives these; the engine itself
|
||||
// runs in tickClockTools() from loop() so it fires regardless of the screen.
|
||||
void onAlarmChanged() { _alarm_next_fire = 0; } // re-schedule after an alarm edit
|
||||
void startTimer(uint32_t duration_ms) { _timer_running = true; _timer_deadline_ms = millis() + duration_ms; }
|
||||
void stopTimer() { _timer_running = false; }
|
||||
bool isTimerRunning() const { return _timer_running; }
|
||||
uint32_t timerRemainingMs() const {
|
||||
if (!_timer_running) return 0;
|
||||
uint32_t now = millis();
|
||||
return (now >= _timer_deadline_ms) ? 0 : (_timer_deadline_ms - now);
|
||||
}
|
||||
bool isRinging() const { return _ringing; }
|
||||
void dismissRing() { stopMelody(); _ringing = false; clearAlert(); }
|
||||
TrailStore& trail() { return _trail; }
|
||||
WaypointStore& waypoints() { return _waypoints; }
|
||||
LiveTrackStore& liveTrack() { return _livetrack; }
|
||||
@@ -256,10 +315,11 @@ public:
|
||||
void stopMelody();
|
||||
bool isMelodyPlaying();
|
||||
void showAlert(const char* text, int duration_millis);
|
||||
void addChannelMsg(uint8_t channel_idx, const char* text) override;
|
||||
void addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) override;
|
||||
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) override;
|
||||
void onMsgAck(uint32_t ack_crc) override;
|
||||
void onChannelRelayed(uint32_t seq) override;
|
||||
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override;
|
||||
int getDMUnreadTotal() const;
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
int getChannelUnreadCount() const;
|
||||
@@ -333,6 +393,7 @@ public:
|
||||
void cycleBuzzerMode(); // ON → OFF → Auto → ON
|
||||
int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto
|
||||
bool getGPSState();
|
||||
bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off)
|
||||
void toggleGPS();
|
||||
void applyBrightness();
|
||||
void setBrightnessLevel(uint8_t level);
|
||||
@@ -343,6 +404,11 @@ public:
|
||||
void applyPowerSave(); // hardware duty-cycle RX on/off from prefs
|
||||
void applyApc(); // Adaptive Power Control on/off from prefs
|
||||
void applyRadioParams(); // freq/bw/sf/cr from prefs (radio preset change)
|
||||
// Save-on-exit helper for the screen `_dirty` pattern: persists NodePrefs once
|
||||
// only if `dirty`, then clears the flag. Standardises the screens' exit paths
|
||||
// (some used to leave the flag set, relying on onShow() to reset it) and keeps
|
||||
// the "did we touch flash?" answer in one place. Returns whether it saved.
|
||||
bool savePrefsIfDirty(bool& dirty);
|
||||
void applyFont();
|
||||
void applyRotation();
|
||||
void applyFullRefreshInterval();
|
||||
|
||||
@@ -20,11 +20,29 @@ class WaypointsView {
|
||||
TrailStore* _store;
|
||||
|
||||
// Sub-modes layered over the trail views. OFF = the component is dormant.
|
||||
enum Mode { OFF, LIST, NAV, ADD };
|
||||
enum Mode { OFF, LIST, NAV, ADD, AVG, TRACKBACK };
|
||||
uint8_t _mode = OFF;
|
||||
int _sel = 0;
|
||||
int _scroll = 0;
|
||||
|
||||
// Track-back (Tools › Trail › Track back): retrace the recorded trail in
|
||||
// reverse using NavView. _tb_idx is the current target breadcrumb; it walks
|
||||
// down to 0 (the start) as each is reached within TB_ARRIVE_M.
|
||||
static const int TB_ARRIVE_M = 20;
|
||||
int _tb_idx = 0;
|
||||
navview::EtaTracker _tb_eta;
|
||||
|
||||
// GPS averaging (Tools › Trail › Settings › Mark avg). When enabled, markHere()
|
||||
// accumulates fixes for gps_avg_idx seconds and marks the mean position — a
|
||||
// steadier mark than one instantaneous fix. Sampling runs in poll() on a 1 s
|
||||
// gate, independent of (slow, on e-ink) redraws. int64 sums: 30 samples ×
|
||||
// ~180e6 overflows int32.
|
||||
long long _avg_sum_lat = 0, _avg_sum_lon = 0;
|
||||
uint32_t _avg_n = 0; // fixes accumulated so far
|
||||
uint32_t _avg_end_ms = 0; // millis() when the averaging window closes
|
||||
uint32_t _avg_next_ms = 0; // millis() of the next sample
|
||||
uint16_t _avg_total_s = 0; // configured window length (for the readout)
|
||||
|
||||
PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint
|
||||
bool _kb_active = false;
|
||||
int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index
|
||||
@@ -99,6 +117,17 @@ class WaypointsView {
|
||||
_kb_active = true;
|
||||
}
|
||||
|
||||
// Capture a mark position and open the keyboard for its label. Shared by the
|
||||
// instant "Mark here" and the end of GPS averaging. _mode goes OFF: the
|
||||
// keyboard takes over the screen, and there's no sub-view to return to once
|
||||
// the label is committed (active() then tracks _kb_active alone).
|
||||
void beginLabel(int32_t lat, int32_t lon) {
|
||||
_mode = OFF;
|
||||
_mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime();
|
||||
_kb_rename_idx = -1;
|
||||
openKb("", WAYPOINT_LABEL_LEN - 1);
|
||||
}
|
||||
|
||||
// Commit a mark-here / rename label from the keyboard.
|
||||
void commitLabel(const char* buf) {
|
||||
if (_kb_rename_idx >= 0) {
|
||||
@@ -144,6 +173,23 @@ class WaypointsView {
|
||||
}
|
||||
}
|
||||
|
||||
// GPS-averaging progress screen. Sampling itself happens in poll(); this only
|
||||
// reports remaining time and the running sample count.
|
||||
void renderAvg(DisplayDriver& display) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawCenteredHeader("AVERAGING GPS");
|
||||
const int top = display.listStart();
|
||||
const int step = display.lineStep();
|
||||
int remain = (int)((int32_t)(_avg_end_ms - millis()) / 1000);
|
||||
if (remain < 0) remain = 0;
|
||||
char line[28];
|
||||
snprintf(line, sizeof(line), "%ds left (of %us)", remain, (unsigned)_avg_total_s);
|
||||
display.setCursor(2, top); display.print(line);
|
||||
snprintf(line, sizeof(line), "Samples: %u", (unsigned)_avg_n);
|
||||
display.setCursor(2, top + step); display.print(line);
|
||||
display.setCursor(2, top + 2 * step); display.print("Cancel to abort");
|
||||
}
|
||||
|
||||
void renderWpList(DisplayDriver& display) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
char title[24];
|
||||
@@ -153,12 +199,10 @@ class WaypointsView {
|
||||
|
||||
int n = wpListCount();
|
||||
int total = n + 1; // final row = "+ Add by coords"
|
||||
const int step = display.lineStep();
|
||||
|
||||
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
|
||||
|
||||
drawList(display, total, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, step - 1, sel);
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
|
||||
if (row == n) { // the synthetic "Add" row
|
||||
display.setCursor(2, y); display.print("+ Add by coords");
|
||||
@@ -189,6 +233,20 @@ class WaypointsView {
|
||||
navview::draw(display, have, mylat, mylon, tlat, tlon, label, cogv, cog, useImperial());
|
||||
}
|
||||
|
||||
// Navigate to the current track-back breadcrumb. The header doubles as the
|
||||
// progress readout: "Trail start" on the last leg, else points still to go.
|
||||
void renderTrackBack(DisplayDriver& display) {
|
||||
if (_tb_idx < 0 || _tb_idx >= _store->count()) { _mode = OFF; return; }
|
||||
const TrailPoint& t = _store->at(_tb_idx);
|
||||
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
|
||||
int cog; bool cogv = _task->currentCourse(cog);
|
||||
char label[20];
|
||||
if (_tb_idx == 0) snprintf(label, sizeof(label), "Trail start");
|
||||
else snprintf(label, sizeof(label), "Back: %d pt", _tb_idx);
|
||||
navview::draw(display, have, mylat, mylon, t.lat_1e6, t.lon_1e6,
|
||||
label, cogv, cog, useImperial(), &_tb_eta);
|
||||
}
|
||||
|
||||
// Resolve a combined-list row to a nav target. Row 0 is the trail start when
|
||||
// a trail exists; the rest are saved waypoints.
|
||||
bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) {
|
||||
@@ -222,9 +280,70 @@ public:
|
||||
int32_t lat, lon;
|
||||
if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
|
||||
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
|
||||
_mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime();
|
||||
_kb_rename_idx = -1;
|
||||
openKb("", WAYPOINT_LABEL_LEN - 1);
|
||||
NodePrefs* p = _task->getNodePrefs();
|
||||
uint8_t avg_idx = p ? p->gps_avg_idx : 0;
|
||||
if (avg_idx == 0) { beginLabel(lat, lon); return; } // instant mark (default)
|
||||
// Averaging: seed with the current fix, then sample for N more seconds.
|
||||
_avg_total_s = NodePrefs::gpsAvgSecs(avg_idx);
|
||||
_avg_sum_lat = lat; _avg_sum_lon = lon; _avg_n = 1;
|
||||
uint32_t now = millis();
|
||||
_avg_end_ms = now + (uint32_t)_avg_total_s * 1000;
|
||||
_avg_next_ms = now + 1000;
|
||||
_mode = AVG;
|
||||
}
|
||||
|
||||
// Retrace the recorded trail back to its start. Snaps onto the route at the
|
||||
// nearest recorded point, then NavView guides to each earlier breadcrumb in
|
||||
// turn (poll() advances the target) until the start is reached.
|
||||
void startTrackBack() {
|
||||
if (_store->count() < 2) { _task->showAlert("No trail", 1000); return; }
|
||||
int idx = _store->count() - 1; // default: the newest end of the trail
|
||||
int32_t lat, lon;
|
||||
if (ownPos(lat, lon)) { // else snap to the nearest recorded point
|
||||
float best = 1e30f;
|
||||
for (int i = 0; i < _store->count(); i++) {
|
||||
float d = geo::haversineKm(lat, lon, _store->at(i).lat_1e6, _store->at(i).lon_1e6);
|
||||
if (d < best) { best = d; idx = i; }
|
||||
}
|
||||
}
|
||||
_tb_idx = idx;
|
||||
_tb_eta.reset();
|
||||
_mode = TRACKBACK;
|
||||
}
|
||||
|
||||
// Driven from TrailScreen::poll() so the position-tracking sub-modes tick on
|
||||
// the main loop, not on the (slow on e-ink) render cadence.
|
||||
void poll() {
|
||||
if (_mode == AVG) pollAvg();
|
||||
else if (_mode == TRACKBACK) pollTrackBack();
|
||||
}
|
||||
|
||||
// Accumulate GPS fixes while averaging; mark the mean when the window closes.
|
||||
void pollAvg() {
|
||||
uint32_t now = millis();
|
||||
if ((int32_t)(now - _avg_next_ms) >= 0) {
|
||||
int32_t lat, lon;
|
||||
if (ownPos(lat, lon)) { _avg_sum_lat += lat; _avg_sum_lon += lon; _avg_n++; }
|
||||
_avg_next_ms = now + 1000;
|
||||
}
|
||||
if ((int32_t)(now - _avg_end_ms) >= 0) { // window closed → mark the mean
|
||||
if (_avg_n == 0) { _task->showAlert("No GPS fix", 1000); _mode = OFF; return; }
|
||||
int32_t mlat = (int32_t)(_avg_sum_lat / (long long)_avg_n);
|
||||
int32_t mlon = (int32_t)(_avg_sum_lon / (long long)_avg_n);
|
||||
beginLabel(mlat, mlon); // opens the label keyboard
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the track-back target when the current breadcrumb is reached; once
|
||||
// at the start (index 0) and within range, announce arrival and exit.
|
||||
void pollTrackBack() {
|
||||
int32_t lat, lon;
|
||||
if (!ownPos(lat, lon)) return;
|
||||
float d_m = geo::haversineKm(lat, lon, _store->at(_tb_idx).lat_1e6,
|
||||
_store->at(_tb_idx).lon_1e6) * 1000.0f;
|
||||
if (d_m > (float)TB_ARRIVE_M) return;
|
||||
if (_tb_idx > 0) { _tb_idx--; _tb_eta.reset(); }
|
||||
else { _task->showAlert("Back at start", 1500); _mode = OFF; }
|
||||
}
|
||||
|
||||
// Only called while active().
|
||||
@@ -233,6 +352,8 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen
|
||||
if (_mode == ADD) { renderAddForm(display); return 1000; }
|
||||
if (_mode == AVG) { renderAvg(display); return display.isEink() ? 1000 : 300; }
|
||||
if (_mode == TRACKBACK) { renderTrackBack(display); return 1000; }
|
||||
if (_mode == NAV) { renderWpNav(display); return 1000; }
|
||||
renderWpList(display); // LIST
|
||||
if (_ctx.active) _ctx.render(display);
|
||||
@@ -269,6 +390,8 @@ public:
|
||||
}
|
||||
} else if (sel == 1) { // Delete
|
||||
if (wi >= 0 && wi < _task->waypoints().count()) {
|
||||
const Waypoint& w = _task->waypoints().at(wi);
|
||||
_task->clearTargetIfWaypoint(w.lat_1e6, w.lon_1e6); // don't leave the Locator pointed at a deleted spot
|
||||
_task->waypoints().remove(wi);
|
||||
_task->saveWaypoints();
|
||||
if (_sel >= wpListCount()) _sel = wpListCount() - 1;
|
||||
@@ -293,6 +416,12 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
// Averaging window — any cancel aborts the mark; otherwise just wait it out.
|
||||
if (_mode == AVG) {
|
||||
if (c == KEY_CANCEL) _mode = OFF;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ADD form: scroll-edit lat/lon (magnitude) + hemisphere toggle + label.
|
||||
if (_mode == ADD) {
|
||||
// The digit editor, while open, consumes all input (UP/DOWN change the
|
||||
@@ -307,7 +436,7 @@ public:
|
||||
if (c == KEY_CANCEL) { _mode = OFF; return true; }
|
||||
if (c == KEY_UP) { _add_sel = (_add_sel > 0) ? _add_sel - 1 : 3; return true; }
|
||||
if (c == KEY_DOWN) { _add_sel = (_add_sel < 3) ? _add_sel + 1 : 0; return true; }
|
||||
if (c == KEY_LEFT || c == KEY_RIGHT) {
|
||||
if (keyIsPrev(c) || keyIsNext(c)) {
|
||||
if (_add_sel == 0) _add_lat_neg = !_add_lat_neg; // N <-> S
|
||||
else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W
|
||||
return true;
|
||||
@@ -328,6 +457,13 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
// Track-back — launched from the action menu, so Cancel returns to the
|
||||
// trail views (not the waypoint list).
|
||||
if (_mode == TRACKBACK) {
|
||||
if (c == KEY_CANCEL) _mode = OFF;
|
||||
return true;
|
||||
}
|
||||
|
||||
// List (row 0 is the synthetic "Trail start" when a trail exists; the final
|
||||
// row is "+ Add by coords"). Cancel returns control to the trail views.
|
||||
int n = wpListCount();
|
||||
|
||||
@@ -150,6 +150,14 @@ MINI_ICON(ICON_ADVERT, 6, // broadcast mast + radiating waves (auto-advert)
|
||||
packRow(".####."),
|
||||
packRow(".####."));
|
||||
|
||||
MINI_ICON(ICON_ALARM, 5, // bell — an alarm is armed
|
||||
packRow("..#.."),
|
||||
packRow(".###."),
|
||||
packRow(".###."),
|
||||
packRow(".###."),
|
||||
packRow("#####"),
|
||||
packRow("..#.."));
|
||||
|
||||
MINI_ICON(ICON_TRAIL, 6, // map pin / location marker (GPS trail logging)
|
||||
packRow(".####."),
|
||||
packRow("######"),
|
||||
@@ -499,6 +507,16 @@ inline int drawList(DisplayDriver& d, int total, int sel, int& scroll, RenderRow
|
||||
return visible;
|
||||
}
|
||||
|
||||
// Canonical selection bar for a drawList() row: spans the row width minus the
|
||||
// scroll-indicator `reserve`, one pixel short of the row height, anchored one
|
||||
// pixel above `y` (the row's text baseline-top). Call as the first line of a
|
||||
// row callback, then draw content over it. Captures the geometry every list row
|
||||
// repeated by hand; rows that intentionally differ (full-width, custom height)
|
||||
// still call display.drawSelectionRow() directly.
|
||||
inline void drawRowSelection(DisplayDriver& d, int y, bool sel, int reserve) {
|
||||
d.drawSelectionRow(0, y - 1, d.width() - reserve, d.lineStep() - 1, sel);
|
||||
}
|
||||
|
||||
// ── Big ASCII-art icons (skeleton, not yet used) ─────────────────────────────
|
||||
// Same authoring idea as the mini-icons but for full page glyphs up to 32 px
|
||||
// wide: one uint32_t per row. The existing XBM bitmaps below (logo/bluetooth/…)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- **Trail auto-pause** — recording **freezes on stops** (banking elapsed time and breaking the map line across the idle gap) and **resumes on movement** without ending the session; the home-screen blink keeps going while paused.
|
||||
- **Collapsible Tools** — tools are grouped into fold-in-place **Location / Comms / System** sections, the same model as Settings (Tools always opens folded to the section list), and the home carousel now uses **page-indicator icons** instead of dots.
|
||||
- **Waypoint coordinate editor** — add a waypoint by scroll-editing its latitude/longitude digit by digit.
|
||||
- **On-device room login with saved passwords** — log in to a room server straight from the device (no phone app): pick the room and the password prompt appears automatically (a blank password works for open rooms), or re-login any time via the room's **context-menu "Login…"**. The password is **remembered across reboots**, so a room you've used before logs back in without retyping; a failed login (e.g. the server's password changed) forgets the stale password so the next attempt prompts again. Room passwords entered via the **phone app** are saved on the device too, so it can post to that room standalone after a reboot. Saved passwords are written with the same atomic, crash-safe persistence as contacts and channels.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -20,6 +21,7 @@
|
||||
- **Nearby Nodes** — live `[LOC]` senders now respect the type filter and sort by their shared position; the distance-sorted list refreshes so live shares bubble to the top.
|
||||
- **Map** — live contacts are labelled before waypoints, so a person's name shows rather than a nearby waypoint's.
|
||||
- **GPS status icon** is hidden when GPS is turned off in Settings, instead of sitting there empty.
|
||||
- **Trail — start with GPS off** now prompts **"GPS is off — Enable GPS & start"** instead of silently starting a session that shows "Waiting for GPS fix" forever and records nothing.
|
||||
- Null-guarded the Locator target picker and clamped the loc-share channel index on load.
|
||||
|
||||
### Under the hood
|
||||
|
||||
@@ -57,5 +57,10 @@ public:
|
||||
virtual int render(DisplayDriver& display) =0; // return value is number of millis until next render
|
||||
virtual bool handleInput(char c) { return false; }
|
||||
virtual void poll() { }
|
||||
// Called by UITask::setCurrScreen() each time this screen becomes current —
|
||||
// the place to reset per-visit state (selection, dirty flag, sub-views).
|
||||
// Default no-op for screens that keep state across visits. Because it's
|
||||
// invoked centrally, a new screen can't "forget" to be reset on show.
|
||||
virtual void onShow() { }
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user