mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
feat(admin): typed Radio/Routing field editors; pre-release audit fixes
Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string field (free-text keyboard) into 4 independent, type-appropriate rows -- Frequency (same digit-cursor DigitEditor Settings/Repeater use locally), Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping), plus TX power and the 3 Routing numeric fields as number steppers and Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per keystroke); only Enter sends one combined `set`, Cancel sends nothing. Full pre-release audit of all 16 commits since v1.22 (5 parallel focus areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels, UITask+NodePrefs schema) turned up and fixed: - Admin: fetched FK_NUMBER values weren't clamped to the field's range, so a value already out-of-range could get stuck unreachable; fixed commit-time float format noise (%.6f -> %.3f); Cancel/failed-login always returned to the Nodes picker even when Admin was opened directly from a node's Hold-Enter action -- now returns to wherever it was actually opened from (AdminScreen::_from_picker). - Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap, so cycling to the 2nd/3rd candidate always came out lowercase -- fixed by caching the cycle's caps state (t9_caps). - Messages: history is numbered newest-first, so a message arriving while scrolled up to an older one silently relabeled the view onto a different message -- selection now shifts with the insert. - Channels: onChannelRemoved() didn't clear ch_notif_override/ ch_notif_muted/ch_fav_bitmask despite its own contract comment requiring it (now far more reachable via the on-device Delete); an all-zero hex secret silently self-deleted the channel it was just saved into (collides with the empty-slot sentinel) -- rejected. - Room login: isRoomLoggedIn() indexed the login-tracking ring directly instead of via its head offset, silently wrong once the ring wraps (8+ rooms/session) -- the new on-device Logout depends on this being right. - Font/display: removed the now fully-inert Settings > Display > Font toggle and the dead LemonFont.h (retired by the earlier misc-fixed font unification, zero remaining includes); fixed a copy-pasted "5x7" comment (font is 6x9), two meaningless dead ternaries, and an OLED/e-ink inconsistency in the undefined-glyph fallback box offset. - AdminField's `kind`/bounds fields no longer rely on default member initializers inside aggregate-init: this toolchain's actual nRF52 build (unlike env:native) has no explicit -std= override, so it predates C++14's aggregate-with-default-member-initializer rule. Given an explicit constructor instead -- portable regardless of standard, all existing field-table literals unchanged. Docs updated to match: tools_screen.md (Diagnostics as a 3-tab carousel, was documented as one flat screen), message_screen.md (chat bubbles, newest-at-bottom, cursor mode, secret validation), settings_screen.md (dropped the dead Font row), solo_ui_framework.md (header menu_hint signatures, icon priority-drop, KeyboardWidget's T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section covering all of the above plus the other 15 commits since v1.22. Build-verified on WioTrackerL1_companion_solo_dual. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -95,8 +95,14 @@ pixel sizes** — derive everything from these:
|
|||||||
|
|
||||||
Drawing helpers (all clip/measure for you):
|
Drawing helpers (all clip/measure for you):
|
||||||
|
|
||||||
- `drawCenteredHeader(title)` — plain centered title + separator.
|
- `drawCenteredHeader(title, menu_hint=false, menu_open=false)` — plain centered
|
||||||
- `drawInvertedHeader(label)` — filled title bar (used by detail views).
|
title + separator.
|
||||||
|
- `drawInvertedHeader(label, menu_hint=false, menu_open=false)` — filled title
|
||||||
|
bar (used by detail views).
|
||||||
|
- Both take an optional `menu_hint`: pass `true` on a screen with a Hold-Enter
|
||||||
|
context menu to reserve a `≡` glyph (`menuHintWidth()`/`drawContextMenuHint()`)
|
||||||
|
in the header, so the menu is discoverable without already knowing the
|
||||||
|
shortcut; `menu_open` highlights it while the menu is actually up.
|
||||||
- `drawSelectionRow(x, y, w, h, sel)` — the highlight bar behind a list row.
|
- `drawSelectionRow(x, y, w, h, sel)` — the highlight bar behind a list row.
|
||||||
- `drawTextEllipsized(x, y, max_w, str)` — truncates with `…`; **use this for
|
- `drawTextEllipsized(x, y, max_w, str)` — truncates with `…`; **use this for
|
||||||
any user string** (names, labels) so long/UTF-8 text can't overrun.
|
any user string** (names, labels) so long/UTF-8 text can't overrun.
|
||||||
@@ -167,6 +173,19 @@ sensor tokens) via `addPlaceholder()` / `clearPlaceholders()`; the shared
|
|||||||
`kbAddSensorPlaceholders()` (`ui-new/SensorPlaceholders.h`) adds only the tokens
|
`kbAddSensorPlaceholders()` (`ui-new/SensorPlaceholders.h`) adds only the tokens
|
||||||
the board's sensors actually provide. Expand them with `expandMsg()` at send time.
|
the board's sensors actually provide. Expand them with `expandMsg()` at send time.
|
||||||
|
|
||||||
|
Two layouts share every grid: **ABC** (one key per letter) and **T9**
|
||||||
|
(phone-keypad multi-tap — repeated Enter within `KB_T9_TIMEOUT_MS` cycles a
|
||||||
|
cell's letter group, then its digit). `NodePrefs::keyboard_alt_alphabet` adds a
|
||||||
|
non-Latin alphabet's own page to the cycle (Latin → alphabet → Symbols →
|
||||||
|
Latin); each alphabet defines both its ABC grid (`KB_*_CHARS`) and T9 group
|
||||||
|
table (`KB_T9_GROUPS_*`) so the two layouts always offer the same letters.
|
||||||
|
Shift is one-shot by default (capitalises the next letter, including whichever
|
||||||
|
candidate a T9 multi-tap cycle settles on) or Hold-Enter to toggle caps-lock;
|
||||||
|
Hold-Clear erases the whole field. Hold-Enter elsewhere in the field enters
|
||||||
|
**cursor mode** (LEFT/RIGHT move the insertion point, UP/DOWN jump to
|
||||||
|
start/end) so edits/inserts can target any point in the typed text, not just
|
||||||
|
the end.
|
||||||
|
|
||||||
`FullscreenMsgView::wrapLines()` is a standalone pixel-accurate word-wrapper
|
`FullscreenMsgView::wrapLines()` is a standalone pixel-accurate word-wrapper
|
||||||
(O(n), variable-width-font aware) reusable by any multi-line layout; it writes
|
(O(n), variable-width-font aware) reusable by any multi-line layout; it writes
|
||||||
into the shared `s_wrap_trans` / `s_wrap_lines` scratch (single-threaded render,
|
into the shared `s_wrap_trans` / `s_wrap_lines` scratch (single-threaded render,
|
||||||
@@ -223,6 +242,17 @@ with a `blinkOn()` cadence for "leave it on and forget" broadcasts (auto-advert,
|
|||||||
Live Share, trail, repeater) — follow that pattern when adding an indicator:
|
Live Share, trail, repeater) — follow that pattern when adding an indicator:
|
||||||
always shown on e-ink, blinking on OLED.
|
always shown on e-ink, blinking on OLED.
|
||||||
|
|
||||||
|
Icons are drawn from a fixed priority-ordered table (`HomeScreen::renderBatteryIndicator()`,
|
||||||
|
`UITask.cpp`); once the row runs out of horizontal space the loop just stops,
|
||||||
|
so the lowest-priority icons silently drop first rather than the whole bar
|
||||||
|
crushing the node name. A blinking icon still reserves its width on the
|
||||||
|
off-phase of its blink, so the row's layout can't visibly shift width as icons
|
||||||
|
blink in and out.
|
||||||
|
|
||||||
|
Screens with a Hold-Enter context menu (Nodes, Bot, Admin, Diagnostics, …) pass
|
||||||
|
`menu_hint=true` to their header call (see §2) so a `≡` glyph advertises the
|
||||||
|
menu; `KEY_CONTEXT_MENU` (Hold-Enter) opens it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Input
|
## 7. Input
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ The Messages screen is split into three modes — **DMs**, **Channels**, and **R
|
|||||||
| :-----------------------: | :-----------------------: |
|
| :-----------------------: | :-----------------------: |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
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:
|
Press **Enter** on a contact or channel to open its history, then press **Enter** again (or select the **[+ send]** button, anchored at the right edge of the history) to compose a message. Choose between:
|
||||||
|
|
||||||
- **Custom message** — opens the on-screen keyboard
|
- **Custom message** — opens the on-screen keyboard
|
||||||
- **Q1–Q10** — quick reply templates editable in Settings › Messages
|
- **Q1–Q10** — quick reply templates editable in Settings › Messages
|
||||||
|
|
||||||
|
While typing, **Hold Enter** enters cursor mode (LEFT/RIGHT move the insertion point, UP/DOWN jump to start/end, Enter/Cancel exit) so you can edit or insert in the middle of what you've typed instead of only at the end — see the on-screen keyboard section of the [UI framework guide](../../design/solo_ui_framework.md) for the full key set (Shift, T9 multi-tap, alternate alphabets).
|
||||||
|
|
||||||
The keyboard supports placeholders that insert live data at send time:
|
The keyboard supports placeholders that insert live data at send time:
|
||||||
|
|
||||||
| Placeholder | Value | Availability |
|
| Placeholder | Value | Availability |
|
||||||
@@ -60,7 +62,7 @@ Posting to a **room server** requires a login handshake first, so the device can
|
|||||||
| :-----------------------: | :-----------------------: |
|
| :-----------------------: | :-----------------------: |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
Each entry in the history list shows the sender name and a compact age indicator (`3m`, `2h`, `>1d`) in the top-right corner.
|
Messages are drawn as chat bubbles sized to fit their content, anchored **right** for your own outgoing messages and **left** for incoming ones (like a typical messenger), with the sender name and a compact age indicator (`3m`, `2h`, `>1d`) in the top-right corner of each bubble. The list runs **newest at the bottom** — opening a history starts you at the latest message, and scrolling **up** goes further into the past.
|
||||||
|
|
||||||
**Short Enter** on a message opens it in fullscreen. **Hold Enter** — on a history row or in fullscreen — opens the same options menu: Reply, plus **Navigate** / **Save waypoint** when the message contains a location (see Fullscreen message view). You don't need to open the message first.
|
**Short Enter** on a message opens it in fullscreen. **Hold Enter** — on a history row or in fullscreen — opens the same options menu: Reply, plus **Navigate** / **Save waypoint** when the message contains a location (see Fullscreen message view). You don't need to open the message first.
|
||||||
|
|
||||||
@@ -144,7 +146,7 @@ Joining a new community channel, or creating one to share with others, no longer
|
|||||||
| Secret | **LEFT/RIGHT** toggles between two entry modes; **Enter** opens the keyboard for whichever is selected |
|
| Secret | **LEFT/RIGHT** toggles between two entry modes; **Enter** opens the keyboard for whichever is selected |
|
||||||
|
|
||||||
- **Passphrase** (default) — type any text; the device hashes it down to the channel's 16-byte secret. Easiest to agree on verbally, the same idea as a room password — two people who type the same passphrase end up on the same channel.
|
- **Passphrase** (default) — type any text; the device hashes it down to the channel's 16-byte secret. Easiest to agree on verbally, the same idea as a room password — two people who type the same passphrase end up on the same channel.
|
||||||
- **Hex key** — type the exact 32-hex-character secret (the format used by channel QR codes, see [QR Codes](../../qr_codes.md)), for joining a channel whose precise secret you were given rather than agreeing on a new passphrase.
|
- **Hex key** — type the exact 32-hex-character secret (the format used by channel QR codes, see [QR Codes](../../qr_codes.md)), for joining a channel whose precise secret you were given rather than agreeing on a new passphrase. An all-zero secret (`00…0`) is rejected ("Invalid secret") — that value is reserved internally to mark an empty channel slot.
|
||||||
|
|
||||||
Select **[Save]** to commit. The secret can't be redisplayed once saved (only the derived key is kept) — editing it later means typing a new passphrase or hex key, the same as re-logging into a room with a new password.
|
Select **[Save]** to commit. The secret can't be redisplayed once saved (only the derived key is kept) — editing it later means typing a new passphrase or hex key, the same as re-logging into a room with a new password.
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ Press **Cancel/Back** to save and return to the home screen.
|
|||||||
| Battery | icon / % / V | Display mode for the top-bar battery indicator |
|
| Battery | icon / % / V | Display mode for the top-bar battery indicator |
|
||||||
| Clock seconds | show / hide | Hiding reduces OLED refresh from 1 s to 60 s |
|
| Clock seconds | show / hide | Hiding reduces OLED refresh from 1 s to 60 s |
|
||||||
| Clock format | 24 h / 12 h | 12 h appends AM/PM |
|
| Clock format | 24 h / 12 h | 12 h appends AM/PM |
|
||||||
| Font | Default / Lemon | Default: 5×7 Adafruit with ASCII transliteration; Lemon: native Unicode with pixel-accurate line wrap |
|
|
||||||
| Display rotation _(e-ink only)_ | 0° / 90° / 180° / 270° | Applied immediately |
|
| Display rotation _(e-ink only)_ | 0° / 90° / 180° / 270° | Applied immediately |
|
||||||
| Joystick rotation _(e-ink only)_ | 0° / 90° / 180° / 270° | Rotates input mapping independently of display rotation; useful for custom enclosures |
|
| Joystick rotation _(e-ink only)_ | 0° / 90° / 180° / 270° | Rotates input mapping independently of display rotation; useful for custom enclosures |
|
||||||
| Full refresh interval _(e-ink only)_ | off / 5 / 10 / 20 / 30 | Partial refreshes between full clears; reduces ghosting on long sessions |
|
| Full refresh interval _(e-ink only)_ | off / 5 / 10 / 20 / 30 | Partial refreshes between full clears; reduces ghosting on long sessions |
|
||||||
@@ -101,7 +100,7 @@ The **repeater** mode and its flood filters live on their own screen — see **T
|
|||||||
| Setting | Options | Notes |
|
| Setting | Options | Notes |
|
||||||
| -------- | ---------- | -------------------------------------------------------------------------------------------------- |
|
| -------- | ---------- | -------------------------------------------------------------------------------------------------- |
|
||||||
| Layout | ABC / T9 | On-screen keyboard style. **ABC**: an a-b-c…z grid, one key per letter (the original layout). **T9**: phone-keypad multi-tap — each key is labelled with its **digit** and a letter group (e.g. `2abc`); repeated **Enter** presses cycle through the letters and then the digit itself. Applies to whichever alphabet page is active (see Alphabet below), not just Latin. |
|
| Layout | ABC / T9 | On-screen keyboard style. **ABC**: an a-b-c…z grid, one key per letter (the original layout). **T9**: phone-keypad multi-tap — each key is labelled with its **digit** and a letter group (e.g. `2abc`); repeated **Enter** presses cycle through the letters and then the digit itself. Applies to whichever alphabet page is active (see Alphabet below), not just Latin. |
|
||||||
| Alphabet | Latin / Cyrillic / Greek / Polish / Czech / Slovak / German / French / Spanish / Portuguese / Nordic | Which extra (non-Latin) alphabet, if any, joins the keyboard's page cycle. **Latin** (default): only the Latin letters and Symbols pages, as before. Any other choice adds that alphabet's own page to the same **#@/abc** key's cycle (Latin → alphabet → Symbols → Latin) — no separate key to switch scripts. Each Latin-diacritic language (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic) is its own separate page with that language's full, correct set of non-ASCII letters — e.g. Czech gets `á č ď é ě í ň ó ř š ť ú ů ý ž`, German just `ä ö ü ß`. Nordic covers `å ä æ ö ø`, shared across Danish/Norwegian/Swedish since their alphabets only differ in which of those five each one uses. **Greek** covers the 24-letter alphabet plus final sigma (`ς`) but not the tonos stress accents used in proper Modern Greek spelling. Typing in the chosen alphabet needs the **Lemon** font to actually display (Settings › Display › Font) — the keyboard forces it on temporarily while that page is open even if Font is set to Default, but received messages and everything else still follow your own Font choice. |
|
| Alphabet | Latin / Cyrillic / Greek / Polish / Czech / Slovak / German / French / Spanish / Portuguese / Nordic | Which extra (non-Latin) alphabet, if any, joins the keyboard's page cycle. **Latin** (default): only the Latin letters and Symbols pages, as before. Any other choice adds that alphabet's own page to the same **#@/abc** key's cycle (Latin → alphabet → Symbols → Latin) — no separate key to switch scripts. Each Latin-diacritic language (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic) is its own separate page with that language's full, correct set of non-ASCII letters — e.g. Czech gets `á č ď é ě í ň ó ř š ť ú ů ý ž`, German just `ä ö ü ß`. Nordic covers `å ä æ ö ø`, shared across Danish/Norwegian/Swedish since their alphabets only differ in which of those five each one uses. **Greek** covers the 24-letter alphabet plus final sigma (`ς`) but not the tonos stress accents used in proper Modern Greek spelling. Every alphabet's letters render natively — the display font (a single unified Unicode font used everywhere on-screen) covers all of them, no separate toggle needed. |
|
||||||
|
|
||||||
Applies to every on-screen text field (messages, waypoint labels, room passwords, preset names). Earlier releases labelled the grid *QWERTY*; the layout has always been alphabetical, so it is now named **ABC**.
|
Applies to every on-screen text field (messages, waypoint labels, room passwords, preset names). Earlier releases labelled the grid *QWERTY*; the layout has always been alphabetical, so it is now named **ABC**.
|
||||||
|
|
||||||
|
|||||||
@@ -453,7 +453,15 @@ Each target's Commands toggle is independent — e.g. answer `!ping` in DMs but
|
|||||||
|
|
||||||
<!-- screenshot pending: Diagnostics — live device/mesh stats rows (uptime, rx/tx counters, heap, RSSI/SNR, queue, errors) -->
|
<!-- 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.
|
A circular tab carousel of live device and mesh stats, refreshed once a second (same tab idiom as Auto-Reply Bot / Nodes). **LEFT/RIGHT** switches tab; **UP/DOWN** scrolls within it on a small OLED — on a larger e-ink display a tab's rows all fit at once.
|
||||||
|
|
||||||
|
| Tab | Shows |
|
||||||
|
| --- | ----- |
|
||||||
|
| **Live** | Live counters — see table below. |
|
||||||
|
| **System** | Static device identity: firmware version + build date, device model, node name, and the active radio parameters. |
|
||||||
|
| **Font** | A rendering test card — one sample line per script the on-device font claims to cover (Latin, diacritics, Greek, Cyrillic, digits, symbols), so its coverage can be eyeballed directly. |
|
||||||
|
|
||||||
|
**Live** tab rows:
|
||||||
|
|
||||||
| Row | Shows |
|
| Row | Shows |
|
||||||
| ------------ | -------------------------------------------------------------------------------------------------- |
|
| ------------ | -------------------------------------------------------------------------------------------------- |
|
||||||
@@ -472,7 +480,7 @@ A single read-only screen of live device and mesh stats, refreshed once a second
|
|||||||
| Queue | Packets waiting in the outbound queue |
|
| Queue | Packets waiting in the outbound queue |
|
||||||
| Errors | Radio error flags since boot/reset — `OK`, or tokens `F` (queue full), `C` (CAD timeout), `R` (RX-start timeout) |
|
| Errors | Radio error flags since boot/reset — `OK`, or tokens `F` (queue full), `C` (CAD timeout), `R` (RX-start timeout) |
|
||||||
|
|
||||||
The packet counters, **Forwarded** and **Errors** are cumulative since boot. **Hold Enter** opens a one-item *Reset counters* menu (Back dismisses it); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. **Cancel/Back** returns to the Tools list.
|
The packet counters, **Forwarded** and **Errors** are cumulative since boot. On the **Live** tab, **Hold Enter** opens a one-item *Reset counters* menu (Back dismisses it); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. **Cancel/Back** returns to the Tools list.
|
||||||
|
|
||||||
The counters make the repeater behaviour observable: **Forwarded** confirms the node is actually relaying (not just configured to), and **Pool free** / **Queue** show whether forwarding is exhausting the packet pool. See **Tools › Repeater** for the relaying options.
|
The counters make the repeater behaviour observable: **Forwarded** confirms the node is actually relaying (not just configured to), and **Pool free** / **Queue** show whether forwarding is exhausting the packet pool. See **Tools › Repeater** for the relaying options.
|
||||||
|
|
||||||
@@ -526,12 +534,13 @@ Send commands to a **repeater/room server you have admin permission on** — the
|
|||||||
| Tab | Rows |
|
| Tab | Rows |
|
||||||
| --- | ---- |
|
| --- | ---- |
|
||||||
| **System** | Name, Owner info, Admin password |
|
| **System** | Name, Owner info, Admin password |
|
||||||
| **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power |
|
| **Radio** | Frequency, Bandwidth, Spreading factor, Coding rate, TX power |
|
||||||
| **Routing** | Repeat, Advert interval, Flood advert interval, Max hops |
|
| **Routing** | Repeat, Advert interval, Flood advert interval, Max hops |
|
||||||
| **Actions** | Send advert, Send zero-hop advert, Sync clock, Reboot, **Custom command...** |
|
| **Actions** | Send advert, Send zero-hop advert, Sync clock, Reboot, **Custom command...** |
|
||||||
|
|
||||||
**Enter** on a row does one of three things, depending on the field:
|
**Enter** on a row does one of four things, depending on the field:
|
||||||
- Most **System/Radio/Routing** rows first **fetch** the node's current value, then open the keyboard **pre-filled** with it to edit — submitting sends the change. If the fetch fails or times out, the keyboard still opens (blank), so the value can be set blind.
|
- **Name / Owner info** first **fetch** the node's current value, then open the keyboard **pre-filled** with it to edit — submitting sends the change. If the fetch fails or times out, the keyboard still opens (blank), so the value can be set blind.
|
||||||
|
- **Radio and Routing rows** are typed, not free text: **Repeat** is an ON/OFF toggle; **Advert interval / Flood advert interval / Max hops / TX power** are number steppers (**LEFT/RIGHT** to adjust, within that field's valid range); **Frequency** uses the same digit-by-digit cursor editor as Settings' own Radio screen (**LEFT/RIGHT** moves between digits, **UP/DOWN** changes the selected one); **Bandwidth / Spreading factor / Coding rate** step through their valid discrete LoRa values with **LEFT/RIGHT**. All four Radio-tuple fields (Frequency/Bandwidth/SF/Coding rate) fetch and re-send the same underlying `radio` value together — editing any one of them still only overwrites that one, the other three round-trip unchanged. **Enter** sends the change; **Cancel** discards it and returns to the row list without sending anything.
|
||||||
- **Admin password** has no fetch (there's no way to read a password back) — it opens straight to a blank keyboard.
|
- **Admin password** has no fetch (there's no way to read a password back) — it opens straight to a blank keyboard.
|
||||||
- **Actions** (Reboot, Send advert, …) send immediately, no editing step.
|
- **Actions** (Reboot, Send advert, …) send immediately, no editing step.
|
||||||
- **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar. The keyboard's **{}** key doubles as command completion here: it lists commands matching whatever's typed since the last space (narrowing as you type), and picking one completes that word instead of just inserting after it.
|
- **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar. The keyboard's **{}** key doubles as command completion here: it lists commands matching whatever's typed since the last space (narrowing as you type), and picking one completes that word instead of just inserting after it.
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ public:
|
|||||||
// To check if there is pending work
|
// To check if there is pending work
|
||||||
bool hasPendingWork() const;
|
bool hasPendingWork() const;
|
||||||
|
|
||||||
// Number of auto-replies sent since boot (DM + channel). Shown on BotScreen.
|
// Number of auto-replies sent since boot (DM + channel + room). Shown on BotScreen.
|
||||||
uint16_t botReplyCount() const { return _bot_reply_count; }
|
uint16_t botReplyCount() const { return _bot_reply_count; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ struct NodePrefs { // persisted to file
|
|||||||
uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV
|
uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV
|
||||||
uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage
|
uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage
|
||||||
char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time})
|
char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time})
|
||||||
uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting
|
uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting [del→onChannelRemoved]
|
||||||
uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set)
|
uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) [del→onChannelRemoved]
|
||||||
uint8_t dm_show_all; // 0=favourites only (default), 1=all chat contacts
|
uint8_t dm_show_all; // 0=favourites only (default), 1=all chat contacts
|
||||||
uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only
|
uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only
|
||||||
uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180}
|
uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180}
|
||||||
@@ -148,7 +148,7 @@ struct NodePrefs { // persisted to file
|
|||||||
uint8_t trail_min_delta_idx; // min-distance gate level (0=finest..3); metres or feet per units_imperial
|
uint8_t trail_min_delta_idx; // min-distance gate level (0=finest..3); metres or feet per units_imperial
|
||||||
uint8_t trail_units_idx; // legacy: old combined speed/pace+unit index (km/h, mph, min/km, min/mi)
|
uint8_t trail_units_idx; // legacy: old combined speed/pace+unit index (km/h, mph, min/km, min/mi)
|
||||||
|
|
||||||
uint64_t ch_fav_bitmask; // bit i = channel i is marked as favourite
|
uint64_t ch_fav_bitmask; // bit i = channel i is marked as favourite [del→onChannelRemoved]
|
||||||
uint8_t ch_fav_only; // 0=show all channels (default), 1=show favourites only
|
uint8_t ch_fav_only; // 0=show all channels (default), 1=show favourites only
|
||||||
|
|
||||||
// Global measurement system for every distance/speed shown in the UI
|
// Global measurement system for every distance/speed shown in the UI
|
||||||
@@ -329,8 +329,8 @@ struct NodePrefs { // persisted to file
|
|||||||
// key (Latin → alphabet → symbols → Latin), so composing in it needs no new
|
// key (Latin → alphabet → symbols → Latin), so composing in it needs no new
|
||||||
// key, just Settings › Keyboard › Alphabet to pick which one is available.
|
// key, just Settings › Keyboard › Alphabet to pick which one is available.
|
||||||
// See KeyboardWidget.h for the per-alphabet grids (KB_CYRILLIC_CHARS etc.)
|
// See KeyboardWidget.h for the per-alphabet grids (KB_CYRILLIC_CHARS etc.)
|
||||||
// and Lemon font (src/helpers/ui/LemonFont.h) for on-screen rendering —
|
// and the misc-fixed font (src/helpers/ui/MiscFixedFont.h) for on-screen
|
||||||
// every alphabet here must be in Lemon's U+0020-04FF range.
|
// rendering — every alphabet here must be in its U+0020-04FF range.
|
||||||
//
|
//
|
||||||
// The Latin-diacritic entries (Polish..Nordic) replace what used to be a
|
// The Latin-diacritic entries (Polish..Nordic) replace what used to be a
|
||||||
// single combined "Ext.Latin" curated subset — each is now its own full,
|
// single combined "Ext.Latin" curated subset — each is now its own full,
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
// see docs/cli_commands.md). This device's own settings live in Settings /
|
// see docs/cli_commands.md). This device's own settings live in Settings /
|
||||||
// Tools, not here -- Admin is purely for remote nodes.
|
// Tools, not here -- Admin is purely for remote nodes.
|
||||||
//
|
//
|
||||||
// Entry is always a target-then-act flow: Tools > Admin (or a repeater/room's
|
// Entry is a target-then-act flow, reached two ways: Tools > Admin opens
|
||||||
// Hold-Enter "Admin" action in Nodes) opens Tools > Nodes in a pick-mode
|
// Tools > Nodes in a pick-mode (NearbyScreen::startPickAdminTarget(), the same
|
||||||
// (NearbyScreen::startPickAdminTarget(), the same borrow-another-screen's-list
|
// borrow-another-screen's-list idiom the channel/bot pickers use) and picking
|
||||||
// idiom the channel/bot pickers use), and picking a node calls the single
|
// a node from it enters Admin; or a repeater/room's own Hold-Enter "Admin"
|
||||||
// canonical UITask::openAdminFor(ContactInfo) -> startFor(). Backing out of a
|
// action in Nodes enters Admin directly while browsing normally. Both call the
|
||||||
// command screen returns to that picker; backing out of the picker returns to
|
// single canonical UITask::openAdminFor(ContactInfo, from_picker) -> startFor(),
|
||||||
// Tools.
|
// which remembers which door was used: Cancel/login-failure from a command
|
||||||
|
// screen returns to the picker if that's how the user arrived, or straight
|
||||||
|
// back to Nodes if they came in directly (see returnToOrigin()); backing out
|
||||||
|
// of the picker itself returns to Tools.
|
||||||
//
|
//
|
||||||
// After login (session password, self-heals like room logins) a category tab
|
// After login (session password, self-heals like room logins) a category tab
|
||||||
// carousel of AdminField{label, get_cmd, set_prefix} rows unlocks:
|
// carousel of AdminField{label, get_cmd, set_prefix} rows unlocks:
|
||||||
@@ -22,6 +25,7 @@
|
|||||||
|
|
||||||
#include "FullscreenMsgView.h"
|
#include "FullscreenMsgView.h"
|
||||||
#include "TabBar.h"
|
#include "TabBar.h"
|
||||||
|
#include "RadioParamsEditor.h" // DigitEditor + stepSF/stepBW/stepCR -- same widgets Settings/Repeater use locally
|
||||||
#include <helpers/ClientACL.h> // PERM_ACL_ADMIN / PERM_ACL_ROLE_MASK
|
#include <helpers/ClientACL.h> // PERM_ACL_ADMIN / PERM_ACL_ROLE_MASK
|
||||||
|
|
||||||
class AdminScreen : public UIScreen {
|
class AdminScreen : public UIScreen {
|
||||||
@@ -32,6 +36,18 @@ class AdminScreen : public UIScreen {
|
|||||||
|
|
||||||
ContactInfo _target;
|
ContactInfo _target;
|
||||||
|
|
||||||
|
// True when this visit was reached via the "Remote node..." picker
|
||||||
|
// (UITask::pickAdminTarget() -> NearbyScreen's pick-mode); false when reached
|
||||||
|
// directly from a node's own Hold-Enter "Admin" action while browsing Nodes
|
||||||
|
// normally. Determines where Cancel/failure paths send the user back to --
|
||||||
|
// see returnToOrigin(). Set fresh by startFor() on every entry.
|
||||||
|
bool _from_picker = true;
|
||||||
|
|
||||||
|
// Cancel/failure exit: back to the target picker if that's how we got here,
|
||||||
|
// otherwise straight back to Nodes (we were opened directly from its
|
||||||
|
// "Admin" context-menu action, so a bare pick-list would be a detour).
|
||||||
|
void returnToOrigin() { if (_from_picker) _task->pickAdminTarget(); else _task->gotoNearbyScreen(); }
|
||||||
|
|
||||||
// LOGIN
|
// LOGIN
|
||||||
char _login_pw[16] = "";
|
char _login_pw[16] = "";
|
||||||
// True while waiting for a login result with no keyboard on screen -- either a
|
// True while waiting for a login result with no keyboard on screen -- either a
|
||||||
@@ -50,7 +66,33 @@ class AdminScreen : public UIScreen {
|
|||||||
enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT };
|
enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT };
|
||||||
static const char* TAB_LABELS[ATAB_COUNT];
|
static const char* TAB_LABELS[ATAB_COUNT];
|
||||||
|
|
||||||
struct AdminField { const char* label; const char* get_cmd; const char* set_prefix; };
|
// Most fields are still free text (get reply pre-fills the keyboard, user
|
||||||
|
// retypes, "set <prefix> <text>" on submit) -- fine for names/passwords, but
|
||||||
|
// painful for values that are really a number or one of a handful of options.
|
||||||
|
// `kind` (default FK_TEXT, so every existing 3-field {label,get,set} literal
|
||||||
|
// below still compiles unchanged) opts a field into a typed, in-place editor
|
||||||
|
// instead: FK_ONOFF toggles on/off; FK_NUMBER steps an int within
|
||||||
|
// [min_val,max_val] by `step`; FK_RADIO_* are four views onto the same
|
||||||
|
// "get radio"/"set radio f,bw,sf,cr" tuple (freq via the same digit-cursor
|
||||||
|
// DigitEditor Settings/Repeater use, bw/sf/cr via RadioParamsEditor's
|
||||||
|
// discrete-set steppers) -- editing any one re-sends all four, the other
|
||||||
|
// three carried over unchanged from the fetch. See activateField()/
|
||||||
|
// beginValueEdit()/the `_value_editing` block in handleInput().
|
||||||
|
enum FieldKind : uint8_t { FK_TEXT, FK_ONOFF, FK_NUMBER, FK_RADIO_FREQ, FK_RADIO_BW, FK_RADIO_SF, FK_RADIO_CR };
|
||||||
|
struct AdminField {
|
||||||
|
const char* label; const char* get_cmd; const char* set_prefix;
|
||||||
|
FieldKind kind;
|
||||||
|
float min_val, max_val, step; // FK_NUMBER only
|
||||||
|
// Explicit ctor (not default member initializers) so every existing
|
||||||
|
// 3-field {label,get,set} literal below still compiles as a converting
|
||||||
|
// constructor call -- this toolchain's actual C++ standard (the Adafruit
|
||||||
|
// nRF52 core's default; only `env:native` sets -std=c++17) predates
|
||||||
|
// C++14's aggregate-with-default-member-initializer rule, so plain
|
||||||
|
// aggregate init here fails to convert.
|
||||||
|
AdminField(const char* l, const char* g, const char* s, FieldKind k = FK_TEXT,
|
||||||
|
float mn = 0, float mx = 0, float st = 1)
|
||||||
|
: label(l), get_cmd(g), set_prefix(s), kind(k), min_val(mn), max_val(mx), step(st) {}
|
||||||
|
};
|
||||||
static const AdminField SYSTEM_FIELDS[];
|
static const AdminField SYSTEM_FIELDS[];
|
||||||
static const AdminField RADIO_FIELDS[];
|
static const AdminField RADIO_FIELDS[];
|
||||||
static const AdminField ROUTING_FIELDS[];
|
static const AdminField ROUTING_FIELDS[];
|
||||||
@@ -76,6 +118,15 @@ class AdminScreen : public UIScreen {
|
|||||||
bool _fetch_for_edit = false; // true while waiting on a "get" whose reply opens an editor
|
bool _fetch_for_edit = false; // true while waiting on a "get" whose reply opens an editor
|
||||||
const char* _pending_set_prefix = nullptr; // non-null => edited keyboard text gets this prefix on submit
|
const char* _pending_set_prefix = nullptr; // non-null => edited keyboard text gets this prefix on submit
|
||||||
|
|
||||||
|
// ── Typed value editors (FK_ONOFF/FK_NUMBER/FK_RADIO_*) ────────────────────
|
||||||
|
bool _fetch_for_value = false; // true while waiting on a "get" whose reply feeds a typed editor
|
||||||
|
bool _value_editing = false; // true while the selected row's typed editor is live (Enter to send, Cancel to drop)
|
||||||
|
const AdminField* _edit_field = nullptr; // field currently open in _value_editing
|
||||||
|
float _edit_val = 0; // FK_ONOFF (0/1) / FK_NUMBER current value
|
||||||
|
float _radio_freq = 0, _radio_bw = 0; // last-fetched radio tuple -- kept for whichever of the
|
||||||
|
uint8_t _radio_sf = 0, _radio_cr = 0; // 4 radio sub-fields isn't the one actually being edited
|
||||||
|
DigitEditor _freq_ed; // FK_RADIO_FREQ's digit-cursor editor (same widget as Settings/Repeater)
|
||||||
|
|
||||||
// REPLY
|
// REPLY
|
||||||
FullscreenMsgView _reply_view;
|
FullscreenMsgView _reply_view;
|
||||||
char _reply_text[200] = "";
|
char _reply_text[200] = "";
|
||||||
@@ -157,6 +208,14 @@ class AdminScreen : public UIScreen {
|
|||||||
void activateField(const AdminField& f) {
|
void activateField(const AdminField& f) {
|
||||||
_fetch_for_edit = false;
|
_fetch_for_edit = false;
|
||||||
_pending_set_prefix = nullptr;
|
_pending_set_prefix = nullptr;
|
||||||
|
if (f.kind != FK_TEXT) { // typed editor: on/off, number, or a radio sub-field
|
||||||
|
_edit_field = &f;
|
||||||
|
_fetch_for_value = true;
|
||||||
|
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
|
||||||
|
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
|
||||||
|
sendCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (f.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command...
|
if (f.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command...
|
||||||
openValueKb(_cmd_text, true);
|
openValueKb(_cmd_text, true);
|
||||||
} else if (f.set_prefix == nullptr) { // Action
|
} else if (f.set_prefix == nullptr) { // Action
|
||||||
@@ -183,6 +242,73 @@ class AdminScreen : public UIScreen {
|
|||||||
openValueKb("");
|
openValueKb("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits a "get radio" reply's value ("freq,bw,sf,cr") into its 4 parts.
|
||||||
|
// Self-contained (no mesh::Utils dependency) since it only ever needs to
|
||||||
|
// parse this one, fixed 4-field shape.
|
||||||
|
static bool parseRadioReply(const char* val, float& freq, float& bw, uint8_t& sf, uint8_t& cr) {
|
||||||
|
char tmp[48];
|
||||||
|
strncpy(tmp, val, sizeof(tmp) - 1);
|
||||||
|
tmp[sizeof(tmp) - 1] = '\0';
|
||||||
|
char* p = tmp;
|
||||||
|
char* parts[4];
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
parts[i] = p;
|
||||||
|
if (i < 3) {
|
||||||
|
char* c = strchr(p, ',');
|
||||||
|
if (!c) return false;
|
||||||
|
*c = '\0';
|
||||||
|
p = c + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freq = strtof(parts[0], nullptr);
|
||||||
|
bw = strtof(parts[1], nullptr);
|
||||||
|
sf = (uint8_t)atoi(parts[2]);
|
||||||
|
cr = (uint8_t)atoi(parts[3]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeds the typed editor from a (already "> "-stripped) get-reply, per
|
||||||
|
// _edit_field->kind. Returns false on a malformed reply (caller falls back
|
||||||
|
// to an alert instead of entering an editor with garbage in it).
|
||||||
|
bool beginValueEdit(const char* val) {
|
||||||
|
switch (_edit_field->kind) {
|
||||||
|
case FK_ONOFF:
|
||||||
|
_edit_val = (memcmp(val, "on", 2) == 0) ? 1.0f : 0.0f;
|
||||||
|
return true;
|
||||||
|
case FK_NUMBER:
|
||||||
|
// Clamp in case the node's real value sits outside this row's declared
|
||||||
|
// range (e.g. set via a different firmware/build or the Custom command) --
|
||||||
|
// otherwise every LEFT/RIGHT step lands out of range and gets silently
|
||||||
|
// rejected in both directions, stranding the field on an unreachable value.
|
||||||
|
_edit_val = constrain((float)atoi(val), _edit_field->min_val, _edit_field->max_val);
|
||||||
|
return true;
|
||||||
|
case FK_RADIO_FREQ:
|
||||||
|
case FK_RADIO_BW:
|
||||||
|
case FK_RADIO_SF:
|
||||||
|
case FK_RADIO_CR:
|
||||||
|
if (!parseRadioReply(val, _radio_freq, _radio_bw, _radio_sf, _radio_cr)) return false;
|
||||||
|
if (_edit_field->kind == FK_RADIO_FREQ)
|
||||||
|
_freq_ed.begin(_radio_freq, _edit_field->min_val, _edit_field->max_val, 4, 3);
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-column display text for the row currently in _value_editing (freq
|
||||||
|
// has its own DigitEditor::render call instead -- see render()). Matches
|
||||||
|
// RepeaterScreen's itemValue() formatting for the same quantities (plain
|
||||||
|
// %d for SF/CR, %.1f for BW) so a value reads the same wherever it's shown.
|
||||||
|
void formatEditValue(char* buf, size_t n) const {
|
||||||
|
switch (_edit_field->kind) {
|
||||||
|
case FK_ONOFF: snprintf(buf, n, "%s", _edit_val != 0 ? "ON" : "OFF"); break;
|
||||||
|
case FK_RADIO_BW: snprintf(buf, n, "%.1f", _radio_bw); break;
|
||||||
|
case FK_RADIO_SF: snprintf(buf, n, "%d", (int)_radio_sf); break;
|
||||||
|
case FK_RADIO_CR: snprintf(buf, n, "%d", (int)_radio_cr); break;
|
||||||
|
default: snprintf(buf, n, "%d", (int)_edit_val); break; // FK_NUMBER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit AdminScreen(UITask* task) : _task(task) {}
|
explicit AdminScreen(UITask* task) : _task(task) {}
|
||||||
|
|
||||||
@@ -196,6 +322,8 @@ public:
|
|||||||
_waiting = false;
|
_waiting = false;
|
||||||
_fetch_for_edit = false;
|
_fetch_for_edit = false;
|
||||||
_pending_set_prefix = nullptr;
|
_pending_set_prefix = nullptr;
|
||||||
|
_fetch_for_value = false;
|
||||||
|
_value_editing = false;
|
||||||
_login_waiting = false;
|
_login_waiting = false;
|
||||||
_admin_ok = false;
|
_admin_ok = false;
|
||||||
}
|
}
|
||||||
@@ -204,8 +332,9 @@ public:
|
|||||||
// itself reached from NearbyScreen's "Admin" context-menu action or its
|
// itself reached from NearbyScreen's "Admin" context-menu action or its
|
||||||
// Admin-target pick-mode. Skips straight past login if this contact is already
|
// Admin-target pick-mode. Skips straight past login if this contact is already
|
||||||
// admin-ok this visit.
|
// admin-ok this visit.
|
||||||
void startFor(const ContactInfo& ci) {
|
void startFor(const ContactInfo& ci, bool from_picker) {
|
||||||
_target = ci;
|
_target = ci;
|
||||||
|
_from_picker = from_picker;
|
||||||
if (isAdminOk(ci.id.pub_key)) {
|
if (isAdminOk(ci.id.pub_key)) {
|
||||||
_tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0;
|
_tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0;
|
||||||
_phase = COMMAND;
|
_phase = COMMAND;
|
||||||
@@ -235,13 +364,13 @@ public:
|
|||||||
// Correct password, just insufficient permission -- leave any saved
|
// Correct password, just insufficient permission -- leave any saved
|
||||||
// password alone, retyping the same one won't change the outcome.
|
// password alone, retyping the same one won't change the outcome.
|
||||||
_task->showAlert("Not admin on this node", 1600);
|
_task->showAlert("Not admin on this node", 1600);
|
||||||
_task->pickAdminTarget();
|
returnToOrigin();
|
||||||
} else {
|
} else {
|
||||||
// Wrong/stale password -- forget it so the next attempt prompts fresh,
|
// Wrong/stale password -- forget it so the next attempt prompts fresh,
|
||||||
// same self-healing behaviour as a room login.
|
// same self-healing behaviour as a room login.
|
||||||
the_mesh.forgetRoomPassword(pub_key);
|
the_mesh.forgetRoomPassword(pub_key);
|
||||||
_task->showAlert("Login failed", 1400);
|
_task->showAlert("Login failed", 1400);
|
||||||
_task->pickAdminTarget();
|
returnToOrigin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,10 +378,25 @@ public:
|
|||||||
void onAdminReply(const uint8_t* pub_key, const char* text) {
|
void onAdminReply(const uint8_t* pub_key, const char* text) {
|
||||||
if (!_waiting || memcmp(_target.id.pub_key, pub_key, 4) != 0) return;
|
if (!_waiting || memcmp(_target.id.pub_key, pub_key, 4) != 0) return;
|
||||||
_waiting = false;
|
_waiting = false;
|
||||||
|
// Every "get ..." reply comes back as "> value" (see CommonCLI::handleGetCmd) --
|
||||||
|
// strip that CLI-decoration prefix before parsing/pre-filling from it. The
|
||||||
|
// final free-form REPLY view still shows `text` raw: action confirmations
|
||||||
|
// like "OK" never have the prefix, and a user-typed Custom "get ..." is
|
||||||
|
// meant to echo the raw wire reply verbatim, prefix included.
|
||||||
|
const char* val = (text[0] == '>' && text[1] == ' ') ? text + 2 : text;
|
||||||
|
if (_fetch_for_value) {
|
||||||
|
_fetch_for_value = false;
|
||||||
|
if (beginValueEdit(val)) {
|
||||||
|
_value_editing = true;
|
||||||
|
} else {
|
||||||
|
_task->showAlert("Fetch failed", 1400);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_fetch_for_edit) {
|
if (_fetch_for_edit) {
|
||||||
_fetch_for_edit = false;
|
_fetch_for_edit = false;
|
||||||
char trimmed[161];
|
char trimmed[161];
|
||||||
strncpy(trimmed, text, sizeof(trimmed) - 1);
|
strncpy(trimmed, val, sizeof(trimmed) - 1);
|
||||||
trimmed[sizeof(trimmed) - 1] = '\0';
|
trimmed[sizeof(trimmed) - 1] = '\0';
|
||||||
size_t n = strlen(trimmed);
|
size_t n = strlen(trimmed);
|
||||||
while (n > 0 && (trimmed[n-1] == '\n' || trimmed[n-1] == '\r' || trimmed[n-1] == ' ')) trimmed[--n] = '\0';
|
while (n > 0 && (trimmed[n-1] == '\n' || trimmed[n-1] == '\r' || trimmed[n-1] == ' ')) trimmed[--n] = '\0';
|
||||||
@@ -268,8 +412,9 @@ public:
|
|||||||
void poll() override {
|
void poll() override {
|
||||||
if (_phase == COMMAND && _waiting && (int32_t)(millis() - _cmd_deadline_ms) >= 0) {
|
if (_phase == COMMAND && _waiting && (int32_t)(millis() - _cmd_deadline_ms) >= 0) {
|
||||||
_waiting = false;
|
_waiting = false;
|
||||||
if (_fetch_for_edit) { fallBackToBlankEdit(); _task->showAlert("Fetch failed - enter value", 1400); }
|
if (_fetch_for_edit) { fallBackToBlankEdit(); _task->showAlert("Fetch failed - enter value", 1400); }
|
||||||
else { _task->showAlert("No response (timeout)", 1600); }
|
else if (_fetch_for_value) { _fetch_for_value = false; _task->showAlert("Fetch failed - try again", 1400); }
|
||||||
|
else { _task->showAlert("No response (timeout)", 1600); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,16 +439,26 @@ public:
|
|||||||
snprintf(title, sizeof(title), "%.23s", _target.name);
|
snprintf(title, sizeof(title), "%.23s", _target.name);
|
||||||
display.drawCenteredHeader(title);
|
display.drawCenteredHeader(title);
|
||||||
display.setCursor(2, display.listStart());
|
display.setCursor(2, display.listStart());
|
||||||
display.print(_fetch_for_edit ? "Fetching..." : "Waiting for reply...");
|
display.print((_fetch_for_edit || _fetch_for_value) ? "Fetching..." : "Waiting for reply...");
|
||||||
return 500;
|
return 500;
|
||||||
}
|
}
|
||||||
tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab);
|
tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab);
|
||||||
int n = ROWS_PER_TAB[_tab];
|
int n = ROWS_PER_TAB[_tab];
|
||||||
drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) {
|
drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) {
|
||||||
drawRowSelection(display, y, sel, reserve);
|
drawRowSelection(display, y, sel, reserve);
|
||||||
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, fieldAt(_tab, i).label);
|
const AdminField& f = fieldAt(_tab, i);
|
||||||
|
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, f.label);
|
||||||
|
if (_value_editing && sel) { // the row whose typed editor is currently open
|
||||||
|
if (f.kind == FK_RADIO_FREQ) {
|
||||||
|
_freq_ed.render(display, display.valCol(), y);
|
||||||
|
} else {
|
||||||
|
char val[16];
|
||||||
|
formatEditValue(val, sizeof(val));
|
||||||
|
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return 2000;
|
return _value_editing ? 50 : 2000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// REPLY
|
// REPLY
|
||||||
@@ -313,18 +468,18 @@ public:
|
|||||||
bool handleInput(char c) override {
|
bool handleInput(char c) override {
|
||||||
if (_phase == LOGIN) {
|
if (_phase == LOGIN) {
|
||||||
if (_login_waiting) {
|
if (_login_waiting) {
|
||||||
if (c == KEY_CANCEL) { _login_waiting = false; _task->pickAdminTarget(); }
|
if (c == KEY_CANCEL) { _login_waiting = false; returnToOrigin(); }
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
auto r = kb().handleInput(c);
|
auto r = kb().handleInput(c);
|
||||||
if (r == KeyboardWidget::CANCELLED) {
|
if (r == KeyboardWidget::CANCELLED) {
|
||||||
_task->pickAdminTarget();
|
returnToOrigin();
|
||||||
} else if (r == KeyboardWidget::DONE) {
|
} else if (r == KeyboardWidget::DONE) {
|
||||||
strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1);
|
strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1);
|
||||||
_login_pw[sizeof(_login_pw) - 1] = '\0';
|
_login_pw[sizeof(_login_pw) - 1] = '\0';
|
||||||
bool sent = the_mesh.sendRoomLogin(_target, _login_pw);
|
bool sent = the_mesh.sendRoomLogin(_target, _login_pw);
|
||||||
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
|
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
|
||||||
if (sent) _login_waiting = true; else _task->pickAdminTarget();
|
if (sent) _login_waiting = true; else returnToOrigin();
|
||||||
// else: stay in LOGIN until onRoomLoginResult() fires above.
|
// else: stay in LOGIN until onRoomLoginResult() fires above.
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -353,14 +508,58 @@ public:
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_waiting) {
|
if (_value_editing) {
|
||||||
if (c == KEY_CANCEL) {
|
bool commit = false;
|
||||||
_waiting = false;
|
if (_edit_field->kind == FK_RADIO_FREQ) {
|
||||||
if (_fetch_for_edit) fallBackToBlankEdit();
|
auto r = _freq_ed.handleInput(c);
|
||||||
|
if (r == DigitEditor::DONE) { _radio_freq = _freq_ed.value; commit = true; }
|
||||||
|
else if (r == DigitEditor::CANCELLED) { _value_editing = false; }
|
||||||
|
} else if (keyIsPrev(c) || keyIsNext(c)) {
|
||||||
|
int dir = keyIsNext(c) ? 1 : -1;
|
||||||
|
switch (_edit_field->kind) {
|
||||||
|
case FK_ONOFF: _edit_val = (_edit_val != 0) ? 0.0f : 1.0f; break;
|
||||||
|
case FK_NUMBER: {
|
||||||
|
float nv = _edit_val + dir * _edit_field->step;
|
||||||
|
if (nv >= _edit_field->min_val && nv <= _edit_field->max_val) _edit_val = nv;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FK_RADIO_BW: RadioParamsEditor::stepBW(_radio_bw, dir); break;
|
||||||
|
case FK_RADIO_SF: RadioParamsEditor::stepSF(_radio_sf, dir); break;
|
||||||
|
case FK_RADIO_CR: RadioParamsEditor::stepCR(_radio_cr, dir); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
} else if (c == KEY_ENTER) {
|
||||||
|
commit = true;
|
||||||
|
} else if (c == KEY_CANCEL) {
|
||||||
|
_value_editing = false;
|
||||||
|
}
|
||||||
|
if (commit) {
|
||||||
|
_value_editing = false;
|
||||||
|
switch (_edit_field->kind) {
|
||||||
|
case FK_RADIO_FREQ: case FK_RADIO_BW: case FK_RADIO_SF: case FK_RADIO_CR:
|
||||||
|
snprintf(_cmd_text, sizeof(_cmd_text), "%s %.3f,%.3f,%d,%d",
|
||||||
|
_edit_field->set_prefix, _radio_freq, _radio_bw, (int)_radio_sf, (int)_radio_cr);
|
||||||
|
break;
|
||||||
|
case FK_ONOFF:
|
||||||
|
snprintf(_cmd_text, sizeof(_cmd_text), "%s %s", _edit_field->set_prefix, _edit_val != 0 ? "on" : "off");
|
||||||
|
break;
|
||||||
|
default: // FK_NUMBER
|
||||||
|
snprintf(_cmd_text, sizeof(_cmd_text), "%s %d", _edit_field->set_prefix, (int)_edit_val);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sendCommand();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c == KEY_CANCEL) { _task->pickAdminTarget(); return true; }
|
if (_waiting) {
|
||||||
|
if (c == KEY_CANCEL) {
|
||||||
|
_waiting = false;
|
||||||
|
if (_fetch_for_edit) fallBackToBlankEdit();
|
||||||
|
else if (_fetch_for_value) _fetch_for_value = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c == KEY_CANCEL) { returnToOrigin(); return true; }
|
||||||
if (keyIsPrev(c)) { _tab = (_tab + ATAB_COUNT - 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
|
if (keyIsPrev(c)) { _tab = (_tab + ATAB_COUNT - 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
|
||||||
if (keyIsNext(c)) { _tab = (_tab + 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
|
if (keyIsNext(c)) { _tab = (_tab + 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
|
||||||
int n = ROWS_PER_TAB[_tab];
|
int n = ROWS_PER_TAB[_tab];
|
||||||
@@ -384,15 +583,21 @@ const AdminScreen::AdminField AdminScreen::SYSTEM_FIELDS[] = {
|
|||||||
{ "Owner info", "get owner.info", "set owner.info" },
|
{ "Owner info", "get owner.info", "set owner.info" },
|
||||||
{ "Admin password", nullptr, "password" },
|
{ "Admin password", nullptr, "password" },
|
||||||
};
|
};
|
||||||
|
// The 4 radio rows all share get_cmd/set_prefix ("get radio" / "set radio") --
|
||||||
|
// each fetches and re-sends the whole f,bw,sf,cr tuple, only its own value
|
||||||
|
// actually editable (see beginValueEdit()/the commit switch in handleInput()).
|
||||||
const AdminScreen::AdminField AdminScreen::RADIO_FIELDS[] = {
|
const AdminScreen::AdminField AdminScreen::RADIO_FIELDS[] = {
|
||||||
{ "Radio (f,bw,sf,cr)", "get radio", "set radio" },
|
{ "Frequency (MHz)", "get radio", "set radio", AdminScreen::FK_RADIO_FREQ, 150.0f, 2500.0f },
|
||||||
{ "TX power", "get tx", "set tx" },
|
{ "Bandwidth (kHz)", "get radio", "set radio", AdminScreen::FK_RADIO_BW },
|
||||||
|
{ "Spreading factor", "get radio", "set radio", AdminScreen::FK_RADIO_SF },
|
||||||
|
{ "Coding rate", "get radio", "set radio", AdminScreen::FK_RADIO_CR },
|
||||||
|
{ "TX power (dBm)", "get tx", "set tx", AdminScreen::FK_NUMBER, -9, 30, 1 },
|
||||||
};
|
};
|
||||||
const AdminScreen::AdminField AdminScreen::ROUTING_FIELDS[] = {
|
const AdminScreen::AdminField AdminScreen::ROUTING_FIELDS[] = {
|
||||||
{ "Repeat", "get repeat", "set repeat" },
|
{ "Repeat", "get repeat", "set repeat", AdminScreen::FK_ONOFF },
|
||||||
{ "Advert interval (min)", "get advert.interval", "set advert.interval" },
|
{ "Advert interval (min)", "get advert.interval", "set advert.interval", AdminScreen::FK_NUMBER, 0, 240, 2 },
|
||||||
{ "Flood advert interval (h)", "get flood.advert.interval", "set flood.advert.interval" },
|
{ "Flood advert interval (h)", "get flood.advert.interval", "set flood.advert.interval", AdminScreen::FK_NUMBER, 0, 168, 1 },
|
||||||
{ "Max hops", "get flood.max", "set flood.max" },
|
{ "Max hops", "get flood.max", "set flood.max", AdminScreen::FK_NUMBER, 0, 64, 1 },
|
||||||
};
|
};
|
||||||
const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = {
|
const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = {
|
||||||
{ "Send advert", "advert", nullptr },
|
{ "Send advert", "advert", nullptr },
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ class ChannelsView {
|
|||||||
if (*end != 0) return false;
|
if (*end != 0) return false;
|
||||||
tmp[i] = (uint8_t)v;
|
tmp[i] = (uint8_t)v;
|
||||||
}
|
}
|
||||||
|
// An all-zero 16-byte secret is the sentinel setChannelLocal() reads as
|
||||||
|
// "slot deleted" (see isAllZero() in MyMesh.cpp) -- saving one here would
|
||||||
|
// pass, then immediately trigger onChannelRemoved() on this very slot,
|
||||||
|
// silently discarding the channel and its bot/notif/favourite state.
|
||||||
|
// Reject it up front rather than let that self-delete happen invisibly.
|
||||||
|
bool all_zero = true;
|
||||||
|
for (int i = 0; i < 16 && all_zero; i++) if (tmp[i]) all_zero = false;
|
||||||
|
if (all_zero) return false;
|
||||||
memset(out, 0, 32);
|
memset(out, 0, 32);
|
||||||
memcpy(out, tmp, 16);
|
memcpy(out, tmp, 16);
|
||||||
return true;
|
return true;
|
||||||
@@ -68,7 +76,7 @@ class ChannelsView {
|
|||||||
if (_name[0] == '\0') { _task->showAlert("Name required", 1200); return; }
|
if (_name[0] == '\0') { _task->showAlert("Name required", 1200); return; }
|
||||||
uint8_t secret[32];
|
uint8_t secret[32];
|
||||||
if (!deriveSecret(secret)) {
|
if (!deriveSecret(secret)) {
|
||||||
_task->showAlert(_hex_mode ? "Need 32 hex chars" : "Secret required", 1400);
|
_task->showAlert(_hex_mode ? "Invalid secret" : "Secret required", 1400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ChannelDetails ch;
|
ChannelDetails ch;
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ static const char* const KB_T9_GROUPS[KB_PAGES][9] = {
|
|||||||
|
|
||||||
// Additional (non-Latin) keyboard alphabets — NodePrefs::keyboard_alt_alphabet
|
// Additional (non-Latin) keyboard alphabets — NodePrefs::keyboard_alt_alphabet
|
||||||
// picks which one (if any) joins the Latin/symbols page cycle. Every alphabet
|
// picks which one (if any) joins the Latin/symbols page cycle. Every alphabet
|
||||||
// here must fall inside Lemon's U+0020-04FF range (src/helpers/ui/LemonFont.h)
|
// here must fall inside the misc-fixed font's U+0020-04FF range
|
||||||
// since that's what actually draws these glyphs on-screen.
|
// (src/helpers/ui/MiscFixedFont.h) since that's what actually draws these
|
||||||
|
// glyphs on-screen.
|
||||||
//
|
//
|
||||||
// Unlike KB_CHARS (single ASCII byte per cell), these hold UTF-8 strings —
|
// Unlike KB_CHARS (single ASCII byte per cell), these hold UTF-8 strings —
|
||||||
// Cyrillic is 2 bytes/codepoint — so cells are `const char*`, not `char`.
|
// Cyrillic is 2 bytes/codepoint — so cells are `const char*`, not `char`.
|
||||||
@@ -363,6 +364,12 @@ struct KeyboardWidget {
|
|||||||
int t9_cell = -1;
|
int t9_cell = -1;
|
||||||
int t9_cycle = 0;
|
int t9_cycle = 0;
|
||||||
uint32_t t9_last_ms = 0;
|
uint32_t t9_last_ms = 0;
|
||||||
|
// Caps state the *first* tap of the current T9 cycle applied -- reused by every
|
||||||
|
// later cycling tap on the same cell, since one-shot Shift is consumed (see
|
||||||
|
// below) right after that first tap, before the user has settled on a letter.
|
||||||
|
// Without this, cycling to the 2nd/3rd/... candidate would always render
|
||||||
|
// lowercase regardless of Shift.
|
||||||
|
bool t9_caps = false;
|
||||||
|
|
||||||
int gridRows() const { return isT9() ? KB_T9_ROWS : KB_ROWS_CHAR; }
|
int gridRows() const { return isT9() ? KB_T9_ROWS : KB_ROWS_CHAR; }
|
||||||
int gridCols() const { return isT9() ? KB_T9_COLS : KB_COLS_CHAR; }
|
int gridCols() const { return isT9() ? KB_T9_COLS : KB_COLS_CHAR; }
|
||||||
@@ -765,7 +772,7 @@ struct KeyboardWidget {
|
|||||||
if (t9_cycle < glen) kbUtf8CharAt(group, t9_cycle, one);
|
if (t9_cycle < glen) kbUtf8CharAt(group, t9_cycle, one);
|
||||||
else { one[0] = (char)('1' + cell); one[1] = '\0'; }
|
else { one[0] = (char)('1' + cell); one[1] = '\0'; }
|
||||||
char shown[5];
|
char shown[5];
|
||||||
kbApplyCapsUtf8(one, caps, shown, sizeof(shown));
|
kbApplyCapsUtf8(one, t9_caps, shown, sizeof(shown));
|
||||||
// Replace the codepoint just before the cursor (what the previous
|
// Replace the codepoint just before the cursor (what the previous
|
||||||
// tap inserted), preserving anything after the cursor too.
|
// tap inserted), preserving anything after the cursor too.
|
||||||
int old_n = kbUtf8LastCharBytes(buf, cursor_pos);
|
int old_n = kbUtf8LastCharBytes(buf, cursor_pos);
|
||||||
@@ -793,6 +800,7 @@ struct KeyboardWidget {
|
|||||||
buf[len] = '\0';
|
buf[len] = '\0';
|
||||||
t9_cell = cell;
|
t9_cell = cell;
|
||||||
t9_cycle = 0;
|
t9_cycle = 0;
|
||||||
|
t9_caps = caps; // remember it for every later cycling tap on this cell
|
||||||
if (caps && !caps_lock) caps = false; // one-shot: only this first tap gets capitalised
|
if (caps && !caps_lock) caps = false; // one-shot: only this first tap gets capitalised
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -629,12 +629,22 @@ public:
|
|||||||
// the outgoing path can attach a relay seq to that exact entry.
|
// the outgoing path can attach a relay seq to that exact entry.
|
||||||
int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) {
|
int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) {
|
||||||
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
|
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
|
||||||
return _history.addChannelMsg(ch_idx, text, viewing, timestamp);
|
int pos = _history.addChannelMsg(ch_idx, text, viewing, timestamp);
|
||||||
|
// Ring entries are numbered newest-first (0 == newest), so a new insert
|
||||||
|
// shifts every older message's index up by one. If the user has scrolled
|
||||||
|
// up to an older message (_hist_sel > 0), re-point the selection at that
|
||||||
|
// same message instead of silently relabeling a different one in under
|
||||||
|
// them. At _hist_sel <= 0 (already at newest, or -1 == compose button
|
||||||
|
// focused) there's nothing to preserve.
|
||||||
|
if (viewing && _hist_sel > 0) { _hist_sel++; _hist_scroll++; }
|
||||||
|
return pos;
|
||||||
}
|
}
|
||||||
void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); }
|
void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); }
|
||||||
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
|
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
|
||||||
uint32_t sender_timestamp = 0) {
|
uint32_t sender_timestamp = 0) {
|
||||||
|
bool viewing = (_phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0);
|
||||||
_history.addDMMsg(pub_key, outgoing, text, sender_timestamp);
|
_history.addDMMsg(pub_key, outgoing, text, sender_timestamp);
|
||||||
|
if (viewing && _dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } // see addChannelMsg
|
||||||
}
|
}
|
||||||
void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); }
|
void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); }
|
||||||
|
|
||||||
@@ -648,8 +658,14 @@ public:
|
|||||||
int _room_login_head = 0, _room_login_count = 0;
|
int _room_login_head = 0, _room_login_count = 0;
|
||||||
|
|
||||||
bool isRoomLoggedIn(const uint8_t* pub_key) const {
|
bool isRoomLoggedIn(const uint8_t* pub_key) const {
|
||||||
for (int i = 0; i < _room_login_count; i++)
|
// Indices are relative to _room_login_head, same as forgetRoomLoggedIn() --
|
||||||
if (memcmp(_room_login_prefix[i], pub_key, 4) == 0) return true;
|
// direct 0.._room_login_count indexing only happens to work before the
|
||||||
|
// ring has wrapped once (head==0); after that it silently checks the wrong
|
||||||
|
// slots.
|
||||||
|
for (int i = 0; i < _room_login_count; i++) {
|
||||||
|
int pos = (_room_login_head + i) % ROOM_LOGIN_TABLE_SIZE;
|
||||||
|
if (memcmp(_room_login_prefix[pos], pub_key, 4) == 0) return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ class NearbyScreen : public UIScreen {
|
|||||||
const Entry* e = selected();
|
const Entry* e = selected();
|
||||||
ContactInfo ci;
|
ContactInfo ci;
|
||||||
if (e && e->contact_idx >= 0 && the_mesh.getContactByIdx(e->contact_idx, ci))
|
if (e && e->contact_idx >= 0 && the_mesh.getContactByIdx(e->contact_idx, ci))
|
||||||
_task->openAdminFor(ci);
|
_task->openAdminFor(ci, false); // direct from Nodes -- Cancel should return here, not to a pick-list
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
|
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
|
||||||
@@ -950,7 +950,7 @@ public:
|
|||||||
if (e && e->contact_idx >= 0 && (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM)
|
if (e && e->contact_idx >= 0 && (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM)
|
||||||
&& the_mesh.getContactByIdx(e->contact_idx, ci)) {
|
&& the_mesh.getContactByIdx(e->contact_idx, ci)) {
|
||||||
_pick_admin_target = false;
|
_pick_admin_target = false;
|
||||||
_task->openAdminFor(ci);
|
_task->openAdminFor(ci, true); // via the picker -- Cancel should return here
|
||||||
}
|
}
|
||||||
// else: row isn't an eligible admin target -- ignore, stay on the picker.
|
// else: row isn't an eligible admin target -- ignore, stay on the picker.
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class SettingsScreen : public UIScreen {
|
|||||||
CLOCK_SECONDS,
|
CLOCK_SECONDS,
|
||||||
#endif
|
#endif
|
||||||
CLOCK_FORMAT,
|
CLOCK_FORMAT,
|
||||||
FONT,
|
|
||||||
#if FEAT_DISPLAY_ROTATION_SETTING
|
#if FEAT_DISPLAY_ROTATION_SETTING
|
||||||
ROTATION,
|
ROTATION,
|
||||||
#endif
|
#endif
|
||||||
@@ -585,10 +584,6 @@ class SettingsScreen : public UIScreen {
|
|||||||
display.print("Format");
|
display.print("Format");
|
||||||
display.setCursor(valCol(display), y);
|
display.setCursor(valCol(display), y);
|
||||||
display.print((p && p->clock_12h) ? "12h" : "24h");
|
display.print((p && p->clock_12h) ? "12h" : "24h");
|
||||||
} else if (item == FONT) {
|
|
||||||
display.print("Font");
|
|
||||||
display.setCursor(valCol(display), y);
|
|
||||||
display.print((p && p->use_lemon_font) ? "Lemon" : "Default");
|
|
||||||
#if FEAT_DISPLAY_ROTATION_SETTING
|
#if FEAT_DISPLAY_ROTATION_SETTING
|
||||||
} else if (item == ROTATION) {
|
} else if (item == ROTATION) {
|
||||||
display.print("Rotation");
|
display.print("Rotation");
|
||||||
@@ -961,14 +956,6 @@ public:
|
|||||||
_dirty = true;
|
_dirty = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_selected == FONT && p && (left || right || enter)) {
|
|
||||||
// Normalise (not ^=1): a stale value >1 from an older build with extra
|
|
||||||
// font modes would otherwise toggle 2<->3 and stay stuck on Lemon.
|
|
||||||
p->use_lemon_font = p->use_lemon_font ? 0 : 1;
|
|
||||||
_task->applyFont();
|
|
||||||
_dirty = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#if FEAT_DISPLAY_ROTATION_SETTING
|
#if FEAT_DISPLAY_ROTATION_SETTING
|
||||||
if (_selected == ROTATION && p && (left || right || enter)) {
|
if (_selected == ROTATION && p && (left || right || enter)) {
|
||||||
p->display_rotation = (p->display_rotation + (left ? 3 : 1)) & 3;
|
p->display_rotation = (p->display_rotation + (left ? 3 : 1)) & 3;
|
||||||
|
|||||||
@@ -1481,9 +1481,9 @@ void UITask::pickAdminTarget() {
|
|||||||
((NearbyScreen*)nearby_screen)->startPickAdminTarget();
|
((NearbyScreen*)nearby_screen)->startPickAdminTarget();
|
||||||
}
|
}
|
||||||
|
|
||||||
void UITask::openAdminFor(const ContactInfo& ci) {
|
void UITask::openAdminFor(const ContactInfo& ci, bool from_picker) {
|
||||||
setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first
|
setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first
|
||||||
((AdminScreen*)admin_screen)->startFor(ci);
|
((AdminScreen*)admin_screen)->startFor(ci, from_picker);
|
||||||
}
|
}
|
||||||
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
|
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
|
||||||
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
|
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
|
||||||
@@ -2624,7 +2624,8 @@ void UITask::onContactRemoved(const uint8_t* pub_key) {
|
|||||||
// CONTRACT: every NodePrefs field that keys on a channel index is cleared here,
|
// CONTRACT: every NodePrefs field that keys on a channel index is cleared here,
|
||||||
// so a channel re-added at a freed slot can't inherit the old one's settings.
|
// so a channel re-added at a freed slot can't inherit the old one's settings.
|
||||||
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
|
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
|
||||||
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*.
|
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*,
|
||||||
|
// ch_notif_override/ch_notif_muted, ch_fav_bitmask.
|
||||||
void UITask::onChannelRemoved(uint8_t channel_idx) {
|
void UITask::onChannelRemoved(uint8_t channel_idx) {
|
||||||
if (!_node_prefs) return;
|
if (!_node_prefs) return;
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
@@ -2644,6 +2645,15 @@ void UITask::onChannelRemoved(uint8_t channel_idx) {
|
|||||||
_node_prefs->ch_notif_melody_2 &= ~mask;
|
_node_prefs->ch_notif_melody_2 &= ~mask;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (_node_prefs->ch_notif_override & mask) {
|
||||||
|
_node_prefs->ch_notif_override &= ~mask;
|
||||||
|
_node_prefs->ch_notif_muted &= ~mask;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (_node_prefs->ch_fav_bitmask & mask) {
|
||||||
|
_node_prefs->ch_fav_bitmask &= ~mask;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (changed) the_mesh.savePrefs();
|
if (changed) the_mesh.savePrefs();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ public:
|
|||||||
void gotoRingtoneEditor(int slot = 0);
|
void gotoRingtoneEditor(int slot = 0);
|
||||||
void gotoBotScreen();
|
void gotoBotScreen();
|
||||||
void pickAdminTarget(); // Admin is remote-only: open Nodes to pick a repeater/room
|
void pickAdminTarget(); // Admin is remote-only: open Nodes to pick a repeater/room
|
||||||
void openAdminFor(const ContactInfo& ci); // canonical Admin entry for a specific target (Nodes' Hold-Enter menu or the picker above)
|
void openAdminFor(const ContactInfo& ci, bool from_picker); // canonical Admin entry for a specific target (Nodes' Hold-Enter menu or the picker above)
|
||||||
void gotoNearbyScreen();
|
void gotoNearbyScreen();
|
||||||
void gotoDashboardConfig();
|
void gotoDashboardConfig();
|
||||||
void gotoAutoAdvertScreen();
|
void gotoAutoAdvertScreen();
|
||||||
|
|||||||
@@ -1,4 +1,45 @@
|
|||||||
## MeshCore Solo Companion Firmware v1.22
|
## MeshCore Solo Companion Firmware v1.23
|
||||||
|
|
||||||
|
### What's new
|
||||||
|
|
||||||
|
- **Nodes is now the single node hub.** "Nearby Nodes" is renamed **Nodes** and gains on-device contact management — no phone app needed: **Hold Enter** on a row opens **Add contact** (for a scanned-but-unknown node), **Favourite/Unfavourite** (pins to the first free Favourites Dial slot, shown as a star in the list), and **Delete contact** (confirm first, defaults to Cancel). The filter strip (**LEFT/RIGHT**) is now a visible, wraparound tab carousel instead of a hidden mode. The old read-only **Recent adverts** home page is retired — its passively-heard nodes now simply show up in the Nodes list (All filter) — so there's one list instead of two.
|
||||||
|
- **On-device channel management.** Messages › Channels gets a **"+ Add channel"** row and per-channel **Edit**/**Delete** — create or join a channel by typing either a passphrase (hashed to the channel secret, same idea as a room password) or the exact 32-hex-character key from a channel QR code. No phone app required.
|
||||||
|
- **Tools › Admin — administer a remote repeater/room from the device.** Log into a node you have admin rights on (same self-healing saved-password handshake as room logins) and browse its settings in category tabs — **System** (name, owner info), **Radio** (Frequency/Bandwidth/Spreading factor/Coding rate/TX power), **Routing** (Repeat/Advert interval/Flood advert interval/Max hops), **Actions** (reboot, send advert, sync clock, …) — plus a free-text **Custom command…** escape hatch (with **{}**-key command-name completion) for anything not covered. Radio and Routing fields use a type-appropriate editor instead of raw text — a digit-cursor for Frequency (the same widget Settings' own Radio screen uses locally), discrete-set stepping for Bandwidth/Spreading factor/Coding rate, plain number steppers for the rest, and an ON/OFF toggle for Repeat — adjusting a value costs no mesh traffic until you actually confirm it with **Enter** (**Cancel** sends nothing). Reachable either from **Tools › Admin** (opens the same Nodes list to pick a target) or directly via a repeater/room's own **Hold Enter › Admin** action.
|
||||||
|
- **Auto-Reply Bot gains a third target: room servers**, alongside DM and Channel, with its own trigger/reply/commands — the screen is redesigned as a circular tab carousel (**Direct/Channel/Room/Other**, **LEFT/RIGHT** to switch, **UP/DOWN** within a tab) instead of one long list. Each target's **Enable** and **Commands** toggle is now fully independent (they used to secretly share the DM tab's setting for Channel/Room too). New `{name}`/`{hops}` reply placeholders, a DM allow-list (all chat contacts or favourites only), and a dedicated stepper for Quiet Hours.
|
||||||
|
- **Keyboard: more languages, better editing.** Eight more per-language keyboards — Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic — join Cyrillic and Greek under **Settings › Keyboard › Alphabet**, each with a linguistically complete letter set in both the ABC grid and T9 multi-tap groups. Shift is now **one-shot** by default (capitalises just the next letter; **Hold Enter** on Shift toggles the old sticky caps-lock back on), **Hold Enter** on Backspace clears the whole field, and a new **Hold Enter** cursor-positioning mode (**LEFT/RIGHT** move, **UP/DOWN** jump to start/end) lets you edit or insert anywhere in what you've typed, not just at the end.
|
||||||
|
- **Messages get a messenger-style history.** History bubbles are now sized to their content and anchored **right** (outgoing) / **left** (incoming); the list runs **newest at the bottom**, growing upward as you scroll into the past; the **[+ send]** compose button moved to the right edge to match.
|
||||||
|
- **Clock Tools: alarm repeat.** The Alarm screen gains a **Repeat** row (Off/Daily/Weekdays/Weekends) — Hour/Minute merge into one **Time** row edited with an HH:MM digit cursor, and **Repeat**/**Armed** now respond to **LEFT/RIGHT** like every other Settings field instead of Enter-only.
|
||||||
|
- **Diagnostics becomes a tab carousel** (Live/System/Font): **System** adds firmware version + build date, device model and the active radio parameters; **Font** is a rendering test card with one sample line per script the on-device font covers, so coverage can be eyeballed directly.
|
||||||
|
- **One unified display font.** OLED and e-ink now both draw a single misc-fixed 6×9 font (full Latin/Greek/Cyrillic coverage) generated by a new BDF-to-GFX converter (`tools/bdf2gfx.py`), retiring the old Default/Lemon switch and the keyboard's per-render font-swap workaround.
|
||||||
|
- **Discoverable context menus.** Every screen with a **Hold Enter** action menu now shows a small **≡** glyph in its header, which highlights while the menu is open — so the shortcut no longer has to be guessed.
|
||||||
|
- **Status bar and list polish** — background-mode icons (Bluetooth/GPS fix/alarm/mute/auto-advert/trail/live-share/repeater/battery) now drop by priority once space runs out, instead of crushing the node name down to a couple of characters; unread counts everywhere are drawn as a filled pill badge; DM/Channel history headers match every other screen's header style; the default home-page carousel is trimmed to Clock/Tools/Shutdown/Favourites/Map (Recent/Radio/BT/Advert/GPS/Sensors are still available, just opt-in under Settings › Home Pages — existing users' saved layouts are unaffected).
|
||||||
|
- **On-device room Logout** — Messages' room context menu gains a **Logout** entry (mirrors the app's own logout) alongside the existing **Login…**, forgetting the saved password so the next open prompts for one again.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Admin's typed Radio/Routing fields could get stuck on an unreachable value** — a field fetched from a node whose real value sat outside this screen's declared range (e.g. set by a different firmware/build) rejected every further adjustment in both directions. Fetched values are now clamped into range on entry.
|
||||||
|
- **Admin: the CLI's `"> "` reply-decoration prefix leaked into the pre-filled keyboard** when editing Name/Owner info (and the old single Radio-string field) — stripped once, consistently, before either the typed editors or the keyboard consume a reply.
|
||||||
|
- **Admin: Cancel / a failed login always dropped you onto the "pick a node" list**, even if you'd opened Admin directly from a node's own Hold Enter action while just browsing Nodes — it now returns to wherever you actually came from.
|
||||||
|
- **A message arriving while you'd scrolled up to an older one could silently swap in a different message at the same on-screen position** — history entries are numbered newest-first, so an insert used to shift every older message's index without updating your selection. The selection now shifts with it, so you keep looking at the same message.
|
||||||
|
- **One-shot Shift + T9 multi-tap didn't capitalise the letter you actually landed on** — Shift was consumed after the first tap of a cell, so cycling to the 2nd/3rd/etc. candidate always came out lowercase regardless of Shift. It now capitalises whichever candidate you settle on.
|
||||||
|
- **Deleting a channel could leave stale notification/favourite state behind** for whatever channel later got added into that freed slot — channel delete now also clears the per-channel notification override/mute and favourite flag, matching the existing cleanup for the bot/live-share/melody fields.
|
||||||
|
- **A hex channel secret of all zeros silently self-deleted the channel you'd just saved** — that value is reserved internally as the empty-slot marker. Now rejected up front ("Invalid secret") instead of failing invisibly after the save.
|
||||||
|
- **A room's logged-in status could be misreported after 8+ distinct rooms were logged into in one session** — the tracking ring's lookup didn't account for wraparound (only its insert/remove paths did), which the new on-device Logout now depends on for showing the right menu item.
|
||||||
|
- **Bot screen's tab order** now reliably starts on **Channel** even after the later room-target tabs were added (double-checked against the earlier "starts on Channel" fix, which the tab reshuffle could otherwise have silently undone).
|
||||||
|
- Reverted **corner-anchored context-menu popups** back to centred — looked bad on some screens; the header's **≡** discoverability hint stays.
|
||||||
|
- **Unread pill badge digits weren't centred** — the classic built-in OLED font always pads a measured string by one trailing column regardless of the glyph, throwing off centring math that assumed symmetric padding.
|
||||||
|
- **Status bar battery icon** stood 2px taller than its neighbours and its charge nub could drift off-centre at certain box heights — now sized and centred consistently with the rest of the row.
|
||||||
|
- Removed the now-inert **Settings › Display › Font** toggle, a leftover from the font unification above with no remaining effect.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- **Shared `TabBar.h`** extracted from Nodes/Bot's independently duplicated tab-carousel rendering (Admin and Diagnostics are now the 3rd/4th consumers) — also fixes a tab that didn't quite fit vanishing outright instead of ellipsizing.
|
||||||
|
- **`MyMesh::setChannelLocal()`** factors out the setChannel/saveChannels/onChannelRemoved sequence previously duplicated across the BLE app's two `CMD_SET_CHANNEL` branches, now shared with the new on-device channel editor.
|
||||||
|
- **One canonical Admin entry point** — `UITask::openAdminFor()` / `AdminScreen::startFor()` — reached identically whether Admin is opened from Nodes' Hold-Enter action or the target picker.
|
||||||
|
- **`KeyboardWidget` reworked to UTF-8 codepoints** throughout (insert, backspace, T9 in-place cycling), so a multi-byte alt-alphabet character can never be split.
|
||||||
|
- **`NodePrefs` schema grew twice this cycle** — `alarm_repeat_mask`/`keyboard_alt_alphabet`, then the bot room-target/per-target-independence fields — each verified via a standalone host compile and `sizeof`/`offsetof` check, with the schema sentinel bumped both times; existing users' prefs upgrade in place with safe defaults.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### What's new
|
### What's new
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ public:
|
|||||||
if (_text_sz == 4) return 6 * BIG_TEXT_SCALE;
|
if (_text_sz == 4) return 6 * BIG_TEXT_SCALE;
|
||||||
if (_text_sz == 3) return 17;
|
if (_text_sz == 3) return 17;
|
||||||
if (_text_sz == 2) return 12 * sc;
|
if (_text_sz == 2) return 12 * sc;
|
||||||
return (_use_lemon ? 6 : 6) * sc; // misc-fixed 6x9 is 6px wide
|
return 6 * sc; // misc-fixed 6x9 is 6px wide
|
||||||
}
|
}
|
||||||
int getLineHeight() const override {
|
int getLineHeight() const override {
|
||||||
int sc = scale();
|
int sc = scale();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -93,7 +93,7 @@ int16_t SH1106Display::drawLemonChar(int16_t x, int16_t y, uint32_t cp) {
|
|||||||
// Font glyphs come from misc-fixed 6x9 (full Latin/Greek/Cyrillic, ascent 7) —
|
// Font glyphs come from misc-fixed 6x9 (full Latin/Greek/Cyrillic, ascent 7) —
|
||||||
// baseline +7. The custom UI icons above keep +6, so they sit 1px higher.
|
// baseline +7. The custom UI icons above keep +6, so they sit 1px higher.
|
||||||
if (cp < MiscFixed.first || cp > MiscFixed.last) {
|
if (cp < MiscFixed.first || cp > MiscFixed.last) {
|
||||||
if (cp >= 0x20) display.fillRect(x + sz, y - sz, 4*sz, 6*sz, _color);
|
if (cp >= 0x20) display.fillRect(x + sz, y - 7*sz, 4*sz, 6*sz, _color);
|
||||||
return x + 6 * sz;
|
return x + 6 * sz;
|
||||||
}
|
}
|
||||||
const GFXglyph* g = &MiscFixedGlyphs[cp - MiscFixed.first];
|
const GFXglyph* g = &MiscFixedGlyphs[cp - MiscFixed.first];
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class SH1106Display : public DisplayDriver
|
|||||||
uint8_t _color;
|
uint8_t _color;
|
||||||
uint8_t _contrast;
|
uint8_t _contrast;
|
||||||
uint8_t _precharge;
|
uint8_t _precharge;
|
||||||
bool _use_lemon = true; // OLED is single-font (misc-fixed 5x7); the Lemon/default switch is retired here
|
bool _use_lemon = true; // OLED is single-font (misc-fixed 6x9); the Lemon/default switch is retired here
|
||||||
int _text_sz;
|
int _text_sz;
|
||||||
// Frame-skip: endFrame() hashes the GFX buffer (FNV-1a, no external dep — the
|
// Frame-skip: endFrame() hashes the GFX buffer (FNV-1a, no external dep — the
|
||||||
// CRC32 lib is only wired into e-ink builds) and skips the I²C flush when it's
|
// CRC32 lib is only wired into e-ink builds) and skips the I²C flush when it's
|
||||||
@@ -57,13 +57,13 @@ public:
|
|||||||
if (_use_lemon) return lemonXAdvance(cp);
|
if (_use_lemon) return lemonXAdvance(cp);
|
||||||
return 6 * _text_sz; // built-in 5x7 font: 6 px advance per glyph
|
return 6 * _text_sz; // built-in 5x7 font: 6 px advance per glyph
|
||||||
}
|
}
|
||||||
int getCharWidth() const override { return (_use_lemon ? 6 : 6) * _text_sz; } // misc-fixed 6x9 is 6px wide
|
int getCharWidth() const override { return 6 * _text_sz; } // misc-fixed 6x9 is 6px wide
|
||||||
int getLineHeight() const override { return (_use_lemon ? 9 : 8) * _text_sz; } // misc-fixed 6x9 box height
|
int getLineHeight() const override { return (_use_lemon ? 9 : 8) * _text_sz; } // misc-fixed 6x9 box height
|
||||||
// Only the built-in classic font pads every measured string by one trailing
|
// Only the built-in classic font pads every measured string by one trailing
|
||||||
// advance column (see DisplayDriver::textWidthTrailingGap()); the lemon
|
// advance column (see DisplayDriver::textWidthTrailingGap()); the lemon
|
||||||
// font's width comes from its own glyph table (ink-tight, no padding).
|
// font's width comes from its own glyph table (ink-tight, no padding).
|
||||||
int textWidthTrailingGap() const override { return _use_lemon ? 0 : 1; }
|
int textWidthTrailingGap() const override { return _use_lemon ? 0 : 1; }
|
||||||
void setLemonFont(bool) override { } // single-font: ignore toggles, stay misc-fixed 5x7
|
void setLemonFont(bool) override { } // single-font: ignore toggles, stay misc-fixed 6x9
|
||||||
bool isLemonFont() const override { return _use_lemon; }
|
bool isLemonFont() const override { return _use_lemon; }
|
||||||
void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override;
|
void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override;
|
||||||
void setBrightness(uint8_t level) override;
|
void setBrightness(uint8_t level) override;
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ def pack_glyph_bits(g):
|
|||||||
for row in range(h):
|
for row in range(h):
|
||||||
rowval = g['bitmap'][row] if row < len(g['bitmap']) else 0
|
rowval = g['bitmap'][row] if row < len(g['bitmap']) else 0
|
||||||
# BDF rows are left-justified, padded to a multiple of 8 bits.
|
# BDF rows are left-justified, padded to a multiple of 8 bits.
|
||||||
rowbits = ((len(bin(rowval)) - 2 + 7)//8*8) if rowval else 8
|
|
||||||
rowbytes = max(1, (w + 7)//8)
|
rowbytes = max(1, (w + 7)//8)
|
||||||
for col in range(w):
|
for col in range(w):
|
||||||
byte_i = col // 8
|
byte_i = col // 8
|
||||||
|
|||||||
Reference in New Issue
Block a user