diff --git a/README.md b/README.md index 1572b7fa..80492466 100644 --- a/README.md +++ b/README.md @@ -84,18 +84,44 @@ Hold Enter on a message to open a context menu. From the list or fullscreen view On channel or contact list entries, the context menu also lets you change per-channel notification settings (mute, follow global, or force-on), per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read. +**Mark all read** — Hold Enter on the DM / Channels / Rooms mode-select screen to clear all unread counters for the highlighted category at once. + +### Favourites Dial + +A dedicated home page showing a 2×3 grid of pinned contacts. Navigate tiles with the joystick; press Enter on a filled tile to open that contact's DM directly. + +``` +╔══════════════════════════════╗ +║ Favourites ║ +╠══════════════════════════════╣ +║ ┌──────┐ ┌──────┐ ┌──────┐ ║ +║ │Alice │ │Bob 3│ │ + │ ║ +║ └──────┘ └──────┘ └──────┘ ║ +║ ┌──────┐ ┌──────┐ ┌──────┐ ║ +║ │Carol │ │ + │ │ + │ ║ +║ └──────┘ └──────┘ └──────┘ ║ +╚══════════════════════════════╝ +``` + +- Unread badge shown on filled tiles +- Enter on an empty tile (`+`) opens a contact picker (upstream-favourited contacts first, then recent DMs) +- **Pin / unpin** — open a DM, then Hold Enter › options menu › "Pin to dial" to choose a slot; selecting a contact already pinned elsewhere moves it to the new slot +- LEFT/RIGHT in the Home Pages settings reorders the Favourites page in the navigation sequence + ### Settings Screen -All settings are saved to flash and restored on next boot. +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. - **Display** - Brightness - Auto-off timeout - Battery display mode (icon, %, V) - Clock seconds (show/hide — hiding reduces display refresh from 1 s to 60 s) + - Clock format (12 h / 24 h) - Font — **Default** (5×7 Adafruit, ASCII + transliteration) or **Lemon** (native Unicode, pixel-accurate wrap) - Display rotation *(e-ink only)* — 0 ° / 90 ° / 180 ° / 270 °; applied immediately - Joystick rotation *(e-ink only)* — rotates the joystick input mapping independently of the display rotation; useful when mounting the device in a custom enclosure + - Full refresh interval *(e-ink only)* — how many partial refreshes before a cleansing full refresh (off / 5 / 10 / 20 / 30); reduces ghosting on long sessions - **Sound** - Buzzer: On / Off / **Auto** — Auto mode silences the device while connected via Bluetooth, and re-enables sound when the connection drops - Volume (1–5; preview tone plays on each change) @@ -108,8 +134,6 @@ All settings are saved to flash and restored on next boot. - Timezone (UTC offset in hours) - Low battery shutdown threshold - Auto-lock — automatically locks the device when the display turns off -- **GPS** - - Position broadcast interval - **Contacts** - Show all DMs or favourites only - Show all room servers or favourites only @@ -169,22 +193,47 @@ Press **Enter** from the Nearby screen to send a live `NODE_DISCOVER_REQ` ping. Navigate with **UP/DOWN**. Press **Enter** on a node to open a full-screen detail view showing the public key, RSSI, SNR, remote SNR and whether the node is already in your contacts. -``` -╔══════════════════════════════╗ -║▓▓▓▓▓▓▓▓▓▓▓▓▓ Rptr-A ▓▓▓▓▓▓▓ ║ -╠══════════════════════════════╣ -║ Key: ABCdef12XYZ... ║ -║ RSSI: -79 dBm ║ -║ SNR: 9 dB ║ -║ Rem: 6 dB ║ -║ Status: known ║ -╚══════════════════════════════╝ -``` - Hold **Enter** from the discovery list to rescan. Press **Cancel** or **Back** to return. ### Tools Screen +#### GPS Trail + +Records your route in a RAM ring buffer (up to 512 points). Sampling runs in the background whenever tracking is active — a blinking **G** indicator appears in the status bar. The trail survives display auto-off but is lost on reboot unless saved to flash first. + +Three views cycle with **LEFT / RIGHT**: + +| View | Content | +|------|---------| +| **Summary** | Total 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 with open/filled dots; north arrow | +| **List** | Per-point rows showing local time (HH:MM) and delta distance from the previous point; segment-start rows show `start` | + +**Hold Enter** opens the action menu: + +| Item | Action | +|------|--------| +| Min dist | Cycle min-distance gate: 5 m / 10 m / 25 m / 100 m (LEFT/RIGHT while focused) | +| Units | Cycle speed/pace units: km/h / mph / min/km / min/mi (LEFT/RIGHT while focused) | +| Start / Stop tracking | Begin or end a recording session | +| Save trail | Write the current RAM ring to flash (`/trail`) | +| Load trail | Restore the saved flash trail into RAM | +| Export GPX | Stream the live RAM trail as GPX 1.1 over USB Serial | +| Export saved | Stream the saved flash trail as GPX 1.1 over USB Serial without loading it into RAM | +| Reset trail | Clear the RAM ring and elapsed time | + +#### Downloading GPX to your computer + +1. Connect the device to your computer with a USB cable. +2. Open a serial terminal at **115200 baud** on the device's serial port: + - **macOS / Linux** — `screen /dev/tty.usbmodem* 115200` or `cat /dev/tty.usbmodem*` (replace with your port, e.g. `/dev/ttyACM0` on Linux); to save directly to a file use `cat /dev/tty.usbmodem* > track.gpx` and stop with Ctrl-C after the dump completes + - **Windows** — open PuTTY (Connection type: Serial, Speed: 115200), or use Arduino IDE › Tools › Serial Monitor (set line ending to "No line ending") +3. On the device, navigate to **Tools › Trail**, then **Hold Enter** and select **Export GPX** (live ring) or **Export saved** (flash slot). +4. The device streams a GPX 1.1 XML document to the serial port. Copy the output starting from `` and save it as a `.gpx` file. +5. Import the file into any GPX-compatible application — OsmAnd, Garmin BaseCamp, GPX Studio, Google Earth, etc. + +> **Note:** If the companion app is connected over **BLE**, the export is safe — the app ignores USB input while BLE is active and the device alert will read *"GPX N B (USB)"*. If you are using the app over **USB**, disconnect from the app first before exporting; the raw XML stream will otherwise disrupt the app's serial framing. The alert *"GPX N B - disc. app"* indicates no BLE link was detected and serves as a reminder to reconnect the app afterwards. + #### Auto-Advert Periodically broadcasts a 0-hop advert with your GPS position so nearby nodes can track your location automatically. Configurable interval: off / 1 min / 2 min / 5 min / 10 min / 30 min / 1 h. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 77ccc686..ec4de9b1 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -327,6 +327,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (_prefs.home_pages_mask != 0) { _prefs.home_pages_mask |= NodePrefs::HP_FAVOURITES; } + // 0xC0DE0003 → 0xC0DE0004: trail_units_idx added after trail_min_delta_idx. + // Saves from 0xC0DE0003 have the sentinel bytes where trail_units_idx sits, + // so rd() picks up 0x03 (low byte of the old sentinel) — reset to default 0. + _prefs.trail_units_idx = 0; } file.close(); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index f3521877..e0fef75a 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -109,7 +109,7 @@ struct NodePrefs { // persisted to file // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0003; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0004; // 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 diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index a3bbf6fd..4e6c310a 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -69,6 +69,10 @@ class SettingsScreen : public UIScreen { int _scroll; int _visible; // items fitting on screen; updated each render, used by handleInput 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; #if AUTO_OFF_MILLIS > 0 static const uint16_t AUTO_OFF_OPTS[5]; @@ -140,6 +144,38 @@ 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; + 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; + } + } + } + bool isHomePage(int item) const { return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || @@ -299,25 +335,20 @@ class SettingsScreen : public UIScreen { return item - MSG_SLOT_0; } - int nextSelectable(int from) const { - for (int i = from + 1; i < (int)Count; i++) - if (!isSection(i)) return i; - return from; - } - - int prevSelectable(int from) const { - for (int i = from - 1; i >= 0; i--) - if (!isSection(i)) return i; - return from; - } - void renderItem(DisplayDriver& display, int item, int y) { 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.fillRect(0, y, display.width(), 1); - display.setCursor(2, y + 2); + display.drawSelectionRow(0, y - 1, display.width(), display.lineStep(), sel); + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(display.getCharWidth() + 2, y); + display.print(collapsed ? "+" : "-"); + display.print(" "); display.print(sectionName(item)); return; } @@ -474,10 +505,18 @@ class SettingsScreen : public UIScreen { public: SettingsScreen(UITask* task) - : _task(task), _selected(AUTO_LOCK), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {} + : _task(task), _selected(SECTION_DISPLAY), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) { + buildVis(); + } - void markClean() { _dirty = false; } + void markClean() { + _dirty = false; + _collapsed = 0x7F; + _selected = SECTION_DISPLAY; + buildVis(); + _scroll = 0; + } int render(DisplayDriver& display) override { display.setTextSize(1); @@ -494,8 +533,8 @@ public: display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH()); - for (int i = 0; i < _visible && (_scroll + i) < SettingItem::Count; i++) { - renderItem(display, _scroll + i, start_y + i * item_h); + for (int i = 0; i < _visible && (_scroll + i) < _vis_count; i++) { + renderItem(display, _vis[_scroll + i], start_y + i * item_h); } // scroll indicators @@ -504,7 +543,7 @@ public: display.setCursor(display.width() - display.getCharWidth(), start_y); display.print("^"); } - if (_scroll + _visible < SettingItem::Count) { + if (_scroll + _visible < _vis_count) { display.setColor(DisplayDriver::LIGHT); display.setCursor(display.width() - display.getCharWidth(), start_y + (_visible - 1) * item_h); display.print("v"); @@ -533,18 +572,20 @@ public: } if (c == KEY_UP) { - int prev = prevSelectable(_selected); - if (prev != _selected) { - _selected = prev; - if (_selected < _scroll) _scroll = _selected; + int vi = visIndexOf(_selected); + if (vi > 0) { + vi--; + _selected = _vis[vi]; + if (vi < _scroll) _scroll = vi; } return true; } if (c == KEY_DOWN) { - int next = nextSelectable(_selected); - if (next != _selected) { - _selected = next; - if (_selected >= _scroll + _visible) _scroll = _selected - _visible + 1; + int vi = visIndexOf(_selected); + if (vi + 1 < _vis_count) { + vi++; + _selected = _vis[vi]; + if (vi >= _scroll + _visible) _scroll = vi - _visible + 1; } return true; } @@ -558,6 +599,16 @@ public: bool left = (c == KEY_LEFT || c == KEY_PREV); 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(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5e524c72..21bb1f0c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -180,7 +180,7 @@ class HomeScreen : public UIScreen { } } if (_pin_count == 0) { - _task->showAlert("No contacts available", 1000); + _task->showAlert("No fav contacts", 1000); _pin_target_slot = -1; return; }