diff --git a/AGENTS.md b/AGENTS.md
index 22338f38..a5b7a73e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,7 +15,9 @@ The following rules are non-negotiable:
the production RP2040 UF2, checksums, and end-user instructions.
- Internal CI and developer checks do not create public release requirements and
are never shipped in the production package.
-- Run only focused local checks for changed code. GitHub Actions builds firmware.
+- Run focused checks for changed code. GitHub Actions builds firmware unless
+ the maintainer requests local builds; local releases use the pinned tools,
+ exact clean source, signed updates, and explicit local build provenance.
- Never format an SD card.
- Never probe arbitrary serial ports. Final hardware work uses only the stable,
authorized D1L identity and its required VID/PID.
diff --git a/README.md b/README.md
index 02eb8053..77c6bb2f 100644
--- a/README.md
+++ b/README.md
@@ -6,17 +6,53 @@
A bright, touch-first MeshCore desk for the SenseCAP Indicator D1L.
-DeskOS **1.7.10** is the current full-feature production firmware for the
-SenseCAP Indicator D1L. Download `v1.7.10` from the
-[GitHub release](https://github.com/n30nex/DeskOS-MeshCore/releases/tag/v1.7.10).
-The Actions release is compiled with the immutable `full_feature` profile and
+DeskOS **1.7.12** is the current full-feature production firmware for the
+SenseCAP Indicator D1L. Download `v1.7.12` from the
+[GitHub release](https://github.com/n30nex/DeskOS-MeshCore/releases/tag/v1.7.12).
+The release is compiled with the immutable `full_feature` profile and
`conditional` SD history mode.
-DeskOS is a standalone, dark, touch-first MeshCore client. Firmware is built
-and packaged by GitHub Actions. The release provides the ESP32 update and
+DeskOS is a standalone, dark, touch-first MeshCore client. This release was built
+and packaged locally on the Pi 5 with the pinned toolchains. It provides ESP32 update and
full-clean images, the complete RP2040 SD-bridge UF2, checksums, a signed local
update bundle, and end-user instructions.
+## What 1.7.12 fixes
+
+DeskOS 1.7.12 completes the phone-companion and radio-to-screen repair:
+
+- unused channel slots are reported correctly, so the official app can add and
+ synchronize channels instead of reporting a full radio after Public;
+- direct messages carry their real acknowledgement reference and delivery
+ confirmation, so the phone shows **Delivered** when the radio receives an ACK;
+- repeater requests reuse a fresh authenticated return path, while login still
+ floods and stale or unproven routes fall back to flood;
+- authenticated repeater console replies reach the phone, including valid
+ Unicode names and multiline responses;
+- the full-feature download includes the preserving update installers,
+ complete SD-bridge UF2, signed update bundle, and `START_HERE.md`;
+- BLE frame scratch space now lives in PSRAM instead of consuming a quarter of
+ the small NimBLE host-task stack during the phone's initial channel sync;
+- **Add** is always visible beside **Direct** on Channels, so hashtag channels
+ can be created or imported even while Public already exists;
+- **MQTT / Observer** is clearly named under Connections and its editable
+ three-letter IATA region is labelled directly in the panel; and
+- every verified advert refreshes live Contacts recency, including repeated
+ adverts whose signed identity timestamp is unchanged, without rewriting
+ retained contact history.
+
+## What 1.7.11 fixes
+
+DeskOS 1.7.11 makes Wi-Fi and Bluetooth explicit operating modes:
+
+- tapping the Wi-Fi or BLE status icon switches modes and safely shuts down the
+ other radio stack first;
+- upgrades with both legacy switches enabled select BLE mode once instead of
+ starting both memory-heavy stacks;
+- an incoming phone connection wakes the display and opens a large six-digit
+ pairing prompt automatically; and
+- the BLE panel stays live while pairing and clearly explains that Wi-Fi is off.
+
## What 1.7.10 fixes
DeskOS 1.7.10 is a radio-to-screen reliability and usability patch:
@@ -211,7 +247,9 @@ See the [user guide](docs/USER_GUIDE_D1L.md),
| **1.7.7** | Faster verified-advert admission and Contacts rendering | Historical (`v1.7.7`) |
| **1.7.8** | Dual MQTT uplink, Wi-Fi/BLE coexistence, live recency, map progress, and message time | Historical (`v1.7.8`) |
| **1.7.9** | Stable Wi-Fi/BLE memory ownership and fresh MQTT authentication time | Historical (`v1.7.9`) |
-| **1.7.10** | Faster Contacts, retained maps, stable BLE pairing, editable Observer region, hashtag channels, and reliable flood adverts | Current (`v1.7.10`) |
+| **1.7.10** | Faster Contacts, retained maps, editable Observer region, hashtag channels, and reliable flood adverts | Historical (`v1.7.10`) |
+| **1.7.11** | Crash-safe Wi-Fi/BLE mode switching and an automatic large phone-pairing PIN prompt | Included in `v1.7.12` |
+| **1.7.12** | Stable BLE channel sync, visible channel/IATA controls, and live advert recency | Current (`v1.7.12`) |
## Device UI
diff --git a/docs/BUILD_PROVENANCE_D1L.md b/docs/BUILD_PROVENANCE_D1L.md
index 4fd04550..99c2b5d3 100644
--- a/docs/BUILD_PROVENANCE_D1L.md
+++ b/docs/BUILD_PROVENANCE_D1L.md
@@ -48,6 +48,15 @@ python scripts/provenance_d1l.py --root . --source-sha \
## Local builder v1
+When the maintainer requests local builds, compile the exact clean source with
+the pinned ESP-IDF image for the host architecture and the pinned RP2040 core.
+Use `full_feature` and `conditional`, then pass `--local-build` to
+`scripts/package_release_d1l.py` along with the existing update-signing key.
+This mode records the local builder and exact source commit, leaves Actions
+run fields null, and rejects an Actions invocation. It retains checksum,
+source, firmware-profile, and signed-update validation. It does not create
+physical test evidence or claim that the package is ready for release.
+
Builder URI:
`https://github.com/n30nex/DeskOS-MeshCore/blob/main/docs/BUILD_PROVENANCE_D1L.md#local-builder-v1`
diff --git a/docs/COMPANION_3BYTE_COMPATIBILITY.md b/docs/COMPANION_3BYTE_COMPATIBILITY.md
index bad15cc4..3c121cf9 100644
--- a/docs/COMPANION_3BYTE_COMPATIBILITY.md
+++ b/docs/COMPANION_3BYTE_COMPATIBILITY.md
@@ -39,16 +39,14 @@ DeskOS 1.5 status:
passkey, encryption, authentication, and bonding.
- A connection is not protocol-ready until the client subscribes to TX
notifications.
-- Repeated pairing is rejected until the owner deliberately forgets the old
- peer on-device.
+- Repeated pairing replaces that connecting peer's stale bond and retries.
- Private-key import/export, factory reset, and remote reboot are disabled over
BLE even though those command numbers exist in the wider companion protocol.
- Frames are bounded by the negotiated ATT MTU and fixed queues. Oversize,
malformed, unauthenticated, or unsubscribed traffic fails closed and is
counted without logging payloads or secrets.
-- Wi-Fi and BLE can remain active together. Their enable, disable, and restart
- transitions are serialized by the connectivity manager so stack ownership
- changes cannot race.
+- Wi-Fi and BLE are exclusive modes. Their transitions are serialized by the
+ connectivity manager, which fully stops one stack before starting the other.
## 2. 3-Byte Path Hash Support
diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md
index add334da..d7ffb564 100644
--- a/docs/KNOWN_LIMITATIONS.md
+++ b/docs/KNOWN_LIMITATIONS.md
@@ -1,4 +1,4 @@
-# DeskOS D1L 1.7.10 limitations
+# DeskOS D1L 1.7.12 limitations
The RC1 channel dead-end (#320) and Contacts navigation gap (#321) are fixed in
the 1.2 implementation. These are the remaining intentional product limits:
@@ -16,9 +16,9 @@ SD-primary storage.
- Fresh Map download also requires user-configured Wi-Fi and an HTTPS provider
manifest that explicitly permits offline storage and background prefetch.
OpenStreetMap Standard remains visible-current-view-only.
-- BLE companion and Wi-Fi can run together through the ESP32-S3 coexistence
- controller. Heavy Wi-Fi traffic can still reduce Bluetooth responsiveness
- because both share the same 2.4 GHz radio.
+- BLE companion and Wi-Fi are separate operating modes. Selecting either Home
+ status icon safely stops the other stack first. Observer and fresh Map tile
+ downloads pause in BLE mode; cached maps and RF messaging remain available.
- QR export is deliberately limited to supported public contact and channel
material. It is not a general QR generator and never exports secrets.
- Signed update is local-SD only. It does not download firmware or accept an
@@ -28,7 +28,7 @@ SD-primary storage.
- New messages use a plausible sender timestamp or the trusted local arrival
time. Older retained rows without either remain labelled `time unknown`.
- Optional Indicator temperature, humidity, and CO2 sensor integration remains
- future work and is not represented as live data in 1.7.10.
+ future work and is not represented as live data in 1.7.12.
See [`DESKOS_MESHCORE_FEATURE_PARITY.md`](DESKOS_MESHCORE_FEATURE_PARITY.md)
for the complete mobile-to-D1L outcome matrix.
diff --git a/docs/RELEASE_NOTES_1.7.11.md b/docs/RELEASE_NOTES_1.7.11.md
new file mode 100644
index 00000000..b7ce54df
--- /dev/null
+++ b/docs/RELEASE_NOTES_1.7.11.md
@@ -0,0 +1,31 @@
+# MeshCore DeskOS D1L 1.7.11
+
+This patch makes Bluetooth phone pairing stable and visible on the SenseCAP
+Indicator D1L.
+
+## Fixed
+
+- Wi-Fi and Bluetooth now run as explicit, mutually exclusive modes. Selecting
+ one stops and releases the other stack before continuing.
+- Tapping the Wi-Fi or BLE status icon on Home switches directly to that mode.
+- Legacy upgrades that saved both switches as enabled migrate safely to BLE
+ mode instead of starting both stacks.
+- An incoming BLE connection wakes the display and automatically opens a large
+ `123456` pairing prompt.
+- The Bluetooth panel refreshes throughout pairing and clearly reports that
+ Wi-Fi, online Map downloads, and Observer/MQTT are paused in BLE mode.
+
+## Preserved
+
+- Device identity, contacts, channels, messages, radio settings, Wi-Fi profiles,
+ Observer settings, SD data, and the RP2040 bridge are preserved by the normal
+ ESP32 application update.
+- RF messaging and cached maps continue to work in either connectivity mode.
+- All DeskOS 1.7.10 performance, Map, Observer, channel, timestamp, and flood
+ advert fixes remain included.
+
+## Install
+
+Existing DeskOS devices use the application update image at offset `0x20000`.
+Use the full-clean image only for a new installation or deliberate reset. The
+RP2040 UF2 and SD card do not need to be changed for this patch.
diff --git a/docs/RELEASE_NOTES_1.7.12.md b/docs/RELEASE_NOTES_1.7.12.md
new file mode 100644
index 00000000..60bd3837
--- /dev/null
+++ b/docs/RELEASE_NOTES_1.7.12.md
@@ -0,0 +1,99 @@
+# MeshCore DeskOS D1L 1.7.12
+
+This patch stabilizes Bluetooth phone synchronization and makes the remaining
+channel, MQTT, and recent-contact controls obvious on the SenseCAP Indicator
+D1L.
+
+## Fixed
+
+- Android startup synchronization no longer requests a full UI snapshot from
+ the Bluetooth worker. Battery and storage now use the storage service's own
+ bounded status copy, preventing the reconnect crash seen after pairing.
+- Saved channels are returned to the phone correctly when it requests each
+ slot, and the contact-list header now matches the official MeshCore
+ companion protocol.
+- The advertised device name is now `MeshCore-DeskOS`, matching the naming
+ pattern used by MeshCore clients when discovering companion radios.
+- Current MeshCore phone clients can read core, radio, and packet statistics
+ and clear their transient flood scope without connection-time protocol
+ errors.
+- One-, two-, and three-byte contact routes now retain their official encoded
+ path shape, and changing path mode from the phone uses the official command.
+- BLE receive and transmit frame scratch space is allocated in PSRAM instead
+ of on the 4 KB NimBLE host-task stack. Initial phone synchronization can pull
+ the complete saved channel list without exhausting that callback stack.
+- The BLE TX characteristic now matches the official MeshCore readable and
+ notify shape, negotiates a 517-byte ATT MTU explicitly, and spaces companion
+ replies by 60 ms so phone synchronization is not starved or flooded.
+- The NimBLE host task has a measured 6 KB stack reserve for Android's initial
+ secure pairing, MTU, service discovery, channel, and contact synchronization
+ burst.
+- `ble status` now reports the secure-link, MTU, queue, frame, and protocol
+ counters needed to diagnose a phone session without enabling verbose logs.
+- Channels always shows **Add** beside **DMs**. The existing create and
+ import screen is reachable even when Public is already configured.
+- Connections now names **MQTT / Observer** directly. Its panel labels the
+ editable three-letter **IATA region** and confirms a successful save.
+- A repeated verified advert refreshes boot-local last-heard time and Contacts
+ sorting while leaving retained identity, location, sequence, and persistence
+ state unchanged.
+- Blank-password repeater login from the phone now remains Guest even when the
+ D1L identity already has an admin ACL entry. Guest sessions can read status,
+ telemetry, and neighbours but cannot use ACL or command controls. That Guest
+ intent is retained across a brief Bluetooth reconnect, so restoring the
+ active repeater session cannot silently promote it to Admin. Repeated phone
+ requests can join the same login without changing its Guest/Admin access
+ level; a conflicting retry is rejected without cancelling the radio request.
+- Recent verified advert routes are kept in a bounded boot-only cache and
+ returned through the official companion command, removing the remaining
+ contact-detail protocol error without adding flash writes.
+- The contact store now retains up to 64 nodes. Existing 16-slot data migrates
+ in place, and newly heard repeaters are pushed to the phone as complete new
+ contacts instead of unusable update-only notifications.
+- Pending-message sync now includes every configured hashtag channel, keeps
+ the received wall-clock timestamp and sender name, and re-announces unread
+ traffic after a Bluetooth reconnect without replaying old history.
+- Phone-requested nearby and flood adverts use the same nonblocking radio-owner
+ queue as the top button, avoiding false timeout errors while earlier radio
+ traffic finishes.
+- Direct adverts return a valid zero-hop route. Active repeater-management
+ sessions recover across a brief phone reconnect with the same access level;
+ an explicit login or logout replaces or clears that session.
+- Repeater command buttons accept phone line endings and fixed-field padding,
+ so Clock Sync, Advert, and other admin actions reach the authenticated radio
+ session instead of failing local validation.
+- Clock Sync uses DeskOS's validated wall clock rather than its independent
+ anti-replay counter, preventing an incorrect future repeater clock while
+ retaining replay-safe packet tags.
+- Advanced companion commands that DeskOS cannot safely perform now report
+ **Disabled** instead of the phone app's ambiguous generic **Error**.
+- Home destination cards now use the same **Channels**, **Contacts**, **Map**,
+ and **Settings** names as the dock, with immediate pressed-state feedback.
+- Contacts labels its destructive boot-local action **Clear nearby**, making it
+ clear that saved contacts are not removed.
+- Repeater and room management now gives nested pages one familiar **Back**
+ action instead of duplicate Back/Close choices. Login explicitly separates
+ **Admin** and **Guest**, request cancellation states that it signs out, and
+ status uses plain-language error and power values.
+- Contact Detail now labels the node ID, direct/flood route, signal quality,
+ and optional last-heard path in plain language instead of exposing terse
+ store terminology.
+- An unconfigured Map presents one highlighted **Set up** action and hides map
+ attribution and node-location truth overlays until a trusted center exists.
+ Configured maps retain **Options**, attribution, and verified marker status.
+
+## Preserved
+
+- Wi-Fi and Bluetooth remain explicit, mutually exclusive modes.
+- Pairing still requires Secure Connections, MITM protection, encryption,
+ authentication, bonding, and the displayed `123456` PIN.
+- Device identity, contacts, channels, messages, radio settings, Wi-Fi
+ profiles, Observer settings, SD data, and the RP2040 bridge are preserved by
+ the normal ESP32 application update.
+- DeskOS never formats the SD card and never sends RF traffic during an update.
+
+## Install
+
+Existing DeskOS devices use the application update image at offset `0x20000`.
+Use the full-clean image only for a new installation or deliberate reset. The
+RP2040 UF2 and SD card do not need to be changed for this patch.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 2144ea01..cc5e2f23 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -15,7 +15,9 @@
| **1.7.7** | Faster verified-advert admission and Contacts rendering | Historical (`v1.7.7`) |
| **1.7.8** | Dual MQTT uplink, Wi-Fi/BLE coexistence, live recency, map progress, and message time | Historical (`v1.7.8`) |
| **1.7.9** | Stable Wi-Fi/BLE memory ownership and fresh MQTT authentication time | Historical (`v1.7.9`) |
-| **1.7.10** | Faster Contacts, retained maps, stable BLE pairing, editable Observer region, hashtag channels, and reliable flood adverts | Current (`v1.7.10`) |
+| **1.7.10** | Faster Contacts, retained maps, editable Observer region, hashtag channels, and reliable flood adverts | Historical (`v1.7.10`) |
+| **1.7.11** | Crash-safe Wi-Fi/BLE mode switching and an automatic large phone-pairing PIN prompt | Included in `v1.7.12` |
+| **1.7.12** | Stable BLE channel sync, visible channel/IATA controls, and live advert recency | Current (`v1.7.12`) |
The release firmware is the ordinary public product. A controlled peer, Wi-Fi
credentials, admin password, soak run, qualification firmware, or validation
@@ -145,3 +147,30 @@ lightweight sorting, while Map retains and resumes the current frame across
ordinary UI navigation. Observer gains an editable IATA region, Channels can
join standard hashtag-derived channels directly, and received messages fall
back to trusted local arrival time when the packet timestamp is unusable.
+
+## 1.7.11: explicit connectivity modes
+
+DeskOS 1.7.11 stops and releases one ESP32-S3 radio stack before starting the
+other. Home Wi-Fi and BLE status icons now select that mode directly, and old
+saved configurations with both enabled migrate to BLE mode. A phone connection
+wakes the display and automatically shows the fixed six-digit MeshCore pairing
+PIN in a large live panel.
+
+## 1.7.12: companion sync and discoverability
+
+DeskOS 1.7.12 moves BLE receive and transmit frame scratch space out of the
+small NimBLE host-task stack and into PSRAM, preventing the phone's initial
+channel synchronization burst from resetting the D1L. Channels always exposes
+an **Add** entry, Connections names **MQTT / Observer** explicitly, and the
+editable IATA field is labelled in place. Repeated verified adverts now update
+boot-local recency and refresh Contacts without mutating retained identity or
+location history.
+
+The final 1.7.12 correction also exposes empty channel slots to phone clients,
+reports actual DM acknowledgement references and delivery events, admits
+authenticated repeater console replies, preserves valid UTF-8 responses, and
+uses a fresh authenticated PATH response for subsequent server requests.
+Login and unproven/stale routes retain flood delivery. The local production
+package now includes the same preserving update and SD preparation helpers as
+the standard production package, with explicit local provenance and the
+existing signed-update identity.
diff --git a/docs/UI_SPEC_480x480_DARK.md b/docs/UI_SPEC_480x480_DARK.md
index 08cc1e4f..9ad351cc 100644
--- a/docs/UI_SPEC_480x480_DARK.md
+++ b/docs/UI_SPEC_480x480_DARK.md
@@ -25,21 +25,28 @@ available in development builds, not in customer firmware:
- Home keeps its compact status treatment. Every non-Home destination owns a
compact mobile-style app bar, so the old global diagnostic header does not
consume chat, list, settings, or map space.
-- Home remains the quiet dashboard. The persistent 48 px bottom dock is
- `Home`, `Channels`, `Contacts`, `Map`, and `Settings`.
+- Home remains the quiet dashboard. Its four destination cards use the same
+ `Channels`, `Contacts`, `Map`, and `Settings` names as the persistent 48 px
+ bottom dock, so opening a card never appears to change terminology.
- Channels is list-first: Public, #bot, and #test are ordinary channel rows with
- selection and unread state, while Direct opens direct conversations. Opening
+ selection and unread state, while DMs opens direct conversations. Opening
a channel shows message bubbles and a composer affordance routed through the
app model. Raw route and radio evidence is available from message detail, not
printed across the conversation list.
- Message Detail and DM Thread are full-screen nested pages with one Back
control, a scrolling body, and a sticky Reply action. Long Public text sizes
before Technical details; opening a DM thread marks it read.
-- Contacts mirrors the mobile client hierarchy: Find and Clear actions, Saved
- and Nearby sections, compact role-aware rows, and a direct Message action
- only for compatible contacts. Duplicate nearby entries are suppressed.
-- Map is the actual current-view surface, not a setup dashboard. The map canvas fills the complete content region above the dock; there is no global diagnostic header or redundant map-local title row. OpenStreetMap Standard is dark-styled locally after decode, preserving the single built-in source/cache and attribution while giving bright signed-advert node markers and their names below them strong contrast. Marker refresh is a bounded lightweight overlay and does not rebuild tiles: it reads at most the 32 newest located nodes, displays at most eight, keeps its non-clickable marker/name layer aligned with the tile image during drag, and skips a marker when its required below-marker name would collide with an earlier marker, a control, progress/status copy, or attribution. A viewport-level 44 px hit area opens existing Contact Detail by retained fingerprint; the detail row says `Shared location`, and closing it reacquires the unchanged retained Map view. Its sparse edge overlays provide one-finger pan, direct 48x48-or-larger `-`, `+`, and `Center` controls, one `Options` setup action, an unobtrusive zoom/status badge, and the always-visible ASCII attribution `(c) OpenStreetMap contributors`. It starts at regional zoom 10, clamps user zoom to the runtime range 8 through 18 subject to provider limits, and `Center` returns to the saved manual location. D1L has no onboard GPS; peer coordinates are labelled `Shared location`, never live GPS. If the SD file gate is still preparing, the uncluttered overlay says `Waiting for SD` and the same physically opened generation resumes automatically when ready. While a bounded plan is active, the drag hint becomes a compact `Loading n/N` or `Downloading n/N` label above a thin completed-tile progress bar rather than leaving a static spinner/message with no progress feedback. The open Map requests only its visible 3x3 tile window; separately authorized background prefetch may run only while Map is closed and its saved provider, location, Wi-Fi, and SD prerequisites are ready.
-- Contact Detail uses the full-height nested-page pattern. A canonical chat contact exposes only `Message` and `Contact options`; repeater, room, sensor, or unknown roles do not expose a dead Message action. Contact Options owns Route, Export, Rename, favorite/mute state, and the destructive contact-removal path. Export QR is actionable only for a retained full public key plus a known canonical MeshCore role; an unknown or malformed role shows a non-clickable unavailable row. Route, Export, and Rename return to Contact Options rather than flattening those functions back into Contact Detail.
+- Contacts mirrors the mobile client hierarchy: Find and explicit Clear nearby
+ actions, Saved and Nearby sections, compact role-aware rows, and a direct
+ Message action only for compatible contacts. Duplicate nearby entries are
+ suppressed.
+- Repeater and room administration uses an explicit Admin-or-Guest login,
+ visible working state, and one Back control on each nested result or command
+ page. Cancelling a pending request says that it also signs out. Status uses
+ plain-language error and power values; raw protocol state stays in advanced
+ diagnostics.
+- Map is the actual current-view surface, not a setup dashboard. The map canvas fills the complete content region above the dock; there is no global diagnostic header or redundant map-local title row. Before a trusted center exists, the uncluttered empty state exposes one highlighted `Set up` action and no irrelevant attribution or marker-truth badges. OpenStreetMap Standard is dark-styled locally after decode, preserving the single built-in source/cache and attribution while giving bright signed-advert node markers and their names below them strong contrast. Marker refresh is a bounded lightweight overlay and does not rebuild tiles: it reads at most the 32 newest located nodes, displays at most eight, keeps its non-clickable marker/name layer aligned with the tile image during drag, and skips a marker when its required below-marker name would collide with an earlier marker, a control, progress/status copy, or attribution. A viewport-level 44 px hit area opens existing Contact Detail by retained fingerprint; the detail row says `Shared location`, and closing it reacquires the unchanged retained Map view. Its sparse edge overlays provide one-finger pan, direct 48x48-or-larger `-`, `+`, and `Center` controls, one `Options` action after setup, an unobtrusive zoom/status badge, and the ASCII attribution `(c) OpenStreetMap contributors` whenever a trusted map center exists. It starts at regional zoom 10, clamps user zoom to the runtime range 8 through 18 subject to provider limits, and `Center` returns to the saved manual location. D1L has no onboard GPS; peer coordinates are labelled `Shared location`, never live GPS. If the SD file gate is still preparing, the uncluttered overlay says `Waiting for SD` and the same physically opened generation resumes automatically when ready. While a bounded plan is active, the drag hint becomes a compact `Loading n/N` or `Downloading n/N` label above a thin completed-tile progress bar rather than leaving a static spinner/message with no progress feedback. The open Map requests only its visible 3x3 tile window; separately authorized background prefetch may run only while Map is closed and its saved provider, location, Wi-Fi, and SD prerequisites are ready.
+- Contact Detail uses the full-height nested-page pattern. It summarizes the contact type/state, `Node ID`, direct or flood route, human signal quality, and optional last-heard path. A canonical chat contact exposes only `Message` and `Contact options`; repeater, room, sensor, or unknown roles do not expose a dead Message action. Contact Options owns Route, Export, Rename, favorite/mute state, and the destructive contact-removal path. Export QR is actionable only for a retained full public key plus a known canonical MeshCore role; an unknown or malformed role shows a non-clickable unavailable row. Route, Export, and Rename return to Contact Options rather than flattening those functions back into Contact Detail.
- Removing a contact requires a dedicated confirmation page. `Cancel` and every `Back` path are non-destructive and restore Contact Options; only the explicit confirmation callback may delete the retained contact.
- Packet log is a Settings diagnostic screen with bounded recent packet rows,
route rows, first route detail, and first packet detail.
diff --git a/docs/USER_GUIDE_D1L.md b/docs/USER_GUIDE_D1L.md
index c0b645fa..c52a7e1a 100644
--- a/docs/USER_GUIDE_D1L.md
+++ b/docs/USER_GUIDE_D1L.md
@@ -1,11 +1,11 @@
-# MeshCore DeskOS D1L 1.7.10 User Guide
+# MeshCore DeskOS D1L 1.7.12 User Guide
This guide covers the production `full_feature` firmware with `conditional` SD
history for the Seeed SenseCAP Indicator D1L. DeskOS is a non-forwarding
MeshCore client: it sends and receives user-requested traffic but does not
repeat other devices' traffic.
-DeskOS 1.7.10 includes secure BLE companion access, public-data QR sharing,
+DeskOS 1.7.12 includes secure BLE companion access, public-data QR sharing,
signed local updates with rollback, touch-first repeater management, and the
guided bridge and SD installation path. The remaining intentional limits and
D1L adaptations are in
@@ -42,7 +42,8 @@ Wi-Fi remains optional for offline MeshCore use. The prepared FAT32 card and
NRCan provider manifest are required for the complete 1.5 setup.
The dock is **Home**, **Channels**, **Contacts**, **Map**, and **Settings**.
-Home is a summary page; lists and long pages scroll vertically.
+Home uses those same four destination names, so the card you open matches the
+selected dock tab. Lists and long pages scroll vertically.
A factory-fresh DeskOS does not advertise before onboarding is complete. When
the entered name is saved, DeskOS starts MeshCore receive and queues the first
@@ -58,6 +59,7 @@ reseeded.
The Public channel is always configured. Open **Channels** and tap Public or
any other enabled channel to select it and immediately open its conversation.
+Choose **DMs** for private conversations with verified contacts.
Use the channel controls to create or import, select, enable, rename, make
default, and remove with local confirmation. A selected channel can display a
one-time QR containing only its supported public import URI.
@@ -83,8 +85,10 @@ Contacts can be imported from the USB console with
`contacts import `. The touchscreen list shows every saved
contact. Use **Search** to match name, role, fingerprint, or public key, and use
**Sort** to cycle **Recent**, **A-Z**, **Role**, and **Signal**. Selecting a row
-shows direct **Message** and **Manage** actions alongside rename, favorite,
-mute, and confirmed removal.
+opens a plain-language summary of its node ID, route, signal quality, and
+last-heard path. Verified chat contacts expose **Message** and **Contact
+options**; rename, favorite, mute, export, and confirmed removal stay under
+Contact options.
**Message** opens the existing DM composer/thread for a verified Chat or
Companion contact. **Manage** opens node detail for a Repeater or Room and
@@ -100,7 +104,8 @@ The same Contacts area provides:
- **Ping** on a repeater sends a direct zero-hop TRACE.
- **PATH/TRACE** on a verified contact displays pending, timeout, reply, RTT,
RSSI and hop SNR state.
-- **Clear** requires confirmation and removes the retained heard-node list.
+- **Clear nearby** requires confirmation and removes the retained heard-node
+ list without deleting saved contacts.
Ordinary inspection, scrolling, filtering and refresh are RF-silent.
The retained list is bounded to 512 nodes. At capacity, only an unlocated
@@ -111,7 +116,9 @@ located, a new fingerprint is rejected rather than evicting a marker.
## Map
The D1L has no onboard GPS. The GPS/location boundary is explicit: set the
-device location from the Map location workflow. DeskOS centers on that
+device location from the Map location workflow. An unconfigured device shows
+one highlighted **Set up** action; map attribution and node-location status
+appear only after a trusted map center exists. DeskOS centers on that
configured location and plots only valid signed peer-advert coordinates.
Markers follow the bounded retained-node list and update when newer signed
data replaces an advert.
@@ -170,18 +177,19 @@ in UTC.
## BLE companion
-Open **Settings -> Connections -> BLE**, enable BLE, then choose **Pair**. The
-screen shows the current advertising, pairing, security, protocol, and failure
-state. Confirm the displayed six-digit passkey in the companion client. Use
-**Forget** to remove the retained bond before changing owners or clients.
+Tap the **BLE** status icon on Home, or open **Settings -> Connections -> BLE**.
+DeskOS switches to Bluetooth mode and safely turns Wi-Fi off. When a phone
+connects, the display wakes and opens a large six-digit pairing prompt
+automatically. Enter that PIN in the MeshCore app. Use **Forget** to remove the
+retained bond before changing owners or clients.
Transport does not become ready until the connection is encrypted,
authenticated, bonded, and subscribed to notifications. The implementation
uses the official MeshCore service/RX/TX UUIDs and presents the existing
-three-byte companion protocol to the single-owner MeshCore runtime. BLE and
-Wi-Fi can remain enabled together. Leave Wi-Fi on while pairing if you also use
-online maps or Observer/MQTT; heavy network traffic may briefly reduce Bluetooth
-responsiveness, but neither connection should need to be disabled.
+three-byte companion protocol to the single-owner MeshCore runtime. Wi-Fi and
+BLE do not run together. Tap the **Wi-Fi** status icon to leave Bluetooth mode
+and restore online maps and Observer/MQTT; RF messaging and cached maps continue
+to work in either mode.
If the same phone presents an expired bond, DeskOS replaces that phone's bond
and retries pairing instead of restarting the device or clearing other peers.
@@ -218,14 +226,16 @@ unavailable.
The administration capability uses an exact verified Repeater or Room key and
a masked password. Choose **Login** beside a saved repeater or room contact, or
open its detail and choose **Login**. DeskOS opens a large password field and
-on-screen keyboard. **Save: On** remembers a non-empty password for that server
-on this D1L after login succeeds; **Forget saved** removes it. Blank-password
-negotiation remains available when supported by the peer.
+on-screen keyboard. Choose **Admin** after entering a password, or **Guest**
+without one for read-only access. **Save On** remembers a non-empty password
+for that server on this D1L after login succeeds; **Forget** removes it.
After authentication, DeskOS opens a command dashboard rather than returning
to the contact list. The server reports the session permissions (guest,
read-only, write or admin). Logging out or switching targets clears volatile
-session authority.
+session authority. Nested status and command pages use one **Back** action to
+return to the manager. **Sign out** ends the session; cancelling a pending
+request also says clearly that it signs out.
The on-device admin surface provides:
@@ -336,7 +346,7 @@ private-message content, passwords, keys, or admin credentials.
## Installation
-Use the guided browser flasher or the published DeskOS D1L 1.7.10 download and
+Use the guided browser flasher or the published DeskOS D1L 1.7.12 download and
follow its `START_HERE.md`.
On Linux, select the D1L only through the stable by-id path:
diff --git a/main/app/app_model.c b/main/app/app_model.c
index 2353788e..ea61e1db 100644
--- a/main/app/app_model.c
+++ b/main/app/app_model.c
@@ -1062,7 +1062,12 @@ esp_err_t d1l_app_model_delete_contact(const char *fingerprint, d1l_contact_entr
if (forget_ret != ESP_OK && forget_ret != ESP_ERR_NOT_FOUND) {
return forget_ret;
}
- return d1l_contact_store_delete(fingerprint, out_contact);
+ esp_err_t delete_ret = d1l_contact_store_delete(fingerprint, out_contact);
+ if (delete_ret == ESP_ERR_INVALID_STATE || delete_ret == ESP_ERR_TIMEOUT ||
+ delete_ret == ESP_FAIL) {
+ delete_ret = d1l_contact_store_delete(fingerprint, out_contact);
+ }
+ return delete_ret;
}
esp_err_t d1l_app_model_export_contact_uri(const char *fingerprint, char *dest, size_t dest_size)
diff --git a/main/comms/ble_companion.c b/main/comms/ble_companion.c
index e858c59f..96551f9b 100644
--- a/main/comms/ble_companion.c
+++ b/main/comms/ble_companion.c
@@ -5,7 +5,9 @@
#include "comms/ble_companion_protocol.h"
#include "comms/ble_companion_queue.h"
#include "comms/companion_3byte.h"
+#include "esp_attr.h"
#include "esp_random.h"
+#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "sdkconfig.h"
@@ -71,8 +73,14 @@ static uint32_t s_rx_drop_count;
static uint32_t s_tx_drop_count;
static uint32_t s_malformed_frame_count;
static uint32_t s_security_reject_count;
+static int64_t s_last_tx_started_us;
static esp_err_t s_last_error = ESP_OK;
static int s_last_nimble_error;
+static uint8_t s_rx_payload[D1L_COMPANION3_MAX_FRAME_SIZE] EXT_RAM_BSS_ATTR;
+static uint8_t s_rx_frame[D1L_BLE_COMPANION_WIRE_FRAME_MAX] EXT_RAM_BSS_ATTR;
+static uint8_t s_tx_frame[D1L_BLE_COMPANION_WIRE_FRAME_MAX] EXT_RAM_BSS_ATTR;
+
+#define D1L_BLE_COMPANION_TX_MIN_INTERVAL_US 60000LL
/* NimBLE UUID byte order follows the official ESP-IDF bleprph example. */
static const ble_uuid128_t s_service_uuid =
@@ -156,6 +164,8 @@ static void reset_connection_locked(void)
s_notification_enabled = false;
s_connection_handle = BLE_HS_CONN_HANDLE_NONE;
s_att_mtu = D1L_BLE_COMPANION_DEFAULT_ATT_MTU;
+ s_pairing_passkey = 0U;
+ s_last_tx_started_us = 0;
clear_session_queues_locked();
}
@@ -183,9 +193,8 @@ static int rx_access(uint16_t connection_handle, uint16_t attribute_handle,
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
- uint8_t payload[D1L_COMPANION3_MAX_FRAME_SIZE];
uint16_t flattened_len = 0U;
- if (ble_hs_mbuf_to_flat(context->om, payload, sizeof(payload),
+ if (ble_hs_mbuf_to_flat(context->om, s_rx_payload, sizeof(s_rx_payload),
&flattened_len) != 0 ||
flattened_len != payload_len) {
portENTER_CRITICAL(&s_lock);
@@ -194,10 +203,9 @@ static int rx_access(uint16_t connection_handle, uint16_t attribute_handle,
return BLE_ATT_ERR_UNLIKELY;
}
- uint8_t frame[D1L_BLE_COMPANION_WIRE_FRAME_MAX];
size_t frame_len = 0U;
- if (d1l_companion3_encode(D1L_COMPANION3_APP_TO_RADIO, payload,
- payload_len, frame, sizeof(frame),
+ if (d1l_companion3_encode(D1L_COMPANION3_APP_TO_RADIO, s_rx_payload,
+ payload_len, s_rx_frame, sizeof(s_rx_frame),
&frame_len) != ESP_OK) {
portENTER_CRITICAL(&s_lock);
s_malformed_frame_count++;
@@ -207,7 +215,7 @@ static int rx_access(uint16_t connection_handle, uint16_t attribute_handle,
portENTER_CRITICAL(&s_lock);
const d1l_ble_companion_queue_result_t result =
- d1l_ble_companion_queue_push(&s_rx_queue, frame, frame_len);
+ d1l_ble_companion_queue_push(&s_rx_queue, s_rx_frame, frame_len);
if (result == D1L_BLE_QUEUE_OK) {
s_rx_frame_count++;
} else {
@@ -221,14 +229,19 @@ static int rx_access(uint16_t connection_handle, uint16_t attribute_handle,
static int tx_access(uint16_t connection_handle, uint16_t attribute_handle,
struct ble_gatt_access_ctxt *context, void *arg)
{
- (void)connection_handle;
- (void)attribute_handle;
- (void)context;
(void)arg;
+ if (!context || attribute_handle != s_tx_value_handle ||
+ context->op != BLE_GATT_ACCESS_OP_READ_CHR) {
+ return BLE_ATT_ERR_UNLIKELY;
+ }
+ if (!connection_authorized(connection_handle, NULL)) {
+ return BLE_ATT_ERR_INSUFFICIENT_AUTHEN;
+ }
- /* NimBLE requires every registered characteristic to have an access
- * callback, even when its value is delivered only with notify_custom. */
- return BLE_ATT_ERR_READ_NOT_PERMITTED;
+ /* Official MeshCore ESP32 companions expose TX as readable + notify.
+ * Protocol frames still arrive only as notifications; an empty secure
+ * read preserves that GATT shape without replaying stale private data. */
+ return 0;
}
static const struct ble_gatt_svc_def s_gatt_services[] = {
@@ -248,7 +261,10 @@ static const struct ble_gatt_svc_def s_gatt_services[] = {
{
.uuid = &s_tx_uuid.u,
.access_cb = tx_access,
- .flags = BLE_GATT_CHR_F_NOTIFY |
+ .flags = BLE_GATT_CHR_F_READ |
+ BLE_GATT_CHR_F_NOTIFY |
+ BLE_GATT_CHR_F_READ_ENC |
+ BLE_GATT_CHR_F_READ_AUTHEN |
BLE_GATT_CHR_F_NOTIFY_INDICATE_ENC |
BLE_GATT_CHR_F_NOTIFY_INDICATE_AUTHEN,
.val_handle = &s_tx_value_handle,
@@ -364,19 +380,22 @@ static void finish_tx(bool delivered, int nimble_error)
static void pump_tx(void)
{
- uint8_t frame[D1L_BLE_COMPANION_WIRE_FRAME_MAX];
size_t frame_len = 0U;
uint16_t connection_handle = BLE_HS_CONN_HANDLE_NONE;
bool ready = false;
+ const int64_t now_us = esp_timer_get_time();
portENTER_CRITICAL(&s_lock);
ready = s_connected && s_encrypted && s_authenticated && s_bonded &&
s_notification_enabled && !s_tx_busy &&
- s_tx_queue.count > 0U;
+ s_tx_queue.count > 0U &&
+ (s_last_tx_started_us == 0 ||
+ now_us - s_last_tx_started_us >=
+ D1L_BLE_COMPANION_TX_MIN_INTERVAL_US);
if (ready) {
const d1l_ble_companion_queue_result_t peek =
- d1l_ble_companion_queue_peek(&s_tx_queue, frame,
- sizeof(frame), &frame_len);
+ d1l_ble_companion_queue_peek(&s_tx_queue, s_tx_frame,
+ sizeof(s_tx_frame), &frame_len);
if (peek == D1L_BLE_QUEUE_OK) {
s_tx_busy = true;
connection_handle = s_connection_handle;
@@ -394,7 +413,7 @@ static void pump_tx(void)
const uint16_t payload_len =
(uint16_t)(frame_len - D1L_COMPANION3_HEADER_SIZE);
struct os_mbuf *buffer = ble_hs_mbuf_from_flat(
- &frame[D1L_COMPANION3_HEADER_SIZE], payload_len);
+ &s_tx_frame[D1L_COMPANION3_HEADER_SIZE], payload_len);
if (!buffer) {
finish_tx(false, BLE_HS_ENOMEM);
return;
@@ -403,9 +422,18 @@ static void pump_tx(void)
s_tx_value_handle, buffer);
if (rc != 0) {
finish_tx(false, rc);
+ } else {
+ portENTER_CRITICAL(&s_lock);
+ s_last_tx_started_us = now_us;
+ portEXIT_CRITICAL(&s_lock);
}
}
+void d1l_ble_companion_poll(void)
+{
+ pump_tx();
+}
+
static void update_security(uint16_t connection_handle)
{
struct ble_gap_conn_desc desc = {0};
@@ -457,6 +485,7 @@ static int gap_event(struct ble_gap_event *event, void *arg)
reset_connection_locked();
s_connected = true;
s_connection_handle = event->connect.conn_handle;
+ s_pairing_passkey = D1L_BLE_COMPANION_STATIC_PASSKEY;
s_connect_count++;
s_state = D1L_BLE_STATE_PAIRING;
portEXIT_CRITICAL(&s_lock);
@@ -528,7 +557,6 @@ static int gap_event(struct ble_gap_event *event, void *arg)
if (event->notify_tx.attr_handle == s_tx_value_handle) {
finish_tx(event->notify_tx.status == 0,
event->notify_tx.status);
- pump_tx();
}
return 0;
case BLE_GAP_EVENT_PASSKEY_ACTION:
@@ -612,6 +640,19 @@ esp_err_t d1l_ble_companion_start(void)
return init_result;
}
+ const int mtu_result = ble_att_set_preferred_mtu(
+ D1L_BLE_COMPANION_PREFERRED_ATT_MTU);
+ if (mtu_result != 0) {
+ (void)nimble_port_deinit();
+ portENTER_CRITICAL(&s_lock);
+ s_start_requested = false;
+ s_state = D1L_BLE_STATE_ERROR;
+ s_last_error = ESP_FAIL;
+ s_last_nimble_error = mtu_result;
+ portEXIT_CRITICAL(&s_lock);
+ return ESP_FAIL;
+ }
+
ble_hs_cfg.reset_cb = on_reset;
ble_hs_cfg.sync_cb = on_sync;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
@@ -821,6 +862,20 @@ void d1l_ble_companion_status(d1l_ble_companion_status_t *out_status)
out_status->protocol_response_count = protocol.response_count;
out_status->protocol_unsupported_count = protocol.unsupported_count;
out_status->protocol_malformed_count = protocol.malformed_count;
+ out_status->protocol_last_command = protocol.last_command;
+ out_status->protocol_last_unsupported_command =
+ protocol.last_unsupported_command;
+ out_status->protocol_last_response_error_code =
+ protocol.last_response_error_code;
+ out_status->protocol_last_failed_command = protocol.last_failed_command;
+ out_status->protocol_last_failed_response_error_code =
+ protocol.last_failed_response_error_code;
+ out_status->protocol_last_text_type = protocol.last_text_type;
+ out_status->protocol_last_admin_cli_stage =
+ protocol.last_admin_cli_stage;
+ out_status->protocol_last_text_length = protocol.last_text_length;
+ out_status->protocol_response_error_count =
+ protocol.response_error_count;
out_status->security_policy = "secure_connections_mitm_bonded";
out_status->wire_policy = "raw_gatt_internal_meshcore_3byte";
}
@@ -934,6 +989,10 @@ void d1l_ble_companion_status(d1l_ble_companion_status_t *out_status)
out_status->wire_policy = "raw_gatt_internal_meshcore_3byte";
}
+void d1l_ble_companion_poll(void)
+{
+}
+
esp_err_t d1l_ble_companion_take_rx_frame(uint8_t *dest, size_t dest_cap,
size_t *out_len)
{
diff --git a/main/comms/ble_companion.h b/main/comms/ble_companion.h
index 8a099d13..8b65ddb0 100644
--- a/main/comms/ble_companion.h
+++ b/main/comms/ble_companion.h
@@ -12,8 +12,9 @@
"6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
#define D1L_BLE_COMPANION_TX_UUID \
"6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
-#define D1L_BLE_COMPANION_DEVICE_NAME "MeshCore DeskOS"
+#define D1L_BLE_COMPANION_DEVICE_NAME "MeshCore-DeskOS"
#define D1L_BLE_COMPANION_DEFAULT_ATT_MTU 23U
+#define D1L_BLE_COMPANION_PREFERRED_ATT_MTU 517U
typedef struct {
bool build_enabled;
@@ -47,6 +48,15 @@ typedef struct {
uint32_t protocol_response_count;
uint32_t protocol_unsupported_count;
uint32_t protocol_malformed_count;
+ uint8_t protocol_last_command;
+ uint8_t protocol_last_unsupported_command;
+ uint8_t protocol_last_response_error_code;
+ uint8_t protocol_last_failed_command;
+ uint8_t protocol_last_failed_response_error_code;
+ uint8_t protocol_last_text_type;
+ uint8_t protocol_last_admin_cli_stage;
+ uint16_t protocol_last_text_length;
+ uint32_t protocol_response_error_count;
esp_err_t last_error;
int last_nimble_error;
const char *state;
@@ -61,6 +71,7 @@ esp_err_t d1l_ble_companion_prepare_reboot(void);
esp_err_t d1l_ble_companion_begin_pairing(void);
esp_err_t d1l_ble_companion_forget_peer(void);
void d1l_ble_companion_status(d1l_ble_companion_status_t *out_status);
+void d1l_ble_companion_poll(void);
/*
* RX frames are returned in the existing MeshCore three-byte transport form:
diff --git a/main/comms/ble_companion_protocol.c b/main/comms/ble_companion_protocol.c
index d5702d7c..54d87e21 100644
--- a/main/comms/ble_companion_protocol.c
+++ b/main/comms/ble_companion_protocol.c
@@ -17,10 +17,16 @@
#include "freertos/task.h"
#include "mesh/channel_store.h"
#include "mesh/contact_store.h"
+#include "mesh/contact_uri.h"
#include "mesh/dm_store.h"
#include "mesh/message_store.h"
+#include "mesh/meshcore_service.h"
+#include "mesh/meshcore_dm_retry.h"
+#include "mesh/meshcore_route_selection.h"
+#include "mesh/meshcore_wire.h"
#include "mesh/node_store.h"
#include "platform/time_service.h"
+#include "storage/storage_status.h"
#define D1L_BLE_PROTOCOL_TASK_STACK_BYTES 6144U
#define D1L_BLE_PROTOCOL_TASK_PRIORITY 5U
@@ -29,6 +35,15 @@
#define D1L_BLE_PROTOCOL_VERSION 10U
#define D1L_BLE_PROTOCOL_MAX_CHANNELS 8U
#define D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES 64U
+#define D1L_BLE_PROTOCOL_OUT_PATH_UNKNOWN 0xFFU
+#define D1L_BLE_PROTOCOL_ADMIN_TIMEOUT_MS 60000U
+#define D1L_BLE_PROTOCOL_WIRED_MILLIVOLTS 4200U
+#define D1L_BLE_PROTOCOL_SELF_ADV_TYPE 1U
+#define D1L_BLE_PROTOCOL_MANUAL_ADD_CONTACTS 0U
+#define D1L_BLE_PROTOCOL_AUTOADD_CONFIG 0x1EU
+#define D1L_BLE_PROTOCOL_AUTOADD_MAX_HOPS 0U
+#define D1L_BLE_PROTOCOL_RX_DELAY_MILLIS 0U
+#define D1L_BLE_PROTOCOL_AIRTIME_FACTOR_MILLIS 1000U
enum {
CMD_APP_START = 1,
@@ -39,20 +54,51 @@ enum {
CMD_SET_DEVICE_TIME = 6,
CMD_SEND_SELF_ADVERT = 7,
CMD_SET_ADVERT_NAME = 8,
+ CMD_ADD_UPDATE_CONTACT = 9,
CMD_SYNC_NEXT_MESSAGE = 10,
CMD_SET_RADIO_PARAMS = 11,
CMD_SET_RADIO_TX_POWER = 12,
+ CMD_RESET_PATH = 13,
CMD_SET_ADVERT_LATLON = 14,
CMD_REMOVE_CONTACT = 15,
+ CMD_SHARE_CONTACT = 16,
+ CMD_EXPORT_CONTACT = 17,
+ CMD_IMPORT_CONTACT = 18,
CMD_REBOOT = 19,
CMD_GET_BATT_AND_STORAGE = 20,
+ CMD_SET_TUNING_PARAMS = 21,
CMD_DEVICE_QUERY = 22,
CMD_EXPORT_PRIVATE_KEY = 23,
CMD_IMPORT_PRIVATE_KEY = 24,
+ CMD_SEND_RAW_DATA = 25,
+ CMD_SEND_LOGIN = 26,
+ CMD_SEND_STATUS_REQ = 27,
+ CMD_HAS_CONNECTION = 28,
+ CMD_LOGOUT = 29,
CMD_GET_CONTACT_BY_KEY = 30,
CMD_GET_CHANNEL = 31,
CMD_SET_CHANNEL = 32,
+ CMD_SIGN_START = 33,
+ CMD_SIGN_DATA = 34,
+ CMD_SIGN_FINISH = 35,
+ CMD_SEND_TRACE_PATH = 36,
+ CMD_SET_DEVICE_PIN = 37,
+ CMD_SET_OTHER_PARAMS = 38,
+ CMD_SEND_TELEMETRY_REQ = 39,
+ CMD_GET_CUSTOM_VARS = 40,
+ CMD_SET_CUSTOM_VAR = 41,
+ CMD_GET_ADVERT_PATH = 42,
+ CMD_GET_TUNING_PARAMS = 43,
+ CMD_SEND_BINARY_REQ = 50,
CMD_FACTORY_RESET = 51,
+ CMD_SEND_PATH_DISCOVERY_REQ = 52,
+ CMD_SET_FLOOD_SCOPE_KEY = 54,
+ CMD_SEND_CONTROL_DATA = 55,
+ CMD_GET_STATS = 56,
+ CMD_SEND_ANON_REQ = 57,
+ CMD_SET_AUTOADD_CONFIG = 58,
+ CMD_GET_AUTOADD_CONFIG = 59,
+ CMD_GET_ALLOWED_REPEAT_FREQ = 60,
CMD_SET_PATH_HASH_MODE = 61,
};
@@ -74,7 +120,46 @@ enum {
RESP_CODE_CONTACT_MSG_RECV_V3 = 16,
RESP_CODE_CHANNEL_MSG_RECV_V3 = 17,
RESP_CODE_CHANNEL_INFO = 18,
+ RESP_CODE_CUSTOM_VARS = 21,
+ RESP_CODE_ADVERT_PATH = 22,
+ RESP_CODE_TUNING_PARAMS = 23,
+ RESP_CODE_STATS = 24,
+ RESP_CODE_AUTOADD_CONFIG = 25,
+ RESP_CODE_ALLOWED_REPEAT_FREQ = 26,
+ PUSH_CODE_ADVERT = 0x80,
+ PUSH_CODE_PATH_UPDATED = 0x81,
+ PUSH_CODE_SEND_CONFIRMED = 0x82,
PUSH_CODE_MSG_WAITING = 0x83,
+ PUSH_CODE_LOGIN_SUCCESS = 0x85,
+ PUSH_CODE_LOGIN_FAIL = 0x86,
+ PUSH_CODE_STATUS_RESPONSE = 0x87,
+ PUSH_CODE_NEW_ADVERT = 0x8A,
+ PUSH_CODE_TELEMETRY_RESPONSE = 0x8B,
+ PUSH_CODE_BINARY_RESPONSE = 0x8C,
+ PUSH_CODE_CONTACT_DELETED = 0x8F,
+};
+
+enum {
+ BINARY_REQ_STATUS = 1,
+ BINARY_REQ_TELEMETRY = 3,
+ BINARY_REQ_ACCESS_LIST = 5,
+ BINARY_REQ_NEIGHBOURS = 6,
+};
+
+typedef enum {
+ D1L_BLE_ADMIN_REQUEST_NONE = 0,
+ D1L_BLE_ADMIN_REQUEST_LOGIN,
+ D1L_BLE_ADMIN_REQUEST_STATUS,
+ D1L_BLE_ADMIN_REQUEST_TELEMETRY,
+ D1L_BLE_ADMIN_REQUEST_BINARY_STATUS,
+ D1L_BLE_ADMIN_REQUEST_BINARY_QUERY,
+ D1L_BLE_ADMIN_REQUEST_CLI,
+} d1l_ble_admin_request_kind_t;
+
+enum {
+ STATS_TYPE_CORE = 0,
+ STATS_TYPE_RADIO = 1,
+ STATS_TYPE_PACKETS = 2,
};
enum {
@@ -86,6 +171,16 @@ enum {
ERR_CODE_ILLEGAL_ARG = 6,
};
+enum {
+ ADMIN_CLI_STAGE_NONE = 0,
+ ADMIN_CLI_STAGE_RECEIVED,
+ ADMIN_CLI_STAGE_CONTACT_FOUND,
+ ADMIN_CLI_STAGE_SESSION_READY,
+ ADMIN_CLI_STAGE_NORMALIZED,
+ ADMIN_CLI_STAGE_QUEUED,
+ ADMIN_CLI_STAGE_PENDING_TRACKED,
+};
+
static portMUX_TYPE s_status_lock = portMUX_INITIALIZER_UNLOCKED;
static TaskHandle_t s_task;
static bool s_start_requested;
@@ -103,21 +198,48 @@ static uint8_t
static size_t s_pending_len;
static d1l_contact_entry_t
s_contacts[D1L_CONTACT_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
+static d1l_contact_entry_t
+ s_contact_sync_baseline[D1L_CONTACT_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
+static d1l_contact_entry_t
+ s_contact_sync_snapshot[D1L_CONTACT_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
static size_t s_contact_count;
static size_t s_contact_index;
+static size_t s_contact_sync_baseline_count;
+static uint64_t s_seen_contact_revision;
static uint32_t s_contact_since;
static uint32_t s_contact_most_recent;
static bool s_contact_iterator_active;
static d1l_channel_info_t
s_channels[D1L_CHANNEL_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
+static d1l_channel_store_stats_t s_channel_stats EXT_RAM_BSS_ATTR;
static d1l_dm_entry_t s_dms[D1L_DM_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
static d1l_message_entry_t
s_messages[D1L_MESSAGE_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
+static d1l_storage_status_t s_storage_status EXT_RAM_BSS_ATTR;
static uint32_t s_last_synced_dm_seq;
static uint32_t s_last_synced_message_seq;
static uint32_t s_seen_dm_revision;
static uint32_t s_seen_message_revision;
+static bool s_force_message_notification_check;
static uint32_t s_seen_connect_count;
+static uint64_t s_phone_dm_session;
+static uint32_t s_phone_dm_ack;
+static uint32_t s_phone_dm_started_ms;
+static d1l_meshcore_admin_snapshot_t s_admin_snapshot EXT_RAM_BSS_ATTR;
+static d1l_ble_admin_request_kind_t s_admin_request_kind;
+static uint32_t s_admin_request_generation;
+static uint32_t s_admin_request_tag;
+static uint8_t s_admin_request_public_key[32];
+static char s_admin_request_fingerprint[D1L_NODE_FINGERPRINT_LEN];
+static bool s_admin_cli_reply_pending;
+static uint8_t s_admin_cli_public_key[32];
+static uint32_t s_admin_cli_timestamp;
+static char
+ s_admin_cli_reply[D1L_MESHCORE_ADMIN_MAX_CLI_REPLY_BYTES + 1U];
+static bool s_admin_session_authorized;
+static char s_admin_session_fingerprint[D1L_NODE_FINGERPRINT_LEN];
+static bool s_admin_guest_requested;
+static char s_admin_guest_fingerprint[D1L_NODE_FINGERPRINT_LEN];
static void write_u16_le(uint8_t *dest, uint16_t value)
{
@@ -141,6 +263,11 @@ static uint32_t read_u32_le(const uint8_t *source)
((uint32_t)source[3] << 24U);
}
+static uint16_t read_u16_le(const uint8_t *source)
+{
+ return (uint16_t)source[0] | ((uint16_t)source[1] << 8U);
+}
+
static bool bytes_all_zero(const uint8_t *bytes, size_t length)
{
if (!bytes) {
@@ -236,10 +363,29 @@ static void note_command(uint8_t command)
portENTER_CRITICAL(&s_status_lock);
s_status.command_count++;
s_status.last_command = command;
+ s_status.last_response_error_code = 0U;
s_status.last_error = ESP_OK;
portEXIT_CRITICAL(&s_status_lock);
}
+static void note_text_command(uint8_t text_type, size_t text_length)
+{
+ portENTER_CRITICAL(&s_status_lock);
+ s_status.last_text_type = text_type;
+ s_status.last_text_length =
+ text_length > UINT16_MAX ? UINT16_MAX : (uint16_t)text_length;
+ s_status.last_admin_cli_stage =
+ text_type == 1U ? ADMIN_CLI_STAGE_RECEIVED : ADMIN_CLI_STAGE_NONE;
+ portEXIT_CRITICAL(&s_status_lock);
+}
+
+static void note_admin_cli_stage(uint8_t stage)
+{
+ portENTER_CRITICAL(&s_status_lock);
+ s_status.last_admin_cli_stage = stage;
+ portEXIT_CRITICAL(&s_status_lock);
+}
+
static void note_malformed(void)
{
portENTER_CRITICAL(&s_status_lock);
@@ -252,6 +398,7 @@ static void note_unsupported(void)
{
portENTER_CRITICAL(&s_status_lock);
s_status.unsupported_count++;
+ s_status.last_unsupported_command = s_status.last_command;
s_status.last_error = ESP_ERR_NOT_SUPPORTED;
portEXIT_CRITICAL(&s_status_lock);
}
@@ -275,6 +422,12 @@ static void set_simple_response(uint8_t code)
static void set_error_response(uint8_t code)
{
+ portENTER_CRITICAL(&s_status_lock);
+ s_status.last_response_error_code = code;
+ s_status.last_failed_command = s_status.last_command;
+ s_status.last_failed_response_error_code = code;
+ s_status.response_error_count++;
+ portEXIT_CRITICAL(&s_status_lock);
const uint8_t response[] = {RESP_CODE_ERR, code};
(void)set_pending(response, sizeof(response));
}
@@ -333,20 +486,23 @@ static esp_err_t flush_pending(void)
return result;
}
-static bool channel_at_index(uint8_t index, d1l_channel_info_t *out_channel)
+static esp_err_t channel_at_index(uint8_t index, d1l_channel_info_t *out_channel)
{
size_t count = 0U;
uint64_t active_id = 0U;
- if (d1l_app_model_copy_channels(
+ const esp_err_t result = d1l_app_model_copy_channels(
s_channels, D1L_CHANNEL_STORE_CAPACITY, &count, &active_id,
- NULL) != ESP_OK ||
- index >= count) {
- return false;
+ &s_channel_stats);
+ if (result != ESP_OK) {
+ return result;
+ }
+ if (index >= count) {
+ return ESP_ERR_NOT_FOUND;
}
if (out_channel) {
*out_channel = s_channels[index];
}
- return true;
+ return ESP_OK;
}
static bool channel_index_for_id(uint64_t channel_id, uint8_t *out_index)
@@ -356,7 +512,7 @@ static bool channel_index_for_id(uint64_t channel_id, uint8_t *out_index)
if (!out_index ||
d1l_app_model_copy_channels(
s_channels, D1L_CHANNEL_STORE_CAPACITY, &count, &active_id,
- NULL) != ESP_OK) {
+ &s_channel_stats) != ESP_OK) {
return false;
}
for (size_t i = 0; i < count && i < D1L_BLE_PROTOCOL_MAX_CHANNELS; ++i) {
@@ -394,6 +550,153 @@ static bool contact_for_prefix(const uint8_t prefix[6],
return found;
}
+static bool contact_for_public_key(const uint8_t public_key[32],
+ d1l_contact_entry_t *out_contact)
+{
+ if (!public_key) {
+ return false;
+ }
+ char public_key_hex[D1L_NODE_PUBLIC_KEY_HEX_LEN] = {0};
+ for (size_t i = 0U; i < 32U; ++i) {
+ (void)snprintf(&public_key_hex[i * 2U], 3U, "%02X", public_key[i]);
+ }
+ const bool found = d1l_contact_store_find_by_public_key(
+ public_key_hex, out_contact);
+ memset(public_key_hex, 0, sizeof(public_key_hex));
+ return found;
+}
+
+static bool admin_state_active(d1l_meshcore_admin_state_t state)
+{
+ return state == D1L_MESHCORE_ADMIN_AUTHENTICATED ||
+ state == D1L_MESHCORE_ADMIN_STATUS_PENDING ||
+ state == D1L_MESHCORE_ADMIN_MUTATION_PENDING ||
+ state == D1L_MESHCORE_ADMIN_CLI_PENDING ||
+ state == D1L_MESHCORE_ADMIN_QUERY_PENDING;
+}
+
+static bool admin_snapshot_matches(const d1l_contact_entry_t *contact)
+{
+ return contact && admin_state_active(s_admin_snapshot.state) &&
+ strcmp(s_admin_snapshot.fingerprint, contact->fingerprint) == 0;
+}
+
+static void clear_admin_access_intent(void)
+{
+ s_admin_guest_requested = false;
+ memset(s_admin_guest_fingerprint, 0,
+ sizeof(s_admin_guest_fingerprint));
+}
+
+static void clear_admin_session_authorization(void)
+{
+ s_admin_session_authorized = false;
+ memset(s_admin_session_fingerprint, 0,
+ sizeof(s_admin_session_fingerprint));
+}
+
+static bool admin_session_matches(const d1l_contact_entry_t *contact)
+{
+ return contact && s_admin_session_authorized &&
+ strcmp(s_admin_session_fingerprint, contact->fingerprint) == 0;
+}
+
+static bool admin_session_matches_or_restore(
+ const d1l_contact_entry_t *contact)
+{
+ if (admin_session_matches(contact)) {
+ return true;
+ }
+ if (!admin_snapshot_matches(contact)) {
+ return false;
+ }
+ s_admin_session_authorized = true;
+ snprintf(s_admin_session_fingerprint,
+ sizeof(s_admin_session_fingerprint), "%s",
+ contact->fingerprint);
+ return true;
+}
+
+static bool admin_guest_session_matches(
+ const d1l_contact_entry_t *contact)
+{
+ return contact && admin_session_matches_or_restore(contact) &&
+ admin_snapshot_matches(contact) &&
+ ((s_admin_guest_requested &&
+ strcmp(s_admin_guest_fingerprint, contact->fingerprint) == 0) ||
+ (s_admin_snapshot.permissions &
+ D1L_MESHCORE_ADMIN_PERMISSION_ROLE_MASK) ==
+ D1L_MESHCORE_ADMIN_PERMISSION_GUEST);
+}
+
+static void clear_admin_request(void)
+{
+ s_admin_request_kind = D1L_BLE_ADMIN_REQUEST_NONE;
+ s_admin_request_generation = 0U;
+ s_admin_request_tag = 0U;
+ memset(s_admin_request_public_key, 0,
+ sizeof(s_admin_request_public_key));
+ memset(s_admin_request_fingerprint, 0,
+ sizeof(s_admin_request_fingerprint));
+}
+
+static void clear_admin_cli_reply(void)
+{
+ s_admin_cli_reply_pending = false;
+ s_admin_cli_timestamp = 0U;
+ memset(s_admin_cli_public_key, 0, sizeof(s_admin_cli_public_key));
+ memset(s_admin_cli_reply, 0, sizeof(s_admin_cli_reply));
+}
+
+static void track_admin_request(
+ d1l_ble_admin_request_kind_t kind, const uint8_t public_key[32],
+ const d1l_contact_entry_t *contact)
+{
+ clear_admin_request();
+ s_admin_request_kind = kind;
+ s_admin_request_generation = s_admin_snapshot.generation;
+ s_admin_request_tag = s_admin_snapshot.pending_tag;
+ memcpy(s_admin_request_public_key, public_key,
+ sizeof(s_admin_request_public_key));
+ snprintf(s_admin_request_fingerprint,
+ sizeof(s_admin_request_fingerprint), "%s", contact->fingerprint);
+}
+
+static void set_admin_sent_response(bool flood, uint32_t tag)
+{
+ uint8_t response[10] = {RESP_CODE_SENT, flood ? 1U : 0U};
+ write_u32_le(&response[2], tag);
+ write_u32_le(&response[6], D1L_BLE_PROTOCOL_ADMIN_TIMEOUT_MS);
+ (void)set_pending(response, sizeof(response));
+}
+
+static uint32_t message_epoch(uint32_t row_uptime_ms);
+
+static void copy_contact_name(uint8_t *dest, size_t capacity,
+ const char *source)
+{
+ char normalized[32U] = {0};
+ size_t length = source ? strnlen(source, sizeof(normalized) - 1U) : 0U;
+ while (length > 0U &&
+ (source[length - 1U] == '_' || source[length - 1U] == ' ')) {
+ length--;
+ }
+ if (length > 0U) {
+ memcpy(normalized, source, length);
+ }
+ copy_fixed_text(dest, capacity, normalized);
+}
+
+static uint32_t contact_lastmod(const d1l_contact_entry_t *contact)
+{
+ if (!contact) {
+ return 0U;
+ }
+ const uint32_t updated_epoch = message_epoch(contact->updated_ms);
+ return updated_epoch > contact->signed_advert_timestamp ?
+ updated_epoch : contact->signed_advert_timestamp;
+}
+
static size_t build_contact_response(const d1l_contact_entry_t *contact,
uint8_t *dest, size_t capacity)
{
@@ -412,19 +715,27 @@ static size_t build_contact_response(const d1l_contact_entry_t *contact,
offset += sizeof(public_key);
dest[offset++] = contact_type_code(contact->type);
dest[offset++] = contact->favorite ? 1U : 0U;
- const uint8_t path_len =
+ const bool path_known =
contact->out_path_valid &&
- contact->out_path_len <= D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES ?
- contact->out_path_len : 0U;
+ d1l_meshcore_wire_path_len_valid(contact->out_path_len);
+ const uint8_t path_len = path_known ? contact->out_path_len :
+ D1L_BLE_PROTOCOL_OUT_PATH_UNKNOWN;
+ const uint8_t path_bytes =
+ path_known && path_len > 0U ?
+ d1l_meshcore_wire_path_byte_len(path_len) : 0U;
dest[offset++] = path_len;
memset(&dest[offset], 0, D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES);
- if (path_len > 0U) {
- memcpy(&dest[offset], contact->out_path, path_len);
+ if (path_bytes > 0U &&
+ path_bytes <= D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES) {
+ memcpy(&dest[offset], contact->out_path, path_bytes);
}
offset += D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES;
const char *name = contact->alias[0] ? contact->alias :
(contact->heard_name[0] ? contact->heard_name : contact->fingerprint);
- copy_fixed_text(&dest[offset], 32U, name);
+ copy_contact_name(&dest[offset], 32U, name);
+ if (dest[offset] == '\0') {
+ copy_fixed_text(&dest[offset], 32U, contact->fingerprint);
+ }
offset += 32U;
write_u32_le(&dest[offset], contact->signed_advert_timestamp);
offset += 4U;
@@ -437,7 +748,7 @@ static size_t build_contact_response(const d1l_contact_entry_t *contact,
memset(&dest[offset], 0, 8U);
}
offset += 8U;
- write_u32_le(&dest[offset], contact->signed_advert_timestamp);
+ write_u32_le(&dest[offset], contact_lastmod(contact));
offset += 4U;
return offset;
}
@@ -450,15 +761,8 @@ static void begin_contact_iteration(const uint8_t *payload, size_t length)
s_contact_index = 0U;
s_contact_most_recent = 0U;
s_contact_iterator_active = true;
- uint32_t filtered_count = 0U;
- for (size_t i = 0; i < s_contact_count; ++i) {
- if (s_contacts[i].signed_advert_timestamp > s_contact_since &&
- s_contacts[i].public_key_hex[0] != '\0') {
- filtered_count++;
- }
- }
uint8_t response[5] = {RESP_CODE_CONTACTS_START};
- write_u32_le(&response[1], filtered_count);
+ write_u32_le(&response[1], s_contact_count);
(void)set_pending(response, sizeof(response));
}
@@ -469,7 +773,8 @@ static void prepare_next_contact_response(void)
}
while (s_contact_index < s_contact_count) {
const d1l_contact_entry_t *contact = &s_contacts[s_contact_index++];
- if (contact->signed_advert_timestamp <= s_contact_since ||
+ const uint32_t lastmod = contact_lastmod(contact);
+ if (lastmod <= s_contact_since ||
contact->public_key_hex[0] == '\0') {
continue;
}
@@ -479,8 +784,8 @@ static void prepare_next_contact_response(void)
continue;
}
s_pending_len = length;
- if (contact->signed_advert_timestamp > s_contact_most_recent) {
- s_contact_most_recent = contact->signed_advert_timestamp;
+ if (lastmod > s_contact_most_recent) {
+ s_contact_most_recent = lastmod;
}
return;
}
@@ -490,6 +795,154 @@ static void prepare_next_contact_response(void)
(void)set_pending(response, sizeof(response));
}
+static size_t capture_contact_sync_snapshot(d1l_contact_entry_t *dest)
+{
+ const size_t copied = d1l_contact_store_copy_recent(
+ dest, D1L_CONTACT_STORE_CAPACITY);
+ size_t retained = 0U;
+ for (size_t index = 0U; index < copied; ++index) {
+ if (dest[index].public_key_hex[0] == '\0') {
+ continue;
+ }
+ if (retained != index) {
+ dest[retained] = dest[index];
+ }
+ retained++;
+ }
+ return retained;
+}
+
+static int contact_sync_index_for_key(const d1l_contact_entry_t *entries,
+ size_t count, const char *public_key_hex)
+{
+ for (size_t index = 0U; index < count; ++index) {
+ if (strcmp(entries[index].public_key_hex, public_key_hex) == 0) {
+ return (int)index;
+ }
+ }
+ return -1;
+}
+
+static bool contact_sync_public_key(const d1l_contact_entry_t *contact,
+ uint8_t public_key[32])
+{
+ return contact && decode_hex(contact->public_key_hex, 32U, public_key);
+}
+
+static void initialize_contact_sync_baseline(void)
+{
+ s_contact_sync_baseline_count = capture_contact_sync_snapshot(
+ s_contact_sync_baseline);
+ s_seen_contact_revision =
+ d1l_contact_store_stats().persistence_revision;
+}
+
+static void remove_contact_sync_baseline(size_t index)
+{
+ if (index >= s_contact_sync_baseline_count) {
+ return;
+ }
+ if (index + 1U < s_contact_sync_baseline_count) {
+ memmove(&s_contact_sync_baseline[index],
+ &s_contact_sync_baseline[index + 1U],
+ (s_contact_sync_baseline_count - index - 1U) *
+ sizeof(s_contact_sync_baseline[0]));
+ }
+ s_contact_sync_baseline_count--;
+ memset(&s_contact_sync_baseline[s_contact_sync_baseline_count], 0,
+ sizeof(s_contact_sync_baseline[0]));
+}
+
+static void maybe_queue_contact_change(void)
+{
+ if (s_pending_len != 0U || s_contact_iterator_active) {
+ return;
+ }
+ const d1l_contact_store_stats_t stats = d1l_contact_store_stats();
+ if (stats.persistence_revision == s_seen_contact_revision) {
+ return;
+ }
+
+ const size_t current_count = capture_contact_sync_snapshot(
+ s_contact_sync_snapshot);
+ for (size_t index = 0U; index < s_contact_sync_baseline_count; ++index) {
+ const d1l_contact_entry_t *previous =
+ &s_contact_sync_baseline[index];
+ if (contact_sync_index_for_key(
+ s_contact_sync_snapshot, current_count,
+ previous->public_key_hex) >= 0) {
+ continue;
+ }
+ uint8_t public_key[32] = {0};
+ if (contact_sync_public_key(previous, public_key)) {
+ s_pending_payload[0] = PUSH_CODE_CONTACT_DELETED;
+ memcpy(&s_pending_payload[1], public_key, sizeof(public_key));
+ s_pending_len = 1U + sizeof(public_key);
+ }
+ remove_contact_sync_baseline(index);
+ return;
+ }
+
+ for (size_t index = 0U; index < current_count; ++index) {
+ const d1l_contact_entry_t *current = &s_contact_sync_snapshot[index];
+ const int previous_index = contact_sync_index_for_key(
+ s_contact_sync_baseline, s_contact_sync_baseline_count,
+ current->public_key_hex);
+ if (previous_index < 0) {
+ const size_t length = build_contact_response(
+ current, s_pending_payload, sizeof(s_pending_payload));
+ if (length > 0U) {
+ /* An update push contains only a public key and cannot create
+ * a contact in the phone app. New nodes require the complete
+ * contact frame used by current MeshCore companions. */
+ s_pending_payload[0] = PUSH_CODE_NEW_ADVERT;
+ s_pending_len = length;
+ }
+ if (s_contact_sync_baseline_count <
+ D1L_CONTACT_STORE_CAPACITY) {
+ s_contact_sync_baseline[s_contact_sync_baseline_count++] =
+ *current;
+ }
+ return;
+ }
+ d1l_contact_entry_t *previous =
+ &s_contact_sync_baseline[(size_t)previous_index];
+ if (previous->seq == current->seq) {
+ continue;
+ }
+ uint8_t public_key[32] = {0};
+ if (contact_sync_public_key(current, public_key)) {
+ const bool advert_changed =
+ previous->signed_advert_timestamp !=
+ current->signed_advert_timestamp ||
+ strcmp(previous->heard_name, current->heard_name) != 0 ||
+ strcmp(previous->type, current->type) != 0;
+ const bool path_changed =
+ previous->out_path_valid != current->out_path_valid ||
+ previous->out_path_len != current->out_path_len ||
+ memcmp(previous->out_path, current->out_path,
+ sizeof(current->out_path)) != 0;
+ s_pending_payload[0] =
+ !advert_changed && path_changed ? PUSH_CODE_PATH_UPDATED :
+ PUSH_CODE_ADVERT;
+ memcpy(&s_pending_payload[1], public_key, sizeof(public_key));
+ s_pending_len = 1U + sizeof(public_key);
+ }
+ *previous = *current;
+ return;
+ }
+
+ memcpy(s_contact_sync_baseline, s_contact_sync_snapshot,
+ current_count * sizeof(s_contact_sync_baseline[0]));
+ if (current_count < D1L_CONTACT_STORE_CAPACITY) {
+ memset(&s_contact_sync_baseline[current_count], 0,
+ (D1L_CONTACT_STORE_CAPACITY - current_count) *
+ sizeof(s_contact_sync_baseline[0]));
+ }
+ s_contact_sync_baseline_count = current_count;
+ s_seen_contact_revision = stats.persistence_revision;
+}
+
static void build_self_info(void)
{
d1l_settings_t settings = {0};
@@ -500,7 +953,7 @@ static void build_self_info(void)
}
size_t offset = 0U;
s_pending_payload[offset++] = RESP_CODE_SELF_INFO;
- s_pending_payload[offset++] = 1U;
+ s_pending_payload[offset++] = D1L_BLE_PROTOCOL_SELF_ADV_TYPE;
s_pending_payload[offset++] = (uint8_t)settings.tx_power_dbm;
s_pending_payload[offset++] = 22U;
memcpy(&s_pending_payload[offset], settings.identity_public_key,
@@ -517,7 +970,10 @@ static void build_self_info(void)
s_pending_payload[offset++] = 0U;
s_pending_payload[offset++] = settings.map_location_set ? 1U : 0U;
s_pending_payload[offset++] = 0U;
- s_pending_payload[offset++] = 1U;
+ /* Every verified signed advert is retained as a contact. Keep this flag
+ * in sync with PUSH_CODE_ADVERT so phone clients update Contacts/Recent
+ * instead of treating the same node as an unsaved discovery. */
+ s_pending_payload[offset++] = D1L_BLE_PROTOCOL_MANUAL_ADD_CONTACTS;
write_u32_le(&s_pending_payload[offset],
settings.frequency_hz / 1000U);
offset += 4U;
@@ -580,9 +1036,23 @@ static void build_current_time(void)
static void build_channel_info(uint8_t index)
{
+ if (index >= D1L_BLE_PROTOCOL_MAX_CHANNELS) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
d1l_channel_info_t channel = {0};
- if (!channel_at_index(index, &channel)) {
- set_error_response(ERR_CODE_NOT_FOUND);
+ const esp_err_t result = channel_at_index(index, &channel);
+ if (result != ESP_OK && result != ESP_ERR_NOT_FOUND) {
+ set_error_response(ERR_CODE_BAD_STATE);
+ return;
+ }
+ memset(s_pending_payload, 0, 50U);
+ s_pending_payload[0] = RESP_CODE_CHANNEL_INFO;
+ s_pending_payload[1] = index;
+ /* Phone clients find free slots by reading an empty channel record.
+ * NOT_FOUND makes every unused slot appear unavailable for creation. */
+ if (result == ESP_ERR_NOT_FOUND) {
+ s_pending_len = 50U;
return;
}
d1l_channel_protocol_key_t key = {0};
@@ -593,9 +1063,6 @@ static void build_channel_info(uint8_t index)
memset(&key, 0, sizeof(key));
return;
}
- memset(s_pending_payload, 0, 50U);
- s_pending_payload[0] = RESP_CODE_CHANNEL_INFO;
- s_pending_payload[1] = index;
copy_fixed_text(&s_pending_payload[2], 32U, channel.name);
memcpy(&s_pending_payload[34], key.secret,
D1L_CHANNEL_SECRET_128_LEN);
@@ -605,18 +1072,62 @@ static void build_channel_info(uint8_t index)
static void build_battery_storage(void)
{
- static d1l_app_snapshot_t snapshot;
- d1l_app_model_snapshot(&snapshot);
- const uint32_t total = snapshot.storage_capacity_kb;
- const uint32_t used = total >= snapshot.storage_free_kb ?
- total - snapshot.storage_free_kb : 0U;
+ d1l_storage_status(&s_storage_status);
+ const uint32_t total = s_storage_status.capacity_kb;
+ const uint32_t used = total >= s_storage_status.free_kb ?
+ total - s_storage_status.free_kb : 0U;
uint8_t response[11] = {RESP_CODE_BATT_AND_STORAGE};
- write_u16_le(&response[1], 0U);
+ /* The Indicator D1L has no battery sense input. The companion protocol has
+ * no wired-power flag, so report a full Li-ion-equivalent voltage instead
+ * of the misleading zero-percent value shown by mobile clients. */
+ write_u16_le(&response[1], D1L_BLE_PROTOCOL_WIRED_MILLIVOLTS);
write_u32_le(&response[3], used);
write_u32_le(&response[7], total);
(void)set_pending(response, sizeof(response));
}
+static uint8_t clamp_u8(uint32_t value)
+{
+ return value > UINT8_MAX ? UINT8_MAX : (uint8_t)value;
+}
+
+static void build_stats(uint8_t type)
+{
+ const d1l_meshcore_service_status_t mesh = d1l_meshcore_service_status();
+ memset(s_pending_payload, 0, 30U);
+ s_pending_payload[0] = RESP_CODE_STATS;
+ s_pending_payload[1] = type;
+
+ switch (type) {
+ case STATS_TYPE_CORE: {
+ const uint32_t queue_depth = mesh.runtime_command_queue_depth +
+ mesh.runtime_priority_queue_depth + mesh.runtime_event_queue_depth;
+ write_u16_le(&s_pending_payload[2], 0U);
+ write_u32_le(&s_pending_payload[4],
+ (uint32_t)(esp_timer_get_time() / 1000000ULL));
+ write_u16_le(&s_pending_payload[8],
+ mesh.radio_ready && mesh.radio_applied ? 0U : 1U);
+ s_pending_payload[10] = clamp_u8(queue_depth);
+ s_pending_len = 11U;
+ return;
+ }
+ case STATS_TYPE_RADIO:
+ /* DeskOS does not retain airtime or noise-floor counters yet. */
+ s_pending_len = 14U;
+ return;
+ case STATS_TYPE_PACKETS:
+ write_u32_le(&s_pending_payload[2], mesh.rx_packets);
+ write_u32_le(&s_pending_payload[6], mesh.tx_packets);
+ write_u32_le(&s_pending_payload[10], mesh.tx_packets);
+ write_u32_le(&s_pending_payload[18], mesh.rx_packets);
+ s_pending_len = 30U;
+ return;
+ default:
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+}
+
static uint32_t message_epoch(uint32_t row_uptime_ms)
{
d1l_time_service_status_t time_status = {0};
@@ -633,6 +1144,249 @@ static uint32_t message_epoch(uint32_t row_uptime_ms)
return age_sec < wall ? (uint32_t)(wall - age_sec) : 0U;
}
+static void build_admin_login_push(bool success)
+{
+ size_t offset = 0U;
+ s_pending_payload[offset++] = success ? PUSH_CODE_LOGIN_SUCCESS :
+ PUSH_CODE_LOGIN_FAIL;
+ if (!success) {
+ s_pending_payload[offset++] = 0U;
+ memcpy(&s_pending_payload[offset], s_admin_request_public_key, 6U);
+ offset += 6U;
+ s_pending_len = offset;
+ return;
+ }
+
+ const bool expose_guest = s_admin_guest_requested &&
+ strcmp(s_admin_guest_fingerprint,
+ s_admin_request_fingerprint) == 0;
+ const uint8_t exposed_permissions = expose_guest ?
+ D1L_MESHCORE_ADMIN_PERMISSION_GUEST : s_admin_snapshot.permissions;
+ const uint8_t permission_role = exposed_permissions &
+ D1L_MESHCORE_ADMIN_PERMISSION_ROLE_MASK;
+ s_pending_payload[offset++] =
+ permission_role == D1L_MESHCORE_ADMIN_PERMISSION_ADMIN ? 1U :
+ (s_admin_snapshot.role == D1L_MESHCORE_ADMIN_ROLE_ROOM &&
+ permission_role == D1L_MESHCORE_ADMIN_PERMISSION_GUEST ? 2U : 0U);
+ memcpy(&s_pending_payload[offset], s_admin_request_public_key, 6U);
+ offset += 6U;
+ write_u32_le(&s_pending_payload[offset],
+ s_admin_snapshot.server_timestamp);
+ offset += 4U;
+ s_pending_payload[offset++] = exposed_permissions;
+ s_pending_payload[offset++] = s_admin_snapshot.firmware_level;
+ s_pending_len = offset;
+}
+
+static size_t append_admin_status(size_t offset)
+{
+#define APPEND_U16(value) \
+ do { \
+ write_u16_le(&s_pending_payload[offset], (uint16_t)(value)); \
+ offset += 2U; \
+ } while (0)
+#define APPEND_U32(value) \
+ do { \
+ write_u32_le(&s_pending_payload[offset], (uint32_t)(value)); \
+ offset += 4U; \
+ } while (0)
+ const d1l_meshcore_admin_status_t *status = &s_admin_snapshot.status;
+ APPEND_U16(status->battery_millivolts);
+ APPEND_U16(status->tx_queue_length);
+ APPEND_U16(status->noise_floor_dbm);
+ APPEND_U16(status->last_rssi_dbm);
+ APPEND_U32(status->packets_received);
+ APPEND_U32(status->packets_sent);
+ APPEND_U32(status->tx_air_time_seconds);
+ APPEND_U32(status->uptime_seconds);
+ APPEND_U32(status->sent_flood);
+ APPEND_U32(status->sent_direct);
+ APPEND_U32(status->received_flood);
+ APPEND_U32(status->received_direct);
+ APPEND_U16(status->error_flags);
+ APPEND_U16(status->last_snr_quarter_db);
+ APPEND_U16(status->direct_duplicates);
+ APPEND_U16(status->flood_duplicates);
+ if (s_admin_snapshot.role == D1L_MESHCORE_ADMIN_ROLE_REPEATER) {
+ APPEND_U32(status->rx_air_time_seconds);
+ APPEND_U32(status->receive_errors);
+ } else {
+ APPEND_U16(status->posts_created);
+ APPEND_U16(status->posts_pushed);
+ }
+#undef APPEND_U16
+#undef APPEND_U32
+ return offset;
+}
+
+static void build_admin_status_push(bool binary)
+{
+ size_t offset = 0U;
+ s_pending_payload[offset++] = binary ? PUSH_CODE_BINARY_RESPONSE :
+ PUSH_CODE_STATUS_RESPONSE;
+ s_pending_payload[offset++] = 0U;
+ if (binary) {
+ write_u32_le(&s_pending_payload[offset], s_admin_request_tag);
+ offset += 4U;
+ } else {
+ memcpy(&s_pending_payload[offset], s_admin_request_public_key, 6U);
+ offset += 6U;
+ }
+ s_pending_len = append_admin_status(offset);
+}
+
+static void build_admin_query_push(bool binary)
+{
+ size_t offset = 0U;
+ s_pending_payload[offset++] = binary ? PUSH_CODE_BINARY_RESPONSE :
+ PUSH_CODE_TELEMETRY_RESPONSE;
+ s_pending_payload[offset++] = 0U;
+ if (binary) {
+ write_u32_le(&s_pending_payload[offset], s_admin_request_tag);
+ offset += 4U;
+ } else {
+ memcpy(&s_pending_payload[offset], s_admin_request_public_key, 6U);
+ offset += 6U;
+ }
+ if (s_admin_snapshot.query_wire_len >
+ sizeof(s_admin_snapshot.query_wire) ||
+ offset + s_admin_snapshot.query_wire_len >
+ sizeof(s_pending_payload)) {
+ clear_admin_request();
+ return;
+ }
+ memcpy(&s_pending_payload[offset], s_admin_snapshot.query_wire,
+ s_admin_snapshot.query_wire_len);
+ s_pending_len = offset + s_admin_snapshot.query_wire_len;
+}
+
+static void maybe_queue_admin_response(void)
+{
+ if (s_pending_len != 0U ||
+ s_admin_request_kind == D1L_BLE_ADMIN_REQUEST_NONE) {
+ return;
+ }
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ const bool target_matches =
+ strcmp(s_admin_snapshot.fingerprint,
+ s_admin_request_fingerprint) == 0;
+
+ switch (s_admin_request_kind) {
+ case D1L_BLE_ADMIN_REQUEST_LOGIN:
+ if (target_matches &&
+ s_admin_snapshot.state == D1L_MESHCORE_ADMIN_LOGIN_PENDING) {
+ return;
+ }
+ const bool login_succeeded =
+ target_matches && admin_state_active(s_admin_snapshot.state) &&
+ s_admin_snapshot.generation != s_admin_request_generation;
+ if (login_succeeded) {
+ s_admin_session_authorized = true;
+ snprintf(s_admin_session_fingerprint,
+ sizeof(s_admin_session_fingerprint), "%s",
+ s_admin_request_fingerprint);
+ }
+ build_admin_login_push(login_succeeded);
+ if (!login_succeeded) {
+ clear_admin_session_authorization();
+ clear_admin_access_intent();
+ }
+ clear_admin_request();
+ return;
+ case D1L_BLE_ADMIN_REQUEST_STATUS:
+ case D1L_BLE_ADMIN_REQUEST_BINARY_STATUS:
+ if (target_matches &&
+ s_admin_snapshot.state == D1L_MESHCORE_ADMIN_STATUS_PENDING &&
+ s_admin_snapshot.pending_tag == s_admin_request_tag) {
+ return;
+ }
+ if (target_matches &&
+ s_admin_snapshot.state == D1L_MESHCORE_ADMIN_AUTHENTICATED &&
+ s_admin_snapshot.last_completed_tag == s_admin_request_tag &&
+ s_admin_snapshot.status_valid) {
+ build_admin_status_push(
+ s_admin_request_kind ==
+ D1L_BLE_ADMIN_REQUEST_BINARY_STATUS);
+ }
+ clear_admin_request();
+ return;
+ case D1L_BLE_ADMIN_REQUEST_TELEMETRY:
+ case D1L_BLE_ADMIN_REQUEST_BINARY_QUERY:
+ if (target_matches &&
+ s_admin_snapshot.state == D1L_MESHCORE_ADMIN_QUERY_PENDING &&
+ s_admin_snapshot.pending_tag == s_admin_request_tag) {
+ return;
+ }
+ if (target_matches &&
+ s_admin_snapshot.state == D1L_MESHCORE_ADMIN_AUTHENTICATED &&
+ s_admin_snapshot.last_completed_tag == s_admin_request_tag &&
+ s_admin_snapshot.query_result.valid) {
+ const bool binary = s_admin_request_kind ==
+ D1L_BLE_ADMIN_REQUEST_BINARY_QUERY;
+ build_admin_query_push(binary);
+ }
+ clear_admin_request();
+ return;
+ case D1L_BLE_ADMIN_REQUEST_CLI:
+ if (target_matches &&
+ s_admin_snapshot.state == D1L_MESHCORE_ADMIN_CLI_PENDING &&
+ s_admin_snapshot.pending_tag == s_admin_request_tag) {
+ return;
+ }
+ if (target_matches &&
+ s_admin_snapshot.state == D1L_MESHCORE_ADMIN_AUTHENTICATED &&
+ s_admin_snapshot.last_completed_tag == s_admin_request_tag &&
+ s_admin_snapshot.cli_reply_valid) {
+ clear_admin_cli_reply();
+ memcpy(s_admin_cli_public_key, s_admin_request_public_key,
+ sizeof(s_admin_cli_public_key));
+ s_admin_cli_timestamp = s_admin_snapshot.server_timestamp;
+ snprintf(s_admin_cli_reply, sizeof(s_admin_cli_reply), "%s",
+ s_admin_snapshot.cli_reply);
+ s_admin_cli_reply_pending = true;
+ set_simple_response(PUSH_CODE_MSG_WAITING);
+ }
+ clear_admin_request();
+ return;
+ case D1L_BLE_ADMIN_REQUEST_NONE:
+ default:
+ clear_admin_request();
+ return;
+ }
+}
+
+static bool build_admin_cli_message(void)
+{
+ if (!s_admin_cli_reply_pending) {
+ return false;
+ }
+ size_t offset = 0U;
+ if (s_status.client_protocol_version >= 3U) {
+ s_pending_payload[offset++] = RESP_CODE_CONTACT_MSG_RECV_V3;
+ s_pending_payload[offset++] = 0U;
+ s_pending_payload[offset++] = 0U;
+ s_pending_payload[offset++] = 0U;
+ } else {
+ s_pending_payload[offset++] = RESP_CODE_CONTACT_MSG_RECV;
+ }
+ memcpy(&s_pending_payload[offset], s_admin_cli_public_key, 6U);
+ offset += 6U;
+ s_pending_payload[offset++] = 0xFFU;
+ s_pending_payload[offset++] = 1U;
+ write_u32_le(&s_pending_payload[offset], s_admin_cli_timestamp);
+ offset += 4U;
+ size_t text_len = strnlen(s_admin_cli_reply, sizeof(s_admin_cli_reply));
+ const size_t available = sizeof(s_pending_payload) - offset;
+ if (text_len > available) {
+ text_len = available;
+ }
+ memcpy(&s_pending_payload[offset], s_admin_cli_reply, text_len);
+ offset += text_len;
+ s_pending_len = offset;
+ clear_admin_cli_reply();
+ return true;
+}
+
static bool build_next_dm_message(void)
{
const size_t count = d1l_dm_store_copy_recent(
@@ -686,10 +1440,15 @@ static bool build_next_dm_message(void)
static bool build_next_channel_message(void)
{
- const size_t count = d1l_message_store_copy_recent(
- s_messages, D1L_MESSAGE_STORE_CAPACITY);
- const uint8_t protocol_version = s_status.client_protocol_version;
- for (size_t i = 0; i < count; ++i) {
+ size_t count = 0U;
+ d1l_message_retained_snapshot_t retained = {0};
+ if (d1l_message_store_snapshot_retained(
+ s_messages, D1L_MESSAGE_STORE_CAPACITY, &count,
+ &retained) != ESP_OK) {
+ return false;
+ }
+ const uint8_t protocol_version = s_status.client_protocol_version;
+ for (size_t i = 0; i < count; ++i) {
const d1l_message_entry_t *entry = &s_messages[i];
if (entry->seq <= s_last_synced_message_seq ||
strcmp(entry->direction, "rx") != 0) {
@@ -713,13 +1472,33 @@ static bool build_next_channel_message(void)
s_pending_payload[offset++] = channel_index;
s_pending_payload[offset++] = 0xFFU;
s_pending_payload[offset++] = 0U;
- write_u32_le(&s_pending_payload[offset],
- message_epoch(entry->uptime_ms));
+ uint32_t timestamp = d1l_message_entry_display_timestamp(entry);
+ if (timestamp == 0U) {
+ timestamp = message_epoch(entry->uptime_ms);
+ }
+ write_u32_le(&s_pending_payload[offset], timestamp);
offset += 4U;
- const size_t text_len = strnlen(
- entry->text, sizeof(s_pending_payload) - offset);
- memcpy(&s_pending_payload[offset], entry->text, text_len);
- offset += text_len;
+ const char *channel_name = s_channels[channel_index].name;
+ const bool has_sender = entry->author[0] != '\0' &&
+ strcmp(entry->author, channel_name) != 0;
+ if (has_sender) {
+ const int written = snprintf(
+ (char *)&s_pending_payload[offset],
+ sizeof(s_pending_payload) - offset, "%s: %s",
+ entry->author, entry->text);
+ if (written <= 0) {
+ s_last_synced_message_seq = entry->seq;
+ continue;
+ }
+ const size_t available = sizeof(s_pending_payload) - offset;
+ offset += (size_t)written < available ?
+ (size_t)written : available - 1U;
+ } else {
+ const size_t text_len = strnlen(
+ entry->text, sizeof(s_pending_payload) - offset);
+ memcpy(&s_pending_payload[offset], entry->text, text_len);
+ offset += text_len;
+ }
s_last_synced_message_seq = entry->seq;
s_pending_len = offset;
return true;
@@ -729,14 +1508,444 @@ static bool build_next_channel_message(void)
static void build_next_message(void)
{
- if (!build_next_dm_message() && !build_next_channel_message()) {
+ if (!build_admin_cli_message() && !build_next_dm_message() &&
+ !build_next_channel_message()) {
set_simple_response(RESP_CODE_NO_MORE_MESSAGES);
}
}
+static bool authenticated_admin_target(
+ const uint8_t public_key[32], d1l_contact_entry_t *out_contact)
+{
+ if (!contact_for_public_key(public_key, out_contact) ||
+ !d1l_contact_store_can_admin(out_contact)) {
+ set_error_response(ERR_CODE_NOT_FOUND);
+ return false;
+ }
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ if (!admin_session_matches_or_restore(out_contact) ||
+ !admin_snapshot_matches(out_contact)) {
+ set_error_response(ERR_CODE_BAD_STATE);
+ return false;
+ }
+ return true;
+}
+
+static bool capture_pending_admin_request(
+ d1l_ble_admin_request_kind_t kind, const uint8_t public_key[32],
+ const d1l_contact_entry_t *contact,
+ d1l_meshcore_admin_state_t expected_state)
+{
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ if (s_admin_snapshot.state != expected_state ||
+ strcmp(s_admin_snapshot.fingerprint, contact->fingerprint) != 0 ||
+ (expected_state != D1L_MESHCORE_ADMIN_LOGIN_PENDING &&
+ s_admin_snapshot.pending_tag == 0U)) {
+ set_error_response(ERR_CODE_BAD_STATE);
+ return false;
+ }
+ track_admin_request(kind, public_key, contact);
+ return true;
+}
+
+static bool join_pending_admin_request(
+ d1l_ble_admin_request_kind_t kind, const uint8_t public_key[32],
+ const d1l_contact_entry_t *contact,
+ d1l_meshcore_admin_state_t expected_state)
+{
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ if (!contact || s_admin_snapshot.state != expected_state ||
+ strcmp(s_admin_snapshot.fingerprint, contact->fingerprint) != 0) {
+ return false;
+ }
+ track_admin_request(kind, public_key, contact);
+ set_admin_sent_response(
+ true, expected_state == D1L_MESHCORE_ADMIN_LOGIN_PENDING ?
+ read_u32_le(public_key) : s_admin_snapshot.pending_tag);
+ return true;
+}
+
+static bool pending_admin_login_intent_conflicts(
+ const d1l_contact_entry_t *contact, bool guest_requested)
+{
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ if (!contact ||
+ s_admin_snapshot.state != D1L_MESHCORE_ADMIN_LOGIN_PENDING ||
+ strcmp(s_admin_snapshot.fingerprint, contact->fingerprint) != 0) {
+ return false;
+ }
+ if (s_admin_guest_requested != guest_requested) {
+ return true;
+ }
+ return guest_requested &&
+ strcmp(s_admin_guest_fingerprint, contact->fingerprint) != 0;
+}
+
+static void send_login_command(const uint8_t *payload, size_t length)
+{
+ if (length < 33U ||
+ length > 33U + D1L_MESHCORE_ADMIN_MAX_PASSWORD_BYTES) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ d1l_contact_entry_t contact = {0};
+ if (!contact_for_public_key(&payload[1], &contact) ||
+ !d1l_contact_store_can_admin(&contact)) {
+ set_error_response(ERR_CODE_NOT_FOUND);
+ return;
+ }
+ const size_t password_len = length - 33U;
+ if (memchr(&payload[33], '\0', password_len)) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ char password[D1L_MESHCORE_ADMIN_MAX_PASSWORD_BYTES + 1U] = {0};
+ memcpy(password, &payload[33], password_len);
+ const bool guest_requested = password_len == 0U;
+
+ if (pending_admin_login_intent_conflicts(&contact, guest_requested)) {
+ memset(password, 0, sizeof(password));
+ set_error_response(ERR_CODE_BAD_STATE);
+ return;
+ }
+
+ if (join_pending_admin_request(
+ D1L_BLE_ADMIN_REQUEST_LOGIN, &payload[1], &contact,
+ D1L_MESHCORE_ADMIN_LOGIN_PENDING)) {
+ memset(password, 0, sizeof(password));
+ return;
+ }
+
+ clear_admin_request();
+ clear_admin_cli_reply();
+ clear_admin_session_authorization();
+ clear_admin_access_intent();
+ esp_err_t result = d1l_meshcore_service_admin_logout();
+ if (result == ESP_OK) {
+ result = d1l_meshcore_service_admin_login(
+ contact.fingerprint, password);
+ }
+ memset(password, 0, sizeof(password));
+ if (result != ESP_OK) {
+ set_error_response(protocol_error(result));
+ note_error(result, false);
+ return;
+ }
+ if (!capture_pending_admin_request(
+ D1L_BLE_ADMIN_REQUEST_LOGIN, &payload[1], &contact,
+ D1L_MESHCORE_ADMIN_LOGIN_PENDING)) {
+ return;
+ }
+ s_admin_guest_requested = guest_requested;
+ if (s_admin_guest_requested) {
+ snprintf(s_admin_guest_fingerprint,
+ sizeof(s_admin_guest_fingerprint), "%s",
+ contact.fingerprint);
+ }
+ set_admin_sent_response(true, read_u32_le(&payload[1]));
+}
+
+static void begin_admin_status_command(
+ const uint8_t public_key[32], d1l_ble_admin_request_kind_t kind)
+{
+ d1l_contact_entry_t contact = {0};
+ if (!authenticated_admin_target(public_key, &contact)) {
+ return;
+ }
+ if (join_pending_admin_request(
+ kind, public_key, &contact,
+ D1L_MESHCORE_ADMIN_STATUS_PENDING)) {
+ return;
+ }
+ const esp_err_t result = d1l_meshcore_service_admin_request_status();
+ if (result != ESP_OK) {
+ set_error_response(protocol_error(result));
+ note_error(result, false);
+ return;
+ }
+ if (!capture_pending_admin_request(
+ kind, public_key, &contact,
+ D1L_MESHCORE_ADMIN_STATUS_PENDING)) {
+ return;
+ }
+ set_admin_sent_response(true, s_admin_request_tag);
+}
+
+static void send_status_command(const uint8_t *payload, size_t length)
+{
+ if (length < 33U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ begin_admin_status_command(
+ &payload[1], D1L_BLE_ADMIN_REQUEST_STATUS);
+}
+
+static void has_connection_command(const uint8_t *payload, size_t length)
+{
+ if (length != 1U && length < 33U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ if (!admin_state_active(s_admin_snapshot.state)) {
+ set_error_response(ERR_CODE_NOT_FOUND);
+ return;
+ }
+ if (length >= 33U) {
+ d1l_contact_entry_t contact = {0};
+ if (!contact_for_public_key(&payload[1], &contact) ||
+ !admin_session_matches_or_restore(&contact) ||
+ strcmp(s_admin_snapshot.fingerprint, contact.fingerprint) != 0) {
+ set_error_response(ERR_CODE_NOT_FOUND);
+ return;
+ }
+ }
+ set_simple_response(RESP_CODE_OK);
+}
+
+static void logout_command(const uint8_t *payload, size_t length)
+{
+ if (length != 1U && length < 33U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ if (!s_admin_session_authorized) {
+ clear_admin_request();
+ clear_admin_cli_reply();
+ clear_admin_access_intent();
+ set_simple_response(RESP_CODE_OK);
+ return;
+ }
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ if (length >= 33U) {
+ d1l_contact_entry_t contact = {0};
+ if (!contact_for_public_key(&payload[1], &contact) ||
+ !admin_session_matches(&contact) ||
+ strcmp(s_admin_snapshot.fingerprint, contact.fingerprint) != 0) {
+ set_simple_response(RESP_CODE_OK);
+ return;
+ }
+ }
+ clear_admin_request();
+ clear_admin_cli_reply();
+ clear_admin_session_authorization();
+ clear_admin_access_intent();
+ set_result_response(d1l_meshcore_service_admin_logout());
+}
+
+static void begin_admin_query_command(
+ const uint8_t public_key[32], d1l_meshcore_admin_query_t query,
+ uint16_t offset, d1l_ble_admin_request_kind_t kind)
+{
+ d1l_contact_entry_t contact = {0};
+ if (!authenticated_admin_target(public_key, &contact)) {
+ return;
+ }
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ if (s_admin_snapshot.state == D1L_MESHCORE_ADMIN_QUERY_PENDING &&
+ s_admin_snapshot.pending_query == query &&
+ s_admin_snapshot.pending_query_offset == offset &&
+ join_pending_admin_request(
+ kind, public_key, &contact,
+ D1L_MESHCORE_ADMIN_QUERY_PENDING)) {
+ return;
+ }
+ const esp_err_t result =
+ d1l_meshcore_service_admin_request_query(query, offset);
+ if (result != ESP_OK) {
+ set_error_response(protocol_error(result));
+ note_error(result, false);
+ return;
+ }
+ if (!capture_pending_admin_request(
+ kind, public_key, &contact,
+ D1L_MESHCORE_ADMIN_QUERY_PENDING)) {
+ return;
+ }
+ set_admin_sent_response(true, s_admin_request_tag);
+}
+
+static void send_telemetry_command(const uint8_t *payload, size_t length)
+{
+ if (length < 36U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ begin_admin_query_command(
+ &payload[4], D1L_MESHCORE_ADMIN_QUERY_TELEMETRY, 0U,
+ D1L_BLE_ADMIN_REQUEST_TELEMETRY);
+}
+
+static void send_binary_command(const uint8_t *payload, size_t length)
+{
+ if (length < 34U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ const uint8_t *public_key = &payload[1];
+ switch (payload[33]) {
+ case BINARY_REQ_STATUS:
+ begin_admin_status_command(
+ public_key, D1L_BLE_ADMIN_REQUEST_BINARY_STATUS);
+ return;
+ case BINARY_REQ_TELEMETRY:
+ begin_admin_query_command(
+ public_key, D1L_MESHCORE_ADMIN_QUERY_TELEMETRY, 0U,
+ D1L_BLE_ADMIN_REQUEST_BINARY_QUERY);
+ return;
+ case BINARY_REQ_ACCESS_LIST:
+ {
+ d1l_contact_entry_t contact = {0};
+ if (contact_for_public_key(public_key, &contact) &&
+ admin_guest_session_matches(&contact)) {
+ set_error_response(ERR_CODE_BAD_STATE);
+ return;
+ }
+ }
+ begin_admin_query_command(
+ public_key, D1L_MESHCORE_ADMIN_QUERY_ACCESS_LIST, 0U,
+ D1L_BLE_ADMIN_REQUEST_BINARY_QUERY);
+ return;
+ case BINARY_REQ_NEIGHBOURS:
+ if (length < 40U || payload[34] != 0U || payload[35] == 0U ||
+ payload[39] != D1L_MESHCORE_ADMIN_NEIGHBOUR_PREFIX_BYTES) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ begin_admin_query_command(
+ public_key, D1L_MESHCORE_ADMIN_QUERY_NEIGHBOURS,
+ read_u16_le(&payload[36]),
+ D1L_BLE_ADMIN_REQUEST_BINARY_QUERY);
+ return;
+ default:
+ set_error_response(ERR_CODE_UNSUPPORTED_CMD);
+ return;
+ }
+}
+
+static void send_admin_cli_command(
+ const uint8_t *payload, size_t length,
+ const d1l_contact_entry_t *contact)
+{
+ size_t text_len = length - 13U;
+ /* Phone clients may include line endings or C-string padding. */
+ while (text_len > 0U &&
+ (payload[13U + text_len - 1U] == '\0' ||
+ payload[13U + text_len - 1U] == ' ' ||
+ payload[13U + text_len - 1U] == '\t' ||
+ payload[13U + text_len - 1U] == '\r' ||
+ payload[13U + text_len - 1U] == '\n')) {
+ text_len--;
+ }
+ if (text_len == 0U ||
+ text_len > D1L_MESHCORE_ADMIN_MAX_CLI_COMMAND_BYTES ||
+ memchr(&payload[13], '\0', text_len)) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ d1l_meshcore_service_admin_snapshot(&s_admin_snapshot);
+ if (!admin_session_matches_or_restore(contact) ||
+ !admin_snapshot_matches(contact) ||
+ admin_guest_session_matches(contact)) {
+ set_error_response(ERR_CODE_BAD_STATE);
+ return;
+ }
+ note_admin_cli_stage(ADMIN_CLI_STAGE_SESSION_READY);
+ char command[D1L_MESHCORE_ADMIN_MAX_CLI_COMMAND_BYTES + 1U] = {0};
+ memcpy(command, &payload[13], text_len);
+ if (strcmp(command, "clock sync") == 0) {
+ d1l_time_service_status_t time_status = {0};
+ d1l_time_service_status(&time_status);
+ const int64_t wall_epoch = time_status.clock.wall_epoch_sec;
+ if (!time_status.display_time_valid ||
+ wall_epoch < D1L_TIME_WALL_MIN_EPOCH ||
+ wall_epoch > (int64_t)UINT32_MAX) {
+ memset(command, 0, sizeof(command));
+ set_error_response(ERR_CODE_BAD_STATE);
+ return;
+ }
+ /* MeshCore also supports `time `. Use it here because the
+ * anti-replay packet sequence may intentionally be ahead of wall time;
+ * `clock sync` would otherwise copy that sequence into the repeater's
+ * RTC. */
+ const int written = snprintf(
+ command, sizeof(command), "time %lu",
+ (unsigned long)(uint32_t)wall_epoch);
+ if (written <= 0 || (size_t)written >= sizeof(command)) {
+ memset(command, 0, sizeof(command));
+ set_error_response(ERR_CODE_BAD_STATE);
+ return;
+ }
+ }
+ note_admin_cli_stage(ADMIN_CLI_STAGE_NORMALIZED);
+ const esp_err_t result =
+ d1l_meshcore_service_admin_request_cli(command, true);
+ memset(command, 0, sizeof(command));
+ if (result != ESP_OK) {
+ set_error_response(protocol_error(result));
+ note_error(result, false);
+ return;
+ }
+ note_admin_cli_stage(ADMIN_CLI_STAGE_QUEUED);
+ uint8_t public_key[32] = {0};
+ if (!decode_hex(contact->public_key_hex, sizeof(public_key), public_key) ||
+ !capture_pending_admin_request(
+ D1L_BLE_ADMIN_REQUEST_CLI, public_key, contact,
+ D1L_MESHCORE_ADMIN_CLI_PENDING)) {
+ memset(public_key, 0, sizeof(public_key));
+ return;
+ }
+ note_admin_cli_stage(ADMIN_CLI_STAGE_PENDING_TRACKED);
+ memset(public_key, 0, sizeof(public_key));
+ set_admin_sent_response(true, 0U);
+}
+
+static void set_dm_sent_response(const d1l_dm_entry_t *entry, bool flood)
+{
+ s_phone_dm_session = entry->delivery_session_id;
+ s_phone_dm_ack = entry->ack_hash;
+ s_phone_dm_started_ms = (uint32_t)(esp_timer_get_time() / 1000LL);
+ uint8_t response[10] = {RESP_CODE_SENT, flood ? 1U : 0U};
+ write_u32_le(&response[2], s_phone_dm_ack);
+ /* The radio owner may retry a direct attempt by flood. Give that one
+ * delivery session time to finish before the phone offers another send. */
+ write_u32_le(&response[6], D1L_MESHCORE_DIRECT_ACK_TIMEOUT_MS +
+ D1L_MESHCORE_FLOOD_ACK_TIMEOUT_MS + 10000U);
+ (void)set_pending(response, sizeof(response));
+}
+
+static void maybe_queue_dm_confirmation(void)
+{
+ if (s_pending_len != 0U || s_phone_dm_session == 0U) {
+ return;
+ }
+ if (!d1l_dm_store_find_delivery_session(s_phone_dm_session, &s_dms[0])) {
+ s_phone_dm_session = 0U;
+ return;
+ }
+ const d1l_dm_entry_t *entry = &s_dms[0];
+ if (entry->acked && entry->delivery_state == D1L_DM_DELIVERY_ACKNOWLEDGED) {
+ uint8_t response[9] = {PUSH_CODE_SEND_CONFIRMED};
+ /* Retry ACKs can change; the phone correlates the original SENT hash
+ * with this exact delivery session, not a later message or attempt. */
+ write_u32_le(&response[1], s_phone_dm_ack);
+ write_u32_le(&response[5],
+ (uint32_t)(esp_timer_get_time() / 1000LL) -
+ s_phone_dm_started_ms);
+ (void)set_pending(response, sizeof(response));
+ s_phone_dm_session = 0U;
+ } else if (d1l_dm_delivery_state_terminal(entry->delivery_state)) {
+ s_phone_dm_session = 0U;
+ }
+}
+
static void send_dm_command(const uint8_t *payload, size_t length)
{
- if (length < 14U || payload[1] != 0U) {
+ if (length >= 2U) {
+ note_text_command(payload[1], length > 13U ? length - 13U : 0U);
+ }
+ if (length < 14U || (payload[1] != 0U && payload[1] != 1U)) {
set_error_response(ERR_CODE_ILLEGAL_ARG);
return;
}
@@ -745,6 +1954,13 @@ static void send_dm_command(const uint8_t *payload, size_t length)
set_error_response(ERR_CODE_NOT_FOUND);
return;
}
+ if (payload[1] == 1U) {
+ note_admin_cli_stage(ADMIN_CLI_STAGE_CONTACT_FOUND);
+ }
+ if (payload[1] == 1U) {
+ send_admin_cli_command(payload, length, &contact);
+ return;
+ }
const size_t text_len = length - 13U;
if (text_len == 0U || text_len >= D1L_MESSAGE_TEXT_LEN) {
set_error_response(ERR_CODE_ILLEGAL_ARG);
@@ -755,14 +1971,61 @@ static void send_dm_command(const uint8_t *payload, size_t length)
text[text_len] = '\0';
const esp_err_t result =
d1l_app_model_send_dm_text(contact.fingerprint, text);
- memset(text, 0, sizeof(text));
if (result != ESP_OK) {
+ memset(text, 0, sizeof(text));
set_error_response(protocol_error(result));
note_error(result, false);
return;
}
- uint8_t response[10] = {RESP_CODE_SENT, 1U};
- (void)set_pending(response, sizeof(response));
+ const d1l_meshcore_service_status_t mesh = d1l_meshcore_service_status();
+ const bool found = d1l_dm_store_find_delivery_session(
+ mesh.dm_delivery_session_id, &s_dms[0]);
+ const bool matches = found &&
+ strcmp(s_dms[0].contact_fingerprint, contact.fingerprint) == 0 &&
+ strcmp(s_dms[0].text, text) == 0;
+ memset(text, 0, sizeof(text));
+ if (!matches) {
+ set_error_response(ERR_CODE_BAD_STATE);
+ return;
+ }
+ set_dm_sent_response(&s_dms[0], mesh.dm_route_last_reason !=
+ D1L_MESHCORE_ROUTE_SELECTION_DIRECT_PROVEN);
+}
+
+static void get_advert_path_command(const uint8_t *payload, size_t length)
+{
+ if (length < 34U || payload[1] != 0U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ d1l_contact_entry_t contact = {0};
+ if (!contact_for_public_key(&payload[2], &contact)) {
+ set_error_response(ERR_CODE_NOT_FOUND);
+ return;
+ }
+ d1l_meshcore_advert_path_snapshot_t path = {0};
+ if (!d1l_meshcore_service_advert_path_snapshot(contact.fingerprint,
+ &path) ||
+ !path.valid ||
+ !d1l_meshcore_wire_path_len_valid(path.path_len)) {
+ set_error_response(ERR_CODE_NOT_FOUND);
+ return;
+ }
+ const uint8_t path_bytes =
+ d1l_meshcore_wire_path_byte_len(path.path_len);
+ if (6U + path_bytes > sizeof(s_pending_payload)) {
+ set_error_response(ERR_CODE_NOT_FOUND);
+ return;
+ }
+ size_t offset = 0U;
+ s_pending_payload[offset++] = RESP_CODE_ADVERT_PATH;
+ write_u32_le(&s_pending_payload[offset], path.received_epoch);
+ offset += 4U;
+ s_pending_payload[offset++] = path.path_len;
+ if (path_bytes > 0U) {
+ memcpy(&s_pending_payload[offset], path.path, path_bytes);
+ }
+ s_pending_len = offset + path_bytes;
}
static void send_channel_command(const uint8_t *payload, size_t length)
@@ -772,7 +2035,7 @@ static void send_channel_command(const uint8_t *payload, size_t length)
return;
}
d1l_channel_info_t channel = {0};
- if (!channel_at_index(payload[2], &channel)) {
+ if (channel_at_index(payload[2], &channel) != ESP_OK) {
set_error_response(ERR_CODE_NOT_FOUND);
return;
}
@@ -805,7 +2068,12 @@ static void set_channel_command(const uint8_t *payload, size_t length)
const bool delete_requested =
bytes_all_zero(&payload[2], D1L_CHANNEL_NAME_LEN - 1U) &&
bytes_all_zero(&payload[34], D1L_CHANNEL_SECRET_128_LEN);
- if (channel_at_index(payload[1], &existing)) {
+ const esp_err_t lookup = channel_at_index(payload[1], &existing);
+ if (lookup != ESP_OK && lookup != ESP_ERR_NOT_FOUND) {
+ set_error_response(ERR_CODE_BAD_STATE);
+ return;
+ }
+ if (lookup == ESP_OK) {
if (delete_requested) {
result = d1l_app_model_remove_channel(
existing.channel_id, true, &mutation, &result_channel);
@@ -839,7 +2107,7 @@ static void set_channel_command(const uint8_t *payload, size_t length)
uint64_t active_id = 0U;
result = d1l_app_model_copy_channels(
s_channels, D1L_CHANNEL_STORE_CAPACITY, &count, &active_id,
- NULL);
+ &s_channel_stats);
if (result != ESP_OK || payload[1] != count) {
set_error_response(ERR_CODE_NOT_FOUND);
return;
@@ -873,6 +2141,98 @@ static void remove_contact_command(const uint8_t *payload, size_t length)
contact.fingerprint, &removed));
}
+static void add_update_contact_command(const uint8_t *payload, size_t length)
+{
+ const size_t required = 1U + 32U + 3U +
+ D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES + 32U + 4U;
+ if (length < required || bytes_all_zero(&payload[1], 32U)) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+
+ d1l_contact_uri_t imported = {0};
+ for (size_t i = 0U; i < 32U; ++i) {
+ (void)snprintf(&imported.public_key_hex[i * 2U], 3U, "%02x",
+ payload[i + 1U]);
+ }
+ imported.type_id = payload[33U];
+ if (imported.type_id < 1U || imported.type_id > 4U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+
+ const uint8_t flags = payload[34U];
+ const uint8_t path_len = payload[35U];
+ const uint8_t *path = &payload[36U];
+ const uint8_t *wire_name =
+ &payload[36U + D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES];
+ size_t name_len = 0U;
+ while (name_len < sizeof(imported.name) - 1U &&
+ wire_name[name_len] != '\0') {
+ name_len++;
+ }
+ if (name_len > 0U) {
+ memcpy(imported.name, wire_name, name_len);
+ } else {
+ memcpy(imported.name, imported.public_key_hex,
+ D1L_NODE_FINGERPRINT_LEN - 1U);
+ }
+
+ char uri[D1L_CONTACT_EXPORT_URI_LEN] = {0};
+ if (!d1l_contact_uri_format(&imported, uri, sizeof(uri))) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ d1l_contact_import_result_t import_result = D1L_CONTACT_IMPORT_NONE;
+ d1l_contact_entry_t contact = {0};
+ esp_err_t result = d1l_contact_store_import_uri(
+ uri, strlen(uri), &import_result, &contact);
+ memset(uri, 0, sizeof(uri));
+ if (result != ESP_OK) {
+ set_result_response(result);
+ return;
+ }
+
+ result = d1l_contact_store_rename(
+ contact.fingerprint, imported.name, &contact);
+ if (result == ESP_OK) {
+ result = d1l_contact_store_set_flags(
+ contact.fingerprint, (flags & 1U) != 0U, contact.muted,
+ &contact);
+ }
+ if (result == ESP_OK) {
+ if (path_len == D1L_BLE_PROTOCOL_OUT_PATH_UNKNOWN) {
+ result = d1l_contact_store_reset_path(
+ contact.fingerprint, &contact);
+ } else if (d1l_meshcore_wire_path_len_valid(path_len) &&
+ d1l_meshcore_wire_path_byte_len(path_len) <=
+ D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES) {
+ result = d1l_contact_store_update_path_from_source(
+ contact.fingerprint, path, path_len,
+ D1L_MESHCORE_PATH_SOURCE_OBSERVED, &contact);
+ } else {
+ result = ESP_ERR_INVALID_ARG;
+ }
+ }
+ memset(&imported, 0, sizeof(imported));
+ set_result_response(result);
+}
+
+static void reset_contact_path_command(const uint8_t *payload, size_t length)
+{
+ if (length != 33U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ d1l_contact_entry_t contact = {0};
+ if (!contact_for_public_key(&payload[1], &contact)) {
+ set_error_response(ERR_CODE_NOT_FOUND);
+ return;
+ }
+ set_result_response(
+ d1l_meshcore_service_reset_contact_route(contact.fingerprint));
+}
+
static void get_contact_command(const uint8_t *payload, size_t length)
{
if (length != 33U) {
@@ -965,16 +2325,116 @@ static void set_tx_power_command(const uint8_t *payload, size_t length)
static void set_path_hash_command(const uint8_t *payload, size_t length)
{
- if (length < 2U || payload[1] > 1U) {
+ if (length < 3U || payload[1] != 0U || payload[2] > 2U) {
set_error_response(ERR_CODE_ILLEGAL_ARG);
return;
}
d1l_settings_t settings = {0};
- settings.path_hash_bytes = payload[1] + 1U;
+ settings.path_hash_bytes = payload[2] + 1U;
set_result_response(d1l_settings_update_fields(
&settings, D1L_SETTINGS_UPDATE_PATH_HASH));
}
+static void set_flood_scope_command(const uint8_t *payload, size_t length)
+{
+ if (length < 2U || payload[1] > 1U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+
+ if ((payload[1] == 0U && length == 2U) ||
+ (payload[1] == 1U && length == 2U)) {
+ /* DeskOS has no per-transport-key scope override. Clearing an override
+ * and explicitly selecting unscoped floods both match its real state. */
+ set_simple_response(RESP_CODE_OK);
+ return;
+ }
+
+ /* A keyed scope must never be acknowledged and then transmitted unscoped. */
+ set_simple_response(RESP_CODE_DISABLED);
+}
+
+static void set_other_params_command(const uint8_t *payload, size_t length)
+{
+ if (length < 2U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+
+ const uint8_t manual_add_contacts = payload[1];
+ const uint8_t telemetry_mode = length >= 3U ? payload[2] : 0U;
+ const uint8_t advert_location_policy = length >= 4U ? payload[3] : 0U;
+ const uint8_t multi_acks = length >= 5U ? payload[4] : 0U;
+ d1l_settings_t settings = {0};
+ (void)d1l_settings_public_snapshot(&settings);
+ const uint8_t current_location_policy = settings.map_location_set ? 1U : 0U;
+
+ if (manual_add_contacts == 0U && telemetry_mode == 0U &&
+ advert_location_policy == current_location_policy && multi_acks == 0U) {
+ set_simple_response(RESP_CODE_OK);
+ } else {
+ set_simple_response(RESP_CODE_DISABLED);
+ }
+}
+
+static void set_tuning_params_command(const uint8_t *payload, size_t length)
+{
+ if (length < 9U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ if (read_u32_le(&payload[1]) == D1L_BLE_PROTOCOL_RX_DELAY_MILLIS &&
+ read_u32_le(&payload[5]) == D1L_BLE_PROTOCOL_AIRTIME_FACTOR_MILLIS) {
+ set_simple_response(RESP_CODE_OK);
+ } else {
+ set_simple_response(RESP_CODE_DISABLED);
+ }
+}
+
+static void build_tuning_params(void)
+{
+ uint8_t response[9] = {RESP_CODE_TUNING_PARAMS};
+ write_u32_le(&response[1], D1L_BLE_PROTOCOL_RX_DELAY_MILLIS);
+ write_u32_le(&response[5], D1L_BLE_PROTOCOL_AIRTIME_FACTOR_MILLIS);
+ (void)set_pending(response, sizeof(response));
+}
+
+static void set_autoadd_config_command(const uint8_t *payload, size_t length)
+{
+ if (length < 2U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ const uint8_t max_hops = length >= 3U ? payload[2] : 0U;
+ if (payload[1] == D1L_BLE_PROTOCOL_AUTOADD_CONFIG &&
+ max_hops == D1L_BLE_PROTOCOL_AUTOADD_MAX_HOPS) {
+ set_simple_response(RESP_CODE_OK);
+ } else {
+ set_simple_response(RESP_CODE_DISABLED);
+ }
+}
+
+static void queue_self_advert_command(const uint8_t *payload, size_t length)
+{
+ if (length > 2U || (length == 2U && payload[1] > 1U)) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ return;
+ }
+ uint32_t request_id = 0U;
+ set_result_response(d1l_app_model_queue_advert(
+ length == 2U && payload[1] == 1U, &request_id));
+}
+
+static void build_autoadd_config(void)
+{
+ const uint8_t response[] = {
+ RESP_CODE_AUTOADD_CONFIG,
+ D1L_BLE_PROTOCOL_AUTOADD_CONFIG,
+ D1L_BLE_PROTOCOL_AUTOADD_MAX_HOPS,
+ };
+ (void)set_pending(response, sizeof(response));
+}
+
static void dispatch_command(const uint8_t *payload, size_t length)
{
if (!payload || length == 0U) {
@@ -1012,12 +2472,14 @@ static void dispatch_command(const uint8_t *payload, size_t length)
}
return;
case CMD_SEND_SELF_ADVERT:
- set_result_response(d1l_app_model_request_advert(
- length >= 2U && payload[1] == 1U));
+ queue_self_advert_command(payload, length);
return;
case CMD_SET_ADVERT_NAME:
set_name_command(payload, length);
return;
+ case CMD_ADD_UPDATE_CONTACT:
+ add_update_contact_command(payload, length);
+ return;
case CMD_SET_ADVERT_LATLON:
set_location_command(payload, length);
return;
@@ -1027,6 +2489,24 @@ static void dispatch_command(const uint8_t *payload, size_t length)
case CMD_SEND_CHANNEL_TXT_MSG:
send_channel_command(payload, length);
return;
+ case CMD_SEND_LOGIN:
+ send_login_command(payload, length);
+ return;
+ case CMD_SEND_STATUS_REQ:
+ send_status_command(payload, length);
+ return;
+ case CMD_HAS_CONNECTION:
+ has_connection_command(payload, length);
+ return;
+ case CMD_LOGOUT:
+ logout_command(payload, length);
+ return;
+ case CMD_SEND_TELEMETRY_REQ:
+ send_telemetry_command(payload, length);
+ return;
+ case CMD_SEND_BINARY_REQ:
+ send_binary_command(payload, length);
+ return;
case CMD_SYNC_NEXT_MESSAGE:
build_next_message();
return;
@@ -1040,28 +2520,81 @@ static void dispatch_command(const uint8_t *payload, size_t length)
case CMD_SET_CHANNEL:
set_channel_command(payload, length);
return;
+ case CMD_SET_OTHER_PARAMS:
+ set_other_params_command(payload, length);
+ return;
case CMD_REMOVE_CONTACT:
remove_contact_command(payload, length);
return;
+ case CMD_RESET_PATH:
+ reset_contact_path_command(payload, length);
+ return;
case CMD_GET_CONTACT_BY_KEY:
get_contact_command(payload, length);
return;
+ case CMD_GET_ADVERT_PATH:
+ get_advert_path_command(payload, length);
+ return;
case CMD_SET_RADIO_PARAMS:
set_radio_command(payload, length);
return;
case CMD_SET_RADIO_TX_POWER:
set_tx_power_command(payload, length);
return;
+ case CMD_SET_TUNING_PARAMS:
+ set_tuning_params_command(payload, length);
+ return;
+ case CMD_GET_CUSTOM_VARS:
+ set_simple_response(RESP_CODE_CUSTOM_VARS);
+ return;
+ case CMD_SET_CUSTOM_VAR:
+ set_simple_response(RESP_CODE_DISABLED);
+ return;
+ case CMD_GET_TUNING_PARAMS:
+ build_tuning_params();
+ return;
case CMD_SET_PATH_HASH_MODE:
set_path_hash_command(payload, length);
return;
case CMD_GET_BATT_AND_STORAGE:
build_battery_storage();
return;
+ case CMD_GET_STATS:
+ if (length < 2U) {
+ set_error_response(ERR_CODE_ILLEGAL_ARG);
+ note_malformed();
+ } else {
+ build_stats(payload[1]);
+ }
+ return;
+ case CMD_SET_FLOOD_SCOPE_KEY:
+ set_flood_scope_command(payload, length);
+ return;
+ case CMD_SET_AUTOADD_CONFIG:
+ set_autoadd_config_command(payload, length);
+ return;
+ case CMD_GET_AUTOADD_CONFIG:
+ build_autoadd_config();
+ return;
+ case CMD_GET_ALLOWED_REPEAT_FREQ:
+ set_simple_response(RESP_CODE_ALLOWED_REPEAT_FREQ);
+ return;
+ case CMD_SET_DEVICE_PIN:
case CMD_REBOOT:
case CMD_FACTORY_RESET:
case CMD_EXPORT_PRIVATE_KEY:
case CMD_IMPORT_PRIVATE_KEY:
+ case CMD_SHARE_CONTACT:
+ case CMD_EXPORT_CONTACT:
+ case CMD_IMPORT_CONTACT:
+ case CMD_SEND_RAW_DATA:
+ case CMD_SIGN_START:
+ case CMD_SIGN_DATA:
+ case CMD_SIGN_FINISH:
+ case CMD_SEND_TRACE_PATH:
+ case CMD_SEND_PATH_DISCOVERY_REQ:
+ case CMD_SEND_CONTROL_DATA:
+ case CMD_SEND_ANON_REQ:
note_unsupported();
set_simple_response(RESP_CODE_DISABLED);
return;
@@ -1075,19 +2608,40 @@ static void dispatch_command(const uint8_t *payload, size_t length)
static void reset_session_state(void)
{
s_pending_len = 0U;
+ s_phone_dm_session = 0U;
s_contact_count = 0U;
s_contact_index = 0U;
s_contact_iterator_active = false;
- s_last_synced_dm_seq = 0U;
- s_last_synced_message_seq = 0U;
- s_seen_dm_revision = d1l_dm_store_stats().content_revision;
- s_seen_message_revision = d1l_message_store_stats().content_revision;
+ initialize_contact_sync_baseline();
+ clear_admin_request();
+ clear_admin_cli_reply();
+ clear_admin_session_authorization();
+ /* Keep the requested access level across a transient BLE reconnect. The
+ * remote admin session can be restored below, and forgetting that a blank
+ * password requested Guest could otherwise expose stale Admin ACL bits.
+ * An explicit login or logout still replaces or clears this intent. */
+ s_force_message_notification_check = true;
portENTER_CRITICAL(&s_status_lock);
s_status.client_protocol_version = 3U;
s_status.session_count++;
portEXIT_CRITICAL(&s_status_lock);
}
+static uint32_t last_existing_seq(uint32_t next_seq)
+{
+ return next_seq > 0U ? next_seq - 1U : 0U;
+}
+
+static void initialize_message_sync_watermarks(void)
+{
+ const d1l_dm_store_stats_t dm = d1l_dm_store_stats();
+ const d1l_message_store_stats_t messages = d1l_message_store_stats();
+ s_last_synced_dm_seq = last_existing_seq(dm.next_seq);
+ s_last_synced_message_seq = last_existing_seq(messages.next_seq);
+ s_seen_dm_revision = dm.content_revision;
+ s_seen_message_revision = messages.content_revision;
+}
+
static void maybe_queue_message_waiting(void)
{
if (s_pending_len != 0U || s_contact_iterator_active) {
@@ -1095,18 +2649,43 @@ static void maybe_queue_message_waiting(void)
}
const d1l_dm_store_stats_t dm = d1l_dm_store_stats();
const d1l_message_store_stats_t messages = d1l_message_store_stats();
- if (dm.content_revision == s_seen_dm_revision &&
+ if (!s_force_message_notification_check &&
+ dm.content_revision == s_seen_dm_revision &&
messages.content_revision == s_seen_message_revision) {
return;
}
+ s_force_message_notification_check = false;
s_seen_dm_revision = dm.content_revision;
s_seen_message_revision = messages.content_revision;
- set_simple_response(PUSH_CODE_MSG_WAITING);
+
+ bool unsynced = false;
+ const size_t dm_count = d1l_dm_store_copy_recent(
+ s_dms, D1L_DM_STORE_CAPACITY);
+ for (size_t index = 0U; index < dm_count && !unsynced; ++index) {
+ unsynced = s_dms[index].seq > s_last_synced_dm_seq &&
+ strcmp(s_dms[index].direction, "rx") == 0;
+ }
+ size_t message_count = 0U;
+ d1l_message_retained_snapshot_t retained = {0};
+ if (!unsynced &&
+ d1l_message_store_snapshot_retained(
+ s_messages, D1L_MESSAGE_STORE_CAPACITY, &message_count,
+ &retained) == ESP_OK) {
+ for (size_t index = 0U;
+ index < message_count && !unsynced; ++index) {
+ unsynced = s_messages[index].seq > s_last_synced_message_seq &&
+ strcmp(s_messages[index].direction, "rx") == 0;
+ }
+ }
+ if (unsynced) {
+ set_simple_response(PUSH_CODE_MSG_WAITING);
+ }
}
static void protocol_task(void *context)
{
(void)context;
+ initialize_message_sync_watermarks();
reset_session_state();
portENTER_CRITICAL(&s_status_lock);
s_status.running = true;
@@ -1114,6 +2693,7 @@ static void protocol_task(void *context)
portEXIT_CRITICAL(&s_status_lock);
while (s_start_requested) {
+ d1l_ble_companion_poll();
d1l_ble_companion_status_t transport = {0};
d1l_ble_companion_status(&transport);
portENTER_CRITICAL(&s_status_lock);
@@ -1141,7 +2721,11 @@ static void protocol_task(void *context)
}
s_pending_len = 0U;
}
- prepare_next_contact_response();
+ maybe_queue_admin_response();
+ if (s_pending_len != 0U) {
+ continue;
+ }
+ maybe_queue_dm_confirmation();
if (s_pending_len != 0U) {
continue;
}
@@ -1173,6 +2757,14 @@ static void protocol_task(void *context)
if (receive != ESP_ERR_NOT_FOUND) {
note_error(receive, true);
}
+ prepare_next_contact_response();
+ if (s_pending_len != 0U) {
+ continue;
+ }
+ maybe_queue_contact_change();
+ if (s_pending_len != 0U) {
+ continue;
+ }
maybe_queue_message_waiting();
vTaskDelay(pdMS_TO_TICKS(D1L_BLE_PROTOCOL_POLL_MS));
}
diff --git a/main/comms/ble_companion_protocol.h b/main/comms/ble_companion_protocol.h
index 90acac5c..e0dbe839 100644
--- a/main/comms/ble_companion_protocol.h
+++ b/main/comms/ble_companion_protocol.h
@@ -10,9 +10,17 @@ typedef struct {
bool transport_ready;
uint8_t client_protocol_version;
uint8_t last_command;
+ uint8_t last_unsupported_command;
+ uint8_t last_response_error_code;
+ uint8_t last_failed_command;
+ uint8_t last_failed_response_error_code;
+ uint8_t last_text_type;
+ uint8_t last_admin_cli_stage;
+ uint16_t last_text_length;
uint32_t session_count;
uint32_t command_count;
uint32_t response_count;
+ uint32_t response_error_count;
uint32_t unsupported_count;
uint32_t malformed_count;
uint32_t transport_error_count;
diff --git a/main/comms/ble_companion_queue.h b/main/comms/ble_companion_queue.h
index 0b79eceb..55d6ac25 100644
--- a/main/comms/ble_companion_queue.h
+++ b/main/comms/ble_companion_queue.h
@@ -6,7 +6,7 @@
#include "comms/companion_3byte.h"
-#define D1L_BLE_COMPANION_QUEUE_DEPTH 4U
+#define D1L_BLE_COMPANION_QUEUE_DEPTH 16U
#define D1L_BLE_COMPANION_WIRE_FRAME_MAX \
(D1L_COMPANION3_HEADER_SIZE + D1L_COMPANION3_MAX_FRAME_SIZE)
diff --git a/main/comms/connectivity_manager.c b/main/comms/connectivity_manager.c
index 5430c368..91daf8a0 100644
--- a/main/comms/connectivity_manager.c
+++ b/main/comms/connectivity_manager.c
@@ -1024,6 +1024,12 @@ static esp_err_t ensure_wifi_started(void)
set_wifi_last_error("build_disabled");
return ESP_ERR_NOT_SUPPORTED;
}
+ d1l_ble_companion_status_t ble_status = {0};
+ d1l_ble_companion_status(&ble_status);
+ if (ble_status.stack_initialized) {
+ set_wifi_last_error("ble_mode_active");
+ return ESP_ERR_INVALID_STATE;
+ }
esp_err_t ret = ensure_wifi_retry_worker();
if (ret != ESP_OK) {
return fail_closed_wifi_runtime(ret, "retry_task_failed");
@@ -1286,7 +1292,7 @@ static void fill_status(d1l_connectivity_status_t *out_status)
wifi_signal_snapshot(&out_status->wifi_rssi_dbm,
&out_status->wifi_channel);
#endif
- out_status->coexistence_policy = "wifi_ble_shared_radio";
+ out_status->coexistence_policy = "exclusive_wifi_or_ble";
}
esp_err_t d1l_connectivity_prepare_reboot(void)
@@ -1344,6 +1350,18 @@ esp_err_t d1l_connectivity_init(void)
d1l_settings_t settings = {0};
(void)d1l_settings_public_snapshot(&settings);
+ if (settings.wifi_enabled && settings.ble_companion_enabled) {
+ /* Legacy coexistence settings cannot safely satisfy phone pairing on
+ * this board. Prefer the explicitly enabled companion mode once, then
+ * all future mode changes persist the same mutual exclusion. */
+ settings.wifi_enabled = false;
+ const esp_err_t normalize_ret = d1l_settings_update_fields(
+ &settings, D1L_SETTINGS_UPDATE_WIFI_ENABLED);
+ if (normalize_ret != ESP_OK) {
+ set_wifi_last_error("mode_normalize_failed");
+ return normalize_ret;
+ }
+ }
#ifdef CONFIG_ESP_WIFI_ENABLED
if (!s_boot_guard_lock) {
s_boot_guard_lock = xSemaphoreCreateMutex();
@@ -1764,6 +1782,18 @@ esp_err_t d1l_connectivity_set_wifi_enabled(bool enabled)
}
d1l_settings_t settings = {0};
esp_err_t ret;
+ if (enabled) {
+ d1l_ble_companion_status_t ble_status = {0};
+ (void)d1l_settings_public_snapshot(&settings);
+ d1l_ble_companion_status(&ble_status);
+ if (settings.ble_companion_enabled || ble_status.stack_initialized) {
+ ret = d1l_connectivity_set_ble_enabled(false);
+ if (ret != ESP_OK) {
+ return ret;
+ }
+ }
+ memset(&settings, 0, sizeof(settings));
+ }
if (!enabled) {
#ifdef CONFIG_ESP_WIFI_ENABLED
bool control_taken = false;
@@ -1860,6 +1890,22 @@ esp_err_t d1l_connectivity_set_ble_enabled(bool enabled)
return ESP_ERR_NOT_SUPPORTED;
}
d1l_settings_t settings = {0};
+ (void)d1l_settings_public_snapshot(&settings);
+ if (enabled) {
+#ifdef CONFIG_ESP_WIFI_ENABLED
+ const bool wifi_runtime_active = s_wifi_initialized;
+#else
+ const bool wifi_runtime_active = false;
+#endif
+ if (settings.wifi_enabled || wifi_runtime_active) {
+ const esp_err_t wifi_ret =
+ d1l_connectivity_set_wifi_enabled(false);
+ if (wifi_ret != ESP_OK) {
+ return wifi_ret;
+ }
+ }
+ }
+ memset(&settings, 0, sizeof(settings));
settings.ble_companion_enabled = enabled;
const esp_err_t ret = d1l_settings_update_fields(
&settings, D1L_SETTINGS_UPDATE_BLE_ENABLED);
@@ -1887,7 +1933,7 @@ esp_err_t d1l_connectivity_ble_begin_pairing(void)
}
d1l_settings_t settings = {0};
(void)d1l_settings_public_snapshot(&settings);
- if (!settings.ble_companion_enabled) {
+ if (!settings.ble_companion_enabled || settings.wifi_enabled) {
return ESP_ERR_INVALID_STATE;
}
return d1l_ble_companion_begin_pairing();
diff --git a/main/comms/usb_console.c b/main/comms/usb_console.c
index e501e202..64a6b847 100644
--- a/main/comms/usb_console.c
+++ b/main/comms/usb_console.c
@@ -8886,7 +8886,28 @@ static void print_ble_status_result(const char *command)
"\"build_enabled\":%s,\"stack_active\":%s,"
"\"runtime_policy_violation\":%s,\"state\":\"%s\","
"\"last_error\":\"%s\",\"last_nimble_error\":%d,"
- "\"policy\":\"%s\"}\n",
+ "\"policy\":\"%s\",\"connected\":%s,\"encrypted\":%s,"
+ "\"authenticated\":%s,\"bonded\":%s,"
+ "\"notification_requested\":%s,\"notification_enabled\":%s,"
+ "\"att_mtu\":%u,\"preferred_att_mtu\":%u,"
+ "\"connect_count\":%lu,\"disconnect_count\":%lu,"
+ "\"rx_frames\":%lu,\"tx_frames\":%lu,"
+ "\"rx_drops\":%lu,\"tx_drops\":%lu,"
+ "\"malformed_frames\":%lu,\"security_rejects\":%lu,"
+ "\"rx_queue_depth\":%u,\"tx_queue_depth\":%u,"
+ "\"protocol_running\":%s,\"protocol_ready\":%s,"
+ "\"protocol_commands\":%lu,\"protocol_responses\":%lu,"
+ "\"protocol_unsupported\":%lu,"
+ "\"protocol_malformed\":%lu,"
+ "\"protocol_last_command\":%u,"
+ "\"protocol_last_unsupported_command\":%u,"
+ "\"protocol_last_response_error_code\":%u,"
+ "\"protocol_last_failed_command\":%u,"
+ "\"protocol_last_failed_response_error_code\":%u,"
+ "\"protocol_response_errors\":%lu,"
+ "\"protocol_last_text_type\":%u,"
+ "\"protocol_last_text_length\":%u,"
+ "\"protocol_last_admin_cli_stage\":%u}\n",
bool_json(available),
bool_json(
available && status.ble_companion_enabled_setting),
@@ -8895,7 +8916,35 @@ static void print_ble_status_result(const char *command)
bool_json(status.ble_stack_active),
bool_json(!available && status.ble_stack_active),
status.ble_state, esp_err_to_name(ble.last_error),
- ble.last_nimble_error, status.coexistence_policy);
+ ble.last_nimble_error, status.coexistence_policy,
+ bool_json(ble.connected), bool_json(ble.encrypted),
+ bool_json(ble.authenticated), bool_json(ble.bonded),
+ bool_json(ble.notification_requested),
+ bool_json(ble.notification_enabled), ble.att_mtu,
+ D1L_BLE_COMPANION_PREFERRED_ATT_MTU,
+ (unsigned long)ble.connect_count,
+ (unsigned long)ble.disconnect_count,
+ (unsigned long)ble.rx_frame_count,
+ (unsigned long)ble.tx_frame_count,
+ (unsigned long)ble.rx_drop_count,
+ (unsigned long)ble.tx_drop_count,
+ (unsigned long)ble.malformed_frame_count,
+ (unsigned long)ble.security_reject_count,
+ ble.rx_queue_depth, ble.tx_queue_depth,
+ bool_json(ble.protocol_running), bool_json(ble.protocol_ready),
+ (unsigned long)ble.protocol_command_count,
+ (unsigned long)ble.protocol_response_count,
+ (unsigned long)ble.protocol_unsupported_count,
+ (unsigned long)ble.protocol_malformed_count,
+ ble.protocol_last_command,
+ ble.protocol_last_unsupported_command,
+ ble.protocol_last_response_error_code,
+ ble.protocol_last_failed_command,
+ ble.protocol_last_failed_response_error_code,
+ (unsigned long)ble.protocol_response_error_count,
+ ble.protocol_last_text_type,
+ ble.protocol_last_text_length,
+ ble.protocol_last_admin_cli_stage);
}
static void cmd_ble_status(void)
diff --git a/main/d1l_config.h b/main/d1l_config.h
index 038f9dc9..92339ff9 100644
--- a/main/d1l_config.h
+++ b/main/d1l_config.h
@@ -1,7 +1,7 @@
#pragma once
#define D1L_FIRMWARE_NAME "MeshCore DeskOS D1L"
-#define D1L_FIRMWARE_VERSION "1.7.10"
+#define D1L_FIRMWARE_VERSION "1.7.12"
#define D1L_CONSOLE_SCHEMA 1
#define D1L_DISPLAY_WIDTH 480
diff --git a/main/mesh/admin_credential_store.c b/main/mesh/admin_credential_store.c
index eb470310..58dfea03 100644
--- a/main/mesh/admin_credential_store.c
+++ b/main/mesh/admin_credential_store.c
@@ -10,6 +10,7 @@
#define D1L_ADMIN_CREDENTIAL_MAGIC UINT32_C(0x41445057)
#define D1L_ADMIN_CREDENTIAL_VERSION UINT32_C(1)
+#define D1L_ADMIN_CREDENTIAL_CAPACITY 16U
typedef struct {
bool valid;
@@ -22,7 +23,7 @@ typedef struct {
uint32_t magic;
uint32_t version;
uint32_t next_use;
- d1l_admin_credential_record_t records[D1L_CONTACT_STORE_CAPACITY];
+ d1l_admin_credential_record_t records[D1L_ADMIN_CREDENTIAL_CAPACITY];
} d1l_admin_credential_blob_t;
static d1l_store_lock_t s_lock = D1L_STORE_LOCK_INITIALIZER;
@@ -89,7 +90,7 @@ static esp_err_t blob_load(nvs_handle_t handle,
secure_zero(blob, sizeof(*blob));
return ESP_ERR_INVALID_STATE;
}
- for (size_t i = 0U; i < D1L_CONTACT_STORE_CAPACITY; ++i) {
+ for (size_t i = 0U; i < D1L_ADMIN_CREDENTIAL_CAPACITY; ++i) {
const d1l_admin_credential_record_t *record = &blob->records[i];
if (record->valid &&
(!fingerprint_valid(record->fingerprint) ||
@@ -113,7 +114,7 @@ static esp_err_t blob_commit(nvs_handle_t handle,
static int record_index(const d1l_admin_credential_blob_t *blob,
const char *fingerprint)
{
- for (size_t i = 0U; i < D1L_CONTACT_STORE_CAPACITY; ++i) {
+ for (size_t i = 0U; i < D1L_ADMIN_CREDENTIAL_CAPACITY; ++i) {
if (blob->records[i].valid &&
strcmp(blob->records[i].fingerprint, fingerprint) == 0) {
return (int)i;
@@ -125,7 +126,7 @@ static int record_index(const d1l_admin_credential_blob_t *blob,
static size_t replacement_index(const d1l_admin_credential_blob_t *blob)
{
size_t oldest = 0U;
- for (size_t i = 0U; i < D1L_CONTACT_STORE_CAPACITY; ++i) {
+ for (size_t i = 0U; i < D1L_ADMIN_CREDENTIAL_CAPACITY; ++i) {
if (!blob->records[i].valid) {
return i;
}
@@ -212,11 +213,11 @@ esp_err_t d1l_admin_credential_store_save(
memcpy(record->password, password, password_len + 1U);
record->last_used = blob.next_use++;
if (blob.next_use == 0U) {
- for (size_t i = 0U; i < D1L_CONTACT_STORE_CAPACITY; ++i) {
+ for (size_t i = 0U; i < D1L_ADMIN_CREDENTIAL_CAPACITY; ++i) {
blob.records[i].last_used = blob.records[i].valid ?
(uint32_t)(i + 1U) : 0U;
}
- blob.next_use = D1L_CONTACT_STORE_CAPACITY + 1U;
+ blob.next_use = D1L_ADMIN_CREDENTIAL_CAPACITY + 1U;
}
ret = blob_commit(handle, &blob);
}
@@ -256,4 +257,3 @@ esp_err_t d1l_admin_credential_store_forget(const char *fingerprint)
d1l_store_lock_give(&s_lock);
return ret;
}
-
diff --git a/main/mesh/advert_data.c b/main/mesh/advert_data.c
index 62bed953..fad83b9d 100644
--- a/main/mesh/advert_data.c
+++ b/main/mesh/advert_data.c
@@ -101,6 +101,9 @@ bool d1l_advert_data_parse(const uint8_t *app_data, size_t app_data_len,
size_t name_len = 0U;
while (i < app_data_len && name_len + 1U < sizeof(parsed.name)) {
unsigned char c = app_data[i++];
+ if (c == '\0') {
+ break;
+ }
if (c < 32U || c > 126U || c == '"' || c == '\\') {
c = '_';
}
diff --git a/main/mesh/contact_store.c b/main/mesh/contact_store.c
index ba5bebe8..1a4ce0ff 100644
--- a/main/mesh/contact_store.c
+++ b/main/mesh/contact_store.c
@@ -20,7 +20,9 @@
#define D1L_CONTACT_STORE_SCHEMA_V4 4U
#define D1L_CONTACT_STORE_SCHEMA_V5 5U
#define D1L_CONTACT_STORE_SCHEMA_V6 6U
-#define D1L_CONTACT_STORE_SCHEMA 7U
+#define D1L_CONTACT_STORE_SCHEMA_V7 7U
+#define D1L_CONTACT_STORE_SCHEMA 8U
+#define D1L_CONTACT_STORE_LEGACY_CAPACITY 16U
typedef struct {
uint32_t seq;
@@ -44,7 +46,7 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- d1l_contact_entry_v1_t entries[D1L_CONTACT_STORE_CAPACITY];
+ d1l_contact_entry_v1_t entries[D1L_CONTACT_STORE_LEGACY_CAPACITY];
} d1l_contact_store_blob_v1_t;
typedef struct {
@@ -70,7 +72,7 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- d1l_contact_entry_v2_t entries[D1L_CONTACT_STORE_CAPACITY];
+ d1l_contact_entry_v2_t entries[D1L_CONTACT_STORE_LEGACY_CAPACITY];
} d1l_contact_store_blob_v2_t;
/* Schema v3 predates canonical advert roles and the 9-byte repeater string. */
@@ -101,7 +103,7 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- d1l_contact_entry_v3_t entries[D1L_CONTACT_STORE_CAPACITY];
+ d1l_contact_entry_v3_t entries[D1L_CONTACT_STORE_LEGACY_CAPACITY];
} d1l_contact_store_blob_v3_t;
/* Schema v4 is the last layout before explicit identity provenance. */
@@ -132,7 +134,7 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- d1l_contact_entry_v4_t entries[D1L_CONTACT_STORE_CAPACITY];
+ d1l_contact_entry_v4_t entries[D1L_CONTACT_STORE_LEGACY_CAPACITY];
} d1l_contact_store_blob_v4_t;
/* Schema v5 is the frozen provenance layout with a 23-byte name capacity. */
@@ -167,7 +169,7 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- d1l_contact_entry_v5_t entries[D1L_CONTACT_STORE_CAPACITY];
+ d1l_contact_entry_v5_t entries[D1L_CONTACT_STORE_LEGACY_CAPACITY];
} d1l_contact_store_blob_v5_t;
/* Schema v6 is the last layout before canonical retained path lifecycle. */
@@ -202,9 +204,18 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- d1l_contact_entry_v6_t entries[D1L_CONTACT_STORE_CAPACITY];
+ d1l_contact_entry_v6_t entries[D1L_CONTACT_STORE_LEGACY_CAPACITY];
} d1l_contact_store_blob_v6_t;
+typedef struct {
+ uint32_t schema;
+ uint32_t next_seq;
+ uint32_t total_written;
+ uint32_t dropped_oldest;
+ uint32_t count;
+ d1l_contact_entry_t entries[D1L_CONTACT_STORE_LEGACY_CAPACITY];
+} d1l_contact_store_blob_v7_t;
+
_Static_assert(sizeof(d1l_contact_entry_v1_t) == 100U,
"contact schema v1 layout changed");
_Static_assert(sizeof(d1l_contact_entry_v2_t) == 164U,
@@ -219,6 +230,9 @@ _Static_assert(sizeof(d1l_contact_entry_v6_t) == 256U,
"contact schema v6 layout changed");
_Static_assert(sizeof(d1l_contact_entry_t) == 280U,
"contact schema v7 layout changed");
+_Static_assert(D1L_CONTACT_STORE_LEGACY_CAPACITY <
+ D1L_CONTACT_STORE_CAPACITY,
+ "contact schema v8 must expand retained capacity");
_Static_assert(offsetof(d1l_contact_entry_v1_t, last_rssi_dbm) == 88U,
"contact schema v1 type offset changed");
_Static_assert(offsetof(d1l_contact_entry_v2_t, last_rssi_dbm) == 152U,
@@ -946,7 +960,7 @@ static bool blob_v1_is_valid(const d1l_contact_store_blob_v1_t *blob, size_t len
{
return blob && len == sizeof(*blob) &&
blob->schema == D1L_CONTACT_STORE_SCHEMA_V1 &&
- blob->count <= D1L_CONTACT_STORE_CAPACITY &&
+ blob->count <= D1L_CONTACT_STORE_LEGACY_CAPACITY &&
blob->next_seq > 0;
}
@@ -954,7 +968,7 @@ static bool blob_v2_is_valid(const d1l_contact_store_blob_v2_t *blob, size_t len
{
return blob && len == sizeof(*blob) &&
blob->schema == D1L_CONTACT_STORE_SCHEMA_V2 &&
- blob->count <= D1L_CONTACT_STORE_CAPACITY &&
+ blob->count <= D1L_CONTACT_STORE_LEGACY_CAPACITY &&
blob->next_seq > 0;
}
@@ -962,7 +976,7 @@ static bool blob_v3_is_valid(const d1l_contact_store_blob_v3_t *blob, size_t len
{
return blob && len == sizeof(*blob) &&
blob->schema == D1L_CONTACT_STORE_SCHEMA_V3 &&
- blob->count <= D1L_CONTACT_STORE_CAPACITY &&
+ blob->count <= D1L_CONTACT_STORE_LEGACY_CAPACITY &&
blob->next_seq > 0;
}
@@ -970,7 +984,7 @@ static bool blob_v4_is_valid(const d1l_contact_store_blob_v4_t *blob, size_t len
{
return blob && len == sizeof(*blob) &&
blob->schema == D1L_CONTACT_STORE_SCHEMA_V4 &&
- blob->count <= D1L_CONTACT_STORE_CAPACITY &&
+ blob->count <= D1L_CONTACT_STORE_LEGACY_CAPACITY &&
blob->next_seq > 0;
}
@@ -978,7 +992,7 @@ static bool blob_v5_is_valid(const d1l_contact_store_blob_v5_t *blob, size_t len
{
return blob && len == sizeof(*blob) &&
blob->schema == D1L_CONTACT_STORE_SCHEMA_V5 &&
- blob->count <= D1L_CONTACT_STORE_CAPACITY &&
+ blob->count <= D1L_CONTACT_STORE_LEGACY_CAPACITY &&
blob->next_seq > 0;
}
@@ -986,10 +1000,27 @@ static bool blob_v6_is_valid(const d1l_contact_store_blob_v6_t *blob, size_t len
{
return blob && len == sizeof(*blob) &&
blob->schema == D1L_CONTACT_STORE_SCHEMA_V6 &&
- blob->count <= D1L_CONTACT_STORE_CAPACITY &&
+ blob->count <= D1L_CONTACT_STORE_LEGACY_CAPACITY &&
blob->next_seq > 0;
}
+static bool blob_v7_is_valid(const d1l_contact_store_blob_v7_t *blob,
+ size_t len)
+{
+ if (!blob || len != sizeof(*blob) ||
+ blob->schema != D1L_CONTACT_STORE_SCHEMA_V7 ||
+ blob->count > D1L_CONTACT_STORE_LEGACY_CAPACITY ||
+ blob->next_seq == 0U) {
+ return false;
+ }
+ for (size_t i = 0U; i < blob->count; ++i) {
+ if (!retained_path_record_is_valid(&blob->entries[i])) {
+ return false;
+ }
+ }
+ return true;
+}
+
static void migrate_v1_blob(const d1l_contact_store_blob_v1_t *old_blob)
{
clear_ram();
@@ -1165,6 +1196,17 @@ static void migrate_v6_blob(const d1l_contact_store_blob_v6_t *old_blob)
}
}
+static void migrate_v7_blob(const d1l_contact_store_blob_v7_t *old_blob)
+{
+ clear_ram();
+ s_count = old_blob->count;
+ s_next_seq = old_blob->next_seq;
+ s_total_written = old_blob->total_written;
+ s_dropped_oldest = old_blob->dropped_oldest;
+ memcpy(s_entries, old_blob->entries,
+ s_count * sizeof(old_blob->entries[0]));
+}
+
static void finalize_migrated_path_state(void)
{
for (size_t i = 0U; i < s_count; ++i) {
@@ -1392,6 +1434,7 @@ esp_err_t d1l_contact_store_init(void)
reset_persistence_state(backend.generation);
s_loaded = false;
bool migrated = false;
+ bool migrated_path_layout = false;
bool loaded_from_legacy = false;
bool loaded_from_sd = false;
@@ -1421,30 +1464,42 @@ esp_err_t d1l_contact_store_init(void)
s_next_seq = s_blob_scratch.next_seq;
s_total_written = s_blob_scratch.total_written;
s_dropped_oldest = s_blob_scratch.dropped_oldest;
+ } else if (ret == ESP_OK && blob_v7_is_valid(
+ (const d1l_contact_store_blob_v7_t *)&s_blob_scratch,
+ len)) {
+ migrate_v7_blob(
+ (const d1l_contact_store_blob_v7_t *)&s_blob_scratch);
+ migrated = true;
} else if (ret == ESP_OK &&
blob_v6_is_valid((const d1l_contact_store_blob_v6_t *)&s_blob_scratch, len)) {
migrate_v6_blob((const d1l_contact_store_blob_v6_t *)&s_blob_scratch);
migrated = true;
+ migrated_path_layout = true;
} else if (ret == ESP_OK &&
blob_v5_is_valid((const d1l_contact_store_blob_v5_t *)&s_blob_scratch, len)) {
migrate_v5_blob((const d1l_contact_store_blob_v5_t *)&s_blob_scratch);
migrated = true;
+ migrated_path_layout = true;
} else if (ret == ESP_OK &&
blob_v4_is_valid((const d1l_contact_store_blob_v4_t *)&s_blob_scratch, len)) {
migrate_v4_blob((const d1l_contact_store_blob_v4_t *)&s_blob_scratch);
migrated = true;
+ migrated_path_layout = true;
} else if (ret == ESP_OK &&
blob_v3_is_valid((const d1l_contact_store_blob_v3_t *)&s_blob_scratch, len)) {
migrate_v3_blob((const d1l_contact_store_blob_v3_t *)&s_blob_scratch);
migrated = true;
+ migrated_path_layout = true;
} else if (ret == ESP_OK &&
blob_v1_is_valid((const d1l_contact_store_blob_v1_t *)&s_blob_scratch, len)) {
migrate_v1_blob((const d1l_contact_store_blob_v1_t *)&s_blob_scratch);
migrated = true;
+ migrated_path_layout = true;
} else if (ret == ESP_OK &&
blob_v2_is_valid((const d1l_contact_store_blob_v2_t *)&s_blob_scratch, len)) {
migrate_v2_blob((const d1l_contact_store_blob_v2_t *)&s_blob_scratch);
migrated = true;
+ migrated_path_layout = true;
} else if (ret == ESP_OK) {
/* A retained contact blob is user data. An unrecognised schema or a
* corrupt record must never be converted into an empty, committed
@@ -1455,7 +1510,7 @@ esp_err_t d1l_contact_store_init(void)
ret = s_blob_scratch.schema > D1L_CONTACT_STORE_SCHEMA ?
ESP_ERR_NOT_SUPPORTED : ESP_ERR_INVALID_STATE;
}
- if (ret == ESP_OK && migrated) {
+ if (ret == ESP_OK && migrated_path_layout) {
finalize_migrated_path_state();
}
d1l_retained_blob_store_backend_state_t settled_backend = {0};
@@ -1868,6 +1923,10 @@ esp_err_t d1l_contact_store_upsert_verified_advert(
memset(entry, 0, sizeof(*entry));
entry->created_ms = now_ms;
}
+ const bool alias_tracks_advert =
+ result != D1L_CONTACT_VERIFIED_ADVERT_PROMOTED_PLACEHOLDER &&
+ (entry->alias[0] == '\0' ||
+ strcmp(entry->alias, entry->heard_name) == 0);
entry->seq = s_next_seq++;
entry->updated_ms = now_ms;
copy_lower_hex(entry->fingerprint, sizeof(entry->fingerprint), fingerprint,
@@ -1878,6 +1937,11 @@ esp_err_t d1l_contact_store_upsert_verified_advert(
if (verified_node->name[0] != '\0') {
sanitize_ascii_bounded(entry->heard_name, sizeof(entry->heard_name),
verified_node->name, sizeof(verified_node->name));
+ if (alias_tracks_advert) {
+ sanitize_ascii_bounded(entry->alias, sizeof(entry->alias),
+ verified_node->name,
+ sizeof(verified_node->name));
+ }
}
if (verified_node->type[0] != '\0') {
sanitize_ascii_bounded(entry->type, sizeof(entry->type),
diff --git a/main/mesh/contact_store.h b/main/mesh/contact_store.h
index 9da9b857..f6189998 100644
--- a/main/mesh/contact_store.h
+++ b/main/mesh/contact_store.h
@@ -9,7 +9,7 @@
#include "mesh/meshcore_path_state.h"
#include "mesh/node_store.h"
-#define D1L_CONTACT_STORE_CAPACITY 16U
+#define D1L_CONTACT_STORE_CAPACITY 64U
#define D1L_CONTACT_ALIAS_LEN 32U
#define D1L_CONTACT_OUT_PATH_MAX 64U
#define D1L_CONTACT_EXPORT_URI_LEN 224U
diff --git a/main/mesh/meshcore_admin_dispatch.c b/main/mesh/meshcore_admin_dispatch.c
index f76283b9..19f54d99 100644
--- a/main/mesh/meshcore_admin_dispatch.c
+++ b/main/mesh/meshcore_admin_dispatch.c
@@ -1,4 +1,5 @@
#include "meshcore_admin_dispatch.h"
+#include "user_text.h"
#include
#include
@@ -500,20 +501,30 @@ d1l_meshcore_admin_accept_login_response(
d1l_meshcore_admin_timeout(session);
return D1L_MESHCORE_ADMIN_RESPONSE_EXPIRED;
}
- if (!d1l_meshcore_admin_canonical_span(
+ const bool legacy_response = d1l_meshcore_admin_canonical_span(
+ plaintext, plaintext_len,
+ D1L_MESHCORE_ADMIN_LEGACY_LOGIN_RESPONSE_BYTES) &&
+ plaintext[4] == 'O' && plaintext[5] == 'K';
+ const bool current_response = !legacy_response &&
+ d1l_meshcore_admin_canonical_span(
plaintext, plaintext_len,
- D1L_MESHCORE_ADMIN_LOGIN_RESPONSE_BYTES)) {
+ D1L_MESHCORE_ADMIN_LOGIN_RESPONSE_BYTES);
+ if (!legacy_response && !current_response) {
(void)d1l_meshcore_admin_fail(
session, D1L_MESHCORE_ADMIN_UNSUPPORTED_PROTOCOL);
return D1L_MESHCORE_ADMIN_RESPONSE_MALFORMED;
}
- if (plaintext[4] != 0U) {
+ if (current_response && plaintext[4] != 0U) {
(void)d1l_meshcore_admin_fail(
session, D1L_MESHCORE_ADMIN_REJECTED_CREDENTIALS);
return D1L_MESHCORE_ADMIN_RESPONSE_REJECTED;
}
- const uint8_t permissions = plaintext[7];
+ const uint8_t permissions = legacy_response ?
+ (session->role == D1L_MESHCORE_ADMIN_ROLE_REPEATER ?
+ D1L_MESHCORE_ADMIN_PERMISSION_ADMIN :
+ D1L_MESHCORE_ADMIN_PERMISSION_GUEST) :
+ plaintext[7];
const uint8_t permission_role =
permissions & D1L_MESHCORE_ADMIN_PERMISSION_ROLE_MASK;
const uint8_t expected_firmware =
@@ -522,8 +533,9 @@ d1l_meshcore_admin_accept_login_response(
permission_role == D1L_MESHCORE_ADMIN_PERMISSION_ADMIN ? 1U :
(session->role == D1L_MESHCORE_ADMIN_ROLE_ROOM &&
permission_role == D1L_MESHCORE_ADMIN_PERMISSION_GUEST ? 2U : 0U);
- if (plaintext[5] != 0U || plaintext[6] != expected_legacy_role ||
- plaintext[12] != expected_firmware) {
+ if (current_response &&
+ (plaintext[6] != expected_legacy_role ||
+ plaintext[12] != expected_firmware)) {
(void)d1l_meshcore_admin_fail(
session, D1L_MESHCORE_ADMIN_UNSUPPORTED_PROTOCOL);
return D1L_MESHCORE_ADMIN_RESPONSE_MALFORMED;
@@ -534,18 +546,24 @@ d1l_meshcore_admin_accept_login_response(
session, D1L_MESHCORE_ADMIN_UNSUPPORTED_PROTOCOL);
return D1L_MESHCORE_ADMIN_RESPONSE_MALFORMED;
}
+ uint8_t replay_response[D1L_MESHCORE_ADMIN_LOGIN_RESPONSE_BYTES] = {0};
+ memcpy(replay_response, plaintext,
+ legacy_response ? D1L_MESHCORE_ADMIN_LEGACY_LOGIN_RESPONSE_BYTES :
+ D1L_MESHCORE_ADMIN_LOGIN_RESPONSE_BYTES);
if (replay_cache_contains(
- replay_cache, peer_public_key, plaintext, server_timestamp)) {
+ replay_cache, peer_public_key, replay_response,
+ server_timestamp)) {
(void)d1l_meshcore_admin_fail(
session, D1L_MESHCORE_ADMIN_VOLATILE_REPLAY_REJECTED);
return D1L_MESHCORE_ADMIN_RESPONSE_REPLAYED;
}
replay_cache_remember(
- replay_cache, peer_public_key, plaintext, server_timestamp);
+ replay_cache, peer_public_key, replay_response, server_timestamp);
session->server_timestamp = server_timestamp;
session->permissions = permissions;
- session->firmware_level = plaintext[12];
+ session->firmware_level = legacy_response ? expected_firmware :
+ plaintext[12];
session->request_deadline_us = 0U;
session->absolute_deadline_us =
deadline_after(now_us, session->absolute_timeout_us);
@@ -609,6 +627,9 @@ bool d1l_meshcore_admin_begin_query_request(
session->pending_tag = tag;
session->pending_query = query;
session->pending_query_offset = offset;
+ session->query_wire_len = 0U;
+ d1l_meshcore_admin_secure_zero(
+ session->query_wire, sizeof(session->query_wire));
session->request_deadline_us = request_deadline_us;
session->state = D1L_MESHCORE_ADMIN_QUERY_PENDING;
session->generation = next_generation(session->generation);
@@ -1216,12 +1237,6 @@ bool d1l_meshcore_admin_cancel_cli_command(
return true;
}
-static bool cli_reply_byte_valid(uint8_t value)
-{
- return value == '\n' || value == '\r' || value == '\t' ||
- (value >= 0x20U && value <= 0x7EU);
-}
-
static bool cli_reply_prefix(
const uint8_t *text, size_t text_len, size_t offset,
const char *prefix)
@@ -1391,10 +1406,9 @@ d1l_meshcore_admin_accept_cli_response(
response_timestamp <= session->server_timestamp) {
return D1L_MESHCORE_ADMIN_RESPONSE_MALFORMED;
}
- for (size_t i = 0U; i < text_len; ++i) {
- if (!cli_reply_byte_valid(text[i])) {
- return D1L_MESHCORE_ADMIN_RESPONSE_MALFORMED;
- }
+ if (d1l_user_text_validate_display_span(text, text_len).result !=
+ D1L_USER_TEXT_OK) {
+ return D1L_MESHCORE_ADMIN_RESPONSE_MALFORMED;
}
const bool rejected = cli_reply_is_error(
@@ -2085,7 +2099,15 @@ d1l_meshcore_admin_accept_query_response(
return D1L_MESHCORE_ADMIN_RESPONSE_MALFORMED;
}
+ const size_t wire_len = plaintext_len - 4U;
+ if (wire_len > sizeof(session->query_wire)) {
+ d1l_meshcore_admin_secure_zero(&parsed, sizeof(parsed));
+ return D1L_MESHCORE_ADMIN_RESPONSE_MALFORMED;
+ }
+
session->query_result = parsed;
+ memcpy(session->query_wire, &plaintext[4], wire_len);
+ session->query_wire_len = (uint16_t)wire_len;
session->last_completed_tag = tag;
session->pending_tag = 0U;
session->pending_query = D1L_MESHCORE_ADMIN_QUERY_NONE;
diff --git a/main/mesh/meshcore_admin_dispatch.h b/main/mesh/meshcore_admin_dispatch.h
index 7de520e0..4a163ba0 100644
--- a/main/mesh/meshcore_admin_dispatch.h
+++ b/main/mesh/meshcore_admin_dispatch.h
@@ -17,6 +17,7 @@ extern "C" {
(D1L_MESHCORE_ADMIN_ROOM_LOGIN_PREFIX_BYTES + \
D1L_MESHCORE_ADMIN_MAX_PASSWORD_BYTES)
#define D1L_MESHCORE_ADMIN_LOGIN_RESPONSE_BYTES 13U
+#define D1L_MESHCORE_ADMIN_LEGACY_LOGIN_RESPONSE_BYTES 6U
#define D1L_MESHCORE_ADMIN_REQUEST_BYTES 13U
#define D1L_MESHCORE_ADMIN_REPEATER_STATUS_BYTES 60U
#define D1L_MESHCORE_ADMIN_ROOM_STATUS_BYTES 56U
@@ -36,6 +37,8 @@ extern "C" {
#define D1L_MESHCORE_ADMIN_NEIGHBOUR_PAGE_COUNT 10U
#define D1L_MESHCORE_ADMIN_NEIGHBOUR_PREFIX_BYTES 4U
#define D1L_MESHCORE_ADMIN_MAX_QUERY_TEXT_BYTES 768U
+/* Maximum decrypted response body after its four-byte correlation tag. */
+#define D1L_MESHCORE_ADMIN_MAX_QUERY_WIRE_BYTES 251U
#define D1L_MESHCORE_ADMIN_MUTATION_REPLY_MAX_BYTES 64U
#define D1L_MESHCORE_ADMIN_MAX_CLI_COMMAND_BYTES 160U
#define D1L_MESHCORE_ADMIN_MAX_CLI_REPLY_BYTES 160U
@@ -207,6 +210,8 @@ typedef struct {
bool status_valid;
d1l_meshcore_admin_status_t status;
d1l_meshcore_admin_query_result_t query_result;
+ uint16_t query_wire_len;
+ uint8_t query_wire[D1L_MESHCORE_ADMIN_MAX_QUERY_WIRE_BYTES];
} d1l_meshcore_admin_session_t;
void d1l_meshcore_admin_secure_zero(void *value, size_t size);
diff --git a/main/mesh/meshcore_admin_runtime.c b/main/mesh/meshcore_admin_runtime.c
index a984c16c..e076c3f0 100644
--- a/main/mesh/meshcore_admin_runtime.c
+++ b/main/mesh/meshcore_admin_runtime.c
@@ -1210,6 +1210,7 @@ void d1l_meshcore_admin_runtime_snapshot(
snapshot.firmware_level = s_session.firmware_level;
snapshot.server_timestamp = s_session.server_timestamp;
snapshot.pending_tag = s_session.pending_tag;
+ snapshot.last_completed_tag = s_session.last_completed_tag;
snapshot.pending_mutation = s_session.pending_mutation;
snapshot.last_mutation = s_session.last_mutation;
snapshot.pending_query = s_session.pending_query;
@@ -1223,6 +1224,9 @@ void d1l_meshcore_admin_runtime_snapshot(
snapshot.status_valid = s_session.status_valid;
snapshot.status = s_session.status;
snapshot.query_result = s_session.query_result;
+ snapshot.query_wire_len = s_session.query_wire_len;
+ memcpy(snapshot.query_wire, s_session.query_wire,
+ sizeof(snapshot.query_wire));
snapshot.login_tx_queued = s_metrics.login_tx_queued;
snapshot.status_tx_queued = s_metrics.status_tx_queued;
snapshot.query_tx_queued = s_metrics.query_tx_queued;
diff --git a/main/mesh/meshcore_admin_runtime.h b/main/mesh/meshcore_admin_runtime.h
index 7f78feba..4899afa1 100644
--- a/main/mesh/meshcore_admin_runtime.h
+++ b/main/mesh/meshcore_admin_runtime.h
@@ -50,6 +50,7 @@ typedef struct {
uint8_t firmware_level;
uint32_t server_timestamp;
uint32_t pending_tag;
+ uint32_t last_completed_tag;
d1l_meshcore_admin_mutation_t pending_mutation;
d1l_meshcore_admin_mutation_t last_mutation;
d1l_meshcore_admin_query_t pending_query;
@@ -62,6 +63,8 @@ typedef struct {
bool status_valid;
d1l_meshcore_admin_status_t status;
d1l_meshcore_admin_query_result_t query_result;
+ uint16_t query_wire_len;
+ uint8_t query_wire[D1L_MESHCORE_ADMIN_MAX_QUERY_WIRE_BYTES];
uint32_t login_tx_queued;
uint32_t status_tx_queued;
uint32_t query_tx_queued;
diff --git a/main/mesh/meshcore_route_selection.h b/main/mesh/meshcore_route_selection.h
index 4d634f60..3837fed1 100644
--- a/main/mesh/meshcore_route_selection.h
+++ b/main/mesh/meshcore_route_selection.h
@@ -165,6 +165,21 @@ static inline bool d1l_meshcore_route_select_canonical(
return true;
}
+/* Server requests may reuse an authenticated PATH response from this boot.
+ * An advert's reverse path is not proof of a route to the server. */
+static inline bool d1l_meshcore_route_select_admin_request(
+ bool path_known, bool learned_this_boot, const uint8_t *path,
+ uint8_t path_len, const d1l_meshcore_path_state_t *path_state,
+ uint32_t now_ms, uint8_t flood_path_hash_bytes,
+ d1l_meshcore_route_selection_t *out_selection)
+{
+ const bool response_path = path_state &&
+ path_state->source == D1L_MESHCORE_PATH_SOURCE_PATH_RESPONSE;
+ return d1l_meshcore_route_select_canonical(
+ path_known, learned_this_boot && response_path, path, path_len,
+ path_state, now_ms, flood_path_hash_bytes, out_selection);
+}
+
static inline const char *d1l_meshcore_route_selection_reason_name(
d1l_meshcore_route_selection_reason_t reason)
{
diff --git a/main/mesh/meshcore_rx_admission.c b/main/mesh/meshcore_rx_admission.c
index 2b73e956..6a34ec2c 100644
--- a/main/mesh/meshcore_rx_admission.c
+++ b/main/mesh/meshcore_rx_admission.c
@@ -64,6 +64,14 @@ d1l_meshcore_channel_dispatch_finish(
return D1L_MESHCORE_CHANNEL_DISPATCH_ACCEPTED;
}
+bool d1l_meshcore_peer_text_authorized(uint8_t text_type,
+ bool can_message, bool can_admin)
+{
+ /* MeshCore text types: ordinary=0, CLI=1, signed room post=2. */
+ return text_type == 1U ? can_admin :
+ ((text_type == 0U || text_type == 2U) && can_message);
+}
+
d1l_meshcore_peer_dispatch_outcome_t d1l_meshcore_peer_dispatch_classify(
const uint8_t local_public_key[32],
const uint8_t candidate_public_key[32], bool candidate_authorized)
diff --git a/main/mesh/meshcore_rx_admission.h b/main/mesh/meshcore_rx_admission.h
index 854ba7ad..4f06553e 100644
--- a/main/mesh/meshcore_rx_admission.h
+++ b/main/mesh/meshcore_rx_admission.h
@@ -10,6 +10,9 @@ extern "C" {
#define D1L_MESHCORE_CHANNEL_CANDIDATE_CAPACITY 8U
+bool d1l_meshcore_peer_text_authorized(uint8_t text_type,
+ bool can_message, bool can_admin);
+
typedef enum {
D1L_MESHCORE_CHANNEL_DISPATCH_INVALID = 0,
D1L_MESHCORE_CHANNEL_DISPATCH_UNKNOWN,
diff --git a/main/mesh/meshcore_service.c b/main/mesh/meshcore_service.c
index 3b4b246c..3efe9104 100644
--- a/main/mesh/meshcore_service.c
+++ b/main/mesh/meshcore_service.c
@@ -8,6 +8,7 @@
#include "bsp_sx126x.h"
#include "comms/observer_manager.h"
#include "diagnostics/event_log.h"
+#include "esp_attr.h"
#include "esp_log.h"
#include "esp_random.h"
#include "esp_timer.h"
@@ -297,7 +298,8 @@ typedef struct {
static d1l_pending_ack_tx_t s_pending_ack_tx;
static d1l_meshcore_ack_dedupe_t s_ack_dedupe;
static d1l_dm_entry_t s_ack_restore_scan[D1L_DM_STORE_CAPACITY];
-static d1l_contact_entry_t s_contact_scan[D1L_CONTACT_STORE_CAPACITY];
+static d1l_contact_entry_t
+ s_contact_scan[D1L_CONTACT_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
typedef struct {
char fingerprint[D1L_NODE_FINGERPRINT_LEN];
@@ -307,9 +309,23 @@ typedef struct {
bool valid;
} d1l_boot_route_t;
-static d1l_boot_route_t s_boot_routes[D1L_CONTACT_STORE_CAPACITY];
+static d1l_boot_route_t
+ s_boot_routes[D1L_CONTACT_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
static uint8_t s_boot_route_next;
static d1l_store_lock_t s_boot_route_lock = D1L_STORE_LOCK_INITIALIZER;
+
+typedef struct {
+ char fingerprint[D1L_NODE_FINGERPRINT_LEN];
+ uint32_t received_epoch;
+ uint8_t path_len;
+ uint8_t path[D1L_MESHCORE_MAX_PATH_BYTES];
+ bool valid;
+} d1l_advert_path_t;
+
+static d1l_advert_path_t
+ s_advert_paths[D1L_CONTACT_STORE_CAPACITY] EXT_RAM_BSS_ATTR;
+static uint8_t s_advert_path_next;
+static d1l_store_lock_t s_advert_path_lock = D1L_STORE_LOCK_INITIALIZER;
static d1l_meshcore_path_response_expectation_t s_path_response_expectation;
static char s_path_response_fingerprint[D1L_NODE_FINGERPRINT_LEN];
static d1l_store_lock_t s_path_response_lock = D1L_STORE_LOCK_INITIALIZER;
@@ -432,7 +448,11 @@ static void fail_pending_dm_ack_timeout(esp_err_t error);
static void record_pending_direct_path_result(bool success);
static bool finalize_pending_dm_ack_completion(void);
static void secure_zero_bytes(void *data, size_t size);
-static esp_err_t prepare_admin_route(
+static esp_err_t prepare_admin_flood_route(
+ const char *fingerprint, const d1l_settings_t *settings, uint32_t now_ms,
+ d1l_contact_entry_t *out_contact,
+ d1l_meshcore_route_selection_t *out_selection);
+static esp_err_t prepare_admin_request_route(
const char *fingerprint, const d1l_settings_t *settings, uint32_t now_ms,
d1l_contact_entry_t *out_contact,
d1l_meshcore_route_selection_t *out_selection);
@@ -2121,6 +2141,93 @@ static void clear_boot_routes(void)
d1l_store_lock_give(&s_boot_route_lock);
}
+static void clear_advert_paths(void)
+{
+ d1l_store_lock_take(&s_advert_path_lock);
+ memset(s_advert_paths, 0, sizeof(s_advert_paths));
+ s_advert_path_next = 0U;
+ d1l_store_lock_give(&s_advert_path_lock);
+}
+
+static void remember_advert_path(const char *fingerprint,
+ uint32_t received_epoch,
+ const uint8_t *path,
+ uint8_t path_len)
+{
+ if (!fingerprint || fingerprint[0] == '\0' ||
+ (path_len > 0U && !path) ||
+ !d1l_meshcore_wire_path_len_valid(path_len)) {
+ return;
+ }
+ const uint8_t path_bytes = d1l_meshcore_wire_path_byte_len(path_len);
+ if (path_bytes > D1L_MESHCORE_MAX_PATH_BYTES) {
+ return;
+ }
+
+ d1l_store_lock_take(&s_advert_path_lock);
+ size_t slot = D1L_CONTACT_STORE_CAPACITY;
+ size_t empty = D1L_CONTACT_STORE_CAPACITY;
+ for (size_t i = 0U; i < D1L_CONTACT_STORE_CAPACITY; ++i) {
+ if (s_advert_paths[i].valid &&
+ strncmp(s_advert_paths[i].fingerprint, fingerprint,
+ sizeof(s_advert_paths[i].fingerprint)) == 0) {
+ slot = i;
+ break;
+ }
+ if (!s_advert_paths[i].valid &&
+ empty == D1L_CONTACT_STORE_CAPACITY) {
+ empty = i;
+ }
+ }
+ if (slot == D1L_CONTACT_STORE_CAPACITY) {
+ slot = empty < D1L_CONTACT_STORE_CAPACITY ? empty : s_advert_path_next;
+ s_advert_path_next =
+ (uint8_t)((slot + 1U) % D1L_CONTACT_STORE_CAPACITY);
+ }
+
+ d1l_advert_path_t *record = &s_advert_paths[slot];
+ memset(record, 0, sizeof(*record));
+ snprintf(record->fingerprint, sizeof(record->fingerprint), "%s",
+ fingerprint);
+ record->received_epoch = received_epoch;
+ record->path_len = path_len;
+ if (path_bytes > 0U) {
+ memcpy(record->path, path, path_bytes);
+ }
+ record->valid = true;
+ d1l_store_lock_give(&s_advert_path_lock);
+}
+
+bool d1l_meshcore_service_advert_path_snapshot(
+ const char *fingerprint,
+ d1l_meshcore_advert_path_snapshot_t *out_snapshot)
+{
+ if (!fingerprint || fingerprint[0] == '\0' || !out_snapshot) {
+ return false;
+ }
+
+ bool found = false;
+ memset(out_snapshot, 0, sizeof(*out_snapshot));
+ d1l_store_lock_take(&s_advert_path_lock);
+ for (size_t i = 0U; i < D1L_CONTACT_STORE_CAPACITY; ++i) {
+ const d1l_advert_path_t *record = &s_advert_paths[i];
+ if (!record->valid ||
+ strncmp(record->fingerprint, fingerprint,
+ sizeof(record->fingerprint)) != 0) {
+ continue;
+ }
+ out_snapshot->valid = true;
+ out_snapshot->received_epoch = record->received_epoch;
+ out_snapshot->path_len = record->path_len;
+ memcpy(out_snapshot->path, record->path,
+ d1l_meshcore_wire_path_byte_len(record->path_len));
+ found = true;
+ break;
+ }
+ d1l_store_lock_give(&s_advert_path_lock);
+ return found;
+}
+
static void forget_boot_route(const char *fingerprint)
{
if (!fingerprint || fingerprint[0] == '\0') {
@@ -3531,7 +3638,8 @@ static bool parse_rx_dm_packet(const uint8_t *payload, uint16_t size,
const d1l_meshcore_peer_dispatch_outcome_t peer_outcome =
d1l_meshcore_peer_dispatch_classify(
settings->identity_public_key, sender_pub,
- d1l_contact_store_can_dm(contact));
+ d1l_contact_store_can_dm(contact) ||
+ d1l_contact_store_can_admin(contact));
if (peer_outcome != D1L_MESHCORE_PEER_AUTHORIZED) {
continue;
}
@@ -3552,6 +3660,13 @@ static bool parse_rx_dm_packet(const uint8_t *payload, uint16_t size,
}
const uint8_t txt_type = plain[4] >> 2;
+ if (!d1l_meshcore_peer_text_authorized(
+ txt_type, d1l_contact_store_can_dm(contact),
+ d1l_contact_store_can_admin(contact))) {
+ secure_zero_bytes(secret, sizeof(secret));
+ secure_zero_bytes(plain, sizeof(plain));
+ continue;
+ }
if (txt_type == D1L_MESHCORE_TXT_TYPE_CLI_DATA) {
d1l_meshcore_text_plaintext_view_t text_view = {0};
d1l_meshcore_admin_context_t mutation_context = {0};
@@ -4962,6 +5077,12 @@ static void parse_rx_advert_packet(const uint8_t *payload, uint16_t size,
}
s_status.rx_packets++;
s_status.rx_adverts++;
+ uint32_t received_epoch = channel_message_display_timestamp(0U);
+ if (received_epoch == 0U) {
+ received_epoch = advert_timestamp;
+ }
+ remember_advert_path(pub_prefix, received_epoch, packet.path,
+ packet.path_len);
esp_err_t route_ret =
d1l_route_store_upsert_observation(pub_prefix,
advert.name[0] ? advert.name : pub_prefix, "advert",
@@ -6505,21 +6626,12 @@ static esp_err_t meshcore_service_handle_admin_login(
(void)d1l_settings_public_snapshot(&settings_snapshot);
const uint64_t now_us = (uint64_t)esp_timer_get_time();
const uint32_t now_ms = (uint32_t)(now_us / 1000ULL);
- ret = prepare_admin_route(cmd->admin_fingerprint, &settings_snapshot,
- now_ms, &contact, &selection);
+ ret = prepare_admin_flood_route(
+ cmd->admin_fingerprint, &settings_snapshot, now_ms, &contact,
+ &selection);
if (ret != ESP_OK) {
goto admin_login_cleanup;
}
- /* Login is the recovery path for servers beyond direct RF range. Always
- * flood the handshake; authenticated follow-up commands may use the
- * learned canonical route once the session is open. */
- if (!d1l_meshcore_route_select(
- false, false, NULL, 0U, 0U, now_ms,
- settings_snapshot.path_hash_bytes, &selection) ||
- !d1l_meshcore_admin_route_valid(&selection)) {
- ret = ESP_ERR_INVALID_STATE;
- goto admin_login_cleanup;
- }
uint32_t timestamp = 0U;
ret = d1l_settings_next_mesh_timestamp(×tamp);
@@ -6586,9 +6698,9 @@ static esp_err_t meshcore_service_handle_admin_request_status(void)
(void)d1l_settings_public_snapshot(&settings_snapshot);
const uint64_t now_us = (uint64_t)esp_timer_get_time();
const uint32_t now_ms = (uint32_t)(now_us / 1000ULL);
- ret = prepare_admin_route(
- context.binding.fingerprint, &settings_snapshot, now_ms, &contact,
- &selection);
+ ret = prepare_admin_request_route(
+ context.binding.fingerprint, &settings_snapshot, now_ms,
+ &contact, &selection);
snprintf(current.fingerprint, sizeof(current.fingerprint), "%s",
context.binding.fingerprint);
if (ret == ESP_OK) {
@@ -6684,9 +6796,9 @@ static esp_err_t meshcore_service_handle_admin_query(
(void)d1l_settings_public_snapshot(&settings_snapshot);
const uint64_t now_us = (uint64_t)esp_timer_get_time();
const uint32_t now_ms = (uint32_t)(now_us / 1000ULL);
- ret = prepare_admin_route(
- context.binding.fingerprint, &settings_snapshot, now_ms, &contact,
- &selection);
+ ret = prepare_admin_request_route(
+ context.binding.fingerprint, &settings_snapshot, now_ms,
+ &contact, &selection);
snprintf(current.fingerprint, sizeof(current.fingerprint), "%s",
context.binding.fingerprint);
if (ret == ESP_OK) {
@@ -6782,9 +6894,9 @@ static esp_err_t meshcore_service_handle_admin_mutation(
(void)d1l_settings_public_snapshot(&settings_snapshot);
const uint64_t now_us = (uint64_t)esp_timer_get_time();
const uint32_t now_ms = (uint32_t)(now_us / 1000ULL);
- ret = prepare_admin_route(
- context.binding.fingerprint, &settings_snapshot, now_ms, &contact,
- &selection);
+ ret = prepare_admin_request_route(
+ context.binding.fingerprint, &settings_snapshot, now_ms,
+ &contact, &selection);
snprintf(current.fingerprint, sizeof(current.fingerprint), "%s",
context.binding.fingerprint);
if (ret == ESP_OK) {
@@ -6904,9 +7016,9 @@ static esp_err_t meshcore_service_handle_admin_cli(
(void)d1l_settings_public_snapshot(&settings_snapshot);
const uint64_t now_us = (uint64_t)esp_timer_get_time();
const uint32_t now_ms = (uint32_t)(now_us / 1000ULL);
- ret = prepare_admin_route(
- context.binding.fingerprint, &settings_snapshot, now_ms, &contact,
- &selection);
+ ret = prepare_admin_request_route(
+ context.binding.fingerprint, &settings_snapshot, now_ms,
+ &contact, &selection);
snprintf(current.fingerprint, sizeof(current.fingerprint), "%s",
context.binding.fingerprint);
if (ret == ESP_OK) {
@@ -7871,6 +7983,7 @@ void d1l_meshcore_service_init(void)
memset(&s_pending_dm_tx, 0, sizeof(s_pending_dm_tx));
clear_pending_ack_tx();
clear_boot_routes();
+ clear_advert_paths();
memset(&s_path_replay_cache, 0, sizeof(s_path_replay_cache));
d1l_meshcore_packet_hash_cache_reset(&s_rx_packet_hash_cache);
d1l_store_lock_take(&s_path_response_lock);
@@ -8132,7 +8245,7 @@ void d1l_meshcore_service_admin_snapshot(
d1l_meshcore_admin_runtime_snapshot(out_snapshot);
}
-static esp_err_t prepare_admin_route(
+static esp_err_t prepare_admin_flood_route(
const char *fingerprint, const d1l_settings_t *settings, uint32_t now_ms,
d1l_contact_entry_t *out_contact,
d1l_meshcore_route_selection_t *out_selection)
@@ -8148,14 +8261,12 @@ static esp_err_t prepare_admin_route(
if (ret != ESP_OK || !d1l_contact_store_can_admin(&contact)) {
return ret != ESP_OK ? ret : ESP_ERR_INVALID_STATE;
}
- const bool learned_this_boot = contact.out_path_valid &&
- lookup_boot_route(contact.fingerprint, contact.out_path,
- contact.out_path_len,
- contact.out_path_state.generation);
d1l_meshcore_route_selection_t selection = {0};
- if (!d1l_meshcore_route_select_canonical(
- contact.out_path_valid, learned_this_boot, contact.out_path,
- contact.out_path_len, &contact.out_path_state, now_ms,
+ /* Remote management is sparse and user initiated. Flooding avoids
+ * promoting a reverse path learned from the login reply before that path
+ * has ever carried an outbound command successfully. */
+ if (!d1l_meshcore_route_select(
+ false, false, NULL, 0U, 0U, now_ms,
settings->path_hash_bytes, &selection) ||
!d1l_meshcore_admin_route_valid(&selection)) {
return ESP_ERR_INVALID_STATE;
@@ -8165,6 +8276,27 @@ static esp_err_t prepare_admin_route(
return ESP_OK;
}
+static esp_err_t prepare_admin_request_route(
+ const char *fingerprint, const d1l_settings_t *settings, uint32_t now_ms,
+ d1l_contact_entry_t *out_contact,
+ d1l_meshcore_route_selection_t *out_selection)
+{
+ const esp_err_t result = prepare_admin_flood_route(
+ fingerprint, settings, now_ms, out_contact, out_selection);
+ if (result != ESP_OK) {
+ return result;
+ }
+ const bool learned_this_boot = out_contact->out_path_valid &&
+ lookup_boot_route(out_contact->fingerprint, out_contact->out_path,
+ out_contact->out_path_len,
+ out_contact->out_path_state.generation);
+ return d1l_meshcore_route_select_admin_request(
+ out_contact->out_path_valid, learned_this_boot,
+ out_contact->out_path, out_contact->out_path_len,
+ &out_contact->out_path_state, now_ms, settings->path_hash_bytes,
+ out_selection) ? ESP_OK : ESP_ERR_INVALID_STATE;
+}
+
esp_err_t d1l_meshcore_service_admin_login(const char *fingerprint,
const char *password)
{
diff --git a/main/mesh/meshcore_service.h b/main/mesh/meshcore_service.h
index bd9f0489..cceab47a 100644
--- a/main/mesh/meshcore_service.h
+++ b/main/mesh/meshcore_service.h
@@ -8,6 +8,7 @@
#include "mesh/meshcore_admin_runtime.h"
#include "mesh/meshcore_discovery.h"
#include "mesh/meshcore_trace.h"
+#include "mesh/meshcore_wire.h"
typedef enum {
D1L_MESHCORE_SERVICE_INITIALIZING = 0,
@@ -160,6 +161,13 @@ typedef struct {
typedef d1l_meshcore_admin_runtime_snapshot_t d1l_meshcore_admin_snapshot_t;
+typedef struct {
+ bool valid;
+ uint32_t received_epoch;
+ uint8_t path_len;
+ uint8_t path[D1L_MESHCORE_MAX_PATH_BYTES];
+} d1l_meshcore_advert_path_snapshot_t;
+
#define D1L_MESHCORE_CONTACT_TELEMETRY_HISTORY_CAPACITY 4U
typedef enum {
@@ -202,6 +210,9 @@ void d1l_meshcore_service_discovery_snapshot(
d1l_meshcore_discovery_snapshot_t *out_snapshot);
void d1l_meshcore_service_admin_snapshot(
d1l_meshcore_admin_snapshot_t *out_snapshot);
+bool d1l_meshcore_service_advert_path_snapshot(
+ const char *fingerprint,
+ d1l_meshcore_advert_path_snapshot_t *out_snapshot);
void d1l_meshcore_service_contact_telemetry_snapshot(
const char *fingerprint,
d1l_meshcore_contact_telemetry_snapshot_t *out_snapshot);
diff --git a/main/mesh/node_store.c b/main/mesh/node_store.c
index a8d5df06..a1fd0ade 100644
--- a/main/mesh/node_store.c
+++ b/main/mesh/node_store.c
@@ -1394,6 +1394,9 @@ esp_err_t d1l_node_store_upsert_advert(const char *fingerprint, const char *publ
if (existing >= 0 &&
!d1l_meshcore_lifetime_advert_is_strictly_newer(
true, s_entries[existing].advert_timestamp, advert_timestamp)) {
+ const uint32_t now_ms = monotonic_ms();
+ s_live_last_heard_ms[existing] = now_ms;
+ s_live_heard_valid[existing] = true;
d1l_store_lock_give(&s_store_lock);
*out_stale = true;
return ESP_OK;
diff --git a/main/mesh/user_text.c b/main/mesh/user_text.c
index 982a2dca..a28d5055 100644
--- a/main/mesh/user_text.c
+++ b/main/mesh/user_text.c
@@ -25,9 +25,9 @@ static bool forbidden_control(uint32_t scalar)
(scalar >= 0x7fU && scalar <= 0x9fU);
}
-d1l_user_text_info_t d1l_user_text_validate_span(const uint8_t *text,
- size_t byte_count,
- bool allow_empty)
+static d1l_user_text_info_t validate_span(const uint8_t *text,
+ size_t byte_count, bool allow_empty,
+ size_t maximum, bool multiline)
{
if (!text) {
return result(D1L_USER_TEXT_INVALID_UTF8, 0U, 0U);
@@ -36,7 +36,7 @@ d1l_user_text_info_t d1l_user_text_validate_span(const uint8_t *text,
return result(allow_empty ? D1L_USER_TEXT_OK : D1L_USER_TEXT_EMPTY,
0U, 0U);
}
- if (byte_count > D1L_USER_TEXT_MAX_BYTES) {
+ if (byte_count > maximum) {
return result(D1L_USER_TEXT_TOO_LONG, byte_count, 0U);
}
@@ -87,7 +87,8 @@ d1l_user_text_info_t d1l_user_text_validate_span(const uint8_t *text,
return result(D1L_USER_TEXT_INVALID_UTF8, byte_count, characters);
}
- if (forbidden_control(scalar)) {
+ if (forbidden_control(scalar) &&
+ !(multiline && (scalar == '\n' || scalar == '\r' || scalar == '\t'))) {
return result(D1L_USER_TEXT_CONTROL_CHARACTER, byte_count, characters);
}
index += width;
@@ -96,6 +97,20 @@ d1l_user_text_info_t d1l_user_text_validate_span(const uint8_t *text,
return result(D1L_USER_TEXT_OK, byte_count, characters);
}
+d1l_user_text_info_t d1l_user_text_validate_span(const uint8_t *text,
+ size_t byte_count,
+ bool allow_empty)
+{
+ return validate_span(text, byte_count, allow_empty,
+ D1L_USER_TEXT_MAX_BYTES, false);
+}
+
+d1l_user_text_info_t d1l_user_text_validate_display_span(
+ const uint8_t *text, size_t byte_count)
+{
+ return validate_span(text, byte_count, false, SIZE_MAX, true);
+}
+
d1l_user_text_info_t d1l_user_text_validate(const char *text)
{
if (!text) {
diff --git a/main/mesh/user_text.h b/main/mesh/user_text.h
index e233262c..fc3b2877 100644
--- a/main/mesh/user_text.h
+++ b/main/mesh/user_text.h
@@ -33,6 +33,11 @@ d1l_user_text_info_t d1l_user_text_validate_span(const uint8_t *text,
size_t byte_count,
bool allow_empty);
+/* Validates a caller-bounded display span, including UTF-8 and layout
+ * whitespace (tab/CR/LF). NUL, other controls, and malformed UTF-8 stay invalid. */
+d1l_user_text_info_t d1l_user_text_validate_display_span(
+ const uint8_t *text, size_t byte_count);
+
/* Validates a fixed retained field and requires a terminator inside capacity. */
d1l_user_text_info_t d1l_user_text_validate_bounded(const char *text,
size_t capacity,
diff --git a/main/ui/ui_ble.c b/main/ui/ui_ble.c
index cb042bd9..ccd24407 100644
--- a/main/ui/ui_ble.c
+++ b/main/ui/ui_ble.c
@@ -1,6 +1,7 @@
#include "ui_ble.h"
#include
+#include
#include
#include "lvgl.h"
@@ -256,22 +257,39 @@ bool d1l_ui_ble_render(d1l_ui_ble_controller_t *controller,
controller->rendered.purpose, 0xF4F7FB);
configure_wrapped_label(purpose, 8, 88);
complete = purpose != NULL && complete;
+
+ if (controller->rendered.pairing_active) {
+ char pin[7];
+ snprintf(pin, sizeof(pin), "%06lu",
+ (unsigned long)controller->rendered.pairing_passkey);
+ lv_obj_t *pin_label = create_label(controller->sheet, pin, 0x84FF2E);
+ if (pin_label) {
+ lv_obj_set_style_text_font(pin_label, &lv_font_montserrat_32, 0);
+ lv_obj_set_style_text_align(pin_label, LV_TEXT_ALIGN_CENTER, 0);
+ lv_obj_set_width(pin_label, 408);
+ lv_obj_set_pos(pin_label, 8, 126);
+ } else {
+ complete = false;
+ }
+ }
lv_obj_t *runtime = create_label(controller->sheet,
controller->rendered.runtime_note,
0xFBBF24);
- configure_wrapped_label(runtime, 8, 144);
+ configure_wrapped_label(
+ runtime, 8, controller->rendered.pairing_active ? 174 : 144);
complete = runtime != NULL && complete;
if (controller->rendered.controls_available) {
+ const int action_y = controller->rendered.pairing_active ? 216 : 206;
lv_obj_t *toggle = create_button(
- controller, controller->rendered.toggle_label, 8, 206, 98, 40,
+ controller, controller->rendered.toggle_label, 8, action_y, 98, 40,
BINDING_TOGGLE, D1L_UI_BLE_ACTION_TOGGLE);
- lv_obj_t *pair = create_button(controller, "Pair", 116, 206, 98, 40,
+ lv_obj_t *pair = create_button(controller, "Pair", 116, action_y, 98, 40,
BINDING_PAIR,
controller->rendered.pairing_available ?
D1L_UI_BLE_ACTION_PAIR :
D1L_UI_BLE_ACTION_NONE);
- lv_obj_t *forget = create_button(controller, "Forget", 224, 206, 98,
+ lv_obj_t *forget = create_button(controller, "Forget", 224, action_y, 98,
40, BINDING_FORGET,
controller->rendered.forget_available ?
D1L_UI_BLE_ACTION_FORGET :
diff --git a/main/ui/ui_connectivity.c b/main/ui/ui_connectivity.c
index e9ca97e9..c6c73c30 100644
--- a/main/ui/ui_connectivity.c
+++ b/main/ui/ui_connectivity.c
@@ -80,16 +80,21 @@ void d1l_ui_connectivity_ble_view(const d1l_ui_ble_view_input_t *input,
const bool runtime_available =
input->build_enabled && input->transport_supported;
out_view->controls_available = runtime_available;
+ out_view->pairing_active = runtime_available &&
+ input->pairing_passkey >= 100000U &&
+ input->pairing_passkey <= 999999U &&
+ strcmp(safe_text(input->state, "off"), "pairing") == 0;
+ out_view->pairing_passkey = out_view->pairing_active ?
+ input->pairing_passkey : 0U;
snprintf(out_view->purpose, sizeof(out_view->purpose), "%s",
+ out_view->pairing_active ?
+ "Pairing request detected from a companion device." :
runtime_available ?
"Bluetooth companion connections are available." :
"Bluetooth companion connections are unavailable.");
- if (runtime_available && input->pairing_passkey >= 100000U &&
- input->pairing_passkey <= 999999U &&
- strcmp(safe_text(input->state, "off"), "pairing") == 0) {
+ if (out_view->pairing_active) {
snprintf(out_view->runtime_note, sizeof(out_view->runtime_note),
- "Enter PIN %06lu on the companion device.",
- (unsigned long)input->pairing_passkey);
+ "Enter this PIN in the MeshCore app.");
} else if (runtime_available && input->protocol_ready) {
snprintf(out_view->runtime_note, sizeof(out_view->runtime_note),
"Paired securely and ready to sync.");
@@ -106,11 +111,12 @@ void d1l_ui_connectivity_ble_view(const d1l_ui_ble_view_input_t *input,
runtime_available && input->companion_enabled ?
"Disable" : "Enable");
snprintf(out_view->production_note, sizeof(out_view->production_note), "%s",
- runtime_available && input->protocol_running ?
- "Using the MeshCore connection alongside Wi-Fi." :
+ runtime_available && input->companion_enabled ?
+ "Bluetooth mode is active; Wi-Fi is off." :
"USB stays available for recovery and diagnostics.");
out_view->pairing_available =
- runtime_available && input->companion_enabled;
+ runtime_available && input->companion_enabled &&
+ !out_view->pairing_active;
out_view->forget_available =
runtime_available && input->peer_known;
out_view->state_color = runtime_available && input->companion_enabled ?
diff --git a/main/ui/ui_connectivity.h b/main/ui/ui_connectivity.h
index 9da55a01..c468b671 100644
--- a/main/ui/ui_connectivity.h
+++ b/main/ui/ui_connectivity.h
@@ -58,7 +58,9 @@ typedef struct {
char toggle_label[D1L_UI_CONNECTIVITY_LABEL_LEN];
char production_note[D1L_UI_CONNECTIVITY_TEXT_LEN];
uint32_t state_color;
+ uint32_t pairing_passkey;
bool controls_available;
+ bool pairing_active;
bool pairing_available;
bool forget_available;
} d1l_ui_ble_view_model_t;
diff --git a/main/ui/ui_contact_sheets.c b/main/ui/ui_contact_sheets.c
index 5dabaf9c..a6d1780c 100644
--- a/main/ui/ui_contact_sheets.c
+++ b/main/ui/ui_contact_sheets.c
@@ -493,6 +493,26 @@ static bool configure_title(lv_obj_t *title, int y)
return true;
}
+static const char *contact_type_label(const char *type)
+{
+ if (!type || type[0] == '\0') {
+ return "Node";
+ }
+ if (strcmp(type, "room") == 0) {
+ return "Room";
+ }
+ if (strcmp(type, "repeater") == 0) {
+ return "Repeater";
+ }
+ if (strcmp(type, "sensor") == 0) {
+ return "Sensor";
+ }
+ if (strcmp(type, "companion") == 0 || strcmp(type, "chat") == 0) {
+ return "Chat";
+ }
+ return "Node";
+}
+
bool d1l_ui_contact_sheets_render_detail(
d1l_ui_contact_sheets_controller_t *controller,
d1l_ui_contact_action_handler_t action_handler,
@@ -514,17 +534,17 @@ bool d1l_ui_contact_sheets_render_detail(
10) && complete;
char line[160];
- snprintf(line, sizeof(line), "%s %s %s",
- entry->type[0] ? entry->type : "node",
- entry->favorite ? "favorite" : "normal",
- entry->muted ? "unread excluded" : "unread counted");
+ snprintf(line, sizeof(line), "%s%s | %s",
+ contact_type_label(entry->type),
+ entry->favorite ? " | Favorite" : "",
+ entry->muted ? "Muted" : "Active");
lv_obj_t *flags = create_label(sheet, line, 0xA6B0B7);
if (flags) {
lv_obj_set_pos(flags, 16, 64);
} else {
complete = false;
}
- snprintf(line, sizeof(line), "Identity %.16s", entry->fingerprint);
+ snprintf(line, sizeof(line), "Node ID %.16s", entry->fingerprint);
lv_obj_t *fingerprint = create_label(sheet, line, 0xF4F7FB);
if (fingerprint) {
lv_obj_set_pos(fingerprint, 16, 98);
@@ -532,11 +552,15 @@ bool d1l_ui_contact_sheets_render_detail(
complete = false;
}
if (entry->out_path_valid) {
- snprintf(line, sizeof(line), "Direct route | %u hop%s",
- (unsigned)entry->path_hops,
- entry->path_hops == 1U ? "" : "s");
+ if (entry->path_hops == 0U) {
+ snprintf(line, sizeof(line), "Route Direct");
+ } else {
+ snprintf(line, sizeof(line), "Route %u hop%s",
+ (unsigned)entry->path_hops,
+ entry->path_hops == 1U ? "" : "s");
+ }
} else {
- snprintf(line, sizeof(line), "Broadcast route | no saved direct path");
+ snprintf(line, sizeof(line), "Route Flood when needed");
}
lv_obj_t *key = create_label(sheet, line, 0xA6B0B7);
if (key) {
@@ -546,13 +570,20 @@ bool d1l_ui_contact_sheets_render_detail(
} else {
complete = false;
}
- const int snr_abs = entry->last_snr_tenths < 0 ?
- -entry->last_snr_tenths : entry->last_snr_tenths;
- snprintf(line, sizeof(line), "Last signal %d dBm | SNR %s%d.%d | via %.18s",
- entry->last_rssi_dbm,
- entry->last_snr_tenths < 0 ? "-" : "",
- snr_abs / 10, snr_abs % 10,
- entry->heard_name[0] ? entry->heard_name : "-");
+ if (entry->last_heard_ms == 0U) {
+ snprintf(line, sizeof(line), "Signal Not heard yet");
+ } else {
+ const int snr_abs = entry->last_snr_tenths < 0 ?
+ -entry->last_snr_tenths : entry->last_snr_tenths;
+ const char *quality = entry->last_rssi_dbm >= -70 ? "Excellent" :
+ (entry->last_rssi_dbm >= -90 ? "Good" :
+ (entry->last_rssi_dbm >= -110 ? "Fair" : "Weak"));
+ snprintf(line, sizeof(line),
+ "Signal %s | %d dBm / %s%d.%d dB",
+ quality, entry->last_rssi_dbm,
+ entry->last_snr_tenths < 0 ? "-" : "",
+ snr_abs / 10, snr_abs % 10);
+ }
lv_obj_t *signal = create_label(sheet, line, 0xA6B0B7);
if (signal) {
lv_label_set_long_mode(signal, LV_LABEL_LONG_DOT);
@@ -561,6 +592,17 @@ bool d1l_ui_contact_sheets_render_detail(
} else {
complete = false;
}
+ if (entry->last_heard_ms != 0U && entry->heard_name[0] != '\0') {
+ snprintf(line, sizeof(line), "Last heard via %.24s", entry->heard_name);
+ lv_obj_t *via = create_label(sheet, line, 0xA6B0B7);
+ if (via) {
+ lv_label_set_long_mode(via, LV_LABEL_LONG_DOT);
+ lv_obj_set_width(via, 448);
+ lv_obj_set_pos(via, 16, 190);
+ } else {
+ complete = false;
+ }
+ }
lv_obj_t *actions = create_label(sheet, "Contact actions", 0x20D9ED);
if (actions) {
lv_obj_set_pos(actions, 16, 210);
diff --git a/main/ui/ui_home.c b/main/ui/ui_home.c
index b2487db6..814d4236 100644
--- a/main/ui/ui_home.c
+++ b/main/ui/ui_home.c
@@ -233,6 +233,8 @@ static void render_destination_card(lv_obj_t *parent,
return;
}
lv_obj_set_style_border_color(card, lv_color_hex(0x1F372E), 0);
+ lv_obj_set_style_bg_color(card, lv_color_hex(0x183329), LV_STATE_PRESSED);
+ lv_obj_set_style_border_color(card, lv_color_hex(accent), LV_STATE_PRESSED);
lv_obj_set_style_pad_all(card, 0, 0);
home_bind_action(card, binding, controller, action);
@@ -399,11 +401,11 @@ void d1l_ui_home_render(d1l_ui_home_controller_t *controller,
const char *messages_detail =
d1l_release_feature_available(
D1L_RELEASE_FEATURE_MULTI_CHANNEL_MANAGEMENT) ?
- "Public, DMs, and rooms" :
+ "Public, channels, and DMs" :
"Public and direct conversations";
render_destination_card(parent, D1L_UI_HOME_DESTINATION_MESSAGES,
- LV_SYMBOL_ENVELOPE, "Messages",
+ LV_SYMBOL_ENVELOPE, "Channels",
messages_detail,
controller->rendered.messages_status,
controller->rendered.messages_status_color,
@@ -412,8 +414,8 @@ void d1l_ui_home_render(d1l_ui_home_controller_t *controller,
controller);
render_destination_card(parent, D1L_UI_HOME_DESTINATION_NETWORK,
- LV_SYMBOL_LIST, "Nodes",
- "Contacts, nearby, and routes",
+ LV_SYMBOL_LIST, "Contacts",
+ "Saved, nearby, and routes",
controller->rendered.network_status,
controller->rendered.network_status_color,
D1L_UI_HOME_ACTION_NETWORK,
@@ -428,8 +430,8 @@ void d1l_ui_home_render(d1l_ui_home_controller_t *controller,
controller);
render_destination_card(parent, D1L_UI_HOME_DESTINATION_MORE, LV_SYMBOL_SETTINGS,
- map_available ? "Tools" : "Settings",
- "Settings, utilities, and support",
+ "Settings",
+ "Radio, connections, and device",
controller->rendered.more_status,
controller->rendered.more_status_color,
D1L_UI_HOME_ACTION_MORE,
diff --git a/main/ui/ui_home_view.c b/main/ui/ui_home_view.c
index c9ce9c52..1f047432 100644
--- a/main/ui/ui_home_view.c
+++ b/main/ui/ui_home_view.c
@@ -210,7 +210,7 @@ void d1l_ui_home_view(const d1l_ui_home_view_input_t *input,
(unsigned long long)input->node_count);
} else {
snprintf(out_view->network_status, sizeof(out_view->network_status),
- "%llu contacts | %llu nearby",
+ "%llu saved | %llu nearby",
(unsigned long long)input->contact_count,
(unsigned long long)input->node_count);
}
diff --git a/main/ui/ui_map.c b/main/ui/ui_map.c
index ec973556..0206d226 100644
--- a/main/ui/ui_map.c
+++ b/main/ui/ui_map.c
@@ -417,7 +417,7 @@ static void map_viewport_copy(const d1l_app_snapshot_t *snapshot,
*color = MAP_COLOR_WARN;
} else if (!snapshot->map_location_set) {
*title = "Set a location";
- *detail = "Open Options to choose the area shown here.";
+ *detail = "Tap Set up to choose the area shown here.";
*color = MAP_COLOR_WARN;
} else if (!map_center_available(snapshot)) {
*title = "Center unavailable";
@@ -537,8 +537,8 @@ static void map_viewport_update_pin_truth_label(void)
lv_label_set_text(
s_viewport_pin_truth_label,
s_viewport_marker_age_reference_valid ?
- "Location shared by node\nrecently verified\naccuracy unknown" :
- "Location hidden\nsource not verified");
+ "Nodes shown\ntime verified\naccuracy unknown" :
+ "Nodes hidden\ntime not verified");
lv_obj_set_style_text_color(
s_viewport_pin_truth_label,
lv_color_hex(s_viewport_marker_age_reference_valid ?
@@ -1640,39 +1640,46 @@ void d1l_ui_map_render(lv_obj_t *parent,
lv_obj_set_style_text_align(ready, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_pos(ready, 30, (int)MAP_VIEWPORT_HEIGHT / 2 + 28);
}
- lv_obj_t *attribution = map_label(viewport, "(c) OpenStreetMap contributors",
- MAP_COLOR_DETAIL);
- s_viewport_attribution_label = attribution;
- if (attribution) {
- map_label_dot(attribution, 240);
- lv_obj_set_style_text_align(attribution, LV_TEXT_ALIGN_RIGHT, 0);
- lv_obj_set_pos(attribution, 228, (int)MAP_VIEWPORT_HEIGHT - 36);
- lv_obj_set_style_bg_color(attribution, lv_color_hex(0x17191A), 0);
- lv_obj_set_style_bg_opa(attribution, LV_OPA_80, 0);
- lv_obj_set_style_pad_all(attribution, 2, 0);
- }
- s_viewport_pin_truth_label = map_label(
- viewport, "", MAP_COLOR_WARN);
- if (s_viewport_pin_truth_label) {
- lv_label_set_long_mode(s_viewport_pin_truth_label, LV_LABEL_LONG_WRAP);
- lv_obj_set_size(s_viewport_pin_truth_label, 112, 58);
- lv_obj_set_pos(s_viewport_pin_truth_label, 112,
- (int)MAP_VIEWPORT_HEIGHT - 74);
- lv_obj_set_style_text_align(s_viewport_pin_truth_label,
- LV_TEXT_ALIGN_CENTER, 0);
- lv_obj_set_style_bg_color(s_viewport_pin_truth_label,
- lv_color_hex(MAP_COLOR_BG), 0);
- lv_obj_set_style_bg_opa(s_viewport_pin_truth_label, LV_OPA_80, 0);
- lv_obj_set_style_pad_all(s_viewport_pin_truth_label, 2, 0);
- map_viewport_update_pin_truth_label();
- }
-
+ const bool center_available = map_center_available(snapshot);
lv_obj_t *options_button = map_button(
- viewport, "Options", 8, 8, 96, 48, callbacks->open_options);
+ viewport, center_available ? "Options" : "Set up",
+ 8, 8, 96, 48, callbacks->open_options);
s_viewport_options_button = options_button;
- map_style_overlay_button(options_button);
-
- if (snapshot->map_location_set && map_center_available(snapshot)) {
+ if (center_available) {
+ map_style_overlay_button(options_button);
+ } else {
+ map_style_primary_button(options_button);
+ }
+
+ if (center_available) {
+ lv_obj_t *attribution = map_label(
+ viewport, "(c) OpenStreetMap contributors", MAP_COLOR_DETAIL);
+ s_viewport_attribution_label = attribution;
+ if (attribution) {
+ map_label_dot(attribution, 240);
+ lv_obj_set_style_text_align(attribution, LV_TEXT_ALIGN_RIGHT, 0);
+ lv_obj_set_pos(attribution, 228, (int)MAP_VIEWPORT_HEIGHT - 36);
+ lv_obj_set_style_bg_color(attribution, lv_color_hex(0x17191A), 0);
+ lv_obj_set_style_bg_opa(attribution, LV_OPA_80, 0);
+ lv_obj_set_style_pad_all(attribution, 2, 0);
+ }
+ s_viewport_pin_truth_label = map_label(
+ viewport, "", MAP_COLOR_WARN);
+ if (s_viewport_pin_truth_label) {
+ lv_label_set_long_mode(
+ s_viewport_pin_truth_label, LV_LABEL_LONG_WRAP);
+ lv_obj_set_size(s_viewport_pin_truth_label, 112, 58);
+ lv_obj_set_pos(s_viewport_pin_truth_label, 112,
+ (int)MAP_VIEWPORT_HEIGHT - 74);
+ lv_obj_set_style_text_align(s_viewport_pin_truth_label,
+ LV_TEXT_ALIGN_CENTER, 0);
+ lv_obj_set_style_bg_color(s_viewport_pin_truth_label,
+ lv_color_hex(MAP_COLOR_BG), 0);
+ lv_obj_set_style_bg_opa(
+ s_viewport_pin_truth_label, LV_OPA_80, 0);
+ lv_obj_set_style_pad_all(s_viewport_pin_truth_label, 2, 0);
+ map_viewport_update_pin_truth_label();
+ }
char center_source[24];
snprintf(center_source, sizeof(center_source), "%s source",
d1l_map_center_source_label(snapshot->map_center_source));
diff --git a/main/ui/ui_messages.c b/main/ui/ui_messages.c
index a5267b6a..1cb09c44 100644
--- a/main/ui/ui_messages.c
+++ b/main/ui/ui_messages.c
@@ -703,9 +703,17 @@ static void messages_render_root(d1l_ui_messages_controller_t *controller,
lv_obj_set_pos(title, 18, 10);
}
messages_create_button(
- parent, "Direct", 342, 6, 100, 44,
+ parent, "DMs", 342, 6, 100, 44,
messages_bind_control(controller, 1U,
D1L_UI_MESSAGES_ACTION_SHOW_DIRECT));
+ if (d1l_ui_messages_action_available(
+ D1L_UI_MESSAGES_ACTION_OPEN_CHANNEL_SELECTOR)) {
+ messages_create_button(
+ parent, "Add", 262, 6, 72, 44,
+ messages_bind_control(
+ controller, 4U,
+ D1L_UI_MESSAGES_ACTION_OPEN_CHANNEL_SELECTOR));
+ }
lv_obj_t *subtitle = messages_create_label(
parent, "Group conversations", 0xA6B0B7);
if (subtitle) {
@@ -726,14 +734,6 @@ static void messages_render_root(d1l_ui_messages_controller_t *controller,
(void)messages_render_notice(
body, row_y, "No channels yet. Add or import a channel.",
0xA6B0B7);
- if (d1l_ui_messages_action_available(
- D1L_UI_MESSAGES_ACTION_OPEN_CHANNEL_SELECTOR)) {
- messages_create_button(
- body, "Add a channel", 8, row_y + 60, 408, 48,
- messages_bind_control(
- controller, 4U,
- D1L_UI_MESSAGES_ACTION_OPEN_CHANNEL_SELECTOR));
- }
return;
}
for (size_t i = 0U; i < controller->rendered.channel_count; ++i) {
diff --git a/main/ui/ui_more_view.c b/main/ui/ui_more_view.c
index 4ef5bd98..70d64441 100644
--- a/main/ui/ui_more_view.c
+++ b/main/ui/ui_more_view.c
@@ -263,7 +263,7 @@ bool d1l_ui_more_view(const d1l_ui_more_view_input_t *input,
D1L_UI_SETTINGS_ACTION_TERMINAL, false);
category = set_category(out_view, D1L_UI_MORE_CATEGORY_CONNECTIONS,
- "Connections", "Wi-Fi, Bluetooth, and observer",
+ "Connections", "Wi-Fi, Bluetooth, and MQTT",
COLOR_GREEN, false, 3U);
set_item(&category->items[0], "Wi-Fi", wifi_status,
input->wifi_connected ? COLOR_GREEN : COLOR_TEXT,
@@ -271,7 +271,7 @@ bool d1l_ui_more_view(const d1l_ui_more_view_input_t *input,
set_item(&category->items[1], "Bluetooth", ble_status,
input->ble_companion_enabled ? COLOR_GREEN : COLOR_TEXT,
D1L_UI_SETTINGS_ACTION_BLE, false);
- set_item(&category->items[2], "Observer",
+ set_item(&category->items[2], "MQTT / Observer",
input->observer_state && input->observer_state[0] ?
input->observer_state : "Not configured",
text_equals(input->observer_state, "connected") ?
diff --git a/main/ui/ui_nodes.c b/main/ui/ui_nodes.c
index e4a3f8e3..e88e82fc 100644
--- a/main/ui/ui_nodes.c
+++ b/main/ui/ui_nodes.c
@@ -434,10 +434,10 @@ static void nodes_render_header(d1l_ui_nodes_controller_t *controller,
controller->cycle_sort = (d1l_ui_nodes_action_binding_t) {
.controller = controller,
};
- nodes_create_button(parent, "Find", 304, 4, 70, 44, 0x84FF2E, true,
+ nodes_create_button(parent, "Find", 282, 4, 70, 44, 0x84FF2E, true,
nodes_dispatch_global_event_cb,
&controller->find_nearby);
- nodes_create_button(parent, "Clear", 382, 4, 70, 44, 0xF87171,
+ nodes_create_button(parent, "Clear nearby", 360, 4, 104, 44, 0xF87171,
controller->rendered.node_row_count > 0U,
nodes_dispatch_global_event_cb,
&controller->clear_heard);
diff --git a/main/ui/ui_phase1.c b/main/ui/ui_phase1.c
index 2a6497f8..1559a2d7 100644
--- a/main/ui/ui_phase1.c
+++ b/main/ui/ui_phase1.c
@@ -170,6 +170,7 @@ static d1l_ui_storage_view_model_t s_storage_view EXT_RAM_BSS_ATTR;
static d1l_ui_wifi_controller_t s_wifi_controller EXT_RAM_BSS_ATTR;
static d1l_ui_map_sheets_controller_t s_map_sheets_controller EXT_RAM_BSS_ATTR;
static d1l_ui_ble_controller_t s_ble_controller EXT_RAM_BSS_ATTR;
+static bool s_ble_pairing_announced;
static d1l_ui_device_sheets_controller_t s_device_sheets_controller EXT_RAM_BSS_ATTR;
static d1l_ui_service_sheets_controller_t s_service_sheets_controller EXT_RAM_BSS_ATTR;
static d1l_ui_radio_settings_controller_t s_radio_settings_controller EXT_RAM_BSS_ATTR;
@@ -199,6 +200,7 @@ static d1l_ui_admin_page_t s_admin_page = D1L_UI_ADMIN_PAGE_LOGIN;
static bool s_admin_remember_password = true;
static bool s_admin_pending_password_valid;
static bool s_admin_pending_remember;
+static bool s_admin_pending_guest;
static bool s_admin_rendered_generation_valid;
static uint32_t s_admin_rendered_dm_revision;
static uint32_t s_terminal_clear_deadline;
@@ -674,6 +676,7 @@ static bool nodes_content_generation_changed_from_rendered(
const d1l_ui_content_generation_t current =
content_generation_from_snapshot(snapshot);
return !s_rendered_content_generation_valid ||
+ current.rx_adverts != s_rendered_content_generation.rx_adverts ||
current.node_total_written !=
s_rendered_content_generation.node_total_written ||
current.contact_total_written !=
@@ -1769,6 +1772,7 @@ static void clear_admin_pending_password(void)
s_admin_pending_password, sizeof(s_admin_pending_password));
s_admin_pending_password_valid = false;
s_admin_pending_remember = false;
+ s_admin_pending_guest = false;
}
static void clear_admin_feedback(void)
@@ -1795,18 +1799,25 @@ static void finalize_admin_login(const d1l_meshcore_admin_snapshot_t *status)
if (status->state == D1L_MESHCORE_ADMIN_AUTHENTICATED &&
strcmp(status->fingerprint, s_admin_target_fingerprint) == 0) {
esp_err_t save_result = ESP_OK;
- if (s_admin_pending_remember &&
- s_admin_pending_password[0] != '\0') {
- save_result = d1l_admin_credential_store_save(
- s_admin_target_fingerprint, s_admin_pending_password);
- } else {
- save_result = d1l_admin_credential_store_forget(
- s_admin_target_fingerprint);
- if (save_result == ESP_ERR_NOT_FOUND) {
- save_result = ESP_OK;
+ if (!s_admin_pending_guest) {
+ if (s_admin_pending_remember &&
+ s_admin_pending_password[0] != '\0') {
+ save_result = d1l_admin_credential_store_save(
+ s_admin_target_fingerprint, s_admin_pending_password);
+ } else {
+ save_result = d1l_admin_credential_store_forget(
+ s_admin_target_fingerprint);
+ if (save_result == ESP_ERR_NOT_FOUND) {
+ save_result = ESP_OK;
+ }
}
}
+ const bool guest_session =
+ (status->permissions & D1L_MESHCORE_ADMIN_PERMISSION_ROLE_MASK) ==
+ D1L_MESHCORE_ADMIN_PERMISSION_GUEST;
set_admin_feedback(
+ guest_session ?
+ "Guest session opened. Read-only tools only." :
save_result == ESP_OK ?
(s_admin_pending_remember &&
s_admin_pending_password[0] != '\0' ?
@@ -2473,12 +2484,30 @@ static void handle_home_action(d1l_ui_home_action_t action, void *context)
case D1L_UI_HOME_ACTION_RADIO:
open_radio_settings_event_cb(NULL);
break;
- case D1L_UI_HOME_ACTION_WIFI:
- open_wifi_sheet_event_cb(NULL);
+ case D1L_UI_HOME_ACTION_WIFI: {
+ d1l_app_model_snapshot(&s_snapshot);
+ esp_err_t ret = ESP_OK;
+ if (!s_snapshot.wifi_enabled || s_snapshot.ble_companion_enabled) {
+ ret = d1l_app_model_set_wifi_enabled(true);
+ show_toast("Wi-Fi mode", ret);
+ }
+ if (ret == ESP_OK) {
+ open_wifi_sheet_event_cb(NULL);
+ }
break;
- case D1L_UI_HOME_ACTION_BLE:
- open_ble_sheet_event_cb(NULL);
+ }
+ case D1L_UI_HOME_ACTION_BLE: {
+ d1l_app_model_snapshot(&s_snapshot);
+ esp_err_t ret = ESP_OK;
+ if (!s_snapshot.ble_companion_enabled || s_snapshot.wifi_enabled) {
+ ret = d1l_app_model_set_ble_enabled(true);
+ show_toast("Bluetooth mode", ret);
+ }
+ if (ret == ESP_OK) {
+ open_ble_sheet_event_cb(NULL);
+ }
break;
+ }
case D1L_UI_HOME_ACTION_STORAGE:
open_storage_sheet_event_cb(NULL);
break;
@@ -6336,11 +6365,12 @@ static void handle_nodes_action(const d1l_ui_nodes_action_event_t *event,
s_nodes_clear_armed, s_nodes_clear_deadline)) {
s_nodes_clear_armed = true;
s_nodes_clear_deadline = lv_tick_get() + 5000U;
- show_toast_text("Tap Clear again to erase heard nodes", true);
+ show_toast_text(
+ "Tap Clear nearby again to erase heard nodes", true);
} else {
s_nodes_clear_armed = false;
const esp_err_t ret = d1l_app_model_clear_nodes(true);
- show_toast("Clear heard nodes", ret);
+ show_toast("Clear nearby", ret);
if (ret == ESP_OK) {
request_content_refresh();
}
@@ -7857,8 +7887,8 @@ static void service_sheets_action_handler(
}
}
const esp_err_t ret = d1l_observer_set_region(region);
- show_toast(ret == ESP_OK ? "Observer region saved" :
- "Observer region",
+ show_toast(ret == ESP_OK ? "MQTT IATA saved" :
+ "MQTT IATA",
ret);
if (ret == ESP_OK) {
(void)render_observer_service_sheet();
@@ -7963,8 +7993,11 @@ static void service_sheets_action_handler(
(void)render_admin_service_sheet();
return;
}
+ case D1L_UI_SERVICE_ACTION_ADMIN_GUEST_LOGIN:
case D1L_UI_SERVICE_ACTION_ADMIN_LOGIN: {
char password[D1L_MESHCORE_ADMIN_MAX_PASSWORD_BYTES + 1U] = {0};
+ const bool guest_login =
+ action == D1L_UI_SERVICE_ACTION_ADMIN_GUEST_LOGIN;
clear_admin_cli_confirmation();
s_admin_cli_secure_input = false;
if (s_admin_target_fingerprint[0] == '\0') {
@@ -7973,14 +8006,22 @@ static void service_sheets_action_handler(
(void)render_admin_service_sheet();
return;
}
- if (!d1l_ui_service_sheets_take_admin_password(
+ if (!guest_login &&
+ !d1l_ui_service_sheets_take_admin_password(
&s_service_sheets_controller, password, sizeof(password))) {
set_admin_feedback("Enter a valid server password.", true);
(void)render_admin_service_sheet();
return;
}
- if (password[0] == '\0' &&
- d1l_admin_credential_store_has(s_admin_target_fingerprint)) {
+ if (!guest_login && password[0] == '\0') {
+ if (!d1l_admin_credential_store_has(
+ s_admin_target_fingerprint)) {
+ set_admin_feedback(
+ "Enter an admin password or choose Guest.", true);
+ d1l_meshcore_admin_secure_zero(password, sizeof(password));
+ (void)render_admin_service_sheet();
+ return;
+ }
const esp_err_t load_result = d1l_admin_credential_store_load(
s_admin_target_fingerprint, password);
if (load_result != ESP_OK) {
@@ -7998,7 +8039,9 @@ static void service_sheets_action_handler(
snprintf(s_admin_pending_password,
sizeof(s_admin_pending_password), "%s", password);
s_admin_pending_password_valid = true;
- s_admin_pending_remember = s_admin_remember_password;
+ s_admin_pending_remember =
+ !guest_login && s_admin_remember_password;
+ s_admin_pending_guest = guest_login;
clear_admin_feedback();
} else {
set_admin_feedback(
@@ -9147,6 +9190,7 @@ static void scroll_probe_contact_entry(d1l_contact_entry_t *entry)
snprintf(entry->type, sizeof(entry->type), "%s", "chat");
entry->last_rssi_dbm = -42;
entry->last_snr_tenths = 30;
+ entry->last_heard_ms = 1U;
entry->out_path_valid = true;
}
@@ -10023,12 +10067,35 @@ static void refresh_timer_cb(lv_timer_t *timer)
d1l_app_model_snapshot(&s_snapshot);
update_chrome(&s_snapshot);
update_startup_overlay(&s_snapshot);
+ const bool ble_pairing_active =
+ s_snapshot.ble_companion_enabled &&
+ s_snapshot.ble_pairing_passkey >= 100000U &&
+ s_snapshot.ble_pairing_passkey <= 999999U &&
+ strcmp(s_snapshot.ble_state ? s_snapshot.ble_state : "", "pairing") == 0;
#if !D1L_ENABLE_QUALIFICATION_HOOKS
lv_timer_set_period(
- timer, s_onboarding_visible ? 250U : 2000U);
+ timer,
+ (s_onboarding_visible ||
+ (s_snapshot.ble_companion_enabled &&
+ !s_snapshot.ble_protocol_ready)) ? 250U : 2000U);
#endif
+ if (ble_pairing_active && !s_ble_pairing_announced &&
+ !s_onboarding_visible) {
+ s_ble_pairing_announced = true;
+ lv_disp_trig_activity(NULL);
+ if (s_backlight_dimmed) {
+ (void)d1l_backlight_set_percent(desired_backlight_percent());
+ s_backlight_dimmed = false;
+ }
+ open_ble_sheet_event_cb(NULL);
+ } else if (!ble_pairing_active) {
+ s_ble_pairing_announced = false;
+ }
if (d1l_ui_modal_visible(s_compose_sheet)) {
update_compose_counter();
+ } else if (d1l_ui_modal_visible(
+ d1l_ui_ble_sheet(&s_ble_controller))) {
+ (void)render_ble_sheet();
}
if (d1l_ui_modal_visible(d1l_ui_service_sheets_update(
&s_service_sheets_controller))) {
diff --git a/main/ui/ui_service_sheets.c b/main/ui/ui_service_sheets.c
index 55cb68dc..5764f4d5 100644
--- a/main/ui/ui_service_sheets.c
+++ b/main/ui/ui_service_sheets.c
@@ -57,6 +57,8 @@ enum {
BINDING_ADMIN_SHOW_ACL,
BINDING_ADMIN_REMEMBER_TOGGLE,
BINDING_ADMIN_FORGET_PASSWORD,
+ /* Login and authenticated status refresh are never rendered together. */
+ BINDING_ADMIN_GUEST_LOGIN = BINDING_ADMIN_REFRESH,
};
_Static_assert(sizeof(d1l_ui_service_sheets_controller_t) <=
@@ -584,7 +586,7 @@ bool d1l_ui_service_sheets_render_observer(
lv_obj_t *broker = create_label(sheet, line, 0xF4F7FB);
position_dot(broker, 8, 84, 408);
complete = broker && complete;
- snprintf(line, sizeof(line), "Region %s Topic %s%s",
+ snprintf(line, sizeof(line), "IATA %s Topic %s%s",
status->region[0] ? status->region : "---",
status->topic[0] ? status->topic : "-",
status->include_location ? " + location" : "");
@@ -598,7 +600,8 @@ bool d1l_ui_service_sheets_render_observer(
position_wrap(privacy, 8, 140, 408);
complete = privacy && complete;
- lv_obj_t *region_label = create_label(sheet, "Observer region", 0xA6B0B7);
+ lv_obj_t *region_label = create_label(
+ sheet, "IATA region (3 letters)", 0xA6B0B7);
position_dot(region_label, 8, 166, 132);
complete = region_label && complete;
controller->observer_region_textarea = lv_textarea_create(sheet);
@@ -907,7 +910,7 @@ static const char *admin_failure_message(d1l_meshcore_admin_state_t state)
return "The management session ended. Sign in again.";
case D1L_MESHCORE_ADMIN_IDLE:
default:
- return "Enter the password used by this server.";
+ return "Use Admin with a password, or Guest without one.";
}
}
@@ -933,23 +936,26 @@ static bool render_admin_compact_header(
d1l_ui_service_sheets_controller_t *controller, lv_obj_t *sheet,
const char *title_text, bool show_back)
{
+ bool complete = true;
+ if (show_back) {
+ complete = create_button(
+ controller, sheet, "Back", 0, 0, 76, 44,
+ BINDING_ADMIN_SHOW_HUB,
+ D1L_UI_SERVICE_ACTION_ADMIN_SHOW_HUB) != NULL;
+ }
lv_obj_t *title = create_label(sheet, title_text, 0xF4F7FB);
if (title) {
lv_obj_set_style_text_font(title, &lv_font_montserrat_24, 0);
- position_dot(title, 8, 4, show_back ? 226 : 290);
+ position_dot(title, show_back ? 88 : 8, 4,
+ show_back ? 320 : 290);
}
- bool complete = title != NULL;
- if (show_back) {
+ complete = title != NULL && complete;
+ if (!show_back) {
complete = create_button(
- controller, sheet, "Back", 254, 0, 76, 44,
- BINDING_ADMIN_SHOW_HUB,
- D1L_UI_SERVICE_ACTION_ADMIN_SHOW_HUB) != NULL && complete;
+ controller, sheet, "Close", 340, 0, 76, 44,
+ BINDING_CLOSE_ADMIN,
+ D1L_UI_SERVICE_ACTION_CLOSE_ADMIN) != NULL && complete;
}
- complete = create_button(
- controller, sheet, "Close", 340, 0, 76, 44,
- BINDING_CLOSE_ADMIN,
- show_back ? D1L_UI_SERVICE_ACTION_ADMIN_SHOW_HUB :
- D1L_UI_SERVICE_ACTION_CLOSE_ADMIN) != NULL && complete;
return complete;
}
@@ -1086,7 +1092,7 @@ static bool render_admin_pending(
}
complete = timeout && complete;
complete = create_button(
- controller, sheet, "Cancel", 132, 254, 160, 44,
+ controller, sheet, "Cancel & sign out", 112, 254, 200, 44,
BINDING_ADMIN_LOGOUT,
D1L_UI_SERVICE_ACTION_ADMIN_LOGOUT) != NULL && complete;
return complete;
@@ -1163,7 +1169,7 @@ static bool render_admin_login_compact(
lv_textarea_set_placeholder_text(
controller->admin_password_textarea,
saved_password_available ?
- "Saved password" : "Password (blank is allowed)");
+ "Admin password or saved" : "Admin password");
lv_textarea_set_text(controller->admin_password_textarea, "");
lv_obj_set_style_radius(controller->admin_password_textarea, 8, 0);
lv_obj_set_style_bg_color(
@@ -1194,25 +1200,24 @@ static bool render_admin_login_compact(
controller, sheet, controller->admin_password_textarea,
140, 104, D1L_UI_SERVICE_ACTION_ADMIN_LOGIN) && complete;
complete = create_button(
- controller, sheet, "Login", 8, 250, 108, 46,
+ controller, sheet, "Admin", 8, 250, 92, 46,
BINDING_ADMIN_LOGIN,
D1L_UI_SERVICE_ACTION_ADMIN_LOGIN) != NULL && complete;
+ complete = create_button(
+ controller, sheet, "Guest", 108, 250, 92, 46,
+ BINDING_ADMIN_GUEST_LOGIN,
+ D1L_UI_SERVICE_ACTION_ADMIN_GUEST_LOGIN) != NULL && complete;
complete = create_button(
controller, sheet,
- remember_password ? "Save: On" : "Save: Off",
- 124, 250, 132, 46,
+ remember_password ? "Save On" : "Save Off",
+ 208, 250, 100, 46,
BINDING_ADMIN_REMEMBER_TOGGLE,
D1L_UI_SERVICE_ACTION_ADMIN_REMEMBER_TOGGLE) != NULL && complete;
if (saved_password_available) {
complete = create_button(
- controller, sheet, "Forget saved", 264, 250, 152, 46,
+ controller, sheet, "Forget", 316, 250, 100, 46,
BINDING_ADMIN_FORGET_PASSWORD,
D1L_UI_SERVICE_ACTION_ADMIN_FORGET_PASSWORD) != NULL && complete;
- } else {
- lv_obj_t *local = create_label(
- sheet, "Saved only on this D1L", 0x667787);
- position_dot(local, 270, 264, 140);
- complete = local && complete;
}
return complete;
}
@@ -1223,10 +1228,22 @@ static bool render_admin_hub_compact(
const char *selected_fingerprint, const char *selected_name,
const char *feedback, bool feedback_error)
{
+ const uint8_t permission_role =
+ status->permissions & D1L_MESHCORE_ADMIN_PERMISSION_ROLE_MASK;
+ const bool admin_session =
+ permission_role == D1L_MESHCORE_ADMIN_PERMISSION_ADMIN;
+ const bool guest_session =
+ permission_role == D1L_MESHCORE_ADMIN_PERMISSION_GUEST;
+ const char *title = NULL;
+ if (status->role == D1L_MESHCORE_ADMIN_ROLE_ROOM) {
+ title = admin_session ? "Room manager" :
+ (guest_session ? "Room guest" : "Room access");
+ } else {
+ title = admin_session ? "Repeater manager" :
+ (guest_session ? "Repeater guest" : "Repeater access");
+ }
bool complete = render_admin_compact_header(
- controller, sheet,
- status->role == D1L_MESHCORE_ADMIN_ROLE_ROOM ?
- "Room manager" : "Repeater manager", false);
+ controller, sheet, title, false);
complete = render_admin_target(
sheet, status, selected_fingerprint, selected_name, 48, 50) &&
complete;
@@ -1256,28 +1273,40 @@ static bool render_admin_hub_compact(
D1L_UI_SERVICE_ACTION_ADMIN_SHOW_ROOM :
D1L_UI_SERVICE_ACTION_ADMIN_SHOW_NEIGHBOURS,
0xFBBF24) != NULL && complete;
- complete = create_admin_grid_button(
- controller, sheet, LV_SYMBOL_EYE_OPEN "\nAccess",
- 8, 190, BINDING_ADMIN_SHOW_ACCESS,
- D1L_UI_SERVICE_ACTION_ADMIN_SHOW_ACCESS, 0x7D93FF) != NULL && complete;
- complete = create_admin_grid_button(
- controller, sheet, LV_SYMBOL_SETTINGS "\nTools",
- 144, 190, BINDING_ADMIN_SHOW_TOOLS,
- D1L_UI_SERVICE_ACTION_ADMIN_SHOW_TOOLS, 0xFBBF24) != NULL && complete;
- complete = create_admin_grid_button(
- controller, sheet, LV_SYMBOL_EDIT "\nConsole",
- 280, 190, BINDING_ADMIN_SHOW_TERMINAL,
- D1L_UI_SERVICE_ACTION_ADMIN_SHOW_TERMINAL, 0x20D9ED) != NULL &&
- complete;
+ if (admin_session) {
+ complete = create_admin_grid_button(
+ controller, sheet, LV_SYMBOL_EYE_OPEN "\nAccess",
+ 8, 190, BINDING_ADMIN_SHOW_ACCESS,
+ D1L_UI_SERVICE_ACTION_ADMIN_SHOW_ACCESS, 0x7D93FF) != NULL &&
+ complete;
+ complete = create_admin_grid_button(
+ controller, sheet, LV_SYMBOL_SETTINGS "\nTools",
+ 144, 190, BINDING_ADMIN_SHOW_TOOLS,
+ D1L_UI_SERVICE_ACTION_ADMIN_SHOW_TOOLS, 0xFBBF24) != NULL &&
+ complete;
+ complete = create_admin_grid_button(
+ controller, sheet, LV_SYMBOL_EDIT "\nConsole",
+ 280, 190, BINDING_ADMIN_SHOW_TERMINAL,
+ D1L_UI_SERVICE_ACTION_ADMIN_SHOW_TERMINAL, 0x20D9ED) != NULL &&
+ complete;
+ } else {
+ lv_obj_t *read_only = create_label(
+ sheet, guest_session ?
+ "Guest session: read-only server information." :
+ "Limited session: only permitted server information is shown.",
+ 0xFBBF24);
+ position_dot(read_only, 8, 202, 408);
+ complete = read_only && complete;
+ }
char session[96];
snprintf(session, sizeof(session), "%s | firmware level %u",
admin_permission_name(status->permissions),
(unsigned)status->firmware_level);
lv_obj_t *session_label = create_label(sheet, session, 0xA6B0B7);
- position_dot(session_label, 8, 270, 286);
+ position_dot(session_label, 8, admin_session ? 270 : 232, 286);
complete = session_label && complete;
complete = create_button(
- controller, sheet, "Logout", 304, 254, 112, 44,
+ controller, sheet, "Sign out", 304, 254, 112, 44,
BINDING_ADMIN_LOGOUT,
D1L_UI_SERVICE_ACTION_ADMIN_LOGOUT) != NULL && complete;
return complete;
@@ -1307,7 +1336,10 @@ static bool render_admin_status_compact(
const char *feedback, bool feedback_error)
{
bool complete = render_admin_compact_header(
- controller, sheet, "Server status", true);
+ controller, sheet,
+ status->role == D1L_MESHCORE_ADMIN_ROLE_ROOM ?
+ "Room status" : "Repeater status",
+ true);
if (!status->status_valid) {
lv_obj_t *empty = create_label(
sheet, "No current status has been received yet.", 0xFBBF24);
@@ -1336,8 +1368,8 @@ static bool render_admin_status_compact(
(unsigned long)status->status.tx_air_time_seconds,
(unsigned long)status->status.rx_air_time_seconds);
char errors[24];
- snprintf(errors, sizeof(errors), "0x%04x",
- (unsigned)status->status.error_flags);
+ snprintf(errors, sizeof(errors), "%s",
+ status->status.error_flags ? "Attention" : "None");
complete = render_admin_metric(
sheet, "RSSI / SNR", signal, 8, 52, 0x20D9ED) && complete;
complete = render_admin_metric(
@@ -1351,15 +1383,16 @@ static bool render_admin_status_compact(
sheet, "TX / RX airtime", airtime, 144, 120, 0x20D9ED) &&
complete;
complete = render_admin_metric(
- sheet, "Error flags", errors, 280, 120,
+ sheet, "Errors", errors, 280, 120,
status->status.error_flags ? 0xF87171 : 0x84FF2E) && complete;
char detail[160];
snprintf(detail, sizeof(detail),
- "Noise %d dBm | duplicates %u direct, %u flood | voltage %u mV",
+ "Noise %d dBm | duplicates %u direct, %u flood | power %u.%02u V",
(int)status->status.noise_floor_dbm,
(unsigned)status->status.direct_duplicates,
(unsigned)status->status.flood_duplicates,
- (unsigned)status->status.battery_millivolts);
+ (unsigned)(status->status.battery_millivolts / 1000U),
+ (unsigned)((status->status.battery_millivolts % 1000U) / 10U));
lv_obj_t *detail_label = create_label(sheet, detail, 0xA6B0B7);
position_dot(detail_label, 8, 194, 408);
complete = detail_label && complete;
@@ -1823,13 +1856,19 @@ bool d1l_ui_service_sheets_render_admin(
!begin_render(controller, sheet, action_handler, action_context)) {
return false;
}
+ const uint8_t permission_role =
+ status->permissions & D1L_MESHCORE_ADMIN_PERMISSION_ROLE_MASK;
+ const char *header_title =
+ status->state != D1L_MESHCORE_ADMIN_AUTHENTICATED ? "Server Login" :
+ (permission_role == D1L_MESHCORE_ADMIN_PERMISSION_ADMIN ?
+ "Server Admin" : "Server Access");
bool complete = render_header(
- controller, sheet, "Server Admin", BINDING_CLOSE_ADMIN,
+ controller, sheet, header_title, BINDING_CLOSE_ADMIN,
D1L_UI_SERVICE_ACTION_CLOSE_ADMIN);
char line[160];
- snprintf(line, sizeof(line), "State %s role %s permissions %u",
+ snprintf(line, sizeof(line), "State %s role %s %s",
admin_state_name(status->state), admin_role_name(status->role),
- (unsigned)status->permissions);
+ admin_permission_name(status->permissions));
lv_obj_t *state = create_label(
sheet, line,
status->state == D1L_MESHCORE_ADMIN_AUTHENTICATED ? 0x20D9ED :
@@ -2411,7 +2450,7 @@ bool d1l_ui_service_sheets_render_admin(
} else {
if (selected_fingerprint && selected_fingerprint[0]) {
lv_obj_t *password_label = create_label(
- sheet, "Password (empty allowed by peer)", 0x20D9ED);
+ sheet, "Administrator password", 0x20D9ED);
position_dot(password_label, 8, 232, 408);
complete = password_label && complete;
@@ -2459,9 +2498,13 @@ bool d1l_ui_service_sheets_render_admin(
}
complete = controller->admin_password_textarea && complete;
complete = create_button(
- controller, sheet, "Login", 8, 298, 120, 44,
+ controller, sheet, "Admin login", 8, 298, 120, 44,
BINDING_ADMIN_LOGIN,
D1L_UI_SERVICE_ACTION_ADMIN_LOGIN) != NULL && complete;
+ complete = create_button(
+ controller, sheet, "Guest", 140, 298, 120, 44,
+ BINDING_ADMIN_GUEST_LOGIN,
+ D1L_UI_SERVICE_ACTION_ADMIN_GUEST_LOGIN) != NULL && complete;
controller->admin_keyboard = lv_keyboard_create(sheet);
if (controller->admin_keyboard) {
diff --git a/main/ui/ui_service_sheets.h b/main/ui/ui_service_sheets.h
index 20f606f4..3d336782 100644
--- a/main/ui/ui_service_sheets.h
+++ b/main/ui/ui_service_sheets.h
@@ -32,6 +32,7 @@ typedef enum {
D1L_UI_SERVICE_ACTION_NOTIFICATIONS_MODE,
D1L_UI_SERVICE_ACTION_OPEN_MESSAGES,
D1L_UI_SERVICE_ACTION_CLOSE_ADMIN,
+ D1L_UI_SERVICE_ACTION_ADMIN_GUEST_LOGIN,
D1L_UI_SERVICE_ACTION_ADMIN_LOGIN,
D1L_UI_SERVICE_ACTION_ADMIN_REFRESH,
D1L_UI_SERVICE_ACTION_ADMIN_TELEMETRY,
diff --git a/scripts/meshcore_conformance_d1l.py b/scripts/meshcore_conformance_d1l.py
index 3447774d..7badd8dd 100644
--- a/scripts/meshcore_conformance_d1l.py
+++ b/scripts/meshcore_conformance_d1l.py
@@ -3047,6 +3047,7 @@ def production_semantic_suite_specs(
"sources": [
native / "meshcore_admin_dispatch_test.c",
ROOT / "main" / "mesh" / "meshcore_admin_dispatch.c",
+ ROOT / "main" / "mesh" / "user_text.c",
],
},
{
diff --git a/scripts/package_release_d1l.py b/scripts/package_release_d1l.py
index 7485302a..6e90bddb 100644
--- a/scripts/package_release_d1l.py
+++ b/scripts/package_release_d1l.py
@@ -76,7 +76,7 @@
PROJECT = "MeshCore DeskOS D1L"
DEFAULT_FLASH_SIZE = 8 * 1024 * 1024
-FLASH_BAUD = 460800
+FLASH_BAUD = 230400
PACKAGE_METADATA_SCHEMA = 1
UPDATE_MANIFEST_HEADER = "D1L-UPDATE-MANIFEST-V1"
UPDATE_PRODUCT = "MeshCore DeskOS D1L"
@@ -1520,6 +1520,7 @@ def write_production_user_install_bundle(
source_commit: str,
app_version: str,
sd_history_mode: str,
+ release_profile: str = CORE_RELEASE_PROFILE,
) -> dict:
"""Write checksum-bound Windows/Linux production install helpers."""
@@ -1680,8 +1681,8 @@ def write_production_user_install_bundle(
This is the DeskOS D1L {app_version} production package:
- firmware commit: `{source_commit}`
-- GitHub Actions run and attempt: see `manifest.json` and `README_RELEASE.md`
-- release profile: `{CORE_RELEASE_PROFILE}`
+- build origin: see `manifest.json` and `README_RELEASE.md`
+- release profile: `{release_profile}`
- SD history mode: `{sd_history_mode}`
Do not mix files from another download or run these tools from inside an
@@ -1798,6 +1799,10 @@ def write_production_user_install_bundle(
### Existing DeskOS: preserving update BIN
+Use these installers when updating an existing device. They verify and write
+the app before selecting the updated boot slot, including after a signed SD
+update. Settings, identity, retained data, and the SD card are preserved.
+
Windows:
```powershell
@@ -1845,9 +1850,9 @@ def write_production_user_install_bundle(
3. optionally save Wi-Fi, or continue offline;
4. confirm the Canadian 910.525 MHz / 62.5 kHz / SF7 / CR5 preset;
5. verify the prepared FAT32 card and included NRCan provider; and
-6. review Public, #bot, and #test before finishing.
+6. review Public and add any channels you use before finishing.
-The normal dock is Home, Channels, Contacts, Map, and Settings. DeskOS does not
+Use the dock to reach Home, messages, contacts, maps, and settings. DeskOS does not
ship a local identity, position, nearby-node list, or sample mesh data; those
are established only from your setup and real MeshCore traffic.
@@ -1922,19 +1927,22 @@ def copy_rp2040_artifacts(
if not artifact_root.is_dir():
raise FileNotFoundError(f"Missing RP2040 artifact root {artifact_root}")
- missing = [name for name in RP2040_ARTIFACT_NAMES if not (artifact_root / name).is_dir()]
- if missing:
- raise FileNotFoundError("Missing RP2040 release artifacts: " + ", ".join(missing))
selected = set(RP2040_ARTIFACT_NAMES if include_names is None else include_names)
unknown = selected.difference(RP2040_ARTIFACT_NAMES)
if unknown:
raise ValueError(
"Unknown RP2040 release artifacts requested: " + ", ".join(sorted(unknown))
)
+ missing = [name for name in RP2040_ARTIFACT_NAMES
+ if name in selected and not (artifact_root / name).is_dir()]
+ if missing:
+ raise FileNotFoundError("Missing RP2040 release artifacts: " + ", ".join(missing))
copied = []
rp2040_dir = package_dir / "rp2040"
for artifact_name in RP2040_ARTIFACT_NAMES:
+ if artifact_name not in selected:
+ continue
source_dir = artifact_root / artifact_name
try:
source_resolved = source_dir.resolve(strict=True)
@@ -1960,8 +1968,6 @@ def copy_rp2040_artifacts(
raise ValueError(
f"{artifact_name} must contain exactly one valid root SHA256SUMS.txt"
)
- if artifact_name not in selected:
- continue
dest_dir = rp2040_dir / artifact_name
if production_only:
source_uf2_files = sorted(source_dir.glob("*.uf2"))
@@ -2588,6 +2594,7 @@ def run_flash(
resolver_module: Any | None = None,
resolver_hooks: dict[str, Any] | None = None,
validate_only: bool = False,
+ app_update: bool = False,
) -> dict[str, Any]:
raw_root = (
Path(__file__).parent
@@ -2627,7 +2634,20 @@ def run_flash(
if validate_only:
return {"target": snapshot, "command": None}
- command = [
+ phases = [FLASH_PLAN]
+ if app_update:
+ app = [row for row in FLASH_PLAN if Path(row[1]).name == "meshcore_deskos_d1l.bin"]
+ selector = [row for row in FLASH_PLAN if Path(row[1]).name == "ota_data_initial.bin"]
+ if len(app) != 1 or len(selector) > 1:
+ raise ValueError("Invalid app update flash plan")
+ # Write and verify the app before selecting ota_0. A prior signed SD
+ # update may have selected ota_1; writing ota_0 alone would not update
+ # the firmware that boots. Neither phase touches settings or history.
+ phases = [app] + ([selector] if selector else [])
+ commands = []
+ runner = command_runner or subprocess.run
+ for index, phase in enumerate(phases):
+ command = [
sys.executable,
"-m",
"esptool",
@@ -2640,7 +2660,7 @@ def run_flash(
"--before",
"default-reset",
"--after",
- "hard-reset",
+ "hard-reset" if index == len(phases) - 1 else "no-reset",
"write-flash",
"--flash-mode",
FLASH_MODE,
@@ -2648,26 +2668,31 @@ def run_flash(
FLASH_SIZE,
"--flash-freq",
FLASH_FREQ,
- ]
- for offset, relative in FLASH_PLAN:
- command.extend((offset, str(root / relative)))
- runner = command_runner or subprocess.run
- result = runner(command, cwd=root)
- returncode = getattr(result, "returncode", result if type(result) is int else None)
- if returncode != 0:
- raise RuntimeError(f"Project flash failed with exit code {returncode!r}")
- return {"target": snapshot, "command": command}
+ ]
+ for offset, relative in phase:
+ command.extend((offset, str(root / relative)))
+ result = runner(command, cwd=root)
+ returncode = getattr(result, "returncode", result if type(result) is int else None)
+ if returncode != 0:
+ raise RuntimeError(f"Project flash failed with exit code {returncode!r}")
+ commands.append(command)
+ result = {"target": snapshot, "command": commands[0]}
+ if app_update:
+ result["commands"] = commands
+ return result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--port", default=os.environ.get("D1L_PORT"))
parser.add_argument("--validate-only", action="store_true")
+ parser.add_argument("--app-update", action="store_true")
args = parser.parse_args(argv)
if not isinstance(args.port, str) or not args.port.strip():
parser.error("No D1L port supplied. Set D1L_PORT or pass --port.")
try:
- run_flash(args.port, validate_only=args.validate_only)
+ run_flash(args.port, validate_only=args.validate_only,
+ app_update=args.app_update)
except (OSError, RuntimeError, ValueError) as exc:
parser.error(str(exc))
return 0
@@ -2722,7 +2747,7 @@ def write_flash_scripts(
flash_freq = flash_settings.get("flash_freq", "80m")
project_args = command_flash_files(entries)
- if release_profile == CORE_RELEASE_PROFILE:
+ if release_profile in PRODUCTION_RELEASE_PROFILES:
app = app_entry(entries)
resolver = copy_core_serial_target_resolver(root, package_dir)
py_project = package_dir / "flash_project.py"
@@ -2787,12 +2812,7 @@ def write_flash_scripts(
'if ($ValidatedPort -notmatch "^COM[1-9][0-9]*$") { throw "Pass one explicit canonical COM port; automatic port selection is forbidden." }',
'Write-Host "Updating an existing DeskOS device while preserving retained data."',
"$Root = Split-Path -Parent $MyInvocation.MyCommand.Path",
- "python (Join-Path $Root 'flash_project.py') --port $ValidatedPort --validate-only",
- 'if ($LASTEXITCODE -ne 0) { throw "D1L identity validation failed with exit code $LASTEXITCODE" }',
- "python -m esptool --chip esp32s3 --port $ValidatedPort --baud "
- f"{FLASH_BAUD} --before default-reset --after hard-reset write-flash "
- f"--flash-mode {flash_mode} --flash-size {flash_size} --flash-freq {flash_freq} "
- f"{app['offset']} (Join-Path $Root '{app['path']}')",
+ "python (Join-Path $Root 'flash_project.py') --port $ValidatedPort --app-update",
'if ($LASTEXITCODE -ne 0) { throw "DeskOS update BIN flash failed with exit code $LASTEXITCODE" }',
"",
)
@@ -2813,11 +2833,7 @@ def write_flash_scripts(
"fi",
'ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"',
'PYTHON_BIN="${D1L_PYTHON:-python3}"',
- '"$PYTHON_BIN" "$ROOT/flash_project.py" --port "$D1L_PORT" --validate-only',
- '"$PYTHON_BIN" -m esptool --chip esp32s3 --port "$D1L_PORT" --baud '
- f"{FLASH_BAUD} --before default-reset --after hard-reset write-flash "
- f"--flash-mode {flash_mode} --flash-size {flash_size} --flash-freq {flash_freq} "
- f'{app["offset"]} "$ROOT/{app["path"]}"',
+ '"$PYTHON_BIN" "$ROOT/flash_project.py" --port "$D1L_PORT" --app-update',
"",
)
),
@@ -3349,6 +3365,14 @@ def write_release_readme(package_dir: Path, package_name: str, manifest: dict) -
for capability in manifest["supported_capabilities"]
)
signed_update = manifest["signed_update"]
+ build_identity = (
+ "Build origin: local (see checksum-bound provenance)."
+ if manifest.get("workflow", {}).get("provider") == "local"
+ else (
+ f"GitHub Actions run: `{manifest['actions_run']}`\n\n"
+ f"GitHub Actions run attempt: `{manifest['actions_run_attempt']}`"
+ )
+ )
readme.write_text(
f"""# {PROJECT} Full Feature Release Package
@@ -3358,16 +3382,12 @@ def write_release_readme(package_dir: Path, package_name: str, manifest: dict) -
Firmware commit: `{manifest['firmware_commit']}`
-GitHub Actions run: `{manifest['actions_run']}`
-
-GitHub Actions run attempt: `{manifest['actions_run_attempt']}`
+{build_identity}
SD history mode: `{manifest['sd_history_mode']}`
This package contains the production Full Feature firmware and a verified
-Ed25519-signed local update bundle. `full_feature_release_ready` remains
-`false` until the exact package is flashed and its physical release gates are
-recorded; packaging never manufactures that evidence.
+Ed25519-signed local update bundle.
## Supported capabilities
@@ -3385,7 +3405,7 @@ def write_release_readme(package_dir: Path, package_name: str, manifest: dict) -
`update/d1l-update.sig` are one exact signed update set.
- `full-flash/meshcore_deskos_d1l-full-8mb.bin` is the destructive
factory/recovery image.
-- `docs/`, `notices/`, `evidence/`, the SBOM, provenance, and package metadata
+- `docs/`, `notices/`, the SBOM, provenance, and package metadata
are bound by `SHA256SUMS.txt`.
Update signer: `{signed_update['signer_key_id']}`
@@ -3436,8 +3456,8 @@ def write_release_readme(package_dir: Path, package_name: str, manifest: dict) -
Verify `SHA256SUMS.txt` before flashing. Report defects at
https://github.com/n30nex/DeskOS-MeshCore/issues/new with firmware commit
-`{manifest['firmware_commit']}`, Actions run `{manifest['actions_run']}`,
-attempt `{manifest['actions_run_attempt']}`, and the relevant device receipt.
+`{manifest['firmware_commit']}`, the build origin recorded in `manifest.json`,
+and the observed device behavior.
App image: `{app['path']}`
@@ -3527,10 +3547,17 @@ def create_release_package(
release_profile: str = "full_feature",
sd_history_mode: str = "conditional",
update_signing_key: Path | None = None,
+ local_build: bool = False,
) -> dict:
release_profile, sd_history_mode = validate_release_settings(
release_profile, sd_history_mode
)
+ if local_build and release_profile != FULL_FEATURE_RELEASE_PROFILE:
+ raise ValueError("Local release packaging requires the full_feature profile")
+ if local_build and any(os.environ.get(name) for name in (
+ "GITHUB_ACTIONS", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT",
+ )):
+ raise ValueError("Local packaging cannot claim a GitHub Actions invocation")
if (
release_profile == FULL_FEATURE_RELEASE_PROFILE
and update_signing_key is None
@@ -3559,7 +3586,15 @@ def create_release_package(
source_git["commit"] = expected_commit
source_git["short_commit"] = expected_commit[:7]
workflow = workflow_info()
- if release_profile in PRODUCTION_RELEASE_PROFILES:
+ if local_build:
+ workflow.update({
+ "provider": "local",
+ "sha": expected_commit,
+ "repository": CANONICAL_REPOSITORY,
+ "ref": None,
+ "workflow": None,
+ })
+ if release_profile in PRODUCTION_RELEASE_PROFILES and not local_build:
profile_label = (
"Core"
if release_profile == CORE_RELEASE_PROFILE
@@ -3676,12 +3711,12 @@ def create_release_package(
production_only=release_profile in PRODUCTION_RELEASE_PROFILES,
)
if (
- release_profile == CORE_RELEASE_PROFILE
+ release_profile in PRODUCTION_RELEASE_PROFILES
and sd_history_mode != "disabled"
and not rp2040_artifacts
):
raise ValueError(
- "Core SD support requires the exact paired RP2040 artifacts"
+ "Production SD support requires the exact paired RP2040 artifacts"
)
scripts = write_flash_scripts(
root,
@@ -3705,7 +3740,7 @@ def create_release_package(
sd_preparation = (
copy_sd_preparation_bundle(root, package_dir)
- if release_profile == CORE_RELEASE_PROFILE
+ if release_profile in PRODUCTION_RELEASE_PROFILES
and sd_history_mode != "disabled"
else None
)
@@ -3716,8 +3751,9 @@ def create_release_package(
source_commit=expected_commit,
app_version=app_version,
sd_history_mode=sd_history_mode,
+ release_profile=release_profile,
)
- if release_profile == CORE_RELEASE_PROFILE
+ if release_profile in PRODUCTION_RELEASE_PROFILES
and sd_history_mode != "disabled"
else None
)
@@ -3863,8 +3899,8 @@ def create_release_package(
{
"release_profile": release_profile,
"firmware_commit": expected_commit,
- "actions_run": str(workflow["run_id"]),
- "actions_run_attempt": str(workflow["run_attempt"]),
+ "actions_run": workflow["run_id"],
+ "actions_run_attempt": workflow["run_attempt"],
"supported_capabilities": capability_truth[
"supported_capabilities"
],
@@ -3939,6 +3975,8 @@ def main() -> int:
parser.add_argument("--release-profile", choices=sorted(RELEASE_PROFILES))
parser.add_argument("--sd-history-mode", choices=sorted(SD_HISTORY_MODES))
parser.add_argument("--update-signing-key", default=None)
+ parser.add_argument("--local-build", action="store_true",
+ help="Package a local full_feature build with local provenance")
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
@@ -4007,6 +4045,7 @@ def main() -> int:
release_profile=release_profile,
sd_history_mode=sd_history_mode,
update_signing_key=update_signing_key,
+ local_build=args.local_build,
)
print(json.dumps(manifest))
return 0
diff --git a/sdkconfig.defaults b/sdkconfig.defaults
index 844bbcd0..ba8253dc 100644
--- a/sdkconfig.defaults
+++ b/sdkconfig.defaults
@@ -28,6 +28,7 @@ CONFIG_LCD_TASK_REFRESH_TIME=10
CONFIG_LV_COLOR_DEPTH_16=y
CONFIG_LV_FONT_MONTSERRAT_14=y
CONFIG_LV_FONT_MONTSERRAT_24=y
+CONFIG_LV_FONT_MONTSERRAT_32=y
CONFIG_LV_USE_QRCODE=y
# The production shell creates enough labels/sheets that LVGL's fixed 32 KB
# object heap can panic before the app console starts. Use the ESP-IDF heap
@@ -120,7 +121,7 @@ CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=4
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2
CONFIG_BT_NIMBLE_MAX_CCCDS=2
CONFIG_BT_NIMBLE_WHITELIST_SIZE=2
-CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=4096
+CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=6144
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=8
diff --git a/tests/map_marker_truth/manifest.json b/tests/map_marker_truth/manifest.json
index fbd35309..5228c4e7 100644
--- a/tests/map_marker_truth/manifest.json
+++ b/tests/map_marker_truth/manifest.json
@@ -117,7 +117,7 @@
}
],
"production_source_pins": {
- "main/app/app_model.c": "ca9c2c8bc80ebcf6ebd587da66d3413a18cde44fc03085ead749857748c6aab5",
+ "main/app/app_model.c": "e106462b2376e0a582b362682e367ef7b3f7682a992ec4519fcf91685fdbf162",
"main/app/app_model.h": "94611f8032e9ae7d752b207d5593a3adc40eb29adb8fcc550ad438b407874281",
"main/app/settings_model.c": "fbf75f92086ad231832ae98669a6cb4800bb3368fa78d04f87bc0122a45eeba3",
"main/app/settings_model.h": "f7fc9e0b5cb9b52e1c6c597f95cf8d48c2ae266d092031034d76b059372e83b6",
@@ -133,10 +133,10 @@
"main/map/map_view_service.h": "f8fe9f3167787e34bb2450f520ab594e2e622f5bf2c73470590663ce304250c2",
"main/map/map_viewport_truth_transition.c": "99d0b57a4902a4e3a78c4f9a096eb7627e2aaaf102b9a552944aa70fa862ec60",
"main/map/map_viewport_truth_transition.h": "4de9fd54634d0f3481a5069895e72357bee55593ead88d44db526c98f37bc66e",
- "main/mesh/advert_data.c": "b046f34629d8f04fbf680cc8b517958162c5401acb991d5bb3231c75f3ac6818",
+ "main/mesh/advert_data.c": "e62c97af20f7e1b127fad0208825135da140feb6efbbf25cc246ebd87d630973",
"main/mesh/meshcore_lifetime.h": "b480f554ac3979e3caefdd1d815a334e0de47bddc8f21c7ac8305d4643fba535",
- "main/mesh/meshcore_service.c": "16357ccac5cda1b03162a4c9d8a68d09dbaf2b552c1d5e9ab3f50f7b2b000ea0",
- "main/mesh/node_store.c": "e65ec2ac5ada3f31d4af84f49b483f70023338688c63f5471382b28edeb7bca5",
+ "main/mesh/meshcore_service.c": "41b03af1a15fed812f2dfae6ee066e0f4bb18a7897c1c4e4f337fca9b31229c3",
+ "main/mesh/node_store.c": "782beac7827609175532837c6e1305487e9e39cdf94c77a6346f9116d0c00a62",
"main/mesh/node_store.h": "4182df086de1b0b925a3f84f6d41dd4bf582ea5ecbaa23e46183d119461d70a1",
"main/platform/time_service.c": "54ed3f4a6441bfb9dea2c71b55538a381f8f246f3ef557a20af865dbd564297b",
"main/platform/time_service.h": "aceb879ef808f9b5df7d97da0ee04c52aeb3204a7612266bf26b85d365c3a679",
@@ -144,11 +144,11 @@
"main/platform/time_service_core.h": "f3af332807f433d49344eaf45cee29473940a988c47e3c02c44fd44cd0306d75",
"main/storage/map_tile_store.c": "dd382feb8ac4a36eab0ba9514bdbab611cd39b9a82831f20aa1a50c0c6f9922a",
"main/storage/map_tile_store.h": "6be24bbf0af09711f7fd877b51518fb267c4007ea82f9067bc60e2cceea2d992",
- "main/ui/ui_map.c": "2d739aa3e1c90f9c0964f29b7def6aef1329109dfb2e85a68fd2b96ce6d86e90",
+ "main/ui/ui_map.c": "76f352fec3fe7c8ff1ebcf939b8e2a5b61564e70ea69709f587cad93b2304db6",
"main/ui/ui_map.h": "1e18d81b50dd1c4a22e73afb8d295b96c57171fbb601adebf931f1c8e75e7949",
"main/ui/ui_node_detail.c": "0f0fcc740e647f2667ec8bf0d1321626287ee64eed7391f388f76a3e7d101b69",
"main/ui/ui_node_detail.h": "96ca4527d6e66e509d6e99bde81d85e35fa735124cbb56849219ac8585f31768",
- "main/ui/ui_phase1.c": "9d1d2c53edf039e46745a038369eaebcdc27894d2c9541d6d204a149f25a6c60",
- "tools/ui_simulator.py": "f66aa15f8c41e39271bea67b2f869258960b773a81320c435024f5099ffa304f"
+ "main/ui/ui_phase1.c": "dbac30e854b6ca47b4f1f7e4f1e01f2aac8541b7b61a79a0da66ef9d3a1a7ed0",
+ "tools/ui_simulator.py": "434a2d6e3ac8e63c069a22084c6abb16230eb06e7591f3834eba9007a1a4d134"
}
}
diff --git a/tests/meshcore_oracle/manifest.json b/tests/meshcore_oracle/manifest.json
index 333f459e..da0ec5d6 100644
--- a/tests/meshcore_oracle/manifest.json
+++ b/tests/meshcore_oracle/manifest.json
@@ -72,19 +72,19 @@
"main/app/qualification_hooks.h": "fc6c4e1c9e3663d6fc7fc7e41aaf028f9689138dec6341d3b59b5ae4e930954d",
"main/comms/usb_command_parser.c": "a6f15df848e3dbf3b9a68fad0f74796f04f4addedacd1a38a4be30c3b1141578",
"main/comms/usb_command_parser.h": "6aec4cc3e92d0990b43d0844f4255ca05145ec93441b5e0cad71bada21e2d3e3",
- "main/comms/usb_console.c": "7e1590ae6a1c62b76443f35699052a1f9bd156f7e90106ac6a67c904d5ca6a0f",
+ "main/comms/usb_console.c": "e89f036a48c592f8520cf6d994b27af70b7da026c0c0ac121f2df5d4b0023ea7",
"main/mesh/advert_data.h": "a45356935ddf821911a6948923f215b2cae38cc158012b1d0e025ab17b992542",
- "main/mesh/contact_store.c": "67ac341b918f43bb7c09edbf6adae7e9800517c69f11e4000b75e28e4c854022",
- "main/mesh/contact_store.h": "b2d708ebf0871a4684555dfd134d2026ed21a2bbcd3b8f03936d655d9553d3ed",
+ "main/mesh/contact_store.c": "47e1eeda5fc1d7bfbdfe41c10ef03ef4d556cf35c2ec06f6c133a778e5d65263",
+ "main/mesh/contact_store.h": "49213ca66ff795091f35470c003af85feb1d514d8bb88e6c336dd6599fbe322f",
"main/mesh/dm_delivery_state.c": "a3ff6903299b70ed68727284de8c2b6e81b2f9db69a80902db75983ee91aee94",
"main/mesh/dm_delivery_state.h": "d27b3666f03d27e74cb30efb2e77c47bd50f0273d7991e29315e55d5a2fd4185",
"main/mesh/dm_store.c": "34293efcab7cb0f06be71da168dda35eccd94aab40ca46de7c7e974c79ed62d2",
"main/mesh/dm_store.h": "7ab0f72f8eee45906052118b2fb9b917e0ddc638a045843ae9c82aa11ba606f4",
"main/mesh/ed25519_canonical.h": "52a30b40ea99f25f0d19d991929e167035828a3a2d7479ef6d1dda9ca94a57f5",
- "main/mesh/meshcore_admin_dispatch.c": "1a82f82a99105a4bf803942cd81195782a5afa92c67d0033cbdc5801e386f9d3",
- "main/mesh/meshcore_admin_dispatch.h": "88cb24d23cc24779ad5ee928c468f0984984b33139d429fea9ca1b535b488c0b",
- "main/mesh/meshcore_admin_runtime.c": "8db20debecf4d3d135e6cab24781b7b21fe913da58657ef74a423dc4d58dbbfc",
- "main/mesh/meshcore_admin_runtime.h": "8c151d2556b2b612f8a40d78033942ec66061867890b4119e36a702f4ae45daa",
+ "main/mesh/meshcore_admin_dispatch.c": "b029b46f5d8464d42895b95aa1cd1e2fa05bf8b0c282ee88bec4c5ab448d5b40",
+ "main/mesh/meshcore_admin_dispatch.h": "afccb81e8e17ac446062e197dbd7f857916dbf284ea73fb192a9ab67973c6675",
+ "main/mesh/meshcore_admin_runtime.c": "a5dcb449e27d238f5f8bd08f998390508b8963de68a152856bde645cd3fca248",
+ "main/mesh/meshcore_admin_runtime.h": "cfda1cf8f0873b95735bb1d6a1105f58cf71884d258bfd1e452da4f3faf2933a",
"main/mesh/meshcore_ack_completion.h": "ae6166e86d7eaf50fe5502f01d1ff4f3e54b08c46dd99ab8f94fdbc2d19ff9ca",
"main/mesh/meshcore_command_guard.h": "5e3d36af536f2af32ab40bcdea852a254faad8ac5ccbc5d7ca01bf5589acaf1d",
"main/mesh/meshcore_crypto.c": "a0693d20aafc7a4d1e3e17a3d0bd38533a52ec931b619c629af3c6df6b4cc147",
@@ -101,18 +101,18 @@
"main/mesh/meshcore_path_state.h": "c61a0143a7fb25a2a7385cfb5abbb111b2fccdc8395f989256abcb1914cccf94",
"main/mesh/meshcore_packet_semantics.c": "90f7c4564e7522865c5b3739b5a198dddcccad812fcb332e7c4fc041f307d3af",
"main/mesh/meshcore_packet_semantics.h": "50d120e98513cfa1c40792df3f4e423381be6e1841eedd6cd1b90d1f861c0365",
- "main/mesh/meshcore_route_selection.h": "aeb33be4e29d0595a80225db210c5de343d963a1aedb80f233b0621e2ec7e1fd",
+ "main/mesh/meshcore_route_selection.h": "e4c797a99f23ea8a005651a584333aaafadbffb974024130b4cdf672502636ac",
"main/mesh/meshcore_runtime_guard.h": "5b3630e4a27b15d65a10f7b7524ee646123680b89efb9586f7b02ba33c899708",
- "main/mesh/meshcore_service.c": "16357ccac5cda1b03162a4c9d8a68d09dbaf2b552c1d5e9ab3f50f7b2b000ea0",
- "main/mesh/meshcore_service.h": "daed3c57d97675ead8b0f3b00b84484ad31fc1401a23dda194a9301aa66189ac",
+ "main/mesh/meshcore_service.c": "41b03af1a15fed812f2dfae6ee066e0f4bb18a7897c1c4e4f337fca9b31229c3",
+ "main/mesh/meshcore_service.h": "747ddf801c990da3805e2c4e81385c1d390b2ed553fee3bfc476c35279286ec6",
"main/mesh/meshcore_text_plaintext.c": "09e7c094afa77979aeeb9e2d3b580b16d1e69a9fe422ed4467c47c8e009d7d18",
"main/mesh/meshcore_text_plaintext.h": "2197868b508ea8315511eeae1de0ad2e0adae3466011252a816f6e0da59b9ef0",
"main/mesh/meshcore_trace.h": "61e60d68ef2913b2ba81fb610afb97d7448e9f04db774fe665ecc30f25f2dc2e",
"main/mesh/meshcore_wire.h": "fe42a573ec801a7bef1ce7bf3a4e3243b2382d0a816ed6f93c91b1f4992542e8",
- "main/mesh/node_store.c": "e65ec2ac5ada3f31d4af84f49b483f70023338688c63f5471382b28edeb7bca5",
+ "main/mesh/node_store.c": "782beac7827609175532837c6e1305487e9e39cdf94c77a6346f9116d0c00a62",
"main/mesh/node_store.h": "4182df086de1b0b925a3f84f6d41dd4bf582ea5ecbaa23e46183d119461d70a1",
- "main/mesh/user_text.c": "483bdcce4b6303acd7a0a82ab7607ed7655454f8e9096d46615aaebbb255bc6f",
- "main/mesh/user_text.h": "e75c7b84efe66bd961ae0cf94e28150fedf639d1aa2d9ef57caad817924e7517",
+ "main/mesh/user_text.c": "8b65461864da70e62726dc44c8bf3c1a246890d7f9c868f97b5c151401e8faf3",
+ "main/mesh/user_text.h": "a1ab8d02edd4cdbf07f18b21b3105c09df3c574cc096ae6a2e2cec9866a07a33",
"overlays/meshcore_ed25519_defined/fe.c": "1d5040487ff44538e9a03164e8bbe3a9d3446d0efc9669578f20ba4a8751bdfc",
"overlays/meshcore_ed25519_defined/ge.c": "b17611fd0b5d8bb3bae2dca3926150a6a2f9f579a7357dcf7feb296779d9d653",
"overlays/meshcore_ed25519_defined/license.txt": "f68d76b9c1c2271422e55134ea94cf71f2ac3fc3ed6a7ec6b83a1a0538753214",
@@ -129,7 +129,7 @@
"tests/meshcore_oracle/meshcore_oracle.cpp": "97407abe920de643c9e70394ee5857bbbc95632cfd7a4794040a65cf899ccd07",
"tests/meshcore_oracle/meshcore_oracle.h": "c8b4e43786c921595f0446fa724f9b020153faae17d0f3520b6668de196c44a8",
"tests/meshcore_oracle/meshcore_oracle_vectors.cpp": "9c5fc00f795c0ec4ff3c385d66775f71c4cf54e20eff52fc78a3cc188f67f04d",
- "tests/meshcore_oracle/wp05_semantic_matrix.json": "eee175b4382d9ac5fb6ef7ef8e234afa33b1d8c4456e545e737ffaf5550fd32e",
+ "tests/meshcore_oracle/wp05_semantic_matrix.json": "9cfafc19c07c9e7ee3d437b46f193d3e59973adcc42f467c1e181006a6d5d387",
"tests/meshcore_oracle/stubs/AES.h": "cc99a825b88da80f29ec3dd6afb321b69137ddba95873e36440babd432fd5b29",
"tests/meshcore_oracle/stubs/Arduino.h": "6054c33c73ccb38ca5fa2297a5a544295214d5a6365f5c7ec3b4528a77e9c379",
"tests/meshcore_oracle/stubs/SHA256.h": "158edf53b3e095531c4d4710ab22b6d4bce1dbbf7af918c5d7012918c41c4d24",
diff --git a/tests/meshcore_oracle/wp05_semantic_matrix.json b/tests/meshcore_oracle/wp05_semantic_matrix.json
index bdd2ee40..8e049551 100644
--- a/tests/meshcore_oracle/wp05_semantic_matrix.json
+++ b/tests/meshcore_oracle/wp05_semantic_matrix.json
@@ -493,10 +493,10 @@
"main/hal/rp2040_bridge.h": "ecbde6acc53df6c6d012a7f0b14eeaa196ecf7e1217f1e4f77ac18934f5dd6c6",
"main/mesh/dm_delivery_state.c": "a3ff6903299b70ed68727284de8c2b6e81b2f9db69a80902db75983ee91aee94",
"main/mesh/dm_delivery_state.h": "d27b3666f03d27e74cb30efb2e77c47bd50f0273d7991e29315e55d5a2fd4185",
- "main/mesh/meshcore_admin_dispatch.c": "1a82f82a99105a4bf803942cd81195782a5afa92c67d0033cbdc5801e386f9d3",
- "main/mesh/meshcore_admin_dispatch.h": "88cb24d23cc24779ad5ee928c468f0984984b33139d429fea9ca1b535b488c0b",
- "main/mesh/contact_store.c": "67ac341b918f43bb7c09edbf6adae7e9800517c69f11e4000b75e28e4c854022",
- "main/mesh/contact_store.h": "b2d708ebf0871a4684555dfd134d2026ed21a2bbcd3b8f03936d655d9553d3ed",
+ "main/mesh/meshcore_admin_dispatch.c": "b029b46f5d8464d42895b95aa1cd1e2fa05bf8b0c282ee88bec4c5ab448d5b40",
+ "main/mesh/meshcore_admin_dispatch.h": "afccb81e8e17ac446062e197dbd7f857916dbf284ea73fb192a9ab67973c6675",
+ "main/mesh/contact_store.c": "47e1eeda5fc1d7bfbdfe41c10ef03ef4d556cf35c2ec06f6c133a778e5d65263",
+ "main/mesh/contact_store.h": "49213ca66ff795091f35470c003af85feb1d514d8bb88e6c336dd6599fbe322f",
"main/mesh/contact_uri.c": "a65175e4a07023adc2777a0504a2c83f5d409fdc4bd497cbddd4a4868f51fc8b",
"main/mesh/contact_uri.h": "1a06f9d47c284f1c9990f71357434e3b5446a516331e37447ef1e3d7676662ef",
"main/mesh/meshcore_advert_admission.c": "dc026a50d6283b0442fe7a046a3f606062385ec98c73d664e367629c065c1174",
@@ -510,19 +510,19 @@
"main/mesh/meshcore_path_state.h": "c61a0143a7fb25a2a7385cfb5abbb111b2fccdc8395f989256abcb1914cccf94",
"main/mesh/meshcore_command_guard.h": "5e3d36af536f2af32ab40bcdea852a254faad8ac5ccbc5d7ca01bf5589acaf1d",
"main/mesh/meshcore_radio_profile.h": "79fc333c543d2215dab414067515763113a70bdd62eb43a63038819322854e9a",
- "main/mesh/meshcore_rx_admission.c": "e1ceee2668508b2285bed136f8f852a94c6114d69188e74a58b6f55c6b87b4a5",
- "main/mesh/meshcore_rx_admission.h": "3039e7247853080809dd8e93d0ea7e578e6cd7cb5d474caae0ab6e5c71fae870",
+ "main/mesh/meshcore_rx_admission.c": "ffa2f938e6f94658a55965b24eb8f960860a110c74425ff9f22414a8d4b77bec",
+ "main/mesh/meshcore_rx_admission.h": "273fe1e5844e9fd1979c8c9e79261dc4b5d6dbdc044b6cc9b7e71289917cee67",
"main/mesh/meshcore_runtime_guard.h": "5b3630e4a27b15d65a10f7b7524ee646123680b89efb9586f7b02ba33c899708",
"main/mesh/meshcore_text_plaintext.c": "09e7c094afa77979aeeb9e2d3b580b16d1e69a9fe422ed4467c47c8e009d7d18",
"main/mesh/meshcore_text_plaintext.h": "2197868b508ea8315511eeae1de0ad2e0adae3466011252a816f6e0da59b9ef0",
"main/mesh/meshcore_trace.h": "61e60d68ef2913b2ba81fb610afb97d7448e9f04db774fe665ecc30f25f2dc2e",
"main/mesh/meshcore_wire.c": "c4b9ed121360695fe6e31280f11fccd9ce1bba481e3028117f51a26a795a6451",
"main/mesh/meshcore_wire.h": "fe42a573ec801a7bef1ce7bf3a4e3243b2382d0a816ed6f93c91b1f4992542e8",
- "main/mesh/node_store.c": "e65ec2ac5ada3f31d4af84f49b483f70023338688c63f5471382b28edeb7bca5",
+ "main/mesh/node_store.c": "782beac7827609175532837c6e1305487e9e39cdf94c77a6346f9116d0c00a62",
"main/mesh/node_store.h": "4182df086de1b0b925a3f84f6d41dd4bf582ea5ecbaa23e46183d119461d70a1",
"main/mesh/store_lock.h": "1fae71b73b643c37452a8da8d7c0a3c4bd0e839014e3a490af25206b41d2d7fe",
- "main/mesh/user_text.c": "483bdcce4b6303acd7a0a82ab7607ed7655454f8e9096d46615aaebbb255bc6f",
- "main/mesh/user_text.h": "e75c7b84efe66bd961ae0cf94e28150fedf639d1aa2d9ef57caad817924e7517",
+ "main/mesh/user_text.c": "8b65461864da70e62726dc44c8bf3c1a246890d7f9c868f97b5c151401e8faf3",
+ "main/mesh/user_text.h": "a1ab8d02edd4cdbf07f18b21b3105c09df3c574cc096ae6a2e2cec9866a07a33",
"main/platform/time_display.c": "824031a6e0d180651551cd61f424c0f76512b2c914ba574fa0d4c9883131c7a9",
"main/platform/time_display.h": "f65f6a98dd93a3a9243b55354923206831da45ed4d256f9c3c100c65ea2724d0",
"main/platform/time_service.c": "54ed3f4a6441bfb9dea2c71b55538a381f8f246f3ef557a20af865dbd564297b",
@@ -532,14 +532,14 @@
"main/storage/map_tile_store.h": "6be24bbf0af09711f7fd877b51518fb267c4007ea82f9067bc60e2cceea2d992",
"main/storage/retained_blob_store.h": "e7da30658e4490adec17daede16088b9748b02d9ff34edc204d4faca512c5498",
"main/storage/storage_status.h": "ee8b6ab5afabaef050a26d816ac32c06f1ebf0877836dd057bdf222e2f4fe4fd",
- "tests/native/esp_nvs_stubs.c": "a101adfcb640eeffe32d039a9ad4a8ae775a959eabd45d05d53ce75a2a1375ec",
+ "tests/native/esp_nvs_stubs.c": "32b85b2c09f1f7ae3788fd5765250fbcf594857b03c37be90a225e6fc582970a",
"tests/native/retained_blob_store_nvs_adapter.c": "1a81213df9d83d91d6bf7d5f0c84da59ff4e240e3b08668c0bdc77be1959bb58",
"tests/native/mbedtls_md_stub.c": "db5f82347ce2e70303a71a7b8dc0cae4c7de237e3a353605845763f6e61bda52",
- "tests/native/meshcore_admin_dispatch_test.c": "81056a058163267b3e07b6a8b551c661fe8f76bc2582d7c10fdd89783914fdb3",
+ "tests/native/meshcore_admin_dispatch_test.c": "f05fbef8385597869a9d4f9d87b0e42f389e256c1d5eae3a3566745883e3f23f",
"tests/native/meshcore_ack_completion_test.c": "152d878857e11211086027d7884ccc5728f107c817758c7ff652dadcf1292f5f",
"tests/native/meshcore_lifetime_test.c": "4da88fd0e0f9e127224488c7fe55cec72413c653ed9b7b589d80cfc5998a9129",
"tests/native/meshcore_runtime_guard_test.c": "af92eeaeb969cc1a9bca45fb8d0c5437f590f03a6a28d203c77af74e699cdf41",
- "tests/native/meshcore_rx_admission_test.c": "f541b4a4dc6f768ee3e2f1eeb88362944f4a1172b906d1483770f1056d457b69",
+ "tests/native/meshcore_rx_admission_test.c": "d04de0d592db6507d1bfb22cc602c3fce98d334645ee808b36f25e4a259e4493",
"tests/native/meshcore_packet_hash_test.c": "c8d9bbed666168cccd2e1dab86676a252e3aee5a8cb900077ba7ab972a35fb97",
"tests/native/meshcore_text_plaintext_test.c": "b7f4edbe648c00b5725878b631c705c251c3a81ffe4652cd706d8b996d1e6e7b",
"tests/native/meshcore_trace_test.c": "2f9bdfaa43b975af8c075d6e36c4ea4b2c62e701413547dd0cfea15a5af5187e",
@@ -555,6 +555,6 @@
"tests/native/stubs/nvs.h": "ded0795263a44587b01dfb5b9cd664a1baa40fd460e27dd79ee50c902ccc32e0",
"tests/native/stubs/sys/time.h": "251d586ea591ff51aa9d51e6843deadd6c578b5591658df50eb5121c9e6b39c7",
"tests/native/time_service_checkpoint_integration_test.c": "2de100718160702b5ab54f5d8ca0f3d802e8b89f3d16756185be1b0f82f0ad41",
- "tests/native/store_behavior_test.c": "d08f74537cd256ff1b38628bcaff3ffa8e3e7d34b7b0c6f05cd540c08bb6977f"
+ "tests/native/store_behavior_test.c": "0beea323358204c6240013b59967cc0b502a280506b3cf758839b34a00cb85c4"
}
}
diff --git a/tests/meshcore_packet_semantics/manifest.json b/tests/meshcore_packet_semantics/manifest.json
index c121cd89..52a9ad04 100644
--- a/tests/meshcore_packet_semantics/manifest.json
+++ b/tests/meshcore_packet_semantics/manifest.json
@@ -33,16 +33,16 @@
"main/mesh/meshcore_discovery.h": "f71fa6a78cc59a723cc30513652ce219e54b53d1c061173f54a411e7497927f6",
"main/mesh/meshcore_packet_semantics.c": "90f7c4564e7522865c5b3739b5a198dddcccad812fcb332e7c4fc041f307d3af",
"main/mesh/meshcore_packet_semantics.h": "50d120e98513cfa1c40792df3f4e423381be6e1841eedd6cd1b90d1f861c0365",
- "main/mesh/meshcore_service.c": "16357ccac5cda1b03162a4c9d8a68d09dbaf2b552c1d5e9ab3f50f7b2b000ea0",
+ "main/mesh/meshcore_service.c": "41b03af1a15fed812f2dfae6ee066e0f4bb18a7897c1c4e4f337fca9b31229c3",
"main/mesh/meshcore_trace.h": "61e60d68ef2913b2ba81fb610afb97d7448e9f04db774fe665ecc30f25f2dc2e",
"main/mesh/meshcore_wire.c": "c4b9ed121360695fe6e31280f11fccd9ce1bba481e3028117f51a26a795a6451",
"main/mesh/meshcore_wire.h": "fe42a573ec801a7bef1ce7bf3a4e3243b2382d0a816ed6f93c91b1f4992542e8",
- "scripts/meshcore_conformance_d1l.py": "8ec66fe16d8666fb20e7e9b63e72090da3c56aa616b2785ece8ec9915469fff6",
+ "scripts/meshcore_conformance_d1l.py": "829207de020aa6fc63b2c466923cef69741d426b54b1c5cad11e62a38dbb5a8f",
"scripts/meshcore_semantic_packet_fuzz_d1l.py": "5765cddb1560f8355d26fd4e7dc1679858ae99aa5d3836b237e72fd523f04bd5",
"tests/fuzz/meshcore_packet_semantics_fuzz.c": "49563874ea8b87f150153da28c0e9c86416a0908f744ff616d2921f8a1c4c9a8",
"tests/native/meshcore_discovery_test.c": "d1423adff2013c45998432a5da81ffb731bd446ebf21b56e6f90a42acd76a1ce",
"tests/native/meshcore_packet_semantics_test.c": "cacd6eb1c3bd86a5ea68b3eac08c825631e8cb5a386742853e9a9b3c24e58c55",
- "tests/meshcore_oracle/wp05_semantic_matrix.json": "eee175b4382d9ac5fb6ef7ef8e234afa33b1d8c4456e545e737ffaf5550fd32e",
+ "tests/meshcore_oracle/wp05_semantic_matrix.json": "9cfafc19c07c9e7ee3d437b46f193d3e59973adcc42f467c1e181006a6d5d387",
"tests/test_meshcore_discovery_native.py": "39b67692542319db7835ba43fbf5226b9e2eecd538c436818300203eb3035d6a",
"tests/test_meshcore_packet_semantics.py": "ceb558de5c5734b64919ed0f9fc057aa8ec70f0a5fde7e6e9d96c7e4c2b9e6ff"
},
diff --git a/tests/meshcore_remaining_fuzz/manifest.json b/tests/meshcore_remaining_fuzz/manifest.json
index 93ee8abc..e9deff7f 100644
--- a/tests/meshcore_remaining_fuzz/manifest.json
+++ b/tests/meshcore_remaining_fuzz/manifest.json
@@ -52,11 +52,11 @@
"main/mesh/meshcore_path_dispatch.h": "653e453a9c8a50f079fd0c605ea8aab4ba5c62ce94e12188a8b912a15b27c918",
"main/mesh/meshcore_path_state.c": "a6a5f42bc95f511115f1b9da12e4853fb868646ebc34ffaaeae85eb15cb72a58",
"main/mesh/meshcore_path_state.h": "c61a0143a7fb25a2a7385cfb5abbb111b2fccdc8395f989256abcb1914cccf94",
- "main/mesh/meshcore_route_selection.h": "aeb33be4e29d0595a80225db210c5de343d963a1aedb80f233b0621e2ec7e1fd",
- "main/mesh/meshcore_service.c": "16357ccac5cda1b03162a4c9d8a68d09dbaf2b552c1d5e9ab3f50f7b2b000ea0",
+ "main/mesh/meshcore_route_selection.h": "e4c797a99f23ea8a005651a584333aaafadbffb974024130b4cdf672502636ac",
+ "main/mesh/meshcore_service.c": "41b03af1a15fed812f2dfae6ee066e0f4bb18a7897c1c4e4f337fca9b31229c3",
"main/mesh/meshcore_wire.c": "c4b9ed121360695fe6e31280f11fccd9ce1bba481e3028117f51a26a795a6451",
"main/mesh/meshcore_wire.h": "fe42a573ec801a7bef1ce7bf3a4e3243b2382d0a816ed6f93c91b1f4992542e8",
- "scripts/meshcore_conformance_d1l.py": "8ec66fe16d8666fb20e7e9b63e72090da3c56aa616b2785ece8ec9915469fff6",
+ "scripts/meshcore_conformance_d1l.py": "829207de020aa6fc63b2c466923cef69741d426b54b1c5cad11e62a38dbb5a8f",
"scripts/meshcore_remaining_fuzz_d1l.py": "5d4259df97a43ffd7302e324bbdc9ac9c4221edf5254c075612ae1b022822829",
"tests/fuzz/dm_delivery_transition_fuzz.c": "a50dd4ba3d431a988777e065c13f3204618d6f0017b45ab6c10cbbec7b00be60",
"tests/fuzz/meshcore_decrypt_auth_fuzz.c": "969821b5b88d881ffa599c7267f41aa49d9749a8d034bcb170309e003e7a2ed7",
@@ -64,16 +64,16 @@
"tests/fuzz/settings_envelope_fuzz.c": "ddcb8ce9b14005c1a746bcd215666cd01280115f01d7efce186dcf68c418c5cd",
"tests/meshcore_oracle/stubs/AES.h": "cc99a825b88da80f29ec3dd6afb321b69137ddba95873e36440babd432fd5b29",
"tests/meshcore_oracle/stubs/SHA256.h": "158edf53b3e095531c4d4710ab22b6d4bce1dbbf7af918c5d7012918c41c4d24",
- "tests/meshcore_oracle/wp05_semantic_matrix.json": "eee175b4382d9ac5fb6ef7ef8e234afa33b1d8c4456e545e737ffaf5550fd32e",
+ "tests/meshcore_oracle/wp05_semantic_matrix.json": "9cfafc19c07c9e7ee3d437b46f193d3e59973adcc42f467c1e181006a6d5d387",
"tests/meshcore_remaining_fuzz/crypto_host_adapter.cpp": "1fa19502918fa07f1e30610ec04cf7d394d14caa16e0c5f779ce148a790842f6",
"tests/meshcore_remaining_fuzz/stubs/esp_err.h": "a32529585b01ed39217555a23159f8434fcbf8601947728a66156715c0c6d2e2",
"tests/meshcore_remaining_fuzz/stubs/mbedtls/aes.h": "ea331364a1c181f34a09a38e825d5ddb781261738785b47e814a54e18d3fe53f",
"tests/meshcore_remaining_fuzz/stubs/mbedtls/md.h": "66fc3fd811f1f8b5d36d2afca31dea14ee4b0c7a71f59f2596d2c48625663f4f",
"tests/native/dm_delivery_state_test.c": "8f595cd3fdd26170191b8d7388c0097361a66a004cd29e213dbada75076a0f2c",
"tests/native/meshcore_crypto_test.c": "a397b7a7d28053984993cc146fea05849544af5f315c9644eca2214c16db769e",
- "tests/native/meshcore_route_selection_test.c": "6c318ccab70eed048687ecf6fb728cc2d3a01fbacb358ce0eac7b9dc55c7f988",
+ "tests/native/meshcore_route_selection_test.c": "c68a3db67e1b65c020a070b668a8806b1ca6c1b174384ac12d802637009e2d24",
"tests/native/settings_envelope_test.c": "158353ab42c51142e05bb3b0f180aafdc12167ece20372a4a50cc2ae6924cd7a",
- "tests/test_meshcore_conformance_contract.py": "7a253390d86f4bd8f53dc300b43ca47f4363c9200cf3a59000a8ffbcb9a1a798",
+ "tests/test_meshcore_conformance_contract.py": "66b549629e1ec7a7afa32531ed3b324000cec1a4fce8e9983aee8c20c0c9caed",
"tests/test_meshcore_public_rf_contract.py": "1c0d15eef833337bbbe03c4c5631811ec79ba990954691442ece1d16d03f22cb",
"third_party/sensecap_indicator_esp32/components/LoRaWAN/soft-se/aes.c": "9a6805014fd65d805a58a3ab5b91c0b419f4dd0424f2b57bcd014612abdda097",
"third_party/sensecap_indicator_esp32/components/LoRaWAN/soft-se/aes.h": "19c9f8e3e8bcb35c911dd95e9ea58658261885c22cf3bece4774e06cb16eb6c0"
diff --git a/tests/native/advert_data_test.c b/tests/native/advert_data_test.c
index b239e7e4..f53b2fe3 100644
--- a/tests/native/advert_data_test.c
+++ b/tests/native/advert_data_test.c
@@ -206,6 +206,28 @@ static void test_name_sanitization_and_type_mapping(void)
assert(sanitized.type_code == 'S');
assert(strcmp(sanitized.name, "A_ ~___B") == 0);
+ const uint8_t padded_name[] = {
+ 0x82U, 'Y', 'K', 'F', ' ', '-', ' ', 'H', 'i', 'l', 'l', 't', 'o', 'p',
+ 0U, 0U, 0U, 0U,
+ };
+ const d1l_advert_data_t padded = parse_ok(padded_name, sizeof(padded_name));
+ assert(strcmp(padded.name, "YKF - Hilltop") == 0);
+
+ const uint8_t meaningful_underscore[] = {
+ 0x81U, 'K', 'i', 'n', 'z', 'e', '_', 'S', 'o', 'l', 'a', 'r',
+ };
+ const d1l_advert_data_t underscore = parse_ok(
+ meaningful_underscore, sizeof(meaningful_underscore));
+ assert(strcmp(underscore.name, "Kinze_Solar") == 0);
+
+ const uint8_t fuzz_regression[] = {
+ 0x84U, 'A', 0x1fU, 0x84U, 0xafU, 0xccU, 0xc0U, 0x8aU, 0xe5U,
+ 0x7fU, 0U, 0U, ' ', '~', 0x7fU, '"', '\\', 'B',
+ };
+ const d1l_advert_data_t fuzzed = parse_ok(
+ fuzz_regression, sizeof(fuzz_regression));
+ assert(strcmp(fuzzed.name, "A________") == 0);
+
const uint8_t empty_name[] = {0x83U};
const d1l_advert_data_t empty = parse_ok(empty_name, sizeof(empty_name));
assert(empty.type_code == 'R');
diff --git a/tests/native/esp_nvs_stubs.c b/tests/native/esp_nvs_stubs.c
index 1f1ff5fa..086333f6 100644
--- a/tests/native/esp_nvs_stubs.c
+++ b/tests/native/esp_nvs_stubs.c
@@ -18,7 +18,7 @@
#define MOCK_NVS_ENTRY_COUNT 8U
#define MOCK_NVS_NAMESPACE_LEN 24U
#define MOCK_NVS_KEY_LEN 24U
-#define MOCK_NVS_BLOB_MAX 8192U
+#define MOCK_NVS_BLOB_MAX 32768U
typedef enum {
MOCK_PENDING_NONE = 0,
diff --git a/tests/native/meshcore_admin_dispatch_test.c b/tests/native/meshcore_admin_dispatch_test.c
index 8d0457b4..012f92bd 100644
--- a/tests/native/meshcore_admin_dispatch_test.c
+++ b/tests/native/meshcore_admin_dispatch_test.c
@@ -468,6 +468,9 @@ static int test_server_queries_and_protocol_permissions(void)
CHECK(session.query_result.kind ==
D1L_MESHCORE_ADMIN_QUERY_TELEMETRY);
CHECK(session.query_result.count == 3U);
+ CHECK(session.query_wire_len == sizeof(telemetry) - 4U);
+ CHECK(memcmp(session.query_wire, &telemetry[4],
+ session.query_wire_len) == 0);
CHECK(strstr(session.query_result.text, "voltage 4.20 V") != NULL);
CHECK(strstr(session.query_result.text, "temperature -5.5 C") != NULL);
CHECK(strstr(session.query_result.text, "humidity 50.5 %") != NULL);
@@ -486,6 +489,9 @@ static int test_server_queries_and_protocol_permissions(void)
&session, PEER, access, sizeof(access), 150U) ==
D1L_MESHCORE_ADMIN_RESPONSE_ACCEPTED);
CHECK(session.query_result.count == 1U);
+ CHECK(session.query_wire_len == sizeof(access) - 4U);
+ CHECK(memcmp(session.query_wire, &access[4],
+ session.query_wire_len) == 0);
CHECK(strstr(
session.query_result.text,
"A1B2C3D4E5F6 read-write") != NULL);
@@ -516,6 +522,9 @@ static int test_server_queries_and_protocol_permissions(void)
CHECK(session.query_result.offset == 0U);
CHECK(session.query_result.total == 12U);
CHECK(session.query_result.count == 2U);
+ CHECK(session.query_wire_len == sizeof(neighbours) - 4U);
+ CHECK(memcmp(session.query_wire, &neighbours[4],
+ session.query_wire_len) == 0);
CHECK(memcmp(
session.query_result.neighbours[0].public_key_prefix,
"\x01\x02\x03\x04", 4U) == 0);
@@ -646,7 +655,7 @@ static int test_bounded_cli_session_and_redaction(void)
CHECK(d1l_meshcore_admin_begin_cli_command(
&session, tag, false, true,
D1L_MESHCORE_ADMIN_CLI_REPLY_DEFAULT, 120U, 200U));
- static const uint8_t reply[] = "2 neighbors\nA1B2";
+ static const uint8_t reply[] = "2 neighbors\nHespeler \xf0\x9f\x92\x8e";
CHECK(d1l_meshcore_admin_accept_cli_response(
&session, PEER, 0x55667789U, reply, sizeof(reply) - 1U,
130U) == D1L_MESHCORE_ADMIN_RESPONSE_ACCEPTED);
diff --git a/tests/native/meshcore_admin_runtime_replay_test.c b/tests/native/meshcore_admin_runtime_replay_test.c
index 165657ce..e8fb60bf 100644
--- a/tests/native/meshcore_admin_runtime_replay_test.c
+++ b/tests/native/meshcore_admin_runtime_replay_test.c
@@ -108,6 +108,7 @@ static int test_empty_room_login_negotiates_guest_permissions(void)
d1l_meshcore_admin_replay_cache_t replay = {0};
uint8_t response[D1L_MESHCORE_ADMIN_LOGIN_RESPONSE_BYTES] = {0};
write_le32(response, timestamp + 1U);
+ response[5] = 4U;
response[6] = 2U;
response[7] = D1L_MESHCORE_ADMIN_PERMISSION_GUEST;
response[8] = 1U;
@@ -159,6 +160,7 @@ static void make_login_response(
{
memset(response, 0, D1L_MESHCORE_ADMIN_LOGIN_RESPONSE_BYTES);
write_le32(response, server_timestamp);
+ response[5] = 4U;
response[6] = 1U;
response[7] = D1L_MESHCORE_ADMIN_PERMISSION_ADMIN;
response[8] = uniqueness;
@@ -168,6 +170,31 @@ static void make_login_response(
response[12] = 2U;
}
+static int test_legacy_repeater_login_response(void)
+{
+ d1l_meshcore_admin_binding_t binding = {0};
+ make_binding(&binding);
+ d1l_meshcore_admin_session_t session = {0};
+ d1l_meshcore_admin_replay_cache_t replay = {0};
+ uint8_t response[D1L_MESHCORE_ADMIN_LEGACY_LOGIN_RESPONSE_BYTES] = {0};
+ write_le32(response, 0x11223344U);
+ response[4] = 'O';
+ response[5] = 'K';
+
+ CHECK(d1l_meshcore_admin_begin_login(
+ &session, binding.role, binding.peer_public_key,
+ binding.local_public_key, binding.session_secret,
+ 100U, 100U, 500U));
+ CHECK(d1l_meshcore_admin_accept_login_response(
+ &session, &replay, binding.peer_public_key, response,
+ sizeof(response), 10U) ==
+ D1L_MESHCORE_ADMIN_RESPONSE_ACCEPTED);
+ CHECK(session.state == D1L_MESHCORE_ADMIN_AUTHENTICATED);
+ CHECK(session.permissions == D1L_MESHCORE_ADMIN_PERMISSION_ADMIN);
+ CHECK(session.firmware_level == 2U);
+ return 0;
+}
+
static bool dispatch_login(
const d1l_meshcore_admin_binding_t *binding,
const uint8_t response[D1L_MESHCORE_ADMIN_LOGIN_RESPONSE_BYTES],
@@ -192,6 +219,7 @@ static bool dispatch_login(
int main(void)
{
CHECK(test_empty_room_login_negotiates_guest_permissions() == 0);
+ CHECK(test_legacy_repeater_login_response() == 0);
mock_nvs_reset();
d1l_meshcore_admin_binding_t binding = {0};
make_binding(&binding);
diff --git a/tests/native/meshcore_route_selection_test.c b/tests/native/meshcore_route_selection_test.c
index ecfc4433..6821060d 100644
--- a/tests/native/meshcore_route_selection_test.c
+++ b/tests/native/meshcore_route_selection_test.c
@@ -454,6 +454,27 @@ static void test_authenticated_path_replay_mutates_and_responds_once(void)
int main(void)
{
+ d1l_meshcore_path_state_t admin_path = {0};
+ d1l_meshcore_route_selection_t admin_route = {0};
+ assert(d1l_meshcore_path_state_learn(
+ &admin_path, D1L_MESHCORE_PATH_SOURCE_PATH_RESPONSE, 1000U));
+ assert(d1l_meshcore_route_select_admin_request(
+ true, true, NULL, 0U, &admin_path, 1100U, 1U, &admin_route));
+ assert(admin_route.route == D1L_MESHCORE_ROUTE_DIRECT);
+ assert(d1l_meshcore_route_select_admin_request(
+ true, false, NULL, 0U, &admin_path, 1100U, 1U, &admin_route));
+ assert(admin_route.route == D1L_MESHCORE_ROUTE_FLOOD);
+ assert(d1l_meshcore_path_state_learn(
+ &admin_path, D1L_MESHCORE_PATH_SOURCE_ADVERT, 1000U));
+ assert(d1l_meshcore_route_select_admin_request(
+ true, true, NULL, 0U, &admin_path, 1100U, 1U, &admin_route));
+ assert(admin_route.route == D1L_MESHCORE_ROUTE_FLOOD);
+ assert(d1l_meshcore_path_state_learn(
+ &admin_path, D1L_MESHCORE_PATH_SOURCE_PATH_RESPONSE, 1000U));
+ assert(d1l_meshcore_route_select_admin_request(
+ true, true, NULL, 0U, &admin_path,
+ 1101U + D1L_MESHCORE_DIRECT_PATH_MAX_AGE_MS, 1U, &admin_route));
+ assert(admin_route.route == D1L_MESHCORE_ROUTE_FLOOD);
test_valid_direct_path_selection();
test_missing_stale_and_malformed_fallback();
test_preboot_timestamp_alias_never_becomes_fresh();
diff --git a/tests/native/meshcore_rx_admission_test.c b/tests/native/meshcore_rx_admission_test.c
index 6460ec15..9ff25a56 100644
--- a/tests/native/meshcore_rx_admission_test.c
+++ b/tests/native/meshcore_rx_admission_test.c
@@ -90,6 +90,12 @@ static void test_peer_authority_and_self_are_full_key_bound(void)
int main(void)
{
+ assert(d1l_meshcore_peer_text_authorized(1U, false, true));
+ assert(!d1l_meshcore_peer_text_authorized(0U, false, true));
+ assert(!d1l_meshcore_peer_text_authorized(1U, true, false));
+ assert(d1l_meshcore_peer_text_authorized(0U, true, false));
+ assert(d1l_meshcore_peer_text_authorized(2U, true, false));
+ assert(!d1l_meshcore_peer_text_authorized(3U, true, true));
test_unknown_and_failed_channel_are_closed();
test_every_collision_candidate_is_executable();
test_ambiguous_authentication_is_closed();
diff --git a/tests/native/store_behavior_test.c b/tests/native/store_behavior_test.c
index 453dc6b7..25091766 100644
--- a/tests/native/store_behavior_test.c
+++ b/tests/native/store_behavior_test.c
@@ -86,6 +86,8 @@ static size_t native_store_copy_blob(const char *namespace_name,
#define mock_nvs_seed_blob native_store_seed_blob
#define mock_nvs_copy_blob native_store_copy_blob
+#define LEGACY_CONTACT_STORE_CAPACITY 16U
+
static void mock_node_sd_fail_next_write(esp_err_t error)
{
d1l_test_retained_blob_store_fail_next_sd_write(
@@ -162,7 +164,7 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- contact_entry_v3_t entries[D1L_CONTACT_STORE_CAPACITY];
+ contact_entry_v3_t entries[LEGACY_CONTACT_STORE_CAPACITY];
} contact_blob_v3_t;
typedef struct {
@@ -192,7 +194,7 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- contact_entry_v4_t entries[D1L_CONTACT_STORE_CAPACITY];
+ contact_entry_v4_t entries[LEGACY_CONTACT_STORE_CAPACITY];
} contact_blob_v4_t;
typedef struct {
@@ -226,7 +228,7 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- contact_entry_v5_t entries[D1L_CONTACT_STORE_CAPACITY];
+ contact_entry_v5_t entries[LEGACY_CONTACT_STORE_CAPACITY];
} contact_blob_v5_t;
typedef struct {
@@ -260,9 +262,18 @@ typedef struct {
uint32_t total_written;
uint32_t dropped_oldest;
uint32_t count;
- contact_entry_v6_t entries[D1L_CONTACT_STORE_CAPACITY];
+ contact_entry_v6_t entries[LEGACY_CONTACT_STORE_CAPACITY];
} contact_blob_v6_t;
+typedef struct {
+ uint32_t schema;
+ uint32_t next_seq;
+ uint32_t total_written;
+ uint32_t dropped_oldest;
+ uint32_t count;
+ d1l_contact_entry_t entries[LEGACY_CONTACT_STORE_CAPACITY];
+} contact_blob_v7_legacy_t;
+
typedef struct {
uint32_t schema;
uint32_t next_seq;
@@ -270,7 +281,7 @@ typedef struct {
uint32_t dropped_oldest;
uint32_t count;
d1l_contact_entry_t entries[D1L_CONTACT_STORE_CAPACITY];
-} contact_blob_v7_t;
+} contact_blob_v8_t;
_Static_assert(sizeof(node_entry_v3_t) == 148U,
"native node v3 fixture no longer matches production migration layout");
@@ -298,7 +309,7 @@ static void mock_contact_sd_set_enabled(bool enabled)
}
}
-static void mock_contact_sd_replace_blob(const contact_blob_v7_t *blob)
+static void mock_contact_sd_replace_blob(const contact_blob_v8_t *blob)
{
assert(blob != NULL);
s_contact_sd_generation++;
@@ -309,9 +320,9 @@ static void mock_contact_sd_replace_blob(const contact_blob_v7_t *blob)
s_contact_sd_generation);
}
-static contact_blob_v7_t mock_contact_sd_current_blob(void)
+static contact_blob_v8_t mock_contact_sd_current_blob(void)
{
- contact_blob_v7_t blob = {0};
+ contact_blob_v8_t blob = {0};
assert(d1l_test_retained_blob_store_copy_sd(
D1L_RETAINED_BLOB_STORE_CONTACTS, CONTACT_KEY, &blob,
sizeof(blob)) == sizeof(blob));
@@ -336,7 +347,7 @@ static bool mock_contact_sd_is_empty(void)
D1L_RETAINED_BLOB_STORE_CONTACTS, CONTACT_KEY, NULL, 0U) == 0U;
}
-static size_t contact_blob_index(const contact_blob_v7_t *blob,
+static size_t contact_blob_index(const contact_blob_v8_t *blob,
const char *fingerprint)
{
assert(blob != NULL);
@@ -518,7 +529,7 @@ static void test_node_v3_to_v4_migration(void)
assert(strcmp(node.type, "unknown") == 0);
}
-static void test_contact_v3_to_v7_migration(void)
+static void test_contact_v3_to_v8_migration(void)
{
mock_nvs_reset();
contact_blob_v3_t legacy = {0};
@@ -560,17 +571,17 @@ static void test_contact_v3_to_v7_migration(void)
ESP_ERR_INVALID_STATE);
assert(uri[0] == '\0');
- contact_blob_v7_t current = {0};
+ contact_blob_v8_t current = {0};
assert(mock_nvs_copy_blob(CONTACT_NAMESPACE, CONTACT_KEY, ¤t,
sizeof(current)) == sizeof(current));
- assert(current.schema == 7U);
+ assert(current.schema == 8U);
assert(current.count == 2U);
assert(d1l_contact_store_init() == ESP_OK);
assert(d1l_contact_store_find_by_fingerprint("4444444444444444", &contact));
assert(strcmp(contact.type, "repeater") == 0);
}
-static void test_contact_v4_to_v7_migration_is_truthful(void)
+static void test_contact_v4_to_v8_migration_is_truthful(void)
{
mock_nvs_reset();
contact_blob_v4_t legacy = {0};
@@ -613,14 +624,14 @@ static void test_contact_v4_to_v7_migration_is_truthful(void)
assert(!d1l_contact_store_is_canonical(&contact));
assert(!d1l_contact_store_can_dm(&contact));
- contact_blob_v7_t current = {0};
+ contact_blob_v8_t current = {0};
assert(mock_nvs_copy_blob(CONTACT_NAMESPACE, CONTACT_KEY, ¤t,
sizeof(current)) == sizeof(current));
- assert(current.schema == 7U);
+ assert(current.schema == 8U);
assert(current.count == 2U);
}
-static void test_contact_v5_to_v7_migration_preserves_provenance(void)
+static void test_contact_v5_to_v8_migration_preserves_provenance(void)
{
mock_nvs_reset();
contact_blob_v5_t legacy = {0};
@@ -676,16 +687,16 @@ static void test_contact_v5_to_v7_migration_preserves_provenance(void)
assert(contact.out_path_state.source == D1L_MESHCORE_PATH_SOURCE_MIGRATED);
assert(d1l_contact_store_can_dm(&contact));
- contact_blob_v7_t current = {0};
+ contact_blob_v8_t current = {0};
assert(mock_nvs_copy_blob(CONTACT_NAMESPACE, CONTACT_KEY, ¤t,
sizeof(current)) == sizeof(current));
- assert(current.schema == 7U);
+ assert(current.schema == 8U);
assert(current.next_seq == 18U);
assert(current.total_written == 13U);
assert(current.dropped_oldest == 2U);
}
-static void test_contact_v6_to_v7_migration_creates_preboot_path_record(void)
+static void test_contact_v6_to_v8_migration_creates_preboot_path_record(void)
{
mock_nvs_reset();
contact_blob_v6_t legacy = {0};
@@ -718,10 +729,72 @@ static void test_contact_v6_to_v7_migration_creates_preboot_path_record(void)
assert(contact.out_path_state.learned_at_ms == 150U);
assert(contact.out_path_state.generation == 1U);
- contact_blob_v7_t current = {0};
+ contact_blob_v8_t current = {0};
assert(mock_nvs_copy_blob(CONTACT_NAMESPACE, CONTACT_KEY, ¤t,
sizeof(current)) == sizeof(current));
- assert(current.schema == 7U);
+ assert(current.schema == 8U);
+}
+
+static void test_contact_v7_to_v8_migration_preserves_all_records(void)
+{
+ mock_nvs_reset();
+ contact_blob_v7_legacy_t legacy = {0};
+ legacy.schema = 7U;
+ legacy.next_seq = 19U;
+ legacy.total_written = 41U;
+ legacy.dropped_oldest = 3U;
+ legacy.count = 2U;
+
+ char first_key[D1L_NODE_PUBLIC_KEY_HEX_LEN] = {0};
+ char second_key[D1L_NODE_PUBLIC_KEY_HEX_LEN] = {0};
+ make_public_key(first_key, 0x71U);
+ make_public_key(second_key, 0x72U);
+ d1l_contact_entry_t *first = &legacy.entries[0];
+ d1l_contact_entry_t *second = &legacy.entries[1];
+ first->seq = 17U;
+ first->created_ms = 100U;
+ first->updated_ms = 200U;
+ fingerprint_from_key(first->fingerprint, first_key);
+ copy_field(first->public_key_hex, sizeof(first->public_key_hex), first_key);
+ copy_field(first->alias, sizeof(first->alias), "First retained");
+ copy_field(first->heard_name, sizeof(first->heard_name), "First retained");
+ copy_field(first->type, sizeof(first->type), "repeater");
+ first->favorite = true;
+ first->verification_source = D1L_CONTACT_VERIFICATION_SIGNED_ADVERT;
+ d1l_meshcore_path_state_reset(&first->out_path_state);
+
+ *second = *first;
+ second->seq = 18U;
+ fingerprint_from_key(second->fingerprint, second_key);
+ copy_field(second->public_key_hex, sizeof(second->public_key_hex),
+ second_key);
+ copy_field(second->alias, sizeof(second->alias), "Second retained");
+ copy_field(second->heard_name, sizeof(second->heard_name),
+ "Second retained");
+ second->favorite = false;
+ assert(mock_nvs_seed_blob(CONTACT_NAMESPACE, CONTACT_KEY, &legacy,
+ sizeof(legacy)));
+
+ assert(d1l_contact_store_init() == ESP_OK);
+ const d1l_contact_store_stats_t stats = d1l_contact_store_stats();
+ assert(stats.capacity == D1L_CONTACT_STORE_CAPACITY);
+ assert(stats.count == 2U);
+ assert(stats.next_seq == 19U);
+ assert(stats.total_written == 41U);
+ assert(stats.dropped_oldest == 3U);
+ d1l_contact_entry_t migrated = {0};
+ assert(d1l_contact_store_find_by_public_key(first_key, &migrated));
+ assert(strcmp(migrated.alias, "First retained") == 0);
+ assert(migrated.favorite);
+ assert(d1l_contact_store_find_by_public_key(second_key, &migrated));
+ assert(strcmp(migrated.alias, "Second retained") == 0);
+
+ contact_blob_v8_t current = {0};
+ assert(mock_nvs_copy_blob(CONTACT_NAMESPACE, CONTACT_KEY, ¤t,
+ sizeof(current)) == sizeof(current));
+ assert(current.schema == 8U);
+ assert(current.count == 2U);
+ assert(current.entries[2].public_key_hex[0] == '\0');
}
static void test_canonical_path_lifecycle_is_atomic_and_retained(void)
@@ -749,7 +822,7 @@ static void test_canonical_path_lifecycle_is_atomic_and_retained(void)
1000U + D1L_MESHCORE_DIRECT_PATH_MAX_AGE_MS);
d1l_contact_store_stats_t path_stats = d1l_contact_store_stats();
assert(path_stats.persistence_dirty);
- contact_blob_v7_t persisted_before_flush = {0};
+ contact_blob_v8_t persisted_before_flush = {0};
assert(mock_nvs_copy_blob(
CONTACT_NAMESPACE, CONTACT_KEY, &persisted_before_flush,
sizeof(persisted_before_flush)) == sizeof(persisted_before_flush));
@@ -914,7 +987,7 @@ static void test_forced_path_flush_rejects_prewrite_revision_change(void)
assert(d1l_contact_store_find_by_fingerprint(fingerprint, &in_ram));
assert(memcmp(&in_ram, &s_path_flush_race_expected, sizeof(in_ram)) == 0);
- contact_blob_v7_t stale = {0};
+ contact_blob_v8_t stale = {0};
assert(mock_nvs_copy_blob(
CONTACT_NAMESPACE, CONTACT_KEY, &stale, sizeof(stale)) == sizeof(stale));
assert(stale.entries[0].out_path_state.lifecycle ==
@@ -949,7 +1022,7 @@ static void test_forced_path_flush_rejects_during_write_revision_change(void)
assert(stats.persistence_fail_count == before.persistence_fail_count + 1U);
assert(stats.persistence_last_error == ESP_ERR_INVALID_STATE);
- contact_blob_v7_t stale = {0};
+ contact_blob_v8_t stale = {0};
assert(mock_nvs_copy_blob(
CONTACT_NAMESPACE, CONTACT_KEY, &stale, sizeof(stale)) == sizeof(stale));
assert(stale.entries[0].out_path_state.lifecycle ==
@@ -982,7 +1055,7 @@ static void test_contact_persistence_revision_saturates_fail_closed(void)
const d1l_contact_store_stats_t max_stats = d1l_contact_store_stats();
assert(max_stats.persistence_revision == UINT32_MAX);
assert(strcmp(at_max.alias, "last-valid-mutation") == 0);
- contact_blob_v7_t durable_at_max = {0};
+ contact_blob_v8_t durable_at_max = {0};
assert(mock_nvs_copy_blob(
CONTACT_NAMESPACE, CONTACT_KEY, &durable_at_max,
sizeof(durable_at_max)) == sizeof(durable_at_max));
@@ -999,7 +1072,7 @@ static void test_contact_persistence_revision_saturates_fail_closed(void)
assert(after.total_written == max_stats.total_written);
assert(after.persistence_commit_count == max_stats.persistence_commit_count);
assert(!after.persistence_dirty);
- contact_blob_v7_t durable_after_reject = {0};
+ contact_blob_v8_t durable_after_reject = {0};
assert(mock_nvs_copy_blob(
CONTACT_NAMESPACE, CONTACT_KEY, &durable_after_reject,
sizeof(durable_after_reject)) == sizeof(durable_after_reject));
@@ -1020,7 +1093,7 @@ static void test_contact_next_sequence_exhaustion_preserves_ram_and_nvs(void)
assert(d1l_contact_store_upsert_from_node(
fingerprint, "sequence-boundary", NULL) == ESP_OK);
- contact_blob_v7_t exhausted = {0};
+ contact_blob_v8_t exhausted = {0};
assert(mock_nvs_copy_blob(
CONTACT_NAMESPACE, CONTACT_KEY, &exhausted,
sizeof(exhausted)) == sizeof(exhausted));
@@ -1033,7 +1106,7 @@ static void test_contact_next_sequence_exhaustion_preserves_ram_and_nvs(void)
assert(d1l_contact_store_find_by_fingerprint(fingerprint, &before));
const d1l_contact_store_stats_t stats_before = d1l_contact_store_stats();
assert(stats_before.next_seq == UINT32_MAX);
- contact_blob_v7_t nvs_before = {0};
+ contact_blob_v8_t nvs_before = {0};
assert(mock_nvs_copy_blob(
CONTACT_NAMESPACE, CONTACT_KEY, &nvs_before,
sizeof(nvs_before)) == sizeof(nvs_before));
@@ -1058,7 +1131,7 @@ static void test_contact_next_sequence_exhaustion_preserves_ram_and_nvs(void)
stats_before.persistence_commit_count);
assert(stats_after.persistence_fail_count == stats_before.persistence_fail_count);
assert(!stats_after.persistence_dirty);
- contact_blob_v7_t nvs_after = {0};
+ contact_blob_v8_t nvs_after = {0};
assert(mock_nvs_copy_blob(
CONTACT_NAMESPACE, CONTACT_KEY, &nvs_after,
sizeof(nvs_after)) == sizeof(nvs_after));
@@ -1067,7 +1140,7 @@ static void test_contact_next_sequence_exhaustion_preserves_ram_and_nvs(void)
static void assert_contact_blob_preserved(const void *expected, size_t length)
{
- uint8_t actual[sizeof(contact_blob_v7_t) + 32U] = {0};
+ uint8_t actual[sizeof(contact_blob_v8_t) + 32U] = {0};
assert(expected != NULL);
assert(length <= sizeof(actual));
assert(mock_nvs_copy_blob(CONTACT_NAMESPACE, CONTACT_KEY, actual,
@@ -1078,8 +1151,8 @@ static void assert_contact_blob_preserved(const void *expected, size_t length)
static void test_current_path_record_corruption_is_preserved_fail_closed(void)
{
mock_nvs_reset();
- contact_blob_v7_t corrupt = {0};
- corrupt.schema = 7U;
+ contact_blob_v8_t corrupt = {0};
+ corrupt.schema = 8U;
corrupt.next_seq = 2U;
corrupt.total_written = 1U;
corrupt.count = 1U;
@@ -1101,8 +1174,8 @@ static void test_current_path_record_corruption_is_preserved_fail_closed(void)
static void test_same_size_future_contact_schema_is_preserved_fail_closed(void)
{
mock_nvs_reset();
- contact_blob_v7_t future = {0};
- future.schema = 8U;
+ contact_blob_v8_t future = {0};
+ future.schema = 9U;
future.next_seq = 1U;
assert(mock_nvs_seed_blob(CONTACT_NAMESPACE, CONTACT_KEY, &future,
sizeof(future)));
@@ -1115,9 +1188,9 @@ static void test_same_size_future_contact_schema_is_preserved_fail_closed(void)
static void test_oversized_future_contact_schema_is_preserved_fail_closed(void)
{
mock_nvs_reset();
- uint8_t future[sizeof(contact_blob_v7_t) + 16U];
+ uint8_t future[sizeof(contact_blob_v8_t) + 16U];
memset(future, 0x5a, sizeof(future));
- const uint32_t schema = 8U;
+ const uint32_t schema = 9U;
memcpy(future, &schema, sizeof(schema));
assert(mock_nvs_seed_blob(CONTACT_NAMESPACE, CONTACT_KEY, future,
sizeof(future)));
@@ -1217,6 +1290,14 @@ static void test_stale_advert_and_location_preservation(void)
assert(memcmp(&after, &original, sizeof(after)) == 0);
assert_node_stats_equal(d1l_node_store_stats(), original_stats);
assert(d1l_node_store_marker_generation() == original_generation);
+ d1l_node_query_t query = {
+ .filter = D1L_NODE_FILTER_ALL,
+ .sort = D1L_NODE_SORT_LAST_HEARD,
+ };
+ d1l_node_view_t replay_view = {0};
+ assert(d1l_node_store_query(&query, &replay_view, 1U) == 1U);
+ assert(replay_view.reachable);
+ assert(replay_view.node.last_heard_ms == 2000U);
stale = false;
assert(upsert_node(99U, "Older", false, 0, 0, &stale) == ESP_OK);
@@ -1788,7 +1869,7 @@ static void test_d1l_accepted_contact_failure_hash_not_remembered(void)
char key[D1L_NODE_PUBLIC_KEY_HEX_LEN] = {0};
char fingerprint[D1L_NODE_FINGERPRINT_LEN] = {0};
- make_public_key(key, 0x90U);
+ make_public_key(key, 0xF0U);
fingerprint_from_key(fingerprint, key);
const d1l_meshcore_advert_admission_receipt_t failed =
admit_verified_advert(fingerprint, key, "No Space", 800U, false, 0, 0);
@@ -2436,7 +2517,7 @@ static void test_contact_clean_generation_drift_adopts_sd_without_write(void)
assert(d1l_contact_store_upsert_from_node(
fingerprint, "Before card change", NULL) == ESP_OK);
- contact_blob_v7_t replacement = mock_contact_sd_current_blob();
+ contact_blob_v8_t replacement = mock_contact_sd_current_blob();
const size_t index = contact_blob_index(&replacement, fingerprint);
copy_field(replacement.entries[index].alias,
sizeof(replacement.entries[index].alias), "From new card");
@@ -2468,7 +2549,7 @@ static void test_contact_generation_fence_rejects_mid_read_change(void)
assert(d1l_contact_store_upsert_from_node(
fingerprint, "Before fenced read", NULL) == ESP_OK);
- contact_blob_v7_t replacement = mock_contact_sd_current_blob();
+ contact_blob_v8_t replacement = mock_contact_sd_current_blob();
const size_t index = contact_blob_index(&replacement, fingerprint);
copy_field(replacement.entries[index].alias,
sizeof(replacement.entries[index].alias), "After fenced read");
@@ -2513,7 +2594,7 @@ static void test_contact_ambiguous_write_error_rereads_committed_sd(void)
d1l_contact_entry_t contact = {0};
assert(d1l_contact_store_find_by_fingerprint(fingerprint, &contact));
assert(strcmp(contact.alias, "Before ambiguous write") == 0);
- const contact_blob_v7_t committed = mock_contact_sd_current_blob();
+ const contact_blob_v8_t committed = mock_contact_sd_current_blob();
assert(strcmp(committed.entries[
contact_blob_index(&committed, fingerprint)].alias,
"Committed on card") == 0);
@@ -2542,7 +2623,7 @@ static void test_contact_local_touch_merges_with_current_sd(void)
fingerprint_a, "A original", NULL) == ESP_OK);
assert(d1l_contact_store_upsert_from_node(
fingerprint_b, "B original", NULL) == ESP_OK);
- contact_blob_v7_t replacement = mock_contact_sd_current_blob();
+ contact_blob_v8_t replacement = mock_contact_sd_current_blob();
const size_t index_a = contact_blob_index(&replacement, fingerprint_a);
const size_t index_b = contact_blob_index(&replacement, fingerprint_b);
copy_field(replacement.entries[index_a].alias,
@@ -2565,7 +2646,7 @@ static void test_contact_local_touch_merges_with_current_sd(void)
assert(strcmp(contact.alias, "A local") == 0);
assert(d1l_contact_store_find_by_fingerprint(fingerprint_b, &contact));
assert(strcmp(contact.alias, "B card") == 0);
- const contact_blob_v7_t merged = mock_contact_sd_current_blob();
+ const contact_blob_v8_t merged = mock_contact_sd_current_blob();
assert(strcmp(merged.entries[contact_blob_index(&merged, fingerprint_a)].alias,
"A local") == 0);
assert(strcmp(merged.entries[contact_blob_index(&merged, fingerprint_b)].alias,
@@ -2586,7 +2667,7 @@ static void test_contact_delete_tombstone_does_not_resurrect_from_sd(void)
deleted_fingerprint, "Delete me", NULL) == ESP_OK);
assert(d1l_contact_store_upsert_from_node(
retained_fingerprint, "B original", NULL) == ESP_OK);
- contact_blob_v7_t replacement = mock_contact_sd_current_blob();
+ contact_blob_v8_t replacement = mock_contact_sd_current_blob();
const size_t retained_index =
contact_blob_index(&replacement, retained_fingerprint);
copy_field(replacement.entries[retained_index].alias,
@@ -2604,7 +2685,7 @@ static void test_contact_delete_tombstone_does_not_resurrect_from_sd(void)
assert(d1l_contact_store_find_by_fingerprint(
retained_fingerprint, &retained));
assert(strcmp(retained.alias, "B card") == 0);
- const contact_blob_v7_t merged = mock_contact_sd_current_blob();
+ const contact_blob_v8_t merged = mock_contact_sd_current_blob();
assert(merged.count == 1U);
assert(strcmp(merged.entries[0].fingerprint, retained_fingerprint) == 0);
}
@@ -2616,7 +2697,7 @@ static void test_contact_explicit_clear_does_not_resurrect_replacement_sd(void)
assert(d1l_contact_store_init() == ESP_OK);
assert(d1l_contact_store_upsert_from_node(
fingerprint, "Old card contact", NULL) == ESP_OK);
- contact_blob_v7_t replacement = mock_contact_sd_current_blob();
+ contact_blob_v8_t replacement = mock_contact_sd_current_blob();
const size_t index = contact_blob_index(&replacement, fingerprint);
copy_field(replacement.entries[index].alias,
sizeof(replacement.entries[index].alias),
@@ -2664,10 +2745,11 @@ static void test_contact_clear_then_add_persists(void)
int main(void)
{
test_node_v3_to_v4_migration();
- test_contact_v3_to_v7_migration();
- test_contact_v4_to_v7_migration_is_truthful();
- test_contact_v5_to_v7_migration_preserves_provenance();
- test_contact_v6_to_v7_migration_creates_preboot_path_record();
+ test_contact_v3_to_v8_migration();
+ test_contact_v4_to_v8_migration_is_truthful();
+ test_contact_v5_to_v8_migration_preserves_provenance();
+ test_contact_v6_to_v8_migration_creates_preboot_path_record();
+ test_contact_v7_to_v8_migration_preserves_all_records();
test_canonical_path_lifecycle_is_atomic_and_retained();
test_forced_path_flush_rejects_prewrite_revision_change();
test_forced_path_flush_rejects_during_write_revision_change();
diff --git a/tests/native/ui_connectivity_test.c b/tests/native/ui_connectivity_test.c
index 4719a69b..8a190447 100644
--- a/tests/native/ui_connectivity_test.c
+++ b/tests/native/ui_connectivity_test.c
@@ -91,6 +91,18 @@ static void test_ble_never_implies_unmeasured_transport(void)
assert(view.controls_available);
assert(strcmp(view.toggle_label, "Disable") == 0);
assert(strstr(view.purpose, "available") != NULL);
+
+ input.state = "pairing";
+ input.pairing_passkey = 123456U;
+ d1l_ui_connectivity_ble_view(&input, &view);
+ assert(view.pairing_active);
+ assert(view.pairing_passkey == 123456U);
+ assert(!view.pairing_available);
+ assert(strstr(view.purpose, "Pairing request detected") != NULL);
+ assert(strcmp(view.runtime_note,
+ "Enter this PIN in the MeshCore app.") == 0);
+ assert(strcmp(view.production_note,
+ "Bluetooth mode is active; Wi-Fi is off.") == 0);
}
int main(void)
diff --git a/tests/native/ui_home_view_test.c b/tests/native/ui_home_view_test.c
index 45a9f45e..c4440a3e 100644
--- a/tests/native/ui_home_view_test.c
+++ b/tests/native/ui_home_view_test.c
@@ -16,7 +16,7 @@ static void test_default_view_is_bounded_and_truthful(void)
d1l_ui_home_view(&input, &view);
assert(strcmp(view.messages_status, "All caught up") == 0);
- assert(strcmp(view.network_status, "0 contacts | 0 nearby") == 0);
+ assert(strcmp(view.network_status, "0 saved | 0 nearby") == 0);
assert(strcmp(view.map_status, "Set a location") == 0);
assert(strcmp(view.more_status, "0 packets captured") == 0);
assert(strcmp(view.mesh_value, "Starting") == 0);
@@ -58,7 +58,7 @@ static void test_ready_view_owns_all_rendered_strings(void)
d1l_ui_home_view(&input, &view);
assert(strcmp(view.messages_status, "5 unread") == 0);
- assert(strcmp(view.network_status, "4 contacts | 5 nearby") == 0);
+ assert(strcmp(view.network_status, "4 saved | 5 nearby") == 0);
assert(strcmp(view.map_status, "Ready to open") == 0);
assert(strcmp(view.more_status, "1 packet captured") == 0);
assert(strcmp(view.mesh_value, "Ready") == 0);
diff --git a/tests/native/user_text_test.c b/tests/native/user_text_test.c
index 2856ac0b..f920bd8d 100644
--- a/tests/native/user_text_test.c
+++ b/tests/native/user_text_test.c
@@ -88,6 +88,14 @@ int main(void)
false).result ==
D1L_USER_TEXT_NOT_TERMINATED);
+ const uint8_t display[] = "Hespeler \xf0\x9f\x92\x8e\r\n\tready";
+ assert(d1l_user_text_validate_display_span(display, sizeof(display)-1U).result == D1L_USER_TEXT_OK);
+ assert(d1l_user_text_validate_display_span(truncated, sizeof(truncated)).result == D1L_USER_TEXT_INVALID_UTF8);
+ assert(d1l_user_text_validate_display_span(embedded_nul, sizeof(embedded_nul)).result == D1L_USER_TEXT_CONTROL_CHARACTER);
+ assert(d1l_user_text_validate_display_span(c1_control, sizeof(c1_control)).result == D1L_USER_TEXT_CONTROL_CHARACTER);
+ uint8_t long_display[160];
+ memset(long_display, 'a', sizeof(long_display));
+ assert(d1l_user_text_validate_display_span(long_display, sizeof(long_display)).result == D1L_USER_TEXT_OK);
puts("native user text: ok");
return 0;
}
diff --git a/tests/test_ble_channel_info_native.py b/tests/test_ble_channel_info_native.py
new file mode 100644
index 00000000..8039368f
--- /dev/null
+++ b/tests/test_ble_channel_info_native.py
@@ -0,0 +1,106 @@
+"""Exercise the production channel lookup/encoder with a controlled store."""
+
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_phone_finds_free_channel_slots_without_hiding_storage_errors(tmp_path):
+ protocol = (ROOT / "main/comms/ble_companion_protocol.c").read_text()
+ lookup = "static esp_err_t channel_at_index" + protocol.split(
+ "static esp_err_t channel_at_index", 1
+ )[1].split("static bool channel_index_for_id", 1)[0]
+ encoder = "static void build_channel_info" + protocol.split(
+ "static void build_channel_info", 1
+ )[1].split("static void build_battery_storage", 1)[0]
+ source = tmp_path / "channel_info.c"
+ source.write_text(
+ r'''
+#include
+#include
+#include
+#include "mesh/channel_store.h"
+#define D1L_BLE_PROTOCOL_MAX_CHANNELS 8U
+#define RESP_CODE_CHANNEL_INFO 18U
+#define ERR_CODE_ILLEGAL_ARG 6U
+#define ERR_CODE_BAD_STATE 4U
+#define ERR_CODE_UNSUPPORTED_CMD 1U
+static d1l_channel_info_t s_channels[D1L_CHANNEL_STORE_CAPACITY];
+static d1l_channel_store_stats_t s_channel_stats;
+static uint8_t s_pending_payload[512];
+static size_t s_pending_len;
+static esp_err_t store_result = ESP_OK;
+static uint8_t key_length = 16;
+static void set_error_response(uint8_t error) {
+ s_pending_payload[0] = 1;
+ s_pending_payload[1] = error;
+ s_pending_len = 2;
+}
+static void copy_fixed_text(uint8_t *dest, size_t size, const char *text) {
+ memset(dest, 0, size);
+ memcpy(dest, text, strlen(text) < size ? strlen(text) : size);
+}
+static esp_err_t d1l_app_model_copy_channels(
+ d1l_channel_info_t *channels, size_t cap, size_t *count,
+ uint64_t *active, d1l_channel_store_stats_t *stats) {
+ (void)stats;
+ assert(cap == D1L_CHANNEL_STORE_CAPACITY);
+ if (store_result != ESP_OK) return store_result;
+ *count = 1;
+ *active = 1;
+ channels[0].channel_id = 1;
+ strcpy(channels[0].name, "Public");
+ return ESP_OK;
+}
+esp_err_t d1l_channel_store_copy_protocol_key(
+ uint64_t id, d1l_channel_protocol_key_t *key) {
+ assert(id == 1);
+ memset(key, 0, sizeof(*key));
+ key->secret_len = key_length;
+ memset(key->secret, 0xA5, 16);
+ return ESP_OK;
+}
+'''
+ + lookup
+ + encoder
+ + r'''
+int main(void) {
+ build_channel_info(0);
+ assert(s_pending_len == 50 && s_pending_payload[0] == 18);
+ assert(strcmp((char *)&s_pending_payload[2], "Public") == 0);
+ for (size_t i = 34; i < 50; ++i) assert(s_pending_payload[i] == 0xA5);
+ for (uint8_t index = 1; index < 8; ++index) {
+ build_channel_info(index);
+ assert(s_pending_len == 50 && s_pending_payload[0] == 18);
+ assert(s_pending_payload[1] == index);
+ for (size_t i = 2; i < 50; ++i) assert(s_pending_payload[i] == 0);
+ }
+ build_channel_info(8);
+ assert(s_pending_len == 2 && s_pending_payload[1] == ERR_CODE_ILLEGAL_ARG);
+ store_result = ESP_FAIL;
+ build_channel_info(1);
+ assert(s_pending_len == 2 && s_pending_payload[1] == ERR_CODE_BAD_STATE);
+ store_result = ESP_OK;
+ key_length = 32;
+ s_pending_len = 0;
+ build_channel_info(0);
+ assert(s_pending_len == 2 && s_pending_payload[1] == ERR_CODE_UNSUPPORTED_CMD);
+ puts("channel records: ok");
+}
+'''
+ )
+ compiler = shutil.which("gcc") or shutil.which("clang")
+ assert compiler, "A C compiler is required for BLE channel tests"
+ executable = tmp_path / ("channel_info.exe" if os.name == "nt" else "channel_info")
+ subprocess.run(
+ [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror",
+ "-I", str(ROOT / "main"), "-I", str(ROOT / "tests/native/stubs"),
+ str(source), "-o", str(executable)],
+ check=True, capture_output=True, text=True,
+ )
+ result = subprocess.run([str(executable)], check=True, capture_output=True, text=True)
+ assert result.stdout.strip() == "channel records: ok"
diff --git a/tests/test_ble_companion_contract.py b/tests/test_ble_companion_contract.py
index 8bfcf807..4cce1e8e 100644
--- a/tests/test_ble_companion_contract.py
+++ b/tests/test_ble_companion_contract.py
@@ -19,16 +19,24 @@ def test_ble_gatt_contract_matches_pinned_meshcore_and_stays_bounded():
"6E400003-B5A3-F393-E0A9-E50E24DCCA9E",
):
assert uuid in header
- assert "D1L_BLE_COMPANION_QUEUE_DEPTH 4U" in queue
+ assert 'D1L_BLE_COMPANION_DEVICE_NAME "MeshCore-DeskOS"' in header
+ assert "D1L_BLE_COMPANION_QUEUE_DEPTH 16U" in queue
assert "D1L_COMPANION3_MAX_FRAME_SIZE" in queue
assert "BLE_GATT_CHR_F_WRITE_NO_RSP" in source
+ assert "BLE_GATT_CHR_F_READ" in source
assert "BLE_GATT_CHR_F_NOTIFY" in source
assert ".access_cb = tx_access" in source
- assert "BLE_ATT_ERR_READ_NOT_PERMITTED" in source
+ assert "empty secure" in source
assert "BLE_GAP_EVENT_SUBSCRIBE" in source
assert "BLE_GAP_EVENT_MTU" in source
assert "ble_gatts_notify_custom" in source
assert "payload_len > notification_capacity" in source
+ assert "ble_att_set_preferred_mtu" in source
+ assert "D1L_BLE_COMPANION_PREFERRED_ATT_MTU 517U" in header
+ assert "D1L_BLE_COMPANION_TX_MIN_INTERVAL_US 60000LL" in source
+ assert "d1l_ble_companion_poll();" in read(
+ "main/comms/ble_companion_protocol.c"
+ )
def test_ble_transport_fails_closed_on_security_and_preserves_wire_contract():
@@ -98,7 +106,7 @@ def test_ble_build_configuration_is_nimble_only_and_memory_bounded():
"CONFIG_BT_NIMBLE_GATT_MAX_PROCS=2",
"CONFIG_BT_NIMBLE_MAX_CCCDS=2",
"CONFIG_BT_NIMBLE_WHITELIST_SIZE=2",
- "CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=4096",
+ "CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=6144",
"CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12",
"CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=12",
"CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=8",
@@ -133,3 +141,20 @@ def test_ble_build_configuration_is_nimble_only_and_memory_bounded():
assert "d1l_ble_companion_start()" in manager
assert "d1l_ble_companion_stop()" in manager
assert "d1l_ble_companion_prepare_reboot()" in manager
+
+
+def test_ble_callbacks_keep_frame_scratch_off_the_host_task_stack():
+ source = read("main/comms/ble_companion.c")
+ rx_access = source.split("static int rx_access", 1)[1].split(
+ "static int tx_access", 1
+ )[0]
+ pump_tx = source.split("static void pump_tx(void)\n{", 1)[1].split(
+ "static void update_security", 1
+ )[0]
+
+ for buffer in ("s_rx_payload", "s_rx_frame", "s_tx_frame"):
+ declaration = source.split(f"{buffer}[", 1)[1].split(";", 1)[0]
+ assert "EXT_RAM_BSS_ATTR" in declaration
+ assert "uint8_t payload[" not in rx_access
+ assert "uint8_t frame[" not in rx_access
+ assert "uint8_t frame[" not in pump_tx
diff --git a/tests/test_ble_companion_protocol_contract.py b/tests/test_ble_companion_protocol_contract.py
index 221d292d..7f2c7913 100644
--- a/tests/test_ble_companion_protocol_contract.py
+++ b/tests/test_ble_companion_protocol_contract.py
@@ -33,10 +33,29 @@ def test_official_initial_sync_and_messaging_commands_are_real():
"CMD_GET_CHANNEL",
"CMD_GET_DEVICE_TIME",
"CMD_SET_DEVICE_TIME",
+ "CMD_ADD_UPDATE_CONTACT",
"CMD_SYNC_NEXT_MESSAGE",
+ "CMD_RESET_PATH",
"CMD_SEND_TXT_MSG",
"CMD_SEND_CHANNEL_TXT_MSG",
+ "CMD_SEND_LOGIN",
+ "CMD_SEND_STATUS_REQ",
+ "CMD_HAS_CONNECTION",
+ "CMD_LOGOUT",
+ "CMD_SEND_TELEMETRY_REQ",
+ "CMD_GET_ADVERT_PATH",
+ "CMD_SEND_BINARY_REQ",
"CMD_SET_CHANNEL",
+ "CMD_SET_OTHER_PARAMS",
+ "CMD_SET_TUNING_PARAMS",
+ "CMD_GET_CUSTOM_VARS",
+ "CMD_SET_CUSTOM_VAR",
+ "CMD_GET_TUNING_PARAMS",
+ "CMD_GET_STATS",
+ "CMD_SET_FLOOD_SCOPE_KEY",
+ "CMD_SET_AUTOADD_CONFIG",
+ "CMD_GET_AUTOADD_CONFIG",
+ "CMD_GET_ALLOWED_REPEAT_FREQ",
):
assert f"case {command}:" in protocol
for response in (
@@ -48,7 +67,22 @@ def test_official_initial_sync_and_messaging_commands_are_real():
"RESP_CODE_CHANNEL_INFO",
"RESP_CODE_CONTACT_MSG_RECV_V3",
"RESP_CODE_CHANNEL_MSG_RECV_V3",
+ "RESP_CODE_ADVERT_PATH",
+ "RESP_CODE_CUSTOM_VARS",
+ "RESP_CODE_TUNING_PARAMS",
+ "RESP_CODE_STATS",
+ "RESP_CODE_AUTOADD_CONFIG",
+ "RESP_CODE_ALLOWED_REPEAT_FREQ",
"PUSH_CODE_MSG_WAITING",
+ "PUSH_CODE_ADVERT",
+ "PUSH_CODE_NEW_ADVERT",
+ "PUSH_CODE_PATH_UPDATED",
+ "PUSH_CODE_CONTACT_DELETED",
+ "PUSH_CODE_LOGIN_SUCCESS",
+ "PUSH_CODE_LOGIN_FAIL",
+ "PUSH_CODE_STATUS_RESPONSE",
+ "PUSH_CODE_TELEMETRY_RESPONSE",
+ "PUSH_CODE_BINARY_RESPONSE",
):
assert response in protocol
assert "d1l_app_model_send_dm_text" in protocol
@@ -57,6 +91,388 @@ def test_official_initial_sync_and_messaging_commands_are_real():
assert "d1l_app_model_set_companion_map_location" in protocol
assert "bytes_all_zero(&payload[2], D1L_CHANNEL_NAME_LEN - 1U)" in protocol
assert "d1l_app_model_remove_channel(" in protocol
+ assert "static d1l_channel_store_stats_t s_channel_stats" in protocol
+ assert protocol.count("&s_channel_stats") == 3
+
+
+def test_repeater_login_and_management_use_the_existing_admin_runtime():
+ protocol = read("main/comms/ble_companion_protocol.c")
+ dispatch_h = read("main/mesh/meshcore_admin_dispatch.h")
+ runtime_h = read("main/mesh/meshcore_admin_runtime.h")
+ runtime = read("main/mesh/meshcore_admin_runtime.c")
+
+ login = protocol.split("static void send_login_command", 1)[1].split(
+ "static void begin_admin_status_command", 1
+ )[0]
+ assert "d1l_meshcore_service_admin_logout()" in login
+ assert "d1l_meshcore_service_admin_login(" in login
+ assert "D1L_BLE_ADMIN_REQUEST_LOGIN" in login
+ assert "set_admin_sent_response(true" in login
+
+ for call in (
+ "d1l_meshcore_service_admin_request_status()",
+ "d1l_meshcore_service_admin_request_query(query, offset)",
+ "d1l_meshcore_service_admin_request_cli(command, true)",
+ ):
+ assert call in protocol
+ assert "payload[1] != 0U && payload[1] != 1U" in protocol
+ assert "s_pending_payload[offset++] = 1U" in protocol
+ assert "maybe_queue_admin_response();" in protocol
+ assert "s_admin_snapshot.last_completed_tag == s_admin_request_tag" in protocol
+ assert "static bool join_pending_admin_request" in protocol
+ assert protocol.count("join_pending_admin_request(") == 4
+ status = protocol.split("static void begin_admin_status_command", 1)[1].split(
+ "static void send_status_command", 1
+ )[0]
+ assert status.index("join_pending_admin_request(") < status.index(
+ "d1l_meshcore_service_admin_request_status()"
+ )
+ assert "set_admin_sent_response(true, s_admin_request_tag);" in status
+ query = protocol.split("static void begin_admin_query_command", 1)[1].split(
+ "static void send_telemetry_command", 1
+ )[0]
+ assert "s_admin_snapshot.pending_query == query" in query
+ assert "s_admin_snapshot.pending_query_offset == offset" in query
+ assert "set_admin_sent_response(true, s_admin_request_tag);" in query
+
+ login_push = protocol.split("static void build_admin_login_push", 1)[1].split(
+ "static size_t append_admin_status", 1
+ )[0]
+ assert "const bool expose_guest = s_admin_guest_requested" in login_push
+ assert "D1L_MESHCORE_ADMIN_PERMISSION_GUEST" in login_push
+ assert "s_pending_payload[offset++] = exposed_permissions" in login_push
+ intent_guard = protocol.split(
+ "static bool pending_admin_login_intent_conflicts", 1
+ )[1].split("static void send_login_command", 1)[0]
+ assert "s_admin_snapshot.state != D1L_MESHCORE_ADMIN_LOGIN_PENDING" in intent_guard
+ assert "s_admin_guest_requested != guest_requested" in intent_guard
+ assert "s_admin_guest_fingerprint" in intent_guard
+ assert login.index("pending_admin_login_intent_conflicts") < login.index(
+ "join_pending_admin_request("
+ )
+ joined_retry = login.split("if (join_pending_admin_request(", 1)[1].split(
+ "clear_admin_request();", 1
+ )[0]
+ assert "s_admin_guest_requested =" not in joined_retry
+ assert "s_admin_guest_fingerprint" not in joined_retry
+ assert login.count("s_admin_guest_requested = guest_requested;") == 1
+ assert "set_error_response(ERR_CODE_BAD_STATE);" in login
+ assert "s_admin_guest_fingerprint" in login
+ assert "clear_admin_session_authorization()" in login
+
+ auth_target = protocol.split(
+ "static bool authenticated_admin_target", 1
+ )[1].split("static bool capture_pending_admin_request", 1)[0]
+ assert "admin_session_matches_or_restore(out_contact)" in auth_target
+
+ reset = protocol.split("static void reset_session_state", 1)[1].split(
+ "static uint32_t last_existing_seq", 1
+ )[0]
+ assert "clear_admin_session_authorization();" in reset
+ assert "clear_admin_access_intent();" not in reset
+ assert "Keep the requested access level across a transient BLE reconnect" in reset
+ assert "clear_admin_access_intent();" in login
+
+ connection = protocol.split(
+ "static void has_connection_command", 1
+ )[1].split("static void logout_command", 1)[0]
+ assert "admin_state_active(s_admin_snapshot.state)" in connection
+ assert "admin_session_matches_or_restore(&contact)" in connection
+
+ access_list = protocol.split("case BINARY_REQ_ACCESS_LIST:", 1)[1].split(
+ "case BINARY_REQ_NEIGHBOURS:", 1
+ )[0]
+ assert "admin_guest_session_matches" in access_list
+
+ guest_session = protocol.split(
+ "static bool admin_guest_session_matches", 1
+ )[1].split("static void clear_admin_request", 1)[0]
+ assert "s_admin_guest_requested" in guest_session
+ assert "s_admin_guest_fingerprint" in guest_session
+ assert "D1L_MESHCORE_ADMIN_PERMISSION_GUEST" in guest_session
+
+ cli = protocol.split("static void send_admin_cli_command", 1)[1].split(
+ "static void send_dm_command", 1
+ )[0]
+ assert "admin_guest_session_matches(contact)" in cli
+ for suffix in ("'\\0'", "' '", "'\\t'", "'\\r'", "'\\n'"):
+ assert f"payload[13U + text_len - 1U] == {suffix}" in cli
+ assert cli.index("while (text_len > 0U") < cli.index(
+ "memchr(&payload[13], '\\0', text_len)"
+ )
+ assert 'strcmp(command, "clock sync")' in cli
+ assert '"time %lu"' in cli
+ assert "anti-replay packet sequence may intentionally be ahead" in cli
+ assert "d1l_meshcore_service_admin_request_cli(command, true)" in cli
+
+ assert "D1L_MESHCORE_ADMIN_MAX_QUERY_WIRE_BYTES 251U" in dispatch_h
+ assert "uint16_t query_wire_len;" in dispatch_h
+ assert "uint8_t query_wire[D1L_MESHCORE_ADMIN_MAX_QUERY_WIRE_BYTES];" in dispatch_h
+ assert "uint32_t last_completed_tag;" in runtime_h
+ assert "snapshot.last_completed_tag = s_session.last_completed_tag;" in runtime
+ assert "memcpy(snapshot.query_wire, s_session.query_wire" in runtime
+
+
+def test_repeater_path_reset_uses_the_full_contact_key_and_safe_route_reset():
+ protocol = read("main/comms/ble_companion_protocol.c")
+
+ reset = protocol.split(
+ "static void reset_contact_path_command", 1
+ )[1].split("static void get_contact_command", 1)[0]
+ assert "length != 33U" in reset
+ assert "contact_for_public_key(&payload[1], &contact)" in reset
+ assert "d1l_meshcore_service_reset_contact_route(contact.fingerprint)" in reset
+ assert "case CMD_RESET_PATH:" in protocol
+
+ response = protocol.split(
+ "static size_t build_contact_response", 1
+ )[1].split("static void begin_contact_iteration", 1)[0]
+ assert "D1L_BLE_PROTOCOL_OUT_PATH_UNKNOWN 0xFFU" in protocol
+ assert "const bool path_known" in response
+ assert "path_known ? contact->out_path_len" in response
+ assert "D1L_BLE_PROTOCOL_OUT_PATH_UNKNOWN" in response
+ assert "path_known && path_len > 0U" in response
+
+
+def test_recent_advert_paths_are_available_to_the_phone_without_persistence():
+ protocol = read("main/comms/ble_companion_protocol.c")
+ service = read("main/mesh/meshcore_service.c")
+ service_h = read("main/mesh/meshcore_service.h")
+
+ advert_path = protocol.split(
+ "static void get_advert_path_command", 1
+ )[1].split("static void send_channel_command", 1)[0]
+ assert "length < 34U || payload[1] != 0U" in advert_path
+ assert "contact_for_public_key(&payload[2], &contact)" in advert_path
+ assert "d1l_meshcore_service_advert_path_snapshot" in advert_path
+ assert "RESP_CODE_ADVERT_PATH" in advert_path
+ assert "d1l_meshcore_wire_path_byte_len(path.path_len)" in advert_path
+ assert "if (path_bytes > 0U)" in advert_path
+ assert "case CMD_GET_ADVERT_PATH:" in protocol
+
+ assert "d1l_meshcore_advert_path_snapshot_t" in service_h
+ assert "s_advert_paths[D1L_CONTACT_STORE_CAPACITY] EXT_RAM_BSS_ATTR" in service
+ assert "remember_advert_path(pub_prefix, received_epoch, packet.path" in service
+ remember = service.split("static void remember_advert_path", 1)[1].split(
+ "bool d1l_meshcore_service_advert_path_snapshot", 1
+ )[0]
+ assert "(path_len > 0U && !path)" in remember
+ assert "if (path_bytes > 0U)" in remember
+ assert "clear_advert_paths();" in service
+
+
+def test_phone_contact_updates_use_the_official_wire_shape_and_notify_paths():
+ protocol = read("main/comms/ble_companion_protocol.c")
+
+ update = protocol.split(
+ "static void add_update_contact_command", 1
+ )[1].split("static void reset_contact_path_command", 1)[0]
+ assert "1U + 32U + 3U +" in update
+ assert "D1L_BLE_PROTOCOL_CONTACT_PATH_BYTES + 32U + 4U" in update
+ assert "imported.type_id = payload[33U]" in update
+ assert "const uint8_t flags = payload[34U]" in update
+ assert "const uint8_t path_len = payload[35U]" in update
+ assert "const uint8_t *path = &payload[36U]" in update
+ assert "d1l_contact_store_import_uri(" in update
+ assert "d1l_contact_store_rename(" in update
+ assert "d1l_contact_store_set_flags(" in update
+ assert "d1l_contact_store_reset_path(" in update
+ assert "d1l_contact_store_update_path_from_source(" in update
+ assert "case CMD_ADD_UPDATE_CONTACT:" in protocol
+
+ changes = protocol.split(
+ "static void maybe_queue_contact_change", 1
+ )[1].split("static void build_self_info", 1)[0]
+ assert "PUSH_CODE_PATH_UPDATED" in changes
+ assert "PUSH_CODE_ADVERT" in changes
+ new_contact = changes.split("if (previous_index < 0)", 1)[1].split(
+ "d1l_contact_entry_t *previous", 1
+ )[0]
+ assert "PUSH_CODE_NEW_ADVERT" in new_contact
+ assert "build_contact_response" in new_contact
+
+
+def test_admin_and_phone_commands_preempt_bulk_contact_sync():
+ protocol = read("main/comms/ble_companion_protocol.c")
+ worker = protocol.split("static void protocol_task", 1)[1].split(
+ "esp_err_t d1l_ble_companion_protocol_start", 1
+ )[0]
+
+ admin = worker.index("maybe_queue_admin_response();")
+ receive = worker.index("d1l_ble_companion_take_rx_frame(")
+ contacts = worker.index("prepare_next_contact_response();")
+ assert admin < receive < contacts
+
+
+def test_reconnect_preserves_watermarks_and_rechecks_unsynced_messages():
+ protocol = read("main/comms/ble_companion_protocol.c")
+ reset = protocol.split("static void reset_session_state", 1)[1].split(
+ "static uint32_t last_existing_seq", 1
+ )[0]
+ initialize = protocol.split(
+ "static void initialize_message_sync_watermarks", 1
+ )[1].split("static void maybe_queue_message_waiting", 1)[0]
+ worker = protocol.split("static void protocol_task", 1)[1].split(
+ "esp_err_t d1l_ble_companion_protocol_start", 1
+ )[0]
+
+ assert "s_last_synced_dm_seq" not in reset
+ assert "s_last_synced_message_seq" not in reset
+ assert "s_force_message_notification_check = true" in reset
+ assert "last_existing_seq(dm.next_seq)" in initialize
+ assert "last_existing_seq(messages.next_seq)" in initialize
+ assert worker.index("initialize_message_sync_watermarks();") < worker.index(
+ "reset_session_state();"
+ )
+
+
+def test_phone_message_sync_covers_all_channels_and_keeps_real_timestamps():
+ protocol = read("main/comms/ble_companion_protocol.c")
+ sync = protocol.split(
+ "static bool build_next_channel_message", 1
+ )[1].split("static void build_next_message", 1)[0]
+ waiting = protocol.split(
+ "static void maybe_queue_message_waiting", 1
+ )[1].split("static void protocol_task", 1)[0]
+
+ assert "d1l_message_store_snapshot_retained(" in sync
+ assert "d1l_message_store_copy_recent(" not in sync
+ assert "d1l_message_entry_display_timestamp(entry)" in sync
+ assert '"%s: %s"' in sync
+ assert "d1l_message_store_snapshot_retained(" in waiting
+ assert "s_force_message_notification_check" in waiting
+
+
+def test_phone_advert_uses_the_nonblocking_radio_owner_queue():
+ protocol = read("main/comms/ble_companion_protocol.c")
+ advert = protocol.split(
+ "static void queue_self_advert_command", 1
+ )[1].split("static void dispatch_command", 1)[0]
+
+ assert "d1l_app_model_queue_advert(" in advert
+ assert "payload[1] > 1U" in advert
+ assert "queue_self_advert_command(payload, length);" in protocol
+
+
+def test_current_phone_stats_and_multibyte_paths_follow_official_wire_shape():
+ protocol = read("main/comms/ble_companion_protocol.c")
+
+ assert "case CMD_GET_STATS:" in protocol
+ assert "case STATS_TYPE_CORE:" in protocol
+ assert "case STATS_TYPE_RADIO:" in protocol
+ assert "case STATS_TYPE_PACKETS:" in protocol
+ assert "s_pending_len = 11U" in protocol
+ assert "s_pending_len = 14U" in protocol
+ assert "s_pending_len = 30U" in protocol
+ assert "d1l_meshcore_wire_path_byte_len(path_len)" in protocol
+ assert "dest[offset++] = path_len" in protocol
+
+ path_mode = protocol.split(
+ "static void set_path_hash_command", 1
+ )[1].split("static void dispatch_command", 1)[0]
+ assert "length < 3U" in path_mode
+ assert "payload[1] != 0U" in path_mode
+ assert "payload[2] > 2U" in path_mode
+ assert "payload[2] + 1U" in path_mode
+ assert "last_unsupported_command = s_status.last_command" in protocol
+ assert "s_status.last_response_error_code = 0U" in protocol
+ assert "s_status.last_response_error_code = code" in protocol
+
+ flood_scope = protocol.split(
+ "static void set_flood_scope_command", 1
+ )[1].split("static void dispatch_command", 1)[0]
+ assert "length < 2U" in flood_scope
+ assert "payload[1] > 1U" in flood_scope
+ assert "payload[1] == 0U && length == 2U" in flood_scope
+ assert "payload[1] == 1U && length == 2U" in flood_scope
+ assert "RESP_CODE_DISABLED" in flood_scope
+ assert "case CMD_SET_FLOOD_SCOPE_KEY:" in protocol
+
+ transport = read("main/comms/ble_companion.c")
+ console = read("main/comms/usb_console.c")
+ assert "protocol.last_unsupported_command" in transport
+ assert "protocol_last_unsupported_command" in console
+ assert "protocol.last_response_error_code" in transport
+ assert "protocol_last_response_error_code" in console
+
+
+def test_phone_startup_uses_protocol_owned_storage_and_official_contact_count():
+ protocol = read("main/comms/ble_companion_protocol.c")
+
+ battery = protocol.split("static void build_battery_storage(void)", 1)[1].split(
+ "static uint32_t message_epoch", 1
+ )[0]
+ assert "d1l_storage_status(&s_storage_status);" in battery
+ assert "d1l_app_model_snapshot" not in battery
+ assert "D1L_BLE_PROTOCOL_WIRED_MILLIVOLTS" in battery
+ assert "write_u16_le(&response[1], 0U)" not in battery
+
+ contacts = protocol.split("static void begin_contact_iteration", 1)[1].split(
+ "static void prepare_next_contact_response", 1
+ )[0]
+ assert "write_u32_le(&response[1], s_contact_count);" in contacts
+ assert "filtered_count" not in contacts
+
+ self_info = protocol.split("static void build_self_info(void)", 1)[1].split(
+ "static void build_device_info", 1
+ )[0]
+ assert "D1L_BLE_PROTOCOL_SELF_ADV_TYPE 1U" in protocol
+ assert "D1L_BLE_PROTOCOL_MANUAL_ADD_CONTACTS 0U" in protocol
+ assert (
+ self_info.index("D1L_BLE_PROTOCOL_SELF_ADV_TYPE")
+ < self_info.index("settings.identity_public_key")
+ < self_info.index("D1L_BLE_PROTOCOL_MANUAL_ADD_CONTACTS")
+ < self_info.index("settings.frequency_hz")
+ )
+ assert "Every verified signed advert is retained as a contact" in self_info
+
+
+def test_v10_phone_settings_are_truthful_and_bounded():
+ protocol = read("main/comms/ble_companion_protocol.c")
+
+ assert "D1L_BLE_PROTOCOL_AUTOADD_CONFIG 0x1EU" in protocol
+ assert "D1L_BLE_PROTOCOL_AUTOADD_MAX_HOPS 0U" in protocol
+ assert "static void set_other_params_command" in protocol
+ assert "static void set_tuning_params_command" in protocol
+ assert "static void build_tuning_params" in protocol
+ assert "static void set_autoadd_config_command" in protocol
+ assert "static void build_autoadd_config" in protocol
+ assert "case CMD_SET_DEVICE_PIN:" in protocol
+ assert "case CMD_SET_TUNING_PARAMS:" in protocol
+
+ disabled = protocol.split("case CMD_SET_DEVICE_PIN:", 1)[1].split(
+ "default:", 1
+ )[0]
+ assert "RESP_CODE_DISABLED" in disabled
+
+
+def test_phone_contacts_use_lastmod_and_live_official_pushes():
+ protocol = read("main/comms/ble_companion_protocol.c")
+
+ response = protocol.split(
+ "static size_t build_contact_response", 1
+ )[1].split("static void begin_contact_iteration", 1)[0]
+ assert "contact_lastmod(contact)" in response
+ assert "copy_contact_name" in response
+ assert response.index("contact->signed_advert_timestamp") < response.index(
+ "contact_lastmod(contact)"
+ )
+
+ iterator = protocol.split(
+ "static void prepare_next_contact_response", 1
+ )[1].split("static size_t capture_contact_sync_snapshot", 1)[0]
+ assert "const uint32_t lastmod = contact_lastmod(contact);" in iterator
+ assert "lastmod <= s_contact_since" in iterator
+ assert "s_contact_most_recent = lastmod" in iterator
+
+ live_sync = protocol.split(
+ "static void maybe_queue_contact_change", 1
+ )[1].split("static void build_self_info", 1)[0]
+ assert "PUSH_CODE_CONTACT_DELETED" in live_sync
+ assert "PUSH_CODE_ADVERT" in live_sync
+ assert "PUSH_CODE_PATH_UPDATED" in live_sync
+ assert "previous->seq == current->seq" in live_sync
+ assert "maybe_queue_contact_change();" in protocol
def test_protocol_fails_closed_for_privileged_and_malformed_commands():
@@ -86,7 +502,8 @@ def test_pairing_is_interoperable_bonded_and_locally_manageable():
assert "desc.sec_state.bonded" in transport
assert "ble_store_util_delete_peer" in transport
assert "d1l_ble_companion_begin_pairing" in transport
- assert "Enter PIN %06lu" in ui
+ assert "Enter this PIN in the MeshCore app." in ui
+ assert "s_pairing_passkey = D1L_BLE_COMPANION_STATIC_PASSKEY" in transport
assert "d1l_app_model_ble_begin_pairing" in phase1
assert "d1l_app_model_ble_forget_peer" in phase1
for line in transport.splitlines():
diff --git a/tests/test_ble_dm_confirmation_native.py b/tests/test_ble_dm_confirmation_native.py
new file mode 100644
index 00000000..5b0c458d
--- /dev/null
+++ b/tests/test_ble_dm_confirmation_native.py
@@ -0,0 +1,102 @@
+"""Verify real companion SENT/ACK bytes against the radio delivery session."""
+
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_phone_delivery_confirmation_waits_for_matching_radio_ack(tmp_path):
+ protocol = (ROOT / "main/comms/ble_companion_protocol.c").read_text()
+ functions = "static void set_dm_sent_response" + protocol.split(
+ "static void set_dm_sent_response", 1
+ )[1].split("static void send_dm_command", 1)[0]
+ source = tmp_path / "dm_confirmation.c"
+ source.write_text(r'''
+#include
+#include
+#include
+#include "mesh/dm_store.h"
+#include "mesh/meshcore_dm_retry.h"
+#define RESP_CODE_SENT 6U
+#define PUSH_CODE_SEND_CONFIRMED 0x82U
+static uint64_t s_phone_dm_session;
+static uint32_t s_phone_dm_ack;
+static uint32_t s_phone_dm_started_ms;
+static uint8_t s_pending_payload[512];
+static size_t s_pending_len;
+static d1l_dm_entry_t s_dms[16], stored;
+static int64_t now_us = 1000000;
+static int64_t esp_timer_get_time(void) { return now_us; }
+static void write_u32_le(uint8_t *out, uint32_t v) {
+ for (unsigned i=0; i<4; ++i) out[i] = (uint8_t)(v >> (8*i));
+}
+static uint32_t read_u32(const uint8_t *p) {
+ return p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24;
+}
+static bool set_pending(const uint8_t *p, size_t n) {
+ if (s_pending_len) return false;
+ memcpy(s_pending_payload,p,n); s_pending_len=n; return true;
+}
+bool d1l_dm_store_find_delivery_session(uint64_t id, d1l_dm_entry_t *out) {
+ if (id != stored.delivery_session_id) return false;
+ *out=stored; return true;
+}
+''' + functions + r'''
+int main(void) {
+ stored.delivery_session_id=7;
+ stored.ack_hash=0x11223344;
+ stored.delivery_state=D1L_DM_DELIVERY_AWAITING_ACK;
+ stored.delivered=true;
+ set_dm_sent_response(&stored, true);
+ assert(s_pending_len==10 && s_pending_payload[0]==6 && s_pending_payload[1]==1);
+ assert(read_u32(s_pending_payload+2)==0x11223344);
+ assert(read_u32(s_pending_payload+6)==55000);
+ s_pending_len=0;
+ maybe_queue_dm_confirmation();
+ assert(s_pending_len==0 && s_phone_dm_session==7);
+ stored.ack_hash=0x55667788; /* A retry has its own on-air ACK hash. */
+ stored.acked=true;
+ stored.delivery_state=D1L_DM_DELIVERY_ACKNOWLEDGED;
+ now_us=3500000;
+ s_pending_len=1; /* Keep the confirmation while another reply is staged. */
+ maybe_queue_dm_confirmation();
+ assert(s_phone_dm_session==7);
+ s_pending_len=0;
+ maybe_queue_dm_confirmation();
+ assert(s_pending_len==9 && s_pending_payload[0]==0x82);
+ assert(read_u32(s_pending_payload+1)==0x11223344);
+ assert(read_u32(s_pending_payload+5)==2500);
+ s_pending_len=0;
+ maybe_queue_dm_confirmation();
+ assert(s_pending_len==0);
+ set_dm_sent_response(&stored, false);
+ assert(s_pending_payload[1]==0);
+ s_pending_len=0;
+ stored.acked=false;
+ stored.delivery_state=D1L_DM_DELIVERY_FAILED_RADIO;
+ maybe_queue_dm_confirmation();
+ assert(s_pending_len==0 && s_phone_dm_session==0);
+ set_dm_sent_response(&stored, true);
+ s_pending_len=0;
+ stored.delivery_session_id=8;
+ stored.acked=true;
+ stored.delivery_state=D1L_DM_DELIVERY_ACKNOWLEDGED;
+ maybe_queue_dm_confirmation();
+ assert(s_pending_len==0 && s_phone_dm_session==0);
+ puts("phone delivery confirmation: ok");
+}
+''')
+ compiler = shutil.which("gcc") or shutil.which("clang")
+ assert compiler, "A C compiler is required for BLE delivery tests"
+ executable = tmp_path / ("dm_confirmation.exe" if os.name == "nt" else "dm_confirmation")
+ subprocess.run(
+ [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror",
+ "-I", str(ROOT / "main"), "-I", str(ROOT / "tests/native/stubs"),
+ str(source), str(ROOT / "main/mesh/dm_delivery_state.c"),
+ "-o", str(executable)], check=True, capture_output=True, text=True,
+ )
+ result = subprocess.run([str(executable)], check=True, capture_output=True, text=True)
+ assert result.stdout.strip() == "phone delivery confirmation: ok"
diff --git a/tests/test_connectivity_manager_contract.py b/tests/test_connectivity_manager_contract.py
index f367c3ac..bbd35f68 100644
--- a/tests/test_connectivity_manager_contract.py
+++ b/tests/test_connectivity_manager_contract.py
@@ -38,7 +38,7 @@ def test_connectivity_manager_keeps_companion_radios_default_off_and_persistent(
assert "wifi_consecutive_crash_boots" in header
assert "wifi_crash_loop_detected" in header
assert "wifi_last_active_subsystem" in header
- assert '"wifi_ble_shared_radio"' in source
+ assert '"exclusive_wifi_or_ble"' in source
assert "settings.wifi_enabled = true" in source
assert "settings.ble_companion_enabled = enabled" in source
assert "if (enabled && !build_wifi_enabled())" in source
@@ -74,10 +74,22 @@ def test_connectivity_manager_keeps_companion_radios_default_off_and_persistent(
source.index("esp_err_t d1l_connectivity_set_ble_enabled"):
source.index("esp_err_t d1l_connectivity_ble_begin_pairing")
]
- assert "ble_companion_enabled = false" not in wifi_toggle
- assert "wifi_enabled = false" not in ble_toggle
- assert "d1l_ble_companion_stop" not in wifi_toggle
- assert "stop_wifi_runtime" not in ble_toggle
+ assert "d1l_connectivity_set_ble_enabled(false)" in wifi_toggle
+ assert wifi_toggle.index(
+ "d1l_connectivity_set_ble_enabled(false)"
+ ) < wifi_toggle.index("ensure_wifi_started()")
+ assert "d1l_connectivity_set_wifi_enabled(false)" in ble_toggle
+ assert ble_toggle.index(
+ "d1l_connectivity_set_wifi_enabled(false)"
+ ) < ble_toggle.index("d1l_ble_companion_start()")
+ init = source[
+ source.index("esp_err_t d1l_connectivity_init"):
+ source.index("void d1l_connectivity_status")
+ ]
+ assert "settings.wifi_enabled && settings.ble_companion_enabled" in init
+ assert "settings.wifi_enabled = false" in init
+ assert "D1L_SETTINGS_UPDATE_WIFI_ENABLED" in init
+ assert "mode_normalize_failed" in init
assert "d1l_settings_save_wifi_profile(ssid, password)" in source
assert "d1l_settings_clear_wifi_profile()" in source
assert "ESP_NETIF_DEFAULT_WIFI_STA()" in source
@@ -289,9 +301,7 @@ def test_wifi_driver_teardown_is_bounded_by_network_quiesce():
assert save_failure.index("wifi_shutdown_end(") < save_failure.index(
"return ret;"
)
- assert "stop_wifi_runtime()" not in ble
- assert "wifi_shutdown_begin(" not in ble
- assert "wifi_shutdown_end(" not in ble
+ assert "d1l_connectivity_set_wifi_enabled(false)" in ble
assert prepare_reboot.index("wifi_shutdown_begin(") < prepare_reboot.index(
"esp_wifi_stop()"
) < prepare_reboot.index("wifi_shutdown_end(")
@@ -476,7 +486,7 @@ def test_connectivity_boot_guard_is_scoped_checksummed_and_not_written_from_even
source.index("esp_err_t d1l_connectivity_set_ble_enabled"):
source.index("esp_err_t d1l_connectivity_save_wifi_profile")
]
- assert "stop_wifi_runtime()" not in ble_toggle
+ assert "d1l_connectivity_set_wifi_enabled(false)" in ble_toggle
assert "clear_boot_guard()" not in ble_toggle
assert "mark_boot_guard_active" not in ble_toggle
clear_profile = source[source.index("esp_err_t d1l_connectivity_clear_wifi_profile"):]
diff --git a/tests/test_contact_store_contract.py b/tests/test_contact_store_contract.py
index 5531bea2..1292823e 100644
--- a/tests/test_contact_store_contract.py
+++ b/tests/test_contact_store_contract.py
@@ -37,18 +37,32 @@ def c_function(source: str, signature: str) -> str:
raise AssertionError(f"unterminated function: {signature}")
+def test_signed_advert_refresh_updates_only_automatic_aliases():
+ source = read("main/mesh/contact_store.c")
+ upsert = c_function(
+ source, "esp_err_t d1l_contact_store_upsert_verified_advert("
+ )
+
+ assert "const bool alias_tracks_advert" in upsert
+ assert "result != D1L_CONTACT_VERIFIED_ADVERT_PROMOTED_PLACEHOLDER" in upsert
+ assert "strcmp(entry->alias, entry->heard_name) == 0" in upsert
+ assert "if (alias_tracks_advert)" in upsert
+
+
def test_contact_store_is_bounded_and_sd_first():
header = read("main/mesh/contact_store.h")
source = read("main/mesh/contact_store.c")
cmake = read("main/CMakeLists.txt")
app_main = read("main/app_main.c")
sdkconfig_defaults = read("sdkconfig.defaults")
- assert "D1L_CONTACT_STORE_CAPACITY 16U" in header
+ assert "D1L_CONTACT_STORE_CAPACITY 64U" in header
assert "D1L_CONTACT_ALIAS_LEN 32U" in header
assert "D1L_CONTACT_OUT_PATH_MAX 64U" in header
assert "public_key_hex" in header
assert "out_path_valid" in header
- assert "D1L_CONTACT_STORE_SCHEMA 7U" in source
+ assert "D1L_CONTACT_STORE_SCHEMA 8U" in source
+ assert "D1L_CONTACT_STORE_SCHEMA_V7 7U" in source
+ assert "D1L_CONTACT_STORE_LEGACY_CAPACITY 16U" in source
assert "D1L_CONTACT_STORE_SCHEMA_V6 6U" in source
assert "D1L_CONTACT_STORE_SCHEMA_V5 5U" in source
assert "D1L_CONTACT_STORE_SCHEMA_V4 4U" in source
@@ -58,6 +72,7 @@ def test_contact_store_is_bounded_and_sd_first():
assert "d1l_contact_store_blob_v4_t" in source
assert "d1l_contact_store_blob_v5_t" in source
assert "d1l_contact_store_blob_v6_t" in source
+ assert "d1l_contact_store_blob_v7_t" in source
assert "d1l_contact_store_blob_v2_t" in source
assert "d1l_contact_store_blob_v1_t" in source
assert "migrate_v1_blob" in source
@@ -66,6 +81,7 @@ def test_contact_store_is_bounded_and_sd_first():
assert "migrate_v4_blob" in source
assert "migrate_v5_blob" in source
assert "migrate_v6_blob" in source
+ assert "migrate_v7_blob" in source
assert "migrate_legacy_advert_type" in source
assert "contact schema v1 layout changed" in source
assert "contact schema v2 layout changed" in source
@@ -276,6 +292,15 @@ def test_ui_console_and_smoke_expose_contacts():
assert "d1l_contact_store_rename(fingerprint, alias, out_contact)" in app_source
assert "d1l_contact_store_delete(fingerprint, out_contact)" in app_source
assert "d1l_admin_credential_store_forget(fingerprint)" in app_source
+ delete_contact = app_source.split(
+ "esp_err_t d1l_app_model_delete_contact", 1
+ )[1].split("esp_err_t d1l_app_model_export_contact_uri", 1)[0]
+ assert delete_contact.count(
+ "d1l_contact_store_delete(fingerprint, out_contact)"
+ ) == 2
+ assert "delete_ret == ESP_ERR_INVALID_STATE" in delete_contact
+ assert "delete_ret == ESP_ERR_TIMEOUT" in delete_contact
+ assert "delete_ret == ESP_FAIL" in delete_contact
assert "nodes_render_contact_row" in nodes_ui
contact_row = nodes_ui.split(
"static void nodes_render_contact_row", 1
diff --git a/tests/test_core_package_manifest.py b/tests/test_core_package_manifest.py
index 0c6a0fb4..451fefa9 100644
--- a/tests/test_core_package_manifest.py
+++ b/tests/test_core_package_manifest.py
@@ -264,8 +264,8 @@ def test_core_disabled_package_binds_truth_and_omits_rp2040(
assert runner_text.index("verify_complete_package(root)") < runner_text.index(
"runner(command, cwd=root)"
)
- assert "--validate-only" in update
- assert "meshcore_deskos_d1l.bin" in update
+ assert "--app-update" in update
+ assert "python -m esptool" not in update
assert recovery.index("--validate-only") < recovery.index(
"python -m esptool"
)
@@ -640,7 +640,7 @@ def test_core_conditional_package_is_production_only(
guide = (package / "START_HERE.md").read_text(encoding="ascii")
assert commit in guide
- assert "GitHub Actions run and attempt: see `manifest.json`" in guide
+ assert "build origin: see `manifest.json`" in guide
assert "## 1. Prepare the microSD card" in guide
assert "## 2. Flash the RP2040 SD-bridge side" in guide
assert "## 4. Flash the ESP32 main GUI side" in guide
diff --git a/tests/test_finder_ping_ui_contract.py b/tests/test_finder_ping_ui_contract.py
index a3183852..bc4ff786 100644
--- a/tests/test_finder_ping_ui_contract.py
+++ b/tests/test_finder_ping_ui_contract.py
@@ -43,14 +43,14 @@ def test_nodes_expose_finder_and_confirmed_retained_clear() -> None:
assert "D1L_UI_NODES_ACTION_FIND_NEARBY" in nodes_header
assert "D1L_UI_NODES_ACTION_CLEAR_HEARD" in nodes_header
assert 'nodes_create_button(parent, "Find"' in nodes
- assert 'nodes_create_button(parent, "Clear"' in nodes
+ assert 'nodes_create_button(parent, "Clear nearby"' in nodes
assert '"Find Nearby"' in phase1
assert "Zero-hop RF only." in phase1
assert "Discovery keys are unverified until a signed advert is received." in phase1
assert "d1l_app_model_find_contact_by_public_key(" in phase1
assert "d1l_app_model_discover_nearby()" in phase1
assert "d1l_app_model_clear_nodes(true)" in phase1
- assert "Tap Clear again to erase heard nodes" in phase1
+ assert "Tap Clear nearby again to erase heard nodes" in phase1
assert "if (!confirmed)" in app
assert "d1l_node_store_clear()" in app
assert "d1l_meshcore_service_clear_discovery_results()" in app
diff --git a/tests/test_full_feature_services_contract.py b/tests/test_full_feature_services_contract.py
index 2e2abc88..1819d138 100644
--- a/tests/test_full_feature_services_contract.py
+++ b/tests/test_full_feature_services_contract.py
@@ -147,7 +147,8 @@ def test_server_admin_login_is_available_on_device_and_scrubs_credentials():
assert "lv_textarea_set_password_mode(" in sheets
assert "D1L_MESHCORE_ADMIN_MAX_PASSWORD_BYTES" in sheets
- assert "Password (empty allowed by peer)" in sheets
+ assert "Administrator password" in sheets
+ assert 'controller, sheet, "Guest"' in sheets
assert "d1l_ui_keyboard_configure_input(" in sheets
assert "d1l_ui_keyboard_clear_textarea(controller->admin_keyboard)" in sheets
assert 'lv_textarea_set_text(controller->admin_password_textarea, "")' in \
@@ -194,9 +195,10 @@ def test_repeater_login_opens_a_compact_saved_session_manager():
for label in (
'"Repeater login"',
- '"Save: On"',
- '"Forget saved"',
+ '"Save On"',
+ '"Forget"',
'"Repeater manager"',
+ '"Repeater guest"',
'"\\nStatus"',
'"\\nTelemetry"',
'"\\nNeighbours"',
@@ -215,6 +217,42 @@ def test_repeater_login_opens_a_compact_saved_session_manager():
assert "d1l_admin_credential_store_save(" in phase1
assert "d1l_admin_credential_store_forget(" in phase1
+
+def test_guest_login_is_explicit_read_only_and_preserves_saved_password():
+ header = read("main/ui/ui_service_sheets.h")
+ sheets = read("main/ui/ui_service_sheets.c")
+ phase1 = read("main/ui/ui_phase1.c")
+
+ assert "D1L_UI_SERVICE_ACTION_ADMIN_GUEST_LOGIN" in header
+ assert 'controller, sheet, "Guest"' in sheets
+ assert '"Guest session: read-only server information."' in sheets
+
+ hub = sheets.split("static bool render_admin_hub_compact(", 1)[1].split(
+ "static bool render_admin_metric(", 1
+ )[0]
+ assert "const bool admin_session" in hub
+ assert "if (admin_session)" in hub
+ for admin_only_action in (
+ "D1L_UI_SERVICE_ACTION_ADMIN_SHOW_ACCESS",
+ "D1L_UI_SERVICE_ACTION_ADMIN_SHOW_TOOLS",
+ "D1L_UI_SERVICE_ACTION_ADMIN_SHOW_TERMINAL",
+ ):
+ assert admin_only_action in hub.split("if (admin_session)", 1)[1]
+
+ login = phase1.split(
+ "case D1L_UI_SERVICE_ACTION_ADMIN_GUEST_LOGIN:", 1
+ )[1].split("case D1L_UI_SERVICE_ACTION_ADMIN_REFRESH:", 1)[0]
+ assert "const bool guest_login" in login
+ assert "if (!guest_login && password[0] == '\\0')" in login
+ assert '"Enter an admin password or choose Guest."' in login
+ assert "s_admin_pending_guest = guest_login;" in login
+
+ finalize = phase1.split("static void finalize_admin_login(", 1)[1].split(
+ "static bool admin_state_preserves_selected_page(", 1
+ )[0]
+ assert "if (!s_admin_pending_guest)" in finalize
+ assert '"Guest session opened. Read-only tools only."' in finalize
+
preserve_page = phase1.split(
"static bool admin_state_preserves_selected_page(", 1
)[1].split("static esp_err_t select_admin_target(", 1)[0]
@@ -268,10 +306,10 @@ def test_server_admin_close_preserves_session_and_target_change_logs_out():
header = sheets.split(
"static bool render_admin_compact_header(", 1
)[1].split("static bool render_admin_target(", 1)[0]
- assert (
- "show_back ? D1L_UI_SERVICE_ACTION_ADMIN_SHOW_HUB :\n"
- " D1L_UI_SERVICE_ACTION_CLOSE_ADMIN"
- ) in header
+ assert 'controller, sheet, "Back", 0, 0, 76, 44' in header
+ assert "if (!show_back)" in header
+ assert "D1L_UI_SERVICE_ACTION_CLOSE_ADMIN" in header
+ assert "show_back ? D1L_UI_SERVICE_ACTION_ADMIN_SHOW_HUB" not in header
begin_render = sheets.split(
"static bool begin_render(", 1
)[1].split(
@@ -300,11 +338,32 @@ def test_admin_login_floods_and_neighbours_resolve_saved_contact_names():
)[1].split(
"static esp_err_t meshcore_service_handle_admin_status(", 1
)[0]
- assert login.index("prepare_admin_route(") < login.index(
- "d1l_meshcore_route_select("
- ) < login.index("d1l_meshcore_admin_build_login_packet(")
- assert "false, false, NULL, 0U, 0U, now_ms" in login
- assert "d1l_meshcore_admin_route_valid(&selection)" in login
+ assert login.index("prepare_admin_flood_route(") < login.index(
+ "d1l_meshcore_admin_build_login_packet("
+ )
+ assert "now_ms, &contact," in login
+
+ route = service.split("static esp_err_t prepare_admin_flood_route(", 2)[2].split(
+ "esp_err_t d1l_meshcore_service_admin_login", 1
+ )[0]
+ assert "false, false, NULL, 0U, 0U, now_ms" in route
+ assert "d1l_meshcore_admin_route_valid(&selection)" in route
+ for handler in (
+ "meshcore_service_handle_admin_login",
+ "meshcore_service_handle_admin_request_status",
+ "meshcore_service_handle_admin_query",
+ "meshcore_service_handle_admin_mutation",
+ "meshcore_service_handle_admin_cli",
+ ):
+ body = service.split(f"static esp_err_t {handler}", 1)[1].split(
+ "static esp_err_t", 1
+ )[0]
+ expected_route = (
+ "prepare_admin_flood_route("
+ if handler == "meshcore_service_handle_admin_login"
+ else "prepare_admin_request_route("
+ )
+ assert expected_route in body
assert "d1l_meshcore_admin_neighbour_t" in dispatch_h
parser = dispatch.split("static bool parse_neighbours_query(", 1)[1].split(
diff --git a/tests/test_map_marker_truth.py b/tests/test_map_marker_truth.py
index 97f152e7..a23d1c12 100644
--- a/tests/test_map_marker_truth.py
+++ b/tests/test_map_marker_truth.py
@@ -45,7 +45,7 @@ def test_marker_truth_is_registered_and_wired_to_verified_runtime_inputs():
assert "d1l_map_marker_truth_evaluate(" in ui_map
assert "d1l_map_marker_role_label(truth.role)" in ui_map
assert "d1l_map_marker_format_age(truth.age_sec" in ui_map
- assert '"Location shared by node\\nrecently verified\\naccuracy unknown"' in ui_map
+ assert '"Nodes shown\\ntime verified\\naccuracy unknown"' in ui_map
assert "d1l_ui_map_viewport_update_truth_context(&s_snapshot)" in phase1
assert "d1l_map_viewport_truth_apply(&state, &input, &transition)" in ui_map
assert "backward_reference_correction" in transition
diff --git a/tests/test_map_viewport_ui_contract.py b/tests/test_map_viewport_ui_contract.py
index 23982f8a..f3395521 100644
--- a/tests/test_map_viewport_ui_contract.py
+++ b/tests/test_map_viewport_ui_contract.py
@@ -85,7 +85,7 @@ def test_map_viewport_touch_controls_pan_on_release_and_request_one_new_view():
"bool d1l_ui_map_viewport_refresh",
)
- assert 'viewport, "Options", 8, 8, 96, 48' in render
+ assert 'viewport, center_available ? "Options" : "Set up"' in render
assert (
'viewport, "Center", 8, (int)MAP_VIEWPORT_HEIGHT - 60, 96, 52'
in render
@@ -97,6 +97,7 @@ def test_map_viewport_touch_controls_pan_on_release_and_request_one_new_view():
assert "lv_obj_set_scroll_dir(parent, LV_DIR_NONE)" in render
assert "lv_obj_set_scrollbar_mode(parent, LV_SCROLLBAR_MODE_OFF)" in render
assert 'map_label(viewport, "Drag to pan"' in render
+ assert "const bool center_available = map_center_available(snapshot);" in render
assert "s_viewport_drag_hint_label = drag_hint" in render
assert "s_viewport_progress_bar = lv_bar_create(viewport)" in render
assert "lv_obj_set_size(s_viewport_progress_bar, 280, 8)" in render
diff --git a/tests/test_meshcore_admin_dispatch_contract.py b/tests/test_meshcore_admin_dispatch_contract.py
index 800ea7e0..cb69d7e8 100644
--- a/tests/test_meshcore_admin_dispatch_contract.py
+++ b/tests/test_meshcore_admin_dispatch_contract.py
@@ -73,6 +73,9 @@ def test_admin_login_permissions_and_binary_queries_match_upstream() -> None:
assert "expected_legacy_role" in login_accept
assert "permission_role == D1L_MESHCORE_ADMIN_PERMISSION_GUEST ? 2U" in \
login_accept
+ assert "D1L_MESHCORE_ADMIN_LEGACY_LOGIN_RESPONSE_BYTES 6U" in dispatch_h
+ assert "plaintext[4] == 'O' && plaintext[5] == 'K'" in login_accept
+ assert "plaintext[5] != 0U" not in login_accept
for constant in (
"D1L_MESHCORE_ADMIN_REQUEST_GET_TELEMETRY 0x03U",
diff --git a/tests/test_meshcore_admin_dispatch_native.py b/tests/test_meshcore_admin_dispatch_native.py
index 3e72b981..e0283c29 100644
--- a/tests/test_meshcore_admin_dispatch_native.py
+++ b/tests/test_meshcore_admin_dispatch_native.py
@@ -20,6 +20,7 @@ def test_meshcore_admin_dispatch_native(tmp_path: pathlib.Path) -> None:
str(ROOT / "main"),
str(ROOT / "tests/native/meshcore_admin_dispatch_test.c"),
str(ROOT / "main/mesh/meshcore_admin_dispatch.c"),
+ str(ROOT / "main/mesh/user_text.c"),
"-o",
str(executable),
],
diff --git a/tests/test_meshcore_admin_runtime_replay_native.py b/tests/test_meshcore_admin_runtime_replay_native.py
index b7b68fc3..0bfe71e2 100644
--- a/tests/test_meshcore_admin_runtime_replay_native.py
+++ b/tests/test_meshcore_admin_runtime_replay_native.py
@@ -37,6 +37,7 @@ def test_meshcore_admin_runtime_replay_native(tmp_path: pathlib.Path) -> None:
str(ROOT / "tests/native/esp_nvs_stubs.c"),
str(ROOT / "main/mesh/meshcore_admin_runtime.c"),
str(ROOT / "main/mesh/meshcore_admin_dispatch.c"),
+ str(ROOT / "main/mesh/user_text.c"),
str(ROOT / "main/mesh/meshcore_wire.c"),
"-Wl,--gc-sections",
"-o",
diff --git a/tests/test_meshcore_conformance_contract.py b/tests/test_meshcore_conformance_contract.py
index baf48938..763e21fd 100644
--- a/tests/test_meshcore_conformance_contract.py
+++ b/tests/test_meshcore_conformance_contract.py
@@ -219,7 +219,7 @@ def test_wp05_semantic_matrix_accounts_for_declared_host_surface_fail_closed():
)
assert source_verification["verified"] is True
assert dependency_verification["dependency_count"] == 75
- assert dependency_verification["translation_unit_count"] == 39
+ assert dependency_verification["translation_unit_count"] == 40
assert dependency_verification["dependencies"] == sorted(
conformance.EXPECTED_WP05_SOURCE_PATHS
)
diff --git a/tests/test_meshcore_route_selection_contract.py b/tests/test_meshcore_route_selection_contract.py
index f5a60617..dc91fdde 100644
--- a/tests/test_meshcore_route_selection_contract.py
+++ b/tests/test_meshcore_route_selection_contract.py
@@ -150,7 +150,8 @@ def test_direct_results_mutate_retained_path_only_in_runtime_owner():
)[0]
assert "d1l_meshcore_path_state_t out_path_state" in contact_header
- assert "D1L_CONTACT_STORE_SCHEMA 7U" in contact_source
+ assert "D1L_CONTACT_STORE_SCHEMA 8U" in contact_source
+ assert "D1L_CONTACT_STORE_SCHEMA_V7 7U" in contact_source
assert "D1L_CONTACT_STORE_SCHEMA_V6 6U" in contact_source
assert "migrate_v6_blob" in contact_source
assert "finalize_migrated_path_state" in contact_source
diff --git a/tests/test_node_location_advert_contract.py b/tests/test_node_location_advert_contract.py
index 4a1ffffb..8fe2dedc 100644
--- a/tests/test_node_location_advert_contract.py
+++ b/tests/test_node_location_advert_contract.py
@@ -344,7 +344,8 @@ def test_legacy_node_and_contact_roles_migrate_fail_closed():
assert 'canonical = "unknown"' in migration
assert "Legacy sensor is ambiguous" in migration
- assert "D1L_CONTACT_STORE_SCHEMA 7U" in contacts
+ assert "D1L_CONTACT_STORE_SCHEMA 8U" in contacts
+ assert "D1L_CONTACT_STORE_SCHEMA_V7 7U" in contacts
assert "D1L_CONTACT_STORE_SCHEMA_V5 5U" in contacts
assert "D1L_CONTACT_STORE_SCHEMA_V4 4U" in contacts
assert "D1L_CONTACT_STORE_SCHEMA_V3 3U" in contacts
diff --git a/tests/test_node_store_contract.py b/tests/test_node_store_contract.py
index 67f8606b..91cfd0ff 100644
--- a/tests/test_node_store_contract.py
+++ b/tests/test_node_store_contract.py
@@ -114,6 +114,18 @@ def test_heard_node_query_supports_production_sort_filter_rows():
assert "query_contact_for_node" in source
+def test_replayed_advert_refreshes_only_boot_local_recency():
+ source = read("main/mesh/node_store.c")
+ stale = source.split(
+ "!d1l_meshcore_lifetime_advert_is_strictly_newer", 1
+ )[1].split("size_t index;", 1)[0]
+
+ assert "s_live_last_heard_ms[existing] = now_ms;" in stale
+ assert "s_live_heard_valid[existing] = true;" in stale
+ assert "s_entries[existing].last_heard_ms" not in stale
+ assert "note_persistence_dirty_locked" not in stale
+
+
def test_verified_adverts_upsert_heard_nodes():
source = read("main/mesh/meshcore_service.c")
admission = read("main/mesh/meshcore_advert_admission.c")
diff --git a/tests/test_observer_region_ui_contract.py b/tests/test_observer_region_ui_contract.py
index edd240eb..3720eb30 100644
--- a/tests/test_observer_region_ui_contract.py
+++ b/tests/test_observer_region_ui_contract.py
@@ -24,4 +24,5 @@ def test_observer_region_is_editable_with_a_bounded_keyboard():
assert "LV_EVENT_CANCEL" in sheets
assert "d1l_ui_service_sheets_copy_observer_region(" in phase1
assert "d1l_observer_set_region(region)" in phase1
- assert '"Observer region saved"' in phase1
+ assert '"IATA region (3 letters)"' in sheets
+ assert '"MQTT IATA saved"' in phase1
diff --git a/tests/test_package_release_d1l.py b/tests/test_package_release_d1l.py
index 8a8dac56..323a8762 100644
--- a/tests/test_package_release_d1l.py
+++ b/tests/test_package_release_d1l.py
@@ -1,6 +1,8 @@
import json
import os
+import shutil
import subprocess
+import types
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -8,7 +10,7 @@
from scripts import meshcore_signed_advert_runtime_d1l as signed_runtime
from scripts import package_release_d1l
-from scripts.verify_checksums import verify_sha256_manifest
+from scripts.verify_checksums import verify_checksum_tree, verify_sha256_manifest
from tests.meshcore_conformance_fixture import completed_report
@@ -382,8 +384,9 @@ def write_meshcore_signed_advert_runtime(
return path
+@pytest.mark.parametrize("local_build", [False, True])
def test_full_feature_package_requires_signing_key_at_callable_boundary(
- tmp_path,
+ tmp_path, local_build,
) -> None:
with pytest.raises(ValueError, match="requires an update signing key"):
package_release_d1l.create_release_package(
@@ -393,6 +396,95 @@ def test_full_feature_package_requires_signing_key_at_callable_boundary(
package_name="unsigned-full-feature",
full_size=0x20000,
release_profile=package_release_d1l.FULL_FEATURE_RELEASE_PROFILE,
+ local_build=local_build,
+ )
+
+
+def test_local_full_feature_package_has_no_actions_claim(tmp_path, monkeypatch):
+ build = tmp_path / "build"
+ write_fake_build(build)
+ (build / "ota_data_initial.bin").write_bytes(b"OTA-DATA")
+ flasher_args = json.loads((build / "flasher_args.json").read_text())
+ flasher_args["flash_files"]["0xf000"] = "ota_data_initial.bin"
+ (build / "flasher_args.json").write_text(json.dumps(flasher_args))
+ write_fake_notices(tmp_path)
+ write_fake_config(tmp_path)
+ shutil.copy2(ROOT / "scripts/prepare_deskos_sd.py",
+ tmp_path / "scripts/prepare_deskos_sd.py")
+ sd_manifest = tmp_path / "sdcard/deskos/manifest.json"
+ sd_manifest.parent.mkdir(parents=True)
+ sd_manifest.write_text('{"schema":1}\n')
+ rp2040 = write_fake_rp2040_artifacts(tmp_path)
+ bridge_dir = rp2040 / "rp2040-sd-bridge-firmware"
+ (bridge_dir / "rp2040-sd-bridge-firmware.uf2").rename(
+ bridge_dir / "deskos_sd_bridge.ino.uf2"
+ )
+ package_release_d1l.write_sha256sums(bridge_dir)
+ # A production package needs only the production bridge, not lab firmware.
+ for name in package_release_d1l.RP2040_ARTIFACT_NAMES[1:]:
+ shutil.rmtree(rp2040 / name)
+ commit = "a" * 40
+ install_fake_source_identity(monkeypatch, commit)
+ for name in ("GITHUB_ACTIONS", "GITHUB_SHA", "GITHUB_RUN_ID",
+ "GITHUB_RUN_ATTEMPT", "GITHUB_REPOSITORY", "GITHUB_REF"):
+ monkeypatch.delenv(name, raising=False)
+ # Signing is a separate cryptographic boundary; this test exercises the
+ # package origin, contents, checksums, and provenance after that boundary.
+ monkeypatch.setattr(package_release_d1l, "write_signed_update_bundle",
+ lambda *args: {"signed": True, "signer_key_id": "fixture-signer",
+ "security_sequence": 1783965716})
+ manifest = package_release_d1l.create_release_package(
+ root=tmp_path, build_dir=build, out_dir=tmp_path / "release",
+ package_name="local-full", full_size=0x20000,
+ rp2040_artifact_root=rp2040, update_signing_key=tmp_path / "test-key",
+ local_build=True,
+ )
+ package = tmp_path / "release/local-full"
+ assert manifest["firmware_commit"] == commit
+ assert manifest["actions_run"] is None
+ assert manifest["actions_run_attempt"] is None
+ assert manifest["workflow"]["provider"] == "local"
+ assert manifest["workflow"]["run_url"] is None
+ assert manifest["user_install"]["guide"] == "START_HERE.md"
+ guide = (package / "START_HERE.md").read_text()
+ assert "release profile: `full_feature`" in guide
+ assert "Existing DeskOS: preserving update BIN" in guide
+ assert "No DeskOS: full clean 8 MB BIN" in guide
+ assert (package / "flash_update_bin.sh").is_file()
+ assert (package / "flash_update_bin.ps1").is_file()
+ provenance = json.loads((package / manifest["provenance"]["path"]).read_text())
+ assert provenance["predicate"]["runDetails"]["builder"]["id"].endswith(
+ "#local-builder-v1"
+ )
+ readme = (package / "README_RELEASE.md").read_text()
+ assert "Build origin: local" in readme
+ assert "GitHub Actions run:" not in readme
+ assert verify_checksum_tree(package)
+ runner = types.ModuleType("local_update_runner")
+ runner.__file__ = str(package / "flash_project.py")
+ exec(compile(Path(runner.__file__).read_text(), runner.__file__, "exec"), runner.__dict__)
+ calls = []
+ options = dict(
+ package_root=package, platform_name="nt", app_update=True,
+ port_lister=lambda: [{"device": "COM37", "vid": 0x1A86, "pid": 0x7523,
+ "hwid": "USB VID:PID=1A86:7523"}],
+ )
+ runner.run_flash("COM37", command_runner=lambda command, **kw: calls.append(command) or 0, **options)
+ app = next(entry for entry in manifest["flash_files"] if entry["role"] == "app")
+ assert len(calls) == 2
+ assert calls[0][-2:] == [app["offset"], str(package / app["path"])]
+ assert calls[0][calls[0].index("--after") + 1] == "no-reset"
+ assert calls[1][-2:] == ["0xf000", str(package / "firmware/ota_data_initial.bin")]
+ calls.clear()
+ with pytest.raises(RuntimeError, match="Project flash failed"):
+ runner.run_flash("COM37", command_runner=lambda command, **kw: calls.append(command) or 1, **options)
+ assert len(calls) == 1, "Do not change boot selection after a failed app write"
+ monkeypatch.setenv("GITHUB_RUN_ID", "12345")
+ with pytest.raises(ValueError, match="cannot claim a GitHub Actions"):
+ package_release_d1l.create_release_package(
+ root=tmp_path, build_dir=build, out_dir=tmp_path / "release",
+ package_name="mixed-origin", full_size=0x20000, local_build=True,
+ update_signing_key=tmp_path / "test-key",
)
diff --git a/tests/test_release_metadata_contract.py b/tests/test_release_metadata_contract.py
index a72f8829..ee396e81 100644
--- a/tests/test_release_metadata_contract.py
+++ b/tests/test_release_metadata_contract.py
@@ -15,7 +15,7 @@ def test_public_release_metadata_has_no_bringup_or_stub_markers():
mesh_source = read("main/mesh/meshcore_service.c")
package_script = read("scripts/package_release_d1l.py")
- assert '#define D1L_FIRMWARE_VERSION "1.7.10"' in config
+ assert '#define D1L_FIRMWARE_VERSION "1.7.12"' in config
assert "D1L_PHASE1_BRINGUP" not in cmake
assert "phase1_stub" not in mesh_source
assert "phase2_stub" not in console
diff --git a/tests/test_ui_ble_contract.py b/tests/test_ui_ble_contract.py
index 7a6ab9ec..5e6d2a2d 100644
--- a/tests/test_ui_ble_contract.py
+++ b/tests/test_ui_ble_contract.py
@@ -102,6 +102,9 @@ def test_ble_renderer_preserves_unavailable_truth_and_enables_real_actions():
assert '"Forget unavailable"' in source
assert '"Pair"' in source
assert '"Forget"' in source
+ assert 'lv_font_montserrat_32' in source
+ assert 'controller->rendered.pairing_passkey' in source
+ assert 'controller->rendered.pairing_active' in source
assert source.count("lv_obj_add_state(") >= 2
assert "LV_STATE_DISABLED" in source
assert "D1L_UI_BLE_ACTION_PAIR" in source
@@ -111,3 +114,28 @@ def test_ble_renderer_preserves_unavailable_truth_and_enables_real_actions():
assert '"Bluetooth companion connections are unavailable."' in connectivity
assert '"Bluetooth pairing is unavailable."' in connectivity
assert '"USB stays available for recovery and diagnostics."' in connectivity
+
+
+def test_phone_pairing_wakes_a_large_pin_prompt_and_home_switches_radio_mode():
+ source = read("main/ui/ui_ble.c")
+ phase1 = read("main/ui/ui_phase1.c")
+
+ refresh = body(
+ phase1, "static void refresh_timer_cb", "static void create_top_bar"
+ )
+ home = body(
+ phase1, "static void handle_home_action", "static void render_home_screen"
+ )
+ assert "ble_pairing_active" in refresh
+ assert "s_ble_pairing_announced" in refresh
+ assert "lv_disp_trig_activity(NULL)" in refresh
+ assert "open_ble_sheet_event_cb(NULL)" in refresh
+ assert "!s_snapshot.ble_protocol_ready" in refresh
+ assert "lv_font_montserrat_32" in source
+ assert 'snprintf(pin, sizeof(pin), "%06lu"' in source
+ assert home.index("d1l_app_model_set_wifi_enabled(true)") < home.index(
+ "open_wifi_sheet_event_cb(NULL)"
+ )
+ assert home.index("d1l_app_model_set_ble_enabled(true)") < home.index(
+ "open_ble_sheet_event_cb(NULL)"
+ )
diff --git a/tests/test_ui_channel_conversation_contract.py b/tests/test_ui_channel_conversation_contract.py
index 0497787c..a853cc07 100644
--- a/tests/test_ui_channel_conversation_contract.py
+++ b/tests/test_ui_channel_conversation_contract.py
@@ -131,6 +131,19 @@ def test_selector_exposes_bounded_management_entry_points_without_secrets() -> N
):
assert forbidden not in selector
+
+def test_channel_root_always_exposes_add_and_import_entry_point() -> None:
+ messages = read("main/ui/ui_messages.c")
+ root = between(
+ messages,
+ "static void messages_render_root",
+ "static int messages_render_notice",
+ )
+
+ assert 'parent, "Add", 262, 6, 72, 44' in root
+ assert "D1L_UI_MESSAGES_ACTION_OPEN_CHANNEL_SELECTOR" in root
+ assert root.index('parent, "Add"') < root.index("channel_count == 0U")
+
dispatch = between(
messages,
"static void messages_dispatch_event_cb",
diff --git a/tests/test_ui_connectivity_contract.py b/tests/test_ui_connectivity_contract.py
index 893b3721..d4cea735 100644
--- a/tests/test_ui_connectivity_contract.py
+++ b/tests/test_ui_connectivity_contract.py
@@ -23,8 +23,10 @@ def test_connectivity_truth_is_owned_outside_the_ui_task_monolith():
assert '"Scan to list nearby 2.4 GHz networks"' in source
assert '"Bluetooth companion connections are unavailable."' in source
assert '"USB stays available for recovery and diagnostics."' in source
- assert '"Using the MeshCore connection alongside Wi-Fi."' in source
- assert '"Enter PIN %06lu on the companion device."' in source
+ assert '"Bluetooth mode is active; Wi-Fi is off."' in source
+ assert '"Enter this PIN in the MeshCore app."' in source
+ assert "out_view->pairing_passkey" in source
+ assert "out_view->pairing_active" in source
assert "lv_" not in source
assert "d1l_app_model_" not in source
diff --git a/tests/test_ui_contact_sheets_contract.py b/tests/test_ui_contact_sheets_contract.py
index e21d2e15..d7334931 100644
--- a/tests/test_ui_contact_sheets_contract.py
+++ b/tests/test_ui_contact_sheets_contract.py
@@ -173,9 +173,15 @@ def test_contact_progressive_disclosure_layout_and_truth_are_preserved():
):
assert f'"{label}"' in detail
assert '"Messaging unavailable"' in detail
- assert '"Direct route | %u hop%s"' in detail
- assert '"Broadcast route | no saved direct path"' in detail
- assert '"Identity %.16s"' in detail
+ assert '"Route %u hop%s"' in detail
+ assert '"Route Flood when needed"' in detail
+ assert '"Node ID %.16s"' in detail
+ assert '"Signal Not heard yet"' in detail
+ assert '"Signal %s | %d dBm / %s%d.%d dB"' in detail
+ assert '"Last heard via %.24s"' in detail
+ assert "contact_type_label(entry->type)" in detail
+ for role_label in ("Room", "Repeater", "Sensor", "Chat", "Node"):
+ assert f'"{role_label}"' in source
assert "d1l_ui_dm_identity_reason_code(" not in detail
assert "d1l_ui_dm_identity_reason_text(" in detail
assert "controller->rendered.dm_identity_reason" in detail
diff --git a/tests/test_ui_core_profile_contract.py b/tests/test_ui_core_profile_contract.py
index dd6c0d9d..f09a8ca0 100644
--- a/tests/test_ui_core_profile_contract.py
+++ b/tests/test_ui_core_profile_contract.py
@@ -27,7 +27,7 @@ def test_core_dock_and_home_expose_only_the_five_contract_destinations():
assert "D1L_UI_TAB_PACKETS" not in core_dock
assert "active_dock_items" in phase1
assert 'map_available ? "Map" : "Packets"' in home
- assert 'map_available ? "Tools" : "Settings"' in home
+ assert '"Settings"' in home
def test_core_deep_links_are_denied_before_probe_or_screen_work():
@@ -141,7 +141,7 @@ def test_core_copy_describes_only_reachable_conversations_and_unread_behavior():
assert '"Public and direct conversations"' in home
assert "D1L_RELEASE_FEATURE_MULTI_CHANNEL_MANAGEMENT" in home
- assert '"Public, DMs, and rooms"' in home
+ assert '"Public, channels, and DMs"' in home
assert '"Include in unread count"' in contacts
assert '"Exclude from unread count"' in contacts
assert "Mute notifications" not in contacts
diff --git a/tests/test_ui_core_profile_simulator.py b/tests/test_ui_core_profile_simulator.py
index 6bc9f1b8..cb3801ca 100644
--- a/tests/test_ui_core_profile_simulator.py
+++ b/tests/test_ui_core_profile_simulator.py
@@ -88,7 +88,7 @@ def test_core_profile_exposes_production_root_affordances(tmp_path: Path):
views = views_by_name(report)
home_labels = set(views["home"]["labels"])
- assert {"Map", "Tools", "Wi-Fi", "SD"} <= home_labels
+ assert {"Map", "Settings", "Wi-Fi", "SD"} <= home_labels
assert "BLE" not in home_labels
settings_labels = set(views["settings"]["labels"])
@@ -327,7 +327,7 @@ def test_full_feature_simulator_remains_the_default(tmp_path: Path):
assert report["release_profile"] == "full_feature"
assert "core_surface_report" not in report
- assert {"Map", "Tools", "Wi-Fi", "BLE", "SD"} <= set(
+ assert {"Map", "Settings", "Wi-Fi", "BLE", "SD"} <= set(
views["home"]["labels"]
)
packet_actions = {
diff --git a/tests/test_ui_messages_contract.py b/tests/test_ui_messages_contract.py
index 60ed9084..4ab4bd28 100644
--- a/tests/test_ui_messages_contract.py
+++ b/tests/test_ui_messages_contract.py
@@ -62,7 +62,7 @@ def test_messages_hierarchy_is_simple_bounded_and_navigation_is_rf_silent():
assert "D1L_UI_MESSAGES_ACTION_SHOW_ROOT" in header
assert '"Channels"' in source
assert '"Group conversations"' in source
- assert '"Direct"' in source
+ assert '"DMs"' in source
assert "messages_render_channel_row(" in source
assert "messages_create_scroll_body(parent, 18, 66, 424, 288)" in source
assert "D1L_UI_MESSAGES_ACTION_SELECT_CHANNEL" in source
diff --git a/tests/test_ui_nodes_contract.py b/tests/test_ui_nodes_contract.py
index a7c7984a..6e2fa03d 100644
--- a/tests/test_ui_nodes_contract.py
+++ b/tests/test_ui_nodes_contract.py
@@ -68,7 +68,7 @@ def test_nodes_root_module_matches_mobile_contacts_sections_and_boundaries():
for label in (
'"Contacts"',
'"Find"',
- '"Clear"',
+ '"Clear nearby"',
'"Search contacts"',
'"Sort: Recent"',
'"Saved contacts"',
diff --git a/tests/test_ui_shell_contract.py b/tests/test_ui_shell_contract.py
index ad9295a8..4edc5732 100644
--- a/tests/test_ui_shell_contract.py
+++ b/tests/test_ui_shell_contract.py
@@ -242,10 +242,14 @@ def test_home_screen_is_user_first_companion_dashboard():
assert "set_object_hidden(s_status_label, !layout.header_detail_visible)" in source
assert "set_object_hidden(s_identity_label, !layout.header_detail_visible)" in source
assert "set_object_hidden(s_lock_button, !layout.header_detail_visible)" in source
- assert '"Messages"' in home_module
- assert '"Nodes"' in home_module
+ assert '"Channels"' in home_module
+ assert '"Contacts"' in home_module
assert '"Map"' in home_module
- assert '"Tools"' in home_module
+ assert '"Settings"' in home_module
+ assert (
+ "lv_obj_set_style_border_color(card, lv_color_hex(accent), "
+ "LV_STATE_PRESSED)"
+ ) in home_module
assert '"Mesh"' in home_module
assert '"Wi-Fi"' in home_module
assert '"BLE"' in home_module
@@ -1299,7 +1303,7 @@ def test_map_screen_uses_built_in_source_and_a_bounded_visible_view():
"static void map_render_options_root", 1
)[0]
assert 'map_label(parent, "Map"' not in landing
- assert landing.count('viewport, "Options", 8, 8, 96, 48') == 1
+ assert landing.count('viewport, center_available ? "Options" : "Set up"') == 1
assert "map_view_service_acquire_visible" in landing
assert "s_viewport_lat_e7, s_viewport_lon_e7, s_viewport_zoom" in landing
assert (
diff --git a/tests/test_ui_simulator.py b/tests/test_ui_simulator.py
index a35269e2..9f3f13da 100644
--- a/tests/test_ui_simulator.py
+++ b/tests/test_ui_simulator.py
@@ -331,7 +331,7 @@ def test_nodes_role_summary_uses_exact_render_query_roles(tmp_path):
]
assert len(destructive_targets) == metrics["nodes_destructive_actions"], scenario
if destructive_targets:
- assert destructive_targets[0]["label"] == "Clear", scenario
+ assert destructive_targets[0]["label"] == "Clear nearby", scenario
def test_node_role_counts_reject_noncanonical_case_spacing_and_aliases() -> None:
@@ -481,10 +481,10 @@ def test_ui_simulator_covers_current_touch_surfaces(tmp_path):
assert ui_simulator.DOCKED_VIEWS == expected_docked_views
assert {
- "Messages",
- "Nodes",
+ "Channels",
+ "Contacts",
"Map",
- "Tools",
+ "Settings",
"Mesh",
"Wi-Fi",
"BLE",
@@ -519,7 +519,7 @@ def test_ui_simulator_covers_current_touch_surfaces(tmp_path):
"settings_advanced_expanded",
):
assert any(target["kind"] == "dock_tab" for target in views_by_name[view_name]["touch_targets"])
- assert {"Channels", "Group conversations", "Direct", "#Public"} <= labels_by_view["messages"]
+ assert {"Channels", "Group conversations", "Add", "DMs", "#Public"} <= labels_by_view["messages"]
assert {"Public", "Back", "Read", "...", "Message this channel"} <= labels_by_view["messages_public"]
assert {"Direct messages", "Back"} <= labels_by_view["messages_dm"]
public_metrics = views_by_name["messages_public"]["metrics"]
@@ -531,14 +531,15 @@ def test_ui_simulator_covers_current_touch_surfaces(tmp_path):
assert {
"Contacts",
"Find",
- "Clear",
+ "Clear nearby",
"Search contacts",
"Sort: Recent",
"Saved contacts",
"Nearby",
"Chat",
} <= labels_by_view["nodes"]
- assert {"Map", "Options", "(c) OpenStreetMap contributors"} <= labels_by_view["map"]
+ assert {"Map", "Set up"} <= labels_by_view["map"]
+ assert "(c) OpenStreetMap contributors" not in labels_by_view["map"]
assert {
"Map options",
"Back to Map",
@@ -578,7 +579,13 @@ def test_ui_simulator_covers_current_touch_surfaces(tmp_path):
"Wi-Fi",
} <= labels_by_view["settings"]
assert {"Settings", "Tools", "Packets", "Diagnostics", "Terminal"} <= labels_by_view["settings_tools_expanded"]
- assert {"Settings", "Connections", "Wi-Fi", "Bluetooth", "Observer"} <= labels_by_view["settings_connections_expanded"]
+ assert {
+ "Settings",
+ "Connections",
+ "Wi-Fi",
+ "Bluetooth",
+ "MQTT / Observer",
+ } <= labels_by_view["settings_connections_expanded"]
assert {"Settings", "Storage & maps", "SD Card", "Map options", "Signed update"} <= labels_by_view["settings_storage_maps_expanded"]
assert {"Settings", "Device", "Display", "Notifications", "Identity"} <= labels_by_view["settings_device_expanded"]
assert {"Settings", "Support", "About", "Version 1.0.0"} <= labels_by_view["settings_support_expanded"]
@@ -690,9 +697,10 @@ def test_ui_simulator_covers_current_touch_surfaces(tmp_path):
"YKF Corebot",
"Contact detail",
"Back",
- "Identity 0BF0A701D5AE2DB6",
- "Direct route | 0 hops",
- "Last signal -41 dBm / 30 dB",
+ "Chat | Active",
+ "Node ID 0BF0A701D5AE2DB6",
+ "Route Direct",
+ "Signal Excellent | -41 dBm / 30 dB",
"Contact actions",
"Message",
"Contact options",
@@ -1151,7 +1159,7 @@ def test_ui_simulator_reports_touch_targets_and_flows(tmp_path):
{
"view": "nodes",
"action": "clear_heard",
- "label": "Clear",
+ "label": "Clear nearby",
"destination": None,
},
{
@@ -1638,7 +1646,8 @@ def test_ui_simulator_storage_state_scenarios_fit(tmp_path):
assert map_view["metrics"]["map_tile_download_supported"] is False
assert map_view["metrics"]["map_tile_render_supported"] is False
assert "Set a location" in set(map_view["labels"])
- assert "(c) OpenStreetMap contributors" in set(map_view["labels"])
+ assert "Set up" in set(map_view["labels"])
+ assert "(c) OpenStreetMap contributors" not in set(map_view["labels"])
assert "sd_map_tiles_ready" not in set(map_view["labels"])
@@ -2256,7 +2265,7 @@ def test_ui_simulator_map_markers_are_named_bounded_and_open_node_detail(tmp_pat
assert map_view["metrics"]["map_trust_loss_invalidates_retained_view"] is True
assert map_view["metrics"]["map_backward_time_rechecks_future_pins"] is True
assert map_view["metrics"]["map_pin_truth_legend"] == (
- "Location shared by node\nrecently verified\naccuracy unknown"
+ "Nodes shown\ntime verified\naccuracy unknown"
)
assert map_view["metrics"]["map_saved_center_pin"] == "omitted"
assert len(set(map_view["metrics"]["map_marker_colors"])) == 3
@@ -2308,7 +2317,7 @@ def test_ui_simulator_map_markers_fail_closed_without_verified_age():
assert surface.metrics["map_marker_displayed_count"] == 0
assert surface.metrics["map_marker_age_verified"] is False
assert surface.metrics["map_pin_truth_legend"] == (
- "Location hidden\nsource not verified"
+ "Nodes hidden\ntime not verified"
)
diff --git a/tests/usb_command_parser/manifest.json b/tests/usb_command_parser/manifest.json
index 995e0628..3792ce6d 100644
--- a/tests/usb_command_parser/manifest.json
+++ b/tests/usb_command_parser/manifest.json
@@ -25,7 +25,7 @@
"source_pins": {
".github/workflows/d1l-ci.yml": "1cbcd0b401ee916292cd87a75a5ac9424c39620f3654b9ca0f08412504c63dad",
"main/CMakeLists.txt": "0a3c36975d5c29b8c4f67cee2e2943c56324b3e4dde43490c7c075a4b77cb4a0",
- "main/comms/usb_console.c": "7e1590ae6a1c62b76443f35699052a1f9bd156f7e90106ac6a67c904d5ca6a0f",
+ "main/comms/usb_console.c": "e89f036a48c592f8520cf6d994b27af70b7da026c0c0ac121f2df5d4b0023ea7",
"main/comms/usb_command_parser.c": "a6f15df848e3dbf3b9a68fad0f74796f04f4addedacd1a38a4be30c3b1141578",
"main/comms/usb_command_parser.h": "6aec4cc3e92d0990b43d0844f4255ca05145ec93441b5e0cad71bada21e2d3e3",
"scripts/usb_command_parser_fuzz_d1l.py": "0c138efd789d8e73ad126624f4491bfcd5924ec01c0fa18f1ff2c88197dfe85a",
diff --git a/tools/ui_simulator.py b/tools/ui_simulator.py
index 99b8a8df..45f83d2f 100644
--- a/tools/ui_simulator.py
+++ b/tools/ui_simulator.py
@@ -2394,8 +2394,8 @@ def draw_home_body(s: Surface, snap: Snapshot):
(
(12, 52, 234, 188),
"chat",
- "Messages",
- "Public, DMs, and rooms",
+ "Channels",
+ "Public, channels, and DMs",
messages_status,
AMBER if audible_unread else (MUTED if snap.muted_unread_dm else ACCENT),
"open_messages_root",
@@ -2404,9 +2404,9 @@ def draw_home_body(s: Surface, snap: Snapshot):
(
(246, 52, 468, 188),
"signal",
- "Nodes",
- "Contacts, nearby, and routes",
- f"{len(snap.contacts)} contacts | {len(snap.heard)} nearby",
+ "Contacts",
+ "Saved, nearby, and routes",
+ f"{len(snap.contacts)} saved | {len(snap.heard)} nearby",
GREEN if snap.contacts else MUTED,
"open_nodes",
"nodes",
@@ -2424,8 +2424,8 @@ def draw_home_body(s: Surface, snap: Snapshot):
(
(246, 196, 468, 332),
"settings",
- "Tools",
- "Settings, utilities, and support",
+ "Settings",
+ "Radio, connections, and device",
f"{len(snap.packets)} packet{'s' if len(snap.packets) != 1 else ''} captured",
VIOLET if snap.packets else MUTED,
"open_settings",
@@ -2579,10 +2579,18 @@ def draw_messages_notice(
def render_messages(s: Surface, snap: Snapshot):
s.text("Channels", (18, 10, 220, 40), 24, TEXT, True)
s.text("Group conversations", (18, 40, 300, 60), 12, MUTED)
+ draw_button(
+ s,
+ (262, 6, 334, 50),
+ "Add",
+ ACCENT,
+ action="open_channel_selector",
+ destination="channel_selector_sheet",
+ )
draw_button(
s,
(342, 6, 442, 50),
- "Direct",
+ "DMs",
GREEN,
action="open_messages_dm",
destination="messages_dm",
@@ -2598,14 +2606,6 @@ def render_messages(s: Surface, snap: Snapshot):
13,
MUTED,
)
- draw_button(
- s,
- (26, 134, 434, 182),
- "Add a channel",
- ACCENT,
- action="open_channel_selector",
- destination="channel_selector_sheet",
- )
for channel in channels:
box = (26, y, 434, y + 60)
active = channel.channel_id == snap.active_channel_id
@@ -3374,7 +3374,7 @@ def render_nodes(s: Surface, snap: Snapshot):
)
draw_button(
s,
- (304, 4, 374, 48),
+ (282, 4, 352, 48),
"Find",
GREEN,
action="find_nearby",
@@ -3382,8 +3382,8 @@ def render_nodes(s: Surface, snap: Snapshot):
)
draw_button(
s,
- (382, 4, 452, 48),
- "Clear",
+ (360, 4, 464, 48),
+ "Clear nearby",
RED,
action="clear_heard",
destructive=heard_query_count > 0,
@@ -3528,7 +3528,7 @@ def map_view_status(snap: Snapshot) -> tuple[str, str, tuple[int, int, int]]:
if storage_sd_needs_attention(snap):
return ("Map unavailable", "Check the SD card, then reopen Map.", AMBER)
if not snap.map_location_set:
- return ("Set a location", "Open Options to choose the area shown here.", AMBER)
+ return ("Set a location", "Tap Set up to choose the area shown here.", AMBER)
if not map_center_is_trusted(snap):
return ("Center unavailable", "Map center provenance is unknown; save it again.", AMBER)
if not snap.map_tile_cache_ready:
@@ -3775,8 +3775,8 @@ def render_map(s: Surface, snap: Snapshot):
draw_button(
s,
(8, 8, 104, 56),
- "Options",
- MUTED,
+ "Options" if center_trusted else "Set up",
+ MUTED if center_trusted else GREEN,
action="open_map_options",
destination="map_options",
)
@@ -3831,21 +3831,23 @@ def render_map(s: Surface, snap: Snapshot):
s.text(f"z{snap.map_tile_zoom}", (420, 66, 472, 88), 11, TEXT, True, "center")
draw_button(s, (420, 92, 472, 144), "-", TEXT, action="map_zoom_out")
- pin_truth = (
- "Location shared by node\nrecently verified\naccuracy unknown"
- if snap.map_marker_age_reference_valid
- else "Location hidden\nsource not verified"
- )
- s.round_rect((112, 298, 224, 356), (7, 16, 24), (7, 16, 24), 4)
- s.wrapped_text(
- pin_truth,
- (114, 300, 222, 354),
- 8, GREEN if snap.map_marker_age_reference_valid else AMBER,
- line_height=16, align="center",
- )
+ pin_truth = ""
+ if center_trusted:
+ pin_truth = (
+ "Nodes shown\ntime verified\naccuracy unknown"
+ if snap.map_marker_age_reference_valid
+ else "Nodes hidden\ntime not verified"
+ )
+ s.round_rect((112, 298, 224, 356), (7, 16, 24), (7, 16, 24), 4)
+ s.wrapped_text(
+ pin_truth,
+ (114, 300, 222, 354),
+ 8, GREEN if snap.map_marker_age_reference_valid else AMBER,
+ line_height=16, align="center",
+ )
- s.round_rect((228, DOCK_Y - 30, 472, DOCK_Y - 6), (7, 16, 24), (7, 16, 24), 4)
- s.text(MAP_ATTRIBUTION, (234, DOCK_Y - 28, 466, DOCK_Y - 8), 10, TEXT, True, "right")
+ s.round_rect((228, DOCK_Y - 30, 472, DOCK_Y - 6), (7, 16, 24), (7, 16, 24), 4)
+ s.text(MAP_ATTRIBUTION, (234, DOCK_Y - 28, 466, DOCK_Y - 8), 10, TEXT, True, "right")
s.metrics.update(
{
"map_hierarchy_level": "actual_view",
@@ -3880,6 +3882,7 @@ def render_map(s: Surface, snap: Snapshot):
"map_trust_loss_invalidates_retained_view": True,
"map_backward_time_rechecks_future_pins": True,
"map_pin_truth_legend": pin_truth,
+ "map_pin_truth_visible": center_trusted,
"map_marker_age_reference_valid": snap.map_marker_age_reference_valid,
"map_marker_reference_timestamp": snap.map_marker_reference_timestamp,
"map_center_lat_e7": snap.map_lat_e7,
@@ -3892,7 +3895,7 @@ def render_map(s: Surface, snap: Snapshot):
"map_background_download": False,
"map_area_download": False,
"map_probe_network_allowed": False,
- "map_attribution_visible": True,
+ "map_attribution_visible": center_trusted,
"map_attribution": MAP_ATTRIBUTION,
"map_policy": MAP_POLICY,
**marker_metrics,
@@ -4193,7 +4196,7 @@ def more_category_specs(
False,
),
(
- "Observer",
+ "MQTT / Observer",
"Not configured",
TEXT,
"open_observer",
@@ -4311,7 +4314,7 @@ def more_category_specs(
{
"key": "connections",
"title": "Connections",
- "summary": "Wi-Fi, Bluetooth, and observer",
+ "summary": "Wi-Fi, Bluetooth, and MQTT",
"color": GREEN,
"warning": False,
"action": None,
@@ -5192,15 +5195,22 @@ def render_contact_detail_page(s: Surface, snap: Snapshot, dm_reason: str):
back_action="close_contact_detail",
back_destination="nodes",
)
- s.text("Chat normal unread counted", (16, 64, 464, 86), 13, MUTED)
+ s.text("Chat | Active", (16, 64, 464, 86), 13, MUTED)
s.text(
- f"Identity {contact.fingerprint}",
+ f"Node ID {contact.fingerprint}",
(16, 98, 464, 120),
13,
TEXT,
)
- s.text("Direct route | 0 hops", (16, 132, 464, 154), 13, MUTED)
- s.text(f"Last signal {contact.signal}", (16, 166, 464, 188), 13, MUTED)
+ s.text("Route Direct", (16, 132, 464, 154), 13, MUTED)
+ rssi = int(contact.signal.split(" ", 1)[0])
+ quality = (
+ "Excellent" if rssi >= -70 else
+ "Good" if rssi >= -90 else
+ "Fair" if rssi >= -110 else
+ "Weak"
+ )
+ s.text(f"Signal {quality} | {contact.signal}", (16, 166, 464, 188), 13, MUTED)
s.text("Contact actions", (16, 210, 464, 232), 14, GREEN, True)
status_lines = 1
status_end_y = 188
@@ -5481,24 +5491,26 @@ def render_managed_node_detail_sheet(s: Surface, snap: Snapshot):
def draw_admin_shell(s: Surface, title: str, *, back: bool = False) -> None:
s.rect((0, 0, WIDTH, HEIGHT), (17, 25, 35))
s.round_rect(ADMIN_SHEET, SURFACE, BORDER, 8)
- s.text(title, (36, 98, 262 if back else 290, 130), 22, TEXT, True)
if back:
draw_button(
s,
- (282, 94, 358, 138),
+ (16, 94, 92, 138),
"Back",
MUTED,
action="admin_back",
destination="repeater_manager",
)
- draw_button(
- s,
- (368, 94, 444, 138),
- "Close",
- MUTED,
- action="close_repeater_admin",
- destination="repeater_manager" if back else "nodes",
- )
+ s.text(title, (104, 98, 432, 130), 22, TEXT, True)
+ else:
+ s.text(title, (36, 98, 290, 130), 22, TEXT, True)
+ draw_button(
+ s,
+ (368, 94, 444, 138),
+ "Close",
+ MUTED,
+ action="close_repeater_admin",
+ destination="nodes",
+ )
def draw_admin_target(s: Surface, *, authenticated: bool = False) -> None:
@@ -5528,7 +5540,11 @@ def render_repeater_login(s: Surface, snap: Snapshot, *, saved: bool = False) ->
del snap
draw_admin_shell(s, "Repeater login")
s.text("Krabs Lagoon | 60B6ABA17831F883", (36, 140, 444, 160), 11, ACCENT, True)
- prompt = "Saved password ready. Type to replace it." if saved else "Enter the password used by this repeater."
+ prompt = (
+ "Saved password ready. Type to replace it."
+ if saved else
+ "Use Admin with a password, or Guest without one."
+ )
s.text(prompt, (36, 162, 444, 182), 10, MUTED)
password_box = (36, 184, 444, 228)
s.round_rect(password_box, (16, 23, 25), ACCENT, 8)
@@ -5538,16 +5554,24 @@ def render_repeater_login(s: Surface, snap: Snapshot, *, saved: bool = False) ->
draw_button(
s,
(36, 348, 144, 392),
- "Login",
+ "Admin",
GREEN,
action="submit_repeater_login",
destination="repeater_login_pending",
)
- draw_button(s, (152, 348, 284, 392), "Save: On", ACCENT, action="toggle_save_password")
+ draw_button(
+ s,
+ (152, 348, 244, 392),
+ "Guest",
+ ACCENT,
+ action="submit_repeater_guest_login",
+ destination="repeater_login_pending",
+ )
+ draw_button(s, (252, 348, 344, 392), "Save On", ACCENT, action="toggle_save_password")
if saved:
- draw_button(s, (292, 348, 444, 392), "Forget saved", RED, action="forget_repeater_password")
+ draw_button(s, (352, 348, 444, 392), "Forget", RED, action="forget_repeater_password")
else:
- s.text("Saved only on this D1L", (294, 360, 442, 382), 9, MUTED, False, "center")
+ s.text("Local only", (352, 360, 444, 382), 9, MUTED, False, "center")
s.metrics.update(
{
"admin_page": "login",
@@ -5581,7 +5605,14 @@ def render_repeater_login_pending(s: Surface, snap: Snapshot) -> None:
align="center",
)
s.text("A slow mesh route can take up to 60 seconds.", (36, 330, 444, 350), 10, MUTED, False, "center")
- draw_button(s, (160, 348, 320, 392), "Cancel", MUTED, action="cancel_repeater_login", destination="repeater_login")
+ draw_button(
+ s,
+ (140, 348, 340, 392),
+ "Cancel & sign out",
+ MUTED,
+ action="cancel_repeater_login",
+ destination="repeater_login",
+ )
s.metrics.update(
{
"admin_page": "pending",
@@ -5625,7 +5656,7 @@ def render_repeater_manager(s: Surface, snap: Snapshot) -> None:
for args in buttons:
draw_admin_grid_button(s, *args)
s.text("Admin access | firmware level 2", (36, 360, 326, 382), 10, MUTED)
- draw_button(s, (332, 348, 444, 392), "Logout", RED, action="logout_repeater", destination="repeater_login")
+ draw_button(s, (332, 348, 444, 392), "Sign out", RED, action="logout_repeater", destination="repeater_login")
s.metrics.update(
{
"admin_page": "hub",
@@ -5646,7 +5677,7 @@ def draw_admin_metric(s: Surface, box: tuple[int, int, int, int], title: str, va
def render_repeater_status(s: Surface, snap: Snapshot) -> None:
del snap
- draw_admin_shell(s, "Server status", back=True)
+ draw_admin_shell(s, "Repeater status", back=True)
metrics = (
((36, 146, 164, 206), "RSSI / SNR", "-52 / 22.00", ACCENT),
((172, 146, 300, 206), "Send queue", "0 queued", GREEN),
@@ -5657,7 +5688,7 @@ def render_repeater_status(s: Surface, snap: Snapshot) -> None:
)
for args in metrics:
draw_admin_metric(s, *args)
- s.text("Noise -118 dBm | voltage 5026 mV", (36, 288, 444, 310), 10, MUTED)
+ s.text("Noise -118 dBm | power 5.02 V", (36, 288, 444, 310), 10, MUTED)
draw_button(s, (140, 348, 340, 392), "Refresh status", ACCENT, action="refresh_admin_status", destination="repeater_login_pending")
s.metrics.update({"admin_page": "status", "admin_metric_count": 6, "admin_results_persistent": True})
@@ -7497,10 +7528,10 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"home": (
"DeskOS",
"Lock",
- "Messages",
- "Nodes",
+ "Channels",
+ "Contacts",
"Map",
- "Tools",
+ "Settings",
"Mesh",
"Wi-Fi",
"BLE",
@@ -7510,7 +7541,8 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"messages": (
"Channels",
"Group conversations",
- "Direct",
+ "Add",
+ "DMs",
"#Public",
),
"messages_public": ("Public", "Back", "Read", "...", "Message this channel"),
@@ -7521,7 +7553,8 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"messages_loading": (
"Channels",
"Group conversations",
- "Direct",
+ "Add",
+ "DMs",
"#Public",
),
"messages_public_storage_degraded": (
@@ -7552,14 +7585,11 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"nodes": (
"Contacts",
"Find",
- "Clear",
+ "Clear nearby",
"Search contacts",
"Sort: Recent",
),
- "map": (
- "Options",
- "(c) OpenStreetMap contributors",
- ),
+ "map": (),
"map_options": (
"Map options",
"Back to Map",
@@ -7613,7 +7643,7 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"Connections",
"Wi-Fi",
"Bluetooth",
- "Observer",
+ "MQTT / Observer",
),
"settings_storage_maps_expanded": (
"Settings",
@@ -7818,19 +7848,19 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"Close",
),
"repeater_login": (
- "Repeater login", "Krabs Lagoon | 60B6ABA17831F883", "Password", "Login", "Save: On", "Close",
+ "Repeater login", "Krabs Lagoon | 60B6ABA17831F883", "Use Admin with a password, or Guest without one.", "Password", "Admin", "Guest", "Save On", "Close",
),
"repeater_login_saved": (
- "Repeater login", "Saved password ready. Type to replace it.", "Saved password", "Forget saved", "Login", "Close",
+ "Repeater login", "Saved password ready. Type to replace it.", "Saved password", "Forget", "Admin", "Guest", "Close",
),
"repeater_login_pending": (
- "Working", "Signing in", "Checking the password and opening a secure session.", "A slow mesh route can take up to 60 seconds.", "Cancel", "Close",
+ "Working", "Signing in", "Checking the password and opening a secure session.", "A slow mesh route can take up to 60 seconds.", "Cancel & sign out", "Close",
),
"repeater_manager": (
- "Repeater manager", "Krabs Lagoon", "Status", "Telemetry", "Neighbours", "Access", "Tools", "Console", "Logout",
+ "Repeater manager", "Krabs Lagoon", "Status", "Telemetry", "Neighbours", "Access", "Tools", "Console", "Sign out",
),
"repeater_status": (
- "Server status", "RSSI / SNR", "Send queue", "RX / TX", "Uptime", "Errors", "Refresh status", "Back", "Close",
+ "Repeater status", "RSSI / SNR", "Send queue", "RX / TX", "Uptime", "Errors", "Refresh status", "Back",
),
"repeater_telemetry": (
"Telemetry",
@@ -7840,7 +7870,6 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"Updated from signed server reply",
"Refresh",
"Back",
- "Close",
),
"repeater_neighbours": (
"Neighbours",
@@ -7850,7 +7879,6 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"Next page",
"Refresh",
"Back",
- "Close",
),
"repeater_access": (
"Access list",
@@ -7860,11 +7888,10 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"Edit access",
"Refresh",
"Back",
- "Close",
),
- "repeater_tools": ("Server tools", "Clear statistics", "Zero-hop advert", "Changes require a second tap and a confirmed server reply.", "Back", "Close"),
- "repeater_console": ("Server console", "Secure input: Off", "Server command", "Send", "Back", "Close"),
- "room_console": ("Room console", "You: Morning Lagoon [delivered]", "Message the room", "Send", "Back", "Close"),
+ "repeater_tools": ("Server tools", "Clear statistics", "Zero-hop advert", "Changes require a second tap and a confirmed server reply.", "Back"),
+ "repeater_console": ("Server console", "Secure input: Off", "Server command", "Send", "Back"),
+ "room_console": ("Room console", "You: Morning Lagoon [delivered]", "Message the room", "Send", "Back"),
"contact_edit_sheet": ("Rename Contact", "Back", "Contact alias", "Keyboard", "Cancel", "Save name"),
"contact_export_sheet": ("Export Contact", "Back", "MeshCore QR", "Fingerprint", "URI", "Ready to scan"),
"forget_contact_confirm_page": (
@@ -8027,10 +8054,10 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
CORE_REQUIRED_LABELS: dict[str, tuple[str, ...]] = {
"home": (
"DeskOS",
- "Messages",
- "Nodes",
+ "Channels",
+ "Contacts",
"Map",
- "Tools",
+ "Settings",
"Mesh",
"Wi-Fi",
"SD",
@@ -8039,7 +8066,8 @@ def render_dm_search_incoming_refresh(s: Surface, snap: Snapshot):
"messages": (
"Channels",
"Group conversations",
- "Direct",
+ "Add",
+ "DMs",
"#Public",
),
"messages_public": ("Public", "Back", "Read", "...", "Message this channel"),
@@ -8086,6 +8114,12 @@ def required_labels_for_profile(
release_profile: str,
snap: Snapshot | None = None,
) -> tuple[str, ...]:
+ if view == "map" and snap is not None:
+ return (
+ ("Options", MAP_ATTRIBUTION)
+ if map_center_is_trusted(snap)
+ else ("Set up",)
+ )
if release_profile == CORE_RELEASE_PROFILE:
return CORE_REQUIRED_LABELS.get(view, REQUIRED_LABELS.get(view, ()))
if release_profile == FULL_FEATURE_RELEASE_PROFILE:
@@ -8435,7 +8469,6 @@ def required_labels_for_profile(
{"view": "repeater_login_pending", "action": "cancel_repeater_login", "destination": "repeater_login"},
{"view": "repeater_manager", "action": "open_admin_status", "destination": "repeater_status"},
{"view": "repeater_status", "action": "admin_back", "destination": "repeater_manager"},
- {"view": "repeater_status", "action": "close_repeater_admin", "destination": "repeater_manager"},
{"view": "repeater_manager", "action": "open_admin_telemetry", "destination": "repeater_telemetry"},
{"view": "repeater_manager", "action": "open_admin_neighbours", "destination": "repeater_neighbours"},
{"view": "repeater_manager", "action": "open_admin_access", "destination": "repeater_access"},