feat(companion): live location sharing, Locator geofencing, trail auto-pause
Squash merge of feat/location-beacon-alerts-autopause (v1.21). Features: - Live Location Sharing — broadcast position as movement-gated [LOC] messages to a channel or contact; live shares show as map pins with distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is parsed in DMs, channel messages and room messages; DM shares name the sender. - Locator (geofence) — arm a geofence around a saved waypoint or a person (live [LOC] or last-known position), alert on arrive/leave or near/far, with an optional homing beeper (gated to arrive/both modes). Arm from Tools > Locator, Nearby Nodes, or Waypoints; target picker lists favourites first, clearable via a "None" entry. Active target is drawn as a flag on the map. - One active target shared across Locator / Navigate / Map via a single resolver (resolvePersonPos / activeTargetPos) that prefers a live [LOC] share over the last-advertised GPS fix. - Follow live contacts — Navigate to a live-sharing contact follows them as they move and adds an ETA line; quick-share your own position from the Map. - Map & status-bar upgrades — home mini-map gets a north marker, scale tick, and a connected trail line (was disconnected dots); status line shows tracked-node count, an arrow + distance to the active Locator/Nav target (falling back to the nearest live-tracked contact); GPS fix icon in the top status bar, shown only on GPS boards with GPS enabled. - Trail auto-pause — recording freezes on stops (banking elapsed time, breaking the map line across the idle gap) and resumes on movement without ending the session. - Streaming trail simplification — GPS points are simplified in-stream via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight runs collapse to their endpoints, curves stay bounded to the Min-dist tolerance, so the 512-point buffer covers a far longer route than a flat point budget would suggest. - Collapsible Tools (Location / Comms / System sections, fold-in-place like Settings) and page-indicator icons on the home carousel. - Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon digit by digit. Fixes: - Critical: low-heap hang and contact loss on RAM-tight builds. Halved message-history scrollback rings (recovering ~14 KB free heap) and made contacts/channels/prefs persistence atomic (temp file + rename), so an interrupted save can no longer corrupt or wipe the store. - Serial.write() bounded so a stalled USB host can't hang the device. - Nearby Nodes: live [LOC] senders respect the type filter, sort by shared position, and the list refreshes so live shares bubble up. - Map: live contacts are labelled before waypoints. - GPS status icon hidden when GPS is off in Settings. - Splash screen no longer truncates a pre-release tag's own dash (e.g. v1.21-rc1) when stripping the build's commit-hash suffix. - Null-guarded the Locator target picker; clamped loc-share channel index on load. Under the hood: - -Os size optimisation on the e-ink and GAT562 30S solo envs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -36,7 +36,9 @@ All firmware files are published on the [releases page](https://github.com/Marek
|
||||
- **GPS compass** — heading derived from course-over-ground (no magnetometer needed), shown as a clear scrolling heading tape with a large degrees + cardinal readout
|
||||
- **Navigate to anything** — a saved waypoint, a node straight from Nearby Nodes, or a location someone shares with you in a message
|
||||
- **Share & save locations** — send a waypoint to a contact or channel; on the other end, navigate to or save any shared location with one menu
|
||||
- **GPS trail** — background route recording with an auto-fit map (waypoints + live position), summary stats, and [GPX export](#solo-tools)
|
||||
- **Live location sharing** — broadcast your position over the mesh as you move (movement-gated, to a channel or contact) and see others who share theirs as pins on the map and live distance/bearing in Nearby
|
||||
- **Locator** — arm a geofence around a target — a saved waypoint *or* a person (their live/last-known position) — and get an alert when you arrive/leave or they get near/far, with an optional homing beeper that ticks faster the closer you get. Set it from the Locator screen or straight from Nearby Nodes / Waypoints, and see the target as a flag on the map
|
||||
- **GPS trail** — background route recording with an auto-fit map (waypoints + live position), summary stats, auto-pause on stops, and [GPX export](#solo-tools)
|
||||
- **Metric or imperial** — one global Units setting drives every distance and speed across the UI
|
||||
|
||||
- [Messages Screen](./docs/solo_features/message_screen/message_screen.md) — view and send messages, open message details, reply with quick messages or custom text, navigate to / save locations shared in a message, per-channel notification and melody overrides
|
||||
@@ -49,7 +51,7 @@ All firmware files are published on the [releases page](https://github.com/Marek
|
||||
|
||||
- [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) — lock the device to prevent accidental keypresses, with a lock screen showing time and sensor data
|
||||
|
||||
- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, diagnostics, repeater
|
||||
- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater
|
||||
|
||||
- **Battery saving (radio)** — two optional, independent toggles under Settings › Radio:
|
||||
- **Pwr save** — hardware duty-cycle receive (SX126x `SetRxDutyCycle`): the radio cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current with only a little added receive latency
|
||||
@@ -97,7 +99,7 @@ Updating to a newer version usually does not require erasing flash unless the re
|
||||
| [Clock Screen](./docs/solo_features/clock_screen/clock_screen.md) | Clock page, date, configurable data fields |
|
||||
| [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, diagnostics, repeater |
|
||||
| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater |
|
||||
|
||||
### Upstream MeshCore
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
A full-screen clock page on the home screen. Shows the current time and date, with up to three configurable data fields below.
|
||||
|
||||
@@ -27,7 +27,7 @@ If no time source is available, the screen shows _"! No time sync"_ with a hint
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Up to three data fields are shown below the date separator. Each field displays a label and a value on the same line.
|
||||
|
||||
@@ -52,4 +52,10 @@ Sensor fields show `--` when the sensor is not connected or has no data.
|
||||
|
||||
### Configuring fields
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- 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**.
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -6,7 +6,7 @@
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :------------------------: | :------------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
A dedicated home page showing a grid of up to 6 pinned contacts for quick access. The layout adapts to the display orientation:
|
||||
|
||||
@@ -41,6 +41,12 @@ If a pinned contact has been removed from the contacts list, the tile shows `(go
|
||||
2. Recent DM contacts — listed after
|
||||
3. All remaining chat contacts — fallback when the first two tiers are empty (e.g. fresh install before any DMs)
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :------------------------: | :------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Pin-to-dial contact picker (favourites first, then recent DMs, then all contacts) -->
|
||||
|
||||
Select a contact to pin it to that slot.
|
||||
|
||||
**From a DM conversation** — **Hold Enter** › context menu › **Pin to dial**, then choose a slot from the slot picker (Slot 1–6, showing the current occupant name or "empty").
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -6,7 +6,7 @@
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
The Messages screen is split into three modes — **DMs**, **Channels**, and **Rooms** — selectable with UP/DOWN on the mode-select screen. Each mode shows the corresponding list of conversations with unread counters.
|
||||
|
||||
@@ -16,7 +16,7 @@ The Messages screen is split into three modes — **DMs**, **Channels**, and **R
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Press **Enter** on a contact or channel to open its history, then press **Enter** again (or select an empty send row) to compose a message. Choose between:
|
||||
|
||||
@@ -44,7 +44,7 @@ Sensor placeholders appear automatically in the placeholder picker when the corr
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Each entry in the history list shows the sender name and a compact age indicator (`3m`, `2h`, `>1d`) in the top-right corner.
|
||||
|
||||
@@ -56,7 +56,7 @@ Each entry in the history list shows the sender name and a compact age indicator
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Navigate between messages with **LEFT** (newer) and **RIGHT** (older). Long messages scroll with **UP/DOWN**.
|
||||
|
||||
@@ -64,7 +64,7 @@ If the message is a reply addressed to someone (`@[nick]`), a **To: nick** bar i
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
**Hold Enter** in fullscreen opens the options menu. It always offers **Reply** for an incoming message, and when the message contains a **location** it adds two more:
|
||||
|
||||
@@ -81,7 +81,7 @@ A location is any `lat,lon` pair in the text — exactly what the `{loc}` placeh
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Item | Action |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------ |
|
||||
@@ -98,7 +98,7 @@ When **Pin to dial** is selected, a slot picker opens (Slot 1–6 showing curren
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
**Hold Enter** on a channel entry opens a context menu:
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -6,7 +6,7 @@
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Screen lock prevents accidental keypresses. While locked the display turns off and all input is ignored.
|
||||
|
||||
@@ -34,7 +34,7 @@ If no press is made for 3 seconds, the counter resets.
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
A brief press of any button wakes the display and shows the lock screen. It displays:
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -6,7 +6,7 @@
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
All settings are saved to flash and restored on next boot. Settings are organised into collapsible sections. Press **Enter** on a section header to expand or collapse it — all sections start collapsed for faster navigation. Press **LEFT/RIGHT** to change a value, or **Enter** for toggle items.
|
||||
|
||||
@@ -50,7 +50,7 @@ Melody 1 and Melody 2 are custom sequences editable in **Tools › Ringtone Edit
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Lists all available home screen pages. For each entry:
|
||||
|
||||
@@ -74,6 +74,12 @@ Lists all available home screen pages. For each entry:
|
||||
| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive: the SX126x cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current. Trades a little receive latency; leave OFF for lowest-latency reception. Requires an SX126x radio (otherwise stays on continuous RX). **Forced off (shown as `--`) while the repeater is on** — a repeater must listen continuously; your setting is restored when the repeater is switched off. |
|
||||
| Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers actual TX power on strong links to save energy, ramping back up — to the **TX Pwr** ceiling — on weak or lost links. Link quality comes from direct-message ACK SNR and, for channel messages (no ACK), from hearing a repeater rebroadcast your packet. The radio page / name bar shows the live power. Default OFF (fixed TX power). **Suppressed (shown as `--`) while the repeater is on** — a repeater holds full TX power for consistent relay reach; your setting is restored when the repeater is switched off. |
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Radio — preset popup (pick/save/delete) and/or the digit-by-digit frequency editor -->
|
||||
|
||||
The **repeater** mode and its flood filters live on their own screen — see **Tools › Repeater**.
|
||||
|
||||
---
|
||||
|
||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -6,9 +6,9 @@
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, auto-advert, compass, device diagnostics, and repeater mode. Navigate the tool list with **UP/DOWN** and press **Enter** to open a tool.
|
||||
The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, auto-advert, live location sharing, locator, compass, device diagnostics, and repeater mode. Tools are grouped into collapsible **Location** / **Comms** / **System** sections — the same fold-in-place model as Settings; Tools always opens folded back to the section list. Navigate with **UP/DOWN**, press **Enter** on a section header to expand or collapse it, or on a tool to open it.
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@ The Tools screen is a hub for GPS trail recording, nearby node browsing, rington
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Browse nodes that have recently advertised on the mesh. **Filter** (which nodes) and **sort** (in what order) are independent axes and combine freely.
|
||||
|
||||
@@ -31,15 +31,16 @@ Filter by category with **LEFT/RIGHT** (one coherent axis — type only):
|
||||
| Room | Room servers |
|
||||
| Snsr | Sensors |
|
||||
|
||||
Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time.
|
||||
Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. A node that is **broadcasting its position** via Live Share is marked with a **♦ diamond** beside its name in the list (the same marker the map uses), and its detail shows `Sharing pos:` with the share age and whether it's DM-verified or channel-only.
|
||||
|
||||
**Hold Enter** opens the same **Options** menu everywhere (list and detail), in a fixed order — only the actions that apply appear:
|
||||
|
||||
| Action | Available when |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Navigate | selected node has GPS |
|
||||
| Navigate | selected node has GPS — for a node sharing live position, the view follows it as it moves and adds an ETA line |
|
||||
| Ping | a public key is known for the node |
|
||||
| Save waypoint | selected node has GPS |
|
||||
| Set as target | selected node has GPS **and** a public key — pins it as the active **Locator/Nav target** right away (see **Locator**) |
|
||||
| Sort: Dist/Recent | browsing stored nodes — **LEFT/RIGHT** on the row flips distance ↔ last-heard in place |
|
||||
| Discover scan / Rescan | always (live `NODE_DISCOVER_REQ` scan) |
|
||||
|
||||
@@ -49,7 +50,7 @@ Selecting **Ping** opens the Ping popup:
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :----------------------------: | :----------------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Use **Enter** on the popup’s `Ping` row to send a direct mesh ping to that node. The popup then shows the RTT and SNR values on the next lines, and can be used again immediately for another ping.
|
||||
|
||||
@@ -62,7 +63,7 @@ Use **Enter** on the popup’s `Ping` row to send a direct mesh ping to that nod
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
**Options → Discover scan** sends a `NODE_DISCOVER_REQ`. Repeaters, sensors and room servers within zero-hop range respond immediately with name, type and signal data. This is not a separate screen — it is the **same list switched to a live-scan source**: the right-hand column shows **RSSI** instead of distance, and node detail shows the public key, signal data and contact status.
|
||||
|
||||
@@ -76,21 +77,26 @@ Because it is the same list, all the same keys apply — **UP/DOWN** to navigate
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Records your route in a RAM ring buffer (up to 512 points, sampled every 1 s). Tracking runs in the background — a blinking **G** appears in the status bar. The trail survives display auto-off but is lost on reboot unless saved to flash first.
|
||||
Records your route in a RAM ring buffer (up to 512 points, sampled every 1 s). The track is **simplified as it's recorded** — a long straight stretch is kept as just its two endpoints while curves keep their detail (bounded to within the **Min dist** tolerance of the real path), so the buffer covers a far longer route than a flat point budget would suggest. Tracking runs in the background — a blinking **G** appears in the status bar. The trail survives display auto-off but is lost on reboot unless saved to flash first.
|
||||
|
||||
> [!TIP]
|
||||
> The **Map** view is also reachable directly from the home carousel — the **Map** page shows a live mini-preview (your position, trail, and tracked contacts) with a **north marker** and a bottom-left **scale tick**. The status line below reads `Track:N` (tracked-node count) and, when you have a fix and at least one tracked contact, an **arrow + distance** to the **nearest** one (e.g. `Track:3 →120m`). If a **Locator/Nav target** is set it's drawn as a **flag marker** (see **Locator**). Press **Enter** to open the full Trail Map; **Hold Enter** shares your position (see **Live Share**); **Back** returns home.
|
||||
|
||||
A GPS fix indicator also sits in the top status bar, alongside the trail/auto-advert/repeater icons — boxed (lit) once the receiver has a valid fix, a plain glyph while still searching. It only appears on boards with GPS hardware and while **GPS** is turned on in Settings; it's hidden the rest of the time rather than sitting there empty.
|
||||
|
||||
Cycle views with **LEFT / RIGHT**:
|
||||
|
||||
| View | Content |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Summary** | Distance, elapsed time, avg speed or pace, point count, tracking status |
|
||||
| **Map** | Auto-fit dot-and-line plot with cos(lat) aspect correction; segment breaks marked; north arrow; square scale grid fitted to the map frame (toggle under **Hold Enter → Settings → Grid**, Map view only). Your **current GPS position** and all **waypoints** are always drawn — even with no trail recording — so the map is useful standalone |
|
||||
| **Map** | Auto-fit dot-and-line plot with cos(lat) aspect correction; segment breaks marked; north arrow; square scale grid fitted to the map frame (toggle under **Hold Enter → Settings → Grid**, Map view only). Your **current GPS position**, all **waypoints**, and any **live-tracked contacts** (positions shared via Live Share) are always drawn — even with no trail recording — so the map is useful standalone. The active **Locator/Nav target**, if set, is drawn as a **flag** on top (folded into the frame so it's never off-screen). Point labels are auto-placed to avoid overlapping (a crowded cluster drops some labels rather than smearing them) |
|
||||
| **List** | Per-point rows showing local time (HH:MM) and delta distance from the previous point; segment-start rows show `start`; scroll with **UP/DOWN** |
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
**Hold Enter** opens the **action menu**. It is two-level — a short main menu, plus **Trail file…** and **Settings…** submenus. **Cancel/Back** in a submenu returns to the main menu.
|
||||
|
||||
@@ -101,6 +107,7 @@ Cycle views with **LEFT / RIGHT**:
|
||||
| Start / Stop tracking | Begin or end a recording session |
|
||||
| Mark here | Drop a waypoint at the current GPS fix (see below) |
|
||||
| Waypoints… | Open the waypoint list / navigation / add-by-coords |
|
||||
| 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) |
|
||||
|
||||
@@ -116,14 +123,17 @@ Cycle views with **LEFT / RIGHT**:
|
||||
|
||||
**Settings…** (values cycle with **LEFT/RIGHT** or **Enter**; shown only where they apply):
|
||||
|
||||
| Item | Available | Action |
|
||||
| -------- | --------- | ------------------------------------------------------- |
|
||||
| Min dist | always | Sample gate, 4 levels — metric: 5/10/25/100 m, imperial: 15/30/75/300 ft |
|
||||
| Readout | Summary view | Summary shows Speed or Pace (in the global unit system) |
|
||||
| Grid | Map view | Toggle scale grid on the map |
|
||||
| Item | Available | Action |
|
||||
| ---------- | --------- | ------------------------------------------------------- |
|
||||
| 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) |
|
||||
| Readout | Summary view | Summary shows Speed or Pace (in the global unit system) |
|
||||
| Grid | Map view | Toggle scale grid on the map |
|
||||
|
||||
(Trail file… appears only when a live or saved trail exists. Mark here needs a GPS fix; Waypoints is always available.)
|
||||
|
||||
**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.
|
||||
|
||||
### 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`).
|
||||
@@ -132,7 +142,14 @@ A waypoint is a saved spot — your car, camp, a water source — that you can n
|
||||
|
||||
**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**:
|
||||
|
||||
- **Lat** / **Lon** — **Enter** opens the keyboard to type the value in decimal degrees (magnitude only; the keyboard has no minus sign), and **LEFT/RIGHT** toggles the hemisphere — N/S for latitude, E/W for longitude.
|
||||
| OLED | E-Ink |
|
||||
| :------------------------: | :------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Add-by-coords form — Lat/Lon scroll editor, hemisphere toggle, Label, Save -->
|
||||
|
||||
|
||||
- **Lat** / **Lon** — **Enter** opens the digit-by-digit scroll editor (the same widget as the radio frequency field): **LEFT/RIGHT** move the cursor between decimal places, **UP/DOWN** change the digit under it, **Enter** confirms. With the editor closed, **LEFT/RIGHT** on the row toggles the hemisphere — N/S for latitude, E/W for longitude.
|
||||
- **Label** — **Enter** to type a name (blank → auto `WP<n>`).
|
||||
- **Save** — validates the range and stores the waypoint. Missing or out-of-range values report a brief error.
|
||||
|
||||
@@ -147,9 +164,15 @@ A waypoint is a saved spot — your car, camp, a water source — that you can n
|
||||
Hdg: 090° E ← your current course over ground (-- when stationary)
|
||||
```
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :------------------------: | :------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Waypoints navigation view — target label, distance, To/Hdg bearings (shared with Nearby/message Navigate) -->
|
||||
|
||||
There is no magnetometer, so the screen shows two *absolute* bearings and you compare them: target at 145°, travelling at 90° → bear right. The **Hdg** line is derived from GPS movement (see Compass) and reads `--` until you move.
|
||||
|
||||
**Managing** — **Hold Enter** on a waypoint row offers **Rename** / **Delete** / **Send** (the *Trail start* row is navigate-only). Delete removes one at a time; there is no bulk clear.
|
||||
**Managing** — **Hold Enter** on a waypoint row offers **Rename** / **Delete** / **Send** / **Set as target** (the *Trail start* row is navigate-only). **Set as target** pins the waypoint as the active **Locator/Nav target** in one step (see **Locator**). Delete removes one at a time; there is no bulk clear.
|
||||
|
||||
**Sharing** — **Send** hands the waypoint to the Messages screen: pick a contact or channel, and the message is pre-filled as `[WAY]<lat>,<lon> <label>` (e.g. `[WAY]37.42123,-122.08456 CAR`) for you to confirm or edit before sending. On the receiving device, opening that message and **Hold Enter → Navigate / Save waypoint** turns it back into a navigable point (see *Messages › Fullscreen message view*). The format is plain text, so it stays readable on other firmware and the phone app.
|
||||
|
||||
@@ -191,8 +214,92 @@ Periodically broadcasts a 0-hop advert with your GPS position. Configurable inte
|
||||
|
||||
---
|
||||
|
||||
## Live Share
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :------------------------: | :------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Live Share screen — Track loc / Auto share / To / Move / Min gap / Heartbeat rows -->
|
||||
|
||||
Share your live position over the mesh **as ordinary chat messages**, and put other people who do the same on your map. A position is sent as a `[LOC]<lat>,<lon>` message — the same coordinate format waypoints use, so it stays readable on other firmware and the phone app (it just looks like a coordinate to anything that doesn't know the tag).
|
||||
|
||||
This is **independent of Auto-Advert** and runs alongside it: Auto-Advert announces your *presence* as a 0-hop beacon for Nearby Nodes, while Live Share sends your *position* to a specific channel or contact you choose.
|
||||
|
||||
The tool holds both directions of sharing in one flat list. Navigate with **UP/DOWN**, change a value with **LEFT/RIGHT** (or **Enter**); **Cancel/Back** saves and returns to Tools.
|
||||
|
||||
| Setting | Options | Notes |
|
||||
| ---------- | --------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Track loc | ON / OFF | Receive incoming `[LOC]` shares (DM, monitored channels, and room-server posts) and pin those senders on the map / in Nearby. Off by default. |
|
||||
| Auto share | ON / OFF | Periodically broadcast **your own** position to the target below while you move. |
|
||||
| To | channel or contact | **Enter** opens the Messages recipient chooser to pick the target channel or DM contact. |
|
||||
| Move | 50 / 100 / 250 / 500 m | Movement gate — only send after you've moved at least this far since the last share. |
|
||||
| Min gap | 30 s / 1 / 2 / 5 min | Minimum time between sends, so fast movement can't flood the channel. |
|
||||
| Heartbeat | Off / 5 / 15 min | Optional keep-alive: re-send even while stationary, so the other end knows you're still there. |
|
||||
|
||||
**How auto-share decides to send.** With **Auto share** on, the device checks a few times a minute: it transmits when you've moved at least **Move** metres *and* at least **Min gap** has passed since the last send — so a stationary device stays silent unless a **Heartbeat** is set. It also sends once immediately when you enable sharing (or change the target), so the other end gets a fresh fix right away.
|
||||
|
||||
**Receiving.** With **Track loc** on, incoming `[LOC]` messages update a small live table (up to 16 nodes, entries expire ~20 min after the last update). DM shares are keyed by the sender's public key (reliable); channel and room-server shares are keyed by name (best-effort, since channel names are unsigned and a room post only carries a short sender prefix). Tracked nodes appear on the **Trail Map** as a filled diamond with the first two characters of their name, and in **Nearby Nodes** with their live distance/bearing.
|
||||
|
||||
**One-shot share.** To send your position once without enabling auto-share, use **Tools › Trail → Hold Enter → Share my pos** — it builds a `[LOC]` message and hands it to the Messages screen to pick a recipient. There's also a shortcut from the home **Map** page: **Hold Enter** sends an immediate position update to your Live Share target while auto-sharing is on (toast `Position shared`), or opens the recipient picker if it isn't — so you never broadcast to a default channel by accident.
|
||||
|
||||
---
|
||||
|
||||
## Locator
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- 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.
|
||||
|
||||
Navigate with **UP/DOWN**, change a value with **LEFT/RIGHT** (or **Enter**); **Cancel/Back** saves and returns to Tools.
|
||||
|
||||
| Setting | Options | Notes |
|
||||
| ------- | -------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Alert | ON / OFF | Master switch. Enabling without a target prompts you to pick one. |
|
||||
| Target | none / person / waypoint | **Enter** opens a picker — **None** first (clears the target), then **favourites** (offered even with no known position yet, so you can arm ahead of time), then any other contact with a currently-resolvable position (live-sharing *or* just last-advertised, e.g. a repeater), then waypoints; **UP/DOWN** + **Enter** to choose. **LEFT/RIGHT** quick-cycles the same set in place, including back to **None**. A person is shown with an `@` prefix, plus a compact **age tag** (e.g. `@Bob (5m)`) when the position is last-advertised rather than a live share. Shows `none` until set. |
|
||||
| Radius | 50 / 100 / 250 / 500 m / 1 km | Geofence size. |
|
||||
| Mode | Arrive / Leave / Both | Which crossing fires the alert — entering the radius, leaving it, or both. |
|
||||
| Beeper | ON / OFF | Optional homing tone — shown only in **Arrive** / **Both** modes (see below). |
|
||||
|
||||
**Crossing alert.** When armed with a target, the device watches its own GPS fix and fires the alert (a short melody plus an on-screen message) the moment you cross the radius, according to **Mode**. The wording adapts to the target — `Arrived` / `Left` for a waypoint, `Near` / `Away` for a person. The edge has a little hysteresis so a fix hovering right on the boundary doesn't chatter, and the first reading after arming only seeds the in/out state — it won't fire spuriously just because you armed it while already inside.
|
||||
|
||||
**Following a person.** Pick a **favourite** (or any contact with a known position) as the target and the geofence tracks the distance *between you and them*, so it works even while both of you move. The position is resolved with a fixed precedence: an **active live `[LOC]` share** wins, and with no current share it **falls back to the contact's last-advertised GPS position** — so a rarely-updating but stationary node (a repeater, or someone who shared a fix once) still works as a target. You can arm it **ahead of time** — choosing a favourite locks onto their identity (pubkey), and the alert starts working as soon as a position is known. Live following requires a **DM** share (a channel share carries no stable identity to lock onto); the last-advertised fallback works for any contact regardless.
|
||||
|
||||
**Proximity beeper.** With **Beeper** on, the device also ticks while you're inside the radius and **shortens the gap between ticks the closer you get to the target** — slow near the edge, rapid near the centre — like a homing beeper guiding you to the exact spot. It's silent outside the radius. Because the beeper is its own opt-in toggle, turning it on **overrides the global buzzer mute** (**Settings › Sound › Buzzer**) — it's an explicit "I want to hear this". Since homing only makes sense while you're approaching a target, the **Beeper** row appears only in **Arrive** or **Both** mode — it's hidden in **Leave**-only mode, and stays silent there even if it was switched on earlier. Otherwise it's independent of the crossing alert (which does follow the mute), so you can use either or both.
|
||||
|
||||
**Setting the target from anywhere.** Besides this screen's picker, the *same* active target can be set in one step with **Set as target** from **Nearby Nodes**' or **Waypoints**' own **Hold Enter** menu — handy so you don't need a detour through Tools. Picking from this screen's picker saves on exit (so **LEFT/RIGHT** cycling stays cheap); the per-item shortcuts save immediately and confirm with a `Target set` toast.
|
||||
|
||||
**On the map.** Whatever the active target is — person or waypoint — it's drawn as a **flag marker** on both the home **Map** preview and the full **Trail Map**, on top of any waypoint/contact it overlaps and folded into the frame so it never sits off-screen. This shows even when the **Alert** master switch is off, so a target you set purely to navigate to still appears.
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------------: | :-----------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: PICK TARGET picker — None, favourites, a last-advertised contact with age tag (e.g. "@Bob (5m)"), and waypoints -->
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------------: | :-----------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Trail Map (or home Map preview) with the active-target flag marker visible -->
|
||||
|
||||
> [!TIP]
|
||||
> Mark the spot first with **Tools › Trail → Hold Enter → Mark here** (or **+ Add by coords**), then set it as the Locator target.
|
||||
|
||||
---
|
||||
|
||||
## Compass
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :------------------------: | :------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Compass — scrolling heading tape with centre pointer + large degrees/cardinal readout -->
|
||||
|
||||
A heads-up GPS compass. The L1 has no magnetometer, so the heading is the **course over ground** — derived from how your GPS position moves over the last few seconds. The display is a horizontal **heading tape**: a fixed travel-direction pointer sits at the centre and the N..E..S..W scale scrolls underneath it as you turn, so whatever is under the pointer is your current course. A large numeric readout below shows that course in degrees and cardinal (e.g. `145° SE`).
|
||||
|
||||
Because the heading comes from movement, it only updates while you are actually moving: standing still shows *move to set heading* (and navigation's **Hdg** line reads `--`). Gross GPS jumps are rejected so a single bad fix can't swing the heading. The heading source runs whenever there's a GPS fix — recording a trail is **not** required.
|
||||
@@ -203,7 +310,7 @@ Because the heading comes from movement, it only updates while you are actually
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
A step sequencer for composing custom notification melodies. Two slots — **Melody 1** and **Melody 2** — switchable from within the editor.
|
||||
|
||||
@@ -244,7 +351,7 @@ Melodies can be assigned in **Settings › Sound** (global default) or overridde
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match).
|
||||
|
||||
@@ -292,6 +399,12 @@ Commands also work on the **monitored channel** (the one selected for channel mo
|
||||
|
||||
## Diagnostics
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :------------------------: | :------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Diagnostics — live device/mesh stats rows (uptime, rx/tx counters, heap, RSSI/SNR, queue, errors) -->
|
||||
|
||||
A single read-only screen of live device and mesh stats, refreshed once a second. On a small OLED the rows scroll with **UP/DOWN**; on a larger e-ink display they all fit at once.
|
||||
|
||||
| Row | Shows |
|
||||
@@ -319,6 +432,12 @@ The counters make the repeater behaviour observable: **Forwarded** confirms the
|
||||
|
||||
## Repeater
|
||||
|
||||
| OLED | E-Ink |
|
||||
| :------------------------: | :------------------------: |
|
||||
|  |  |
|
||||
|
||||
<!-- screenshot pending: Repeater — toggle + Network/profile + flood-filter rows -->
|
||||
|
||||
Turns the companion into a packet **repeater** while it keeps working as a normal companion — no separate firmware. By default, enabling it switches the radio to a dedicated repeater profile rather than relaying on whatever network you're chatting on (see **Network** below) — that matches the MeshCore community norm of repeaters sitting on a standard channel, not a private one. Loop-detection and an advert flood-depth cap are always applied. This screen keeps the toggle, the network/profile, and its flood-filter options together; live forwarding stats are on **Tools › Diagnostics**.
|
||||
|
||||
Navigate with **UP/DOWN**; change a value with **LEFT/RIGHT** (or **Enter** for toggles). **Cancel/Back** saves and returns to Tools.
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -63,5 +63,11 @@ public:
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual void addChannelMsg(uint8_t channel_idx, const char* text) {}
|
||||
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
|
||||
// (keyed by name, best-effort). Default no-op so UI variants opt in.
|
||||
virtual void onSharedLocation(const uint8_t* pub_key, const char* name,
|
||||
int32_t lat_1e6, int32_t lon_1e6,
|
||||
uint32_t ts, bool verified) {}
|
||||
virtual void loop() = 0;
|
||||
};
|
||||
|
||||
@@ -44,6 +44,21 @@ static File openWrite(FILESYSTEM* fs, const char* filename) {
|
||||
#endif
|
||||
}
|
||||
|
||||
// Atomically swap a fully-written temp file over its final path. LittleFS
|
||||
// (nRF52/STM32) rename replaces an existing destination atomically, so a crash
|
||||
// leaves either the old file or the new one intact — never a truncated mix.
|
||||
// Other Arduino filesystems can't rename onto an existing file, so the
|
||||
// destination is dropped first (a metadata-only window, vs. the record-by-record
|
||||
// write window of a direct overwrite). Returns false if the swap fails.
|
||||
static bool commitTempFile(FILESYSTEM* fs, const char* tmp, const char* final_path) {
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
return fs->rename(tmp, final_path);
|
||||
#else
|
||||
fs->remove(final_path);
|
||||
return fs->rename(tmp, final_path);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
static uint32_t _ContactsChannelsTotalBlocks = 0;
|
||||
#endif
|
||||
@@ -357,6 +372,48 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
|
||||
rd(&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
|
||||
rd(&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
|
||||
// → 0xC0DE0011: track_shared_loc. Pre-0x11 files leave a stray sentinel byte
|
||||
// here; clamp so upgraders fall back to "off".
|
||||
rd(&_prefs.track_shared_loc, sizeof(_prefs.track_shared_loc));
|
||||
if (_prefs.track_shared_loc > 1) _prefs.track_shared_loc = 0;
|
||||
// → 0xC0DE0012: live location sharing. Pre-0x12 files leave stray bytes here;
|
||||
// clamp each field back to its default so upgraders start with sharing off.
|
||||
rd(&_prefs.loc_share_enabled, sizeof(_prefs.loc_share_enabled));
|
||||
rd(&_prefs.loc_share_target_type, sizeof(_prefs.loc_share_target_type));
|
||||
rd(&_prefs.loc_share_channel_idx, sizeof(_prefs.loc_share_channel_idx));
|
||||
rd(_prefs.loc_share_dm_prefix, sizeof(_prefs.loc_share_dm_prefix));
|
||||
rd(&_prefs.loc_share_move_idx, sizeof(_prefs.loc_share_move_idx));
|
||||
rd(&_prefs.loc_share_interval_idx, sizeof(_prefs.loc_share_interval_idx));
|
||||
rd(&_prefs.loc_share_heartbeat_idx, sizeof(_prefs.loc_share_heartbeat_idx));
|
||||
if (_prefs.loc_share_enabled > 1) _prefs.loc_share_enabled = 0;
|
||||
if (_prefs.loc_share_target_type > 1) _prefs.loc_share_target_type = 0;
|
||||
if (_prefs.loc_share_channel_idx >= MAX_GROUP_CHANNELS) _prefs.loc_share_channel_idx = 0;
|
||||
if (_prefs.loc_share_move_idx >= NodePrefs::LOC_SHARE_MOVE_COUNT) _prefs.loc_share_move_idx = 1;
|
||||
if (_prefs.loc_share_interval_idx >= NodePrefs::LOC_SHARE_INTERVAL_COUNT) _prefs.loc_share_interval_idx = 1;
|
||||
if (_prefs.loc_share_heartbeat_idx >= NodePrefs::LOC_SHARE_HEARTBEAT_COUNT) _prefs.loc_share_heartbeat_idx = 0;
|
||||
// → 0xC0DE0013: locator + trail auto-pause. Pre-0x13 files leave stray bytes
|
||||
// here; clamp each back to its default so upgraders start with both off.
|
||||
rd(&_prefs.locator_enabled, sizeof(_prefs.locator_enabled));
|
||||
rd(&_prefs.locator_has_target, sizeof(_prefs.locator_has_target));
|
||||
rd(&_prefs.locator_radius_idx, sizeof(_prefs.locator_radius_idx));
|
||||
rd(&_prefs.locator_mode, sizeof(_prefs.locator_mode));
|
||||
rd(&_prefs.locator_lat_1e6, sizeof(_prefs.locator_lat_1e6));
|
||||
rd(&_prefs.locator_lon_1e6, sizeof(_prefs.locator_lon_1e6));
|
||||
rd(_prefs.locator_label, sizeof(_prefs.locator_label));
|
||||
rd(&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
|
||||
if (_prefs.locator_enabled > 1) _prefs.locator_enabled = 0;
|
||||
if (_prefs.locator_has_target > 1) _prefs.locator_has_target = 0;
|
||||
if (_prefs.locator_radius_idx >= NodePrefs::LOCATOR_RADIUS_COUNT) _prefs.locator_radius_idx = 1;
|
||||
if (_prefs.locator_mode >= NodePrefs::LOCATOR_MODE_COUNT) _prefs.locator_mode = 0;
|
||||
if (_prefs.trail_autopause_idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) _prefs.trail_autopause_idx = 0;
|
||||
_prefs.locator_label[sizeof(_prefs.locator_label) - 1] = '\0';
|
||||
// → 0xC0DE0014: locator proximity beeper.
|
||||
rd(&_prefs.locator_beeper, sizeof(_prefs.locator_beeper));
|
||||
if (_prefs.locator_beeper > 1) _prefs.locator_beeper = 0;
|
||||
// → 0xC0DE0015: locator can target a live contact (kind + pubkey prefix).
|
||||
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;
|
||||
// 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
|
||||
@@ -432,7 +489,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
}
|
||||
|
||||
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
|
||||
File file = ::openWrite(_fs, "/new_prefs");
|
||||
// Atomic temp-then-rename (see commitTempFile) so an interrupted save can't
|
||||
// wipe settings; loadPrefs() still validates the tail sentinel on read.
|
||||
File file = ::openWrite(_fs, "/new_prefs.tmp");
|
||||
if (file) {
|
||||
uint8_t pad[8];
|
||||
memset(pad, 0, sizeof(pad));
|
||||
@@ -536,12 +595,38 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
|
||||
file.write((uint8_t *)&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
|
||||
file.write((uint8_t *)&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
|
||||
file.write((uint8_t *)&_prefs.track_shared_loc, sizeof(_prefs.track_shared_loc));
|
||||
file.write((uint8_t *)&_prefs.loc_share_enabled, sizeof(_prefs.loc_share_enabled));
|
||||
file.write((uint8_t *)&_prefs.loc_share_target_type, sizeof(_prefs.loc_share_target_type));
|
||||
file.write((uint8_t *)&_prefs.loc_share_channel_idx, sizeof(_prefs.loc_share_channel_idx));
|
||||
file.write((uint8_t *)_prefs.loc_share_dm_prefix, sizeof(_prefs.loc_share_dm_prefix));
|
||||
file.write((uint8_t *)&_prefs.loc_share_move_idx, sizeof(_prefs.loc_share_move_idx));
|
||||
file.write((uint8_t *)&_prefs.loc_share_interval_idx, sizeof(_prefs.loc_share_interval_idx));
|
||||
file.write((uint8_t *)&_prefs.loc_share_heartbeat_idx, sizeof(_prefs.loc_share_heartbeat_idx));
|
||||
file.write((uint8_t *)&_prefs.locator_enabled, sizeof(_prefs.locator_enabled));
|
||||
file.write((uint8_t *)&_prefs.locator_has_target, sizeof(_prefs.locator_has_target));
|
||||
file.write((uint8_t *)&_prefs.locator_radius_idx, sizeof(_prefs.locator_radius_idx));
|
||||
file.write((uint8_t *)&_prefs.locator_mode, sizeof(_prefs.locator_mode));
|
||||
file.write((uint8_t *)&_prefs.locator_lat_1e6, sizeof(_prefs.locator_lat_1e6));
|
||||
file.write((uint8_t *)&_prefs.locator_lon_1e6, sizeof(_prefs.locator_lon_1e6));
|
||||
file.write((uint8_t *)_prefs.locator_label, sizeof(_prefs.locator_label));
|
||||
file.write((uint8_t *)&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
|
||||
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));
|
||||
|
||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
|
||||
// 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
|
||||
// sentinel write means the whole record fit. Only then swap it in.
|
||||
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;
|
||||
file.write((uint8_t *)&sentinel, sizeof(sentinel));
|
||||
bool ok = (file.write((uint8_t *)&sentinel, sizeof(sentinel)) == sizeof(sentinel));
|
||||
|
||||
file.close();
|
||||
if (ok) {
|
||||
commitTempFile(_fs, "/new_prefs.tmp", "/new_prefs");
|
||||
} else {
|
||||
_fs->remove("/new_prefs.tmp"); // keep the previous good /new_prefs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,35 +682,48 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
}
|
||||
|
||||
void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) {
|
||||
File file = ::openWrite(_getContactsChannelsFS(), "/contacts3");
|
||||
if (file) {
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
// Write to a temp file, then atomically rename it over /contacts3 only once
|
||||
// every record has written cleanly. The old code truncated /contacts3 up
|
||||
// front and wrote in place, so a crash, reset or full flash mid-save wiped
|
||||
// the entire contact list. Now an interrupted save leaves the previous good
|
||||
// file untouched.
|
||||
File file = ::openWrite(fs, "/contacts3.tmp");
|
||||
if (!file) return;
|
||||
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
if (filter && !filter(c)) {
|
||||
idx++; // advance to next contact
|
||||
continue;
|
||||
}
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) break; // write failed
|
||||
bool ok = true;
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
if (filter && !filter(c)) {
|
||||
idx++; // advance to next contact
|
||||
continue;
|
||||
}
|
||||
file.close();
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) { ok = false; break; } // write failed (e.g. flash full)
|
||||
|
||||
idx++; // advance to next contact
|
||||
}
|
||||
file.close();
|
||||
|
||||
if (ok) {
|
||||
commitTempFile(fs, "/contacts3.tmp", "/contacts3");
|
||||
} else {
|
||||
fs->remove("/contacts3.tmp"); // keep the previous good /contacts3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,29 +775,39 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
||||
}
|
||||
|
||||
void DataStore::saveChannels(DataStoreHost* host) {
|
||||
File file = ::openWrite(_getContactsChannelsFS(), "/channels2");
|
||||
if (file) {
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
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");
|
||||
if (!file) return;
|
||||
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
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.
|
||||
bool empty = true;
|
||||
for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; }
|
||||
if (empty) continue;
|
||||
bool ok = true;
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
|
||||
bool success = (file.write(unused, 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) break; // write failed
|
||||
}
|
||||
file.close();
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
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.
|
||||
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);
|
||||
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
|
||||
}
|
||||
file.close();
|
||||
|
||||
if (ok) {
|
||||
commitTempFile(fs, "/channels2.tmp", "/channels2");
|
||||
} else {
|
||||
fs->remove("/channels2.tmp"); // keep the previous good /channels2
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,19 @@ static inline void fmtDist(char* buf, int n, float km, bool imperial) {
|
||||
}
|
||||
}
|
||||
|
||||
// Compact age tag for a timestamp, e.g. "12s" / "5m" / "3h" / "2d" — sized to
|
||||
// sit inline after a name (unlike a full "X ago" sentence). Empty string for
|
||||
// an unknown (0) or future timestamp. Takes `now` rather than reading the RTC
|
||||
// itself, so this stays a pure function like the rest of this file.
|
||||
static inline void fmtAgeShort(char* buf, int n, uint32_t now, uint32_t lastmod) {
|
||||
if (lastmod == 0 || now < lastmod) { buf[0] = '\0'; return; }
|
||||
uint32_t age = now - lastmod;
|
||||
if (age < 60) snprintf(buf, n, "%us", (unsigned)age);
|
||||
else if (age < 3600) snprintf(buf, n, "%um", (unsigned)(age / 60));
|
||||
else if (age < 86400) snprintf(buf, n, "%uh", (unsigned)(age / 3600));
|
||||
else snprintf(buf, n, "%ud", (unsigned)(age / 86400));
|
||||
}
|
||||
|
||||
// Tag marking a shared waypoint inside a message: "[WAY]<lat>,<lon> <label>".
|
||||
// A plain {loc} expansion ("<lat>,<lon>") parses too — the tag just adds intent
|
||||
// and a label, and keeps the text readable on apps/firmware that don't know it.
|
||||
@@ -109,4 +122,23 @@ static inline bool parseLatLon(const char* text, int32_t& lat_1e6, int32_t& lon_
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tag marking a live position share inside a message: "[LOC]<lat>,<lon>".
|
||||
// Distinct from WAYPOINT_MSG_TAG: a waypoint is a static point-of-interest to
|
||||
// save, whereas this announces the *sender's own* current position so the
|
||||
// receiver can update its bearing/track on that node. Both reuse parseLatLon
|
||||
// for the coordinate, and both stay readable on firmware/apps that don't know
|
||||
// the tag (it just looks like a coordinate).
|
||||
#define LOCATION_MSG_TAG "[LOC]"
|
||||
|
||||
// True if `text` carries a LOCATION_MSG_TAG share; fills lat/lon (1e6-scaled)
|
||||
// from the coordinate that follows it. Returns false for plain text, for a
|
||||
// bare coordinate, or for a [WAY] share — only an explicit [LOC] tag counts,
|
||||
// so ordinary messages that happen to contain digits don't move anyone's pin.
|
||||
static inline bool parseLocShare(const char* text, int32_t& lat_1e6, int32_t& lon_1e6) {
|
||||
if (!text) return false;
|
||||
const char* tag = strstr(text, LOCATION_MSG_TAG);
|
||||
if (!tag) return false;
|
||||
return parseLatLon(tag + strlen(LOCATION_MSG_TAG), lat_1e6, lon_1e6);
|
||||
}
|
||||
|
||||
} // namespace geo
|
||||
|
||||
119
examples/companion_radio/LiveTrack.h
Normal file
@@ -0,0 +1,119 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
// RAM-only table of positions other nodes have shared via [LOC] messages
|
||||
// (see geo::parseLocShare / LOCATION_MSG_TAG). Lets the device show a live
|
||||
// bearing/distance to people who broadcast their location on a DM or channel,
|
||||
// independent of how often they advert.
|
||||
//
|
||||
// Keying:
|
||||
// DM share → keyed by pubkey prefix, verified == true (reliable).
|
||||
// Channel share → keyed by sender name, verified == false (best-effort:
|
||||
// channel sender names are unsigned and not unique).
|
||||
//
|
||||
// The table never persists (positions go stale fast) and entries expire after
|
||||
// EXPIRY_SECS without a fresh update. When full, the oldest entry is evicted.
|
||||
class LiveTrackStore {
|
||||
public:
|
||||
static const int CAPACITY = 16; // max simultaneously tracked nodes
|
||||
static const uint32_t EXPIRY_SECS = 20UL * 60UL; // drop a track 20 min after its last update
|
||||
static const int NAME_LEN = 16; // display name buffer (incl. NUL)
|
||||
static const int KEY_LEN = 6; // pubkey prefix bytes (matches favourites)
|
||||
|
||||
struct Entry {
|
||||
uint8_t key[KEY_LEN]; // pubkey prefix (verified DM); all-zero for channel entries
|
||||
char name[NAME_LEN]; // display name, always set
|
||||
int32_t lat_1e6, lon_1e6;
|
||||
uint32_t ts; // RTC epoch seconds of the share
|
||||
bool verified; // true = DM/pubkey, false = channel/name
|
||||
bool used;
|
||||
};
|
||||
|
||||
// Insert or refresh a shared position. `key` may be null/ignored when not
|
||||
// verified. `name` is required (used as the channel key and for display).
|
||||
void update(const uint8_t* key, const char* name,
|
||||
int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) {
|
||||
int slot = find(key, name, verified);
|
||||
if (slot < 0) slot = allocSlot();
|
||||
Entry& e = _e[slot];
|
||||
if (verified && key) memcpy(e.key, key, KEY_LEN);
|
||||
else memset(e.key, 0, KEY_LEN);
|
||||
if (name) { strncpy(e.name, name, NAME_LEN - 1); e.name[NAME_LEN - 1] = '\0'; }
|
||||
else { e.name[0] = '\0'; }
|
||||
e.lat_1e6 = lat_1e6;
|
||||
e.lon_1e6 = lon_1e6;
|
||||
e.ts = ts;
|
||||
e.verified = verified;
|
||||
e.used = true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void clear() { for (int i = 0; i < CAPACITY; i++) _e[i].used = false; }
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Number of currently non-expired entries.
|
||||
int active(uint32_t now) const {
|
||||
int n = 0;
|
||||
for (int i = 0; i < CAPACITY; i++) if (isActive(i, now)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Latest active position for a verified (DM/pubkey) share, or null if that
|
||||
// node hasn't shared recently. Used by the locator engine to follow a
|
||||
// moving contact.
|
||||
const Entry* activeByKey(const uint8_t* key, uint32_t now) const {
|
||||
if (!key) return nullptr;
|
||||
for (int i = 0; i < CAPACITY; i++) {
|
||||
if (!isActive(i, now)) continue;
|
||||
const Entry& e = _e[i];
|
||||
if (e.verified && memcmp(e.key, key, KEY_LEN) == 0) return &e;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
Entry _e[CAPACITY] = {};
|
||||
|
||||
// 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++) {
|
||||
if (!_e[i].used) continue;
|
||||
if (verified) {
|
||||
if (_e[i].verified && key && memcmp(_e[i].key, key, KEY_LEN) == 0) return i;
|
||||
} else {
|
||||
if (!_e[i].verified && name && strncmp(_e[i].name, name, NAME_LEN - 1) == 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// First free slot, else evict the oldest (smallest ts).
|
||||
int allocSlot() {
|
||||
int oldest = 0;
|
||||
for (int i = 0; i < CAPACITY; i++) {
|
||||
if (!_e[i].used) return i;
|
||||
if (_e[i].ts < _e[oldest].ts) oldest = i;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "MyMesh.h"
|
||||
#include "MsgExpand.h"
|
||||
#include "GeoUtils.h"
|
||||
#include "Features.h"
|
||||
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
@@ -484,7 +485,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
||||
if (should_display && _ui) {
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, from.type, from.id.pub_key);
|
||||
_ui->notify(UIEventType::contactMessage);
|
||||
_ui->notify(from.type == ADV_TYPE_ROOM ? UIEventType::roomMessage : UIEventType::contactMessage);
|
||||
// Add to the on-device conversation history. Room servers (ADV_TYPE_ROOM) are
|
||||
// viewed through the same history list as chat contacts (keyed by the server's
|
||||
// pubkey), so their posts must be stored too — otherwise an incoming room
|
||||
@@ -636,6 +637,12 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t
|
||||
markConnectionActive(from); // in case this is from a server, and we have a connection
|
||||
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
|
||||
|
||||
// Live position share: a verified DM, so key the track by the sender's pubkey.
|
||||
int32_t loc_lat, loc_lon;
|
||||
if (_ui && geo::parseLocShare(text, loc_lat, loc_lon)) {
|
||||
_ui->onSharedLocation(from.id.pub_key, from.name, loc_lat, loc_lon, sender_timestamp, true);
|
||||
}
|
||||
|
||||
// hop count of the received message. getPathHashCount() (low 6 bits of path_len)
|
||||
// is the number of repeaters traversed — the same value the mesh uses for flood
|
||||
// retransmit priority. 0 = heard directly. (Raw path_len is a size/count
|
||||
@@ -657,6 +664,17 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin
|
||||
// from.sync_since change needs to be persisted
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text);
|
||||
|
||||
// Live position share inside a room (mirrors the DM and channel paths). The
|
||||
// post's author is the signed sender_prefix, not the room server `from`, so
|
||||
// resolve that 4-byte prefix to a contact name and track by name. Unverified:
|
||||
// we only hold a 4-byte prefix here, not the full pubkey LiveTrack keys on.
|
||||
int32_t loc_lat, loc_lon;
|
||||
if (_ui && geo::parseLocShare(text, loc_lat, loc_lon)) {
|
||||
ContactInfo* sc = sender_prefix ? lookupContactByPubKey(sender_prefix, 4) : nullptr;
|
||||
const char* who = (sc && sc->name[0]) ? sc->name : from.name;
|
||||
_ui->onSharedLocation(nullptr, who, loc_lat, loc_lon, sender_timestamp, false);
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
|
||||
@@ -710,6 +728,23 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
||||
channel_name = channel_details.name;
|
||||
}
|
||||
if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len, 0);
|
||||
|
||||
// Live position share on a channel. The sender's identity here is only the
|
||||
// unsigned "name: msg" prefix (no pubkey), so track it by name — best-effort
|
||||
// and unverified. parseLocShare requires an explicit [LOC] tag, so ordinary
|
||||
// chatter is ignored.
|
||||
int32_t loc_lat, loc_lon;
|
||||
if (_ui && geo::parseLocShare(text, loc_lat, loc_lon)) {
|
||||
char sender[32] = {0};
|
||||
const char* sep = strstr(text, ": ");
|
||||
if (sep && sep > text) {
|
||||
int n = (int)(sep - text);
|
||||
if (n > (int)sizeof(sender) - 1) n = sizeof(sender) - 1;
|
||||
memcpy(sender, text, n);
|
||||
sender[n] = '\0';
|
||||
}
|
||||
_ui->onSharedLocation(nullptr, sender[0] ? sender : "?", loc_lat, loc_lon, timestamp, false);
|
||||
}
|
||||
#endif
|
||||
|
||||
// hop count for !hops (see onMessageRecv); not the wire path_len above.
|
||||
|
||||
@@ -219,11 +219,103 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t repeater_sf;
|
||||
uint8_t repeater_cr;
|
||||
|
||||
// Track positions shared by other nodes via [LOC] messages (LiveTrackStore).
|
||||
// 0 = ignore shared positions (default), 1 = parse incoming DM/channel [LOC]
|
||||
// shares and update the live-track table (Nearby "Live" view / map).
|
||||
uint8_t track_shared_loc;
|
||||
|
||||
// Live location sharing — a message-based "beacon". When enabled, the device
|
||||
// periodically sends a [LOC] message to the chosen target while it moves.
|
||||
// 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_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)
|
||||
|
||||
// Locator — a single geofence around a saved point. When enabled the device
|
||||
// watches its own GPS fix and beeps + shows an alert when it crosses into
|
||||
// (arrive) or out of (leave) the radius. The target coordinate/label is a
|
||||
// snapshot of a chosen waypoint, so the alert survives the waypoint being
|
||||
// edited or deleted. Configured from Tools › Locator.
|
||||
uint8_t locator_enabled; // 0=off (default), 1=armed
|
||||
uint8_t locator_has_target; // 0=no target chosen yet, 1=target set
|
||||
uint8_t locator_radius_idx; // index into locatorRadiusMeters
|
||||
uint8_t locator_mode; // 0=arrive, 1=leave, 2=both
|
||||
int32_t locator_lat_1e6; // target latitude (1e6-scaled; last-known for a contact)
|
||||
int32_t locator_lon_1e6; // target longitude (1e6-scaled; last-known for a contact)
|
||||
char locator_label[12]; // target name for the alert text (WAYPOINT_LABEL_LEN)
|
||||
// Target can be a static waypoint or a live contact: for a contact the engine
|
||||
// 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
|
||||
|
||||
// Trail auto-pause — when tracking, automatically freeze the trail (timer +
|
||||
// sampling) after the device has sat still for this long, and resume on the
|
||||
// next real movement. 0 = off. Index into trailAutoPauseSecs.
|
||||
uint8_t trail_autopause_idx;
|
||||
|
||||
// Locator proximity beeper — when on (and the alert is armed with a target),
|
||||
// the device ticks while inside the radius and shortens the gap between ticks
|
||||
// the closer it gets to the target, like a homing beeper. Independent of the
|
||||
// discrete arrive/leave alert (locator_mode).
|
||||
uint8_t locator_beeper; // 0=off (default), 1=on
|
||||
|
||||
// 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;
|
||||
static uint16_t locShareMoveMeters(uint8_t idx) {
|
||||
static const uint16_t M[LOC_SHARE_MOVE_COUNT] = { 50, 100, 250, 500 };
|
||||
return M[idx < LOC_SHARE_MOVE_COUNT ? idx : 1];
|
||||
}
|
||||
static const uint8_t LOC_SHARE_INTERVAL_COUNT = 4;
|
||||
static uint16_t locShareIntervalSecs(uint8_t idx) {
|
||||
static const uint16_t S[LOC_SHARE_INTERVAL_COUNT] = { 30, 60, 120, 300 };
|
||||
return S[idx < LOC_SHARE_INTERVAL_COUNT ? idx : 1];
|
||||
}
|
||||
static const uint8_t LOC_SHARE_HEARTBEAT_COUNT = 3;
|
||||
static uint16_t locShareHeartbeatSecs(uint8_t idx) {
|
||||
static const uint16_t H[LOC_SHARE_HEARTBEAT_COUNT] = { 0, 300, 900 }; // off / 5 min / 15 min
|
||||
return H[idx < LOC_SHARE_HEARTBEAT_COUNT ? idx : 0];
|
||||
}
|
||||
|
||||
// Locator option tables (shared by the Tools › Locator UI labels and the
|
||||
// evaluation engine in UITask).
|
||||
static const uint8_t LOCATOR_RADIUS_COUNT = 5;
|
||||
static uint16_t locatorRadiusMeters(uint8_t idx) {
|
||||
static const uint16_t R[LOCATOR_RADIUS_COUNT] = { 50, 100, 250, 500, 1000 };
|
||||
return R[idx < LOCATOR_RADIUS_COUNT ? idx : 1];
|
||||
}
|
||||
static const uint8_t LOCATOR_MODE_COUNT = 3; // 0=arrive, 1=leave, 2=both
|
||||
static const char* locatorModeLabel(uint8_t m) {
|
||||
static const char* L[LOCATOR_MODE_COUNT] = { "Arrive", "Leave", "Both" };
|
||||
return L[m < LOCATOR_MODE_COUNT ? m : 0];
|
||||
}
|
||||
|
||||
// Trail auto-pause delays (seconds). 0 = off.
|
||||
static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4;
|
||||
static uint16_t trailAutoPauseSecs(uint8_t idx) {
|
||||
static const uint16_t S[TRAIL_AUTOPAUSE_COUNT] = { 0, 60, 120, 300 }; // off / 1 / 2 / 5 min
|
||||
return S[idx < TRAIL_AUTOPAUSE_COUNT ? idx : 0];
|
||||
}
|
||||
static const char* trailAutoPauseLabel(uint8_t idx) {
|
||||
static const char* L[TRAIL_AUTOPAUSE_COUNT] = { "Off", "1m", "2m", "5m" };
|
||||
return L[idx < TRAIL_AUTOPAUSE_COUNT ? idx : 0];
|
||||
}
|
||||
// Movement under this many metres counts as "stationary" for auto-pause.
|
||||
// Deliberately coarser than the trail min-delta gate (and independent of it)
|
||||
// so GPS jitter while parked doesn't keep resetting the idle timer. Engine
|
||||
// tuning only — not persisted.
|
||||
static const uint16_t TRAIL_AUTOPAUSE_MOVE_M = 15;
|
||||
|
||||
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
|
||||
// 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 = 0xC0DE0010;
|
||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0015;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -9,9 +9,19 @@
|
||||
|
||||
// RAM-only GPS trail ring buffer.
|
||||
// Storage cost: CAPACITY(512) × sizeof(TrailPoint)(16 B, padded) = 8 KB,
|
||||
// always resident (UITask::_trail member). The trail survives auto-off (only
|
||||
// always resident in .bss (UITask::_trail, and ui_task is a global object —
|
||||
// see main.cpp). Because the nRF52 heap region starts just above .bss, every
|
||||
// byte added here is a byte taken from the heap: at 1024 points (16 KB) free
|
||||
// heap fell to ~4 KB and the input/menu path started failing its allocations
|
||||
// while the periodic redraw kept running. Keep this conservative. The trail
|
||||
// survives auto-off (only
|
||||
// the display blanks) but is lost on reboot — user explicitly snapshots to a
|
||||
// LittleFS slot before powering down to keep it.
|
||||
//
|
||||
// Samples are simplified in-stream as they arrive (see addPoint()): a straight
|
||||
// stretch is stored as just its two endpoints, no matter how long, while a
|
||||
// turn still gets a vertex roughly every min-delta of deviation — so CAPACITY
|
||||
// covers a much longer route than CAPACITY × min-delta would suggest.
|
||||
|
||||
struct TrailPoint {
|
||||
int32_t lat_1e6;
|
||||
@@ -73,6 +83,7 @@ public:
|
||||
if (a && !_active) {
|
||||
// off → on: start a new session timer.
|
||||
_session_start_ms = millis();
|
||||
_paused = false;
|
||||
} else if (_active && !a) {
|
||||
// on → off: bank the elapsed of this session and arm a segment break
|
||||
// so the renderer doesn't draw a straight line through the dead time.
|
||||
@@ -80,11 +91,34 @@ public:
|
||||
_accumulated_ms += millis() - _session_start_ms;
|
||||
_session_start_ms = 0;
|
||||
}
|
||||
flushPending();
|
||||
_pending_seg_break = true;
|
||||
_paused = false;
|
||||
}
|
||||
_active = a;
|
||||
}
|
||||
|
||||
// Auto-pause: freeze the active trail without ending the session. Banks the
|
||||
// running session time (so elapsedSeconds() stops advancing) and arms a
|
||||
// segment break so the map doesn't draw a line across the idle gap; resuming
|
||||
// restarts the session timer. Distinct from setActive() — the trail stays
|
||||
// "on" (UI shows "paused", home-screen blink keeps going). No-op if inactive.
|
||||
bool isPaused() const { return _paused; }
|
||||
void setPaused(bool p) {
|
||||
if (!_active || p == _paused) return;
|
||||
if (p) {
|
||||
if (_session_start_ms != 0) {
|
||||
_accumulated_ms += millis() - _session_start_ms;
|
||||
_session_start_ms = 0;
|
||||
}
|
||||
flushPending();
|
||||
_pending_seg_break = true;
|
||||
} else {
|
||||
_session_start_ms = millis(); // resume timing from now
|
||||
}
|
||||
_paused = p;
|
||||
}
|
||||
|
||||
int count() const { return _count; }
|
||||
bool empty() const { return _count == 0; }
|
||||
|
||||
@@ -98,36 +132,83 @@ public:
|
||||
_pending_seg_break = false;
|
||||
_accumulated_ms = 0;
|
||||
_session_start_ms = 0;
|
||||
_paused = false;
|
||||
_has_pending = false;
|
||||
}
|
||||
|
||||
// Returns true if the point was stored (passed the min-delta gate).
|
||||
// First point of the ring and the first point after a stop/start cycle
|
||||
// get flagged TRAIL_FLAG_SEG_START so the map renderer breaks the line.
|
||||
// Returns true if the sample was accepted (passed the min-delta gate) —
|
||||
// whether that landed it as a new committed vertex right away, or just
|
||||
// extended the pending candidate (see below). First point of the ring and
|
||||
// the first point after a stop/start cycle get flagged TRAIL_FLAG_SEG_START
|
||||
// so the map renderer breaks the line.
|
||||
//
|
||||
// Straight-run simplification (a fixed-corridor / Reumann–Witkam pass): a
|
||||
// sample is only ever a *candidate* vertex (_pending) until a later one
|
||||
// proves the run has bent. The corridor is the straight line anchored at the
|
||||
// last committed vertex and aimed at the first sample of this run (_dir);
|
||||
// each new sample is tested against *that fixed line*. While it stays within
|
||||
// CORRIDOR_FACTOR x min_delta_m of the corridor the run is still "straight
|
||||
// enough", so drop the intermediate and extend; once a sample leaves the
|
||||
// corridor the previous in-corridor sample (_pending) was the last good
|
||||
// vertex, so commit it and open a fresh corridor from there.
|
||||
//
|
||||
// The corridor is deliberately tighter than the min-delta gate itself
|
||||
// (CORRIDOR_FACTOR < 1): the gate is the noise floor for *rejecting* samples
|
||||
// outright, while the corridor decides when a run has bent enough to need a
|
||||
// new vertex. Field data showed plenty of headroom (4 km at a 10 m gate cost
|
||||
// just 38 of the 512-point capacity), so a tighter corridor trades some of
|
||||
// that headroom for a polyline that hugs the real track more closely.
|
||||
//
|
||||
// The fixed direction is the whole point: testing the newest sample against
|
||||
// a line that re-aims at the newest sample (or testing the previous
|
||||
// candidate, which always sits right beside that moving endpoint where the
|
||||
// cross-track is near zero) lets a long gentle curve slip through one
|
||||
// sub-min_delta step at a time and collapse to a single chord that deviates
|
||||
// arbitrarily far. Anchoring the direction bounds the stored polyline to
|
||||
// ~CORRIDOR_FACTOR x min_delta_m of the real track, while a straight road
|
||||
// still costs just its two endpoints no matter how long it is.
|
||||
// How much tighter the simplification corridor is than the min-delta gate
|
||||
// (see addPoint() above). 1.0 would track the gate exactly; lowering this
|
||||
// commits more vertices per route (more fidelity, less reduction).
|
||||
static constexpr float CORRIDOR_FACTOR = 0.5f;
|
||||
|
||||
bool addPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint16_t min_delta_m) {
|
||||
if (_count > 0 && !_pending_seg_break) {
|
||||
float d = haversineMeters(last().lat_1e6, last().lon_1e6, lat_1e6, lon_1e6);
|
||||
const TrailPoint* ref = _has_pending ? &_pending : (_count > 0 ? &last() : nullptr);
|
||||
if (ref && !_pending_seg_break) {
|
||||
float d = haversineMeters(ref->lat_1e6, ref->lon_1e6, lat_1e6, lon_1e6);
|
||||
if (d < (float)min_delta_m) return false;
|
||||
}
|
||||
uint8_t flags = (_count == 0 || _pending_seg_break) ? TRAIL_FLAG_SEG_START : 0;
|
||||
_pending_seg_break = false;
|
||||
|
||||
int pos;
|
||||
if (_count < CAPACITY) {
|
||||
pos = (_head + _count) % CAPACITY;
|
||||
_count++;
|
||||
} else {
|
||||
pos = _head;
|
||||
_head = (_head + 1) % CAPACITY;
|
||||
if (_count == 0 || _pending_seg_break) {
|
||||
flushPending(); // shouldn't normally have one here, but never lose real distance
|
||||
commitPoint(lat_1e6, lon_1e6, ts, TRAIL_FLAG_SEG_START);
|
||||
_pending_seg_break = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
TrailPoint sample{ lat_1e6, lon_1e6, ts, 0 };
|
||||
if (!_has_pending) {
|
||||
_pending = sample; // last in-corridor sample (the commit candidate)
|
||||
_dir = sample; // fixes the corridor direction: last() → _dir
|
||||
_has_pending = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (crossTrackMeters(last(), _dir, sample) <= (float)min_delta_m * CORRIDOR_FACTOR) {
|
||||
_pending = sample; // within corridor — extend
|
||||
} else {
|
||||
commitPoint(_pending.lat_1e6, _pending.lon_1e6, _pending.ts, 0); // left corridor — keep last good
|
||||
_pending = sample; // the exiting sample opens the next run...
|
||||
_dir = sample; // ...and fixes its corridor direction from the just-committed vertex
|
||||
}
|
||||
_buf[pos].lat_1e6 = lat_1e6;
|
||||
_buf[pos].lon_1e6 = lon_1e6;
|
||||
_buf[pos].ts = ts;
|
||||
_buf[pos].flags = flags;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sum of pairwise Haversine deltas across the whole ring, skipping segment
|
||||
// boundaries (a SEG_START point isn't reached from its predecessor).
|
||||
// boundaries (a SEG_START point isn't reached from its predecessor). The
|
||||
// uncommitted candidate (_pending) is counted too, so the live total doesn't
|
||||
// stall over a long straight run — where simplification holds the whole
|
||||
// stretch as one pending point until a bend forces a commit (see addPoint()).
|
||||
uint32_t totalDistanceMeters() const {
|
||||
float d = 0;
|
||||
for (int i = 1; i < _count; i++) {
|
||||
@@ -135,6 +216,9 @@ public:
|
||||
d += haversineMeters(at(i - 1).lat_1e6, at(i - 1).lon_1e6,
|
||||
at(i).lat_1e6, at(i).lon_1e6);
|
||||
}
|
||||
if (_has_pending && _count > 0)
|
||||
d += haversineMeters(last().lat_1e6, last().lon_1e6,
|
||||
_pending.lat_1e6, _pending.lon_1e6);
|
||||
return (uint32_t)d;
|
||||
}
|
||||
|
||||
@@ -182,6 +266,7 @@ public:
|
||||
// cleanly. The file is left open for the caller to close.
|
||||
template <typename F>
|
||||
bool writeTo(F& file) {
|
||||
flushPending(); // the snapshot should include the latest position, not lag behind it
|
||||
if (!persist::writeHeader(file, SAVE_MAGIC, SAVE_VERSION, (uint16_t)_count)) return false;
|
||||
uint32_t accum = currentAccumulatedMs();
|
||||
if (file.write((uint8_t*)&accum, sizeof(accum)) != sizeof(accum)) return false;
|
||||
@@ -202,6 +287,8 @@ public:
|
||||
_active = false;
|
||||
_session_start_ms = 0;
|
||||
}
|
||||
_paused = false;
|
||||
_has_pending = false; // any candidate belonged to the session being replaced
|
||||
_head = 0;
|
||||
_count = 0;
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
@@ -367,7 +454,58 @@ private:
|
||||
int _head = 0;
|
||||
int _count = 0;
|
||||
bool _active = false;
|
||||
bool _paused = false; // auto-paused (active but timer/sampling frozen)
|
||||
bool _pending_seg_break = false; // next addPoint flags itself SEG_START
|
||||
uint32_t _accumulated_ms = 0; // banked active time across previous sessions
|
||||
uint32_t _session_start_ms = 0; // millis() of the current active session, 0 if none
|
||||
|
||||
// Streaming simplification: a sample that passed the min-delta gate but
|
||||
// hasn't been committed as a real vertex yet — see addPoint(). _dir is the
|
||||
// first sample of the current run; last()→_dir fixes the corridor direction
|
||||
// every later sample of the run is tested against.
|
||||
bool _has_pending = false;
|
||||
TrailPoint _pending;
|
||||
TrailPoint _dir;
|
||||
|
||||
void commitPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint8_t flags) {
|
||||
int pos;
|
||||
if (_count < CAPACITY) {
|
||||
pos = (_head + _count) % CAPACITY;
|
||||
_count++;
|
||||
} else {
|
||||
pos = _head;
|
||||
_head = (_head + 1) % CAPACITY;
|
||||
}
|
||||
_buf[pos].lat_1e6 = lat_1e6;
|
||||
_buf[pos].lon_1e6 = lon_1e6;
|
||||
_buf[pos].ts = ts;
|
||||
_buf[pos].flags = flags;
|
||||
}
|
||||
|
||||
// Commit the pending candidate (if any) as a real vertex. Called before a
|
||||
// segment break (stop/pause/save) so the last stretch of a straight run is
|
||||
// never silently dropped just because no bend came along to force a commit.
|
||||
void flushPending() {
|
||||
if (!_has_pending) return;
|
||||
commitPoint(_pending.lat_1e6, _pending.lon_1e6, _pending.ts, 0);
|
||||
_has_pending = false;
|
||||
}
|
||||
|
||||
// Perpendicular ("cross-track") distance from q to the line through a→b, in
|
||||
// metres. Planar approximation (equirectangular, centred at a) — accurate
|
||||
// enough at trail scale, where consecutive points are metres to a few km
|
||||
// apart. Falls back to a straight distance to a if a and b coincide.
|
||||
static float crossTrackMeters(const TrailPoint& a, const TrailPoint& b, const TrailPoint& q) {
|
||||
static const float M_PER_DEG = 111320.0f; // metres per degree of latitude
|
||||
float lat_rad = (a.lat_1e6 * 1e-6f) * ((float)M_PI / 180.0f);
|
||||
float lon_scale = cosf(lat_rad);
|
||||
float bx = (b.lon_1e6 - a.lon_1e6) * 1e-6f * M_PER_DEG * lon_scale;
|
||||
float by = (b.lat_1e6 - a.lat_1e6) * 1e-6f * M_PER_DEG;
|
||||
float ab_len = sqrtf(bx * bx + by * by);
|
||||
if (ab_len < 0.01f) return haversineMeters(a.lat_1e6, a.lon_1e6, q.lat_1e6, q.lon_1e6);
|
||||
float qx = (q.lon_1e6 - a.lon_1e6) * 1e-6f * M_PER_DEG * lon_scale;
|
||||
float qy = (q.lat_1e6 - a.lat_1e6) * 1e-6f * M_PER_DEG;
|
||||
float cross = bx * qy - by * qx;
|
||||
return fabsf(cross) / ab_len;
|
||||
}
|
||||
};
|
||||
|
||||
97
examples/companion_radio/ui-new/AccordionList.h
Normal file
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
// Shared collapsible section list (accordion) — a Settings-style list whose
|
||||
// rows are grouped under headers that expand/collapse in place. Owns the
|
||||
// collapse bitmask, the flattened visible-row list, selection and scroll; the
|
||||
// host screen only supplies the section sizes and two row painters.
|
||||
//
|
||||
// Reusable UI element (cf. drawList / DigitEditor / NavView): any screen that
|
||||
// wants grouped, foldable rows drives it the same way —
|
||||
// _acc.begin(sizes, n); // once, on enter()
|
||||
// _acc.render(display, headerFn, itemFn); // in render()
|
||||
// switch (_acc.handleInput(c)) { ... } // in handleInput()
|
||||
//
|
||||
// Relies on drawList() + the KEY_* codes already pulled in by icons.h /
|
||||
// UIScreen.h before this header (same include-order contract as the screens).
|
||||
#include "icons.h"
|
||||
|
||||
class AccordionList {
|
||||
public:
|
||||
// A visible row is either a section header (item < 0) or the item-th entry
|
||||
// within section `sec`.
|
||||
struct Row { int8_t sec; int8_t item; };
|
||||
|
||||
enum Result { IGNORED, HANDLED, ACTIVATED };
|
||||
|
||||
static const int MAX_SECTIONS = 8; // collapse bitmask is 8 bits
|
||||
static const int MAX_ROWS = 64;
|
||||
|
||||
// (Re)initialise from per-section item counts. `collapsed` sets the initial
|
||||
// fold state for every section (Settings opens folded, so default true).
|
||||
void begin(const uint8_t* section_sizes, int section_count, bool collapsed = true) {
|
||||
_count = section_count > MAX_SECTIONS ? MAX_SECTIONS : section_count;
|
||||
for (int i = 0; i < _count; i++) _sizes[i] = section_sizes[i];
|
||||
_collapsed = collapsed ? 0xFF : 0x00;
|
||||
_sel = 0; _scroll = 0;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
int visibleCount() const { return _vis_count; }
|
||||
bool collapsed(int sec) const { return (_collapsed >> sec) & 1; }
|
||||
const Row& selected() const { return _rows[_sel]; }
|
||||
bool onHeader() const { return _vis_count && _rows[_sel].item < 0; }
|
||||
|
||||
// Paint via the shared drawList skeleton. `header(sec, y, sel, reserve,
|
||||
// collapsed)` and `item(sec, item, y, sel, reserve)` each draw one row
|
||||
// (including its own selection bar, as drawList's callers do).
|
||||
template <class HeaderFn, class ItemFn>
|
||||
void render(DisplayDriver& d, HeaderFn header, ItemFn item) {
|
||||
drawList(d, _vis_count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
|
||||
const Row& r = _rows[idx];
|
||||
if (r.item < 0) header(r.sec, y, sel, reserve, collapsed(r.sec));
|
||||
else item(r.sec, r.item, y, sel, reserve);
|
||||
});
|
||||
}
|
||||
|
||||
// Standard navigation. Returns ACTIVATED when Enter lands on an item (the
|
||||
// host then acts on selected()); HANDLED for nav / fold toggles; IGNORED
|
||||
// otherwise so the host can treat the key itself (e.g. Cancel).
|
||||
Result handleInput(char c) {
|
||||
if (c == KEY_UP) { if (_vis_count) _sel = (_sel > 0) ? _sel - 1 : _vis_count - 1; return HANDLED; }
|
||||
if (c == KEY_DOWN) { if (_vis_count) _sel = (_sel + 1 < _vis_count) ? _sel + 1 : 0; return HANDLED; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (!_vis_count) return HANDLED;
|
||||
if (_rows[_sel].item < 0) { toggle(_rows[_sel].sec); return HANDLED; }
|
||||
return ACTIVATED;
|
||||
}
|
||||
return IGNORED;
|
||||
}
|
||||
|
||||
// Fold/unfold a section, keeping that section's header selected afterwards so
|
||||
// the cursor never jumps to an unrelated row when items appear/disappear.
|
||||
void toggle(int sec) {
|
||||
if (sec < 0 || sec >= _count) return;
|
||||
_collapsed ^= (uint8_t)(1u << sec);
|
||||
rebuild();
|
||||
for (int i = 0; i < _vis_count; i++)
|
||||
if (_rows[i].sec == sec && _rows[i].item < 0) { _sel = i; break; }
|
||||
}
|
||||
|
||||
private:
|
||||
uint8_t _sizes[MAX_SECTIONS];
|
||||
int _count = 0;
|
||||
uint8_t _collapsed = 0xFF;
|
||||
Row _rows[MAX_ROWS];
|
||||
int _vis_count = 0;
|
||||
int _sel = 0, _scroll = 0;
|
||||
|
||||
void rebuild() {
|
||||
_vis_count = 0;
|
||||
for (int s = 0; s < _count; s++) {
|
||||
if (_vis_count < MAX_ROWS) _rows[_vis_count++] = { (int8_t)s, (int8_t)-1 };
|
||||
if (!collapsed(s))
|
||||
for (int it = 0; it < _sizes[s] && _vis_count < MAX_ROWS; it++)
|
||||
_rows[_vis_count++] = { (int8_t)s, (int8_t)it };
|
||||
}
|
||||
if (_sel >= _vis_count) _sel = _vis_count ? _vis_count - 1 : 0;
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <stdlib.h> // abs
|
||||
#include "../Trail.h"
|
||||
|
||||
namespace gfx {
|
||||
|
||||
@@ -37,4 +38,25 @@ static inline void drawCircle(DisplayDriver& d, int cx, int cy, int r) {
|
||||
}
|
||||
}
|
||||
|
||||
// Walks a TrailStore and renders it as a connected polyline via drawLine(),
|
||||
// breaking the line at each TRAIL_FLAG_SEG_START point (trail paused/
|
||||
// restarted) instead of bridging the dead-time gap. Shared by the full Trail
|
||||
// map and the Home screen's mini-map preview, which only differ in how they
|
||||
// project lat/lon to screen coords and what (if anything) they draw at a
|
||||
// break. `project(lat, lon, x, y)` fills the screen coords for one point;
|
||||
// `on_break(x_prev_end, y_prev_end, x_new_start, y_new_start)` runs at each
|
||||
// break instead of drawLine() — pass a no-op lambda for a silent gap.
|
||||
template <typename Project, typename OnBreak>
|
||||
static void drawTrail(DisplayDriver& d, TrailStore& tr, Project project, OnBreak on_break) {
|
||||
if (tr.count() == 0) return;
|
||||
int x0, y0; project(tr.at(0).lat_1e6, tr.at(0).lon_1e6, x0, y0);
|
||||
for (int i = 1; i < tr.count(); i++) {
|
||||
const TrailPoint& pt = tr.at(i);
|
||||
int x1, y1; project(pt.lat_1e6, pt.lon_1e6, x1, y1);
|
||||
if (pt.flags & TRAIL_FLAG_SEG_START) on_break(x0, y0, x1, y1);
|
||||
else drawLine(d, x0, y0, x1, y1);
|
||||
x0 = x1; y0 = y1;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace gfx
|
||||
|
||||
175
examples/companion_radio/ui-new/LiveShareScreen.h
Normal file
@@ -0,0 +1,175 @@
|
||||
#pragma once
|
||||
// Live location sharing config tool. Tools › Live Share.
|
||||
// One place for both directions of position sharing:
|
||||
// Track loc — receive [LOC] shares from others (LiveTrackStore).
|
||||
// Auto share — periodically broadcast my own [LOC] to a channel/contact
|
||||
// while moving (movement-gated engine in UITask).
|
||||
// Independent of (and able to run alongside) the 0-hop Auto-Advert. The
|
||||
// one-shot "Share my pos" lives on the Map screen.
|
||||
// Included by UITask.cpp after AutoAdvertScreen.h.
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
#include "icons.h" // drawList (shared scrolling-list helper)
|
||||
|
||||
class LiveShareScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
bool _dirty = false;
|
||||
int _sel = 0;
|
||||
int _scroll = 0;
|
||||
|
||||
enum Kind : uint8_t { K_TRACK, K_AUTO, K_TARGET, K_MOVE, K_GAP, K_HB };
|
||||
struct Row { Kind kind; const char* label; };
|
||||
static const int ROW_COUNT = 6;
|
||||
static Row rows(int i) {
|
||||
static const Row R[ROW_COUNT] = {
|
||||
{ K_TRACK, "Track loc" },
|
||||
{ K_AUTO, "Auto share" },
|
||||
{ K_TARGET, "To" },
|
||||
{ K_MOVE, "Move" },
|
||||
{ K_GAP, "Min gap" },
|
||||
{ K_HB, "Heartbeat" },
|
||||
};
|
||||
return R[i];
|
||||
}
|
||||
|
||||
public:
|
||||
LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() { _dirty = false; _sel = 0; _scroll = 0; }
|
||||
|
||||
// Resolve the configured target's display name (channel name or contact name).
|
||||
void currentTargetName(char* buf, int n) {
|
||||
if (!_prefs) { snprintf(buf, n, "none"); return; }
|
||||
if (_prefs->loc_share_target_type == 0) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(_prefs->loc_share_channel_idx, ch) && ch.name[0])
|
||||
snprintf(buf, n, "%s", ch.name);
|
||||
else
|
||||
snprintf(buf, n, "Ch %d", (int)_prefs->loc_share_channel_idx);
|
||||
} else {
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->loc_share_dm_prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (c) snprintf(buf, n, "%s", c->name);
|
||||
else snprintf(buf, n, "(contact?)");
|
||||
}
|
||||
}
|
||||
|
||||
void valueLabel(Kind k, char* buf, int n) {
|
||||
switch (k) {
|
||||
case K_TRACK:
|
||||
snprintf(buf, n, "%s", (_prefs && _prefs->track_shared_loc) ? "ON" : "OFF");
|
||||
break;
|
||||
case K_AUTO:
|
||||
snprintf(buf, n, "%s", (_prefs && _prefs->loc_share_enabled) ? "ON" : "OFF");
|
||||
break;
|
||||
case K_TARGET:
|
||||
currentTargetName(buf, n);
|
||||
break;
|
||||
case K_MOVE:
|
||||
snprintf(buf, n, "%um", (unsigned)NodePrefs::locShareMoveMeters(_prefs ? _prefs->loc_share_move_idx : 1));
|
||||
break;
|
||||
case K_GAP: {
|
||||
uint16_t s = NodePrefs::locShareIntervalSecs(_prefs ? _prefs->loc_share_interval_idx : 1);
|
||||
if (s < 60) snprintf(buf, n, "%us", (unsigned)s);
|
||||
else snprintf(buf, n, "%um", (unsigned)(s / 60));
|
||||
break;
|
||||
}
|
||||
case K_HB: {
|
||||
uint16_t s = NodePrefs::locShareHeartbeatSecs(_prefs ? _prefs->loc_share_heartbeat_idx : 0);
|
||||
if (s == 0) snprintf(buf, n, "OFF");
|
||||
else snprintf(buf, n, "%um", (unsigned)(s / 60));
|
||||
break;
|
||||
}
|
||||
default: buf[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawCenteredHeader("LIVE SHARE");
|
||||
|
||||
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);
|
||||
display.setCursor(4, y);
|
||||
display.print(r.label);
|
||||
char val[24];
|
||||
valueLabel(r.kind, val, sizeof(val));
|
||||
if (val[0]) display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val);
|
||||
});
|
||||
return 500;
|
||||
}
|
||||
|
||||
void moveSel(int dir) { _sel = (_sel + dir + ROW_COUNT) % ROW_COUNT; }
|
||||
|
||||
// Cycle/act on the selected row. `enter` opens the target picker (vs LEFT/RIGHT
|
||||
// value cycling).
|
||||
void activate(int dir, bool enter) {
|
||||
if (!_prefs) return;
|
||||
switch (rows(_sel).kind) {
|
||||
case K_TRACK: _prefs->track_shared_loc ^= 1; _dirty = true; break;
|
||||
case K_AUTO: _prefs->loc_share_enabled ^= 1; _dirty = true; break;
|
||||
case K_TARGET:
|
||||
if (enter) { _task->pickLocShareTarget(); return; } // full chooser
|
||||
cycleTarget(dir); _dirty = true; // L/R quick cycle
|
||||
break;
|
||||
case K_MOVE:
|
||||
_prefs->loc_share_move_idx = (uint8_t)((_prefs->loc_share_move_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_MOVE_COUNT - 1)) % NodePrefs::LOC_SHARE_MOVE_COUNT);
|
||||
_dirty = true; break;
|
||||
case K_GAP:
|
||||
_prefs->loc_share_interval_idx = (uint8_t)((_prefs->loc_share_interval_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_INTERVAL_COUNT - 1)) % NodePrefs::LOC_SHARE_INTERVAL_COUNT);
|
||||
_dirty = true; break;
|
||||
case K_HB:
|
||||
_prefs->loc_share_heartbeat_idx = (uint8_t)((_prefs->loc_share_heartbeat_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_HEARTBEAT_COUNT - 1)) % NodePrefs::LOC_SHARE_HEARTBEAT_COUNT);
|
||||
_dirty = true; break;
|
||||
}
|
||||
}
|
||||
|
||||
void cycleTarget(int dir) {
|
||||
struct T { uint8_t type, ch; uint8_t prefix[NodePrefs::FAVOURITE_PREFIX_LEN]; } list[24];
|
||||
int n = 0;
|
||||
ChannelDetails ch;
|
||||
for (int i = 0; i < 64 && n < 24; i++) {
|
||||
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
|
||||
list[n].type = 0; list[n].ch = (uint8_t)i; n++;
|
||||
}
|
||||
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT && n < 24; i++) {
|
||||
const uint8_t* pre = _prefs->favourite_contacts[i];
|
||||
bool empty = true;
|
||||
for (int b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) if (pre[b]) { empty = false; break; }
|
||||
ContactInfo* fc = the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
// Skip room servers: a [LOC] DM to a room server isn't reposted to the
|
||||
// room's members, so it can't serve as a live-share broadcast target.
|
||||
if (empty || !fc || fc->type == ADV_TYPE_ROOM) continue;
|
||||
list[n].type = 1; list[n].ch = 0;
|
||||
memcpy(list[n].prefix, pre, NodePrefs::FAVOURITE_PREFIX_LEN); n++;
|
||||
}
|
||||
if (n == 0) { _task->showAlert("No channels/favs", 1200); return; }
|
||||
int cur = -1;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (list[i].type != _prefs->loc_share_target_type) continue;
|
||||
if (list[i].type == 0) { if (list[i].ch == _prefs->loc_share_channel_idx) { cur = i; break; } }
|
||||
else if (memcmp(list[i].prefix, _prefs->loc_share_dm_prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { cur = i; break; }
|
||||
}
|
||||
int nx = (cur < 0) ? 0 : ((cur + (dir >= 0 ? 1 : n - 1)) % n);
|
||||
_prefs->loc_share_target_type = list[nx].type;
|
||||
if (list[nx].type == 0) _prefs->loc_share_channel_idx = list[nx].ch;
|
||||
else memcpy(_prefs->loc_share_dm_prefix, list[nx].prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) { the_mesh.savePrefs(); _dirty = false; }
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_UP) { moveSel(-1); return true; }
|
||||
if (c == KEY_DOWN) { moveSel(+1); return true; }
|
||||
if (keyIsPrev(c)) { activate(-1, false); return true; }
|
||||
if (keyIsNext(c)) { activate(+1, false); return true; }
|
||||
if (c == KEY_ENTER) { activate(+1, true); return true; }
|
||||
return false;
|
||||
}
|
||||
};
|
||||
310
examples/companion_radio/ui-new/LocatorScreen.h
Normal file
@@ -0,0 +1,310 @@
|
||||
#pragma once
|
||||
// Locator config tool. Tools › Locator.
|
||||
// A single geofence whose target is either a saved waypoint (a place) or a
|
||||
// person — a favourite/contact or a live [LOC] sender, keyed by pubkey prefix.
|
||||
// When armed the device beeps / alerts as it crosses into (arrive/near) or out
|
||||
// of (leave/away) the radius. A waypoint target is snapshotted (coord + label);
|
||||
// a person target follows their latest shared position. The crossing engine
|
||||
// lives in UITask::evaluateLocator(). The Target row's Enter opens a picker
|
||||
// ("None" first — the only way to unset a target once chosen — then
|
||||
// favourites, offered even with no known position yet so you can arm ahead
|
||||
// of time, then any other contact with a currently-known position:
|
||||
// live-sharing or just last-advertised, e.g. a repeater; then waypoints).
|
||||
// LEFT/RIGHT quick-cycles the same set. The same active target can also be set
|
||||
// directly from Nearby Nodes' or Waypoints' own "Set as target" menu item
|
||||
// (UITask::setTargetNow()), bypassing this picker entirely.
|
||||
// Included by UITask.cpp after LiveShareScreen.h.
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
#include "../Waypoint.h"
|
||||
#include "../LiveTrack.h"
|
||||
#include "../GeoUtils.h"
|
||||
#include "icons.h" // drawList (shared scrolling-list helper)
|
||||
|
||||
class LocatorScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
bool _dirty = false;
|
||||
int _sel = 0;
|
||||
int _scroll = 0;
|
||||
|
||||
// Target picker (Enter on the Target row): a flat selectable list of people
|
||||
// and places, rebuilt from favourites / known-position contacts / waypoints
|
||||
// on open. `live` + `ts` are picker-display-only (freshness), never written
|
||||
// to prefs — a person target is re-resolved by key at evaluation time.
|
||||
bool _picking = false;
|
||||
int _pick_sel = 0, _pick_scroll = 0;
|
||||
struct Target {
|
||||
uint8_t kind; // 0=waypoint 1=person 2=none (clear target)
|
||||
int32_t lat, lon;
|
||||
uint8_t key[6];
|
||||
char name[20];
|
||||
uint32_t ts; // last position update (0 = unknown), for the age tag
|
||||
bool live; // true = an active [LOC] share right now
|
||||
};
|
||||
static const int TARGET_MAX = 40;
|
||||
Target _targets[TARGET_MAX];
|
||||
int _target_n = 0;
|
||||
|
||||
enum Kind : uint8_t { K_ENABLE, K_TARGET, K_RADIUS, K_MODE, K_BEEPER };
|
||||
struct Row { Kind kind; const char* label; };
|
||||
static const int ROW_COUNT = 5;
|
||||
static Row rows(int i) {
|
||||
static const Row R[ROW_COUNT] = {
|
||||
{ K_ENABLE, "Alert" },
|
||||
{ K_TARGET, "Target" },
|
||||
{ K_RADIUS, "Radius" },
|
||||
{ K_MODE, "Mode" },
|
||||
{ K_BEEPER, "Beeper" },
|
||||
};
|
||||
return R[i];
|
||||
}
|
||||
|
||||
// The homing Beeper only makes sense when "arrive" is part of the alert mode —
|
||||
// it guides you toward the target while inside the radius. In pure "leave"
|
||||
// mode (1) it's contradictory (silent outside, slows as you near the exit), so
|
||||
// hide its row. Beeper is the last row, so this is just a shorter visible count;
|
||||
// the beeper engine (UITask::locatorProximityBeeper) is gated on the mode too.
|
||||
int visibleRows() const {
|
||||
return (_prefs && _prefs->locator_mode == 1) ? ROW_COUNT - 1 : ROW_COUNT;
|
||||
}
|
||||
|
||||
public:
|
||||
LocatorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() { _dirty = false; _sel = 0; _scroll = 0; _picking = false; }
|
||||
|
||||
void valueLabel(Kind k, char* buf, int n) {
|
||||
switch (k) {
|
||||
case K_ENABLE:
|
||||
snprintf(buf, n, "%s", (_prefs && _prefs->locator_enabled) ? "ON" : "OFF");
|
||||
break;
|
||||
case K_TARGET:
|
||||
if (_prefs && _prefs->locator_has_target) {
|
||||
const char* nm = _prefs->locator_label[0] ? _prefs->locator_label : "(unnamed)";
|
||||
// '@' prefix marks a live contact target (a moving person) vs a waypoint.
|
||||
if (_prefs->locator_target_kind == 1) snprintf(buf, n, "@%s", nm);
|
||||
else snprintf(buf, n, "%s", nm);
|
||||
} else {
|
||||
snprintf(buf, n, "none");
|
||||
}
|
||||
break;
|
||||
case K_RADIUS: {
|
||||
uint16_t r = NodePrefs::locatorRadiusMeters(_prefs ? _prefs->locator_radius_idx : 1);
|
||||
if (r < 1000) snprintf(buf, n, "%um", (unsigned)r);
|
||||
else snprintf(buf, n, "%.1fkm", r / 1000.0f);
|
||||
break;
|
||||
}
|
||||
case K_MODE:
|
||||
snprintf(buf, n, "%s", NodePrefs::locatorModeLabel(_prefs ? _prefs->locator_mode : 0));
|
||||
break;
|
||||
case K_BEEPER:
|
||||
snprintf(buf, n, "%s", (_prefs && _prefs->locator_beeper) ? "ON" : "OFF");
|
||||
break;
|
||||
default: buf[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (_picking) { renderPicker(display); return 400; }
|
||||
display.drawCenteredHeader("LOCATOR");
|
||||
|
||||
const int rc = visibleRows();
|
||||
if (_sel >= rc) _sel = rc - 1; // beeper row may have just been hidden
|
||||
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);
|
||||
display.setCursor(4, y);
|
||||
display.print(r.label);
|
||||
char val[24];
|
||||
valueLabel(r.kind, val, sizeof(val));
|
||||
if (val[0]) display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val);
|
||||
});
|
||||
return 500;
|
||||
}
|
||||
|
||||
void moveSel(int dir) { int rc = visibleRows(); _sel = (_sel + dir + rc) % rc; }
|
||||
|
||||
void activate(int dir) {
|
||||
if (!_prefs) return;
|
||||
switch (rows(_sel).kind) {
|
||||
case K_ENABLE:
|
||||
_prefs->locator_enabled ^= 1;
|
||||
if (_prefs->locator_enabled && !_prefs->locator_has_target)
|
||||
_task->showAlert("Pick a target", 1200);
|
||||
_dirty = true;
|
||||
break;
|
||||
case K_TARGET:
|
||||
cycleTarget(dir);
|
||||
break;
|
||||
case K_RADIUS:
|
||||
_prefs->locator_radius_idx = (uint8_t)((_prefs->locator_radius_idx
|
||||
+ (dir >= 0 ? 1 : NodePrefs::LOCATOR_RADIUS_COUNT - 1)) % NodePrefs::LOCATOR_RADIUS_COUNT);
|
||||
_dirty = true;
|
||||
break;
|
||||
case K_MODE:
|
||||
_prefs->locator_mode = (uint8_t)((_prefs->locator_mode
|
||||
+ (dir >= 0 ? 1 : NodePrefs::LOCATOR_MODE_COUNT - 1)) % NodePrefs::LOCATOR_MODE_COUNT);
|
||||
_dirty = true;
|
||||
break;
|
||||
case K_BEEPER:
|
||||
_prefs->locator_beeper ^= 1;
|
||||
_dirty = true;
|
||||
break;
|
||||
}
|
||||
// Re-seed the crossing engine after any change so editing the target/radius
|
||||
// while armed can't fire a stale arrive/leave before the screen is closed.
|
||||
_task->resetLocator();
|
||||
}
|
||||
|
||||
// Add a person candidate to _targets, deduped by pubkey prefix. Freshness is
|
||||
// resolved here for display only: an active [LOC] share wins (live=true),
|
||||
// else the contact's last-advertised position if it has one — the same
|
||||
// precedence UITask::locatorDistance() uses at evaluation time. When
|
||||
// `require_position` is false (favourites), a contact with neither is still
|
||||
// added with no position — "arm ahead of time", per the existing feature.
|
||||
bool addPersonTarget(const uint8_t* key, const char* name, bool require_position) {
|
||||
if (_target_n >= TARGET_MAX) return false;
|
||||
for (int j = 0; j < _target_n; j++)
|
||||
if (_targets[j].kind == 1 && memcmp(_targets[j].key, key, 6) == 0) return false; // already added
|
||||
|
||||
int32_t lat = 0, lon = 0; uint32_t ts = 0; bool live = false;
|
||||
bool has_pos = _task->resolvePersonPos(key, lat, lon, &live, &ts); // same precedence as the engine
|
||||
if (require_position && !has_pos) return false; // nothing to navigate to yet
|
||||
|
||||
Target& t = _targets[_target_n++];
|
||||
t.kind = 1; t.lat = lat; t.lon = lon; t.ts = ts; t.live = live;
|
||||
memcpy(t.key, key, 6);
|
||||
snprintf(t.name, sizeof(t.name), "%s", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Build the selectable target set into _targets: "None" first (clears the
|
||||
// target — the only way to unset it once chosen), then favourites (the quick
|
||||
// path you pin ahead of time, offered even with no known position yet), then
|
||||
// any other contact with a currently-known position — live-sharing or just
|
||||
// last-advertised (a repeater, a room, or someone who shared a fix once) —
|
||||
// then saved waypoints. A person is keyed by pubkey prefix so the engine
|
||||
// re-resolves their position each evaluation rather than trusting a snapshot.
|
||||
void buildTargets() {
|
||||
_target_n = 0;
|
||||
Target& none = _targets[_target_n++];
|
||||
none.kind = 2; none.lat = 0; none.lon = 0; none.ts = 0; none.live = false;
|
||||
memset(none.key, 0, 6);
|
||||
snprintf(none.name, sizeof(none.name), "(none)");
|
||||
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
|
||||
const uint8_t* pre = _prefs->favourite_contacts[i];
|
||||
bool empty = true;
|
||||
for (int b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) if (pre[b]) { empty = false; break; }
|
||||
if (empty) continue;
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (!c) continue;
|
||||
addPersonTarget(pre, c->name, /*require_position=*/false);
|
||||
}
|
||||
for (int idx = 0; _target_n < TARGET_MAX; idx++) {
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(idx, c)) break;
|
||||
addPersonTarget(c.id.pub_key, c.name, /*require_position=*/true);
|
||||
}
|
||||
WaypointStore& wp = _task->waypoints();
|
||||
for (int i = 0; i < wp.count() && _target_n < TARGET_MAX; i++) {
|
||||
const Waypoint& w = wp.at(i);
|
||||
Target& t = _targets[_target_n++];
|
||||
t.kind = 0; t.lat = w.lat_1e6; t.lon = w.lon_1e6;
|
||||
memset(t.key, 0, 6);
|
||||
snprintf(t.name, sizeof(t.name), "%s", w.label);
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the currently-configured target within _targets, or -1.
|
||||
// No target set at all maps to the synthetic "None" entry (always index 0).
|
||||
int currentTargetIndex() const {
|
||||
if (!_prefs->locator_has_target) return 0;
|
||||
for (int i = 0; i < _target_n; i++) {
|
||||
if (_targets[i].kind == 1 && _prefs->locator_target_kind == 1) {
|
||||
if (memcmp(_targets[i].key, _prefs->locator_key, 6) == 0) return i;
|
||||
} else if (_targets[i].kind == 0 && _prefs->locator_target_kind == 0) {
|
||||
if (_targets[i].lat == _prefs->locator_lat_1e6 &&
|
||||
_targets[i].lon == _prefs->locator_lon_1e6) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void applyTarget(const Target& t) {
|
||||
if (t.kind == 2) { _task->clearTarget(); _dirty = true; return; }
|
||||
// Same target definition as every other entry point (UITask::setTarget),
|
||||
// but the save is deferred to screen exit (_dirty) so LEFT/RIGHT cycling
|
||||
// through candidates doesn't write flash on each step.
|
||||
_task->setTarget(t.kind, t.kind == 1 ? t.key : nullptr, t.lat, t.lon, t.name);
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
// LEFT/RIGHT quick-cycle over the same set the picker shows (includes "None").
|
||||
void cycleTarget(int dir) {
|
||||
buildTargets();
|
||||
int cur = currentTargetIndex();
|
||||
int nx = (cur < 0) ? (dir >= 0 ? 0 : _target_n - 1)
|
||||
: ((cur + (dir >= 0 ? 1 : _target_n - 1)) % _target_n);
|
||||
applyTarget(_targets[nx]);
|
||||
}
|
||||
|
||||
void openPicker() {
|
||||
if (!_prefs) return;
|
||||
buildTargets();
|
||||
int cur = currentTargetIndex();
|
||||
_pick_sel = (cur >= 0) ? cur : 0;
|
||||
_pick_scroll = 0;
|
||||
_picking = true;
|
||||
}
|
||||
|
||||
void renderPicker(DisplayDriver& display) {
|
||||
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);
|
||||
const Target& t = _targets[i];
|
||||
char row[36];
|
||||
if (t.kind != 1) {
|
||||
snprintf(row, sizeof(row), "%s", t.name); // a plain name is a waypoint
|
||||
} else if (t.live) {
|
||||
snprintf(row, sizeof(row), "@%s", t.name); // '@' marks a person; live needs no age tag
|
||||
} else if (t.ts) {
|
||||
char age[8];
|
||||
geo::fmtAgeShort(age, sizeof(age), now, t.ts);
|
||||
snprintf(row, sizeof(row), "@%s (%s)", t.name, age); // last-advertised, not actively sharing
|
||||
} else {
|
||||
snprintf(row, sizeof(row), "@%s", t.name); // favourite, no position known yet
|
||||
}
|
||||
display.drawTextEllipsized(2, y, display.width() - 2 - reserve, row);
|
||||
});
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// Target picker takes all input while open.
|
||||
if (_picking) {
|
||||
if (c == KEY_UP) { _pick_sel = (_pick_sel > 0) ? _pick_sel - 1 : _target_n - 1; return true; }
|
||||
if (c == KEY_DOWN) { _pick_sel = (_pick_sel < _target_n - 1) ? _pick_sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER) { applyTarget(_targets[_pick_sel]); _picking = false; return true; }
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _picking = false; return true; }
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) { the_mesh.savePrefs(); _dirty = false; } // engine re-seeded per edit
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_UP) { moveSel(-1); return true; }
|
||||
if (c == KEY_DOWN) { moveSel(+1); return true; }
|
||||
if (keyIsPrev(c)) { activate(-1); return true; }
|
||||
if (keyIsNext(c)) { activate(+1); return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (rows(_sel).kind == K_TARGET) { openPicker(); return true; } // full list picker
|
||||
activate(+1); return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -12,14 +12,42 @@
|
||||
|
||||
namespace navview {
|
||||
|
||||
// Closing-speed / ETA estimator. The caller keeps one instance per navigation
|
||||
// session and passes it to draw(), which feeds it the live target distance each
|
||||
// frame. Speed is the rate the gap closes (smoothed), so it works for both a
|
||||
// fixed waypoint and a moving live contact; ETA = remaining distance / closing
|
||||
// speed, shown only while actually approaching.
|
||||
struct EtaTracker {
|
||||
float prev_km = -1.0f;
|
||||
uint32_t prev_ms = 0;
|
||||
float closing_kmh = 0.0f; // > 0 = gap shrinking (approaching)
|
||||
bool have = false;
|
||||
void reset() { prev_km = -1.0f; have = false; closing_kmh = 0.0f; }
|
||||
void update(float dist_km, uint32_t now_ms) {
|
||||
if (prev_km >= 0.0f && now_ms > prev_ms) {
|
||||
float dt_h = (now_ms - prev_ms) / 3600000.0f;
|
||||
if (dt_h > 0.0f) {
|
||||
float inst = (prev_km - dist_km) / dt_h; // +approaching, -receding
|
||||
closing_kmh = have ? (closing_kmh * 0.6f + inst * 0.4f) : inst; // light EMA
|
||||
have = true;
|
||||
}
|
||||
}
|
||||
prev_km = dist_km;
|
||||
prev_ms = now_ms;
|
||||
}
|
||||
};
|
||||
|
||||
// own_valid : is there a usable GPS fix for the device right now
|
||||
// cog_valid : is there a stable course-over-ground (UITask::currentCourse)
|
||||
// eta : optional closing-speed/ETA tracker; when supplied a third line is
|
||||
// shown with the approach speed and estimated time of arrival
|
||||
inline void draw(DisplayDriver& d,
|
||||
bool own_valid, int32_t own_lat, int32_t own_lon,
|
||||
int32_t tgt_lat, int32_t tgt_lon,
|
||||
const char* label,
|
||||
bool cog_valid, int cog_deg,
|
||||
bool imperial) {
|
||||
bool imperial,
|
||||
EtaTracker* eta = nullptr) {
|
||||
const int cx = d.width() / 2;
|
||||
const int hdr = d.headerH();
|
||||
|
||||
@@ -52,6 +80,23 @@ inline void draw(DisplayDriver& d,
|
||||
if (cog_valid) snprintf(line, sizeof(line), "Hdg: %d %s", cog_deg, geo::bearingCardinal(cog_deg));
|
||||
else snprintf(line, sizeof(line), "Hdg: --");
|
||||
d.drawTextLeftAlign(2, y, line); y += step;
|
||||
|
||||
// Optional ETA / closing-speed line. Approaching only — a receding or
|
||||
// stationary target shows "ETA: --" rather than a misleading time.
|
||||
if (eta) {
|
||||
eta->update(dist_km, millis());
|
||||
if (eta->have && eta->closing_kmh > 0.3f) {
|
||||
int secs = (int)(dist_km / eta->closing_kmh * 3600.0f);
|
||||
float spd = imperial ? eta->closing_kmh * 0.621371f : eta->closing_kmh;
|
||||
char eta_s[12];
|
||||
if (secs < 3600) snprintf(eta_s, sizeof(eta_s), "%dm", (secs + 59) / 60);
|
||||
else snprintf(eta_s, sizeof(eta_s), "%dh%02dm", secs / 3600, (secs % 3600) / 60);
|
||||
snprintf(line, sizeof(line), "ETA: %s @%d%s", eta_s, (int)(spd + 0.5f), imperial ? "mph" : "kmh");
|
||||
} else {
|
||||
snprintf(line, sizeof(line), "ETA: --");
|
||||
}
|
||||
d.drawTextLeftAlign(2, y, line); y += step;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace navview
|
||||
|
||||
@@ -28,7 +28,7 @@ class NearbyScreen : public UIScreen {
|
||||
enum Source : uint8_t { SRC_STORED, SRC_SCAN };
|
||||
|
||||
// ── action-menu actions (matched by id, not by row index) ────────────────────
|
||||
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_SORT, ACT_SCAN };
|
||||
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, ACT_SORT, ACT_SCAN };
|
||||
|
||||
// ── unified list entry ───────────────────────────────────────────────────────
|
||||
struct Entry {
|
||||
@@ -44,6 +44,11 @@ class NearbyScreen : public UIScreen {
|
||||
// scan-source fields
|
||||
int8_t rssi, snr_x4, remote_snr_x4;
|
||||
bool is_known;
|
||||
// live-track ([LOC] share) overlay: this row's position/age came from a
|
||||
// shared-location message. live_verified == true for a DM (pubkey) share,
|
||||
// false for a channel (name, best-effort) share.
|
||||
bool is_live;
|
||||
bool live_verified;
|
||||
};
|
||||
|
||||
static const int MAX_NEARBY = 32;
|
||||
@@ -53,6 +58,7 @@ class NearbyScreen : public UIScreen {
|
||||
int _scroll;
|
||||
bool _detail;
|
||||
bool _nav = false; // full-screen navigate-to-node view (over detail)
|
||||
navview::EtaTracker _nav_eta; // closing-speed/ETA for the navigate view
|
||||
int32_t _own_lat, _own_lon;
|
||||
bool _own_gps;
|
||||
|
||||
@@ -63,6 +69,7 @@ class NearbyScreen : public UIScreen {
|
||||
unsigned long _detail_refresh_ms;
|
||||
unsigned long _list_refresh_ms = 0;
|
||||
static const unsigned long DETAIL_REFRESH_MS = 10000UL;
|
||||
static const unsigned long NAV_REFRESH_MS = 1000UL; // navigate view tracks a moving target
|
||||
static const unsigned long TIME_LIST_REFRESH_MS = 3000UL;
|
||||
|
||||
// ── live-scan state ──────────────────────────────────────────────────────────
|
||||
@@ -178,12 +185,95 @@ class NearbyScreen : public UIScreen {
|
||||
e.contact_idx = i;
|
||||
e.lastmod = ci.lastmod;
|
||||
e.is_known = true;
|
||||
e.is_live = false;
|
||||
e.live_verified = false;
|
||||
}
|
||||
|
||||
mergeLiveTrack();
|
||||
sortStored();
|
||||
clampSelection();
|
||||
}
|
||||
|
||||
// Rebuild the stored list (re-merge live shares + re-sort) while keeping the
|
||||
// currently highlighted node selected across the rebuild — by contact index
|
||||
// for a stored node, or by name for a non-contact live sender. Returns false
|
||||
// if that node is no longer in the list (caller decides whether to drop the
|
||||
// detail/nav view). Shared by the list, detail and navigate refresh paths.
|
||||
bool refreshKeepingSelection() {
|
||||
int saved_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1;
|
||||
bool saved_live = (_sel < _count) && _entries[_sel].is_live && saved_idx < 0;
|
||||
char saved_name[sizeof(_entries[0].name)]; saved_name[0] = '\0';
|
||||
if (_sel < _count) {
|
||||
strncpy(saved_name, _entries[_sel].name, sizeof(saved_name) - 1);
|
||||
saved_name[sizeof(saved_name) - 1] = '\0';
|
||||
}
|
||||
refreshStored();
|
||||
if (saved_idx >= 0) {
|
||||
for (int i = 0; i < _count; i++)
|
||||
if (_entries[i].contact_idx == saved_idx) { _sel = i; return true; }
|
||||
} else if (saved_live && saved_name[0]) {
|
||||
for (int i = 0; i < _count; i++)
|
||||
if (_entries[i].is_live && strncmp(_entries[i].name, saved_name, sizeof(saved_name) - 1) == 0)
|
||||
{ _sel = i; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Overlay live [LOC] shares onto the stored list: refresh a matching contact
|
||||
// with the fresher shared position, and append senders who aren't contacts so
|
||||
// a group sharing on a channel still shows up. DM shares match by pubkey
|
||||
// prefix (verified); channel shares match by name (best-effort).
|
||||
void mergeLiveTrack() {
|
||||
if (!_task) return;
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
|
||||
if (!lt.isActive(i, now)) continue;
|
||||
const LiveTrackStore::Entry& s = lt.slotAt(i);
|
||||
|
||||
int m = -1;
|
||||
for (int j = 0; j < _count; j++) {
|
||||
if (s.verified && _entries[j].has_key
|
||||
&& memcmp(_entries[j].pub_key, s.key, LiveTrackStore::KEY_LEN) == 0) { m = j; break; }
|
||||
if (!s.verified && strncmp(_entries[j].name, s.name, sizeof(_entries[j].name) - 1) == 0) { m = j; break; }
|
||||
}
|
||||
|
||||
if (m >= 0) {
|
||||
Entry& e = _entries[m];
|
||||
// A [LOC] share is an explicit "here I am now", so it defines the pin and
|
||||
// the distance (used for proximity sorting). Recency for the time sort is
|
||||
// the most recent of the advert and the share.
|
||||
e.lat_e6 = s.lat_1e6;
|
||||
e.lon_e6 = s.lon_1e6;
|
||||
e.dist_km = _own_gps ? geo::haversineKm(_own_lat, _own_lon, s.lat_1e6, s.lon_1e6) : -1.0f;
|
||||
if (s.ts > e.lastmod) e.lastmod = s.ts;
|
||||
e.is_live = true;
|
||||
e.live_verified = s.verified;
|
||||
} else if (_count < MAX_NEARBY && typeMatchesFilter(ADV_TYPE_CHAT, 0, false)) {
|
||||
// A sender we don't have as a contact: treat it as a companion (the only
|
||||
// node type that shares position), so the type filter still applies —
|
||||
// e.g. it must not appear under the Repeater/Room/Sensor filters.
|
||||
Entry& e = _entries[_count++];
|
||||
memset(&e, 0, sizeof(e));
|
||||
strncpy(e.name, s.name, sizeof(e.name) - 1);
|
||||
e.name[sizeof(e.name) - 1] = '\0';
|
||||
// We only keep a key *prefix* for shares, not the full pubkey, so Ping
|
||||
// and the base64 key view (which need 32 bytes) stay unavailable for a
|
||||
// non-contact live entry. Navigate / Save-waypoint work off lat/lon.
|
||||
e.has_key = false;
|
||||
e.type = ADV_TYPE_CHAT;
|
||||
e.lat_e6 = s.lat_1e6;
|
||||
e.lon_e6 = s.lon_1e6;
|
||||
e.dist_km = _own_gps ? geo::haversineKm(_own_lat, _own_lon, s.lat_1e6, s.lon_1e6) : -1.0f;
|
||||
e.lastmod = s.ts;
|
||||
e.contact_idx = -1;
|
||||
e.is_known = false;
|
||||
e.is_live = true;
|
||||
e.live_verified = s.verified;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sortStored() {
|
||||
uint32_t now_ts = rtc_clock.getCurrentTime();
|
||||
for (int i = 0; i < _count - 1; i++) {
|
||||
@@ -223,6 +313,8 @@ class NearbyScreen : public UIScreen {
|
||||
e.dist_km = -1.0f;
|
||||
e.lastmod = 0;
|
||||
e.contact_idx = -1;
|
||||
e.is_live = false;
|
||||
e.live_verified = false;
|
||||
}
|
||||
// strongest first
|
||||
for (int i = 0; i < _count - 1; i++) {
|
||||
@@ -367,7 +459,7 @@ class NearbyScreen : public UIScreen {
|
||||
|
||||
buildSortLabel();
|
||||
_menu_action_count = 0;
|
||||
_menu.begin("Options", 5);
|
||||
_menu.begin("Options", 7);
|
||||
auto add = [&](const char* label, Action a) {
|
||||
_menu.addItem(label);
|
||||
_menu_actions[_menu_action_count++] = a;
|
||||
@@ -376,6 +468,9 @@ class NearbyScreen : public UIScreen {
|
||||
if (has_gps) add("Navigate", ACT_NAV);
|
||||
if (has_key) add("Ping", ACT_PING);
|
||||
if (has_gps) add("Save waypoint", ACT_WAYPOINT);
|
||||
// Needs both a position and a stable identity — a person target is keyed
|
||||
// by pubkey prefix, so a name-only live-scan/channel row can't offer this.
|
||||
if (has_gps && has_key) add("Set as target", ACT_LOCATOR);
|
||||
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
|
||||
add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
|
||||
}
|
||||
@@ -384,7 +479,7 @@ class NearbyScreen : public UIScreen {
|
||||
switch (a) {
|
||||
case ACT_NAV: {
|
||||
const Entry* e = selected();
|
||||
if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) _nav = true;
|
||||
if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) { _nav = true; _nav_eta.reset(); }
|
||||
else _task->showAlert("No node GPS", 1000);
|
||||
break;
|
||||
}
|
||||
@@ -396,6 +491,12 @@ class NearbyScreen : public UIScreen {
|
||||
break;
|
||||
}
|
||||
case ACT_WAYPOINT: saveSelectedWaypoint(); break;
|
||||
case ACT_LOCATOR: {
|
||||
const Entry* e = selected();
|
||||
if (e && e->has_key && (e->lat_e6 != 0 || e->lon_e6 != 0))
|
||||
_task->setTargetNow(1, e->pub_key, e->lat_e6, e->lon_e6, e->name);
|
||||
break;
|
||||
}
|
||||
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
|
||||
case ACT_SCAN: enterScan(); break;
|
||||
}
|
||||
@@ -428,7 +529,10 @@ class NearbyScreen : public UIScreen {
|
||||
display.setCursor(2, hdr + step * 3); display.print(buf);
|
||||
char age[16];
|
||||
fmtAge(age, sizeof(age), e.lastmod);
|
||||
snprintf(buf, sizeof(buf), "Seen: %s", age);
|
||||
// For a live [LOC] row, label the timestamp as a position share and note
|
||||
// whether the sender's identity is verified (DM) or name-only (channel).
|
||||
if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)");
|
||||
else snprintf(buf, sizeof(buf), "Seen: %s", age);
|
||||
display.drawTextEllipsized(2, hdr + step * 4, display.width() - 4, buf);
|
||||
}
|
||||
|
||||
@@ -507,16 +611,16 @@ public:
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
|
||||
// periodic refresh in stored-detail — preserve selected contact by idx
|
||||
if (_detail && _source == SRC_STORED &&
|
||||
millis() - _detail_refresh_ms >= DETAIL_REFRESH_MS) {
|
||||
int saved = (_sel < _count) ? _entries[_sel].contact_idx : -1;
|
||||
refreshStored();
|
||||
bool found = false;
|
||||
if (saved >= 0)
|
||||
for (int i = 0; i < _count; i++)
|
||||
if (_entries[i].contact_idx == saved) { _sel = i; found = true; break; }
|
||||
if (!found) { _detail = false; _nav = false; } // contact gone — drop both views
|
||||
// Periodic refresh of the selected entry while in detail or navigate view,
|
||||
// preserving the selection across the list rebuild. Navigate refreshes
|
||||
// faster so a moving live target (a contact sharing [LOC]) tracks smoothly.
|
||||
// Re-selection keys on contact index for stored nodes, and on name for a
|
||||
// non-contact live sender — without the latter, navigating to someone who
|
||||
// only shares on a channel would drop out on the first refresh.
|
||||
unsigned long refresh_due = _nav ? NAV_REFRESH_MS : DETAIL_REFRESH_MS;
|
||||
if ((_detail || _nav) && _source == SRC_STORED &&
|
||||
millis() - _detail_refresh_ms >= refresh_due) {
|
||||
if (!refreshKeepingSelection()) { _detail = false; _nav = false; } // node/share gone
|
||||
_detail_refresh_ms = millis();
|
||||
}
|
||||
|
||||
@@ -525,7 +629,7 @@ public:
|
||||
const Entry& e = _entries[_sel];
|
||||
int cog; bool cogv = _task->currentCourse(cog);
|
||||
navview::draw(display, _own_gps, _own_lat, _own_lon,
|
||||
e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial());
|
||||
e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial(), &_nav_eta);
|
||||
return 1000;
|
||||
}
|
||||
|
||||
@@ -541,9 +645,11 @@ public:
|
||||
if (_source == SRC_SCAN) {
|
||||
refreshScan();
|
||||
if (_scanning && millis() - _scan_started_ms >= SCAN_DURATION_MS) _scanning = false;
|
||||
} else if (_sort == SORT_TIME && millis() - _list_refresh_ms >= TIME_LIST_REFRESH_MS) {
|
||||
// re-sort periodically so newly-heard contacts bubble up
|
||||
refreshStored();
|
||||
} else if (millis() - _list_refresh_ms >= TIME_LIST_REFRESH_MS) {
|
||||
// Re-merge + re-sort periodically so newly-heard contacts and fresh live
|
||||
// [LOC] shares bubble to the right spot under either sort (distances move
|
||||
// as you and they do, not just recency). Keep the highlighted node put.
|
||||
refreshKeepingSelection();
|
||||
_list_refresh_ms = millis();
|
||||
}
|
||||
|
||||
@@ -589,11 +695,20 @@ public:
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
|
||||
|
||||
char filt[32];
|
||||
int tx = 2;
|
||||
if (e.is_live) {
|
||||
// Diamond marker = this node is broadcasting its position ([LOC]),
|
||||
// the same marker the map uses for a live-tracked contact. DM-verified
|
||||
// vs channel-only is spelled out in the detail view.
|
||||
int iw = ICON_MAP_CONTACT.w * miniIconScale(display);
|
||||
miniIconDrawCentered(display, 2 + iw / 2, y + display.getLineHeight() / 2 - 1, ICON_MAP_CONTACT);
|
||||
tx = 2 + iw + 2;
|
||||
}
|
||||
display.translateUTF8ToBlocks(filt, e.name, sizeof(filt));
|
||||
if (_source == SRC_SCAN && !e.name[0]) { // unknown node → "[Type]"
|
||||
snprintf(filt, sizeof(filt), "[%s]", typeName(e.type));
|
||||
}
|
||||
display.drawTextEllipsized(2, y, dist_col - 4, filt);
|
||||
display.drawTextEllipsized(tx, y, dist_col - tx - 2, filt);
|
||||
|
||||
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
|
||||
char right[10];
|
||||
|
||||
@@ -45,11 +45,12 @@ class QuickMsgScreen : public UIScreen {
|
||||
FullscreenMsgView _fs;
|
||||
int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST
|
||||
int _viewing_max_seen; // highest _hist_sel reached in current session
|
||||
// Shared ring for all channels combined. Sized to comfortably exceed
|
||||
// typical inter-visit accumulation; addChannelMsg() decrements the
|
||||
// Shared ring for all channels combined. addChannelMsg() decrements the
|
||||
// matching _ch_unread when an entry is evicted so the badge can't claim
|
||||
// unread messages that no longer exist in the ring.
|
||||
static const int CH_HIST_MAX = 96;
|
||||
// unread messages that no longer exist in the ring. Each slot carries a full
|
||||
// MSG_TEXT_BUF, so this ring dominates the screen's RAM footprint — kept
|
||||
// modest to leave heap headroom (see DM_HIST_MAX).
|
||||
static const int CH_HIST_MAX = 48;
|
||||
|
||||
// KEYBOARD
|
||||
KeyboardWidget* _kb;
|
||||
@@ -84,6 +85,9 @@ class QuickMsgScreen : public UIScreen {
|
||||
// in the keyboard to confirm/edit before sending.
|
||||
bool _share_mode = false;
|
||||
char _share_text[160] = "";
|
||||
// Live-share target picker: reuse the recipient chooser to set the persistent
|
||||
// auto-share target (channel / DM) instead of composing a message.
|
||||
bool _pick_target = false;
|
||||
|
||||
// History text holds a full received message. Channel messages carry the
|
||||
// sender embedded as "Name: body" in the payload, so a message can be up to
|
||||
@@ -123,7 +127,8 @@ class QuickMsgScreen : public UIScreen {
|
||||
uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1
|
||||
uint8_t resends_left; // remaining auto-resends before the marker shows ✗
|
||||
};
|
||||
static const int DM_HIST_MAX = 64;
|
||||
// Each slot carries a full MSG_TEXT_BUF; kept modest to bound heap use.
|
||||
static const int DM_HIST_MAX = 32;
|
||||
DmHistEntry _dm_hist[DM_HIST_MAX];
|
||||
int _dm_hist_head, _dm_hist_count;
|
||||
int _dm_hist_sel, _dm_hist_scroll;
|
||||
@@ -834,6 +839,7 @@ public:
|
||||
_ctx_dirty = false;
|
||||
_nav_active = false;
|
||||
_share_mode = false;
|
||||
_pick_target = false;
|
||||
_pin_picker_active = false;
|
||||
_dm_direct_entry = false;
|
||||
_unread_at_entry = 0;
|
||||
@@ -881,6 +887,44 @@ public:
|
||||
_phase = MODE_SELECT;
|
||||
}
|
||||
|
||||
// Open the recipient chooser to set the live-share target (channel/DM). On
|
||||
// selection the target is stored in NodePrefs and the Map screen is restored.
|
||||
void startPickTarget() {
|
||||
reset();
|
||||
_pick_target = true;
|
||||
_phase = MODE_SELECT;
|
||||
}
|
||||
|
||||
void commitPickTargetChannel(int ch_idx) {
|
||||
NodePrefs* p = _task->getNodePrefs();
|
||||
if (p) {
|
||||
p->loc_share_target_type = 0;
|
||||
p->loc_share_channel_idx = (uint8_t)ch_idx;
|
||||
the_mesh.savePrefs();
|
||||
}
|
||||
_pick_target = false;
|
||||
_task->showAlert("Share target set", 1200);
|
||||
_task->gotoLiveShareScreen();
|
||||
}
|
||||
|
||||
void commitPickTargetDM(const ContactInfo& ci) {
|
||||
// A room server can't be a live-share target — a [LOC] DM to it is never
|
||||
// reposted to the room's members. Reject the pick and keep the chooser open.
|
||||
if (ci.type == ADV_TYPE_ROOM) {
|
||||
_task->showAlert("Rooms not supported", 1400);
|
||||
return;
|
||||
}
|
||||
NodePrefs* p = _task->getNodePrefs();
|
||||
if (p) {
|
||||
p->loc_share_target_type = 1;
|
||||
memcpy(p->loc_share_dm_prefix, ci.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
the_mesh.savePrefs();
|
||||
}
|
||||
_pick_target = false;
|
||||
_task->showAlert("Share target set", 1200);
|
||||
_task->gotoLiveShareScreen();
|
||||
}
|
||||
|
||||
void enterDM(const ContactInfo& ci) {
|
||||
_sel_contact = ci;
|
||||
_task->clearDMUnread(ci.id.pub_key);
|
||||
@@ -1500,6 +1544,7 @@ public:
|
||||
if (c == KEY_DOWN && _num_contacts > 0) { _contact_sel = (_contact_sel < _num_contacts - 1) ? _contact_sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER && _num_contacts > 0) {
|
||||
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
|
||||
if (_pick_target) { commitPickTargetDM(_sel_contact); return true; }
|
||||
_task->clearDMUnread(_sel_contact.id.pub_key);
|
||||
_dm_hist_sel = -1;
|
||||
_dm_hist_scroll = 0;
|
||||
@@ -1590,6 +1635,7 @@ public:
|
||||
if (c == KEY_DOWN && _num_channels > 0) { _channel_sel = (_channel_sel < _num_channels - 1) ? _channel_sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER && _num_channels > 0) {
|
||||
_sel_channel_idx = _channel_indices[_channel_sel];
|
||||
if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; }
|
||||
int hc = histCountForChannel(_sel_channel_idx);
|
||||
_unread_at_entry = (int)_ch_unread[_sel_channel_idx];
|
||||
_hist_scroll = 0;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../RadioPresets.h"
|
||||
#include "RadioParamsEditor.h"
|
||||
#include "RadioPresetPicker.h"
|
||||
#include "AccordionList.h"
|
||||
|
||||
class SettingsScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -76,15 +77,20 @@ class SettingsScreen : public UIScreen {
|
||||
Count
|
||||
};
|
||||
|
||||
int _selected;
|
||||
int _scroll;
|
||||
int _visible; // items fitting on screen; updated each render, used by handleInput
|
||||
int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits)
|
||||
bool _dirty;
|
||||
uint8_t _collapsed = 0x7F; // bit N set = section N collapsed (Display=0..Messages=6)
|
||||
static const int MAX_VIS = 60;
|
||||
uint8_t _vis[MAX_VIS]; // filtered list of visible SettingItem values
|
||||
int _vis_count = 0;
|
||||
// Cursor + scroll, fold state and the flattened visible list are owned by the
|
||||
// shared AccordionList helper. We keep only the section→SettingItem mapping it
|
||||
// needs (sections are walked once from the enum, honouring the #if guards).
|
||||
int _selected = 0; // SettingItem under the cursor, resolved per input/render
|
||||
int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits)
|
||||
bool _dirty = false;
|
||||
|
||||
AccordionList _acc;
|
||||
static const int NUM_SECTIONS = 7;
|
||||
static const int MAX_PER_SEC = 16;
|
||||
uint8_t _sec_items[NUM_SECTIONS][MAX_PER_SEC]; // SettingItem per (section, row)
|
||||
uint8_t _sec_count[NUM_SECTIONS];
|
||||
uint8_t _sec_header[NUM_SECTIONS]; // the SECTION_* enum for each section
|
||||
int _num_sections = 0;
|
||||
|
||||
#if AUTO_OFF_MILLIS > 0
|
||||
static const uint16_t AUTO_OFF_OPTS[5];
|
||||
@@ -176,36 +182,33 @@ class SettingsScreen : public UIScreen {
|
||||
return "";
|
||||
}
|
||||
|
||||
int sectionIndex(int item) const {
|
||||
if (item == SECTION_DISPLAY) return 0;
|
||||
if (item == SECTION_SOUND) return 1;
|
||||
if (item == SECTION_HOME_PAGES) return 2;
|
||||
if (item == SECTION_RADIO) return 3;
|
||||
if (item == SECTION_SYSTEM) return 4;
|
||||
if (item == SECTION_CONTACTS) return 5;
|
||||
if (item == SECTION_MESSAGES) return 6;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int visIndexOf(int item) const {
|
||||
for (int i = 0; i < _vis_count; i++)
|
||||
if (_vis[i] == (uint8_t)item) return i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void buildVis() {
|
||||
_vis_count = 0;
|
||||
int cur_sec = -1;
|
||||
bool cur_collapsed = false;
|
||||
// Walk the SettingItem enum once, bucketing items under their section header.
|
||||
// #if-guarded items need no special handling — they simply aren't in the enum.
|
||||
void buildSections() {
|
||||
int cur = -1;
|
||||
for (int i = 0; i < (int)Count; i++) {
|
||||
if (isSection(i)) {
|
||||
cur_sec++;
|
||||
cur_collapsed = (_collapsed >> cur_sec) & 1;
|
||||
if (_vis_count < MAX_VIS) _vis[_vis_count++] = (uint8_t)i;
|
||||
} else if (!cur_collapsed && _vis_count < MAX_VIS) {
|
||||
_vis[_vis_count++] = (uint8_t)i;
|
||||
if (++cur >= NUM_SECTIONS) break;
|
||||
_sec_header[cur] = (uint8_t)i;
|
||||
_sec_count[cur] = 0;
|
||||
} else if (cur >= 0 && _sec_count[cur] < MAX_PER_SEC) {
|
||||
_sec_items[cur][_sec_count[cur]++] = (uint8_t)i;
|
||||
}
|
||||
}
|
||||
_num_sections = cur + 1;
|
||||
}
|
||||
|
||||
// (Re)load the section sizes into the accordion (folds all, resets the cursor).
|
||||
void resetList() {
|
||||
uint8_t sizes[NUM_SECTIONS];
|
||||
for (int i = 0; i < _num_sections; i++) sizes[i] = _sec_count[i];
|
||||
_acc.begin(sizes, _num_sections);
|
||||
}
|
||||
|
||||
// Resolve the accordion's selected row to a SettingItem (header → SECTION_*).
|
||||
int currentItem() const {
|
||||
const AccordionList::Row& r = _acc.selected();
|
||||
return (r.item < 0) ? _sec_header[r.sec] : _sec_items[r.sec][r.item];
|
||||
}
|
||||
|
||||
bool isHomePage(int item) const {
|
||||
@@ -416,23 +419,9 @@ class SettingsScreen : public UIScreen {
|
||||
return item - MSG_SLOT_0;
|
||||
}
|
||||
|
||||
void renderItem(DisplayDriver& display, int item, int y) {
|
||||
void renderItem(DisplayDriver& display, int item, int y, bool sel) {
|
||||
NodePrefs* p = _task->getNodePrefs();
|
||||
|
||||
if (isSection(item)) {
|
||||
int si = sectionIndex(item);
|
||||
bool collapsed = (_collapsed >> si) & 1;
|
||||
bool sel = (item == _selected);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel);
|
||||
display.setCursor(2, y);
|
||||
display.print(collapsed ? "+" : "-");
|
||||
display.print(" ");
|
||||
display.print(sectionName(item));
|
||||
return;
|
||||
}
|
||||
|
||||
bool sel = (item == _selected);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel);
|
||||
|
||||
display.setCursor(2, y);
|
||||
@@ -641,7 +630,7 @@ class SettingsScreen : public UIScreen {
|
||||
}
|
||||
|
||||
// Keyboard state for editing message slots
|
||||
int _edit_slot; // -1 = not editing, 0..9 = slot being edited
|
||||
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
|
||||
KeyboardWidget* _kb;
|
||||
|
||||
// Radio preset picker — names are too long for the value column, so Enter on
|
||||
@@ -656,17 +645,15 @@ class SettingsScreen : public UIScreen {
|
||||
|
||||
public:
|
||||
SettingsScreen(UITask* task, KeyboardWidget* kb)
|
||||
: _task(task), _kb(kb), _selected(SECTION_DISPLAY), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {
|
||||
buildVis();
|
||||
: _task(task), _kb(kb) {
|
||||
buildSections();
|
||||
resetList();
|
||||
}
|
||||
|
||||
|
||||
void markClean() {
|
||||
_dirty = false;
|
||||
_collapsed = 0x7F;
|
||||
_selected = SECTION_DISPLAY;
|
||||
buildVis();
|
||||
_scroll = 0;
|
||||
resetList();
|
||||
_editor.freq.active = false;
|
||||
}
|
||||
|
||||
@@ -677,18 +664,24 @@ public:
|
||||
return _kb->render(display);
|
||||
}
|
||||
|
||||
int item_h = display.lineStep();
|
||||
int start_y = display.listStart();
|
||||
_visible = display.listVisible(item_h);
|
||||
_reserve = scrollIndicatorReserve(display, _vis_count, _visible);
|
||||
|
||||
display.drawCenteredHeader("SETTINGS");
|
||||
|
||||
for (int i = 0; i < _visible && (_scroll + i) < _vis_count; i++) {
|
||||
renderItem(display, _vis[_scroll + i], start_y + i * item_h);
|
||||
}
|
||||
|
||||
drawScrollIndicator(display, start_y, _visible * item_h, _vis_count, _visible, _scroll);
|
||||
_acc.render(display,
|
||||
// Section header: "[+/-] Name"
|
||||
[&](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);
|
||||
display.setCursor(2, y);
|
||||
display.print(collapsed ? "+" : "-");
|
||||
display.print(" ");
|
||||
display.print(sectionName(_sec_header[sec]));
|
||||
},
|
||||
// Item row
|
||||
[&](int sec, int item, int y, bool sel, int reserve) {
|
||||
_reserve = reserve;
|
||||
renderItem(display, _sec_items[sec][item], y, sel);
|
||||
});
|
||||
|
||||
if (_picker.menu.active) _picker.menu.render(display);
|
||||
|
||||
@@ -760,42 +753,23 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c == KEY_UP && _vis_count > 0) {
|
||||
int vi = visIndexOf(_selected);
|
||||
vi = (vi > 0) ? vi - 1 : _vis_count - 1;
|
||||
_selected = _vis[vi];
|
||||
if (vi < _scroll) _scroll = vi;
|
||||
else if (vi >= _scroll + _visible) _scroll = vi - _visible + 1;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_DOWN && _vis_count > 0) {
|
||||
int vi = visIndexOf(_selected);
|
||||
vi = (vi + 1 < _vis_count) ? vi + 1 : 0;
|
||||
_selected = _vis[vi];
|
||||
if (vi >= _scroll + _visible) _scroll = vi - _visible + 1;
|
||||
else if (vi < _scroll) _scroll = vi;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL) {
|
||||
if (_dirty) the_mesh.savePrefs();
|
||||
_task->gotoHomeScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Up/down navigation and section fold/unfold live in the shared helper.
|
||||
// Enter on an item returns ACTIVATED and falls through to the per-item logic
|
||||
// below; left/right are ignored by the helper and likewise fall through.
|
||||
AccordionList::Result ar = _acc.handleInput(c);
|
||||
if (ar == AccordionList::HANDLED) return true;
|
||||
_selected = currentItem();
|
||||
|
||||
bool right = keyIsNext(c);
|
||||
bool left = keyIsPrev(c);
|
||||
bool enter = (c == KEY_ENTER);
|
||||
|
||||
if (enter && isSection(_selected)) {
|
||||
int si = sectionIndex(_selected);
|
||||
_collapsed ^= (1 << si);
|
||||
buildVis();
|
||||
int vi = visIndexOf(_selected);
|
||||
if (vi < _scroll) _scroll = vi;
|
||||
if (_visible > 0 && vi >= _scroll + _visible) _scroll = vi - _visible + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
#if FEAT_BRIGHTNESS_SETTING
|
||||
if (_selected == BRIGHTNESS) {
|
||||
uint8_t lvl = _task->getBrightnessLevel();
|
||||
|
||||
@@ -1,46 +1,132 @@
|
||||
#pragma once
|
||||
// Custom screen — not part of upstream UITask.cpp
|
||||
// Included by UITask.cpp just before HomeScreen.
|
||||
//
|
||||
// The flat 10-item list grew crowded, so tools are grouped into collapsible
|
||||
// sections (Location / Comms / System) via the shared AccordionList helper —
|
||||
// the same fold-in-place model as Settings. Each row carries a mini-icon;
|
||||
// section headers show their cog/marker plus a fold indicator.
|
||||
|
||||
#include "AccordionList.h"
|
||||
|
||||
class ToolsScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
int _sel;
|
||||
int _scroll = 0;
|
||||
|
||||
static const int ITEM_COUNT = 8;
|
||||
static const char* ITEMS[ITEM_COUNT];
|
||||
enum Action {
|
||||
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
|
||||
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER,
|
||||
ACT_RINGTONE, ACT_DIAGNOSTICS
|
||||
};
|
||||
struct Tool { const char* label; const MiniIcon* icon; Action action; };
|
||||
struct Section { const char* name; const MiniIcon* icon; const Tool* tools; uint8_t count; };
|
||||
|
||||
static const Tool LOCATION_TOOLS[];
|
||||
static const Tool COMMS_TOOLS[];
|
||||
static const Tool SYSTEM_TOOLS[];
|
||||
static const Section SECTIONS[];
|
||||
static const int SECTION_COUNT = 3;
|
||||
|
||||
AccordionList _acc;
|
||||
|
||||
// Fixed icon gutter so labels line up whether or not a row has a glyph. Sized
|
||||
// for the widest icon (the 7px cog) at the current font scale.
|
||||
static int gutter(DisplayDriver& d) { return 7 * miniIconScale(d) + 2; }
|
||||
|
||||
static void drawIcon(DisplayDriver& d, int x, int y, const MiniIcon* ic) {
|
||||
if (!ic) return;
|
||||
const int s = miniIconScale(d);
|
||||
const int top = (y - 1) + ((d.lineStep() - 1) - ic->h * s) / 2;
|
||||
miniIconDrawTop(d, x, top, *ic);
|
||||
}
|
||||
|
||||
void dispatch(Action a) {
|
||||
switch (a) {
|
||||
case ACT_NEARBY: _task->gotoNearbyScreen(); break;
|
||||
case ACT_LIVESHARE: _task->gotoLiveShareScreen(); break;
|
||||
case ACT_TRAIL: _task->gotoTrailScreen(); break;
|
||||
case ACT_LOCATOR: _task->gotoLocatorScreen(); break;
|
||||
case ACT_COMPASS: _task->gotoCompassScreen(); break;
|
||||
case ACT_BOT: _task->gotoBotScreen(); break;
|
||||
case ACT_AUTOADVERT: _task->gotoAutoAdvertScreen(); break;
|
||||
case ACT_REPEATER: _task->gotoRepeaterScreen(); break;
|
||||
case ACT_RINGTONE: _task->gotoRingtoneEditor(); break;
|
||||
case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break;
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
ToolsScreen(UITask* task) : _task(task), _sel(0) {}
|
||||
ToolsScreen(UITask* task) : _task(task) {}
|
||||
|
||||
// Open folded at the section list each time Tools is entered from Home.
|
||||
void enter() {
|
||||
static uint8_t sizes[SECTION_COUNT];
|
||||
for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count;
|
||||
_acc.begin(sizes, SECTION_COUNT);
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawCenteredHeader("TOOLS");
|
||||
|
||||
drawList(display, ITEM_COUNT, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
display.setCursor(2, y);
|
||||
display.print(ITEMS[idx]);
|
||||
});
|
||||
const int cw = display.getCharWidth();
|
||||
const int g = gutter(display);
|
||||
|
||||
_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);
|
||||
display.setCursor(2, y);
|
||||
display.print(collapsed ? "+" : "-");
|
||||
const int icon_x = 2 + cw + 2;
|
||||
// drawIcon(display, icon_x, y, SECTIONS[sec].icon); // icons disabled for now, don't fit visually
|
||||
display.setCursor(icon_x + g, y);
|
||||
display.print(SECTIONS[sec].name);
|
||||
},
|
||||
// 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);
|
||||
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);
|
||||
display.print(SECTIONS[sec].tools[item].label);
|
||||
});
|
||||
return 500;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : ITEM_COUNT - 1; return true; }
|
||||
if (c == KEY_DOWN) { _sel = (_sel < ITEM_COUNT - 1) ? _sel + 1 : 0; return true; }
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sel == 0) { _task->gotoRingtoneEditor(); return true; }
|
||||
if (_sel == 1) { _task->gotoBotScreen(); return true; }
|
||||
if (_sel == 2) { _task->gotoNearbyScreen(); return true; }
|
||||
if (_sel == 3) { _task->gotoAutoAdvertScreen(); return true; }
|
||||
if (_sel == 4) { _task->gotoTrailScreen(); return true; }
|
||||
if (_sel == 5) { _task->gotoCompassScreen(); return true; }
|
||||
if (_sel == 6) { _task->gotoDiagnosticsScreen(); return true; }
|
||||
if (_sel == 7) { _task->gotoRepeaterScreen(); return true; }
|
||||
switch (_acc.handleInput(c)) {
|
||||
case AccordionList::ACTIVATED: {
|
||||
const AccordionList::Row& r = _acc.selected();
|
||||
dispatch(SECTIONS[r.sec].tools[r.item].action);
|
||||
return true;
|
||||
}
|
||||
case AccordionList::HANDLED: return true;
|
||||
case AccordionList::IGNORED: return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const char* ToolsScreen::ITEMS[8] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics", "Repeater" };
|
||||
|
||||
const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = {
|
||||
{ "Nearby Nodes", &ICON_MAP_CONTACT, ACT_NEARBY },
|
||||
{ "Live Share", &ICON_GPS, ACT_LIVESHARE },
|
||||
{ "Trail", &ICON_TRAIL, ACT_TRAIL },
|
||||
{ "Locator", &ICON_MAP_WAYPOINT, ACT_LOCATOR },
|
||||
{ "Compass", &ICON_MAP_NORTH, ACT_COMPASS },
|
||||
};
|
||||
const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = {
|
||||
{ "Auto-Reply Bot", &ICON_BOT, ACT_BOT },
|
||||
{ "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT },
|
||||
{ "Repeater", &ICON_REPEATER, ACT_REPEATER },
|
||||
};
|
||||
const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
|
||||
{ "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE },
|
||||
{ "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS },
|
||||
};
|
||||
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
|
||||
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 },
|
||||
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 3 },
|
||||
{ "System", &ICON_GEAR, SYSTEM_TOOLS, 2 },
|
||||
};
|
||||
|
||||
@@ -11,8 +11,41 @@
|
||||
#include "icons.h" // scalable mini-icons (map markers, north arrow, grid dots)
|
||||
#include "NavView.h"
|
||||
#include "WaypointsView.h"
|
||||
#include <helpers/StreamUtils.h>
|
||||
#include <math.h>
|
||||
|
||||
// Bounds every Serial write the GPX export makes. A full trail dump is tens
|
||||
// of KB across hundreds of individual write() calls (one or more per point) —
|
||||
// without this, a serial monitor that's open but not actively reading (DTR
|
||||
// asserted, nothing draining the USB-CDC FIFO) would block each one, and the
|
||||
// main loop with it, indefinitely (see StreamUtils.h). Duck-typed to the
|
||||
// handful of Print methods Trail.h's GPX writers actually call, so it slots
|
||||
// into their `S& out` template parameter without any change there.
|
||||
// __FlashStringHelper content is plain flash on nRF52/ARM (no
|
||||
// Harvard-architecture PROGMEM split), so it's safe to read like any other
|
||||
// char*.
|
||||
//
|
||||
// The first stalled write gives up for the rest of the export (_gave_up),
|
||||
// rather than re-arming a fresh timeout on every one of those hundreds of
|
||||
// calls — a host that's truly stuck would otherwise cost calls × timeout_ms
|
||||
// in total instead of one bounded stall.
|
||||
struct BoundedSerialPrint {
|
||||
uint32_t timeout_ms;
|
||||
bool _gave_up;
|
||||
BoundedSerialPrint(uint32_t t) : timeout_ms(t), _gave_up(false) {}
|
||||
size_t write(const uint8_t* buf, size_t len) {
|
||||
if (_gave_up) return 0;
|
||||
size_t sent = streamWriteUntil(Serial, buf, len, millis() + timeout_ms);
|
||||
if (sent < len) _gave_up = true;
|
||||
return sent;
|
||||
}
|
||||
size_t print(const char* s) { return write((const uint8_t*)s, strlen(s)); }
|
||||
size_t print(const __FlashStringHelper* s) {
|
||||
const char* p = reinterpret_cast<const char*>(s);
|
||||
return write((const uint8_t*)p, strlen(p));
|
||||
}
|
||||
};
|
||||
|
||||
class TrailScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
TrailStore* _store;
|
||||
@@ -28,6 +61,7 @@ class TrailScreen : public UIScreen {
|
||||
int _list_max_scroll = 0;
|
||||
bool _cfg_dirty = false;
|
||||
bool _map_grid = true; // show scale grid in map view (Enter to toggle)
|
||||
bool _return_home = false; // entered via Home "Map" page → KEY_CANCEL returns to Home
|
||||
|
||||
// Waypoint management UI (list / nav / add / mark / rename / delete / send)
|
||||
// lives in its own component; TrailScreen delegates to it while it's active.
|
||||
@@ -38,10 +72,13 @@ 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_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS };
|
||||
// The action popup is two-level: a short main menu, plus "Trail file…" and
|
||||
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 };
|
||||
// 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 };
|
||||
PopupMenu _action_menu;
|
||||
uint8_t _menu_level = ML_MAIN;
|
||||
@@ -50,6 +87,7 @@ class TrailScreen : public UIScreen {
|
||||
char _act_min_dist_label[24];
|
||||
char _act_units_label[24];
|
||||
char _act_grid_label[16];
|
||||
char _act_autopause_label[24];
|
||||
char _act_toggle_label[20];
|
||||
|
||||
static const int SUMMARY_ITEM_COUNT = 5;
|
||||
@@ -65,9 +103,14 @@ public:
|
||||
_cfg_dirty = false;
|
||||
_action_menu.active = false;
|
||||
_menu_level = ML_MAIN;
|
||||
_return_home = false;
|
||||
_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; }
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -101,11 +144,10 @@ public:
|
||||
// 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) {
|
||||
if (_menu_level == ML_SETTINGS) {
|
||||
int idx = _action_menu.selectedIndex();
|
||||
if (idx >= 0 && idx < _act_count)
|
||||
cycleSetting((ActionId)_act_map[idx], (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1);
|
||||
}
|
||||
int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1;
|
||||
int idx = _action_menu.selectedIndex();
|
||||
if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS)
|
||||
cycleSetting((ActionId)_act_map[idx], dir);
|
||||
return true; // swallow elsewhere
|
||||
}
|
||||
auto res = _action_menu.handleInput(c);
|
||||
@@ -113,12 +155,14 @@ public:
|
||||
int sel = _action_menu.selectedIndex();
|
||||
ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE;
|
||||
switch (act) {
|
||||
case ACT_FILE: buildFileMenu(); return true; // descend into submenu
|
||||
case ACT_SETTINGS: buildSettingsMenu(); return true;
|
||||
case ACT_FILE: buildFileMenu(); return true; // descend into submenu
|
||||
case ACT_SETTINGS: buildSettingsMenu(); return true;
|
||||
// Settings rows: Enter advances/toggles the value and keeps focus.
|
||||
case ACT_MIN_DIST:
|
||||
case ACT_UNITS:
|
||||
case ACT_GRID: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
|
||||
case ACT_GRID:
|
||||
case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
|
||||
case ACT_SHARE_NOW: shareMyLocationNow(); break;
|
||||
case ACT_TOGGLE: handleToggle(); break;
|
||||
case ACT_MARK: _wp.markHere(); break;
|
||||
case ACT_WAYPOINTS: _wp.openList(); break;
|
||||
@@ -138,7 +182,8 @@ public:
|
||||
|
||||
if (c == KEY_CANCEL) {
|
||||
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
|
||||
_task->gotoToolsScreen();
|
||||
if (_return_home) _task->gotoHomeScreen();
|
||||
else _task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
|
||||
@@ -207,7 +252,8 @@ private:
|
||||
}
|
||||
void handleExport() {
|
||||
if (!Serial) { _task->showAlert("Connect USB first", 1200); return; }
|
||||
size_t n = _store->exportGpx(Serial, _task->waypoints());
|
||||
BoundedSerialPrint out{300};
|
||||
size_t n = _store->exportGpx(out, _task->waypoints());
|
||||
showExportAlert(n);
|
||||
}
|
||||
|
||||
@@ -217,7 +263,8 @@ private:
|
||||
if (!ds) { _task->showAlert("FS unavailable", 800); return; }
|
||||
File f = ds->openRead(TRAIL_FILE);
|
||||
if (!f) { _task->showAlert("No saved trail", 800); return; }
|
||||
size_t n = TrailStore::exportGpxFromFile(f, Serial, _task->waypoints());
|
||||
BoundedSerialPrint out{300};
|
||||
size_t n = TrailStore::exportGpxFromFile(f, out, _task->waypoints());
|
||||
f.close();
|
||||
if (n == 0) { _task->showAlert("Bad saved file", 1000); return; }
|
||||
showExportAlert(n);
|
||||
@@ -249,6 +296,8 @@ private:
|
||||
"Readout: %s", (p && p->trail_show_pace) ? "Pace" : "Speed");
|
||||
snprintf(_act_grid_label, sizeof(_act_grid_label),
|
||||
"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_toggle_label, sizeof(_act_toggle_label),
|
||||
"%s tracking", _store->isActive() ? "Stop" : "Start");
|
||||
}
|
||||
@@ -276,6 +325,7 @@ 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...");
|
||||
pushAction(ACT_SHARE_NOW, "Share my pos");
|
||||
if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file...");
|
||||
pushAction(ACT_SETTINGS, "Settings...");
|
||||
}
|
||||
@@ -299,8 +349,9 @@ private:
|
||||
refreshActionLabels();
|
||||
_menu_level = ML_SETTINGS;
|
||||
_act_count = 0;
|
||||
_action_menu.begin("Settings", 3);
|
||||
pushAction(ACT_MIN_DIST, _act_min_dist_label);
|
||||
_action_menu.begin("Settings", 4);
|
||||
pushAction(ACT_MIN_DIST, _act_min_dist_label);
|
||||
pushAction(ACT_AUTOPAUSE, _act_autopause_label);
|
||||
if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label);
|
||||
if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label);
|
||||
}
|
||||
@@ -311,6 +362,7 @@ private:
|
||||
if (act == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; }
|
||||
if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -346,6 +398,24 @@ private:
|
||||
// The trail "Readout" row toggles speed vs pace; the metric/imperial unit
|
||||
// comes from the global Settings preference, so this is a plain on/off flip.
|
||||
void cycleUnits(NodePrefs* p, int /*dir*/) { p->trail_show_pace ^= 1; }
|
||||
void cycleAutoPause(NodePrefs* p, int dir) {
|
||||
uint8_t idx = p->trail_autopause_idx;
|
||||
if (idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) idx = 0;
|
||||
idx = (dir > 0) ? (idx + 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT
|
||||
: (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT;
|
||||
p->trail_autopause_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
|
||||
// config lives in Tools › Live Share.)
|
||||
void shareMyLocationNow() {
|
||||
int32_t lat, lon;
|
||||
if (!_task->currentLocation(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
|
||||
char text[40];
|
||||
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
|
||||
_task->shareToMessage(text);
|
||||
}
|
||||
|
||||
// Render the average speed (or pace) in the globally-selected unit system.
|
||||
void formatAvgPaceOrSpeed(char* buf, size_t n, NodePrefs* p) const {
|
||||
@@ -376,6 +446,7 @@ private:
|
||||
case 0: {
|
||||
const char* st;
|
||||
if (!_store->isActive()) st = "stopped";
|
||||
else if (_store->isPaused()) st = "paused";
|
||||
else if (_store->empty()) st = "waiting fix";
|
||||
else st = "tracking";
|
||||
snprintf(buf, n, "Status: %s", st);
|
||||
@@ -523,6 +594,15 @@ private:
|
||||
for (int i = 0; i < wp.count(); i++) fold(wp.at(i).lat_1e6, wp.at(i).lon_1e6);
|
||||
}
|
||||
if (have_gps) fold(my_lat, my_lon);
|
||||
// Fold in live-tracked contacts ([LOC] shares) so they stay in frame.
|
||||
{
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY; i++)
|
||||
if (lt.isActive(i, now)) fold(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6);
|
||||
}
|
||||
// Fold in the active target too, so a destination you set never sits off-frame.
|
||||
{ int32_t tla, tlo; if (_task->activeTargetPos(tla, tlo)) fold(tla, tlo); }
|
||||
return init;
|
||||
}
|
||||
|
||||
@@ -534,6 +614,25 @@ private:
|
||||
const int area_w = proj.area_w, area_h = proj.area_h;
|
||||
WaypointStore& wp = _task->waypoints();
|
||||
const int wp_x_max = area_x + area_w - 1, wp_y_max = area_y + area_h - 1;
|
||||
// Reserved rectangles of labels already placed this frame, so a dense
|
||||
// cluster of points doesn't smear their labels on top of one another.
|
||||
// Live-tracked contacts are labelled FIRST so a moving person's name wins a
|
||||
// slot over a nearby static waypoint (on a live map the person matters most).
|
||||
LblRect occ[40]; int nocc = 0;
|
||||
{
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
|
||||
if (!lt.isActive(i, now)) continue;
|
||||
const LiveTrackStore::Entry& s = lt.slotAt(i);
|
||||
int cx, cy; proj.project(s.lat_1e6, s.lon_1e6, cx, cy);
|
||||
if (cx < area_x) cx = area_x; else if (cx > wp_x_max) cx = wp_x_max;
|
||||
if (cy < area_y) cy = area_y; else if (cy > wp_y_max) cy = wp_y_max;
|
||||
drawContactMarker(display, cx, cy);
|
||||
char s2[3] = { s.name[0], s.name[0] ? s.name[1] : (char)0, 0 };
|
||||
drawLabelAvoiding(display, s2, cx, cy, area_x, area_y, area_w, area_h, occ, nocc, 40);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < wp.count(); i++) {
|
||||
const Waypoint& w = wp.at(i);
|
||||
int wx, wy; proj.project(w.lat_1e6, w.lon_1e6, wx, wy);
|
||||
@@ -541,8 +640,9 @@ private:
|
||||
if (wy < area_y) wy = area_y; else if (wy > wp_y_max) wy = wp_y_max;
|
||||
drawWaypointMarker(display, wx, wy);
|
||||
char s[3] = { w.label[0], w.label[0] ? w.label[1] : (char)0, 0 };
|
||||
drawWaypointLabel(display, s, wx, wy, area_x, area_y, area_w, area_h);
|
||||
drawLabelAvoiding(display, s, wx, wy, area_x, area_y, area_w, area_h, occ, nocc, 40);
|
||||
}
|
||||
|
||||
if (have_gps) {
|
||||
int mx, my; proj.project(my_lat, my_lon, mx, my);
|
||||
drawCurrentMarker(display, mx, my);
|
||||
@@ -550,6 +650,16 @@ private:
|
||||
int ex, ey; proj.project(_store->last().lat_1e6, _store->last().lon_1e6, ex, ey);
|
||||
drawCurrentMarker(display, ex, ey);
|
||||
}
|
||||
|
||||
// Active target flag, last so it stays legible atop any waypoint/contact it
|
||||
// coincides with. Clamped to the frame like the other off-edge markers.
|
||||
int32_t tla, tlo;
|
||||
if (_task->activeTargetPos(tla, tlo)) {
|
||||
int tx, ty; proj.project(tla, tlo, tx, ty);
|
||||
if (tx < area_x) tx = area_x; else if (tx > wp_x_max) tx = wp_x_max;
|
||||
if (ty < area_y) ty = area_y; else if (ty > wp_y_max) ty = wp_y_max;
|
||||
drawTargetMarker(display, tx, ty);
|
||||
}
|
||||
}
|
||||
|
||||
void renderMap(DisplayDriver& display) {
|
||||
@@ -564,8 +674,11 @@ private:
|
||||
// when no trail is being recorded.
|
||||
int32_t my_lat, my_lon;
|
||||
const bool have_gps = ownPos(my_lat, my_lon);
|
||||
const bool have_live = _task->liveTrack().active(rtc_clock.getCurrentTime()) > 0;
|
||||
int32_t tgt_lat, tgt_lon;
|
||||
const bool have_tgt = _task->activeTargetPos(tgt_lat, tgt_lon);
|
||||
|
||||
if (!have_trail && nwp == 0 && !have_gps) {
|
||||
if (!have_trail && nwp == 0 && !have_gps && !have_live && !have_tgt) {
|
||||
display.drawTextCentered(display.width() / 2, (top + bottom) / 2, "No GPS / no trail");
|
||||
return;
|
||||
}
|
||||
@@ -595,6 +708,7 @@ private:
|
||||
drawWaypointLabel(display, s, ccx, ccy, area_x, area_y, area_w, area_h);
|
||||
}
|
||||
if (have_gps || have_trail) drawCurrentMarker(display, ccx, ccy);
|
||||
{ int32_t tla, tlo; if (_task->activeTargetPos(tla, tlo)) drawTargetMarker(display, ccx, ccy); }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,19 +740,9 @@ private:
|
||||
int x0, y0;
|
||||
proj.project(_store->at(0).lat_1e6, _store->at(0).lon_1e6, x0, y0);
|
||||
miniIconDrawCentered(display, x0, y0, ICON_MAP_DOT);
|
||||
for (int i = 1; i < _store->count(); i++) {
|
||||
const TrailPoint& pt = _store->at(i);
|
||||
int x1, y1;
|
||||
proj.project(pt.lat_1e6, pt.lon_1e6, x1, y1);
|
||||
if (pt.flags & TRAIL_FLAG_SEG_START) {
|
||||
drawFilledDot(display, x0, y0);
|
||||
drawOpenDot(display, x1, y1);
|
||||
} else {
|
||||
gfx::drawLine(display, x0, y0, x1, y1);
|
||||
}
|
||||
x0 = x1;
|
||||
y0 = y1;
|
||||
}
|
||||
gfx::drawTrail(display, *_store,
|
||||
[&](int32_t la, int32_t lo, int& x, int& y) { proj.project(la, lo, x, y); },
|
||||
[&](int xa, int ya, int xb, int yb) { drawFilledDot(display, xa, ya); drawOpenDot(display, xb, yb); });
|
||||
int sx, sy;
|
||||
proj.project(_store->first().lat_1e6, _store->first().lon_1e6, sx, sy);
|
||||
drawStartMarker(display, sx, sy);
|
||||
@@ -787,6 +891,38 @@ private:
|
||||
d.print(s);
|
||||
}
|
||||
|
||||
// Map label collision avoidance. Each placed label reserves a rectangle; a new
|
||||
// label tries up to four offsets around its marker (upper-right, upper-left,
|
||||
// lower-right, lower-left) and is skipped — the marker still shows — if none
|
||||
// fit on-frame without overlapping an already-placed one. On a dense map this
|
||||
// drops a few labels instead of smearing them into an unreadable blob.
|
||||
struct LblRect { int x, y, w, h; };
|
||||
static bool rectsOverlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) {
|
||||
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
|
||||
}
|
||||
static void drawLabelAvoiding(DisplayDriver& d, const char* s, int wx, int wy,
|
||||
int ax, int ay, int aw, int ah,
|
||||
LblRect* occ, int& nocc, int occ_max) {
|
||||
if (!s[0]) return;
|
||||
int tw = (int)d.getTextWidth(s);
|
||||
int ch = d.getLineHeight();
|
||||
const int cand[4][2] = { { wx + 4, wy - 3 }, { wx - 4 - tw, wy - 3 },
|
||||
{ wx + 4, wy + 3 }, { wx - 4 - tw, wy + 3 } };
|
||||
for (int c = 0; c < 4; c++) {
|
||||
int lx = cand[c][0], ly = cand[c][1];
|
||||
if (lx < ax || lx + tw > ax + aw || ly < ay || ly + ch > ay + ah) continue;
|
||||
bool clash = false;
|
||||
for (int k = 0; k < nocc; k++)
|
||||
if (rectsOverlap(lx, ly, tw, ch, occ[k].x, occ[k].y, occ[k].w, occ[k].h)) { clash = true; break; }
|
||||
if (clash) continue;
|
||||
d.setCursor(lx, ly);
|
||||
d.print(s);
|
||||
if (nocc < occ_max) occ[nocc++] = { lx, ly, tw, ch };
|
||||
return;
|
||||
}
|
||||
// No free slot — skip the label; the marker alone still conveys the point.
|
||||
}
|
||||
|
||||
static void drawFilledDot(DisplayDriver& d, int cx, int cy) {
|
||||
miniIconDrawCentered(d, cx, cy, ICON_MAP_DOT);
|
||||
}
|
||||
@@ -804,4 +940,13 @@ private:
|
||||
static void drawCurrentMarker(DisplayDriver& d, int cx, int cy) {
|
||||
miniIconDrawCentered(d, cx, cy, ICON_MAP_CURRENT);
|
||||
}
|
||||
// Filled diamond — a contact whose position arrived via a [LOC] share.
|
||||
static void drawContactMarker(DisplayDriver& d, int cx, int cy) {
|
||||
miniIconDrawCentered(d, cx, cy, ICON_MAP_CONTACT);
|
||||
}
|
||||
// Flag — the active Locator/Nav target. Drawn last (on top) so it stays
|
||||
// legible even when it lands on the same spot as a waypoint or contact.
|
||||
static void drawTargetMarker(DisplayDriver& d, int cx, int cy) {
|
||||
miniIconDrawCentered(d, cx, cy, ICON_MAP_TARGET);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#endif
|
||||
|
||||
#include "icons.h"
|
||||
#include "GfxUtils.h" // gfx::drawLine — connects trail points on the Home map preview
|
||||
|
||||
// Blinking status indicators: on for the first half of a 4 s cycle, but e-ink
|
||||
// can't repaint fast enough to blink, so it shows them steadily.
|
||||
@@ -53,9 +54,12 @@ public:
|
||||
strncpy(_version_info, MESHCORE_VERSION, sizeof(_version_info) - 1);
|
||||
_version_info[sizeof(_version_info) - 1] = '\0';
|
||||
|
||||
// Solo firmware version: strip commit hash suffix (v1.15-solo.1-abcdef -> v1.15)
|
||||
// Solo firmware version: strip the commit-hash suffix build.sh always
|
||||
// appends as the LAST dash-segment (v1.15-solo.1-abcdef -> v1.15-solo.1).
|
||||
// Must be the last dash, not the first: a tag like v1.21-rc1 has a dash
|
||||
// of its own before the commit hash gets appended.
|
||||
const char *ver = FIRMWARE_VERSION;
|
||||
const char *dash = strchr(ver, '-');
|
||||
const char *dash = strrchr(ver, '-');
|
||||
int plen = dash ? (int)(dash - ver) : (int)strlen(ver);
|
||||
if (plen >= (int)sizeof(_solo_ver)) plen = sizeof(_solo_ver) - 1;
|
||||
memcpy(_solo_ver, ver, plen);
|
||||
@@ -123,6 +127,8 @@ static const int QUICK_MSGS_MAX = 10;
|
||||
#include "NearbyScreen.h"
|
||||
#include "DashboardConfigScreen.h"
|
||||
#include "AutoAdvertScreen.h"
|
||||
#include "LiveShareScreen.h"
|
||||
#include "LocatorScreen.h"
|
||||
#include "TrailScreen.h"
|
||||
#include "CompassScreen.h"
|
||||
#include "DiagnosticsScreen.h"
|
||||
@@ -240,6 +246,7 @@ class HomeScreen : public UIScreen {
|
||||
SENSORS,
|
||||
#endif
|
||||
SETTINGS,
|
||||
MAP,
|
||||
TOOLS,
|
||||
QUICK_MSG,
|
||||
SHUTDOWN,
|
||||
@@ -472,6 +479,16 @@ class HomeScreen : public UIScreen {
|
||||
leftmostX = aX - 1;
|
||||
}
|
||||
|
||||
// Live location sharing active. Same blink convention — another
|
||||
// "leave it on and forget" broadcast, like auto-advert above. Reuses
|
||||
// the diamond the map uses for a live-tracked contact, so the glyph
|
||||
// already means "sharing position" elsewhere in the UI.
|
||||
if (_node_prefs && _node_prefs->loc_share_enabled) {
|
||||
int lsX = leftmostX - ind;
|
||||
if (blinkOn()) drawBoxedIcon(display, lsX, ind, ind_h, ICON_MAP_CONTACT);
|
||||
leftmostX = lsX - 1;
|
||||
}
|
||||
|
||||
// GPS trail logging active. Same blink convention.
|
||||
if (_task->trail().isActive()) {
|
||||
int gX = leftmostX - ind;
|
||||
@@ -488,6 +505,18 @@ class HomeScreen : public UIScreen {
|
||||
leftmostX = rX - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// GPS fix status — boxed (lit) when the receiver has a valid fix, plain
|
||||
// glyph while searching. Hidden entirely on boards with no GPS hardware
|
||||
// and while the GPS setting itself is off, so it doesn't sit there as a
|
||||
// permanently-empty slot or imply a search that isn't happening.
|
||||
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
|
||||
if (loc && _node_prefs && _node_prefs->gps_enabled) {
|
||||
int gX = leftmostX - ind - ind_gap;
|
||||
if (loc->isValid()) drawBoxedIcon(display, gX, ind, ind_h, ICON_GPS);
|
||||
else drawSlotIcon(display, gX, ind, ind_h, ICON_GPS);
|
||||
leftmostX = gX - 1;
|
||||
}
|
||||
return leftmostX;
|
||||
}
|
||||
|
||||
@@ -527,13 +556,165 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// Compact map preview for the Home "Map" page: own position, the GPS trail,
|
||||
// and live-tracked contacts (◆) folded into one auto-scaled box. A simplified
|
||||
// cousin of TrailScreen's map (no grid/labels, no break markers) so the home
|
||||
// carousel stays light. Returns false (and draws nothing) when there's
|
||||
// nothing to show.
|
||||
bool drawMapPreview(DisplayDriver& display, int ax, int ay, int aw, int ah) {
|
||||
if (aw < 8 || ah < 8) return false;
|
||||
bool init = false;
|
||||
int32_t mnla = 0, mxla = 0, mnlo = 0, mxlo = 0;
|
||||
auto fold = [&](int32_t la, int32_t lo) {
|
||||
if (!init) { mnla = mxla = la; mnlo = mxlo = lo; init = true; }
|
||||
else { if (la < mnla) mnla = la; if (la > mxla) mxla = la;
|
||||
if (lo < mnlo) mnlo = lo; if (lo > mxlo) mxlo = lo; }
|
||||
};
|
||||
TrailStore& tr = _task->trail();
|
||||
if (!tr.empty()) { int32_t a, b, c, d; tr.boundingBox(a, b, c, d); fold(a, b); fold(c, d); }
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY; i++)
|
||||
if (lt.isActive(i, now)) fold(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6);
|
||||
int32_t mla, mlo;
|
||||
bool have_gps = _task->currentLocation(mla, mlo);
|
||||
if (have_gps) fold(mla, mlo);
|
||||
int32_t tla, tlo;
|
||||
bool have_tgt = _task->activeTargetPos(tla, tlo); // active Locator/Nav target
|
||||
if (have_tgt) fold(tla, tlo);
|
||||
if (!init) return false;
|
||||
|
||||
// North marker — top-right, the same mini-icon as the full Trail map.
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
{
|
||||
const int ns = miniIconScale(display);
|
||||
miniIconDrawTop(display, ax + aw - ICON_MAP_NORTH.w * ns - 1, ay + 1, ICON_MAP_NORTH);
|
||||
}
|
||||
|
||||
int cx = ax + aw / 2, cy = ay + ah / 2;
|
||||
// Degenerate: one coincident point — just centre the markers.
|
||||
if (mnla == mxla && mnlo == mxlo) {
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY; i++)
|
||||
if (lt.isActive(i, now)) { miniIconDrawCentered(display, cx, cy, ICON_MAP_CONTACT); break; }
|
||||
if (have_gps || !tr.empty()) miniIconDrawCentered(display, cx, cy, ICON_MAP_CURRENT);
|
||||
if (have_tgt) miniIconDrawCentered(display, cx, cy, ICON_MAP_TARGET); // highlight on top
|
||||
return true;
|
||||
}
|
||||
float avg_lat_rad = ((mnla + mxla) / 2.0e6f) * (float)M_PI / 180.0f;
|
||||
float lon_scale = cosf(avg_lat_rad); if (lon_scale < 0.05f) lon_scale = 0.05f;
|
||||
float lat_span = (float)(mxla - mnla);
|
||||
float lon_span = (float)(mxlo - mnlo) * lon_scale;
|
||||
float slat = (float)ah / (lat_span > 0 ? lat_span : 1.0f);
|
||||
float slon = (float)aw / (lon_span > 0 ? lon_span : 1.0f);
|
||||
float scale = (slat < slon) ? slat : slon;
|
||||
int off_x = ax + (aw - (int)(lon_span * scale)) / 2;
|
||||
int off_y = ay + (ah - (int)(lat_span * scale)) / 2;
|
||||
auto project = [&](int32_t la, int32_t lo, int& px, int& py) {
|
||||
px = off_x + (int)((float)(lo - mnlo) * lon_scale * scale);
|
||||
py = off_y + (int)((float)(mxla - la) * scale);
|
||||
};
|
||||
// Trail as a connected line, matching the full Trail map (shared helper —
|
||||
// see gfx::drawTrail); no break marker here, just a silent gap.
|
||||
gfx::drawTrail(display, tr, project, [](int, int, int, int) {});
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
|
||||
if (!lt.isActive(i, now)) continue;
|
||||
int px, py; project(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6, px, py);
|
||||
miniIconDrawCentered(display, px, py, ICON_MAP_CONTACT);
|
||||
}
|
||||
if (have_gps) { int px, py; project(mla, mlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_CURRENT); }
|
||||
// Active target flag drawn last so it stays legible even atop a contact/own dot.
|
||||
if (have_tgt) { int px, py; project(tla, tlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_TARGET); }
|
||||
|
||||
// Bottom-left scale reference, always shown — distance to the active
|
||||
// target (or else the nearest live-tracked contact) now lives on the
|
||||
// status line below instead (see statusDistanceKm() / render()), so this
|
||||
// corner is free for it.
|
||||
{
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
int ty = ay + ah - display.getLineHeight();
|
||||
static const float M_PER_1E6 = 0.11132f; // metres per 1e-6° lat
|
||||
float ppm = scale / M_PER_1E6; // pixels per metre
|
||||
if (ppm > 0.0f) {
|
||||
bool imp = _task->useImperial();
|
||||
static const float MET_M[] = { 5,10,25,50,100,250,500,1000,2000,5000,10000,25000,50000 };
|
||||
static const char* MET_L[] = { "5m","10m","25m","50m","100m","250m","500m","1km","2km","5km","10km","25km","50km" };
|
||||
static const float IMP_M[] = { 4.572f,15.24f,30.48f,76.2f,152.4f,402.34f,804.67f,1609.34f,4828.0f,16093.4f,80467.2f };
|
||||
static const char* IMP_L[] = { "15ft","50ft","100ft","250ft","500ft","1/4mi","1/2mi","1mi","3mi","10mi","50mi" };
|
||||
const float* M = imp ? IMP_M : MET_M;
|
||||
const char* const* L = imp ? IMP_L : MET_L;
|
||||
int N = imp ? (int)(sizeof(IMP_M) / sizeof(IMP_M[0])) : (int)(sizeof(MET_M) / sizeof(MET_M[0]));
|
||||
float target = 8.0f / ppm; // short reference tick, not 1/3 of the width
|
||||
int sel = 0;
|
||||
for (int i = N - 1; i >= 0; i--) if (M[i] <= target) { sel = i; break; }
|
||||
int barpx = (int)(M[sel] * ppm + 0.5f);
|
||||
if (barpx < 5) barpx = 5;
|
||||
if (barpx > aw / 4) barpx = aw / 4;
|
||||
int bx = ax + 1, mid = ty + display.getLineHeight() / 2;
|
||||
display.fillRect(bx, mid, barpx, 1); // single tick, on the text baseline
|
||||
display.setCursor(bx + barpx + 2, ty);
|
||||
display.print(L[sel]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Distance shown on the MAP status line. The active Locator/Nav target
|
||||
// takes priority — that's what the flag on the mini-map is pointing at,
|
||||
// and it's the only way a waypoint target ever gets a distance readout
|
||||
// here (a waypoint isn't a live-tracked contact). Falls back to the
|
||||
// nearest live-tracked ([LOC]-sharing) contact when no target is set.
|
||||
// -1 when we don't have a fix or nothing to measure against.
|
||||
float statusDistanceKm() {
|
||||
int32_t mla, mlo;
|
||||
if (!_task->currentLocation(mla, mlo)) return -1.0f;
|
||||
int32_t tla, tlo;
|
||||
if (_task->activeTargetPos(tla, tlo)) return geo::haversineKm(mla, mlo, tla, tlo);
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
float nearest_km = -1.0f;
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
|
||||
if (!lt.isActive(i, now)) continue;
|
||||
float d = geo::haversineKm(mla, mlo, lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6);
|
||||
if (nearest_km < 0.0f || d < nearest_km) nearest_km = d;
|
||||
}
|
||||
return nearest_km;
|
||||
}
|
||||
|
||||
// Small 5x5 glyph shown in the page-indicator row for each HomePage.
|
||||
static const MiniIcon* pageIcon(int page) {
|
||||
switch (page) {
|
||||
case CLOCK: return &ICON_PG_CLOCK;
|
||||
case FAVOURITES: return &ICON_PG_STAR;
|
||||
case RECENT: return &ICON_PG_RECENT;
|
||||
case RADIO: return &ICON_PG_RADIO;
|
||||
case BLUETOOTH: return &ICON_PG_BT;
|
||||
case ADVERT: return &ICON_PG_ADVERT;
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
case GPS: return &ICON_PG_GPS;
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
case SENSORS: return &ICON_PG_SENSORS;
|
||||
#endif
|
||||
case SETTINGS: return &ICON_PG_SETTINGS;
|
||||
case MAP: return &ICON_PG_MAP;
|
||||
case TOOLS: return &ICON_PG_TOOLS;
|
||||
case QUICK_MSG: return &ICON_PG_MSG;
|
||||
case SHUTDOWN: return &ICON_PG_POWER;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[80];
|
||||
display.setTextSize(1);
|
||||
const int lh = display.getLineHeight(); // line height at sz1
|
||||
const int step = display.lineStep(); // lh + 2
|
||||
const int dots_y = lh + 4; // page-dot row: just below header
|
||||
const int content_y = dots_y + 6; // first content row (6px gap keeps dots visible)
|
||||
// Page-indicator row: small (5px) page icons replace the old dots. Centre and
|
||||
// gap scale with the font so the band clears the header above and content
|
||||
// below (identical to the old lh+4 / +6 dots layout at 1x).
|
||||
const int pg_half = (5 * miniIconScale(display) + 1) / 2;
|
||||
const int dots_y = lh + pg_half + 1; // icon-row centre, below the header
|
||||
const int content_y = dots_y + pg_half + 3; // first content row, below the icons
|
||||
|
||||
// node name + battery — hidden on CLOCK page (full screen used for dashboard)
|
||||
if (_page != CLOCK) {
|
||||
@@ -560,17 +741,26 @@ public:
|
||||
// ensure current page is visible (e.g. after settings change)
|
||||
if (!isPageVisible(_page)) _page = navPage(_page, +1);
|
||||
|
||||
// curr page indicator — hidden on CLOCK page (full screen used for dashboard)
|
||||
// curr page indicator — a row of small page icons, one per visible page, with
|
||||
// the current page underlined. Hidden on CLOCK (full screen used for dashboard).
|
||||
if (_page != CLOCK) {
|
||||
int order[(int)Count]; int n = buildVisibleOrder(order);
|
||||
int curr_vis = 0;
|
||||
for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; }
|
||||
int x = display.width() / 2 - 5 * (n - 1);
|
||||
const int s = miniIconScale(display);
|
||||
const int icon_w = 5 * s;
|
||||
int pitch = icon_w + 5 * s; // comfortable spacing
|
||||
if (n > 1) { // shrink to fit if many pages
|
||||
int fit = (display.width() - icon_w) / (n - 1);
|
||||
if (fit < pitch) pitch = fit;
|
||||
}
|
||||
int x = display.width() / 2 - pitch * (n - 1) / 2;
|
||||
for (int i = 0; i < n; i++) {
|
||||
int ds = display.isLandscape() ? 2 : 1;
|
||||
if (i == curr_vis) display.fillRect(x-ds, dots_y-ds, 2*ds+1, 2*ds+1);
|
||||
else display.fillRect(x-ds+1, dots_y-ds+1, 2*ds-1, 2*ds-1);
|
||||
x += 10;
|
||||
const MiniIcon* ic = pageIcon(order[i]);
|
||||
if (ic) miniIconDrawCentered(display, x, dots_y, *ic);
|
||||
if (i == curr_vis) // underline the current page
|
||||
display.fillRect(x - icon_w / 2, dots_y + pg_half + 1, icon_w, s);
|
||||
x += pitch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,6 +1081,43 @@ public:
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, content_y, "Settings");
|
||||
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
|
||||
} else if (_page == HomePage::MAP) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
// Mini-map preview filling the page, with one status line at the bottom.
|
||||
int info_y = display.height() - step;
|
||||
int area_h = info_y - content_y - 2;
|
||||
bool drew = drawMapPreview(display, 2, content_y, display.width() - 4, area_h);
|
||||
char left[20], right[16] = {0};
|
||||
uint32_t now_m = rtc_clock.getCurrentTime();
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
int trk = lt.active(now_m);
|
||||
// Fix state lives in the top-bar GPS icon. Track count plus an arrow +
|
||||
// distance (to the active target, else the nearest live-tracked
|
||||
// contact) share this one status line.
|
||||
snprintf(left, sizeof(left), "Track:%d", trk);
|
||||
float nearest_km = statusDistanceKm();
|
||||
if (nearest_km >= 0.0f) geo::fmtDist(right, sizeof(right), nearest_km, _task->useImperial());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (!drew)
|
||||
display.drawTextCentered(display.width() / 2, content_y + area_h / 2, "No GPS / no trail");
|
||||
if (right[0]) {
|
||||
// Manual layout (not drawTextCentered) so the arrow mini-icon sits
|
||||
// inline between the two text runs.
|
||||
const int s = miniIconScale(display);
|
||||
const int gap = 3;
|
||||
int lw = display.getTextWidth(left);
|
||||
int iw = ICON_MAP_ARROW.w * s;
|
||||
int rw = display.getTextWidth(right);
|
||||
int x = display.width() / 2 - (lw + gap + iw + gap + rw) / 2;
|
||||
display.setCursor(x, info_y);
|
||||
display.print(left);
|
||||
miniIconDrawTop(display, x + lw + gap, info_y + (lh - ICON_MAP_ARROW.h * s) / 2, ICON_MAP_ARROW);
|
||||
display.setCursor(x + lw + gap + iw + gap, info_y);
|
||||
display.print(right);
|
||||
} else {
|
||||
display.drawTextCentered(display.width() / 2, info_y, left);
|
||||
}
|
||||
} else if (_page == HomePage::TOOLS) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
@@ -1008,8 +1235,10 @@ public:
|
||||
// Any blinking status-bar indicator needs a 1 s refresh to animate evenly —
|
||||
// but the status bar (and its icons) is hidden on the CLOCK page, so don't
|
||||
// pay the 1 s cadence there for icons that aren't drawn.
|
||||
bool repeating = _node_prefs && _node_prefs->client_repeat;
|
||||
bool need_blink = (_page != HomePage::CLOCK) && (auto_adv || _task->trail().isActive() || repeating);
|
||||
bool repeating = _node_prefs && _node_prefs->client_repeat;
|
||||
bool loc_sharing = _node_prefs && _node_prefs->loc_share_enabled;
|
||||
bool need_blink = (_page != HomePage::CLOCK) &&
|
||||
(auto_adv || _task->trail().isActive() || repeating || loc_sharing);
|
||||
if (Features::IS_EINK) {
|
||||
// slow display: poll every 30 s; inbound msgs force immediate refresh via notify()
|
||||
return Features::HOME_REFRESH_MS;
|
||||
@@ -1127,6 +1356,10 @@ public:
|
||||
_task->gotoSettingsScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::MAP) {
|
||||
_task->gotoMapScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::TOOLS) {
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
@@ -1143,6 +1376,10 @@ public:
|
||||
_task->gotoDashboardConfig();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU && _page == HomePage::MAP) {
|
||||
_task->quickShareMyLocation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1212,6 +1449,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
nearby_screen = new NearbyScreen(this);
|
||||
dashboard_config = new DashboardConfigScreen(this, node_prefs);
|
||||
auto_advert_screen = new AutoAdvertScreen(this, node_prefs);
|
||||
live_share_screen = new LiveShareScreen(this, node_prefs);
|
||||
locator_screen = new LocatorScreen(this, node_prefs);
|
||||
trail_screen = new TrailScreen(this, &_trail);
|
||||
compass_screen = new CompassScreen(this);
|
||||
diag_screen = new DiagnosticsScreen(this);
|
||||
@@ -1229,6 +1468,7 @@ void UITask::gotoSettingsScreen() {
|
||||
}
|
||||
|
||||
void UITask::gotoToolsScreen() {
|
||||
((ToolsScreen*)tools_screen)->enter();
|
||||
setCurrScreen(tools_screen);
|
||||
}
|
||||
|
||||
@@ -1257,6 +1497,11 @@ void UITask::gotoTrailScreen() {
|
||||
setCurrScreen(trail_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoMapScreen() {
|
||||
((TrailScreen*)trail_screen)->enterMap();
|
||||
setCurrScreen(trail_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoCompassScreen() {
|
||||
((CompassScreen*)compass_screen)->enter();
|
||||
setCurrScreen(compass_screen);
|
||||
@@ -1271,6 +1516,16 @@ void UITask::gotoRepeaterScreen() {
|
||||
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);
|
||||
@@ -1364,6 +1619,11 @@ void UITask::shareToMessage(const char* text) {
|
||||
setCurrScreen(quick_msg);
|
||||
}
|
||||
|
||||
void UITask::pickLocShareTarget() {
|
||||
((QuickMsgScreen*)quick_msg)->startPickTarget();
|
||||
setCurrScreen(quick_msg);
|
||||
}
|
||||
|
||||
int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
|
||||
return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max);
|
||||
}
|
||||
@@ -1414,6 +1674,11 @@ void UITask::notify(UIEventType t) {
|
||||
sn.playCH(_last_notif_ch_idx);
|
||||
_last_notif_ch_idx = -1;
|
||||
break;
|
||||
case UIEventType::roomMessage:
|
||||
// Rooms have many authors and no per-room melody pref, so use the default DM
|
||||
// notification (no per-sender melody/mute lookup — the author varies per post).
|
||||
sn.playDM(false, nullptr);
|
||||
break;
|
||||
case UIEventType::advertReceivedFlood:
|
||||
case UIEventType::advertReceivedZeroHop:
|
||||
sn.playAD(t == UIEventType::advertReceivedFlood);
|
||||
@@ -1421,7 +1686,6 @@ void UITask::notify(UIEventType t) {
|
||||
case UIEventType::ack:
|
||||
buzzer.play("ack:d=32,o=8,b=120:c");
|
||||
break;
|
||||
case UIEventType::roomMessage:
|
||||
case UIEventType::none:
|
||||
default:
|
||||
break;
|
||||
@@ -1918,19 +2182,81 @@ void UITask::loop() {
|
||||
// GPS trail sampling — runs in the background while the trail is
|
||||
// active, independent of which screen is shown. Skips silently if no GPS
|
||||
// fix; min-delta gate inside addPoint() avoids near-stationary spam.
|
||||
if (!_trail.isActive()) _trail_pause_has_ref = false; // fresh ref on next start
|
||||
if (_trail.isActive() && _node_prefs != NULL
|
||||
&& (int32_t)(millis() - _next_trail_sample_ms) >= 0) {
|
||||
_next_trail_sample_ms = millis() + (uint32_t)TrailStore::SAMPLING_SECS * 1000UL;
|
||||
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
|
||||
if (loc && loc->isValid()) {
|
||||
int32_t la = (int32_t)loc->getLatitude();
|
||||
int32_t lo = (int32_t)loc->getLongitude();
|
||||
uint16_t md = TrailStore::minDeltaMeters(_node_prefs->trail_min_delta_idx,
|
||||
_node_prefs->units_imperial);
|
||||
_trail.addPoint((int32_t)loc->getLatitude(),
|
||||
(int32_t)loc->getLongitude(),
|
||||
(uint32_t)rtc_clock.getCurrentTime(), md);
|
||||
// Auto-pause: freeze the trail once the device has stayed within
|
||||
// TRAIL_AUTOPAUSE_MOVE_M of one spot for the configured delay; resume on
|
||||
// the next real move. Its own coarse gate (not the trail min-delta) so
|
||||
// GPS jitter while parked doesn't keep the idle timer alive.
|
||||
uint16_t ap = NodePrefs::trailAutoPauseSecs(_node_prefs->trail_autopause_idx);
|
||||
if (ap > 0) {
|
||||
uint32_t now = millis();
|
||||
float moved = _trail_pause_has_ref
|
||||
? geo::haversineKm(_trail_pause_ref_lat, _trail_pause_ref_lon, la, lo) * 1000.0f
|
||||
: 1e9f;
|
||||
if (!_trail_pause_has_ref || moved >= (float)NodePrefs::TRAIL_AUTOPAUSE_MOVE_M) {
|
||||
_trail_pause_ref_lat = la; _trail_pause_ref_lon = lo;
|
||||
_trail_pause_has_ref = true;
|
||||
_trail_last_move_ms = now;
|
||||
if (_trail.isPaused()) _trail.setPaused(false);
|
||||
} else if (!_trail.isPaused() && (now - _trail_last_move_ms) >= (uint32_t)ap * 1000UL) {
|
||||
_trail.setPaused(true);
|
||||
}
|
||||
} else if (_trail.isPaused()) {
|
||||
_trail.setPaused(false); // feature turned off → resume
|
||||
}
|
||||
if (!_trail.isPaused())
|
||||
_trail.addPoint(la, lo, (uint32_t)rtc_clock.getCurrentTime(), md);
|
||||
}
|
||||
}
|
||||
|
||||
// Live-track housekeeping — drop shared positions that have gone stale, so
|
||||
// the Nearby "Live" view / map don't show ghosts. Cheap; once a minute.
|
||||
if ((int32_t)(millis() - _next_livetrack_expire_ms) >= 0) {
|
||||
_next_livetrack_expire_ms = millis() + 60000UL;
|
||||
_livetrack.expire((uint32_t)rtc_clock.getCurrentTime());
|
||||
}
|
||||
|
||||
// Live location sharing — periodically broadcast my [LOC] to the configured
|
||||
// target while moving (Map › Live share). Movement-gated so a stationary
|
||||
// device stays quiet unless a heartbeat is configured.
|
||||
if (_node_prefs && _node_prefs->loc_share_enabled
|
||||
&& (int32_t)(millis() - _next_loc_share_check_ms) >= 0) {
|
||||
_next_loc_share_check_ms = millis() + 2000UL;
|
||||
if (!_loc_share_was_enabled) _loc_share_has_last = false; // re-announce on enable
|
||||
_loc_share_was_enabled = true;
|
||||
int32_t lat, lon;
|
||||
if (currentLocation(lat, lon)) {
|
||||
uint16_t move_m = NodePrefs::locShareMoveMeters(_node_prefs->loc_share_move_idx);
|
||||
uint16_t gap_s = NodePrefs::locShareIntervalSecs(_node_prefs->loc_share_interval_idx);
|
||||
uint16_t hb_s = NodePrefs::locShareHeartbeatSecs(_node_prefs->loc_share_heartbeat_idx);
|
||||
uint32_t now = millis();
|
||||
bool first = !_loc_share_has_last;
|
||||
float moved = first ? 1e9f
|
||||
: geo::haversineKm(_loc_share_last_lat, _loc_share_last_lon, lat, lon) * 1000.0f;
|
||||
bool gap_ok = first || (now - _loc_share_last_ms) >= (uint32_t)gap_s * 1000UL;
|
||||
bool hb_due = (hb_s > 0) && !first && (now - _loc_share_last_ms) >= (uint32_t)hb_s * 1000UL;
|
||||
if ((moved >= (float)move_m && gap_ok) || first || hb_due) {
|
||||
if (sendLocationShare(lat, lon)) {
|
||||
_loc_share_last_lat = lat;
|
||||
_loc_share_last_lon = lon;
|
||||
_loc_share_last_ms = now;
|
||||
_loc_share_has_last = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (_node_prefs && !_node_prefs->loc_share_enabled) {
|
||||
_loc_share_was_enabled = false;
|
||||
}
|
||||
|
||||
// Course-over-ground sampling — every ~1 s regardless of trail state, so the
|
||||
// heading is available to navigation even when not recording a trail.
|
||||
if ((int32_t)(millis() - _next_cog_sample_ms) >= 0) {
|
||||
@@ -1940,6 +2266,158 @@ void UITask::loop() {
|
||||
pushCogFix((int32_t)loc->getLatitude(), (int32_t)loc->getLongitude());
|
||||
}
|
||||
}
|
||||
|
||||
// Locator — beep + alert when the device crosses into / out of the armed
|
||||
// geofence. Cheap; a few seconds of latency at the boundary is fine.
|
||||
if ((int32_t)(millis() - _next_locator_ms) >= 0) {
|
||||
_next_locator_ms = millis() + 3000UL;
|
||||
evaluateLocator();
|
||||
}
|
||||
|
||||
// Locator proximity beeper — ticks faster the closer to the target. Runs on
|
||||
// its own short cadence (the crossing check above is too coarse for this).
|
||||
locatorProximityBeeper();
|
||||
}
|
||||
|
||||
// Evaluate the single geofence against the current GPS fix. Crossing the radius
|
||||
// fires fireLocator() according to the configured mode; a hysteresis band on
|
||||
// the "leave" edge stops it chattering at the boundary, and the first reading
|
||||
// after arming only seeds the inside/outside state (no spurious alert).
|
||||
// Distance (m) from the current GPS fix to the locator target, plus the
|
||||
// configured radius (m). Returns false when no target is set or there's no fix
|
||||
// — the single place the target-distance maths lives, shared by the crossing
|
||||
// evaluator and the proximity beeper.
|
||||
// One precedence for a person's position — an active [LOC] live share wins,
|
||||
// else the last-advertised GPS fix. Not everyone keeps live-sharing on, so the
|
||||
// fallback lets a rarely-updating but stationary node (a repeater, or someone
|
||||
// who shared a fix once) still work as a target.
|
||||
bool UITask::resolvePersonPos(const uint8_t* key, int32_t& lat, int32_t& lon,
|
||||
bool* live, uint32_t* ts) const {
|
||||
if (live) *live = false;
|
||||
if (ts) *ts = 0;
|
||||
if (!key) return false;
|
||||
const LiveTrackStore::Entry* e =
|
||||
_livetrack.activeByKey(key, (uint32_t)rtc_clock.getCurrentTime());
|
||||
if (e) {
|
||||
lat = e->lat_1e6; lon = e->lon_1e6;
|
||||
if (live) *live = true;
|
||||
if (ts) *ts = e->ts;
|
||||
return true;
|
||||
}
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (c && (c->gps_lat != 0 || c->gps_lon != 0)) {
|
||||
lat = c->gps_lat; lon = c->gps_lon;
|
||||
if (ts) *ts = c->lastmod;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UITask::activeTargetPos(int32_t& lat, int32_t& lon) const {
|
||||
if (!_node_prefs || !_node_prefs->locator_has_target) return false;
|
||||
if (_node_prefs->locator_target_kind == 1)
|
||||
return resolvePersonPos(_node_prefs->locator_key, lat, lon);
|
||||
lat = _node_prefs->locator_lat_1e6;
|
||||
lon = _node_prefs->locator_lon_1e6;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UITask::locatorDistance(float& dist_m, float& radius_m) const {
|
||||
int32_t tlat, tlon;
|
||||
if (!activeTargetPos(tlat, tlon)) return false;
|
||||
int32_t lat, lon;
|
||||
if (!currentLocation(lat, lon)) return false;
|
||||
dist_m = geo::haversineKm(lat, lon, tlat, tlon) * 1000.0f;
|
||||
radius_m = (float)NodePrefs::locatorRadiusMeters(_node_prefs->locator_radius_idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
void UITask::evaluateLocator() {
|
||||
if (!_node_prefs || !_node_prefs->locator_enabled || !_node_prefs->locator_has_target) {
|
||||
_locator_known = false;
|
||||
return;
|
||||
}
|
||||
float dist, r;
|
||||
if (!locatorDistance(dist, r)) return; // armed but no fix yet — keep state
|
||||
bool inside;
|
||||
if (!_locator_known) inside = dist <= r; // seed state
|
||||
else if (_locator_inside) inside = dist <= r * 1.25f; // leave past band
|
||||
else inside = dist <= r; // arrive at edge
|
||||
|
||||
if (_locator_known && inside != _locator_inside) {
|
||||
uint8_t mode = _node_prefs->locator_mode; // 0=arrive,1=leave,2=both
|
||||
bool fire = inside ? (mode == 0 || mode == 2) : (mode == 1 || mode == 2);
|
||||
if (fire) fireLocator(inside);
|
||||
}
|
||||
_locator_inside = inside;
|
||||
_locator_known = true;
|
||||
}
|
||||
|
||||
void UITask::fireLocator(bool arrived) {
|
||||
const char* lbl = _node_prefs->locator_label[0] ? _node_prefs->locator_label : "target";
|
||||
bool person = _node_prefs->locator_target_kind == 1;
|
||||
char msg[40];
|
||||
// "Near/Away" reads naturally for a moving person; "Arrived/Left" for a place.
|
||||
snprintf(msg, sizeof(msg),
|
||||
arrived ? (person ? "Near: %s" : "Arrived: %s")
|
||||
: (person ? "Away: %s" : "Left: %s"), lbl);
|
||||
showAlert(msg, 3000);
|
||||
if (!isBuzzerQuiet())
|
||||
playMelody(arrived ? "locarr:d=8,o=6,b=140:c,e,g" : "loclv:d=8,o=6,b=140:g,e,c");
|
||||
}
|
||||
|
||||
void UITask::setTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) {
|
||||
if (!_node_prefs) return;
|
||||
_node_prefs->locator_target_kind = kind;
|
||||
if (kind == 1 && key) memcpy(_node_prefs->locator_key, key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
_node_prefs->locator_lat_1e6 = lat;
|
||||
_node_prefs->locator_lon_1e6 = lon;
|
||||
snprintf(_node_prefs->locator_label, sizeof(_node_prefs->locator_label), "%s", name);
|
||||
_node_prefs->locator_has_target = 1;
|
||||
resetLocator(); // re-seed the crossing engine so the change can't fire on a stale state
|
||||
}
|
||||
|
||||
void UITask::setTargetNow(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) {
|
||||
if (!_node_prefs) return;
|
||||
setTarget(kind, key, lat, lon, name);
|
||||
the_mesh.savePrefs();
|
||||
showAlert("Target set", 1200);
|
||||
}
|
||||
|
||||
void UITask::clearTarget() {
|
||||
if (!_node_prefs) return;
|
||||
_node_prefs->locator_has_target = 0;
|
||||
resetLocator();
|
||||
}
|
||||
|
||||
// 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
|
||||
// radius. The beeper has its own toggle (locator_beeper), so turning it on is
|
||||
// an explicit "I want to hear this" — it deliberately overrides the global
|
||||
// buzzer mute (playMelody → buzzer.playForced ignores the quiet flag).
|
||||
void UITask::locatorProximityBeeper() {
|
||||
static const uint32_t BEEP_MIN_MS = 150; // fastest cadence (at the target)
|
||||
static const uint32_t BEEP_MAX_MS = 2000; // slowest cadence (at the edge)
|
||||
if (!_node_prefs || !_node_prefs->locator_enabled || !_node_prefs->locator_beeper
|
||||
|| !_node_prefs->locator_has_target || _node_prefs->locator_mode == 1) { // leave-only mode: no homing
|
||||
return;
|
||||
}
|
||||
if ((int32_t)(millis() - _locator_beep_check_ms) < 0) return;
|
||||
_locator_beep_check_ms = millis() + 250UL;
|
||||
|
||||
float dist, r;
|
||||
if (!locatorDistance(dist, r)) return;
|
||||
if (dist > r) { // outside the zone: stay quiet, beep on re-entry
|
||||
_locator_beep_next_ms = millis();
|
||||
return;
|
||||
}
|
||||
if ((int32_t)(millis() - _locator_beep_next_ms) < 0) return;
|
||||
float frac = (r > 0) ? dist / r : 0; // 0 at centre, 1 at edge
|
||||
if (frac < 0) frac = 0; else if (frac > 1) frac = 1;
|
||||
uint32_t interval = BEEP_MIN_MS + (uint32_t)(frac * (BEEP_MAX_MS - BEEP_MIN_MS));
|
||||
playMelody("locp:d=32,o=7,b=200:c");
|
||||
_locator_beep_next_ms = millis() + interval;
|
||||
}
|
||||
|
||||
// Insert a GPS fix into the course-over-ground ring, rejecting gross outliers
|
||||
@@ -1997,6 +2475,56 @@ bool UITask::currentLocation(int32_t& lat, int32_t& lon) const {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A peer broadcast its position via a [LOC] message (parsed in MyMesh). Record
|
||||
// it in the live-track table for the Nearby "Live" view / map. Gated on the
|
||||
// user preference so it stays opt-in.
|
||||
void UITask::onSharedLocation(const uint8_t* pub_key, const char* name,
|
||||
int32_t lat_1e6, int32_t lon_1e6,
|
||||
uint32_t ts, bool verified) {
|
||||
if (!_node_prefs || !_node_prefs->track_shared_loc) return;
|
||||
_livetrack.update(pub_key, name, lat_1e6, lon_1e6, ts, verified);
|
||||
}
|
||||
|
||||
bool UITask::sendLocationShare(int32_t lat, int32_t lon) {
|
||||
if (!_node_prefs) return false;
|
||||
char text[80];
|
||||
if (_node_prefs->loc_share_target_type == 0) {
|
||||
// Channel: sendGroupMessage prepends "<name>: ", so the payload already
|
||||
// names the sender — keep the [LOC] text bare.
|
||||
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
|
||||
ChannelDetails ch;
|
||||
if (!the_mesh.getChannel(_node_prefs->loc_share_channel_idx, ch)) return false;
|
||||
return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel,
|
||||
the_mesh.getNodeName(), text, strlen(text));
|
||||
}
|
||||
// DM carries no per-message sender prefix, so embed the name in the text — the
|
||||
// share is then self-describing in any chat client (a trailing token after the
|
||||
// coordinate, which parseLocShare ignores on the receiving side).
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(_node_prefs->loc_share_dm_prefix,
|
||||
NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (!c) return false;
|
||||
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f %s",
|
||||
lat / 1e6, lon / 1e6, the_mesh.getNodeName());
|
||||
uint32_t expected_ack = 0, est_timeout = 0;
|
||||
return the_mesh.sendMessage(*c, rtc_clock.getCurrentTime(), 0, text, expected_ack, est_timeout) > 0;
|
||||
}
|
||||
|
||||
// One-shot "share my position" from the home Map page (Hold Enter). When live
|
||||
// sharing is already on, push an immediate [LOC] to the same target; otherwise
|
||||
// hand a [LOC] message to the recipient picker so the user chooses where it
|
||||
// goes (no accidental broadcast to a default channel).
|
||||
void UITask::quickShareMyLocation() {
|
||||
int32_t lat, lon;
|
||||
if (!currentLocation(lat, lon)) { showAlert("No GPS fix", 1000); return; }
|
||||
if (_node_prefs && _node_prefs->loc_share_enabled && sendLocationShare(lat, lon)) {
|
||||
showAlert("Position shared", 900);
|
||||
return;
|
||||
}
|
||||
char text[40];
|
||||
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
|
||||
shareToMessage(text);
|
||||
}
|
||||
|
||||
void UITask::saveWaypoints() {
|
||||
DataStore* ds = the_mesh.getDataStore();
|
||||
if (!ds) return;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "../NodePrefs.h"
|
||||
#include "../Trail.h"
|
||||
#include "../Waypoint.h"
|
||||
#include "../LiveTrack.h"
|
||||
#include "KeyboardWidget.h"
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
@@ -77,6 +78,8 @@ class UITask : public AbstractUITask {
|
||||
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;
|
||||
@@ -85,7 +88,38 @@ class UITask : public AbstractUITask {
|
||||
CayenneLPP _dash_lpp;
|
||||
TrailStore _trail;
|
||||
WaypointStore _waypoints;
|
||||
LiveTrackStore _livetrack;
|
||||
uint32_t _next_trail_sample_ms = 0;
|
||||
uint32_t _next_livetrack_expire_ms = 0;
|
||||
|
||||
// Live location sharing engine state (auto [LOC] broadcast while moving).
|
||||
uint32_t _next_loc_share_check_ms = 0;
|
||||
uint32_t _loc_share_last_ms = 0;
|
||||
int32_t _loc_share_last_lat = 0, _loc_share_last_lon = 0;
|
||||
bool _loc_share_has_last = false;
|
||||
bool _loc_share_was_enabled = false;
|
||||
|
||||
// Trail auto-pause engine state. _trail_pause_ref is the last position the
|
||||
// device was considered "at"; if it doesn't move beyond the trail min-delta
|
||||
// gate for the configured delay, the trail is auto-paused.
|
||||
int32_t _trail_pause_ref_lat = 0, _trail_pause_ref_lon = 0;
|
||||
bool _trail_pause_has_ref = false;
|
||||
uint32_t _trail_last_move_ms = 0;
|
||||
|
||||
// Locator engine state. _locator_known guards the first evaluation after
|
||||
// arming (initialise inside/outside silently, fire only on later crossings).
|
||||
uint32_t _next_locator_ms = 0;
|
||||
bool _locator_inside = false;
|
||||
bool _locator_known = false;
|
||||
// Proximity beeper: ticks while inside the radius, faster the nearer the
|
||||
// target. _locator_beep_check_ms throttles the distance poll; _locator_beep_next_ms
|
||||
// is when the next tick is due.
|
||||
uint32_t _locator_beep_check_ms = 0;
|
||||
uint32_t _locator_beep_next_ms = 0;
|
||||
bool locatorDistance(float& dist_m, float& radius_m) const;
|
||||
void evaluateLocator();
|
||||
void fireLocator(bool arrived);
|
||||
void locatorProximityBeeper();
|
||||
|
||||
// Course-over-ground ring — a heading source independent of trail recording.
|
||||
// Filled from the same periodic GPS poll regardless of _trail.isActive().
|
||||
@@ -145,6 +179,8 @@ public:
|
||||
void gotoQuickMsgScreen();
|
||||
void openContactDM(const ContactInfo& ci);
|
||||
void shareToMessage(const char* text); // open Messages pre-loaded to share `text`
|
||||
void quickShareMyLocation(); // Home Map Hold-Enter: one-shot position share
|
||||
void pickLocShareTarget(); // open Messages to choose the live-share target
|
||||
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const;
|
||||
void gotoToolsScreen();
|
||||
void gotoRingtoneEditor(int slot = 0);
|
||||
@@ -152,12 +188,45 @@ public:
|
||||
void gotoNearbyScreen();
|
||||
void gotoDashboardConfig();
|
||||
void gotoAutoAdvertScreen();
|
||||
void gotoLiveShareScreen();
|
||||
void gotoLocatorScreen();
|
||||
// Re-arm the locator state machine so the next evaluation initialises
|
||||
// silently (called by the Locator tool after the target/radius changes,
|
||||
// so re-entering the zone doesn't fire on a stale inside/outside state).
|
||||
void resetLocator() { _locator_known = false; }
|
||||
// The one "active target" the device tracks — shared by the Locator geofence,
|
||||
// the Nav bearing/ETA view and (future) the map focus, so every entry point
|
||||
// sets the same thing. kind 0 = waypoint (key ignored), 1 = person (key
|
||||
// required, 6-byte prefix). setTarget() only *defines* the target (fields +
|
||||
// re-arm); the caller decides when to persist. Two commit policies, by
|
||||
// context: a screen with an exit hook (LocatorScreen) batches the save so
|
||||
// LEFT/RIGHT cycling doesn't thrash flash, while a per-item popup with no
|
||||
// such hook uses setTargetNow() to save + confirm on the spot.
|
||||
void setTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name);
|
||||
void setTargetNow(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name);
|
||||
// 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();
|
||||
// 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
|
||||
// freshness for the picker's age tag. One precedence, used by both the
|
||||
// Locator engine (locatorDistance) and the target picker.
|
||||
bool resolvePersonPos(const uint8_t* key, int32_t& lat, int32_t& lon,
|
||||
bool* live = nullptr, uint32_t* ts = nullptr) const;
|
||||
// Resolved position of the active target — a waypoint's coords, or a person
|
||||
// via resolvePersonPos(). Gated only on a target being set, independent of
|
||||
// whether the Locator alert is enabled, so a destination you set still shows
|
||||
// on the map. Used by locatorDistance() and the map renderers.
|
||||
bool activeTargetPos(int32_t& lat, int32_t& lon) const;
|
||||
void gotoTrailScreen();
|
||||
void gotoMapScreen(); // opens the Trail screen directly in its Map view
|
||||
void gotoCompassScreen();
|
||||
void gotoDiagnosticsScreen();
|
||||
void gotoRepeaterScreen();
|
||||
TrailStore& trail() { return _trail; }
|
||||
WaypointStore& waypoints() { return _waypoints; }
|
||||
LiveTrackStore& liveTrack() { return _livetrack; }
|
||||
// Shared on-screen keyboard — only one screen drives it at a time.
|
||||
KeyboardWidget& keyboard() { return _kb; }
|
||||
void saveWaypoints();
|
||||
@@ -276,7 +345,13 @@ public:
|
||||
void msgRead(int msgcount) override;
|
||||
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) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void onSharedLocation(const uint8_t* pub_key, const char* name,
|
||||
int32_t lat_1e6, int32_t lon_1e6,
|
||||
uint32_t ts, bool verified) override;
|
||||
void loop() override;
|
||||
// Send one [LOC] message to the configured live-share target. Returns false
|
||||
// if the target can't be resolved (no such channel / contact).
|
||||
bool sendLocationShare(int32_t lat, int32_t lon);
|
||||
|
||||
void shutdown(bool restart = false);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "KeyboardWidget.h"
|
||||
#include "PopupMenu.h"
|
||||
#include "NavView.h"
|
||||
#include "DigitEditor.h"
|
||||
|
||||
class WaypointsView {
|
||||
UITask* _task;
|
||||
@@ -30,14 +31,18 @@ class WaypointsView {
|
||||
int32_t _mark_lat = 0, _mark_lon = 0; // pending new-mark position
|
||||
uint32_t _mark_ts = 0;
|
||||
|
||||
// ADD form — type a waypoint by coordinates. The keyboard has no comma or
|
||||
// minus, so lat/lon are entered as magnitude + a hemisphere toggle (LEFT/RIGHT).
|
||||
char _add_lat[12] = "", _add_lon[12] = ""; // magnitude strings
|
||||
// ADD form — enter a waypoint by coordinates. Lat/Lon are numeric, so they use
|
||||
// the digit-by-digit scroll editor (same widget as the radio frequency field)
|
||||
// rather than the text keyboard: Enter opens the editor on the magnitude,
|
||||
// LEFT/RIGHT on the row toggles the hemisphere (N/S, E/W). Only the Label field
|
||||
// uses the keyboard.
|
||||
float _add_lat_mag = 0.0f, _add_lon_mag = 0.0f; // magnitude (degrees)
|
||||
char _add_label[WAYPOINT_LABEL_LEN] = "";
|
||||
bool _add_lat_neg = false; // true = S
|
||||
bool _add_lon_neg = false; // true = W
|
||||
int _add_sel = 0; // 0=Lat 1=Lon 2=Label 3=Save
|
||||
int _kb_field = -1; // which ADD field the keyboard edits (-1 = label/rename)
|
||||
int _kb_field = -1; // ADD label edit (>=0) vs mark/rename (-1)
|
||||
DigitEditor _add_editor; // active while editing a Lat/Lon magnitude
|
||||
|
||||
bool useImperial() const { return _task && _task->useImperial(); }
|
||||
bool ownPos(int32_t& lat, int32_t& lon) const { return _task->currentLocation(lat, lon); }
|
||||
@@ -53,38 +58,23 @@ class WaypointsView {
|
||||
// Add a waypoint by coordinates (no GPS fix needed). Opens the ADD form.
|
||||
void openAddForm() {
|
||||
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
|
||||
_add_lat[0] = _add_lon[0] = _add_label[0] = '\0';
|
||||
_add_lat_mag = _add_lon_mag = 0.0f;
|
||||
_add_label[0] = '\0';
|
||||
_add_lat_neg = _add_lon_neg = false;
|
||||
_add_sel = 0;
|
||||
_add_editor.active = false;
|
||||
_mode = ADD;
|
||||
}
|
||||
|
||||
// Store the just-typed value into the focused ADD field.
|
||||
// Store the just-typed label (the only ADD field that still uses the keyboard).
|
||||
void applyAddField(const char* buf) {
|
||||
char* dst = (_kb_field == 0) ? _add_lat
|
||||
: (_kb_field == 1) ? _add_lon : _add_label;
|
||||
int cap = (_kb_field == 2) ? WAYPOINT_LABEL_LEN : (int)sizeof(_add_lat);
|
||||
strncpy(dst, buf, cap - 1);
|
||||
dst[cap - 1] = '\0';
|
||||
}
|
||||
|
||||
// Parse a numeric field: true only when the whole string is a number (so a
|
||||
// stray letter from the full keyboard can't silently read as 0).
|
||||
static bool parseNum(const char* s, double& out) {
|
||||
if (!s || !s[0]) return false;
|
||||
char* end = nullptr;
|
||||
out = strtod(s, &end);
|
||||
if (end == s) return false; // nothing numeric parsed
|
||||
while (*end == ' ') end++;
|
||||
return *end == '\0'; // no trailing non-numeric chars
|
||||
strncpy(_add_label, buf, sizeof(_add_label) - 1);
|
||||
_add_label[sizeof(_add_label) - 1] = '\0';
|
||||
}
|
||||
|
||||
// Validate the ADD form and save the waypoint.
|
||||
void commitAddForm() {
|
||||
double la, lo;
|
||||
if (!parseNum(_add_lat, la) || !parseNum(_add_lon, lo)) {
|
||||
_task->showAlert("Lat/Lon invalid", 1200); return;
|
||||
}
|
||||
double la = _add_lat_mag, lo = _add_lon_mag;
|
||||
if (_add_lat_neg) la = -la;
|
||||
if (_add_lon_neg) lo = -lo;
|
||||
if (la < -90.0 || la > 90.0 || lo < -180.0 || lo > 180.0) {
|
||||
@@ -134,12 +124,22 @@ class WaypointsView {
|
||||
int y = top + i * step;
|
||||
bool sel = (i == _add_sel);
|
||||
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
|
||||
char row[28];
|
||||
if (i == 0) snprintf(row, sizeof(row), "Lat: %c %s", _add_lat_neg ? 'S' : 'N', _add_lat[0] ? _add_lat : "--");
|
||||
else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %s", _add_lon_neg ? 'W' : 'E', _add_lon[0] ? _add_lon : "--");
|
||||
else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)");
|
||||
else snprintf(row, sizeof(row), "[Save]");
|
||||
display.setCursor(2, y); display.print(row);
|
||||
// While editing a magnitude, draw the row prefix + the digit editor so the
|
||||
// place under the cursor is highlighted; otherwise draw the plain value.
|
||||
if ((i == 0 || i == 1) && sel && _add_editor.active) {
|
||||
char prefix[10];
|
||||
if (i == 0) snprintf(prefix, sizeof(prefix), "Lat: %c ", _add_lat_neg ? 'S' : 'N');
|
||||
else snprintf(prefix, sizeof(prefix), "Lon: %c ", _add_lon_neg ? 'W' : 'E');
|
||||
display.setCursor(2, y); display.print(prefix);
|
||||
_add_editor.render(display, 2 + (int)strlen(prefix) * display.getCharWidth(), y);
|
||||
} else {
|
||||
char row[28];
|
||||
if (i == 0) snprintf(row, sizeof(row), "Lat: %c %.5f", _add_lat_neg ? 'S' : 'N', _add_lat_mag);
|
||||
else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %.5f", _add_lon_neg ? 'W' : 'E', _add_lon_mag);
|
||||
else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)");
|
||||
else snprintf(row, sizeof(row), "[Save]");
|
||||
display.setCursor(2, y); display.print(row);
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ public:
|
||||
if (_sel >= wpListCount()) _sel = wpListCount() - 1;
|
||||
if (_sel < 0) _sel = 0;
|
||||
}
|
||||
} else { // Send (share in a message)
|
||||
} else if (sel == 2) { // Send (share in a message)
|
||||
if (wi >= 0 && wi < _task->waypoints().count()) {
|
||||
const Waypoint& w = _task->waypoints().at(wi);
|
||||
double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0;
|
||||
@@ -283,13 +283,27 @@ public:
|
||||
else snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon);
|
||||
_task->shareToMessage(text); // hands off to the Messages screen
|
||||
}
|
||||
} else { // Set as target
|
||||
if (wi >= 0 && wi < _task->waypoints().count()) {
|
||||
const Waypoint& w = _task->waypoints().at(wi);
|
||||
_task->setTargetNow(0, nullptr, w.lat_1e6, w.lon_1e6, w.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ADD form: type lat/lon (magnitude) + hemisphere toggle + label.
|
||||
// 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
|
||||
// digit, LEFT/RIGHT move the cursor, Enter commits, Cancel aborts).
|
||||
if (_add_editor.active) {
|
||||
if (_add_editor.handleInput(c) == DigitEditor::DONE) {
|
||||
if (_add_sel == 0) _add_lat_mag = _add_editor.value;
|
||||
else _add_lon_mag = _add_editor.value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL) { _mode = OFF; return true; }
|
||||
if (c == KEY_UP) { _add_sel = (_add_sel > 0) ? _add_sel - 1 : 3; return true; }
|
||||
if (c == KEY_DOWN) { _add_sel = (_add_sel < 3) ? _add_sel + 1 : 0; return true; }
|
||||
@@ -299,8 +313,8 @@ public:
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
if (_add_sel == 0) { _kb_field = 0; openKb(_add_lat, 11); }
|
||||
else if (_add_sel == 1) { _kb_field = 1; openKb(_add_lon, 11); }
|
||||
if (_add_sel == 0) _add_editor.begin(_add_lat_mag, 0.0f, 90.0f, 2, 5);
|
||||
else if (_add_sel == 1) _add_editor.begin(_add_lon_mag, 0.0f, 180.0f, 3, 5);
|
||||
else if (_add_sel == 2) { _kb_field = 2; openKb(_add_label, WAYPOINT_LABEL_LEN - 1); }
|
||||
else { commitAddForm(); }
|
||||
return true;
|
||||
@@ -327,13 +341,14 @@ public:
|
||||
else _mode = NAV; // a waypoint / Trail-start row
|
||||
return true;
|
||||
}
|
||||
// Rename/Delete/Send apply to saved waypoints only — not Trail-start or Add.
|
||||
// Rename/Delete/Send/Locator apply to saved waypoints only — not Trail-start or Add.
|
||||
if (c == KEY_CONTEXT_MENU && !selIsStart() && _sel != n &&
|
||||
wpIndex() >= 0 && wpIndex() < _task->waypoints().count()) {
|
||||
_ctx.begin("Waypoint", 3);
|
||||
_ctx.begin("Waypoint", 4);
|
||||
_ctx.addItem("Rename");
|
||||
_ctx.addItem("Delete");
|
||||
_ctx.addItem("Send");
|
||||
_ctx.addItem("Set as target");
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -165,6 +165,127 @@ MINI_ICON(ICON_REPEATER, 6, // » double chevron — relaying/forwarding (repe
|
||||
packRow(".#..#."),
|
||||
packRow("#..#.."));
|
||||
|
||||
MINI_ICON(ICON_GPS, 5, // reticle — GPS fix status (boxed = fix, plain = searching)
|
||||
packRow(".###."),
|
||||
packRow("#...#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#...#"),
|
||||
packRow(".###."));
|
||||
|
||||
// Tools-menu glyphs (auto-reply bot, ringtone editor, diagnostics, system).
|
||||
MINI_ICON(ICON_BOT, 5, // robot head: antenna + eyes + grille (auto-reply bot)
|
||||
packRow("..#.."),
|
||||
packRow("#####"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#####"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#####"));
|
||||
MINI_ICON(ICON_NOTE, 5, // ♪ quaver — ringtone editor
|
||||
packRow("...##"),
|
||||
packRow("...##"),
|
||||
packRow("...#."),
|
||||
packRow("...#."),
|
||||
packRow("...#."),
|
||||
packRow("####."),
|
||||
packRow("####."));
|
||||
MINI_ICON(ICON_CHART, 5, // ascending bars — diagnostics / stats
|
||||
packRow("....#"),
|
||||
packRow("..#.#"),
|
||||
packRow("..#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#####"));
|
||||
MINI_ICON(ICON_GEAR, 7, // ⚙ cog with hub hole — system
|
||||
packRow("..#.#.."),
|
||||
packRow(".#####."),
|
||||
packRow("#######"),
|
||||
packRow(".##.##."),
|
||||
packRow("#######"),
|
||||
packRow(".#####."),
|
||||
packRow("..#.#.."));
|
||||
|
||||
// Home-carousel page glyphs — a uniform 5x5 set, deliberately smaller than the
|
||||
// menu/status icons above, used in place of the page-indicator dots. One per
|
||||
// HomePage; see UITask HomeScreen::pageIcon().
|
||||
MINI_ICON(ICON_PG_CLOCK, 5, // clock face + hands
|
||||
packRow(".###."),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.###"),
|
||||
packRow("#...#"),
|
||||
packRow(".###."));
|
||||
MINI_ICON(ICON_PG_STAR, 5, // favourites
|
||||
packRow("..#.."),
|
||||
packRow("#####"),
|
||||
packRow(".###."),
|
||||
packRow("##.##"),
|
||||
packRow("#...#"));
|
||||
MINI_ICON(ICON_PG_RECENT, 5, // stacked lines — recent list
|
||||
packRow("#####"),
|
||||
packRow("....."),
|
||||
packRow("#####"),
|
||||
packRow("....."),
|
||||
packRow("#####"));
|
||||
MINI_ICON(ICON_PG_RADIO, 5, // antenna tower — radio params
|
||||
packRow("..#.."),
|
||||
packRow(".###."),
|
||||
packRow("..#.."),
|
||||
packRow(".#.#."),
|
||||
packRow("#...#"));
|
||||
MINI_ICON(ICON_PG_BT, 5, // bluetooth (compact)
|
||||
packRow("..#.."),
|
||||
packRow("#.##."),
|
||||
packRow(".###."),
|
||||
packRow("#.##."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_PG_ADVERT, 5, // mast + radiating waves — advert
|
||||
packRow("#...#"),
|
||||
packRow(".#.#."),
|
||||
packRow("..#.."),
|
||||
packRow("..#.."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_PG_GPS, 5, // location pin — GPS
|
||||
packRow(".###."),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow(".#.#."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_PG_SENSORS, 5, // thermometer/gauge — sensors
|
||||
packRow("..#.."),
|
||||
packRow(".#.#."),
|
||||
packRow(".#.#."),
|
||||
packRow(".###."),
|
||||
packRow(".###."));
|
||||
MINI_ICON(ICON_PG_SETTINGS, 5, // small cog — settings
|
||||
packRow(".#.#."),
|
||||
packRow("#####"),
|
||||
packRow("##.##"),
|
||||
packRow("#####"),
|
||||
packRow(".#.#."));
|
||||
MINI_ICON(ICON_PG_MAP, 5, // folded map — map page
|
||||
packRow("#####"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#####"));
|
||||
MINI_ICON(ICON_PG_TOOLS, 5, // wrench (open jaw + handle) — tools
|
||||
packRow(".#.#."),
|
||||
packRow(".###."),
|
||||
packRow("..#.."),
|
||||
packRow("..#.."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_PG_MSG, 5, // speech bubble — quick messages
|
||||
packRow("#####"),
|
||||
packRow("#...#"),
|
||||
packRow("#...#"),
|
||||
packRow("#####"),
|
||||
packRow(".#..."));
|
||||
MINI_ICON(ICON_PG_POWER, 5, // power symbol — shutdown
|
||||
packRow("..#.."),
|
||||
packRow("#.#.#"),
|
||||
packRow("#...#"),
|
||||
packRow("#...#"),
|
||||
packRow(".###."));
|
||||
|
||||
// Trail-map markers — centred on a point (see miniIconDrawCentered) rather
|
||||
// than anchored to a text line.
|
||||
MINI_ICON(ICON_MAP_DOT, 3, // ● filled trail point
|
||||
@@ -193,6 +314,12 @@ MINI_ICON(ICON_MAP_CURRENT, 5, // ✕ live position / last trail point
|
||||
packRow("..#.."),
|
||||
packRow(".#.#."),
|
||||
packRow("#...#"));
|
||||
MINI_ICON(ICON_MAP_CONTACT, 5, // ◆ filled diamond — a live-tracked contact ([LOC] share)
|
||||
packRow("..#.."),
|
||||
packRow(".###."),
|
||||
packRow("#####"),
|
||||
packRow(".###."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_MAP_NORTH, 5, // "N" with a peaked roof — compass north marker
|
||||
packRow("..#.."),
|
||||
packRow(".###."),
|
||||
@@ -201,6 +328,18 @@ MINI_ICON(ICON_MAP_NORTH, 5, // "N" with a peaked roof — compass north ma
|
||||
packRow("#.#.#"),
|
||||
packRow("#..##"),
|
||||
packRow("#...#"));
|
||||
MINI_ICON(ICON_MAP_ARROW, 5, // → distance-to-nearest-tracked-contact indicator
|
||||
packRow("..#.."),
|
||||
packRow("...#."),
|
||||
packRow("#####"),
|
||||
packRow("...#."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_MAP_TARGET, 5, // ⚑ flag on a pole — the active Locator/Nav target
|
||||
packRow("####."),
|
||||
packRow("#..#."),
|
||||
packRow("####."),
|
||||
packRow("#...."),
|
||||
packRow("#...."));
|
||||
|
||||
// Keyboard special-key glyphs.
|
||||
MINI_ICON(ICON_SHIFT, 7, // ⇧ caps
|
||||
|
||||
@@ -43,10 +43,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
_display->turnOn();
|
||||
}
|
||||
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
// strip off the commit-hash suffix build.sh always appends as the LAST
|
||||
// dash-segment, e.g: v1.2.3-abcdef -> v1.2.3, v1.21-rc1-abcdef -> v1.21-rc1
|
||||
// (must use the last dash, not the first, since a tag itself may contain one)
|
||||
char *version = strdup(FIRMWARE_VERSION);
|
||||
char *dash = strchr(version, '-');
|
||||
char *dash = strrchr(version, '-');
|
||||
if (dash) {
|
||||
*dash = 0;
|
||||
}
|
||||
|
||||
@@ -39,10 +39,11 @@ class SplashScreen : public UIScreen {
|
||||
|
||||
public:
|
||||
SplashScreen(UITask* task) : _task(task) {
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
// strip off the commit-hash suffix build.sh always appends as the LAST
|
||||
// dash-segment, e.g: v1.2.3-abcdef -> v1.2.3, v1.21-rc1-abcdef -> v1.21-rc1
|
||||
// (must use the last dash, not the first, since a tag itself may contain one)
|
||||
const char *ver = FIRMWARE_VERSION;
|
||||
const char *dash = strchr(ver, '-');
|
||||
const char *dash = strrchr(ver, '-');
|
||||
|
||||
int len = dash ? dash - ver : strlen(ver);
|
||||
if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1;
|
||||
|
||||
31
release-notes.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## MeshCore Solo Companion Firmware v1.21
|
||||
|
||||
### What's new
|
||||
|
||||
- **Live Location Sharing** — broadcast your position over the mesh as `[LOC]` messages, **movement-gated**, to a channel or a single contact. Others who share theirs show up as pins on the map with live **distance/bearing in Nearby Nodes**, and a **status-bar indicator** appears while your own share is active. `[LOC]` is parsed in DMs, channel messages **and room messages**; DM shares name the sender.
|
||||
- **Locator** (geofence) — arm a geofence around a **target** — a saved waypoint *or* a person (their live `[LOC]` or last-known position) — and get an alert when you **arrive/leave** or they get **near/far**, with an optional **homing beeper** that ticks faster the closer you get (and overrides a muted buzzer). Arm it from **Tools › Locator** or straight from **Nearby Nodes** / **Waypoints**; pick the target from a list (favourites first, then any contact), and clear it via a **"None"** entry. The active target is drawn as a **flag** on the map.
|
||||
- **One active target across Locator / Navigate / Map** — a single **"Set as target"** action everywhere, backed by a shared resolver that prefers a live `[LOC]` share over the last-advertised GPS, so the three features always agree on where you're headed.
|
||||
- **Follow live contacts** — **Navigate** to a contact who is live-sharing and the view **follows them as they move**, adding an **ETA** line. **Quick-share** your own position straight from the Map.
|
||||
- **Map & status-bar upgrades** — the home mini-map gains a **north marker** and a **scale tick**; the status line shows the tracked-node count and, with a fix, an **arrow + distance to the nearest tracked contact** (e.g. `Track:3 →120m`); a **GPS fix icon** sits in the top status bar (boxed once a fix is valid, plain while searching), shown only on GPS boards while **GPS** is enabled in Settings.
|
||||
- **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.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Critical — low-heap hang and contact loss on RAM-tight builds.** With `MAX_CONTACTS=350` and `OFFLINE_QUEUE_SIZE=256` the device ran with very little free heap; an allocation in the input/menu path could then fail and **hang the UI** (notably when entering Diagnostics), and a crash or reset mid-save could **wipe all contacts**. Fixed by two independent changes:
|
||||
- **Right-sized message-history rings** — the on-device scrollback rings were halved (96→48 channel, 64→32 DM), recovering **~14 KB of free heap** (measured 3 → 17 KB). History is RAM-only, so the only cost is shorter on-device scrollback.
|
||||
- **Atomic persistence** — contacts, channels and prefs are now written to a temp file and **atomically renamed** into place; an interrupted save (crash, reset, full flash) leaves the previous good file intact instead of truncating it.
|
||||
- **USB host stall** — `Serial.write()` is bounded so a stalled USB host can no longer hang the device indefinitely.
|
||||
- **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.
|
||||
- Null-guarded the Locator target picker and clamped the loc-share channel index on load.
|
||||
|
||||
### Under the hood
|
||||
|
||||
- **Streaming trail simplification** — GPS points are simplified as they're recorded via a fixed-corridor (Reumann–Witkam) pass: straight runs collapse to their two endpoints while curves stay bounded to within the **Min dist** tolerance of the real track, so the 512-point buffer covers a far longer route than a flat point budget would suggest. The buffer is kept at 512 points to bound the static RAM footprint on nRF52.
|
||||
- A UITask-decoupled active-target resolver (`resolvePersonPos` / `activeTargetPos`) replaces the duplicated target-resolution logic that lived separately in the Locator, Navigate and Map entry points.
|
||||
- `-Os` size optimisation on the e-ink and GAT562 30S solo envs to keep them within the flash budget.
|
||||
|
||||
> **Upgrade note:** if a previous version lost your contacts after a hang, you'll need to re-add them once — the atomic-save protection applies from this release onward.
|
||||
@@ -1,4 +1,9 @@
|
||||
#include "ArduinoSerialInterface.h"
|
||||
#include "StreamUtils.h"
|
||||
|
||||
// Worst-case time we'll let writeFrame() block waiting for the host to drain
|
||||
// the link, before giving up on the rest of the frame. See StreamUtils.h.
|
||||
#define SERIAL_WRITE_TIMEOUT_MS 300
|
||||
|
||||
#define RECV_STATE_IDLE 0
|
||||
#define RECV_STATE_HDR_FOUND 1
|
||||
@@ -32,8 +37,11 @@ size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
|
||||
hdr[1] = (len & 0xFF); // LSB
|
||||
hdr[2] = (len >> 8); // MSB
|
||||
|
||||
_serial->write(hdr, 3);
|
||||
return _serial->write(src, len);
|
||||
// Single deadline shared across header + payload, so a stalled host costs
|
||||
// at most SERIAL_WRITE_TIMEOUT_MS total, not that much per write() call.
|
||||
uint32_t deadline = millis() + SERIAL_WRITE_TIMEOUT_MS;
|
||||
if (streamWriteUntil(*_serial, hdr, 3, deadline) < 3) return 0;
|
||||
return streamWriteUntil(*_serial, src, len, deadline);
|
||||
}
|
||||
|
||||
size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
|
||||
|
||||
40
src/helpers/StreamUtils.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
// A USB-CDC serial monitor or companion app can leave the port open (DTR
|
||||
// asserted) without ever draining it. Stream::write() then blocks inside the
|
||||
// platform's "TX FIFO full" wait loop for as long as that holds true — which
|
||||
// is forever in practice, since the main loop()'s hardware watchdog is only
|
||||
// re-armed once per iteration. streamWriteUntil() caps that wait instead of
|
||||
// trusting the platform to give up.
|
||||
//
|
||||
// Under normal conditions (host actually reading) this is a no-op compared to
|
||||
// a plain write(): writes are never asked for more than availableForWrite()
|
||||
// already reports free, so they return immediately without entering the
|
||||
// platform's internal wait/yield loop at all. Only a genuinely stalled host
|
||||
// (zero throughput) hits the deadline; a slow-but-alive one just takes longer,
|
||||
// same as a plain write() would.
|
||||
#include <Arduino.h>
|
||||
|
||||
// Returns the number of bytes actually written; less than `len` only when the
|
||||
// host stopped draining the link before `deadline_ms` (an absolute millis()
|
||||
// value — share one deadline across multiple calls that make up one logical
|
||||
// message, so a stall costs a fixed total budget, not one per call).
|
||||
static inline size_t streamWriteUntil(Stream& s, const uint8_t* buf, size_t len, uint32_t deadline_ms) {
|
||||
size_t sent = 0;
|
||||
while (sent < len) {
|
||||
int avail = s.availableForWrite();
|
||||
if (avail <= 0) {
|
||||
if ((int32_t)(millis() - deadline_ms) >= 0) break; // host stalled — give up
|
||||
yield(); // let the USB/BLE background task run so it can drain the FIFO
|
||||
continue;
|
||||
}
|
||||
size_t chunk = (size_t)avail < (len - sent) ? (size_t)avail : (len - sent);
|
||||
size_t w = s.write(buf + sent, chunk);
|
||||
if (w == 0) {
|
||||
if ((int32_t)(millis() - deadline_ms) >= 0) break;
|
||||
yield();
|
||||
continue;
|
||||
}
|
||||
sent += w;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -116,6 +116,11 @@ lib_deps =
|
||||
extends = GAT562_30S_Mesh_Kit
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
board_upload.maximum_size = 712704
|
||||
; Optimise for size, not speed: the nRF52 Arduino core defaults to -Ofast
|
||||
; (≈ -O3 + -ffast-math), which bloats flash by ~200 KB on this feature-rich
|
||||
; solo build. -Os keeps every feature, fits comfortably, and is actually safer
|
||||
; for float (no fast-math reassociation). Speed is irrelevant for the UI/mesh.
|
||||
build_unflags = -Ofast
|
||||
build_flags =
|
||||
${GAT562_30S_Mesh_Kit.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
@@ -130,6 +135,7 @@ build_flags =
|
||||
-D UI_HAS_JOYSTICK_UPDOWN=1
|
||||
-D ADVERT_NAME='"@@MAC"'
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-Os
|
||||
; QSPIFLASH disabled: QSPI IO0/IO2 pins (P0.30/P0.28) conflict with joystick LEFT/UP
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
@@ -148,6 +154,11 @@ lib_deps =
|
||||
extends = GAT562_30S_Mesh_Kit
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
board_upload.maximum_size = 712704
|
||||
; Optimise for size, not speed: the nRF52 Arduino core defaults to -Ofast
|
||||
; (≈ -O3 + -ffast-math), which bloats flash by ~200 KB on this feature-rich
|
||||
; solo build. -Os keeps every feature, fits comfortably, and is actually safer
|
||||
; for float (no fast-math reassociation). Speed is irrelevant for the UI/mesh.
|
||||
build_unflags = -Ofast
|
||||
build_flags =
|
||||
${GAT562_30S_Mesh_Kit.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
@@ -164,6 +175,7 @@ build_flags =
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-D DUAL_SERIAL=1
|
||||
-D ENABLE_SCREENSHOT
|
||||
-Os
|
||||
; QSPIFLASH disabled: QSPI IO0/IO2 pins (P0.30/P0.28) conflict with joystick LEFT/UP
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
|
||||
@@ -72,9 +72,15 @@ extra_scripts = post:create-uf2.py
|
||||
; Dual BLE+USB with added Solo
|
||||
[env:WioTrackerL1Eink_companion_solo_dual]
|
||||
extends = WioTrackerL1Eink
|
||||
; Optimise for size, not speed: the nRF52 Arduino core defaults to -Ofast
|
||||
; (≈ -O3 + -ffast-math), which bloats flash by ~200 KB on this feature-rich
|
||||
; solo build. -Os keeps every feature, fits comfortably, and is actually safer
|
||||
; for float (no fast-math reassociation). Speed is irrelevant for the UI/mesh.
|
||||
build_unflags = -Ofast
|
||||
build_flags = ${WioTrackerL1Eink.build_flags}
|
||||
-D DUAL_SERIAL=1
|
||||
-D ENABLE_SCREENSHOT
|
||||
-Os
|
||||
extra_scripts = post:create-uf2.py
|
||||
|
||||
; Dual BLE+USB with screenshot support (development/debug)
|
||||
|
||||
@@ -152,9 +152,15 @@ extra_scripts = post:create-uf2.py
|
||||
|
||||
[env:WioTrackerL1_companion_solo_dual]
|
||||
extends = WioTrackerL1CompanionDual
|
||||
; Optimise for size, not speed: the nRF52 Arduino core defaults to -Ofast
|
||||
; (≈ -O3 + -ffast-math), which bloats flash by ~200 KB on this feature-rich
|
||||
; solo build. -Os keeps every feature, fits comfortably, and is actually safer
|
||||
; for float (no fast-math reassociation). Speed is irrelevant for the UI/mesh.
|
||||
build_unflags = -Ofast
|
||||
build_flags = ${WioTrackerL1CompanionDual.build_flags}
|
||||
-D UI_HAS_JOYSTICK_UPDOWN=1
|
||||
-D ENABLE_SCREENSHOT
|
||||
-Os
|
||||
extra_scripts = post:create-uf2.py
|
||||
|
||||
[env:WioTrackerL1_companion_dual_dev]
|
||||
|
||||