diff --git a/.coderabbit.yaml b/.coderabbit.yaml index cdcd43f3ae..c99af3e1d6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -31,6 +31,35 @@ reviews: instructions: > meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging. Ensure configurations include metadata found in other configs. + - path: test/** + instructions: > + Native C++ unit tests. The camelCase convention used in src/ does NOT apply here, + and this is deliberate, not drift. Two separate rules. Suite directories are + strictly test_[a-z0-9_]+, lowercase only. Test functions take a test_ prefix + followed by underscore-separated segments, and the case WITHIN a segment is free: + test_5byte_sequence_rejected and test_getRegion_returnsCorrectRegion_US are both + correct and both common. Only two things are forbidden for a test function - + dropping the test_ prefix, and collapsing the segments into a single camelCase + identifier. Rationale: bin/run-tests.sh matches suite verdict lines against + test_[a-z0-9_]+, so an uppercase suite directory is reported as missing and + downgrades the run to AMBER; RUN_TEST in test/TestUtil.h passes #func to Unity, so + the function name is the only attribution a CI failure carries. Do NOT raise + naming-convention comments on suite directories or test_* functions, and do NOT + flag a camelCase segment inside an otherwise underscore-separated test name. + Helpers and fixtures inside a suite do follow the normal src/ conventions. + Authoritative rule, which this entry mirrors: the "Test naming" section of + .github/copilot-instructions.md. + + Comment length is a second deliberate exception. The one-or-two-line comment limit + does NOT apply to a test's header comment. The header states what is under test by + symbol and file, why that behavior is required, and the regression that returns if + the assertions are deleted or relaxed; that routinely runs past two lines and is + correct at whatever length it needs. Do NOT ask for a test header to be shortened, + condensed, or moved to the commit message, and do NOT flag it as a multi-paragraph + block comment. Still DO flag narrative that carries no contract - debugging journey, + changelog prose, restating what the assertions do - and per-case comments that + merely repeat the test name. Authoritative rule, which this entry mirrors: the + "Test comments" section of .github/copilot-instructions.md. - path: "**/*.md" instructions: > Documentation does not live in this repo; it lives in diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml index 99f9e62cfe..4c1a2ff792 100644 --- a/.github/actions/setup-base/action.yml +++ b/.github/actions/setup-base/action.yml @@ -16,7 +16,7 @@ runs: sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x cache: pip diff --git a/.github/actions/setup-native-test/action.yml b/.github/actions/setup-native-test/action.yml new file mode 100644 index 0000000000..247832d722 --- /dev/null +++ b/.github/actions/setup-native-test/action.yml @@ -0,0 +1,52 @@ +name: Setup native test build +description: >- + Minimal toolchain for the native test suites. Separate from setup-native, which is shared with + the firmware matrix and is not the place to trim things only the test path can spare. + +runs: + using: composite + steps: + # No checkout: the caller must already have one to reference this action at all. + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: 3.x + cache: pip + cache-dependency-path: | + .github/actions/** + **.ini + + - name: Install build and test dependencies + shell: bash + # C libraries are the full setup-native list: they are real link deps of the portduino HAL, + # the web server and the config parser. cppcheck is check_tool for `pio check`, not `pio test`. + run: | + set -euo pipefail + sudo apt-get -y update --fix-missing + sudo apt-get install -y ccache lcov \ + libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \ + libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libcurl4-gnutls-dev + + - name: Install PlatformIO + shell: bash + # No adafruit-nrfutil (nRF52 DFU), poetry, or meshtastic (the client, needed only by the + # simulator job), and no `pio upgrade` right after installing the current release. + run: | + set -euo pipefail + python -m pip install --upgrade pip + pip install -U --no-build-isolation --no-cache-dir "setuptools<72" + pip install -U platformio --no-build-isolation + + - name: Configure ccache + shell: bash + # /usr/lib/ccache holds compiler-named symlinks, so PATH is the whole wiring. time_macros: + # ccache otherwise refuses any TU mentioning __DATE__/__TIME__, which BUILD_EPOCH disables. + run: | + set -euo pipefail + echo "/usr/lib/ccache" >> "$GITHUB_PATH" + { + echo "CCACHE_DIR=$HOME/.ccache" + echo "CCACHE_COMPRESS=1" + echo "CCACHE_MAXSIZE=400M" + echo "CCACHE_SLOPPINESS=time_macros" + } >> "$GITHUB_ENV" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3d20ca974f..90286936f0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -337,7 +337,7 @@ firmware/ - **Do not zero-pad one-byte values to 8.** `next_hop`, `relay_node`, and the next-hop hint are `uint8_t` last-byte route hints, and `channel` is a one-byte hash/index - log these as `0x%x` (or `%d`). Padding a byte to `0x000000ab` falsely implies a full node number. The same goes for I2C addresses, register values, flags/bitmasks, and error/reason codes: they are not IDs, so leave them `0x%x`. - Use `assert()` for invariants that should never fail - C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.) -- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. +- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. Code under `test/` is a deliberate exception - see [Test comments](#test-comments) below. - **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. - **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. @@ -352,14 +352,58 @@ firmware/ **And decide which way the sentinel should fall.** "Inactive" does not always mean "suppress". At the GPS fix-hold site `fixHoldEnds == 0` means _no hold is in force_, which is exactly when a new hold must be armed - the naive comparison it replaced was `(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, always true when nothing was armed. Guarding it with `fixHoldEnds != 0 &&` looks like this rule and inverts the site: nothing re-arms, nothing publishes, and the receiver stays powered until the search timeout. Read the surrounding logic before adding the guard. `fixHoldInForce()` in `src/gps/GPS.cpp` is the worked example - state the predicate positively, so the sentinel has an honest answer, and derive both decisions from it - with `test/test_gps_fix_hold/` pinning both directions. + + +#### Test comments - a test must justify what it pins and name the regression it guards + +**This section is the single authoritative statement of the rule. `AGENTS.md` and `CLAUDE.md` link here and must not restate it. The one permitted copy is the `test/**` entry in `.coderabbit.yaml`, because a YAML instruction cannot follow a link; keep it in sync with this section.** + +The limit above rests on "the diff and commit message carry the rationale". For a test that premise is false: it is read when it fails, long after that message is out of reach, by someone deciding whether the failure is a real regression or a stale expectation. **A review comment asking a test header to be cut to one or two lines is wrong, and should be rejected rather than acted on.** + +The header of `test_main.cpp` states three things, at whatever length they take: + +- **What is under test**, by symbol and file - `fixHoldInForce()` in `src/gps/GPS.cpp`, not "the GPS logic". +- **Why that behavior is required** - the contract being pinned. +- **The regression guarded** - the wrong behavior that returns if these assertions are deleted or relaxed. + +Per-case comments stay short; add one only where an assertion turns on something non-obvious. The allowance is for the argument, not for narrative: no debugging journey, no changelog prose, no restating what the assertions do. Worked example: `test/test_gps_fix_hold/test_main.cpp`. + ### Naming Conventions +These apply to firmware source under `src/`. Code under `test/` is a deliberate exception - see [Test naming](#test-naming) below. + - Classes: `PascalCase` (e.g., `PositionModule`, `NodeDB`) - Functions/Methods: `camelCase` (e.g., `sendOurPosition`, `getNodeNum`) - Constants/Defines: `UPPER_SNAKE_CASE` (e.g., `MAX_INTERVAL`, `ONE_DAY`) - Member variables: `camelCase` (e.g., `lastGpsSend`, `nodeDB`) - Config defines: `USERPREFS_*` for user-configurable options + + +#### Test naming - `test_` prefix and underscores, never one `camelCase` identifier + +**This section is the single authoritative statement of the rule. `AGENTS.md` and `CLAUDE.md` link here and must not restate it. The one permitted copy is the `test/**` entry in `.coderabbit.yaml`, because a YAML instruction cannot follow a link; keep it in sync with this section.** + +Code under `test/` does not follow the `camelCase` rule above, and this is neither drift nor an oversight - the harness and Unity both depend on it. **A review comment asking for `camelCase` on a test suite directory or a `test_*` function is wrong, and should be rejected rather than acted on.** + +Two distinct rules, often conflated: + +| Thing | Rule | Examples | +| ------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Suite directory | Strictly `test_[a-z0-9_]+` - lowercase `snake_case`, no exceptions | `test_gps_fix_hold/`, `test_admin_radio/` | +| Test function | `test_` prefix, then `_`-separated segments. Case _within_ a segment is free | `test_5byte_sequence_rejected()`, `test_getRegion_returnsCorrectRegion_US()` | +| Helpers/fixtures inside a suite | normal `src/` conventions | `makeFakePacket()`, `class FakeRadio` | + +For functions, what is fixed is the `test_` prefix and the underscores between segments - not the case inside a segment. Both `test_validateConfigRegion_unsetRegionReturnsTrue` (segment mirrors the `camelCase` symbol under test) and `test_5byte_sequence_rejected` (all lowercase) are correct and both are common in the tree. What is forbidden is dropping the prefix or collapsing the segments into a single `camelCase` identifier (`testValidateConfigRegionUnsetRegionReturnsTrue`). + +Why it is fixed: + +- **The harness discovers suites by prefix and parses their verdicts by regex.** `bin/run-tests.sh` enumerates suites with `find test -maxdepth 1 -type d -name 'test_*'`, then matches PlatformIO's per-suite result lines against `test_[a-z0-9_]+` - lowercase only. A suite directory with an uppercase letter is enumerated but never matched, so it is reported as _missing_ and the whole run downgrades from GREEN to AMBER. +- **The function name is the failure message.** `RUN_TEST` in `test/TestUtil.h` passes `#func` to `UnityDefaultTestRun()`, `testAssertEnvironmentIntact()` and `testStateCheckpoint()`, so the identifier is the only attribution a CI log carries for a failed assertion or a dirtied sandbox. The underscores are what make it readable there; a single `camelCase` run-on is not. +- **It is Unity's own convention**, shared with every other PlatformIO C++ project. + +Renaming a suite directory to `camelCase` breaks the harness's suite accounting; renaming the functions destroys the readability of CI output. Leave both alone. + ### Key Patterns #### Module System @@ -632,7 +676,7 @@ The project uses GitHub Actions extensively for CI/CD. Key workflows are in `.gi - Includes native tests and hardware-in-the-loop testing - **`test_native.yml`** - Native platform unit tests - - Runs `pio test -e native` + - Runs the `test/test_*` suites under `[env:coverage]`, sharded across a matrix. `bin/test-shards.py` derives the matrix from the tree - it groups suites into named areas, splits an area too big for one runner and packs the ones too small to fill one - so adding a suite needs no CI change. The `generate-reports` job collects every shard's JUnit report, checks the union against the canonical `test/test_*` set, and states the verdict; `Native PlatformIO Tests` is the single required check over the fan-out. ### Release Workflows @@ -719,7 +763,7 @@ Unit tests in `test/` directory. The canonical suite count is detected on the fl **A signal name from the runner is not a crash.** `exit(UNITY_END())` returns the failure count, and PlatformIO's native runner renders a non-zero exit code as a POSIX signal - 4 failures prints `Program received signal SIGILL`, 5 prints `SIGTRAP`, and the suite is reported `[ERRORED]` instead of `[FAILED]`. Check the exit code against the failure count before theorising about memory bugs; confirm any real crash under a debugger. -**Suite order is randomisable.** `./bin/run-tests.sh --shuffle` runs suites in a seeded random order; `--seed ` replays one. The seed defaults to the commit SHA (deterministic per commit, varied across commits), is printed at the start and on the `RESULT:` line, and the full order is printed on failure. CI shuffles its area order the same way, seeded from `GITHUB_SHA`. A single green seed is not evidence of order independence. +**Suite order is randomisable.** `./bin/run-tests.sh --shuffle` runs suites in a seeded random order; `--seed ` replays one. The seed defaults to the commit SHA (deterministic per commit, varied across commits), is printed at the start and on the `RESULT:` line, and the full order is printed on failure. CI seeds from `GITHUB_SHA` the same way, but its shards run in parallel, so there the seed varies which suites _share_ a shard rather than the order they run in; `pull_request` keeps the declared arrangement so a contributor's PR never goes red for a pairing they did not choose. A single green seed is not evidence of order independence. **`-f` is not a gate.** A filtered run can pass while a full run fails, because filtering removes the suites that _create_ the state a later suite trips over. Iterate with `-f`; gate on a full run. diff --git a/.github/nightly/hintro.html b/.github/nightly/hintro.html new file mode 100644 index 0000000000..c8737fd414 --- /dev/null +++ b/.github/nightly/hintro.html @@ -0,0 +1,153 @@ + + + + + + +Meshtastic Nightly v%%VERSION%% + + + + + +
+
+ + + + + + +

MeshtasticNightly Firmware

+
+ +

+ Firmware built straight from the tip of develop, published every night. + Grab a .bin, .uf2 or .hex for your board below, or point the + Web Flasher at the nightly channel and let it pick for you. +

+ +
+
Version
%%VERSION%%
+
Commit
%%COMMIT_SHORT%%
+
Built
%%BUILD_DATE%%
+
Build log
run %%RUN_ID%%
+
+ +

+ Nightlies are untested pre-release builds and can be unstable. Only flash hardware you can afford to + fully erase, and back up your config first. Start with the + release notes — they cover what changed and how to report what you find. +

+ +

Files

+
diff --git a/.github/nightly/houtro.html b/.github/nightly/houtro.html new file mode 100644 index 0000000000..02529e1be7 --- /dev/null +++ b/.github/nightly/houtro.html @@ -0,0 +1,11 @@ +
+ + +
+ + diff --git a/.github/nightly/release_notes.md b/.github/nightly/release_notes.md new file mode 100644 index 0000000000..972431e2c9 --- /dev/null +++ b/.github/nightly/release_notes.md @@ -0,0 +1,161 @@ +# Help Test 2.8 - Flash a Nightly, Send Feedback + +We're preparing the **2.8** release, and we need your help shaking it out on real hardware. The web flasher now has a **built-in feedback form**. Flash a nightly, use your node like you normally would, and tell us what you find. Every report on a real board moves the release forward. + +⚠️ 2.8 nightlies are **experimental, pre-release builds** and can be unstable. Please read the warnings and only flash hardware you can afford to fully erase. Back up your config first. + +## Big changes + +There are a number of fundamental changes to 2.8 that require a heads-up. Please review some of these higher profile changes so that you are aware of them before installing: + +- Node numbers (ID) are now derived from the public-key identity of the node +- XEdDSA based packet signing +- Reduction of long-name to 25 bytes +- Precise position no longer allowed on known-keys (public mesh. Please use private channels for this) +- Telemetry and position are off-by-default and must be opted into +- Ground-up redesigned NodeDB storage and traffic management +- Completely new allocation of LoRa Regions and presets, including ham specific carve-outs + +--- + +## How to help in 3 steps + +1. **Flash a 2.8 nightly** from the web flasher onto supported hardware. +2. Make sure your apps / clients are up to date with the latest release in order to support the 2.8 firmware features like packet signing. +3. **Use it normally** for a while - send messages, move around, let it mesh, sleep, and wake. Try the features you actually rely on. +4. **Open the feedback form in the web flasher and tell us what happened** - good, bad, or broken. "Works great on my board" is genuinely useful data too. + +--- + +## Where we most need coverage + +The more different boards and setups we hear from, the better. Especially valuable: + +- **A variety of supported boards** +- **Both radios and roles** - routers, clients, repeaters, and low-power/sleep configurations. +- **Different regions** and channel/modem-preset combinations. +- **Bluetooth pairing and reconnection** from Android and iOS. +- **Upgrade paths** - flashing 2.8 over an existing 2.x install and confirming your config/keys survive. +- **Peripherals** - GPS, screens (OLED/E-Ink/TFT), sensors, and buttons. + +--- + +## What makes a feedback report actionable + +The feedback form captures a lot automatically, but the more of the following you include, the faster we can act: + +| Include | Why it helps | +| ------------------------- | --------------------------------------------------------------------------------------------- | +| **Exact board / variant** | Behavior is often board-specific (e.g. `rak4631`, `heltec-v3`, `t1000-e`). | +| **Build identifier** | The nightly version / commit / date you flashed, so we can reproduce against the right build. | +| **What you did** | Concrete steps leading up to the problem - the shorter the repro, the better. | +| **Expected vs. actual** | What you thought would happen, and what actually happened. | +| **How often** | Every time? Once? Only after a reboot or after X hours? | +| **Logs / screenshots** | Serial or app logs, and photos of the screen or error, when you have them. | +| **Environment** | Region, phone OS + app version, and anything unusual about your setup. | + +### Especially flag these + +- **Boot loops, hangs, watchdog resets, or unexpected reboots** +- **Config, channel, or key loss** after flashing or upgrading +- **Regressions** - something that worked on your previous (stable) firmware but is now broken +- **Meshing / DM problems** between a 2.8 node and nodes on other firmware +- **Power regressions** - noticeably worse battery life + +--- + +## Good feedback vs. vague feedback + +**Vague:** "It's broken, keeps rebooting." + +**Actionable:** "On a `rak4631` flashed with the 2.8 nightly from , the node reboots every time I open the Android app (v2.7.15) and tap Position. Happens every time. Serial log attached, region US." + +--- + +> [!NOTE] +> Thank you for testing. Reports from real hardware - including the boring "everything works" ones - are exactly what let us promote 2.8 from nightly preview to a stable release with confidence. + +# Changes + +### Radio & mesh protocol + +- **Packet Signing via XEdDSA** (#10478) plus a **BaseUI signing status UI** (#10841), unsigned-packet policy hardening with test coverage (#10858), and runtime-toggleable `MESHTASTIC_LOCKDOWN` hardening for nRF52 (#10349, opt-in via #10712). +- **Traffic Management Module** for packet forwarding - dedup, rate limiting, role-aware policing (#9358 base, #10706 dedup/rate-limit expansion, #10745 next-hop cache overflow store, #9921 congestion-aware position interval/hop-exhaustion tuning). +- **Automatic variable hop limits** based on live mesh activity and message-size estimation (#10176). +- **Mesh beacon** feature and admin controls (#10618 base implementation, #10839 second pass adding beacon admin/config). +- **Noise floor** tracking with a sliding-window estimate (#9347). +- **Hash table index for O(1) packet history lookups** - improves routing/dedup performance on busy meshes (#9499). +- New amateur-radio regions: 70cm (#10627), 1.25m/125cm (#10638), 2m/~144MHz (#10623); EU regions merge plus Narrow/Lite region enablement for EU (#10675, #10120); LoRa region preset map for cleaner per-region defaults (#10736). +- **TinyFast and TinySlow modem presets** added to config and menu (#10597). +- LoRa config changes now apply live without a reboot (#9962); LoRa settings expansion and validation improvements (#9878). +- Position privacy: direct-send position packets are clamped to channel precision (#10383), and public/known-key position precision is clamped similarly (#10665) and honors explicit channel settings to prevent location leaks (#10513). +- Licensed operators are now prevented from rebroadcasting packets to/from unlicensed users, for regulatory compliance (#9958). +- Packets with missing/invalid `hop_start` (pre-hop firmware) are now deprecated/blocked (#9476). +- Spoof detection added for UDP multicast packets (#9905). +- Low-bandwidth conversion support added to MeshRadio (#10595). +- ATAK Plugin V2 implemented (drops legacy unishox2 compression) (#10105), including TAKTALK voice/text chat message and room data structures. +- Ethernet HTTP/HTTPS API server ported to RP2350 + W5500 boards (#10573), plus Ethernet OTA support for RP2350/W5500 (#10136). +- Optional `LED_LORA` indicator to show LoRa TX activity (#10465), and an LED indicator for LoRa RX (#10674). +- GPS time sync: device now sets its clock from GPS every 30 minutes (#10737); GPS is skipped at startup if the LoRa region is unset, to save battery (#10386); GPS model/baudrate now cached across reboots to skip a full sweep (#10544). +- Config: **position & telemetry broadcast are now opt-in** rather than always-on (#10929). +- **Extra-repeat tolerance** - device tolerates a configurable number of heard repeats before cancelling its own rebroadcast, with tolerance suppressed when the mesh is busy or dense (recent local branch work). + +### UI / display - BaseUI + +_BaseUI is the current default graphical UI (`src/graphics/Screen.cpp`, `draw/UIRenderer`, `draw/MenuHandler`, `draw/CompassRenderer`, `draw/NotificationRenderer`, `draw/NodeListRenderer`)._ + +- **"Ham Mode"** - first implementation (#10663). +- **Color support for TFT-equipped nodes** (#10233). +- Status-message display added to Favorite/NodeList screens (#9504, #10197). +- **Hex picker** UI added for entering hex values (#10650). +- Save/restore of frame visibility state across sessions (#10576). +- BLE pairing PIN now shown via a proper on-screen banner (#8902). +- Compass rendering/behavior improvements (#10166). +- Emote handling refactor (#9896). + +### UI / display - InkHUD + +_InkHUD is the e-ink-optimized UI (`src/graphics/niche/InkHUD`)._ + +- **Offline map tiles with zoom controls** (#10785) and general GPS UX improvements (#10846). +- **Full touch support** for the T5 E-Paper S3 (#10286) and a full InkHUD port for the LilyGo T5 E-Paper S3 Pro (#10211). +- **"Wipe all messages"** option (#10721). +- T-mini E-Ink S3 support added (#9856). + +### UI / display - MUI + +_MUI is the older/legacy graphical UI menu system, still selectable alongside BaseUI (`draw/MenuHandler`'s `MuiPicker`/`switchToMUIMenu`)._ + +- WiFi map-tile download adapted for Heltec V4 (#10011). + +### UI / display - shared / cross-cutting + +_Touches the display driver layer or more than one UI system at once._ + +- InkHUD and BaseUI **message store unified** into a shared implementation (#10596). +- T-mini E-Ink S3 support landed for both InkHUD and BaseUI (#9856). +- Board-specific TFT driver support and simplified TFT ifdef chains for easier porting (#10827, #10803); faster TFT color conversion (#10814); touchscreen variant flags (`VARIANT_TOUCHSCREEN`/`ENABLE_TOUCH_INT`) added for new touch boards (#10815). + +## Backend / developer-facing features + +### Memory & stability infrastructure + +- **MemClass.h** - a central memory-class ladder providing fail-safe-small defaults across constrained platforms (#10901). +- **MemAudit** - per-subsystem heap accounting reported in the boot log (#10900). +- nRF52 heap tiers and SoftDevice RAM reservation right-sized, freeing an extra ~8KB heap arena (#10898, #10903). +- Native Portduino malloc shim added for more realistic memory behavior in simulation (#10677). +- Bluetooth memory freed automatically when Wi-Fi is enabled or Bluetooth is disabled (#10398); Bluetooth wait is skipped entirely when disabled (#10571). +- NodeDB "warm store" - new persistent warm-tier node storage layer (#10705 base, #10746/#10759 right-sizing for constrained platforms, #10809 fixing associated spiLock deadlocks). +- 2.8 NodeDB shrink/decoupling/restructuring to reduce per-node memory footprint (#10413). +- Flash hardening, filesystem platform unification, and a write-behind LFS cache for STM32WL/nRF52 - a storage-format break (#10171). + +### New APIs / module-author infrastructure + +- **MCP server** added for interacting with Meshtastic devices and driving a testing framework / TUI, later extracted to its own repo (#10194, #10861). +- Native "sensors" simulation support for Portduino (#10748); `PortduinoSetOptions` to override the `realhardware` flag for tests (#10157). +- Hardfault handler added for STM32, making crashes visibly obvious in logs (#10071). +- `BinarySemaphorePosix` implemented with proper pthread synchronization for native builds (#9895). +- NimBLE parameter overhaul, including a fix attempt for incompatible BLE bond cleanup (#10741). +- STM32 ADC support added to `AnalogBatteryLevel` (#9369). +- JSON library dependency removed from firmware builds while retaining full JSON support in meshtasticd (#10152) - reduces firmware footprint for module authors relying on the JSON path. +- Improved manual build flow / developer build ergonomics (#8839).- External Notifications module logic fully reworked for more flexible notification rules (#10006). diff --git a/.github/workflows/build_one_target.yml b/.github/workflows/build_one_target.yml index b6ec92edb7..b66d701fb8 100644 --- a/.github/workflows/build_one_target.yml +++ b/.github/workflows/build_one_target.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7.0.0 with: python-version: 3.x cache: pip diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml index 3f0515ea6c..40d878cced 100644 --- a/.github/workflows/build_windows_bin.yml +++ b/.github/workflows/build_windows_bin.yml @@ -54,7 +54,7 @@ jobs: git - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: "3.14" cache: pip diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 59cc44c8c8..10281b6bd0 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -35,7 +35,8 @@ on: #- "**.yml" schedule: - # Nightly develop build, published to meshtastic.github.io firmware-nightly/ (no GitHub release). + # Nightly develop build, published to the meshtastic-firmware-nightly R2 + # bucket (no GitHub release). # Scheduled runs execute on the default branch (develop). 07:00 UTC avoids the 00:00 tests # and 02:00 daily_packaging crons. - cron: 0 7 * * * # Nightly develop build/publish (default branch is develop) @@ -44,7 +45,7 @@ on: inputs: # trunk-ignore(checkov/CKV_GHA_7): intentional manual-test switch for the nightly publish path nightly: - description: "Nightly mode: build + publish develop to github.io firmware-nightly/ (skips creating a GitHub release)" + description: "Nightly mode: build + publish develop to the nightly R2 bucket (skips creating a GitHub release)" type: boolean default: false @@ -58,7 +59,7 @@ jobs: with: # Needed to diff against the base branch for newly added variants. fetch-depth: 0 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7.0.0 with: python-version: 3.x cache: pip @@ -73,8 +74,11 @@ jobs: # first env of each ADDED variant config. A new env in an existing one does not count. DIFF_BASE="" if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then - git fetch --no-tags --depth=1 origin "$BASE_REF" - DIFF_BASE=$(git merge-base FETCH_HEAD HEAD) + # checkout above already fetched every branch (fetch-depth: 0), so the base ref + # is local. Do not re-fetch it with --depth=1: that grafts the fetched tip as + # parentless, and merge-base then finds no common commit whenever the base + # branch has moved past this merge commit, which is every re-run of an older run. + DIFF_BASE=$(git merge-base "origin/$BASE_REF" HEAD) elif [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then DIFF_BASE="$MERGE_GROUP_BASE_SHA" fi @@ -497,7 +501,7 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x @@ -564,8 +568,8 @@ jobs: path: firmware-${{ needs.version.outputs.long }}.json - name: Add sources to GitHub Release - # Only run when targeting master branch with workflow_dispatch - if: github.ref_name == 'master' + # Only run when targeting the default branch with workflow_dispatch + if: github.ref_name == github.event.repository.default_branch run: | gh release upload v${{ needs.version.outputs.long }} ./firmware-${{ needs.version.outputs.long }}.json gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip @@ -596,7 +600,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x @@ -631,8 +635,8 @@ jobs: run: ls -lR - name: Add bins and debug elfs to GitHub Release - # Only run when targeting master branch with workflow_dispatch - if: github.ref_name == 'master' + # Only run when targeting the default branch with workflow_dispatch + if: github.ref_name == github.event.repository.default_branch run: | gh release upload v${{ needs.version.outputs.long }} ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip gh release upload v${{ needs.version.outputs.long }} ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip @@ -646,6 +650,7 @@ jobs: env: targets: |- esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 + r2_bucket: meshtastic-firmware-release steps: - name: Checkout uses: actions/checkout@v7 @@ -653,7 +658,7 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x @@ -694,10 +699,39 @@ jobs: commit_message: ${{ needs.version.outputs.long }} enable_jekyll: true - # Nightly publish: refresh the single, stable firmware-nightly/ folder on - # meshtastic.github.io with the current develop build. Runs on the cron schedule - # (or a manual nightly=true dispatch) and never creates a GitHub release. The - # folder's release_notes.md is maintained by hand and deliberately left untouched. + # Mirror the same staged directory to Cloudflare R2, under a version prefix + # at the bucket root. Additive (no --delete), matching keep_files:true + # above: release_channels.yml later publishes an updated release_notes.md + # into this same prefix, and a --delete sync on a re-run would remove it. + - name: Publish firmware to Cloudflare R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + # R2 is single-region; the S3 API still requires a region to be set. + AWS_DEFAULT_REGION: auto + # Cloudflare's documented aws-cli settings for R2. Left at the AWS + # default, CLI >= 2.23 attaches CRC32 request checksums that R2 can + # reject, so both of these must stay at when_required. + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + # Same event/* prefixing as the github.io publish above. + DEST_PREFIX: ${{ contains(github.ref_name, 'event/') && format('{0}/', github.ref_name) || '' }} + VERSION: ${{ needs.version.outputs.long }} + run: | + set -euo pipefail + aws --version + # Cache for 1 day in browser, 1 month on CDN. + aws s3 sync ./publish "s3://${r2_bucket}/${DEST_PREFIX}${VERSION}/" \ + --endpoint-url "$R2_ENDPOINT" \ + --no-progress \ + --metadata "commit=${{ github.sha }},run=${{ github.run_id }},version=${VERSION}" \ + --cache-control 'public, max-age=86400, s-maxage=2592000' + + # Nightly publish: refresh the root of the meshtastic-firmware-nightly R2 + # bucket with the current develop build. Runs on the cron schedule (or a manual + # nightly=true dispatch) and never creates a GitHub release. The bucket's + # release_notes.md is published from .github/nightly/ in this repo. publish-nightly: runs-on: ubuntu-24.04 if: github.repository_owner == 'meshtastic' && (github.event_name == 'schedule' || github.event.inputs.nightly == 'true') @@ -705,7 +739,13 @@ jobs: env: targets: |- esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 + r2_bucket: meshtastic-firmware-nightly steps: + # Only the index templates and release notes are needed here. + - uses: actions/checkout@v7 + with: + sparse-checkout: .github/nightly + - name: Get firmware artifacts uses: actions/download-artifact@v8 with: @@ -728,43 +768,89 @@ jobs: '{version: $ver, id: ("v" + $ver), title: ("Meshtastic Firmware " + $ver + " Nightly"), commit: $sha}' \ > ./stage/index.json - - name: Preserve manually-maintained release notes - # firmware-nightly/release_notes.md is edited by hand. Carry the current - # copy into ./stage so the keep_files:false publish (which refreshes the - # folder and clears stale nightly binaries) does not drop it. Seed a - # placeholder on the first run (404); fail closed on any other error so a - # transient fetch failure never clobbers the notes. - run: | - set -euo pipefail - url=https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-nightly/release_notes.md - code=$(curl -sSL -o ./stage/release_notes.md -w '%{http_code}' --retry 5 --retry-all-errors "$url" || echo 000) - if [ "$code" = "200" ]; then - echo "Preserved existing release_notes.md" - elif [ "$code" = "404" ]; then - echo "No existing release_notes.md; seeding placeholder" - printf '# Nightly (develop)\n\nAutomated nightly build from the `develop` branch. Edit these notes by hand.\n' > ./stage/release_notes.md - else - echo "Unexpected HTTP $code fetching release_notes.md; refusing to publish to avoid clobbering manual notes" - exit 1 - fi + # The notes are maintained in-tree and published to the bucket root. + - name: Stage the nightly release notes + run: cp .github/nightly/release_notes.md ./stage/release_notes.md # For diagnostics - name: Display structure of files to publish run: ls -lR ./stage - - name: Publish nightly to meshtastic.github.io - uses: peaceiris/actions-gh-pages@v4 - with: - deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }} - external_repository: meshtastic/meshtastic.github.io - publish_branch: master - publish_dir: ./stage - # keep_files:false is scoped to destination_dir, so this refreshes only - # firmware-nightly/ (clearing stale nightly binaries) while sibling - # release folders stay untouched; release_notes.md is carried in above. - destination_dir: firmware-nightly - keep_files: false - user_name: github-actions[bot] - user_email: github-actions[bot]@users.noreply.github.com - commit_message: Nightly ${{ needs.version.outputs.long }} - enable_jekyll: true + - name: Verify the staged nightly is not empty + # The publish below refreshes the bucket in place (the R2 sync runs with + # --delete), so an empty ./stage would clear the live nightly rather than + # replace it. A failed or pattern-mismatched artifact download is the way + # that happens, so fail closed here instead. + run: | + set -euo pipefail + images=$(find ./stage -type f \ + \( -name 'firmware-*.bin' -o -name 'firmware-*.uf2' -o -name 'firmware-*.hex' \) | wc -l) + echo "Staged firmware images: $images" + if [ "$images" -eq 0 ]; then + echo "::error::No firmware images in ./stage; refusing to publish an empty nightly." + exit 1 + fi + + # tree renders the file listing; .github/nightly wraps it in the page + # chrome. tree silently skips an --hintro/--houtro file it cannot open, so + # the rendered page is checked for both markers below. + - name: Generate nightly html index + env: + VERSION: ${{ needs.version.outputs.long }} + COMMIT: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + working-directory: ./stage + run: | + set -euo pipefail + # Substitute placeholders in the nightly index header template. + sed -e "s|%%VERSION%%|$VERSION|g" \ + -e "s|%%COMMIT%%|$COMMIT|g" \ + -e "s|%%COMMIT_SHORT%%|${COMMIT:0:7}|g" \ + -e "s|%%BUILD_DATE%%|$(date -u +'%Y-%m-%d %H:%M UTC')|g" \ + -e "s|%%RUN_ID%%|$RUN_ID|g" \ + -e "s|%%RUN_URL%%|$RUN_URL|g" \ + "$GITHUB_WORKSPACE/.github/nightly/hintro.html" > "$RUNNER_TEMP/hintro.html" + # Render the html index using tree, with custom header and footer templates. + tree -H "." -h --noreport -I "index.html" --charset utf-8 \ + --hintro "$RUNNER_TEMP/hintro.html" \ + --houtro "$GITHUB_WORKSPACE/.github/nightly/houtro.html" \ + > index.html + + # Mirror the staged directory to Cloudflare R2. --delete at the bucket root + # clears stale nightly binaries, and is in scope for the whole bucket because + # this bucket holds nothing but the nightly build. + - name: Publish nightly to Cloudflare R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + # R2 is single-region; the S3 API still requires a region to be set. + AWS_DEFAULT_REGION: auto + # Cloudflare's documented aws-cli settings for R2. Left at the AWS + # default, CLI >= 2.23 attaches CRC32 request checksums that R2 can + # reject, so both of these must stay at when_required. + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + run: | + set -euo pipefail + aws --version + # Cache for 1 hour in browser, 1 day on CDN. + aws s3 sync ./stage "s3://${r2_bucket}/" \ + --endpoint-url "$R2_ENDPOINT" \ + --no-progress \ + --delete \ + --exclude 'index.json' \ + --exclude 'index.html' \ + --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ + --cache-control 'public, max-age=3600, s-maxage=86400' + # The indices point at whatever the current nightly is - index.json for + # clients, index.html for browsers - so they are excluded from the sync + # above and uploaded here with a 5 minute cache instead. + for index in index.json index.html; do + aws s3 cp "./stage/$index" "s3://${r2_bucket}/$index" \ + --endpoint-url "$R2_ENDPOINT" \ + --no-progress \ + --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ + --cache-control 'public, max-age=300' + done diff --git a/.github/workflows/package_pio_deps.yml b/.github/workflows/package_pio_deps.yml index bf2576a53e..ec42634169 100644 --- a/.github/workflows/package_pio_deps.yml +++ b/.github/workflows/package_pio_deps.yml @@ -28,7 +28,7 @@ jobs: submodules: recursive - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x diff --git a/.github/workflows/release_channels.yml b/.github/workflows/release_channels.yml index f301030e42..719e0ce623 100644 --- a/.github/workflows/release_channels.yml +++ b/.github/workflows/release_channels.yml @@ -93,11 +93,11 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - # Always use master branch for version bumps - ref: master + # Always use the default branch for version bumps + ref: ${{ github.event.repository.default_branch }} - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 2167e29564..4af381abd0 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -5,22 +5,32 @@ on: inputs: suite_order_seed: description: >- - Seed for shuffling the test-area order. Empty (the default) means: fixed declared order on - pull_request, so a contributor's PR never turns red because of an order they did not - choose; commit-SHA-derived elsewhere. Set a number to force that exact order anywhere - - that is how you replay a shuffled failure. + Seed varying which suites share a shard. Empty (the default) means: the fixed declared + arrangement on pull_request, so a contributor's PR never turns red because of a pairing + they did not choose; commit-SHA-derived elsewhere. Set a number to force that exact + arrangement anywhere - that is how you replay a shuffled failure. type: string required: false default: "" + max_suites_per_shard: + description: >- + Largest shard, in suites. Lower splits the matrix further: faster wall clock, more + runners. The floor per shard is checkout + toolchain + one src build, so below about 8 + the fixed cost starts to dominate what is being parallelised. + type: number + required: false + default: 10 workflow_dispatch: permissions: {} env: - # Only pushes to the default branch (develop) populate the cache; PR / merge_group runs + # Only pushes to the default branch (develop) populate the caches; PR / merge_group runs # restore it but never save, so they stop filling up the repo's Actions cache storage. SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }} - LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}" + # No --directory: callers add it, since shards capture from .pio/build//src. Keeping the + # include/exclude filters shared is what stops a shard capturing a different file set. + LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --base-directory "${PWD}" jobs: # Tripwire against the native suite set shrinking by accident. `platformio test` discovers and @@ -184,7 +194,7 @@ jobs: shell: bash run: | sudo apt-get install -y lcov - lcov ${{ env.LCOV_CAPTURE_FLAGS }} --initial --output-file coverage_base.info + lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory .pio/build/coverage/src --initial --output-file coverage_base.info sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative. - name: Config check tests @@ -234,12 +244,12 @@ jobs: - name: Capture coverage information if: always() # run this step even if previous step failed run: | - lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name integration --output-file coverage_integration.info + lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory .pio/build/coverage/src --test-name integration --output-file coverage_integration.info sed -i -e "s#${PWD}#.#" coverage_integration.info # Make paths relative. - name: Get release version string if: always() # run this step even if previous step failed - run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT" id: version - name: Save coverage information @@ -250,24 +260,102 @@ jobs: overwrite: true path: ./coverage_*.info - platformio-tests: - name: Native PlatformIO Tests - runs-on: ubuntu-24.04-arm + # bin/test-shards.py derives the matrix from test/, so adding a suite needs no CI change. + # Cheap by design: a checkout and a python run, sitting on every shard critical path. + discover: + name: Native Test Shards + # ubuntu-latest, not the slim image: this needs a python3 to run bin/test-shards.py, and it is + # on the critical path of every shard, so it must not have to install one. + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.shards.outputs.matrix }} + suites: ${{ steps.shards.outputs.suites }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - submodules: recursive + persist-credentials: false - - name: Setup native build + - name: Build the shard matrix + id: shards + shell: bash + # Both inputs reach the script through env: rather than ${{ }} inside run:, so nothing from + # the event payload is ever spliced into the shell text. + env: + SUITE_ORDER_SEED: ${{ inputs.suite_order_seed }} + MAX_SUITES: ${{ inputs.max_suites_per_shard || 10 }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + + # pull_request keeps the declared arrangement so a PR never reds for a pairing its author did not + # choose; elsewhere the SHA seeds it. Varies co-location, not order within a shard. + if [ -n "${SUITE_ORDER_SEED:-}" ]; then + seed="$SUITE_ORDER_SEED" + echo "shard arrangement: shuffled with explicitly supplied seed $seed" + elif [ "${EVENT_NAME:-}" = "pull_request" ]; then + seed="" + echo "shard arrangement: declared order (pull_request)" + echo " to exercise a different arrangement, re-run this workflow with a suite_order_seed input" + else + seed=$((16#${GITHUB_SHA:0:8})) + echo "shard arrangement: shuffled with seed $seed (from ${GITHUB_SHA:0:8})" + fi + + matrix=$(./bin/test-shards.py --max-suites "$MAX_SUITES" --seed "$seed" --summary) + # The canonical suite set, passed to the collector so its whole-run gate checks against + # the same walk this matrix was built from rather than a second one. + suites=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort | tr '\n' ' ') + + # A newline in a value writes a second entry, setting outputs this step never declared. Both feed + # control flow, so assert single-line rather than assume it. + for value in "$matrix" "$suites"; do + if [ "$value" != "${value%%$'\n'*}" ]; then + echo "::error title=Multi-line step output::bin/test-shards.py or the suite walk produced a value spanning lines. Refusing to write it to \$GITHUB_OUTPUT - a newline there sets outputs this step did not declare." + exit 1 + fi + done + [ -n "$matrix" ] && [ -n "$suites" ] || { + echo "::error title=Empty shard matrix::the matrix or the suite list came out empty; a downstream gate that expects nothing passes on anything." + exit 1 + } + + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + echo "suites=$suites" >> "$GITHUB_OUTPUT" + + # No build-then-run split: PlatformIO relinks each suite regardless, so the warm build bought a + # shared src build and ~75 throwaway links. ccache carries src objects between shards instead. + platformio-tests: + name: Suites (${{ matrix.shard }}) + needs: discover + runs-on: ubuntu-24.04-arm + # Measured cold-cache shards run 5-12 minutes. Without this a hung suite, or a runner that + # stops reporting, holds a runner until GitHub's 6-hour default - times twelve shards. + timeout-minutes: 30 + permissions: + contents: read + # fail-fast off: a cancelled sibling stops the collector telling "failed" from "never ran". + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} + steps: + # No submodules: src/mesh/generated is tracked and meshtestic is the hardware harness, so neither + # is reachable from . + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup native test build id: base - uses: ./.github/actions/setup-native + uses: ./.github/actions/setup-native-test - name: Get release version string - run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT" id: version - # Disable (comment-out) BUILD_EPOCH. It causes a full rebuild between tests and resets the - # coverage information each time. + # Disable (comment-out) BUILD_EPOCH. It forces a full rebuild between tests, resets coverage each + # time, and would put a fresh timestamp in every TU, which is also what would defeat ccache. - name: Disable BUILD_EPOCH run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini @@ -280,203 +368,163 @@ jobs: restore-keys: | pio-coverage-tests- - - name: Warm the shared test build - # Compiles src + every test program once so no single area absorbs the whole src build in - # its reported duration; gcov then accumulates counts into this shared - # .pio/build/coverage/src as the areas run. NOT a substitute for building in the run step: - # PlatformIO links every test program to the one .pio/build/coverage/meshtasticd path, so a - # --without-building run executes whichever suite was linked last under every suite's name. - run: platformio test -e coverage --without-testing + # Every shard compiles the same ~450 src TUs; unshared, that is the cost of fanning out. + - name: Restore ccache + id: ccache-restore + uses: actions/cache/restore@v6 + with: + path: ~/.ccache + # One lineage for all shards: src objects dominate and are identical. run_id only makes each save + # a fresh entry; the prefix restore-key selects the newest. + key: ccache-native-tests-${{ github.run_id }} + restore-keys: | + ccache-native-tests- + + - name: Run this shard's suites + id: run + shell: bash + # Suite names come from bin/test-shards.py, which refuses any name outside + # ^test_[A-Za-z0-9_]+$ - so the word-split below cannot pick up shell metacharacters. + env: + PIO_ENV: ${{ matrix.env }} + SHARD: ${{ matrix.shard }} + SUITES: ${{ matrix.suites }} + run: | + set -uo pipefail + # read -ra, not an unquoted expansion: word-splits without letting a token glob against + # the workspace. bin/test-shards.py holds every name to ^test_[A-Za-z0-9_]+$ as well. + read -ra suites <<<"$SUITES" + filters=() + for suite in "${suites[@]}"; do filters+=(-f "$suite"); done + echo "shard $SHARD: ${#suites[@]} suite(s) under [env:$PIO_ENV] -> $SUITES" + + # Log to a file for platformio real exit status, then drop the per-variant SKIPPED rows: suites + # outside this shard are reported SKIPPED by design. + rc=0 + platformio test -e "$PIO_ENV" -v "${filters[@]}" \ + --junit-output-path "testreport-$SHARD.xml" > shard.log 2>&1 || rc=$? + grep -v "[[:space:]]SKIPPED$" shard.log || true + exit $rc + + - name: Verify this shard ran its own tests + # Not conditional on the run passing: a suite that reported another suite's test cases is a + # different, worse finding than a failing assertion, and it must not be hidden behind one. + if: always() + env: + SHARD: ${{ matrix.shard }} + SUITES: ${{ matrix.suites }} + run: ./bin/check-test-attribution.py --label "shard $SHARD" --expect "$SUITES" "testreport-$SHARD.xml" + + - name: Capture coverage information + if: always() # run this step even if previous step failed + env: + PIO_ENV: ${{ matrix.env }} + SHARD: ${{ matrix.shard }} + run: | + sudo apt-get install -y lcov + # One tracefile per shard; the collector sums them with --add-tracefile into the union. + lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory ".pio/build/$PIO_ENV/src" \ + --test-name "$SHARD" --output-file "coverage_tests_$SHARD.info" + sed -i -e "s#${PWD}#.#" "coverage_tests_$SHARD.info" # Make paths relative. + + - name: ccache statistics + # Printed, not asserted. A cache that has silently stopped hitting shows up here as the + # shards getting slower, which is the symptom worth being able to explain. + if: always() + run: ccache --show-stats || true + + - name: Save ccache + # Exactly one shard saves (cache_writer): every shard needs the same src objects, and letting all + # of them save would race for the key. + if: always() && env.SAVE_CACHE == 'true' && matrix.cache_writer + uses: actions/cache/save@v6 + with: + path: ~/.ccache + key: ccache-native-tests-${{ github.run_id }} - name: Save PlatformIO cache - if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true' + if: env.SAVE_CACHE == 'true' && matrix.cache_writer && steps.pio-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v6 with: path: ~/.platformio/.cache key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} - - name: Run tests one area at a time - shell: bash - # Both values reach the script through env: rather than ${{ }} inside run:, so nothing from - # the event payload is ever spliced into the shell text. - env: - SUITE_ORDER_SEED: ${{ inputs.suite_order_seed }} - EVENT_NAME: ${{ github.event_name }} - run: | - set -uo pipefail - # One runner, no matrix, no concurrency. Group the test_* suites by area and run each - # area sequentially, reusing the single build above (--without-building). Each area gets - # its own JUnit report and its own collapsible log, so a failure lands in a small named - # section instead of being buried past the log limit. Sequential runs share one build - # dir, so gcov coverage accumulates and the single capture in the next step has the union. - - # Ordered area rules "name:ERE"; first match wins. Anything unmatched falls to "misc", so - # a newly added suite always runs even before it is placed. Add a suite to an area by - # extending that area's regex; add a new area by inserting a rule line. - area_rules=( - "admin:^test_(admin|pki)_" - "crypto:^test_(crypto|packet_signing)$" - "routing:^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_" - "position:^test_position_" - "fuzz:^test_fuzz_" - "packets:^test_(packet|transmit|meshpacket)_" - "io:^test_(serial|stream|xmodem|http|mqtt)" - ) - - mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) - declare -A group - for s in "${suites[@]}"; do - a="misc" - for rule in "${area_rules[@]}"; do - if [[ "$s" =~ ${rule#*:} ]]; then a="${rule%%:*}"; break; fi - done - group[$a]="${group[$a]:-} -f $s" - done - - run_order=() - for rule in "${area_rules[@]}"; do run_order+=("${rule%%:*}"); done - run_order+=("misc") - - # Area order. The rule order above is an accident of how the areas were written, and - # running it fixed forever means order dependence between areas is never observed - but - # randomising it on a contributor's PR would turn their run red for an order they did not - # choose, which is how a randomisation gets reverted instead of the coupling fixed. - # - # So: pull_request keeps the fixed declared order. Everywhere else (push, schedule) the - # order is shuffled, seeded from the commit SHA - deterministic per commit, replayable, - # attributable, and it never blocks someone else's PR. An explicit seed input overrides - # both, which is how you replay a specific failing order anywhere. - # - # Intra-area order stays PlatformIO's: filters select suites, they do not order them - # (list_test_names() walks test/ with os.walk()), so controlling it needs one invocation - # per suite. bin/run-tests.sh --shuffle does exactly that locally. - seed_input="${SUITE_ORDER_SEED:-}" - if [ -n "$seed_input" ]; then - seed="$seed_input" - echo "area order: shuffled with explicitly supplied seed $seed" - elif [ "${EVENT_NAME:-}" = "pull_request" ]; then - seed="" - echo "area order: fixed declared order (pull_request) - ${run_order[*]}" - echo " to exercise a different order, re-run this workflow with a suite_order_seed input" - else - seed=$((16#${GITHUB_SHA:0:8})) - echo "area order: shuffled with seed $seed (from ${GITHUB_SHA:0:8})" - fi - - if [ -n "$seed" ]; then - # Same shuffle_suites() bin/run-tests.sh uses, so the replay hint below is true by - # construction rather than by two copies happening to agree. - source bin/lib/shuffle.sh - mapfile -t run_order < <(shuffle_suites "$seed" "${run_order[@]}") - echo "area order: ${run_order[*]}" - echo " replay locally: ./bin/run-tests.sh --shuffle --seed $seed" - fi - - fail=0 - for a in "${run_order[@]}"; do - [ -n "${group[$a]:-}" ] || continue - echo "::group::area $a (${group[$a]# })" - # Capture platformio's real exit status (not grep's) via a log file, then show the log - # with the noisy per-variant SKIPPED rows filtered out. - if ! platformio test -e coverage -v ${group[$a]# } \ - --junit-output-path "testreport-$a.xml" > "area-$a.log" 2>&1; then - fail=1 - echo "::error::area $a had test failures" - fi - # Suites outside this area are reported SKIPPED by design (PlatformIO lists every suite - # in the env and marks the unselected ones finished), so those rows are noise here. The - # attribution check below is what catches a suite that was selected and did not run. - grep -v "[[:space:]]SKIPPED$" "area-$a.log" || true - # Per area, so a mismatch names the area it happened in rather than the whole run. - if ! ./bin/check-test-attribution.py --label "area $a" \ - --expect "${group[$a]# }" "testreport-$a.xml"; then - fail=1 - echo "::error::area $a ran suites that did not match their own test binaries" - fi - echo "::endgroup::" - done - exit $fail - - - name: Merge per-area reports into testreport.xml - # Preserve the single-file JUnit contract that downstream consumers rely on - # (pr_tests.yml's summary and generate-reports' Test Report both read testreport.xml). - # The per-area split is only for readable logs; the report stays consolidated. - if: always() # run even when a chunk failed, so the report captures the failures - shell: bash - run: | - python3 - <<'PY' - import glob, xml.etree.ElementTree as ET - out = ET.Element('testsuites') - for f in sorted(glob.glob('testreport-*.xml')): - try: - root = ET.parse(f).getroot() - except ET.ParseError: - continue - # PlatformIO writes a root; fold in a bare too, just in case. - out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root]) - ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True) - PY - - - name: Verify every suite ran its own tests - # Whole-run gate over the merged report: every test_* directory must appear with at least - # one test case, and every case must come from the suite that reported it. The per-area - # check above cannot see an area that never executed - this can. - if: always() # a suite going missing is the finding; do not hide it behind an earlier failure - shell: bash - run: | - set -euo pipefail - mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) - ./bin/check-test-attribution.py --label "coverage (all areas)" \ - --expect "${suites[*]}" testreport.xml - - - name: Capture coverage information - if: always() # run this step even if previous step failed - run: | - sudo apt-get install -y lcov - lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info - sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative. - - - name: Attribution canary - # Guards the guard above: runs two suites the broken way (--without-building, so PlatformIO - # does not relink and both execute the same leftover binary) and requires the checker to - # catch it. Fails if the checker regressed, or if the reproduction stops reproducing - in - # which case the reason both harnesses stopped passing that flag no longer holds. - # - # Lives in this job, not simulator-tests: it relinks $BUILD_DIR/$PROGNAME, and there that - # replaced the daemon binary with a test suite, so the integration test waited for a socket - # a test binary never opens. Here the binary is already per-suite and nothing later needs it. - timeout-minutes: 15 - run: ./bin/test-attribution-canary.sh -e coverage - - - name: Event channel policy tests - run: platformio test -e coverage-event-policy -v --junit-output-path event-policy-testreport.xml - - - name: Verify the event-policy suites ran their own tests - # Expected set read through PlatformIO's own config parser, so it cannot drift from the - # env's test_filter the way a second hand-maintained list would. - run: | - set -euo pipefail - expect=$(python3 -c "from platformio.project.config import ProjectConfig; \ - print(' '.join(ProjectConfig().get('env:coverage-event-policy', 'test_filter', [])))") - ./bin/check-test-attribution.py --label coverage-event-policy \ - --expect "$expect" event-policy-testreport.xml - - name: Save test results if: always() # run this step even if previous step failed uses: actions/upload-artifact@v7 with: - name: platformio-test-report-${{ steps.version.outputs.long }} + name: platformio-test-report-${{ matrix.shard }}-${{ steps.version.outputs.long }} overwrite: true - path: ./*testreport.xml + # Named, not globbed: a test suite can write to the workspace, and the collector merges whatever + # arrives into the report its gate reads. + path: ./testreport-${{ matrix.shard }}.xml - name: Save coverage information - uses: actions/upload-artifact@v7 if: always() # run this step even if previous step failed + uses: actions/upload-artifact@v7 with: - name: lcov-coverage-info-native-platformio-tests-${{ steps.version.outputs.long }} + name: lcov-coverage-info-native-shard-${{ matrix.shard }}-${{ steps.version.outputs.long }} overwrite: true - path: ./coverage_*.info + # Named exactly, for the same reason as the report above: everything uploaded here is + # merged into the published coverage report. + path: ./coverage_tests_${{ matrix.shard }}.info + # Reproduces the false green on purpose (--without-building) and requires the checker to catch it. + # Its own job because it relinks $BUILD_DIR/$PROGNAME, which no shard build dir can survive. + attribution-canary: + name: Attribution Canary + runs-on: ubuntu-24.04-arm + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup native test build + uses: ./.github/actions/setup-native-test + + - name: Disable BUILD_EPOCH + run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini + + - name: Restore PlatformIO cache + uses: actions/cache/restore@v6 + with: + path: ~/.platformio/.cache + key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} + restore-keys: | + pio-coverage-tests- + + - name: Restore ccache + uses: actions/cache/restore@v6 + with: + path: ~/.ccache + key: ccache-native-tests-${{ github.run_id }} + restore-keys: | + ccache-native-tests- + + - name: Attribution canary + timeout-minutes: 15 + run: ./bin/test-attribution-canary.sh -e coverage + + # Load-bearing name: branch protection matches it, and matrix rows are named per shard so none of + # them can carry it. + platformio-tests-gate: + name: Native PlatformIO Tests + needs: platformio-tests + if: ${{ !cancelled() }} + runs-on: ubuntu-slim + steps: + - name: Report the matrix result + env: + RESULT: ${{ needs.platformio-tests.result }} + run: | + set -euo pipefail + echo "shard matrix: $RESULT" + [ "$RESULT" = "success" ] + + # The collector. A shard knows only its own suites and one that never started reports nothing, so + # only here can the union be checked against the canonical set. generate-reports: name: Generate Test Reports runs-on: ubuntu-latest @@ -485,32 +533,85 @@ jobs: actions: read checks: write needs: + - discover - simulator-tests - platformio-tests + - attribution-canary # Run this job even if the previous jobs failed, but skip if the workflow was cancelled. if: ${{ !cancelled() }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Get release version string - run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT" id: version - - name: Download test artifacts + - name: Download per-shard test artifacts uses: actions/download-artifact@v8 with: - name: platformio-test-report-${{ steps.version.outputs.long }} + pattern: platformio-test-report-*-${{ steps.version.outputs.long }} merge-multiple: true + - name: Merge the shard reports into testreport.xml + # Preserve the single-file JUnit contract downstream consumers rely on (pr_tests.yml summary, and + # the Test Report below). The split is only how the run executes; the report stays consolidated. + if: always() # run even when a shard failed, so the report captures the failures + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import glob, xml.etree.ElementTree as ET + out = ET.Element('testsuites') + files = sorted(glob.glob('testreport-*.xml')) + for f in files: + try: + root = ET.parse(f).getroot() + except ET.ParseError: + print(f"WARNING: {f} is not parseable, skipping") + continue + # PlatformIO writes a root; fold in a bare too, just in case. + out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root]) + ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True) + print(f"merged {len(files)} shard report(s) into testreport.xml") + PY + + - name: Verdict - every suite ran, and ran its own tests + # Only here is the union compared against the canonical test_* set, which is what catches a shard + # that failed to start or was cancelled. + if: always() # a suite going missing is the finding; do not hide it behind an earlier failure + env: + SUITES: ${{ needs.discover.outputs.suites }} + run: | + set -euo pipefail + # --expect "" passes over anything, and it is empty exactly when discover failed, which is one of + # the situations this gate exists to catch. + if [ -z "${SUITES// /}" ]; then + echo "::error title=No expected suite set::the discover job produced no suite list, so the whole-run attribution gate has nothing to check against. Treating that as a failure - a gate with an empty expectation passes vacuously." + exit 1 + fi + ./bin/check-test-attribution.py --label "all shards" --expect "$SUITES" testreport.xml + + - name: Save merged test results + # Same artifact name the single-runner job used to publish, so pr_tests.yml's summary keeps + # finding it. + if: always() + uses: actions/upload-artifact@v7 + with: + name: platformio-test-report-${{ steps.version.outputs.long }} + overwrite: true + path: ./testreport.xml + - name: Drop no-status testsuites from the report # PlatformIO emits a self-closing row for every test_* dir # crossed with every hardware variant it cannot run on the native host (~4900 rows). # They carry no pass/fail/skip status and bury the suites that actually ran. Strip # them so the Test Report lists only suites with a real status. Only the copy the # reporter renders is trimmed; the uploaded artifact keeps the full XML. + if: always() run: sed -i -E 's#]*tests="0"[^>]*/>##g' testreport.xml - name: Test Report + if: always() uses: dorny/test-reporter@v3.0.0 with: name: PlatformIO Tests @@ -518,6 +619,7 @@ jobs: reporter: java-junit - name: Download coverage artifacts + if: always() uses: actions/download-artifact@v8 with: pattern: lcov-coverage-info-native-*-${{ steps.version.outputs.long }} @@ -526,9 +628,10 @@ jobs: - name: Generate Code Coverage Report # Merge every tracefile the jobs produced: coverage_base.info (zeroed baseline), - # coverage_integration.info, and one coverage_tests_.info per chunk. lcov - # sums hit counts across them, so the merged report is the union of all chunks - + # coverage_integration.info, and one coverage_tests_.info per shard. lcov + # sums hit counts across them, so the merged report is the union of all shards - # identical to running the whole suite in one job. + if: always() run: | sudo apt-get install -y lcov args=() @@ -539,7 +642,54 @@ jobs: genhtml --quiet --legend --prefix "${PWD}" code-coverage-report/coverage_src.info --output-directory code-coverage-report - name: Save Code Coverage Report + if: always() uses: actions/upload-artifact@v7 with: name: code-coverage-report-${{ steps.version.outputs.long }} path: code-coverage-report + + - name: Final verdict + # States the run result in one place, rather than leaving it reconstructed from a dozen shard logs. + if: always() + env: + SHARDS: ${{ needs.platformio-tests.result }} + SIMULATOR: ${{ needs.simulator-tests.result }} + CANARY: ${{ needs.attribution-canary.result }} + run: | + set -uo pipefail + { + echo "## Native tests" + echo "" + echo "| Part | Result |" + echo "| --- | --- |" + echo "| Suite shards | \`$SHARDS\` |" + echo "| Simulator | \`$SIMULATOR\` |" + echo "| Attribution canary | \`$CANARY\` |" + } >> "$GITHUB_STEP_SUMMARY" + + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import xml.etree.ElementTree as ET + cases = fails = skips = 0 + failed = [] + for suite in ET.parse('testreport.xml').getroot().iter('testsuite'): + n = int(suite.get('tests', '0')) + bad = int(suite.get('failures', '0')) + int(suite.get('errors', '0')) + cases += n + fails += bad + skips += int(suite.get('skipped', '0')) + if bad: + failed.append(f"{suite.get('name', '?')} ({bad})") + print("") + print(f"{cases} test case(s), {fails} failed, {skips} skipped.") + if failed: + print("") + print("Failing suites: " + ", ".join(sorted(failed))) + PY + + for result in "$SHARDS" "$SIMULATOR" "$CANARY"; do + [ "$result" = "success" ] || { + echo "::error title=Native tests failed::shards=$SHARDS simulator=$SIMULATOR canary=$CANARY - see the job summary." + exit 1 + } + done + echo "RESULT: GREEN - every shard, the simulator, and the canary passed." diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index aec3fc6f87..765e2889c6 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -92,11 +92,30 @@ lint: run: ${workspace}/bin/lint-unity-exit.sh ${target} success_codes: [0] read_output_from: stdout + # Flags a 0-means-unset deadline in src/ armed from a raw millis()/getMillis() read. The sum + # lands on 0 once per ~49.7-day wrap and every reader then decides the timer was never set, so + # a pending reboot, shutdown, DFU jump or banner expiry is silently dropped. The fix is + # Time::timerEndsAtMillis() / Time::skipZero() from src/UptimeClock.h. Unlike its neighbours + # this one is blocking: nothing catches it at run time, because the window is one tick in seven + # weeks. A site where 0 really is legal opts out with an `unset-sentinel-ok: ` comment on + # the write or the line above it; the reason is mandatory, so nothing can be muted silently. The + # field list lives in bin/lint-unset-sentinel-millis.sh; read the header there before adding to + # it, and re-run bin/test-lint-unset-sentinel-millis.sh after any change to the rule. + - name: unset-sentinel-millis + files: [cpp-sources] + commands: + - name: lint + output: regex + parse_regex: (?P.+):(?P\d+):(?P\d+):(?P\w+):(?P.+):(?P[a-z-]+) + run: ${workspace}/bin/lint-unset-sentinel-millis.sh ${target} + success_codes: [0] + read_output_from: stdout enabled: - ascii-dash@SYSTEM - too-many-defined@SYSTEM - node-id-format@SYSTEM - unity-exit@SYSTEM + - unset-sentinel-millis@SYSTEM - checkov@3.3.8 - renovate@44.2.3 - prettier@3.9.6 @@ -161,6 +180,12 @@ lint: - test/test_airtime/test_main.cpp - test/test_throttle/test_main.cpp - test/test_uptime_clock/test_main.cpp + # The nightly index templates are halves of one page, not whole documents: + # hintro.html stops mid-
and houtro.html starts with closing tags, so + # that `tree --hintro/--houtro` can splice the file listing between them. + - linters: [ALL] + paths: + - .github/nightly/*.html runtimes: enabled: - python@3.14.4 diff --git a/AGENTS.md b/AGENTS.md index 66a8ca6847..ec315c9dae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,9 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor - **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI - see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure. - **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) repo by the `update_protobufs.yml` workflow (entry point: `bin/regen-protos.sh`). Local edits will be overwritten and create merge conflicts. If a `.proto` change is needed, open a PR against the protobufs repo first, then let the workflow re-sync this repo. - **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. +- **The `src/` naming rule does not apply under `test/`.** Suite directories and `test_*` functions follow their own rule and must not be renamed to match `src/`. What that rule is, and why: [**Test naming** in `.github/copilot-instructions.md`](.github/copilot-instructions.md#test-naming) - authoritative there, not restated here. - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. +- **That limit does not bind a test's header comment.** A test header states what it pins and the regression it guards, at whatever length that takes, and must not be cut to two lines. What it must contain, and why: [**Test comments** in `.github/copilot-instructions.md`](.github/copilot-instructions.md#test-comments) - authoritative there, not restated here. - **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. - **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. diff --git a/CLAUDE.md b/CLAUDE.md index a7dbf6991e..8869be9803 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,15 @@ > > **Need this? It's here.** > -> | | | -> | --------------------------------------------------------- | ---------------------------------------------------------- | -> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | -> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | -> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` | -> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | -> | Observer / event wiring | `src/Observer.h` | +> | | | +> | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | +> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | +> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | +> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` | +> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | +> | Observer / event wiring | `src/Observer.h` | +> | Test naming (the `src/` rule does **not** apply) | [copilot-instructions.md#test-naming](.github/copilot-instructions.md#test-naming) | +> | Test comments (the 1-2 line limit does **not** apply) | [copilot-instructions.md#test-comments](.github/copilot-instructions.md#test-comments) | **Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions, the build system, CI/CD, the native C++ test suite, and the MCP Server & Hardware Test Harness. Read it top-to-bottom before starting any non-trivial change. diff --git a/SECURITY.md b/SECURITY.md index 34977e15a2..f9b59896fa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Firmware Version | Supported | | ---------------- | ------------------ | -| 2.7.x | :white_check_mark: | -| <= 2.6.x | :x: | +| 2.8.x | :white_check_mark: | +| <= 2.7.x | :x: | ## Reporting a Vulnerability diff --git a/bin/bme680_iaq_replay.cpp b/bin/bme680_iaq_replay.cpp index 62d0a5286e..aa65c43c94 100644 --- a/bin/bme680_iaq_replay.cpp +++ b/bin/bme680_iaq_replay.cpp @@ -1,5 +1,6 @@ // Replays a captured BME680 CSV trace (gas_ohms,rh[,bsec_iaq]) through -// BME680IaqEstimator for offline tuning. See docs/bme680_iaq_replay.md. +// BME680IaqEstimator for offline tuning. Build from the repo root: +// c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp #include "modules/Telemetry/Sensor/BME680IaqEstimator.h" diff --git a/bin/build-nrf54l15.sh b/bin/build-nrf54l15.sh new file mode 100755 index 0000000000..9ff90384f7 --- /dev/null +++ b/bin/build-nrf54l15.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -e + +VERSION=$(bin/buildinfo.py long) + +BUILDDIR=.pio/build/$1 +OUTDIR=release + +rm -f $OUTDIR/firmware* +rm -r $OUTDIR/* || true + +# Important to pull latest version of libs into all device flavors, otherwise some devices might be stale +platformio pkg install -e $1 + +echo "Building for $1 with $PLATFORMIO_BUILD_FLAGS" +rm -f $BUILDDIR/firmware* + +# The shell vars the build tool expects to find +export APP_VERSION=$VERSION + +basename=firmware-$1-$VERSION +ota_basename=${basename}-ota + +pio run --environment $1 -t dfu -t mtjson # -v + +cp $BUILDDIR/$basename.elf $OUTDIR/$basename.elf + +echo "Copying merged hex (bootloader + SoftDevice + application)" +cp $BUILDDIR/$basename.hex $OUTDIR/$basename.hex + +echo "Copying nRF54L dfu (OTA) file" +cp $BUILDDIR/$basename.zip $OUTDIR/$ota_basename.zip + +echo "Copying manifest" +cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json || true diff --git a/bin/check-all.sh b/bin/check-all.sh index 3186899aa5..0b22069efa 100755 --- a/bin/check-all.sh +++ b/bin/check-all.sh @@ -50,7 +50,8 @@ trap 'rm -f "$LOG"' EXIT # Keep streaming to the console so the CI log reads exactly as it did before; tee a copy for the # post-mortem classification below. -pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG" +# define PROGMEM to avoid cppcheck reporting unknownMacro (--skip-packages excludes Arduino.h) +pio check --flags "-DAPP_VERSION=${APP_VERSION} -DPROGMEM= --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG" STATUS=${PIPESTATUS[0]} if [[ $STATUS -eq 0 ]]; then diff --git a/bin/check-test-attribution.py b/bin/check-test-attribution.py index 2d3d258e46..311086f93f 100755 --- a/bin/check-test-attribution.py +++ b/bin/check-test-attribution.py @@ -55,8 +55,12 @@ def collect(paths): cases = {} for path in paths: try: - # The input is the JUnit report PlatformIO just wrote in this same run, not untrusted - # data, and defusedxml is not installed for this job. + # The input is a JUnit report PlatformIO wrote, not untrusted data, and defusedxml is + # not installed for this job. In CI the collector reads these back from artifacts + # rather than off the same disk that produced them, so what keeps that true is the + # upload step naming the one file the shard was told to write instead of globbing: + # a test suite can write to the workspace, and under a glob its own XML would ride + # along into the merged report. See .github/workflows/test_native.yml. # nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse root = ET.parse(path).getroot() except (ET.ParseError, OSError) as exc: diff --git a/bin/config.d/lora-usb-rak19714.yaml b/bin/config.d/lora-usb-rak19714.yaml new file mode 100644 index 0000000000..9d4a3fed28 --- /dev/null +++ b/bin/config.d/lora-usb-rak19714.yaml @@ -0,0 +1,18 @@ +Meta: + name: rak19714 + support: official + compatible: + - usb + +Lora: + Module: sx1262 + CS: 0 + IRQ: 6 + Reset: 2 + Busy: 4 + RXen: 1 + DIO2_AS_RF_SWITCH: true + spidev: ch341 + DIO3_TCXO_VOLTAGE: true + USB_PID: 0x5512 + USB_VID: 0x1A86 diff --git a/bin/lib/test-state.sh b/bin/lib/test-state.sh index 138d57e906..03b818394f 100644 --- a/bin/lib/test-state.sh +++ b/bin/lib/test-state.sh @@ -65,14 +65,20 @@ state_fingerprint() { # Scoped to this user's processes: /proc//environ is unreadable for anyone else's anyway, and # the narrower sweep costs ~270ms against ~460ms for all of /proc. state_find_survivors() { - local home="$1" pid + local home="$1" pid entry [[ -n $home ]] || return 0 for pid in $(ps -u "$(id -u)" -o pid= 2>/dev/null); do [[ $pid == "$$" ]] && continue - # Grouped so the redirect's own open failure is silenced too, not just tr's stderr. - if { tr '\0' '\n' <"/proc/$pid/environ"; } 2>/dev/null | grep -qxF "HOME=$home"; then - printf '%s\n' "$pid" - fi + # In-shell, not `tr | grep -qxF`: -q closes the pipe on the match, tr takes SIGPIPE, and + # under `set -o pipefail` the hit reports as a miss. Grouped so a vanished pid is silent. + { + while IFS= read -r -d '' entry; do + if [[ $entry == "HOME=$home" ]]; then + printf '%s\n' "$pid" + break + fi + done <"/proc/$pid/environ" + } 2>/dev/null done } diff --git a/bin/lint-unset-sentinel-millis.sh b/bin/lint-unset-sentinel-millis.sh new file mode 100755 index 0000000000..d3348cb8f4 --- /dev/null +++ b/bin/lint-unset-sentinel-millis.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +# lint-unset-sentinel-millis.sh - flag a 0-means-unset deadline armed from a raw clock read. +# +# A family of fields in this tree uses 0 to mean "unarmed", and is then read as `if (field)` or +# `field != 0` before the deadline is compared. src/main.h says so out loud: +# +# extern uint32_t enterDfuAtMsec; // 0 = unset; else millis() deadline for the deferred DFU jump +# +# Arming one of those with `field = millis() + delay` is correct for ~49.7 days and then wrong for +# one tick: the sum lands on 0 exactly once per wrap, and at that instant every reader decides the +# timer was never set. A pending reboot, shutdown, DFU jump or banner expiry is silently dropped. +# `field = millis()` has the same hole for a stamp. The fix is Time::timerEndsAtMillis(delay) for a +# countdown, or Time::skipZero(Time::getMillis()) for a stamp; both are in src/UptimeClock.h, whose +# static_asserts pin the behaviour this rule steers people toward. +# +# Why a name list and not a pattern over every `millis() + x`: most sums are fine. A local +# `const uint32_t deadline = millis() + timeoutMs;` that is compared a few lines later never stores +# 0 for anything to misread, and src/ has nine such sites that are all correct. The 0 contract is +# also declared in one file and enforced in six others - rebootAtMsec is written in AdminModule.cpp +# and tested in Power.cpp, PowerFSM.cpp, main.cpp, Screen.cpp and portduino/USBHal.h - so no +# single-file scan can infer it. The list below is therefore explicit. +# +# Two kinds of field are on it: +# +# * Fields whose 0 IS the unset state. These must be armed through the helpers. +# * Fields whose unset state is a separate flag, so 0 is a value they may legally hold. These are +# listed so that the rule notices them, and each arm site carries an opt-out comment naming the +# flag that actually carries the armed state. Listing-plus-opt-out beats silent omission: if +# someone later rewrites `if (haveSample && ...)` into `if (lastSampleMs && ...)`, the field has +# quietly acquired the contract, and the opt-out comment is sitting right there at the write to +# be reconsidered. +# +# OPTING OUT. Put `unset-sentinel-ok: ` in a comment, either on the line of the write or on +# a comment line above it: +# +# // unset-sentinel-ok: busyTx carries the armed state, so 0 is a legal timestamp here +# lastTxStart = Time::getMillis(); +# +# The reason is mandatory - a bare `unset-sentinel-ok` with nothing after the colon is reported +# rather than honoured, so a site cannot be muted without saying why. trunk-ignore works too, but +# prefer this: it states the justification at the write, and it also applies when the script is run +# directly rather than through trunk. +# +# Adding a field: append it to SENTINELS. If its 0 is the unset state, fix the arm sites; if a +# separate flag carries the armed state, add an opt-out comment at each write. Verify which of the +# two it is by reading every site that READS the field - one missed read is what makes this wrong. +# +# Three fields are absent on purpose, and NOT because 0 is safe there. Each was examined and came +# back unresolved rather than exempt, so listing one would mean stamping an opt-out over a claim +# that does not hold: +# +# * nagCycleCutoff. ExternalNotificationModule::handleInputEvent reads +# `if (nagCycleCutoff != UINT32_MAX)` without consulting isNagging, so at that read the field is +# its own armed flag with UINT32_MAX - not 0 - as the sentinel, and the arm site can land there. +# skipZero() would not help: it lifts 0 to 1 and leaves UINT32_MAX alone, by design. The fix is +# to gate that read on isNagging, a behaviour change that belongs in its own PR. +# * TouchScreenBase::_start. Overloaded as both an event stamp and a `+ 30000` suppression +# deadline compared by signed subtraction, so a near-zero value reads as "long ago" rather than +# "armed 30s out", and LONG_PRESS re-fires. skipZero() does not fix that either - 1 reads as +# long-ago exactly as 0 does. It needs the stamp and the deadline held separately. +# * StoreForwardModule::retry_delay. Has no reads at all today, so nothing misbehaves yet; +# exempting it now would pre-approve the raw arm for whoever implements the retry its own +# comment promises. +# +# Also absent, for the ordinary reason that 0 carries no meaning there: locals such as +# NodeInfoModule's lastNodeInfo, derived per call from TransmitHistory rather than stored. +# +# Emitted at "error" rather than the "note" its neighbours use, because nothing catches this at run +# time: the window is one tick in seven weeks, so a test run, a soak and a bench session all pass. +# The tree has zero unexplained violations, so blocking costs nothing and is the only thing that +# actually prevents the next one. +# +# Not handled: a write through an alias (`uint32_t &d = rebootAtMsec; d = millis() + 5;`) or through +# a pointer, and a sentinel armed by a helper that takes it by reference. None occur, and tracking +# aliases is untested code guarding a case that does not exist. Raw string literals (R"(...)") are +# not tokenised either, for the same reason as in bin/lint-unity-exit.sh. +# +# bin/test-lint-unset-sentinel-millis.sh is this rule's self-test; every false positive and false +# negative found in review belongs there as a fixture. +# +# Emits one line per finding in the format +# ::::: +# which trunk parses via parse_regex. Always exits 0; findings go to stdout. + +set -uo pipefail + +# Millisecond fields this rule watches. See the notes above before editing. +SENTINELS='rebootAtMsec|shutdownAtMsec|enterDfuAtMsec|alertBannerUntil|pulseOffAt|delayedPulseAt|ntp_renew|tx_after|suppressTouchTapUntilMs|fixHoldEnds|lastChipRecoveryMs|activeReceiveStart|rxTimeMsec|lastInterruptTime|lastSentReply|lastSort|lastTxStart|lastHeartbeat|lastAveraged|lastSampleMs|lastIaqMs|last_format_ms|nextRepeatX|nextRepeatY|_cached_next_run|connect_time_ms|directionStartTime|downStartTime|fileage|keyDownStart|lastAuthFailure|lastContactMsec|lastDirectResponseMs|lastDiskSave|lastDownLongEventTime|lastDrawMsec|lastGpsSend|lastHeadingAtMs|lastHeapLogTime|lastHeapWarning|lastLfsFormatMs|lastMillis|lastPressLongEventTime|lastRemoteSessionMs|lastSentStatsToPhone|lastSentToPhone|lastSetFromPhoneNtpOrGps|lastTraceRouteTime|lastUpLongEventTime|lastUpdateMs|last_probe|last_report_to_map|lastrun_ntp|navBarLastShown|pressStartTime|startSendConditions|suppressFromMs|touchResumeAtMs|upStartTime' + +for target in "$@"; do + [[ -f $target ]] || continue + + # Path is reported relative to the workspace so findings are clickable from the repo root. + rel="${target#"$PWD"/}" + + # Firmware sources only. Tests construct raw wrap values on purpose - pinning what happens at + # 0xFFFFFFFF is the point of test_uptime_clock - and UptimeClock.h itself defines the helpers. + [[ $rel == src/* ]] || continue + [[ $rel == src/UptimeClock.h ]] && continue + + awk -v path="$rel" -v sentinels="$SENTINELS" ' + # Return the line with comments and string/char literals removed, carrying /* ... */ state + # across lines, and fill colmap[] mapping each position in the result back to its raw column. + # Character-level rather than layered regexes, for the reasons spelled out at length in + # bin/lint-unity-exit.sh: a /* inside a string literal flips comment state and hides real code, + # and an assignment quoted inside a log string reads as one. Literals collapse to a space so two + # tokens cannot be glued together. + # + # Also sets CMT to this line s comment text, which is where an opt-out has to live. Collecting + # it here rather than re-scanning the raw line is what stops `LOG_DEBUG("unset-sentinel-ok: x")` + # from muting anything: a string literal is not a comment. + function strip_noncode(s, out, i, n, c, two, q) { + n = length(s); i = 1; out = "" + delete colmap + CMT = "" + while (i <= n) { + if (in_block) { + if (substr(s, i, 2) == "*/") { in_block = 0; i += 2 } + else { CMT = CMT substr(s, i, 1); i++ } + continue + } + two = substr(s, i, 2) + if (two == "//") { CMT = CMT " " substr(s, i + 2); return out } + if (two == "/*") { in_block = 1; i += 2; continue } + c = substr(s, i, 1) + if (c == "\"" || c == "'"'"'") { # skip a whole literal, honouring backslash escapes + q = c + out = out " "; colmap[length(out)] = i + i++ + while (i <= n) { + c = substr(s, i, 1) + if (c == "\\") { i += 2; continue } + i++ + if (c == q) break + } + continue + } + out = out c; colmap[length(out)] = i; i++ + } + return out + } + + # An opt-out only counts with something after the colon. A bare marker is reported instead of + # honoured, so "shut this up" is not available without writing down why. + function has_reasoned_ok(t) { return (t ~ /unset-sentinel-ok:[ \t]*[^ \t]/) } + function has_bare_ok(t) { return (t ~ /unset-sentinel-ok/) && !has_reasoned_ok(t) } + + # Is the character before position `at` part of an identifier? Used to require a token boundary, + # so myRebootAtMsec is not mistaken for rebootAtMsec. A `.`, `->` or `::` qualifier is a + # boundary on purpose: txp->tx_after and NotificationRenderer::alertBannerUntil are the real + # call sites and must still be seen. + function ident_before(s, at, c) { + if (at <= 1) return 0 + c = substr(s, at - 1, 1) + return (c ~ /[A-Za-z0-9_]/) + } + + # Brace depth, and which depths are a class/struct BODY rather than a function body. Needed + # because a typed declaration means opposite things in the two places: inside a function it is a + # throwaway local that shadows the field, but at class scope it IS the field, with an initializer + # that can read the clock - src/modules/SerialModule.h does exactly that. Treating the second as a + # local silently excused a real arm site. + function update_scope(s, i, c) { + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "{") { + depth++ + if (pending_class) { class_body[depth] = 1; pending_class = 0 } + } else if (c == "}") { + delete class_body[depth] + if (depth > 0) depth-- + } + } + } + + # Is the statement being judged sitting directly in a class body? `opened` is the net brace + # count seen earlier in this same statement, which update_scope() has not applied yet: a body + # opened on this very line (a one-line inline method) puts the statement inside a function, not + # in the class body. + function at_class_scope(opened) { + # The class body opened on this very statement, so its own brace is the class brace: exactly + # one unmatched brace means class scope, two or more means a method body inside it. + if (stmt_class_brace) return (opened == 1) + return ((depth in class_body) && opened <= 0) + } + + # Net unmatched `{` in the first `at` characters of the statement being judged. + function braces_before(s, at, i, c, n) { + n = 0 + for (i = 1; i < at && i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "{") n++ + else if (c == "}") n-- + } + return n + } + + # A local declaration that happens to reuse a sentinel name shadows the field and carries none + # of its contract, so it is not this rule business. Detected by a type-ish token immediately + # before the name - `uint32_t tx_after = millis() + d;` declares a local, `tx_after = ...` does + # not. Kept narrow: only the spellings this tree actually uses for a millis value. + # + # Only honoured inside a function body; see update_scope() for why class scope is different. + function is_declaration(s, at, head) { + if (at_class_scope(braces_before(s, at))) return 0 + head = substr(s, 1, at - 1) + sub(/[ \t]*(\*|&)?[ \t]*$/, "", head) + return (head ~ /(^|[^A-Za-z0-9_])(uint32_t|uint64_t|int32_t|unsigned[ \t]+long|unsigned[ \t]+int|unsigned|long|int|auto|size_t|TickType_t)$/) + } + + # Does this statement read a clock directly? Matches millis(), Time::getMillis() and any wrapper + # whose name ends in millis, which is how almost every clock read in this tree spells itself, plus + # Zephyr k_uptime_get_32() - the nRF54L15 BLE code has no millis() at all and wraps at 32 bits just + # the same, so a sentinel armed from it needs the same guard. + function reads_clock(s) { return (s ~ /[Mm]illis[ \t]*\(/ || s ~ /k_uptime_get_32[ \t]*\(/) } + + # Already routed through a helper that dodges 0, so it is the fix rather than the defect. Note + # stampMillis() belongs here even though its name ends in millis: a local read through it holds a + # value that is already non-zero, so a write from that local is safe and must not be flagged. + function is_safe_arm(s) { return (s ~ /skipZero/ || s ~ /timerEndsAtMillis/ || s ~ /stampMillis/) } + + # Does the expression apply + or - to an already-dodged value at the OUTERMOST level? A dodged + # value is safe to store or copy, but not to do arithmetic on: stampMillis() guarantees only its + # own result, and `now + 5000` can carry a non-zero stamp straight back onto 0 - 0xFFFFEC78 + 5000 + # is exactly 0. That sum is what Time::timerEndsAtMillis() exists to dodge, so it has to be + # reported rather than excused. + # + # Depth-aware on purpose: the operator inside Time::skipZero(getMillis() - msAgo) is at depth 1 + # and is fine, because the helper wraps the result. So is the `? :` in the ternary arming form, + # which has no top-level + or - at all. + function toplevel_arith(s, i, n, c, d, prev) { + n = length(s); d = 0; prev = "" + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "(") d++ + else if (c == ")") d-- + else if (d == 0 && (c == "+" || c == "-")) { + # not a unary sign, and not part of -> or ++/-- + if (prev != "" && prev != "(" && prev != "," && prev != "=" && prev != "+" && + prev != "-" && prev != "*" && prev != "/" && prev != "?" && prev != ":" && + substr(s, i + 1, 1) != ">") + return 1 + } + if (c != " " && c != "\t") prev = c + } + return 0 + } + + # Remember a local that was just assigned from a clock, so `field = now` a few lines later is + # recognised as the raw arm it really is. Without this the rule is blind to the commonest shape + # in the tree - `unsigned long now = millis();` at the top of a runOnce(), then half a dozen + # `xStartTime = now;` writes below it - and listing those fields would buy no protection at all. + # + # Deliberately shallow: one hop, within one function, name-based. It records ` = ` + # and ` = `, and it FORGETS the name when the same local is + # reassigned from anything else, so a variable reused for something unrelated stops matching. + # Taint is dropped at every function boundary (see the reset below), because a name that means a + # clock in one function usually means nothing in the next. + function note_taint(s, lhs, rhs, eqp, semi) { + eqp = index(s, "=") + if (eqp == 0) return + if (substr(s, eqp + 1, 1) == "=") return # `==` is a comparison + if (substr(s, eqp - 1, 1) ~ /[-+*\/%&|^!<>=]/) return # `+=`, `!=`, ... are not plain + lhs = substr(s, 1, eqp - 1) + rhs = substr(s, eqp + 1) + # This assignment only. Without the cut, a second statement on the same line teaches taint + # for the first - the same defect the judging path was fixed for. + semi = index(rhs, ";") + if (semi > 0) rhs = substr(rhs, 1, semi - 1) + # Take the last identifier on the left, which skips any type and `*`/`&` decoration. + if (!match(lhs, /[A-Za-z_][A-Za-z0-9_]*[ \t]*$/)) return + lhs = substr(lhs, RSTART, RLENGTH) + sub(/[ \t]+$/, "", lhs) + if (lhs == "") return + if (is_safe_arm(rhs) && !toplevel_arith(rhs)) { + delete tainted[lhs] + normalized[lhs] = 1 # holds a value that has already dodged 0 + } else if (reads_clock(rhs) || rhs_is_tainted(rhs)) { + tainted[lhs] = 1 + delete normalized[lhs] + } else { + delete tainted[lhs] # reused for something else - stop trusting the name + delete normalized[lhs] + } + } + + # Is any tainted local read in this expression, as a whole token? Token-bounded so a tainted + # `now` does not match `nowMs` or `snowfall`. Local names are plain identifiers, so using one + # as a match() pattern carries no regex metacharacters. + function rhs_is_normalized(s, name, t, p, before, after) { + for (name in normalized) { + t = s + while (match(t, name)) { + p = RSTART + before = (p == 1) ? " " : substr(t, p - 1, 1) + after = substr(t, p + length(name), 1) + if (before !~ /[A-Za-z0-9_]/ && after !~ /[A-Za-z0-9_]/) return 1 + t = substr(t, p + length(name)) + if (t == "") break + } + } + return 0 + } + + function rhs_is_tainted(s, name, t, p, before, after) { + for (name in tainted) { + t = s + while (match(t, name)) { + p = RSTART + before = (p == 1) ? " " : substr(t, p - 1, 1) + after = substr(t, p + length(name), 1) + if (before !~ /[A-Za-z0-9_]/ && after !~ /[A-Za-z0-9_]/) return 1 + t = substr(t, p + length(name)) + if (t == "") break + } + } + return 0 + } + + BEGIN { LINE_CAP = 12 } # give up accumulating a statement after this many lines + + { + code = strip_noncode($0) + + # A class or struct header whose body opens on this line or the next. Anchored at the start + # of the line, because the keyword appears mid-line in shapes that are not class bodies at + # all: `template ` on a function, and an elaborated type in a parameter list such as + # `void g(struct Bar *b)`. Both used to mark the following FUNCTION body as class scope, which + # then reported every typed local in it. Not a forward declaration either, which ends in a + # semicolon with no brace. + stmt_class_brace = 0 + if (code ~ /^[ \t]*(class|struct)[ \t]+[A-Za-z_][A-Za-z0-9_]*/) { + # The body may open on this line or the next. `class Foo;` is a forward declaration and + # opens nothing; `class Foo { uint32_t t = millis(); };` opens AND closes here, so the + # trailing semicolon cannot be used to rule it out. + if (code ~ /\{/) { + # Body opens on this line. stmt_class_brace judges THIS statement (a one-liner + # whose member sits after the brace); pending_class is still needed so + # update_scope() registers the body for the lines that follow. + stmt_class_brace = 1 + pending_class = 1 + } else if (code !~ /;[ \t]*$/) { + pending_class = 1 # body opens on a later line + } + } + + # A closing brace in column 1 is the end of a function as this tree formats code, and a + # local called `now` there has nothing to do with the one in the next function. clang-format + # is enforced repo-wide, so this is reliable enough for a one-hop heuristic. + if ($0 ~ /^\}/) delete tainted + + # An opt-out is sticky until the next statement that actually contains code is judged. That + # is what lets it sit on its own line above the write, however many comment lines intervene, + # without leaking past the statement it was written for. + if (has_reasoned_ok(CMT)) pending_ok = 1 + if (has_bare_ok(CMT)) pending_bare = 1 + + if (stmt == "") { start = NR; nhits = 0 } + base = length(stmt) + 1 # the leading space added below shifts everything by one + stmt = stmt " " code + + # Record every ` =` on this line, with its position in the accumulated statement + # so the RHS can be judged once the statement is whole. + rest = code; off = 0 + while (match(rest, "(" sentinels ")[ \t]*=")) { + p = RSTART; len = RLENGTH + off += p + # A plain assignment only: ==, !=, <=, >=, +=, -= and friends are reads or updates, + # not an arming write, and `=` must not be the head of `==`. + eq = off + len - 1 + prev = (eq - 1 >= 1) ? substr(code, eq - 1, 1) : " " + nxt = (eq + 1 <= length(code)) ? substr(code, eq + 1, 1) : " " + if (prev !~ /[-+*\/%&|^!<>=]/ && nxt != "=" && !ident_before(code, off) && + !is_declaration(code, off)) { + nhits++ + hit_at[nhits] = base + eq # index of the `=` within stmt + hit_line[nhits] = NR + hit_col[nhits] = colmap[off] + hit_name[nhits] = substr(code, off, len - 1) + sub(/[ \t]*$/, "", hit_name[nhits]) + } + rest = substr(rest, p + len - 1) + off += len - 2 + } + + # End of statement: judge each recorded write against its own right-hand side. + if (code ~ /;/ || NR - start >= LINE_CAP) { + for (k = 1; k <= nhits; k++) { + # The right-hand side of THIS write only, cut at its own semicolon. Without the + # cut, rhs ran on to the end of the accumulated statement and judged a neighbour + # as if it belonged to this write - wrongly in both directions. On + # rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10); + # the later helper call suppressed a genuine raw arm, and on + # rebootAtMsec = otherDeadline; shutdownAtMsec = millis(); + # the later millis() reported a safe copy. Both are fixtures now. + rhs = substr(stmt, hit_at[k] + 1) + semi = index(rhs, ";") + if (semi > 0) rhs = substr(rhs, 1, semi - 1) + + # Only a raw clock read is a finding. `= 0` disarms, a copy from another + # variable inherits whatever that one did, and anything already routed through + # the helpers is the fix rather than the defect. Matching `millis` loosely + # covers millis(), Time::getMillis() and any wrapper ending in millis. + # Arithmetic applied on top of an already-dodged value can wrap it back onto 0, + # so it is reported even though a helper appears in the expression. + if ((is_safe_arm(rhs) || rhs_is_normalized(rhs)) && !toplevel_arith(rhs)) { + continue # stored or copied straight through - safe + } + if (is_safe_arm(rhs) && !toplevel_arith(rhs)) + continue # already routed through the helpers + if (!reads_clock(rhs) && !rhs_is_tainted(rhs) && !rhs_is_normalized(rhs)) + continue # not a clock read, directly or via a local holding one + if (pending_ok) + continue # opted out, with a reason, at the write + if (pending_bare) + printf "%s:%d:%d:%s:%s:%s\n", path, hit_line[k], hit_col[k], "error", + "unset-sentinel-ok needs a reason after the colon saying why 0 is legal for " hit_name[k] " (see bin/lint-unset-sentinel-millis.sh)", + "unset-sentinel-millis" + else + printf "%s:%d:%d:%s:%s:%s\n", path, hit_line[k], hit_col[k], "error", + hit_name[k] " is 0-means-unset - arm it with Time::timerEndsAtMillis(delay), or Time::skipZero(Time::getMillis()) for a stamp (see src/UptimeClock.h)", + "unset-sentinel-millis" + } + # Learn from this statement before dropping it: `now = millis()` here is what makes + # `field = now` below recognisable. Done after judging so a sentinel write cannot + # taint its own name. + if (nhits == 0) note_taint(stmt) + + # Comment-only lines carry an opt-out toward the write below them, so they must not + # clear it; a statement with real code in it consumes it. + if (stmt ~ /[^ \t]/) { pending_ok = 0; pending_bare = 0 } + stmt = "" + nhits = 0 + } + + # Last, so every brace on this line counts toward the scope of the NEXT line: a declaration + # sits at the depth its own line opened with. + update_scope(code) + } + ' "$target" +done + +exit 0 diff --git a/bin/optional-modules.py b/bin/optional-modules.py new file mode 100644 index 0000000000..443a608084 --- /dev/null +++ b/bin/optional-modules.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# trunk-ignore-all(ruff/F821) +# trunk-ignore-all(flake8/F821): For SConstruct imports +# +# Registers optional modules dropped into src/modules/optional/. +# +# A module is a directory / holding .h, which declares `void setup();`. This +# writes $BUILD_DIR/OptionalModules.h with an include and a setup call for each one found; +# Modules.cpp picks that header up through __has_include and calls OPTIONAL_MODULES_SETUP(). +# +# src/modules/optional/ does not exist in a stock checkout, so a stock build generates a header that +# defines nothing and registers nothing. Sources under the directory are compiled by the default +# recursive build_src_filter, so dropping a module in needs no platformio.ini edit. +import os +import re + +Import("env") + +# The directory name becomes a C++ call, so it has to be a usable identifier. +identifier = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +optionalDir = os.path.join(env["PROJECT_DIR"], "src", "modules", "optional") + +names = [] +if os.path.isdir(optionalDir): + for entry in sorted(os.listdir(optionalDir)): + entryDir = os.path.join(optionalDir, entry) + if not os.path.isdir(entryDir): + continue + if not identifier.match(entry): + print(f"optional-modules: skipping {entry}/, setup{entry}() is not a valid identifier") + elif os.path.isfile(os.path.join(entryDir, entry + ".h")): + names.append(entry) + else: + print(f"optional-modules: skipping {entry}/, no {entry}.h") + +lines = ["// Generated by bin/optional-modules.py. Do not edit.", "#pragma once", ""] +for name in names: + lines.append(f'#include "modules/optional/{name}/{name}.h"') +if names: + lines.append("") + lines.append("#define OPTIONAL_MODULES_SETUP() \\") + lines.append(" do { \\") + for name in names: + lines.append(f" setup{name}(); \\") + lines.append(" } while (0)") +lines.append("") +content = "\n".join(lines) + +buildDir = env.subst("$BUILD_DIR") +os.makedirs(buildDir, exist_ok=True) +header = os.path.join(buildDir, "OptionalModules.h") + +# Rewrite only on a change, so an unchanged set of modules does not keep rebuilding Modules.cpp. +previous = None +if os.path.isfile(header): + with open(header, encoding="utf-8") as f: + previous = f.read() +if previous != content: + with open(header, "w", encoding="utf-8") as f: + f.write(content) + +env.Append(CPPPATH=[buildDir]) + +if names: + print("optional-modules: " + ", ".join(names)) diff --git a/bin/org.meshtastic.meshtasticd.desktop b/bin/org.meshtastic.meshtasticd.desktop index 215c7ee054..00b59f3c83 100644 --- a/bin/org.meshtastic.meshtasticd.desktop +++ b/bin/org.meshtastic.meshtasticd.desktop @@ -1,6 +1,6 @@ [Desktop Entry] -Name=Meshtastic -Comment=Meshtastic App +Name=MeshtasticD +Comment=Meshtastic Daemon + MUI Exec=meshtasticd Icon=org.meshtastic.meshtasticd Terminal=true diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml index ed5338af64..9b762720bc 100644 --- a/bin/org.meshtastic.meshtasticd.metainfo.xml +++ b/bin/org.meshtastic.meshtasticd.metainfo.xml @@ -2,7 +2,7 @@ org.meshtastic.meshtasticd - Meshtastic + MeshtasticD Decentralized mesh communication CC-BY-4.0 @@ -13,6 +13,9 @@ +

+ MeshtasticD is an app that allows you to use your Linux computer as a Meshtastic node (using a CH341 USB LoRa radio). +

Meshtastic is an open source project for creating off-grid, affordable, and resilient communication with LoRa mesh networks.

@@ -87,6 +90,18 @@ + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.8.1 + + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.8.0 + + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.26 + + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.25 + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24 diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py index 08d010e086..7ded02b121 100644 --- a/bin/platformio-custom.py +++ b/bin/platformio-custom.py @@ -41,8 +41,11 @@ def infer_architecture(board_cfg): return "rp2350" if "nrf52" in mcu_l or "nrf52840" in mcu_l: return "nrf52840" + if "nrf54l15" in mcu_l: + return "nrf54l15" if "stm32" in mcu_l: return "stm32" + print(f"mtjson: could not infer architecture from MCU '{mcu_l}'") return None def run_size_tool(env, flag, purpose): @@ -255,8 +258,12 @@ def manifest_write(files, env, ram_bytes=None, flash_bytes=None): if parsed is not None and parsed != "": device_meta[manifest_key] = parsed - # Determine architecture once; if we can't infer it, skip manifest generation - board_arch = device_meta.get("architecture") or infer_architecture(env.BoardConfig()) + # Board MCU wins over a hand-typed custom_meshtastic_architecture: only the + # spellings infer_architecture emits are recognized downstream. + declared = device_meta.get("architecture") + board_arch = infer_architecture(env.BoardConfig()) or declared + if declared and declared != board_arch: + print(f"{pioenv}: architecture '{declared}' overridden with '{board_arch}'") if not board_arch: print(f"Skipping mtjson write for unknown architecture (env={env.get('PIOENV')})") return @@ -299,6 +306,30 @@ with open(jsonLoc) as f: jsonStr = re.sub("//.*","", f.read(), flags=re.MULTILINE) userPrefs = json.loads(jsonStr) +# Channels::initDefaultChannel() applies a configured index as a whole, so resolve per-field +# optionality here: any field the vendor left out gets the value that function would have kept. +MAX_NUM_CHANNELS = 8 +CHANNEL_FIELD_DEFAULTS = { + "PSK": "{ 0x01 }", # short-form index into the well-known default PSK + "NAME": "", + "PRECISION": "0", + "IS_MUTED": "false", + "UPLINK_ENABLED": "false", + "DOWNLINK_ENABLED": "false", +} +channelsToWriteRaw = userPrefs.get("USERPREFS_CHANNELS_TO_WRITE", "1") +channelsToWrite = int(channelsToWriteRaw, 16 if channelsToWriteRaw.lower().startswith("0x") else 10) +if channelsToWrite > MAX_NUM_CHANNELS: + sys.exit( + f"userPrefs.jsonc: USERPREFS_CHANNELS_TO_WRITE is {channelsToWrite}, " + f"the channel table holds {MAX_NUM_CHANNELS}" + ) +for i in range(MAX_NUM_CHANNELS): + prefix = f"USERPREFS_CHANNEL_{i}_" + if any(k.startswith(prefix) for k in list(userPrefs)): + for field, default in CHANNEL_FIELD_DEFAULTS.items(): + userPrefs.setdefault(prefix + field, default) + pref_flags = [] # Pre-process the userPrefs for pref in userPrefs: @@ -306,6 +337,8 @@ for pref in userPrefs: pref_flags.append("-D" + pref + "=" + userPrefs[pref]) elif userPrefs[pref].lstrip("-").replace(".", "").isdigit(): pref_flags.append("-D" + pref + "=" + userPrefs[pref]) + elif re.fullmatch(r"0[xX][0-9a-fA-F]+", userPrefs[pref]): + pref_flags.append("-D" + pref + "=" + userPrefs[pref]) elif userPrefs[pref] == "true" or userPrefs[pref] == "false": pref_flags.append("-D" + pref + "=" + userPrefs[pref]) elif userPrefs[pref].startswith("meshtastic_"): diff --git a/bin/test-lint-unset-sentinel-millis.sh b/bin/test-lint-unset-sentinel-millis.sh new file mode 100755 index 0000000000..ab0923182f --- /dev/null +++ b/bin/test-lint-unset-sentinel-millis.sh @@ -0,0 +1,481 @@ +#!/usr/bin/env bash +# test-lint-unset-sentinel-millis.sh - self-test for bin/lint-unset-sentinel-millis.sh. +# +# The scanner has to tell an arming write apart from a read, a disarm, a shadowing local, a quoted +# string and an already-fixed site. Each case below is a fixture: a snippet, the lines that must be +# reported, and nothing else. Run it after any change to the rule. +# +# Exit 0 = all cases pass, 1 = at least one case failed. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LINT="$ROOT_DIR/bin/lint-unset-sentinel-millis.sh" + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +FAILURES=0 + +# run_case +run_case() { + local name="$1" expect="$2" body="$3" + # The rule only looks at src/, and reports paths relative to $PWD, so the fixture has to live + # under a src/ directory that is also the working directory's child. + local dir="$WORK/case" + rm -rf "$dir" + mkdir -p "$dir/src" + printf '%s\n' "$body" >"$dir/src/fixture.cpp" + + local got + got=$(cd "$dir" && "$LINT" src/fixture.cpp | awk -F: '{print $2}' | paste -sd, -) + local want + want=$(printf '%s' "$expect" | paste -sd, -) + + if [[ $got == "$want" ]]; then + echo "PASS $name" + else + echo "FAIL $name: expected lines [$want], got [$got]" + FAILURES=$((FAILURES + 1)) + fi +} + +# run_case_h - same, but the fixture is a HEADER, so class-scope +# cases can be pinned. A typed declaration means opposite things in a class body and a function body. +run_case_h() { + local name="$1" expect="$2" body="$3" + local dir="$WORK/case_h" + rm -rf "$dir" + mkdir -p "$dir/src" + printf '%s\n' "$body" >"$dir/src/fixture.h" + + local got want + got=$(cd "$dir" && "$LINT" src/fixture.h | awk -F: '{print $2}' | paste -sd, -) + want=$(printf '%s' "$expect" | paste -sd, -) + + if [[ $got == "$want" ]]; then + echo "PASS $name" + else + echo "FAIL $name: expected lines [$want], got [$got]" + FAILURES=$((FAILURES + 1)) + fi +} + +# --- must be reported --------------------------------------------------------- + +run_case "bare millis() sum" "2" 'void f() { + rebootAtMsec = millis() + 5000; +}' + +run_case "bare millis() stamp" "2" 'void f() { + shutdownAtMsec = millis(); +}' + +run_case "Time::getMillis() sum" "2" 'void f() { + enterDfuAtMsec = Time::getMillis() + 25; +}' + +run_case "qualified and arrow targets" "2 +3" 'void f(MeshPacket *txp) { + NotificationRenderer::alertBannerUntil = millis() + durationMs; + txp->tx_after = millis() + delay; +}' + +run_case "statement split across lines" "2" 'void f() { + ntp_renew = + millis() + 43200 * 1000; +}' + +run_case "parenthesised sum" "2" 'void f() { + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); +}' + +run_case "two writes on one line" "2 +2" 'void f() { + rebootAtMsec = millis() + 5; shutdownAtMsec = millis(); +}' + +run_case "code after a block comment on the same line" "2" 'void f() { + /* arm it */ pulseOffAt = millis() + durationMs; +}' + +# --- must NOT be reported ---------------------------------------------------- + +run_case "already fixed - timerEndsAtMillis" "" 'void f() { + rebootAtMsec = Time::timerEndsAtMillis(5000); +}' + +run_case "already fixed - skipZero stamp" "" 'void f() { + shutdownAtMsec = Time::skipZero(Time::getMillis()); +}' + +run_case "already fixed - ternary keeping the 0 arm" "" 'void f() { + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); +}' + +run_case "disarm" "" 'void f() { + rebootAtMsec = 0; + shutdownAtMsec = 0; +}' + +run_case "reads and comparisons" "" 'void f() { + if (rebootAtMsec && Throttle::deadlinePassed(rebootAtMsec)) {} + if (shutdownAtMsec == 0 && millis() > 5) {} + if (tx_after != 0) {} +}' + +run_case "shadowing local declaration" "" 'void f() { + uint32_t tx_after = millis() + 100; + unsigned long rxTimeMsec = millis(); +}' + +run_case "longer identifier containing a sentinel name" "" 'void f() { + myRebootAtMsec = millis() + 5000; + lastRxTimeMsec = millis(); +}' + +run_case "inside a line comment" "" 'void f() { + // rebootAtMsec = millis() + 5000; +}' + +run_case "inside a block comment" "" 'void f() { +/* + rebootAtMsec = millis() + 5000; +*/ +}' + +run_case "inside a string literal" "" 'void f() { + LOG_DEBUG("rebootAtMsec = millis() + 5000"); +}' + +run_case "copy from another variable" "" 'void f() { + rebootAtMsec = otherDeadline; +}' + +run_case "compound assignment" "" 'void f() { + rebootAtMsec += millis(); +}' + +# A field that is simply not on the list, and a local that never persists. nagCycleCutoff is NOT +# used as the example here: it is off the list because its exemption was rejected, not because 0 is +# safe for it, so pinning it as a negative fixture would encode the opposite of what the header says. +run_case "unlisted field and a local deadline" "" 'void f() { + someUnrelatedDeadline = millis() + durationMs; + const uint32_t deadline = millis() + BODY_TIMEOUT_MS; +}' + +# --- opt-out comments -------------------------------------------------------- + +run_case "opt-out on the same line" "" 'void f() { + lastSort = millis(); // unset-sentinel-ok: sortingIsPaused gates it, 0 is legal +}' + +run_case "opt-out on the line above" "" 'void f() { + // unset-sentinel-ok: busyTx carries the armed state + tx_after = millis() + d; +}' + +run_case "opt-out above, separated by more comment lines" "" 'void f() { + // unset-sentinel-ok: a separate flag carries the armed state + // and here is some more explanation spilling onto another line + // and another + tx_after = millis() + d; +}' + +run_case "opt-out in a block comment" "" 'void f() { + /* unset-sentinel-ok: a separate flag carries the armed state */ + tx_after = millis() + d; +}' + +run_case "opt-out in a multi-line block comment" "" 'void f() { + /* + * unset-sentinel-ok: a separate flag carries the armed state + */ + tx_after = millis() + d; +}' + +# A bare marker is reported rather than honoured, so nothing can be muted silently. +run_case "bare opt-out with no reason" "2" 'void f() { + rebootAtMsec = millis() + 5000; // unset-sentinel-ok +}' + +run_case "bare opt-out with a colon but nothing after it" "2" 'void f() { + rebootAtMsec = millis() + 5000; // unset-sentinel-ok: +}' + +# Must not be mutable from data. A marker inside a string literal is not a comment. +run_case "marker inside a string literal does not mute" "3" 'void f() { + LOG_DEBUG("unset-sentinel-ok: pretend this counts"); + rebootAtMsec = millis() + 5000; +}' + +run_case "marker in a trailing string on the same line does not mute" "2" 'void f() { + rebootAtMsec = millis() + 5000; LOG_DEBUG("unset-sentinel-ok: nope"); +}' + +# The opt-out is consumed by the statement it was written for and must not leak onward. +run_case "opt-out does not leak to the next write" "3" 'void f() { + lastSort = millis(); // unset-sentinel-ok: legal here + rebootAtMsec = millis() + 5000; +}' + +run_case "opt-out attached to an unrelated statement does not leak" "3" 'void f() { + int x = 1; // unset-sentinel-ok: nothing to do with the line below + rebootAtMsec = millis() + 5000; +}' + +run_case "opt-out covers both writes on its own line only" "3" 'void f() { + tx_after = millis() + 1; lastSort = millis(); // unset-sentinel-ok: both legal + rebootAtMsec = millis() + 5000; +}' + +# --- clock held in a local --------------------------------------------------- +# +# The commonest shape in the tree: one `now = millis()` at the top of a runOnce(), then several +# writes from it. Without these the rule is blind to every such field and listing one buys nothing. + +run_case "stamp copied from a tainted local" "3" 'void f() { + unsigned long now = millis(); + rebootAtMsec = now; +}' + +run_case "deadline built from a tainted local" "3" 'void f() { + uint32_t now = Time::getMillis(); + tx_after = now + delay; +}' + +run_case "several writes from one tainted local" "3 +4 +5" 'void f() { + unsigned long now = millis(); + pulseOffAt = now; + rebootAtMsec = now + 5000; + lastSort = now; +}' + +run_case "taint carried one hop through another local" "4" 'void f() { + uint32_t now = millis(); + uint32_t alsoNow = now; + lastSort = alsoNow; +}' + +run_case "tainted local still fixable via the helpers" "" 'void f() { + unsigned long now = millis(); + rebootAtMsec = Time::skipZero(now); +}' + +run_case "tainted local with an opt-out" "" 'void f() { + unsigned long now = millis(); + // unset-sentinel-ok: heldX carries the armed state + nextRepeatX = now + JOY_REPEAT_INTERVAL_MS; +}' + +# --- the taint must NOT spread further than one function, one name ----------- + +run_case "untainted local is not flagged" "" 'void f() { + uint32_t now = packet->rx_time; + rebootAtMsec = now; +}' + +run_case "similarly named local is not tainted" "" 'void f() { + uint32_t now = millis(); + rebootAtMsec = nowMs; +}' + +run_case "taint dropped when the local is reassigned from something else" "" 'void f() { + uint32_t now = millis(); + now = packet->rx_time; + rebootAtMsec = now; +}' + +run_case "taint does not cross a function boundary" "" 'void f() { + uint32_t now = millis(); +} +void g() { + rebootAtMsec = now; +}' + +run_case "taint from a comparison is not recorded" "" 'void f() { + if (now == millis()) {} + rebootAtMsec = now; +}' + +run_case "compound assignment does not taint" "" 'void f() { + now += millis(); + rebootAtMsec = now; +}' + +# --- one write must not be judged by its neighbour on the same line ---------- +# +# rhs used to run to the end of the accumulated statement, so a neighbour decided this write. + +run_case "raw write is not excused by a helper call later on the line" "2" 'void f() { + rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10); +}' + +run_case "safe copy is not blamed for a raw write later on the line" "2" 'void f() { + rebootAtMsec = otherDeadline; shutdownAtMsec = millis(); +}' + +run_case "helper call is not blamed for a raw write later on the line" "2" 'void f() { + rebootAtMsec = Time::skipZero(Time::getMillis()); shutdownAtMsec = millis(); +}' + +run_case "two raw writes on one line are both reported" "2 +2" 'void f() { + rebootAtMsec = millis() + 5; shutdownAtMsec = millis(); +}' + +run_case "two helper writes on one line are both quiet" "" 'void f() { + rebootAtMsec = Time::timerEndsAtMillis(5); shutdownAtMsec = Time::skipZero(Time::getMillis()); +}' + +run_case "multi-line statement still sees its whole right-hand side" "2" 'void f() { + ntp_renew = + millis() + 43200 * 1000; +}' + +# --- stampMillis() is the read-side dodge, not a raw clock ------------------- +# +# Its name ends in millis, so the clock-read test matches it. It must still count as safe, or every +# site that normalises at the read and then stores the local gets flagged. + +run_case "storing a dodged local straight through is safe" "" 'void f() { + uint32_t now = Time::stampMillis(); + lastSort = now; +}' + +# A dodged value is safe to store or copy, NOT to do arithmetic on: stampMillis() guarantees only its +# own result, and 0xFFFFEC78 + 5000 is exactly 0. That sum is what timerEndsAtMillis() is for. +run_case "arithmetic on a dodged local can wrap back onto 0" "3" 'void f() { + uint32_t now = Time::stampMillis(); + rebootAtMsec = now + 5000; +}' + +run_case "arithmetic on a direct helper call is reported too" "2" 'void f() { + rebootAtMsec = Time::stampMillis() + 5000; +}' + +run_case "an operator INSIDE the helper call is fine" "" 'void f() { + lastSort = Time::skipZero(Time::getMillis() - msAgo); +}' + +run_case "copying a dodged local one more hop stays safe" "" 'void f() { + uint32_t now = Time::stampMillis(); + uint32_t alsoNow = now; + lastSort = alsoNow; +}' + +run_case "stampMillis directly in the write is safe" "" 'void f() { + lastSort = Time::stampMillis(); +}' + +run_case "a raw read after a safe one re-taints the local" "5" 'void f() { + uint32_t now = Time::stampMillis(); + lastSort = now; + now = millis(); + rebootAtMsec = now; +}' + +# --- class scope versus function scope --------------------------------------- +# +# A typed declaration is a shadowing local inside a function, but AT CLASS SCOPE it is the field +# itself, with an initializer that can read the clock - src/modules/SerialModule.h does that today. +# Excusing the second as a local silently skipped a real arm site. + +run_case_h "class member initialised from the clock is reported" "2" 'class Foo { + uint32_t lastSort = millis(); +};' + +run_case_h "local inside an inline method is still excused" "6" 'class Foo { + void tick() + { + uint32_t lastSort = millis(); + } + uint32_t lastDrawMsec = millis(); +};' + +run_case_h "member already routed through the helpers is quiet" "" 'class Foo { + uint32_t lastSort = Time::stampMillis(); +};' + +run_case_h "forward declaration does not open a class body" "3" 'class Foo; +void f() { + lastSort = millis(); +}' + +# --- class scope must not swallow ordinary function bodies -------------------- +# +# The keyword appears mid-line in shapes that are not class bodies, and a body opened on the same +# line puts the statement inside a function. All three reported every typed local in the body. + +run_case "template on a function is not a class body" "" 'template void f(T x) { + uint32_t lastSort = millis(); +}' + +run_case "struct in a parameter list is not a class body" "" 'void g(struct Bar *b) { + uint32_t lastSort = millis(); +}' + +run_case_h "one-line inline method is a function body" "" 'class Foo { + void tick() { uint32_t lastSort = millis(); } +};' + +run_case_h "class with a multi-line method: member yes, local no" "6" 'class Foo { + void tick() + { + uint32_t lastSort = millis(); + } + uint32_t lastDrawMsec = millis(); +};' + +# note_taint gets the same per-write cut the judging path has. +run_case "taint is not learned from a neighbour on the same line" "" 'void f() { + uint32_t a = 0; uint32_t now = packet->rx_time; + rebootAtMsec = now; +}' + +# --- a class body that opens and closes on one line --------------------------- +# +# The trailing semicolon cannot be used to rule out a class header, because the whole body fits on +# the line; and that line\'s own brace is the CLASS brace, not a function body. + +run_case_h "one-line class body reports its member initialiser" "1" 'class Foo { uint32_t lastSort = millis(); };' + +run_case_h "one-line class with a one-line method excuses the local" "" 'class Foo { void tick() { uint32_t lastSort = millis(); } };' + +run_case_h "forward declaration opens nothing" "3" 'class Foo; +void f() { + lastSort = millis(); +}' + +# --- scope ------------------------------------------------------------------- + +# test/ builds raw wrap values on purpose, so the rule must not reach into it. +mkdir -p "$WORK/scope/test" +printf 'void f() { rebootAtMsec = millis() + 5000; }\n' >"$WORK/scope/test/test_main.cpp" +if [[ -z $(cd "$WORK/scope" && "$LINT" test/test_main.cpp) ]]; then + echo "PASS test/ is out of scope" +else + echo "FAIL test/ is out of scope: expected no findings" + FAILURES=$((FAILURES + 1)) +fi + +# The helpers' own header must not report itself. +mkdir -p "$WORK/self/src" +printf 'void f() { rebootAtMsec = millis() + 5000; }\n' >"$WORK/self/src/UptimeClock.h" +if [[ -z $(cd "$WORK/self" && "$LINT" src/UptimeClock.h) ]]; then + echo "PASS src/UptimeClock.h is exempt" +else + echo "FAIL src/UptimeClock.h is exempt: expected no findings" + FAILURES=$((FAILURES + 1)) +fi + +echo +if [[ $FAILURES -eq 0 ]]; then + echo "RESULT: PASS" + exit 0 +fi +echo "RESULT: FAIL ($FAILURES case(s))" +exit 1 diff --git a/bin/test-shards.py b/bin/test-shards.py new file mode 100755 index 0000000000..4e02093942 --- /dev/null +++ b/bin/test-shards.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Emit the native-test CI matrix: one shard per matrix row, derived from test/. + +Shards are safe to run in parallel because isolation is per suite, not per run: every suite gets +its own scratch $HOME via bin/pio-test-isolate.sh. + +Two kinds of row come out: + + * general - a slice of the test_* tree under [env:coverage]. AREA_RULES place each suite, first + match wins, unmatched to "misc". Areas over --max-suites split, smaller ones pack together. + + * fixed-env - one row per SPECIAL_ENVS entry, whose suite list is read from its test_filter in + platformio.ini rather than restated here. + +Usage: + bin/test-shards.py # matrix JSON on stdout + bin/test-shards.py --summary # ... plus a human-readable table on stderr + bin/test-shards.py --max-suites 8 # smaller shards, more of them + bin/test-shards.py --seed 12345 # vary which suites share a shard + +Exit: 0 ok, 2 on a malformed tree or a fixed env whose test_filter went missing. +""" + +from __future__ import annotations + +import argparse +import configparser +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# Ordered "area name" -> regex; first match wins. Extend an area by widening its regex, add an area +# by inserting a line. Anything unmatched lands in FALLBACK_AREA. +AREA_RULES = [ + ("admin", r"^test_(admin|pki)_"), + ("crypto", r"^test_(crypto|packet_signing)$"), + ("routing", r"^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_"), + ("position", r"^test_position_"), + ("fuzz", r"^test_fuzz_"), + ("packets", r"^test_(packet|transmit|meshpacket)_"), + ("io", r"^test_(serial|stream|xmodem|http|mqtt)"), +] +FALLBACK_AREA = "misc" + +# Envs that rebuild a fixed set of suites with different build flags. Their test_filter lives in +# the ini and is read from there. +NATIVE_INI = REPO / "variants" / "native" / "portduino" / "platformio.ini" +SPECIAL_ENVS = ["coverage-event-policy", "coverage-channel-table"] + +# Suite names reach a shell as `-f `. Constrained here, the one place the list is produced, +# so a creatively named directory cannot become shell text. +SUITE_RE = re.compile(r"^test_[A-Za-z0-9_]+$") + + +def discover_suites(): + """Every test_* directory directly under test/, sorted. The canonical set.""" + suites = sorted( + p.name for p in (REPO / "test").iterdir() if p.is_dir() and p.name.startswith("test_") + ) + bad = [s for s in suites if not SUITE_RE.match(s)] + if bad: + sys.exit(f"test-shards: refusing to shard, unusable suite name(s): {' '.join(bad)}") + if not suites: + sys.exit("test-shards: no test_* directories under test/ - the tree is not what it should be") + return suites + + +def shuffle(seed, items): + """Reorder via bin/lib/shuffle.sh, the one implementation of the seeded shuffle. + + Two copies of a Fisher-Yates would drift, and announce it as a replay reproducing a different + arrangement. The repo path goes in as $1 so a checkout directory never becomes shell source. + """ + script = 'source "$1"; shift; shuffle_suites "$@"' + out = subprocess.run( + ["bash", "-c", script, "_", str(REPO / "bin" / "lib" / "shuffle.sh"), seed, *items], + capture_output=True, + text=True, + check=True, + ) + return out.stdout.split() + + +def areas_of(suites): + """Bucket suites into areas, preserving AREA_RULES order and putting misc last.""" + grouped = {name: [] for name, _ in AREA_RULES} + grouped[FALLBACK_AREA] = [] + for suite in suites: + area = next((name for name, rule in AREA_RULES if re.search(rule, suite)), FALLBACK_AREA) + grouped[area].append(suite) + return {name: members for name, members in grouped.items() if members} + + +def split(members, cap): + """Split into the fewest chunks of at most `cap`, sized as evenly as the count allows. + + Wall clock is the slowest shard, so 11 suites at cap 10 becomes 6+5, not 10+1. + """ + chunks = -(-len(members) // cap) # ceil + base, extra = divmod(len(members), chunks) + out, start = [], 0 + for i in range(chunks): + size = base + (1 if i < extra else 0) + out.append(members[start : start + size]) + start += size + return out + + +def pack(areas, cap): + """Pack whole areas into the fewest shards of at most `cap`, keeping the loads even. + + Longest-processing-time-first: within 4/3 of optimal, and unlike first-fit it will not leave + one shard holding a single two-suite area. + """ + load = lambda b: sum(len(areas[a]) for a in b) # noqa: E731 + ranked = sorted(areas, key=lambda a: len(areas[a]), reverse=True) + # ceil(total / cap) is a lower bound, not a guarantee - whole areas do not divide, so three + # areas of 6 at cap 10 would put 12 in one of two bins. Grow the count until every bin fits. + for count in range(-(-sum(len(m) for m in areas.values()) // cap), len(areas) + 1): + bins = [[] for _ in range(count)] + for area in ranked: + min(bins, key=load).append(area) + if all(load(b) <= cap for b in bins): + break + # Report each shard's areas in declared order, so a name reads the same way the rules do. + order = list(areas) + return [sorted(b, key=order.index) for b in bins if b] + + +def build(areas, cap): + """Lay the areas out into shards of at most `cap` suites. Returns (rows, suites placed).""" + rows, placed, small = [], [], {} + # Oversized areas become numbered shards of their own; what is left is packed together. + for area, members in areas.items(): + if len(members) <= cap: + small[area] = members + continue + for i, chunk in enumerate(split(members, cap), start=1): + rows.append({"shard": f"{area}-{i}", "env": "coverage", "suites": " ".join(chunk)}) + placed += chunk + for group in pack(small, cap): + members = [suite for area in group for suite in small[area]] + rows.append({"shard": "+".join(group), "env": "coverage", "suites": " ".join(members)}) + placed += members + return rows, placed + + +def fixed_env_filter(env): + """The suites [env:] pins in its own test_filter.""" + # interpolation=None: platformio.ini interpolates with ${section.option}, not configparser's + # %(name)s, so a bare % in any value elsewhere in the file would otherwise abort the parse. + parser = configparser.ConfigParser(strict=False, interpolation=None) + parser.read(NATIVE_INI, encoding="utf-8") + section = f"env:{env}" + if not parser.has_option(section, "test_filter"): + sys.exit( + f"test-shards: [{section}] in {NATIVE_INI.name} has no test_filter. It had one when this " + f"matrix was written; either restore it or drop {env} from SPECIAL_ENVS - silently " + f"emitting an empty filter would run every suite under the wrong build flags." + ) + # test_filter accepts globs, and these tokens reach the same unquoted word-split and the same + # attribution gate as discovered names. Hold them to SUITE_RE too, at the producer. + names = parser.get(section, "test_filter").split() + bad = [n for n in names if not SUITE_RE.match(n)] + if bad: + sys.exit( + f"test-shards: [{section}] test_filter names something that is not a literal suite: " + f"{' '.join(bad)}. The matrix and the attribution gate both need exact names." + ) + return names + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument( + "--max-suites", + type=int, + default=10, + help="largest shard, in suites (default: 10). Lower is faster and costs more runners; the " + "per-shard floor is checkout + toolchain + one src build, so past ~8 the fixed cost wins.", + ) + ap.add_argument( + "--max-shards", + type=int, + default=24, + help="hard ceiling on matrix rows (default: 24). A budget, not a preference: --max-suites " + "is raised until the matrix fits, so no branch can size the fan-out by adding directories.", + ) + ap.add_argument( + "--seed", + default="", + help="vary which suites share a shard. Co-location, not order - PlatformIO picks the order " + "within a shard either way. Empty means the declared alphabetical arrangement.", + ) + ap.add_argument("--summary", action="store_true", help="also print the shard table to stderr") + args = ap.parse_args() + + if args.max_suites < 1: + sys.exit("test-shards: --max-suites must be at least 1") + if args.max_shards <= len(SPECIAL_ENVS): + sys.exit(f"test-shards: --max-shards must leave room for the {len(SPECIAL_ENVS)} fixed envs") + + suites = discover_suites() + areas = areas_of(suites) + if args.seed: + areas = {area: shuffle(args.seed, members) for area, members in areas.items()} + + # Shard size is a preference, shard count is a budget: without this a branch could size the + # fan-out by adding directories. --max-suites gives way so the runner count stays bounded. + cap = args.max_suites + while True: + rows, placed = build(areas, cap) + if len(rows) + len(SPECIAL_ENVS) <= args.max_shards: + break + cap += 1 + if cap != args.max_suites: + print( + f"test-shards: {len(suites)} suites would need more than {args.max_shards} shards at " + f"--max-suites {args.max_suites}; using {cap} per shard instead.", + file=sys.stderr, + ) + + # Prove nothing fell out, rather than discovering an unrun suite from a coverage graph later. + if sorted(placed) != suites: + missing = sorted(set(suites) - set(placed)) + sys.exit(f"test-shards: {len(missing)} suite(s) reached no shard: {' '.join(missing)}") + + for env in SPECIAL_ENVS: + rows.append( + { + "shard": env.removeprefix("coverage-"), + "env": env, + "suites": " ".join(fixed_env_filter(env)), + } + ) + + # Exactly one shard writes the shared compiler cache: all of them compile the same src/ tree, + # and letting each save would race for the key and store the same objects a dozen times. + for row in rows: + row["cache_writer"] = False + rows[0]["cache_writer"] = True + + if args.summary: + width = max(len(row["shard"]) for row in rows) + for row in rows: + count = len(row["suites"].split()) + print( + f" {row['shard']:<{width}} {row['env']:<24} {count:>2} suite(s)", file=sys.stderr + ) + print( + f" {len(rows)} shard(s), {len(suites)} suite(s) in test/, " + f"largest shard {max(len(row['suites'].split()) for row in rows)}", + file=sys.stderr, + ) + + print(json.dumps({"include": rows})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/test-state-check.sh b/bin/test-state-check.sh index 14dad9d447..250bf1a9f1 100755 --- a/bin/test-state-check.sh +++ b/bin/test-state-check.sh @@ -19,6 +19,8 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$ROOT_DIR" || exit 1 +# shellcheck source=bin/lib/test-state.sh +source "$SCRIPT_DIR/lib/test-state.sh" WORK="$(mktemp -d -t meshstatecheck.XXXXXX)" trap 'rm -rf "$WORK"' EXIT @@ -97,18 +99,32 @@ mkdir -p "$HOME/.portduino/default/prefs" # Detached from this shell's stdout so the wrapper's `| tee` sees EOF and the pipeline returns - # the survivor outlives the suite exactly as a spun loop() does. setsid sleep 300 >/dev/null 2>&1 & -printf '%s\n' "$!" > "$HOME/../survivor.pid" +survivor=$! +# A real survivor has been running for the whole suite; this one is a microsecond old, and the +# wrapper scans the instant this shell exits - so wait for the exec before reporting it up. +for _ in {1..500}; do [[ "$(cat "/proc/$survivor/comm" 2>/dev/null)" == sleep ]] && break; sleep 0.01; done +# Recheck rather than trust the loop: falling out of it on the timeout would stage a pid the +# fixture never saw reach exec, which is the race this wait exists to close. +[[ "$(cat "/proc/$survivor/comm" 2>/dev/null)" == sleep ]] || { echo "fixture: survivor never reached exec" >&2; exit 1; } +printf '%s\n' "$survivor" > "$HOME/../survivor.pid" exit 0 EOF chmod +x "$LEAKY" survivor_dir="$WORK/state-survivor" mkdir -p "$survivor_dir" +# KEEP_STATE so the sandbox stays whatever the verdict: without it a missed survivor also deletes +# the pid file staged inside it, and the reap assertion below fails for the wrong reason. FIXTURE_SUITE=test_fixture_survivor \ MESHTASTIC_TEST_STATE_DIR="$survivor_dir" \ MESHTASTIC_TEST_STATE_SUMMARY="$survivor_dir/summary.tsv" \ MESHTASTIC_TEST_STATE_MANIFEST="$MANIFEST" \ - "$SCRIPT_DIR/pio-test-isolate.sh" "$LEAKY" >/dev/null 2>&1 + MESHTASTIC_TEST_KEEP_STATE=1 \ + "$SCRIPT_DIR/pio-test-isolate.sh" "$LEAKY" >/dev/null 2>"$survivor_dir/wrapper.err" + +# Find the pid file by search, not by a glob that assumes a directory depth: the wrapper renames +# its mktemp'd sandbox to the suite name when it keeps it, so the path is not fixed. +leaked_pid="$(head -1 "$(find "$survivor_dir" -name survivor.pid -print -quit 2>/dev/null)" 2>/dev/null)" recorded="$(awk -F'\t' '$1 == "test_fixture_survivor" { print $6; exit }' "$survivor_dir/summary.tsv" 2>/dev/null)" if [[ -n ${recorded// /} ]]; then @@ -116,14 +132,28 @@ if [[ -n ${recorded// /} ]]; then PASSES=$((PASSES + 1)) else echo " FAIL a process outliving the suite went unreported" + # Name the cause here rather than spending a CI round-trip on it: whether the fixture's + # process exists at all, whether the scan can see it, and what the wrapper recorded instead. + visible_pids="$(ps -u "$(id -u)" -o pid= 2>/dev/null | tr -d ' ')" + if [[ -z $leaked_pid ]]; then + alive="no pid staged" + listed="n/a" + its_home="" + else + kill -0 "$leaked_pid" 2>/dev/null && alive="alive" || alive="gone" + grep -Fxq "$leaked_pid" <<<"$visible_pids" && listed="yes" || listed="no" + its_home="$(tr '\0' '\n' <"/proc/$leaked_pid/environ" 2>/dev/null | grep -m1 '^HOME=')" + fi + echo " summary line: $(awk -F'\t' '$1 == "test_fixture_survivor"' "$survivor_dir/summary.tsv" 2>/dev/null | tr '\t' '|')" + echo " staged pid ${leaked_pid:-}: $alive, listed by ps: $listed, its ${its_home:-}" + echo " ps -u $(id -u) listed $(grep -c . <<<"$visible_pids") pids; the sandbox is under $survivor_dir" + echo " wrapper stderr: $(tr '\n' '|' <"$survivor_dir/wrapper.err" 2>/dev/null)" + echo " same scan, run again now: [$(state_find_survivors "${its_home#HOME=}" | tr '\n' ' ')]" FAILURES=$((FAILURES + 1)) fi -# Find the pid file by search, not by a glob that assumes a directory depth: the wrapper renames -# its mktemp'd sandbox to the suite name when it keeps it, so the path is not fixed. Assert the -# file was found BEFORE asserting the process is gone - otherwise an empty pid takes the "not -# running" branch and the check passes without having checked anything. -leaked_pid="$(cat "$(find "$survivor_dir" -name survivor.pid -print -quit 2>/dev/null)" 2>/dev/null | head -1)" +# Assert the pid file was found BEFORE asserting the process is gone - otherwise an empty pid takes +# the "not running" branch and the check passes without having checked anything. if [[ -z $leaked_pid ]]; then echo " FAIL no survivor pid recorded - the reap assertion would pass vacuously" FAILURES=$((FAILURES + 1)) @@ -140,8 +170,6 @@ fi # it; exercise the assertion the wrapper actually calls instead - same function, same code path. echo echo "Before-empty assertion (state_assert_empty):" -# shellcheck source=bin/lib/test-state.sh -source "$SCRIPT_DIR/lib/test-state.sh" seeded="$WORK/seeded" mkdir -p "$seeded/$PREFS" diff --git a/boards/nrf54l15dk.json b/boards/nrf54l15dk.json deleted file mode 100644 index 863ad290b4..0000000000 --- a/boards/nrf54l15dk.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "build": { - "cpu": "cortex-m33", - "f_cpu": "128000000L", - "mcu": "nrf54l15", - "zephyr": { - "variant": "nrf54l15dk/nrf54l15/cpuapp" - } - }, - "connectivity": ["bluetooth"], - "debug": { - "default_tools": ["jlink"], - "jlink_device": "nRF54L15_M33", - "svd_path": "nrf54l15.svd" - }, - "frameworks": ["zephyr"], - "name": "Nordic nRF54L15-DK (PCA10156)", - "upload": { - "maximum_ram_size": 262144, - "maximum_size": 1572864, - "protocol": "jlink", - "protocols": ["jlink"] - }, - "url": "https://www.nordicsemi.com/Products/nRF54L15", - "vendor": "Nordic Semiconductor" -} diff --git a/boards/seeed-sensecap-indicator.json b/boards/seeed-sensecap-indicator.json index 37a97cdf19..861b5bafe9 100644 --- a/boards/seeed-sensecap-indicator.json +++ b/boards/seeed-sensecap-indicator.json @@ -15,7 +15,6 @@ ], "f_cpu": "240000000L", "f_flash": "80000000L", - "f_boot": "120000000L", "boot": "qio", "flash_mode": "qio", "psram_type": "opi", diff --git a/boards/seeed_wio_tracker_L2.json b/boards/seeed_wio_tracker_L2.json new file mode 100644 index 0000000000..1421c19a76 --- /dev/null +++ b/boards/seeed_wio_tracker_L2.json @@ -0,0 +1,42 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-D BOARD_HAS_PSRAM", + "-D ARDUINO_USB_CDC_ON_BOOT=1", + "-D ARDUINO_USB_MODE=1", + "-D ARDUINO_RUNNING_CORE=1", + "-D ARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "seeed_wio_tracker_L2 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.seeedstudio.com/", + "vendor": "Seeed Studio" +} diff --git a/boards/t-connect-pro.json b/boards/t-connect-pro.json new file mode 100644 index 0000000000..a10c9183ad --- /dev/null +++ b/boards/t-connect-pro.json @@ -0,0 +1,40 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-D BOARD_HAS_PSRAM", + "-D ARDUINO_USB_CDC_ON_BOOT=1", + "-D ARDUINO_USB_MODE=1", + "-D ARDUINO_RUNNING_CORE=1", + "-D ARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth", "ethernet", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "LilyGo T-Connect-Pro (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://lilygo.cc/products/t-connect-pro", + "vendor": "LilyGo" +} diff --git a/debian/changelog b/debian/changelog index 6b9d0668ef..ab4b5c11a1 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,27 @@ +meshtasticd (2.8.1.0) unstable; urgency=medium + + * Version 2.8.1 + + -- GitHub Actions Tue, 01 Sep 2026 10:59:38 +0000 + +meshtasticd (2.8.0.0) unstable; urgency=medium + + * Version 2.8.0 + + -- GitHub Actions Wed, 24 Jun 2026 11:20:05 +0000 + +meshtasticd (2.7.26.0) unstable; urgency=medium + + * Version 2.7.26 + + -- GitHub Actions Wed, 10 Jun 2026 00:19:23 +0000 + +meshtasticd (2.7.25.0) unstable; urgency=medium + + * Version 2.7.25 + + -- GitHub Actions Sat, 23 May 2026 01:16:20 +0000 + meshtasticd (2.7.24.0) unstable; urgency=medium * Version 2.7.24 @@ -73,14 +97,6 @@ meshtasticd (2.7.13.0) unstable; urgency=medium meshtasticd (2.7.12.0) unstable; urgency=medium - [ Austin Lane ] - * Initial packaging - * Version 2.5.19 - - [ ] - * GitHub Actions Automatic version bump - - [ GitHub Actions ] * Version 2.7.12 -- GitHub Actions Wed, 01 Oct 2025 19:51:41 +0000 diff --git a/docs/bme680_iaq_replay.md b/docs/bme680_iaq_replay.md deleted file mode 100644 index 5fc8d96df3..0000000000 --- a/docs/bme680_iaq_replay.md +++ /dev/null @@ -1,54 +0,0 @@ -# BME680 IAQ replay harness - -`bin/bme680_iaq_replay.cpp` replays a captured sensor trace through the in-tree -`BME680IaqEstimator` on a dev machine, for tuning the estimator's constants -against recorded Bosch BSEC output. The estimator is pure math with no platform -dependencies, so a trace replays in milliseconds - edit the constants in -`src/modules/Telemetry/Sensor/BME680IaqEstimator.h`, recompile, rerun. - -## Build - -From the repo root: - -```bash -c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay \ - bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp -``` - -## Input - -CSV on stdin or as a file argument, one sample per line: - -```text -gas_ohms,relative_humidity[,bsec_iaq] -``` - -Lines starting with `#` are ignored; a single non-numeric header row is -tolerated; any other malformed line is reported on stderr and skipped. - -## Capturing a trace - -On a firmware build that still links BSEC (any release tag before the BSEC -removal), add one log line to `BME680Sensor::getMetrics` in the BSEC branch: - -```cpp -LOG_INFO("IAQCSV,%.0f,%.2f,%.0f", bme680.getData(BSEC_OUTPUT_RAW_GAS).signal, - bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal, - bme680.getData(BSEC_OUTPUT_IAQ).signal); -``` - -then extract the columns from the serial log: - -```bash -grep -o 'IAQCSV,.*' serial.log | cut -d, -f2- > trace.csv -``` - -BSEC's `RAW_GAS` and heat-compensated humidity are exactly the estimator's -inputs, so one physical sensor feeds both algorithms identically. - -## Output - -Per-sample CSV `n,gas_ohms,rh,est_iaq,bsec_iaq` on stdout (empty `est_iaq` -during the estimator's warm-up/burn-in window), plus a stderr summary with the -mean absolute error and UI-band agreement against the `bsec_iaq` column, using -the same 0-500 band thresholds the device screen applies. diff --git a/extra_scripts/esp32_fatfs_exfat.py b/extra_scripts/esp32_fatfs_exfat.py new file mode 100644 index 0000000000..1fc5636a99 --- /dev/null +++ b/extra_scripts/esp32_fatfs_exfat.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# trunk-ignore-all(ruff/F821) +# trunk-ignore-all(flake8/F821): For SConstruct imports +import re +from os.path import exists, join + +Import("env") + +# --------------------------------------------------------------------------- +# exFAT for the IDF FatFs component. +# +# ESP-IDF exposes no Kconfig symbol for exFAT (checked 5.5.x and master): +# components/fatfs/src/ffconf.h hardcodes "#define FF_FS_EXFAT 0", so +# CONFIG_FATFS_FS_EXFAT=y in custom_sdkconfig is silently dropped by kconfgen +# and an exFAT card fails to mount with ESP_FAIL from esp_vfs_fat_sdmmc_mount. +# This script turns that inert symbol into a real ffconf.h patch. +# +# Two headers must agree, because FF_FS_EXFAT changes the FATFS/FIL/DIR layout +# in ff.h: +# 1. framework-espidf - compiled into libfatfs.a by the HybridCompile +# IDF-libs rebuild (arduino.py -> espidf.py). Patched in the pre: pass. +# 2. framework-arduinoespressif32-libs//include - used when the +# application and the Arduino SD_MMC library are compiled. espidf.py's +# idf_lib_copy() copies back archives and sdkconfig.h but no headers, and +# a custom_sdkconfig hash change wipes the whole package and reinstalls +# it, so this copy is patched again in the post: pass. +# +# framework-espidf is shared by every ESP32 env while this script is registered +# only on the SDIO variants, so the patch there is undone once the build is +# done: an env that never asked for exFAT must not rebuild its IDF libs against +# a header the rest of its build does not see. The per-chip -libs copy stays +# patched, matching the libfatfs.a it was built with. +# +# The libs rebuild is keyed on the md5 of custom_sdkconfig, which the patch +# state does not otherwise reach, so the state is appended as a comment line +# (inert in kconfig). Keep the marker lowercase: arduino.py greps +# custom_sdkconfig for "PSRAM" and "CONFIG_SPIRAM=y". +# --------------------------------------------------------------------------- + +SWITCH = "CONFIG_FATFS_FS_EXFAT=y" +MARKER_KEY = "meshtastic_fatfs_exfat" +PATTERN = re.compile(r"^(#define\s+FF_FS_EXFAT\s+)([01])", re.MULTILINE) + + +def wants_exfat(env): + config = env.GetProjectConfig() + section = "env:" + env["PIOENV"] + if not config.has_option(section, "custom_sdkconfig"): + return False + return any( + line.strip() == SWITCH + for line in env.GetProjectOption("custom_sdkconfig").splitlines() + ) + + +def ffconf_paths(env, phase): + platform = env.PioPlatform() + board = env.BoardConfig() + chip = board.get("build.chip_variant", "").lower() or board.get( + "build.mcu", "esp32" + ) + + paths = [] + if phase == "pre": + idf = idf_ffconf(env) + if idf: + paths.append(idf) + libs_dir = platform.get_package_dir("framework-arduinoespressif32-libs") + if libs_dir: + paths.append(join(libs_dir, chip, "include", "fatfs", "src", "ffconf.h")) + return [p for p in paths if exists(p)] + + +def idf_ffconf(env): + idf_dir = env.PioPlatform().get_package_dir("framework-espidf") + return join(idf_dir, "components", "fatfs", "src", "ffconf.h") if idf_dir else None + + +def set_ff_fs_exfat(path, enable): + with open(path) as src: + content = src.read() + patched = PATTERN.sub(r"\g<1>%d" % (1 if enable else 0), content, count=1) + if patched == content: + return False + with open(path, "w") as dst: + dst.write(patched) + print("*** FF_FS_EXFAT=%d: %s ***" % (1 if enable else 0, path)) + return True + + +def tag_sdkconfig_state(env, enable): + config = env.GetProjectConfig() + section = "env:" + env["PIOENV"] + if not config.has_option(section, "custom_sdkconfig"): + return + current = env.GetProjectOption("custom_sdkconfig") + if MARKER_KEY in current: + return + marker = "# %s: %d" % (MARKER_KEY, 1 if enable else 0) + config.set(section, "custom_sdkconfig", current.rstrip("\n") + "\n" + marker) + + +phase = "post" if env.get("MESHTASTIC_EXFAT_PATCHED") else "pre" +enable = wants_exfat(env) + +for path in ffconf_paths(env, phase): + set_ff_fs_exfat(path, enable) + +if phase == "pre": + # revert as well as enable, so an env without the switch never links a + # FatFs built with a different struct layout than the headers it compiles against + tag_sdkconfig_state(env, enable) + env["MESHTASTIC_EXFAT_PATCHED"] = True + + if enable: + + def restore_idf_ffconf(target, source, env): + idf = idf_ffconf(env) + if idf and exists(idf): + set_ff_fs_exfat(idf, False) + + # the IDF libs are compiled during the build phase, so this is the first + # point at which the shared package can be handed back unpatched + env.AddPostAction("checkprogsize", restore_idf_ffconf) diff --git a/extra_scripts/esp32_pre.py b/extra_scripts/esp32_pre.py index b2c4171e7e..d9884862ae 100755 --- a/extra_scripts/esp32_pre.py +++ b/extra_scripts/esp32_pre.py @@ -3,7 +3,8 @@ # trunk-ignore-all(flake8/F821): For SConstruct imports import json import sys -from os.path import isfile +from os import remove +from os.path import getmtime, isfile, join Import("env") @@ -167,3 +168,22 @@ def tag_sdkconfig_cache_key(env): tag_sdkconfig_cache_key(env) + + +# The platform writes its cache-key hash into sdkconfig.defaults before compiling +# the IDF libs, so an aborted pass leaves it describing libs that were never built. +def drop_stale_sdkconfig_defaults(env): + defaults = join(env.subst("$PROJECT_DIR"), "sdkconfig.defaults") + mcu = env.BoardConfig().get("build.mcu", "esp32") + try: + libs = env.PioPlatform().get_package_dir("framework-arduinoespressif32-libs") + # Rewritten last by a completed compile, so it marks "libs built". + if getmtime(join(libs, mcu, "sdkconfig")) >= getmtime(defaults): + return + except (OSError, TypeError): + return + print("*** Stale %s IDF libs; forcing a HybridCompile rebuild ***" % mcu) + remove(defaults) + + +drop_stale_sdkconfig_defaults(env) diff --git a/extra_scripts/nrf54l15_linker.py b/extra_scripts/nrf54l15_linker.py deleted file mode 100644 index 824aae7ce3..0000000000 --- a/extra_scripts/nrf54l15_linker.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# trunk-ignore-all(ruff/F821) -# trunk-ignore-all(flake8/F821): For SConstruct imports -# -# post:extra_scripts/nrf54l15_linker.py -# -# Fix for Zephyr two-pass link on nRF54L15: -# platformio-build.py registers env.Depends("$PROG_PATH", final_ld_script) but -# the SCons dependency chain is broken (final_ld_script Command never runs). -# This script adds a PreAction on the final firmware binary that runs the gcc -# preprocessing command directly (extracted from build.ninja) to generate -# zephyr/linker.cmd before the link step. -# -# PlatformIO bundles an old Ninja that can't handle multi-output depslog rules, -# so we parse the COMMAND line from build.ninja and run just the gcc -E part, -# skipping the cmake_transform_depfile step (only needed for Ninja deps tracking). - -import os -import re -import subprocess - -Import("env") - -if env.get("PIOENV") != "nrf54l15dk": - pass # Only for the nrf54l15dk environment -else: - - def _extract_gcc_command(ninja_build): - """Parse build.ninja to find the gcc -E command that generates linker.cmd. - - The rule format depends on the host: - Windows (CMake's RunCMake wraps every command): - COMMAND = cmd.exe /C "cd /D DIR && arm-none-eabi-gcc.exe ... -o linker.cmd && cmake.exe -E cmake_transform_depfile ..." - POSIX (Linux/macOS - no wrapper): - COMMAND = cd DIR && arm-none-eabi-gcc ... -o linker.cmd && cmake -E cmake_transform_depfile ... - - Returns (gcc_cmd_string, cwd_path) or raises RuntimeError. - """ - in_rule = False - with open(ninja_build, "r", encoding="utf-8", errors="replace") as f: - for line in f: - # Detect start of the linker.cmd custom command rule - if not in_rule: - if "build zephyr/linker.cmd" in line and "CUSTOM_COMMAND" in line: - in_rule = True - continue - - stripped = line.strip() - if not stripped.startswith("COMMAND = "): - continue - - command_val = stripped[len("COMMAND = ") :] - - # On Windows the value is wrapped in `cmd.exe /C "..."` - strip - # the wrapper. On POSIX hosts the inner sequence is the value - # itself (no quoting layer). - m = re.search(r'/C\s+"(.*)"\s*$', command_val) - inner = m.group(1) if m else command_val - parts = inner.split(" && ") - - cwd = None - gcc_cmd = None - for part in parts: - part = part.strip() - if part.startswith("cd /D "): # Windows form - cwd = part[len("cd /D ") :] - elif part.startswith("cd "): # POSIX form - cwd = part[len("cd ") :] - elif "arm-none-eabi-gcc" in part: - gcc_cmd = part - - if not gcc_cmd: - raise RuntimeError( - "nRF54L15 linker fix: arm-none-eabi-gcc command not found in:\n%s" - % inner[:400] - ) - - return gcc_cmd, cwd - - raise RuntimeError( - "nRF54L15 linker fix: 'build zephyr/linker.cmd' rule not found in build.ninja" - ) - - def _generate_linker_cmd(target, source, env): - """Generate zephyr/linker.cmd via direct gcc invocation before the final link.""" - build_dir = env.subst("$BUILD_DIR") - zephyr_dir = os.path.join(build_dir, "zephyr") - linker_cmd = os.path.join(zephyr_dir, "linker.cmd") - - if os.path.exists(linker_cmd): - return # Already present - nothing to do - - ninja_build = os.path.join(build_dir, "build.ninja") - if not os.path.exists(ninja_build): - raise RuntimeError( - "nRF54L15 linker fix: build.ninja not found at %s\n" - "Run a full build first so CMake generates the Ninja files." - % ninja_build - ) - - gcc_cmd, cwd = _extract_gcc_command(ninja_build) - run_cwd = cwd if cwd else zephyr_dir - - print( - "==> nRF54L15: Generating zephyr/linker.cmd (LINKER_ZEPHYR_FINAL) via GCC" - ) - # gcc_cmd comes verbatim from our own build.ninja (never user input) and - # contains Windows-style paths with spaces that cannot be safely argv-split - # with shlex, so we run it via the platform shell. nosec/nosemgrep below - # acknowledge this deliberate, scoped use of shell=True. - result = subprocess.run( # nosec B602 - gcc_cmd, - shell=True, # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true - cwd=run_cwd, - capture_output=True, - text=True, - ) - if result.returncode != 0: - print("GCC stdout:", result.stdout[:2000]) - print("GCC stderr:", result.stderr[:2000]) - raise RuntimeError( - "nRF54L15 linker fix: GCC failed to generate linker.cmd (rc=%d)" - % result.returncode - ) - if not os.path.exists(linker_cmd): - raise RuntimeError( - "nRF54L15 linker fix: GCC returned 0 but linker.cmd was not created at %s" - % linker_cmd - ) - print("==> linker.cmd generated successfully") - - # Use PIOMAINPROG (set by ZephyrBuildProgram) to get the exact SCons node - prog = env.get("PIOMAINPROG") - if prog: - env.AddPreAction(prog, _generate_linker_cmd) - else: - print( - "[nrf54l15_linker] WARNING: PIOMAINPROG not set, falling back to $PROG_PATH" - ) - env.AddPreAction(env.subst("$PROG_PATH"), _generate_linker_cmd) diff --git a/platformio.ini b/platformio.ini index 2300847467..aa3e8f9ed6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -25,8 +25,8 @@ build_flags = test_build_src = true extra_scripts = pre:bin/platformio-pre.py + pre:bin/optional-modules.py bin/platformio-custom.py - post:extra_scripts/nrf54l15_linker.py ; note: we add src to our include search path so that lmic_project_config can override ; note: TINYGPS_OPTION_NO_CUSTOM_FIELDS is VERY important. We don't use custom fields and somewhere in that pile ; of code is a heap corruption bug! @@ -89,7 +89,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic-ArduinoThread packageName=https://github.com/meshtastic/ArduinoThread gitBranch=master https://github.com/meshtastic/ArduinoThread/archive/b841b0415721f1341ea41cccfb4adccfaf951567.zip # renovate: datasource=github-tags depName=Nanopb packageName=nanopb/nanopb - https://github.com/nanopb/nanopb/archive/refs/tags/nanopb-0.4.9.1.zip + https://github.com/nanopb/nanopb/archive/0.4.92.zip # renovate: datasource=github-tags depName=ErriezCRC32 packageName=Erriez/ErriezCRC32 https://github.com/Erriez/ErriezCRC32/archive/refs/tags/1.0.1.zip @@ -98,6 +98,8 @@ check_tool = cppcheck check_skip_packages = yes check_flags = -DAPP_VERSION=1.0.0 + ; define PROGMEM to avoid cppcheck reporting unknownMacro (--skip-packages excludes Arduino.h) + -DPROGMEM= --suppressions-list=suppressions.txt --inline-suppr @@ -137,10 +139,13 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/27443d05de4678cdf6a9f4ef35aa30c0aa5f4e29.zip + https://github.com/meshtastic/device-ui/archive/776ab044345fcd41c4d9eda0eb86c9ad2206b2e1.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y + ; not an IDF symbol; read by extra_scripts/esp32_fatfs_exfat.py to patch ffconf.h + CONFIG_FATFS_FS_EXFAT=y + CONFIG_FATFS_LFN_STACK=y ; Common libs for environmental measurements in telemetry module [environmental_base] diff --git a/protobufs b/protobufs index aca181b97b..723a31e420 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit aca181b97b7db047d76e9f000220a11a234cd389 +Subproject commit 723a31e42013f155b529929e675db8caef20c534 diff --git a/src/AudioThread.h b/src/AudioThread.h index f4f5781fcf..6d4b26dad4 100644 --- a/src/AudioThread.h +++ b/src/AudioThread.h @@ -1,5 +1,6 @@ #pragma once #include "PowerFSM.h" +#include "SPILock.h" #include "concurrency/OSThread.h" #include "configuration.h" #include "main.h" @@ -15,9 +16,9 @@ // A board with an I2S amplifier opts in by defining AUDIO_AMP_ENABLE(on) in its variant.h to power the // amp on/off around playback (e.g. an enable pin on an I/O expander). The includes below expose the // expander instances (io / mcpIoExpander) those macros typically reference. -#ifdef USE_XL9555 -#include "ExtensionIOXL9555.hpp" -extern ExtensionIOXL9555 io; +#ifdef USE_PCA95X5 +#include PCA95X5_INC +extern PCA95X5_CLS io; #endif #ifdef USE_MCP23017 @@ -33,9 +34,7 @@ class AudioThread : public concurrency::OSThread void beginRttl(const void *data, uint32_t len) { -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(true); -#endif + ampEnable(true); setCPUFast(true); rtttlFile = std::unique_ptr(new AudioFileSourcePROGMEM(data, len)); i2sRtttl = std::unique_ptr(new AudioGeneratorRTTTL()); @@ -61,9 +60,7 @@ class AudioThread : public concurrency::OSThread rtttlFile = nullptr; setCPUFast(false); -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(false); -#endif + ampEnable(false); } void readAloud(const char *text) @@ -73,16 +70,12 @@ class AudioThread : public concurrency::OSThread i2sRtttl = nullptr; } -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(true); -#endif + ampEnable(true); auto sam = std::unique_ptr(new ESP8266SAM); sam->Say(audioOut.get(), text); setCPUFast(false); audioOut->stop(); -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(false); -#endif + ampEnable(false); } protected: @@ -97,6 +90,21 @@ class AudioThread : public concurrency::OSThread } private: + // Amps like the NS4150 need time to leave shutdown, longer when the enable is an I/O expander write. + // Without a variant's AUDIO_AMP_SETTLE_MS the short system tones are over before any audio gets out. + static void ampEnable(bool on) + { +#ifdef AUDIO_AMP_ENABLE + AUDIO_AMP_ENABLE(on); +#ifdef AUDIO_AMP_SETTLE_MS + if (on) + delay(AUDIO_AMP_SETTLE_MS); +#endif +#else + (void)on; +#endif + } + void initOutput() { audioOut = std::unique_ptr(new AudioOutputI2S(1, AudioOutputI2S::EXTERNAL_I2S)); diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index ef0d5841ad..b6269141b6 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -40,7 +40,11 @@ size_t fsUsedBytes() return 0; size_t blocks = 0; FSCom._lockFS(); +#if LFS_VERSION_MAJOR >= 2 + int err = lfs_fs_traverse(fs, fsCountBlockCb, &blocks); +#else int err = lfs_traverse(fs, fsCountBlockCb, &blocks); +#endif FSCom._unlockFS(); if (err < 0) return fsTotalBytes(); // report "full" so capacity checks fail safe @@ -133,9 +137,6 @@ bool renameFile(const char *pathFrom, const char *pathTo) #include #include #include -#ifdef ARCH_ESP32 -#include -#endif /** * @brief Platform-agnostic filesystem format / wipe. @@ -253,12 +254,6 @@ void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vec } // namespace #endif -#ifdef ARCH_ESP32 -// Headroom kept below the allocator's largest free block when sizing the manifest: the block reported -// includes the allocator's own bookkeeping, and other tasks keep allocating while the SPI lock is held. -static constexpr size_t FILES_MANIFEST_HEAP_MARGIN = 1024; -#endif - /** * @brief Get the list of files in a directory. * @@ -286,32 +281,24 @@ std::vector getFiles(const char *dirname, uint8_t levels, s // Cap at what a vector of FileInfo can hold at all: it keeps the probe's byte count from wrapping // for a huge maxCount, and it is also the bound reserve() would otherwise reject with a throw. size_t reservedCount = std::min(maxCount, filenames.max_size()); -#ifdef ARCH_ESP32 - // Ask the allocator for the largest contiguous block malloc() could hand out. MALLOC_CAP_DEFAULT - // is the capability heap_caps_malloc_default() (what operator new resolves to) falls back to - // across every region, internal and PSRAM alike, so this is the "will new succeed" question - // asked directly. Nothing is freed before the reserve, so there is no hole for another task to - // take between the probe and the allocation. - const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); - // Leave a margin below the largest block: the allocator's own overhead sits inside it, and other - // threads keep allocating while we hold the SPI lock. - const size_t usable = largest > FILES_MANIFEST_HEAP_MARGIN ? largest - FILES_MANIFEST_HEAP_MARGIN : 0; - reservedCount = std::min(reservedCount, usable / sizeof(meshtastic_FileInfo)); -#else - // Other targets have no largest-block query. Probe with malloc() - the allocation that returns - // nullptr on failure under every build (new(std::nothrow) is not that: libstdc++ implements it as - // a try/catch around the throwing form) - free the probe, and reserve the size that fit. Not - // airtight against a concurrent allocator, but the SPI lock the caller holds serialises the usual - // competitors and it is strictly better than letting reserve() be the first to find out. + // Probe with malloc() - the allocation that returns nullptr on failure under every build (new(std::nothrow) + // is not that: libstdc++ implements it as a try/catch around the throwing form) - free the probe, and + // reserve the size that fit. Not airtight against a concurrent allocator, but the SPI lock the caller holds + // serialises the usual competitors and it is strictly better than letting reserve() be the first to find + // out. On ESP32 do NOT replace this with heap_caps_get_largest_free_block(): it walks every TLSF block of + // every matching heap while holding the allocator lock, and on PSRAM boards that walk blocks wifi_malloc() + // on the other core long enough to trip the interrupt watchdog (#11666). while (reservedCount > 0) { void *probe = malloc(reservedCount * sizeof(meshtastic_FileInfo)); if (probe) { + // Observable access so LTO cannot elide the malloc()/free() pair and turn the probe + // into a compile-time yes. + *static_cast(probe) = 0; free(probe); break; } reservedCount /= 2; } -#endif if (reservedCount == 0) { if (wasLimited) *wasLimited = true; diff --git a/src/FSCommon.h b/src/FSCommon.h index 7daa57ad90..de4322579e 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -48,14 +48,6 @@ using namespace STM32_LittleFS_Namespace; using namespace Adafruit_LittleFS_Namespace; #endif -#if defined(ARCH_NRF54L15) -// nRF54L15 - Zephyr LittleFS on 36 KB storage_partition (internal RRAM) -#include "InternalFileSystem.h" -#define FSCom InternalFS -#define FSBegin() FSCom.begin() -using namespace Adafruit_LittleFS_Namespace; -#endif - // Filesystem capacity, in bytes. Only ESP32's LittleFS and the nRF54L15 wrapper expose totalBytes()/usedBytes() // directly; the other backends need per-platform work (littlefs v1 traversal, FSInfo, statvfs), so callers must // use these helpers rather than reaching into FSCom. diff --git a/src/Observer.h b/src/Observer.h index 6e1ec44c81..9c445e2b5b 100644 --- a/src/Observer.h +++ b/src/Observer.h @@ -81,6 +81,7 @@ template class Observable // Not called directly, instead call observer.observe void addObserver(Observer *o) { observers.push_back(o); } + // cppcheck-suppress constParameterPointer ; std::list::remove() needs a non-const pointer void removeObserver(Observer *o) { observers.remove(o); } }; diff --git a/src/Pca9555.h b/src/Pca9555.h new file mode 100644 index 0000000000..b29cbdda82 --- /dev/null +++ b/src/Pca9555.h @@ -0,0 +1,97 @@ +#pragma once +#include + +// Lightweight Wire-based TCA9555/PCA9555/XL9555 16-bit I/O expander driver. +// Opt in via USE_PCA95X5 / PCA95X5_CLS / PCA95X5_INC in the board's variant.h. +class Pca9555 +{ + public: + bool begin(TwoWire &wire, uint8_t addr, int sda = -1, int scl = -1) + { + _wire = &wire; + _addr = addr; + if (sda >= 0 && scl >= 0) + wire.begin(sda, scl); + wire.beginTransmission(addr); + return wire.endTransmission() == 0; + } + + bool pinMode(int pin, int mode) + { + if (pin < 0 || pin > 15 || !_wire) + return false; + uint8_t port = pin / 8, bit = pin % 8; + uint8_t cfg; + uint8_t res = readReg(0x06 + port, cfg); + if (res) { + bool isInput = (mode == INPUT || mode == INPUT_PULLUP); + if (isInput) + cfg |= (1u << bit); + else + cfg &= ~(1u << bit); + return writeReg(0x06 + port, cfg); + } + return res; + } + + bool digitalWrite(int pin, int value) + { + if (pin < 0 || pin > 15 || !_wire) + return false; + uint8_t port = pin / 8, bit = pin % 8; + uint8_t out; + bool res = readReg(0x02 + port, out); + if (res) { + if (value) + out |= (1u << bit); + else + out &= ~(1u << bit); + return writeReg(0x02 + port, out); + } + return res; + } + + bool digitalRead(int pin) + { + if (pin < 0 || pin > 15 || !_wire) + return false; + uint8_t port = pin / 8, bit = pin % 8; + uint8_t reg; + if (readReg(0x00 + port, reg)) + return (reg & (1u << bit)) != 0; + else + return 0; + } + + private: + TwoWire *_wire = nullptr; + uint8_t _addr = 0x20; + + bool readReg(uint8_t reg, uint8_t &out) + { + _wire->beginTransmission(_addr); + _wire->write(reg); + if (_wire->endTransmission(false) != 0) { + _wire->end(); + _wire->begin(); + return false; + } + if (_wire->requestFrom((uint8_t)_addr, (uint8_t)1) != 1) + return false; + out = _wire->read(); + return true; + } + + bool writeReg(uint8_t reg, uint8_t val) + { + _wire->beginTransmission(_addr); + _wire->write(reg); + _wire->write(val); + if (_wire->endTransmission() != 0) { + _wire->end(); + _wire->begin(); + return false; + } + return true; + } +}; diff --git a/src/Power.cpp b/src/Power.cpp index 2cb73296b7..ee14b3c639 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -19,9 +19,12 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "Throttle.h" +#include "UptimeClock.h" +#include "WaypointStore.h" #include "buzz/buzz.h" #include "configuration.h" #include "main.h" +#include "memory/MemAudit.h" #include "meshUtils.h" #include "power/PowerHAL.h" #include "power/SGM41562.h" @@ -40,6 +43,10 @@ #include "input/LinuxJoystick.h" #endif +#ifdef HAS_ADS1115 +#include +#endif + // Working USB detection for powered/charging states on the RAK platform #ifdef NRF_APM #include "nrfx_power.h" @@ -594,13 +601,8 @@ class AnalogBatteryLevel : public HasBatteryLevel return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse; } #endif -#if defined(ELECROW_ThinkNode_M6) - return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn(); -#elif defined(EXT_CHRG_DETECT) - return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE; -#elif defined(BATTERY_CHARGING_INV) - return !digitalRead(BATTERY_CHARGING_INV); -#else + // A configured INA outranks the board's own charge-status pin, as it does BATTERY_PIN in + // getBattVoltage(): an external charger leaves that pin idle, reading "not charging" forever. #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !defined(DISABLE_INA_CHARGING_DETECTION) if (hasINA()) { // get current flow from INA sensor - negative value means power flowing @@ -613,6 +615,16 @@ class AnalogBatteryLevel : public HasBatteryLevel return getINACurrent() < 0; #endif } +#endif +#if defined(ELECROW_ThinkNode_M6) + return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn(); +#elif defined(EXT_CHRG_DETECT) + return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE; +#elif defined(BATTERY_CHARGING_INV) + return !digitalRead(BATTERY_CHARGING_INV); +#else +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !defined(DISABLE_INA_CHARGING_DETECTION) + // No charge-status pin and no INA: infer from battery presence plus external power. return isBatteryConnect() && isVbusIn(); #endif #endif @@ -679,6 +691,9 @@ class AnalogBatteryLevel : public HasBatteryLevel } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first == config.power.device_battery_ina_address) { return ina226Sensor.getCurrentMa(); + } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first == + config.power.device_battery_ina_address) { + return ina260Sensor.getCurrentMa(); } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first == config.power.device_battery_ina_address) { return ina3221Sensor.getCurrentMa(); @@ -686,30 +701,29 @@ class AnalogBatteryLevel : public HasBatteryLevel return 0; } + // Open the sensor if it isn't open yet, then report whether it is actually running. runOnce() + // answers with a poll interval, so only isRunning() tells us the device replied. + static bool sensorReady(TelemetrySensor &sensor) + { + if (!sensor.isInitialized()) + sensor.runOnce(); + return sensor.isRunning(); + } + bool hasINA() { - if (!config.power.device_battery_ina_address) { + const uint8_t inaAddress = config.power.device_battery_ina_address; + if (!inaAddress) { return false; } - if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA219].first == config.power.device_battery_ina_address) { - if (!ina219Sensor.isInitialized()) - return ina219Sensor.runOnce() > 0; - return ina219Sensor.isRunning(); - } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first == - config.power.device_battery_ina_address) { - if (!ina226Sensor.isInitialized()) - return ina226Sensor.runOnce() > 0; - return ina226Sensor.isRunning(); - } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first == - config.power.device_battery_ina_address) { - if (!ina260Sensor.isInitialized()) - return ina260Sensor.runOnce() > 0; - return ina260Sensor.isRunning(); - } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first == - config.power.device_battery_ina_address) { - if (!ina3221Sensor.isInitialized()) - return ina3221Sensor.runOnce() > 0; - return ina3221Sensor.isRunning(); + if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA219].first == inaAddress) { + return sensorReady(ina219Sensor); + } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first == inaAddress) { + return sensorReady(ina226Sensor); + } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first == inaAddress) { + return sensorReady(ina260Sensor); + } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first == inaAddress) { + return sensorReady(ina3221Sensor); } return false; } @@ -718,6 +732,135 @@ class AnalogBatteryLevel : public HasBatteryLevel static AnalogBatteryLevel analogLevel; +#ifdef HAS_ADS1115 +#include "SPILock.h" +#include + +/** + * @brief Battery level sensor using an ADS1115 16-bit ADC on I2C. + * Channel 0 measures battery voltage through a 1:2 resistive divider. + * USB / Charging status is managed via an AW35615 USB-C CC controller. + */ +class ADS1115BatteryLevel : public AnalogBatteryLevel +{ + public: + bool init() + { + { + concurrency::LockGuard guard(spiLock); + if (!_ads.begin(ADS1115_ADDR, &Wire)) { + LOG_WARN("ADS1115 not found on I2C bus - battery sensor unavailable"); + return false; + } + _ads.setGain(GAIN_ONE); // ±4.096 V FSR matches standard 1:2 voltage-divider + _ads.setDataRate(RATE_ADS1115_860SPS); // Maximize conversion speed to keep bus locking minimal + } + + initialized = true; + LOG_INFO("[ADS1115] battery sensor initialized"); + + if (_aw35615.begin(Wire)) { + LOG_INFO("[AW35615] USB-C CC controller initialized"); + } else { + LOG_WARN("[AW35615] not found at 0x22"); + } + getBattVoltage(); // initial read cached_mv + return true; + } + + virtual bool isBatteryConnect() override { return true; } + virtual uint16_t getBattVoltage() override + { + if (!initialized) + return 0; + + static constexpr uint32_t MIN_READ_INTERVAL_MS = 30000; + if (!initial_read_done || !Throttle::isWithinTimespanMs(last_read_ms, MIN_READ_INTERVAL_MS)) { + last_read_ms = millis(); + float sum = 0; + { + concurrency::LockGuard guard(spiLock); + for (uint8_t i = 0; i < SAMPLE_COUNT; i++) { + int16_t raw = _ads.readADC_SingleEnded(0); + sum += _ads.computeVolts(raw); + } + // Piggyback a toggle-engine watchdog on this same throttle interval. + // Only re-arm when VBUS is absent - calling this while attached + // would restart CC toggling and could glitch an active sink attach. + if (_aw35615.isReady() && !_aw35615.isVbusPresent()) { + _aw35615.rearmToggle(); + } + } + + // Voltage divider scales by 2.0; convert volts to millivolts + float v = (sum / (float)SAMPLE_COUNT) * 2.0f * 1000.0f; + + if (!initial_read_done) { + cached_mv = static_cast(v); + initial_read_done = true; + } else { + // Exponential moving average filter (50% smoothing) + cached_mv = static_cast(cached_mv + (v - cached_mv) * 0.5f); + } + } + return cached_mv; + } + + virtual bool isVbusIn() override + { + if (_aw35615.isReady()) { + concurrency::LockGuard guard(spiLock); + + bool vbus = _aw35615.isVbusPresent(); + if (!vbus) { + // VBUS just went away (or has been away) - make sure the CC + // toggle engine is re-armed so the next attach gets detected. + _aw35615.rearmToggle(); + } + return vbus; + } + // Fallback to base GPIO/board checks (or false) if CC chip is absent + return false; + } + + virtual bool isCharging() override + { + if (!isBatteryConnect()) + return false; + + if (_aw35615.isReady()) { + concurrency::LockGuard guard(spiLock); + // Charging == VBUS present AND we're attached as a sink. + // (isSinkAttached() is a latched result - safe to trust here since + // isVbusIn() above keeps re-arming toggle on every detach.) + return _aw35615.isVbusPresent() && _aw35615.isSinkAttached(); + } + return isVbusIn(); + } + + private: + static constexpr uint8_t SAMPLE_COUNT = 3; + Adafruit_ADS1115 _ads; + AW35615 _aw35615; + + bool initialized = false; + bool initial_read_done = false; + uint16_t cached_mv = 0; + uint32_t last_read_ms = 0; +}; + +static ADS1115BatteryLevel ads1115BattLevel; + +bool Power::ads1115Init() +{ + if (ads1115BattLevel.init()) { + batteryLevel = &ads1115BattLevel; + return true; + } + return false; +} +#endif // HAS_ADS1115 + Power::Power() : OSThread("Power") { statusHandler = {}; @@ -814,6 +957,10 @@ bool Power::setup() found = true; } else if (meshSolarInit()) { found = true; +#ifdef HAS_ADS1115 + } else if (ads1115Init()) { + found = true; +#endif } else if (analogInit()) { found = true; } else { @@ -847,11 +994,22 @@ void Power::powerCommandsCheck() shutdownAtMsec = 0; shutdown(); } + +#ifdef ARCH_STM32 + // Deferred DFU entry; the delay is armed by AdminModule's enter_dfu handler (rationale there). + if (enterDfuAtMsec && Throttle::deadlinePassed(enterDfuAtMsec)) { + enterDfuAtMsec = 0; + enterDfuMode(); // never returns + } +#endif } void Power::reboot() { notifyReboot.notifyObservers(NULL); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.saveToFlash(); +#endif #if defined(ARCH_ESP32) ESP.restart(); #elif defined(ARCH_NRF52) @@ -916,6 +1074,9 @@ void Power::shutdown() #if HAS_SCREEN messageStore.saveToFlash(); #endif +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.saveToFlash(); +#endif #if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_STM32WL) #ifdef PIN_LED1 ledOff(PIN_LED1); @@ -937,6 +1098,20 @@ void Power::shutdown() #endif } +// Consecutive readings only: a battery-less board's floating divider drifts in and out of the +// "battery present" window, and a count that survived the gaps would deep-sleep a USB-powered node. +bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv) +{ + if (!hasBattery || hasUsb || battMv >= cutoffMv) { + counter = 0; + return false; + } + + if (counter < UINT8_MAX) + counter++; + return counter > LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN; +} + /// Reads power status to powerStatus singleton. // // TODO(girts): move this and other axp stuff to power.h/power.cpp. @@ -1069,16 +1244,16 @@ void Power::readPowerStatus() // is 2.0 to 2.5V, current OCV min is set to 3100 that is large enough. // - if (batteryLevel && powerStatus2.getHasBattery() && !powerStatus2.getHasUSB()) { - if (batteryLevel->getBattVoltage() < OCV[NUM_OCV_POINTS - 1]) { - low_voltage_counter++; - LOG_DEBUG("Low voltage counter: %d/10", low_voltage_counter); - if (low_voltage_counter > 10) { - LOG_INFO("Low voltage detected, trigger deep sleep"); - powerFSM.trigger(EVENT_LOW_BATTERY); - } - } else { - low_voltage_counter = 0; + if (batteryLevel) { + // getBattVoltage() reports pack voltage; the OCV table is per cell. + const bool shutdownNow = + updateLowVoltageCounter(low_voltage_counter, powerStatus2.getHasBattery(), powerStatus2.getHasUSB(), + batteryLevel->getBattVoltage(), OCV[NUM_OCV_POINTS - 1] * NUM_CELLS); + if (low_voltage_counter) + LOG_DEBUG("Low voltage counter: %d/%d", low_voltage_counter, LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN); + if (shutdownNow) { + LOG_INFO("Low voltage detected, trigger deep sleep"); + powerFSM.trigger(EVENT_LOW_BATTERY); } } } @@ -1103,15 +1278,26 @@ void Power::logHeapUsage() // The first line has no earlier sample to difference against const int32_t delta = lastHeapLogTime ? (int32_t)(heapFree - lastHeapLogFree) : 0; + // min only ever falls: one step down is a transient alloc, repeated new lows are a leak. + // A steady min with a shrinking largest block is fragmentation. Empty where unsupported. + char detail[64] = ""; + const uint32_t minFree = memGet.getMinFreeHeap(); + const uint32_t maxAlloc = memGet.getMaxAllocHeap(); + if (minFree || maxAlloc) + snprintf(detail, sizeof(detail), ", min %u, largest block %u", minFree, maxAlloc); + const uint32_t psramTotal = memGet.getPsramSize(); if (psramTotal) - LOG_INFO("Heap: %u/%u bytes free (%d since last), PSRAM: %u/%u bytes free", heapFree, heapTotal, delta, + LOG_INFO("Heap: %u/%u bytes free (%d since last)%s, PSRAM: %u/%u bytes free", heapFree, heapTotal, delta, detail, memGet.getFreePsram(), psramTotal); else - LOG_INFO("Heap: %u/%u bytes free (%d since last)", heapFree, heapTotal, delta); + LOG_INFO("Heap: %u/%u bytes free (%d since last)%s", heapFree, heapTotal, delta, detail); + + // Which tagged subsystem moved since boot + memaudit::logBreakdown("periodic"); lastHeapLogFree = heapFree; - lastHeapLogTime = millis(); + lastHeapLogTime = Time::skipZero(Time::getMillis()); #endif } diff --git a/src/Power.h b/src/Power.h index b47d66aff2..6a7bd21643 100644 --- a/src/Power.h +++ b/src/Power.h @@ -30,6 +30,12 @@ #define NUM_CELLS 1 #endif +/// Consecutive below-cutoff readings needed before the low-battery deep sleep fires. +static constexpr uint8_t LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN = 10; + +/// Advance the low-battery shutdown counter by one reading; true once the device should deep sleep. +bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv); + #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #include "modules/Telemetry/Sensor/nullSensor.h" #if __has_include() @@ -98,7 +104,7 @@ class Power : public concurrency::OSThread virtual int32_t runOnce() override; void setStatusHandler(meshtastic::PowerStatus *handler) { statusHandler = handler; } const uint16_t OCV[11] = {OCV_ARRAY}; - bool isLowBattery() { return low_voltage_counter >= 10; }; + bool isLowBattery() { return low_voltage_counter >= LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN; }; #ifdef ARCH_ESP32 int beforeLightSleep(void *unused); @@ -127,6 +133,10 @@ class Power : public concurrency::OSThread bool meshSolarInit(); /// Setup a serial battery sensor bool serialBatteryInit(); +#ifdef HAS_ADS1115 + /// Setup ADS1115 I2C battery level sensor + bool ads1115Init(); +#endif private: void shutdown(); diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 2ef5b2ea1c..1a5d3f34db 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -12,6 +12,7 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerMon.h" +#include "UptimeClock.h" #include "configuration.h" #include "graphics/Screen.h" #include "main.h" @@ -58,23 +59,6 @@ static bool isPowered() return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB()); } -static bool isBluetoothEnabledForPowerFSM() -{ -#if HAS_BLUETOOTH && !MESHTASTIC_EXCLUDE_BLUETOOTH - return config.bluetooth.enabled; -#else - return false; -#endif -} - -static uint32_t getBluetoothWaitMs() -{ - if (!isBluetoothEnabledForPowerFSM()) - return 0; - - return Default::getConfiguredOrDefaultMs(config.power.wait_bluetooth_secs, default_wait_bluetooth_secs); -} - #if defined(T5_S3_EPAPER_PRO) static void t5BacklightOffForSleep() { @@ -121,7 +105,7 @@ extern Power *power; static void shutdownEnter() { LOG_POWERFSM("State: SHUTDOWN"); - shutdownAtMsec = millis(); + shutdownAtMsec = Time::skipZero(Time::getMillis()); } #include "error.h" @@ -184,6 +168,13 @@ static void lsIdle() if (pressed) { powerFSM.trigger(EVENT_PRESS); } +#ifdef MOTION_WAKE_INT_PIN + // Not the button: the accelerometer can have raised the line instead. + else if (config.display.wake_on_tap_or_motion && + digitalRead(MOTION_WAKE_INT_PIN) == (MOTION_WAKE_INT_ACTIVE_HIGH ? HIGH : LOW)) { + powerFSM.trigger(EVENT_INPUT); + } +#endif break; } default: @@ -212,6 +203,17 @@ static void lsExit() t5BacklightWakeFromSleep(); } +/// Skip the BLE re-enable while a reboot/shutdown is armed: AdminModule tears BLE down before +/// scheduling the restart, and a state transition in that window would otherwise bring it back up. +static void setBluetoothEnableUnlessRestarting() +{ + if (rebootAtMsec || shutdownAtMsec) { + LOG_POWERFSM("Skip BLE enable, restart pending"); + return; + } + setBluetoothEnable(true); +} + static void nbEnter() { LOG_POWERFSM("State: nbEnter"); @@ -228,7 +230,7 @@ static void nbEnter() static void darkEnter() { LOG_POWERFSM("State: darkEnter"); - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); if (screen) screen->setOn(false); // Screen timeout enters DARK; ensure backlight also turns off. @@ -252,7 +254,7 @@ static void serialExit() { LOG_POWERFSM("State: serialExit"); // Turn bluetooth back on when we leave serial stream API - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); } static void powerEnter() @@ -265,7 +267,7 @@ static void powerEnter() } else { if (screen) screen->setOn(true); - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); // within enter() the function getState() returns the state we came from } } @@ -283,7 +285,7 @@ static void powerIdle() static void powerExit() { LOG_POWERFSM("State: powerExit"); - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); } static void onEnter() @@ -291,7 +293,7 @@ static void onEnter() LOG_POWERFSM("State: onEnter"); if (screen) screen->setOn(true); - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); } static void onIdle() @@ -448,7 +450,10 @@ void PowerFSM_setup() // If ESP32 and using power-saving, timer mover from DARK to light-sleep // Also serves purpose of the old DARK to DARK transition(?) See https://github.com/meshtastic/firmware/issues/3517 - powerFSM.add_timed_transition(&stateDARK, &stateLS, getBluetoothWaitMs(), NULL, "Bluetooth timeout"); + powerFSM.add_timed_transition( + &stateDARK, &stateLS, + Default::getConfiguredOrDefaultMs(config.power.wait_bluetooth_secs, default_wait_bluetooth_secs), NULL, + "Bluetooth timeout"); } else { // If ESP32, but not using power-saving, check periodically if config has drifted out of stateDark powerFSM.add_timed_transition(&stateDARK, &stateDARK, diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 66a266d960..e4a56bb4af 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -235,8 +235,6 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ isBleConnected = nimbleBluetooth && nimbleBluetooth->isActive() && nimbleBluetooth->isConnected(); #elif defined(ARCH_NRF52) isBleConnected = nrf52Bluetooth != nullptr && nrf52Bluetooth->isConnected(); -#elif defined(ARCH_NRF54L15) - isBleConnected = nrf54l15Bluetooth != nullptr && nrf54l15Bluetooth->isConnected(); #endif if (isBleConnected) { auto thread = concurrency::OSThread::currentThread; @@ -253,8 +251,6 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ nimbleBluetooth->sendLog(buffer.get(), size); #elif defined(ARCH_NRF52) nrf52Bluetooth->sendLog(buffer.get(), size); -#elif defined(ARCH_NRF54L15) - nrf54l15Bluetooth->sendLog(buffer.get(), size); #endif } } diff --git a/src/UptimeClock.h b/src/UptimeClock.h index efc04eb899..853fe7cbcf 100644 --- a/src/UptimeClock.h +++ b/src/UptimeClock.h @@ -44,6 +44,37 @@ void setMonotonicPublishHookForTests(MonotonicPublishHook hook); /// which is wrap-correct with no carry state at all. uint32_t getMillis(); +/// Step a millis value past 0. Stored stamps and deadlines conventionally use 0 for "unset", so the +/// one tick per ~49.7-day wrap that lands on 0 would read as never-set; 1 is a 1 ms error instead. +constexpr uint32_t skipZero(uint32_t ms) +{ + return ms ? ms : 1; +} + +/// Start a countdown to delayMs from now, never 0. The sum is what has to dodge 0 - a non-zero +/// read plus a delay lands there once per wrap - so this is not skipZero(getMillis()) + delayMs. +inline uint32_t timerEndsAtMillis(uint32_t delayMs) +{ + return skipZero(getMillis() + delayMs); +} + +/// getMillis() for 0-means-unset stamps, with the 0 tick called 1. Use at the read when the value +/// is both stored and compared against stamps: skipZero() only at the store makes `now - stamp` wrap. +inline uint32_t stampMillis() +{ + return skipZero(getMillis()); +} + +// skipZero() is the whole 0-means-unset contract in one expression, and it is constexpr, so pin it +// here rather than only in test_uptime_clock: a build that breaks it stops at this header instead of +// shipping a deadline that reads as never-set. The two obvious "simplifications" are what these +// catch - `ms | 1` perturbs every even value, and `ms + 1` turns the last tick of the wrap into the +// 0 the function exists to avoid. Both compile and both pass a test that only checks skipZero(0). +static_assert(skipZero(0) == 1, "skipZero must lift the one 0 tick to 1"); +static_assert(skipZero(1) == 1, "skipZero must leave 1 alone"); +static_assert(skipZero(2) == 2, "skipZero must pass even values through untouched (ms | 1 would not)"); +static_assert(skipZero(UINT32_MAX) == UINT32_MAX, "skipZero must not wrap the last tick to 0 (ms + 1 would)"); + /// Milliseconds since boot as a monotonic 64-bit count. /// /// A pure read: it derives its answer from a complete snapshot published by serviceMonotonic() diff --git a/src/WaypointStore.cpp b/src/WaypointStore.cpp new file mode 100644 index 0000000000..b5a0208740 --- /dev/null +++ b/src/WaypointStore.cpp @@ -0,0 +1,400 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "Throttle.h" +#include "UptimeClock.h" +#include "WaypointStore.h" +#include "concurrency/LockGuard.h" +#include "gps/RTC.h" +#include "meshUtils.h" +#include +#include +#include + +namespace +{ + +constexpr uint8_t WAYPOINT_STORE_VERSION = 3; +constexpr const char *WAYPOINT_STORE_FILENAME = "/Waypoints_default.wpts"; + +#ifndef WAYPOINT_AUTOSAVE_INTERVAL_SEC +#define WAYPOINT_AUTOSAVE_INTERVAL_SEC (2 * 60 * 60) +#endif + +struct __attribute__((packed)) StoredWaypointRecord { + uint32_t creatorNodeNum; + uint32_t receivedTime; + uint8_t notificationPreferences; + uint16_t payloadLength; + uint8_t payload[meshtastic_Waypoint_size]; +}; + +bool decodeWaypointPayload(const uint8_t *payload, size_t payloadLength, meshtastic_Waypoint &wp) +{ + memset(&wp, 0, sizeof(wp)); + return pb_decode_from_bytes(payload, payloadLength, &meshtastic_Waypoint_msg, &wp); +} + +uint16_t encodeWaypointPayload(const meshtastic_Waypoint &wp, uint8_t *payload, size_t payloadCapacity) +{ + return (uint16_t)pb_encode_to_bytes(payload, payloadCapacity, &meshtastic_Waypoint_msg, &wp); +} + +static bool g_waypointStoreHasUnsavedChanges = false; +static uint32_t g_lastWaypointAutoSaveMs = 0; + +uint32_t autosaveIntervalMs() +{ + uint32_t sec = (uint32_t)WAYPOINT_AUTOSAVE_INTERVAL_SEC; + if (sec < 60) + sec = 60; + return sec * 1000UL; +} + +void markWaypointStoreUnsaved() +{ + g_waypointStoreHasUnsavedChanges = true; + if (g_lastWaypointAutoSaveMs == 0) + g_lastWaypointAutoSaveMs = Time::getMillis(); +} + +void persistWaypointStore() +{ + LOG_INFO("Autosaving WaypointStore to flash"); + waypointStore.saveToFlash(); +} + +} // namespace + +WaypointStore waypointStore; + +void WaypointStore::notifyChanged() +{ + notifyObservers(this); +} + +bool WaypointStore::isExpired(const meshtastic_Waypoint &wp, uint32_t now) +{ + // getTime() counts from boot until the RTC is set, which reads every real expiry as future. + if (now == 0) + now = getValidTime(RTCQuality::RTCQualityDevice); + + return !waypointIsActive(wp.expire, now); +} + +bool WaypointStore::isExpired(const StoredWaypoint &entry, uint32_t now) +{ + return isExpired(entry.waypoint, now); +} + +uint8_t WaypointStore::notificationPreferencesFromWaypoint(const meshtastic_Waypoint &wp) +{ + uint8_t preferences = 0; + if (wp.notify_on_enter) + preferences |= WAYPOINT_NOTIFY_ENTER; + if (wp.notify_on_exit) + preferences |= WAYPOINT_NOTIFY_EXIT; + if (wp.notify_favorites_only) + preferences |= WAYPOINT_NOTIFY_FAVORITES_ONLY; + return preferences; +} + +uint8_t WaypointStore::mergeNotificationPreferences(bool locallyAuthored, bool hasExisting, uint8_t existingPreferences, + const meshtastic_Waypoint &incoming) +{ + if (locallyAuthored) + return notificationPreferencesFromWaypoint(incoming); + return hasExisting ? existingPreferences : 0; +} + +void WaypointStore::clearWireNotificationPreferences(meshtastic_Waypoint &wp) +{ + wp.notify_on_enter = false; + wp.notify_on_exit = false; + wp.notify_favorites_only = false; +} + +const StoredWaypoint *WaypointStore::findWaypoint(uint32_t id) const +{ + for (const StoredWaypoint &entry : waypoints) { + if (entry.waypoint.id == id) + return &entry; + } + return nullptr; +} + +bool WaypointStore::removeWaypointById(uint32_t id) +{ + for (auto it = waypoints.begin(); it != waypoints.end(); ++it) { + if (it->waypoint.id == id) { + waypoints.erase(it); + return true; + } + } + + return false; +} + +bool WaypointStore::removeWaypoint(uint32_t id) +{ + const bool removed = removeWaypointById(id); + if (!removed) + return false; + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + + return true; +} + +bool WaypointStore::setNotificationPreference(uint32_t id, WaypointNotificationPreference preference, bool enabled) +{ + for (StoredWaypoint &entry : waypoints) { + if (entry.waypoint.id != id) + continue; + + const uint8_t previous = entry.notificationPreferences; + if (enabled) + entry.notificationPreferences |= preference; + else + entry.notificationPreferences &= ~preference; + if (entry.notificationPreferences == previous) + return true; + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + return true; + } + + return false; +} + +void WaypointStore::addStoredWaypoint(const StoredWaypoint &entry) +{ + removeWaypointById(entry.waypoint.id); + + waypoints.push_front(entry); + while (waypoints.size() > WAYPOINT_HISTORY_LIMIT) + waypoints.pop_back(); +} + +bool WaypointStore::addFromPacket(const meshtastic_MeshPacket &packet, bool locallyAuthored, StoredWaypoint *stored) +{ + StoredWaypoint entry; + if (!decodeWaypointPayload(packet.decoded.payload.bytes, packet.decoded.payload.size, entry.waypoint)) + return false; + + const StoredWaypoint *existing = findWaypoint(entry.waypoint.id); + entry.notificationPreferences = mergeNotificationPreferences( + locallyAuthored, existing != nullptr, existing ? existing->notificationPreferences : 0, entry.waypoint); + clearWireNotificationPreferences(entry.waypoint); + entry.receivedTime = packet.rx_time ? packet.rx_time : getTime(); + entry.creatorNodeNum = getFrom(&packet); + + if (stored) + *stored = entry; + + // rx_time holds uptime, not an epoch, when has_rx_time is false; pass 0 so isExpired() resolves + // the clock itself rather than comparing an expiry against seconds since boot. + if (isExpired(entry, packet.has_rx_time ? packet.rx_time : 0)) { + // Respect the lock: only the node a waypoint is locked to may delete it on our device. + // An unauthorized deletion attempt is ignored entirely, rather than applied locally. + for (const auto &storedEntry : waypoints) { + if (storedEntry.waypoint.id != entry.waypoint.id) + continue; + if (storedEntry.waypoint.locked_to != 0 && storedEntry.waypoint.locked_to != entry.creatorNodeNum) + return true; // Packet handled, but the deletion is not honored + break; + } + + removeWaypoint(entry.waypoint.id); + return true; + } + + addStoredWaypoint(entry); + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + + return true; +} + +bool WaypointStore::purgeExpired(uint32_t now) +{ + // No local clock normalization: isExpired() owns that policy, including the delete convention. + bool changed = false; + for (auto it = waypoints.begin(); it != waypoints.end();) { + if (!isExpired(*it, now)) { + ++it; + continue; + } + + it = waypoints.erase(it); + changed = true; + } + + if (changed) { +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + } + + return changed; +} + +void WaypointStore::saveToFlash() +{ + purgeExpired(); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + if (!g_waypointStoreHasUnsavedChanges) + return; + + spiLock->lock(); + FSCom.mkdir("/"); + spiLock->unlock(); + + SafeFile f(WAYPOINT_STORE_FILENAME, false); + + spiLock->lock(); + const uint8_t version = WAYPOINT_STORE_VERSION; + size_t countFull = waypoints.size(); + if (countFull > WAYPOINT_HISTORY_LIMIT) + countFull = WAYPOINT_HISTORY_LIMIT; + if (countFull > UINT8_MAX) + countFull = UINT8_MAX; + const uint8_t count = (uint8_t)countFull; + + f.write(&version, 1); + f.write(&count, 1); + + for (uint8_t i = 0; i < count; ++i) { + StoredWaypointRecord rec = {}; + rec.creatorNodeNum = waypoints[i].creatorNodeNum; + rec.receivedTime = waypoints[i].receivedTime; + rec.notificationPreferences = waypoints[i].notificationPreferences; + rec.payloadLength = encodeWaypointPayload(waypoints[i].waypoint, rec.payload, sizeof(rec.payload)); + f.write(reinterpret_cast(&rec), sizeof(rec)); + } + spiLock->unlock(); + f.close(); +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif +} + +void WaypointStore::loadFromFlash() +{ + std::deque().swap(waypoints); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + { + concurrency::LockGuard guard(spiLock); + + if (FSCom.exists(WAYPOINT_STORE_FILENAME)) { + auto f = FSCom.open(WAYPOINT_STORE_FILENAME, FILE_O_READ); + if (f) { + uint8_t version = 0; + uint8_t count = 0; + f.readBytes(reinterpret_cast(&version), 1); + f.readBytes(reinterpret_cast(&count), 1); + + if (version != WAYPOINT_STORE_VERSION) { + LOG_WARN("WaypointStore version mismatch (%u)", version); + f.close(); + } else { + if (count > WAYPOINT_HISTORY_LIMIT) + count = WAYPOINT_HISTORY_LIMIT; + + for (uint8_t i = 0; i < count; ++i) { + StoredWaypoint entry; + StoredWaypointRecord rec = {}; + if (f.readBytes(reinterpret_cast(&rec), sizeof(rec)) != sizeof(rec)) + break; + if (rec.payloadLength == 0 || rec.payloadLength > sizeof(rec.payload)) { + LOG_WARN("WaypointStore skipping corrupt record %u", i); + continue; + } + if (!decodeWaypointPayload(rec.payload, rec.payloadLength, entry.waypoint)) + continue; + entry.receivedTime = rec.receivedTime; + entry.creatorNodeNum = rec.creatorNodeNum; + entry.notificationPreferences = rec.notificationPreferences; + + if (isExpired(entry.waypoint)) + continue; + waypoints.push_back(entry); + } + f.close(); + } + } + } + } +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif +} + +void WaypointStore::clearAllWaypoints() +{ + const bool hadWaypoints = !waypoints.empty(); + + std::deque().swap(waypoints); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + SafeFile f(WAYPOINT_STORE_FILENAME, false); + { + concurrency::LockGuard guard(spiLock); + const uint8_t version = WAYPOINT_STORE_VERSION; + const uint8_t count = 0; + f.write(&version, 1); + f.write(&count, 1); + } + f.close(); +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif + + if (hadWaypoints) + notifyChanged(); +} + +#if ENABLE_WAYPOINT_PERSISTENCE +void waypointStoreAutosaveTick() +{ + if (!g_waypointStoreHasUnsavedChanges) { + if (g_lastWaypointAutoSaveMs == 0) + g_lastWaypointAutoSaveMs = Time::getMillis(); + return; + } + + if (g_lastWaypointAutoSaveMs == 0) { + g_lastWaypointAutoSaveMs = Time::getMillis(); + return; + } + + Throttle::execute(&g_lastWaypointAutoSaveMs, autosaveIntervalMs(), persistWaypointStore); +} +#endif + +#endif diff --git a/src/WaypointStore.h b/src/WaypointStore.h new file mode 100644 index 0000000000..1dbab78e6c --- /dev/null +++ b/src/WaypointStore.h @@ -0,0 +1,75 @@ +#pragma once + +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#ifndef ENABLE_WAYPOINT_PERSISTENCE +#define ENABLE_WAYPOINT_PERSISTENCE 1 +#endif + +#ifndef WAYPOINT_HISTORY_LIMIT +#define WAYPOINT_HISTORY_LIMIT 10 +#endif + +#include "Observer.h" +#include "mesh/MeshTypes.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +enum WaypointNotificationPreference : uint8_t { + WAYPOINT_NOTIFY_ENTER = 1 << 0, + WAYPOINT_NOTIFY_EXIT = 1 << 1, + WAYPOINT_NOTIFY_FAVORITES_ONLY = 1 << 2, +}; + +struct StoredWaypoint { + meshtastic_Waypoint waypoint = meshtastic_Waypoint_init_zero; + uint32_t receivedTime = 0; + NodeNum creatorNodeNum = 0; + uint8_t notificationPreferences = 0; + + bool notificationEnabled(WaypointNotificationPreference preference) const + { + return (notificationPreferences & preference) != 0; + } +}; + +class WaypointStore : public Observable +{ + public: + bool addFromPacket(const meshtastic_MeshPacket &packet, bool locallyAuthored, StoredWaypoint *stored = nullptr); + bool purgeExpired(uint32_t now = 0); + bool removeWaypoint(uint32_t id); + bool setNotificationPreference(uint32_t id, WaypointNotificationPreference preference, bool enabled); + + const std::deque &getWaypoints() const { return waypoints; } + const StoredWaypoint *findWaypoint(uint32_t id) const; + + void saveToFlash(); + void loadFromFlash(); + void clearAllWaypoints(); + + static bool isExpired(const meshtastic_Waypoint &wp, uint32_t now = 0); + static bool isExpired(const StoredWaypoint &entry, uint32_t now = 0); + static uint8_t notificationPreferencesFromWaypoint(const meshtastic_Waypoint &wp); + static uint8_t mergeNotificationPreferences(bool locallyAuthored, bool hasExisting, uint8_t existingPreferences, + const meshtastic_Waypoint &incoming); + static void clearWireNotificationPreferences(meshtastic_Waypoint &wp); + + private: + void addStoredWaypoint(const StoredWaypoint &entry); + bool removeWaypointById(uint32_t id); + void notifyChanged(); + + std::deque waypoints; +}; + +#if ENABLE_WAYPOINT_PERSISTENCE +void waypointStoreAutosaveTick(); +#endif + +extern WaypointStore waypointStore; + +#endif diff --git a/src/WaypointUtils.h b/src/WaypointUtils.h new file mode 100644 index 0000000000..ec78c214e2 --- /dev/null +++ b/src/WaypointUtils.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace WaypointUtils +{ + +inline std::string utf8FromCodepoint(uint32_t codepoint) +{ + if (codepoint == 0 || (codepoint >= 0xD800 && codepoint <= 0xDFFF) || codepoint > 0x10FFFF) + return ""; + + char buf[4]; + if (codepoint <= 0x7F) { + buf[0] = static_cast(codepoint); + return std::string(buf, 1); + } + if (codepoint <= 0x7FF) { + buf[0] = static_cast(0xC0 | (codepoint >> 6)); + buf[1] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 2); + } + if (codepoint <= 0xFFFF) { + buf[0] = static_cast(0xE0 | (codepoint >> 12)); + buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[2] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 3); + } + + buf[0] = static_cast(0xF0 | (codepoint >> 18)); + buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[3] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 4); +} + +} // namespace WaypointUtils diff --git a/src/airtime.cpp b/src/airtime.cpp index aaacefb092..154014cf8e 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -2,7 +2,9 @@ #include "NodeDB.h" #include "UptimeClock.h" #include "configuration.h" +#include #include +#include #include AirTime *airTime = NULL; @@ -60,7 +62,7 @@ uint8_t AirTime::Windows::getPeriodUtilHour(const Held &) return (secSinceBoot / 60) % MINUTES_IN_HOUR; } -void AirTime::Windows::syncNow(const Held &) +void AirTime::Windows::syncNow(const Held &held) { // Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move // airtime accounting. Pure read; the main loop publishes the wrap carry it derives from. @@ -112,13 +114,16 @@ void AirTime::Windows::syncNow(const Held &) // Channel utilization is a rolling 60-second view split into six 10-second buckets. // Clear every bucket crossed while asleep so old airtime decays by real elapsed time. uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10); - if (elapsedUtilPeriods >= CHANNEL_UTILIZATION_PERIODS) { - memset(this->channelUtilization, 0, sizeof(this->channelUtilization)); - } else { - for (uint32_t i = 1; i <= elapsedUtilPeriods; i++) { - this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; - } + // Fold one reading per crossed bucket, each before that bucket is cleared, so one delayed sync + // lands where the same number of 10 s syncs would have. Bounded: six clears empty the window. + const uint32_t steppedUtilPeriods = std::min(elapsedUtilPeriods, CHANNEL_UTILIZATION_PERIODS); + for (uint32_t i = 1; i <= steppedUtilPeriods; i++) { + foldChannelUtil(channelUtilizationPercentRaw(held), 1, held); + this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; } + // Anything past a full window is elapsed time against an already-empty ring, so it folds as + // idle in closed form rather than looping over a sleep that may have lasted days. + foldChannelUtil(0.0f, elapsedUtilPeriods - steppedUtilPeriods, held); // TX utilization is a rolling 60-minute view used by duty-cycle checks. uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60); @@ -154,11 +159,8 @@ bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size return true; } -float AirTime::Windows::channelUtilizationPercent(const Held &held) +float AirTime::Windows::channelUtilizationPercentRaw(const Held &) { - // Gate decisions should see buckets that have decayed across light-sleep time. - syncNow(held); - uint32_t sum = 0; for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) { sum += this->channelUtilization[i]; @@ -167,6 +169,42 @@ float AirTime::Windows::channelUtilizationPercent(const Held &held) return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100; } +float AirTime::Windows::channelUtilizationPercent(const Held &held) +{ + // Gate decisions should see buckets that have decayed across light-sleep time. + syncNow(held); + + return channelUtilizationPercentRaw(held); +} + +void AirTime::Windows::foldChannelUtil(float sample, uint32_t steps, const Held &) +{ + if (steps == 0) + return; + + if (!hasChannelUtilSample) { + // Seed from the first reading, or a node booting onto a busy channel reports it quiet + // for a whole time constant. + channelUtilAvg = sample; + hasChannelUtilSample = true; + steps--; + } + + if (steps > 0) { + const float retained = powf(1.0f - 1.0f / float(CHANNEL_UTILIZATION_EMA_DIVISOR), float(steps)); + channelUtilAvg = sample + (channelUtilAvg - sample) * retained; + } +} + +float AirTime::Windows::smoothedChannelUtilizationPercent(const Held &held) +{ + syncNow(held); + + // Nothing folded yet before the first bucket crossing, and 0 would read as an idle channel + // rather than as no data. + return hasChannelUtilSample ? channelUtilAvg : channelUtilizationPercentRaw(held); +} + float AirTime::Windows::utilizationTXPercent(const Held &held) { // Duty-cycle checks use this value, so keep it current even outside the periodic thread. @@ -242,6 +280,12 @@ float AirTime::channelUtilizationPercent() return w.channelUtilizationPercent(held); } +float AirTime::smoothedChannelUtilizationPercent() +{ + Held held(this); + return w.smoothedChannelUtilizationPercent(held); +} + float AirTime::utilizationTXPercent() { Held held(this); diff --git a/src/airtime.h b/src/airtime.h index b1e1172a76..e789aa62b5 100644 --- a/src/airtime.h +++ b/src/airtime.h @@ -34,6 +34,8 @@ OUTPUTS: channelUtilizationPercent() % of the last 60s busy, all three types + smoothedChannelUtilizationPercent() + the same, behind a ~21 min EMA folded per bucket utilizationTXPercent() % of the last hour we transmitted isTxAllowedChannelUtil() gate on the former, 40% or 25% "polite" isTxAllowedAirUtil() gate on the latter, at HALF the duty cycle @@ -77,6 +79,9 @@ */ #define CHANNEL_UTILIZATION_PERIODS 6 +// EMA weight per crossed 10 s bucket: 1/128 is a time constant of about 21 minutes, so one busy +// or quiet minute cannot move the smoothed figure far. +#define CHANNEL_UTILIZATION_EMA_DIVISOR 128 #define SECONDS_PER_PERIOD 3600 #define PERIODS_TO_LOG 8 #define MINUTES_IN_HOUR 60 @@ -130,6 +135,9 @@ class AirTime : private concurrency::OSThread void logAirtime(reportTypes reportType, uint32_t airtime_ms); float channelUtilizationPercent(); + /// channelUtilizationPercent() behind an EMA advanced by elapsed time, for a caller that must + /// judge load from a trend rather than from one 60-second window. + float smoothedChannelUtilizationPercent(); float utilizationTXPercent(); /// Compatibility shim: no caller in the tree, kept for out-of-tree ones. @@ -182,7 +190,12 @@ class AirTime : private concurrency::OSThread // Modular rings: index is absolute phase, (uptime secs / period) % N, never age. uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; // 6 x 10s - uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only + + // EMA over channelUtilization, folded in syncNow() once per crossed 10 s bucket so its + // time constant follows elapsed time rather than how often a caller happens to ask. + float channelUtilAvg = 0.0f; + bool hasChannelUtilSample = false; + uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only // Hour crossings rotated but not yet traced. The core cannot log its own rotations: it // only ever runs under the lock, and DEBUG_PORT.log() blocks on a UART write. runOnce() @@ -198,6 +211,13 @@ class AirTime : private concurrency::OSThread void logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &); float channelUtilizationPercent(const Held &); + /// The bucket sum alone. syncNow() folds the EMA and cannot reach it through + /// channelUtilizationPercent(), which would re-enter syncNow(). + float channelUtilizationPercentRaw(const Held &); + float smoothedChannelUtilizationPercent(const Held &); + /// Fold `steps` readings of `sample` into channelUtilAvg. Closed form, not a loop, so a + /// multi-day sleep decays by the time elapsed at the cost of one powf. + void foldChannelUtil(float sample, uint32_t steps, const Held &); float utilizationTXPercent(const Held &); bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &); uint8_t getSilentMinutes(float txPercent, float dutyCycle, const Held &); diff --git a/src/concurrency/OSThread.cpp b/src/concurrency/OSThread.cpp index ce9a256b73..defc040b9c 100644 --- a/src/concurrency/OSThread.cpp +++ b/src/concurrency/OSThread.cpp @@ -54,6 +54,7 @@ void OSThread::setIntervalFromNow(unsigned long _interval) interval = _interval; // Cache the next run based on the last_run + // unset-sentinel-ok: enabled is the armed flag, and tillRun() reads this as a wrap-safe delta _cached_next_run = millis() + interval; } diff --git a/src/configuration.h b/src/configuration.h index b55ce262f7..b2bd7e4f60 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -29,18 +29,23 @@ along with this program. If not, see . #if __has_include("Melopero_RV3028.h") #include "Melopero_RV3028.h" #endif -#if __has_include("SensorRtcHelper.hpp") -#include "SensorRtcHelper.hpp" -// SensorLib defines isBitSet as a macro; undefine it here to avoid conflicts -// with the SparkFun MMC5983MA library, which has a class method of the same name. -#ifdef isBitSet -#undef isBitSet -#endif +#if __has_include() +#include #endif /* Offer chance for variant-specific defines */ #include "variant.h" +// Both PCF parts answer at the same address and differ only in register layout, so a variant +// picks one by defining PCF8563_RTC or PCF85063_RTC to it. +#if defined(PCF8563_RTC) +#define PCF_RTC_ADDRESS PCF8563_RTC +#define PCF_RTC_CHIP PCF8xRTC::PCF8563 +#elif defined(PCF85063_RTC) +#define PCF_RTC_ADDRESS PCF85063_RTC +#define PCF_RTC_CHIP PCF8xRTC::PCF85063 +#endif + // ----------------------------------------------------------------------------- // Display feature overrides // ----------------------------------------------------------------------------- @@ -254,10 +259,11 @@ along with this program. If not, see . // Define if screen should be mirrored left to right // #define SCREEN_MIRROR -// I2C Keyboards (M5Stack, RAK14004, T-Deck, T-Deck Pro, T-Lora Pager, CardKB, BBQ10, MPR121, TCA8418) +// I2C Keyboards (M5Stack, RAK14004, T-Deck, T-Deck Pro, T-Lora Pager, CardKB, BBQ10, MCP23017, MPR121, TCA8418) #define CARDKB_ADDR 0x5F #define TDECK_KB_ADDR 0x55 #define BBQ10_KB_ADDR 0x1F +#define MCP23017_KB_ADDR 0x20 // MCP23017 with A0/A1/A2 tied low - same address as TCA9535_ADDR #define MPR121_KB_ADDR 0x5A #define TCA8418_KB_ADDR 0x34 #define TSTC8_KB_ADDR 0x6C // STC8H companion-MCU keypad on the ThinkNode-M9 @@ -353,6 +359,15 @@ along with this program. If not, see . // ----------------------------------------------------------------------------- #define NCP5623_ADDR 0x38 #define LP5562_ADDR 0x30 +#define LP5814_ADDR 0x2C + +// ----------------------------------------------------------------------------- +// Audio Codec +// ----------------------------------------------------------------------------- +#if not __has_include("Codecs/es8311/ES8311.h") +#define ES8311_ADDR 0x18 // same address as MCP9808_ADDR / STK8BXX_ADDR / LIS3DH_ADDR +#endif +#define ES7243E_ADDR 0x14 // ----------------------------------------------------------------------------- // Security @@ -367,10 +382,11 @@ along with this program. If not, see . // ----------------------------------------------------------------------------- // Touchscreen // ----------------------------------------------------------------------------- -#define FT6336U_ADDR 0x48 -#define CST328_ADDR 0x1A // same address as CST226SE +#define FT6336U_ADDR 0x48 // same address as ADS1115 +#define CST328_ADDR 0x1A // same address as CST226SE #define CHSC6X_ADDR 0x2E #define CST226SE_ADDR_ALT 0x5A +#define GT911_ADDR 0x5D // same address as SFA30_ADDR / LPS22HB_ADDR_ALT // ----------------------------------------------------------------------------- // RAK12035VB Soil Monitor (using RAK12023 up to 3 RAK12035 monitors can be connected) @@ -592,7 +608,12 @@ along with this program. If not, see . #define MESHTASTIC_EXCLUDE_ADMIN 1 #endif -// // Turn off wifi even if HW supports wifi (webserver relies on wifi and is also disabled) +// Store & Forward is implemented only for ESP32 and Portduino +#if !defined(ARCH_ESP32) && !defined(ARCH_PORTDUINO) && !defined(MESHTASTIC_EXCLUDE_STOREFORWARD) +#define MESHTASTIC_EXCLUDE_STOREFORWARD 1 +#endif + +// Turn off wifi even if HW supports wifi (webserver relies on wifi and is also disabled) #ifdef MESHTASTIC_EXCLUDE_WIFI #define MESHTASTIC_EXCLUDE_WEBSERVER 1 #undef HAS_WIFI @@ -622,6 +643,33 @@ along with this program. If not, see . #define HAS_SCREEN 0 #endif +// ----------------------------------------------------------------------------- +// Motion sensor wake +// ----------------------------------------------------------------------------- + +/* The motion driver that owns this pin attaches the ISR. sleep.cpp reuses it as a + light-sleep wake source and PowerFSM attributes the resulting GPIO wake to motion. + Must stay below the exclusion cascade: MESHTASTIC_MINIMIZE_BUILD derives + MESHTASTIC_EXCLUDE_I2C above, and no motion driver is built when it is set. */ +#if !MESHTASTIC_EXCLUDE_I2C +#if defined(BMA4XX_INT) && defined(HAS_BMA423) +#define MOTION_WAKE_INT_PIN BMA4XX_INT +#define MOTION_WAKE_INT_ACTIVE_HIGH 1 +#elif defined(BHI260AP_INT) && defined(HAS_BHI260AP) +#define MOTION_WAKE_INT_PIN BHI260AP_INT +#define MOTION_WAKE_INT_ACTIVE_HIGH 1 +#elif defined(STK8XXX_INT) && defined(HAS_STK8XXX) +#define MOTION_WAKE_INT_PIN STK8XXX_INT +#define MOTION_WAKE_INT_ACTIVE_HIGH 1 +#elif defined(ICM_20948_INT_PIN) && defined(HAS_ICM20948) +#define MOTION_WAKE_INT_PIN ICM_20948_INT_PIN +#define MOTION_WAKE_INT_ACTIVE_HIGH 0 +#elif defined(QMA_6100P_INT_PIN) && defined(HAS_QMA6100P) +#define MOTION_WAKE_INT_PIN QMA_6100P_INT_PIN +#define MOTION_WAKE_INT_ACTIVE_HIGH 0 +#endif +#endif + #ifndef USE_ETHERNET_DEFAULT #define USE_ETHERNET_DEFAULT 0 #endif diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index 19d5615ba9..56a784b17d 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -31,8 +31,8 @@ ScanI2C::FoundDevice ScanI2C::firstRTC() const ScanI2C::FoundDevice ScanI2C::firstKeyboard() const { - ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MCP23017, MPR121KB, TCA8418KB, STC8HKB}; - return firstOfOrNONE(7, types); + ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MCP23017KB, MPR121KB, TCA8418KB, STC8HKB}; + return firstOfOrNONE(8, types); } ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index e278a5571e..65c758cc1d 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -51,6 +51,7 @@ class ScanI2C BMA423, BQ24295, LSM6DS3, + AW35615, TCA9535, TCA9555, VEML7700, @@ -72,7 +73,7 @@ class ScanI2C SCD4X, MAX30102, TPS65233, - MCP23017, + MCP23017KB, MPR121KB, CGRADSENS, INA226, @@ -110,7 +111,11 @@ class ScanI2C STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9) DS248X, HM330X, - AS3935 + AS3935, + GT911, + LP5814, + ES8311, + ES7243E, } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 6861f666da..85d1867cef 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -319,11 +319,14 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) } #endif - // We only need to scan 112 addresses, the rest is reserved for special - // purposes 0x00 General Call 0x01 CBUS addresses 0x02 Reserved for different - // bus formats 0x03 Reserved for future purposes 0x04-0x07 High Speed Master - // Code 0x78-0x7B 10-bit slave addressing 0x7C-0x7F Reserved for future - // purposes + // We only need to scan 112 addresses, the rest is reserved for special purposes + // 0x00 General Call + // 0x01 CBUS addresses + // 0x02 Reserved for different bus formats + // 0x03 Reserved for future purposes + // 0x04-0x07 High Speed Master Code + // 0x78-0x7B 10-bit slave addressing + // 0x7C-0x7F Reserved for future purposes for (addr.address = 8; addr.address < 120; addr.address++) { #if defined(HAS_QMA6100P) && (defined(ARCH_NRF52) || defined(NRF52_SERIES) || defined(NRF52)) @@ -334,8 +337,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) continue; LOG_DEBUG("Scan address 0x%x", (uint8_t)addr.address); } - // For QMA6100P candidates on nRF52, use bounded I2C probing; otherwise use - // normal Wire + // For QMA6100P candidates on nRF52, use bounded I2C probing; otherwise use normal Wire #if defined(HAS_QMA6100P) && (defined(ARCH_NRF52) || defined(NRF52_SERIES) || defined(NRF52)) if (addr.address == QMA6100P_ADDRESS_LOW || addr.address == QMA6100P_ADDRESS_HIGH) { nrf52QmaFound = probeQMA6100P(addr.address); @@ -446,8 +448,18 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) SCAN_SIMPLE_CASE(ST7567_ADDRESS, SCREEN_ST7567, "ST7567", (uint8_t)addr.address); #ifdef HAS_NCP5623 SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address); +#endif +#if defined(HAS_LP5562) && (LP5562_ADDR != MMC5983MA_ADDR) + SCAN_SIMPLE_CASE(LP5562_ADDR, LP5562, "LP5562", (uint8_t)addr.address); +#endif +#ifdef HAS_LP5814 + SCAN_SIMPLE_CASE(LP5814_ADDR, LP5814, "LP5814", (uint8_t)addr.address); +#endif +#ifdef HAS_ES7243E + SCAN_SIMPLE_CASE(ES7243E_ADDR, ES7243E, "ES7243E", (uint8_t)addr.address); #endif case XPOWERS_AXP192_AXP2101_ADDRESS: +#ifndef SEEED_WIO_TRACKER_L2 // false positive on Wio Tracker L2 // Do we have the axp2101/192 or the TCA8418 registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x90), 1); if (registerValue == 0x0) { @@ -457,6 +469,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("AXP192/AXP2101", (uint8_t)addr.address); type = PMU_AXP192_AXP2101; } +#endif break; case BME_ADDR: case BME_ADDR_ALTERNATE: @@ -525,8 +538,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) LOG_DEBUG("Register MFG_UID: 0x%x", mfg); // Only read DIE_UID for vendors we recognize as INA-compatible to avoid - // an extra I2C transaction + delay on other devices sharing this - // address. + // an extra I2C transaction + delay on other devices sharing this address. if (mfg == 0x5449 || mfg == 0x190F) { uint16_t die = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2); LOG_DEBUG("Register DIE_UID: 0x%x", die); @@ -608,10 +620,16 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; #endif case MCP9808_ADDR: - // We need to check for STK8BAXX first, since register 0x07 is new data - // flag for the z-axis and can produce some weird result. and register - // 0x00 doesn't seems to be colliding with MCP9808 and LIS3DH chips. + // We need to check for STK8BAXX first, since register 0x07 is new data flag for the z-axis and can produce some + // weird result. and register 0x00 doesn't seems to be colliding with MCP9808 and LIS3DH chips. { + // Check register 0xFD for 0x83 to ID ES8311 audio codec. + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFD), 1); + if (registerValue == 0x83) { + type = ES8311; + logFoundDevice("ES8311", (uint8_t)addr.address); + break; + } #ifdef HAS_STK8XXX // Check register 0x00 for 0x8700 response to ID STK8BA53 chip. registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 2); @@ -672,6 +690,22 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; case LPS22HB_ADDR_ALT: + // GT911 touchscreen: product ID register 0x8140 returns "911" + { + uint8_t gt911_reg[] = {0x81, 0x40}; + uint8_t gt911_buf[4] = {0}; + i2cBus->beginTransmission(addr.address); + i2cBus->write(gt911_reg, 2); + if (i2cBus->endTransmission() == 0) { + i2cBus->requestFrom((int)addr.address, 4); + i2cBus->readBytes(gt911_buf, 4); + if (gt911_buf[0] == '9' && gt911_buf[1] == '1' && gt911_buf[2] == '1') { + type = GT911; + logFoundDevice("GT911", (uint8_t)addr.address); + break; + } + } + } // SFA30 detection: send 2-byte command 0xD060 (Get Device Marking) and check for 48-byte response if (i2cCommandResponseLength(addr, 0xD060, 48)) { type = SFA30; @@ -803,24 +837,42 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("BMA423", (uint8_t)addr.address); break; - case TCA9535_ADDR: // this can also be MCP23017_ADDR (both 0x20) + case RAK120353_ADDR: { // AW35615 USB-C CC controller - must be checked before + // RAK120353_ADDR which shares 0x22 but is a TCA9535 variant + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 1); + if ((registerValue & 0xF0) == 0x90) { // DEVICE_ID upper nibble = 0x9 for AW35615 + type = AW35615; + logFoundDevice("AW35615", (uint8_t)addr.address); + break; + } + // Fall through to TCA9535/RAK check + } + case TCA9535_ADDR: case RAK120352_ADDR: - case RAK120353_ADDR: registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x02), 1); if (registerValue == addr.address) { // RAK12035 returns its I2C address at 0x02 (eg 0x20) type = RAK12035; logFoundDevice("RAK12035", (uint8_t)addr.address); - } else { - // TCA9535 only has registers 0x00-0x07; MCP23017 has IOCON at 0x0A + break; + } +#if defined(HAS_MCP23017_KEYBOARD) && !defined(USE_MCP23017) + // The MCP23017 shares 0x20 with the TCA9535 and there is no read-only probe that + // separates them reliably, so only boards that declare an MCP23017 keypad look for + // one here. USE_MCP23017 means the part is wired as the radio's GPIO expander and + // must never be handed to the keyboard driver, which reprograms IODIR/GPPU. + if (addr.address == MCP23017_KB_ADDR) { + // IOCON is a single register mirrored at 0x0A and 0x0B; the TCA9535 has no + // register there at all. Matching reads are a cheap confirmation. registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0A), 1); - if (registerValue != 0xFF) { - type = MCP23017; + if (registerValue == getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0B), 1)) { + type = MCP23017KB; logFoundDevice("MCP23017", (uint8_t)addr.address); - } else { - type = TCA9535; - logFoundDevice("TCA9535", (uint8_t)addr.address); + break; } } +#endif + type = TCA9535; + logFoundDevice("TCA9535", (uint8_t)addr.address); break; @@ -915,9 +967,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) SCAN_SIMPLE_CASE(CHSC6X_ADDR, CHSC6X, "CHSC6X", (uint8_t)addr.address); case LTR553ALS_ADDR: - registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x86), - 1); // Part ID register - if (registerValue == 0x92) { // LTR553ALS Part ID + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x86), 1); // Part ID register + if (registerValue == 0x92) { // LTR553ALS Part ID type = LTR553ALS; logFoundDevice("LTR553ALS", (uint8_t)addr.address); } else { @@ -971,8 +1022,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = MLX90614; logFoundDevice("MLX90614", (uint8_t)addr.address); } else { - registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), - 1); // DRV2605_REG_STATUS + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); // DRV2605_REG_STATUS if (registerValue == 0xe0) { type = DRV2605; logFoundDevice("DRV2605", (uint8_t)addr.address); @@ -983,10 +1033,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) } break; - case ICM20948_ADDR: // same as BMX160_ADDR, BMI270_ADDR_ALT, - // ICM42607P_ADDR_ALT, and SEN5X_ADDR - case ICM20948_ADDR_ALT: // same as MPU6050_ADDR, BMI270_ADDR, and - // ICM42607P_ADDR + case ICM20948_ADDR: // same as BMX160_ADDR, BMI270_ADDR_ALT, ICM42607P_ADDR_ALT, and SEN5X_ADDR + case ICM20948_ADDR_ALT: // same as MPU6050_ADDR, BMI270_ADDR, and ICM42607P_ADDR registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); #ifdef HAS_ICM20948 type = ICM20948; @@ -1027,9 +1075,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; case CGRADSENS_ADDR: - // Register 0x00 of the RadSens sensor contains is product identifier - // 0x7D Undocumented, but some devices return a product identifier of - // 0x7A + // Register 0x00 of the RadSens sensor contains is product identifier 0x7D + // Undocumented, but some devices return a product identifier of 0x7A registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); if (registerValue == 0x7D || registerValue == 0x7A) { type = CGRADSENS; @@ -1041,6 +1088,14 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; case 0x48: { + // Check ADS1X15 FIRST - the SE050 probe writes 5 bytes which corrupts the ADS1X15 Lo_thresh register. + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2); + if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700 || registerValue == 0xc580) { + type = ADS1X15; + logFoundDevice("ADS1X15 ADC", (uint8_t)addr.address); + break; + } + // T=1oI2C soft reset; an SE050 answers A5 E0 00 3F 19. requestFrom() is // required: readBytes() only drains the RX buffer requestFrom() fills. const uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC}; @@ -1059,24 +1114,15 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) info[i] = i2cBus->read(); isSE050 = (memcmp(expectedInfo, info, sizeof(info)) == 0); } - } - if (isSE050) { - LOG_INFO("NXP SE050 crypto chip found"); - type = NXP_SE050; - break; + if (isSE050) { + LOG_INFO("NXP SE050 crypto chip found"); + type = NXP_SE050; + } else { + LOG_INFO("FT6336U touchscreen found"); + type = FT6336U; + } } - - // ADS1X15 default config register is 8583h - registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2); - if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700) { - type = ADS1X15; - logFoundDevice("ADS1X15 ADC", (uint8_t)addr.address); - break; - } - - LOG_INFO("FT6336U touchscreen found"); - type = FT6336U; break; } @@ -1105,55 +1151,55 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) deviceAddresses[type] = addr; foundDevices[addr] = type; } + } #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR - // AS3935 addresses (0x01-0x03) fall in the reserved range the loop above skips; probe - // them separately rather than widening that loop for every board. - static const uint8_t as3935Candidates[] = {AS3935_ADDR_ALT, AS3935_ADDR_ALT2, AS3935_ADDR}; - for (uint8_t i = 0; i < sizeof(as3935Candidates); i++) { - // Respect the caller's address filter, same as the main loop above (line ~269). - if (asize != 0 && !in_array(address, asize, as3935Candidates[i])) - continue; + // AS3935 addresses (0x01-0x03) fall in the reserved range the loop above skips; probe + // them separately rather than widening that loop for every board. + static const uint8_t as3935Candidates[] = {AS3935_ADDR_ALT, AS3935_ADDR_ALT2, AS3935_ADDR}; + for (uint8_t i = 0; i < sizeof(as3935Candidates); i++) { + // Respect the caller's address filter, same as the main loop above (line ~269). + if (asize != 0 && !in_array(address, asize, as3935Candidates[i])) + continue; - DeviceAddress as3935Addr(port, as3935Candidates[i]); + DeviceAddress as3935Addr(port, as3935Candidates[i]); + i2cBus->beginTransmission(as3935Candidates[i]); + uint8_t as3935Err = i2cBus->endTransmission(); + if (as3935Err == 0) { + // No WHOAMI, and a POR-only check can't survive a warm reboot (initDevice rewrites + // REG0x00). Write a test pattern to bits[5:1] instead and confirm it reads back. + constexpr uint8_t AS3935_PROBE_PATTERN = 0b01010; // arbitrary, bits[5:1] i2cBus->beginTransmission(as3935Candidates[i]); - uint8_t as3935Err = i2cBus->endTransmission(); - if (as3935Err == 0) { - // No WHOAMI, and a POR-only check can't survive a warm reboot (initDevice rewrites - // REG0x00). Write a test pattern to bits[5:1] instead and confirm it reads back. - constexpr uint8_t AS3935_PROBE_PATTERN = 0b01010; // arbitrary, bits[5:1] - i2cBus->beginTransmission(as3935Candidates[i]); - i2cBus->write((uint8_t)0x00); // REG0x00 (AFE_GAIN) - i2cBus->write((uint8_t)(AS3935_PROBE_PATTERN << 1)); // PWD=0, gain bits = pattern - if (i2cBus->endTransmission() == 0) { - uint16_t reg0 = getRegisterValue(ScanI2CTwoWire::RegisterLocation(as3935Addr, 0x00), 1); - if (((reg0 >> 1) & 0x1F) == AS3935_PROBE_PATTERN) { - logFoundDevice("AS3935", as3935Candidates[i]); - deviceAddresses[AS3935] = as3935Addr; - foundDevices[as3935Addr] = AS3935; - break; // only one AS3935 expected per bus - } else { - LOG_DEBUG("Unexpected REG0x00 readback for AS3935: addr=0x%x val=0x%x", as3935Candidates[i], reg0); - } + i2cBus->write((uint8_t)0x00); // REG0x00 (AFE_GAIN) + i2cBus->write((uint8_t)(AS3935_PROBE_PATTERN << 1)); // PWD=0, gain bits = pattern + if (i2cBus->endTransmission() == 0) { + uint16_t reg0 = getRegisterValue(ScanI2CTwoWire::RegisterLocation(as3935Addr, 0x00), 1); + if (((reg0 >> 1) & 0x1F) == AS3935_PROBE_PATTERN) { + logFoundDevice("AS3935", as3935Candidates[i]); + deviceAddresses[AS3935] = as3935Addr; + foundDevices[as3935Addr] = AS3935; + break; // only one AS3935 expected per bus + } else { + LOG_DEBUG("Unexpected REG0x00 readback for AS3935: addr=0x%x val=0x%x", as3935Candidates[i], reg0); } } } + } #endif - // The QMC6309 magnetometer sits at 0x7C, above the general scan ceiling (the loop above stops at 0x77 to - // avoid the reserved 0x78-0x7F block). Probe it explicitly. Gated on the SensorLib driver being present so - // only boards that can actually drive the chip poke this reserved address. + // The QMC6309 magnetometer sits at 0x7C, above the general scan ceiling (the loop above stops at 0x77 to + // avoid the reserved 0x78-0x7F block). Probe it explicitly. Gated on the SensorLib driver being present so + // only boards that can actually drive the chip poke this reserved address. #if __has_include() - addr.address = QMC6309_ADDR; - i2cBus->beginTransmission(addr.address); - if (i2cBus->endTransmission() == 0 && - getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1) == 0x90 /* QMC6309 chip id */) { - deviceAddresses[QMC6309] = addr; - foundDevices[addr] = QMC6309; - logFoundDevice("QMC6309", (uint8_t)addr.address); - } -#endif + addr.address = QMC6309_ADDR; + i2cBus->beginTransmission(addr.address); + if (i2cBus->endTransmission() == 0 && + getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1) == 0x90 /* QMC6309 chip id */) { + deviceAddresses[QMC6309] = addr; + foundDevices[addr] = QMC6309; + logFoundDevice("QMC6309", (uint8_t)addr.address); } +#endif } void ScanI2CTwoWire::scanPort(I2CPort port) diff --git a/src/freertosinc.h b/src/freertosinc.h index e9e6cd53a0..db49fbab11 100644 --- a/src/freertosinc.h +++ b/src/freertosinc.h @@ -12,7 +12,7 @@ #include #endif -#if defined(ARDUINO_NRF52_ADAFRUIT) || defined(ARDUINO_ARCH_RP2040) +#if defined(ARDUINO_NRF52_ADAFRUIT) || defined(ARDUINO_ARCH_NRF54) || defined(ARDUINO_ARCH_RP2040) #define HAS_FREE_RTOS #include diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 9f29c41f0c..c10e3a3e67 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1363,7 +1363,7 @@ void GPS::up() // We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially. void GPS::down() { - if (hasValidLocation) + if (scheduling.hasValidFixSinceSearchStarted()) scheduling.informGotLock(); else scheduling.informSearchFailed(); @@ -1545,6 +1545,7 @@ int32_t GPS::runOnce() // 2. Got a lock for the first time, or 3. Got a lock after turning back on bool gotLoc = lookForLocation(); if (gotLoc) { + scheduling.informValidFix(); #if GPS_DEBUG if (!hasValidLocation) { // declare that we have location ASAP LOG_DEBUG("hasValidLocation RISING EDGE"); @@ -1560,14 +1561,13 @@ int32_t GPS::runOnce() if (holdTime > GPS_FIX_HOLD_MAX_MS) holdTime = GPS_FIX_HOLD_MAX_MS; // Same clock the Throttle evaluation reads, and never the "no hold" sentinel. - const uint32_t holdEnds = Time::getMillis() + holdTime; - fixHoldEnds = holdEnds == 0 ? 1 : holdEnds; + fixHoldEnds = Time::timerEndsAtMillis(holdTime); LOG_DEBUG_GPS("Holding for %ums after lock", holdTime); } } bool tooLong = scheduling.searchedTooLong(); - if (tooLong && !gotLoc) { + if (tooLong && !scheduling.hasValidFixSinceSearchStarted()) { LOG_WARN("Can't publish valid location: no GPS lock in time"); // we didn't get a location during this ack window, therefore declare loss of lock if (hasValidLocation) { diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index 7f37e100c9..9f6cc6695f 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -32,9 +32,16 @@ uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs) void GPSUpdateScheduling::informSearching() { searching = true; + validFixReceived = false; searchStartedMs = Time::getMillis(); } +void GPSUpdateScheduling::informValidFix() +{ + if (searching) + validFixReceived = true; +} + // Mark the time when searching for GPS is complete, // then update the predicted lock-time void GPSUpdateScheduling::informGotLock() @@ -64,6 +71,7 @@ void GPSUpdateScheduling::informSearchFailed() void GPSUpdateScheduling::reset() { searching = false; + validFixReceived = false; searchStartedMs = 0; searchEndedMs = 0; searchCount = 0; @@ -152,6 +160,11 @@ bool GPSUpdateScheduling::searchedTooLong() return false; } +bool GPSUpdateScheduling::hasValidFixSinceSearchStarted() const +{ + return searching && validFixReceived; +} + // Updates the predicted time-to-get-lock, by exponentially smoothing the latest observation void GPSUpdateScheduling::updateLockTimePrediction() { diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h index d7e11ad1ab..b8de3ff654 100644 --- a/src/gps/GPSUpdateScheduling.h +++ b/src/gps/GPSUpdateScheduling.h @@ -12,12 +12,14 @@ class GPSUpdateScheduling public: // Marks the time of these events, for calculation use void informSearching(); + void informValidFix(); void informGotLock(); // Predicted lock-time is recalculated here void informSearchFailed(); // Search ended without a fix; prediction is left untouched void reset(); // Reset the prediction - after GPS::disable() / GPS::enable() bool isUpdateDue(); // Is it time to begin searching for a GPS position? bool searchedTooLong(); // Have we been searching for too long? + bool hasValidFixSinceSearchStarted() const; uint32_t msUntilNextSearch(); // How long until we need to begin searching for a GPS? Info provided to GPS hardware for sleep uint32_t elapsedSearchMs(); // How long have we been searching so far? @@ -26,6 +28,7 @@ class GPSUpdateScheduling private: void updateLockTimePrediction(); // Called from informGotLock bool searching = false; // Set by the inform*() calls; never inferred from stamp ordering + bool validFixReceived = false; uint32_t searchStartedMs = 0; uint32_t searchEndedMs = 0; uint32_t searchCount = 0; @@ -33,4 +36,4 @@ class GPSUpdateScheduling uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time". -}; \ No newline at end of file +}; diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 93e59e31e6..ca5ebe146a 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -8,6 +8,7 @@ #include "mesh/MeshService.h" #include "mesh/NodeDB.h" #include "modules/NodeInfoModule.h" +#include "modules/WaypointModule.h" #include #include #include @@ -35,6 +36,10 @@ static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQual if (nodeDB) nodeDB->backfillHeardAt(); } +#if !MESHTASTIC_EXCLUDE_WAYPOINT && HAS_SCREEN + if (waypointModule && oldQuality != newQuality) + waypointModule->onDeviceTimeChanged(); +#endif } RTCQuality getRTCQuality() @@ -151,24 +156,25 @@ RTCSetResult readFromRTC() LOG_WARN("RTC read: not found (addr 0x%02X)", rtc_found.address); } #elif defined(PCF8563_RTC) || defined(PCF85063_RTC) -#if defined(PCF8563_RTC) - if (rtc_found.address == PCF8563_RTC) { - SensorPCF8563 rtc; -#elif defined(PCF85063_RTC) - if (rtc_found.address == PCF85063_RTC) { - SensorPCF85063 rtc; - -#endif + if (rtc_found.address == PCF_RTC_ADDRESS) { + PCF8xRTC rtc; const uint64_t now = Time::getMillisMonotonic(); #if WIRE_INTERFACES_COUNT == 2 - rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found)); + TwoWire &rtcBus = *ScanI2CTwoWire::fetchI2CBus(rtc_found); #else - rtc.begin(Wire); + TwoWire &rtcBus = Wire; #endif - - RTC_DateTime datetime = rtc.getDateTime(); - tm t = datetime.toUnixTime(); + tm t; + if (!rtc.begin(rtcBus, PCF_RTC_ADDRESS, PCF_RTC_CHIP)) { + LOG_WARN("%s not responding at 0x%02X", rtc.chipName(), PCF_RTC_ADDRESS); + return RTCSetResultInvalidTime; + } + if (!rtc.getTime(t)) { + // Only the chip itself can tell us the oscillator stopped, so ask after begin() worked. + LOG_WARN("%s read failed%s", rtc.chipName(), rtc.lostPower() ? " (oscillator stopped)" : ""); + return RTCSetResultInvalidTime; + } tv.tv_sec = gm_mktime(&t); tv.tv_usec = 0; uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms @@ -183,7 +189,7 @@ RTCSetResult readFromRTC() } #endif - LOG_DEBUG_GPS("RTC time from %s getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t.tm_year + 1900, + LOG_DEBUG_GPS("RTC time from %s getTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.chipName(), t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; @@ -326,7 +332,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd currentQuality = q; lastSetMsec = now; if (currentQuality >= RTCQualityNTP) { - lastSetFromPhoneNtpOrGps = now; + lastSetFromPhoneNtpOrGps = Time::skipZero(now); } // This delta value works on all platforms @@ -352,27 +358,23 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address); } #elif defined(PCF8563_RTC) || defined(PCF85063_RTC) -#if defined(PCF8563_RTC) - if (rtc_found.address == PCF8563_RTC) { - SensorPCF8563 rtc; -#elif defined(PCF85063_RTC) - if (rtc_found.address == PCF85063_RTC) { - SensorPCF85063 rtc; - -#endif - + if (rtc_found.address == PCF_RTC_ADDRESS) { + PCF8xRTC rtc; #if WIRE_INTERFACES_COUNT == 2 - rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found)); + TwoWire &rtcBus = *ScanI2CTwoWire::fetchI2CBus(rtc_found); #else - rtc.begin(Wire); + TwoWire &rtcBus = Wire; #endif // tv_sec is a long, which is not time_t everywhere: on Windows // time_t is 64-bit while long is 32-bit. Copy before taking &. time_t setSecs = tv->tv_sec; const tm *t = gmtime(&setSecs); - rtc.setDateTime(*t); - LOG_DEBUG_GPS("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, - t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + if (rtc.begin(rtcBus, PCF_RTC_ADDRESS, PCF_RTC_CHIP) && rtc.setTime(*t)) { + LOG_DEBUG_GPS("%s setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.chipName(), t->tm_year + 1900, t->tm_mon + 1, + t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + } else { + LOG_WARN("%s set time failed", rtc.chipName()); + } } else { LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address); } diff --git a/src/graphics/BaseUIEInkDisplay.cpp b/src/graphics/BaseUIEInkDisplay.cpp index 0506ba68dd..15d00f1ee7 100644 --- a/src/graphics/BaseUIEInkDisplay.cpp +++ b/src/graphics/BaseUIEInkDisplay.cpp @@ -1,6 +1,7 @@ #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS #include "./BaseUIEInkDisplay.h" +#include "UptimeClock.h" #include "configuration.h" #include "main.h" @@ -73,7 +74,7 @@ void BaseUIEInkDisplay::display() // Keyframe path. Returns true if a frame was pushed (sets lastDrawMsec). bool BaseUIEInkDisplay::forceDisplay(uint32_t msecLimit) { - const uint32_t now = millis(); + const uint32_t now = Time::stampMillis(); if (lastDrawMsec != 0 && (now - lastDrawMsec) < msecLimit) return false; diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index de058bc1f9..aec2c2caf2 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #include "graphics/Backlight.h" @@ -59,7 +60,7 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit) // No need to grab this lock because we are on our own SPI bus // concurrency::LockGuard g(spiLock); - uint32_t now = millis(); + uint32_t now = Time::stampMillis(); uint32_t sinceLast = now - lastDrawMsec; if (adafruitDisplay && (sinceLast > msecLimit || lastDrawMsec == 0)) diff --git a/src/graphics/EInkParallelDisplay.cpp b/src/graphics/EInkParallelDisplay.cpp index a61b1bae97..d8d0f9475c 100644 --- a/src/graphics/EInkParallelDisplay.cpp +++ b/src/graphics/EInkParallelDisplay.cpp @@ -1,4 +1,5 @@ #include "EInkParallelDisplay.h" +#include "UptimeClock.h" #ifdef USE_EINK_PARALLELDISPLAY @@ -208,7 +209,7 @@ void EInkParallelDisplay::display(void) const uint16_t h = this->displayHeight; // Simple rate limiting: avoid very-frequent responsive updates - uint32_t nowMs = millis(); + uint32_t nowMs = Time::stampMillis(); if (lastUpdateMs != 0 && (nowMs - lastUpdateMs) < EPD_RESPONSIVE_MIN_MS) { LOG_DEBUG("rate-limited, skipping update"); return; @@ -367,11 +368,11 @@ void EInkParallelDisplay::display(void) startAsyncFullUpdate(forceFull ? CLEAR_SLOW : CLEAR_FAST); } - lastUpdateMs = millis(); + lastUpdateMs = Time::stampMillis(); previousImageHash = imageHash; // Keep same behavior as before - lastDrawMsec = millis(); + lastDrawMsec = Time::stampMillis(); } #ifdef EINK_LIMIT_GHOSTING_PX @@ -420,7 +421,7 @@ bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit) if (!displayReady) return false; - uint32_t now = millis(); + uint32_t now = Time::stampMillis(); if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) { display(); return true; diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8cf3f8c926..8d9c35ae42 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -24,6 +24,7 @@ along with this program. If not, see . #include "NodeDB.h" #include "PowerMon.h" #include "Throttle.h" +#include "UptimeClock.h" #include "configuration.h" #include "meshUtils.h" #if HAS_SCREEN @@ -327,7 +328,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) NotificationRenderer::parseBannerMessageWithFonts(NotificationRenderer::alertBannerMessage); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination NotificationRenderer::alertBannerUntil = - (banner_overlay_options.durationMs == 0) ? 0 : millis() + banner_overlay_options.durationMs; + (banner_overlay_options.durationMs == 0) ? 0 : Time::timerEndsAtMillis(banner_overlay_options.durationMs); NotificationRenderer::optionsArrayPtr = banner_overlay_options.optionsArrayPtr; NotificationRenderer::optionsEnumPtr = banner_overlay_options.optionsEnumPtr; NotificationRenderer::alertBannerOptions = banner_overlay_options.optionsCount; @@ -351,7 +352,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct // Store the message and set the expiration timestamp strncpy(NotificationRenderer::alertBannerMessage, message, 255); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); NotificationRenderer::alertBannerCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; @@ -373,7 +374,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t // Store the message and set the expiration timestamp strncpy(NotificationRenderer::alertBannerMessage, message, 255); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); NotificationRenderer::alertBannerCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; @@ -402,7 +403,7 @@ void Screen::showAlphanumericPicker(const char *message, const char *initialText strncpy(NotificationRenderer::alertBannerMessage, message, 255); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); NotificationRenderer::textInputCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; @@ -439,7 +440,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t // Store the message and set the expiration timestamp (use same pattern as other notifications) strncpy(NotificationRenderer::alertBannerMessage, header ? header : "Text Input", 255); NotificationRenderer::alertBannerMessage[255] = '\0'; - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); NotificationRenderer::pauseBanner = false; NotificationRenderer::current_notification_type = notificationTypeEnum::text_input; @@ -492,7 +493,7 @@ float Screen::estimatedHeading(double lat, double lon) static double oldLat, oldLon; static float b = -1.0f; static uint32_t lastHeadingAtMs = 0; - const uint32_t now = millis(); + const uint32_t now = Time::stampMillis(); const uint32_t gpsUpdateIntervalSecs = Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval); uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs; @@ -1083,6 +1084,7 @@ int32_t Screen::runOnce() { // If we don't have a screen, don't ever spend any CPU for us. if (!useDisplay) { + textMessageFrameShown = false; enabled = false; return RUN_SAME; } @@ -1202,7 +1204,11 @@ int32_t Screen::runOnce() handleStartFirmwareUpdateScreen(); break; case Cmd::STOP_ALERT_FRAME: + // Cleared even while a module holds the screen: START_ALERT_FRAME set it and nothing + // else would, so swallowing it here would leave banners suppressed for good. NotificationRenderer::pauseBanner = false; + if (hasModalModule()) + break; // only the owning module may take the screen back off its own frame // Return from one-off alert mode back to regular frames. if (!showingNormalScreen && NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { setFrames(); @@ -1223,6 +1229,7 @@ int32_t Screen::runOnce() if (!screenOn) { // If we didn't just wake and the screen is still off, then // stop updating until it is on again + textMessageFrameShown = false; enabled = false; return 0; } @@ -1260,7 +1267,7 @@ int32_t Screen::runOnce() // standard screen switching is stopped. if (showingNormalScreen) { // standard screen loop handling here - if (config.display.auto_screen_carousel_secs > 0 && + if (config.display.auto_screen_carousel_secs > 0 && !hasModalModule() && NotificationRenderer::current_notification_type != notificationTypeEnum::text_input && !Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) { @@ -1276,6 +1283,9 @@ int32_t Screen::runOnce() } } + textMessageFrameShown = showingNormalScreen && framesetInfo.positions.textMessage != 255 && ui && + ui->getUiState()->currentFrame == framesetInfo.positions.textMessage; + // LOG_DEBUG("want fps %d, fixed=%d", targetFramerate, // ui->getUiState()->frameState); If we are scrolling we need to be called // soon, otherwise just 1 fps (to save CPU) We also ask to be called twice @@ -1315,6 +1325,10 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) if (einkScreensaver != NULL) { screensaverFrame = einkScreensaver; ui->setFrames(&screensaverFrame, 1); + + // Hide the nav bar before the sleep / shutdown screen is rendered + static OverlayCallback screensaverOverlays[] = {NotificationRenderer::drawBannercallback}; + ui->setOverlays(screensaverOverlays, 1); } // Else, display the usual "overlay" screensaver @@ -1372,6 +1386,7 @@ void Screen::setFrames(FrameFocus focus) return; } + const FramesetInfo previousFramesetInfo = framesetInfo; uint8_t originalPosition = ui->getUiState()->currentFrame; uint8_t previousFrameCount = framesetInfo.frameCount; FramesetInfo fsi; // Location of specific frames, for applying focus parameter @@ -1624,8 +1639,15 @@ void Screen::setFrames(FrameFocus focus) break; case FOCUS_PRESERVE: - // No more adjustment - force stay on same index - if (previousFrameCount > fsi.frameCount) { + if (previousFramesetInfo.positions.waypoint == 255 && fsi.positions.waypoint != 255) { + const uint8_t target = originalPosition >= fsi.positions.waypoint ? originalPosition + 1 : originalPosition; + ui->switchToFrame(target); + } else if (previousFramesetInfo.positions.waypoint != 255 && fsi.positions.waypoint == 255) { + const uint8_t target = originalPosition > previousFramesetInfo.positions.waypoint + ? originalPosition - 1 + : std::min(originalPosition, fsi.frameCount - 1); + ui->switchToFrame(target); + } else if (previousFrameCount > fsi.frameCount) { ui->switchToFrame(originalPosition - 1); } else if (previousFrameCount < fsi.frameCount) { ui->switchToFrame(originalPosition + 1); @@ -1848,6 +1870,19 @@ void Screen::applyHiddenFramesMask(uint32_t mask) hiddenFrames.chirpy = getBit(mask, FVBIT_CHIRPY); } +bool Screen::isShowingModuleFrame(const MeshModule *m) const +{ + if (!m || !showingNormalScreen) + return false; + // Same effective frame drawModuleFrame() picks: mid-transition the incoming frame is the one + // being rendered, so comparing currentFrame would report false while the module is on screen. + const OLEDDisplayUiState *state = ui->getUiState(); + uint8_t frame = state->currentFrame; + if (state->frameState == IN_TRANSITION && state->transitionFrameRelationship == TransitionRelationship_INCOMING) + frame = state->transitionFrameTarget; + return frame < moduleFrames.size() && moduleFrames.at(frame) == m; +} + void Screen::loadFrameVisibility() { #ifdef FSCom @@ -2109,6 +2144,21 @@ int Screen::handleUIFrameEvent(const UIFrameEvent *event) return 0; } +// Only the environmental telemetry frame answers SELECT with a menu. A module frame that has none +// must not claim the press, or every frame matched after it in the dispatch chain is unreachable. +static bool moduleFrameHasMenu(size_t frame) +{ +#if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + // moduleFrames bounds the module-frame region, before favorites are appended; its leading slots + // are nullptr padding for the built-in frames, so only a non-null entry is a real module frame. + const MeshModule *module = frame < moduleFrames.size() ? moduleFrames.at(frame) : nullptr; + return module != nullptr && environmentTelemetryModule != nullptr && environmentTelemetryModule->ownsFrame(module); +#else + (void)frame; + return false; +#endif +} + int Screen::handleInputEvent(const InputEvent *event) { LOG_INPUT("Screen Input event %u! kb %u", event->inputEvent, event->kbchar); @@ -2262,7 +2312,8 @@ int Screen::handleInputEvent(const InputEvent *event) #endif if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS) { showFrame(FrameDirection::PREVIOUS); - } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) { + } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS || + (event->inputEvent == INPUT_BROKER_ANYKEY && event->kbchar == ' ')) { showFrame(FrameDirection::NEXT); } else if (event->inputEvent == INPUT_BROKER_FN_F1) { this->ui->switchToFrame(0); @@ -2336,16 +2387,8 @@ int Screen::handleInputEvent(const InputEvent *event) menuHandler::textMessageBaseMenu(); } } - // moduleFrames.size() bounds the module-frame region, before favorites are appended; its leading - // slots are nullptr padding for the built-in frames, so only a non-null entry is a real module frame. - } else if (this->ui->getUiState()->currentFrame < moduleFrames.size() && - moduleFrames.at(this->ui->getUiState()->currentFrame) != nullptr) { -#if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR - const MeshModule *currentModule = moduleFrames.at(this->ui->getUiState()->currentFrame); - if (environmentTelemetryModule != nullptr && environmentTelemetryModule->ownsFrame(currentModule)) { - menuHandler::environmentTelemetryMenu(); - } -#endif + } else if (moduleFrameHasMenu(this->ui->getUiState()->currentFrame)) { + menuHandler::environmentTelemetryMenu(); } else if (framesetInfo.positions.firstFavorite != 255 && this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { @@ -2360,6 +2403,9 @@ int Screen::handleInputEvent(const InputEvent *event) menuHandler::nodeListMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.wifi) { menuHandler::wifiBaseMenu(); + } else if (framesetInfo.positions.waypoint != 255 && + this->ui->getUiState()->currentFrame == framesetInfo.positions.waypoint) { + menuHandler::waypointBaseMenu(); } } else if (event->inputEvent == INPUT_BROKER_BACK) { showFrame(FrameDirection::PREVIOUS); @@ -2393,6 +2439,11 @@ bool Screen::isOverlayBannerShowing() return NotificationRenderer::isOverlayBannerShowing(); } +bool Screen::isTextMessageFrameShown() const +{ + return textMessageFrameShown.load(); +} + bool Screen::isGamesFrameShown() { return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games; diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index e7a77942f5..7a99c4820b 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -5,6 +5,7 @@ #include "detect/ScanI2C.h" #include "mesh/generated/meshtastic/config.pb.h" #include +#include #include #include #include @@ -46,6 +47,8 @@ struct BannerOverlayOptions { bool shouldWakeOnReceivedMessage(); +class MeshModule; + #if !HAS_SCREEN #include "Power.h" namespace graphics @@ -64,6 +67,8 @@ class Screen }; explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY); + // These are empty stubs, but they mirror the real Screen's instance API, so they can't become static. + // cppcheck-suppress-begin functionStatic void onPress() {} void setup() {} void setOn(bool) {} @@ -73,6 +78,10 @@ class Screen void increaseBrightness() {} void decreaseBrightness() {} void startAlert(const char *) {} + void setModalModule(const MeshModule *) {} + void clearModalModule(const MeshModule *) {} + bool hasModalModule() const { return false; } + bool isShowingModuleFrame(const MeshModule *) const { return false; } void showSimpleBanner(const char *message, uint32_t durationMs = 0) {} void showOverlayBanner(BannerOverlayOptions) {} void setFrames(FrameFocus focus) {} @@ -80,6 +89,7 @@ class Screen bool getIsI2cScreen() const { return false; } uint32_t getI2cFrequency() const { return 0; } ScanI2C::I2CPort getI2CPort() const { return ScanI2C::I2CPort::NO_I2C; } + // cppcheck-suppress-end functionStatic }; } // namespace graphics #else @@ -277,6 +287,9 @@ class Screen : public concurrency::OSThread bool isOverlayBannerShowing(); + // Thread-safe snapshot of whether the text-message frame is currently shown. + bool isTextMessageFrameShown() const; + // True if the always-present games frame is the one currently on screen. Lets the games module // ignore D-pad input when the player has navigated to a different frame. bool isGamesFrameShown(); @@ -338,6 +351,20 @@ class Screen : public concurrency::OSThread enqueueCmd(cmd); } + // Holds the screen against the carousel, the new-message banner and a foreign endAlert(). + // Only the owner can release it, unlike endAlert(), which any caller can fire. + void setModalModule(const MeshModule *owner) { modalModule = owner; } + void clearModalModule(const MeshModule *owner) + { + if (modalModule == owner) + modalModule = nullptr; + } + bool hasModalModule() const { return modalModule != nullptr; } + + // True while this module's own frame is on screen. Modules observe input before Screen does, + // so one handling keys needs this or it takes them from the frame the user is looking at. + bool isShowingModuleFrame(const MeshModule *m) const; + void showSimpleBanner(const char *message, uint32_t durationMs = 0); void showOverlayBanner(BannerOverlayOptions); @@ -680,6 +707,9 @@ class Screen : public concurrency::OSThread uint16_t displayHeight = 0; private: + // nullptr for every build with no modal module, which is why the three sites are unchanged. + const MeshModule *modalModule = nullptr; + FrameCallback alertFrames[1]; struct ScreenCmd { Cmd cmd; @@ -801,6 +831,7 @@ class Screen : public concurrency::OSThread // Whether we are showing the regular screen (as opposed to booth screen or // Bluetooth PIN screen) bool showingNormalScreen = false; + std::atomic textMessageFrameShown{false}; /// Track USB power state to only wake screen on actual power state changes bool lastPowerUSBState = false; diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 5e6567206c..762b618239 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -557,13 +557,20 @@ const int *getTextPositions(OLEDDisplay *display) textPositions[5] = textFifthLine_medium; textPositions[6] = textSixthLine_medium; } else { + int bodyShift = 0; + if (isTFTColoringEnabled()) { + const int headerBottom = FONT_HEIGHT_SMALL + 1 + BASEUI_HEADER_MARGIN; + const int overlap = headerBottom - textFirstLine; + if (overlap > 0 && (textSixthLine + FONT_HEIGHT_SMALL + overlap) <= SCREEN_HEIGHT) + bodyShift = overlap; + } textPositions[0] = textZeroLine; - textPositions[1] = textFirstLine; - textPositions[2] = textSecondLine; - textPositions[3] = textThirdLine; - textPositions[4] = textFourthLine; - textPositions[5] = textFifthLine; - textPositions[6] = textSixthLine; + textPositions[1] = textFirstLine + bodyShift; + textPositions[2] = textSecondLine + bodyShift; + textPositions[3] = textThirdLine + bodyShift; + textPositions[4] = textFourthLine + bodyShift; + textPositions[5] = textFifthLine + bodyShift; + textPositions[6] = textSixthLine + bodyShift; } return textPositions; } diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 445d5c8960..07bb7e6350 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -830,6 +830,183 @@ class LGFX : public lgfx::LGFX_Device static LGFX *tft = nullptr; +#elif defined(SEEED_WIO_TRACKER_L2) // Inline LGFX: NV3031B panel + SPI3 + GT911 touch + LP5814 backlight + +#include + +// LP5814 4-channel LED driver (backlight), I2C 0x2C +class Wio_Tracker_Light : public lgfx::v1::ILight +{ + static constexpr uint8_t REG_DEVICE_CONFIG0 = 0x00; + static constexpr uint8_t REG_MAX_CURRENT = 0x01; + static constexpr uint8_t REG_ENABLE_CONTROL = 0x02; + static constexpr uint8_t REG_DIM_MODE = 0x04; + static constexpr uint8_t REG_ENGINE_MODE = 0x05; + static constexpr uint8_t REG_UPDATE = 0x0F; + static constexpr uint8_t REG_LED0_DC = 0x14; + static constexpr uint8_t REG_LED0_PWM = 0x18; + + public: + struct config_t { + uint8_t brightness = 153; + }; // 60% + + const config_t &config(void) const { return _cfg; } + void config(const config_t &cfg) { _cfg = cfg; } + + bool init(uint8_t brightness) override + { + Wire.beginTransmission(0x2c); + if (Wire.endTransmission() != 0) { + LOG_ERROR("LP5814 not found at 0x2c"); + return false; + } + bool result = true; + result &= writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable + result &= writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current + result &= writeReg(REG_ENABLE_CONTROL, 0x00); // disable outputs while configuring + result &= writeReg(REG_DIM_MODE, 0x4E); // dim mode config + result &= writeReg(REG_ENGINE_MODE, 0xF0); // engine mode config + // Set DC current for all 4 channels (registers 0x14..0x17) + for (uint8_t i = 0; i < 4; i++) { + result &= writeReg(REG_LED0_DC + i, 200); + } + result &= writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels + result &= writeReg(REG_UPDATE, 0x55); // latch parameters (LP5814 requires 0x55) + delay(5); // LP5814 engine startup settling time + + setBrightness(brightness); + return result; + } + + void setBrightness(uint8_t brightness) override + { + // Write PWM to all 4 channels (registers 0x18..0x1B). + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_PWM + i, brightness); + } + _cfg.brightness = brightness; + } + + uint8_t getBrightness(void) const { return _cfg.brightness; } + virtual ~Wio_Tracker_Light(void) = default; + + private: + bool writeReg(uint8_t reg, uint8_t value) + { + Wire.beginTransmission(0x2c); + Wire.write(reg); + Wire.write(value); + uint8_t error = Wire.endTransmission(); + if (error != 0) { + LOG_ERROR("LP5814 write reg 0x%02x failed: %d", reg, error); + return false; + } + return true; + } + config_t _cfg; +}; + +class LGFX : public lgfx::LGFX_Device +{ + lgfx::Panel_NV3031B _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Touch_GT911 _touch_instance; + Wio_Tracker_Light _light_instance; + + public: + const uint32_t screenWidth = 320; + const uint32_t screenHeight = 240; + + bool hasButton(void) { return true; } + + bool init_impl(bool use_reset, bool use_clear) override + { + _light_instance.init(_light_instance.config().brightness); + bool result = LGFX_Device::init_impl(use_reset, use_clear); + // GT911 probe leaves I2C BUSY flag stuck; reset peripheral for LP5814 setBrightness + Wire.end(); + Wire.begin(47, 48); + Wire.setClock(100000); + return result; + } + + lgfx::ILight *light(void) const { return (lgfx::ILight *)&_light_instance; } + + LGFX(void) + { + // Bus: SPI3 quad pins (mode 3, 75 MHz write / 16 MHz read) + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI3_HOST; + cfg.spi_mode = 3; + cfg.freq_write = 75000000; + cfg.freq_read = 16000000; + cfg.pin_sclk = 42; + cfg.pin_io0 = 41; + cfg.pin_io1 = 40; + cfg.pin_io2 = 39; + cfg.pin_io3 = 38; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + // Panel: NV3031B (CS=46) + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = 46; + cfg.pin_rst = -1; + cfg.pin_busy = -1; + cfg.panel_width = screenHeight; // NV3031B native: 240 wide × 320 tall + cfg.panel_height = screenWidth; + cfg.memory_width = screenHeight; + cfg.memory_height = screenWidth; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 1; // Landscape with setRotation(0); matches MUI + cfg.invert = true; // NV3031B requires invert, otherwise screen shows green grid + cfg.rgb_order = true; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + // Touch: GT911 (I2C 0x5D on SDA=47 SCL=48) + { + auto cfg = _touch_instance.config(); + cfg.pin_cs = -1; + cfg.x_min = 0; + cfg.x_max = screenHeight - 1; + cfg.y_min = 0; + cfg.y_max = screenWidth - 1; + cfg.pin_int = -1; + cfg.offset_rotation = 2; + cfg.i2c_port = 0; + cfg.i2c_addr = 0x5D; + cfg.pin_sda = 47; + cfg.pin_scl = 48; + cfg.bus_shared = false; + cfg.freq = 100000; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + _panel_instance.setLight(&_light_instance); + setPanel(&_panel_instance); + } + + void sleep(void) + { + _panel->setSleep(true); + _light_instance.setBrightness(0); + } + + void wakeup(void) + { + _panel->setSleep(false); + _light_instance.setBrightness(_light_instance.config().brightness); + } +}; + +static LGFX *tft = nullptr; + #elif defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) #include // Graphics and font library for ILI9341/ILI9342 driver chip @@ -1874,7 +2051,8 @@ bool TFTDisplay::connect() tft->setRotation(1); // T-Deck has the TFT in landscape #elif defined(T_WATCH_S3) tft->setRotation(2); // T-Watch S3 left-handed orientation -#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) || defined(T_WATCH_ULTRA) +#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) || defined(T_WATCH_ULTRA) || \ + defined(SEEED_WIO_TRACKER_L2) tft->setRotation(0); // use config.yaml to set rotation #else tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index b7eb83a20e..b631fa8416 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -9,6 +9,7 @@ #include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "buzz.h" #include "graphics/Backlight.h" #include "graphics/Screen.h" @@ -28,11 +29,16 @@ #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/ExternalNotificationModule.h" +#include "modules/GeofenceModule.h" #include "modules/KeyVerificationModule.h" #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #include "modules/Telemetry/EnvironmentTelemetry.h" #endif #include "modules/TraceRouteModule.h" +#include "modules/WaypointModule.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "WaypointStore.h" +#endif #include #include #include @@ -45,6 +51,10 @@ namespace graphics namespace { +#if !MESHTASTIC_EXCLUDE_WAYPOINT +uint32_t selectedGeofenceWaypointId = 0; +#endif + // Caller must ensure the provided options array outlives the banner callback. template BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOption (&options)[N], @@ -218,8 +228,33 @@ void menuHandler::OnboardMessage() screen->showOverlayBanner(bannerOptions); } +// Out-of-box US setup starts on LongTurbo rather than the region table's LongFast. Menu-only: the +// US entry in `regions[]` keeps LongFast, so no other route onto US changes. Anything that already +// states a preset - a pinned userpref, or a preset moved off the install default - outranks it. +meshtastic_Config_LoRaConfig_ModemPreset menuHandler::presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, + meshtastic_Config_LoRaConfig_RegionCode selected) +{ +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + (void)selected; // the pinned preset wins outright; nothing to decide +#else + if (lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && selected == meshtastic_Config_LoRaConfig_RegionCode_US && + lora.use_preset && lora.modem_preset == meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST) { + return meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + } +#endif + return lora.modem_preset; +} + static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam) { + // Decided first: it keys off the *outgoing* region being UNSET. + const meshtastic_Config_LoRaConfig_ModemPreset selectionPreset = menuHandler::presetForRegionSelection(config.lora, region); + if (selectionPreset != config.lora.modem_preset) { + LOG_INFO("First region is %s, default preset to %s", getRegion(region)->name, + DisplayFormatters::getModemPresetDisplayName(selectionPreset, false, true)); + config.lora.modem_preset = selectionPreset; + } + config.lora.region = region; config.lora.channel_num = 0; // Reset to default channel @@ -263,6 +298,10 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool if (gps != nullptr && !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) gps->enable(); #endif + if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && !config.lora.tx_enabled && !owner.is_licensed) { + LOG_WARN("Setting config.lora.tx_enabled to true"); + config.lora.tx_enabled = true; + } service->reloadConfig(changes); } @@ -440,7 +479,7 @@ void menuHandler::deviceRolePicker() config.device.role = meshtastic_Config_DeviceConfig_Role_TRACKER; } service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); }; screen->showOverlayBanner(bannerOptions); } @@ -1834,12 +1873,12 @@ void menuHandler::resetNodeDBMenu() LOG_INFO("Initiate node-db reset"); nodeDB->resetNodes(); disableBluetooth(); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == 2) { LOG_INFO("Initiate node-db reset, keep favorites"); nodeDB->resetNodes(1); disableBluetooth(); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == 0) { menuQueue = NodeBaseMenu; screen->runNow(); @@ -2034,12 +2073,12 @@ void menuHandler::GPSSmartPositionMenu() config.position.position_broadcast_smart_enabled = true; saveUIConfig(); service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == 2) { config.position.position_broadcast_smart_enabled = false; saveUIConfig(); service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; bannerOptions.InitialSelected = config.position.position_broadcast_smart_enabled ? 1 : 2; @@ -2094,7 +2133,7 @@ void menuHandler::GPSUpdateIntervalMenu() if (selected != 0) { saveUIConfig(); service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; @@ -2184,7 +2223,7 @@ void menuHandler::GPSPositionBroadcastMenu() if (selected != 0) { saveUIConfig(); service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; @@ -2325,7 +2364,7 @@ void menuHandler::switchToMUIMenu() config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR; config.bluetooth.enabled = false; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; screen->showOverlayBanner(bannerOptions); @@ -2346,7 +2385,7 @@ void menuHandler::rebootMenu() IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0)); nodeDB->saveToDisk(); messageStore.saveToFlash(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else { menuQueue = PowerMenu; screen->runNow(); @@ -2400,6 +2439,158 @@ void menuHandler::removeFavoriteMenu() screen->showOverlayBanner(bannerOptions); } +void menuHandler::waypointBaseMenu() +{ + enum optionsNumbers { Back, GeofenceAlerts, RemoveWaypoint }; + static const char *optionsArray[] = {"Back", "Geofence Alerts", "Remove Waypoint"}; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Waypoint Action"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == GeofenceAlerts) { + menuQueue = GeofenceWaypointMenu; + screen->runNow(); + } else if (selected == RemoveWaypoint) { + menuQueue = RemoveWaypointMenu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::geofenceWaypointMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + static const char *optionsArray[WAYPOINT_HISTORY_LIMIT + 1]; + static uint32_t waypointIds[WAYPOINT_HISTORY_LIMIT + 1]; + static std::string labelStorage[WAYPOINT_HISTORY_LIMIT + 1]; + + optionsArray[0] = "Back"; + int options = 1; + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (options > WAYPOINT_HISTORY_LIMIT || !GeofenceModule::hasGeofence(entry.waypoint)) + continue; + std::string name = sanitizeString(entry.waypoint.name); + if (name.empty()) + name = "Unnamed Geofence"; + labelStorage[options] = name.substr(0, 20); + optionsArray[options] = labelStorage[options].c_str(); + waypointIds[options] = entry.waypoint.id; + options++; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = options > 1 ? "Geofence Alerts" : "No Geofences"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = WaypointBaseMenu; + } else { + selectedGeofenceWaypointId = waypointIds[selected]; + menuQueue = GeofenceOptionsMenu; + } + screen->runNow(); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + +void menuHandler::geofenceOptionsMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) { + menuQueue = GeofenceWaypointMenu; + screen->runNow(); + return; + } + + static std::string labels[4]; + static const char *optionsArray[4]; + labels[0] = "Back"; + labels[1] = std::string("Enter Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_ENTER) ? "On" : "Off"); + labels[2] = std::string("Exit Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_EXIT) ? "On" : "Off"); + labels[3] = std::string("Favorites Only: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY) ? "On" : "Off"); + for (size_t i = 0; i < 4; ++i) + optionsArray[i] = labels[i].c_str(); + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Geofence Alerts"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 4; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = GeofenceWaypointMenu; + } else { + const StoredWaypoint *current = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (current) { + const WaypointNotificationPreference preference = + selected == 1 ? WAYPOINT_NOTIFY_ENTER + : (selected == 2 ? WAYPOINT_NOTIFY_EXIT : WAYPOINT_NOTIFY_FAVORITES_ONLY); + waypointStore.setNotificationPreference(selectedGeofenceWaypointId, preference, + !current->notificationEnabled(preference)); + } + menuQueue = GeofenceOptionsMenu; + } + screen->runNow(); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + +void menuHandler::removeWaypointMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + static const char *optionsArray[WAYPOINT_HISTORY_LIMIT + 1]; + static uint32_t waypointIds[WAYPOINT_HISTORY_LIMIT + 1]; + static std::string labelStorage[WAYPOINT_HISTORY_LIMIT + 1]; + + optionsArray[0] = "Back"; + int options = 1; + + for (const auto &entry : waypointStore.getWaypoints()) { + if (options > WAYPOINT_HISTORY_LIMIT) + break; + std::string name = sanitizeString(entry.waypoint.name); + if (name.empty()) + name = "Unnamed Waypoint"; + labelStorage[options] = name.substr(0, 20); + optionsArray[options] = labelStorage[options].c_str(); + waypointIds[options] = entry.waypoint.id; + options++; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Remove Waypoint"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = WaypointBaseMenu; + screen->runNow(); + return; + } + const uint32_t waypointId = waypointIds[selected]; + LOG_INFO("Removing waypoint 0x%08x", waypointId); + if (waypointModule) + waypointModule->broadcastDelete(waypointId); + else + waypointStore.removeWaypoint(waypointId); + screen->setFrames(graphics::Screen::FOCUS_DEFAULT); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + void menuHandler::traceRouteMenu() { screen->showNodePicker("Node to Trace", 30000, [](uint32_t nodenum) -> void { @@ -2497,12 +2688,12 @@ void menuHandler::wifiToggleMenu() config.network.wifi_enabled = false; config.bluetooth.enabled = true; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == Wifi_enable) { config.network.wifi_enabled = true; config.bluetooth.enabled = false; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; screen->showOverlayBanner(bannerOptions); @@ -3037,6 +3228,18 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case RemoveFavorite: removeFavoriteMenu(); break; + case WaypointBaseMenu: + waypointBaseMenu(); + break; + case GeofenceWaypointMenu: + geofenceWaypointMenu(); + break; + case GeofenceOptionsMenu: + geofenceOptionsMenu(); + break; + case RemoveWaypointMenu: + removeWaypointMenu(); + break; case TraceRouteMenu: traceRouteMenu(); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 9650932232..a2cc14e677 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -37,6 +37,10 @@ class menuHandler NodePickerMenu, ManageNodeMenu, RemoveFavorite, + WaypointBaseMenu, + GeofenceWaypointMenu, + GeofenceOptionsMenu, + RemoveWaypointMenu, TestMenu, NumberTest, EnvironmentTelemetryMenu, @@ -108,6 +112,10 @@ class menuHandler static void manageNodeMenu(); static void addFavoriteMenu(); static void removeFavoriteMenu(); + static void waypointBaseMenu(); + static void geofenceWaypointMenu(); + static void geofenceOptionsMenu(); + static void removeWaypointMenu(); static void traceRouteMenu(); static void testMenu(); static void numberTest(); @@ -133,6 +141,11 @@ class menuHandler // ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable. static void toggleNodeMuted(uint32_t nodeNum); // uint32_t, matching pickedNodeNum above + // Preset a region selection should leave installed. `lora` is the config as it stands *before* + // the selection is written. + static meshtastic_Config_LoRaConfig_ModemPreset presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, + meshtastic_Config_LoRaConfig_RegionCode selected); + private: static void saveUIConfig(); static void keyVerificationInitMenu(); diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index acfc4d11b6..284bfc6c4f 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -3,6 +3,8 @@ #include "MessageRenderer.h" // Core includes +#include "Channels.h" +#include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" #include "UIRenderer.h" @@ -1132,18 +1134,13 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht { if (packet.from != 0) { hasUnreadMessage = true; - const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive(); + const bool suppressBanner = + (cannedMessageModule && cannedMessageModule->isFreeTextActive()) || (screen && screen->isTextMessageFrameShown()); // Don't let the pop-up clobber a menu/picker the user is interacting with; the wake below // still happens so a message can light the screen back up. const bool menuShowing = NotificationRenderer::isMenuShowing(); - // Determine if message belongs to a muted channel - bool isChannelMuted = false; - if (sm.type == MessageType::BROADCAST) { - const meshtastic_Channel channel = channels.getByIndex(packet.channel ? packet.channel : channels.getPrimaryIndex()); - if (channel.settings.has_module_settings && channel.settings.module_settings.is_muted) - isChannelMuted = true; - } + const bool isMuted = isMutedForPacket(packet); // Banner logic const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from); @@ -1163,21 +1160,9 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht char truncatedLongName[64]; graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName), availWidth); - const char *msgRaw = reinterpret_cast(packet.decoded.payload.bytes); char banner[256]; - bool isAlert = false; - - // Check if alert detection is enabled via external notification module - if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra || - moduleConfig.external_notification.alert_bell_buzzer) { - for (size_t i = 0; i < packet.decoded.payload.size && i < 100; i++) { - if (msgRaw[i] == '\x07') { - isAlert = true; - break; - } - } - } + const bool isAlert = MeshService::isAlertPayload(packet); if (isAlert) { if (truncatedLongName[0]) @@ -1185,8 +1170,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht else strcpy(banner, "Alert Received"); } else { - // Skip muted channels unless it's an alert - if (isChannelMuted) + // Skip muted channels/senders unless it's an alert + if (isMuted) return; if (truncatedLongName[0]) { @@ -1230,7 +1215,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht screen->setOn(true); } - if (!suppressBanner && !menuShowing) { + if (!suppressBanner && !menuShowing && !screen->hasModalModule()) { screen->showSimpleBanner(banner, inThread ? 1000 : 3000); } } diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 24a5eeaf63..6f2f6ff650 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -566,9 +566,7 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon); float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian); float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing); - // Shrink size by 2px - int size = FONT_HEIGHT_SMALL - 5; - CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg); + drawRelativeCompassArrow(display, centerX, centerY, relativeBearingDeg); /* float angle = relativeBearing * DEG_TO_RAD; float halfSize = size / 2.0; @@ -613,6 +611,12 @@ void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int display->drawString(centerX, y, "?"); } +void drawRelativeCompassArrow(OLEDDisplay *display, int16_t centerX, int16_t centerY, float relativeBearingDeg) +{ + const int size = FONT_HEIGHT_SMALL - 5; + CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg); +} + // ============================= // Main Screen Functions // ============================= diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index d1e8bac1dd..3d14b6466f 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -45,6 +45,7 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 // Extras renderers void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeadingRadian, double userLat, double userLon); +void drawRelativeCompassArrow(OLEDDisplay *display, int16_t centerX, int16_t centerY, float relativeBearingDeg); // Screen frame functions void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 7abfd210da..28de1a4b74 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -802,7 +802,10 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp uint16_t screenHeight = display->height(); uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; - uint8_t visibleTotalLines = std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight); + // Pairing PIN: pass every line, drawNotificationBox fits them (tiny panels spread them over the full screen). + uint8_t visibleTotalLines = (current_notification_type == notificationTypeEnum::pairing_pin) + ? totalLines + : std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight); uint8_t linesShown = lineCount; const char *linePointers[visibleTotalLines + 1] = {0}; // this is sort of a dynamic allocation @@ -940,10 +943,16 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; uint8_t visibleTotalLines = 0; uint16_t contentHeight = 0; +#if defined(OLED_TINY) + // Tiny panels: the pairing PIN takes the whole screen, all lines shown and spread evenly over it. + const bool fullScreenPin = (current_notification_type == notificationTypeEnum::pairing_pin); +#else + const bool fullScreenPin = false; +#endif const uint16_t availableHeight = (screenHeight > (vPadding * 2)) ? (screenHeight - vPadding * 2) : 0; for (uint8_t i = 0; i < lineCount; i++) { uint8_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight; - if (contentHeight + thisLineHeight > availableHeight) { + if (!fullScreenPin && contentHeight + thisLineHeight > availableHeight) { break; } contentHeight += thisLineHeight; @@ -964,7 +973,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay } int16_t boxTop = (display->height() / 2) - (boxHeight / 2); boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1; - if (graphics::isCompactPanel(display)) { + if (fullScreenPin || graphics::isCompactPanel(display)) { boxLeft = 0; boxTop = 0; boxWidth = display->width(); @@ -1008,6 +1017,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay for (int i = 0; i < visibleTotalLines; i++) { display->setFont(fontForBannerLine(lineFonts[i])); int16_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight; + if (fullScreenPin) { + // Equal slots over the full height (10 rows each on a 32px panel, glyphs sit in rows 3..9). + thisLineHeight = boxHeight / visibleTotalLines; + lineY = i * thisLineHeight; + } int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2; if (needs_bell && i == 0) { int fontHeight = thisLineHeight + 3; diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 804f949ffb..58d759679b 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if HAS_SCREEN #include "CompassRenderer.h" @@ -545,7 +546,9 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht // Draw satellite image if (currentResolution == ScreenResolution::High) { - NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display); + const int iconSlack = FONT_HEIGHT_SMALL - (imgGPS_height * 2); + const int iconY = y + (iconSlack > 0 ? iconSlack / 2 : 0); + NodeListRenderer::drawScaledXBitmap16x16(x, iconY, imgGPS_width, imgGPS_height, imgGPS, display); } else { display->drawXbm(x + 1, y + 3, imgGPS_width, imgGPS_height, imgGPS); } @@ -2210,12 +2213,12 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta if (navBarVisible && !navBarPrevVisible) { EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when showing nav bar cosmeticRefreshDone = false; - navBarLastShown = millis(); + navBarLastShown = Time::skipZero(Time::getMillis()); } if (!navBarVisible && navBarPrevVisible) { - EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when hiding nav bar - navBarLastShown = millis(); // Mark when it disappeared + EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when hiding nav bar + navBarLastShown = Time::skipZero(Time::getMillis()); // Mark when it disappeared } if (!navBarVisible && navBarLastShown != 0 && !cosmeticRefreshDone) { diff --git a/src/graphics/emotes.cpp b/src/graphics/emotes.cpp index 9fb26fa1fd..9bb807d56f 100644 --- a/src/graphics/emotes.cpp +++ b/src/graphics/emotes.cpp @@ -188,7 +188,8 @@ const Emote emotes[] = { {"\u2603\uFE0F", snow_man, snow_man_width, snow_man_height}, // ☃️ Snowman // --- Misc --- - {"\U0001F4A8", dashing_away, dashing_away_width, dashing_away_height} // 💨 Dashing Away + {"\U0001F4A8", dashing_away, dashing_away_width, dashing_away_height}, // 💨 Dashing Away + {"\U0001F4CD", pushpin, pushpin_width, pushpin_height} // 📍 Pushpin #endif }; @@ -606,6 +607,10 @@ const unsigned char wood[] PROGMEM = {0xF0, 0x0F, 0x08, 0x10, 0x04, 0x20, 0x0C, const unsigned char beer[] PROGMEM = {0x00, 0x00, 0x50, 0x05, 0xA8, 0x0A, 0x5C, 0x1D, 0xE4, 0x13, 0x1C, 0x7C, 0xE4, 0x73, 0x04, 0x50, 0x04, 0x51, 0x14, 0x50, 0x44, 0x54, 0x04, 0x71, 0x44, 0x70, 0x04, 0x10, 0x18, 0x0C, 0xE0, 0x03}; + +const unsigned char pushpin[] PROGMEM = {0x00, 0x00, 0xC0, 0x07, 0xE0, 0x0F, 0xF0, 0x1F, 0xF0, 0x1F, 0xF0, + 0x17, 0xF0, 0x13, 0xF0, 0x11, 0x60, 0x08, 0xC0, 0x07, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00}; #endif } // namespace graphics diff --git a/src/graphics/emotes.h b/src/graphics/emotes.h index 9646ac78ef..cb00f5d0ed 100644 --- a/src/graphics/emotes.h +++ b/src/graphics/emotes.h @@ -425,6 +425,10 @@ extern const unsigned char wood[] PROGMEM; #define beer_width 16 #define beer_height 16 extern const unsigned char beer[] PROGMEM; + +#define pushpin_width 16 +#define pushpin_height 16 +extern const unsigned char pushpin[] PROGMEM; #endif // EXCLUDE_EMOJI } // namespace graphics diff --git a/src/graphics/niche/InkHUD/Applet.cpp b/src/graphics/niche/InkHUD/Applet.cpp index 50efff8ffe..03fdd0ea40 100644 --- a/src/graphics/niche/InkHUD/Applet.cpp +++ b/src/graphics/niche/InkHUD/Applet.cpp @@ -635,7 +635,7 @@ std::string InkHUD::Applet::getTimeString(uint32_t epochSeconds) // Format the clock string, either 12 hour or 24 hour char clockStr[11]; if (config.display.use_12h_clock) - sprintf(clockStr, "%u:%02u %s", (hour % 12 == 0 ? 12 : hour % 12), min, hour > 11 ? "PM" : "AM"); + sprintf(clockStr, "%u:%02u %s", (hour % 12 == 0 ? 12u : hour % 12), min, hour > 11 ? "PM" : "AM"); else sprintf(clockStr, "%02u:%02u", hour, min); diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h index 84891b1fec..0f48039588 100644 --- a/src/graphics/niche/InkHUD/Applet.h +++ b/src/graphics/niche/InkHUD/Applet.h @@ -129,6 +129,7 @@ class Applet : public GFX virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification virtual class MapApplet *asMapApplet() { return nullptr; } // Returns non-null only for MapApplet and its subclasses + virtual class WaypointListApplet *asWaypointListApplet() { return nullptr; } // Returns non-null only for WaypointListApplet static uint16_t getHeaderHeight(); // How tall the "standard" applet header is diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp index 5c8d78cb80..795447b749 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp @@ -2,6 +2,8 @@ #include "./MapApplet.h" #include "./MapTile.h" +#include "WaypointStore.h" +#include "WaypointUtils.h" #include #include @@ -21,11 +23,95 @@ static int tileTyAt(int tileIndex); static int tileMetadataZoomCount(); static int tileMetadataZoomAt(int index); -// Observe GPS position updates so the map redraws whenever a new location arrives. +namespace +{ + +bool waypointHasAnchor(const meshtastic_Waypoint &waypoint) +{ + return waypoint.has_latitude_i && waypoint.has_longitude_i; +} + +bool waypointHasMapGeometry(const meshtastic_Waypoint &waypoint) +{ + return waypointHasAnchor(waypoint) || waypoint.has_bounding_box; +} + +void includeMapPoint(float latNode, float lngNode, float lngCenter, float &northernmost, float &southernmost, float &easternmost, + float &westernmost) +{ + northernmost = max(northernmost, latNode); + southernmost = min(southernmost, latNode); + + const float degEastward = fmodf(((lngNode - lngCenter) + 360.0f), 360.0f); + const float degWestward = fabsf(fmodf(((lngNode - lngCenter) - 360.0f), 360.0f)); + if (degEastward < degWestward) + easternmost = max(easternmost, lngCenter + degEastward); + else + westernmost = min(westernmost, lngCenter - degWestward); +} + +} // namespace + +bool InkHUD::MapApplet::mapWaypointIconGlyph(uint32_t codepoint, std::string &glyph) +{ + if (!codepoint) + return false; + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(codepoint); + if (utf8.empty()) + return false; + + glyph = getFont().decodeUTF8(utf8); + return glyph.size() == 1 && glyph[0] != '\x1A' && glyph[0] != '\x7F'; +} + +static int16_t markerScreenX(float eastMeters, float metersToPx, uint16_t width) +{ + return (width * 0.5f) + (eastMeters * metersToPx); +} + +static int16_t markerScreenY(float northMeters, float metersToPx, uint16_t height) +{ + return (height * 0.5f) - (northMeters * metersToPx); +} + +uint8_t InkHUD::MapApplet::fallbackBadgeNumber(const WaypointMarker &entry) +{ + uint8_t badge = 0; + + for (auto it = waypointMarkers.rbegin(); it != waypointMarkers.rend(); ++it) { + if (!it->hasMarker) + continue; + + std::string glyph; + if (mapWaypointIconGlyph(it->icon, glyph)) + continue; + + if (it->id == entry.id) + return badge; + + if (badge < 9) + ++badge; + } + + return 0; +} + +void InkHUD::MapApplet::drawWaypointFallbackMarker(const WaypointMarker &entry, int16_t x, int16_t y) +{ + char badgeText[3]; + snprintf(badgeText, sizeof(badgeText), "%u", (unsigned)fallbackBadgeNumber(entry)); + + // Keep fallback digits centered so they read like map markers. + setFont(fontSmall); + printAt(x, y + 1, badgeText, CENTER, MIDDLE); +} + InkHUD::MapApplet::MapApplet() { if (gpsStatus) gpsStatusObserver.observe(&gpsStatus->onNewStatus); + waypointStoreObserver.observe(&waypointStore); } int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status) @@ -39,6 +125,17 @@ int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status) return 0; } +int InkHUD::MapApplet::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + + if (!isActive()) + return 0; + + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return 0; +} + // Zoom in one step from the current display zoom. void InkHUD::MapApplet::zoomIn() { @@ -72,6 +169,19 @@ void InkHUD::MapApplet::resetZoom() { s_zoomLocked = false; s_lockedZoom = -1; + focusedWaypointId = 0; +} + +bool InkHUD::MapApplet::focusWaypoint(uint32_t waypointId) +{ + const StoredWaypoint *entry = waypointStore.findWaypoint(waypointId); + if (!entry || WaypointStore::isExpired(*entry) || !waypointHasMapGeometry(entry->waypoint)) + return false; + + s_zoomLocked = false; + s_lockedZoom = -1; + focusedWaypointId = waypointId; + return true; } bool InkHUD::MapApplet::canZoomIn() const @@ -405,7 +515,7 @@ void InkHUD::MapApplet::onRender(bool full) chosenZoom = s_lockedZoom; float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad); chosenMetersToPx = 1.0f / mpp; - } else if ((markers.empty() || metersToPxFit <= 0.0f) && nzooms > 0) { + } else if (((markers.empty() && waypointMarkers.empty()) || metersToPxFit <= 0.0f) && nzooms > 0) { // No spread to fit (own node only, or single remote node at map center). Use highest zoom at native scale. chosenZoom = zooms[0]; float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad); @@ -490,6 +600,48 @@ void InkHUD::MapApplet::onRender(bool full) setTextColor(BLACK); } + // Draw waypoint markers after nodes so the boxed icons stay legible. + for (const WaypointMarker &m : waypointMarkers) { + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + const int16_t radiusPx = std::max(1, (int16_t)lroundf(m.geofenceRadiusMeters * metersToPx)); + const int16_t centerX = markerScreenX(m.eastMeters, metersToPx, width()); + const int16_t centerY = markerScreenY(m.northMeters, metersToPx, height()); + drawCircle(centerX, centerY, radiusPx, BLACK); + } + + if (m.hasBoundingBox) { + const int16_t westX = markerScreenX(m.boxWestMeters, metersToPx, width()); + const int16_t eastX = markerScreenX(m.boxEastMeters, metersToPx, width()); + const int16_t northY = markerScreenY(m.boxNorthMeters, metersToPx, height()); + const int16_t southY = markerScreenY(m.boxSouthMeters, metersToPx, height()); + const int16_t left = std::min(westX, eastX); + const int16_t right = std::max(westX, eastX); + const int16_t top = std::min(northY, southY); + const int16_t bottom = std::max(northY, southY); + drawRect(left, top, std::max(1, right - left + 1), std::max(1, bottom - top + 1), BLACK); + } + + if (!m.hasMarker) + continue; + + int16_t x = markerScreenX(m.eastMeters, metersToPx, width()); + int16_t y = markerScreenY(m.northMeters, metersToPx, height()); + constexpr int outlinePad = 1; + const int boxSize = fontSmall.lineHeight() + 2; + const int radius = max(2, boxSize / 6); + + fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE); + drawRoundRect(x - (boxSize / 2), y - (boxSize / 2), boxSize, boxSize, radius, BLACK); + + std::string glyph; + if (mapWaypointIconGlyph(m.icon, glyph)) { + setFont(fontSmall); + printAt(x, y + 1, glyph, CENTER, MIDDLE); + } else { + drawWaypointFallbackMarker(m, x, y); + } + } + // Dual map scale bars if (metersToPx <= 0.0f) return; @@ -588,6 +740,25 @@ void InkHUD::MapApplet::onRender(bool full) void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) { + if (focusedWaypointId != 0) { + const StoredWaypoint *entry = waypointStore.findWaypoint(focusedWaypointId); + if (entry && !WaypointStore::isExpired(*entry) && waypointHasMapGeometry(entry->waypoint)) { + *lat = waypointHasAnchor(entry->waypoint) + ? entry->waypoint.latitude_i * 1e-7f + : ((float)entry->waypoint.bounding_box.latitude_south_i + entry->waypoint.bounding_box.latitude_north_i) * + 0.5e-7f; + *lng = waypointHasAnchor(entry->waypoint) + ? entry->waypoint.longitude_i * 1e-7f + : ((float)entry->waypoint.bounding_box.longitude_west_i + entry->waypoint.bounding_box.longitude_east_i) * + 0.5e-7f; + latCenter = *lat; + lngCenter = *lng; + centerIsOurNode = false; + return; + } + focusedWaypointId = 0; + } + // If we have a valid position for our own node, use that as the anchor const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); meshtastic_PositionLite ourSelfPos; @@ -646,6 +817,34 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) positionCount++; } + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (!waypointHasMapGeometry(entry.waypoint)) + continue; + + const float latDeg = + waypointHasAnchor(entry.waypoint) + ? (entry.waypoint.latitude_i * 1e-7f) + : ((float)entry.waypoint.bounding_box.latitude_south_i + entry.waypoint.bounding_box.latitude_north_i) * + 0.5e-7f; + const float lngDeg = + waypointHasAnchor(entry.waypoint) + ? (entry.waypoint.longitude_i * 1e-7f) + : ((float)entry.waypoint.bounding_box.longitude_west_i + entry.waypoint.bounding_box.longitude_east_i) * + 0.5e-7f; + float latRad = latDeg * DEG_TO_RAD; + float lngRad = lngDeg * DEG_TO_RAD; + float x = cos(latRad) * cos(lngRad); + float y = cos(latRad) * sin(lngRad); + float z = sin(latRad); + + xAvg += x; + yAvg += y; + zAvg += z; + positionCount++; + } + // All NodeDB processed, find mean values if (positionCount == 0) return; @@ -759,15 +958,24 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) float latNode = pos.latitude_i * 1e-7; float lngNode = pos.longitude_i * 1e-7; - northernmost = max(northernmost, latNode); - southernmost = min(southernmost, latNode); + includeMapPoint(latNode, lngNode, lngCenter, northernmost, southernmost, easternmost, westernmost); + } - float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees east from center to node - float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees west from center to node - if (degEastward < degWestward) - easternmost = max(easternmost, lngCenter + degEastward); - else - westernmost = min(westernmost, lngCenter - degWestward); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (waypointHasAnchor(entry.waypoint)) + includeMapPoint(entry.waypoint.latitude_i * 1e-7f, entry.waypoint.longitude_i * 1e-7f, lngCenter, northernmost, + southernmost, easternmost, westernmost); + + if (entry.waypoint.has_bounding_box) { + includeMapPoint(entry.waypoint.bounding_box.latitude_south_i * 1e-7f, + entry.waypoint.bounding_box.longitude_west_i * 1e-7f, lngCenter, northernmost, southernmost, + easternmost, westernmost); + includeMapPoint(entry.waypoint.bounding_box.latitude_north_i * 1e-7f, + entry.waypoint.bounding_box.longitude_east_i * 1e-7f, lngCenter, northernmost, southernmost, + easternmost, westernmost); + } } // Todo: check for issues with map spans >180 deg. MQTT only.. @@ -787,12 +995,48 @@ void InkHUD::MapApplet::getMapSize(uint32_t *widthMeters, uint32_t *heightMeters *widthMeters = 0; *heightMeters = 0; + if (focusedWaypointId != 0) { + for (const WaypointMarker &m : waypointMarkers) { + if (m.id != focusedWaypointId) + continue; + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + *widthMeters = m.geofenceRadiusMeters * 2; + *heightMeters = m.geofenceRadiusMeters * 2; + } + if (m.hasBoundingBox) { + *widthMeters = max(*widthMeters, (uint32_t)std::max(fabsf(m.boxWestMeters), fabsf(m.boxEastMeters)) * 2); + *heightMeters = max(*heightMeters, (uint32_t)std::max(fabsf(m.boxSouthMeters), fabsf(m.boxNorthMeters)) * 2); + } + *widthMeters *= 1.1; + *heightMeters *= 1.1; + return; + } + } + // Find the greatest distance horizontally and vertically from map center for (Marker m : markers) { *widthMeters = max(*widthMeters, (uint32_t)abs(m.eastMeters) * 2); *heightMeters = max(*heightMeters, (uint32_t)abs(m.northMeters) * 2); } + // Waypoints contribute to the fit-all bounding box just like nodes, including geofence extents. + for (const WaypointMarker &m : waypointMarkers) { + if (m.hasMarker) { + *widthMeters = max(*widthMeters, (uint32_t)fabsf(m.eastMeters) * 2); + *heightMeters = max(*heightMeters, (uint32_t)fabsf(m.northMeters) * 2); + } + + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + *widthMeters = max(*widthMeters, (uint32_t)(fabsf(m.eastMeters) + m.geofenceRadiusMeters) * 2); + *heightMeters = max(*heightMeters, (uint32_t)(fabsf(m.northMeters) + m.geofenceRadiusMeters) * 2); + } + + if (m.hasBoundingBox) { + *widthMeters = max(*widthMeters, (uint32_t)std::max(fabsf(m.boxWestMeters), fabsf(m.boxEastMeters)) * 2); + *heightMeters = max(*heightMeters, (uint32_t)std::max(fabsf(m.boxSouthMeters), fabsf(m.boxNorthMeters)) * 2); + } + } + // Add padding *widthMeters *= 1.1; *heightMeters *= 1.1; @@ -928,6 +1172,13 @@ bool InkHUD::MapApplet::enoughMarkers() if (nodeDB->hasValidPosition(node) && shouldDrawNode(node)) return true; } + + // Any live waypoint with a marker or box is enough to justify showing the map. + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (!WaypointStore::isExpired(entry) && waypointHasMapGeometry(entry.waypoint)) + return true; + } + return false; } @@ -937,6 +1188,8 @@ void InkHUD::MapApplet::calculateAllMarkers() { // Clear old markers markers.clear(); + waypointMarkers.clear(); + waypointMarkers.reserve(waypointStore.getWaypoints().size()); // For each node in db for (uint32_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { @@ -965,6 +1218,37 @@ void InkHUD::MapApplet::calculateAllMarkers() markers.push_back(calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away)); } + + // Cache waypoint markers once per render pass to avoid repeated geo math below. + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (!waypointHasMapGeometry(entry.waypoint)) + continue; + + WaypointMarker marker; + marker.id = entry.waypoint.id; + marker.icon = entry.waypoint.icon; + marker.geofenceRadiusMeters = entry.waypoint.geofence_radius; + marker.hasMarker = waypointHasAnchor(entry.waypoint); + marker.hasBoundingBox = entry.waypoint.has_bounding_box; + if (marker.hasMarker) { + Marker base = calculateMarker(entry.waypoint.latitude_i * 1e-7, entry.waypoint.longitude_i * 1e-7, 0); + marker.eastMeters = base.eastMeters; + marker.northMeters = base.northMeters; + } + if (entry.waypoint.has_bounding_box) { + Marker southWest = calculateMarker(entry.waypoint.bounding_box.latitude_south_i * 1e-7, + entry.waypoint.bounding_box.longitude_west_i * 1e-7, 0); + Marker northEast = calculateMarker(entry.waypoint.bounding_box.latitude_north_i * 1e-7, + entry.waypoint.bounding_box.longitude_east_i * 1e-7, 0); + marker.boxWestMeters = southWest.eastMeters; + marker.boxSouthMeters = southWest.northMeters; + marker.boxEastMeters = northEast.eastMeters; + marker.boxNorthMeters = northEast.northMeters; + } + waypointMarkers.push_back(marker); + } } void InkHUD::MapApplet::calculateMapScale() diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h index b447273df9..fa90c0c5dd 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h @@ -16,7 +16,9 @@ The base applet doesn't handle any events; this is left to the derived applets. #include "configuration.h" #include +#include +#include "WaypointStore.h" #include "graphics/niche/InkHUD/Applet.h" #include "GPSStatus.h" @@ -41,6 +43,7 @@ class MapApplet : public Applet void zoomIn(); void zoomOut(); void resetZoom(); + bool focusWaypoint(uint32_t waypointId); bool isZoomLocked() const { return s_zoomLocked; } bool canZoomIn() const; bool canZoomOut() const; @@ -57,6 +60,9 @@ class MapApplet : public Applet int onGpsStatusUpdate(const meshtastic::Status *status); CallbackObserver gpsStatusObserver = CallbackObserver(this, &MapApplet::onGpsStatusUpdate); + int onWaypointStoreChanged(const WaypointStore *store); + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &MapApplet::onWaypointStoreChanged); static bool s_zoomLocked; static int s_lockedZoom; @@ -69,6 +75,24 @@ class MapApplet : public Applet uint8_t hopsAway = 0; // Determines marker size }; + struct WaypointMarker { + float eastMeters = 0; + float northMeters = 0; + uint32_t id = 0; + uint32_t icon = 0; + uint32_t geofenceRadiusMeters = 0; + bool hasMarker = false; + bool hasBoundingBox = false; + float boxWestMeters = 0; + float boxEastMeters = 0; + float boxSouthMeters = 0; + float boxNorthMeters = 0; + }; + + bool mapWaypointIconGlyph(uint32_t codepoint, std::string &glyph); + uint8_t fallbackBadgeNumber(const WaypointMarker &entry); + void drawWaypointFallbackMarker(const WaypointMarker &entry, int16_t x, int16_t y); + Marker calculateMarker(float lat, float lng, uint8_t hopsAway); void calculateAllMarkers(); void calculateMapScale(); // Conversion factor for meters to pixels @@ -81,10 +105,12 @@ class MapApplet : public Applet bool centerIsOurNode = false; // True if map is centered on our own position (GPS or phone) std::list markers; + std::vector waypointMarkers; uint32_t widthMeters = 0; // Map width: meters uint32_t heightMeters = 0; // Map height: meters + uint32_t focusedWaypointId = 0; }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp index 005327baea..de4e2ee85e 100644 --- a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp @@ -205,6 +205,8 @@ IconKind iconKindForAppletName(const char *name) return IconKind::CHANNEL; if (lower.find("position") != std::string::npos) return IconKind::POSITIONS; + if (lower.find("waypoint") != std::string::npos) + return IconKind::POSITIONS; if (lower.find("recent") != std::string::npos) return IconKind::RECENTS; if (lower.find("heard") != std::string::npos) diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index 3b7606e123..b2a5f5ee67 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -141,6 +141,12 @@ enum MenuAction { MAP_ZOOM_IN, MAP_ZOOM_OUT, MAP_ZOOM_RESET, + // Waypoints (WaypointListApplet) + REMOVE_WAYPOINT, + SELECT_GEOFENCE_WAYPOINT, + TOGGLE_GEOFENCE_ENTER, + TOGGLE_GEOFENCE_EXIT, + TOGGLE_GEOFENCE_FAVORITES_ONLY, }; } // namespace NicheGraphics::InkHUD diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 863c1e85d4..6de9c48a5c 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -9,12 +9,16 @@ #include "MessageStore.h" #include "Power.h" #include "Router.h" +#include "UptimeClock.h" #include "airtime.h" #include "gps/RTC.h" #include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/Utils/FlashData.h" #include "main.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "modules/GeofenceModule.h" +#include "modules/WaypointModule.h" #include #include #if defined(ARCH_ESP32) && HAS_WIFI @@ -346,7 +350,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); service->reloadConfig(changes); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role) @@ -363,7 +367,7 @@ static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role) // Notify UI that changes are being applied InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) @@ -380,7 +384,7 @@ static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) // Notify UI that changes are being applied InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = false) @@ -390,7 +394,7 @@ static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = f if (reboot) { InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } } @@ -569,7 +573,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) case SHUTDOWN: LOG_INFO("Shutting down from menu"); - shutdownAtMsec = millis(); + shutdownAtMsec = Time::skipZero(Time::getMillis()); // Menu is then sent to background via onShutdown break; @@ -672,7 +676,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) config.bluetooth.enabled = true; nodeDB->saveToDisk(SEGMENT_CONFIG); InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + 2000; + rebootAtMsec = Time::timerEndsAtMillis(2000); break; // Power / Network (ESP32-only) @@ -681,7 +685,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) config.power.is_power_saving = !config.power.is_power_saving; nodeDB->saveToDisk(SEGMENT_CONFIG); InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; case TOGGLE_WIFI: @@ -694,7 +698,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) nodeDB->saveToDisk(SEGMENT_CONFIG); InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; #endif // ADC Calibration @@ -772,7 +776,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) nodeDB->saveToDisk(SEGMENT_CONFIG); InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; case TOGGLE_BLUETOOTH_PAIR_MODE: @@ -1120,13 +1124,13 @@ void InkHUD::MenuApplet::execute(MenuItem item) case RESET_NODEDB_ALL: InkHUD::getInstance()->notifyApplyingChanges(); nodeDB->resetNodes(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; case RESET_NODEDB_KEEP_FAVORITES: InkHUD::getInstance()->notifyApplyingChanges(); nodeDB->resetNodes(1); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; case WIPE_MESSAGES_ALL: @@ -1157,6 +1161,35 @@ void InkHUD::MenuApplet::execute(MenuItem item) break; } + case REMOVE_WAYPOINT: { + // cursor - 2 because index 0 is "Back" and index 1 is the "Select to Remove" header + const size_t index = cursor - 2; + if (waypointModule && index < removeWaypointIds.size()) + waypointModule->broadcastDelete(removeWaypointIds.at(index)); + break; + } + + case SELECT_GEOFENCE_WAYPOINT: { + const size_t index = cursor - 2; + if (index < geofenceWaypointIds.size()) + selectedGeofenceWaypointId = geofenceWaypointIds.at(index); + break; + } + + case TOGGLE_GEOFENCE_ENTER: + case TOGGLE_GEOFENCE_EXIT: + case TOGGLE_GEOFENCE_FAVORITES_ONLY: { + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) + break; + const WaypointNotificationPreference preference = + item.action == TOGGLE_GEOFENCE_ENTER + ? WAYPOINT_NOTIFY_ENTER + : (item.action == TOGGLE_GEOFENCE_EXIT ? WAYPOINT_NOTIFY_EXIT : WAYPOINT_NOTIFY_FAVORITES_ONLY); + waypointStore.setNotificationPreference(selectedGeofenceWaypointId, preference, !entry->notificationEnabled(preference)); + break; + } + default: LOG_WARN("Action not implemented"); } @@ -1173,6 +1206,8 @@ void InkHUD::MenuApplet::showPage(MenuPage page) items.clear(); items.shrink_to_fit(); nodeConfigLabels.clear(); + removeWaypointIds.clear(); + geofenceWaypointIds.clear(); switch (page) { case ROOT: @@ -1196,6 +1231,20 @@ void InkHUD::MenuApplet::showPage(MenuPage page) } } + // Remove Waypoint - only when viewing the waypoint list applet + { + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (waypointListApplet && waypointListApplet->waypointCount() > 0) { + items.push_back(MenuItem("Remove Waypoint", MenuPage::REMOVE_WAYPOINT_LIST)); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (GeofenceModule::hasGeofence(entry.waypoint)) { + items.push_back(MenuItem("Geofence Alerts", MenuPage::GEOFENCE_WAYPOINT_LIST)); + break; + } + } + } + } + items.push_back(MenuItem("Options", MenuPage::OPTIONS)); // items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG)); @@ -1213,6 +1262,43 @@ void InkHUD::MenuApplet::showPage(MenuPage page) previousPage = MenuPage::SEND; break; + case REMOVE_WAYPOINT_LIST: + previousPage = MenuPage::ROOT; + populateRemoveWaypointPage(); // must be first + items.insert(items.begin(), MenuItem::Header("Select to Remove")); + items.insert(items.begin(), MenuItem("Back", previousPage)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case GEOFENCE_WAYPOINT_LIST: + previousPage = MenuPage::ROOT; + populateGeofenceWaypointPage(); + items.insert(items.begin(), MenuItem::Header("Select Geofence")); + items.insert(items.begin(), MenuItem("Back", previousPage)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case GEOFENCE_OPTIONS: { + previousPage = MenuPage::GEOFENCE_WAYPOINT_LIST; + items.push_back(MenuItem("Back", previousPage)); + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) { + items.push_back(MenuItem::Header("Geofence unavailable")); + break; + } + const std::string enterLabel = + std::string("Enter Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_ENTER) ? "On" : "Off"); + const std::string exitLabel = + std::string("Exit Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_EXIT) ? "On" : "Off"); + const std::string favoritesLabel = + std::string("Favorites Only: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY) ? "On" : "Off"); + items.push_back(MenuItem(enterLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_ENTER, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem(exitLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_EXIT, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem(favoritesLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_FAVORITES_ONLY, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + } + case OPTIONS: previousPage = MenuPage::ROOT; items.push_back(MenuItem("Back", previousPage)); @@ -2442,6 +2528,40 @@ void InkHUD::MenuApplet::populateRecipientPage() items.push_back(MenuItem("Exit", MenuPage::EXIT)); } +// Dynamically create MenuItem entries for each waypoint in the active WaypointListApplet +void InkHUD::MenuApplet::populateRemoveWaypointPage() +{ + assert(items.size() == 0); + + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (waypointListApplet) { + for (size_t i = 0; i < waypointListApplet->waypointCount(); i++) { + removeWaypointIds.push_back(waypointListApplet->waypointIdAt(i)); + items.push_back(MenuItem(waypointListApplet->waypointLabelAt(i).c_str(), MenuAction::REMOVE_WAYPOINT, + MenuPage::REMOVE_WAYPOINT_LIST)); + } + } +} + +void InkHUD::MenuApplet::populateGeofenceWaypointPage() +{ + assert(items.empty()); + + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (!waypointListApplet) + return; + + for (size_t i = 0; i < waypointListApplet->waypointCount(); ++i) { + const uint32_t id = waypointListApplet->waypointIdAt(i); + const StoredWaypoint *entry = waypointStore.findWaypoint(id); + if (!entry || !GeofenceModule::hasGeofence(entry->waypoint)) + continue; + geofenceWaypointIds.push_back(id); + items.push_back(MenuItem(waypointListApplet->waypointLabelAt(i).c_str(), MenuAction::SELECT_GEOFENCE_WAYPOINT, + MenuPage::GEOFENCE_OPTIONS)); + } +} + void InkHUD::MenuApplet::drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text) { setFont(fontSmall); diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h index 2d7fbd398b..28289203df 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h @@ -55,6 +55,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds void populateDisplayTimeoutPage(); // Create menu items for config.display.screen_on_secs + void populateRemoveWaypointPage(); // Create menu items: one per waypoint in the active WaypointListApplet + void populateGeofenceWaypointPage(); void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text); // Draw input field for free text @@ -74,7 +76,10 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread uint16_t systemInfoPanelHeight = 0; // Need to know before we render uint16_t menuTextLimit = 200; - std::vector items; // MenuItems for the current page. Filled by ShowPage + std::vector items; // MenuItems for the current page. Filled by ShowPage + std::vector removeWaypointIds; // Parallel to items, for REMOVE_WAYPOINT page + std::vector geofenceWaypointIds; + uint32_t selectedGeofenceWaypointId = 0; std::vector nodeConfigLabels; // Persistent labels for Node Config pages uint8_t selectedChannelIndex = 0; // Currently selected LoRa channel (Node Config → Radio → Channel) bool channelPositionEnabled = false; diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h index 925aa70872..655a823d84 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h @@ -19,6 +19,9 @@ enum MenuPage : uint8_t { ROOT, // Initial menu page SEND, CANNEDMESSAGE_RECIPIENT, // Select destination for a canned message + REMOVE_WAYPOINT_LIST, // Pick a waypoint to delete (WaypointListApplet only) + GEOFENCE_WAYPOINT_LIST, + GEOFENCE_OPTIONS, OPTIONS, NODE_CONFIG, NODE_CONFIG_LORA, diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h b/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h index d8c4f83662..7e7fe80912 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h @@ -19,7 +19,12 @@ namespace NicheGraphics::InkHUD class Notification { public: - enum Type : uint8_t { NOTIFICATION_MESSAGE_BROADCAST, NOTIFICATION_MESSAGE_DIRECT, NOTIFICATION_BATTERY } type; + enum Type : uint8_t { + NOTIFICATION_MESSAGE_BROADCAST, + NOTIFICATION_MESSAGE_DIRECT, + NOTIFICATION_BATTERY, + NOTIFICATION_GEOFENCE + } type; uint32_t timestamp; @@ -33,8 +38,12 @@ class Notification uint8_t channel; uint32_t sender; uint8_t batteryPercentage; + char geofenceNodeName[sizeof(meshtastic_User::long_name)]; + char geofenceName[sizeof(meshtastic_Waypoint::name)]; + uint32_t geofenceWaypointId; + bool geofenceEntered; }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp index e2090269ea..d4f900c9dc 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -4,7 +4,12 @@ #include "./Notification.h" #include "MessageStore.h" +#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" #include "graphics/niche/InkHUD/Persistence.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "modules/GeofenceModule.h" +#include +#endif #include "meshUtils.h" #include "modules/TextMessageModule.h" @@ -16,6 +21,27 @@ using namespace NicheGraphics; InkHUD::NotificationApplet::NotificationApplet() { textMessageObserver.observe(textMessageModule); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + if (geofenceModule) + geofenceObserver.observe(geofenceModule); +#endif +} + +void InkHUD::NotificationApplet::showNotification(const Notification &n) +{ + assert(isActive()); + + if (!settings->optionalFeatures.notifications) + return; + + dismiss(); + hasNotification = true; + currentNotification = n; + if (isApproved()) { + bringToForeground(); + inkhud->forceUpdate(); + } else + hasNotification = false; } // Collect meta-info about the text message, and ask for approval for the notification @@ -25,11 +51,6 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket // System applets are always active assert(isActive()); - // Abort if feature disabled - // This is a bit clumsy, but avoids complicated handling when the feature is enabled / disabled - if (!settings->optionalFeatures.notifications) - return 0; - // Abort if this is an outgoing message if (getFrom(p) == nodeDB->getNodeNum()) return 0; @@ -49,23 +70,35 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket n.sender = p->from; } - // Close an old notification, if shown - dismiss(); - - // Check if we should display the notification - // A foreground applet might already be displaying this info - hasNotification = true; - currentNotification = n; - if (isApproved()) { - bringToForeground(); - inkhud->forceUpdate(); - } else - hasNotification = false; // Clear the pending notification: it was rejected + showNotification(n); // Return zero: no issues here, carry on notifying other observers! return 0; } +#if !MESHTASTIC_EXCLUDE_WAYPOINT +int InkHUD::NotificationApplet::onGeofenceEvent(const GeofenceNotificationEvent *event) +{ + assert(isActive()); + + if (!event) + return 0; + + Notification n; + n.type = Notification::Type::NOTIFICATION_GEOFENCE; + n.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); + n.geofenceWaypointId = event->waypointId; + strncpy(n.geofenceName, event->geofenceName, sizeof(n.geofenceName) - 1); + n.geofenceName[sizeof(n.geofenceName) - 1] = '\0'; + strncpy(n.geofenceNodeName, event->nodeName, sizeof(n.geofenceNodeName) - 1); + n.geofenceNodeName[sizeof(n.geofenceNodeName) - 1] = '\0'; + n.geofenceEntered = event->entered; + + showNotification(n); + return 0; +} +#endif + void InkHUD::NotificationApplet::onRender(bool full) { // Clear the region beneath the tile @@ -145,7 +178,10 @@ void InkHUD::NotificationApplet::onBackground() void InkHUD::NotificationApplet::onButtonShortPress() { - dismiss(); + if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) + openGeofenceOnMap(); + else + dismiss(); } void InkHUD::NotificationApplet::onButtonLongPress() @@ -180,6 +216,25 @@ void InkHUD::NotificationApplet::onNavLeft() void InkHUD::NotificationApplet::onNavRight() { + if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) + openGeofenceOnMap(); + else + dismiss(); +} + +void InkHUD::NotificationApplet::openGeofenceOnMap() +{ + for (uint8_t i = 0; i < inkhud->userApplets.size(); ++i) { + Applet *applet = inkhud->userApplets.at(i); + MapApplet *map = applet ? applet->asMapApplet() : nullptr; + if (!map || !applet->isActive() || !map->focusWaypoint(currentNotification.geofenceWaypointId)) + continue; + + dismiss(); + inkhud->showApplet(i); + return; + } + dismiss(); } @@ -267,6 +322,12 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila } } + else if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) { + text += currentNotification.geofenceNodeName; + text += currentNotification.geofenceEntered ? " IN " : " OUT "; + text += currentNotification.geofenceName; + } + // Parse any non-ascii characters and return return parse(text); } diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h index d398a36f32..02f6a89139 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h @@ -15,8 +15,10 @@ Feature should be optional; enable disable via on-screen menu #include "configuration.h" #include "concurrency/OSThread.h" - #include "graphics/niche/InkHUD/SystemApplet.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +struct GeofenceNotificationEvent; +#endif namespace NicheGraphics::InkHUD { @@ -39,6 +41,9 @@ class NotificationApplet : public SystemApplet void onNavRight() override; int onReceiveTextMessage(const meshtastic_MeshPacket *p); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + int onGeofenceEvent(const GeofenceNotificationEvent *event); +#endif bool isApproved(); // Does a foreground applet make notification redundant? void dismiss(); // Close the Notification Popup @@ -47,13 +52,19 @@ class NotificationApplet : public SystemApplet // Get notified when a new text message arrives CallbackObserver textMessageObserver = CallbackObserver(this, &NotificationApplet::onReceiveTextMessage); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + CallbackObserver geofenceObserver = + CallbackObserver(this, &NotificationApplet::onGeofenceEvent); +#endif + void showNotification(const Notification &n); + void openGeofenceOnMap(); std::string getNotificationText(uint16_t widthAvailable); // Get text for notification, to suit screen width - bool hasNotification = false; // Only used for assert. Todo: remove? + bool hasNotification = false; Notification currentNotification = Notification(); // Set when something notification-worthy happens. Used by render() }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h index 5cb20ed3b2..a5b675bf00 100644 --- a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h +++ b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h @@ -42,4 +42,4 @@ class PositionsApplet : public MapApplet, public SinglePortModule } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp new file mode 100644 index 0000000000..4023482b43 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp @@ -0,0 +1,572 @@ +#if defined(MESHTASTIC_INCLUDE_INKHUD) + +#include "./WaypointListApplet.h" + +#include "GeoCoord.h" +#include "NodeDB.h" +#include "RTC.h" +#include "WaypointStore.h" +#include "WaypointUtils.h" +#include "modules/WaypointModule.h" + +#include +#include +#include + +using namespace NicheGraphics; + +namespace +{ + +uint32_t fnv1aAppend(uint32_t hash, const char *text) +{ + while (*text) { + hash ^= (uint8_t)*text++; + hash *= 16777619u; + } + + return hash; +} + +} // namespace + +InkHUD::WaypointListApplet::WaypointListApplet() : concurrency::OSThread("WaypointListApplet") +{ + OSThread::disable(); +} + +void InkHUD::WaypointListApplet::onActivate() +{ + setInputsSubscribed(NAV_UP | NAV_DOWN, true); + scrollOffset = 0; + hasRenderHash = false; + waypointStoreObserver.observe(&waypointStore); + waypointStore.purgeExpired(); + updateRefreshTimer(); +} + +void InkHUD::WaypointListApplet::onDeactivate() +{ + waypointStoreObserver.unobserve(&waypointStore); + setInputsSubscribed(NAV_UP | NAV_DOWN, false); + OSThread::disable(); +} + +int32_t InkHUD::WaypointListApplet::runOnce() +{ + bool needsUpdate = false; + if (isActive()) + waypointStore.purgeExpired(); + + if (isActive() && !waypointStore.getWaypoints().empty()) { + const uint32_t renderHash = buildRenderHash(); + if (!hasRenderHash || renderHash != lastRenderHash) { + lastRenderHash = renderHash; + hasRenderHash = true; + needsUpdate = true; + } + + updateRefreshTimer(); + } + + if (isActive() && needsUpdate) + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + + return OSThread::interval; +} + +void InkHUD::WaypointListApplet::updateRefreshTimer() +{ + if (waypointStore.getWaypoints().empty()) { + OSThread::disable(); + return; + } + + OSThread::enabled = true; + OSThread::setIntervalFromNow(nextRefreshIntervalMs()); +} + +bool InkHUD::WaypointListApplet::hasDescription(const meshtastic_Waypoint &waypoint) +{ + return waypoint.description[0] != '\0'; +} + +uint8_t InkHUD::WaypointListApplet::rowHeight(const meshtastic_Waypoint &waypoint) +{ + const uint8_t lines = hasDescription(waypoint) ? 3 : 2; + return (fontSmall.lineHeight() * lines) + lines; +} + +uint8_t InkHUD::WaypointListApplet::visibleRows(uint8_t start) +{ + const auto &waypoints = waypointStore.getWaypoints(); + const int16_t contentTop = getHeaderHeight() + 2; + const uint16_t availableH = (height() > contentTop) ? (height() - contentTop) : 1; + if (waypoints.empty() || start >= waypoints.size()) + return 0; + + uint16_t usedH = 0; + uint8_t count = 0; + for (uint8_t i = start; i < waypoints.size(); ++i) { + const uint8_t nextH = rowHeight(waypoints.at(i).waypoint); + if (count > 0 && usedH + nextH > availableH) + break; + + usedH += nextH; + ++count; + + if (usedH >= availableH) + break; + } + + return count; +} + +uint8_t InkHUD::WaypointListApplet::maxScrollOffset() +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return 0; + + const int16_t contentTop = getHeaderHeight() + 2; + const uint16_t availableH = (height() > contentTop) ? (height() - contentTop) : 1; + uint16_t usedH = 0; + uint8_t start = (uint8_t)waypoints.size(); + + while (start > 0) { + const uint8_t nextH = rowHeight(waypoints.at(start - 1).waypoint); + if (usedH > 0 && usedH + nextH > availableH) + break; + + usedH += nextH; + --start; + + if (usedH >= availableH) + break; + } + + return start; +} + +bool InkHUD::WaypointListApplet::rowIndexAt(int16_t y, uint8_t &indexOut) +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return false; + + const uint8_t start = std::min(scrollOffset, (uint8_t)waypoints.size() - 1); + const uint8_t rows = visibleRows(start); + const uint8_t end = std::min((uint8_t)waypoints.size(), start + rows); + + // Walk the same row layout used by onRender, stopping at whichever row contains y + int16_t rowTop = getHeaderHeight() + 2; + for (uint8_t i = start; i < end; ++i) { + const uint8_t rowH = rowHeight(waypoints.at(i).waypoint); + if (y < rowTop + rowH) { + indexOut = i; + return true; + } + rowTop += rowH; + } + + return false; +} + +void InkHUD::WaypointListApplet::scrollBy(int delta) +{ + const int next = std::clamp((int)scrollOffset + delta, 0, maxScrollOffset()); + if (next == scrollOffset) + return; + + scrollOffset = (uint8_t)next; + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::WaypointListApplet::onNavUp() +{ + scrollBy(-1); +} + +void InkHUD::WaypointListApplet::onNavDown() +{ + scrollBy(1); +} + +bool InkHUD::WaypointListApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress) +{ + (void)x; + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty() || y < getHeaderHeight()) + return false; + + // Long press a row to delete that waypoint (broadcasts the deletion to the mesh too) + if (longPress) { + uint8_t index = 0; + if (!rowIndexAt(y, index)) + return false; + + if (waypointModule) + waypointModule->broadcastDelete(waypoints.at(index).waypoint.id); + return true; + } + + const uint16_t midpoint = getHeaderHeight() + ((height() - getHeaderHeight()) / 2); + scrollBy(y < midpoint ? -1 : 1); + return true; +} + +int InkHUD::WaypointListApplet::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + if (!isActive()) + return 0; + + syncListState(); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return 0; +} + +std::string InkHUD::WaypointListApplet::headerText() +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return "Waypoints"; + + const uint8_t rows = visibleRows(scrollOffset); + const uint8_t first = scrollOffset + 1; + const uint8_t last = std::min((uint8_t)waypoints.size(), scrollOffset + rows); + + char buf[32]; + snprintf(buf, sizeof(buf), "Waypoints %u-%u/%u", first, last, (unsigned)waypoints.size()); + return buf; +} + +std::string InkHUD::WaypointListApplet::waypointName(const meshtastic_Waypoint &waypoint) +{ + if (waypoint.name[0]) + return parse(waypoint.name); + + char buf[20]; + snprintf(buf, sizeof(buf), "Waypoint 0x%x", (unsigned)waypoint.id); + return buf; +} + +std::string InkHUD::WaypointListApplet::waypointDescription(const meshtastic_Waypoint &waypoint) +{ + if (!hasDescription(waypoint)) + return ""; + + return parse(waypoint.description); +} + +std::string InkHUD::WaypointListApplet::coordinateText(const meshtastic_Waypoint &waypoint, bool landscape) +{ + if (!waypoint.has_latitude_i || !waypoint.has_longitude_i) + return "--"; + + const uint8_t decimals = landscape ? (width() >= 220 ? 4 : 3) : (width() >= 140 ? 3 : 2); + const double lat = waypoint.latitude_i * 1e-7; + const double lon = waypoint.longitude_i * 1e-7; + + char buf[40]; + snprintf(buf, sizeof(buf), "%.*f,%.*f", decimals, lat, decimals, lon); + return buf; +} + +bool InkHUD::WaypointListApplet::tryGetOwnPosition(meshtastic_PositionLite &out) +{ + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + return ourNode && nodeDB->copyNodePosition(ourNode->num, out) && (out.latitude_i != 0 || out.longitude_i != 0); +} + +std::string InkHUD::WaypointListApplet::distanceText(const meshtastic_Waypoint &waypoint) +{ + if (!waypoint.has_latitude_i || !waypoint.has_longitude_i) + return ""; + + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; + if (!tryGetOwnPosition(ownPos)) + return ""; + + const float meters = GeoCoord::latLongToMeter(waypoint.latitude_i * 1e-7, waypoint.longitude_i * 1e-7, + ownPos.latitude_i * 1e-7, ownPos.longitude_i * 1e-7); + if (meters < 0) + return ""; + + return localizeDistance((uint32_t)std::lround(meters)); +} + +std::string InkHUD::WaypointListApplet::expireText(uint32_t expireEpoch) +{ + if (expireEpoch == 0) + return "--"; + + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now == 0) + return ""; + if (expireEpoch <= now) + return "exp"; + + const uint32_t left = expireEpoch - now; + char buf[12]; + if (left < 3600) + snprintf(buf, sizeof(buf), "%lum", (unsigned long)((left + 59) / 60)); + else if (left < 86400) + snprintf(buf, sizeof(buf), "%luh", (unsigned long)((left + 3599) / 3600)); + else + snprintf(buf, sizeof(buf), "%lud", (unsigned long)((left + 86399) / 86400)); + return buf; +} + +uint32_t InkHUD::WaypointListApplet::nextExpiryUpdateMs(uint32_t secondsLeft) +{ + if (secondsLeft < 60) + return secondsLeft * 1000UL; + + const uint32_t step = (secondsLeft < 3600) ? 60UL : (secondsLeft < 86400 ? 3600UL : 86400UL); + return ((((secondsLeft - 1) % step) + 1) * 1000UL); +} + +uint32_t InkHUD::WaypointListApplet::nextRefreshIntervalMs() +{ + static constexpr uint32_t WAITING_STATE_REFRESH_MS = 1000UL; + static constexpr uint32_t GPS_DISTANCE_REFRESH_MS = 5000UL; + static constexpr uint32_t IDLE_REFRESH_MS = 60000UL; + + uint32_t intervalMs = UINT32_MAX; + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; + const bool haveOwnPos = tryGetOwnPosition(ownPos); + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &waypoint = entry.waypoint; + if (waypoint.expire != 0) { + if (now == 0) { + intervalMs = std::min(intervalMs, WAITING_STATE_REFRESH_MS); + } else if (waypoint.expire > now) { + intervalMs = std::min(intervalMs, nextExpiryUpdateMs(waypoint.expire - now)); + } + } + + if (waypoint.has_latitude_i && waypoint.has_longitude_i) { + if (!haveOwnPos) { + intervalMs = std::min(intervalMs, WAITING_STATE_REFRESH_MS); + } else if (!config.position.fixed_position) { + intervalMs = std::min(intervalMs, GPS_DISTANCE_REFRESH_MS); + } + } + } + + if (intervalMs == UINT32_MAX) + return IDLE_REFRESH_MS; + + return intervalMs; +} + +uint32_t InkHUD::WaypointListApplet::buildRenderHash() +{ + uint32_t hash = 2166136261u; + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &waypoint = entry.waypoint; + char idBuf[11]; + snprintf(idBuf, sizeof(idBuf), "%lu", (unsigned long)waypoint.id); + hash = fnv1aAppend(hash, idBuf); + hash = fnv1aAppend(hash, "|"); + + const std::string distance = distanceText(waypoint); + hash = fnv1aAppend(hash, distance.c_str()); + hash = fnv1aAppend(hash, "|"); + + const std::string expire = expireText(waypoint.expire); + hash = fnv1aAppend(hash, expire.c_str()); + hash = fnv1aAppend(hash, ";"); + } + + return hash; +} + +void InkHUD::WaypointListApplet::syncListState() +{ + scrollOffset = std::min(scrollOffset, maxScrollOffset()); + hasRenderHash = false; + // Re-arm the timer whenever visible waypoint state changes. + updateRefreshTimer(); +} + +bool InkHUD::WaypointListApplet::canRenderWaypointIcon(const meshtastic_Waypoint &waypoint, std::string *mapped) +{ + if (!waypoint.icon) + return false; + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(waypoint.icon); + if (utf8.empty()) + return false; + + const std::string glyph = getFont().decodeUTF8(utf8); + if (glyph.size() != 1 || glyph[0] == '\x1A' || glyph[0] == '\x7F') + return false; + + if (mapped) + *mapped = glyph; + return true; +} + +uint8_t InkHUD::WaypointListApplet::fallbackBadgeNumber(const meshtastic_Waypoint &waypoint) +{ + uint8_t badge = 0; + + for (auto it = waypointStore.getWaypoints().rbegin(); it != waypointStore.getWaypoints().rend(); ++it) { + const meshtastic_Waypoint &candidate = it->waypoint; + if (canRenderWaypointIcon(candidate)) + continue; + + if (candidate.id == waypoint.id) + return badge; + + if (badge < 9) + ++badge; + } + + return 0; +} + +bool InkHUD::WaypointListApplet::drawWaypointIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t centerY, + uint16_t boxSize) +{ + std::string mappedGlyph; + if (!canRenderWaypointIcon(waypoint, &mappedGlyph)) + return false; + + printAt(left + (boxSize / 2), centerY, mappedGlyph, CENTER, MIDDLE); + return true; +} + +void InkHUD::WaypointListApplet::drawFallbackIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t rowTop, + uint16_t boxWidth, uint16_t rowHeight) +{ + char badgeText[3]; + snprintf(badgeText, sizeof(badgeText), "%u", (unsigned)fallbackBadgeNumber(waypoint)); + + setFont(fontSmall); + + const int16_t cx = left + (boxWidth / 2); + const uint16_t badgeTextW = std::max(getTextWidth(badgeText), getTextWidth("0")); + const int16_t centerY = rowTop + (fontSmall.lineHeight() / 2) + 1; + const int16_t boxSize = std::max((int16_t)boxWidth, (int16_t)(badgeTextW + 5)); + const int16_t radius = std::max(2, boxSize / 6); + const int16_t boxLeft = cx - (boxSize / 2); + const int16_t boxTop = centerY - (boxSize / 2); + + // Match the boxed fallback marker style used on the map. + fillRoundRect(boxLeft, boxTop, boxSize, boxSize, radius, WHITE); + drawRoundRect(boxLeft, boxTop, boxSize, boxSize, radius, BLACK); + + setCrop(left - 1, rowTop, boxWidth + 3, rowHeight); + printAt(cx, centerY + 1, badgeText, CENTER, MIDDLE); + resetCrop(); +} + +void InkHUD::WaypointListApplet::onRender(bool full) +{ + (void)full; + + const bool landscape = width() > height(); + const auto &waypoints = waypointStore.getWaypoints(); + drawHeader(headerText()); + + if (waypoints.empty()) { + setFont(fontMedium); + printAt(X(0.5f), Y(0.5f), "No Waypoints", CENTER, MIDDLE); + return; + } + + setFont(fontSmall); + + const int16_t contentTop = getHeaderHeight() + 2; + const uint8_t start = std::min(scrollOffset, (uint8_t)waypoints.size() - 1); + const uint8_t rows = visibleRows(start); + const uint8_t end = std::min((uint8_t)waypoints.size(), start + rows); + const uint16_t iconW = fontSmall.lineHeight(); + const uint16_t gap = 2; + + auto ellipsizeToWidth = [this](std::string text, uint16_t maxWidth) { + constexpr const char *ellipsis = "..."; + const uint16_t ellipsisW = getTextWidth(ellipsis); + uint16_t textW = getTextWidth(text); + if (maxWidth == 0) + return std::string(); + if (textW <= maxWidth) + return text; + if (ellipsisW > maxWidth) + return std::string(); + while (!text.empty() && (textW + ellipsisW > maxWidth)) { + text.pop_back(); + textW = getTextWidth(text); + } + return text + ellipsis; + }; + + int16_t rowTop = contentTop; + for (uint8_t i = start; i < end; ++i) { + const meshtastic_Waypoint &waypoint = waypoints.at(i).waypoint; + const uint8_t rowH = rowHeight(waypoint); + const int16_t line1Y = rowTop + (fontSmall.lineHeight() / 2) + 1; + const int16_t line2Y = rowTop + fontSmall.lineHeight() + 1; + const int16_t metaY = + rowTop + (hasDescription(waypoint) ? ((fontSmall.lineHeight() * 2) + 2) : (fontSmall.lineHeight() + 1)); + + if (!drawWaypointIcon(waypoint, 1, line1Y, iconW - 1)) + drawFallbackIcon(waypoint, 0, rowTop, iconW, rowH); + + const std::string name = waypointName(waypoint); + const std::string description = waypointDescription(waypoint); + const std::string distance = distanceText(waypoint); + const std::string coord = coordinateText(waypoint, landscape); + const std::string expire = expireText(waypoint.expire); + + const int16_t nameLeft = iconW + gap; + int16_t nameRight = width() - 1; + if (!distance.empty()) { + printAt(nameRight, line1Y, distance, RIGHT, MIDDLE); + nameRight -= getTextWidth(distance) + gap; + } + + const uint16_t nameWidth = (nameRight >= nameLeft) ? ((nameRight - nameLeft) + 1) : 0; + const std::string shown = ellipsizeToWidth(name, nameWidth); + const uint16_t shownWidth = getTextWidth(shown); + setCrop(nameLeft, rowTop, nameWidth, fontSmall.lineHeight() + 2); + printThick(nameLeft + (shownWidth / 2), line1Y, shown, 2, 1); + resetCrop(); + + if (!description.empty()) { + const std::string descShown = ellipsizeToWidth(description, nameWidth); + setCrop(nameLeft, line2Y - 1, nameWidth, fontSmall.lineHeight() + 2); + printAt(nameLeft, line2Y, descShown, LEFT, TOP); + resetCrop(); + } + + int16_t metaRight = width() - 1; + if (!expire.empty()) { + printAt(metaRight, metaY, expire, RIGHT, TOP); + metaRight -= getTextWidth(expire) + gap; + } + + const uint16_t coordWidth = (metaRight >= nameLeft) ? ((metaRight - nameLeft) + 1) : 0; + const std::string coordShown = ellipsizeToWidth(coord, coordWidth); + setCrop(nameLeft, metaY - 1, coordWidth, fontSmall.lineHeight() + 2); + printAt(nameLeft, metaY, coordShown, LEFT, TOP); + resetCrop(); + + const int16_t separatorY = rowTop + rowH - 1; + if (separatorY < height() - 1 && i + 1 < end) { + for (int16_t x = 0; x < width(); x += 2) + drawPixel(x, separatorY, BLACK); + } + + rowTop += rowH; + } +} + +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h new file mode 100644 index 0000000000..73bfd26e3d --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h @@ -0,0 +1,78 @@ +#if defined(MESHTASTIC_INCLUDE_INKHUD) + +#pragma once + +#include "configuration.h" + +#include "Observer.h" +#include "WaypointStore.h" +#include "concurrency/OSThread.h" +#include "graphics/niche/InkHUD/Applet.h" +#include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "mesh/generated/meshtastic/mesh.pb.h" + +#include + +namespace NicheGraphics::InkHUD +{ + +class WaypointListApplet : public Applet, public concurrency::OSThread +{ + public: + WaypointListApplet(); + + void onActivate() override; + void onDeactivate() override; + void onRender(bool full) override; + void onNavUp() override; + void onNavDown() override; + bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override; + + WaypointListApplet *asWaypointListApplet() override { return this; } // Identify as WaypointListApplet without RTTI + + // Read-only access for MenuApplet's "Remove Waypoint" page + size_t waypointCount() const { return waypointStore.getWaypoints().size(); } + uint32_t waypointIdAt(size_t index) const { return waypointStore.getWaypoints().at(index).waypoint.id; } + std::string waypointLabelAt(size_t index) { return waypointName(waypointStore.getWaypoints().at(index).waypoint); } + + protected: + int32_t runOnce() override; + + private: + void updateRefreshTimer(); + uint8_t visibleRows(uint8_t start); + uint8_t rowHeight(const meshtastic_Waypoint &waypoint); + uint8_t maxScrollOffset(); + void scrollBy(int delta); + bool rowIndexAt(int16_t y, uint8_t &indexOut); // Which waypoint row is at this y, if any + bool tryGetOwnPosition(meshtastic_PositionLite &out); + uint32_t nextExpiryUpdateMs(uint32_t secondsLeft); + uint32_t nextRefreshIntervalMs(); + uint32_t buildRenderHash(); + void syncListState(); + + std::string headerText(); + std::string waypointName(const meshtastic_Waypoint &waypoint); + std::string waypointDescription(const meshtastic_Waypoint &waypoint); + std::string coordinateText(const meshtastic_Waypoint &waypoint, bool landscape); + std::string distanceText(const meshtastic_Waypoint &waypoint); + std::string expireText(uint32_t expireEpoch); + bool canRenderWaypointIcon(const meshtastic_Waypoint &waypoint, std::string *mapped = nullptr); + uint8_t fallbackBadgeNumber(const meshtastic_Waypoint &waypoint); + bool drawWaypointIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t centerY, uint16_t boxSize); + void drawFallbackIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t rowTop, uint16_t boxWidth, + uint16_t rowHeight); + bool hasDescription(const meshtastic_Waypoint &waypoint); + + int onWaypointStoreChanged(const WaypointStore *store); + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &WaypointListApplet::onWaypointStoreChanged); + + uint8_t scrollOffset = 0; + uint32_t lastRenderHash = 0; + bool hasRenderHash = false; +}; + +} // namespace NicheGraphics::InkHUD + +#endif diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index 1a5e438d93..54d061fa90 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -4,6 +4,8 @@ #include "MessageStore.h" #include "PowerFSM.h" +#include "UptimeClock.h" +#include "WaypointStore.h" #include "buzz.h" #include "gps/RTC.h" #include "modules/ExternalNotificationModule.h" @@ -364,7 +366,7 @@ void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress) // A long-press used to open the menu can be followed by a synthetic/queued tap at release. // Ignore that brief follow-up window so touch-opened menus do not auto-select an item. if (touchEnabledBuild && !longPress && suppressTouchTapUntilMs != 0) { - if ((int32_t)(millis() - suppressTouchTapUntilMs) < 0) { + if ((int32_t)(Time::getMillis() - suppressTouchTapUntilMs) < 0) { noteInkHUDUserInteraction(); return; } @@ -400,7 +402,7 @@ void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress) // Only arm suppression if the long-press actually opened menu foreground. SystemApplet *menu = inkhud->getSystemApplet("Menu"); if (touchEnabledBuild && menu && menu->isForeground()) { - suppressTouchTapUntilMs = millis() + TOUCH_MENU_OPEN_TAP_SUPPRESS_MS; + suppressTouchTapUntilMs = Time::timerEndsAtMillis(TOUCH_MENU_OPEN_TAP_SUPPRESS_MS); } } else onButtonShort(); @@ -468,6 +470,7 @@ int InkHUD::Events::beforeDeepSleep(void *unused) inkhud->persistence->saveSettings(); inkhud->persistence->saveLatestMessage(); + waypointStore.saveToFlash(); // LogoApplet::onShutdown attempted to heal the display by drawing a "shutting down" screen twice, // then prepared a final powered-off screen for us, which shows device shortname. @@ -516,6 +519,7 @@ int InkHUD::Events::beforeReboot(void *unused) } else { NicheGraphics::clearFlashData(); messageStore.clearAllMessages(); // also wipe the shared message store + waypointStore.clearAllWaypoints(); } // Note: no forceUpdate call here diff --git a/src/input/ExpressLRSFiveWay.cpp b/src/input/ExpressLRSFiveWay.cpp index e9efeda52e..7d1e4de639 100644 --- a/src/input/ExpressLRSFiveWay.cpp +++ b/src/input/ExpressLRSFiveWay.cpp @@ -80,7 +80,7 @@ void ExpressLRSFiveWay::update(int *keyValue, bool *keyLongPressed) if (keyInProcess == NO_PRESS) { // New key down if (newKey != NO_PRESS) { - keyDownStart = Time::getMillis(); + keyDownStart = Time::skipZero(Time::getMillis()); // DBGLN("down=%u", newKey); } } else { diff --git a/src/input/HapticFeedback.cpp b/src/input/HapticFeedback.cpp index fcd215be06..d345c9cd4d 100644 --- a/src/input/HapticFeedback.cpp +++ b/src/input/HapticFeedback.cpp @@ -4,6 +4,8 @@ #include +#include "UptimeClock.h" + #ifdef HAPTIC_FEEDBACK_ACTIVE_LOW #define HAPTIC_FEEDBACK_ON_STATE LOW #define HAPTIC_FEEDBACK_OFF_STATE HIGH @@ -34,17 +36,13 @@ void HapticFeedback::motorWrite(bool on) void HapticFeedback::pulse(uint16_t durationMs) { motorWrite(true); - pulseOffAt = millis() + durationMs; - if (pulseOffAt == 0) // 0 is the "no pulse" sentinel - pulseOffAt = 1; + pulseOffAt = Time::timerEndsAtMillis(durationMs); scheduleNext(); } void HapticFeedback::armDelayedPulse(uint16_t delayMs, uint16_t durationMs) { - delayedPulseAt = millis() + delayMs; - if (delayedPulseAt == 0) - delayedPulseAt = 1; + delayedPulseAt = Time::timerEndsAtMillis(delayMs); delayedPulseDuration = durationMs; scheduleNext(); } @@ -56,7 +54,7 @@ void HapticFeedback::cancelDelayedPulse() void HapticFeedback::scheduleNext() { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); uint32_t next = 0; if (pulseOffAt != 0) next = pulseOffAt; @@ -70,7 +68,7 @@ void HapticFeedback::scheduleNext() int32_t HapticFeedback::runOnce() { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); if (pulseOffAt != 0 && (int32_t)(now - pulseOffAt) >= 0) { motorWrite(false); diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 8ea9af2bb3..30c1bd60e1 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -47,7 +47,7 @@ static bool touchBacklightActive = false; #endif #endif -#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) +#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(MUZI_BASE) ButtonThread *UserButtonThread = nullptr; #endif @@ -504,11 +504,35 @@ void InputBroker::Init() } #endif #if !MESHTASTIC_EXCLUDE_INPUTBROKER && HAS_TRACKBALL - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + if (screen && config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { trackballInterruptImpl1 = new TrackballInterruptImpl1(); trackballInterruptImpl1->init(TB_DOWN, TB_UP, TB_LEFT, TB_RIGHT, TB_PRESS); } #endif +#if MUZI_BASE + if (!screen) { + UserButtonThread = new ButtonThread("UserButton"); + ButtonConfig userConfigNoScreen; + userConfigNoScreen.pinNumber = (uint8_t)TB_PRESS; + userConfigNoScreen.activeLow = true; + userConfigNoScreen.activePullup = true; + userConfigNoScreen.pullupSense = pullup_sense; + userConfigNoScreen.intRoutine = []() { + UserButtonThread->userButton.tick(); + UserButtonThread->setIntervalFromNow(0); + runASAP = true; + BaseType_t higherWake = 0; + concurrency::mainDelay.interruptFromISR(&higherWake); + }; + userConfigNoScreen.singlePress = INPUT_BROKER_USER_PRESS; + userConfigNoScreen.longPress = INPUT_BROKER_NONE; + userConfigNoScreen.longPressTime = 500; + userConfigNoScreen.longLongPress = INPUT_BROKER_SHUTDOWN; + userConfigNoScreen.doublePress = INPUT_BROKER_SEND_PING; + userConfigNoScreen.triplePress = INPUT_BROKER_GPS_TOGGLE; + UserButtonThread->initButton(userConfigNoScreen); + } +#endif #ifdef INPUTBROKER_EXPRESSLRSFIVEWAY_TYPE expressLRSFiveWayInput = new ExpressLRSFiveWay(); #endif diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp index f6848a5908..d8f99ccec0 100644 --- a/src/input/LinuxJoystick.cpp +++ b/src/input/LinuxJoystick.cpp @@ -138,6 +138,7 @@ int32_t LinuxJoystick::runOnce() heldX = zone; if (zone != 0) { emitEvent((zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT); + // unset-sentinel-ok: heldX carries the armed state, so 0 is a legal deadline nextRepeatX = millis() + JOY_REPEAT_DELAY_MS; } } @@ -147,6 +148,7 @@ int32_t LinuxJoystick::runOnce() heldY = zone; if (zone != 0) { emitEvent((zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN); + // unset-sentinel-ok: heldY carries the armed state, so 0 is a legal deadline nextRepeatY = millis() + JOY_REPEAT_DELAY_MS; } } @@ -165,10 +167,12 @@ int32_t LinuxJoystick::runOnce() uint32_t now = millis(); if (heldX != 0 && (int32_t)(now - nextRepeatX) >= 0) { emitEvent((heldX < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT); + // unset-sentinel-ok: heldX carries the armed state, so 0 is a legal deadline nextRepeatX = now + JOY_REPEAT_INTERVAL_MS; } if (heldY != 0 && (int32_t)(now - nextRepeatY) >= 0) { emitEvent((heldY < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN); + // unset-sentinel-ok: heldY carries the armed state, so 0 is a legal deadline nextRepeatY = now + JOY_REPEAT_INTERVAL_MS; } diff --git a/src/input/MCP23017Keyboard.cpp b/src/input/MCP23017Keyboard.cpp index 235caac626..223ff1cd99 100644 --- a/src/input/MCP23017Keyboard.cpp +++ b/src/input/MCP23017Keyboard.cpp @@ -1,4 +1,5 @@ #include "MCP23017Keyboard.h" +#include "mesh/Throttle.h" // Registers #define _MCP23017_IODIRA 0x00 @@ -53,18 +54,17 @@ #define MCP23017_KEYMAP_12 12 #define MCP23017_KEYMAP_13 13 #define MCP23017_KEYMAP_14 14 -#define MCP23017_KEYMAP_15 15 + +#endif #define LONG_PRESS_THRESHOLD 1000 #define MULTI_TAP_THRESHOLD 2000 -#endif - // Num chars per key -uint8_t MCP23017_TapMod[15] = {1, 6, 6, 6, 6, 6, 8, 6, 8, 1, 1, 1, 1, 1, 1}; +static const uint8_t MCP23017_TapMod[_NUM_KEYS] = {1, 6, 6, 6, 6, 6, 8, 6, 8, 1, 1, 1, 1, 1, 1}; // Tap Map -static const unsigned char MCP23017_TapMap[15][13] = { +static const unsigned char MCP23017_TapMap[_NUM_KEYS][8] = { {' '}, // 0: K_1 {'a', 'b', 'c', 'A', 'B', 'C'}, // 1: K_2 {'d', 'e', 'f', 'D', 'E', 'F'}, // 2: K_3 @@ -83,7 +83,7 @@ static const unsigned char MCP23017_TapMap[15][13] = { }; // Long Press map -static const unsigned char MCP23017_LongPressMap[15] = { +static const unsigned char MCP23017_LongPressMap[_NUM_KEYS] = { '1', // 0: K_1 '2', // 1: K_2 '3', // 2: K_3 @@ -102,10 +102,10 @@ static const unsigned char MCP23017_LongPressMap[15] = { }; // Bit position to logical index translation (0-14) -uint8_t MCP23017_KeyMap[16] = {MCP23017_KEYMAP_0, MCP23017_KEYMAP_1, MCP23017_KEYMAP_2, MCP23017_KEYMAP_3, - MCP23017_KEYMAP_4, MCP23017_KEYMAP_5, MCP23017_KEYMAP_6, MCP23017_KEYMAP_7, - MCP23017_KEYMAP_8, MCP23017_KEYMAP_9, MCP23017_KEYMAP_10, MCP23017_KEYMAP_11, - MCP23017_KEYMAP_12, MCP23017_KEYMAP_13, MCP23017_KEYMAP_14, MCP23017_KEYMAP_15}; +static const uint8_t MCP23017_KeyMap[_NUM_KEYS] = {MCP23017_KEYMAP_0, MCP23017_KEYMAP_1, MCP23017_KEYMAP_2, MCP23017_KEYMAP_3, + MCP23017_KEYMAP_4, MCP23017_KEYMAP_5, MCP23017_KEYMAP_6, MCP23017_KEYMAP_7, + MCP23017_KEYMAP_8, MCP23017_KEYMAP_9, MCP23017_KEYMAP_10, MCP23017_KEYMAP_11, + MCP23017_KEYMAP_12, MCP23017_KEYMAP_13, MCP23017_KEYMAP_14}; MCP23017Keyboard::MCP23017Keyboard() : m_wire(nullptr), m_addr(0), readCallback(nullptr), writeCallback(nullptr) { @@ -135,7 +135,7 @@ void MCP23017Keyboard::begin(i2c_com_fptr_t r, i2c_com_fptr_t w, uint8_t addr) void MCP23017Keyboard::reset() { - LOG_DEBUG("MCP23017 Reset\n"); + LOG_DEBUG("MCP23017 Reset"); // Configure I/O writeRegister(_MCP23017_IODIRA, 0xFF); // All set as inputs writeRegister(_MCP23017_IODIRB, 0xFF); @@ -191,7 +191,7 @@ uint8_t MCP23017Keyboard::keyCount(uint16_t value) const { uint16_t buttonState = value & _KEY_MASK; uint8_t numButtonsPressed = 0; - for (uint8_t i = 0; i < 15; ++i) { + for (uint8_t i = 0; i < _NUM_KEYS; ++i) { if (buttonState & (1 << i)) { numButtonsPressed++; } @@ -263,7 +263,7 @@ void MCP23017Keyboard::pressed(uint16_t keyRegister) uint16_t buttonState = keyRegister & _KEY_MASK; uint8_t next_pin = 0; - for (uint8_t i = 0; i < 15; ++i) { + for (uint8_t i = 0; i < _NUM_KEYS; ++i) { if (buttonState & (1 << i)) { next_pin = i; break; @@ -271,24 +271,17 @@ void MCP23017Keyboard::pressed(uint16_t keyRegister) } uint8_t next_key = MCP23017_KeyMap[next_pin]; - uint32_t now = millis(); - int32_t tap_interval = now - last_tap; - if (tap_interval < 0) { - // long running, millis has overflowed. - last_tap = 0; - state = Busy; - return; - } - - if (next_key != last_key || tap_interval > MULTI_TAP_THRESHOLD) { + // A different key, or the same one after the multi-tap window closed, starts a fresh + // character rather than advancing through the tap map. + if (next_key != last_key || Throttle::hasElapsed(last_tap, MULTI_TAP_THRESHOLD)) { char_idx = 0; } else { char_idx += 1; } last_key = next_key; - last_tap = now; + last_tap = millis(); state = Held; return; } @@ -300,10 +293,9 @@ void MCP23017Keyboard::held(uint16_t keyRegister) if (keyCount(keyRegister) != 1) return; - LOG_DEBUG("Held"); uint16_t buttonState = keyRegister & _KEY_MASK; uint8_t next_pin = 0; - for (uint8_t i = 0; i < 15; ++i) { + for (uint8_t i = 0; i < _NUM_KEYS; ++i) { if (buttonState & (1 << i)) { next_pin = i; break; @@ -311,19 +303,19 @@ void MCP23017Keyboard::held(uint16_t keyRegister) } uint8_t next_key = MCP23017_KeyMap[next_pin]; - uint32_t now = millis(); - int32_t held_interval = now - last_tap; - if (held_interval < 0 || next_key != last_key) { + // last_key indexes the long-press map below; a CUSTOM_MCP23017_MAP entry outside the + // key range would run off the end of it. + if (last_key >= _NUM_KEYS || next_key != last_key) { last_tap = 0; state = Busy; return; } - if (held_interval > LONG_PRESS_THRESHOLD) { + if (Throttle::hasElapsed(last_tap, LONG_PRESS_THRESHOLD)) { state = HeldLong; queueEvent(MCP23017_LongPressMap[last_key]); - last_tap = now; + last_tap = millis(); } } diff --git a/src/input/MCP23017Keyboard.h b/src/input/MCP23017Keyboard.h index 1f592283d2..a62f6252de 100644 --- a/src/input/MCP23017Keyboard.h +++ b/src/input/MCP23017Keyboard.h @@ -4,8 +4,6 @@ #include #include -#define MCP23017_KB_ADDR 0x20 // Default address (A0, A1 y A2 to GND) - class MCP23017Keyboard { public: diff --git a/src/input/RotaryEncoderInterruptBase.cpp b/src/input/RotaryEncoderInterruptBase.cpp index c177403bf0..20e40d58e7 100644 --- a/src/input/RotaryEncoderInterruptBase.cpp +++ b/src/input/RotaryEncoderInterruptBase.cpp @@ -1,4 +1,5 @@ #include "RotaryEncoderInterruptBase.h" +#include "UptimeClock.h" #include "configuration.h" RotaryEncoderInterruptBase::RotaryEncoderInterruptBase(const char *name) : concurrency::OSThread(name) @@ -48,13 +49,14 @@ int32_t RotaryEncoderInterruptBase::runOnce() InputEvent e = {}; e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); // Handle press long/short detection if (this->action == ROTARY_ACTION_PRESSED) { bool buttonPressed = !digitalRead(_pinPress); if (!pressDetected && buttonPressed) { pressDetected = true; + // unset-sentinel-ok: pressDetected is the armed flag; no read tests the stamp against 0 pressStartTime = now; pressAndTurnFired = false; } diff --git a/src/input/TouchScreenBase.cpp b/src/input/TouchScreenBase.cpp index 8512300f71..0770e7aec8 100644 --- a/src/input/TouchScreenBase.cpp +++ b/src/input/TouchScreenBase.cpp @@ -1,5 +1,6 @@ #include "TouchScreenBase.h" #include "main.h" +#include "mesh/Throttle.h" #if defined(RAK14014) && !defined(MESHTASTIC_EXCLUDE_CANNEDMESSAGES) #include "modules/CannedMessageModule.h" @@ -9,6 +10,12 @@ #define TIME_LONG_PRESS 400 #endif +// The deferred-tap window is `TIME_LONG_PRESS - 50`, unsigned: below 50 it underflows to ~49.7 days. +static_assert(TIME_LONG_PRESS >= 50, "TIME_LONG_PRESS must be at least 50ms: see the deferred-tap window below"); + +// How long a held finger stays suppressed after a LONG_PRESS is reported. +#define LONG_PRESS_REPEAT_SUPPRESS_MS 30000 + // Touch sampling cadence (milliseconds). // Can be overridden by board variants for faster touch panels. #ifndef TOUCH_POLL_INTERVAL_IDLE @@ -49,7 +56,8 @@ TouchScreenBase::TouchScreenBase(const char *name, uint16_t width, uint16_t height) : concurrency::OSThread(name), _display_width(width), _display_height(height), _first_x(0), _last_x(0), _first_y(0), - _last_y(0), _start(0), _lastTouchSeenMs(0), _tapped(false), _originName(name) + _last_y(0), _pressStartMs(0), _longPressSuppressed(false), _longPressSuppressUntilMs(0), _lastTouchSeenMs(0), + _tapped(false), _originName(name) { } @@ -95,12 +103,13 @@ int32_t TouchScreenBase::runOnce() if (touched) { hapticFeedback(); _state = TOUCH_EVENT_OCCURRED; - _start = millis(); + _pressStartMs = nowMs; + _longPressSuppressed = false; _first_x = x; _first_y = y; } else { _state = TOUCH_EVENT_CLEARED; - time_t duration = millis() - _start; + uint32_t duration = nowMs - _pressStartMs; x = _last_x; y = _last_y; this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_RELEASE_FAST : TOUCH_POLL_INTERVAL_RELEASE); @@ -157,7 +166,7 @@ int32_t TouchScreenBase::runOnce() LOG_DEBUG("action TAP(%d/%d)", _last_x, _last_y); } } else { - if (_tapped && (time_t(millis()) - _start) > TIME_LONG_PRESS - 50) { + if (_tapped && Throttle::hasElapsed(_pressStartMs, TIME_LONG_PRESS - 50)) { _tapped = false; e.touchEvent = static_cast(TOUCH_ACTION_TAP); LOG_DEBUG("action TAP(%d/%d)", _last_x, _last_y); @@ -173,9 +182,13 @@ int32_t TouchScreenBase::runOnce() #endif // fire LONG_PRESS event without the need for release - if (allowLongPress && touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) { - // tricky: prevent reoccurring events and another touch event when releasing - _start = millis() + 30000; + // Armed and expired are asked separately; folding the deadline into the press stamp repeated + // LONG_PRESS every poll across the wrap on 64-bit time_t hosts. + const bool longPressSuppressed = _longPressSuppressed && !Throttle::deadlinePassed(_longPressSuppressUntilMs); + if (allowLongPress && touched && !longPressSuppressed && Throttle::hasElapsed(_pressStartMs, TIME_LONG_PRESS)) { + // A finger held past the window re-reports LONG_PRESS once per window, as before. + _longPressSuppressed = true; + _longPressSuppressUntilMs = nowMs + LONG_PRESS_REPEAT_SUPPRESS_MS; e.touchEvent = static_cast(TOUCH_ACTION_LONG_PRESS); LOG_DEBUG("action LONG PRESS(%d/%d)", _last_x, _last_y); } diff --git a/src/input/TouchScreenBase.h b/src/input/TouchScreenBase.h index 91ec165ec7..67667232a2 100644 --- a/src/input/TouchScreenBase.h +++ b/src/input/TouchScreenBase.h @@ -50,10 +50,15 @@ class TouchScreenBase : public Observable, public concurrenc bool _touchedOld = false; // previous touch state int16_t _first_x, _last_x; // horizontal swipe direction int16_t _first_y, _last_y; // vertical swipe direction - time_t _start; // for LONG_PRESS - uint32_t _lastTouchSeenMs; // helps suppress brief touch-controller dropouts - bool _tapped; // for DOUBLE_TAP - uint32_t _lastRun = 0; // helps suppress too fast consecutive runOnce() executions + uint32_t _pressStartMs; // when the current touch began; read via Throttle::hasElapsed() + + // LONG_PRESS repeat suppression while one touch is held: the bool is the armed flag, the + // deadline is read only while it is set. No value of the deadline can mean "unarmed". + bool _longPressSuppressed; + uint32_t _longPressSuppressUntilMs; // meaningful only while _longPressSuppressed + uint32_t _lastTouchSeenMs; // helps suppress brief touch-controller dropouts + bool _tapped; // for DOUBLE_TAP + uint32_t _lastRun = 0; // helps suppress too fast consecutive runOnce() executions const char *_originName; }; diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index 1bbe756296..e30cddd90e 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -1,11 +1,48 @@ #include "TrackballInterruptBase.h" #include "Throttle.h" +#include "UptimeClock.h" #include "configuration.h" extern bool osk_found; TrackballInterruptBase::TrackballInterruptBase(const char *name) : concurrency::OSThread(name), _originName(name) {} +TrackballInterruptBase::PressResult TrackballInterruptBase::updatePress(bool irqLatched, uint32_t irqTimeMs, bool pinLow) +{ + if (pressDetected) { + if (!pinLow) { + const bool wasShort = Throttle::isWithinTimespanMs(pressStartTime, LONG_PRESS_DURATION); + pressDetected = false; + pressStartTime = 0; + lastLongPressEventTime = 0; + longPressRepeatSent = false; + return wasShort ? PressResult::Short : PressResult::None; + } + // Tracked by a flag, not by lastLongPressEventTime == 0, which is a valid instant at rollover. + if (Throttle::hasElapsed(pressStartTime, LONG_PRESS_DURATION) && + (!longPressRepeatSent || Throttle::hasElapsed(lastLongPressEventTime, LONG_PRESS_REPEAT_INTERVAL))) { + lastLongPressEventTime = Time::getMillis(); + longPressRepeatSent = true; + return PressResult::LongRepeat; + } + return PressResult::None; + } + + if (!irqLatched) + return PressResult::None; + + // Already released by the time we polled: classify from the latched interrupt time, since a + // delayed poll can hide a long hold behind the same latch. + if (!pinLow) + return Throttle::isWithinTimespanMs(irqTimeMs, LONG_PRESS_DURATION) ? PressResult::Short : PressResult::None; + + pressDetected = true; + pressStartTime = irqTimeMs; + lastLongPressEventTime = 0; + longPressRepeatSent = false; + return PressResult::None; +} + void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, input_broker_event eventRight, input_broker_event eventPressed, @@ -72,33 +109,21 @@ int32_t TrackballInterruptBase::runOnce() #endif #endif - // Handle long press detection for press button - if (pressDetected && pressStartTime > 0) { - uint32_t pressDuration = millis() - pressStartTime; - bool buttonStillPressed = false; - - buttonStillPressed = !digitalRead(_pinPress); - - if (!buttonStillPressed) { - // Button released - if (pressDuration < LONG_PRESS_DURATION) { - // Short press - e.inputEvent = this->_eventPressed; - } - // Reset state - pressDetected = false; - pressStartTime = 0; - lastLongPressEventTime = 0; - this->action = TB_ACTION_NONE; - } else if (pressDuration >= LONG_PRESS_DURATION) { - // Long press detected - uint32_t currentTime = millis(); - // Only trigger long press event if enough time has passed since the last one - if (lastLongPressEventTime == 0 || (currentTime - lastLongPressEventTime) >= LONG_PRESS_REPEAT_INTERVAL) { - e.inputEvent = this->_eventPressedLong; - lastLongPressEventTime = currentTime; - } - this->action = TB_ACTION_PRESSED_LONG; + bool pressLatched = false; + if (_pinPress != 255) { + const uint32_t irqSeq = pressIrqSeq; + const uint32_t irqTimeMs = pressIrqTime; + pressLatched = irqSeq != pressIrqSeen; + pressIrqSeen = irqSeq; + switch (updatePress(pressLatched, irqTimeMs, !digitalRead(_pinPress))) { + case PressResult::Short: + e.inputEvent = this->_eventPressed; + break; + case PressResult::LongRepeat: + e.inputEvent = this->_eventPressedLong; + break; + case PressResult::None: + break; } } @@ -125,8 +150,9 @@ int32_t TrackballInterruptBase::runOnce() directionStartTime = 0; directionInterval = 0; this->action = TB_ACTION_NONE; - } else if (directionDuration >= LONG_PRESS_DURATION && directionInterval >= DIRECTION_REPEAT_THRESHOLD) { - // repeat event when long press these direction. + } else if (directionDuration >= LONG_PRESS_DURATION && directionInterval >= DIRECTION_REPEAT_THRESHOLD && + e.inputEvent == INPUT_BROKER_NONE) { + // repeat event when long press these direction, unless a press event already claimed this poll. switch (directionPressedNow) { case TB_ACTION_UP: e.inputEvent = this->_eventUp; @@ -147,55 +173,52 @@ int32_t TrackballInterruptBase::runOnce() } #if TB_THRESHOLD - if (this->action == TB_ACTION_PRESSED && (!pressDetected || pressStartTime == 0)) { - // Start long press detection - pressDetected = true; - pressStartTime = millis(); - // Don't send event yet, wait to see if it's a long press - } else if (up_counter >= TB_THRESHOLD) { + // A starting press suppresses direction events as it always has, and a press event already + // emitted this poll (a release classified on a later poll) must not be overwritten either. + if (!pressLatched && e.inputEvent == INPUT_BROKER_NONE) { + if (up_counter >= TB_THRESHOLD) { #ifdef INPUT_DEBUG - LOG_DEBUG("Trackball event UP %u", millis()); + LOG_DEBUG("Trackball event UP %u", millis()); #endif - e.inputEvent = this->_eventUp; - } else if (down_counter >= TB_THRESHOLD) { + e.inputEvent = this->_eventUp; + } else if (down_counter >= TB_THRESHOLD) { #ifdef INPUT_DEBUG - LOG_DEBUG("Trackball event DOWN %u", millis()); + LOG_DEBUG("Trackball event DOWN %u", millis()); #endif - e.inputEvent = this->_eventDown; - } else if (left_counter >= TB_THRESHOLD) { + e.inputEvent = this->_eventDown; + } else if (left_counter >= TB_THRESHOLD) { #ifdef INPUT_DEBUG - LOG_DEBUG("Trackball event LEFT %u", millis()); + LOG_DEBUG("Trackball event LEFT %u", millis()); #endif - e.inputEvent = this->_eventLeft; - } else if (right_counter >= TB_THRESHOLD) { + e.inputEvent = this->_eventLeft; + } else if (right_counter >= TB_THRESHOLD) { #ifdef INPUT_DEBUG - LOG_DEBUG("Trackball event RIGHT %u", millis()); + LOG_DEBUG("Trackball event RIGHT %u", millis()); #endif - e.inputEvent = this->_eventRight; + e.inputEvent = this->_eventRight; + } } #else - if (this->action == TB_ACTION_PRESSED && !digitalRead(_pinPress) && !pressDetected) { - // Start long press detection - pressDetected = true; - pressStartTime = millis(); - // Don't send event yet, wait to see if it's a long press - } else if (this->action == TB_ACTION_UP && !digitalRead(_pinUp) && !directionDetected) { - directionDetected = true; - directionStartTime = millis(); - e.inputEvent = this->_eventUp; - // send event first,will automatically trigger every 50ms * 3 after 500ms - } else if (this->action == TB_ACTION_DOWN && !digitalRead(_pinDown) && !directionDetected) { - directionDetected = true; - directionStartTime = millis(); - e.inputEvent = this->_eventDown; - } else if (this->action == TB_ACTION_LEFT && !digitalRead(_pinLeft) && !directionDetected) { - directionDetected = true; - directionStartTime = millis(); - e.inputEvent = this->_eventLeft; - } else if (this->action == TB_ACTION_RIGHT && !digitalRead(_pinRight) && !directionDetected) { - directionDetected = true; - directionStartTime = millis(); - e.inputEvent = this->_eventRight; + // A press event already claimed this poll: a direction IRQ must not overwrite the tap. + if (e.inputEvent == INPUT_BROKER_NONE) { + if (this->action == TB_ACTION_UP && !digitalRead(_pinUp) && !directionDetected) { + directionDetected = true; + directionStartTime = Time::skipZero(Time::getMillis()); + e.inputEvent = this->_eventUp; + // send event first,will automatically trigger every 50ms * 3 after 500ms + } else if (this->action == TB_ACTION_DOWN && !digitalRead(_pinDown) && !directionDetected) { + directionDetected = true; + directionStartTime = Time::skipZero(Time::getMillis()); + e.inputEvent = this->_eventDown; + } else if (this->action == TB_ACTION_LEFT && !digitalRead(_pinLeft) && !directionDetected) { + directionDetected = true; + directionStartTime = Time::skipZero(Time::getMillis()); + e.inputEvent = this->_eventLeft; + } else if (this->action == TB_ACTION_RIGHT && !digitalRead(_pinRight) && !directionDetected) { + directionDetected = true; + directionStartTime = Time::skipZero(Time::getMillis()); + e.inputEvent = this->_eventRight; + } } #endif @@ -224,16 +247,20 @@ int32_t TrackballInterruptBase::runOnce() void TrackballInterruptBase::intPressHandler() { - if (!Throttle::isWithinTimespanMs(lastInterruptTime, 10)) - this->action = TB_ACTION_PRESSED; - lastInterruptTime = millis(); + // pressIrqSeq == 0 means nothing recorded yet, so a press at clock 0 is not read as a cooldown. + if (pressIrqSeq != 0 && Throttle::isWithinTimespanMs(lastPressInterruptTime, 10)) + return; + lastPressInterruptTime = Time::getMillis(); + pressIrqTime = lastPressInterruptTime; + pressIrqSeq++; + this->action = TB_ACTION_PRESSED; } void TrackballInterruptBase::intDownHandler() { if (TB_THRESHOLD || !Throttle::isWithinTimespanMs(lastInterruptTime, 10)) this->action = TB_ACTION_DOWN; - lastInterruptTime = millis(); + lastInterruptTime = Time::skipZero(Time::getMillis()); #if TB_THRESHOLD down_counter++; @@ -244,7 +271,7 @@ void TrackballInterruptBase::intUpHandler() { if (TB_THRESHOLD || !Throttle::isWithinTimespanMs(lastInterruptTime, 10)) this->action = TB_ACTION_UP; - lastInterruptTime = millis(); + lastInterruptTime = Time::skipZero(Time::getMillis()); #if TB_THRESHOLD up_counter++; @@ -255,7 +282,7 @@ void TrackballInterruptBase::intLeftHandler() { if (TB_THRESHOLD || !Throttle::isWithinTimespanMs(lastInterruptTime, 10)) this->action = TB_ACTION_LEFT; - lastInterruptTime = millis(); + lastInterruptTime = Time::skipZero(Time::getMillis()); #if TB_THRESHOLD left_counter++; #endif @@ -265,7 +292,7 @@ void TrackballInterruptBase::intRightHandler() { if (TB_THRESHOLD || !Throttle::isWithinTimespanMs(lastInterruptTime, 10)) this->action = TB_ACTION_RIGHT; - lastInterruptTime = millis(); + lastInterruptTime = Time::skipZero(Time::getMillis()); #if TB_THRESHOLD right_counter++; #endif diff --git a/src/input/TrackballInterruptBase.h b/src/input/TrackballInterruptBase.h index 908f62769c..100207d8ce 100644 --- a/src/input/TrackballInterruptBase.h +++ b/src/input/TrackballInterruptBase.h @@ -49,6 +49,12 @@ class TrackballInterruptBase : public Observable, public con volatile TrackballInterruptBaseActionType action = TB_ACTION_NONE; + enum class PressResult : uint8_t { None, Short, LongRepeat }; + + /// Press state machine, hardware-free so it can be unit tested. irqLatched/irqTimeMs come from + /// intPressHandler(), pinLow is the debounced pin state now (pull-up: pressed reads low). + PressResult updatePress(bool irqLatched, uint32_t irqTimeMs, bool pinLow); + // Long press detection for press button uint32_t pressStartTime = 0; uint32_t directionStartTime = 0; @@ -70,6 +76,13 @@ class TrackballInterruptBase : public Observable, public con const char *_originName; TrackballInterruptBaseActionType lastEvent = TB_ACTION_NONE; volatile uint32_t lastInterruptTime = 0; + // Own debounce clock so a tilt cannot swallow the click. A sequence rather than a flag, so an + // interrupt landing mid-poll is seen next poll instead of being cleared unread. + volatile uint32_t pressIrqSeq = 0; + volatile uint32_t pressIrqTime = 0; + volatile uint32_t lastPressInterruptTime = 0; + uint32_t pressIrqSeen = 0; + bool longPressRepeatSent = false; #if TB_THRESHOLD volatile uint8_t left_counter = 0; diff --git a/src/input/UpDownInterruptBase.cpp b/src/input/UpDownInterruptBase.cpp index d597c8d8f4..5ad0d64ff0 100644 --- a/src/input/UpDownInterruptBase.cpp +++ b/src/input/UpDownInterruptBase.cpp @@ -1,4 +1,5 @@ #include "UpDownInterruptBase.h" +#include "UptimeClock.h" #include "configuration.h" UpDownInterruptBase::UpDownInterruptBase(const char *name) : concurrency::OSThread(name) @@ -50,7 +51,7 @@ int32_t UpDownInterruptBase::runOnce() { InputEvent e = {}; e.inputEvent = INPUT_BROKER_NONE; - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); // Read all button states once at the beginning bool pressButtonPressed = !digitalRead(_pinPress); diff --git a/src/input/cardKbI2cImpl.cpp b/src/input/cardKbI2cImpl.cpp index fa1925c5a6..ee2e649d98 100644 --- a/src/input/cardKbI2cImpl.cpp +++ b/src/input/cardKbI2cImpl.cpp @@ -13,11 +13,7 @@ void CardKbI2cImpl::init() if (cardkb_found.address == 0x00) { LOG_DEBUG("Rescan for I2C keyboard"); uint8_t i2caddr_scan[] = {CARDKB_ADDR, TDECK_KB_ADDR, BBQ10_KB_ADDR, MCP23017_KB_ADDR, MPR121_KB_ADDR, TCA8418_KB_ADDR}; -#if defined(T_LORA_PAGER) uint8_t i2caddr_asize = sizeof(i2caddr_scan) / sizeof(i2caddr_scan[0]); -#else - uint8_t i2caddr_asize = 6; -#endif auto i2cScanner = std::unique_ptr(new ScanI2CTwoWire()); #if WIRE_INTERFACES_COUNT == 2 @@ -43,7 +39,7 @@ void CardKbI2cImpl::init() // assign an arbitrary value to distinguish from other models kb_model = 0x11; break; - case ScanI2C::DeviceType::MCP23017: + case ScanI2C::DeviceType::MCP23017KB: // assign an arbitrary value to distinguish from other models kb_model = 0x20; break; diff --git a/src/input/kbI2cBase.cpp b/src/input/kbI2cBase.cpp index 7a45e7893a..82e57fd4a4 100644 --- a/src/input/kbI2cBase.cpp +++ b/src/input/kbI2cBase.cpp @@ -80,7 +80,7 @@ int32_t KbI2cBase::runOnce() Q10keyboard.setBacklight(0); } if (cardkb_found.address == MCP23017_KB_ADDR) { - MCPkeyboard.begin(MCP23017_KB_ADDR, &Wire1); + MCPkeyboard.begin(MCP23017_KB_ADDR, i2cBus); } if (cardkb_found.address == MPR121_KB_ADDR) { MPRkeyboard.begin(MPR121_KB_ADDR, i2cBus); @@ -196,14 +196,10 @@ int32_t KbI2cBase::runOnce() case 0x13: // Code scanner says the SYM key is 0x13 is_sym = !is_sym; e.inputEvent = INPUT_BROKER_ANYKEY; - e.kbchar = is_sym ? INPUT_BROKER_MSG_FN_SYMBOL_ON // send 0xf1 to tell - // CannedMessages to - // display that - : INPUT_BROKER_MSG_FN_SYMBOL_OFF; // the modifier - // key is active + e.kbchar = is_sym ? INPUT_BROKER_MSG_FN_SYMBOL_ON // send 0xf1 to tell CannedMessages to display that + : INPUT_BROKER_MSG_FN_SYMBOL_OFF; // the modifier key is active break; - case 0x0a: // apparently Enter on Q10 is a line feed instead of carriage - // return + case 0x0a: // apparently Enter on Q10 is a line feed instead of carriage return e.inputEvent = INPUT_BROKER_SELECT; break; case 0x00: // nopress @@ -223,6 +219,66 @@ int32_t KbI2cBase::runOnce() } break; } + case 0x20: { // MCP23017 keypad + MCPkeyboard.trigger(); + InputEvent e = {}; + + while (MCPkeyboard.hasEvent()) { + char nextEvent = MCPkeyboard.dequeueEvent(); + e.inputEvent = INPUT_BROKER_ANYKEY; + e.kbchar = 0x00; + e.source = this->_originName; + + switch (nextEvent) { + case 0x00: // KB_NONE + e.inputEvent = INPUT_BROKER_NONE; + e.kbchar = 0x00; + break; + case 0xb4: // KB_LEFT + e.inputEvent = INPUT_BROKER_LEFT; + e.kbchar = 0x00; + break; + case 0xb5: // KB_UP + e.inputEvent = INPUT_BROKER_UP; + e.kbchar = 0x00; + break; + case 0xb6: // KB_DOWN + e.inputEvent = INPUT_BROKER_DOWN; + e.kbchar = 0x00; + break; + case 0xb7: // KB_RIGHT + e.inputEvent = INPUT_BROKER_RIGHT; + e.kbchar = 0x00; + break; + case 0x1b: // KB_ESC + e.inputEvent = INPUT_BROKER_CANCEL; + e.kbchar = 0; + break; + case 0x08: // KB_BSP + e.inputEvent = INPUT_BROKER_BACK; + e.kbchar = 0x08; + break; + case 0x0d: // KB_SELECT + e.inputEvent = INPUT_BROKER_SELECT; + e.kbchar = 0x00; + break; + default: + if ((uint8_t)nextEvent > 127) { // Not a printable character, ignore it + e.inputEvent = INPUT_BROKER_NONE; + e.kbchar = 0x00; + break; + } + e.inputEvent = INPUT_BROKER_ANYKEY; + e.kbchar = nextEvent; + break; + } + + if (e.inputEvent != INPUT_BROKER_NONE) { + this->notifyObservers(&e); + } + } + break; + } case 0x37: { // MPR121 MPRkeyboard.trigger(); InputEvent e = {}; @@ -428,8 +484,7 @@ int32_t KbI2cBase::runOnce() e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; switch (c) { - case 0x71: // This is the button q. If modifier and q pressed, it cancels - // the input + case 0x71: // This is the button q. If modifier and q pressed, it cancels the input if (is_sym) { is_sym = false; e.inputEvent = INPUT_BROKER_CANCEL; @@ -521,14 +576,11 @@ int32_t KbI2cBase::runOnce() e.inputEvent = INPUT_BROKER_RIGHT; e.kbchar = 0; break; - case 0xc: // Modifier key: 0xc is alt+c (Other options could be: 0xea = - // shift+mic button or 0x4 shift+$(speaker)) + case 0xc: // Modifier key: 0xc is alt+c (Other options could be: 0xea = shift+mic button or 0x4 shift+$(speaker)) // toggle modifiers button. is_sym = !is_sym; e.inputEvent = INPUT_BROKER_ANYKEY; - e.kbchar = is_sym ? INPUT_BROKER_MSG_FN_SYMBOL_ON // send 0xf1 to tell - // CannedMessages to display - // that the + e.kbchar = is_sym ? INPUT_BROKER_MSG_FN_SYMBOL_ON // send 0xf1 to tell CannedMessages to display that the : INPUT_BROKER_MSG_FN_SYMBOL_OFF; // modifier key is active break; case 0x9e: // fn+g INPUT_BROKER_GPS_TOGGLE @@ -576,66 +628,6 @@ int32_t KbI2cBase::runOnce() } break; } - case 0x20: { // MCP23017 (Dirección I2C por defecto) - MCPkeyboard.trigger(); - InputEvent e = {}; - - while (MCPkeyboard.hasEvent()) { - char nextEvent = MCPkeyboard.dequeueEvent(); - e.inputEvent = INPUT_BROKER_ANYKEY; - e.kbchar = 0x00; - e.source = this->_originName; - - switch (nextEvent) { - case 0x00: // KB_NONE - e.inputEvent = INPUT_BROKER_NONE; - e.kbchar = 0x00; - break; - case 0xb4: // KB_LEFT - e.inputEvent = INPUT_BROKER_LEFT; - e.kbchar = 0x00; - break; - case 0xb5: // KB_UP - e.inputEvent = INPUT_BROKER_UP; - e.kbchar = 0x00; - break; - case 0xb6: // KB_DOWN - e.inputEvent = INPUT_BROKER_DOWN; - e.kbchar = 0x00; - break; - case 0xb7: // KB_RIGHT - e.inputEvent = INPUT_BROKER_RIGHT; - e.kbchar = 0x00; - break; - case 0x1b: // KB_ESC - e.inputEvent = INPUT_BROKER_CANCEL; - e.kbchar = 0; - break; - case 0x08: // KB_BSP - e.inputEvent = INPUT_BROKER_BACK; - e.kbchar = 0x08; - break; - case 0x0d: // KB_SELECT - e.inputEvent = INPUT_BROKER_SELECT; - e.kbchar = 0x00; - break; - default: - if (nextEvent > 127) { // Invalid key, ignore it - e.inputEvent = INPUT_BROKER_NONE; - e.kbchar = 0x00; - break; - } - // Normal character - e.inputEvent = INPUT_BROKER_ANYKEY; - e.kbchar = nextEvent; - break; - } - - if (e.inputEvent != INPUT_BROKER_NONE) { - this->notifyObservers(&e); - } - } - } #if defined(ELECROW_ThinkNode_M9) case 0x12: { // STC8H companion-MCU keypad (ThinkNode-M9) Stc8HKeyBoard.key_event = false; @@ -737,12 +729,7 @@ int32_t KbI2cBase::runOnce() default: LOG_WARN("Unknown kb_model 0x%02x", kb_model); } - break; -} -default: -LOG_WARN("Unknown kb_model 0x%02x", kb_model); -} -return 300; + return 300; } void KbI2cBase::toggleBacklight(bool on) diff --git a/src/main.cpp b/src/main.cpp index 834b84bee0..7429278a80 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,7 @@ #include "Power.h" #include "SPILock.h" #include "Throttle.h" +#include "WaypointStore.h" #include "concurrency/OSThread.h" #include "concurrency/Periodic.h" #include "detect/ScanI2C.h" @@ -75,12 +76,6 @@ NimbleBluetooth *nimbleBluetooth = nullptr; NRF52Bluetooth *nrf52Bluetooth = nullptr; #endif -#ifdef ARCH_NRF54L15 -void nrf54l15Setup(); -void nrf54l15Loop(); -NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr; -#endif - #ifdef MESHTASTIC_ENABLE_APPROTECT #include "security/APProtect.h" #endif @@ -110,8 +105,7 @@ NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr; #ifdef ARCH_PORTDUINO #include "linux/LinuxHardwareI2C.h" -#ifndef ARCH_PORTDUINO_WASM // raspi HTTP server (ulfius/zlib/openssl) excluded - // in the browser/wasm build +#ifndef ARCH_PORTDUINO_WASM // raspi HTTP server (ulfius/zlib/openssl) excluded in the browser/wasm build #include "mesh/raspihttp/PiWebServer.h" #endif #include "platform/portduino/PortduinoGlue.h" @@ -128,37 +122,35 @@ NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr; #ifdef DEBUG_PARTITION_TABLE #include "esp_partition.h" -void printPartitionTable() { - printf("\n--- Partition Table ---\n"); - // Print Column Headers - printf("| %-16s | %-4s | %-7s | %-10s | %-10s |\n", "Label", "Type", - "Subtype", "Offset", "Size"); - printf("|------------------|------|---------|------------|------------|\n"); +void printPartitionTable() +{ + printf("\n--- Partition Table ---\n"); + // Print Column Headers + printf("| %-16s | %-4s | %-7s | %-10s | %-10s |\n", "Label", "Type", "Subtype", "Offset", "Size"); + printf("|------------------|------|---------|------------|------------|\n"); - // Create an iterator to find ALL partitions (Type ANY, Subtype ANY) - esp_partition_iterator_t it = esp_partition_find( - ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); + // Create an iterator to find ALL partitions (Type ANY, Subtype ANY) + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); - // Loop through the iterator - if (it != NULL) { - do { - const esp_partition_t *part = esp_partition_get(it); + // Loop through the iterator + if (it != NULL) { + do { + const esp_partition_t *part = esp_partition_get(it); - // Print details: Label, Type (Hex), Subtype (Hex), Offset (Hex), Size - // (Hex) - printf("| %-16s | 0x%02x | 0x%02x | 0x%08x | 0x%08x |\n", part->label, - part->type, part->subtype, part->address, part->size); + // Print details: Label, Type (Hex), Subtype (Hex), Offset (Hex), Size (Hex) + printf("| %-16s | 0x%02x | 0x%02x | 0x%08x | 0x%08x |\n", part->label, part->type, part->subtype, part->address, + part->size); - // Move to next partition - it = esp_partition_next(it); - } while (it != NULL); + // Move to next partition + it = esp_partition_next(it); + } while (it != NULL); - // Release the iterator memory - esp_partition_iterator_release(it); - } else { - printf("No partitions found.\n"); - } - printf("-----------------------\n"); + // Release the iterator memory + esp_partition_iterator_release(it); + } else { + printf("No partitions found.\n"); + } + printf("-----------------------\n"); } #endif // DEBUG_PARTITION_TABLE #endif // ARCH_ESP32 @@ -166,13 +158,11 @@ void printPartitionTable() { #include "AmbientLightingThread.h" #include "PowerFSMThread.h" -#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && \ - !MESHTASTIC_EXCLUDE_ACCELEROMETER +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_ACCELEROMETER #include "motion/AccelerometerThread.h" AccelerometerThread *accelerometerThread = nullptr; #endif -#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && \ - !MESHTASTIC_EXCLUDE_MAGNETOMETER +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_MAGNETOMETER #include "motion/MagnetometerThread.h" MagnetometerThread *magnetometerThread = nullptr; #endif @@ -182,9 +172,9 @@ MagnetometerThread *magnetometerThread = nullptr; AudioThread *audioThread = nullptr; #endif -#ifdef USE_XL9555 -#include "ExtensionIOXL9555.hpp" -ExtensionIOXL9555 io; +#ifdef USE_PCA95X5 +#include PCA95X5_INC +PCA95X5_CLS io; #endif #ifdef USE_MCP23017 @@ -201,9 +191,7 @@ UdpMulticastHandler *udpHandler = nullptr; #endif #if defined(TCXO_OPTIONAL) -float tcxoVoltage = - SX126X_DIO3_TCXO_VOLTAGE; // if TCXO is optional, put this here so it can be - // changed further down. +float tcxoVoltage = SX126X_DIO3_TCXO_VOLTAGE; // if TCXO is optional, put this here so it can be changed further down. #endif #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS @@ -213,10 +201,9 @@ void setupNicheGraphics(); #if defined(HW_SPI1_DEVICE) && defined(ARCH_ESP32) #if defined(HAS_SDCARD) && defined(SDCARD_USE_SPI1) -// Reuse FSCommon's SPI_HSPI instance to avoid double-initializing SPI2_HOST in -// arduino-esp32 3.x. Two SPIClass(HSPI) objects on the same bus cause the -// second spi_bus_initialize() to return ESP_ERR_INVALID_STATE, leaving the LoRa -// device handle invalid and blocking SPI transfers. +// Reuse FSCommon's SPI_HSPI instance to avoid double-initializing SPI2_HOST in arduino-esp32 3.x. +// Two SPIClass(HSPI) objects on the same bus cause the second spi_bus_initialize() to return +// ESP_ERR_INVALID_STATE, leaving the LoRa device handle invalid and blocking SPI transfers. extern SPIClass SPI_HSPI; SPIClass &SPI1 = SPI_HSPI; #else @@ -241,8 +228,7 @@ meshtastic::GPSStatus *gpsStatus = new meshtastic::GPSStatus(); meshtastic::NodeStatus *nodeStatus = new meshtastic::NodeStatus(); // Global Bluetooth status -meshtastic::BluetoothStatus *bluetoothStatus = - new meshtastic::BluetoothStatus(); +meshtastic::BluetoothStatus *bluetoothStatus = new meshtastic::BluetoothStatus(); // Scan for I2C Devices @@ -265,8 +251,7 @@ ScanI2C::DeviceAddress accelerometer_found = ScanI2C::ADDRESS_NONE; // The I2C address of the Magnetometer (if found) ScanI2C::DeviceAddress magnetometer_found = ScanI2C::ADDRESS_NONE; // The I2C address of the RGB LED (if found) -ScanI2C::FoundDevice rgb_found = - ScanI2C::FoundDevice(ScanI2C::DeviceType::NONE, ScanI2C::ADDRESS_NONE); +ScanI2C::FoundDevice rgb_found = ScanI2C::FoundDevice(ScanI2C::DeviceType::NONE, ScanI2C::ADDRESS_NONE); /// The I2C address of our Air Quality Indicator (if found) ScanI2C::DeviceAddress aqi_found = ScanI2C::ADDRESS_NONE; @@ -284,34 +269,30 @@ bool pauseBluetoothLogging = false; bool pmu_found; #if !MESHTASTIC_EXCLUDE_I2C -// Array map of sensor types with i2c address and wire as we'll find in the i2c -// scan -std::pair - nodeTelemetrySensorsMap[_meshtastic_TelemetrySensorType_MAX + 1] = {}; +// Array map of sensor types with i2c address and wire as we'll find in the i2c scan +std::pair nodeTelemetrySensorsMap[_meshtastic_TelemetrySensorType_MAX + 1] = {}; #endif -Router *router = NULL; // Users of router don't care what sort of subclass - // implements that API +Router *router = NULL; // Users of router don't care what sort of subclass implements that API const char *firmware_version = optstr(APP_VERSION_SHORT); -const char *getDeviceName() { - uint8_t dmac[6]; +const char *getDeviceName() +{ + uint8_t dmac[6]; - getMacAddr(dmac); + getMacAddr(dmac); - // Meshtastic_ab3c or Shortname_abcd - static char name[20]; - snprintf(name, sizeof(name), "%02x%02x", dmac[4], dmac[5]); - // if the shortname exists and is NOT the new default of ab3c, use it for BLE - // name. - if (strcmp(owner.short_name, name) != 0) { - snprintf(name, sizeof(name), "%s_%02x%02x", owner.short_name, dmac[4], - dmac[5]); - } else { - snprintf(name, sizeof(name), "Meshtastic_%02x%02x", dmac[4], dmac[5]); - } - return name; + // Meshtastic_ab3c or Shortname_abcd + static char name[20]; + snprintf(name, sizeof(name), "%02x%02x", dmac[4], dmac[5]); + // if the shortname exists and is NOT the new default of ab3c, use it for BLE name. + if (strcmp(owner.short_name, name) != 0) { + snprintf(name, sizeof(name), "%s_%02x%02x", owner.short_name, dmac[4], dmac[5]); + } else { + snprintf(name, sizeof(name), "Meshtastic_%02x%02x", dmac[4], dmac[5]); + } + return name; } uint32_t timeLastPowered = 0; @@ -322,10 +303,12 @@ AmbientLightingThread *ambientLightingThread; RadioLibHal *RadioLibHAL = NULL; /** - * Some platforms (nrf52) might provide an alterate version that suppresses - * calling delay from sleep. + * Some platforms (nrf52) might provide an alterate version that suppresses calling delay from sleep. */ -__attribute__((weak, noinline)) bool loopCanSleep() { return true; } +__attribute__((weak, noinline)) bool loopCanSleep() +{ + return true; +} // Weak empty variant initialization function. // May be redefined by variant files. @@ -339,71 +322,72 @@ __attribute__((noinline)) void lateInitVariant() {} __attribute__((noinline)) void earlyInitVariant() __attribute__((weak)); __attribute__((noinline)) void earlyInitVariant() {} -// NRF52 (and probably other platforms) can report when system is in power -// failure mode (eg. too low battery voltage) and operating it is unsafe (data -// corruption, bootloops, etc). For example NRF52 will prevent any flash writes -// in that case automatically (but it causes issues we need to handle). This -// detection is independent from whatever ADC or dividers used in Meshtastic +// NRF52 (and probably other platforms) can report when system is in power failure mode +// (eg. too low battery voltage) and operating it is unsafe (data corruption, bootloops, etc). +// For example NRF52 will prevent any flash writes in that case automatically +// (but it causes issues we need to handle). +// This detection is independent from whatever ADC or dividers used in Meshtastic // boards and is internal to chip. -// we use powerHAL layer to get this info and delay booting until power level is -// safe +// we use powerHAL layer to get this info and delay booting until power level is safe // wait until power level is safe to continue booting (to avoid bootloops) // blink user led in 3 flashes sequence to indicate what is happening -void waitUntilPowerLevelSafe() { - while (powerHAL_isPowerLevelSafe() == false) { +void waitUntilPowerLevelSafe() +{ + while (powerHAL_isPowerLevelSafe() == false) { #ifdef LED_POWER - // 3x: blink for 300 ms, pause for 300 ms + // 3x: blink for 300 ms, pause for 300 ms - for (int i = 0; i < 3; i++) { - digitalWrite(LED_POWER, LED_STATE_ON); - delay(300); - digitalWrite(LED_POWER, LED_STATE_OFF); - delay(300); - } + for (int i = 0; i < 3; i++) { + digitalWrite(LED_POWER, LED_STATE_ON); + delay(300); + digitalWrite(LED_POWER, LED_STATE_OFF); + delay(300); + } #endif - // sleep for 2s - delay(2000); - } + // sleep for 2s + delay(2000); + } } /** * Print info as a structured log message (for automated log processing) */ -void printInfo() { - LOG_INFO("S:B:%d,%s,%s,%s", HW_VENDOR, optstr(APP_VERSION), optstr(APP_ENV), - optstr(APP_REPO)); +void printInfo() +{ + LOG_INFO("S:B:%d,%s,%s,%s", HW_VENDOR, optstr(APP_VERSION), optstr(APP_ENV), optstr(APP_REPO)); } #ifndef PIO_UNIT_TESTING -void setup() { +void setup() +{ - // initialize power HAL layer as early as possible - powerHAL_init(); + // initialize power HAL layer as early as possible + powerHAL_init(); #ifdef LED_POWER - pinMode(LED_POWER, OUTPUT); - digitalWrite(LED_POWER, LED_STATE_ON); + pinMode(LED_POWER, OUTPUT); + digitalWrite(LED_POWER, LED_STATE_ON); #endif - // prevent booting if device is in power failure mode - // boot sequence will follow when battery level raises to safe mode - waitUntilPowerLevelSafe(); + // prevent booting if device is in power failure mode + // boot sequence will follow when battery level raises to safe mode + waitUntilPowerLevelSafe(); - // Defined in variant.cpp for early init code - earlyInitVariant(); + // Defined in variant.cpp for early init code + earlyInitVariant(); #if defined(PIN_POWER_EN) - pinMode(PIN_POWER_EN, OUTPUT); - digitalWrite(PIN_POWER_EN, HIGH); + pinMode(PIN_POWER_EN, OUTPUT); + digitalWrite(PIN_POWER_EN, HIGH); #endif #ifdef LED_NOTIFICATION - pinMode(LED_NOTIFICATION, OUTPUT); - digitalWrite(LED_NOTIFICATION, HIGH ^ LED_STATE_ON); + pinMode(LED_NOTIFICATION, OUTPUT); + digitalWrite(LED_NOTIFICATION, HIGH ^ LED_STATE_ON); #endif #ifdef LED_LORA @@ -412,52 +396,50 @@ void setup() { #endif #ifdef WIFI_LED - pinMode(WIFI_LED, OUTPUT); - digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); + pinMode(WIFI_LED, OUTPUT); + digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); #endif #ifdef BLE_LED - pinMode(BLE_LED, OUTPUT); - digitalWrite(BLE_LED, LED_STATE_OFF); + pinMode(BLE_LED, OUTPUT); + digitalWrite(BLE_LED, LED_STATE_OFF); #endif - concurrency::hasBeenSetup = true; + concurrency::hasBeenSetup = true; #if HAS_SCREEN - meshtastic_Config_DisplayConfig_OledType screen_model = - meshtastic_Config_DisplayConfig_OledType:: - meshtastic_Config_DisplayConfig_OledType_OLED_AUTO; + meshtastic_Config_DisplayConfig_OledType screen_model = + meshtastic_Config_DisplayConfig_OledType::meshtastic_Config_DisplayConfig_OledType_OLED_AUTO; #endif - OLEDDISPLAY_GEOMETRY screen_geometry = GEOMETRY_128_64; + OLEDDISPLAY_GEOMETRY screen_geometry = GEOMETRY_128_64; #ifdef USE_SEGGER - auto mode = false ? SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL - : SEGGER_RTT_MODE_NO_BLOCK_TRIM; + auto mode = false ? SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL : SEGGER_RTT_MODE_NO_BLOCK_TRIM; #ifdef NRF52840_XXAA - auto buflen = 4096; // this board has a fair amount of ram + auto buflen = 4096; // this board has a fair amount of ram #else - auto buflen = 256; // this board has a fair amount of ram + auto buflen = 256; // this board has a fair amount of ram #endif - SEGGER_RTT_ConfigUpBuffer(SEGGER_STDOUT_CH, NULL, NULL, buflen, mode); + SEGGER_RTT_ConfigUpBuffer(SEGGER_STDOUT_CH, NULL, NULL, buflen, mode); #endif #ifdef DEBUG_PORT - consoleInit(); // Set serial baud rate and init our mesh console + consoleInit(); // Set serial baud rate and init our mesh console #endif - // M23 (audit): APPROTECT engagement moved below fsInit() so we can gate - // on EncryptedStorage::isProvisioned(). Engaging on an unprovisioned dev - // board permanently locks SWD before the operator has even set a - // passphrase - a misconfigured CI build flashed to a developer device - // would brick its debug port on first boot. Now we only engage when the - // device has a DEK file on flash, i.e. the operator has explicitly - // committed to lockdown via passphrase provisioning. + // M23 (audit): APPROTECT engagement moved below fsInit() so we can gate + // on EncryptedStorage::isProvisioned(). Engaging on an unprovisioned dev + // board permanently locks SWD before the operator has even set a + // passphrase - a misconfigured CI build flashed to a developer device + // would brick its debug port on first boot. Now we only engage when the + // device has a DEK file on flash, i.e. the operator has explicitly + // committed to lockdown via passphrase provisioning. #ifdef UNPHONE - unphone.printStore(); + unphone.printStore(); #endif #if ARCH_PORTDUINO - RTCQuality ourQuality = RTCQualityDevice; + RTCQuality ourQuality = RTCQualityDevice; #ifdef __linux__ // timedatectl is systemd-only, so macOS, Windows and WASM stay at @@ -468,102 +450,100 @@ void setup() { } #endif - struct timeval tv; - tv.tv_sec = time(NULL); - tv.tv_usec = 0; - perhapsSetRTC(ourQuality, &tv); + struct timeval tv; + tv.tv_sec = time(NULL); + tv.tv_usec = 0; + perhapsSetRTC(ourQuality, &tv); #endif - powerMonInit(); - serialSinceMsec = millis(); + powerMonInit(); + serialSinceMsec = millis(); - LOG_INFO("\n\n//\\ E S H T /\\ S T / C\n"); + LOG_INFO("\n\n//\\ E S H T /\\ S T / C\n"); #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) #ifndef SENSECAP_INDICATOR - // use PSRAM for malloc calls > 2048 bytes - heap_caps_malloc_extmem_enable(2048); + // use PSRAM for malloc calls > 2048 bytes + heap_caps_malloc_extmem_enable(2048); #endif #endif - // The DEBUG_MUTE "we are muted, FYI" banner spills APP_VERSION / APP_ENV / - // APP_REPO out the USB CDC even with logging otherwise suppressed - a free - // firmware-fingerprinting primitive for an attacker holding the cable. - // Under MESHTASTIC_LOCKDOWN we want the device to look uniformly silent - // until the operator authenticates, so skip the banner entirely there. + // The DEBUG_MUTE "we are muted, FYI" banner spills APP_VERSION / APP_ENV / + // APP_REPO out the USB CDC even with logging otherwise suppressed - a free + // firmware-fingerprinting primitive for an attacker holding the cable. + // Under MESHTASTIC_LOCKDOWN we want the device to look uniformly silent + // until the operator authenticates, so skip the banner entirely there. #if defined(DEBUG_MUTE) && defined(DEBUG_PORT) && !defined(MESHTASTIC_LOCKDOWN) - DEBUG_PORT.printf("\r\n\r\n//\\ E S H T /\\ S T / C\r\n"); - DEBUG_PORT.printf("Version %s for %s from %s\r\n", optstr(APP_VERSION), - optstr(APP_ENV), optstr(APP_REPO)); - DEBUG_PORT.printf( - "Debug mute is enabled, there will be no serial output.\r\n"); + DEBUG_PORT.printf("\r\n\r\n//\\ E S H T /\\ S T / C\r\n"); + DEBUG_PORT.printf("Version %s for %s from %s\r\n", optstr(APP_VERSION), optstr(APP_ENV), optstr(APP_REPO)); + DEBUG_PORT.printf("Debug mute is enabled, there will be no serial output.\r\n"); #endif - initDeepSleep(); + initDeepSleep(); #if defined(MODEM_POWER_EN) - pinMode(MODEM_POWER_EN, OUTPUT); - digitalWrite(MODEM_POWER_EN, LOW); + pinMode(MODEM_POWER_EN, OUTPUT); + digitalWrite(MODEM_POWER_EN, LOW); #endif #if defined(MODEM_PWRKEY) - pinMode(MODEM_PWRKEY, OUTPUT); - digitalWrite(MODEM_PWRKEY, LOW); + pinMode(MODEM_PWRKEY, OUTPUT); + digitalWrite(MODEM_PWRKEY, LOW); #endif #if defined(LORA_TCXO_GPIO) - pinMode(LORA_TCXO_GPIO, OUTPUT); - digitalWrite(LORA_TCXO_GPIO, HIGH); + pinMode(LORA_TCXO_GPIO, OUTPUT); + digitalWrite(LORA_TCXO_GPIO, HIGH); #endif #if defined(VEXT_ENABLE) - pinMode(VEXT_ENABLE, OUTPUT); - digitalWrite(VEXT_ENABLE, VEXT_ON_VALUE); // turn on the display power + pinMode(VEXT_ENABLE, OUTPUT); + digitalWrite(VEXT_ENABLE, VEXT_ON_VALUE); // turn on the display power #endif #if defined(PIN_SENSOR_EN) - pinMode(PIN_SENSOR_EN, OUTPUT); - digitalWrite(PIN_SENSOR_EN, PIN_SENSOR_EN_ACTIVE); // turn on sensor power + pinMode(PIN_SENSOR_EN, OUTPUT); + digitalWrite(PIN_SENSOR_EN, PIN_SENSOR_EN_ACTIVE); // turn on sensor power #endif #if defined(BIAS_T_ENABLE) - pinMode(BIAS_T_ENABLE, OUTPUT); - digitalWrite(BIAS_T_ENABLE, BIAS_T_VALUE); // turn on 5V for GPS Antenna + pinMode(BIAS_T_ENABLE, OUTPUT); + digitalWrite(BIAS_T_ENABLE, BIAS_T_VALUE); // turn on 5V for GPS Antenna #endif #if defined(VTFT_CTRL) - pinMode(VTFT_CTRL, OUTPUT); - digitalWrite(VTFT_CTRL, LOW); + pinMode(VTFT_CTRL, OUTPUT); + digitalWrite(VTFT_CTRL, LOW); #endif #ifdef RESET_OLED - pinMode(RESET_OLED, OUTPUT); - digitalWrite(RESET_OLED, 1); - delay(2); - digitalWrite(RESET_OLED, 0); - delay(10); - digitalWrite(RESET_OLED, 1); + pinMode(RESET_OLED, OUTPUT); + digitalWrite(RESET_OLED, 1); + delay(2); + digitalWrite(RESET_OLED, 0); + delay(10); + digitalWrite(RESET_OLED, 1); #endif #ifdef SENSOR_POWER_CTRL_PIN - pinMode(SENSOR_POWER_CTRL_PIN, OUTPUT); - digitalWrite(SENSOR_POWER_CTRL_PIN, SENSOR_POWER_ON); + pinMode(SENSOR_POWER_CTRL_PIN, OUTPUT); + digitalWrite(SENSOR_POWER_CTRL_PIN, SENSOR_POWER_ON); #endif #ifdef SENSOR_GPS_CONFLICT - bool sensor_detected = false; + bool sensor_detected = false; #endif #ifdef PERIPHERAL_WARMUP_MS - // Some peripherals may require additional time to stabilize after power is - // connected e.g. I2C on Heltec Vision Master - LOG_INFO("Wait for peripherals to stabilize"); - delay(PERIPHERAL_WARMUP_MS); + // Some peripherals may require additional time to stabilize after power is connected + // e.g. I2C on Heltec Vision Master + LOG_INFO("Wait for peripherals to stabilize"); + delay(PERIPHERAL_WARMUP_MS); #endif - initSPI(); + initSPI(); - OSThread::setup(); + OSThread::setup(); - fsInit(); + fsInit(); #ifdef MESHTASTIC_ENCRYPTED_STORAGE EncryptedStorage::initLocked(); @@ -591,10 +571,10 @@ void setup() { LOG_INFO("APPROTECT deferred: not provisioned"); } #elif defined(MESHTASTIC_ENABLE_APPROTECT) - // Lockdown without encrypted storage shouldn't be reachable per - // configuration.h, but if it ever is, fall back to the unconditional - // engagement. - enableAPProtect(); + // Lockdown without encrypted storage shouldn't be reachable per + // configuration.h, but if it ever is, fall back to the unconditional + // engagement. + enableAPProtect(); #endif #if !MESHTASTIC_EXCLUDE_I2C @@ -606,64 +586,62 @@ void setup() { Wire1.setSCL(I2C_SCL1); Wire1.begin(); #elif defined(I2C_SDA1) && !defined(ARCH_RP2040) - Wire1.begin(I2C_SDA1, I2C_SCL1); + Wire1.begin(I2C_SDA1, I2C_SCL1); #elif WIRE_INTERFACES_COUNT == 2 - Wire1.begin(); + Wire1.begin(); #endif #if defined(I2C_SDA) && defined(ARCH_RP2040) - Wire.setSDA(I2C_SDA); - Wire.setSCL(I2C_SCL); - Wire.begin(); + Wire.setSDA(I2C_SDA); + Wire.setSCL(I2C_SCL); + Wire.begin(); #elif defined(I2C_SDA) && !defined(ARCH_RP2040) - LOG_INFO("Starting Bus with (SDA) %d and (SCL) %d: ", I2C_SDA, I2C_SCL); - Wire.begin(I2C_SDA, I2C_SCL); + LOG_INFO("Starting Bus with (SDA) %d and (SCL) %d: ", I2C_SDA, I2C_SCL); + Wire.begin(I2C_SDA, I2C_SCL); #elif defined(ARCH_PORTDUINO) - if (portduino_config.i2cdev != "") { - LOG_INFO("Use %s as I2C device", portduino_config.i2cdev.c_str()); - Wire.begin(portduino_config.i2cdev.c_str()); - } else { - LOG_INFO("No I2C device configured, Skip"); - } + if (portduino_config.i2cdev != "") { + LOG_INFO("Use %s as I2C device", portduino_config.i2cdev.c_str()); + Wire.begin(portduino_config.i2cdev.c_str()); + } else { + LOG_INFO("No I2C device configured, Skip"); + } #elif HAS_WIRE - Wire.begin(); + Wire.begin(); #endif #endif #if defined(M5STACK_UNITC6L) - pinMode(LORA_CS, OUTPUT); - digitalWrite(LORA_CS, 1); - c6l_init(); + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, 1); + c6l_init(); #endif #ifdef PIN_LCD_RESET - // FIXME - move this someplace better, LCD is at address 0x3F - pinMode(PIN_LCD_RESET, OUTPUT); - digitalWrite(PIN_LCD_RESET, 0); - delay(1); - digitalWrite(PIN_LCD_RESET, 1); - delay(1); + // FIXME - move this someplace better, LCD is at address 0x3F + pinMode(PIN_LCD_RESET, OUTPUT); + digitalWrite(PIN_LCD_RESET, 0); + delay(1); + digitalWrite(PIN_LCD_RESET, 1); + delay(1); #endif #ifdef AQ_SET_PIN - // RAK-12039 set pin for Air quality sensor. Detectable on I2C after ~3 - // seconds, so we need to rescan later - pinMode(AQ_SET_PIN, OUTPUT); - digitalWrite(AQ_SET_PIN, HIGH); + // RAK-12039 set pin for Air quality sensor. Detectable on I2C after ~3 seconds, so we need to rescan later + pinMode(AQ_SET_PIN, OUTPUT); + digitalWrite(AQ_SET_PIN, HIGH); #endif - // Currently only the tbeam has a PMU - // PMU initialization needs to be placed before i2c scanning - power = new Power(); - power->setStatusHandler(powerStatus); - powerStatus->observe(&power->newStatus); - power->setup(); // Must be after status handler is installed, so that handler - // gets notified of the initial configuration + // Currently only the tbeam has a PMU + // PMU initialization needs to be placed before i2c scanning + power = new Power(); + power->setStatusHandler(powerStatus); + powerStatus->observe(&power->newStatus); + power->setup(); // Must be after status handler is installed, so that handler gets notified of the initial configuration #ifdef USE_MCP23017 - // Bring up the I2C IO expander (LoRa reset, LCD reset, GPS wake) now that the - // PMU rails are up, before the I2C scan and radio/display init - mcp23017EarlyInit(); + // Bring up the I2C IO expander (LoRa reset, LCD reset, GPS wake) now that the PMU rails are up, + // before the I2C scan and radio/display init + mcp23017EarlyInit(); #endif #ifdef SENSECAP_INDICATOR @@ -681,11 +659,11 @@ void setup() { #endif #if !MESHTASTIC_EXCLUDE_I2C - // We need to scan here to decide if we have a screen for nodeDB.init() and - // because power has been applied to accessories - auto i2cScanner = std::unique_ptr(new ScanI2CTwoWire()); + // We need to scan here to decide if we have a screen for nodeDB.init() and because power has been applied to + // accessories + auto i2cScanner = std::unique_ptr(new ScanI2CTwoWire()); #if HAS_WIRE - LOG_INFO("Scan for i2c devices"); + LOG_INFO("Scan for i2c devices"); #endif #if defined(SENSECAP_INDICATOR) || defined(I2C_SDA1) || (defined(NRF52840_XXAA) && (WIRE_INTERFACES_COUNT == 2)) @@ -693,28 +671,28 @@ void setup() { #endif #if defined(I2C_SDA) - i2cScanner->scanPort(ScanI2C::I2CPort::WIRE); -#elif defined(ARCH_PORTDUINO) - if (portduino_config.i2cdev != "") { - LOG_INFO("Scan for i2c devices"); i2cScanner->scanPort(ScanI2C::I2CPort::WIRE); - } +#elif defined(ARCH_PORTDUINO) + if (portduino_config.i2cdev != "") { + LOG_INFO("Scan for i2c devices"); + i2cScanner->scanPort(ScanI2C::I2CPort::WIRE); + } #elif HAS_WIRE - i2cScanner->scanPort(ScanI2C::I2CPort::WIRE); + i2cScanner->scanPort(ScanI2C::I2CPort::WIRE); #endif - auto i2cCount = i2cScanner->countDevices(); - if (i2cCount == 0) { - LOG_INFO("No I2C devices found"); - } else { - LOG_INFO("%i I2C devices found", i2cCount); + auto i2cCount = i2cScanner->countDevices(); + if (i2cCount == 0) { + LOG_INFO("No I2C devices found"); + } else { + LOG_INFO("%i I2C devices found", i2cCount); #ifdef SENSOR_GPS_CONFLICT - sensor_detected = true; + sensor_detected = true; #endif - } + } #ifdef ARCH_ESP32 #ifdef DEBUG_PARTITION_TABLE - printPartitionTable(); + printPartitionTable(); #endif #endif // ARCH_ESP32 #ifdef ARCH_ESP32 @@ -726,39 +704,33 @@ void setup() { #endif #if HAS_SCREEN - auto screenInfo = i2cScanner->firstScreen(); - screen_found = screenInfo.type != ScanI2C::DeviceType::NONE - ? screenInfo.address - : ScanI2C::ADDRESS_NONE; + auto screenInfo = i2cScanner->firstScreen(); + screen_found = screenInfo.type != ScanI2C::DeviceType::NONE ? screenInfo.address : ScanI2C::ADDRESS_NONE; - if (screen_found.port != ScanI2C::I2CPort::NO_I2C) { - switch (screenInfo.type) { - case ScanI2C::DeviceType::SCREEN_SH1106: - screen_model = meshtastic_Config_DisplayConfig_OledType:: - meshtastic_Config_DisplayConfig_OledType_OLED_SH1106; - break; - case ScanI2C::DeviceType::SCREEN_SSD1306: - screen_model = meshtastic_Config_DisplayConfig_OledType:: - meshtastic_Config_DisplayConfig_OledType_OLED_SSD1306; - break; - case ScanI2C::DeviceType::SCREEN_ST7567: - case ScanI2C::DeviceType::SCREEN_UNKNOWN: - default: - screen_model = meshtastic_Config_DisplayConfig_OledType:: - meshtastic_Config_DisplayConfig_OledType_OLED_AUTO; + if (screen_found.port != ScanI2C::I2CPort::NO_I2C) { + switch (screenInfo.type) { + case ScanI2C::DeviceType::SCREEN_SH1106: + screen_model = meshtastic_Config_DisplayConfig_OledType::meshtastic_Config_DisplayConfig_OledType_OLED_SH1106; + break; + case ScanI2C::DeviceType::SCREEN_SSD1306: + screen_model = meshtastic_Config_DisplayConfig_OledType::meshtastic_Config_DisplayConfig_OledType_OLED_SSD1306; + break; + case ScanI2C::DeviceType::SCREEN_ST7567: + case ScanI2C::DeviceType::SCREEN_UNKNOWN: + default: + screen_model = meshtastic_Config_DisplayConfig_OledType::meshtastic_Config_DisplayConfig_OledType_OLED_AUTO; + } } - } #endif #define UPDATE_FROM_SCANNER(FIND_FN) #if defined(USE_VIRTUAL_KEYBOARD) - kb_found = true; + kb_found = true; #endif - auto rtc_info = i2cScanner->firstRTC(); - rtc_found = - rtc_info.type != ScanI2C::DeviceType::NONE ? rtc_info.address : rtc_found; + auto rtc_info = i2cScanner->firstRTC(); + rtc_found = rtc_info.type != ScanI2C::DeviceType::NONE ? rtc_info.address : rtc_found; - auto kb_info = i2cScanner->firstKeyboard(); + auto kb_info = i2cScanner->firstKeyboard(); if (kb_info.type != ScanI2C::DeviceType::NONE) { kb_found = true; @@ -778,10 +750,10 @@ void setup() { // assign an arbitrary value to distinguish from other models kb_model = 0x11; break; - case ScanI2C::DeviceType::MCP23017: + case ScanI2C::DeviceType::MCP23017KB: // assign an arbitrary value to distinguish from other models kb_model = 0x20; - break; + break; case ScanI2C::DeviceType::MPR121KB: // assign an arbitrary value to distinguish from other models kb_model = 0x37; @@ -801,164 +773,125 @@ void setup() { } } - pmu_found = i2cScanner->exists(ScanI2C::DeviceType::PMU_AXP192_AXP2101); + pmu_found = i2cScanner->exists(ScanI2C::DeviceType::PMU_AXP192_AXP2101); - auto aqiInfo = i2cScanner->firstAQI(); - aqi_found = aqiInfo.type != ScanI2C::DeviceType::NONE ? aqiInfo.address - : ScanI2C::ADDRESS_NONE; + auto aqiInfo = i2cScanner->firstAQI(); + aqi_found = aqiInfo.type != ScanI2C::DeviceType::NONE ? aqiInfo.address : ScanI2C::ADDRESS_NONE; /* - * There are a bunch of sensors that have no further logic than to be found and - * stuffed into the nodeTelemetrySensorsMap singleton. This wraps that logic in - * a temporary scope to declare the temporary field "found". + * There are a bunch of sensors that have no further logic than to be found and stuffed into the + * nodeTelemetrySensorsMap singleton. This wraps that logic in a temporary scope to declare the temporary field + * "found". */ // Two supported RGB LED currently #ifdef HAS_RGB_LED - rgb_found = i2cScanner->firstRGBLED(); + rgb_found = i2cScanner->firstRGBLED(); #endif #ifdef HAS_TPS65233 - // TPS65233 is a power management IC for satellite modems, used in the - // Dreamcatcher We are switching it off here since we don't use an LNB. - if (i2cScanner->exists(ScanI2C::DeviceType::TPS65233)) { - Wire.beginTransmission(TPS65233_ADDR); - Wire.write(0); // Register 0 - Wire.write(128); // Turn off the LNB power, keep I2C Control enabled - Wire.endTransmission(); - Wire.beginTransmission(TPS65233_ADDR); - Wire.write(1); // Register 1 - Wire.write(0); // Turn off Tone Generator 22kHz - Wire.endTransmission(); - } + // TPS65233 is a power management IC for satellite modems, used in the Dreamcatcher + // We are switching it off here since we don't use an LNB. + if (i2cScanner->exists(ScanI2C::DeviceType::TPS65233)) { + Wire.beginTransmission(TPS65233_ADDR); + Wire.write(0); // Register 0 + Wire.write(128); // Turn off the LNB power, keep I2C Control enabled + Wire.endTransmission(); + Wire.beginTransmission(TPS65233_ADDR); + Wire.write(1); // Register 1 + Wire.write(0); // Turn off Tone Generator 22kHz + Wire.endTransmission(); + } #endif #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ACCELEROMETER - auto acc_info = i2cScanner->firstAccelerometer(); - accelerometer_found = acc_info.type != ScanI2C::DeviceType::NONE - ? acc_info.address - : accelerometer_found; - LOG_DEBUG("acc_info = %i", acc_info.type); + auto acc_info = i2cScanner->firstAccelerometer(); + accelerometer_found = acc_info.type != ScanI2C::DeviceType::NONE ? acc_info.address : accelerometer_found; + LOG_DEBUG("acc_info = %i", acc_info.type); #endif #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_MAGNETOMETER - auto mag_info = i2cScanner->firstMagnetometer(); - magnetometer_found = mag_info.type != ScanI2C::DeviceType::NONE - ? mag_info.address - : magnetometer_found; - LOG_DEBUG("mag_info = %i", mag_info.type); + auto mag_info = i2cScanner->firstMagnetometer(); + magnetometer_found = mag_info.type != ScanI2C::DeviceType::NONE ? mag_info.address : magnetometer_found; + LOG_DEBUG("mag_info = %i", mag_info.type); #endif - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA260, - meshtastic_TelemetrySensorType_INA260); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA226, - meshtastic_TelemetrySensorType_INA226); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA219, - meshtastic_TelemetrySensorType_INA219); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA3221, - meshtastic_TelemetrySensorType_INA3221); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MAX17048, - meshtastic_TelemetrySensorType_MAX17048); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMC6310U, - meshtastic_TelemetrySensorType_QMC6310); - // TODO: Types need to be added meshtastic_TelemetrySensorType_QMC6310N - // scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMC6310N, - // meshtastic_TelemetrySensorType_QMC6310N); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMI8658, - meshtastic_TelemetrySensorType_QMI8658); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMC5883L, - meshtastic_TelemetrySensorType_QMC5883L); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::HMC5883L, - meshtastic_TelemetrySensorType_QMC5883L); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MMC5983MA, - meshtastic_TelemetrySensorType_MMC5983MA); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::ICM42607P, - meshtastic_TelemetrySensorType_ICM42607P); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MLX90614, - meshtastic_TelemetrySensorType_MLX90614); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::ICM20948, - meshtastic_TelemetrySensorType_ICM20948); - scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MAX30102, - meshtastic_TelemetrySensorType_MAX30102); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA260, meshtastic_TelemetrySensorType_INA260); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA226, meshtastic_TelemetrySensorType_INA226); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA219, meshtastic_TelemetrySensorType_INA219); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::INA3221, meshtastic_TelemetrySensorType_INA3221); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MAX17048, meshtastic_TelemetrySensorType_MAX17048); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMC6310U, meshtastic_TelemetrySensorType_QMC6310); + // TODO: Types need to be added meshtastic_TelemetrySensorType_QMC6310N + // scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMC6310N, meshtastic_TelemetrySensorType_QMC6310N); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMI8658, meshtastic_TelemetrySensorType_QMI8658); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::QMC5883L, meshtastic_TelemetrySensorType_QMC5883L); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::HMC5883L, meshtastic_TelemetrySensorType_QMC5883L); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MMC5983MA, meshtastic_TelemetrySensorType_MMC5983MA); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::ICM42607P, meshtastic_TelemetrySensorType_ICM42607P); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MLX90614, meshtastic_TelemetrySensorType_MLX90614); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::ICM20948, meshtastic_TelemetrySensorType_ICM20948); + scannerToSensorsMap(i2cScanner, ScanI2C::DeviceType::MAX30102, meshtastic_TelemetrySensorType_MAX30102); #endif #ifdef HAS_SDCARD - setupSDCard(); + setupSDCard(); #endif - // Hello - printInfo(); + // Hello + printInfo(); #ifdef BUILD_EPOCH - LOG_INFO("Build timestamp: %ld", BUILD_EPOCH); + LOG_INFO("Build timestamp: %ld", BUILD_EPOCH); #endif #ifdef ARCH_ESP32 - esp32Setup(); + esp32Setup(); #endif #ifdef ARCH_NRF52 - nrf52Setup(); -#endif -#ifdef ARCH_NRF54L15 - nrf54l15Setup(); + nrf52Setup(); #endif #ifdef ARCH_RP2040 - rp2040Setup(); + rp2040Setup(); #endif #ifdef ARCH_STM32WL stm32wlSetup(); #endif - // We do this as early as possible because this loads preferences from flash - // but we need to do this after main cpu init (esp32setup), because we need - // the random seed set - nodeDB = new NodeDB; + // We do this as early as possible because this loads preferences from flash + // but we need to do this after main cpu init (esp32setup), because we need the random seed set + nodeDB = new NodeDB; #ifdef ARCH_ESP32 - // Config is loaded now, and Bluetooth has not been initialized yet. If the - // saved config will keep Bluetooth inactive, return its reserved memory - // early. - esp32ReleaseBluetoothMemoryIfUnused(); + // Config is loaded now, and Bluetooth has not been initialized yet. If the + // saved config will keep Bluetooth inactive, return its reserved memory early. + esp32ReleaseBluetoothMemoryIfUnused(); #endif - // Initialize transmit history to persist broadcast throttle timers across - // reboots - TransmitHistory::getInstance()->loadFromDisk(); + // Initialize transmit history to persist broadcast throttle timers across reboots + TransmitHistory::getInstance()->loadFromDisk(); #if HAS_TFT - if (config.display.displaymode == - meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { - tftSetup(); - } + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + tftSetup(); + } #endif - router = new ReliableRouter(); - - // only play start melody when role is not tracker or sensor - if (config.power.is_power_saving == true && - IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_TRACKER, - meshtastic_Config_DeviceConfig_Role_TAK_TRACKER, - meshtastic_Config_DeviceConfig_Role_SENSOR)) - LOG_DEBUG("Tracker/Sensor: Skip start melody"); - else - playStartMelody(); + router = new ReliableRouter(); #if HAS_SCREEN - // fixed screen override? - // The geometry picks below are skipped on variants that pin the panel size with - // OLED_GEOMETRY_OVERRIDE (see the end of this block) - there they would only be dead stores. + // fixed screen override? + // The geometry picks below are skipped on variants that pin the panel size with + // OLED_GEOMETRY_OVERRIDE (see the end of this block) - there they would only be dead stores. #if defined(USE_SH1107) screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // set dimension of 128x128 #ifndef OLED_GEOMETRY_OVERRIDE screen_geometry = GEOMETRY_128_128; #endif #elif defined(USE_SH1107_128_64) - screen_model = - meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // keep dimension of - // 128x64 + screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // keep dimension of 128x64 #else - if (config.display.oled != - meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) { - screen_model = config.display.oled; + if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) { + screen_model = config.display.oled; // Fix: update geometry for SH1107 128x128 selected via menu if (screen_model == meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128) { @@ -970,104 +903,100 @@ void setup() { } #endif #ifdef OLED_GEOMETRY_OVERRIDE - // Per-variant geometry (e.g. 72x40 micro-OLEDs). Takes precedence over the - // default GEOMETRY_128_64 set at the top of setup(). - screen_geometry = OLED_GEOMETRY_OVERRIDE; + // Per-variant geometry (e.g. 72x40 micro-OLEDs). Takes precedence over the + // default GEOMETRY_128_64 set at the top of setup(). + screen_geometry = OLED_GEOMETRY_OVERRIDE; #endif #endif #if !MESHTASTIC_EXCLUDE_I2C #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ACCELEROMETER - if (acc_info.type != ScanI2C::DeviceType::NONE) { - accelerometerThread = new AccelerometerThread(acc_info.type); - } + if (acc_info.type != ScanI2C::DeviceType::NONE) { + accelerometerThread = new AccelerometerThread(acc_info.type); + } #endif #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_MAGNETOMETER - if (mag_info.type != ScanI2C::DeviceType::NONE) { - magnetometerThread = new MagnetometerThread(mag_info.type); - } + if (mag_info.type != ScanI2C::DeviceType::NONE) { + magnetometerThread = new MagnetometerThread(mag_info.type); + } #endif #if defined(HAS_NEOPIXEL) || defined(UNPHONE) || defined(RGBLED_RED) - ambientLightingThread = new AmbientLightingThread(ScanI2C::DeviceType::NONE); + ambientLightingThread = new AmbientLightingThread(ScanI2C::DeviceType::NONE); #elif !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) - if (rgb_found.type != ScanI2C::DeviceType::NONE) { - ambientLightingThread = new AmbientLightingThread(rgb_found.type); - } + if (rgb_found.type != ScanI2C::DeviceType::NONE) { + ambientLightingThread = new AmbientLightingThread(rgb_found.type); + } #endif #endif #ifdef HAS_DRV2605 #if defined(PIN_DRV_EN) - pinMode(PIN_DRV_EN, OUTPUT); - digitalWrite(PIN_DRV_EN, HIGH); - delay(10); + pinMode(PIN_DRV_EN, OUTPUT); + digitalWrite(PIN_DRV_EN, HIGH); + delay(10); #endif - drv.begin(); + drv.begin(); - // Bits Field Value Meaning - // 7 N_ERM_LRA 1 LRA mode (vs 0 = ERM) - // 6:4 FB_BRAKE_FACTOR 3 4× brake factor - // 3:2 LOOP_GAIN 1 medium loop gain - // 1:0 BEMF_GAIN 2 back-EMF gain + // Bits Field Value Meaning + // 7 N_ERM_LRA 1 LRA mode (vs 0 = ERM) + // 6:4 FB_BRAKE_FACTOR 3 4× brake factor + // 3:2 LOOP_GAIN 1 medium loop gain + // 1:0 BEMF_GAIN 2 back-EMF gain #if defined(DRV2605_USE_LRA) - drv.writeRegister8(DRV2605_REG_FEEDBACK, 0xB6); + drv.writeRegister8(DRV2605_REG_FEEDBACK, 0xB6); #endif - drv.selectLibrary(1); - // I2C trigger by sending 'go' command - drv.setMode(DRV2605_MODE_INTTRIG); + drv.selectLibrary(1); + // I2C trigger by sending 'go' command + drv.setMode(DRV2605_MODE_INTTRIG); #endif - // Init our SPI controller (must be before screen and lora) + // Init our SPI controller (must be before screen and lora) #ifdef ARCH_RP2040 #ifdef HW_SPI1_DEVICE - SPI1.setSCK(LORA_SCK); - SPI1.setTX(LORA_MOSI); - SPI1.setRX(LORA_MISO); - pinMode(LORA_CS, OUTPUT); - digitalWrite(LORA_CS, HIGH); - SPI1.begin(false); + SPI1.setSCK(LORA_SCK); + SPI1.setTX(LORA_MOSI); + SPI1.setRX(LORA_MISO); + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, HIGH); + SPI1.begin(false); #else // HW_SPI1_DEVICE - SPI.setSCK(LORA_SCK); - SPI.setTX(LORA_MOSI); - SPI.setRX(LORA_MISO); - SPI.begin(false); + SPI.setSCK(LORA_SCK); + SPI.setTX(LORA_MOSI); + SPI.setRX(LORA_MISO); + SPI.begin(false); #endif // HW_SPI1_DEVICE #elif ARCH_PORTDUINO - if (portduino_config.lora_spi_dev != "ch341") { - SPI.begin(); - } + if (portduino_config.lora_spi_dev != "ch341") { + SPI.begin(); + } #elif !defined(ARCH_ESP32) // ARCH_RP2040 #if defined(RAK3401) || defined(RAK13302) - pinMode(WB_IO2, OUTPUT); - digitalWrite(WB_IO2, HIGH); - SPI1.setPins(LORA_MISO, LORA_SCK, LORA_MOSI); - SPI1.begin(); + pinMode(WB_IO2, OUTPUT); + digitalWrite(WB_IO2, HIGH); + SPI1.setPins(LORA_MISO, LORA_SCK, LORA_MOSI); + SPI1.begin(); #else - SPI.begin(); + SPI.begin(); #endif #else - // ESP32 + // ESP32 #if defined(HW_SPI1_DEVICE) - SPI1.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); - LOG_DEBUG("SPI1.begin(SCK=%d, MISO=%d, MOSI=%d, NSS=%d)", LORA_SCK, LORA_MISO, - LORA_MOSI, LORA_CS); - SPI1.setFrequency(4000000); + SPI1.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); + LOG_DEBUG("SPI1.begin(SCK=%d, MISO=%d, MOSI=%d, NSS=%d)", LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); + SPI1.setFrequency(4000000); #else - SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); - LOG_DEBUG("SPI.begin(SCK=%d, MISO=%d, MOSI=%d, NSS=%d)", LORA_SCK, LORA_MISO, - LORA_MOSI, LORA_CS); - SPI.setFrequency(4000000); + SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); + LOG_DEBUG("SPI.begin(SCK=%d, MISO=%d, MOSI=%d, NSS=%d)", LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); + SPI.setFrequency(4000000); #endif #endif - // Initialize the screen first so we can show the logo while we start up - // everything else. + // Initialize the screen first so we can show the logo while we start up everything else. #if HAS_SCREEN - if (config.display.displaymode != - meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { #if defined(HAS_SPI_TFT) || defined(USE_EINK) || defined(USE_SPISSD1306) screen = std::make_unique(screen_found, screen_model, screen_geometry); @@ -1080,152 +1009,143 @@ void setup() { if (screen_found.port != ScanI2C::I2CPort::NO_I2C) screen = std::make_unique(screen_found, screen_model, screen_geometry); #endif - } + } #endif // HAS_SCREEN - // TODO Remove magic string - // setup TZ prior to time actions. + // TODO Remove magic string + // setup TZ prior to time actions. #if !MESHTASTIC_EXCLUDE_TZ - LOG_DEBUG( - "Use compiled/slipstreamed %s", - slipstreamTZString); // important, removing this clobbers our magic string - if (*config.device.tzdef && config.device.tzdef[0] != 0) { - LOG_DEBUG("Saved TZ: %s ", config.device.tzdef); - setenv("TZ", config.device.tzdef, 1); - } else { - if (strncmp((const char *)slipstreamTZString, "tzpl", 4) == 0) { - setenv("TZ", "GMT0", 1); + LOG_DEBUG("Use compiled/slipstreamed %s", slipstreamTZString); // important, removing this clobbers our magic string + if (*config.device.tzdef && config.device.tzdef[0] != 0) { + LOG_DEBUG("Saved TZ: %s ", config.device.tzdef); + setenv("TZ", config.device.tzdef, 1); } else { - setenv("TZ", (const char *)slipstreamTZString, 1); - strcpy(config.device.tzdef, (const char *)slipstreamTZString); + if (strncmp((const char *)slipstreamTZString, "tzpl", 4) == 0) { + setenv("TZ", "GMT0", 1); + } else { + setenv("TZ", (const char *)slipstreamTZString, 1); + strcpy(config.device.tzdef, (const char *)slipstreamTZString); + } } - } - tzset(); - LOG_DEBUG("Set Timezone to %s", getenv("TZ")); + tzset(); + LOG_DEBUG("Set Timezone to %s", getenv("TZ")); #endif - readFromRTC(); // read the main CPU RTC at first (in case we can't get GPS - // time) + readFromRTC(); // read the main CPU RTC at first (in case we can't get GPS time) #if !MESHTASTIC_EXCLUDE_GPS - // If we're taking on the repeater role, ignore GPS + // If we're taking on the repeater role, ignore GPS #ifdef SENSOR_GPS_CONFLICT - if (sensor_detected == false) { + if (sensor_detected == false) { #endif - if (HAS_GPS) { - if (config.position.gps_mode != - meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) { - gps = GPS::createGps(); - if (gps) { - gpsStatus->observe(&gps->newStatus); + if (HAS_GPS) { + if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) { + gps = GPS::createGps(); + if (gps) { + gpsStatus->observe(&gps->newStatus); - // If lora region is unset, disable the gps thread - if (config.lora.region == - meshtastic_Config_LoRaConfig_RegionCode_UNSET && - config.position.gps_mode == - meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - gps->disable(); - } - } else { - LOG_DEBUG("Run without GPS"); + // If lora region is unset, disable the gps thread + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && + config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + gps->disable(); + } + } else { + LOG_DEBUG("Run without GPS"); + } + } } - } - } #ifdef SENSOR_GPS_CONFLICT - } + } #endif #endif - nodeStatus->observe(&nodeDB->newStatus); + nodeStatus->observe(&nodeDB->newStatus); #ifdef HAS_I2S - LOG_DEBUG("Start audio thread"); - audioThread = new AudioThread(); + LOG_DEBUG("Start audio thread"); + audioThread = new AudioThread(); #endif #ifdef HAS_UDP_MULTICAST - LOG_DEBUG("Start multicast thread"); - udpHandler = new UdpMulticastHandler(); + LOG_DEBUG("Start multicast thread"); + udpHandler = new UdpMulticastHandler(); #ifdef ARCH_PORTDUINO - // FIXME: portduino does not ever call onNetworkConnected so call it here - // because I don't know what happen if I call onNetworkConnected there - if (config.network.enabled_protocols & - meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST) { - udpHandler->start(); - } + // FIXME: portduino does not ever call onNetworkConnected so call it here because I don't know what happen if I call + // onNetworkConnected there + if (config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST) { + udpHandler->start(); + } #endif #endif - service = new MeshService(); - service->init(); + service = new MeshService(); + service->init(); - // Set osk_found for trackball/encoder devices BEFORE setupModules so - // CannedMessageModule can detect it -#if defined(HAS_TRACKBALL) || \ - (defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2) + // Set osk_found for trackball/encoder devices BEFORE setupModules so CannedMessageModule can detect it +#if defined(HAS_TRACKBALL) || (defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2) #ifndef HAS_PHYSICAL_KEYBOARD - osk_found = true; + osk_found = true; #endif #endif - // Now that the mesh service is created, create any modules - setupModules(); + // Now that the mesh service is created, create any modules + setupModules(); + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.loadFromFlash(); +#endif #if !MESHTASTIC_EXCLUDE_I2C - // Inform modules about I2C devices - ScanI2CCompleted(i2cScanner.get()); - i2cScanner.reset(); + // Inform modules about I2C devices + ScanI2CCompleted(i2cScanner.get()); + i2cScanner.reset(); #endif #if !defined(MESHTASTIC_EXCLUDE_PKI) - // warn the user about a low entropy key - if (nodeDB->keyIsLowEntropy && !nodeDB->hasWarned) { - LOG_WARN(LOW_ENTROPY_WARNING); - meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); - if (cn) { - cn->level = meshtastic_LogRecord_Level_WARNING; - cn->time = getValidTime(RTCQualityFromNet); - sprintf(cn->message, LOW_ENTROPY_WARNING); - service->sendClientNotification(cn); + // warn the user about a low entropy key + if (nodeDB->keyIsLowEntropy && !nodeDB->hasWarned) { + LOG_WARN(LOW_ENTROPY_WARNING); + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + if (cn) { + cn->level = meshtastic_LogRecord_Level_WARNING; + cn->time = getValidTime(RTCQualityFromNet); + sprintf(cn->message, LOW_ENTROPY_WARNING); + service->sendClientNotification(cn); + } + nodeDB->hasWarned = true; } - nodeDB->hasWarned = true; - } #endif nodeDB->notifyPendingLicensedIdentityMigration(); #if !MESHTASTIC_EXCLUDE_INPUTBROKER - if (inputBroker) - inputBroker->Init(); + if (inputBroker) + inputBroker->Init(); #endif #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // After modules are setup, so we can observe modules - setupNicheGraphics(); + // After modules are setup, so we can observe modules + setupNicheGraphics(); #endif // Do this after service.init (because that clears error_code) #ifdef HAS_PMU - if (!pmu_found) - RECORD_CRITICALERROR( - meshtastic_CriticalErrorCode_NO_AXP192); // Record a hardware fault for - // missing hardware + if (!pmu_found) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_AXP192); // Record a hardware fault for missing hardware #endif #if !MESHTASTIC_EXCLUDE_I2C // Don't call screen setup until after nodedb is setup (because we need // the current region name) #if defined(HAS_SPI_TFT) || defined(USE_EINK) || defined(USE_SPISSD1306) - if (screen) - screen->setup(); + if (screen) + screen->setup(); #elif defined(ARCH_PORTDUINO) - if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || - portduino_config.displayPanel) && - config.display.displaymode != - meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { - screen->setup(); - } + if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) && + config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + screen->setup(); + } #else - if (screen_found.port != ScanI2C::I2CPort::NO_I2C && screen) - screen->setup(); + if (screen_found.port != ScanI2C::I2CPort::NO_I2C && screen) + screen->setup(); #endif #endif @@ -1246,104 +1166,106 @@ void setup() { LOG_DEBUG("SPI2 restarted after ST7701 init (SCK=%d, MISO=%d, MOSI=%d)", LORA_SCK, LORA_MISO, LORA_MOSI); #endif - auto rIf = initLoRa(); + auto rIf = initLoRa(); - lateInitVariant(); // Do board specific init (see extra_variants/README.md for - // documentation) + lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation) + + // Must follow lateInitVariant(): on I2S boards audioThread and the codec are only up by this point. + // Skipped for power-saving tracker/sensor roles. + if (config.power.is_power_saving == true && + IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_TRACKER, + meshtastic_Config_DeviceConfig_Role_TAK_TRACKER, meshtastic_Config_DeviceConfig_Role_SENSOR)) + LOG_DEBUG("Tracker/Sensor: Skip start melody"); + else + playStartMelody(); #if !MESHTASTIC_EXCLUDE_MQTT - mqttInit(); + mqttInit(); #endif #ifdef RF95_FAN_EN - // Ability to disable FAN if PIN has been set with RF95_FAN_EN. - // Make sure LoRa has been started before disabling FAN. - if (config.lora.pa_fan_disabled) - digitalWrite(RF95_FAN_EN, LOW ^ 0); + // Ability to disable FAN if PIN has been set with RF95_FAN_EN. + // Make sure LoRa has been started before disabling FAN. + if (config.lora.pa_fan_disabled) + digitalWrite(RF95_FAN_EN, LOW ^ 0); #endif #ifndef ARCH_PORTDUINO - // Initialize Wifi + // Initialize Wifi #if HAS_WIFI - initWifi(); + initWifi(); #endif #if HAS_ETHERNET - // Initialize Ethernet - initEthernet(); + // Initialize Ethernet + initEthernet(); #endif #endif #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WEBSERVER - // Start web server thread. - webServerThread = new WebServerThread(); + // Start web server thread. + webServerThread = new WebServerThread(); #endif #ifdef ARCH_PORTDUINO #if __has_include() - if (portduino_config.webserverport != -1) { - piwebServerThread = new PiWebServerThread(); - std::atexit([] { delete piwebServerThread; }); - } + if (portduino_config.webserverport != -1) { + piwebServerThread = new PiWebServerThread(); + std::atexit([] { delete piwebServerThread; }); + } #endif - initApiServer(TCPPort); + initApiServer(TCPPort); #endif - // Start airtime logger thread. - airTime = new AirTime(); + // Start airtime logger thread. + airTime = new AirTime(); - if (!rIf) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO); - else { + if (!rIf) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO); + else { #ifndef ARCH_PORTDUINO_WASM - // Log bit rate to debug output - LOG_DEBUG( - "LoRA bitrate = %f bytes / sec", - (float(meshtastic_Constants_DATA_PAYLOAD_LEN) / - (float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) * - 1000); + // Log bit rate to debug output + LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) / + (float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) * + 1000); #endif - router->addInterface(std::move(rIf)); - } + router->addInterface(std::move(rIf)); + } - // This must be _after_ service.init because we need our preferences loaded - // from flash to have proper timeout values - PowerFSM_setup(); // we will transition to ON in a couple of seconds, FIXME, - // only do this for cold boots, not waking from SDS - powerFSMthread = new PowerFSMThread(); + // This must be _after_ service.init because we need our preferences loaded from flash to have proper timeout values + PowerFSM_setup(); // we will transition to ON in a couple of seconds, FIXME, only do this for cold boots, not waking from SDS + powerFSMthread = new PowerFSMThread(); #if !HAS_TFT - setCPUFast(false); // 80MHz is fine for our slow peripherals + setCPUFast(false); // 80MHz is fine for our slow peripherals #endif #ifdef ARDUINO_ARCH_ESP32 - LOG_DEBUG("Free heap : %7d bytes", ESP.getFreeHeap()); - LOG_DEBUG("Free PSRAM : %7d bytes", ESP.getFreePsram()); + LOG_DEBUG("Free heap : %7d bytes", ESP.getFreeHeap()); + LOG_DEBUG("Free PSRAM : %7d bytes", ESP.getFreePsram()); #endif - // Log the per-subsystem heap breakdown now that the big allocations are done - memaudit::logBreakdown("boot"); + // Log the per-subsystem heap breakdown now that the big allocations are done + memaudit::logBreakdown("boot"); - // We manually run this to update the NodeStatus - nodeDB->notifyObservers(true); + // We manually run this to update the NodeStatus + nodeDB->notifyObservers(true); #ifdef MESHTASTIC_HEAP_WATERMARK_CHECK - // Opt-in CI guardrail: on nRF52840 static RAM growth eats the heap arena 1:1, - // so flag loudly when less than 20% of the heap is free at the end of - // setup(). - { - uint32_t heapTotal = memGet.getHeapSize(); - // Platforms without heap accounting report UINT32_MAX (or 0); skip those - if (heapTotal != 0 && heapTotal != UINT32_MAX) { - uint32_t heapFree = memGet.getFreeHeap(); - if (heapFree < heapTotal / 5) { - LOG_ERROR("Boot heap watermark: only %u of %u bytes free (<20%%)", - heapFree, heapTotal); - } + // Opt-in CI guardrail: on nRF52840 static RAM growth eats the heap arena 1:1, + // so flag loudly when less than 20% of the heap is free at the end of setup(). + { + uint32_t heapTotal = memGet.getHeapSize(); + // Platforms without heap accounting report UINT32_MAX (or 0); skip those + if (heapTotal != 0 && heapTotal != UINT32_MAX) { + uint32_t heapFree = memGet.getFreeHeap(); + if (heapFree < heapTotal / 5) { + LOG_ERROR("Boot heap watermark: only %u of %u bytes free (<20%%)", heapFree, heapTotal); + } + } } - } #endif #if defined(ARCH_PORTDUINO) && defined(_WIN32) @@ -1353,24 +1275,20 @@ void setup() { } #endif -uint32_t rebootAtMsec; // If not zero we will reboot at this time (used to - // reboot shortly after the update completes) -uint32_t shutdownAtMsec; // If not zero we will shutdown at this time (used to - // shutdown from python or mobile client) -bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for - // OTA handoff) - -#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && \ - defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) -volatile bool lockdownReloadPending; // see main.h - deferred NodeDB reload - // after lockdown unlock -volatile bool lockdownDisablePending; // see main.h - deferred decrypt-revert - // after lockdown disable +uint32_t rebootAtMsec; // If not zero we will reboot at this time (used to reboot shortly after the update completes) +uint32_t shutdownAtMsec; // If not zero we will shutdown at this time (used to shutdown from python or mobile client) +bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for OTA handoff) +#ifdef ARCH_STM32 +uint32_t enterDfuAtMsec; // If not zero, enter DFU mode at this millis() deadline (see main.h) #endif -// If a thread does something that might need for it to be rescheduled ASAP it -// can set this flag This will suppress the current delay and instead try to run -// ASAP. +#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) +volatile bool lockdownReloadPending; // see main.h - deferred NodeDB reload after lockdown unlock +volatile bool lockdownDisablePending; // see main.h - deferred decrypt-revert after lockdown disable +#endif + +// If a thread does something that might need for it to be rescheduled ASAP it can set this flag +// This will suppress the current delay and instead try to run ASAP. bool runASAP; // TODO find better home than main.cpp @@ -1379,7 +1297,11 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() meshtastic_DeviceMetadata deviceMetadata = meshtastic_DeviceMetadata_init_default; strncpy(deviceMetadata.firmware_version, optstr(APP_VERSION), sizeof(deviceMetadata.firmware_version)); deviceMetadata.device_state_version = DEVICESTATE_CUR_VER; +#if defined(ARCH_STM32WL) && HAS_CPU_SHUTDOWN + deviceMetadata.canShutdown = stm32wlRtcAvailable(); +#else deviceMetadata.canShutdown = pmu_found || HAS_CPU_SHUTDOWN; +#endif deviceMetadata.hasBluetooth = HAS_BLUETOOTH; deviceMetadata.hasWifi = HAS_WIFI; deviceMetadata.hasEthernet = HAS_ETHERNET; @@ -1389,67 +1311,61 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() deviceMetadata.hasRemoteHardware = moduleConfig.remote_hardware.enabled; deviceMetadata.excluded_modules = meshtastic_ExcludedModules_EXCLUDED_NONE; #if MESHTASTIC_EXCLUDE_REMOTEHARDWARE - deviceMetadata.excluded_modules |= - meshtastic_ExcludedModules_REMOTEHARDWARE_CONFIG; + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_REMOTEHARDWARE_CONFIG; #endif #if MESHTASTIC_EXCLUDE_AUDIO - deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AUDIO_CONFIG; + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AUDIO_CONFIG; #endif -// Option to explicitly include canned messages for edge cases, e.g. niche -// graphics -#if ((!HAS_SCREEN || NO_EXT_GPIO) || MESHTASTIC_EXCLUDE_CANNEDMESSAGES) && \ - !defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) - deviceMetadata.excluded_modules |= - meshtastic_ExcludedModules_CANNEDMSG_CONFIG; +#if MESHTASTIC_EXCLUDE_MQTT + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_MQTT_CONFIG; +#endif +#if MESHTASTIC_EXCLUDE_NEIGHBORINFO + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_NEIGHBORINFO_CONFIG; +#endif +#if MESHTASTIC_EXCLUDE_STOREFORWARD + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_STOREFORWARD_CONFIG; +#endif +#if !HAS_TELEMETRY + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_TELEMETRY_CONFIG; +#endif +// Option to explicitly include canned messages for edge cases, e.g. niche graphics +#if ((!HAS_SCREEN || NO_EXT_GPIO) || MESHTASTIC_EXCLUDE_CANNEDMESSAGES) && !defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_CANNEDMSG_CONFIG; #endif #if NO_EXT_GPIO || MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION - deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_EXTNOTIF_CONFIG; + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_EXTNOTIF_CONFIG; #endif -// Only edge case here is if we apply this a device with built in Accelerometer -// and want to detect interrupts We'll have to macro guard against those targets -// potentially +// Only edge case here is if we apply this a device with built in Accelerometer and want to detect interrupts +// We'll have to macro guard against those targets potentially #if NO_EXT_GPIO || MESHTASTIC_EXCLUDE_DETECTIONSENSOR - deviceMetadata.excluded_modules |= - meshtastic_ExcludedModules_DETECTIONSENSOR_CONFIG; + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_DETECTIONSENSOR_CONFIG; #endif -// If we don't have any GPIO and we don't have GPS OR we don't want too - no -// purpose in having serial config +// If we don't have any GPIO and we don't have GPS OR we don't want too - no purpose in having serial config #if NO_EXT_GPIO && NO_GPS || MESHTASTIC_EXCLUDE_SERIAL - deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_SERIAL_CONFIG; + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_SERIAL_CONFIG; #endif -#ifndef ARCH_ESP32 - deviceMetadata.excluded_modules |= - meshtastic_ExcludedModules_PAXCOUNTER_CONFIG; +#if !defined(ARCH_ESP32) || MESHTASTIC_EXCLUDE_PAXCOUNTER + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_PAXCOUNTER_CONFIG; #endif #if !defined(HAS_RGB_LED) && !RAK_4631 - deviceMetadata.excluded_modules |= - meshtastic_ExcludedModules_AMBIENTLIGHTING_CONFIG; + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AMBIENTLIGHTING_CONFIG; #endif // Range test is always excluded as of 2.8 deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_RANGETEST_CONFIG; // No bluetooth on these targets (yet): // Pico W / 2W may get it at some point -// Portduino and ESP32-C6 are excluded because we don't have a working bluetooth -// stacks integrated yet. -#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || \ - defined(CONFIG_IDF_TARGET_ESP32C6) - deviceMetadata.excluded_modules |= - meshtastic_ExcludedModules_BLUETOOTH_CONFIG; +// Portduino and ESP32-C6 are excluded because we don't have a working bluetooth stacks integrated yet. +#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6) || !HAS_BLUETOOTH + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_BLUETOOTH_CONFIG; #endif -#if defined(ARCH_NRF52) && \ - !HAS_ETHERNET // nrf52 doesn't have network unless it's a RAK ethernet - // gateway currently - deviceMetadata.excluded_modules |= - meshtastic_ExcludedModules_NETWORK_CONFIG; // No network on nRF52 -#elif defined(ARCH_RP2040) && !HAS_WIFI && !HAS_ETHERNET - deviceMetadata.excluded_modules |= - meshtastic_ExcludedModules_NETWORK_CONFIG; // No network on RP2040 +#if !HAS_NETWORKING // covers nRF52 (non-ethernet RAK) and RP2040 without WiFi/ethernet + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_NETWORK_CONFIG; #endif #if !(MESHTASTIC_EXCLUDE_PKI) - deviceMetadata.hasPKC = true; + deviceMetadata.hasPKC = true; #endif #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) deviceMetadata.has_xeddsa = true; @@ -1458,21 +1374,21 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() } #if !MESHTASTIC_EXCLUDE_I2C -void scannerToSensorsMap(const std::unique_ptr &i2cScanner, - ScanI2C::DeviceType deviceType, - meshtastic_TelemetrySensorType sensorType) { - auto found = i2cScanner->find(deviceType); - if (found.type != ScanI2C::DeviceType::NONE) { - nodeTelemetrySensorsMap[sensorType].first = found.address.address; - nodeTelemetrySensorsMap[sensorType].second = - i2cScanner->fetchI2CBus(found.address); - } +void scannerToSensorsMap(const std::unique_ptr &i2cScanner, ScanI2C::DeviceType deviceType, + meshtastic_TelemetrySensorType sensorType) +{ + auto found = i2cScanner->find(deviceType); + if (found.type != ScanI2C::DeviceType::NONE) { + nodeTelemetrySensorsMap[sensorType].first = found.address.address; + nodeTelemetrySensorsMap[sensorType].second = i2cScanner->fetchI2CBus(found.address); + } } #endif #ifndef PIO_UNIT_TESTING -void loop() { - runASAP = false; +void loop() +{ + runASAP = false; // The single writer of the monotonic wrap carry; every other caller only reads it. Time::serviceMonotonic(); @@ -1484,7 +1400,7 @@ void loop() { if (nodeDB->disableLockdownToPlaintext()) { LOG_INFO("Lockdown: disabled, reboot to normal mode"); PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else { // Revert failed mid-way (a file couldn't be decrypted/rewritten). // The DEK file is still present (it's deleted last), so the device @@ -1538,7 +1454,7 @@ void loop() { EncryptedStorage::lockNow(); PhoneAPI::revokeAllAuth(); PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "session_budget_exhausted", 0, 0, 0); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else { uint8_t newBoots = EncryptedStorage::consumeSessionBoot(); LOG_WARN("Lockdown: session expired, next budget slot (boots=%u left)", newBoots); @@ -1557,47 +1473,45 @@ void loop() { #endif #ifdef ARCH_ESP32 - esp32Loop(); + esp32Loop(); #endif #ifdef ARCH_NRF52 - nrf52Loop(); -#endif -#ifdef ARCH_NRF54L15 - nrf54l15Loop(); + nrf52Loop(); #endif #ifdef ARCH_RP2040 - rp2040Loop(); + rp2040Loop(); #endif - power->powerCommandsCheck(); + power->powerCommandsCheck(); - if (RadioLibInterface::instance != nullptr) { - static uint32_t lastRadioMissedIrqPoll; - if (!Throttle::isWithinTimespanMs(lastRadioMissedIrqPoll, 1000)) { - lastRadioMissedIrqPoll = millis(); - RadioLibInterface::instance->pollMissedIrqs(); - } + if (RadioLibInterface::instance != nullptr) { + static uint32_t lastRadioMissedIrqPoll; + if (!Throttle::isWithinTimespanMs(lastRadioMissedIrqPoll, 1000)) { + lastRadioMissedIrqPoll = millis(); + RadioLibInterface::instance->pollMissedIrqs(); + } - // Periodic AGC reset - warm sleep + recalibrate to prevent stuck AGC gain - static uint32_t lastAgcReset; - if (!Throttle::isWithinTimespanMs(lastAgcReset, AGC_RESET_INTERVAL_MS)) { - lastAgcReset = millis(); - RadioLibInterface::instance->resetAGC(); + // Periodic radio upkeep - re-arms RX if it was left off, else AGC reset (stuck-gain prevention) + static uint32_t lastAgcReset; + if (!Throttle::isWithinTimespanMs(lastAgcReset, AGC_RESET_INTERVAL_MS)) { + lastAgcReset = millis(); + // Sample before resetAGC(): recalibrating the frontend biases an RSSI read taken right after it. + RadioLibInterface::instance->updateNoiseFloor(); + RadioLibInterface::instance->periodicRadioMaintenance(); + } } - } #ifdef DEBUG_STACK - static uint32_t lastPrint = 0; - if (!Throttle::isWithinTimespanMs(lastPrint, 10 * 1000L)) { - lastPrint = millis(); - meshtastic::printThreadInfo("main"); - } + static uint32_t lastPrint = 0; + if (!Throttle::isWithinTimespanMs(lastPrint, 10 * 1000L)) { + lastPrint = millis(); + meshtastic::printThreadInfo("main"); + } #endif - service->loop(); -#if !MESHTASTIC_EXCLUDE_INPUTBROKER && defined(HAS_FREE_RTOS) && \ - !defined(ARCH_RP2040) - if (inputBroker) - inputBroker->processInputEventQueue(); + service->loop(); +#if !MESHTASTIC_EXCLUDE_INPUTBROKER && defined(HAS_FREE_RTOS) && !defined(ARCH_RP2040) + if (inputBroker) + inputBroker->processInputEventQueue(); #endif #if ARCH_PORTDUINO if (portduino_config.lora_spi_dev == "ch341" && ch341Hal != nullptr) { @@ -1629,40 +1543,44 @@ void loop() { if (screen) { screen->showSimpleBanner("Rebooting..."); } - rebootAtMsec = millis() + 25; + rebootAtMsec = Time::timerEndsAtMillis(25); } } -#if HAS_TFT - if (screen && portduino_config.displayPanel == x11 && - config.display.displaymode != - meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { - auto dispdev = screen->getDisplayDevice(); - if (dispdev) - static_cast(dispdev)->sdlLoop(); - } +#if HAS_TFT && HAS_SCREEN + if (screen && portduino_config.displayPanel == x11 && + config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + auto dispdev = screen->getDisplayDevice(); + if (dispdev) + static_cast(dispdev)->sdlLoop(); + } #endif #endif -#if (HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)) && \ - ENABLE_MESSAGE_PERSISTENCE - messageStoreAutosaveTick(); +#if (HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)) && ENABLE_MESSAGE_PERSISTENCE + messageStoreAutosaveTick(); #endif - long delayMsec = mainController.runOrDelay(); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.purgeExpired(); +#endif +#if !MESHTASTIC_EXCLUDE_WAYPOINT && ENABLE_WAYPOINT_PERSISTENCE + waypointStoreAutosaveTick(); +#endif + long delayMsec = mainController.runOrDelay(); - // We want to sleep as long as possible here - because it saves power - if (!runASAP && loopCanSleep()) { + // We want to sleep as long as possible here - because it saves power + if (!runASAP && loopCanSleep()) { #ifdef DEBUG_LOOP_TIMING - LOG_DEBUG("main loop delay: %d", delayMsec); + LOG_DEBUG("main loop delay: %d", delayMsec); #endif #ifdef ARCH_PORTDUINO_WASM - // Single-threaded wasm: mainDelay's InterruptableDelay is a pthread - // cond/mutex semaphore that no other thread can ever give(), and - // emscripten's single-threaded pthread_cond_timedwait busy-spins. Suspend - // cooperatively via Asyncify instead, capping idle sleep so the per-tick - // IRQ poll latency stays bounded (RX/TX-done is detected by polling). - emscripten_sleep(delayMsec > 50 ? 50 : delayMsec); + // Single-threaded wasm: mainDelay's InterruptableDelay is a pthread + // cond/mutex semaphore that no other thread can ever give(), and + // emscripten's single-threaded pthread_cond_timedwait busy-spins. Suspend + // cooperatively via Asyncify instead, capping idle sleep so the per-tick + // IRQ poll latency stays bounded (RX/TX-done is detected by polling). + emscripten_sleep(delayMsec > 50 ? 50 : delayMsec); #else - mainDelay.delay(delayMsec); + mainDelay.delay(delayMsec); #endif - } + } } #endif diff --git a/src/main.h b/src/main.h index 19b1bace0d..29a76aa2f6 100644 --- a/src/main.h +++ b/src/main.h @@ -20,10 +20,6 @@ extern NimbleBluetooth *nimbleBluetooth; #include "NRF52Bluetooth.h" extern NRF52Bluetooth *nrf52Bluetooth; #endif -#ifdef ARCH_NRF54L15 -#include "NRF54L15Bluetooth.h" -extern NRF54L15Bluetooth *nrf54l15Bluetooth; -#endif #if !MESHTASTIC_EXCLUDE_I2C #include "detect/ScanI2CTwoWire.h" #endif @@ -92,6 +88,9 @@ extern uint32_t timeLastPowered; extern uint32_t rebootAtMsec; extern uint32_t shutdownAtMsec; extern bool suppressRebootBanner; +#ifdef ARCH_STM32 +extern uint32_t enterDfuAtMsec; // 0 = unset; else millis() deadline for the deferred DFU jump +#endif #if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) // Set by PhoneAPI::handleLockdownAuthInline after a successful unlock. diff --git a/src/memGet.cpp b/src/memGet.cpp index 49d1953c25..4283bb2659 100644 --- a/src/memGet.cpp +++ b/src/memGet.cpp @@ -79,6 +79,32 @@ uint32_t MemGet::getHeapSize() #endif } +/** + * Returns the lowest the free heap has ever been since boot. + * @return uint32_t Low watermark in bytes, or 0 if the platform can't report it. + */ +uint32_t MemGet::getMinFreeHeap() +{ +#ifdef ARCH_ESP32 + return ESP.getMinFreeHeap(); +#else + return 0; +#endif +} + +/** + * Returns the largest contiguous block malloc() could still return. + * @return uint32_t Block size in bytes, or 0 if the platform can't report it. + */ +uint32_t MemGet::getMaxAllocHeap() +{ +#ifdef ARCH_ESP32 + return ESP.getMaxAllocHeap(); +#else + return 0; +#endif +} + /** * Returns the amount of free psram memory in bytes. * diff --git a/src/memGet.h b/src/memGet.h index 130d2ca7ea..69348b4dad 100644 --- a/src/memGet.h +++ b/src/memGet.h @@ -9,6 +9,10 @@ class MemGet public: uint32_t getFreeHeap(); uint32_t getHeapSize(); + // Lowest free heap seen since boot, or 0 where the platform can't report it + uint32_t getMinFreeHeap(); + // Largest block malloc() could still return, or 0 where the platform can't report it + uint32_t getMaxAllocHeap(); uint32_t getFreePsram(); uint32_t getPsramSize(); }; diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 5860c6fc74..d79dada511 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -47,6 +47,12 @@ int16_t Channels::generateHash(ChannelIndex channelNum) h ^= xorHash(k.bytes, k.length); + // Differentiate AEAD channels in routing so AEAD and non-AEAD + // channels with the same PSK have different hashes + auto &ch = getByIndex(channelNum); + if (ch.has_settings && ch.settings.use_aead) + h ^= 0xAE; + return h; } } @@ -71,6 +77,13 @@ meshtastic_Channel &Channels::fixupChannel(ChannelIndex chIndex) // Convert the old string "Default" to our new short representation if (strcmp(meshtastic_channelSettings.name, "Default") == 0) *meshtastic_channelSettings.name = '\0'; + + // AEAD needs key material. Left set on a channel that resolves to no PSK it would make every + // send fail with BAD_REQUEST and every receive drop, with nothing in the config to show why. + if (meshtastic_channelSettings.use_aead && getKey(chIndex).length <= 0) { + LOG_WARN("Channel %d has AEAD enabled but no PSK; clearing use_aead", chIndex); + meshtastic_channelSettings.use_aead = false; + } } hashes[chIndex] = generateHash(chIndex); @@ -166,67 +179,70 @@ void Channels::initDefaultChannel(ChannelIndex chIndex) ch.has_settings = true; ch.role = chIndex == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; + static_assert(MAX_NUM_CHANNELS == 8, "the userPrefs switch below covers indices 0-7"); + +// bin/platformio-custom.py completes every field of an index the vendor configured, so no field is +// individually optional here and a new index costs one case rather than eighteen lines. +#define USERPREFS_APPLY_CHANNEL(n) \ + do { \ + static const uint8_t userprefsPsk[] = USERPREFS_CHANNEL_##n##_PSK; \ + static_assert(sizeof(userprefsPsk) <= sizeof(channelSettings.psk.bytes), \ + "USERPREFS_CHANNEL_" #n "_PSK is wider than psk.bytes"); \ + memcpy(channelSettings.psk.bytes, userprefsPsk, sizeof(userprefsPsk)); \ + channelSettings.psk.size = sizeof(userprefsPsk); \ + strncpy(channelSettings.name, (const char *)USERPREFS_CHANNEL_##n##_NAME, sizeof(channelSettings.name) - 1); \ + channelSettings.module_settings.position_precision = USERPREFS_CHANNEL_##n##_PRECISION; \ + channelSettings.module_settings.is_muted = USERPREFS_CHANNEL_##n##_IS_MUTED; \ + channelSettings.uplink_enabled = USERPREFS_CHANNEL_##n##_UPLINK_ENABLED; \ + channelSettings.downlink_enabled = USERPREFS_CHANNEL_##n##_DOWNLINK_ENABLED; \ + } while (0) + switch (chIndex) { - case 0: #ifdef USERPREFS_CHANNEL_0_PSK - static const uint8_t defaultpsk0[] = USERPREFS_CHANNEL_0_PSK; - memcpy(channelSettings.psk.bytes, defaultpsk0, sizeof(defaultpsk0)); - channelSettings.psk.size = sizeof(defaultpsk0); -#endif -#ifdef USERPREFS_CHANNEL_0_NAME - strcpy(channelSettings.name, (const char *)USERPREFS_CHANNEL_0_NAME); -#endif -#ifdef USERPREFS_CHANNEL_0_PRECISION - channelSettings.module_settings.position_precision = USERPREFS_CHANNEL_0_PRECISION; -#endif -#ifdef USERPREFS_CHANNEL_0_UPLINK_ENABLED - channelSettings.uplink_enabled = USERPREFS_CHANNEL_0_UPLINK_ENABLED; -#endif -#ifdef USERPREFS_CHANNEL_0_DOWNLINK_ENABLED - channelSettings.downlink_enabled = USERPREFS_CHANNEL_0_DOWNLINK_ENABLED; -#endif + case 0: + USERPREFS_APPLY_CHANNEL(0); break; - case 1: +#endif #ifdef USERPREFS_CHANNEL_1_PSK - static const uint8_t defaultpsk1[] = USERPREFS_CHANNEL_1_PSK; - memcpy(channelSettings.psk.bytes, defaultpsk1, sizeof(defaultpsk1)); - channelSettings.psk.size = sizeof(defaultpsk1); -#endif -#ifdef USERPREFS_CHANNEL_1_NAME - strcpy(channelSettings.name, (const char *)USERPREFS_CHANNEL_1_NAME); -#endif -#ifdef USERPREFS_CHANNEL_1_PRECISION - channelSettings.module_settings.position_precision = USERPREFS_CHANNEL_1_PRECISION; -#endif -#ifdef USERPREFS_CHANNEL_1_UPLINK_ENABLED - channelSettings.uplink_enabled = USERPREFS_CHANNEL_1_UPLINK_ENABLED; -#endif -#ifdef USERPREFS_CHANNEL_1_DOWNLINK_ENABLED - channelSettings.downlink_enabled = USERPREFS_CHANNEL_1_DOWNLINK_ENABLED; -#endif + case 1: + USERPREFS_APPLY_CHANNEL(1); break; - case 2: +#endif #ifdef USERPREFS_CHANNEL_2_PSK - static const uint8_t defaultpsk2[] = USERPREFS_CHANNEL_2_PSK; - memcpy(channelSettings.psk.bytes, defaultpsk2, sizeof(defaultpsk2)); - channelSettings.psk.size = sizeof(defaultpsk2); -#endif -#ifdef USERPREFS_CHANNEL_2_NAME - strcpy(channelSettings.name, (const char *)USERPREFS_CHANNEL_2_NAME); -#endif -#ifdef USERPREFS_CHANNEL_2_PRECISION - channelSettings.module_settings.position_precision = USERPREFS_CHANNEL_2_PRECISION; -#endif -#ifdef USERPREFS_CHANNEL_2_UPLINK_ENABLED - channelSettings.uplink_enabled = USERPREFS_CHANNEL_2_UPLINK_ENABLED; -#endif -#ifdef USERPREFS_CHANNEL_2_DOWNLINK_ENABLED - channelSettings.downlink_enabled = USERPREFS_CHANNEL_2_DOWNLINK_ENABLED; -#endif + case 2: + USERPREFS_APPLY_CHANNEL(2); break; +#endif +#ifdef USERPREFS_CHANNEL_3_PSK + case 3: + USERPREFS_APPLY_CHANNEL(3); + break; +#endif +#ifdef USERPREFS_CHANNEL_4_PSK + case 4: + USERPREFS_APPLY_CHANNEL(4); + break; +#endif +#ifdef USERPREFS_CHANNEL_5_PSK + case 5: + USERPREFS_APPLY_CHANNEL(5); + break; +#endif +#ifdef USERPREFS_CHANNEL_6_PSK + case 6: + USERPREFS_APPLY_CHANNEL(6); + break; +#endif +#ifdef USERPREFS_CHANNEL_7_PSK + case 7: + USERPREFS_APPLY_CHANNEL(7); + break; +#endif default: break; } + +#undef USERPREFS_APPLY_CHANNEL } CryptoKey Channels::getKey(ChannelIndex chIndex) @@ -314,16 +330,21 @@ void Channels::initDefaults() void Channels::onConfigChanged() { - // Make sure the phone hasn't mucked anything up + // Make sure the phone hasn't mucked anything up. Settle the primary first: fixupChannel() + // hashes through getKey(), which follows a keyless secondary to primaryIndex. bool hasPrimary = false; for (int i = 0; i < channelFile.channels_count; i++) { - const meshtastic_Channel &ch = fixupChannel(i); + const meshtastic_Channel &ch = getByIndex(i); - if (ch.role == meshtastic_Channel_Role_PRIMARY) { + if (ch.has_settings && ch.role == meshtastic_Channel_Role_PRIMARY) { primaryIndex = i; hasPrimary = true; } } + + for (int i = 0; i < channelFile.channels_count; i++) + fixupChannel(i); + // Enforce the invariant that primaryIndex references a PRIMARY channel: a malformed config can // demote every slot, which would leave all getPrimaryIndex() readers on a stale non-primary slot if (!hasPrimary) { @@ -336,7 +357,9 @@ void Channels::onConfigChanged() initDefaultChannel(0); } LOG_WARN("Config has no PRIMARY channel, restored one at slot %u", primaryIndex); - fixupChannel(primaryIndex); + // The key every keyless secondary resolves through just changed, so re-run the lot + for (int i = 0; i < channelFile.channels_count; i++) + fixupChannel(i); } #if !MESHTASTIC_EXCLUDE_MQTT if (channels.anyMqttEnabled() && mqtt && !mqtt->isEnabled()) { @@ -571,6 +594,12 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash) return false; } +bool Channels::isAEADEnabled(ChannelIndex chIndex) +{ + auto &ch = getByIndex(chIndex); + return ch.has_settings && ch.settings.use_aead; +} + /** Given a channel index setup crypto for encoding that channel (or the primary channel if that channel is unsecured) * * This method is called before encoding outbound packets @@ -581,3 +610,12 @@ int16_t Channels::setActiveByIndex(ChannelIndex channelIndex) { return setCrypto(channelIndex); } + +bool isMutedForPacket(const meshtastic_MeshPacket &mp) +{ + if (!isBroadcast(mp.to) && isToUs(&mp)) + return nodeInfoLiteIsMuted(nodeDB->getMeshNode(mp.from)); + + const meshtastic_Channel &ch = channels.getByIndex(mp.channel ? mp.channel : channels.getPrimaryIndex()); + return ch.settings.has_module_settings && ch.settings.module_settings.is_muted; +} diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 27833130c0..6fd9f807a4 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -119,6 +119,15 @@ class Channels int16_t getHash(ChannelIndex i) { return hashes[i]; } + /** Return true if the channel has AEAD (authenticated encryption) enabled */ + bool isAEADEnabled(ChannelIndex chIndex); + + /** + * Return the key used for encrypting this channel (if channel is secondary and no key provided, use the primary channel's + * PSK) + */ + CryptoKey getKey(ChannelIndex chIndex); + private: /** Given a channel index, change to use the crypto key specified by that index * @@ -145,12 +154,6 @@ class Channels * Write default channels defined in UserPrefs */ void initDefaultChannel(ChannelIndex chIndex); - - /** - * Return the key used for encrypting this channel (if channel is secondary and no key provided, use the primary channel's - * PSK) - */ - CryptoKey getKey(ChannelIndex chIndex); }; /// Singleton channel table @@ -160,6 +163,10 @@ extern Channels channels; static const uint8_t defaultpsk[] = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01}; +/// True if the user muted the source of this packet: the sender for a DM addressed to us, +/// otherwise the channel it arrived on. +bool isMutedForPacket(const meshtastic_MeshPacket &mp); + /// True if a getKey()-resolved key offers no privacy: length 0 (off) or the public defaultpsk family. Pure; for tests. bool cryptoKeyIsPublic(const CryptoKey &key); diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index bd199e8fd9..ac35773894 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -1,17 +1,17 @@ #include "CryptoEngine.h" // #include "NodeDB.h" +#include "aes-ccm.h" #include "architecture.h" +#include #include #if !(MESHTASTIC_EXCLUDE_PKI) #include "HardwareRNG.h" #include "NodeDB.h" -#include "aes-ccm.h" #include "meshUtils.h" #include #include #include -#include #if !(MESHTASTIC_EXCLUDE_XEDDSA) #include "XEdDSA.h" @@ -292,6 +292,8 @@ void CryptoEngine::setDHPrivateKey(uint8_t *_private_key) memcpy(private_key, _private_key, 32); } +#endif // !(MESHTASTIC_EXCLUDE_PKI) + /** * Hash arbitrary data using SHA256. * @@ -314,11 +316,16 @@ void CryptoEngine::hash(uint8_t *bytes, size_t numBytes) hash.finalize(bytes, 32); } +// aes-ccm.cpp drives the block cipher through these two, and it is compiled in every build, +// so they must stay outside the PKI guard or MESHTASTIC_EXCLUDE_PKI=1 fails to link. void CryptoEngine::aesSetKey(const uint8_t *key_bytes, size_t key_len) { aes = nullptr; - if (key_len != 0) { - aes = std::unique_ptr(new AESSmall256()); + if (key_len == 16) { + aes = std::unique_ptr(new AESSmall128()); + aes->setKey(key_bytes, 16); + } else if (key_len != 0) { + aes = std::unique_ptr(new AESSmall256()); aes->setKey(key_bytes, key_len); } } @@ -328,6 +335,8 @@ void CryptoEngine::aesEncrypt(uint8_t *in, uint8_t *out) aes->encryptBlock(out, in); } +#if !(MESHTASTIC_EXCLUDE_PKI) + bool CryptoEngine::setDHPublicKey(uint8_t *pubKey) { uint8_t local_priv[32]; @@ -369,6 +378,50 @@ bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_pu } #endif + +// AAD layout: [fromNode (4)] [toNode (4)], in the same native byte order initNonce uses. +static void initAad(uint32_t fromNode, uint32_t toNode, uint8_t *aad) +{ + // memcpy to avoid breaking strict-aliasing, as initNonce does + memcpy(aad, &fromNode, sizeof(uint32_t)); + memcpy(aad + sizeof(uint32_t), &toNode, sizeof(uint32_t)); +} + +bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint32_t toNode, uint64_t packetId, size_t numBytes, + const uint8_t *plaintext, uint8_t *ciphertextWithTag) +{ + // length is int8_t and the aes_ccm_* key length is size_t, so the -1 "invalid key" + // sentinel would widen into a huge unsigned length rather than being rejected. + if (psk.length <= 0) { + LOG_ERROR("AEAD encryption requires a valid, non-empty PSK"); + return false; + } + initNonce(fromNode, packetId); + uint8_t aad[AEAD_AAD_SIZE]; + initAad(fromNode, toNode, aad); + // Output layout: [ciphertext (numBytes)] [auth_tag (AEAD_TAG_SIZE bytes)] + return aes_ccm_ae(psk.bytes, psk.length, nonce, AEAD_TAG_SIZE, plaintext, numBytes, aad, sizeof(aad), ciphertextWithTag, + ciphertextWithTag + numBytes) == 0; +} + +bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint32_t toNode, uint64_t packetId, + size_t totalBytes, const uint8_t *ciphertextWithTag, uint8_t *plaintext) +{ + if (psk.length <= 0) { + LOG_ERROR("AEAD decryption requires a valid, non-empty PSK"); + return false; + } + if (totalBytes <= AEAD_TAG_SIZE) + return false; + initNonce(fromNode, packetId); + uint8_t aad[AEAD_AAD_SIZE]; + initAad(fromNode, toNode, aad); + size_t crypt_len = totalBytes - AEAD_TAG_SIZE; + const uint8_t *auth = ciphertextWithTag + crypt_len; + return aes_ccm_ad(psk.bytes, psk.length, nonce, AEAD_TAG_SIZE, ciphertextWithTag, crypt_len, aad, sizeof(aad), auth, + plaintext); +} + concurrency::Lock *cryptLock; void CryptoEngine::setKey(const CryptoKey &k) diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 95c7eb8ece..b23df129fc 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -57,7 +57,6 @@ class CryptoEngine virtual bool decryptCurve25519(uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut); virtual bool setDHPublicKey(uint8_t *publicKey); - virtual void hash(uint8_t *bytes, size_t numBytes); // Temporary holder for a peer's not-yet-verified public key, learned in-band during an // in-progress key-verification handshake before it is committed to NodeDB. Lets the Router @@ -69,13 +68,26 @@ class CryptoEngine void clearPendingPublicKey(); // Fills `out` (size set to 32) and returns true iff a pending key is held for `node`. bool getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_public_key_t &out); +#endif + + // Plain SHA256; outside the guard because PortduinoGlue uses it on EXCLUDE_PKI builds. + virtual void hash(uint8_t *bytes, size_t numBytes); virtual void aesSetKey(const uint8_t *key, size_t key_len); virtual void aesEncrypt(uint8_t *in, uint8_t *out); - std::unique_ptr aes = nullptr; + std::unique_ptr aes = nullptr; -#endif + static constexpr size_t AEAD_TAG_SIZE = 12; + // Sender and destination IDs are authenticated as associated data: the nonce already binds + // `from` and the packet id, and the hop fields are left out because relays rewrite them. + static constexpr size_t AEAD_AAD_SIZE = 2 * sizeof(uint32_t); + + virtual bool encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint32_t toNode, uint64_t packetId, size_t numBytes, + const uint8_t *plaintext, uint8_t *ciphertextWithTag); + + virtual bool decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint32_t toNode, uint64_t packetId, size_t totalBytes, + const uint8_t *ciphertextWithTag, uint8_t *plaintext); /** * Set the key used for encrypt, decrypt. diff --git a/src/mesh/Default.h b/src/mesh/Default.h index e5e8b8ab19..b6d93eb218 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -21,7 +21,9 @@ #define default_broadcast_smart_minimum_interval_secs 5 * 60 // Floor for our own position broadcasts when stationary (unchanged beyond the broadcast // precision) or fixed_position: identical positions get deduped by traffic management anyway. -#define default_position_stationary_broadcast_secs (12 * 60 * 60) +// Held one hour above default_traffic_mgmt_position_min_interval_secs so this refresh clears +// the receivers' dedup window instead of being dropped as a duplicate. +#define default_position_stationary_broadcast_secs (6 * 60 * 60) #define min_default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60) #define min_default_broadcast_smart_minimum_interval_secs 5 * 60 #define default_wait_bluetooth_secs IF_ROUTER(1, 60) @@ -39,9 +41,11 @@ enum class TrafficType { POSITION, TELEMETRY }; // Traffic management defaults -#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m) -#define default_traffic_mgmt_position_min_interval_secs (11 * 60 * 60) // 11 hours between identical positions -// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 11h default). +#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m) +// Kept below default_position_stationary_broadcast_secs so a stationary node's periodic refresh +// is not deduped away by its neighbours. +#define default_traffic_mgmt_position_min_interval_secs (5 * 60 * 60) // 5 hours between identical positions +// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 5h default). #define default_traffic_mgmt_tracker_position_min_interval_secs (60 * 60) // 1 hour // Role cap: lost-and-found origins may refresh a duplicate position this often, so a lost // device updates frequently without flooding. (Quantised to the dedup tick: ~2 ticks.) @@ -54,6 +58,17 @@ enum class TrafficType { POSITION, TELEMETRY }; #define default_hop_scaling_min_target_nodes_floor 5 // minimum allowed min_target_nodes #define default_hop_scaling_max_target_nodes_ceiling 512 // maximum allowed max_target_nodes +// Congestion gate: hop scaling only applies while the channel is measurably busy. Engage sits below +// AirTime::polite_channel_util_percent (25) so hops ease back before the polite gate withholds traffic. +#define default_hop_scaling_congestion_engage_pct 20 // smoothed channel utilization that engages scaling +#define default_hop_scaling_congestion_release_pct 12 // smoothed channel utilization that releases it +#define default_hop_scaling_congestion_confirm_runs 3 // consecutive 5-min samples needed to flip either way +// Above this the hop walk's one-hop extension is cut to its strictest setting: the radio is +// already withholding metadata at the polite gate, so an extra relay is the wrong thing to spend. +#define default_hop_scaling_congestion_strict_pct 25 // = AirTime::polite_channel_util_percent +// Hop floor for the infrastructure roles Router.cpp already groups for zero-cost hops. +#define default_hop_scaling_infrastructure_hop_floor 3 + #ifdef USERPREFS_RINGTONE_NAG_SECS #define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS #else diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index 43a3e03854..451b8d8b74 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -10,7 +10,9 @@ #include "RadioLibInterface.h" #endif -#if defined(ARCH_NRF52) +#if defined(ARCH_NRF54L) +#include +#elif defined(ARCH_NRF52) #include extern Adafruit_nRFCrypto nRFCrypto; #elif defined(ARCH_ESP32) @@ -107,7 +109,12 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) bool filled = false; -#if defined(ARCH_NRF52) +#if defined(ARCH_NRF54L) + // CRACEN TRNG + nRF54Crypto.begin(); + filled = nRF54Crypto.random(buffer, length); + nRF54Crypto.end(); +#elif defined(ARCH_NRF52) // The Nordic SDK RNG provides cryptographic-quality randomness backed by hardware. nRFCrypto.begin(); auto result = nRFCrypto.Random.generate(buffer, length); diff --git a/src/mesh/IndicatorSerial.cpp b/src/mesh/IndicatorSerial.cpp index 74608b5fa9..e146178954 100644 --- a/src/mesh/IndicatorSerial.cpp +++ b/src/mesh/IndicatorSerial.cpp @@ -1,6 +1,7 @@ #ifdef SENSECAP_INDICATOR #include "IndicatorSerial.h" +#include "UptimeClock.h" #include "concurrency/LockGuard.h" #include "mesh/comms/UARTProxy.h" #include @@ -61,7 +62,7 @@ void SensecapIndicator::probe_link() msg.data.ping = meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT; stamp_request(msg); send_uplink_unlocked(msg); - last_probe = millis(); + last_probe = Time::skipZero(Time::getMillis()); } // Read whatever is available on the link and process complete packets diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 8be0b64139..fdad36fca8 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -172,6 +172,8 @@ template bool LR11x0Interface::init() res = tryBegin(3, attemptVoltage); } + resolvedTcxoVoltage = attemptVoltage; + // \todo Display actual typename of the adapter, not just `LR11x0` LOG_INFO("LR11x0 init result %d", res); @@ -183,6 +185,8 @@ template bool LR11x0Interface::init() if (lora.updateFirmware(lr11xx_firmware_image, LR11XX_FIRMWARE_IMAGE_SIZE, true) == RADIOLIB_ERR_NONE) { LOG_INFO("LR1110 firmware recovery OK, re-init radio"); res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + if (res == RADIOLIB_ERR_NONE) + resolvedTcxoVoltage = tcxoVoltage; } #endif if (res != RADIOLIB_ERR_NONE) @@ -220,6 +224,7 @@ template bool LR11x0Interface::init() LOG_ERROR("LR11x0 re-init after firmware update failed %s%d", radioLibErr, res); return false; } + resolvedTcxoVoltage = tcxoVoltage; if (lora.getVersionInfo(&version) == RADIOLIB_ERR_NONE) { transceiverFw = ((uint16_t)version.fwMajor << 8) | version.fwMinor; @@ -269,28 +274,32 @@ template bool LR11x0Interface::init() return res == RADIOLIB_ERR_NONE; } -template bool LR11x0Interface::reconfigure() +template int16_t LR11x0Interface::programModemParams() { - RadioLibInterface::reconfigure(); - - // set mode to standby - setStandby(); - // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + int16_t err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + return err; + } err = lora.setBandwidth(bw, wideLora() && (getFreq() > 1000.0f)); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + return err; + } err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setCodingRate(%u) %s%d", cr, radioLibErr, err); + return err; + } err = lora.setSyncWord(syncWord); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setSyncWord %s%d", radioLibErr, err); + return err; + } if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range limitPower(LR1120_MAX_POWER); @@ -299,20 +308,89 @@ template bool LR11x0Interface::reconfigure() } err = lora.setPreambleLength(preambleLength); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + return err; + } err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err); + return err; + } err = lora.setOutputPower(power); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setOutputPower(%d) %s%d", power, radioLibErr, err); + return err; + } // Apply RX gain mode - valid in STDBY, matches resetAGC() pattern err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); if (err != RADIOLIB_ERR_NONE) LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err); + return RADIOLIB_ERR_NONE; +} + +template bool LR11x0Interface::reinitChip() +{ + // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw + // config value, and the recovery path reaches begin() without passing through the params clamp + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + limitPower(LR1120_MAX_POWER); + } else { + limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range + } + + int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, resolvedTcxoVoltage); + if (res == RADIOLIB_ERR_NONE) + res = lora.setCRC(2); + if (res == RADIOLIB_ERR_NONE) + res = lora.setRegulatorDCDC(); + +#ifdef LR11X0_DIO_AS_RF_SWITCH + bool dioAsRfSwitch = true; +#elif defined(ARCH_PORTDUINO) + bool dioAsRfSwitch = portduino_config.has_rfswitch_table; +#else + bool dioAsRfSwitch = false; +#endif + + // setRfSwitchTable() pushed the DIO switch config to the chip when init() called it; a reset chip has + // lost it and begin() does not restore it + if (res == RADIOLIB_ERR_NONE && dioAsRfSwitch) + lora.setRfSwitchTable(rfswitch_dio_pins, rfswitch_table); + + if (res != RADIOLIB_ERR_NONE) + LOG_ERROR("LR11x0 re-init failed %s%d", radioLibErr, res); + return res == RADIOLIB_ERR_NONE; +} + +template bool LR11x0Interface::reconfigure() +{ + RadioLibInterface::reconfigure(); + + // set mode to standby - a chip that lost its state to a reset/brownout can time out here (-707), + // so don't let setStandby()'s assert fire before the recovery below gets a chance + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = programModemParams(); + + if (err != RADIOLIB_ERR_NONE) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration - packet type included - to a chip-internal reset or brownout. + // Recover in place: begin() hardware-resets the chip and restores the LoRa packet type. Crashing + // here instead would reboot before MeshService persists the config change that triggered us. + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + LOG_ERROR("LR11x0 rejected modem params, chip state lost? Full re-init"); + if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 unrecoverable %s%d, radio down until reboot", radioLibErr, err); + return false; + } + LOG_INFO("LR11x0 recovered after re-init"); + } + startReceive(); // restart receiving return true; @@ -323,23 +401,28 @@ template void LR11x0Interface::clearRadioIsr() lora.clearIrqAction(); } -template void LR11x0Interface::setStandby() +template int16_t LR11x0Interface::trySetStandby() { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + int16_t err = lora.standby(); if (err != RADIOLIB_ERR_NONE) { LOG_DEBUG("LR11x0 standby failed, err %d", err); } - assert(err == RADIOLIB_ERR_NONE); - isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void LR11x0Interface::setStandby() +{ + int16_t err = trySetStandby(); + assert(err == RADIOLIB_ERR_NONE); } /** @@ -370,16 +453,31 @@ template void LR11x0Interface::startReceive() sleep(); #else - setStandby(); + int16_t err = trySetStandby(); - lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. + if (err == RADIOLIB_ERR_NONE) { + lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. - // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. - int err = - lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - if (err) + // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. + err = + lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("StartReceive error: %d", err); - assert(err == RADIOLIB_ERR_NONE); + if (maybeRecoverChipStateLoss()) { + lora.setPreambleLength(preambleLength); + err = lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, + RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + } + + if (err != RADIOLIB_ERR_NONE) { + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("LR11x0 RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; + } RadioLibInterface::startReceive(); @@ -400,16 +498,18 @@ template bool LR11x0Interface::isChannelActive() .timeout = 0, .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; - int16_t result; + int16_t result = trySetStandby(); + if (result == RADIOLIB_ERR_NONE) { + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; + } - setStandby(); - result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) - return true; - - assert(result != RADIOLIB_ERR_WRONG_MODEM); - - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -457,7 +557,7 @@ template bool LR11x0Interface::sleep() { // \todo Display actual typename of the adapter, not just `LR11x0` LOG_DEBUG("LR11x0 entering sleep mode"); - setStandby(); // Stop any pending operations + (void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash // turn off TCXO if it was powered lora.setTCXO(0); diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index 9280c05dee..e3b4f392af 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -78,5 +78,21 @@ template class LR11x0Interface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + + private: + /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ + int16_t programModemParams(); + + /** Reset and re-begin() a chip that lost its runtime configuration (reset/brownout) */ + bool reinitChip(); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ + bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } + + /// The TCXO Vref that init() settled on, so reinitChip() can begin() with the same oscillator setup + float resolvedTcxoVoltage = 0; }; #endif \ No newline at end of file diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index dcc514041e..c3a4c12059 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -6,6 +6,12 @@ #include "error.h" #include "mesh/NodeDB.h" +#if defined(LR2021_DCDC_WORKAROUND) && RADIOLIB_GODMODE +// The DCDC sensitivity workaround pokes RadioLib-internal DCDC registers that are NOT exposed via the +// public LR2021.h, so pull in the internal register map explicitly. Opt-in only (see LR2021_DCDC_WORKAROUND). +#include +#endif + // Keep LR20x0 naming while RadioLib exposes LR2021 symbols. #ifndef LR20x0 #define LR20x0 LR2021 @@ -25,6 +31,10 @@ static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { }; #endif +#ifdef LR2021_CUSTOM_PA_TABLE +#include "pa_table.h" +#endif + // Particular boards might define a different max power based on what their hardware can do, default to max power output if not // specified (may be dangerous if using external PA and LR20x0 power config forgotten) #if ARCH_PORTDUINO @@ -46,6 +56,12 @@ static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { // Last programmed carrier; LF/HF hops use full begin() (live setOutputPower returns -706). static float lr20x0LastFreqMHz = 0; +// Unlike SX126x/LR11x0 (on/off bool), the LR2021 RX gain boost is a 0-7 level (0 = disabled, 7 = max boost). +// Map the historical on/off sx126x_rx_boosted_gain flag to max boost when enabled. +#ifndef LR2021_RX_GAIN_BOOST_LEVEL +#define LR2021_RX_GAIN_BOOST_LEVEL 7 +#endif + template LR20x0Interface::LR20x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, RADIOLIB_PIN_TYPE busy) @@ -140,6 +156,26 @@ template bool LR20x0Interface::init() if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED) return false; + // Some basic info about the module's explicit firmware version - no other info available + // Currently requires radiolib godmode + +#if RADIOLIB_GODMODE + if (res == RADIOLIB_ERR_NONE) { + uint8_t fwMajor = 0; + uint8_t fwMinor = 0; + int versionRes = lora.getVersion(&fwMajor, &fwMinor); + if (versionRes == RADIOLIB_ERR_NONE) + LOG_DEBUG("LR20x0 FW %d.%d", fwMajor, fwMinor); + } +#endif + + // Semtech DCDC sensitivity workaround for sub-GHz operation - applied here after lora.begin() has set the + // packet type and modulation params. reconfigure() reapplies it after its own modulation changes. + if (res == RADIOLIB_ERR_NONE) + applyDcdcWorkaround(); + + applyCustomLfPaTable(getFreq()); + LOG_INFO("Frequency set to %f", getFreq()); LOG_INFO("Bandwidth set to %f", bw); LOG_INFO("Power output set to %d", power); @@ -147,6 +183,18 @@ template bool LR20x0Interface::init() if (res == RADIOLIB_ERR_NONE) res = lora.setCRC(2); + // Standard DCDC ramp timing from RadioLib workarounds (register 0x00F20024) + // Currently requires radiolib godmode +#if RADIOLIB_GODMODE + if (res == RADIOLIB_ERR_NONE) { + uint8_t rampTimes[4] = {15, 15, 15, 15}; // Standard case for all conditions + // godmode-only DCDC ramp tuning: log failures but don't fail init (radio is already up) + int16_t rmRes = lora.setRegMode(RADIOLIB_LR2021_REG_MODE_SIMO_NORMAL, rampTimes); + if (rmRes != RADIOLIB_ERR_NONE) + LOG_WARN("LR2021 setRegMode failed: %d", rmRes); + } +#endif + #ifdef LR2021_DIO_AS_RF_SWITCH bool dioAsRfSwitch = true; #elif defined(ARCH_PORTDUINO) @@ -162,11 +210,11 @@ template bool LR20x0Interface::init() if (res == RADIOLIB_ERR_NONE) { if (config.lora.sx126x_rx_boosted_gain) { // the name is unfortunate but historically accurate - res = lora.setRxBoostedGainMode(true); - LOG_INFO("Set RX gain to boosted mode; result: %d", res); + res = lora.setRxBoostedGainMode(LR2021_RX_GAIN_BOOST_LEVEL); + LOG_INFO("Set RX gain to boosted mode (level %d); result: %d", LR2021_RX_GAIN_BOOST_LEVEL, res); } else { - res = lora.setRxBoostedGainMode(false); - LOG_INFO("Set RX gain to power saving mode; result: %d", res); + res = lora.setRxBoostedGainMode(0); + LOG_INFO("Set RX gain to power saving mode (boosted mode off); result: %d", res); } } @@ -179,7 +227,9 @@ template bool LR20x0Interface::init() template bool LR20x0Interface::reconfigure() { - bool success = RadioLibInterface::reconfigure(); + // Propagated to the return value below, separately from the chip-programming outcome, so a + // base-class failure isn't masked as success. + const bool reconfigureSuccess = RadioLibInterface::reconfigure(); if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { limitPower(LR2021_MAX_POWER_HF); @@ -192,8 +242,107 @@ template bool LR20x0Interface::reconfigure() if (bandHop) { LOG_INFO("LR20x0 LF/HF band hop %.1f -> %.1f MHz, full begin()", lr20x0LastFreqMHz, freq); - setStandby(); + // fullBegin() hardware-resets the chip, so a standby failure is survivable here + (void)trySetStandby(); + if (!fullBegin(freq)) + return false; + + startReceive(); + return reconfigureSuccess; + } + + // Same-band reconfigure (previous incremental path) + bool standbySuccess = true; + int16_t standbyErr = trySetStandby(); + if (standbyErr != RADIOLIB_ERR_NONE) + standbySuccess = false; + + if (standbyErr == RADIOLIB_ERR_NONE) { + int err = lora.setFrequency(freq); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setFrequency %.3f MHz %s%d", freq, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + standbySuccess = false; + } + + err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + standbySuccess = false; + } + + err = lora.setBandwidth(bw); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + standbySuccess = false; + } + + err = lora.setCodingRate(cr, cr != 7); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setCodingRate(%u) %s%d", cr, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + standbySuccess = false; + } + + err = lora.setSyncWord(syncWord); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setSyncWord %s%d", radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + standbySuccess = false; + } + + err = lora.setPreambleLength(preambleLength); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + standbySuccess = false; + } + + err = lora.setOutputPower(power); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setOutputPower %d dBm @ %.3f MHz %s%d", power, freq, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + standbySuccess = false; + } + + // Warn-only, as in LR11x0: a rejected gain mode is cosmetic and not a lost-state signature, so + // it must not drag reconfigure() into a full chip reset. + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain ? LR2021_RX_GAIN_BOOST_LEVEL : 0); + if (err != RADIOLIB_ERR_NONE) + LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err); + } + + if (!standbySuccess) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration to a chip-internal reset or brownout. Recover in place with the + // same full begin() the band-hop path uses - it hardware-resets the chip. Crashing here instead + // would reboot before MeshService persists the config change that triggered us. + LOG_ERROR("LR20x0 rejected modem params, chip state lost? Full re-init"); + if (!fullBegin(freq)) { + LOG_ERROR("LR20x0 unrecoverable, radio down until reboot"); + return false; + } + LOG_INFO("LR20x0 recovered after re-init"); + } + + // setSpreadingFactor/setBandwidth/setCodingRate each re-run setLoRaModulationParams(), which resets + // the DCDC configure state, so reapply the workaround before we resume receiving. + if (standbySuccess) + applyDcdcWorkaround(); + + startReceive(); + lr20x0LastFreqMHz = freq; + return reconfigureSuccess; +} + +// The chip-side re-init the band-hop and recovery paths share: front-end switch GPIOs for the target +// band, a fresh begin() (which hardware-resets the chip), CRC, DIO RF-switch table, and RX gain. +template bool LR20x0Interface::fullBegin(float freq) +{ + { // Match init(): external LF/HF front-end GPIOs (if board defines them). #ifdef LR2021_RF_SWITCH_SUBGHZ pinMode(LR2021_RF_SWITCH_SUBGHZ, OUTPUT); @@ -236,6 +385,9 @@ template bool LR20x0Interface::reconfigure() RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); return false; } + + applyCustomLfPaTable(freq); + lr20x0LastFreqMHz = freq; res = lora.setCRC(2); @@ -252,75 +404,99 @@ template bool LR20x0Interface::reconfigure() lora.setRfSwitchTable(lr20x0_rfswitch_dio_pins, lr20x0_rfswitch_table); #endif - res = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + res = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain ? LR2021_RX_GAIN_BOOST_LEVEL : 0); if (res != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 band-hop setRxBoostedGainMode %s%d", radioLibErr, res); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); return false; } - startReceive(); + // begin() above reprogrammed the modulation params, so the DCDC configure state is reset here too. + applyDcdcWorkaround(); + return true; } +} - // Same-band reconfigure (previous incremental path) - setStandby(); +// Board LF PA table after begin(); pointer is retained. HF keeps the RadioLib default. +// Warn-only: a calibration miss must not fail init/fullBegin, keep the begin() PA config. +template void LR20x0Interface::applyCustomLfPaTable(float freq) +{ +#ifdef LR2021_CUSTOM_PA_TABLE + if (isLr20x0HighBand(freq)) + return; + lora.setPaTable(lr2021_pa_table_lf, false); + int16_t paRes = lora.setOutputPower(power); + if (paRes != RADIOLIB_ERR_NONE) + LOG_WARN("LR2021 custom LF PA table setOutputPower failed (%s%d)", radioLibErr, paRes); + else + LOG_DEBUG("LR2021 custom LF PA table installed"); +#else + (void)freq; +#endif +} - int err = lora.setFrequency(freq); - if (err != RADIOLIB_ERR_NONE) { - LOG_ERROR("LR20x0 setFrequency %.3f MHz %s%d", freq, radioLibErr, err); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; +// Semtech DCDC sensitivity workaround for sub-GHz operation on engineering sample date code 2513. +// Worthy of note is that we tested this on non-engineering samples and it didn't make any difference, but +// we went to the trouble of writing this, so it can stay in, albeit gated behind a compile-time option. +// lr20xx_workarounds_dcdc_reset must follow setPacketType; lr20xx_workarounds_dcdc_configure must follow +// setModulationParams. In init() both hold once lora.begin() returns; in reconfigure() the caller invokes this +// after setSpreadingFactor/setBandwidth/setCodingRate, whose RadioLib implementations re-run +// setLoRaModulationParams() and reset the DCDC configure state. Only applies to sub-GHz; 2.4 GHz (LORA_24) is +// excluded. Opt-in only: requires -DLR2021_DCDC_WORKAROUND (and RADIOLIB_GODMODE for the internal register access). +template void LR20x0Interface::applyDcdcWorkaround() +{ +#if defined(LR2021_DCDC_WORKAROUND) && RADIOLIB_GODMODE + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) + return; + + // Helper: set DCDC LF frequency register and re-apply the current RF frequency. + auto dcdcSetFreq = [&](uint32_t freqHz) -> int16_t { + const uint32_t freqLf = (uint32_t)((float)freqHz * 1.048576f); + int16_t s = lora.writeRegMem32(RADIOLIB_LR2021_REG_DCDC_FREQ_LF, &freqLf, 1); + if (s != RADIOLIB_ERR_NONE) + return s; + uint32_t rawRfFreq = 0; + s = lora.readRegMem32(RADIOLIB_LR2021_REG_RTTOF_RF_FREQ, &rawRfFreq, 1); + if (s != RADIOLIB_ERR_NONE) + return s; + // Convert PLL steps to Hz: (steps * 15625 + 16383) / 16384 + uint32_t rfHz = (uint32_t)(((uint64_t)rawRfFreq * 15625ULL + 16383ULL) / 16384ULL); + return lora.setRfFrequency(rfHz); + }; + + // dcdc_reset: reset RISE/FALL ramp fields to conservative 15/15 at 2.8 MHz + int16_t dcdcRes = lora.writeRegMemMask32(RADIOLIB_LR2021_REG_DCDC_SWITCHER, 0xFu << 20, 15u << 20); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = lora.writeRegMemMask32(RADIOLIB_LR2021_REG_DCDC_SWITCHER, 0xFu << 16, 15u << 16); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = dcdcSetFreq(2800000); + + // dcdc_configure: tune RISE/FALL and DC freq based on ADC decimation and RX path. + // Matches lr20xx_workarounds_dcdc_configure() exactly. + if (dcdcRes == RADIOLIB_ERR_NONE) { + uint32_t adcCtrl = 0, rxPath = 0; + dcdcRes = lora.readRegMem32(RADIOLIB_LR2021_REG_DCDC_ADC_CTRL, &adcCtrl, 1); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = lora.readRegMem32(RADIOLIB_LR2021_REG_DCDC_RX_PATH, &rxPath, 1); + if (dcdcRes == RADIOLIB_ERR_NONE) { + const uint32_t anaDec = (adcCtrl >> 8) & 0x7; + const bool isRxHf = (rxPath & 0x3) == 1; + // Narrowband sub-GHz path (ana_dec 1 or 2): use tighter RISE=11/FALL=13 timing + const uint32_t rise = (!isRxHf && (anaDec == 1 || anaDec == 2)) ? 11u : 15u; + const uint32_t fall = (!isRxHf && (anaDec == 1 || anaDec == 2)) ? 13u : 15u; + dcdcRes = lora.writeRegMemMask32(RADIOLIB_LR2021_REG_DCDC_SWITCHER, 0xFu << 20, rise << 20); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = lora.writeRegMemMask32(RADIOLIB_LR2021_REG_DCDC_SWITCHER, 0xFu << 16, fall << 16); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = dcdcSetFreq(anaDec == 1 ? 4300000 : 2800000); + } } - - err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setCodingRate(cr, cr != 7); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setOutputPower(power); - if (err != RADIOLIB_ERR_NONE) { - LOG_ERROR("LR20x0 setOutputPower %d dBm @ %.3f MHz %s%d", power, freq, radioLibErr, err); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); - if (err != RADIOLIB_ERR_NONE) { - LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err); - success = false; - } - - if (success) { - startReceive(); - lr20x0LastFreqMHz = freq; - } - return success; + if (dcdcRes != RADIOLIB_ERR_NONE) + LOG_WARN("LR20x0 DCDC workaround failed: %d", dcdcRes); + else + LOG_DEBUG("LR20x0 DCDC workaround applied"); +#endif } template void LR20x0Interface::clearRadioIsr() @@ -328,23 +504,28 @@ template void LR20x0Interface::clearRadioIsr() lora.clearIrqAction(); } -template void LR20x0Interface::setStandby() +template int16_t LR20x0Interface::trySetStandby() { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + int16_t err = lora.standby(); if (err != RADIOLIB_ERR_NONE) { LOG_DEBUG("LR20x0 standby failed, err %d", err); } - assert(err == RADIOLIB_ERR_NONE); - isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void LR20x0Interface::setStandby() +{ + int16_t err = trySetStandby(); + assert(err == RADIOLIB_ERR_NONE); } /** @@ -376,16 +557,31 @@ template void LR20x0Interface::startReceive() sleep(); #else - setStandby(); + int16_t err = trySetStandby(); - lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. + if (err == RADIOLIB_ERR_NONE) { + lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. - // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. - int err = - lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - if (err) + // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. + err = + lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("StartReceive error: %d", err); - assert(err == RADIOLIB_ERR_NONE); + if (maybeRecoverChipStateLoss()) { + lora.setPreambleLength(preambleLength); + err = lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, + RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + } + + if (err != RADIOLIB_ERR_NONE) { + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("LR20x0 RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; + } RadioLibInterface::startReceive(); @@ -406,16 +602,18 @@ template bool LR20x0Interface::isChannelActive() .timeout = 0, .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; - int16_t result; + int16_t result = trySetStandby(); + if (result == RADIOLIB_ERR_NONE) { + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; + } - setStandby(); - result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) - return true; - - assert(result != RADIOLIB_ERR_WRONG_MODEM); - - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -451,7 +649,7 @@ template void LR20x0Interface::resetAGC() lora.calibrateImageRejection(getFreq() - 4.0f, getFreq() + 4.0f); // 5. Re-apply RX boosted gain mode - lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain ? LR2021_RX_GAIN_BOOST_LEVEL : 0); // 6. Resume receiving startReceive(); @@ -462,7 +660,7 @@ template bool LR20x0Interface::sleep() { // \todo Display actual typename of the adapter, not just `LR20x0` LOG_DEBUG("LR20x0 entering sleep mode"); - setStandby(); // Stop any pending operations + (void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash // turn off TCXO if it was powered lora.setTCXO(0); diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index ed04dfb0e1..45399db368 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -72,6 +72,28 @@ template class LR20x0Interface : public RadioLibInterface virtual void setStandby() override; + /** + * Apply the Semtech DCDC sensitivity workaround (opt-in, godmode-only). Must be called after the LoRa + * modulation parameters have been set - i.e. after lora.begin() in init(), or after the + * setSpreadingFactor/setBandwidth/setCodingRate calls in reconfigure(), all of which re-run + * setLoRaModulationParams() and thereby reset the DCDC configure state. No-op unless built with + * -DLR2021_DCDC_WORKAROUND (and RADIOLIB_GODMODE). Logs success/failure; never fatal. + */ + void applyDcdcWorkaround(); + uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + + private: + /** Chip-side re-init shared by the band-hop and recovery paths: front-end GPIOs, begin(), CRC, RF switch, RX gain */ + bool fullBegin(float freq); + + /** Board LF PA table after begin(); HF keeps RadioLib default. Warn-only on setOutputPower miss. */ + void applyCustomLfPaTable(float freq); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state via the same full begin() the band-hop path uses */ + bool recoverChipStateLoss() override { return fullBegin(getFreq()); } }; #endif diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index 71da37145b..059f90ee81 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -52,7 +52,7 @@ int32_t MeshModule::setStartDelay() } meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, - uint8_t hopLimit) + uint8_t hopLimit, const meshtastic_MeshPacket *relaySource) { meshtastic_Routing c = meshtastic_Routing_init_default; @@ -75,6 +75,16 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod p->to = to; p->decoded.request_id = idFrom; p->channel = chIndex; + // When this ack reports an overheard rebroadcast of our own packet, carry that copy's relaying node + // and the link metrics (RSSI/SNR) we heard it at, so the phone can attribute them to the relayer. The + // ack is delivered locally (to == us), so Router::send() is bypassed and won't overwrite these. + if (relaySource) { + p->relay_node = relaySource->relay_node; + // rx_rssi has explicit presence: has_rx_rssi has to travel with it or the reading never encodes + p->has_rx_rssi = relaySource->has_rx_rssi; + p->rx_rssi = relaySource->rx_rssi; + p->rx_snr = relaySource->rx_snr; + } if (err != meshtastic_Routing_Error_NONE) LOG_WARN("Alloc an err=%d,to=0x%08x,idFrom=0x%08x,id=0x%08x", err, to, idFrom, p->id); diff --git a/src/mesh/MeshModule.h b/src/mesh/MeshModule.h index 3dc6414a51..da7be49ba7 100644 --- a/src/mesh/MeshModule.h +++ b/src/mesh/MeshModule.h @@ -187,7 +187,7 @@ class MeshModule virtual Observable *getUIFrameObservable() { return NULL; } meshtastic_MeshPacket *allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, - uint8_t hopLimit = 0); + uint8_t hopLimit = 0, const meshtastic_MeshPacket *relaySource = nullptr); /// Send an error response for the specified packet. meshtastic_MeshPacket *allocErrorResponse(meshtastic_Routing_Error err, const meshtastic_MeshPacket *p); diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index cfe213ff9b..e3803c43ae 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -138,9 +138,15 @@ void MeshService::loop() (void)sendQueueStatusToPhone(qs, 0, 0); } if (oldFromNum != fromNum) { // We don't want to generate extra notifies for multiple new packets - int result = fromNumChanged.notifyObservers(fromNum); - if (result == 0) // If any observer returns non-zero, we will try again - oldFromNum = fromNum; + // Snapshot both first: the identity move can run on another task, and anything it bumps during + // the pass must still be pending afterwards rather than being marked delivered. + const uint32_t num = fromNum; + const uint32_t generation = identityGeneration; + int result = fromNumChanged.notifyObservers(num); + if (result == 0) { // If any observer returns non-zero, we will try again + oldFromNum = num; + identityGenerationSeen = generation; + } } } @@ -157,6 +163,10 @@ void MeshService::reloadConfig(int saveWhat) nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc + + // Nothing is swept and nothing extra persisted: each node carries the slot it was heard on, so + // a client rolling through presets just moves this and moves it back. + nodeDB->refreshCommittedLoraSlot(); } nodeDB->saveToDisk(saveWhat); } @@ -368,7 +378,7 @@ ErrorCode MeshService::sendQueueStatusToPhone(const meshtastic_QueueStatus &qs, return res ? ERRNO_OK : ERRNO_UNKNOWN; } -void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPhone) +ErrorCode MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPhone) { uint32_t mesh_packet_id = p->id; nodeDB->updateFrom(*p); // update our local DB for this packet (because phone might have sent position packets etc...) @@ -404,6 +414,8 @@ void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPh if (res == ERRNO_SHOULD_RELEASE) { releaseToPool(p); } + + return res; } bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) @@ -412,36 +424,38 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) assert(node); - if (nodeDB->hasValidPosition(node)) { #if HAS_GPS && !MESHTASTIC_EXCLUDE_GPS - if (positionModule) { - if (!config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot()) { - LOG_DEBUG("Skip position ping; no fresh position since boot"); - return false; - } - // Prefer the node's current channel, but fall back to the position channel - // (matching PositionModule::sendOurPosition() behavior). - uint8_t sendChan = node->channel; - if (getPositionPrecisionForChannel(sendChan) == 0 && !findPositionChannel(sendChan)) { - // No channel with position enabled: fall back to sending nodeinfo, as before. - if (nodeInfoModule) { - LOG_INFO("No position-enabled channel; send nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", dest, - wantReplies, node->channel); - nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); - } - return false; - } - LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan); - positionModule->sendOurPosition(dest, wantReplies, sendChan); + // Prefer the node's current channel, but fall back to the position channel + // (matching PositionModule::sendOurPosition() behavior). + uint8_t sendChan = node->channel; + if (nodeDB->hasValidPosition(node) && positionModule && + (config.position.fixed_position || nodeDB->hasLocalPositionSinceBoot()) && + (getPositionPrecisionForChannel(sendChan) != 0 || findPositionChannel(sendChan))) { + LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan); + if (positionModule->sendOurPosition(dest, wantReplies, sendChan)) return true; - } - } else { -#endif - if (nodeInfoModule) { - LOG_INFO("Send nodeinfo ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel); - nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); - } } +#endif + // No position went out, so a false return tells the callers the nodeinfo fallback was used. + if (nodeInfoModule) { + LOG_INFO("Send nodeinfo ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel); + nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); + } + return false; +} + +// ASCII BEL, the in-band alert marker. Numeric so no control byte sits in the source, and +// file-local because ASCII_BELL is already a macro in Screen.cpp and ExternalNotificationModule.cpp. +static const uint8_t kAsciiBell = 7; + +bool MeshService::isAlertPayload(const meshtastic_MeshPacket &p) +{ + if (!moduleConfig.external_notification.alert_bell && !moduleConfig.external_notification.alert_bell_vibra && + !moduleConfig.external_notification.alert_bell_buzzer) + return false; + for (pb_size_t i = 0; i < p.decoded.payload.size; i++) + if (p.decoded.payload.bytes[i] == kAsciiBell) + return true; return false; } diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 7adcdb7c6d..8c48964fc3 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "GPSStatus.h" @@ -72,8 +73,9 @@ class MeshService // This holds the last QueueStatus send meshtastic_QueueStatus lastQueueStatus; - /// The current nonce for the newest packet which has been queued for the phone - uint32_t fromNum = 0; + /// The current nonce for the newest packet which has been queued for the phone. Bumped from + /// whichever task queued it, read by loop(), hence atomic. + std::atomic fromNum{0}; /// Updated in loop() to detect when fromNum changes uint32_t oldFromNum = 0; @@ -101,6 +103,10 @@ class MeshService p->decoded.portnum == meshtastic_PortNum_ALERT_APP; } + /// True if the sender flagged this text as an alert: an ASCII BEL in the payload while at least + /// one alert_bell_* output is enabled. Alerts deliberately break through a mute. + static bool isAlertPayload(const meshtastic_MeshPacket &p); + /// Returns false when a decoded NodeInfo/Waypoint payload fails nested protobuf decode (invalid /// UTF-8 under PB_VALIDATE_UTF8, etc.); other portnums pass through. Callers gate on the variant. static bool phonePayloadIsDecodable(const meshtastic_Data &decoded); @@ -156,6 +162,14 @@ class MeshService /// senders. void nudgeFromNum() { fromNum++; } + /// Bumped with a nudgeFromNum() when our node num changes; the seen counter only advances once a + /// notify pass has reached every client, so a move landing during a pass stays pending after it. + std::atomic identityGeneration{0}; + std::atomic identityGenerationSeen{0}; + + /// True while a node num change still owes connected clients a fresh MyInfo. + bool identityMovePending() const { return identityGeneration != identityGenerationSeen; } + /** * Given a ToRadio buffer parse it and properly handle it (setup radio, owner or send packet into the mesh) * Called by PhoneAPI.handleToRadio. Note: p is a scratch buffer, this function is allowed to write to it but it can not keep @@ -185,7 +199,8 @@ class MeshService /// Send a packet into the mesh - note p must have been allocated from packetPool. We will return it to that pool after /// sending. This is the ONLY function you should use for sending messages into the mesh, because it also updates the nodedb /// cache - void sendToMesh(meshtastic_MeshPacket *p, RxSource src = RX_SRC_LOCAL, bool ccToPhone = false); + /// Returns the router's verdict: ERRNO_OK / ERRNO_SHOULD_RELEASE accepted, anything else released unsent. + ErrorCode sendToMesh(meshtastic_MeshPacket *p, RxSource src = RX_SRC_LOCAL, bool ccToPhone = false); /** Attempt to cancel a previously sent packet from this _local_ node. Returns true if a packet was found we could cancel */ bool cancelSending(PacketId id); diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index c86f35ec76..00ccc814ef 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -201,7 +201,7 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast p->relay_node, wasAlreadyRelayer, weWereSoleRelayer); origTx->next_hop = p->relay_node; } - noteRouteLearned(p->from, p->relay_node, millis()); // M3: anchor freshness (hot or overflow route) + noteRouteLearned(p->from, p->relay_node, Time::stampMillis()); // M3: anchor freshness (hot or overflow route) #if HAS_TRAFFIC_MANAGEMENT // Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it // survives even when the source isn't (or is no longer) in the hot NodeDB. @@ -313,7 +313,7 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) // a health record that still matches the stored byte; a next_hop set by another path (e.g. // TraceRouteModule) with no matching record is left authoritative. const RouteHealth *h = findRouteHealth(to); - if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) { + if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, Time::stampMillis())) { LOG_INFO("Next hop 0x%x for 0x%08x stale (age/fails); flood and clear", node->next_hop, to); node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route clearRouteHealth(to); // clear RAM health @@ -345,7 +345,7 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) uint8_t hint = trafficManagementModule->getNextHopHint(to); if (hint && hint != relay_node) { const RouteHealth *h = findRouteHealth(to); - if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) { + if (h && h->lastNextHop == hint && isRouteStale(*h, Time::stampMillis())) { LOG_INFO("TMM next hop 0x%x for 0x%08x stale (age/fails); flood and clear", hint, to); trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0) clearRouteHealth(to); // clear RAM health @@ -444,7 +444,7 @@ int32_t NextHopRouter::doRetransmissions() { // Same clock Throttle reads, so setNextTx() deadlines and this test can't diverge under an // injected test clock. - uint32_t now = Time::getMillis(); + uint32_t now = Time::stampMillis(); int32_t d = INT32_MAX; // FIXME, we should use a better datastructure rather than walking through this map. @@ -617,7 +617,8 @@ void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now h->lastNextHop = nextHop; h->consecutiveFailures = 0; } - h->learnedAtMsec = now ? now : 1; + // `now` is a parameter, so guard at the store too: 0 is the empty-slot marker. + h->learnedAtMsec = Time::skipZero(now); } void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now) @@ -626,7 +627,7 @@ void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now) if (!h) return; // only routes we actually learned have health to refresh h->consecutiveFailures = 0; - h->learnedAtMsec = now ? now : 1; + h->learnedAtMsec = Time::skipZero(now); // a parameter, so guard at the store too } void NextHopRouter::noteRouteFailure(NodeNum dest) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 87aab5ad99..ba7eefd4c8 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -20,6 +20,9 @@ #include "TransmitHistory.h" #include "TypeConversions.h" #include "UptimeClock.h" +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT +#include "WaypointStore.h" +#endif #include "error.h" #include "gps/RTC.h" #include "main.h" @@ -250,6 +253,12 @@ std::map *s_decodeEnvironmentTarget = nu #if !MESHTASTIC_EXCLUDE_STATUSDB std::map *s_decodeStatusTarget = nullptr; #endif + +// Keys that can never name a real node. +[[maybe_unused]] inline bool isUsableSatelliteKey(NodeNum n) +{ + return n != 0 && !isBroadcast(n); +} } // namespace bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field) @@ -291,7 +300,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre case meshtastic_NodeDatabase_positions_tag: { if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodePositionEntry_fields, &item)) @@ -303,7 +312,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (pb_decode(istream, meshtastic_NodePositionEntry_fields, &entry)) { #if !MESHTASTIC_EXCLUDE_POSITIONDB if (s_decodePositionsTarget) { - if (entry.has_position) + if (entry.has_position && isUsableSatelliteKey(entry.num)) (*s_decodePositionsTarget)[entry.num] = entry.position; return true; } @@ -317,7 +326,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre case meshtastic_NodeDatabase_telemetry_tag: { if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeTelemetryEntry_fields, &item)) @@ -329,7 +338,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (pb_decode(istream, meshtastic_NodeTelemetryEntry_fields, &entry)) { #if !MESHTASTIC_EXCLUDE_TELEMETRYDB if (s_decodeTelemetryTarget) { - if (entry.has_device_metrics) + if (entry.has_device_metrics && isUsableSatelliteKey(entry.num)) (*s_decodeTelemetryTarget)[entry.num] = entry.device_metrics; return true; } @@ -343,7 +352,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre case meshtastic_NodeDatabase_status_tag: { if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeStatusEntry_fields, &item)) @@ -355,7 +364,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (pb_decode(istream, meshtastic_NodeStatusEntry_fields, &entry)) { #if !MESHTASTIC_EXCLUDE_STATUSDB if (s_decodeStatusTarget) { - if (entry.has_status) + if (entry.has_status && isUsableSatelliteKey(entry.num)) (*s_decodeStatusTarget)[entry.num] = entry.status; return true; } @@ -369,7 +378,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre case meshtastic_NodeDatabase_environment_tag: { if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeEnvironmentEntry_fields, &item)) @@ -381,7 +390,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (pb_decode(istream, meshtastic_NodeEnvironmentEntry_fields, &entry)) { #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB if (s_decodeEnvironmentTarget) { - if (entry.has_environment_metrics) + if (entry.has_environment_metrics && isUsableSatelliteKey(entry.num)) (*s_decodeEnvironmentTarget)[entry.num] = entry.environment_metrics; return true; } @@ -699,15 +708,24 @@ NodeDB::NodeDB() #if !MESHTASTIC_EXCLUDE_POSITIONDB { concurrency::LockGuard guard(&satelliteMutex); - nodePositions[info->num] = TypeConversions::ConvertToPositionLite(fixedGPS); + nodePositions[getNodeNum()] = TypeConversions::ConvertToPositionLite(fixedGPS); } + // nodePositions is a member map, so the nodeDatabase CRC compare above cannot see this write - + // and it has already run. Flag the segment or the fixed position is only persisted by chance. + saveWhat |= SEGMENT_NODEDATABASE; #endif - nodeDB->setLocalPosition(fixedGPS); + setLocalPosition(fixedGPS); config.position.fixed_position = true; + // Same for config, whose CRC compare also ran before this block. Keep that compare's + // degraded-boot guard so an unreadable config is never overwritten with UNSET defaults. + if (!configDecodeFailed) + saveWhat |= SEGMENT_CONFIG; #endif } #endif sortMeshDB(); + // resetRadioConfig() above loaded config and channels, so this records the slot we booted on. + refreshCommittedLoraSlot(); saveToDisk(saveWhat); bootInitializationInProgress = false; } @@ -816,6 +834,63 @@ void NodeDB::resetRadioConfig(bool is_fresh_install) initRegion(); } +LoraSlotSnapshot loraSlotSnapshotFrom(const meshtastic_Config_LoRaConfig &lora, const char *primaryChannelName) +{ + LoraSlotSnapshot snap; + snap.region = lora.region; + snap.use_preset = lora.use_preset; + // Record only the modem fields the radio is actually using. The unused half of the pair keeps + // whatever the client last wrote into it, and editing a dormant field moves nothing on air. + if (lora.use_preset) { + snap.modem_preset = lora.modem_preset; + } else { + snap.bandwidth = lora.bandwidth; + snap.spread_factor = lora.spread_factor; + snap.coding_rate = lora.coding_rate; + } + snap.override_frequency = lora.override_frequency; + snap.channel_num = lora.channel_num; + strncpy(snap.primary_channel_name, primaryChannelName, sizeof(snap.primary_channel_name) - 1); + return snap; +} + +uint16_t LoraSlotSnapshot::fingerprint() const +{ + // FNV-1a over the populated fields. Only ever compared against another fingerprint, so the hash + // needs to be stable and well-spread, not cryptographic. + uint32_t h = 2166136261u; + auto mix = [&h](const void *data, size_t len) { + const uint8_t *p = static_cast(data); + for (size_t i = 0; i < len; i++) { + h ^= p[i]; + h *= 16777619u; + } + }; + const uint8_t scalars[] = {(uint8_t)region, (uint8_t)use_preset, (uint8_t)modem_preset, + (uint8_t)coding_rate, (uint8_t)bandwidth, (uint8_t)(bandwidth >> 8), + (uint8_t)spread_factor, (uint8_t)channel_num, (uint8_t)(channel_num >> 8)}; + mix(scalars, sizeof(scalars)); + mix(&override_frequency, sizeof(override_frequency)); + mix(primary_channel_name, strnlen(primary_channel_name, sizeof(primary_channel_name))); + // Fold the full width down rather than truncating, so every input bit reaches the stored value. + const uint16_t folded = (uint16_t)((h ^ (h >> 16)) & ((1u << NODEINFO_BITFIELD_HEARD_SLOT_BITS) - 1)); + return folded; +} + +LoraSlotSnapshot NodeDB::currentLoraSlot() const +{ + return loraSlotSnapshotFrom(config.lora, channels.getName(channels.getPrimaryIndex())); +} + +void NodeDB::refreshCommittedLoraSlot() +{ + // A beacon TX parks the radio on someone else's preset and puts it back; config.lora is not the + // committed config for that window, and adopting it would read every node as unheard meanwhile. + if (loraSlotTransient) + return; + committedSlot = currentLoraSlot().fingerprint(); +} + bool NodeDB::factoryReset(bool eraseBleBonds) { LOG_INFO("Factory reset"); @@ -839,6 +914,9 @@ bool NodeDB::factoryReset(bool eraseBleBonds) #if HAS_SCREEN messageStore.clearAllMessages(); #endif +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.clearAllWaypoints(); +#endif #if WARM_NODE_COUNT > 0 // On nRF52840 the warm tier lives in raw flash outside /prefs, so rmDir @@ -1004,6 +1082,9 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #else config.lora.ignore_mqtt = false; #endif +#ifdef USERPREFS_CONFIG_LORA_CONFIG_OK_TO_MQTT + config.lora.config_ok_to_mqtt = USERPREFS_CONFIG_LORA_CONFIG_OK_TO_MQTT; +#endif // Initialize admin_key_count to zero byte numAdminKeys = 0; @@ -1037,6 +1118,16 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.security.admin_key_count = numAdminKeys; +#ifdef USERPREFS_CONFIG_SECURITY_IS_MANAGED + // is_managed is the supported way for a vendor to lock configuration, but without an admin key + // it locks the vendor out too and only a factory reset recovers it. + if (USERPREFS_CONFIG_SECURITY_IS_MANAGED && numAdminKeys == 0) { + LOG_WARN("USERPREFS is_managed needs an admin key, ignored"); + } else { + config.security.is_managed = USERPREFS_CONFIG_SECURITY_IS_MANAGED; + } +#endif + // Left at COMPATIBLE when signature checking is compiled out, so we never report a policy // nothing enforces (mirrors the set-config guard in AdminModule). #if defined(USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY) && !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) @@ -1088,7 +1179,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \ defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2) || \ - defined(ELECROW_ThinkNode_M9) || defined(T_WATCH_ULTRA)) && \ + defined(ELECROW_ThinkNode_M9) || defined(SEEED_WIO_TRACKER_L2) || defined(T_WATCH_ULTRA)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; @@ -1198,6 +1289,23 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) installRoleDefaults(config.device.role); #endif +#ifdef USERPREFS_CONFIG_DEVICE_REBROADCAST_MODE + config.device.rebroadcast_mode = USERPREFS_CONFIG_DEVICE_REBROADCAST_MODE; + // Same restriction AdminModule enforces on a set-config; apply it here so a vendor build can't + // ship a combination the device would silently refuse later. + if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_NONE && + IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE)) { + LOG_WARN("Rebroadcast mode can't be NONE for a router role, use ALL"); + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + } +#endif +#ifdef USERPREFS_CONFIG_DEVICE_NODE_INFO_BROADCAST_SECS + // Clamped to the same window AdminModule enforces on a set-config + config.device.node_info_broadcast_secs = clamp((uint32_t)USERPREFS_CONFIG_DEVICE_NODE_INFO_BROADCAST_SECS, + (uint32_t)min_node_info_broadcast_secs, (uint32_t)MAX_INTERVAL); +#endif + initConfigIntervals(); variantDefaultConfig(); variantDefaultModuleConfig(); @@ -1242,7 +1350,7 @@ static void installTrafficManagementDefaults(meshtastic_LocalModuleConfig &mc) mc.has_traffic_management = true; mc.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; #if HAS_TRAFFIC_MANAGEMENT - // Position dedup ships enabled at the 11-hour default window on all supported targets. + // Position dedup ships enabled at the 5-hour default window on all supported targets. // STM32WL is excluded at compile time (HAS_TRAFFIC_MANAGEMENT=0 in mesh-pb-constants.h). // Set position_min_interval_secs=0 at runtime to disable dedup. mc.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; @@ -1282,7 +1390,7 @@ void optInDisableTelemetryBroadcast(meshtastic_LocalModuleConfig &mc) void NodeDB::installDefaultModuleConfig() { LOG_INFO("Install default ModuleConfig"); - memset(&moduleConfig, 0, sizeof(meshtastic_ModuleConfig)); + memset(&moduleConfig, 0, sizeof(meshtastic_LocalModuleConfig)); moduleConfig.version = DEVICESTATE_CUR_VER; moduleConfig.has_mqtt = true; @@ -1478,30 +1586,14 @@ void NodeDB::installDefaultModuleConfig() memcpy(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes, beaconOfferPsk, sizeof(beaconOfferPsk)); moduleConfig.mesh_beacon.broadcast_offer_channel.psk.size = sizeof(beaconOfferPsk); #endif -#ifdef USERPREFS_MESH_BEACON_ON_PRESET - moduleConfig.mesh_beacon.has_broadcast_on_preset = true; - moduleConfig.mesh_beacon.broadcast_on_preset = USERPREFS_MESH_BEACON_ON_PRESET; -#endif -#ifdef USERPREFS_MESH_BEACON_ON_REGION - moduleConfig.mesh_beacon.broadcast_on_region = USERPREFS_MESH_BEACON_ON_REGION; -#endif -#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NAME - moduleConfig.mesh_beacon.has_broadcast_on_channel = true; - strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, USERPREFS_MESH_BEACON_ON_CHANNEL_NAME, - sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); - moduleConfig.mesh_beacon.broadcast_on_channel.name[sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1] = '\0'; -#endif -#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_PSK - moduleConfig.mesh_beacon.has_broadcast_on_channel = true; - static const uint8_t beaconOnPsk[] = USERPREFS_MESH_BEACON_ON_CHANNEL_PSK; - static_assert(sizeof(beaconOnPsk) <= sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes), - "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK exceeds the 32-byte channel PSK buffer"); - memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconOnPsk, sizeof(beaconOnPsk)); - moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconOnPsk); -#endif -#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NUM - moduleConfig.mesh_beacon.has_broadcast_on_channel = true; - moduleConfig.mesh_beacon.broadcast_on_channel.channel_num = USERPREFS_MESH_BEACON_ON_CHANNEL_NUM; +// The USERPREFS_MESH_BEACON_ON_* keys were removed with the broadcast_on_* config fields. Fail the +// build rather than silently dropping a preconfigured beacon channel: define the equivalent +// USERPREFS_MESH_BEACON_TARGET_0_{PRESET,REGION,CHANNEL_INDEX} keys instead. CHANNEL_INDEX names a +// slot in the device's channel table, so the channel must also be provisioned on the node. +#if defined(USERPREFS_MESH_BEACON_ON_PRESET) || defined(USERPREFS_MESH_BEACON_ON_REGION) || \ + defined(USERPREFS_MESH_BEACON_ON_CHANNEL_NAME) || defined(USERPREFS_MESH_BEACON_ON_CHANNEL_PSK) || \ + defined(USERPREFS_MESH_BEACON_ON_CHANNEL_NUM) +#error "USERPREFS_MESH_BEACON_ON_* removed; use USERPREFS_MESH_BEACON_TARGET_0_* (channel must be in the channel table)" #endif #ifdef USERPREFS_MESH_BEACON_LEGACY_SPLIT BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_LEGACY_SPLIT, meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT); @@ -1916,16 +2008,35 @@ bool NodeDB::enforceSatelliteCaps() { concurrency::LockGuard guard(&satelliteMutex); bool trimmedAny = false; - auto trim = [this, &trimmedAny](auto &map, const char *name) { + const NodeNum self = getNodeNum(); + // One sorted snapshot of the hot keys serves all four maps; the orphan test is a binary search. + std::vector hotNums; + hotNums.reserve(numMeshNodes); + for (int i = 0; i < numMeshNodes; i++) + hotNums.push_back(meshNodes->at(i).num); + std::sort(hotNums.begin(), hotNums.end()); + + auto trim = [this, &trimmedAny, &hotNums, self](auto &map, const char *name) { const size_t before = map.size(); + // Orphans (key with no hot-table owner) only ever arrive from disk, and the + // cap paths never reclaim them because they fire above the cap, not at it. + size_t orphans = 0; + for (auto it = map.begin(); it != map.end();) { + if (it->first != self && !std::binary_search(hotNums.begin(), hotNums.end(), it->first)) { + it = map.erase(it); + orphans++; + } else { + ++it; + } + } while (map.size() > MAX_SATELLITE_NODES) { if (!evictStalestSatellite(*this, map)) break; } if (map.size() != before) { trimmedAny = true; - LOG_MIGRATION("Trimmed %s satellites %u -> %u (cap %d)", name, (unsigned)before, (unsigned)map.size(), - MAX_SATELLITE_NODES); + LOG_MIGRATION("Trimmed %s satellites %u -> %u (cap %d, %u orphaned)", name, (unsigned)before, (unsigned)map.size(), + MAX_SATELLITE_NODES, (unsigned)orphans); } }; #if !MESHTASTIC_EXCLUDE_POSITIONDB @@ -2917,6 +3028,9 @@ bool NodeDB::reloadFromDisk() channels.onConfigChanged(); rIface->reconfigure(); } + // The unlock replaced the locked-default config with the operator's, so the boot snapshot + // describes a slot we were never on. + refreshCommittedLoraSlot(); return true; } @@ -3256,6 +3370,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) moduleConfig.has_audio = true; moduleConfig.has_paxcounter = true; moduleConfig.has_statusmessage = true; + moduleConfig.has_traffic_management = true; moduleConfig.has_tak = true; #if !MESHTASTIC_EXCLUDE_BEACON moduleConfig.has_mesh_beacon = true; @@ -3764,6 +3879,11 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_HAS_SNR_MASK, true); } + // RF-origin only (a via_mqtt rebroadcast proves the gateway is in earshot, not the node); not + // has_rx_rssi-gated, as SimRadio omits it. Live slot, so a beacon-preset hear fails to match home. + if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt) + nodeInfoLiteSetHeardSlot(info, currentLoraSlot().fingerprint()); + nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK, mp.via_mqtt); // Store if we received this packet via MQTT @@ -3907,7 +4027,7 @@ void NodeDB::pause_sort(bool paused) void NodeDB::sortMeshDB() { if (!sortingIsPaused && (lastSort == 0 || !Throttle::isWithinTimespanMs(lastSort, 1000 * 5))) { - lastSort = millis(); + lastSort = Time::skipZero(Time::getMillis()); bool changed = true; while (changed) { // dumb reverse bubble sort, but probably not bad for what we're doing changed = false; @@ -4396,6 +4516,39 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub } #endif +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) +// A freshly minted keypair must not itself land on the blacklist. Fail with no key rather than persist +// a known-weak identity: only a broken entropy source can land here, and retrying would not fix that. +bool NodeDB::generateBlacklistCheckedKeyPair() +{ + crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); + if (!checkLowEntropyPublicKey(config.security.public_key)) + return true; + LOG_ERROR("PKI keygen produced a known low-entropy key; entropy source is broken"); + config.security.public_key.size = 0; + config.security.private_key.size = 0; + return false; +} + +// Derive the public key from the stored private key and vet it. The entry check cannot see a weak key +// when the stored public key is absent, and a failed derivation must not leave sizes claiming a pair. +bool NodeDB::derivePublicKeyFromPrivate() +{ + config.security.public_key.size = 32; + if (!crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { + LOG_ERROR("Can't generate public key from private key"); + config.security.public_key.size = 0; + config.security.private_key.size = 0; + return false; + } + if (!checkLowEntropyPublicKey(config.security.public_key)) + return true; + keyIsLowEntropy = true; + LOG_WARN("Private key derives a known low-entropy public key; generating a new keypair"); + return generateBlacklistCheckedKeyPair(); +} +#endif + bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) @@ -4421,29 +4574,24 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) LOG_INFO("Using provided private key for PKI"); memcpy(config.security.private_key.bytes, privateKey, 32); config.security.private_key.size = 32; - config.security.public_key.size = 32; - // Generate public key from the provided private key - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } else { - LOG_ERROR("Can't generate public key from private key"); + if (!derivePublicKeyFromPrivate()) return false; - } + keygenSuccess = true; } // Try to regenerate public key from existing private key if it's valid and not low entropy else if (config.security.private_key.size == 32 && !keyIsLowEntropy) { - config.security.public_key.size = 32; LOG_DEBUG("Regenerate PKI public key from private key"); - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } + if (!derivePublicKeyFromPrivate()) + return false; + keygenSuccess = true; } else { // Generate a new key pair LOG_INFO("Generate new PKI keys"); config.security.public_key.size = 32; config.security.private_key.size = 32; - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); + if (!generateBlacklistCheckedKeyPair()) + return false; keygenSuccess = true; } @@ -4506,11 +4654,21 @@ bool NodeDB::createNewIdentity() // The number has moved, so the caller must persist it whatever happens next. Returning false here // would leave the new key saved against the old number, which is the break this exists to prevent. meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum()); - if (info) + if (info) { TypeConversions::CopyUserToNodeInfoLite(info, owner); - else + // Our row was appended, but index 0 is self by invariant: the phone's own-nodeinfo read and the + // demote/evict scans that skip index 0 to protect us both depend on it. + if (info != &meshNodes->at(0)) + std::swap(meshNodes->at(0), *info); + } else LOG_ERROR("No room for our own node 0x%08x, identity moved without a self record", newNodeNum); + // Clients cache my_node_num from the handshake; the region set that mints the key never reboots. + if (service) { + service->identityGeneration++; + service->nudgeFromNum(); + } + return true; } @@ -4608,6 +4766,10 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, if (restoreWhat & SEGMENT_CHANNELS) channels.onConfigChanged(); + // Restore reboots without going through MeshService::reloadConfig(), which is where the + // committed slot is otherwise re-read. + refreshCommittedLoraSlot(); + success = saveToDisk(restoreWhat); if (success) { LOG_INFO("Restored prefs from backup"); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 0e669cca54..d36fd7ca03 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -80,6 +81,11 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = { {0xcc, 0x11, 0xfb, 0x1a, 0xab, 0xa1, 0x31, 0x87, 0x6a, 0xc6, 0xde, 0x88, 0x87, 0xa9, 0xb9, 0x59, 0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}}; static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated."; +// Shown when a user tries to restore/set a known pre-2.8 low-entropy key: explains why the saved +// key did not persist and that the node's identity (NodeNum == crc32(public_key)) changed with it. +static const char LOW_ENTROPY_RESTORE_WARNING[] = + "That key is a known pre-2.8 low-entropy key and can't be restored. A new secure key was " + "generated; your node number has changed."; #endif static const char LICENSED_IDENTITY_MIGRATION_WARNING[] = "Licensed signing generated a new identity key; this node identity changed."; @@ -268,6 +274,29 @@ struct NodeHeardAt { uint32_t heardAtUptimeSecs = 0; ///< Time::getUptimeSecs() when last heard }; +/// What decides which LoRa slot this radio listens on. Only ever consumed as a fingerprint(), which +/// is what each node stores and what the committed slot is compared against. +struct LoraSlotSnapshot { + meshtastic_Config_LoRaConfig_RegionCode region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + bool use_preset = false; + /// Only the modem fields actually in force are populated - see loraSlotSnapshotFrom(). + meshtastic_Config_LoRaConfig_ModemPreset modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + uint16_t bandwidth = 0; + uint32_t spread_factor = 0; + uint8_t coding_rate = 0; + float override_frequency = 0; + uint16_t channel_num = 0; + /// Channels::getName(); 16 covers name[12] and the preset name it substitutes for an empty one. + char primary_channel_name[16] = {0}; + + /// Fold into the NODEINFO_BITFIELD_HEARD_SLOT_BITS-wide value stored per node. A collision only + /// costs a node heard on one slot reading as heard on another, which 12 bits makes remote. + uint16_t fingerprint() const; +}; + +/// Normalises to the modem fields actually in force, so editing a dormant one is not a slot change. +LoraSlotSnapshot loraSlotSnapshotFrom(const meshtastic_Config_LoRaConfig &lora, const char *primaryChannelName); + class NodeDB { // NodeNum provisionalNodeNum; // if we are trying to find a node num this is our current attempt @@ -326,6 +355,18 @@ class NodeDB /// we updateGUI and updateGUIforNode if we think our this change is big enough for a redraw void updateFrom(const meshtastic_MeshPacket &p); + /// Re-read which slot this radio is committed to. Cheap, touches no node and writes nothing, so + /// it is safe on every config write - a client hopping presets just moves it and moves it back. + void refreshCommittedLoraSlot(); + + /// Fingerprint of the slot the radio is committed to, which a node's stored slot is compared + /// against to derive NodeInfo.heard_on_current_lora. + uint16_t committedLoraSlot() const { return committedSlot; } + + /// Declare that config.lora holds a temporary radio switch - a beacon keying up on another preset. + /// While set the committed slot is pinned, so neither the switch nor its restore reads as a move. + void setLoraSlotTransient(bool transient) { loraSlotTransient = transient; } + void addFromContact(const meshtastic_SharedContact); /// On the clock-becoming-trusted transition (see RTC.cpp): convert every RAM arrival stamp into @@ -587,6 +628,10 @@ class NodeDB #if !defined(MESHTASTIC_EXCLUDE_PKI) bool checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest); #endif +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + bool generateBlacklistCheckedKeyPair(); + bool derivePublicKeyFromPrivate(); +#endif /// Consolidate crypto key generation logic used across multiple modules /// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys. @@ -691,6 +736,11 @@ class NodeDB EvictionRecency evictionRecency(const meshtastic_NodeInfoLite *n) const; static bool evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent); + /// The slot this radio is committed to; see refreshCommittedLoraSlot(). + uint16_t committedSlot = 0; + bool loraSlotTransient = false; + LoraSlotSnapshot currentLoraSlot() const; + /* * Internal boolean to track sorting paused */ @@ -812,7 +862,16 @@ extern uint32_t error_address; // Use this instead of `if (snr_q4)`. Legacy records (bit clear) are unambiguously "unknown". #define NODEINFO_BITFIELD_HAS_SNR_SHIFT 10 #define NODEINFO_BITFIELD_HAS_SNR_MASK (1u << NODEINFO_BITFIELD_HAS_SNR_SHIFT) -// Bits 11..31 reserved for future single-bit flags. +// Set on a genuine RF hear, and with it the slot fingerprint below. Clear means never heard over our +// own radio, so the fingerprint is meaningless - legacy records read that way and are correct. +#define NODEINFO_BITFIELD_HAS_RF_HEAR_SHIFT 11 +#define NODEINFO_BITFIELD_HAS_RF_HEAR_MASK (1u << NODEINFO_BITFIELD_HAS_RF_HEAR_SHIFT) +// Bits 12..23: fingerprint of the LoRa slot this node was last heard on. NodeInfo.heard_on_current_lora +// is derived from it matching the slot the radio is committed to, which is what makes scanning harmless. +#define NODEINFO_BITFIELD_HEARD_SLOT_SHIFT 12 +#define NODEINFO_BITFIELD_HEARD_SLOT_BITS 12 +#define NODEINFO_BITFIELD_HEARD_SLOT_MASK (((1u << NODEINFO_BITFIELD_HEARD_SLOT_BITS) - 1) << NODEINFO_BITFIELD_HEARD_SLOT_SHIFT) +// Bits 24..31 reserved for future single-bit flags. // Convenience accessors so call sites read like the old struct fields. inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n) @@ -861,6 +920,32 @@ inline bool nodeInfoLiteHasSnr(const meshtastic_NodeInfoLite *n) { return n && (n->bitfield & NODEINFO_BITFIELD_HAS_SNR_MASK); } + +inline bool nodeInfoLiteHasRfHear(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_HAS_RF_HEAR_MASK); +} + +inline uint16_t nodeInfoLiteHeardSlot(const meshtastic_NodeInfoLite *n) +{ + return n ? (n->bitfield & NODEINFO_BITFIELD_HEARD_SLOT_MASK) >> NODEINFO_BITFIELD_HEARD_SLOT_SHIFT : 0; +} + +/// Record that this node was just heard over RF on `slot`. +inline void nodeInfoLiteSetHeardSlot(meshtastic_NodeInfoLite *n, uint16_t slot) +{ + if (!n) + return; + n->bitfield = (n->bitfield & ~NODEINFO_BITFIELD_HEARD_SLOT_MASK) | + (((uint32_t)slot << NODEINFO_BITFIELD_HEARD_SLOT_SHIFT) & NODEINFO_BITFIELD_HEARD_SLOT_MASK) | + NODEINFO_BITFIELD_HAS_RF_HEAR_MASK; +} + +/// True iff this node was last heard over RF on the slot the radio is committed to right now. +inline bool nodeInfoLiteHeardOnSlot(const meshtastic_NodeInfoLite *n, uint16_t committedSlot) +{ + return nodeInfoLiteHasRfHear(n) && nodeInfoLiteHeardSlot(n) == committedSlot; +} /// A node that the eviction/migration paths must not drop: a favourite, an /// ignored (blocked) node, or a manually-verified key. inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n) diff --git a/src/mesh/NodeDBLegacyMigration.cpp b/src/mesh/NodeDBLegacyMigration.cpp index 408df62e28..8186f06261 100644 --- a/src/mesh/NodeDBLegacyMigration.cpp +++ b/src/mesh/NodeDBLegacyMigration.cpp @@ -26,7 +26,7 @@ bool meshtastic_NodeDatabase_Legacy_callback(pb_istream_t *istream, pb_ostream_t const auto *iter = reinterpret_cast(field); if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_Legacy_fields, &item)) @@ -78,7 +78,9 @@ bool NodeDB::migrateLegacyNodeDatabase() slim.has_hops_away = legacy.has_hops_away; slim.hops_away = legacy.hops_away; slim.next_hop = legacy.next_hop; - slim.bitfield = legacy.bitfield; + // v24 assigned bits 0..10 only; anything above is noise and must not arrive as RF-hear + // state or a slot fingerprint (see NODEINFO_BITFIELD_HEARD_SLOT_SHIFT). + slim.bitfield = legacy.bitfield & (NODEINFO_BITFIELD_HAS_RF_HEAR_MASK - 1); if (legacy.via_mqtt) slim.bitfield |= NODEINFO_BITFIELD_VIA_MQTT_MASK; if (legacy.is_favorite) diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index da745a25e7..8e22f5bb8f 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -8,6 +8,7 @@ #include "platform/portduino/PortduinoGlue.h" #endif #include "Throttle.h" +#include "UptimeClock.h" #define RECENT_WARN_AGE (10 * 60 * 1000L) // Warn if the packet that gets removed was more recent than 10 min @@ -17,14 +18,16 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initialize members { if (size < 4 || size > PACKETHISTORY_MAX) { // Copilot suggested - makes sense - LOG_WARN("Packet History - Invalid size %d, using default %d", size, PACKETHISTORY_MAX); + LOG_WARN("Packet History - Invalid size %u, using default %u", static_cast(size), + static_cast(PACKETHISTORY_MAX)); size = PACKETHISTORY_MAX; // Use default size if invalid } #if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH // Ensure capacity fits in uint16_t hash index (HASH_EMPTY = 0xFFFF is the sentinel) if (size >= HASH_EMPTY) { - LOG_WARN("Packet History - Clamping size %d to %d (hash index limit)", size, HASH_EMPTY - 1); + LOG_WARN("Packet History - Clamping size %u to %u (hash index limit)", static_cast(size), + static_cast(HASH_EMPTY - 1)); size = HASH_EMPTY - 1; } #endif @@ -33,7 +36,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initia recentPacketsCapacity = size; recentPackets.reset(new PacketRecord[recentPacketsCapacity]); if (!recentPackets) { // No logging here, console/log probably uninitialized yet. - LOG_ERROR("Packet History - Memory allocation failed for size=%d entries / %d Bytes", size, + LOG_ERROR("Packet History - Memory allocation failed for size=%u entries / %zu Bytes", static_cast(size), sizeof(PacketRecord) * recentPacketsCapacity); recentPacketsCapacity = 0; // mark allocation fail return; // return early @@ -49,7 +52,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initia hashMask = hashCapacity - 1; hashIndex.reset(new uint16_t[hashCapacity]); if (!hashIndex) { - LOG_ERROR("Packet History - Hash index allocation failed for %d entries", hashCapacity); + LOG_ERROR("Packet History - Hash index allocation failed for %u entries", static_cast(hashCapacity)); hashCapacity = 0; hashMask = 0; return; @@ -91,9 +94,9 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd r.relayed_by[0] = p->relay_node; } - r.rxTimeMsec = millis(); // - if (r.rxTimeMsec == 0) // =0 every 49.7 days? 0 is special - r.rxTimeMsec = 1; + // TODO(elapsed-stamp): 0 means "empty slot" here and insert() drops a record stamped 0, so the + // dodge is important; a same-instant `now - rxTimeMsec` read still underflows to a huge age. + r.rxTimeMsec = Time::skipZero(Time::getMillis()); #if VERBOSE_PACKET_HISTORY LOG_DEBUG( @@ -601,4 +604,4 @@ inline uint8_t PacketHistory::getOurTxHopLimit(const PacketRecord &r) inline void PacketHistory::setOurTxHopLimit(PacketRecord &r, uint8_t hopLimit) { r.hop_limit = (r.hop_limit & ~HOP_LIMIT_OUR_TX_MASK) | ((hopLimit << HOP_LIMIT_OUR_TX_SHIFT) & HOP_LIMIT_OUR_TX_MASK); -} \ No newline at end of file +} diff --git a/src/mesh/PacketHistory.h b/src/mesh/PacketHistory.h index 84af59874e..42359e5808 100644 --- a/src/mesh/PacketHistory.h +++ b/src/mesh/PacketHistory.h @@ -68,7 +68,7 @@ class PacketHistory void setOurTxHopLimit(PacketRecord &r, uint8_t hopLimit); public: - explicit PacketHistory(uint32_t size = -1); // Constructor with size parameter, default is PACKETHISTORY_MAX + explicit PacketHistory(uint32_t size = PACKETHISTORY_MAX); /** * Update recentBroadcasts and return true if we have already seen this packet diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index fdffd0c260..aa5ff2d48e 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -22,6 +22,7 @@ #include "Router.h" #include "SPILock.h" #include "TypeConversions.h" +#include "UptimeClock.h" #include "concurrency/LockGuard.h" #include "main.h" #include "modules/NodeInfoModule.h" @@ -244,7 +245,7 @@ static void clearAuthSlot_LH(const PhoneAPI *p) PhoneAPI::PhoneAPI() { - lastContactMsec = millis(); + lastContactMsec = Time::skipZero(Time::getMillis()); std::fill(std::begin(recentToRadioPacketIds), std::end(recentToRadioPacketIds), 0); } @@ -436,7 +437,7 @@ bool PhoneAPI::checkConnectionTimeout() bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) { powerFSM.trigger(EVENT_CONTACT_FROM_PHONE); // As long as the phone keeps talking to us, don't let the radio go to sleep - lastContactMsec = millis(); + lastContactMsec = Time::skipZero(Time::getMillis()); memset(&toRadioScratch, 0, sizeof(toRadioScratch)); if (pb_decode_from_bytes(buf, bufLength, &meshtastic_ToRadio_msg, &toRadioScratch)) { @@ -549,6 +550,27 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) STATE_SEND_PACKETS // send packets or debug strings */ +void PhoneAPI::fillMyInfo() +{ + fromRadioScratch.which_payload_variant = meshtastic_FromRadio_my_info_tag; + strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env)); + // strncpy does not terminate when the source fills the buffer; a 40+ char + // APP_ENV would make nanopb reject the MyInfo encode ("unterminated string"). + myNodeInfo.pio_env[sizeof(myNodeInfo.pio_env) - 1] = '\0'; + myNodeInfo.nodedb_count = static_cast(nodeDB->getNumMeshNodes()); + fromRadioScratch.my_info = myNodeInfo; +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL + if (!getAdminAuthorized()) { + // device_id fingerprints the hardware and pio_env/min_app_version name the exact build to pick a + // known CVE for. my_node_num is broadcast on the mesh anyway, and nodedb_count is not secret. + fromRadioScratch.my_info.device_id.size = 0; + memset(fromRadioScratch.my_info.device_id.bytes, 0, sizeof(fromRadioScratch.my_info.device_id.bytes)); + memset(fromRadioScratch.my_info.pio_env, 0, sizeof(fromRadioScratch.my_info.pio_env)); + fromRadioScratch.my_info.min_app_version = 0; + } +#endif +} + size_t PhoneAPI::getFromRadio(uint8_t *buf) { // Respond to heartbeat by sending queue status @@ -575,30 +597,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) break; case STATE_SEND_MY_INFO: LOG_DEBUG("FromRadio=STATE_SEND_MY_INFO"); - // If the user has specified they don't want our node to share its location, make sure to tell the phone - // app not to send locations on our behalf. - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_my_info_tag; - strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env)); - // strncpy does not terminate when the source fills the buffer; a 40+ char - // APP_ENV would make nanopb reject the MyInfo encode ("unterminated string"). - myNodeInfo.pio_env[sizeof(myNodeInfo.pio_env) - 1] = '\0'; - myNodeInfo.nodedb_count = static_cast(nodeDB->getNumMeshNodes()); - fromRadioScratch.my_info = myNodeInfo; -#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL - if (!getAdminAuthorized()) { - // device_id is a stable hardware identifier - useful for an attacker - // to fingerprint / correlate the device across observations. Strip it - // for unauthenticated clients. my_node_num is kept (it's broadcast - // on the mesh anyway). pio_env / min_app_version reveal the exact - // build flavour, useful only for picking which known-CVE to try. - // nodedb_count stays - clients need it to decide whether to pull - // the node DB after unlocking. - fromRadioScratch.my_info.device_id.size = 0; - memset(fromRadioScratch.my_info.device_id.bytes, 0, sizeof(fromRadioScratch.my_info.device_id.bytes)); - memset(fromRadioScratch.my_info.pio_env, 0, sizeof(fromRadioScratch.my_info.pio_env)); - fromRadioScratch.my_info.min_app_version = 0; - } -#endif + fillMyInfo(); state = STATE_SEND_UIDATA; service->refreshLocalMeshNode(); // Update my NodeInfo because the client will be asking for it soon. @@ -920,9 +919,9 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL if (!getAdminAuthorized()) { // Unauthenticated: emit an empty MeshBeaconConfig (zero-init from - // the top-of-loop memset). The embedded ChannelSettings - // (broadcast_offer_channel / broadcast_on_channel) carry PSKs that - // must not be visible to an unauth client. + // the top-of-loop memset). The embedded broadcast_offer_channel + // ChannelSettings carries a PSK that must not be visible to an + // unauth client. } else #endif { @@ -1097,6 +1096,14 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) } break; + case STATE_RESEND_MY_INFO: + // Our node num moved after this client's handshake, so it is addressing a number we no + // longer answer to. Re-announce, then carry on with live traffic. + LOG_INFO("FromRadio=STATE_RESEND_MY_INFO, node num now 0x%08x", nodeDB->getNodeNum()); + fillMyInfo(); + state = STATE_SEND_PACKETS; + break; + default: LOG_ERROR("getFromRadio unexpected state %d", state); } @@ -1275,26 +1282,26 @@ bool lastHeardIsWallClock(const meshtastic_NodeInfoLite *header) // Previously these packets carried a bare 0, which a client renders as a real reading. // Note the asymmetry with rx_snr below: that field is still proto3 singular, so "unknown" and // "0 dB" remain indistinguishable there. -meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos) +meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_PositionLite &pos) { // Shape this exactly like a fresh live broadcast Position from the peer so the // phone runs it through its normal "live position broadcast" handler path. // to=ourNum would read as a DM-from-peer and never lands in node detail UI. meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; - const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); - pkt.from = num; + pkt.from = header->num; pkt.to = NODENUM_BROADCAST; // rx_time means "when *we* received this" - use last_heard, not the position's own GPS // fix time (which is often 0 and, when present, already round-trips inside the payload // via ConvertToPosition). - pkt.rx_time = header ? header->last_heard : 0; + pkt.rx_time = header->last_heard; // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). pkt.has_rx_time = lastHeardIsWallClock(header); // Stable per-node/per-fix id: replaying the same unchanged history on every // reconnect must not look like a brand new packet to the phone's history/dedup. - pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_POSITION_APP); - pkt.channel = header ? header->channel : 0; - pkt.rx_snr = header ? header->snr : 0; + pkt.id = makeReplayPacketId(header->num, pkt.rx_time, meshtastic_PortNum_POSITION_APP); + pkt.channel = header->channel; + pkt.rx_snr = header->snr; pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -1309,19 +1316,19 @@ meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const mesh return pkt; } -meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const meshtastic_DeviceMetrics &metrics) +meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_DeviceMetrics &metrics) { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; - pkt.from = num; + pkt.from = header->num; pkt.to = NODENUM_BROADCAST; // No native timestamp on telemetry packets here; use last_heard. - const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); - pkt.rx_time = header ? header->last_heard : 0; + pkt.rx_time = header->last_heard; // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). pkt.has_rx_time = lastHeardIsWallClock(header); - pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag); - pkt.channel = header ? header->channel : 0; - pkt.rx_snr = header ? header->snr : 0; + pkt.id = makeReplayPacketId(header->num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag); + pkt.channel = header->channel; + pkt.rx_snr = header->snr; pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -1370,10 +1377,13 @@ void PhoneAPI::prefetchReplayPositions() wasEmpty = replayQueue.empty(); while (replayQueue.size() < kReplayPrefetchDepth && replayPositionIndex < replayPositionOrder.size()) { NodeNum num = replayPositionOrder[replayPositionIndex++]; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); meshtastic_PositionLite pos; - if (!nodeDB->copyNodePosition(num, pos)) - continue; // entry was evicted between snapshot and now - replayQueue.push_back(makeReplayPositionPacket(num, pos)); + // No header means an orphan the phone cannot attribute; a failed copy means + // the entry was evicted between snapshot and now. + if (!header || !nodeDB->copyNodePosition(num, pos)) + continue; + replayQueue.push_back(makeReplayPositionPacket(header, pos)); added = true; } } @@ -1406,10 +1416,11 @@ void PhoneAPI::prefetchReplayTelemetry() wasEmpty = replayQueue.empty(); while (replayQueue.size() < kReplayPrefetchDepth && replayTelemetryIndex < replayTelemetryOrder.size()) { NodeNum num = replayTelemetryOrder[replayTelemetryIndex++]; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); meshtastic_DeviceMetrics dm; - if (!nodeDB->copyNodeTelemetry(num, dm)) + if (!header || !nodeDB->copyNodeTelemetry(num, dm)) continue; - replayQueue.push_back(makeReplayTelemetryPacket(num, dm)); + replayQueue.push_back(makeReplayTelemetryPacket(header, dm)); added = true; } } @@ -1418,18 +1429,18 @@ void PhoneAPI::prefetchReplayTelemetry() #endif } -meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env) +meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_EnvironmentMetrics &env) { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; - pkt.from = num; + pkt.from = header->num; pkt.to = NODENUM_BROADCAST; - const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); - pkt.rx_time = header ? header->last_heard : 0; + pkt.rx_time = header->last_heard; // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). pkt.has_rx_time = lastHeardIsWallClock(header); - pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag); - pkt.channel = header ? header->channel : 0; - pkt.rx_snr = header ? header->snr : 0; + pkt.id = makeReplayPacketId(header->num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag); + pkt.channel = header->channel; + pkt.rx_snr = header->snr; pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -1472,10 +1483,11 @@ void PhoneAPI::prefetchReplayEnvironment() wasEmpty = replayQueue.empty(); while (replayQueue.size() < kReplayPrefetchDepth && replayEnvironmentIndex < replayEnvironmentOrder.size()) { NodeNum num = replayEnvironmentOrder[replayEnvironmentIndex++]; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); meshtastic_EnvironmentMetrics env; - if (!nodeDB->copyNodeEnvironment(num, env)) + if (!header || !nodeDB->copyNodeEnvironment(num, env)) continue; - replayQueue.push_back(makeReplayEnvironmentPacket(num, env)); + replayQueue.push_back(makeReplayEnvironmentPacket(header, env)); added = true; } } @@ -1484,19 +1496,19 @@ void PhoneAPI::prefetchReplayEnvironment() #endif } -meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status) +meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_StatusMessage &status) { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; - pkt.from = num; + pkt.from = header->num; pkt.to = NODENUM_BROADCAST; // StatusMessage has no native timestamp; use last_heard. - const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); - pkt.rx_time = header ? header->last_heard : 0; + pkt.rx_time = header->last_heard; // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). pkt.has_rx_time = lastHeardIsWallClock(header); - pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP); - pkt.channel = header ? header->channel : 0; - pkt.rx_snr = header ? header->snr : 0; + pkt.id = makeReplayPacketId(header->num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP); + pkt.channel = header->channel; + pkt.rx_snr = header->snr; pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -1534,10 +1546,11 @@ void PhoneAPI::prefetchReplayStatus() wasEmpty = replayQueue.empty(); while (replayQueue.size() < kReplayPrefetchDepth && replayStatusIndex < replayStatusOrder.size()) { NodeNum num = replayStatusOrder[replayStatusIndex++]; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); meshtastic_StatusMessage status; - if (!nodeDB->copyNodeStatus(num, status) || status.status[0] == '\0') + if (!header || !nodeDB->copyNodeStatus(num, status) || status.status[0] == '\0') continue; - replayQueue.push_back(makeReplayStatusPacket(num, status)); + replayQueue.push_back(makeReplayStatusPacket(header, status)); added = true; } } @@ -1668,6 +1681,7 @@ bool PhoneAPI::available() case STATE_SEND_OWN_NODEINFO: case STATE_SEND_FILEMANIFEST: case STATE_SEND_COMPLETE_ID: + case STATE_RESEND_MY_INFO: return true; case STATE_SEND_OTHER_NODEINFOS: { @@ -1904,8 +1918,17 @@ int PhoneAPI::onNotify(uint32_t newValue) // doesn't call this from idle) if (state == STATE_SEND_PACKETS) { + // Consumed by every connected client in this one notify pass, so no per-connection bookkeeping. + if (service->identityMovePending()) + state = STATE_RESEND_MY_INFO; LOG_INFO("Tell client new packets %u", newValue); onNowHasData(newValue); + } else if (service->identityMovePending() && state != STATE_SEND_NOTHING && state != STATE_SEND_MY_INFO) { + // Mid-sync, so this dump is already carrying the old number in its my_info, its self record or + // both, and has no steady state to fall back from. Restart it on the new one. + LOG_INFO("Node num moved mid-sync, restart client config"); + handleStartConfig(); + onNowHasData(newValue); } else { LOG_DEBUG("Client not yet interested in packets (state=%d)", state); } @@ -2080,7 +2103,7 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la) revokeAllAuth(); queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "", 0, 0, 0); zeroPassphrase(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); return true; } diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index ab04c178b0..1762a37948 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -53,7 +53,8 @@ class PhoneAPI STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to to the client STATE_SEND_FILEMANIFEST, // Send file manifest STATE_SEND_COMPLETE_ID, - STATE_SEND_PACKETS // live mesh packets + any cached satellite-DB replay that trails sync completion + STATE_SEND_PACKETS, // live mesh packets + any cached satellite-DB replay that trails sync completion + STATE_RESEND_MY_INFO // one-shot: our node num moved after the handshake, re-announce and fall back }; // Satellite-DB replay (positions / telemetry / environment / status) used to live @@ -133,6 +134,9 @@ class PhoneAPI void resetReadIndex() { readIndex = 0; } + /// Load fromRadioScratch with a MyInfo for this connection and record the number it carried. + void fillMyInfo(); + public: PhoneAPI(); @@ -284,10 +288,12 @@ class PhoneAPI void prefetchReplayEnvironment(); void beginReplayStatus(); void prefetchReplayStatus(); - meshtastic_MeshPacket makeReplayPositionPacket(uint32_t num, const meshtastic_PositionLite &pos); - meshtastic_MeshPacket makeReplayTelemetryPacket(uint32_t num, const meshtastic_DeviceMetrics &metrics); - meshtastic_MeshPacket makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env); - meshtastic_MeshPacket makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status); + meshtastic_MeshPacket makeReplayPositionPacket(const meshtastic_NodeInfoLite *header, const meshtastic_PositionLite &pos); + meshtastic_MeshPacket makeReplayTelemetryPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_DeviceMetrics &metrics); + meshtastic_MeshPacket makeReplayEnvironmentPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_EnvironmentMetrics &env); + meshtastic_MeshPacket makeReplayStatusPacket(const meshtastic_NodeInfoLite *header, const meshtastic_StatusMessage &status); // Post-sync replay drain: pop one cached packet from the active phase, advancing // through positions -> telemetry -> environment -> status until everything is drained. diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 909d47e23e..5f2322417f 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -127,8 +127,6 @@ bool RF95Interface::init() power = dacDbValues.db; #endif - limitPower(RF95_MAX_POWER); - lora.reset(new RadioLibRF95(&module)); iface = lora.get(); @@ -181,6 +179,26 @@ bool RF95Interface::init() #endif setTransmitEnable(false); +#if defined(RADIOMASTER_900_BANDIT_NANO) || defined(RADIOMASTER_900_BANDIT) + LOG_INFO("DAC output set to %d", powerDAC); +#endif + + if (!reinitChip()) + return false; + + startReceive(); // start receiving + + return true; +} + +// begin() and the chip-side setup that a reset chip loses. Shared by init() and by reconfigure()'s +// recovery of a chip that lost its state. +bool RF95Interface::reinitChip() +{ + // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw + // config value, and the recovery path reaches begin() without passing through the params clamp + limitPower(RF95_MAX_POWER); + int res = lora->begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength); LOG_INFO("RF95 init result %d", res); if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED) @@ -189,16 +207,12 @@ bool RF95Interface::init() LOG_INFO("Frequency set to %f", getFreq()); LOG_INFO("Bandwidth set to %f", bw); LOG_INFO("Power output set to %d", power); -#if defined(RADIOMASTER_900_BANDIT_NANO) || defined(RADIOMASTER_900_BANDIT) - LOG_INFO("DAC output set to %d", powerDAC); -#endif if (res == RADIOLIB_ERR_NONE) res = lora->setCRC(RADIOLIB_SX126X_LORA_CRC_ON); - if (res == RADIOLIB_ERR_NONE) - startReceive(); // start receiving - + if (res != RADIOLIB_ERR_NONE) + LOG_ERROR("RF95 re-init failed %s%d", radioLibErr, res); return res == RADIOLIB_ERR_NONE; } @@ -207,44 +221,50 @@ void RF95Interface::clearRadioIsr() lora->clearDio0Action(); } -bool RF95Interface::reconfigure() +int16_t RF95Interface::programModemParams() { - RadioLibInterface::reconfigure(); - - // set mode to standby - setStandby(); - // configure publicly accessible settings - int err = lora->setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + int16_t err = lora->setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + return err; + } err = lora->setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + return err; + } err = lora->setCodingRate(cr); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setCodingRate(%u) %s%d", cr, radioLibErr, err); + return err; + } err = lora->setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("RF95 setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; + } err = lora->setCurrentLimit(currentLimit); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("RF95 setCurrentLimit %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; + } err = lora->setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("RF95 setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + return err; + } err = lora->setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err); + return err; + } limitPower(RF95_MAX_POWER); @@ -253,8 +273,37 @@ bool RF95Interface::reconfigure() #else err = lora->setOutputPower(power); #endif - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setOutputPower(%d) %s%d", power, radioLibErr, err); + return err; + } + + return RADIOLIB_ERR_NONE; +} + +bool RF95Interface::reconfigure() +{ + RadioLibInterface::reconfigure(); + + // set mode to standby - a chip that lost its state to a reset/brownout can fail here, + // so don't let setStandby()'s assert fire before the recovery below gets a chance + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = programModemParams(); + + if (err != RADIOLIB_ERR_NONE) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration to a chip-internal reset or brownout. Recover in place: + // begin() reprograms the chip. Crashing here instead would reboot before MeshService persists + // the config change that triggered us. RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + LOG_ERROR("RF95 rejected modem params, chip state lost? Full re-init"); + if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 unrecoverable %s%d, radio down until reboot", radioLibErr, err); + return false; + } + LOG_INFO("RF95 recovered after re-init"); + } startReceive(); // restart receiving @@ -272,17 +321,23 @@ void RF95Interface::addReceiveMetadata(meshtastic_MeshPacket *mp) LOG_DEBUG("Corrected frequency offset: %f", lora->getFrequencyError()); } -void RF95Interface::setStandby() +int16_t RF95Interface::trySetStandby() { - int err = lora->standby(); + int16_t err = lora->standby(); if (err != RADIOLIB_ERR_NONE) LOG_ERROR("RF95 standby %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); isReceiving = false; // If we were receiving, not any more disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +void RF95Interface::setStandby() +{ + int16_t err = trySetStandby(); + assert(err == RADIOLIB_ERR_NONE); } /** We override to turn on transmitter power as needed. @@ -297,13 +352,24 @@ void RF95Interface::configHardwareForSend() void RF95Interface::startReceive() { setTransmitEnable(false); - setStandby(); - int err = lora->startReceive(); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("RF95 startReceive %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = lora->startReceive(); - isReceiving = true; + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 startReceive %s%d", radioLibErr, err); + if (maybeRecoverChipStateLoss()) + err = lora->startReceive(); + } + + if (err != RADIOLIB_ERR_NONE) { + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("RF95 RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; + } + + RadioLibInterface::startReceive(); // Must be done AFTER, starting receive, because startReceive clears (possibly stale) interrupt pending register bits enableInterrupt(isrRxLevel0); @@ -313,21 +379,24 @@ void RF95Interface::startReceive() bool RF95Interface::isChannelActive() { // check if we can detect a LoRa preamble on the current channel - int16_t result; setTransmitEnable(false); - setStandby(); // needed for smooth transition - result = lora->scanChannel(); + int16_t result = trySetStandby(); // needed for smooth transition + if (result == RADIOLIB_ERR_NONE) { + result = lora->scanChannel(); - if (result == RADIOLIB_PREAMBLE_DETECTED) { - // LOG_DEBUG("Channel is busy"); - return true; + if (result == RADIOLIB_PREAMBLE_DETECTED) { + // LOG_DEBUG("Channel is busy"); + return true; + } + if (result != RADIOLIB_CHANNEL_FREE) + LOG_ERROR("RF95 isChannelActive %s%d", radioLibErr, result); + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; } - if (result != RADIOLIB_CHANNEL_FREE) - LOG_ERROR("RF95 isChannelActive %s%d", radioLibErr, result); - assert(result != RADIOLIB_ERR_WRONG_MODEM); - // LOG_DEBUG("Channel is free"); - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -339,7 +408,7 @@ bool RF95Interface::isActivelyReceiving() bool RF95Interface::sleep() { // put chipset into sleep mode - setStandby(); // First cancel any active receiving/sending + (void)trySetStandby(); // First cancel any active receiving/sending - going to sleep, a failure must not crash lora->sleep(); #ifdef RF95_POWER_EN diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index 2cd4835720..4536dbd502 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -78,5 +78,17 @@ class RF95Interface : public RadioLibInterface private: /** Some boards require GPIO control of tx vs rx paths */ void setTransmitEnable(bool txon); + + /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ + int16_t programModemParams(); + + /** begin() and chip-side setup, shared by init() and by reconfigure()'s recovery of a chip that lost its state */ + bool reinitChip(); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ + bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } }; #endif diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index c0212f4ded..aafc848e36 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -14,6 +14,7 @@ #include "SX1262Interface.h" #include "SX1268Interface.h" #include "SX1280Interface.h" +#include "UptimeClock.h" #include "configuration.h" #include "detect/LoRaRadioType.h" #include "main.h" @@ -643,7 +644,7 @@ std::unique_ptr initLoRa() if (screen) { screen->showSimpleBanner("Rebooting..."); } - rebootAtMsec = millis() + 5000; + rebootAtMsec = Time::timerEndsAtMillis(5000); } } return rIf; @@ -1432,11 +1433,13 @@ uint32_t RadioInterface::computeSlotTimeMsec() /** * Some regulatory regions limit xmit power. - * This function should be called by subclasses after setting their desired power. It might lower it + * This function should be called by subclasses after setting their desired power. It might lower it. + * Re-derives `power` from config each call so a re-init that runs it twice cannot subtract PA gain twice. */ void RadioInterface::limitPower(int8_t loraMaxPower) { - uint8_t maxPower = 255; // No limit + power = config.lora.tx_power; // applyModemConfig() writes the resolved value back here + uint8_t maxPower = 255; // No limit if (myRegion->powerLimit) maxPower = myRegion->powerLimit; @@ -1518,15 +1521,17 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) // if the sender nodenum is zero, that means uninitialized assert(radioBuffer.header.from); - // Runtime packet payload size bounds check against radioBuffer to prevent overflow in memcpy() - if (static_cast(p->encrypted.size) > sizeof(radioBuffer.payload)) { - LOG_ERROR("Packet payload size %u exceeds radioBuffer capacity %u", static_cast(p->encrypted.size), - static_cast(sizeof(radioBuffer.payload))); - packetPool.release(p); - return 0; + + // Oversize is rejected at the radio queue in Router::send(); clamp rather than fail here so this + // stays a call that always succeeds, with no failure return for startSend() to unwind. + size_t payloadLen = p->encrypted.size; + if (payloadLen > MAX_RADIO_PAYLOAD_LEN) { + LOG_ERROR("Payload %u exceeds radioBuffer capacity %u, truncate", (unsigned)payloadLen, (unsigned)MAX_RADIO_PAYLOAD_LEN); + payloadLen = MAX_RADIO_PAYLOAD_LEN; } - memcpy(radioBuffer.payload, p->encrypted.bytes, p->encrypted.size); + + memcpy(radioBuffer.payload, p->encrypted.bytes, payloadLen); sendingPacket = p; - return p->encrypted.size + sizeof(PacketHeader); + return payloadLen + sizeof(PacketHeader); } diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index eb9315ac12..67870d0ad4 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -20,6 +20,7 @@ typedef struct _meshtastic_Config_LoRaConfig meshtastic_Config_LoRaConfig; #define MAX_LORA_PAYLOAD_LEN 255 // max length of 255 per Semtech's datasheets on SX12xx #define MESHTASTIC_HEADER_LENGTH 16 #define MESHTASTIC_PKC_OVERHEAD 12 +#define MESHTASTIC_AEAD_OVERHEAD 12 #define PACKET_FLAGS_HOP_LIMIT_MASK 0x07 #define PACKET_FLAGS_WANT_ACK_MASK 0x08 @@ -67,6 +68,11 @@ typedef struct { } RadioBuffer; +/// On-air ceiling for MeshPacket.encrypted. RadioBuffer holds one byte more, but the PHY caps a whole +/// frame at MAX_LORA_PAYLOAD_LEN, so the header comes out of the same budget (matches perhapsEncode). +constexpr size_t MAX_RADIO_PAYLOAD_LEN = MAX_LORA_PAYLOAD_LEN - sizeof(PacketHeader); +static_assert(MAX_RADIO_PAYLOAD_LEN < sizeof(RadioBuffer::payload), "payload ceiling must fit the buffer"); + /** * Basic operations all radio chipsets must implement. * diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 3018a34dd3..ca534993b5 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -2,6 +2,7 @@ #include "MeshTypes.h" #include "NodeDB.h" #include "PowerMon.h" +#include "RadioTxHook.h" #include "SPILock.h" #include "Throttle.h" #include "UptimeClock.h" @@ -9,9 +10,6 @@ #include "error.h" #include "main.h" #include "mesh-pb-constants.h" -#if !MESHTASTIC_EXCLUDE_BEACON -#include "modules/MeshBeaconModule.h" -#endif #include #include @@ -111,7 +109,7 @@ bool RadioLibInterface::canSendImmediately() LOG_ERROR("Hardware Failure! busyTx >60s"); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_TRANSMIT_FAILED); // reboot in 5 seconds when this condition occurs. - rebootAtMsec = lastTxStart + 65000; + rebootAtMsec = Time::skipZero(lastTxStart + 65000); } if (busyRx) { LOG_WARN("Can not send yet, busyRx"); @@ -127,7 +125,7 @@ bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHead // Handle false detections if (detected) { if (!activeReceiveStart) { - activeReceiveStart = millis(); + activeReceiveStart = Time::skipZero(Time::getMillis()); } else if (!Throttle::isWithinTimespanMs(activeReceiveStart, 2 * preambleTimeMsec)) { if (!(irq & syncWordHeaderValidFlag)) { // The HEADER_VALID flag should be set by now if it was really a packet, so ignore PREAMBLE_DETECTED flag @@ -248,8 +246,10 @@ bool RadioLibInterface::isSending() bool RadioLibInterface::cancelSending(NodeNum from, PacketId id) { auto p = txQueue.remove(from, id); - if (p) + if (p) { + RadioTxHooks::packetReleased(this, p); packetPool.release(p); // free the packet we just removed + } bool result = (p != NULL); LOG_DEBUG("cancelSending id=0x%08x, removed=%d", id, result); @@ -269,11 +269,10 @@ void RadioLibInterface::updateNoiseFloor() return; } - uint32_t now = millis(); - if (now - lastNoiseFloorUpdate < NOISE_FLOOR_UPDATE_INTERVAL_MS) { + if (Throttle::isWithinTimespanMs(lastNoiseFloorUpdate, NOISE_FLOOR_UPDATE_INTERVAL_MS)) { return; } - lastNoiseFloorUpdate = now; + lastNoiseFloorUpdate = Time::getMillis(); int16_t rssi = getCurrentRSSI(); if (rssi == NOISE_FLOOR_INVALID || rssi >= 0 || rssi < NOISE_FLOOR_VALID_MIN) { @@ -408,14 +407,10 @@ void RadioLibInterface::onNotify(uint32_t notification) switch (notification) { case ISR_TX: handleTransmitInterrupt(); // completeSending() already restored the radio to the home config -#if !MESHTASTIC_EXCLUDE_BEACON - // Pre-switch the radio to the NEXT queued packet's beacon config (no-op for normal traffic). - // Not required for correctness - TRANSMIT_DELAY_COMPLETED would switch before CAD anyway - but - // doing it here lets the next beacon skip the switch-only delay cycle and, more importantly, - // keeps the post-TX listen window (and the CAD/LBT that follows) on the channel we're about to - // transmit on. Only engages when the next packet is itself a beacon - exactly when we want it. - MeshBeaconModule::reconfigureForBeaconTX(this, txQueue.getFront()); -#endif + // Let the hooks pre-stage the radio for the NEXT queued packet. Not required for correctness - + // TRANSMIT_DELAY_COMPLETED asks again before the scan, which is where the answer is acted on - + // but it keeps the post-TX listen window on the channel we are about to transmit on. + (void)RadioTxHooks::beforeTransmit(this, txQueue.getFront()); startReceive(); setTransmitDelay(); break; @@ -438,30 +433,25 @@ void RadioLibInterface::onNotify(uint32_t notification) meshtastic_MeshPacket *txp = txQueue.getFront(); assert(txp); const uint32_t now = Time::getMillis(); - // Not `long remaining = tx_after - millis()`: that uint32_t subtraction widens to + // Not `long remaining = tx_after - Time::getMillis()`: that uint32_t subtraction widens to // ~4.29e9 where long is 64-bit (portduino), rescheduling a due packet ~49.7 days out. if (txp->tx_after && !Throttle::deadlinePassedAt(now, txp->tx_after)) { // There's still some delay pending on this packet, so resume waiting for it to elapse notifyLater(txp->tx_after - now, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); -#if !MESHTASTIC_EXCLUDE_BEACON - } else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) { - // The beacon's target radio config is invalid (bad preset/region, or an - // unlicensed node keying up on a ham-only region). Drop the packet - never - // transmit it on the current (home) config - and move on to the next queued packet. - LOG_DEBUG("Beacon: invalid TX radio config, drop packet 0x%08x", txp->id); + } else if (const RadioTxHook::PreTxAction action = RadioTxHooks::beforeTransmit(this, txp); + action == RadioTxHook::PRETX_DROP) { + // A module refuses this packet on the radio config we are holding: drop it rather + // than transmit it, and move on to the next queued packet. meshtastic_MeshPacket *bad = txQueue.dequeue(); - MeshBeaconModule::clearTargetRadioSettings(bad); + LOG_DEBUG("Drop Tx packet 0x%08x, refused before transmit", bad->id); + RadioTxHooks::packetReleased(this, bad); packetPool.release(bad); setTransmitDelay(); - } else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) { - setTransmitDelay(); -#endif + } else if (action == RadioTxHook::PRETX_DEFER) { + setTransmitDelay(); // the radio config moved, so re-run the delay and scan on it } else { if (isChannelActive()) { // check if there is currently a LoRa packet on the channel -#if !MESHTASTIC_EXCLUDE_BEACON - if (!MeshBeaconModule::hasTargetRadioSettings(txp)) -#endif - { + if (!RadioTxHooks::holdsRadio(txp)) { startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again } setTransmitDelay(); @@ -497,8 +487,17 @@ void RadioLibInterface::setTransmitDelay() if (p->tx_after) { unsigned long add_delay = p->rx_rssi ? getTxDelayMsecWeighted(p) : getTxDelayMsec(); - unsigned long now = millis(); - p->tx_after = min(max(p->tx_after + add_delay, now + add_delay), now + 2 * getTxDelayMsecWeightedWorst(p->rx_snr)); + unsigned long now = Time::getMillis(); + // skipZero, not timerEndsAtMillis: this is a clamp of three candidates rather than a plain + // now + delay, and `if (p->tx_after)` above is the read that takes 0 as "no delay wanted" - + // so a recomputation landing on 0 drops the CSMA backoff and the packet goes out at once. + // + // Narrow to uint32_t BEFORE skipZero, not after: add_delay is unsigned long, 64-bit on the + // portduino host, so the clamp can exceed UINT32_MAX there. skipZero on the wide value would + // pass 0x100000000 through as non-zero and the store to this uint32_t field would truncate it + // back to the 0 being avoided. + p->tx_after = Time::skipZero( + (uint32_t)min(max(p->tx_after + add_delay, now + add_delay), now + 2 * getTxDelayMsecWeightedWorst(p->rx_snr))); notifyLater(p->tx_after - now, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); } else if (p->rx_snr == 0 && p->rx_rssi == 0) { /* We assume if rx_snr = 0 and rx_rssi = 0, the packet was generated locally. @@ -539,7 +538,7 @@ void RadioLibInterface::clampToLateRebroadcastWindow(NodeNum from, PacketId id) // Look for non-late packets only, so we don't do this twice! meshtastic_MeshPacket *p = txQueue.remove(from, id, true, false); if (p) { - p->tx_after = millis() + getTxDelayMsecWeightedWorst(p->rx_snr); + p->tx_after = Time::timerEndsAtMillis(getTxDelayMsecWeightedWorst(p->rx_snr)); bool dropped = false; if (txQueue.enqueue(p, &dropped)) { LOG_TRACE("Move queued packet to late rebroadcast window %ums from now", (uint32_t)(p->tx_after - millis())); @@ -561,6 +560,7 @@ bool RadioLibInterface::removePendingTXPacket(NodeNum from, PacketId id, uint32_ meshtastic_MeshPacket *p = txQueue.remove(from, id, true, true, hop_limit_lt); if (p) { LOG_DEBUG("Drop pending-TX packet 0x%08x, hop limit %d", p->id, p->hop_limit); + RadioTxHooks::packetReleased(this, p); packetPool.release(p); return true; } @@ -595,15 +595,13 @@ void RadioLibInterface::completeSending() if (!isFromUs(p)) txRelay++; printPacket("Completed sending", p); -#if !MESHTASTIC_EXCLUDE_BEACON - MeshBeaconModule::clearTargetRadioSettings(p); -#endif + // Keep this inside `if (p)`: completeSending() also runs on every setStandby(), where a hook + // undoing its own pre-TX switch would recurse back through reconfigure(). + RadioTxHooks::packetReleased(this, p); + // We are done sending that packet, release it packetPool.release(p); } -#if !MESHTASTIC_EXCLUDE_BEACON - MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); -#endif } void RadioLibInterface::handleReceiveInterrupt() @@ -717,6 +715,10 @@ void RadioLibInterface::handleReceiveInterrupt() void RadioLibInterface::startReceive() { isReceiving = true; + // Drivers only reach here once the chip actually accepted the RX start, so the radio is alive again. + // This is the sole place the recovery ladder is cleared - nothing short of an armed RX counts as fixed. + rxOffline = false; + chipRecoveryFailures = 0; powerMon->setState(meshtastic_PowerMon_State_Lora_RXOn); } @@ -736,6 +738,49 @@ void RadioLibInterface::resetAGC() // Base implementation: no-op. Override in chip-specific subclasses. } +void RadioLibInterface::periodicRadioMaintenance() +{ + // Every startReceive() call site is event-driven (RX/TX ISR, the CAD-busy branch, reconfigure), and a + // radio left with RX off can no longer raise an RX interrupt - on a node with nothing to transmit + // nothing would ever re-arm it. This periodic tick is that retry; maybeRecoverChipStateLoss() throttles. + if (rxOffline) { + LOG_WARN("Radio RX offline, retrying"); + if (maybeRecoverChipStateLoss()) + startReceive(); + return; // a chip just re-inited (or still dead) has no use for an AGC reset this tick + } + + resetAGC(); +} + +bool RadioLibInterface::maybeRecoverChipStateLoss() +{ + // One attempt per window: the transient resets this recovers from need a single re-init, and a + // chip that stays dead must not stall the TX/RX paths with a begin() attempt on every call + if (lastChipRecoveryMs && Throttle::isWithinTimespanMs(lastChipRecoveryMs, 30 * 1000UL)) { + LOG_DEBUG("Radio recovery suppressed, %us since the last attempt", (Time::getMillis() - lastChipRecoveryMs) / 1000); + return false; + } + + // The ladder counts re-arms, not re-inits: only RadioLibInterface::startReceive() clears the count, and + // only once the chip really accepted RX. Judging the previous attempt here - a throttle window later, + // after its retry - is what stops a begin() that succeeded while leaving RX dead from crediting itself. + if (chipRecoveryFailures >= MAX_CHIP_RECOVERY_FAILURES && rebootAtMsec == 0) { + // Attempts are a throttle window apart, so this is minutes of a provably deaf chip. begin() alone + // clearly isn't reviving it; reboot to re-run init(), which redoes the power-on sequence it skips. + LOG_ERROR("Radio still deaf after %u re-inits, rebooting", chipRecoveryFailures); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); + } + chipRecoveryFailures++; + + lastChipRecoveryMs = Time::skipZero(Time::getMillis()); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + LOG_ERROR("Radio chip state lost mid-operation, re-init"); + bool recovered = recoverChipStateLoss(); + LOG_INFO("Radio re-init %s", recovered ? "succeeded" : "failed"); + return recovered; +} + void RadioLibInterface::checkRxDoneIrqFlag() { if (iface->checkIrq(RADIOLIB_IRQ_RX_DONE)) { @@ -771,30 +816,14 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) channel scan and actual transmit as low as possible to avoid collisions. */ if (disabled || !config.lora.tx_enabled) { LOG_WARN("Drop Tx packet: LoRa Tx disabled"); -#if !MESHTASTIC_EXCLUDE_BEACON - // This packet may have already triggered a beacon radio switch in TRANSMIT_DELAY_COMPLETED; - // since it never reaches completeSending() here, restore the radio so it isn't left on the - // beacon config (which would also break RX on the home channel). - MeshBeaconModule::clearTargetRadioSettings(txp); - MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); -#endif + // Never reaches completeSending(), so any per-packet radio state has to be released here. + RadioTxHooks::packetReleased(this, txp); packetPool.release(txp); return false; } else { configHardwareForSend(); // must be after setStandby -#if !MESHTASTIC_EXCLUDE_BEACON - MeshBeaconModule::clearTargetRadioSettings(txp); -#endif size_t numbytes = beginSending(txp); - if (numbytes == 0) { - if (!sendingPacket) { - completeSending(); - powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); - startReceive(); - } - return false; - } int res = iface->startTransmit((uint8_t *)&radioBuffer, numbytes); if (res != RADIOLIB_ERR_NONE) { @@ -809,7 +838,8 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) // Must be done AFTER, starting transmit, because startTransmit clears (possibly stale) interrupt pending register // bits enableInterrupt(isrTxLevel0); - lastTxStart = millis(); + // unset-sentinel-ok: busyTx/sendingPacket is the armed flag, so 0 is a legal stamp + lastTxStart = Time::getMillis(); printPacket("Started Tx", txp); #ifdef LED_LORA digitalWrite(LED_LORA, LED_STATE_ON); diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 0142721789..6dcd0876f7 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -180,6 +180,24 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ virtual void resetAGC(); + /** Periodic radio upkeep: re-arms RX if a failed startReceive() left it off, otherwise resets AGC. */ + void periodicRadioMaintenance(); + + /** Chip-specific recovery of a chip that lost its state to a reset/brownout. Returns true if reprogrammed. */ + virtual bool recoverChipStateLoss() { return false; } + + /** Throttled recoverChipStateLoss(), so a dead chip can't stall the RX/TX hot paths with repeated begin(). */ + bool maybeRecoverChipStateLoss(); + + uint32_t lastChipRecoveryMs = 0; + + /// Consecutive recovery attempts that never got RX armed again, before rebooting to re-run init() + static constexpr uint8_t MAX_CHIP_RECOVERY_FAILURES = 5; + uint8_t chipRecoveryFailures = 0; + + /// Set by a driver's startReceive() when it gives up and leaves RX off; cleared once RX is armed again. + bool rxOffline = false; + /** * Debugging counts */ diff --git a/src/mesh/RadioTxHook.cpp b/src/mesh/RadioTxHook.cpp new file mode 100644 index 0000000000..f1fc9e8aca --- /dev/null +++ b/src/mesh/RadioTxHook.cpp @@ -0,0 +1,43 @@ +#include "RadioTxHook.h" + +RadioTxHook *RadioTxHook::hookList = nullptr; + +RadioTxHook::RadioTxHook() +{ + nextHook = hookList; + hookList = this; +} + +RadioTxHook::~RadioTxHook() +{ + for (RadioTxHook **slot = &hookList; *slot; slot = &(*slot)->nextHook) { + if (*slot == this) { + *slot = nextHook; + break; + } + } +} + +RadioTxHook::PreTxAction RadioTxHooks::beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p) +{ + for (RadioTxHook *h = RadioTxHook::hookList; h; h = h->nextHook) { + const RadioTxHook::PreTxAction action = h->beforeTransmit(iface, p); + if (action != RadioTxHook::PRETX_SEND) + return action; + } + return RadioTxHook::PRETX_SEND; +} + +bool RadioTxHooks::holdsRadio(const meshtastic_MeshPacket *p) +{ + for (RadioTxHook *h = RadioTxHook::hookList; h; h = h->nextHook) + if (h->holdsRadio(p)) + return true; + return false; +} + +void RadioTxHooks::packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p) +{ + for (RadioTxHook *h = RadioTxHook::hookList; h; h = h->nextHook) + h->packetReleased(iface, p); +} diff --git a/src/mesh/RadioTxHook.h b/src/mesh/RadioTxHook.h new file mode 100644 index 0000000000..3681d59d61 --- /dev/null +++ b/src/mesh/RadioTxHook.h @@ -0,0 +1,50 @@ +#pragma once + +#include "MeshTypes.h" + +class RadioInterface; + +/** + * A module's hook into the radio driver's per-packet TX lifecycle. + * + * The driver knows this interface and nothing about who implements it: a module needing per-packet + * radio state (MeshBeacon's preset switch) subclasses this, and one instance registers itself for + * the life of the program. + */ +class RadioTxHook +{ + friend class RadioTxHooks; + + RadioTxHook *nextHook = nullptr; + static RadioTxHook *hookList; + + public: + /// What the driver should do with the packet at the head of the TX queue. + enum PreTxAction { + PRETX_SEND, ///< nothing pending, transmit as usual + PRETX_DEFER, ///< the radio config changed, re-run the transmit delay before sending + PRETX_DROP ///< this packet must not go out on the current radio config + }; + + RadioTxHook(); + virtual ~RadioTxHook(); + + /// The driver is about to transmit p (NULL when the queue is empty): set up any state it needs. + virtual PreTxAction beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p) { return PRETX_SEND; } + + /// True while p needs the radio left on its own config, so the driver must not listen instead. + virtual bool holdsRadio(const meshtastic_MeshPacket *p) { return false; } + + /// The driver is done with p - sent, cancelled or dropped. Release anything held for it. + virtual void packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p) {} +}; + +/// Driver-side fan-out over the registered hooks; every call is a no-op when none are registered. +class RadioTxHooks +{ + public: + /// The first hook not returning PRETX_SEND decides, and the rest are not consulted. + static RadioTxHook::PreTxAction beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p); + static bool holdsRadio(const meshtastic_MeshPacket *p); + static void packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p); +}; diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 4e8d2c2e90..7be23ee810 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -2,6 +2,7 @@ #include "Default.h" #include "MeshTypes.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "configuration.h" #include "memGet.h" #include "mesh-pb-constants.h" @@ -74,7 +75,9 @@ void ReliableRouter::perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_ LOG_DEBUG("Generate implicit ack"); // NOTE: we do NOT check p->wantAck here because p is the INCOMING rebroadcast and that packet is not expected to be // marked as wantAck - sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel); + // Pass the overheard rebroadcast as the relay source so the ack carries the relaying node's id + // and the RSSI/SNR we heard it at. + sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel, 0, false, p); // Only stop retransmissions if the rebroadcast came via LoRa if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) { @@ -178,7 +181,7 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas // M3: an end-to-end ACK proves the directed route to the ACK's sender currently works, // so clear its failure count and refresh freshness (keeps a good route pinned). if (!isBroadcast(getFrom(p))) - noteRouteSuccess(getFrom(p), millis()); + noteRouteSuccess(getFrom(p), Time::stampMillis()); } else { stopRetransmission(p->to, nakId); } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 34f477438c..55a10ebaa4 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -34,6 +34,11 @@ #include "serialization/MeshPacketSerializer.h" #endif +// The size checks below budget for the tag that encryptPacketCCM actually appends, so the +// two constants must not drift apart. +static_assert(MESHTASTIC_AEAD_OVERHEAD == CryptoEngine::AEAD_TAG_SIZE, + "MESHTASTIC_AEAD_OVERHEAD must match CryptoEngine::AEAD_TAG_SIZE"); + #define MAX_RX_FROMRADIO \ 4 // max number of packets destined to our queue, we dispatch packets quickly so it doesn't need to be big @@ -384,9 +389,9 @@ meshtastic_MeshPacket *Router::allocForSending() * Send an ack or a nak packet back towards whoever sent idFrom */ void Router::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit, - bool ackWantsAck) + bool ackWantsAck, const meshtastic_MeshPacket *relaySource) { - routingModule->sendAckNak(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + routingModule->sendAckNak(err, to, idFrom, chIndex, hopLimit, ackWantsAck, relaySource); } void Router::abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p) @@ -602,6 +607,15 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) } #endif + // Only already-encrypted frames (relayed, phone-sourced) reach here oversized; perhapsEncode() + // bounds everything it encodes. No NAK: p->channel is a wire hash by now, not an index. + if (p->encrypted.size > MAX_RADIO_PAYLOAD_LEN) { + LOG_WARN("Drop 0x%08x: payload %u exceeds radio capacity %u", p->id, (unsigned)p->encrypted.size, + (unsigned)MAX_RADIO_PAYLOAD_LEN); + packetPool.release(p); + return meshtastic_Routing_Error_TOO_LARGE; + } + assert(iface); // This should have been detected already in sendLocal (or we just received a packet from outside) return iface->send(p); } @@ -1032,15 +1046,32 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) // we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a // fresh copy for each decrypt attempt. memcpy(bytes, p->encrypted.bytes, rawSize); - // Try to decrypt the packet if we can - crypto->decrypt(p->from, p->id, rawSize, bytes); + + size_t decryptedSize = rawSize; + + if (channels.isAEADEnabled(chIndex)) { + // AEAD decryption - no CTR fallback + if (rawSize <= MESHTASTIC_AEAD_OVERHEAD) { + LOG_ERROR("Packet too small for AEAD (size=%d)", rawSize); + continue; + } + CryptoKey k = channels.getKey(chIndex); + if (!crypto->decryptPacketCCM(k, p->from, p->to, p->id, rawSize, p->encrypted.bytes, bytes)) { + LOG_WARN("AEAD authentication failed for ch %d", chIndex); + continue; // reject - no fallback to CTR + } + decryptedSize = rawSize - MESHTASTIC_AEAD_OVERHEAD; + } else { + // Standard AES-CTR decryption + crypto->decrypt(p->from, p->id, rawSize, bytes); + } // printBytes("plaintext", bytes, p->encrypted.size); // Take those raw bytes and convert them back into a well structured protobuf we can understand meshtastic_Data decodedtmp; memset(&decodedtmp, 0, sizeof(decodedtmp)); - if (!pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp)) { + if (!pb_decode_from_bytes(bytes, decryptedSize, &meshtastic_Data_msg, &decodedtmp)) { LOG_DEBUG("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)", p->id); } else if (decodedtmp.portnum == meshtastic_PortNum_UNKNOWN_APP) { LOG_DEBUG("Invalid portnum (bad psk?)"); @@ -1120,7 +1151,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) JSONFile.close(); } JSONFile.open(portduino_config.JSONFilename + "_" + datetime, std::ios::out | std::ios::app); - fileage = millis(); + fileage = Time::skipZero(Time::getMillis()); } } if (portduino_config.JSONFilter == (_meshtastic_PortNum)0 || portduino_config.JSONFilter == p->decoded.portnum) { @@ -1294,38 +1325,38 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) numbytes += MESHTASTIC_PKC_OVERHEAD; p->channel = 0; p->pki_encrypted = true; - } else { + } else +#endif + { if (p->pki_encrypted == true) { // Client specifically requested PKI encryption return meshtastic_Routing_Error_PKI_FAILED; } + const bool useAead = channels.isAEADEnabled(chIndex); + if (useAead && numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_AEAD_OVERHEAD > MAX_LORA_PAYLOAD_LEN) + return meshtastic_Routing_Error_TOO_LARGE; + hash = channels.setActiveByIndex(chIndex); // Now that we are encrypting the packet channel should be the hash (no longer the index) p->channel = hash; - if (hash < 0) { - // No suitable channel could be found for + if (hash < 0) return meshtastic_Routing_Error_NO_CHANNEL; - } - crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); - memcpy(p->encrypted.bytes, bytes, numbytes); - } -#else - if (p->pki_encrypted == true) { - // Client specifically requested PKI encryption - return meshtastic_Routing_Error_PKI_FAILED; - } - hash = channels.setActiveByIndex(chIndex); - // Now that we are encrypting the packet channel should be the hash (no longer the index) - p->channel = hash; - if (hash < 0) { - // No suitable channel could be found for - return meshtastic_Routing_Error_NO_CHANNEL; + if (useAead) { + // AEAD (AES-CCM) authenticated encryption path + CryptoKey k = channels.getKey(chIndex); + if (!crypto->encryptPacketCCM(k, getFrom(p), p->to, p->id, numbytes, bytes, p->encrypted.bytes)) { + LOG_ERROR("AEAD encryption failed for ch %d", chIndex); + return meshtastic_Routing_Error_BAD_REQUEST; + } + numbytes += MESHTASTIC_AEAD_OVERHEAD; + } else { + // Standard AES-CTR encryption path + crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); + memcpy(p->encrypted.bytes, bytes, numbytes); + } } - crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); - memcpy(p->encrypted.bytes, bytes, numbytes); -#endif // Copy back into the packet and set the variant type p->encrypted.size = numbytes; diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 069b4ede0a..d4ffe85c15 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -171,7 +171,7 @@ class Router : protected concurrency::OSThread, protected PacketHistory * Send an ack or a nak packet back towards whoever sent idFrom */ void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false); + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr); private: /** diff --git a/src/mesh/STM32WLE5JCInterface.cpp b/src/mesh/STM32WLE5JCInterface.cpp index f6e4b3512a..567cd4c00f 100644 --- a/src/mesh/STM32WLE5JCInterface.cpp +++ b/src/mesh/STM32WLE5JCInterface.cpp @@ -19,8 +19,8 @@ bool STM32WLE5JCInterface::init() RadioLibInterface::init(); // https://github.com/Seeed-Studio/LoRaWan-E5-Node/blob/main/Middlewares/Third_Party/SubGHz_Phy/stm32_radio_driver/radio_driver.c -#if (!defined(_VARIANT_RAK3172_)) - setTCXOVoltage(1.7); +#if defined(SX126X_DIO3_TCXO_VOLTAGE) + setTCXOVoltage(SX126X_DIO3_TCXO_VOLTAGE); #endif lora.setRfSwitchTable(rfswitch_pins, rfswitch_table); @@ -29,6 +29,17 @@ bool STM32WLE5JCInterface::init() int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); +#if defined(TCXO_OPTIONAL) + // If a TCXO was requested but isn't actually populated (e.g. non-T RAK3172), retry on XTAL + if (res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) { + LOG_WARN("STM32WLx init failed with TCXO Vref %fV (err %d), retrying without TCXO", tcxoVoltage, res); + setTCXOVoltage(0); + res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + if (res == RADIOLIB_ERR_NONE) + LOG_INFO("STM32WLx init success without TCXO (XTAL mode)"); + } +#endif + LOG_INFO("STM32WLx init result %d", res); LOG_INFO("Frequency set to %f", getFreq()); diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 2400a8e03f..ab1f5eb38b 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -81,16 +81,32 @@ template bool SX126xInterface::init() else LOG_DEBUG("SX126X_DIO3_TCXO_VOLTAGE defined, DIO3 as TCXO Vref %f V", tcxoVoltage); setTransmitEnable(false); - // FIXME: May want to set depending on a definition, currently all SX126x variant files use the DC-DC regulator option - bool useRegulatorLDO = false; // Seems to depend on the connection to pin 9/DCC_SW - if an inductor DCDC? RadioLibInterface::init(); + if (!reinitChip()) + return false; + + startReceive(); // start receiving + + return true; +} + +// begin() and the chip-side setup that a reset chip loses: begin() hardware-resets the chip, then +// PA ramp, OCP limit, DIO2-as-RF-switch, RF switch pins, RX gain, the 0x8B5 RX patch, and CRC are +// reprogrammed. Shared by init() and by reconfigure()'s recovery path. +template bool SX126xInterface::reinitChip() +{ + // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw + // config value, and the recovery path reaches begin() without passing through the params clamp limitPower(SX126X_MAX_POWER); // Make sure we reach the minimum power supported to turn the chip on (-9dBm) if (power < -9) power = -9; + // FIXME: May want to set depending on a definition, currently all SX126x variant files use the DC-DC regulator option + bool useRegulatorLDO = false; // Seems to depend on the connection to pin 9/DCC_SW - if an inductor DCDC? + int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage, useRegulatorLDO); #ifdef SX126X_PA_RAMP_US @@ -182,50 +198,55 @@ template bool SX126xInterface::init() res = lora.setOutputPower(power, false); #endif - if (res == RADIOLIB_ERR_NONE) - startReceive(); // start receiving - + if (res != RADIOLIB_ERR_NONE) + LOG_ERROR("SX126x re-init failed %s%d", radioLibErr, res); return res == RADIOLIB_ERR_NONE; } -template bool SX126xInterface::reconfigure() +template int16_t SX126xInterface::programModemParams() { - RadioLibInterface::reconfigure(); - - // set mode to standby - setStandby(); - // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + int16_t err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + return err; + } err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + return err; + } err = lora.setCodingRate(cr); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setCodingRate(%u) %s%d", cr, radioLibErr, err); + return err; + } err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; + } err = lora.setCurrentLimit(currentLimit); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X setCurrentLimit %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; + } err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX126X setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + return err; + } err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err); + return err; + } limitPower(SX126X_MAX_POWER); // Make sure we reach the minimum power supported to turn the chip on (-9dBm) @@ -249,6 +270,31 @@ template bool SX126xInterface::reconfigure() if (err != RADIOLIB_ERR_NONE) LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err); + return RADIOLIB_ERR_NONE; +} + +template bool SX126xInterface::reconfigure() +{ + RadioLibInterface::reconfigure(); + + // set mode to standby - a chip that lost its state to a reset/brownout can time out here (-707), + // so don't let setStandby()'s assert fire before the recovery below gets a chance + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = programModemParams(); + + if (err != RADIOLIB_ERR_NONE) { + // Chip likely lost its state (reset/brownout); recover in place rather than crash - see + // RadioLibInterface::recoverChipStateLoss(). + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + LOG_ERROR("SX126x rejected modem params, chip state lost? Full re-init"); + if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126x unrecoverable %s%d, radio down until reboot", radioLibErr, err); + return false; + } + LOG_INFO("SX126x recovered after re-init"); + } + startReceive(); // restart receiving return true; @@ -316,25 +362,34 @@ template void SX126xInterface::handleSoftwareLoraIrqPoll() } #endif -template void SX126xInterface::setStandby() +template int16_t SX126xInterface::trySetStandby() { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + int16_t err = lora.standby(); if (err != RADIOLIB_ERR_NONE) LOG_DEBUG("SX126x standby %s%d", radioLibErr, err); #ifdef ARCH_PORTDUINO if (err != RADIOLIB_ERR_NONE) portduino_status.LoRa_in_error = true; -#else - assert(err == RADIOLIB_ERR_NONE); #endif isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void SX126xInterface::setStandby() +{ + int16_t err = trySetStandby(); +#ifdef ARCH_PORTDUINO + (void)err; +#else + assert(err == RADIOLIB_ERR_NONE); +#endif } /** @@ -367,26 +422,43 @@ template void SX126xInterface::startReceive() #else setTransmitEnable(false); - setStandby(); #ifdef ARCH_PORTDUINO_WASM - // Continuous RX in the browser: duty-cycle sleep parks BUSY high between RX - // windows and stalls the slow WebUSB SPI link. No battery to save here. - int err = lora.startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); const char *rxMethod = "startReceive"; #else - // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. - int err = lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); const char *rxMethod = "startReceiveDutyCycleAuto"; #endif - if (err != RADIOLIB_ERR_NONE) + auto tryStartRx = [&]() -> int16_t { +#ifdef ARCH_PORTDUINO_WASM + // Continuous RX in the browser: duty-cycle sleep parks BUSY high between RX + // windows and stalls the slow WebUSB SPI link. No battery to save here. + return lora.startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); +#else + // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. + return lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); +#endif + }; + + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = tryStartRx(); + + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X %s %s%d", rxMethod, radioLibErr, err); + if (maybeRecoverChipStateLoss()) + err = tryStartRx(); + } + + if (err != RADIOLIB_ERR_NONE) { #ifdef ARCH_PORTDUINO - if (err != RADIOLIB_ERR_NONE) portduino_status.LoRa_in_error = true; #else - assert(err == RADIOLIB_ERR_NONE); + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("SX126X RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; #endif + } RadioLibInterface::startReceive(); @@ -407,22 +479,23 @@ template bool SX126xInterface::isChannelActive() .timeout = 0, .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; - int16_t result; setTransmitEnable(false); - setStandby(); - result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) - return true; - if (result != RADIOLIB_CHANNEL_FREE) - LOG_ERROR("SX126X scanChannel %s%d", radioLibErr, result); + int16_t result = trySetStandby(); + if (result == RADIOLIB_ERR_NONE) { + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + if (result != RADIOLIB_CHANNEL_FREE) + LOG_ERROR("SX126X scanChannel %s%d", radioLibErr, result); + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; + } #ifdef ARCH_PORTDUINO - if (result == RADIOLIB_ERR_WRONG_MODEM) - portduino_status.LoRa_in_error = true; -#else - assert(result != RADIOLIB_ERR_WRONG_MODEM); + portduino_status.LoRa_in_error = true; #endif - - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -438,7 +511,7 @@ template bool SX126xInterface::sleep() // Not keeping config is busted - next time nrf52 board boots lora sending fails tcxo related? - see datasheet // \todo Display actual typename of the adapter, not just `SX126x` LOG_DEBUG("SX126x entering sleep mode"); // (FIXME, don't keep config) - setStandby(); // Stop any pending operations + (void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash // turn off TCXO if it was powered // FIXME - this isn't correct @@ -496,6 +569,10 @@ template void SX126xInterface::resetAGC() // 5. Re-calibrate image rejection for actual operating frequency // Calibrate(0x7F) defaults to 902-928 MHz which is wrong for other regions. lora.calibrateImage(getFreq()); + // 6. CalibrateImage keeps working internally after it returns, and BUSY does not + // stay asserted for it; a register write in that window fails write-verify + // (RADIOLIB_ERR_SPI_WRITE_FAILED) and stalls the chip. + module.hal->delay(50); // Re-apply settings that calibration may have reset @@ -519,7 +596,7 @@ template void SX126xInterface::resetAGC() LOG_WARN("SX126x resetAGC: 0x8B5 RX patch re-apply failed"); } - // 6. Resume receiving + // 7. Resume receiving startReceive(); } diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index 9465064b8a..eb1080d8b9 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -90,5 +90,17 @@ template class SX126xInterface : public RadioLibInterface #endif /** Some boards require GPIO control of tx vs rx paths */ void setTransmitEnable(bool txon); + + /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ + int16_t programModemParams(); + + /** begin() and chip-side setup, shared by init() and by reconfigure()'s recovery of a chip that lost its state */ + bool reinitChip(); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ + bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } }; #endif \ No newline at end of file diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index bb1d890247..3de65fad0e 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -62,6 +62,20 @@ template bool SX128xInterface::init() RadioLibInterface::init(); + if (!reinitChip(/*fromInit=*/true)) + return false; + + startReceive(); // start receiving + + return true; +} + +// begin() and the chip-side setup that a reset chip loses. Shared by init() and by reconfigure()'s +// recovery of a chip that lost its state. +template bool SX128xInterface::reinitChip(bool fromInit) +{ + // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw + // config value, and the recovery path reaches begin() without passing through the params clamp limitPower(SX128X_MAX_POWER); preambleLength = 12; // 12 is the default for this chip, 32 does not RX at all @@ -73,6 +87,12 @@ template bool SX128xInterface::init() return false; if ((config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) && (res == RADIOLIB_ERR_INVALID_FREQUENCY)) { + // Boot-time only: rebooting out of a runtime recovery would reintroduce exactly the crash this + // recovery path exists to avoid, and would do it while a config save is still pending. + if (!fromInit) { + LOG_ERROR("SX128x rejected the frequency during recovery; leaving region alone"); + return false; + } LOG_WARN("Radio only supports 2.4GHz LoRa. Adjusting Region and rebooting"); config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; nodeDB->saveToDisk(SEGMENT_CONFIG); @@ -104,52 +124,84 @@ template bool SX128xInterface::init() if (res == RADIOLIB_ERR_NONE) res = lora.setCRC(2); - if (res == RADIOLIB_ERR_NONE) - startReceive(); // start receiving - + if (res != RADIOLIB_ERR_NONE) + LOG_ERROR("SX128x re-init failed %s%d", radioLibErr, res); return res == RADIOLIB_ERR_NONE; } +template int16_t SX128xInterface::programModemParams() +{ + // configure publicly accessible settings + int16_t err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + return err; + } + + err = lora.setBandwidth(bw); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + return err; + } + + err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setCodingRate(%u) %s%d", cr, radioLibErr, err); + return err; + } + + err = lora.setSyncWord(syncWord); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setSyncWord %s%d", radioLibErr, err); + return err; + } + + err = lora.setPreambleLength(preambleLength); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + return err; + } + + err = lora.setFrequency(getFreq()); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err); + return err; + } + + limitPower(SX128X_MAX_POWER); + + err = lora.setOutputPower(power); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setOutputPower(%d) %s%d", power, radioLibErr, err); + return err; + } + + return RADIOLIB_ERR_NONE; +} + template bool SX128xInterface::reconfigure() { RadioLibInterface::reconfigure(); - // set mode to standby - setStandby(); + // set mode to standby - a chip that lost its state to a reset/brownout can time out here, + // so don't let setStandby()'s assert fire before the recovery below gets a chance + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = programModemParams(); - // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration - packet type included - to a chip-internal reset or brownout. + // Recover in place: begin() hardware-resets the chip and restores the LoRa packet type. Crashing + // here instead would reboot before MeshService persists the config change that triggered us. RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); - - err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); - - err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - limitPower(SX128X_MAX_POWER); - - err = lora.setOutputPower(power); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setOutputPower %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + LOG_ERROR("SX128x rejected modem params, chip state lost? Full re-init"); + if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128x unrecoverable %s%d, radio down until reboot", radioLibErr, err); + return false; + } + LOG_INFO("SX128x recovered after re-init"); + } startReceive(); // restart receiving @@ -166,15 +218,14 @@ template bool SX128xInterface::wideLora() return true; } -template void SX128xInterface::setStandby() +template int16_t SX128xInterface::trySetStandby() { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + int16_t err = lora.standby(); if (err != RADIOLIB_ERR_NONE) LOG_ERROR("SX128x standby %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); #if ARCH_PORTDUINO if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { digitalWrite(portduino_config.lora_rxen_pin.pin, LOW); @@ -195,6 +246,13 @@ template void SX128xInterface::setStandby() disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void SX128xInterface::setStandby() +{ + int16_t err = trySetStandby(); + assert(err == RADIOLIB_ERR_NONE); } /** @@ -242,8 +300,6 @@ template void SX128xInterface::startReceive() sleep(); #else - setStandby(); - #if ARCH_PORTDUINO if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { digitalWrite(portduino_config.lora_rxen_pin.pin, HIGH); @@ -261,11 +317,22 @@ template void SX128xInterface::startReceive() #endif #endif - int err = lora.startReceive(RADIOLIB_SX128X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = lora.startReceive(RADIOLIB_SX128X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX128X startReceive %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + if (maybeRecoverChipStateLoss()) + err = lora.startReceive(RADIOLIB_SX128X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); + } + + if (err != RADIOLIB_ERR_NONE) { + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("SX128X RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; + } RadioLibInterface::startReceive(); @@ -286,17 +353,20 @@ template bool SX128xInterface::isChannelActive() .timeout = 0, .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; - int16_t result; + int16_t result = trySetStandby(); + if (result == RADIOLIB_ERR_NONE) { + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + if (result != RADIOLIB_CHANNEL_FREE) + LOG_ERROR("SX128X scanChannel %s%d", radioLibErr, result); + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; + } - setStandby(); - result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) - return true; - if (result != RADIOLIB_CHANNEL_FREE) - LOG_ERROR("SX128X scanChannel %s%d", radioLibErr, result); - assert(result != RADIOLIB_ERR_WRONG_MODEM); - - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -310,7 +380,7 @@ template bool SX128xInterface::sleep() // Not keeping config is busted - next time nrf52 board boots lora sending fails tcxo related? - see datasheet // \todo Display actual typename of the adapter, not just `SX128x` LOG_DEBUG("SX128x entering sleep mode"); // (FIXME, don't keep config) - setStandby(); // Stop any pending operations + (void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash // turn off TCXO if it was powered // FIXME - this isn't correct diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index 3b9015249e..967142c49a 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -74,4 +74,19 @@ template class SX128xInterface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + + private: + /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ + int16_t programModemParams(); + + /** begin() and chip-side setup, shared by init() and by reconfigure()'s recovery of a chip that lost its state */ + /** @param fromInit true only for the boot-time call, which may adjust region and reboot on a + * 2.4GHz-only part; a runtime recovery must never reboot the node. */ + bool reinitChip(bool fromInit = false); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ + bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } }; diff --git a/src/mesh/Throttle.cpp b/src/mesh/Throttle.cpp index 606ba737e9..0c2e316da6 100644 --- a/src/mesh/Throttle.cpp +++ b/src/mesh/Throttle.cpp @@ -10,6 +10,8 @@ /// @return true if the function was executed, false if it was deferred bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*throttleFunc)(void), void (*onDefer)(void)) { + // TODO(elapsed-stamp): 0 doubles as "never run" here, so neither store is safe on the wrap tick: + // skipZero()'s 1 underflows a same-instant `now - *lastExecutionMs`, and 0 re-takes this branch. if (*lastExecutionMs == 0) { *lastExecutionMs = Time::getMillis(); throttleFunc(); diff --git a/src/mesh/Throttle.h b/src/mesh/Throttle.h index f9d68a4143..86740f5777 100644 --- a/src/mesh/Throttle.h +++ b/src/mesh/Throttle.h @@ -32,15 +32,17 @@ class Throttle /// Deadline::in(ms) / .armed() / .passed() / .disarm(). A hand-built `now + interval` could then /// no longer land on the sentinel by accident, and "armed" would stay a question separate from /// "passed" - the split that has to survive, because which way "inactive" falls is the caller's - /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the four + /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the three /// meanings they give the sentinel today: - /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec (the cheapest pair to convert), and - /// GPS.cpp fixHoldEnds, whose arm site remaps a 0 result to 1 by hand. + /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec, GPS.cpp fixHoldEnds, AdminModule.cpp + /// enterDfuAtMsec and the other timerEndsAtMillis()/skipZero() arm sites dodge + /// it; RadioLibInterface::setTransmitDelay()'s tx_after recompute still cannot. /// 0 = forever - NotificationRenderer.cpp alertBannerUntil. Every read spells its own `> 0` /// guard, so this third state wants naming rather than repeating. - /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. - /// UINT32_MAX - ExternalNotificationModule.cpp nagCycleCutoff, whose armed() also lives in a - /// second variable (isNagging) and whose arm site can land on the sentinel. + /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. A computed renewal now dodges 0, + /// so only a deliberate write still means "due now". + /// ExternalNotificationModule.cpp nagCycleCutoff reserves nothing: isNagging is the only armed + /// flag and the deadline is read only while it is set. static bool deadlinePassed(uint32_t deadlineMs); /// deadlinePassed() against a caller-supplied "now", for a loop that snapshots the time once and diff --git a/src/mesh/TransmitHistory.cpp b/src/mesh/TransmitHistory.cpp index 35144ec0d7..4b2ebc8a37 100644 --- a/src/mesh/TransmitHistory.cpp +++ b/src/mesh/TransmitHistory.cpp @@ -1,6 +1,7 @@ #include "TransmitHistory.h" #include "FSCommon.h" #include "SPILock.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include @@ -81,7 +82,7 @@ void TransmitHistory::loadFromDisk() void TransmitHistory::setLastSentToMesh(uint16_t key) { - lastMillis[key] = millis(); + lastMillis[key] = Time::skipZero(Time::getMillis()); uint32_t now = getTime(); if (now >= 2) { const uint8_t flags = (getRTCQuality() == RTCQualityNone) ? ENTRY_FLAG_BOOT_RELATIVE : ENTRY_FLAG_NONE; @@ -94,7 +95,7 @@ void TransmitHistory::setLastSentToMesh(uint16_t key) // after boot so a crash-reboot loop can't avoid persisting. if (lastDiskSave == 0 || !Throttle::isWithinTimespanMs(lastDiskSave, SAVE_INTERVAL_MS)) { if (saveToDisk()) { - lastDiskSave = millis(); + lastDiskSave = Time::skipZero(Time::getMillis()); } } } @@ -151,7 +152,7 @@ uint32_t TransmitHistory::getLastSentAbsoluteMillis(uint32_t storedEpoch) const return 0; } - return millis() - msAgo; + return Time::skipZero(Time::getMillis() - msAgo); } uint32_t TransmitHistory::getLastSentBootRelativeMillis(uint32_t storedSeconds) const @@ -167,7 +168,7 @@ uint32_t TransmitHistory::getLastSentBootRelativeMillis(uint32_t storedSeconds) if (secondsAgo > BOOT_RELATIVE_RECOVERY_WINDOW_SEC) { return 0; } - return millis() - (secondsAgo * 1000); + return Time::skipZero(Time::getMillis() - (secondsAgo * 1000)); } uint32_t secondsAhead = storedSeconds - now; @@ -175,7 +176,7 @@ uint32_t TransmitHistory::getLastSentBootRelativeMillis(uint32_t storedSeconds) return 0; } - return millis(); + return Time::skipZero(Time::getMillis()); } uint32_t TransmitHistory::getLastSentToMeshMillis(uint16_t key) const @@ -286,7 +287,7 @@ void TransmitHistory::loadFromDisk() {} void TransmitHistory::setLastSentToMesh(uint16_t key) { - lastMillis[key] = millis(); + lastMillis[key] = Time::skipZero(Time::getMillis()); } uint32_t TransmitHistory::getLastSentToMeshEpoch(uint16_t key) const diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 9fc80fd863..3f137e107c 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -22,6 +22,7 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite); info.is_muted = nodeInfoLiteIsMuted(lite); info.has_xeddsa_signed = nodeInfoLiteHasXeddsaSigned(lite); + info.heard_on_current_lora = nodeDB && nodeInfoLiteHeardOnSlot(lite, nodeDB->committedLoraSlot()); if (lite->has_hops_away) { info.has_hops_away = true; diff --git a/src/mesh/aes-ccm.cpp b/src/mesh/aes-ccm.cpp index 29e96cd4be..d9702c0c1b 100644 --- a/src/mesh/aes-ccm.cpp +++ b/src/mesh/aes-ccm.cpp @@ -8,7 +8,6 @@ */ #define AES_BLOCK_SIZE 16 #include "aes-ccm.h" -#if !MESHTASTIC_EXCLUDE_PKI /** * Constant-time comparison of two byte arrays @@ -178,4 +177,3 @@ bool aes_ccm_ad(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t } return true; } -#endif \ No newline at end of file diff --git a/src/mesh/aes-ccm.h b/src/mesh/aes-ccm.h index 6b8edcde49..b3642ceb6d 100644 --- a/src/mesh/aes-ccm.h +++ b/src/mesh/aes-ccm.h @@ -1,10 +1,8 @@ #pragma once #include "CryptoEngine.h" -#if !MESHTASTIC_EXCLUDE_PKI int aes_ccm_ae(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t M, const uint8_t *plain, size_t plain_len, const uint8_t *aad, size_t aad_len, uint8_t *crypt, uint8_t *auth); bool aes_ccm_ad(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t M, const uint8_t *crypt, size_t crypt_len, const uint8_t *aad, size_t aad_len, const uint8_t *auth, uint8_t *plain); -#endif \ No newline at end of file diff --git a/src/mesh/api/PacketAPI.cpp b/src/mesh/api/PacketAPI.cpp index c8adda5204..3590cfdb76 100644 --- a/src/mesh/api/PacketAPI.cpp +++ b/src/mesh/api/PacketAPI.cpp @@ -2,6 +2,7 @@ // First, in its own block so the include sorter keeps it there: configuration.h supplies the // variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime). +#include "UptimeClock.h" #include "configuration.h" #include "MeshService.h" @@ -59,7 +60,7 @@ bool PacketAPI::receivePacket(void) data_received = true; powerFSM.trigger(EVENT_INPUT); - lastContactMsec = millis(); + lastContactMsec = Time::skipZero(Time::getMillis()); meshtastic_ToRadio *mr; auto p = server->receivePacket()->move(); diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index bf85eef925..ce26923d6f 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -1,5 +1,6 @@ #include "mesh/eth/ethClient.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "concurrency/Periodic.h" #include "configuration.h" #include "gps/RTC.h" @@ -211,10 +212,10 @@ static int32_t reconnectETH() perhapsSetRTC(RTCQualityNTP, &tv); - ntp_renew = millis() + 43200 * 1000; // success, refresh every 12 hours + ntp_renew = Time::timerEndsAtMillis(43200 * 1000); // success, refresh every 12 hours } else { LOG_ERROR("NTP Update failed"); - ntp_renew = millis() + 300 * 1000; // failure, retry every 5 minutes + ntp_renew = Time::timerEndsAtMillis(300 * 1000); // failure, retry every 5 minutes } timeClient.end(); // W5100S: release UDP socket for other services } diff --git a/src/mesh/eth/ethOTA.cpp b/src/mesh/eth/ethOTA.cpp index b99ff73046..458b9cf5fc 100644 --- a/src/mesh/eth/ethOTA.cpp +++ b/src/mesh/eth/ethOTA.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if HAS_ETHERNET && defined(HAS_ETHERNET_OTA) @@ -119,7 +120,7 @@ static bool authenticateClient(EthernetClient &client) uint8_t clientHash[OTA_HASH_SIZE]; if (!readExact(client, clientHash, OTA_HASH_SIZE)) { LOG_WARN("ETH OTA: Timeout reading auth response"); - lastAuthFailure = millis(); + lastAuthFailure = Time::skipZero(Time::getMillis()); return false; } @@ -136,7 +137,7 @@ static bool authenticateClient(EthernetClient &client) if (diff != 0) { LOG_WARN("ETH OTA: Authentication failed"); client.write(OTA_ERR_AUTH); - lastAuthFailure = millis(); + lastAuthFailure = Time::skipZero(Time::getMillis()); return false; } diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index ccf6f54c8b..036505da34 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -186,7 +186,7 @@ typedef struct _meshtastic_LockdownAuth { token at unlock time: the client-supplied boots_remaining when non-zero, otherwise the firmware default (TOKEN_DEFAULT_BOOTS). Note that boots_remaining == 0 in this message means "use firmware - default", NOT "zero boots" — a client computing the ceiling for + default", NOT "zero boots" - a client computing the ceiling for display should mirror that resolution rather than multiplying the raw request value. @@ -196,7 +196,7 @@ typedef struct _meshtastic_LockdownAuth { Uses millis() (CPU uptime), not wall-clock time, so the cap is immune to GPS spoofing, RTC backup-battery removal, and Faraday - cage isolation — none of those move the uptime counter. The only + cage isolation - none of those move the uptime counter. The only way to reset the session clock is a reboot, which costs a boot from the on-flash, HMAC-bound counter. */ uint32_t max_session_seconds; @@ -213,7 +213,7 @@ typedef struct _meshtastic_LockdownAuth { NOT reversed by this operation: APPROTECT. Once the debug port lockout has been burned (on silicon where it is effective) it is - permanent — disabling lockdown decrypts your data and removes the + permanent - disabling lockdown decrypts your data and removes the access gates, but the SWD/JTAG port stays locked for the life of the device (recoverable only via a full chip erase over a debug probe, which destroys all data). Clients should make this diff --git a/src/mesh/generated/meshtastic/apponly.pb.h b/src/mesh/generated/meshtastic/apponly.pb.h index 88cbcb5e67..592dc68157 100644 --- a/src/mesh/generated/meshtastic/apponly.pb.h +++ b/src/mesh/generated/meshtastic/apponly.pb.h @@ -55,7 +55,7 @@ extern const pb_msgdesc_t meshtastic_ChannelSet_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_APPONLY_PB_H_MAX_SIZE meshtastic_ChannelSet_size -#define meshtastic_ChannelSet_size 685 +#define meshtastic_ChannelSet_size 701 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/atak.pb.h b/src/mesh/generated/meshtastic/atak.pb.h index 6ea298f9ed..e0dab86590 100644 --- a/src/mesh/generated/meshtastic/atak.pb.h +++ b/src/mesh/generated/meshtastic/atak.pb.h @@ -332,7 +332,7 @@ typedef enum _meshtastic_CotType { /* y-: TAKTALK room/membership broadcast. Payload carried via the TakTalkRoomData typed variant (sender_callsign, room_id, room_name, participants). The CoT type literally has a trailing dash and no - second atom — not a typo. */ + second atom - not a typo. */ meshtastic_CotType_CotType_y = 126 } meshtastic_CotType; @@ -380,7 +380,7 @@ typedef enum _meshtastic_DrawnShape_Kind { /* u-r-b-bullseye: Bullseye ring with range rings and bearing reference */ meshtastic_DrawnShape_Kind_Kind_Bullseye = 7, /* u-d-c-e: Ellipse with distinct major/minor axes (same storage as - Kind_Circle — uses major_cm/minor_cm/angle_deg — but receivers + Kind_Circle - uses major_cm/minor_cm/angle_deg - but receivers render it as a non-circular ellipse rather than a round circle). */ meshtastic_DrawnShape_Kind_Kind_Ellipse = 8, /* u-d-v: 2D vehicle outline drawn on the map. Vertices carry the @@ -400,7 +400,7 @@ typedef enum _meshtastic_DrawnShape_Kind { end of parse; builder uses it to decide which of / to emit in the reconstructed XML. */ typedef enum _meshtastic_DrawnShape_StyleMode { - /* Unspecified — receiver infers from which color fields are non-zero. */ + /* Unspecified - receiver infers from which color fields are non-zero. */ meshtastic_DrawnShape_StyleMode_StyleMode_Unspecified = 0, /* Stroke only. No in the source XML. Used for polylines, ranging lines, bullseye rings. */ @@ -417,7 +417,7 @@ typedef enum _meshtastic_DrawnShape_StyleMode { alone is ambiguous (e.g. a-u-G could be a 2525 symbol or a custom icon depending on the iconset path). */ typedef enum _meshtastic_Marker_Kind { - /* Unspecified — fall back to TAKPacketV2.cot_type_id */ + /* Unspecified - fall back to TAKPacketV2.cot_type_id */ meshtastic_Marker_Kind_Kind_Unspecified = 0, /* b-m-p-s-m: Spot map marker */ meshtastic_Marker_Kind_Kind_Spot = 1, @@ -680,10 +680,10 @@ typedef struct _meshtastic_AircraftTrack { hundred meters of the anchor has per-vertex deltas in the ±10^4 range. Under sint32+zigzag those encode as 2 bytes each (tag+varint), versus the 4 bytes that sfixed32 would always require. At 32 vertices that is ~128 - bytes of savings — the difference between fitting under the LoRa MTU or + bytes of savings - the difference between fitting under the LoRa MTU or not. Absolute coordinates (values ~10^9) would cost sint32 varint 5 bytes per field, which is why TAKPacketV2's top-level latitude_i / longitude_i - stay sfixed32 — only small values win with sint32. */ + stay sfixed32 - only small values win with sint32. */ typedef struct _meshtastic_CotGeoPoint { /* Latitude delta from TAKPacketV2.latitude_i, in 1e-7 degree units. Add to the enclosing event's latitude_i to recover the absolute latitude. */ @@ -791,7 +791,7 @@ typedef struct _meshtastic_Marker { Covers CoT type u-rb-a. The anchor position is on TAKPacketV2.latitude_i/longitude_i; the target endpoint is carried as a - CotGeoPoint — same delta-from-anchor encoding used by DrawnShape.vertices + CotGeoPoint - same delta-from-anchor encoding used by DrawnShape.vertices so a self-anchored RAB (common case) encodes in zero bytes. */ typedef struct _meshtastic_RangeAndBearing { /* Target/anchor endpoint (delta-encoded from TAKPacketV2.latitude_i/longitude_i). */ @@ -899,12 +899,12 @@ typedef struct _meshtastic_CasevacReport { same as the envelope callsign but ATAK sometimes carries a distinct ops-number here. */ pb_callback_t title; - /* Primary medline free-text — the single most clinically important line + /* Primary medline free-text - the single most clinically important line on a MEDLINE form (e.g. "2 urgent litter patients, smoke on approach"). MUST be preserved under MTU pressure as long as any casevac is sent. */ pb_callback_t medline_remarks; /* Line 3 (newer ATAK format): patient counts by precedence level. - Coexists with the enum-style `precedence` field (tag 1) — older ATAK + Coexists with the enum-style `precedence` field (tag 1) - older ATAK emits a single enum, newer ATAK emits these counts, and both can be set simultaneously. Senders populate whichever style(s) the source XML had; receivers prefer counts when non-zero. */ @@ -946,19 +946,19 @@ typedef struct _meshtastic_CasevacReport { (e.g. "Primary HLZ is soccer field"). */ pb_callback_t hlz_remarks; /* Per-patient clinical records. Each entry is one patient's ZMIST card - (Zap number / Mechanism / Injuries / Signs / Treatment). Repeatable — + (Zap number / Mechanism / Injuries / Signs / Treatment). Repeatable - a mass-casualty event can carry 1-6 entries in practice, limited by the 237 B LoRa MTU. */ pb_callback_t zmist; } meshtastic_CasevacReport; -/* Per-patient clinical summary record — one entry per patient in a CASEVAC. +/* Per-patient clinical summary record - one entry per patient in a CASEVAC. Maps directly to ATAK's child element inside . All fields are optional free-text; senders populate what they have. */ typedef struct _meshtastic_ZMistEntry { /* Patient identifier / sequence label (e.g. "ZMIST-1", "ZMIST-2"). */ pb_callback_t title; - /* Zap number — unique patient tracking ID (often a terse code like + /* Zap number - unique patient tracking ID (often a terse code like "Gunshot" or a serial). */ pb_callback_t z; /* Mechanism of injury (e.g. "Penetrating trauma", "Blast injury"). */ @@ -997,7 +997,7 @@ typedef struct _meshtastic_EmergencyAlert { creation time; the fields below carry structured metadata the raw-detail fallback currently loses. - Fields are deliberately lean — this variant is closer to the MTU ceiling + Fields are deliberately lean - this variant is closer to the MTU ceiling than the others, so every string is capped in options. */ typedef struct _meshtastic_TaskRequest { /* Short tag for the task category (e.g. "engage", "observe", "recon", @@ -1017,7 +1017,7 @@ typedef struct _meshtastic_TaskRequest { /* Weather annotation from CoT detail element. - Attaches to any TAKPacketV2 regardless of payload_variant — an Aircraft, + Attaches to any TAKPacketV2 regardless of payload_variant - an Aircraft, PLI, or Marker can all carry observed conditions at the emitting station. ATAK-CIV ships an XSD for but no dedicated handler, so the element round-trips through the generic detail pipeline; this message @@ -1026,7 +1026,7 @@ typedef struct _meshtastic_TaskRequest { Target wire cost: ~6-8 bytes compressed with a fully populated instance. Named `TAKEnvironment` (not just `Environment`) because the bare name - collides with `SwiftUI.Environment` — every SwiftUI view in a consuming + collides with `SwiftUI.Environment` - every SwiftUI view in a consuming iOS app uses the `@Environment` property wrapper, and importing the generated proto module would make `Environment` ambiguous in every one of those files. The `TAK` prefix matches the convention used by the @@ -1055,7 +1055,7 @@ typedef struct _meshtastic_TAKEnvironment { The receiving ATAK client restores those from its own defaults, same as every other CoT carried over Meshtastic today. - Attaches to any TAKPacketV2 — a PLI with a sensor on the operator's head, + Attaches to any TAKPacketV2 - a PLI with a sensor on the operator's head, an Aircraft with a FLIR turret, a Marker dropped on a UAV. Target wire cost: ~7-14 bytes compressed (dominated by model string). */ typedef struct _meshtastic_SensorFov { @@ -1065,30 +1065,30 @@ typedef struct _meshtastic_SensorFov { SensorDetailHandler default (270°) and save varint bytes over centi-deg. */ uint32_t azimuth_deg; /* Maximum range of the cone in meters. - Optional — if unset, receivers should use the ATAK-CIV default of 100m. */ + Optional - if unset, receivers should use the ATAK-CIV default of 100m. */ bool has_range_m; uint32_t range_m; /* Horizontal field of view in whole degrees (cone's angular width). ATAK-CIV default is 45°. */ uint32_t fov_horizontal_deg; /* Vertical field of view in whole degrees. ATAK-CIV default is 45°. - Optional — a value of 0 means "not set / use horizontal FOV". */ + Optional - a value of 0 means "not set / use horizontal FOV". */ uint32_t fov_vertical_deg; /* Elevation angle in whole degrees. Positive = up, negative = down. Range -90 to +90. sint32 for varint efficiency on small negatives. */ int32_t elevation_deg; /* Roll (camera tilt) in whole degrees, -180 to +180. - Optional — use 0 if the sensor doesn't track roll. */ + Optional - use 0 if the sensor doesn't track roll. */ int32_t roll_deg; /* Free-form device model identifier, e.g. "FLIR-Boson-640", "SEEK". - Optional — empty string means "unknown model" (ATAK-CIV default). */ + Optional - empty string means "unknown model" (ATAK-CIV default). */ pb_callback_t model; } meshtastic_SensorFov; /* TAKTALK chat message payload (CoT type m-t-t). TAKTALK is an ATAK plugin for voice + text team messaging. The voice - audio stream goes over UDP/RTP and is NOT carried by the mesh — only + audio stream goes over UDP/RTP and is NOT carried by the mesh - only the text envelope (this message) is. `from_voice` marks messages sent via push-to-talk speech-to-text so receivers can render a mic icon next to the text. @@ -1122,7 +1122,7 @@ typedef struct _meshtastic_TakTalkMessage { Announces a TAKTALK chatroom's friendly name and roster so peers can resolve room UUIDs (used in TakTalkMessage.chatroom_id and GeoChat.room_id) to a display name and participant list. Not a chat - message itself — these events are emitted by TAKTALK when rooms are + message itself - these events are emitted by TAKTALK when rooms are created or memberships change. */ typedef struct _meshtastic_TakTalkRoomData { /* Callsign of the device broadcasting the room state (typically the @@ -1161,7 +1161,7 @@ typedef struct _meshtastic_Marti { primary-vs-cc distinction the same way ATAK does. If dest_callsign is [TAKPacketV2.callsign] (self-addressed, unusual but - legal — e.g. ATAK echoing back to its own room), the builder still emits + legal - e.g. ATAK echoing back to its own room), the builder still emits the element so loopback shapes round-trip cleanly. */ pb_callback_t dest_callsign; } meshtastic_Marti; diff --git a/src/mesh/generated/meshtastic/channel.pb.h b/src/mesh/generated/meshtastic/channel.pb.h index 9dc757ab4a..9e66edbb8e 100644 --- a/src/mesh/generated/meshtastic/channel.pb.h +++ b/src/mesh/generated/meshtastic/channel.pb.h @@ -97,6 +97,12 @@ typedef struct _meshtastic_ChannelSettings { /* Per-channel module settings. */ bool has_module_settings; meshtastic_ModuleSettings module_settings; + /* Enable authenticated encryption (AES-CCM) for this channel. + When true, messages include a 12-byte authentication tag that prevents + forgery and bit-flipping attacks. All nodes on the channel must have + this enabled - unauthenticated (AES-CTR) packets are rejected. + Experimental. Default: false (standard AES-CTR encryption). */ + bool use_aead; } meshtastic_ChannelSettings; /* A pair of a channel number, mode and the (sharable) settings for that channel */ @@ -128,10 +134,10 @@ extern "C" { /* Initializer values for message structs */ -#define meshtastic_ChannelSettings_init_default {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_default} +#define meshtastic_ChannelSettings_init_default {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_default, 0} #define meshtastic_ModuleSettings_init_default {0, 0} #define meshtastic_Channel_init_default {0, false, meshtastic_ChannelSettings_init_default, _meshtastic_Channel_Role_MIN} -#define meshtastic_ChannelSettings_init_zero {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_zero} +#define meshtastic_ChannelSettings_init_zero {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_zero, 0} #define meshtastic_ModuleSettings_init_zero {0, 0} #define meshtastic_Channel_init_zero {0, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Channel_Role_MIN} @@ -145,6 +151,7 @@ extern "C" { #define meshtastic_ChannelSettings_uplink_enabled_tag 5 #define meshtastic_ChannelSettings_downlink_enabled_tag 6 #define meshtastic_ChannelSettings_module_settings_tag 7 +#define meshtastic_ChannelSettings_use_aead_tag 8 #define meshtastic_Channel_index_tag 1 #define meshtastic_Channel_settings_tag 2 #define meshtastic_Channel_role_tag 3 @@ -157,7 +164,8 @@ X(a, STATIC, SINGULAR, STRING, name, 3) \ X(a, STATIC, SINGULAR, FIXED32, id, 4) \ X(a, STATIC, SINGULAR, BOOL, uplink_enabled, 5) \ X(a, STATIC, SINGULAR, BOOL, downlink_enabled, 6) \ -X(a, STATIC, OPTIONAL, MESSAGE, module_settings, 7) +X(a, STATIC, OPTIONAL, MESSAGE, module_settings, 7) \ +X(a, STATIC, SINGULAR, BOOL, use_aead, 8) #define meshtastic_ChannelSettings_CALLBACK NULL #define meshtastic_ChannelSettings_DEFAULT NULL #define meshtastic_ChannelSettings_module_settings_MSGTYPE meshtastic_ModuleSettings @@ -187,8 +195,8 @@ extern const pb_msgdesc_t meshtastic_Channel_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_CHANNEL_PB_H_MAX_SIZE meshtastic_Channel_size -#define meshtastic_ChannelSettings_size 72 -#define meshtastic_Channel_size 87 +#define meshtastic_ChannelSettings_size 74 +#define meshtastic_Channel_size 89 #define meshtastic_ModuleSettings_size 8 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/device_ui.pb.h b/src/mesh/generated/meshtastic/device_ui.pb.h index b99fb10b93..461477bdd0 100644 --- a/src/mesh/generated/meshtastic/device_ui.pb.h +++ b/src/mesh/generated/meshtastic/device_ui.pb.h @@ -70,6 +70,10 @@ typedef enum _meshtastic_Language { meshtastic_Language_CZECH = 18, /* Danish */ meshtastic_Language_DANISH = 19, + /* Hungarian */ + meshtastic_Language_HUNGARIAN = 20, + /* Azerbaijani */ + meshtastic_Language_AZERBAIJANI = 21, /* Simplified Chinese (experimental) */ meshtastic_Language_SIMPLIFIED_CHINESE = 30, /* Traditional Chinese (experimental) */ diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 51e43526e0..893f980593 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -81,7 +81,9 @@ typedef struct _meshtastic_NodeInfoLite { uint8_t hops_away; /* Last byte of the node number of the node that should be used as the next hop to reach this node. */ uint8_t next_hop; - /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. */ + /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. + Bit 11 is NODEINFO_BITFIELD_HEARD_ON_CURRENT_LORA, mirrored on the wire as + NodeInfo.heard_on_current_lora. */ uint32_t bitfield; /* A full name for this user, i.e. "Kevin Hester". */ char long_name[25]; @@ -455,8 +457,8 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2740 -#define meshtastic_ChannelFile_size 718 +#define meshtastic_BackupPreferences_size 2674 +#define meshtastic_ChannelFile_size 734 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 231 #define meshtastic_NodeInfoLite_size 112 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index c560d5447e..ebf9bdc654 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -212,7 +212,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size #define meshtastic_LocalConfig_size 759 -#define meshtastic_LocalModuleConfig_size 1126 +#define meshtastic_LocalModuleConfig_size 1044 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/lorawan_bridge.pb.cpp b/src/mesh/generated/meshtastic/lorawan_bridge.pb.cpp new file mode 100644 index 0000000000..5847396530 --- /dev/null +++ b/src/mesh/generated/meshtastic/lorawan_bridge.pb.cpp @@ -0,0 +1,26 @@ +/* Automatically generated nanopb constant definitions */ +/* Generated by nanopb-0.4.9.1 */ + +#include "meshtastic/lorawan_bridge.pb.h" +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +PB_BIND(meshtastic_LoRaWANBridge, meshtastic_LoRaWANBridge, AUTO) + + +PB_BIND(meshtastic_LoRaWANBridge_Uplink, meshtastic_LoRaWANBridge_Uplink, AUTO) + + +PB_BIND(meshtastic_LoRaWANBridge_Downlink, meshtastic_LoRaWANBridge_Downlink, AUTO) + + +PB_BIND(meshtastic_LoRaWANBridge_PayloadChunk, meshtastic_LoRaWANBridge_PayloadChunk, AUTO) + + +PB_BIND(meshtastic_LoRaWANBridge_TxResult, meshtastic_LoRaWANBridge_TxResult, AUTO) + + + + + diff --git a/src/mesh/generated/meshtastic/lorawan_bridge.pb.h b/src/mesh/generated/meshtastic/lorawan_bridge.pb.h new file mode 100644 index 0000000000..933db600cc --- /dev/null +++ b/src/mesh/generated/meshtastic/lorawan_bridge.pb.h @@ -0,0 +1,271 @@ +/* Automatically generated nanopb header */ +/* Generated by nanopb-0.4.9.1 */ + +#ifndef PB_MESHTASTIC_MESHTASTIC_LORAWAN_BRIDGE_PB_H_INCLUDED +#define PB_MESHTASTIC_MESHTASTIC_LORAWAN_BRIDGE_PB_H_INCLUDED +#include + +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +/* Enum definitions */ +/* Values 0 to 7 mirror the Semtech TX_ACK error field. Keep every value non + negative; enums encode as int32. */ +typedef enum _meshtastic_LoRaWANBridge_TxResult_Status { + meshtastic_LoRaWANBridge_TxResult_Status_NONE = 0, + meshtastic_LoRaWANBridge_TxResult_Status_TOO_LATE = 1, + meshtastic_LoRaWANBridge_TxResult_Status_TOO_EARLY = 2, + meshtastic_LoRaWANBridge_TxResult_Status_COLLISION_PACKET = 3, + meshtastic_LoRaWANBridge_TxResult_Status_COLLISION_BEACON = 4, + meshtastic_LoRaWANBridge_TxResult_Status_TX_FREQ = 5, + meshtastic_LoRaWANBridge_TxResult_Status_TX_POWER = 6, + meshtastic_LoRaWANBridge_TxResult_Status_GPS_UNLOCKED = 7, + /* Held by the gateway for a later receive window. */ + meshtastic_LoRaWANBridge_TxResult_Status_DEFERRED = 8, + /* Discarded, either undeferrable or refused by an authorization check. */ + meshtastic_LoRaWANBridge_TxResult_Status_DROPPED = 9 +} meshtastic_LoRaWANBridge_TxResult_Status; + +/* Struct definitions */ +typedef PB_BYTES_ARRAY_T(183) meshtastic_LoRaWANBridge_Uplink_payload_t; +/* An uplink heard by the gateway, travelling towards the network server. */ +typedef struct _meshtastic_LoRaWANBridge_Uplink { + /* Receive frequency in Hz. */ + uint32_t freq_hz; + /* Concentrator receive timestamp in microseconds. Free running, wraps about + every 72 minutes. */ + uint32_t tmst; + /* Received signal strength in tenths of a dBm. Fits signed 16 bit. */ + int16_t rssi_x10; + /* Signal to noise ratio in tenths of a dB. Fits signed 16 bit. */ + int16_t snr_x10; + /* Packed radio settings, 0 to 255. Meaningless when fsk_bitrate is set. + bits 7-5 0 to 7 for SF5 to SF12 + bits 4-2 bandwidth in kHz: 0 125, 1 250, 2 500, 3 203.125, 4 406.25, + 5 812.5, 6 1625, 7 reserved. Codes 0 to 2 are the sub-GHz set + and 3 to 6 the 2.4 GHz set; the two do not overlap. Any future + bandwidth takes the next free code rather than sorting in. + bits 1-0 0 to 3 for 4/5 to 4/8 */ + uint8_t radio_params; + /* PHY payload, or its first part when chunk_count is 2. */ + meshtastic_LoRaWANBridge_Uplink_payload_t payload; + /* Groups this head with its continuation. 1 to 255, or 0 when not chunked. */ + uint8_t payload_id; + /* 0 when the frame fits one packet, otherwise 2. */ + uint8_t chunk_count; + /* FSK bit rate in bits per second. Non-zero only for an FSK frame, in which + case radio_params does not apply. Fits unsigned 16 bit. */ + uint16_t fsk_bitrate; +} meshtastic_LoRaWANBridge_Uplink; + +typedef PB_BYTES_ARRAY_T(183) meshtastic_LoRaWANBridge_Downlink_payload_t; +/* A downlink from the network server, travelling towards the gateway. */ +typedef struct _meshtastic_LoRaWANBridge_Downlink { + /* Transmit frequency in Hz. */ + uint32_t freq_hz; + /* Concentrator timestamp to transmit at. Ignored when immediate is set. */ + uint32_t tmst; + /* Packed radio settings, encoded as in Uplink.radio_params. FSK downlink is + not supported, so there is no bit rate or frequency deviation here. */ + uint8_t radio_params; + /* Transmit power in dBm, 0 to 255. The gateway clamps it to its region. */ + uint8_t power_dbm; + /* Transmit as soon as possible instead of at tmst. Used for Class C. */ + bool immediate; + /* Invert LoRa polarity. True for every LoRaWAN downlink. */ + bool invert_polarity; + /* Disable the physical layer CRC. */ + bool no_crc; + /* Allow the gateway to send this in a later receive window if it arrives too + late. Leave clear for anything tied to a specific uplink. */ + bool may_defer; + /* PHY payload, or its first part when chunk_count is 2. */ + meshtastic_LoRaWANBridge_Downlink_payload_t payload; + /* Groups this head with its continuation. 1 to 255, or 0 when not chunked. */ + uint8_t payload_id; + /* 0 when the frame fits one packet, otherwise 2. */ + uint8_t chunk_count; + /* Identifies this downlink so its TxResult can be matched to it. 1 to 255. */ + uint8_t request_id; +} meshtastic_LoRaWANBridge_Downlink; + +typedef PB_BYTES_ARRAY_T(183) meshtastic_LoRaWANBridge_PayloadChunk_payload_chunk_t; +/* The remainder of a chunked Uplink or Downlink. Carries no RF metadata. */ +typedef struct _meshtastic_LoRaWANBridge_PayloadChunk { + /* Matches payload_id in the head message. Reassembly keys on the sending + node and this value. */ + uint8_t payload_id; + /* Position of this chunk. Always 1. */ + uint8_t chunk_index; + /* This part of the PHY payload. */ + meshtastic_LoRaWANBridge_PayloadChunk_payload_chunk_t payload_chunk; +} meshtastic_LoRaWANBridge_PayloadChunk; + +/* The outcome of a downlink, reported back towards the network server. */ +typedef struct _meshtastic_LoRaWANBridge_TxResult { + /* The tmst the downlink was scheduled for. Zero when it was immediate, so + diagnostic only; correlate on request_id. */ + uint32_t tmst; + /* What happened to it. */ + meshtastic_LoRaWANBridge_TxResult_Status status; + /* Echoes Downlink.request_id. */ + uint8_t request_id; +} meshtastic_LoRaWANBridge_TxResult; + +/* Payload for LORAWAN_BRIDGE packets. Tunnels raw LoRaWAN PHY payloads and their + RF metadata so a gateway can reach a network server over a mesh. */ +typedef struct _meshtastic_LoRaWANBridge { + pb_size_t which_variant; + union { + meshtastic_LoRaWANBridge_Uplink uplink; + meshtastic_LoRaWANBridge_Downlink downlink; + meshtastic_LoRaWANBridge_TxResult tx_result; + meshtastic_LoRaWANBridge_PayloadChunk chunk; + } variant; +} meshtastic_LoRaWANBridge; + + +#ifdef __cplusplus +extern "C" { +#endif + +/* Helper constants for enums */ +#define _meshtastic_LoRaWANBridge_TxResult_Status_MIN meshtastic_LoRaWANBridge_TxResult_Status_NONE +#define _meshtastic_LoRaWANBridge_TxResult_Status_MAX meshtastic_LoRaWANBridge_TxResult_Status_DROPPED +#define _meshtastic_LoRaWANBridge_TxResult_Status_ARRAYSIZE ((meshtastic_LoRaWANBridge_TxResult_Status)(meshtastic_LoRaWANBridge_TxResult_Status_DROPPED+1)) + + + + + +#define meshtastic_LoRaWANBridge_TxResult_status_ENUMTYPE meshtastic_LoRaWANBridge_TxResult_Status + + +/* Initializer values for message structs */ +#define meshtastic_LoRaWANBridge_init_default {0, {meshtastic_LoRaWANBridge_Uplink_init_default}} +#define meshtastic_LoRaWANBridge_Uplink_init_default {0, 0, 0, 0, 0, {0, {0}}, 0, 0, 0} +#define meshtastic_LoRaWANBridge_Downlink_init_default {0, 0, 0, 0, 0, 0, 0, 0, {0, {0}}, 0, 0, 0} +#define meshtastic_LoRaWANBridge_PayloadChunk_init_default {0, 0, {0, {0}}} +#define meshtastic_LoRaWANBridge_TxResult_init_default {0, _meshtastic_LoRaWANBridge_TxResult_Status_MIN, 0} +#define meshtastic_LoRaWANBridge_init_zero {0, {meshtastic_LoRaWANBridge_Uplink_init_zero}} +#define meshtastic_LoRaWANBridge_Uplink_init_zero {0, 0, 0, 0, 0, {0, {0}}, 0, 0, 0} +#define meshtastic_LoRaWANBridge_Downlink_init_zero {0, 0, 0, 0, 0, 0, 0, 0, {0, {0}}, 0, 0, 0} +#define meshtastic_LoRaWANBridge_PayloadChunk_init_zero {0, 0, {0, {0}}} +#define meshtastic_LoRaWANBridge_TxResult_init_zero {0, _meshtastic_LoRaWANBridge_TxResult_Status_MIN, 0} + +/* Field tags (for use in manual encoding/decoding) */ +#define meshtastic_LoRaWANBridge_Uplink_freq_hz_tag 1 +#define meshtastic_LoRaWANBridge_Uplink_tmst_tag 2 +#define meshtastic_LoRaWANBridge_Uplink_rssi_x10_tag 3 +#define meshtastic_LoRaWANBridge_Uplink_snr_x10_tag 4 +#define meshtastic_LoRaWANBridge_Uplink_radio_params_tag 5 +#define meshtastic_LoRaWANBridge_Uplink_payload_tag 6 +#define meshtastic_LoRaWANBridge_Uplink_payload_id_tag 7 +#define meshtastic_LoRaWANBridge_Uplink_chunk_count_tag 8 +#define meshtastic_LoRaWANBridge_Uplink_fsk_bitrate_tag 9 +#define meshtastic_LoRaWANBridge_Downlink_freq_hz_tag 1 +#define meshtastic_LoRaWANBridge_Downlink_tmst_tag 2 +#define meshtastic_LoRaWANBridge_Downlink_radio_params_tag 3 +#define meshtastic_LoRaWANBridge_Downlink_power_dbm_tag 4 +#define meshtastic_LoRaWANBridge_Downlink_immediate_tag 5 +#define meshtastic_LoRaWANBridge_Downlink_invert_polarity_tag 6 +#define meshtastic_LoRaWANBridge_Downlink_no_crc_tag 7 +#define meshtastic_LoRaWANBridge_Downlink_may_defer_tag 8 +#define meshtastic_LoRaWANBridge_Downlink_payload_tag 9 +#define meshtastic_LoRaWANBridge_Downlink_payload_id_tag 10 +#define meshtastic_LoRaWANBridge_Downlink_chunk_count_tag 11 +#define meshtastic_LoRaWANBridge_Downlink_request_id_tag 12 +#define meshtastic_LoRaWANBridge_PayloadChunk_payload_id_tag 1 +#define meshtastic_LoRaWANBridge_PayloadChunk_chunk_index_tag 2 +#define meshtastic_LoRaWANBridge_PayloadChunk_payload_chunk_tag 3 +#define meshtastic_LoRaWANBridge_TxResult_tmst_tag 1 +#define meshtastic_LoRaWANBridge_TxResult_status_tag 2 +#define meshtastic_LoRaWANBridge_TxResult_request_id_tag 3 +#define meshtastic_LoRaWANBridge_uplink_tag 1 +#define meshtastic_LoRaWANBridge_downlink_tag 2 +#define meshtastic_LoRaWANBridge_tx_result_tag 3 +#define meshtastic_LoRaWANBridge_chunk_tag 4 + +/* Struct field encoding specification for nanopb */ +#define meshtastic_LoRaWANBridge_FIELDLIST(X, a) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,uplink,variant.uplink), 1) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,downlink,variant.downlink), 2) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,tx_result,variant.tx_result), 3) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,chunk,variant.chunk), 4) +#define meshtastic_LoRaWANBridge_CALLBACK NULL +#define meshtastic_LoRaWANBridge_DEFAULT NULL +#define meshtastic_LoRaWANBridge_variant_uplink_MSGTYPE meshtastic_LoRaWANBridge_Uplink +#define meshtastic_LoRaWANBridge_variant_downlink_MSGTYPE meshtastic_LoRaWANBridge_Downlink +#define meshtastic_LoRaWANBridge_variant_tx_result_MSGTYPE meshtastic_LoRaWANBridge_TxResult +#define meshtastic_LoRaWANBridge_variant_chunk_MSGTYPE meshtastic_LoRaWANBridge_PayloadChunk + +#define meshtastic_LoRaWANBridge_Uplink_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, FIXED32, freq_hz, 1) \ +X(a, STATIC, SINGULAR, FIXED32, tmst, 2) \ +X(a, STATIC, SINGULAR, SINT32, rssi_x10, 3) \ +X(a, STATIC, SINGULAR, SINT32, snr_x10, 4) \ +X(a, STATIC, SINGULAR, UINT32, radio_params, 5) \ +X(a, STATIC, SINGULAR, BYTES, payload, 6) \ +X(a, STATIC, SINGULAR, UINT32, payload_id, 7) \ +X(a, STATIC, SINGULAR, UINT32, chunk_count, 8) \ +X(a, STATIC, SINGULAR, UINT32, fsk_bitrate, 9) +#define meshtastic_LoRaWANBridge_Uplink_CALLBACK NULL +#define meshtastic_LoRaWANBridge_Uplink_DEFAULT NULL + +#define meshtastic_LoRaWANBridge_Downlink_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, FIXED32, freq_hz, 1) \ +X(a, STATIC, SINGULAR, FIXED32, tmst, 2) \ +X(a, STATIC, SINGULAR, UINT32, radio_params, 3) \ +X(a, STATIC, SINGULAR, UINT32, power_dbm, 4) \ +X(a, STATIC, SINGULAR, BOOL, immediate, 5) \ +X(a, STATIC, SINGULAR, BOOL, invert_polarity, 6) \ +X(a, STATIC, SINGULAR, BOOL, no_crc, 7) \ +X(a, STATIC, SINGULAR, BOOL, may_defer, 8) \ +X(a, STATIC, SINGULAR, BYTES, payload, 9) \ +X(a, STATIC, SINGULAR, UINT32, payload_id, 10) \ +X(a, STATIC, SINGULAR, UINT32, chunk_count, 11) \ +X(a, STATIC, SINGULAR, UINT32, request_id, 12) +#define meshtastic_LoRaWANBridge_Downlink_CALLBACK NULL +#define meshtastic_LoRaWANBridge_Downlink_DEFAULT NULL + +#define meshtastic_LoRaWANBridge_PayloadChunk_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, payload_id, 1) \ +X(a, STATIC, SINGULAR, UINT32, chunk_index, 2) \ +X(a, STATIC, SINGULAR, BYTES, payload_chunk, 3) +#define meshtastic_LoRaWANBridge_PayloadChunk_CALLBACK NULL +#define meshtastic_LoRaWANBridge_PayloadChunk_DEFAULT NULL + +#define meshtastic_LoRaWANBridge_TxResult_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, FIXED32, tmst, 1) \ +X(a, STATIC, SINGULAR, UENUM, status, 2) \ +X(a, STATIC, SINGULAR, UINT32, request_id, 3) +#define meshtastic_LoRaWANBridge_TxResult_CALLBACK NULL +#define meshtastic_LoRaWANBridge_TxResult_DEFAULT NULL + +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_msg; +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_Uplink_msg; +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_Downlink_msg; +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_PayloadChunk_msg; +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_TxResult_msg; + +/* Defines for backwards compatibility with code written before nanopb-0.4.0 */ +#define meshtastic_LoRaWANBridge_fields &meshtastic_LoRaWANBridge_msg +#define meshtastic_LoRaWANBridge_Uplink_fields &meshtastic_LoRaWANBridge_Uplink_msg +#define meshtastic_LoRaWANBridge_Downlink_fields &meshtastic_LoRaWANBridge_Downlink_msg +#define meshtastic_LoRaWANBridge_PayloadChunk_fields &meshtastic_LoRaWANBridge_PayloadChunk_msg +#define meshtastic_LoRaWANBridge_TxResult_fields &meshtastic_LoRaWANBridge_TxResult_msg + +/* Maximum encoded size of messages (where known) */ +#define MESHTASTIC_MESHTASTIC_LORAWAN_BRIDGE_PB_H_MAX_SIZE meshtastic_LoRaWANBridge_size +#define meshtastic_LoRaWANBridge_Downlink_size 219 +#define meshtastic_LoRaWANBridge_PayloadChunk_size 192 +#define meshtastic_LoRaWANBridge_TxResult_size 10 +#define meshtastic_LoRaWANBridge_Uplink_size 217 +#define meshtastic_LoRaWANBridge_size 222 + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index c59001f105..a490d4e2be 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -341,6 +341,12 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_HELTEC_RCC6 = 143, /* Seeed Wio Tracker L1 Pro 1W, nRF52840 + SX1262 with 1 W external PA */ meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_PRO_1W = 144, + /* Meshnology W12 */ + meshtastic_HardwareModel_MESHNOLOGY_W12 = 145, + /* Seeed Studio MeshPager X2 */ + meshtastic_HardwareModel_MESHPAGER_X2 = 146, + /* Lilygo T-CONNECT PRO */ + meshtastic_HardwareModel_T_CONNECT_PRO = 147, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ @@ -738,7 +744,7 @@ typedef struct _meshtastic_Position { multiplied with DOP to calculate positional accuracy Default: "'bout three meters-ish" :) */ uint32_t gps_accuracy; - /* Ground speed in m/s and True North TRACK in 1/100 degrees + /* Ground speed in km/h and True North TRACK in 1/100 degrees Clarification of terms: - "track" is the direction of motion (measured in horizontal plane) - "heading" is where the fuselage points (measured in horizontal plane) @@ -1211,6 +1217,15 @@ typedef struct _meshtastic_NodeInfo { Persists between NodeDB internal clean ups LSB 1 of the bitfield */ bool has_xeddsa_signed; + /* True if we have heard this node over RF since our current LoRa + configuration took effect. Cleared for every node whenever the region, + modem preset (or the custom bandwidth/spread factor/coding rate when + use_preset is false), override_frequency, channel_num or the primary + channel name changes - the frequency slot is derived from that name. + Not set for nodes heard over MQTT, which reach us over the internet + rather than over our own radio - see via_mqtt. + LSB 11 of the bitfield */ + bool heard_on_current_lora; } meshtastic_NodeInfo; typedef PB_BYTES_ARRAY_T(16) meshtastic_MyNodeInfo_device_id_t; @@ -1273,15 +1288,15 @@ typedef struct _meshtastic_LockdownStatus { /* Current lockdown state being reported. */ meshtastic_LockdownStatus_State state; /* For LOCKED: machine-readable reason. Known values: - "needs_auth" — storage already unlocked, client must auth - "token_missing" — no boot token on flash - "token_expired" — boot token wall-clock TTL elapsed - "token_boots_zero" — boot token boot-count TTL exhausted - "token_hmac_fail" — token tampered or wrong device - "token_dek_fail" — token DEK decrypt failed - "token_wrong_size" — token file corrupted - "token_bad_magic" — token file corrupted - "not_provisioned" — should generally use NEEDS_PROVISION state instead + "needs_auth" - storage already unlocked, client must auth + "token_missing" - no boot token on flash + "token_expired" - boot token wall-clock TTL elapsed + "token_boots_zero" - boot token boot-count TTL exhausted + "token_hmac_fail" - token tampered or wrong device + "token_dek_fail" - token DEK decrypt failed + "token_wrong_size" - token file corrupted + "token_bad_magic" - token file corrupted + "not_provisioned" - should generally use NEEDS_PROVISION state instead Other values may be added; clients should treat unknown values as "locked, ask for passphrase". */ char lock_reason[32]; @@ -1752,7 +1767,7 @@ extern "C" { #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} #define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, false, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} -#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_default {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_default {0, 0, 0, 0} @@ -1791,7 +1806,7 @@ extern "C" { #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} #define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, false, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} -#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_zero {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_zero {0, 0, 0, 0} @@ -1949,6 +1964,7 @@ extern "C" { #define meshtastic_NodeInfo_is_key_manually_verified_tag 12 #define meshtastic_NodeInfo_is_muted_tag 13 #define meshtastic_NodeInfo_has_xeddsa_signed_tag 14 +#define meshtastic_NodeInfo_heard_on_current_lora_tag 15 #define meshtastic_MyNodeInfo_my_node_num_tag 1 #define meshtastic_MyNodeInfo_reboot_count_tag 8 #define meshtastic_MyNodeInfo_min_app_version_tag 11 @@ -2244,7 +2260,8 @@ X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, BOOL, is_key_manually_verified, 12) \ X(a, STATIC, SINGULAR, BOOL, is_muted, 13) \ -X(a, STATIC, SINGULAR, BOOL, has_xeddsa_signed, 14) +X(a, STATIC, SINGULAR, BOOL, has_xeddsa_signed, 14) \ +X(a, STATIC, SINGULAR, BOOL, heard_on_current_lora, 15) #define meshtastic_NodeInfo_CALLBACK NULL #define meshtastic_NodeInfo_DEFAULT NULL #define meshtastic_NodeInfo_user_MSGTYPE meshtastic_User @@ -2600,7 +2617,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_MyNodeInfo_size 83 #define meshtastic_NeighborInfo_size 258 #define meshtastic_Neighbor_size 22 -#define meshtastic_NodeInfo_size 327 +#define meshtastic_NodeInfo_size 329 #define meshtastic_NodeRemoteHardwarePin_size 29 #define meshtastic_Position_size 144 #define meshtastic_QueueStatus_size 23 diff --git a/src/mesh/generated/meshtastic/mesh_beacon.pb.h b/src/mesh/generated/meshtastic/mesh_beacon.pb.h index 028d8269f5..4d23e17340 100644 --- a/src/mesh/generated/meshtastic/mesh_beacon.pb.h +++ b/src/mesh/generated/meshtastic/mesh_beacon.pb.h @@ -15,7 +15,7 @@ /* Payload for MESH_BEACON_APP packets. Periodically broadcast by nodes in beacon mode. Listeners deliver the text message to the local inbox and cache any offered - channel/preset for the client app to act on — the firmware never auto-applies them. */ + channel/preset for the client app to act on - the firmware never auto-applies them. */ typedef struct _meshtastic_MeshBeacon { /* Human-readable beacon message. Max 100 bytes enforced by firmware on send. */ char message[101]; @@ -63,7 +63,7 @@ extern const pb_msgdesc_t meshtastic_MeshBeacon_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_MESH_BEACON_PB_H_MAX_SIZE meshtastic_MeshBeacon_size -#define meshtastic_MeshBeacon_size 180 +#define meshtastic_MeshBeacon_size 182 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index b04c358fc4..dd82f424bb 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -48,8 +48,14 @@ typedef enum _meshtastic_ModuleConfig_AudioConfig_Audio_Baud { meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_1400 = 4, meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_1300 = 5, meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_1200 = 6, + /* Removed from libcodec2 upstream. A device configured to one of these + falls back to CODEC2_700C. */ meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700 = 7, - meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700B = 8 + meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700B = 8, + /* Replaces CODEC2_700. Default for new configurations. */ + meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700C = 9, + /* Lowest rate, and the only one usable on slower modem presets. */ + meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_450 = 10 } meshtastic_ModuleConfig_AudioConfig_Audio_Baud; /* TODO: REPLACE */ @@ -225,7 +231,7 @@ typedef struct _meshtastic_ModuleConfig_AudioConfig { bool codec2_enabled; /* PTT Pin */ uint8_t ptt_pin; - /* The audio sample rate to use for codec2 */ + /* The codec2 bitrate to encode at. Sample rate is always 8 kHz. */ meshtastic_ModuleConfig_AudioConfig_Audio_Baud bitrate; /* I2S Word Select */ uint8_t i2s_ws; @@ -249,10 +255,14 @@ typedef struct _meshtastic_ModuleConfig_PaxcounterConfig { } meshtastic_ModuleConfig_PaxcounterConfig; /* Config for the Traffic Management module. - Provides packet inspection and traffic shaping to help reduce channel utilization */ + Provides packet inspection and traffic shaping to help reduce channel utilization. + Every field uses the proto3 zero value to mean "disabled"; there is no + "use the firmware default" sentinel. Firmware installs its own defaults when it + first creates this config, and a client that writes 0 turns that feature off. */ typedef struct _meshtastic_ModuleConfig_TrafficManagementConfig { /* Minimum interval in seconds between position updates from the same node. - A non-zero value implicitly enables the suppression window; 0 disables it. */ + A non-zero value implicitly enables the suppression window; 0 disables it. + Firmware default: 21600 (6 hours), installed when this config is first created. */ uint32_t position_min_interval_secs; /* Maximum hop distance from the requestor at which direct NodeInfo responses are served from the local cache. A non-zero value implicitly enables direct @@ -457,8 +467,8 @@ typedef struct _meshtastic_ModuleConfig_StatusMessageConfig { char node_status[80]; } meshtastic_ModuleConfig_StatusMessageConfig; -/* One entry in the multi-target broadcast list. - The broadcaster transmits one beacon copy per entry, each on its own radio settings. */ +/* One entry in the broadcast destination list. + Each entry names one set of radio settings to send a beacon copy on. */ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget { /* Modem preset to use for this target. Falls back to the running config preset if unset. */ @@ -478,12 +488,6 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget { typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { /* Bitwise-OR of Flags values (listen / broadcast / legacy-split toggles). */ uint32_t flags; - /* Optional: node ID to send beacon messages AS. - When set, the `from` field of outgoing beacon packets is set to this node ID, - making beacons appear to originate from that node. - When unset (0), beacons are sent as the local node. - A remote admin can only set this field to their own node ID. */ - uint32_t broadcast_send_as_node; /* Message to include in each beacon broadcast. Max 100 bytes enforced by firmware. */ char broadcast_message[101]; /* Optional channel (name + PSK) to advertise in the MeshBeacon offer_channel field. */ @@ -494,30 +498,15 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { /* Optional modem preset to advertise in the MeshBeacon offer_preset field. */ bool has_broadcast_offer_preset; meshtastic_Config_LoRaConfig_ModemPreset broadcast_offer_preset; - /* Single-target TX channel: channel settings (name + PSK) to send beacons on. - If unset, beacons go out on the primary channel. Used only when broadcast_targets is empty. - NOTE: the single-target path embeds the ChannelSettings inline here, whereas a - broadcast_targets entry references a channel-table slot by channel_index instead — see - BroadcastTarget. The two paths are equal, first-class options; only this representation differs. */ - bool has_broadcast_on_channel; - meshtastic_ChannelSettings broadcast_on_channel; - /* Region to use when sending beacons on broadcast_on_preset. */ - meshtastic_Config_LoRaConfig_RegionCode broadcast_on_region; - /* Modem preset to use when sending beacons. - If different from current config, the radio is temporarily switched for TX. */ - bool has_broadcast_on_preset; - meshtastic_Config_LoRaConfig_ModemPreset broadcast_on_preset; /* How often to broadcast, in seconds. Min 3600 (1 h), default 3600. */ uint32_t broadcast_interval_secs; - /* Multi-target broadcast list. - When non-empty the broadcaster transmits one beacon copy per entry in sequence, - each temporarily switching the radio to that entry's preset/region/channel. - When empty, the broadcaster uses the scalar broadcast_on_preset / broadcast_on_region / - broadcast_on_channel fields instead (the single-target path). - Single- and multi-target are equal, first-class options — neither is preferred or - deprecated. They differ only in how the TX channel is named: broadcast_on_channel embeds a - ChannelSettings inline, while a target references an existing channel-table slot by - channel_index (see BroadcastTarget). */ + /* Broadcast destination list. + The broadcaster sends one beacon copy per distinct destination, in sequence, temporarily + switching the radio to that entry's preset/region/channel for each. + When empty, a single beacon is sent on the node's running preset and region over the + primary channel. + Entries that resolve to the same effective preset, region and channel are deduplicated, so + a duplicate entry does not produce a second transmission. */ pb_size_t broadcast_targets_count; meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget broadcast_targets[4]; } meshtastic_ModuleConfig_MeshBeaconConfig; @@ -609,8 +598,8 @@ extern "C" { #define _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_ARRAYSIZE ((meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType)(meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_EITHER_EDGE_ACTIVE_HIGH+1)) #define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MIN meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_DEFAULT -#define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MAX meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700B -#define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_ARRAYSIZE ((meshtastic_ModuleConfig_AudioConfig_Audio_Baud)(meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700B+1)) +#define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MAX meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_450 +#define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_ARRAYSIZE ((meshtastic_ModuleConfig_AudioConfig_Audio_Baud)(meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_450+1)) #define _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MIN meshtastic_ModuleConfig_SerialConfig_Serial_Baud_BAUD_DEFAULT #define _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MAX meshtastic_ModuleConfig_SerialConfig_Serial_Baud_BAUD_921600 @@ -654,8 +643,6 @@ extern "C" { #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode @@ -684,7 +671,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_default {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_default {""} -#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, 0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, {meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default}} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, {meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default}} #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default {false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, 0} #define meshtastic_ModuleConfig_TAKConfig_init_default {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_default {0, "", _meshtastic_RemoteHardwarePinType_MIN} @@ -705,7 +692,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_zero {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_zero {""} -#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, 0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, {meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero}} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, {meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero}} #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero {false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, 0} #define meshtastic_ModuleConfig_TAKConfig_init_zero {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_zero {0, "", _meshtastic_RemoteHardwarePinType_MIN} @@ -821,14 +808,10 @@ extern "C" { #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_region_tag 2 #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_channel_index_tag 4 #define meshtastic_ModuleConfig_MeshBeaconConfig_flags_tag 1 -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_send_as_node_tag 3 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_message_tag 4 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_channel_tag 5 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_region_tag 6 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_preset_tag 7 -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_channel_tag 8 -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_region_tag 9 -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_preset_tag 10 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_interval_secs_tag 11 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_targets_tag 13 #define meshtastic_ModuleConfig_TAKConfig_team_tag 1 @@ -1073,20 +1056,15 @@ X(a, STATIC, SINGULAR, STRING, node_status, 1) #define meshtastic_ModuleConfig_MeshBeaconConfig_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, flags, 1) \ -X(a, STATIC, SINGULAR, UINT32, broadcast_send_as_node, 3) \ X(a, STATIC, SINGULAR, STRING, broadcast_message, 4) \ X(a, STATIC, OPTIONAL, MESSAGE, broadcast_offer_channel, 5) \ X(a, STATIC, SINGULAR, UENUM, broadcast_offer_region, 6) \ X(a, STATIC, OPTIONAL, UENUM, broadcast_offer_preset, 7) \ -X(a, STATIC, OPTIONAL, MESSAGE, broadcast_on_channel, 8) \ -X(a, STATIC, SINGULAR, UENUM, broadcast_on_region, 9) \ -X(a, STATIC, OPTIONAL, UENUM, broadcast_on_preset, 10) \ X(a, STATIC, SINGULAR, UINT32, broadcast_interval_secs, 11) \ X(a, STATIC, REPEATED, MESSAGE, broadcast_targets, 13) #define meshtastic_ModuleConfig_MeshBeaconConfig_CALLBACK NULL #define meshtastic_ModuleConfig_MeshBeaconConfig_DEFAULT NULL #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_channel_MSGTYPE meshtastic_ChannelSettings -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_channel_MSGTYPE meshtastic_ChannelSettings #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_targets_MSGTYPE meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_FIELDLIST(X, a) \ @@ -1164,7 +1142,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_MQTTConfig_size 224 #define meshtastic_ModuleConfig_MapReportSettings_size 14 #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_size 10 -#define meshtastic_ModuleConfig_MeshBeaconConfig_size 324 +#define meshtastic_ModuleConfig_MeshBeaconConfig_size 242 #define meshtastic_ModuleConfig_NeighborInfoConfig_size 10 #define meshtastic_ModuleConfig_PaxcounterConfig_size 30 #define meshtastic_ModuleConfig_RangeTestConfig_size 12 @@ -1175,7 +1153,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_TAKConfig_size 4 #define meshtastic_ModuleConfig_TelemetryConfig_size 50 #define meshtastic_ModuleConfig_TrafficManagementConfig_size 30 -#define meshtastic_ModuleConfig_size 328 +#define meshtastic_ModuleConfig_size 246 #define meshtastic_RemoteHardwarePin_size 21 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/portnums.pb.h b/src/mesh/generated/meshtastic/portnums.pb.h index 32da931034..d6652530cb 100644 --- a/src/mesh/generated/meshtastic/portnums.pb.h +++ b/src/mesh/generated/meshtastic/portnums.pb.h @@ -103,6 +103,12 @@ typedef enum _meshtastic_PortNum { Periodically broadcast by nodes in beacon mode; received by nodes with MeshBeaconConfig.FLAG_LISTEN_ENABLED. Carries a text message plus optional channel/preset offers for client apps. */ meshtastic_PortNum_MESH_BEACON_APP = 37, + /* Acknowledged paging: alerts a person is expected to physically acknowledge, and the + acknowledgements themselves. + ENCODING: protobuf PagingPacket + Distinct from ALERT_APP, which is a text message the recipient never confirms, and from a + routing or delivery ACK, which says the packet arrived rather than that someone saw it. */ + meshtastic_PortNum_PAGING_APP = 38, /* Provides a hardware serial interface to send and receive from the Meshtastic network. Connect to the RX/TX pins of a device with 38400 8N1. Packets received from the Meshtastic network is forwarded to the RX pin while sending a packet to TX will go out to the Mesh network. @@ -148,7 +154,7 @@ typedef enum _meshtastic_PortNum { /* PowerStress based monitoring support (for automated power consumption testing) */ meshtastic_PortNum_POWERSTRESS_APP = 74, /* LoraWAN Payload Transport - ENCODING: compact binary LoRaWAN uplink (10-byte RF metadata + PHY payload) - see LoRaWANBridgeModule */ + ENCODING: LoRaWANBridge protobuf, see lorawan_bridge.proto */ meshtastic_PortNum_LORAWAN_BRIDGE = 75, /* Reticulum Network Stack Tunnel App ENCODING: Fragmented RNS Packet. Handled by Meshtastic RNS interface */ diff --git a/src/mesh/generated/meshtastic/storeforward.pb.h b/src/mesh/generated/meshtastic/storeforward.pb.h index 75cff52058..f481a8c49f 100644 --- a/src/mesh/generated/meshtastic/storeforward.pb.h +++ b/src/mesh/generated/meshtastic/storeforward.pb.h @@ -107,6 +107,8 @@ typedef struct _meshtastic_StoreAndForward { /* Text from history message. */ meshtastic_StoreAndForward_text_t text; } variant; + /* Contains the original ID of the contained message. */ + uint32_t original_id; } meshtastic_StoreAndForward; @@ -126,11 +128,11 @@ extern "C" { /* Initializer values for message structs */ -#define meshtastic_StoreAndForward_init_default {_meshtastic_StoreAndForward_RequestResponse_MIN, 0, {meshtastic_StoreAndForward_Statistics_init_default}} +#define meshtastic_StoreAndForward_init_default {_meshtastic_StoreAndForward_RequestResponse_MIN, 0, {meshtastic_StoreAndForward_Statistics_init_default}, 0} #define meshtastic_StoreAndForward_Statistics_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_StoreAndForward_History_init_default {0, 0, 0} #define meshtastic_StoreAndForward_Heartbeat_init_default {0, 0} -#define meshtastic_StoreAndForward_init_zero {_meshtastic_StoreAndForward_RequestResponse_MIN, 0, {meshtastic_StoreAndForward_Statistics_init_zero}} +#define meshtastic_StoreAndForward_init_zero {_meshtastic_StoreAndForward_RequestResponse_MIN, 0, {meshtastic_StoreAndForward_Statistics_init_zero}, 0} #define meshtastic_StoreAndForward_Statistics_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_StoreAndForward_History_init_zero {0, 0, 0} #define meshtastic_StoreAndForward_Heartbeat_init_zero {0, 0} @@ -155,6 +157,7 @@ extern "C" { #define meshtastic_StoreAndForward_history_tag 3 #define meshtastic_StoreAndForward_heartbeat_tag 4 #define meshtastic_StoreAndForward_text_tag 5 +#define meshtastic_StoreAndForward_original_id_tag 6 /* Struct field encoding specification for nanopb */ #define meshtastic_StoreAndForward_FIELDLIST(X, a) \ @@ -162,7 +165,8 @@ X(a, STATIC, SINGULAR, UENUM, rr, 1) \ X(a, STATIC, ONEOF, MESSAGE, (variant,stats,variant.stats), 2) \ X(a, STATIC, ONEOF, MESSAGE, (variant,history,variant.history), 3) \ X(a, STATIC, ONEOF, MESSAGE, (variant,heartbeat,variant.heartbeat), 4) \ -X(a, STATIC, ONEOF, BYTES, (variant,text,variant.text), 5) +X(a, STATIC, ONEOF, BYTES, (variant,text,variant.text), 5) \ +X(a, STATIC, SINGULAR, UINT32, original_id, 6) #define meshtastic_StoreAndForward_CALLBACK NULL #define meshtastic_StoreAndForward_DEFAULT NULL #define meshtastic_StoreAndForward_variant_stats_MSGTYPE meshtastic_StoreAndForward_Statistics @@ -211,7 +215,7 @@ extern const pb_msgdesc_t meshtastic_StoreAndForward_Heartbeat_msg; #define meshtastic_StoreAndForward_Heartbeat_size 12 #define meshtastic_StoreAndForward_History_size 18 #define meshtastic_StoreAndForward_Statistics_size 50 -#define meshtastic_StoreAndForward_size 238 +#define meshtastic_StoreAndForward_size 244 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/telemetry.pb.cpp b/src/mesh/generated/meshtastic/telemetry.pb.cpp index aa095b1a2a..dba25ace20 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.cpp +++ b/src/mesh/generated/meshtastic/telemetry.pb.cpp @@ -12,6 +12,9 @@ PB_BIND(meshtastic_DeviceMetrics, meshtastic_DeviceMetrics, AUTO) PB_BIND(meshtastic_EnvironmentMetrics, meshtastic_EnvironmentMetrics, 2) +PB_BIND(meshtastic_SoilWaterMetrics, meshtastic_SoilWaterMetrics, AUTO) + + PB_BIND(meshtastic_PowerMetrics, meshtastic_PowerMetrics, AUTO) @@ -27,7 +30,7 @@ PB_BIND(meshtastic_TrafficManagementStats, meshtastic_TrafficManagementStats, AU PB_BIND(meshtastic_HealthMetrics, meshtastic_HealthMetrics, AUTO) -PB_BIND(meshtastic_HostMetrics, meshtastic_HostMetrics, 2) +PB_BIND(meshtastic_HostMetrics, meshtastic_HostMetrics, AUTO) PB_BIND(meshtastic_Telemetry, meshtastic_Telemetry, 2) diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index bfac3b038a..e32f0ec6c2 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -118,7 +118,7 @@ typedef enum _meshtastic_TelemetrySensorType { meshtastic_TelemetrySensorType_DS248X = 51, /* MMC5983MA 3-Axis Digital Magnetic Sensor */ meshtastic_TelemetrySensorType_MMC5983MA = 52, - /* ICM-42607-P 6‑Axis IMU */ + /* ICM-42607-P 6-Axis IMU */ meshtastic_TelemetrySensorType_ICM42607P = 53, /* SPA06 pressure and temperature */ meshtastic_TelemetrySensorType_SPA06 = 54, @@ -276,6 +276,59 @@ typedef struct _meshtastic_EnvironmentMetrics { float lightning_distance_km; } meshtastic_EnvironmentMetrics; +/* Soil and water probe metrics. + + Chemistry reported by soil probes (RS-485/SDI-12 NPK probes) and by + water-quality sondes. Split out of EnvironmentMetrics so that message stays + within the mesh payload budget. */ +typedef struct _meshtastic_SoilWaterMetrics { + /* Soil pH, 0-14 */ + bool has_soil_ph; + float soil_ph; + /* pH of water or other solution, 0-14 */ + bool has_ph; + float ph; + /* Electrical conductivity in mS/cm */ + bool has_electrical_conductivity; + float electrical_conductivity; + /* Salinity in mg/l */ + bool has_salinity; + float salinity; + /* Nitrogen concentration in mg/kg */ + bool has_nitrogen; + float nitrogen; + /* Phosphorus concentration in mg/kg */ + bool has_phosphorus; + float phosphorus; + /* Potassium concentration in mg/kg */ + bool has_potassium; + float potassium; + /* Dissolved oxygen in mg/l */ + bool has_dissolved_oxygen; + float dissolved_oxygen; + /* Oxidation-reduction potential (ORP) in mV */ + bool has_orp; + float orp; + /* Chemical oxygen demand in mg/l */ + bool has_chemical_oxygen_demand; + float chemical_oxygen_demand; + /* Turbidity in NTU */ + bool has_turbidity; + float turbidity; + /* Nitrate concentration in ppm */ + bool has_nitrate; + float nitrate; + /* Ammonium concentration in ppm */ + bool has_ammonium; + float ammonium; + /* Biochemical oxygen demand in mg/l */ + bool has_biochemical_oxygen_demand; + float biochemical_oxygen_demand; + /* Solar irradiance in W/m^2 (distinct from the radiation field's uR/h) */ + bool has_solar_irradiance; + float solar_irradiance; +} meshtastic_SoilWaterMetrics; + /* Power Metrics (voltage / current / etc) */ typedef struct _meshtastic_PowerMetrics { /* Voltage (Ch1) */ @@ -503,7 +556,7 @@ typedef struct _meshtastic_HostMetrics { /* Optional User-provided string for arbitrary host system information that doesn't make sense as a dedicated entry. */ bool has_user_string; - char user_string[200]; + char user_string[161]; } meshtastic_HostMetrics; /* Types of Measurements the telemetry module is equipped to handle */ @@ -528,6 +581,8 @@ typedef struct _meshtastic_Telemetry { meshtastic_HostMetrics host_metrics; /* Traffic management statistics */ meshtastic_TrafficManagementStats traffic_management_stats; + /* Soil and water probe metrics */ + meshtastic_SoilWaterMetrics soil_water_metrics; } variant; } meshtastic_Telemetry; @@ -608,9 +663,11 @@ extern "C" { + /* Initializer values for message structs */ #define meshtastic_DeviceMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_SoilWaterMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -624,6 +681,7 @@ extern "C" { #define meshtastic_SEN6XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_DeviceMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_SoilWaterMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -682,6 +740,21 @@ extern "C" { #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch7_tag 39 #define meshtastic_EnvironmentMetrics_lightning_strike_count_1h_tag 40 #define meshtastic_EnvironmentMetrics_lightning_distance_km_tag 41 +#define meshtastic_SoilWaterMetrics_soil_ph_tag 1 +#define meshtastic_SoilWaterMetrics_ph_tag 2 +#define meshtastic_SoilWaterMetrics_electrical_conductivity_tag 3 +#define meshtastic_SoilWaterMetrics_salinity_tag 4 +#define meshtastic_SoilWaterMetrics_nitrogen_tag 5 +#define meshtastic_SoilWaterMetrics_phosphorus_tag 6 +#define meshtastic_SoilWaterMetrics_potassium_tag 7 +#define meshtastic_SoilWaterMetrics_dissolved_oxygen_tag 8 +#define meshtastic_SoilWaterMetrics_orp_tag 9 +#define meshtastic_SoilWaterMetrics_chemical_oxygen_demand_tag 10 +#define meshtastic_SoilWaterMetrics_turbidity_tag 11 +#define meshtastic_SoilWaterMetrics_nitrate_tag 12 +#define meshtastic_SoilWaterMetrics_ammonium_tag 13 +#define meshtastic_SoilWaterMetrics_biochemical_oxygen_demand_tag 14 +#define meshtastic_SoilWaterMetrics_solar_irradiance_tag 15 #define meshtastic_PowerMetrics_ch1_voltage_tag 1 #define meshtastic_PowerMetrics_ch1_current_tag 2 #define meshtastic_PowerMetrics_ch2_voltage_tag 3 @@ -767,6 +840,7 @@ extern "C" { #define meshtastic_Telemetry_health_metrics_tag 7 #define meshtastic_Telemetry_host_metrics_tag 8 #define meshtastic_Telemetry_traffic_management_stats_tag 9 +#define meshtastic_Telemetry_soil_water_metrics_tag 11 #define meshtastic_Nau7802Config_zeroOffset_tag 1 #define meshtastic_Nau7802Config_calibrationFactor_tag 2 #define meshtastic_AS3935Config_tuning_cap_pf_tag 1 @@ -837,6 +911,25 @@ X(a, STATIC, OPTIONAL, FLOAT, lightning_distance_km, 41) #define meshtastic_EnvironmentMetrics_CALLBACK NULL #define meshtastic_EnvironmentMetrics_DEFAULT NULL +#define meshtastic_SoilWaterMetrics_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, FLOAT, soil_ph, 1) \ +X(a, STATIC, OPTIONAL, FLOAT, ph, 2) \ +X(a, STATIC, OPTIONAL, FLOAT, electrical_conductivity, 3) \ +X(a, STATIC, OPTIONAL, FLOAT, salinity, 4) \ +X(a, STATIC, OPTIONAL, FLOAT, nitrogen, 5) \ +X(a, STATIC, OPTIONAL, FLOAT, phosphorus, 6) \ +X(a, STATIC, OPTIONAL, FLOAT, potassium, 7) \ +X(a, STATIC, OPTIONAL, FLOAT, dissolved_oxygen, 8) \ +X(a, STATIC, OPTIONAL, FLOAT, orp, 9) \ +X(a, STATIC, OPTIONAL, FLOAT, chemical_oxygen_demand, 10) \ +X(a, STATIC, OPTIONAL, FLOAT, turbidity, 11) \ +X(a, STATIC, OPTIONAL, FLOAT, nitrate, 12) \ +X(a, STATIC, OPTIONAL, FLOAT, ammonium, 13) \ +X(a, STATIC, OPTIONAL, FLOAT, biochemical_oxygen_demand, 14) \ +X(a, STATIC, OPTIONAL, FLOAT, solar_irradiance, 15) +#define meshtastic_SoilWaterMetrics_CALLBACK NULL +#define meshtastic_SoilWaterMetrics_DEFAULT NULL + #define meshtastic_PowerMetrics_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, FLOAT, ch1_voltage, 1) \ X(a, STATIC, OPTIONAL, FLOAT, ch1_current, 2) \ @@ -946,7 +1039,8 @@ X(a, STATIC, ONEOF, MESSAGE, (variant,power_metrics,variant.power_metrics) X(a, STATIC, ONEOF, MESSAGE, (variant,local_stats,variant.local_stats), 6) \ X(a, STATIC, ONEOF, MESSAGE, (variant,health_metrics,variant.health_metrics), 7) \ X(a, STATIC, ONEOF, MESSAGE, (variant,host_metrics,variant.host_metrics), 8) \ -X(a, STATIC, ONEOF, MESSAGE, (variant,traffic_management_stats,variant.traffic_management_stats), 9) +X(a, STATIC, ONEOF, MESSAGE, (variant,traffic_management_stats,variant.traffic_management_stats), 9) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,soil_water_metrics,variant.soil_water_metrics), 11) #define meshtastic_Telemetry_CALLBACK NULL #define meshtastic_Telemetry_DEFAULT NULL #define meshtastic_Telemetry_variant_device_metrics_MSGTYPE meshtastic_DeviceMetrics @@ -957,6 +1051,7 @@ X(a, STATIC, ONEOF, MESSAGE, (variant,traffic_management_stats,variant.tra #define meshtastic_Telemetry_variant_health_metrics_MSGTYPE meshtastic_HealthMetrics #define meshtastic_Telemetry_variant_host_metrics_MSGTYPE meshtastic_HostMetrics #define meshtastic_Telemetry_variant_traffic_management_stats_MSGTYPE meshtastic_TrafficManagementStats +#define meshtastic_Telemetry_variant_soil_water_metrics_MSGTYPE meshtastic_SoilWaterMetrics #define meshtastic_Nau7802Config_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, INT32, zeroOffset, 1) \ @@ -991,6 +1086,7 @@ X(a, STATIC, OPTIONAL, FIXED64, voc_state_array, 6) extern const pb_msgdesc_t meshtastic_DeviceMetrics_msg; extern const pb_msgdesc_t meshtastic_EnvironmentMetrics_msg; +extern const pb_msgdesc_t meshtastic_SoilWaterMetrics_msg; extern const pb_msgdesc_t meshtastic_PowerMetrics_msg; extern const pb_msgdesc_t meshtastic_AirQualityMetrics_msg; extern const pb_msgdesc_t meshtastic_LocalStats_msg; @@ -1006,6 +1102,7 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_DeviceMetrics_fields &meshtastic_DeviceMetrics_msg #define meshtastic_EnvironmentMetrics_fields &meshtastic_EnvironmentMetrics_msg +#define meshtastic_SoilWaterMetrics_fields &meshtastic_SoilWaterMetrics_msg #define meshtastic_PowerMetrics_fields &meshtastic_PowerMetrics_msg #define meshtastic_AirQualityMetrics_fields &meshtastic_AirQualityMetrics_msg #define meshtastic_LocalStats_fields &meshtastic_LocalStats_msg @@ -1025,13 +1122,14 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; #define meshtastic_DeviceMetrics_size 27 #define meshtastic_EnvironmentMetrics_size 222 #define meshtastic_HealthMetrics_size 11 -#define meshtastic_HostMetrics_size 264 +#define meshtastic_HostMetrics_size 225 #define meshtastic_LocalStats_size 87 #define meshtastic_Nau7802Config_size 16 #define meshtastic_PowerMetrics_size 81 #define meshtastic_SEN5XState_size 27 #define meshtastic_SEN6XState_size 27 -#define meshtastic_Telemetry_size 272 +#define meshtastic_SoilWaterMetrics_size 75 +#define meshtastic_Telemetry_size 233 #define meshtastic_TrafficManagementStats_size 42 #ifdef __cplusplus diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index 8a44895241..befc5d8776 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -111,7 +111,7 @@ static void handleWebResponse() static uint32_t lastHeapWarning = 0; if (lastHeapWarning == 0 || !Throttle::isWithinTimespanMs(lastHeapWarning, 30000)) { LOG_WARN("Low heap (%u bytes), not accepting HTTPS connections", freeHeap); - lastHeapWarning = millis(); + lastHeapWarning = Time::skipZero(Time::getMillis()); } } } diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index aa41c16952..c45b0008a3 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -239,6 +239,11 @@ static_assert((uint32_t)TRAFFIC_MANAGEMENT_CACHE_SIZE * 10u + (uint32_t)WARM_NOD "MESHTASTIC_BOOT_CACHE_BUDGET in memory/MemClass.h"); #endif +// An oversized encode is silent: pb_encode_to_bytes() returns 0 and allocDataProtobuf() ships a +// well-formed packet carrying nothing (#11797). +static_assert(meshtastic_Telemetry_size <= meshtastic_Constants_DATA_PAYLOAD_LEN, + "Telemetry no longer fits DATA_PAYLOAD_LEN - shrink a variant in the protobufs repo"); + /// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic /// returns the encoded packet size size_t pb_encode_to_bytes(uint8_t *destbuf, size_t destbufsize, const pb_msgdesc_t *fields, const void *src_struct); diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 5dae68a736..75b6458f1c 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -357,13 +357,21 @@ int generate_self_signed_x509(EVP_PKEY *pkey, X509 **x509) X509_set_pubkey(*x509, pkey); - // SET Subject Name - X509_NAME *name = X509_get_subject_name(*x509); - X509_NAME_add_entry_by_txt(name, "C", MBSTRING_ASC, (unsigned char *)"DE", -1, -1, 0); - X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC, (unsigned char *)"Meshtastic", -1, -1, 0); - X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (unsigned char *)"meshtastic.local", -1, -1, 0); - // Selfsigned, Issuer = Subject - X509_set_issuer_name(*x509, name); + // SET Subject Name. Build the name standalone instead of mutating the cert's own in place: + // OpenSSL 4.0 made X509_get_subject_name() return a const pointer, and the setters (which take + // a const name and copy it) are the spelling that compiles against every supported version. + X509_NAME *name = X509_NAME_new(); + if (!name) + return -1; + if (X509_NAME_add_entry_by_txt(name, "C", MBSTRING_ASC, (unsigned char *)"DE", -1, -1, 0) != 1 || + X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC, (unsigned char *)"Meshtastic", -1, -1, 0) != 1 || + X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (unsigned char *)"meshtastic.local", -1, -1, 0) != 1 || + // Selfsigned, Issuer = Subject + X509_set_subject_name(*x509, name) != 1 || X509_set_issuer_name(*x509, name) != 1) { + X509_NAME_free(name); + return -1; + } + X509_NAME_free(name); // Certificate signed with our privte key if (X509_sign(*x509, pkey, EVP_sha256()) <= 0) diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 4a05d1292c..0e35aedef2 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -14,6 +14,9 @@ #if HAS_ETHERNET && defined(ARCH_ESP32) #include +#if HAS_ETHERNET && defined(ETH_SHARED_SPI) +#include "platform/esp32/SharedBusEthernet.h" +#endif #endif // HAS_ETHERNET #define UDP_MULTICAST_DEFAUL_PORT 4403 // Default port for UDP multicast is same as TCP api server diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 8bb80cd96a..8f53187279 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if HAS_WIFI #include "NodeDB.h" @@ -12,8 +13,13 @@ #if HAS_ETHERNET && defined(ARCH_ESP32) #include +#include #endif // HAS_ETHERNET +#if HAS_ETHERNET && defined(ETH_SHARED_SPI) +#include "platform/esp32/SharedBusEthernet.h" +#endif + #if HAS_ETHERNET && defined(USE_CH390D) #include "ESP32_CH390.h" #include "hal/spi_types.h" @@ -123,8 +129,14 @@ bool initEthernet() // Register before begin(): static config can fire ETH_GOT_IP immediately WiFi.onEvent(WiFiEvent); +#ifdef ETH_SHARED_SPI + // SharedBusEthernet takes spiLock around every W5500 transfer; Arduino's ETH cannot. + if (!ETH.begin()) + return false; +#else if (!ETH.begin(ETH_PHY_W5500, 1, ETH_CS_PIN, ETH_INT_PIN, ETH_RST_PIN, SPI3_HOST, ETH_SCLK_PIN, ETH_MISO_PIN, ETH_MOSI_PIN)) return false; +#endif applyEthStaticIp(); #if !MESHTASTIC_EXCLUDE_WEBSERVER @@ -302,7 +314,7 @@ static int32_t reconnectWiFi() tv.tv_usec = 0; perhapsSetRTC(RTCQualityNTP, &tv); - lastrun_ntp = millis(); + lastrun_ntp = Time::skipZero(Time::getMillis()); } else { LOG_DEBUG("NTP Update failed"); } diff --git a/src/meshUtils.h b/src/meshUtils.h index 2b7915778e..70ca27036d 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -85,6 +85,15 @@ bool sanitizeUtf8(char *buf, size_t bufSize); // left at the cut. void clampLongName(char *longName); +// Is a received Waypoint still live? The clients send expire == 0 for "never expires" and expire == 1 +// to delete; now == 0 means we have no trustworthy clock, which must not expire anything. +static inline bool waypointIsActive(uint32_t expire, uint32_t now) +{ + if (expire <= 1) + return expire == 0; + return now == 0 || expire > now; +} + /// Calculate 2^n without calling pow() - used for spreading factor and other calculations inline uint32_t pow_of_2(uint32_t n) { diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 55b029f031..9be681b42f 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -8,6 +8,7 @@ #include "PositionPrecision.h" #include "PowerFSM.h" #include "SPILock.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include "input/InputBroker.h" #include "meshUtils.h" @@ -68,6 +69,11 @@ AdminModule *adminModule; +#ifdef ARCH_STM32 +// Client detach window before the DFU jump (see the enter_dfu case). +static constexpr uint32_t STM32_DFU_DETACH_DELAY_MS = 5000; +#endif + #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) static bool licensedIdentityWillMigrate() { @@ -343,17 +349,6 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta case meshtastic_AdminMessage_set_module_config_tag: LOG_DEBUG("Client set module config"); -#if !MESHTASTIC_EXCLUDE_BEACON - // broadcast_send_as_node: remote admins may only set this to their own node ID. - if (mp.from != 0 && r->set_module_config.which_payload_variant == meshtastic_ModuleConfig_mesh_beacon_tag) { - auto &b = r->set_module_config.payload_variant.mesh_beacon; - if (b.broadcast_send_as_node != 0 && b.broadcast_send_as_node != mp.from) { - LOG_WARN("Beacon: rejecting broadcast_send_as_node 0x%08x from node 0x%08x (must match sender)", - b.broadcast_send_as_node, mp.from); - b.broadcast_send_as_node = moduleConfig.mesh_beacon.broadcast_send_as_node; - } - } -#endif if (!handleSetModuleConfig(r->set_module_config)) { myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); } @@ -368,7 +363,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; case meshtastic_AdminMessage_set_ham_mode_tag: LOG_DEBUG("Client set ham mode"); - handleSetHamMode(r->set_ham_mode); + // Without this a rejected request falls through to the generic Routing_Error_NONE ack below, + // so a client would report ham mode as enabled on a node that changed nothing. + if (!handleSetHamMode(r->set_ham_mode)) + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; case meshtastic_AdminMessage_get_ui_config_request_tag: { LOG_DEBUG("Client is getting device-ui config"); @@ -430,13 +428,13 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #endif int s = 1; // Reboot in 1 second, hard coded LOG_INFO("Reboot in %d seconds", s); - rebootAtMsec = (s < 0) ? 0 : (millis() + s * 1000); + rebootAtMsec = (s < 0) ? 0 : Time::timerEndsAtMillis(s * 1000); break; } case meshtastic_AdminMessage_shutdown_seconds_tag: { int32_t s = r->shutdown_seconds; LOG_INFO("Shutdown in %d seconds", s); - shutdownAtMsec = (s < 0) ? 0 : (millis() + s * 1000); + shutdownAtMsec = (s < 0) ? 0 : Time::timerEndsAtMillis(s * 1000); break; } case meshtastic_AdminMessage_get_device_metadata_request_tag: { @@ -627,7 +625,13 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #if HAS_SCREEN IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0)); #endif -#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32) +#if defined(ARCH_STM32) + // Delay the jump so this ACK reaches the client and it releases the port before the + // STM32WL ROM bootloader takes the UART and autobauds off the next byte it sees. + LOG_INFO("Entering DFU in %us - disconnect now", (STM32_DFU_DETACH_DELAY_MS + 999) / 1000); + // timerEndsAtMillis() dodges 0, the sentinel powerCommandsCheck() reads as unarmed. + enterDfuAtMsec = Time::timerEndsAtMillis(STM32_DFU_DETACH_DELAY_MS); +#elif defined(ARCH_NRF52) || defined(ARCH_RP2040) enterDfuMode(); #endif break; @@ -661,8 +665,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta SEGMENT_DEVICESTATE | SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_CHANNELS)) { myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp); LOG_DEBUG("Rebooting after preferences restore"); - reboot(1000); disableBluetooth(); + reboot(DEFAULT_REBOOT_SECONDS); } else { myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); } @@ -1202,10 +1206,20 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.security = incoming; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI) // First provisioning (no key) generates one; a private key supplied without its public key derives it. + // A supplied public key that is itself blacklisted is re-derived too, so a restore carrying a whole + // low-entropy pair cannot skip the check just by populating both fields. if (config.security.private_key.size != 32) { nodeDB->generateCryptoKeyPair(); - } else if (config.security.public_key.size == 0) { - nodeDB->generateCryptoKeyPair(config.security.private_key.bytes); + } else if (config.security.public_key.size == 0 || nodeDB->checkLowEntropyPublicKey(config.security.public_key)) { + // Warn at set time, not after the next reboot, and only when the key really was replaced: a + // blacklisted public key whose private key derives a clean one is re-derived, and that stuck. + uint8_t priorPrivateKey[32]; + memcpy(priorPrivateKey, config.security.private_key.bytes, 32); + const bool keygenSucceeded = nodeDB->generateCryptoKeyPair(priorPrivateKey); + const bool keyWasReplaced = memcmp(priorPrivateKey, config.security.private_key.bytes, 32) != 0; + if (keygenSucceeded && keyWasReplaced && nodeDB->keyIsLowEntropy) { + sendWarning(LOW_ENTROPY_RESTORE_WARNING); + } } #endif if (config.security.is_managed && !(config.security.admin_key[0].size == 32 || config.security.admin_key[1].size == 32 || @@ -1241,10 +1255,12 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) { bool shouldReboot = true; - // If we are in an open transaction or configuring MQTT or Serial (which have validation), defer disabling Bluetooth - // Otherwise, disable Bluetooth to prevent the phone from interfering with the config - if (!hasOpenEditTransaction && !IS_ONE_OF(c.which_payload_variant, meshtastic_ModuleConfig_mqtt_tag, - meshtastic_ModuleConfig_serial_tag, meshtastic_ModuleConfig_statusmessage_tag)) { + // Skip the variants that must not lose BLE here: MQTT and Serial validate first and disable it + // themselves, and statusmessage/mesh_beacon never reboot, so a disable would strand BLE until the + // next PowerFSM transition. Everything else reboots, so take BLE down before the phone interferes. + if (!hasOpenEditTransaction && + !IS_ONE_OF(c.which_payload_variant, meshtastic_ModuleConfig_mqtt_tag, meshtastic_ModuleConfig_serial_tag, + meshtastic_ModuleConfig_statusmessage_tag, meshtastic_ModuleConfig_mesh_beacon_tag)) { disableBluetooth(); } @@ -1258,8 +1274,10 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) if (!MQTT::isValidConfig(c.payload_variant.mqtt)) { return false; } - // Disable Bluetooth to prevent interference during MQTT configuration - disableBluetooth(); + // Disable Bluetooth to prevent interference during MQTT configuration, except inside an edit + // transaction: saveChanges() defers the reboot there, so nothing would bring BLE back. + if (!hasOpenEditTransaction) + disableBluetooth(); moduleConfig.has_mqtt = true; { char prevPass[sizeof(moduleConfig.mqtt.password)]; @@ -1277,7 +1295,9 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) LOG_ERROR("Invalid serial config"); return false; } - disableBluetooth(); // Disable Bluetooth to prevent interference during Serial configuration + // Same transaction caveat as MQTT above: a deferred reboot would leave BLE down with no restore. + if (!hasOpenEditTransaction) + disableBluetooth(); // Disable Bluetooth to prevent interference during Serial configuration moduleConfig.has_serial = true; moduleConfig.serial = c.payload_variant.serial; break; @@ -1368,19 +1388,6 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) if (beaconCfg.broadcast_interval_secs != 0 && beaconCfg.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs) beaconCfg.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs; - // Validate broadcast_on_preset against broadcast_on_region (or current region if unset). - if (beaconCfg.has_broadcast_on_preset) { - meshtastic_Config_LoRaConfig probe = config.lora; - probe.use_preset = true; - probe.modem_preset = beaconCfg.broadcast_on_preset; - if (beaconCfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) - probe.region = beaconCfg.broadcast_on_region; - if (!RadioInterface::validateConfigLora(probe)) { - LOG_WARN("Beacon: broadcast_on_preset %d invalid for region, clearing", beaconCfg.broadcast_on_preset); - beaconCfg.has_broadcast_on_preset = false; - beaconCfg.has_broadcast_on_channel = false; - } - } // Validate broadcast_offer_preset against broadcast_offer_region (or current region if unset). if (beaconCfg.has_broadcast_offer_preset) { meshtastic_Config_LoRaConfig probe = config.lora; @@ -1401,8 +1408,8 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) beaconCfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; } } - // Validate each multi-target entry the same way as the single-target broadcast_on_* fields, - // so a bad preset/region is cleared on write rather than relying on the runtime TX drop. + // Validate each broadcast target so a bad preset/region is cleared on write rather than + // relying on the runtime TX drop. for (pb_size_t i = 0; i < beaconCfg.broadcast_targets_count; i++) { auto &t = beaconCfg.broadcast_targets[i]; // Region must be a known region code (UNSET = use running config at TX time). @@ -1812,10 +1819,6 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r if (config.bluetooth.enabled && nrf52Bluetooth) { conn.bluetooth.is_connected = nrf52Bluetooth->isConnected(); } -#elif defined(ARCH_NRF54L15) - if (config.bluetooth.enabled && nrf54l15Bluetooth) { - conn.bluetooth.is_connected = nrf54l15Bluetooth->isConnected(); - } #endif #endif conn.has_serial = true; // No serial-less devices @@ -1875,7 +1878,7 @@ void AdminModule::reboot(int32_t seconds) LOG_INFO("Reboot in %d seconds", seconds); if (screen) screen->showSimpleBanner("Rebooting...", 0); // stays on screen - rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000); + rebootAtMsec = (seconds < 0) ? 0 : Time::timerEndsAtMillis(seconds * 1000); } // Without this, a commit that never arrives leaves the transaction open forever and every later @@ -1920,30 +1923,40 @@ void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uic #endif } -void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) +// Unset, or set to nothing but whitespace - the two ways a client can leave a name field empty. +static bool isBlankName(const char *start) { - // Validate ham parameters before setting since this would bypass validation in the owner struct - const char *fieldsToCheck[] = {p.call_sign, p.short_name}; - const char *fieldNames[] = {"call_sign", "short_name"}; - for (int i = 0; i < 2; i++) { - if (*fieldsToCheck[i]) { - const char *start = fieldsToCheck[i]; - while (*start && isspace((unsigned char)*start)) - start++; - if (*start == '\0') { - LOG_WARN("Rejected ham %s: needs 1+ non-whitespace char", fieldNames[i]); - return; - } - } + while (*start && isspace((unsigned char)*start)) + start++; + return *start == '\0'; +} + +bool AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) +{ + // Validate ham parameters before setting since this would bypass validation in the owner struct. + + // The call sign is the station ID the whole licensed mode is built around, so it is required; + // without it we would license a node that never identifies itself on the air. + if (isBlankName(p.call_sign)) { + LOG_WARN("Rejected ham call_sign: needs 1+ non-whitespace char"); + return false; } - // Set call sign and override lora limitations for licensed use - strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name)); + // Set call sign and override lora limitations for licensed use. An optional long_name rides + // behind the call sign with the "//" separator hams already use on the air. + // e.g. call_sign "N0CALL" plus long_name "Attic Heltec" becomes "N0CALL//Attic Heltec". + if (!isBlankName(p.long_name)) + snprintf(owner.long_name, sizeof(owner.long_name), "%s//%s", p.call_sign, p.long_name); + else + strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name)); owner.long_name[sizeof(owner.long_name) - 1] = '\0'; - sanitizeUtf8(owner.long_name, sizeof(owner.long_name)); - strncpy(owner.short_name, p.short_name, sizeof(owner.short_name)); - owner.short_name[sizeof(owner.short_name) - 1] = '\0'; - sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); + clampLongName(owner.long_name); + // short_name is optional per the schema, so a blank one keeps the name the node already had + if (!isBlankName(p.short_name)) { + strncpy(owner.short_name, p.short_name, sizeof(owner.short_name)); + owner.short_name[sizeof(owner.short_name) - 1] = '\0'; + sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); + } owner.is_licensed = true; config.lora.override_duty_cycle = true; config.lora.tx_power = p.tx_power; @@ -1977,6 +1990,7 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) service->reloadOwner(false); saveChanges(SEGMENT_CONFIG | SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); + return true; } AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_APP, &meshtastic_AdminMessage_msg) @@ -2494,9 +2508,6 @@ void disableBluetooth() #elif defined(ARCH_NRF52) if (nrf52Bluetooth) nrf52Bluetooth->shutdown(); -#elif defined(ARCH_NRF54L15) - if (nrf54l15Bluetooth) - nrf54l15Bluetooth->shutdown(); #endif #endif } diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index b8e8c59ade..0fbc8ac3b1 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -95,7 +95,9 @@ class AdminModule : public ProtobufModule, public Obser void handleSetChannel(); public: - void handleSetHamMode(const meshtastic_HamParameters &req); + /// Applies licensed-operator settings. False if the request was rejected and nothing changed, + /// so the caller can answer a want_response client with an error instead of an implicit success. + bool handleSetHamMode(const meshtastic_HamParameters &req); /// Note an admin request leaving this node for a remote, so that remote's response is /// accepted. Called from the client-to-mesh path (MeshService::handleToRadio). diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 7551ac7bbe..ea6f5ea96e 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -444,6 +444,9 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) LaunchWithDestination(NODENUM_BROADCAST); return 1; } + // Space is reserved for advancing frames (handled by Screen), so it must not open the composer + if (event->kbchar == ' ') + return 0; // Printable char (ASCII) opens free text compose if (event->kbchar >= 32 && event->kbchar <= 126) { updateState(CANNED_MESSAGE_RUN_STATE_FREETEXT, true); @@ -2310,7 +2313,12 @@ bool CannedMessageModule::saveProtoForModule() */ void CannedMessageModule::installDefaultCannedMessageModuleConfig() { +#ifdef USERPREFS_CANNED_MESSAGES + strncpy(cannedMessageModuleConfig.messages, USERPREFS_CANNED_MESSAGES, sizeof(cannedMessageModuleConfig.messages)); + cannedMessageModuleConfig.messages[sizeof(cannedMessageModuleConfig.messages) - 1] = '\0'; +#else strncpy(cannedMessageModuleConfig.messages, "Hi|Bye|Yes|No|Ok", sizeof(cannedMessageModuleConfig.messages)); +#endif } /** diff --git a/src/modules/DropzoneModule.cpp b/src/modules/DropzoneModule.cpp index 100b87662c..4adcf8c4a7 100644 --- a/src/modules/DropzoneModule.cpp +++ b/src/modules/DropzoneModule.cpp @@ -2,6 +2,7 @@ #include "DropzoneModule.h" #include "Meshservice->h" +#include "UptimeClock.h" #include "configuration.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" @@ -39,13 +40,13 @@ ProcessMessage DropzoneModule::handleReceived(const meshtastic_MeshPacket &mp) snprintf(matchCompare, sizeof(matchCompare), "%s conditions", owner.short_name); if (received >= strlen(matchCompare) && strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) { LOG_DEBUG("Received dropzone conditions request"); - startSendConditions = millis(); + startSendConditions = Time::skipZero(Time::getMillis()); } snprintf(matchCompare, sizeof(matchCompare), "%s conditions", owner.long_name); if (received >= strlen(matchCompare) && strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) { LOG_DEBUG("Received dropzone conditions request"); - startSendConditions = millis(); + startSendConditions = Time::skipZero(Time::getMillis()); } return ProcessMessage::CONTINUE; } diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 420697689f..28eaf6ccd8 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -14,6 +14,7 @@ * @date [Insert Date] */ #include "ExternalNotificationModule.h" +#include "Channels.h" #include "MeshService.h" #include "NodeDB.h" #include "Router.h" @@ -62,7 +63,10 @@ bool ascending = true; #define ASCII_BELL 0x07 +#if !MESHTASTIC_EXCLUDE_RTTTL meshtastic_RTTTLConfig rtttlConfig; +static const char *rtttlConfigFile = "/prefs/ringtone.proto"; +#endif ExternalNotificationModule *externalNotificationModule; @@ -70,8 +74,6 @@ bool externalCurrentState[3] = {}; uint32_t externalTurnedOn[3] = {}; -static const char *rtttlConfigFile = "/prefs/ringtone.proto"; - int32_t ExternalNotificationModule::runOnce() { if (!moduleConfig.external_notification.enabled) { @@ -86,12 +88,11 @@ int32_t ExternalNotificationModule::runOnce() #if defined(HAS_I2S_SPEAKER_NRF52) isRtttlPlaying = isRtttlPlaying || nrf52RtttlPlayer.isPlaying(); #endif - // isNagging is the armed flag; nagCycleCutoff holds a real deadline only while it is set - // (UINT32_MAX once stopped, 1 at boot), so short-circuit before the comparison. + // isNagging is the armed flag; nagCycleCutoff is only a deadline while it is set, so + // short-circuit before the comparison. `millis() + durationMs` can land on any value. const bool nagWindowExpired = !isNagging || Throttle::deadlinePassed(nagCycleCutoff); if (nagWindowExpired && !isRtttlPlaying) { // Turn off external notification immediately when timeout is reached, regardless of song state - nagCycleCutoff = UINT32_MAX; ExternalNotificationModule::stopNow(); isNagging = false; return INT32_MAX; // save cycles till we're needed again @@ -147,7 +148,7 @@ int32_t ExternalNotificationModule::runOnce() } // Play RTTTL over i2s audio interface if enabled as buzzer -#ifdef HAS_I2S +#if defined(HAS_I2S) && !MESHTASTIC_EXCLUDE_RTTTL if (moduleConfig.external_notification.use_i2s_as_buzzer) { if (audioThread->isPlaying()) { // Continue playing @@ -158,7 +159,7 @@ int32_t ExternalNotificationModule::runOnce() delay = EXT_NOTIFICATION_FAST_THREAD_MS; } #endif -#if defined(HAS_I2S_SPEAKER_NRF52) +#if defined(HAS_I2S_SPEAKER_NRF52) && !MESHTASTIC_EXCLUDE_RTTTL // Play RTTTL over the I2S speaker (no piezo on this board). if (canBuzz() && buzzerShouldAlert) { if (nrf52RtttlPlayer.isPlaying()) { @@ -169,6 +170,7 @@ int32_t ExternalNotificationModule::runOnce() delay = EXT_NOTIFICATION_FAST_THREAD_MS; } #endif +#if !MESHTASTIC_EXCLUDE_RTTTL // now let the PWM buzzer play if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz() && buzzerShouldAlert) { if (rtttl::isPlaying()) { @@ -180,6 +182,7 @@ int32_t ExternalNotificationModule::runOnce() // we need fast updates to play the RTTTL delay = EXT_NOTIFICATION_FAST_THREAD_MS; } +#endif return delay; } @@ -247,7 +250,9 @@ void ExternalNotificationModule::setExternalState(uint8_t index, bool on) blue = 0; white = 0; } - ambientLightingThread->setLighting(moduleConfig.ambient_lighting.current, red, green, blue); + if (ambientLightingThread) { + ambientLightingThread->setLighting(moduleConfig.ambient_lighting.current, red, green, blue); + } #endif #ifdef HAS_DRV2605 @@ -303,9 +308,9 @@ void ExternalNotificationModule::stopNow() #endif // Prevent the state machine from immediately re-triggering outputs after a manual stop. + // Clearing isNagging disarms the cycle; nagCycleCutoff is never read without it. isNagging = false; buzzerShouldAlert = false; - nagCycleCutoff = UINT32_MAX; #ifdef HAS_I2S // GPIO0 is used as mclk for I2S audio and set to OUTPUT by the sound library @@ -346,16 +351,18 @@ ExternalNotificationModule::ExternalNotificationModule() // moduleConfig.external_notification.alert_message_buzzer = true; if (moduleConfig.external_notification.enabled) { -#if !defined(MESHTASTIC_EXCLUDE_INPUTBROKER) +#if !MESHTASTIC_EXCLUDE_INPUTBROKER if (inputBroker) // put our callback in the inputObserver list inputObserver.observe(inputBroker); #endif +#if !MESHTASTIC_EXCLUDE_RTTTL if (nodeDB->loadProto(rtttlConfigFile, meshtastic_RTTTLConfig_size, sizeof(meshtastic_RTTTLConfig), &meshtastic_RTTTLConfig_msg, &rtttlConfig) != LoadFileResult::LOAD_SUCCESS) { memset(rtttlConfig.ringtone, 0, sizeof(rtttlConfig.ringtone)); // The default ringtone is always loaded from userPrefs.jsonc strncpy(rtttlConfig.ringtone, USERPREFS_RINGTONE_RTTTL, sizeof(rtttlConfig.ringtone)); } +#endif LOG_INFO("Init External Notification Module"); @@ -414,15 +421,8 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP } } - const meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(mp.from); - meshtastic_Channel ch = channels.getByIndex(mp.channel ? mp.channel : channels.getPrimaryIndex()); - - // If we receive a broadcast message, apply channel mute setting - // If we receive a direct message and the receipent is us, apply DM mute setting - // Else we just handle it as not muted. const bool isDmToUs = !isBroadcast(mp.to) && isToUs(&mp); - bool is_muted = isDmToUs ? nodeInfoLiteIsMuted(sender) - : (ch.settings.has_module_settings && ch.settings.module_settings.is_muted); + const bool is_muted = isMutedForPacket(mp); const bool buzzerModeIsDirectOnly = (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY); @@ -449,11 +449,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP (moduleConfig.external_notification.alert_message_buzzer && !is_muted))); if (genericShouldAlert || vibraShouldAlert || buzzerShouldAlert) { - nagCycleCutoff = millis() + (moduleConfig.external_notification.nag_timeout - ? (moduleConfig.external_notification.nag_timeout * 1000) - : moduleConfig.external_notification.output_ms); - LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); - isNagging = true; + armNagCycle(); } if (genericShouldAlert) { @@ -463,19 +459,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP if (vibraShouldAlert) { LOG_INFO("externalNotificationModule - Vibra alert"); -#ifdef HAS_DRV2605 - // Set DRV2605 waveform when vibration alert is triggered - drv.setWaveform(0, 16); // Long buzzer 100% - drv.setWaveform(1, 0); // Pause - drv.setWaveform(2, 16); - drv.setWaveform(3, 0); - drv.setWaveform(4, 16); - drv.setWaveform(5, 0); - drv.setWaveform(6, 16); - drv.setWaveform(7, 0); - drv.go(); -#endif - setExternalState(1, true); + triggerVibraOutput(); } if (buzzerShouldAlert) { @@ -484,15 +468,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP LOG_INFO("Buzzer suppressed: mode DIRECT_MSG_ONLY"); } else { // Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us - if (moduleConfig.external_notification.use_i2s_as_buzzer) { -#ifdef HAS_I2S - audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); -#endif - } else if (moduleConfig.external_notification.use_pwm) { - rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); - } else { - setExternalState(2, true); - } + triggerBuzzerOutput(); } } @@ -505,6 +481,82 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP return ProcessMessage::CONTINUE; // Let others look at this message also if they want } +void ExternalNotificationModule::triggerBuzzerOutput() +{ + if (moduleConfig.external_notification.use_i2s_as_buzzer) { +#if defined(HAS_I2S) && !MESHTASTIC_EXCLUDE_RTTTL + audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); +#endif + } else if (moduleConfig.external_notification.use_pwm) { +#if !MESHTASTIC_EXCLUDE_RTTTL + rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); +#endif + } else { + setExternalState(2, true); + } +} + +void ExternalNotificationModule::triggerVibraOutput() +{ +#ifdef HAS_DRV2605 + drv.setWaveform(0, 16); + drv.setWaveform(1, 0); + drv.setWaveform(2, 16); + drv.setWaveform(3, 0); + drv.setWaveform(4, 16); + drv.setWaveform(5, 0); + drv.setWaveform(6, 16); + drv.setWaveform(7, 0); + drv.go(); +#endif + setExternalState(1, true); +} + +void ExternalNotificationModule::armNagCycle() +{ + const uint32_t durationMs = moduleConfig.external_notification.nag_timeout + ? moduleConfig.external_notification.nag_timeout * 1000UL + : moduleConfig.external_notification.output_ms; + nagCycleCutoff = millis() + durationMs; + LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); + isNagging = true; +} + +void ExternalNotificationModule::startNotification() +{ + if (!moduleConfig.external_notification.enabled || isSilenced) + return; + + // Waypoint and geofence events are neither direct messages nor bells. + const bool buzzerModeIsDirectOnly = (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY); + + const bool generic = moduleConfig.external_notification.alert_message; + const bool vibra = moduleConfig.external_notification.alert_message_vibra; + const bool buzzer = canBuzz() && moduleConfig.external_notification.alert_message_buzzer && !buzzerModeIsDirectOnly; + if (canBuzz() && moduleConfig.external_notification.alert_message_buzzer && buzzerModeIsDirectOnly) + LOG_INFO("Non-message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY"); + if (!generic && !vibra && !buzzer) + return; + + buzzerShouldAlert |= buzzer; + + armNagCycle(); + + if (generic) { + LOG_INFO("externalNotificationModule - Generic alert"); + setExternalState(0, true); + } + if (vibra) { + LOG_INFO("externalNotificationModule - Vibra alert"); + triggerVibraOutput(); + } + if (buzzer) { + LOG_INFO("externalNotificationModule - Buzzer alert"); + triggerBuzzerOutput(); + } + + setIntervalFromNow(0); // run once so the nag/stop lifecycle in runOnce() takes over +} /** * @brief An admin message arrived to AdminModule. We are asked whether we want to handle that. * @@ -521,6 +573,7 @@ AdminMessageHandleResult ExternalNotificationModule::handleAdminMessageForModule AdminMessageHandleResult result; switch (request->which_payload_variant) { +#if !MESHTASTIC_EXCLUDE_RTTTL case meshtastic_AdminMessage_get_ringtone_request_tag: LOG_INFO("Client getting ringtone"); this->handleGetRingtone(mp, response); @@ -532,6 +585,7 @@ AdminMessageHandleResult ExternalNotificationModule::handleAdminMessageForModule this->handleSetRingtone(request->set_canned_message_module_messages); result = AdminMessageHandleResult::HANDLED; break; +#endif default: result = AdminMessageHandleResult::NOT_HANDLED; @@ -540,6 +594,7 @@ AdminMessageHandleResult ExternalNotificationModule::handleAdminMessageForModule return result; } +#if !MESHTASTIC_EXCLUDE_RTTTL void ExternalNotificationModule::handleGetRingtone(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response) { LOG_INFO("*** handleGetRingtone"); @@ -563,12 +618,17 @@ void ExternalNotificationModule::handleSetRingtone(const char *from_msg) nodeDB->saveProto(rtttlConfigFile, meshtastic_RTTTLConfig_size, &meshtastic_RTTTLConfig_msg, &rtttlConfig); } } +#endif +#if !MESHTASTIC_EXCLUDE_INPUTBROKER int ExternalNotificationModule::handleInputEvent(const InputEvent *event) { - if (nagCycleCutoff != UINT32_MAX) { + // Testing the deadline instead of isNagging was true at boot, and the non-zero return + // swallowed the first input event from every later observer. + if (isNagging) { stopNow(); return 1; } return 0; } +#endif diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index a5b9f68da7..95ff5b4d66 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -23,10 +23,10 @@ extern AmbientLightingThread *ambientLightingThread; #endif #endif -#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) +#if !MESHTASTIC_EXCLUDE_RTTTL #include #else -// Noop class for portduino. +// Noop class for portduino/STM32WL/ESP32C6 - none can drive PWM RTTTL playback. class rtttl { public: @@ -47,8 +47,10 @@ class rtttl */ class ExternalNotificationModule : public SinglePortModule, private concurrency::OSThread { +#if !MESHTASTIC_EXCLUDE_INPUTBROKER CallbackObserver inputObserver = CallbackObserver(this, &ExternalNotificationModule::handleInputEvent); +#endif uint32_t output = 0; #ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN @@ -58,9 +60,13 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: public: ExternalNotificationModule(); +#if !MESHTASTIC_EXCLUDE_INPUTBROKER int handleInputEvent(const InputEvent *arg); +#endif - uint32_t nagCycleCutoff = 1; + /// When the current nag cycle ends. Meaningful only while isNagging is set; never test it for a + /// magic value. + uint32_t nagCycleCutoff = 0; void setExternalState(uint8_t index = 0, bool on = false); bool getExternal(uint8_t index = 0); @@ -73,8 +79,13 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: void stopNow(); + // Fire the configured message outputs for a non-message event such as a geofence crossing. + void startNotification(); + +#if !MESHTASTIC_EXCLUDE_RTTTL void handleGetRingtone(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response); void handleSetRingtone(const char *from_msg); +#endif protected: /** Called to handle a particular incoming message @@ -87,6 +98,11 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: virtual bool wantPacket(const meshtastic_MeshPacket *p) override; + // Drive the configured buzzer output (I2S, PWM ringtone, or plain GPIO). + void triggerBuzzerOutput(); + void triggerVibraOutput(); + void armNagCycle(); + bool isNagging = false; bool isSilenced = false; @@ -97,4 +113,4 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: meshtastic_AdminMessage *response) override; }; -extern ExternalNotificationModule *externalNotificationModule; \ No newline at end of file +extern ExternalNotificationModule *externalNotificationModule; diff --git a/src/modules/GeofenceModule.cpp b/src/modules/GeofenceModule.cpp new file mode 100644 index 0000000000..ff7d2a5f7a --- /dev/null +++ b/src/modules/GeofenceModule.cpp @@ -0,0 +1,214 @@ +#include "GeofenceModule.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "WaypointStore.h" +#include "gps/GeoCoord.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" +#include + +#if HAS_SCREEN +#include "PowerFSM.h" +#include "graphics/Screen.h" +#include "main.h" // screen +#endif + +#include "modules/ExternalNotificationModule.h" + +GeofenceModule *geofenceModule; + +static constexpr size_t GEOFENCE_MAX_CROSSING = 256; + +GeofenceModule::GeofenceModule() +{ + crossingInside.reserve(GEOFENCE_MAX_CROSSING); + waypointStoreObserver.observe(&waypointStore); +} + +bool GeofenceModule::insideRadius(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters) +{ + if (radiusMeters == 0) + return false; + float meters = GeoCoord::latLongToMeter((double)ptLat_i * 1e-7, (double)ptLon_i * 1e-7, (double)ctrLat_i * 1e-7, + (double)ctrLon_i * 1e-7); + return meters <= (float)radiusMeters; +} + +bool GeofenceModule::insideBox(int32_t ptLat_i, int32_t ptLon_i, const meshtastic_BoundingBox &box) +{ + return ptLat_i >= box.latitude_south_i && ptLat_i <= box.latitude_north_i && ptLon_i >= box.longitude_west_i && + ptLon_i <= box.longitude_east_i; +} + +bool GeofenceModule::insideAny(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters, + bool hasBox, const meshtastic_BoundingBox &box) +{ + if (insideRadius(ptLat_i, ptLon_i, ctrLat_i, ctrLon_i, radiusMeters)) + return true; + if (hasBox && insideBox(ptLat_i, ptLon_i, box)) + return true; + return false; +} + +bool GeofenceModule::inside(const meshtastic_Waypoint &wp, int32_t ptLat_i, int32_t ptLon_i) +{ + return insideAny(ptLat_i, ptLon_i, wp.latitude_i, wp.longitude_i, wp.geofence_radius, wp.has_bounding_box, wp.bounding_box); +} + +bool GeofenceModule::hasGeofence(const meshtastic_Waypoint &wp) +{ + return wp.geofence_radius > 0 || wp.has_bounding_box; +} + +GeofenceModule::Crossing GeofenceModule::classify(bool firstSighting, bool wasInside, bool isInside, bool notifyOnEnter, + bool notifyOnExit) +{ + if (firstSighting) + return Crossing::None; // baseline only + if (wasInside == isInside) + return Crossing::None; // no transition + if (isInside) + return notifyOnEnter ? Crossing::Enter : Crossing::None; + return notifyOnExit ? Crossing::Exit : Crossing::None; +} + +GeofenceModule::CrossingState *GeofenceModule::findCrossingState(uint64_t key) +{ + for (auto &state : crossingInside) { + if (state.key == key) + return &state; + } + + return nullptr; +} + +bool GeofenceModule::shouldTrack(const meshtastic_Waypoint &wp, uint8_t notificationPreferences, uint32_t now) +{ + if (!hasGeofence(wp)) + return false; + if ((notificationPreferences & (WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_EXIT)) == 0) + return false; + // Only the circle is centred on the waypoint; the bounding box carries its own absolute + // corners, so a box-only geofence does not need a latitude/longitude pin. + if (wp.geofence_radius > 0 && !(wp.has_latitude_i && wp.has_longitude_i)) + return false; + // Expired/deleted? (now == 0 means we have no trustworthy clock, so treat it as still live.) + if (now != 0 && wp.expire != 0 && wp.expire <= now) + return false; + return true; +} + +int GeofenceModule::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + crossingInside.clear(); + return 0; +} + +void GeofenceModule::evaluatePosition(NodeNum node, const meshtastic_Position &p) +{ + if (waypointStore.getWaypoints().empty()) + return; + if (!p.has_latitude_i || !p.has_longitude_i) + return; + if (p.latitude_i == 0 && p.longitude_i == 0) + return; // treat the null island as "no fix" + if (node == nodeDB->getNodeNum()) + return; // judge other nodes' positions only (per design#114) + + const int32_t lat = p.latitude_i; + const int32_t lon = p.longitude_i; + const uint32_t now = getTime(); + bool favoriteResolved = false; + bool isFavorite = false; + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &wp = entry.waypoint; + if (!shouldTrack(wp, entry.notificationPreferences, now)) + continue; + + const bool isInside = inside(wp, lat, lon); + const uint64_t key = crossingKey(wp.id, node); + CrossingState *state = findCrossingState(key); + const bool hasTrackedState = (state != nullptr); + const Crossing crossing = + classify(!hasTrackedState, hasTrackedState ? state->inside : false, isInside, + entry.notificationEnabled(WAYPOINT_NOTIFY_ENTER), entry.notificationEnabled(WAYPOINT_NOTIFY_EXIT)); + + // Record/baseline the current state (bounded - drop new pairs once the map is full). + if (!hasTrackedState) { + if (crossingInside.size() < GEOFENCE_MAX_CROSSING) { + crossingInside.push_back(CrossingState{key, isInside}); + } else { + static bool warnedCrossingFull = false; + if (!warnedCrossingFull) { + LOG_WARN("Geofence crossing-state full (%u); new (waypoint,node) pairs will not alert until space frees", + (unsigned)GEOFENCE_MAX_CROSSING); + warnedCrossingFull = true; + } + } + } else { + state->inside = isInside; + } + + if (crossing == Crossing::None) + continue; + + if (entry.notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY)) { + if (!favoriteResolved) { + isFavorite = nodeDB->isFavorite(node); + favoriteResolved = true; + } + if (!isFavorite) + continue; + } + + notify(wp, node, crossing == Crossing::Enter); + } +} + +void GeofenceModule::notify(const meshtastic_Waypoint &wp, NodeNum node, bool entered) +{ + // Resolve a display name for the crossing node. + char who[40]; + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node); + if (info && info->long_name[0]) { + strncpy(who, info->long_name, sizeof(who) - 1); + who[sizeof(who) - 1] = '\0'; + } else if (info && info->short_name[0]) { + strncpy(who, info->short_name, sizeof(who) - 1); + who[sizeof(who) - 1] = '\0'; + } else { + snprintf(who, sizeof(who), "!%08x", (unsigned)node); + } + + LOG_INFO("Geofence: %s %s '%s'", who, entered ? "entered" : "left", wp.name); + +#if HAS_SCREEN + if (screen) + powerFSM.trigger(EVENT_RECEIVED_MSG); // wake the screen so the banner is seen +#endif + + GeofenceNotificationEvent event; + event.waypointId = wp.id; + strncpy(event.nodeName, who, sizeof(event.nodeName) - 1); + event.nodeName[sizeof(event.nodeName) - 1] = '\0'; + event.entered = entered; + strncpy(event.geofenceName, wp.name, sizeof(event.geofenceName) - 1); + event.geofenceName[sizeof(event.geofenceName) - 1] = '\0'; + notifyObservers(&event); + +#if HAS_SCREEN && !defined(MESHTASTIC_INCLUDE_INKHUD) + if (screen) { + char banner[120]; + snprintf(banner, sizeof(banner), "%s %s %s", who, entered ? "IN" : "OUT", wp.name); + screen->showSimpleBanner(banner, 5000); + } +#endif + + if (externalNotificationModule) + externalNotificationModule->startNotification(); +} + +#endif diff --git a/src/modules/GeofenceModule.h b/src/modules/GeofenceModule.h new file mode 100644 index 0000000000..8ef0ca0620 --- /dev/null +++ b/src/modules/GeofenceModule.h @@ -0,0 +1,68 @@ +#pragma once + +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "Observer.h" +#include "WaypointStore.h" +#include "mesh/MeshTypes.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +struct GeofenceNotificationEvent { + uint32_t waypointId = 0; + char nodeName[sizeof(meshtastic_User::long_name)] = {}; + bool entered = false; + char geofenceName[sizeof(meshtastic_Waypoint::name)] = {}; +}; + +// Tracks other nodes crossing waypoint geofences and emits enter/exit notifications on the waypoint creator's device. +class GeofenceModule : public Observable +{ + public: + GeofenceModule(); + + enum class Crossing { None, Enter, Exit }; + + static bool insideRadius(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters); + + static bool insideBox(int32_t ptLat_i, int32_t ptLon_i, const meshtastic_BoundingBox &box); + + static bool insideAny(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters, + bool hasBox, const meshtastic_BoundingBox &box); + + static bool inside(const meshtastic_Waypoint &wp, int32_t ptLat_i, int32_t ptLon_i); + + static bool hasGeofence(const meshtastic_Waypoint &wp); + + // Box-only geofences do not require a waypoint center because their corners are absolute. + static bool shouldTrack(const meshtastic_Waypoint &wp, uint8_t notificationPreferences, uint32_t now); + + // The first sighting establishes a baseline without notifying. + static Crossing classify(bool firstSighting, bool wasInside, bool isInside, bool notifyOnEnter, bool notifyOnExit); + + void evaluatePosition(NodeNum node, const meshtastic_Position &p); + + private: + struct CrossingState { + uint64_t key; + bool inside; + }; + + static uint64_t crossingKey(uint32_t waypointId, NodeNum node) { return ((uint64_t)waypointId << 32) | node; } + + CrossingState *findCrossingState(uint64_t key); + void notify(const meshtastic_Waypoint &wp, NodeNum node, bool entered); + int onWaypointStoreChanged(const WaypointStore *store); + + // Bounded (waypointId, nodeNum) state; new pairs are skipped until an old waypoint frees space. + std::vector crossingInside; + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &GeofenceModule::onWaypointStoreChanged); +}; + +extern GeofenceModule *geofenceModule; + +#endif diff --git a/src/modules/HopScalingModule.cpp b/src/modules/HopScalingModule.cpp index 758715f971..18d143e447 100644 --- a/src/modules/HopScalingModule.cpp +++ b/src/modules/HopScalingModule.cpp @@ -7,6 +7,7 @@ #include "FSCommon.h" #include "NodeDB.h" #include "SPILock.h" +#include "airtime.h" #include "concurrency/LockGuard.h" #include "mesh-pb-constants.h" #include @@ -245,23 +246,15 @@ void HopScalingModule::rollHour() } lastPerHopCounts = counts; - // 1b. Compute politeness factor from the 0-2 h vs 1-3 h activity ratio. - { - const uint32_t recent = static_cast(hourlyRaw[0]) + hourlyRaw[1]; - const uint32_t older = static_cast(hourlyRaw[1]) + hourlyRaw[2]; - if (older > 1 && recent > 1) { - const uint32_t r = static_cast(recent) * ACTIVITY_WEIGHT_SCALE; - const uint32_t o = static_cast(older); - if (r < o * ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER) - lastPoliteNumer = POLITENESS_GENEROUS; - else if (r > o * ACTIVITY_WEIGHT_STRICT_MIN_NUMER) - lastPoliteNumer = POLITENESS_STRICT; - else - lastPoliteNumer = POLITENESS_DEFAULT; - } else { - lastPoliteNumer = POLITENESS_DEFAULT; - } - } + // 1b. Pick the politeness factor from measured channel utilization. How far the walk may + // stretch and whether it is applied at all now read the same signal, so a node cannot be + // told the mesh is filling up by node counts while the channel says it is idle. + if (smoothedUtilPct() >= CONGESTION_STRICT_PCT) + lastPoliteNumer = POLITENESS_STRICT; + else if (smoothedUtilPct() >= CONGESTION_ENGAGE_PCT) + lastPoliteNumer = POLITENESS_DEFAULT; + else + lastPoliteNumer = POLITENESS_GENEROUS; // 1c. Scale and cache trend stats (denominatorHistory already advanced above). { @@ -424,6 +417,34 @@ void HopScalingModule::trimIfNeeded() } } +float HopScalingModule::channelUtil() +{ +#ifdef PIO_UNIT_TESTING + return s_testChannelUtil; +#else + return airTime ? airTime->smoothedChannelUtilizationPercent() : 0.0f; +#endif +} + +void HopScalingModule::updateCongestion() +{ + // AirTime folds its own EMA once per 10 s bucket, so this reads a figure that already covers + // the whole interval between ticks rather than only the 60 s before each one. + utilizationAvg = channelUtil(); + + // Separate engage/release thresholds, each confirmed over several ticks, so a mesh sitting + // near a threshold does not flap the hop limit between rolls. + const uint8_t util = smoothedUtilPct(); + const bool wantsFlip = congested ? (util <= CONGESTION_RELEASE_PCT) : (util >= CONGESTION_ENGAGE_PCT); + congestionConfirmRuns = wantsFlip ? static_cast(congestionConfirmRuns + 1u) : 0u; + if (congestionConfirmRuns >= CONGESTION_CONFIRM_RUNS) { + congested = !congested; + congestionConfirmRuns = 0; + LOG_INFO("[HOPSCALE] Congestion %s at chanUtil=%u%%", congested ? "engaged" : "released", + static_cast(utilizationAvg)); + } +} + void HopScalingModule::logStatusReport(bool didHourlyUpdate) const { const bool histActive = (histogramRollCount > 0 && count > 0); @@ -431,10 +452,11 @@ void HopScalingModule::logStatusReport(bool didHourlyUpdate) const const uint8_t runsRemaining = didHourlyUpdate ? RUNS_PER_HOUR : (RUNS_PER_HOUR - runsSinceLastHourlyUpdate); const uint8_t minsUntilRollover = runsRemaining * (RUN_INTERVAL_MS / (60 * 1000UL)); - LOG_INFO("[HOPSCALE] hop=%u histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u lastCounted=%u polite=%u/4 " - "nextRoll=%umin", - lastRequiredHop, histActive ? 1u : 0u, getFillPercentage(), samplingDenominator, filteringDenominator, count, - histCounts.total, lastPoliteNumer, minsUntilRollover); + LOG_INFO("[HOPSCALE] hop=%u congested=%u chanUtil=%u%% histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u " + "lastCounted=%u polite=%u/4 nextRoll=%umin", + lastRequiredHop, congested ? 1u : 0u, static_cast(utilizationAvg), histActive ? 1u : 0u, + getFillPercentage(), samplingDenominator, filteringDenominator, count, histCounts.total, lastPoliteNumer, + minsUntilRollover); LOG_INFO("[HOPSCALE] nodes perHop: [%u %u %u %u %u %u %u %u]", histCounts.perHop[0], histCounts.perHop[1], histCounts.perHop[2], histCounts.perHop[3], histCounts.perHop[4], histCounts.perHop[5], histCounts.perHop[6], @@ -449,6 +471,9 @@ int32_t HopScalingModule::runOnce() const bool isFirstRun = !hasCompletedInitialRun; bool didHourlyUpdate = false; + // Sampled every tick, not only on a roll, so the gate reacts within minutes of a change. + updateCongestion(); + if (isFirstRun) { hasCompletedInitialRun = true; runsSinceLastHourlyUpdate = 0; @@ -466,23 +491,36 @@ int32_t HopScalingModule::runOnce() } if (didHourlyUpdate) { - uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX; - // Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops, - // SENSOR reaches at least 1, so these reporting roles remain reachable even - // on a dense mesh where the histogram recommends a lower hop count. - uint8_t roleFloor = 0; - switch (config.device.role) { - case meshtastic_Config_DeviceConfig_Role_TRACKER: - case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER: - roleFloor = 2; - break; - case meshtastic_Config_DeviceConfig_Role_SENSOR: - roleFloor = 1; - break; - default: - break; + if (!congested) { + // Density alone is not a reason to throttle. Hand a hop back per roll rather than + // jumping to HOP_MAX, so a mesh that just quietened does not un-throttle all at once. + if (lastRequiredHop < HOP_MAX) + lastRequiredHop++; + } else { + uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX; + // Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops, SENSOR reaches + // at least 1, and the infrastructure roles reach INFRASTRUCTURE_HOP_FLOOR, so these + // reporting roles remain reachable even on a dense mesh recommending fewer hops. + // The infrastructure set matches the one Router.cpp uses for zero-cost hops. + uint8_t roleFloor = 0; + switch (config.device.role) { + case meshtastic_Config_DeviceConfig_Role_ROUTER: + case meshtastic_Config_DeviceConfig_Role_ROUTER_LATE: + case meshtastic_Config_DeviceConfig_Role_CLIENT_BASE: + roleFloor = INFRASTRUCTURE_HOP_FLOOR; + break; + case meshtastic_Config_DeviceConfig_Role_TRACKER: + case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER: + roleFloor = 2; + break; + case meshtastic_Config_DeviceConfig_Role_SENSOR: + roleFloor = 1; + break; + default: + break; + } + lastRequiredHop = std::max(suggested, roleFloor); } - lastRequiredHop = std::max(suggested, roleFloor); } logStatusReport(didHourlyUpdate); diff --git a/src/modules/HopScalingModule.h b/src/modules/HopScalingModule.h index 70d87428a5..b8b6786d39 100644 --- a/src/modules/HopScalingModule.h +++ b/src/modules/HopScalingModule.h @@ -105,16 +105,21 @@ class HopScalingModule : private concurrency::OSThread static constexpr uint8_t POLITENESS_DEFAULT = 2u; // 2/4 = 0.50 static constexpr uint8_t POLITENESS_STRICT = 1u; // 1/4 = 0.25 - // Activity weight thresholds (ratio of 0-2 h window vs 1-3 h window). - // Cross-multiply form: recent * ACTIVITY_WEIGHT_SCALE vs older * threshold_numer. - // GENEROUS if recent*10 < older*9 (ratio < 0.9); STRICT if recent*10 > older*12 (ratio > 1.2) - static constexpr uint8_t ACTIVITY_WEIGHT_SCALE = 10u; - static constexpr uint8_t ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER = 9u; - static constexpr uint8_t ACTIVITY_WEIGHT_STRICT_MIN_NUMER = 12u; - // Scheduling: number of 5-minute runOnce() ticks that make up one hourly rollover static constexpr uint8_t RUNS_PER_HOUR = 12; + // Congestion gate. Scaling is applied only while the smoothed channel utilization says the + // channel is busy; below the release threshold the recommendation is not applied at all. + static constexpr uint8_t CONGESTION_ENGAGE_PCT = default_hop_scaling_congestion_engage_pct; + static constexpr uint8_t CONGESTION_RELEASE_PCT = default_hop_scaling_congestion_release_pct; + static constexpr uint8_t CONGESTION_CONFIRM_RUNS = default_hop_scaling_congestion_confirm_runs; + // Upper band for the politeness numerator, which reads the same smoothed utilization as the + // gate: STRICT at or above this, DEFAULT from CONGESTION_ENGAGE_PCT, GENEROUS below it. + static constexpr uint8_t CONGESTION_STRICT_PCT = default_hop_scaling_congestion_strict_pct; + + // Hop floor for the infrastructure roles, so a remote site's own telemetry still reaches operators. + static constexpr uint8_t INFRASTRUCTURE_HOP_FLOOR = default_hop_scaling_infrastructure_hop_floor; + // ----------------------------------------------------------------------- // Types // ----------------------------------------------------------------------- @@ -179,6 +184,7 @@ class HopScalingModule : private concurrency::OSThread const PerHopCounts &getLastPerHopCounts() const { return lastPerHopCounts; } uint8_t getLastSuggestedHop() const { return lastSuggestedHop; } const MeshTrendStats &getLastTrendStats() const { return lastTrendStats; } + bool isCongested() const { return congested; } // Compatibility accessors used by tests uint8_t getCompactHistogramEntryCount() const { return getEntryCount(); } @@ -200,6 +206,8 @@ class HopScalingModule : private concurrency::OSThread // Writable from tests as HopScalingModule::s_testNowMs; drives nowMs() in PIO_UNIT_TESTING builds. inline static uint32_t s_testNowMs = 0; /// Override the per-session hash seed. Use in tests that need a specific sampling distribution. + // Drives channelUtilizationPercent() in PIO_UNIT_TESTING builds, as s_testNowMs drives nowMs(). + inline static float s_testChannelUtil = 0.0f; void setHashSeed(uint16_t seed) { hashSeed = seed; } uint16_t getHashSeed() const { return hashSeed; } /// Expose hashNodeId for tests that need to compute which node IDs pass a given denominator. @@ -223,6 +231,19 @@ class HopScalingModule : private concurrency::OSThread /// filteringDenominator once toward samplingDenominator per rollHour() call. /// 6. Shifts all seen bitmaps left by one hour slot. void rollHour(); + + /// Cache the smoothed channel utilization and flip the congestion state once the engage or + /// release threshold has held for CONGESTION_CONFIRM_RUNS consecutive runOnce() ticks. + void updateCongestion(); + + /// Smoothed channel utilization percent, or 0 when AirTime is not up yet. + static float channelUtil(); + + /// utilizationAvg to the nearest whole percent. The thresholds are whole percents and the + /// underlying 60 s window is far coarser than a float ULP, so comparing at full float + /// precision only creates dead zones: an EMA converging on a threshold from above settles one + /// ULP off it (12.00006103515625 for a sustained 12%) and an inclusive test never fires. + uint8_t smoothedUtilPct() const { return static_cast(std::min(utilizationAvg + 0.5f, 255.0f)); } // ----------------------------------------------------------------------- // Persistence // ----------------------------------------------------------------------- @@ -309,6 +330,15 @@ class HopScalingModule : private concurrency::OSThread uint8_t lastRequiredHop = HOP_MAX; uint8_t histogramRollCount = 0; + // ----------------------------------------------------------------------- + // Congestion state + // ----------------------------------------------------------------------- + // Cached once per runOnce() from AirTime, so the hourly roll and the status log read one + // consistent value without re-taking the AirTime lock. + float utilizationAvg = 0.0f; + bool congested = false; + uint8_t congestionConfirmRuns = 0; + // ----------------------------------------------------------------------- // Scheduler state // ----------------------------------------------------------------------- diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index eb6c496413..29594f64ab 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -3,6 +3,7 @@ #include "CryptoEngine.h" #include "HardwareRNG.h" #include "MeshService.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include "graphics/draw/MenuHandler.h" #include "main.h" @@ -400,7 +401,7 @@ void KeyVerificationModule::resetToIdle() memset(hash1, 0, 32); memset(hash2, 0, 32); if (sessionFromRemote) - lastRemoteSessionMs = millis(); // start the cooldown when the session ends, not when it opened + lastRemoteSessionMs = Time::skipZero(Time::getMillis()); // start the cooldown when the session ends, not when it opened sessionFromRemote = false; currentNonce = 0; currentNonceTimestamp = 0; diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 9982f8d157..34758de0ed 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -19,6 +19,11 @@ meshtastic_ChannelSettings MeshBeaconModule::originalPrimaryChannel; static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; +// Explicit switch state, not inferred: "live config differs from the snapshot" missed name/PSK-only +// swaps and fired on legitimate channel edits. +static bool radioSwitched = false; +static uint32_t switchedForId = 0; + static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset *preset, uint16_t *slot, bool *legacyHopOverride = nullptr, meshtastic_Config_LoRaConfig_RegionCode *region = nullptr, bool *has_channel = nullptr, @@ -46,6 +51,16 @@ static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Co return false; } +// Is a target entry still live for this packet id? Unlike sendingPacket or the radio's standby +// state, this is our own bookkeeping - it answers "has that beacon finished" without asking the radio. +static bool targetRadioSettingsLive(uint32_t id) +{ + for (const auto &entry : targetRadioSettings) + if (entry.inUse && entry.id == id) + return true; + return false; +} + // --------------------------------------------------------------------------- // MeshBeaconModule base // --------------------------------------------------------------------------- @@ -74,8 +89,22 @@ void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, me if (!target && !entry.inUse) target = &entry; } - if (!target) - target = &targetRadioSettings[0]; + if (!target) { + // Table full. Never evict the entry the outstanding switch is gated on: dropping it would + // unblock the restore and put the home config back under a beacon that has not keyed up. + for (auto &entry : targetRadioSettings) { + if (!radioSwitched || entry.id != switchedForId) { + target = &entry; + break; + } + } + if (!target) { + LOG_WARN("Beacon: target table full and every slot is in flight, drop target for 0x%08x", p->id); + return; + } + LOG_WARN("Beacon: target table full (%u slots), evicting packet 0x%08x for 0x%08x", + (unsigned)(sizeof(targetRadioSettings) / sizeof(targetRadioSettings[0])), target->id, p->id); + } target->inUse = true; target->id = p->id; target->preset = preset; @@ -151,13 +180,23 @@ meshtastic_ChannelSettings MeshBeaconModule::beaconChannelSettings(const meshtas bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p) { - // True while a beacon radio switch is in effect and still needs undoing. We track the switch - // explicitly rather than inferring it from "live config differs from the snapshot", because that - // heuristic both missed cases (a channel name/PSK swap that left preset/slot/region unchanged would - // never be restored) and fired falsely (a legitimate non-beacon channel edit would be reverted on - // the next TX). With the flag the restore fires for ANY field we changed and only when we changed - // it - including on TX-failure paths, which route through this same restore call. - static bool radioSwitched = false; + // Consecutive switches with no restore between them, so a multi-target run can be read off the log + // and the held home snapshot is attributable to a specific switch. + static uint8_t switchDepth = 0; + + // Both branches end in iface->reconfigure(), whose setStandby() runs completeSending() and calls + // straight back in here. Ignore that re-entry: the outer call owns the config it is applying. + static bool applying = false; + if (applying) { + // Expected once per switch and once per restore. A burst of these means something new re-enters. + LOG_DEBUG("Beacon: ignore re-entrant reconfigure while a radio config is being applied"); + return false; + } + struct ApplyingScope { + bool &flag; + explicit ApplyingScope(bool &f) : flag(f) { flag = true; } + ~ApplyingScope() { flag = false; } + } applyingScope(applying); meshtastic_ChannelSettings *primaryCh = &channels.getByIndex(channels.getPrimaryIndex()).settings; meshtastic_Config_LoRaConfig_ModemPreset targetPreset; @@ -202,18 +241,26 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ return false; } - // Snapshot current (non-beacon) settings so we restore to the latest config. Skip while a - // switch is already active, so a second switch before the restore can't capture the beacon - // config as the "home" we later restore to. + // Snapshot the live (non-beacon) config as "home". Skipped while a switch is already active, + // so a second switch before the restore cannot capture the beacon config instead. if (!radioSwitched) { originalModemPreset = config.lora.modem_preset; originalLoraChannel = config.lora.channel_num; originalRegion = config.lora.region; originalPrimaryChannel = *primaryCh; + switchDepth = 0; } + switchDepth++; - LOG_INFO("Beacon: switch radio for packet 0x%08x to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot, - targetRegion); + LOG_INFO("Beacon: switch #%u radio for packet 0x%08x to preset=%d slot=%u region=%d", switchDepth, p->id, targetPreset, + targetSlot, targetRegion); + if (switchDepth > 1) + LOG_WARN("Beacon: switching again with no restore between; home preset=%d slot=%u region=%d still held", + originalModemPreset, originalLoraChannel, originalRegion); + // Before config.lora stops describing the config we are committed to, so the committed slot + // stays pinned to ours while we key up on someone else's preset. + if (nodeDB) + nodeDB->setLoraSlotTransient(true); config.lora.modem_preset = targetPreset; config.lora.channel_num = targetSlot; if (targetRegion != config.lora.region) @@ -222,13 +269,22 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ channels.fixupChannel(channels.getPrimaryIndex()); p->channel = channels.getHash(channels.getPrimaryIndex()); + radioSwitched = true; // set before reconfigure(), so the flag never lags the radio it describes + switchedForId = p->id; iface->reconfigure(); - radioSwitched = true; return true; } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && radioSwitched) { - LOG_INFO("Beacon: restore radio config after TX"); + // Null p is "release if nothing holds it": hold off until the arming beacon has finished. A + // non-null untagged p is the driver about to transmit it, so that always restores. + if (!p && targetRadioSettingsLive(switchedForId)) { + LOG_DEBUG("Beacon: skip restore, packet 0x%08x has not finished sending", switchedForId); + return false; + } + + LOG_INFO("Beacon: restore radio config after TX, undoing %u switch(es) -> preset=%d slot=%u region=%d", switchDepth, + originalModemPreset, originalLoraChannel, originalRegion); config.lora.modem_preset = originalModemPreset; config.lora.channel_num = originalLoraChannel; config.lora.region = originalRegion; @@ -236,13 +292,51 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ primaryCh->name[sizeof(primaryCh->name) - 1] = '\0'; channels.fixupChannel(channels.getPrimaryIndex()); + if (nodeDB) { // config.lora describes the committed config again + nodeDB->setLoraSlotTransient(false); + nodeDB->refreshCommittedLoraSlot(); + } + radioSwitched = false; // cleared before reconfigure(), so the flag never lags the radio it describes + switchDepth = 0; + switchedForId = 0; iface->reconfigure(); - radioSwitched = false; return true; } return false; } +// --------------------------------------------------------------------------- +// MeshBeaconTxHook +// --------------------------------------------------------------------------- + +MeshBeaconTxHook *meshBeaconTxHook; + +RadioTxHook::PreTxAction MeshBeaconTxHook::beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p) +{ + // Invalid target config (bad preset/region, or an unlicensed node keying up on a ham-only + // region): the packet must never fall through onto the current (home) config, so drop it. + if (MeshBeaconModule::beaconTxConfigInvalid(p)) { + LOG_DEBUG("Beacon: invalid TX radio config, drop packet 0x%08x", p->id); + return PRETX_DROP; + } + // A switch leaves the radio on a channel we have not scanned yet, so the driver owes us a + // fresh transmit delay before it keys up. + return MeshBeaconModule::reconfigureForBeaconTX(iface, p) ? PRETX_DEFER : PRETX_SEND; +} + +bool MeshBeaconTxHook::holdsRadio(const meshtastic_MeshPacket *p) +{ + return MeshBeaconModule::hasTargetRadioSettings(p); +} + +void MeshBeaconTxHook::packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p) +{ + // Clear first: the restore is gated on the switching packet still being live, so dropping our + // claim before asking is what lets the home config come back. + MeshBeaconModule::clearTargetRadioSettings(p); + MeshBeaconModule::reconfigureForBeaconTX(iface, nullptr); +} + // --------------------------------------------------------------------------- // MeshBeaconBroadcastModule // --------------------------------------------------------------------------- @@ -283,6 +377,8 @@ void MeshBeaconBroadcastModule::rebuildCache() void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset, bool has_channel, const meshtastic_ChannelSettings *overrideChannel) { + // Beacons uplink to MQTT like any other primary-slot packet - Router::send() publishes on slot 0's + // uplink_enabled, and under the swap below the topic is the beacon channel's. Both intentional. const bool cryptoOverride = has_channel && overrideChannel && (overrideChannel->name[0] != '\0' || overrideChannel->psk.size > 0); if (!cryptoOverride) { @@ -329,22 +425,6 @@ void MeshBeaconBroadcastModule::sendBeacon() const auto stampPacket = [&](meshtastic_MeshPacket *p) { p->to = NODENUM_BROADCAST; p->from = nodeDB->getNodeNum(); - // broadcast_send_as_node: commented out pending further review. - // Spoof notes preserved for when this is re-enabled: - // broadcast_send_as_node overrides the source NodeNum. NOTE: this is a *node-ID* spoof - // only - it rewrites the 'from' field but does NOT forge any signature. Once 'from' is - // not us, the packet is no longer isFromUs(), so Router::perhapsEncode() skips XEdDSA - // signing and receivers get an unsigned packet attributed to another node. - // When broadcast_send_as_node == 0 the beacon is genuinely from us and Router::perhapsEncode() - // signs it under the same XEdDSA broadcast policy as normal channel messages. - // When broadcast_send_as_node rewrites p->from, perhapsEncode() sees isFromUs()=false and - // skips setting has_bitfield - must be set explicitly so receivers can classify hop_start - // correctly and so ok_to_mqtt is honoured on the spoofed packet. - // if (bcfg.broadcast_send_as_node != 0) { - // p->from = bcfg.broadcast_send_as_node; - // p->decoded.has_bitfield = true; - // p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT); - // } p->hop_limit = 0; // all beacon packets are zero hopped to limit spamming. p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; p->want_ack = false; @@ -388,10 +468,9 @@ void MeshBeaconBroadcastModule::sendBeacon() // ── Per-target loop ────────────────────────────────────────────────────── // - // If broadcast_targets is populated, iterate over those. Otherwise use the single-target - // broadcast_on_preset / broadcast_on_region / broadcast_on_channel fields. The two paths are - // equal options; they differ only in how the TX channel is named (single-target embeds a - // ChannelSettings inline; a target references a channel-table slot by channel_index). + // Every destination comes from broadcast_targets. An entry names its TX channel by + // channel_index, a slot in the device's channel table, so the channel must already be + // configured on the node - its key is needed to encrypt. struct EffTarget { meshtastic_Config_LoRaConfig_ModemPreset preset; uint16_t slot; @@ -400,8 +479,9 @@ void MeshBeaconBroadcastModule::sendBeacon() meshtastic_ChannelSettings channel; }; - const bool useTargetList = bcfg.broadcast_targets_count > 0; - const int targetCount = useTargetList ? (int)bcfg.broadcast_targets_count : 1; + // An empty list still beacons once, on the node's running preset and region over the primary + // channel. Each entry below overrides only what it sets. + const int targetCount = bcfg.broadcast_targets_count > 0 ? (int)bcfg.broadcast_targets_count : 1; // Dedup state: the beacon payload is identical across targets, so two targets that resolve to // the same effective radio config (preset + resolved region + channel) would just re-broadcast @@ -422,17 +502,19 @@ void MeshBeaconBroadcastModule::sendBeacon() }; for (int ti = 0; ti < targetCount; ti++) { + // Defaults: running radio config, primary channel. A target entry overrides from here. EffTarget tgt = {}; - if (useTargetList) { + tgt.preset = config.lora.modem_preset; + tgt.slot = config.lora.channel_num; + if (ti < (int)bcfg.broadcast_targets_count) { const auto &bt = bcfg.broadcast_targets[ti]; - tgt.preset = bt.has_preset ? bt.preset : config.lora.modem_preset; + if (bt.has_preset) + tgt.preset = bt.preset; tgt.region = bt.region; // Resolve the channel from the device's channel table by index. A slot is only usable // if it is actually configured (has a name or PSK - its key is needed to encrypt). An // out-of-range index, or a blank slot, falls back to the default channel for the target // preset (see beaconChannelSettings), exactly as an unset channel_index would. - tgt.has_channel = false; - tgt.slot = config.lora.channel_num; if (bt.has_channel_index) { if (bt.channel_index >= (uint32_t)channels.getNumChannels()) { LOG_WARN("Beacon: target %d channel_index %u out of range, use preset default", ti, bt.channel_index); @@ -447,13 +529,6 @@ void MeshBeaconBroadcastModule::sendBeacon() } } } - } else { - tgt.preset = bcfg.has_broadcast_on_preset ? bcfg.broadcast_on_preset : config.lora.modem_preset; - tgt.region = bcfg.broadcast_on_region; - tgt.has_channel = bcfg.has_broadcast_on_channel; - if (tgt.has_channel) - tgt.channel = bcfg.broadcast_on_channel; - tgt.slot = tgt.has_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num; } // Skip a target whose effective radio config duplicates one already sent this cycle. diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index e9faeea4cf..9ae6d4ddda 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -3,6 +3,7 @@ #include "Observer.h" #include "ProtobufModule.h" #include "RadioInterface.h" +#include "RadioTxHook.h" #include "concurrency/OSThread.h" #include "mesh/generated/meshtastic/mesh_beacon.pb.h" #include "mesh/generated/meshtastic/module_config.pb.h" @@ -39,7 +40,7 @@ class MeshBeaconModule /** * Reconfigure the radio for beacon TX, or restore to original config if p is NULL. * Returns true if the radio was reconfigured (caller must re-run transmit delay for CCA). - * Driven by broadcast_on_preset / broadcast_on_channel from MeshBeaconConfig. + * Driven by the broadcast_targets entry associated with the packet. */ static bool reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p); @@ -55,13 +56,13 @@ class MeshBeaconModule /** * Returns true if the sidecar table contains an entry for this packet's ID. - * Used by RadioLibInterface to gate the channel-active check. + * Used via MeshBeaconTxHook to keep the driver from listening while a beacon is queued. */ static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p); /** * Remove the sidecar entry for this packet after it has been sent. - * Called from RadioLibInterface::completeSending(). + * Called via MeshBeaconTxHook once the driver is done with the packet. */ static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); @@ -76,7 +77,7 @@ class MeshBeaconModule protected: /** * Build the ChannelSettings the beacon transmits on: the base (primary) channel overlaid with - * any broadcast_on_channel overrides, defaulting an empty name to the target preset's display + * the target's channel-table slot, defaulting an empty name to the target preset's display * name. Shared by the encrypt-time channel swap and the radio-thread RF swap so the channel * key + hash are identical at both points. */ @@ -90,6 +91,20 @@ class MeshBeaconModule static meshtastic_ChannelSettings originalPrimaryChannel; }; +/** + * Carries the beacon's radio switching into the radio driver's TX lifecycle, so the driver holds no + * beacon-specific code. One instance is created with the beacon modules and registers itself. + */ +class MeshBeaconTxHook : public RadioTxHook +{ + public: + PreTxAction beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p) override; + bool holdsRadio(const meshtastic_MeshPacket *p) override; + void packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p) override; +}; + +extern MeshBeaconTxHook *meshBeaconTxHook; + /** * Broadcaster: periodically sends MeshBeacon packets on the configured preset/channel. * Active only when the FLAG_BROADCAST_ENABLED bit is set in moduleConfig.mesh_beacon.flags. diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index a2de555e90..1c32a0ab2e 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -34,6 +34,11 @@ #if !MESHTASTIC_EXCLUDE_BEACON #include "modules/MeshBeaconModule.h" #endif +// Generated by bin/optional-modules.py from whatever sits in src/modules/optional/. Absent from a +// stock checkout, and empty unless a module has been dropped in. +#if __has_include("OptionalModules.h") +#include "OptionalModules.h" +#endif #if !MESHTASTIC_EXCLUDE_GPS #include "modules/PositionModule.h" #endif @@ -55,6 +60,7 @@ #include "modules/TraceRouteModule.h" #endif #if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "modules/GeofenceModule.h" #include "modules/WaypointModule.h" #endif #if ARCH_PORTDUINO @@ -152,12 +158,14 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_BEACON meshBeaconBroadcastModule = new MeshBeaconBroadcastModule(); meshBeaconListenerModule = new MeshBeaconListenerModule(); + meshBeaconTxHook = new MeshBeaconTxHook(); // registers itself with the radio driver's TX hooks #endif #if !MESHTASTIC_EXCLUDE_GPS positionModule = new PositionModule(); #endif #if !MESHTASTIC_EXCLUDE_WAYPOINT waypointModule = new WaypointModule(); + geofenceModule = new GeofenceModule(); #endif #if !MESHTASTIC_EXCLUDE_TEXTMESSAGE textMessageModule = new TextMessageModule(); @@ -280,6 +288,11 @@ void setupModules() #endif #if defined(HAS_HARDWARE_WATCHDOG) watchdogThread = new WatchdogThread(); +#endif + // Anything dropped into src/modules/optional/. Undefined, so compiled away, unless a module is + // actually present. +#ifdef OPTIONAL_MODULES_SETUP + OPTIONAL_MODULES_SETUP(); #endif // NOTE! This module must be added LAST because it likes to check for replies from other modules and avoid sending extra // acks diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp index a05b09b0fa..c73293ff46 100644 --- a/src/modules/NeighborInfoModule.cpp +++ b/src/modules/NeighborInfoModule.cpp @@ -2,6 +2,7 @@ #include "Default.h" #include "MeshService.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include @@ -151,7 +152,7 @@ meshtastic_MeshPacket *NeighborInfoModule::allocReply() meshtastic_MeshPacket *reply = allocDataProtobuf(neighborInfo); if (reply) { - lastSentReply = millis(); // Track when we sent this reply + lastSentReply = Time::skipZero(Time::getMillis()); // Track when we sent this reply } return reply; } diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 7c4096959c..57a7540c47 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -95,7 +95,7 @@ void NodeInfoModule::alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes), &meshtastic_User_msg, p); } -void NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t channel, bool _shorterTimeout) +bool NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t channel, bool _shorterTimeout) { // cancel any not yet sent (now stale) position packets if (prevPacketId) // if we wrap around to zero, we'll simply fail to cancel in that rare case (no big deal) @@ -125,7 +125,9 @@ void NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t cha service->sendToMesh(p); shorterTimeout = false; + return true; } + return false; } void NodeInfoModule::triggerImmediateNodeInfoCheck() @@ -225,13 +227,12 @@ NodeInfoModule::NodeInfoModule() int32_t NodeInfoModule::runOnce() { - // If we changed channels, ask everyone else for their latest info - bool requestReplies = currentGeneration != radioGeneration; - currentGeneration = radioGeneration; - if (airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { + // If we changed channels, ask everyone else for their latest info + bool requestReplies = currentGeneration != radioGeneration; LOG_INFO("Send our nodeinfo to mesh (wantReplies=%d)", requestReplies); - sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies) + if (sendOurNodeInfo(NODENUM_BROADCAST, requestReplies)) + currentGeneration = radioGeneration; // only a send that went out consumes the channel change } return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); } diff --git a/src/modules/NodeInfoModule.h b/src/modules/NodeInfoModule.h index 8653c71ebc..6cdb8caa74 100644 --- a/src/modules/NodeInfoModule.h +++ b/src/modules/NodeInfoModule.h @@ -19,9 +19,9 @@ class NodeInfoModule : public ProtobufModule, private concurren NodeInfoModule(); /** - * Send our NodeInfo into the mesh + * Send our NodeInfo into the mesh. True only when a packet was handed to the router. */ - void sendOurNodeInfo(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false, uint8_t channel = 0, + bool sendOurNodeInfo(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false, uint8_t channel = 0, bool _shorterTimeout = false); /** diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index f11839bd75..68a7ccdf66 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -2,12 +2,14 @@ #include "PositionModule.h" #include "Default.h" #include "GPS.h" +#include "GeofenceModule.h" #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" #include "Router.h" #include "TransmitHistory.h" #include "TypeConversions.h" +#include "UptimeClock.h" #include "airtime.h" #include "configuration.h" #include "gps/GPSLog.h" @@ -33,6 +35,7 @@ PositionModule::PositionModule() if (transmitHistory) { uint32_t restored = transmitHistory->getLastSentToMeshMillis(meshtastic_PortNum_POSITION_APP); if (restored != 0) { + // unset-sentinel-ok: the enclosing restored != 0 already rules out the unset value lastGpsSend = restored; LOG_INFO("Position: restored lastGpsSend from transmit history"); } @@ -55,6 +58,7 @@ PositionModule::PositionModule() bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_Position *pptr) { auto p = *pptr; + const NodeNum sender = getFrom(&mp); const auto transport = mp.transport_mechanism; if (isFromUs(&mp) && !IS_ONE_OF(transport, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_INTERNAL, @@ -90,7 +94,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes // Log packet size and data fields LOG_TRACE("POSITION node=0x%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d " "time=%d", - getFrom(&mp), mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae, + sender, mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae, p.altitude_geoidal_separation, p.PDOP, p.HDOP, p.VDOP, p.sats_in_view, p.fix_quality, p.fix_type, p.timestamp, p.time); @@ -107,9 +111,14 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes trySetRtc(p, isLocal, force); } - nodeDB->updatePosition(getFrom(&mp), p); + nodeDB->updatePosition(sender, p); precision = getPositionPrecisionForChannel(mp.channel); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + if (geofenceModule && !isLocal) + geofenceModule->evaluatePosition(sender, p); +#endif + return false; // Let others look at this message also if they want } @@ -284,7 +293,7 @@ meshtastic_MeshPacket *PositionModule::allocReply() meshtastic_MeshPacket *reply = allocPositionPacket(precision); if (reply) { - lastSentReply = millis(); // Track when we sent this reply + lastSentReply = Time::skipZero(Time::getMillis()); // Track when we sent this reply } return reply; } @@ -389,19 +398,21 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli() return mp; } -void PositionModule::sendOurPosition() +bool PositionModule::sendOurPosition() { bool requestReplies = currentGeneration != radioGeneration; - currentGeneration = radioGeneration; // If we changed channels, ask everyone else for their latest info uint8_t positionChannel; if (findPositionChannel(positionChannel)) { LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); - sendOurPosition(NODENUM_BROADCAST, requestReplies, positionChannel); - return; + if (!sendOurPosition(NODENUM_BROADCAST, requestReplies, positionChannel)) + return false; + currentGeneration = radioGeneration; // only a send that went out consumes the channel change + return true; } LOG_INFO("Skip pos@%x:6 broadcast; position sharing disabled on all channels", localPosition.timestamp); + return false; } // Position broadcasts are opt-in per channel in 2.8, but our own position still plays to the @@ -424,11 +435,11 @@ bool PositionModule::sendOurPositionToPhone() return true; } -void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t channel) +bool PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t channel) { if (!config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot()) { LOG_DEBUG("Skip position send; no fresh position since boot"); - return; + return false; } // cancel any not yet sent (now stale) position packets @@ -441,7 +452,7 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha meshtastic_MeshPacket *p = allocPositionPacket(precision); if (p == nullptr) { LOG_DEBUG("allocPositionPacket returned a nullptr"); - return; + return false; } p->to = dest; @@ -456,7 +467,12 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha if (channel > 0) p->channel = channel; - service->sendToMesh(p, RX_SRC_LOCAL, true); + // Rejected (full TX queue, duty cycle abort) means released unsent: never stamp the cadence. + ErrorCode res = service->sendToMesh(p, RX_SRC_LOCAL, true); + if (res != ERRNO_OK && res != ERRNO_SHOULD_RELEASE) { + LOG_WARN("Position send rejected by router: 0x%x", res); + return false; + } if (IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_TAK_TRACKER) && @@ -474,6 +490,8 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha LOG_DEBUG("Start next execution in 5s, then sleep"); setIntervalFromNow(FIVE_SECONDS_MS); } + + return true; } #define RUNONCE_INTERVAL 5000; @@ -533,7 +551,7 @@ int32_t PositionModule::runOnce() if (node == nullptr) return RUNONCE_INTERVAL; - uint32_t now = millis(); + uint32_t now = Time::stampMillis(); // Local-only delivery, so it runs regardless of mesh opt-in state or channel utilization. // Only send while the queue is empty (phone assumed connected), like telemetry. The cadence @@ -556,9 +574,7 @@ int32_t PositionModule::runOnce() return RUNONCE_INTERVAL; } - bool waitingForFreshPosition = (lastGpsSend == 0) && !config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot(); - - // Hold to the 12h floor when fixed_position (every role: pinning yourself forfeits the + // Hold to the 6h floor when fixed_position (every role: pinning yourself forfeits the // exception) or when stationary. A real move still goes out early via smart-broadcast below. // Not-fixed exceptions: lost-and-found broadcasts freely; trackers judge movement at their // own (unclamped) precision rather than the on-wire one (useConfiguredPrecision). @@ -575,9 +591,7 @@ int32_t PositionModule::runOnce() effectiveBroadcastIntervalMs(intervalMs, stationary, (uint32_t)default_position_stationary_broadcast_secs * 1000UL); if (lastGpsSend == 0 || msSinceLastSend >= effectiveIntervalMs) { - if (waitingForFreshPosition) { - LOG_DEBUG_GPS("Skip initial position send; no fresh position since boot"); - } else if (nodeDB->hasValidPosition(node)) { + if (nodeDB->hasValidPosition(node) && sendOurPosition()) { lastGpsSend = now; meshtastic_PositionLite selfPos; @@ -588,7 +602,6 @@ int32_t PositionModule::runOnce() if (transmitHistory) transmitHistory->setLastSentToMesh(meshtastic_PortNum_POSITION_APP); - sendOurPosition(); if (config.device.role == meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND) { sendLostAndFoundText(); } @@ -597,28 +610,10 @@ int32_t PositionModule::runOnce() const meshtastic_NodeInfoLite *node2 = service->refreshLocalMeshNode(); // should guarantee there is now a position if (nodeDB->hasValidPosition(node2)) { - // The minimum time (in seconds) that would pass before we are able to send a new position packet. - meshtastic_PositionLite selfPos; if (!nodeDB->copyNodePosition(node->num, selfPos)) return RUNONCE_INTERVAL; // Defensive: hasValidPosition should imply this is non-null - auto smartPosition = getDistanceTraveledSinceLastSend(selfPos); - msSinceLastSend = now - lastGpsSend; - - if (smartPosition.hasTraveledOverThreshold && - Throttle::execute( - &lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); }, - []() { LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); })) { - - LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " - "minTimeInterval=%ims)", - localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, - msSinceLastSend, minimumTimeThreshold); - - // Set the current coords as our last ones, after we've compared distance with current and decided to send - lastGpsLatitude = selfPos.latitude_i; - lastGpsLongitude = selfPos.longitude_i; - } + trySmartBroadcast(selfPos, now); } } @@ -700,6 +695,32 @@ struct SmartPosition PositionModule::getDistanceTraveledSinceLastSend(meshtastic .hasTraveledOverThreshold = distanceTraveled >= distanceTravelThreshold}; } +void PositionModule::trySmartBroadcast(const meshtastic_PositionLite &selfPos, uint32_t nowMs) +{ + auto smartPosition = getDistanceTraveledSinceLastSend(selfPos); + if (!smartPosition.hasTraveledOverThreshold) + return; + + if (!Throttle::hasElapsed(lastGpsSend, minimumTimeThreshold)) { + LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); + return; + } + + uint32_t msSinceLastSend = nowMs - lastGpsSend; + if (!sendOurPosition()) + return; + + lastGpsSend = Time::skipZero(nowMs); // nowMs is a parameter, so guard at the store as well + if (transmitHistory) + transmitHistory->setLastSentToMesh(meshtastic_PortNum_POSITION_APP); + LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " + "minTimeInterval=%ims)", + localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, msSinceLastSend, + minimumTimeThreshold); + lastGpsLatitude = selfPos.latitude_i; + lastGpsLongitude = selfPos.longitude_i; +} + void PositionModule::handleNewPosition() { const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); @@ -709,21 +730,7 @@ void PositionModule::handleNewPosition() meshtastic_PositionLite selfPos; if (!nodeDB->copyNodePosition(node->num, selfPos)) return; - auto smartPosition = getDistanceTraveledSinceLastSend(selfPos); - uint32_t msSinceLastSend = millis() - lastGpsSend; - if (smartPosition.hasTraveledOverThreshold && - Throttle::execute( - &lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); }, - []() { LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); })) { - LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " - "minTimeInterval=%ims)", - localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, msSinceLastSend, - minimumTimeThreshold); - - // Set the current coords as our last ones, after we've compared distance with current and decided to send - lastGpsLatitude = selfPos.latitude_i; - lastGpsLongitude = selfPos.longitude_i; - } + trySmartBroadcast(selfPos, Time::stampMillis()); } } diff --git a/src/modules/PositionModule.h b/src/modules/PositionModule.h index c5a3d47add..74d486f8df 100644 --- a/src/modules/PositionModule.h +++ b/src/modules/PositionModule.h @@ -31,10 +31,10 @@ class PositionModule : public ProtobufModule, private concu PositionModule(); /** - * Send our position into the mesh + * Send our position into the mesh. True only when the router took the packet. */ - void sendOurPosition(NodeNum dest, bool wantReplies = false, uint8_t channel = 0); - void sendOurPosition(); + bool sendOurPosition(NodeNum dest, bool wantReplies = false, uint8_t channel = 0); + bool sendOurPosition(); /** * Answer a position request that arrived on a channel we never share position on (the event channel): @@ -85,6 +85,9 @@ class PositionModule : public ProtobufModule, private concu uint32_t lastPhoneSendMs = 0; static constexpr uint32_t sendToPhoneIntervalMs = 60 * 1000; // Matches telemetry's local cadence struct SmartPosition getDistanceTraveledSinceLastSend(meshtastic_PositionLite currentPosition); + // Broadcast early when we have moved far enough since the last send, subject to the minimum + // interval. Stamps the cadence and the last-sent coords only on a send that went out. + void trySmartBroadcast(const meshtastic_PositionLite &selfPos, uint32_t nowMs); // True when our position is unchanged since the last broadcast: it truncates to the same // precision grid cell, so re-sending would be a duplicate that traffic management dedups // downstream anyway. Used to hold stationary broadcasts to a 12h floor. useConfiguredPrecision diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index 46475a3ae2..5510ec0d67 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -310,9 +310,10 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) fileToAppend.printf("%d,", mp.hop_limit); // Packet Hop Limit // TODO: If quotes are found in the payload, it has to be escaped. - fileToAppend.printf("\"%.*s\"\n", (int)p.payload.size, p.payload.bytes); - fileToAppend.printf("%i,", mp.rx_rssi); // RX RSSI + fileToAppend.printf("\"%.*s\",", (int)p.payload.size, p.payload.bytes); + fileToAppend.printf("%i", mp.rx_rssi); // RX RSSI + fileToAppend.printf("\n"); fileToAppend.flush(); fileToAppend.close(); diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index 3d0812e5ea..1ed9b0fe09 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -48,9 +48,9 @@ meshtastic_MeshPacket *RoutingModule::allocReply() } void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit, - bool ackWantsAck) + bool ackWantsAck, const meshtastic_MeshPacket *relaySource) { - auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit); + auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit, relaySource); if (!p) return; @@ -81,9 +81,9 @@ uint8_t RoutingModule::getHopLimitForResponse(const meshtastic_MeshPacket &mp) } meshtastic_MeshPacket *RoutingModule::allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, - uint8_t hopLimit) + uint8_t hopLimit, const meshtastic_MeshPacket *relaySource) { - return MeshModule::allocAckNak(err, to, idFrom, chIndex, hopLimit); + return MeshModule::allocAckNak(err, to, idFrom, chIndex, hopLimit, relaySource); } RoutingModule::RoutingModule() : ProtobufModule("routing", meshtastic_PortNum_ROUTING_APP, &meshtastic_Routing_msg) diff --git a/src/modules/RoutingModule.h b/src/modules/RoutingModule.h index 2ac42f447c..bd4dc8a2a0 100644 --- a/src/modules/RoutingModule.h +++ b/src/modules/RoutingModule.h @@ -14,10 +14,10 @@ class RoutingModule : public ProtobufModule RoutingModule(); virtual void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false); + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr); meshtastic_MeshPacket *allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, - uint8_t hopLimit = 0); + uint8_t hopLimit = 0, const meshtastic_MeshPacket *relaySource = nullptr); // Given the hopStart and hopLimit upon reception of a request, return the hop limit to use for the response uint8_t getHopLimitForResponse(const meshtastic_MeshPacket &mp); diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index ef26bc360e..3ca69169b0 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -408,17 +408,14 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp HAS_GPS) { // Decode the Payload some more meshtastic_Position scratch; - meshtastic_Position *decoded = NULL; if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.decoded.portnum == ourPortNum) { memset(&scratch, 0, sizeof(scratch)); + // A payload that fails to decode leaves nothing to report, so say nothing. if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Position_msg, &scratch)) { - decoded = &scratch; - } - // send position packet as WPL to the serial port - { - meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp)); + // send position packet as WPL to the serial port + const meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp)); const char *senderName = senderNode ? senderNode->long_name : ""; - printWPL(outbuf, sizeof(outbuf), *decoded, senderName, + printWPL(outbuf, sizeof(outbuf), scratch, senderName, moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO); serialPrint->printf("%s", outbuf); } @@ -666,6 +663,7 @@ void SerialModule::processWXSerial() if (dirAvg < 0) { dirAvg += 360.0; } + // unset-sentinel-ok: gotwind carries the armed state; no read tests this for 0 lastAveraged = millis(); // make a telemetry packet with the data diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index ec0011f4cf..f8e14f87ec 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -45,6 +45,7 @@ int32_t StoreForwardModule::runOnce() } } else if (this->heartbeat && (!Throttle::isWithinTimespanMs(lastHeartbeat, heartbeatInterval * 1000)) && airTime->isTxAllowedChannelUtil(true)) { + // unset-sentinel-ok: the heartbeat bool gates it and the only read is elapsed math lastHeartbeat = millis(); LOG_INFO("Send heartbeat"); meshtastic_StoreAndForward sf = meshtastic_StoreAndForward_init_zero; @@ -535,6 +536,7 @@ bool StoreForwardModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, if (p->which_variant == meshtastic_StoreAndForward_heartbeat_tag) { heartbeatInterval = p->variant.heartbeat.period; } + // unset-sentinel-ok: the heartbeat bool gates it and the only read is elapsed math lastHeartbeat = millis(); LOG_INFO("StoreAndForward Heartbeat received"); } diff --git a/src/modules/SystemCommandsModule.cpp b/src/modules/SystemCommandsModule.cpp index 1b31942903..eca8e9e500 100644 --- a/src/modules/SystemCommandsModule.cpp +++ b/src/modules/SystemCommandsModule.cpp @@ -12,6 +12,7 @@ #include "MeshService.h" #include "Module.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "main.h" #include "modules/AdminModule.h" #include "modules/ExternalNotificationModule.h" @@ -59,10 +60,10 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) if (!config.bluetooth.enabled) { disableBluetooth(); IF_SCREEN(screen->showSimpleBanner("Bluetooth OFF\nRebooting", 3000)); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 2000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 2000); } else { IF_SCREEN(screen->showSimpleBanner("Bluetooth ON\nRebooting", 3000)); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } #else if (!config.bluetooth.enabled) { @@ -70,7 +71,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) IF_SCREEN(screen->showSimpleBanner("Bluetooth OFF", 3000)); } else { IF_SCREEN(screen->showSimpleBanner("Bluetooth ON\nRebooting", 3000)); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } #endif return 0; @@ -80,7 +81,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) #if HAS_SCREEN messageStore.saveToFlash(); #endif - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); // runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; return true; } @@ -125,7 +126,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) return true; // Power control case INPUT_BROKER_SHUTDOWN: - shutdownAtMsec = millis(); + shutdownAtMsec = Time::skipZero(Time::getMillis()); return true; // factory reset case INPUT_BROKER_FACTORY_RST: @@ -136,7 +137,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) LOG_INFO("Reboot in %d seconds", DEFAULT_REBOOT_SECONDS); if (screen) screen->showSimpleBanner("Rebooting...", 0); // stays on screen - rebootAtMsec = (DEFAULT_REBOOT_SECONDS < 0) ? 0 : (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = (DEFAULT_REBOOT_SECONDS < 0) ? 0 : Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); return true; default: diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index 2eb596bd96..bfb0ab73bb 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -1,4 +1,5 @@ #include "DebugConfiguration.h" +#include "UptimeClock.h" #include "configuration.h" #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR @@ -244,7 +245,7 @@ int32_t AirQualityTelemetryModule::runOnce() } else if (phoneDue && phoneAllowed) { // Mesh transmission isn't due yet, but we can still update the phone. if (sendTelemetry(NODENUM_BROADCAST, true)) { - lastSentToPhone = millis(); + lastSentToPhone = Time::skipZero(Time::getMillis()); // Correct the awake time, trimming to 0 const unsigned long elapsed = millis() - startAirQualityTelemetryCycle; awakeAheadOfTimeMs = elapsed >= awakeAheadOfTimeMs ? 0 : awakeAheadOfTimeMs - elapsed; diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index e3ef3f0950..1b6b074e00 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -41,7 +41,7 @@ int32_t DeviceTelemetryModule::runOnce() sendTelemetry(NODENUM_BROADCAST, true); if (lastSentStatsToPhone == 0 || Throttle::hasElapsed(lastSentStatsToPhone, sendStatsToPhoneIntervalMs)) { sendLocalStatsToPhone(); - lastSentStatsToPhone = Time::getMillis(); + lastSentStatsToPhone = Time::skipZero(Time::getMillis()); } } return sendToPhoneIntervalMs; @@ -131,8 +131,10 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry() telemetry.variant.local_stats.num_online_nodes = numOnlineNodes; telemetry.variant.local_stats.num_total_nodes = nodeDB->getNumMeshNodes(); if (RadioLibInterface::instance) { - RadioLibInterface::instance->updateNoiseFloor(); - telemetry.variant.local_stats.noise_floor = RadioLibInterface::instance->getAverageNoiseFloor(); + // No presence bit: leave zero-init when no valid sample exists instead of publishing + // NOISE_FLOOR_DEFAULT as a real reading. + if (RadioLibInterface::instance->hasNoiseFloorSamples()) + telemetry.variant.local_stats.noise_floor = RadioLibInterface::instance->getAverageNoiseFloor(); telemetry.variant.local_stats.num_packets_tx = RadioLibInterface::instance->txGood; telemetry.variant.local_stats.num_packets_rx = RadioLibInterface::instance->rxGood + RadioLibInterface::instance->rxBad; telemetry.variant.local_stats.num_packets_rx_bad = RadioLibInterface::instance->rxBad; diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index a4143de299..e638a5db53 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR @@ -466,7 +467,7 @@ int32_t EnvironmentTelemetryModule::runOnce() // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + lastSentToPhone = Time::skipZero(Time::getMillis()); } } if (sleepOnNextExecution) { diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index 6ec316e701..10b8d68153 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY && !defined(ARCH_PORTDUINO) @@ -90,7 +91,7 @@ int32_t HealthTelemetryModule::runOnce() // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + lastSentToPhone = Time::skipZero(Time::getMillis()); } } if (sleepOnNextExecution) { diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 60fe00c381..f73fbf8120 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR @@ -105,7 +106,7 @@ int32_t PowerTelemetryModule::runOnce() // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + lastSentToPhone = Time::skipZero(Time::getMillis()); } } if (sleepOnNextExecution) { @@ -165,7 +166,7 @@ void PowerTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *s // Display current and voltage based on ...power_metrics.has_[channel/voltage/current]... flags const auto &m = lastMeasurement.variant.power_metrics; - int lineY = textSecondLine; + int lineY = graphics::getTextPositions(display)[line]; auto drawLine = [&](const char *label, float voltage, float current) { char lineStr[64]; diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.cpp b/src/modules/Telemetry/Sensor/BME680Sensor.cpp index 9162a93212..556feced99 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.cpp +++ b/src/modules/Telemetry/Sensor/BME680Sensor.cpp @@ -86,6 +86,7 @@ void BME680Sensor::captureSample() lastPressureHPa = bme680->pressure / 100.0F; lastGasOhms = (float)bme680->gas_resistance; haveSample = true; + // unset-sentinel-ok: haveSample carries the armed state, so 0 is a legal stamp lastSampleMs = Time::getMillis(); uint16_t iaq; diff --git a/src/modules/Telemetry/Sensor/INA260Sensor.cpp b/src/modules/Telemetry/Sensor/INA260Sensor.cpp index 9d9a99c00b..5062a8028e 100644 --- a/src/modules/Telemetry/Sensor/INA260Sensor.cpp +++ b/src/modules/Telemetry/Sensor/INA260Sensor.cpp @@ -40,4 +40,9 @@ uint16_t INA260Sensor::getBusVoltageMv() return lround(ina260.readBusVoltage()); } +int16_t INA260Sensor::getCurrentMa() +{ + return lround(ina260.readCurrent()); +} + #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/INA260Sensor.h b/src/modules/Telemetry/Sensor/INA260Sensor.h index ea71c24e0c..fcff125148 100644 --- a/src/modules/Telemetry/Sensor/INA260Sensor.h +++ b/src/modules/Telemetry/Sensor/INA260Sensor.h @@ -3,11 +3,12 @@ #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "CurrentSensor.h" #include "TelemetrySensor.h" #include "VoltageSensor.h" #include -class INA260Sensor : public TelemetrySensor, VoltageSensor +class INA260Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor { private: Adafruit_INA260 ina260 = Adafruit_INA260(); @@ -20,6 +21,7 @@ class INA260Sensor : public TelemetrySensor, VoltageSensor virtual int32_t runOnce() override; virtual bool getMetrics(meshtastic_Telemetry *measurement) override; virtual uint16_t getBusVoltageMv() override; + virtual int16_t getCurrentMa() override; }; #endif \ No newline at end of file diff --git a/src/modules/TextMessageModule.cpp b/src/modules/TextMessageModule.cpp index 818e39a948..843311a125 100644 --- a/src/modules/TextMessageModule.cpp +++ b/src/modules/TextMessageModule.cpp @@ -1,4 +1,5 @@ #include "TextMessageModule.h" +#include "Channels.h" #include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" @@ -34,8 +35,10 @@ ProcessMessage TextMessageModule::handleReceived(const meshtastic_MeshPacket &mp auto *display = screen ? screen->getDisplayDevice() : nullptr; graphics::MessageRenderer::handleNewMessage(display, *sm, mp); }) - // Only trigger screen wake if configuration allows it - if (shouldWakeOnReceivedMessage()) { + // Only trigger screen wake if configuration allows it and the channel/sender isn't muted. + // An alert breaks through the mute: in COLOR display mode handleNewMessage() above never runs, + // so this trigger is the only wake an alert would get. + if (shouldWakeOnReceivedMessage() && (!isMutedForPacket(mp) || MeshService::isAlertPayload(mp))) { powerFSM.trigger(EVENT_RECEIVED_MSG); } diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index 310cf4bc1b..4549f19df9 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -1,6 +1,7 @@ #include "TraceRouteModule.h" #include "MeshService.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "graphics/Screen.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" @@ -534,7 +535,7 @@ const char *TraceRouteModule::getNodeName(NodeNum node) bool TraceRouteModule::startTraceRoute(NodeNum node) { LOG_INFO("TraceRoute startTraceRoute: node=0x%08x", node); - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); if (node == 0 || node == NODENUM_BROADCAST) { LOG_ERROR("Invalid trace route node: 0x%08x", node); @@ -700,7 +701,7 @@ void TraceRouteModule::launch(NodeNum node) LOG_INFO("TraceRoute first init"); } - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); if (initialized && lastTraceRouteTime > 0 && now - lastTraceRouteTime < cooldownMs) { unsigned long wait = (cooldownMs - (now - lastTraceRouteTime)) / 1000; bannerText = String("Wait for ") + String(wait) + String("s"); @@ -842,7 +843,7 @@ void TraceRouteModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state #endif // HAS_SCREEN int32_t TraceRouteModule::runOnce() { - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); if (runState == TRACEROUTE_STATE_IDLE) { return INT32_MAX; diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 0fdd8c7222..f0892e4590 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -1,4 +1,5 @@ #include "TrafficManagementModule.h" +#include "UptimeClock.h" #if HAS_TRAFFIC_MANAGEMENT @@ -1362,7 +1363,7 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const int32_t lat_truncated = truncateCoordinate(pos->latitude_i, precision); const int32_t lon_truncated = truncateCoordinate(pos->longitude_i, precision); const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); - // Drop gate uses the RAW configured interval: 0 means "dedup disabled". The 12 h default + // Drop gate uses the RAW configured interval: 0 means "dedup disabled". The 5 h default // is only for TTL sizing - feeding it here would silently defeat that contract. uint32_t minIntervalMs = secsToMs(moduleConfig.traffic_management.position_min_interval_secs); @@ -1406,12 +1407,17 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, TM_LOG_TRACE("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, entry->pos_fingerprint, samePosition, withinInterval, isNew); - // Update cache entry (raw tick; 0 is a valid tick value) - entry->pos_fingerprint = fingerprint; - entry->pos_time = nowPosTick; - // Drop only if same position AND within the minimum interval - return samePosition && withinInterval; + const bool drop = samePosition && withinInterval; + + // Stamp only what we let through: re-stamping a dropped duplicate slides the window forward on + // every repeat, muting a node that broadcasts faster than the window instead of refreshing it. + if (!drop) { + entry->pos_fingerprint = fingerprint; + entry->pos_time = nowPosTick; + } + + return drop; #endif } @@ -1518,7 +1524,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // request declined above never spends the budget). false forwards the request instead of consuming // it. Rationale in https://meshtastic.org/docs/development/reference/traffic-management-internals "Throttling direct // responses". - if (!directResponseAllowed(getFrom(p), p->to, clockMs())) { + if (!directResponseAllowed(getFrom(p), p->to, Time::skipZero(clockMs()))) { TM_LOG_DEBUG("NodeInfo direct response throttled for 0x%08x; forwarding request", getFrom(p)); return false; } @@ -1622,7 +1628,7 @@ bool TrafficManagementModule::directResponseAllowed(NodeNum requester, NodeNum t reqSlot->lastReplyMs = nowMs; tgtSlot->key = target; tgtSlot->lastReplyMs = nowMs; - lastDirectResponseMs = nowMs; + lastDirectResponseMs = Time::skipZero(nowMs); // a parameter, so guard at the store as well return true; } diff --git a/src/modules/WaypointModule.cpp b/src/modules/WaypointModule.cpp index 9b41a9f5a7..8f31294843 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -1,221 +1,409 @@ #include "WaypointModule.h" #include "NodeDB.h" #include "PowerFSM.h" +#include "WaypointUtils.h" #include "configuration.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/CompassRenderer.h" #include "meshUtils.h" +#include +#include +#include +#include -#if HAS_SCREEN +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "ExternalNotificationModule.h" +#include "MeshService.h" +#include "WaypointStore.h" +#include "mesh/Router.h" +#include +#endif + +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT #include "gps/RTC.h" #include "graphics/Screen.h" #include "graphics/TimeFormatters.h" #include "graphics/draw/NodeListRenderer.h" +#include "graphics/draw/UIRenderer.h" #include "main.h" #endif WaypointModule *waypointModule; +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT +namespace +{ + +constexpr int16_t WAYPOINT_ROW_GAP = 2; + +void drawFallbackWaypointIcon(OLEDDisplay *display, int16_t left, int16_t top, uint16_t boxSize) +{ + const int16_t cx = left + (boxSize / 2); + const int16_t circleY = top + std::max(2, boxSize / 3); + const int16_t r = std::max(1, boxSize / 4); + display->drawCircle(cx, circleY, r); + display->drawLine(cx, circleY + r, cx, top + boxSize - 2); + display->setPixel(cx - 1, top + boxSize - 2); + display->setPixel(cx + 1, top + boxSize - 2); +} + +void drawWaypointIcon(OLEDDisplay *display, const meshtastic_Waypoint &wp, int16_t left, int16_t top, uint16_t boxSize) +{ + if (!wp.icon) { + drawFallbackWaypointIcon(display, left, top, boxSize); + return; + } + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(wp.icon); + if (utf8.empty()) { + drawFallbackWaypointIcon(display, left, top, boxSize); + return; + } + + graphics::UIRenderer::drawStringWithEmotes(display, left, top, utf8, FONT_HEIGHT_SMALL, 1, false); +} + +void formatWaypointDistance(char *out, size_t outSize, float meters) +{ + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + const float feet = meters * METERS_TO_FEET; + snprintf(out, outSize, feet < (2 * MILES_TO_FEET) ? "%.0fft" : "%.1fmi", + feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET); + } else { + snprintf(out, outSize, meters < 2000 ? "%.0fm" : "%.1fkm", meters < 2000 ? meters : meters / 1000); + } +} + +void formatWaypointCoordinates(char *out, size_t outSize, const meshtastic_Waypoint &wp) +{ + if (!(wp.has_latitude_i && wp.has_longitude_i)) { + snprintf(out, outSize, "--"); + return; + } + + snprintf(out, outSize, "%.4f,%.4f", wp.latitude_i * 1e-7, wp.longitude_i * 1e-7); +} + +void formatWaypointExpire(char *out, size_t outSize, const meshtastic_Waypoint &wp) +{ + if (wp.expire == 0) { + out[0] = '\0'; + return; + } + + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now == 0) { + out[0] = '\0'; + return; + } + if (wp.expire <= now) { + snprintf(out, outSize, "0m"); + return; + } + + const uint32_t left = wp.expire - now; + if (left < 3600) + snprintf(out, outSize, "%lum", (unsigned long)((left + 59) / 60)); + else if (left < 86400) + snprintf(out, outSize, "%luh", (unsigned long)((left + 3599) / 3600)); + else + snprintf(out, outSize, "%lud", (unsigned long)((left + 86399) / 86400)); +} + +std::string trimmedWaypointText(const char *text) +{ + if (!text) + return ""; + + std::string value(text); + const auto first = std::find_if(value.begin(), value.end(), [](unsigned char c) { return !std::isspace(c); }); + if (first == value.end()) + return ""; + + const auto last = std::find_if(value.rbegin(), value.rend(), [](unsigned char c) { return !std::isspace(c); }).base(); + return std::string(first, last); +} + +size_t collectDrawableWaypoints(const StoredWaypoint *entries[], size_t maxEntries) +{ + size_t count = 0; + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (count >= maxEntries) + break; + entries[count] = &entry; + ++count; + } + + return count; +} + +void drawDottedHorizontalDivider(OLEDDisplay *display, int16_t xStart, int16_t xEnd, int16_t y) +{ + for (int16_t x = xStart; x <= xEnd; x += 2) { + display->setPixel(x, y); + } +} + +void notifyWaypointReceived(const StoredWaypoint &stored) +{ + if (screen) { + const std::string waypointName = trimmedWaypointText(stored.waypoint.name); + if (!waypointName.empty()) { + char banner[96]; + snprintf(banner, sizeof(banner), "New Waypoint\n%s", waypointName.c_str()); + screen->showSimpleBanner(banner, 3000); + } else { + screen->showSimpleBanner("New Waypoint", 3000); + } + } + + if (externalNotificationModule) + externalNotificationModule->startNotification(); +} + +} // namespace +#endif + ProcessMessage WaypointModule::handleReceived(const meshtastic_MeshPacket &mp) { #if defined(DEBUG_PORT) && !defined(DEBUG_MUTE) auto &p = mp.decoded; LOG_INFO("Received waypoint msg from=0x%08x, id=0x%08x, msg=%.*s", mp.from, mp.id, p.payload.size, p.payload.bytes); #endif - // We only store/display messages destined for us. - // Keep a copy of the most recent text message. - devicestate.rx_waypoint = mp; - devicestate.has_rx_waypoint = true; +#if MESHTASTIC_EXCLUDE_WAYPOINT + (void)mp; + return ProcessMessage::CONTINUE; +#else + StoredWaypoint stored; + if (!waypointStore.addFromPacket(mp, isFromUs(&mp), &stored)) + return ProcessMessage::CONTINUE; powerFSM.trigger(EVENT_RECEIVED_MSG); #if HAS_SCREEN + if (!isFromUs(&mp) && !WaypointStore::isExpired(stored)) + notifyWaypointReceived(stored); UIFrameEvent e; - - // New or updated waypoint: focus on this frame next time Screen::setFrames runs - if (shouldDraw()) { - requestFocus(); - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; - } - - // Deleting an old waypoint: remove the frame quietly, don't change frame position if possible - else - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; + // Refresh the waypoint frame list quietly; new waypoints alert via banner/sound but do not + // steal focus from the screen the user is already on. + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; notifyObservers(&e); #endif return ProcessMessage::CONTINUE; // Let others look at this message also if they want +#endif } +#if !MESHTASTIC_EXCLUDE_WAYPOINT +bool WaypointModule::broadcastDelete(uint32_t waypointId) +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + bool found = false; + for (const auto &entry : waypointStore.getWaypoints()) { + if (entry.waypoint.id == waypointId) { + wp = entry.waypoint; + found = true; + break; + } + } + if (!found) + return false; + + // Respect the waypoint's lock: we may remove a locked waypoint from our own device, but + // we're not the owner, so we have no authority to delete it mesh-wide. + const NodeNum localNodeNum = nodeDB ? nodeDB->getNodeNum() : 0; + if (wp.locked_to != 0 && wp.locked_to != localNodeNum) { + LOG_INFO("Waypoint 0x%08x is locked to 0x%08x; removing locally only", waypointId, wp.locked_to); + waypointStore.removeWaypoint(waypointId); + return true; + } + + // Already-expired = the mesh convention for "delete this waypoint". + wp.expire = 1; + + if (!service) + return false; + + meshtastic_MeshPacket *p = router ? router->allocForSending() : nullptr; + if (!p) + return false; + + p->decoded.portnum = meshtastic_PortNum_WAYPOINT_APP; + p->decoded.payload.size = + pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Waypoint_msg, &wp); + if (p->decoded.payload.size == 0) { + packetPool.release(p); + return false; + } + + service->sendToMesh(p, RX_SRC_USER); + + waypointStore.removeWaypoint(waypointId); + + return true; +} +#endif + #if HAS_SCREEN bool WaypointModule::shouldDraw() { #if !MESHTASTIC_EXCLUDE_WAYPOINT - if (!screen || !devicestate.has_rx_waypoint) + if (!screen || waypointStore.getWaypoints().empty()) return false; - meshtastic_Waypoint wp{}; // <- replaces memset - if (pb_decode_from_bytes(devicestate.rx_waypoint.decoded.payload.bytes, devicestate.rx_waypoint.decoded.payload.size, - &meshtastic_Waypoint_msg, &wp)) { - return wp.expire > getTime(); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (!WaypointStore::isExpired(entry)) + return true; } - return false; // no LOG_ERROR, no flag writes + return false; #else return false; #endif } -/// Draw the last waypoint we received -void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +void WaypointModule::onDeviceTimeChanged() { +#if !MESHTASTIC_EXCLUDE_WAYPOINT if (!screen) return; + + // Refresh only; never steal focus. + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; + notifyObservers(&e); +#endif +} + +/// Draw the newest non-expired waypoints we received +void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + (void)state; +#if MESHTASTIC_EXCLUDE_WAYPOINT + (void)display; + (void)x; + (void)y; + return; +#else + if (!screen) + return; + display->clear(); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - int line = 1; + const StoredWaypoint *entries[WAYPOINT_HISTORY_LIMIT]; + const size_t totalWaypoints = collectDrawableWaypoints(entries, WAYPOINT_HISTORY_LIMIT); + if (totalWaypoints == 0) + return; - // === Set Title - const char *titleStr = "Waypoint"; - - // === Header === + const char *titleStr = (totalWaypoints == 1) ? "Waypoint" : "Waypoints"; graphics::drawCommonHeader(display, x, y, titleStr); const int *textPos = graphics::getTextPositions(display); - // Decode the waypoint - const meshtastic_MeshPacket &mp = devicestate.rx_waypoint; - meshtastic_Waypoint wp{}; - if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Waypoint_msg, &wp)) { - devicestate.has_rx_waypoint = false; - return; - } - - // Sanitize before these reach the OLED renderer (defense-in-depth vs PB_VALIDATE_UTF8). - sanitizeUtf8(wp.name, sizeof(wp.name)); - sanitizeUtf8(wp.description, sizeof(wp.description)); - - // Get timestamp info. Will pass as a field to drawColumns - char lastStr[20]; - getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr)); - - // Will contain distance information, passed as a field to drawColumns - char distStr[20] = ""; - - // Get our node, to use our own position const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - - // Match compass sizing/placement to favorite node screen logic. - const int w = display->getWidth(); - int16_t compassRadius = 8; - int16_t compassX = x + w - compassRadius - 8; - int16_t compassY = y + display->getHeight() / 2; - - if (SCREEN_WIDTH > SCREEN_HEIGHT) { - const int16_t topY = textPos[1]; - const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); - const int16_t usableHeight = bottomY - topY - 5; - compassRadius = usableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - compassX = x + SCREEN_WIDTH - compassRadius - 8; - compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - } else { - // Waypoint content uses rows 1..4, so place the compass below that block. - const int yBelowContent = textPos[4] + FONT_HEIGHT_SMALL + 2; - const int margin = 4; -#if defined(USE_EINK) - const int iconSize = (graphics::currentResolution == graphics::ScreenResolution::High) ? 16 : 8; - const int navBarHeight = iconSize + 6; -#else - const int navBarHeight = 0; -#endif - const int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; - if (availableHeight > 0) { - compassRadius = availableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - if (compassRadius * 2 > SCREEN_WIDTH - 16) - compassRadius = (SCREEN_WIDTH - 16) / 2; - if (compassRadius < 8) - compassRadius = 8; - compassX = x + SCREEN_WIDTH / 2; - compassY = yBelowContent + availableHeight / 2; - } - } - const uint16_t compassDiam = compassRadius * 2; - const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode)); - const char *statusLine1 = nullptr; - const char *statusLine2 = nullptr; - - // Distance only needs our own position fix; compass/bearing additionally needs heading. - meshtastic_PositionLite ownPos; + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; const bool haveOwnPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ownPos); - if (hasOwnPositionFix && haveOwnPos) { - const meshtastic_PositionLite &op = ownPos; - const float d = - GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); - // Always show distance once we have an own-position fix, even without heading. - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - float feet = d * METERS_TO_FEET; - snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft" : "%.1fmi", - feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET); - } else { - snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm" : "%.1fkm", d < 2000 ? d : d / 1000); - } + const uint16_t iconWidth = FONT_HEIGHT_SMALL; + const uint16_t iconGap = 3; + const uint16_t nameX = iconWidth + iconGap; + const int16_t contentBottom = display->getHeight() - 1; + int16_t rowTop = textPos[1]; + for (size_t i = 0; i < totalWaypoints; ++i) { + const StoredWaypoint &entry = *entries[i]; + const meshtastic_Waypoint &wp = entry.waypoint; + + char safeName[sizeof(wp.name)]; + memcpy(safeName, wp.name, sizeof(safeName)); + safeName[sizeof(safeName) - 1] = '\0'; + sanitizeUtf8(safeName, sizeof(safeName)); + + char safeDescription[sizeof(wp.description)]; + memcpy(safeDescription, wp.description, sizeof(safeDescription)); + safeDescription[sizeof(safeDescription) - 1] = '\0'; + sanitizeUtf8(safeDescription, sizeof(safeDescription)); + + char distStr[20] = ""; + char coordStr[40]; + char expireStr[16]; + formatWaypointCoordinates(coordStr, sizeof(coordStr), wp); + formatWaypointExpire(expireStr, sizeof(expireStr), wp); + + const std::string description = trimmedWaypointText(safeDescription); + const bool hasDescription = !description.empty(); + const int16_t row1Y = rowTop; + const int16_t row2Y = row1Y + FONT_HEIGHT_SMALL + 1; + const int16_t rowMetaY = hasDescription ? (row2Y + FONT_HEIGHT_SMALL + 1) : row2Y; + const int16_t cardBottom = rowMetaY + FONT_HEIGHT_SMALL; + if (cardBottom > contentBottom) + break; + + bool showCompass = false; float myHeading = 0.0f; - const bool hasHeading = - graphics::CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading); - if (hasHeading) { - // Draw compass circle - display->drawCircle(compassX, compassY, compassRadius); - graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + float bearingToOther = 0.0f; + if (hasOwnPositionFix && haveOwnPos && wp.has_latitude_i && wp.has_longitude_i) { + const float d = GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(ownPos.latitude_i), + DegD(ownPos.longitude_i)); + formatWaypointDistance(distStr, sizeof(distStr), d); - // Compass bearing to waypoint - float bearingToOther = - GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(wp.latitude_i), DegD(wp.longitude_i)); - bearingToOther = graphics::CompassRenderer::adjustBearingForCompassMode(bearingToOther, myHeading); - graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); - - const float bearingToOtherDegrees = graphics::CompassRenderer::radiansToDegrees360(bearingToOther); - - // Distance to waypoint with relative bearing when heading is available. - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - float feet = d * METERS_TO_FEET; - snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft %.0f°" : "%.1fmi %.0f°", - feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET, bearingToOtherDegrees); - } else { - snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm %.0f°" : "%.1fkm %.0f°", d < 2000 ? d : d / 1000, - bearingToOtherDegrees); + if (graphics::CompassRenderer::getHeadingRadians(DegD(ownPos.latitude_i), DegD(ownPos.longitude_i), myHeading)) { + showCompass = true; + bearingToOther = GeoCoord::bearing(DegD(ownPos.latitude_i), DegD(ownPos.longitude_i), DegD(wp.latitude_i), + DegD(wp.longitude_i)); + bearingToOther = graphics::CompassRenderer::adjustBearingForCompassMode(bearingToOther, myHeading); } - - } else { - statusLine1 = "No"; - statusLine2 = "Heading"; } - } else { - // No own fix yet, so compass/bearing data would be misleading. - statusLine1 = "No"; - statusLine2 = "Fix"; - } - if (statusLine1) { - display->drawCircle(compassX, compassY, compassRadius); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(compassX, compassY - FONT_HEIGHT_SMALL, statusLine1); - display->drawString(compassX, compassY, statusLine2); - } + const int16_t compactArrowCenterX = display->getWidth() - ((FONT_HEIGHT_SMALL > 10) ? 9 : 7); + const int16_t compactArrowCenterY = (hasDescription ? row2Y : row1Y) + (FONT_HEIGHT_SMALL / 2); + const int16_t compactContentRight = compactArrowCenterX - 8; + const char *distanceLabel = distStr[0] ? distStr : "--"; + const char *expireLabel = expireStr[0] ? expireStr : "--"; + const uint16_t metaWidth = + std::max(display->getStringWidth(distanceLabel), display->getStringWidth(expireLabel)) + 4; + const int16_t metaLeft = std::max(nameX + 16, compactContentRight - metaWidth); + const int16_t textRight = metaLeft - 4; + const uint16_t nameWidth = (textRight > nameX) ? (textRight - nameX) : 0; + const std::string shownName = graphics::UIRenderer::truncateStringWithEmotes(display, safeName, nameWidth); + const std::string shownDescription = + hasDescription ? graphics::UIRenderer::truncateStringWithEmotes(display, description, nameWidth) : std::string(); - display->setTextAlignment(TEXT_ALIGN_LEFT); // Something above me changes to a different alignment, forcing a fix here! - display->drawString(0, textPos[line++], lastStr); - display->drawString(0, textPos[line++], wp.name); - display->drawString(0, textPos[line++], wp.description); - if (distStr[0]) - display->drawString(0, textPos[line++], distStr); + drawWaypointIcon(display, wp, 0, row1Y, iconWidth); + graphics::UIRenderer::drawStringWithEmotes(display, nameX, row1Y, shownName, FONT_HEIGHT_SMALL, 1, false); + const int16_t underlineY = row1Y + FONT_HEIGHT_SMALL; + const int16_t underlineRight = + std::min(textRight, nameX + graphics::UIRenderer::measureStringWithEmotes(display, shownName) - 1); + if (underlineRight >= nameX) + display->drawLine(nameX, underlineY, underlineRight, underlineY); + + if (hasDescription) + graphics::UIRenderer::drawStringWithEmotes(display, nameX, row2Y, shownDescription, FONT_HEIGHT_SMALL, 1, false); + + if (showCompass) + graphics::NodeListRenderer::drawRelativeCompassArrow(display, compactArrowCenterX, compactArrowCenterY, + graphics::CompassRenderer::radiansToDegrees360(bearingToOther)); + + display->drawStringMaxWidth(nameX, rowMetaY, nameWidth, coordStr); + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(metaLeft + metaWidth - 1, row1Y, distanceLabel); + display->drawString(metaLeft + metaWidth - 1, rowMetaY, expireLabel); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const int16_t separatorY = cardBottom + 1; + const int16_t nextRowTop = separatorY + WAYPOINT_ROW_GAP; + if (i + 1 < totalWaypoints && nextRowTop + ((FONT_HEIGHT_SMALL * 2) + 1) <= contentBottom) { + drawDottedHorizontalDivider(display, 0, display->getWidth() - 1, separatorY); + rowTop = nextRowTop; + } else { + break; + } + } +#endif } #endif diff --git a/src/modules/WaypointModule.h b/src/modules/WaypointModule.h index 4c9c7b86b0..fc811c7139 100644 --- a/src/modules/WaypointModule.h +++ b/src/modules/WaypointModule.h @@ -14,6 +14,11 @@ class WaypointModule : public SinglePortModule, public Observable) #define BOSCH_BHI260_KLIO -#define USING_DATA_HELPER +#include "mesh/Throttle.h" #include + +#ifdef BHI260AP_INT +static volatile bool BHI_IRQ = false; +#endif + BHI260APSensor::BHI260APSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} + +void BHI260APSensor::onWristTilt(uint8_t, const uint8_t *, uint32_t, uint64_t *, void *user_data) +{ + static_cast(user_data)->wakeRequested = true; +} // https://github.com/lewisxhe/SensorLib/blob/master/examples/Sensors/IMU/BHI260AP_InterruptSettings/BHI260AP_InterruptSettings.ino bool BHI260APSensor::init() @@ -14,40 +24,40 @@ bool BHI260APSensor::init() sensor.setFirmware(bosch_firmware_image, bosch_firmware_size, bosch_firmware_type); sensor.setBootFromFlash(bosch_firmware_type); if (sensor.begin(Wire, deviceAddress())) { - sensor.setRemapAxes(SensorBHI260AP::TOP_LAYER_BOTTOM_RIGHT_CORNER); + sensor.setRemapAxes(SensorRemap::TOP_LAYER_BOTTOM_RIGHT_CORNER); BoschSensorInfo info = sensor.getSensorInfo(); - LOG_INFO("Product ID : %02x\n", info.product_id); - LOG_INFO("Kernel version : %04u\n", info.kernel_version); - LOG_INFO("User version : %04u\n", info.user_version); - LOG_INFO("ROM version : %04u\n", info.rom_version); - LOG_INFO("Power state : %s\n", (info.host_status & BHY2_HST_POWER_STATE) ? "sleeping" : "active"); - LOG_INFO("Host interface : %s\n", (info.host_status & BHY2_HST_HOST_PROTOCOL) ? "SPI" : "I2C"); - LOG_INFO("Feature status : 0x%02x\n", info.feat_status); + LOG_INFO("Product ID : %02x\n", info.getProductId()); + LOG_INFO("Kernel version : %04u\n", info.getKernelVersion()); + LOG_INFO("User version : %04u\n", info.getUserVersion()); + LOG_INFO("ROM version : %04u\n", info.getRomVersion()); + LOG_INFO("Power state : %s\n", (info.getHostStatus() & BHY2_HST_POWER_STATE) ? "sleeping" : "active"); + LOG_INFO("Host interface : %s\n", (info.getHostStatus() & BHY2_HST_HOST_PROTOCOL) ? "SPI" : "I2C"); + LOG_INFO("Feature status : 0x%02x\n", info.getFeatStatus()); stepCounter = new SensorStepCounter(sensor); // stepDetector = new SensorStepDetector(sensor); // sensor.configAccelerometer(sensor.RANGE_2G, sensor.ODR_100HZ, sensor.BW_NORMAL_AVG4, sensor.PERF_CONTINUOUS_MODE); // sensor.enableAccelerometer(); - // sensor.configInterrupt(); #ifdef BHI260AP_INT + // Defaults: active-high, level-triggered, push-pull, FIFO sources unmasked. + InterruptConfig intConfig; + sensor.configureInterrupt(intConfig); pinMode(BHI260AP_INT, INPUT); attachInterrupt( - BHI260AP_INT, - [] { - // Set interrupt to set irq value to true - }, - RISING); // Select the interrupt mode according to the actual circuit + BHI260AP_INT, [] { BHI_IRQ = true; }, RISING); #endif -#ifdef T_WATCH_S3 - // Need to raise the wrist function, need to set the correct axis - sensor.setRemapAxes(sensor.REMAP_TOP_LAYER_RIGHT_CORNER); -#else - // sensor.setRemapAxes(sensor.REMAP_BOTTOM_LAYER_BOTTOM_LEFT_CORNER); -#endif + // Wrist tilt wakes the screen. Not every firmware image ships it and SensorAnyMotion + // is BHI360-only, so fall back to step counting alone. + constexpr uint8_t wristTilt = static_cast(BoschSensorID::WRIST_TILT_GESTURE); + if (sensor.onResultEvent(wristTilt, onWristTilt, this) && sensor.configure(wristTilt, 1.0f, 0)) { + LOG_DEBUG("BHI260AP wrist tilt wake enabled"); + } else { + LOG_WARN("BHI260AP firmware has no wrist tilt gesture, motion wake unavailable"); + } // stepDetector->enable(1.0, 0); stepCounter->enable(1.0, 0); @@ -60,6 +70,14 @@ bool BHI260APSensor::init() int32_t BHI260APSensor::runOnce() { +#ifdef BHI260AP_INT + // The INT line is the fast path; the keepalive keeps the step counter alive without it. + if (!BHI_IRQ && !Throttle::hasElapsed(lastPollMs, MOTION_SENSOR_IRQ_KEEPALIVE_MS)) + return MOTION_SENSOR_CHECK_INTERVAL_MS; + BHI_IRQ = false; + lastPollMs = millis(); +#endif + sensor.update(); if (stepCounter->hasUpdated()) { steps = stepCounter->getStepCount(); @@ -67,14 +85,16 @@ int32_t BHI260APSensor::runOnce() if (screen) screen->steps = steps; } - // LOG_WARN("Step count: %u", stepCounter->getStepCount()); - // if (sensor.readIrqStatus()) { - // if (sensor.isTilt() || sensor.isDoubleTap()) { - // wakeScreen(); - // return 500; - // } - //} + if (wakeRequested) { + wakeRequested = false; + wakeScreen(); + } +#ifdef BHI260AP_INT + // Tick fast for tilt latency; without the INT line every tick would be an I2C drain. + return MOTION_SENSOR_CHECK_INTERVAL_MS; +#else return 1000; +#endif } -#endif \ No newline at end of file +#endif diff --git a/src/motion/BHI260APSensor.h b/src/motion/BHI260APSensor.h index b0a4064872..ec8163c45e 100644 --- a/src/motion/BHI260APSensor.h +++ b/src/motion/BHI260APSensor.h @@ -15,10 +15,16 @@ class BHI260APSensor : public MotionSensor { private: SensorBHI260AP sensor; - volatile bool BHI_IRQ = false; SensorStepCounter *stepCounter; SensorStepDetector *stepDetector; uint32_t steps = 0; + bool wakeRequested = false; +#ifdef BHI260AP_INT + uint32_t lastPollMs = 0; +#endif + + // Fires from sensor.update() when the fusion hub reports a wrist tilt. + static void onWristTilt(uint8_t sensor_id, const uint8_t *data, uint32_t size, uint64_t *timestamp, void *user_data); public: explicit BHI260APSensor(ScanI2C::FoundDevice foundDevice); diff --git a/src/motion/BMA423Sensor.cpp b/src/motion/BMA423Sensor.cpp index 5111dae325..6271d70caa 100755 --- a/src/motion/BMA423Sensor.cpp +++ b/src/motion/BMA423Sensor.cpp @@ -2,59 +2,76 @@ #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && defined(HAS_BMA423) && __has_include() +#include "mesh/Throttle.h" + +#ifdef BMA4XX_INT +static volatile bool BMA_IRQ = false; +#endif + BMA423Sensor::BMA423Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} bool BMA423Sensor::init() { - if (sensor.begin(Wire, deviceAddress())) { - sensor.configAccelerometer(sensor.RANGE_2G, sensor.ODR_100HZ, sensor.BW_NORMAL_AVG4, sensor.PERF_CONTINUOUS_MODE); - sensor.enableAccelerometer(); - sensor.configInterrupt(); + if (!sensor.begin(Wire, deviceAddress())) { + LOG_DEBUG("BMA423 init failed"); + return false; + } -#ifdef BMA423_INT - pinMode(BMA4XX_INT, INPUT); - attachInterrupt( - BMA4XX_INT, - [] { - // Set interrupt to set irq value to true - BMA_IRQ = true; - }, - RISING); // Select the interrupt mode according to the actual circuit -#endif + if (!sensor.configAccelerometer(OperationMode::NORMAL, AccelFullScaleRange::FS_2G, 100.0f, AccelBandwidth::NORMAL_AVG4, + AccelPerfMode::CONTINUOUS_MODE)) { + LOG_DEBUG("BMA423 accelerometer config failed"); + return false; + } #ifdef T_WATCH_S3 - // Need to raise the wrist function, need to set the correct axis - sensor.setRemapAxes(sensor.REMAP_TOP_LAYER_RIGHT_CORNER); + // Need to raise the wrist function, need to set the correct axis + sensor.setRemapAxes(SensorRemap::TOP_LAYER_RIGHT_CORNER); #else - sensor.setRemapAxes(sensor.REMAP_BOTTOM_LAYER_BOTTOM_LEFT_CORNER); + sensor.setRemapAxes(SensorRemap::BOTTOM_LAYER_BOTTOM_LEFT_CORNER); #endif - // sensor.enableFeature(sensor.FEATURE_STEP_CNTR, true); - sensor.enableFeature(sensor.FEATURE_TILT, true); - sensor.enableFeature(sensor.FEATURE_WAKEUP, true); - // sensor.resetPedometer(); - // Turn on feature interrupt - sensor.enablePedometerIRQ(); - sensor.enableTiltIRQ(); +#ifdef BMA4XX_INT + // enableTiltDetector()/enableTapDetector() only map the feature onto INT1, and the BMA4 + // reset default leaves that pin's output driver off. Arm it push-pull active-high. + if (!sensor.setInterruptPinConfig(InterruptPinMap::PIN1, false, false, true, false)) + LOG_DEBUG("BMA423 INT1 pin config failed, keeping the polled path"); // not fatal +#endif - // It corresponds to isDoubleClick interrupt - sensor.enableWakeupIRQ(); - LOG_DEBUG("BMA423 init ok"); - return true; + // The tap detector defaults to double tap; tilt and double tap both wake the screen. + sensor.setOnTiltDetectedCallback([this] { wakeRequested = true; }); + sensor.setOnTapCallback([this](TapType) { wakeRequested = true; }); + if (!sensor.enableTiltDetector(true, true) || !sensor.enableTapDetector(true, true)) { + LOG_DEBUG("BMA423 wake detector setup failed"); + return false; } - LOG_DEBUG("BMA423 init failed"); - return false; + +#ifdef BMA4XX_INT + pinMode(BMA4XX_INT, INPUT); + attachInterrupt( + BMA4XX_INT, [] { BMA_IRQ = true; }, RISING); +#endif + + LOG_DEBUG("BMA423 init ok"); + return true; } int32_t BMA423Sensor::runOnce() { - if (sensor.readIrqStatus()) { - if (sensor.isTilt() || sensor.isDoubleTap()) { - wakeScreen(); - return 500; - } +#ifdef BMA4XX_INT + // INT1 is the fast path; update() reads and clears the status register and fires the callbacks. + if (!BMA_IRQ && !Throttle::hasElapsed(lastPollMs, MOTION_SENSOR_IRQ_KEEPALIVE_MS)) + return MOTION_SENSOR_CHECK_INTERVAL_MS; + BMA_IRQ = false; + lastPollMs = millis(); +#endif + + wakeRequested = false; + sensor.update(); + if (wakeRequested) { + wakeScreen(); + return 500; } return MOTION_SENSOR_CHECK_INTERVAL_MS; } -#endif \ No newline at end of file +#endif diff --git a/src/motion/BMA423Sensor.h b/src/motion/BMA423Sensor.h index b9d7b4aa0d..512457daf9 100755 --- a/src/motion/BMA423Sensor.h +++ b/src/motion/BMA423Sensor.h @@ -13,7 +13,10 @@ class BMA423Sensor : public MotionSensor { private: SensorBMA423 sensor; - volatile bool BMA_IRQ = false; + bool wakeRequested = false; +#ifdef BMA4XX_INT + uint32_t lastPollMs = 0; +#endif public: explicit BMA423Sensor(ScanI2C::FoundDevice foundDevice); diff --git a/src/motion/ICM20948Sensor.cpp b/src/motion/ICM20948Sensor.cpp index 8238b3dbc9..a05f9aeca7 100644 --- a/src/motion/ICM20948Sensor.cpp +++ b/src/motion/ICM20948Sensor.cpp @@ -2,6 +2,7 @@ #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() #include "detect/ScanI2CTwoWire.h" +#include "mesh/Throttle.h" #if !defined(MESHTASTIC_EXCLUDE_SCREEN) // screen is defined in main.cpp @@ -34,21 +35,6 @@ bool ICM20948Sensor::init() return wakeOnMotionOk; } -#ifdef ICM_20948_INT_PIN - -int32_t ICM20948Sensor::runOnce() -{ - // Wake on motion using hardware interrupts - this is the most efficient way to check for motion - if (ICM20948_IRQ) { - ICM20948_IRQ = false; - sensor->clearInterrupts(); - wakeScreen(); - } - return MOTION_SENSOR_CHECK_INTERVAL_MS; -} - -#else - int32_t ICM20948Sensor::runOnce() { #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN @@ -105,7 +91,21 @@ int32_t ICM20948Sensor::runOnce() screen->setHeading(heading); #endif - // Wake on motion using polling - this is not as efficient as using hardware interrupt pin (see above) +#ifdef ICM_20948_INT_PIN + if (ICM20948_IRQ) { + ICM20948_IRQ = false; + intPinProven = true; + sensor->clearInterrupts(); + wakeScreen(); + return MOTION_SENSOR_CHECK_INTERVAL_MS; + } + // Back off to the keepalive only once the pin has actually fired. No vendor firmware + // uses this line, so an unproven one keeps full-rate polling instead of costing latency. + if (intPinProven && !Throttle::hasElapsed(lastWomPollMs, MOTION_SENSOR_IRQ_KEEPALIVE_MS)) + return MOTION_SENSOR_CHECK_INTERVAL_MS; + lastWomPollMs = millis(); +#endif + auto status = sensor->setBank(0); if (sensor->status != ICM_20948_Stat_Ok) { LOG_DEBUG("ICM20948 isWakeOnMotion failed to set bank - %s", sensor->statusString()); @@ -126,8 +126,6 @@ int32_t ICM20948Sensor::runOnce() return MOTION_SENSOR_CHECK_INTERVAL_MS; } -#endif - void ICM20948Sensor::calibrate(uint16_t forSeconds) { #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN diff --git a/src/motion/ICM20948Sensor.h b/src/motion/ICM20948Sensor.h index e84c7ea1bd..152ea9c95c 100755 --- a/src/motion/ICM20948Sensor.h +++ b/src/motion/ICM20948Sensor.h @@ -24,10 +24,8 @@ #define ICM_20948_WOM_THRESHOLD 16U #endif -// Define a pin in variant.h to use interrupts to read the ICM-20948 -#ifndef ICM_20948_WOM_THRESHOLD -#define ICM_20948_INT_PIN 255 -#endif +// Define ICM_20948_INT_PIN in variant.h to drive wake-on-motion from the INT pin +// instead of polling. The driver configures it active-low. // Uncomment this line to enable helpful debug messages on Serial // #define ICM_20948_DEBUG 1 @@ -83,6 +81,10 @@ class ICM20948Sensor : public MotionSensor ICM20948Singleton *sensor = nullptr; bool showingScreen = false; bool isAsleep = false; +#ifdef ICM_20948_INT_PIN + uint32_t lastWomPollMs = 0; + bool intPinProven = false; +#endif static constexpr const char *compassCalibrationFileName = "/prefs/compass_icm20948.dat"; #ifdef MUZI_BASE float highestX = 449.000000, lowestX = -140.000000, highestY = 422.000000, lowestY = -232.000000, highestZ = 749.000000, diff --git a/src/motion/MMC5983MASensor.h b/src/motion/MMC5983MASensor.h index 9d349b53a4..f3a6696f13 100644 --- a/src/motion/MMC5983MASensor.h +++ b/src/motion/MMC5983MASensor.h @@ -6,6 +6,12 @@ #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() +// SensorLib defines isBitSet as a macro, which collides with the class method of the same name +// below. Drop it here, where the two actually meet, rather than relying on include order. +#ifdef isBitSet +#undef isBitSet +#endif + #include class MMC5983MASensor : public MotionSensor diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index e6331ea857..575fff2bcb 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -2,6 +2,8 @@ #include "FSCommon.h" #include "SPILock.h" #include "SafeFile.h" +#include "Throttle.h" +#include "UptimeClock.h" #include "concurrency/LockGuard.h" #include "graphics/draw/CompassRenderer.h" @@ -149,8 +151,7 @@ void MotionSensor::beginCalibrationDisplay(bool &showingScreen) void MotionSensor::finishCalibrationIfExpired(bool &showingScreen, const char *filePath, float highestX, float lowestX, float highestY, float lowestY, float highestZ, float lowestZ) { - const uint32_t now = millis(); - if ((int32_t)(now - endCalibrationAt) < 0) + if (!Throttle::deadlinePassed(endCalibrationAt)) return; doCalibration = false; @@ -170,7 +171,7 @@ void MotionSensor::startCalibrationWindow(uint16_t forSeconds) { doCalibration = true; const uint32_t calibrateFor = static_cast(forSeconds) * 1000U; - endCalibrationAt = millis() + calibrateFor; + endCalibrationAt = Time::timerEndsAtMillis(calibrateFor); #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN if (screen) screen->setEndCalibration(endCalibrationAt); @@ -288,7 +289,7 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState const bool compactLayout = (height <= 80); const int16_t margin = 4; - const uint32_t now = millis(); + const uint32_t now = Time::getMillis(); const uint32_t endCalibrationAt = screen->getEndCalibration(); uint32_t timeRemaining = 0; // Signed delta, as in finishCalibrationIfExpired(): this needs the remaining magnitude, not diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h index ef84e1b19b..5a7111c52b 100755 --- a/src/motion/MotionSensor.h +++ b/src/motion/MotionSensor.h @@ -3,6 +3,8 @@ #define _MOTION_SENSOR_H_ #define MOTION_SENSOR_CHECK_INTERVAL_MS 50 +// Safety-net drain for the interrupt-driven drivers: a dead INT pin degrades to polling. +#define MOTION_SENSOR_IRQ_KEEPALIVE_MS 1000 #define MOTION_SENSOR_CLICK_THRESHOLD 40 #include "../configuration.h" diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 6bd2f3688f..8108ba050c 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -3,6 +3,7 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "ServiceEnvelope.h" +#include "UptimeClock.h" #include "configuration.h" #include "main.h" #include "mesh/Channels.h" @@ -22,6 +23,9 @@ #endif #if HAS_ETHERNET && defined(ARCH_ESP32) #include +#if HAS_ETHERNET && defined(ETH_SHARED_SPI) +#include "platform/esp32/SharedBusEthernet.h" +#endif #endif // HAS_ETHERNET #if HAS_ETHERNET && defined(USE_CH390D) #include "ESP32_CH390.h" @@ -867,5 +871,5 @@ void MQTT::perhapsReportToMap() packetPool.release(mp); // Update the last report time - last_report_to_map = millis(); + last_report_to_map = Time::skipZero(Time::getMillis()); } diff --git a/src/platform/esp32/IramMemcpy.c b/src/platform/esp32/IramMemcpy.c index 898f1bdc61..d4e2a8ef76 100644 --- a/src/platform/esp32/IramMemcpy.c +++ b/src/platform/esp32/IramMemcpy.c @@ -3,9 +3,14 @@ #include #include "esp_attr.h" +#include "sdkconfig.h" #ifdef ESP32_FORCE_IRAM_MEMSET +#if !defined(CONFIG_IDF_TARGET_ESP32) +#error "ESP32_FORCE_IRAM_MEMSET is classic-ESP32 only: cache_is_enabled() reads a DPORT register that other targets do not map." +#endif + /* * T-Beam/classic ESP32 boot workaround * ------------------------------------ @@ -16,6 +21,9 @@ * We wrap memcpy/memset for the T-Beam environment. Fast path uses the * normal libc routines when cache is enabled; slow path uses IRAM-safe byte * loops when cache is disabled. + * + * Classic ESP32 only: the probe below reads DPORT_PRO_CACHE_CTRL_REG, an address + * no other target maps, so the guard above keeps this off the S3/C3/C6. */ extern void *__real_memcpy(void *dst, const void *src, size_t n); diff --git a/src/platform/esp32/IramMemset.c b/src/platform/esp32/IramMemset.c index 66d74d8bc5..fb8155c1f0 100644 --- a/src/platform/esp32/IramMemset.c +++ b/src/platform/esp32/IramMemset.c @@ -3,9 +3,14 @@ #include #include "esp_attr.h" +#include "sdkconfig.h" #ifdef ESP32_FORCE_IRAM_MEMSET +#if !defined(CONFIG_IDF_TARGET_ESP32) +#error "ESP32_FORCE_IRAM_MEMSET is classic-ESP32 only: cache_is_enabled() reads a DPORT register that other targets do not map." +#endif + /* * T-Beam/classic ESP32 boot workaround * ------------------------------------ @@ -16,6 +21,9 @@ * We wrap memcpy/memset for the T-Beam environment. Fast path uses the * normal libc routines when cache is enabled; slow path uses IRAM-safe byte * loops when cache is disabled. + * + * Classic ESP32 only: the probe below reads DPORT_PRO_CACHE_CTRL_REG, an address + * no other target maps, so the guard above keeps this off the S3/C3/C6. */ extern void *__real_memset(void *dst, int c, size_t n); diff --git a/src/platform/esp32/SharedBusEthernet.cpp b/src/platform/esp32/SharedBusEthernet.cpp new file mode 100644 index 0000000000..2b30361ac5 --- /dev/null +++ b/src/platform/esp32/SharedBusEthernet.cpp @@ -0,0 +1,222 @@ +#include "SharedBusEthernet.h" + +#if defined(ARCH_ESP32) && defined(USE_WS5500) && defined(ETH_SHARED_SPI) + +#include "SPILock.h" +#include "concurrency/LockGuard.h" +#include +#include + +#ifndef ETH_SHARED_SPI_MHZ +#define ETH_SHARED_SPI_MHZ 20 +#endif + +SharedBusEthernet sharedBusEthernet; + +// Called from esp_eth's RX task. Lock order is spiLock -> SPI transaction, matching +// LockingArduinoHal, so the radio path cannot deadlock against this. +static void *ethSpiInit(const void *ctx) +{ + return (void *)ctx; +} + +static esp_err_t ethSpiDeinit(void *) +{ + return ESP_OK; +} + +static void ethSpiSelect(uint32_t cmd, uint32_t addr) +{ + ETH_SHARED_SPI.beginTransaction(SPISettings(ETH_SHARED_SPI_MHZ * 1000000, MSBFIRST, SPI_MODE0)); + digitalWrite(ETH_CS_PIN, LOW); + ETH_SHARED_SPI.write16(cmd); + ETH_SHARED_SPI.write(addr); +} + +static void ethSpiRelease() +{ + digitalWrite(ETH_CS_PIN, HIGH); + ETH_SHARED_SPI.endTransaction(); +} + +static esp_err_t ethSpiRead(void *, uint32_t cmd, uint32_t addr, void *data, uint32_t len) +{ + concurrency::LockGuard g(spiLock); + ethSpiSelect(cmd, addr); + ETH_SHARED_SPI.transferBytes(nullptr, (uint8_t *)data, len); + ethSpiRelease(); + return ESP_OK; +} + +static esp_err_t ethSpiWrite(void *, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) +{ + concurrency::LockGuard g(spiLock); + ethSpiSelect(cmd, addr); + ETH_SHARED_SPI.writeBytes((const uint8_t *)data, len); + ethSpiRelease(); + return ESP_OK; +} + +void SharedBusEthernet::onEthEvent(void *arg, esp_event_base_t, int32_t id, void *) +{ + SharedBusEthernet *self = (SharedBusEthernet *)arg; + arduino_event_t event; + event.event_id = ARDUINO_EVENT_MAX; + + switch (id) { + case ETHERNET_EVENT_CONNECTED: + event.event_id = ARDUINO_EVENT_ETH_CONNECTED; + event.event_info.eth_connected = self->ethHandle; + self->setStatusBits(ESP_NETIF_CONNECTED_BIT); + break; + case ETHERNET_EVENT_DISCONNECTED: + event.event_id = ARDUINO_EVENT_ETH_DISCONNECTED; + self->clearStatusBits(ESP_NETIF_CONNECTED_BIT | ESP_NETIF_HAS_IP_BIT | ESP_NETIF_HAS_LOCAL_IP6_BIT | + ESP_NETIF_HAS_GLOBAL_IP6_BIT); + break; + case ETHERNET_EVENT_START: + event.event_id = ARDUINO_EVENT_ETH_START; + self->setStatusBits(ESP_NETIF_STARTED_BIT); + break; + case ETHERNET_EVENT_STOP: + event.event_id = ARDUINO_EVENT_ETH_STOP; + self->clearStatusBits(ESP_NETIF_STARTED_BIT | ESP_NETIF_CONNECTED_BIT | ESP_NETIF_HAS_IP_BIT | + ESP_NETIF_HAS_LOCAL_IP6_BIT | ESP_NETIF_HAS_GLOBAL_IP6_BIT | ESP_NETIF_HAS_STATIC_IP_BIT); + break; + default: + return; + } + + Network.postEvent(&event); +} + +// Reverse of begin()'s creation order, so a failed begin() leaves no handle set and can be retried. +void SharedBusEthernet::teardown() +{ + esp_event_handler_unregister(ETH_EVENT, ESP_EVENT_ANY_ID, onEthEvent); + if (glueHandle) { + esp_eth_del_netif_glue(glueHandle); + glueHandle = nullptr; + } + destroyNetif(); + if (ethHandle) { + esp_eth_driver_uninstall(ethHandle); + ethHandle = nullptr; + } + if (ethPhy) { + ethPhy->del(ethPhy); + ethPhy = nullptr; + } + if (ethMac) { + ethMac->del(ethMac); + ethMac = nullptr; + } +} + +bool SharedBusEthernet::begin() +{ + if (ethHandle) + return true; + + Network.begin(); + + pinMode(ETH_CS_PIN, OUTPUT); + digitalWrite(ETH_CS_PIN, HIGH); + + spi_device_interface_config_t devcfg = {}; + + // spi_host_id and devcfg go unused once custom_spi_driver is set, but the config macro wants them. + eth_w5500_config_t w5500Config = ETH_W5500_DEFAULT_CONFIG(SPI2_HOST, &devcfg); + w5500Config.int_gpio_num = ETH_INT_PIN; + w5500Config.custom_spi_driver.config = this; + w5500Config.custom_spi_driver.init = ethSpiInit; + w5500Config.custom_spi_driver.deinit = ethSpiDeinit; + w5500Config.custom_spi_driver.read = ethSpiRead; + w5500Config.custom_spi_driver.write = ethSpiWrite; + + eth_mac_config_t macConfig = ETH_MAC_DEFAULT_CONFIG(); + eth_phy_config_t phyConfig = ETH_PHY_DEFAULT_CONFIG(); + phyConfig.phy_addr = 1; + phyConfig.reset_gpio_num = ETH_RST_PIN; + + ethMac = esp_eth_mac_new_w5500(&w5500Config, &macConfig); + ethPhy = esp_eth_phy_new_w5500(&phyConfig); + if (!ethMac || !ethPhy) { + LOG_ERROR("W5500 MAC/PHY alloc failed"); + teardown(); + return false; + } + + esp_eth_config_t ethConfig = ETH_DEFAULT_CONFIG(ethMac, ethPhy); + if (esp_eth_driver_install(ðConfig, ðHandle) != ESP_OK || !ethHandle) { + LOG_ERROR("W5500 driver install failed"); + ethHandle = nullptr; + teardown(); + return false; + } + + uint8_t macAddress[6]; + if (esp_read_mac(macAddress, ESP_MAC_ETH) == ESP_OK) + esp_eth_ioctl(ethHandle, ETH_CMD_S_MAC_ADDR, macAddress); + + esp_netif_inherent_config_t netifBase = ESP_NETIF_INHERENT_DEFAULT_ETH(); + esp_netif_config_t netifConfig = ESP_NETIF_DEFAULT_ETH(); + netifConfig.base = &netifBase; + _esp_netif = esp_netif_new(&netifConfig); + if (!_esp_netif) { + LOG_ERROR("W5500 netif alloc failed"); + teardown(); + return false; + } + if (!initNetif(ESP_NETIF_ID_ETH)) { + LOG_ERROR("W5500 netif init failed"); + teardown(); + return false; + } + + glueHandle = esp_eth_new_netif_glue(ethHandle); + if (!glueHandle || esp_netif_attach(_esp_netif, glueHandle) != ESP_OK) { + LOG_ERROR("W5500 netif attach failed"); + teardown(); + return false; + } + + // Registered before start so the START event is not missed. + if (esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, onEthEvent, this) != ESP_OK) { + LOG_ERROR("W5500 event handler register failed"); + teardown(); + return false; + } + + if (esp_eth_start(ethHandle) != ESP_OK) { + LOG_ERROR("W5500 start failed"); + teardown(); + return false; + } + + LOG_INFO("W5500 on shared SPI bus, %u MHz", (unsigned)ETH_SHARED_SPI_MHZ); + return true; +} + +uint16_t SharedBusEthernet::linkSpeed() const +{ + eth_speed_t speed = ETH_SPEED_100M; + if (ethHandle) + esp_eth_ioctl(ethHandle, ETH_CMD_G_SPEED, &speed); + return speed == ETH_SPEED_10M ? 10 : 100; +} + +bool SharedBusEthernet::fullDuplex() const +{ + eth_duplex_t duplex = ETH_DUPLEX_FULL; + if (ethHandle) + esp_eth_ioctl(ethHandle, ETH_CMD_G_DUPLEX_MODE, &duplex); + return duplex == ETH_DUPLEX_FULL; +} + +size_t SharedBusEthernet::printDriverInfo(Print &out) const +{ + return out.print(",W5500"); +} + +#endif diff --git a/src/platform/esp32/SharedBusEthernet.h b/src/platform/esp32/SharedBusEthernet.h new file mode 100644 index 0000000000..7c9f1823ba --- /dev/null +++ b/src/platform/esp32/SharedBusEthernet.h @@ -0,0 +1,38 @@ +#pragma once + +#include "configuration.h" + +#if defined(ARCH_ESP32) && defined(USE_WS5500) && defined(ETH_SHARED_SPI) + +#include +#include + +// W5500 driver for boards whose MAC shares its SPI bus. Installs esp_eth directly so the SPI +// callbacks can take spiLock, which Arduino's ETHClass cannot. +class SharedBusEthernet : public NetworkInterface +{ + public: + bool begin(); + uint16_t linkSpeed() const; + bool fullDuplex() const; + + protected: + size_t printDriverInfo(Print &out) const override; + + private: + static void onEthEvent(void *arg, esp_event_base_t base, int32_t id, void *data); + void teardown(); + + esp_eth_handle_t ethHandle = nullptr; + esp_eth_netif_glue_handle_t glueHandle = nullptr; + esp_eth_mac_t *ethMac = nullptr; + esp_eth_phy_t *ethPhy = nullptr; +}; + +extern SharedBusEthernet sharedBusEthernet; + +// Route the firmware's ETH.* calls at this driver instead of Arduino's global, matching how +// USE_CH390D swaps in its own class. +#define ETH sharedBusEthernet + +#endif diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 2c409b0b87..2e695a8aa4 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -215,6 +215,8 @@ #define HW_VENDOR meshtastic_HardwareModel_M5STACK_CARDPUTER_ADV #elif defined(MESHNOLOGY_W10) #define HW_VENDOR meshtastic_HardwareModel_MESHNOLOGY_W10 +#elif defined(T5_S3_EPAPER_PRO) +#define HW_VENDOR meshtastic_HardwareModel_T5_S3_EPAPER_PRO #elif defined(ELECROW_ThinkNode_M9) #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M9 #elif defined(HELTEC_V4_R8) @@ -225,6 +227,10 @@ #define HW_VENDOR meshtastic_HardwareModel_HELTEC_RC32 #elif defined(HELTEC_RCC6) #define HW_VENDOR meshtastic_HardwareModel_HELTEC_RCC6 +#elif defined(SEEED_WIO_TRACKER_L2) +#define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L2 +#elif defined(T_CONNECT_PRO) +#define HW_VENDOR meshtastic_HardwareModel_T_CONNECT_PRO #else #define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW #endif diff --git a/src/platform/esp32/esp_partition_read_mmap_wrap.c b/src/platform/esp32/esp_partition_read_mmap_wrap.c deleted file mode 100644 index a685b43113..0000000000 --- a/src/platform/esp32/esp_partition_read_mmap_wrap.c +++ /dev/null @@ -1,81 +0,0 @@ -// Workaround for the IDF 5.5 manual esp_flash read regression on t-watch-ultra. -// -// On this board (Winbond W25Q128JW, ef:8018), the IDF 5.5 *direct* flash read path -// (esp_flash_read / esp_partition_read) returns 0x00 for data that is physically -// correct on flash. We proved the *memory-mapped* (cache) read returns the right -// data, writes work, and it's not read-mode/HPM/timing-tuning/PSRAM. So route every -// esp_partition_read through esp_partition_mmap + memcpy, which uses the working -// cache path. Activated by `-Wl,--wrap=esp_partition_read` (t-watch-ultra only). -// -// That alone isn't enough: nvs_flash reads through the lower-level esp_flash_read, -// which hits the same 0x00 regression, so NVS came up empty every boot (0 entries) -// and silently dropped BLE bonds and everything else stored via Preferences. Wrap -// esp_flash_read too, via raw spi_flash_mmap, for callers that bypass esp_partition_t. -#if defined(T_WATCH_ULTRA) - -#include "esp_flash.h" -#include "esp_flash_encrypt.h" -#include "esp_partition.h" -#include "spi_flash_mmap.h" -#include - -extern esp_err_t __real_esp_partition_read(const esp_partition_t *partition, size_t src_offset, void *dst, size_t size); -extern esp_err_t __real_esp_flash_read(esp_flash_t *chip, void *buffer, uint32_t address, uint32_t length); - -esp_err_t __wrap_esp_partition_read(const esp_partition_t *partition, size_t src_offset, void *dst, size_t size) -{ - if (partition == NULL || dst == NULL) - return ESP_ERR_INVALID_ARG; - if (size == 0) - return ESP_OK; - - // mmap requires a 64KB-aligned start; map the containing page span and copy - // out from the requested offset. - const size_t PAGE = 0x10000; - size_t aligned = src_offset & ~(PAGE - 1); - size_t delta = src_offset - aligned; - - const void *ptr = NULL; - esp_partition_mmap_handle_t handle; - esp_err_t err = esp_partition_mmap(partition, aligned, delta + size, ESP_PARTITION_MMAP_DATA, &ptr, &handle); - if (err != ESP_OK) { - // Encrypted partitions / regions mmap can't serve: fall back to the real - // read (may be wrong on this board, but better than failing the call). - return __real_esp_partition_read(partition, src_offset, dst, size); - } - memcpy(dst, (const uint8_t *)ptr + delta, size); - esp_partition_munmap(handle); - return ESP_OK; -} - -esp_err_t __wrap_esp_flash_read(esp_flash_t *chip, void *buffer, uint32_t address, uint32_t length) -{ - if (buffer == NULL) - return ESP_ERR_INVALID_ARG; - if (length == 0) - return ESP_OK; - // spi_flash_mmap only maps the default (main) chip's address space - anything - // else (e.g. a second chip on another bus) falls back to the real call. - if (chip != NULL && chip != esp_flash_default_chip) - return __real_esp_flash_read(chip, buffer, address, length); - // esp_flash_read is specified to return raw bytes, but the cache decrypts transparently. - // With flash encryption on, the mmap path would hand back plaintext - so don't take it. - if (esp_flash_encryption_enabled()) - return __real_esp_flash_read(chip, buffer, address, length); - - const size_t PAGE = 0x10000; - size_t aligned = address & ~(PAGE - 1); - size_t delta = address - aligned; - - const void *ptr = NULL; - spi_flash_mmap_handle_t handle; - esp_err_t err = spi_flash_mmap(aligned, delta + length, SPI_FLASH_MMAP_DATA, &ptr, &handle); - if (err != ESP_OK) - return __real_esp_flash_read(chip, buffer, address, length); - - memcpy(buffer, (const uint8_t *)ptr + delta, length); - spi_flash_munmap(handle); - return ESP_OK; -} - -#endif // T_WATCH_ULTRA diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index f7879a333f..74b8e0865f 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -345,16 +345,24 @@ void cpuDeepSleep(uint32_t msecToWake) #endif 34, 35, 37}; - for (int i = 0; i < sizeof(rtcGpios); i++) - rtc_gpio_isolate((gpio_num_t)rtcGpios[i]); +#ifdef BUTTON_PIN + const int wakeButton = config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN; +#else + const int wakeButton = -1; +#endif + // Isolating a pad holds it, and ext1 cannot re-arm a held pad - skip the pin we wake on. + for (int i = 0; i < sizeof(rtcGpios); i++) { + if (rtcGpios[i] != wakeButton) + rtc_gpio_isolate((gpio_num_t)rtcGpios[i]); + } #endif - // FIXME, disable internal rtc pullups/pulldowns on the non isolated pins. for inputs that we aren't using - // to detect wake and in normal operation the external part drives them hard. + // FIXME, disable internal rtc pullups/pulldowns on the non isolated pins. for inputs that we aren't using + // to detect wake and in normal operation the external part drives them hard. #ifdef BUTTON_PIN - // Only GPIOs which are have RTC functionality can be used in this bit map: 0,2,4,12-15,25-27,32-39. + // Only GPIOs which are have RTC functionality can be used in this bit map: 0,2,4,12-15,25-27,32-39. #if SOC_RTCIO_HOLD_SUPPORTED && SOC_PM_SUPPORT_EXT_WAKEUP - uint64_t gpioMask = (1ULL << (config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN)); + uint64_t gpioMask = (1ULL << wakeButton); #endif #ifdef ALT_BUTTON_WAKE gpioMask |= (1ULL << BUTTON_PIN_ALT); diff --git a/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.cpp b/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.cpp new file mode 100644 index 0000000000..4be29fd103 --- /dev/null +++ b/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.cpp @@ -0,0 +1,190 @@ +#ifdef SEEED_WIO_TRACKER_L2 + +#include "WakeKey.h" +#include "DebugConfiguration.h" +#include "PowerFSM.h" +#include "SPILock.h" +#include "graphics/Screen.h" // BaseUI +#include "graphics/TFTDisplay.h" // BaseUI +#include "main.h" + +#if defined(HAS_TFT) && HAS_TFT +#include "graphics/DeviceScreen.h" // MUI +extern DeviceScreen *deviceScreen; +#endif + +#include PCA95X5_INC +extern PCA95X5_CLS io; + +// wake button handling +static bool isPca9535WakeKeyPressed() +{ + concurrency::LockGuard guard(spiLock); + return !io.digitalRead(EXPANDS_BTN_WAKE_UP); +} + +WakeKeyInterruptThread *WakeKeyInterruptThread::wakeKey = nullptr; + +WakeKeyInterruptThread *WakeKeyInterruptThread::instance(void) +{ + if (!wakeKey) + wakeKey = new WakeKeyInterruptThread(); + return wakeKey; +} + +WakeKeyInterruptThread::WakeKeyInterruptThread() : concurrency::OSThread("WioL2WakeKeyInt", SAMPLE_MS) +{ + // Do not run unless an edge arrives. + OSThread::disable(); +#ifdef ARCH_ESP32 + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif +} + +void WakeKeyInterruptThread::begin(void) +{ + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, WakeKeyInterruptThread::isr, FALLING); +} + +int32_t WakeKeyInterruptThread::runOnce(void) +{ + const uint32_t now = millis(); + + // Safe, sequential handling of the edge flag outside the ISR context + if (rawIrqSignaled) { + rawIrqSignaled = false; + if (state == State::REST) { + state = State::IRQ_PENDING; + irqAtMs = now; + } + } + + // Ignore side-key handling while BOOT/user button is held. + if (digitalRead(BUTTON_PIN) == LOW) { + LOG_WARN("ignore wake button press"); + resetStateAndStop(); + return OSThread::disable(); + } + + switch (state) { + case State::IRQ_PENDING: + // Initial debounce after expander interrupt edge. + if ((uint32_t)(now - irqAtMs) < DEBOUNCE_MS) { + return SAMPLE_MS; + } + + if (isPca9535WakeKeyPressed()) { + LOG_DEBUG("wake button pressed"); +#if defined(HAS_TFT) && HAS_TFT + if (deviceScreen) + deviceScreen->toggleDisplay(); +#endif +#if defined(HAS_SCREEN) && HAS_SCREEN + if (screen) + screen->setOn(sleeping); +#endif + if (sleeping) { + powerFSM.trigger(EVENT_PRESS); + } + sleeping = !sleeping; + state = State::PRESSED; + return SAMPLE_MS; + } + + // Spurious/cleared edge. + resetStateAndStop(); + return OSThread::disable(); + + case State::PRESSED: { + if (isPca9535WakeKeyPressed()) { + return SAMPLE_MS; + } + + resetStateAndStop(); + return OSThread::disable(); + } + + case State::REST: + default: + return OSThread::disable(); + } +} + +void IRAM_ATTR WakeKeyInterruptThread::isr(void) +{ + if (wakeKey) { + wakeKey->rawIrqSignaled = true; + wakeKey->enabled = true; + wakeKey->setInterval(0); // TODO + BaseType_t higherWake = 0; + concurrency::mainDelay.interruptFromISR(&higherWake); + runASAP = true; + } +} + +void WakeKeyInterruptThread::onInterruptEdge(void) +{ + if (state != State::REST) { + return; + } + + state = State::IRQ_PENDING; + irqAtMs = millis(); + startThread(); +} + +void WakeKeyInterruptThread::startThread(void) +{ + if (!OSThread::enabled) { + OSThread::setIntervalFromNow(0); + OSThread::enabled = true; + runASAP = true; + } +} + +void WakeKeyInterruptThread::resetStateAndStop(void) +{ + state = State::REST; + if (OSThread::enabled) { + OSThread::disable(); + } +} + +#ifdef ARCH_ESP32 +int WakeKeyInterruptThread::onLightSleep(void *) +{ + detachInterrupt(BOARD_PCA9535_INT); + // Clear any latched PCA9535 interrupt before enabling GPIO wake. + // If INT is left asserted low, light sleep exits immediately. + spiLock->lock(); + volatile bool dummy = io.digitalRead(EXPANDS_BTN_WAKE_UP); + (void)dummy; + spiLock->unlock(); + resetStateAndStop(); + sleeping = true; + return 0; +} + +int WakeKeyInterruptThread::onLightSleepEnd(esp_sleep_wakeup_cause_t cause) +{ + (void)cause; + // Consume any pending interrupt source before reattaching ISR. + // Check BEFORE clearing: if INT is still asserted the button may still be held, + // and no new falling edge will fire until it is released. + bool intAsserted = (digitalRead(BOARD_PCA9535_INT) == LOW); + spiLock->lock(); + (void)io.digitalRead(EXPANDS_BTN_WAKE_UP); + spiLock->unlock(); + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, WakeKeyInterruptThread::isr, FALLING); + if (intAsserted) { + onInterruptEdge(); + } + return 0; +} + +#endif + +#endif \ No newline at end of file diff --git a/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.h b/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.h new file mode 100644 index 0000000000..05d96496e8 --- /dev/null +++ b/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.h @@ -0,0 +1,47 @@ +#pragma once +#include "concurrency/OSThread.h" +#include "sleep.h" + +class WakeKeyInterruptThread : public concurrency::OSThread +{ + public: + static WakeKeyInterruptThread *instance(void); + void begin(void); + + protected: + int32_t runOnce() override; + + private: + enum class State : uint8_t { + REST, + IRQ_PENDING, + PRESSED, + }; + + static constexpr uint32_t SAMPLE_MS = 15; + static constexpr uint32_t DEBOUNCE_MS = 25; + + static void IRAM_ATTR isr(void); + void onInterruptEdge(void); + + void startThread(void); + void resetStateAndStop(void); + +#ifdef ARCH_ESP32 + int onLightSleep(void *); + int onLightSleepEnd(esp_sleep_wakeup_cause_t cause); + + CallbackObserver lsObserver{this, &WakeKeyInterruptThread::onLightSleep}; + CallbackObserver lsEndObserver{this, + &WakeKeyInterruptThread::onLightSleepEnd}; +#endif + + bool sleeping = false; + volatile bool rawIrqSignaled = false; + volatile State state = State::REST; + volatile uint32_t irqAtMs = 0; + + WakeKeyInterruptThread(void); + static WakeKeyInterruptThread *wakeKey; + WakeKeyInterruptThread *wakeKeyThread = nullptr; +}; diff --git a/src/platform/extra_variants/seeed_wio_tracker_l2/variant.cpp b/src/platform/extra_variants/seeed_wio_tracker_l2/variant.cpp new file mode 100644 index 0000000000..ce42e9b5d8 --- /dev/null +++ b/src/platform/extra_variants/seeed_wio_tracker_l2/variant.cpp @@ -0,0 +1,109 @@ +#include "configuration.h" + +#ifdef SEEED_WIO_TRACKER_L2 + +#include "AudioBoard.h" +#include "DebugConfiguration.h" +#include "SPILock.h" +#include "WakeKey.h" +#include "input/InputBroker.h" + +#include PCA95X5_INC +extern PCA95X5_CLS io; + +DriverPins PinsAudioBoardES8311; +AudioBoard board(AudioDriverES8311, PinsAudioBoardES8311); + +static bool initOK = false; + +void earlyInitVariant() +{ + Wire.begin(I2C_SDA, I2C_SCL); + Wire.setClock(100000); // Pin main I2C0 bus to 100 kHz (TCA9535, ADS1115, AW35615, ES8311 share this Wire) + if (io.begin(Wire, BOARD_PCA9535_ADDR, I2C_SDA, I2C_SCL)) { + io.pinMode(EXPANDS_BTN_WAKE_UP, INPUT); // wakeup button + io.pinMode(EXPANDS_I2C_0_INT, INPUT); // I2C IRQ + io.pinMode(EXPANDS_SD_DETECT, INPUT); // SD detect + + io.pinMode(EXPANDS_EXP_OTG_EN, OUTPUT); // OTG EN + io.digitalWrite(EXPANDS_EXP_OTG_EN, LOW); // OTG EN low + delay(10); + io.pinMode(EXPANDS_PA_PWR_EN, OUTPUT); // PA EN + io.digitalWrite(EXPANDS_PA_PWR_EN, LOW); // PA EN low, controlled by AudioThread + delay(10); + io.pinMode(EXPANDS_GNSS_PWR_EN, OUTPUT); // GNSS EN + io.digitalWrite(EXPANDS_GNSS_PWR_EN, HIGH); // GNSS EN high + delay(10); + io.pinMode(EXPANDS_SD_PWR_EN, OUTPUT); // TF EN + io.digitalWrite(EXPANDS_SD_PWR_EN, HIGH); // TF EN high + delay(10); + io.pinMode(EXPANDS_BAT_ADC_EN, OUTPUT); // BAT ADC EN + io.digitalWrite(EXPANDS_BAT_ADC_EN, HIGH); // BAT ADC EN high + delay(10); + io.pinMode(EXPANDS_GNSS_RST, OUTPUT); // GNSS RST (active HIGH on this board) + // Expander output defaults to HIGH, so module is already in reset. + // Hold reset for 10ms, then release LOW so the module starts running. + delay(10); + io.digitalWrite(EXPANDS_GNSS_RST, LOW); // release reset - module starts running + io.pinMode(EXPANDS_LED_USER, OUTPUT); // User LED + io.digitalWrite(EXPANDS_LED_USER, LOW); // User LED + delay(10); + io.pinMode(EXPANDS_GROVE_PWR_EN, OUTPUT); // GROVE EN + io.digitalWrite(EXPANDS_GROVE_PWR_EN, HIGH); // GROVE EN high + delay(10); + + io.pinMode(EXPANDS_LCD_PWR_EN, OUTPUT); // LCD EN + io.digitalWrite(EXPANDS_LCD_PWR_EN, HIGH); // LCD EN high + delay(50); + io.pinMode(EXPANDS_LCD_RST, OUTPUT); // LCD RST + io.digitalWrite(EXPANDS_LCD_RST, HIGH); // LCD RST high + delay(5); + io.digitalWrite(EXPANDS_LCD_RST, LOW); // LCD RST low + delay(10); + io.digitalWrite(EXPANDS_LCD_RST, HIGH); // LCD RST high + delay(500); + io.pinMode(EXPANDS_LCD_CS, OUTPUT); // LCD CS + io.digitalWrite(EXPANDS_LCD_CS, HIGH); // LCD CS high + delay(10); + + io.pinMode(EXPANDS_TP_RST, OUTPUT); // TP RST + io.digitalWrite(EXPANDS_TP_RST, LOW); // TP RST low + io.pinMode(EXPANDS_TP_INT, OUTPUT); // TP INT: disable (we use wake button for wakeup) + io.digitalWrite(EXPANDS_TP_INT, LOW); // TP INT low + delay(10); + io.digitalWrite(EXPANDS_TP_INT, LOW); // TP INT low + delay(1); + io.digitalWrite(EXPANDS_TP_RST, HIGH); // TP RST high + delay(60); + initOK = true; + } +} + +void lateInitVariant() +{ + if (!initOK) { + LOG_ERROR("PCA9555 initialization failed"); + return; + } + + // wake button initialization + WakeKeyInterruptThread::instance()->begin(); + + // AudioDriverLogger.begin(Serial, AudioDriverLogLevel::Debug); + // I2C: function, scl, sda + PinsAudioBoardES8311.addI2C(PinFunction::CODEC, Wire); + // I2S: function, mclk, bck, ws, data_out, data_in + PinsAudioBoardES8311.addI2S(PinFunction::CODEC, DAC_I2S_MCLK, DAC_I2S_BCK, DAC_I2S_WS, DAC_I2S_DOUT, DAC_I2S_DIN); + + // configure codec + CodecConfig cfg; + cfg.input_device = ADC_INPUT_LINE1; + cfg.output_device = DAC_OUTPUT_ALL; + cfg.i2s.bits = BIT_LENGTH_16BITS; + cfg.i2s.rate = RATE_44K; + board.begin(cfg); + board.setVolume(75); // 75% volume + LOG_INFO("ES8311 Audio board initialized"); +} + +#endif \ No newline at end of file diff --git a/src/platform/extra_variants/t-watch-ultra/variant.cpp b/src/platform/extra_variants/t-watch-ultra/variant.cpp index f77c1e97b5..252a9fa143 100644 --- a/src/platform/extra_variants/t-watch-ultra/variant.cpp +++ b/src/platform/extra_variants/t-watch-ultra/variant.cpp @@ -2,19 +2,15 @@ #ifdef T_WATCH_ULTRA -// Board-specific init lives here (rather than in variants/esp32s3/t-watch-ultra/variant.cpp) -// so that PlatformIO's library dependency finder can resolve headers such as -// input/TouchScreenImpl1.h (which transitively pulls in the ArduinoThread "Thread.h"), -// ExtensionIOXL9555.hpp and TouchDrvCSTXXX.hpp. Files compiled from outside src/ only get -// include paths for libraries they reference directly, so the transitive Thread.h include -// is not found there. See src/platform/extra_variants/README.md. +// Lives here, not under variants/, so PlatformIO's LDF resolves the Thread.h that +// input/TouchScreenImpl1.h pulls in transitively. See extra_variants/README.md. -#include "TouchDrvCSTXXX.hpp" #include "input/TouchScreenImpl1.h" -#include +#include "touch/TouchDrvCST92xx.h" +#include #include -static ExtensionIOXL9555 io; +static IoExpanderXL9555 io; static TouchDrvCST92xx touchDrv; void earlyInitVariant() diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp index a83b04b0fb..39ebcdc231 100644 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -1,9 +1,9 @@ +#include "UptimeClock.h" #include "configuration.h" #ifdef T5_S3_EPAPER_PRO #include "Observer.h" -#include "TouchDrvGT911.hpp" #include "Wire.h" #include "buzz.h" #include "concurrency/OSThread.h" @@ -12,6 +12,7 @@ #include "main.h" #include "mesh/Throttle.h" #include "sleep.h" +#include "touch/TouchDrvGT911.hpp" #include #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS @@ -555,7 +556,7 @@ struct TouchLightSleepEndObserver { } touchStateEpoch++; - touchResumeAtMs = millis(); + touchResumeAtMs = Time::skipZero(Time::getMillis()); touchIndicatorRefreshPending = !isTouchInputEnabled(); #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // Clear sleep-time touch overlay after wake. @@ -602,7 +603,7 @@ bool readTouch(int16_t *x, int16_t *y) LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); touch.wakeup(); touchNeedsWake = false; - suppressFromMs = millis(); + suppressFromMs = Time::skipZero(Time::getMillis()); return false; } @@ -613,9 +614,11 @@ bool readTouch(int16_t *x, int16_t *y) #endif if (!digitalRead(GT911_PIN_INT)) { - int16_t raw_x; - int16_t raw_y; - if (touch.getPoint(&raw_x, &raw_y)) { + // 0.4.x deprecates getPoint() in favour of getTouchPoints(); only the first touch is used here. + const TouchPoints &points = touch.getTouchPoints(); + if (points.getPointCount()) { + const int16_t raw_x = static_cast(points.getPoint(0).x); + const int16_t raw_y = static_cast(points.getPoint(0).y); #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. // rotation=3 is the physical identity (device's default orientation). diff --git a/src/platform/extra_variants/tbeam_displayshield/variant.cpp b/src/platform/extra_variants/tbeam_displayshield/variant.cpp index 7beac22934..af3fb13b91 100644 --- a/src/platform/extra_variants/tbeam_displayshield/variant.cpp +++ b/src/platform/extra_variants/tbeam_displayshield/variant.cpp @@ -2,11 +2,30 @@ #ifdef HAS_CST226SE -#include "TouchDrvCSTXXX.hpp" #include "input/TouchScreenImpl1.h" +#include "touch/TouchDrvCST226.h" #include -TouchDrvCSTXXX tsPanel; +#ifndef TOUCH_RST +#define TOUCH_RST -1 +#endif +#ifndef SCREEN_TOUCH_INT +#define SCREEN_TOUCH_INT -1 +#endif + +// The panel reports raw coordinates in its portrait frame. Rotated boards run the UI landscape, +// so mirror the swap TFTDisplay does for them (setGeometry(TFT_HEIGHT, TFT_WIDTH)). +#ifdef SCREEN_ROTATE +static constexpr int16_t screenWidth = TFT_HEIGHT; +static constexpr int16_t screenHeight = TFT_WIDTH; +#else +static constexpr int16_t screenWidth = TFT_WIDTH; +static constexpr int16_t screenHeight = TFT_HEIGHT; +#endif + +// Concrete CST226 driver, not the TouchDrvCSTXXX wrapper: the wrapper walks CST816 and CST92xx +// too, and its two 1s retries cost ~2.4s of boot probing chips this panel never is. +TouchDrvCST226 tsPanel; static constexpr uint8_t PossibleAddresses[2] = {CST328_ADDR, CST226SE_ADDR_ALT}; uint8_t i2cAddress = 0; @@ -16,9 +35,9 @@ bool readTouch(int16_t *x, int16_t *y) uint8_t touched = tsPanel.getPoint(x_array, y_array, 1); if (touched > 0) { *y = x_array[0]; - *x = (TFT_WIDTH - y_array[0]); + *x = (screenWidth - y_array[0]); // Check bounds - if (*x < 0 || *x >= TFT_WIDTH || *y < 0 || *y >= TFT_HEIGHT) { + if (*x < 0 || *x >= screenWidth || *y < 0 || *y >= screenHeight) { return false; } return true; // Valid touch detected @@ -28,12 +47,13 @@ bool readTouch(int16_t *x, int16_t *y) void lateInitVariant() { - tsPanel.setTouchDrvModel(TouchDrv_CST226); + tsPanel.setPins(TOUCH_RST, SCREEN_TOUCH_INT); for (uint8_t addr : PossibleAddresses) { - if (tsPanel.begin(Wire, addr, I2C_SDA, I2C_SCL)) { + // -1 pins: Wire is already begun by the I2C scan, re-initializing it only logs warnings + if (tsPanel.begin(Wire, addr, -1, -1)) { i2cAddress = addr; LOG_DEBUG("CST226SE init OK at address 0x%02X", addr); - touchScreenImpl1 = new TouchScreenImpl1(TFT_WIDTH, TFT_HEIGHT, readTouch); + touchScreenImpl1 = new TouchScreenImpl1(screenWidth, screenHeight, readTouch); touchScreenImpl1->init(); return; } diff --git a/src/platform/nrf52/BLEDfuSecure.cpp b/src/platform/nrf52/BLEDfuSecure.cpp index 040df8bdff..c198e86388 100644 --- a/src/platform/nrf52/BLEDfuSecure.cpp +++ b/src/platform/nrf52/BLEDfuSecure.cpp @@ -121,7 +121,12 @@ static void bledfu_control_wr_authorize_cb(uint16_t conn_hdl, BLECharacteristic Bluefruit.Advertising.restartOnDisconnect(false); conn->disconnect(); +#ifdef ARCH_NRF54L + sd_power_gpregret_clr(0, 0xFF); + sd_power_gpregret_set(0, 0xB1); +#else NRF_POWER->GPREGRET = 0xB1; +#endif NVIC_SystemReset(); } } diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 85a29e05a9..c0c5a2bb2f 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -11,6 +11,11 @@ #include "mesh/mesh-pb-constants.h" #include #include + +#ifdef ARCH_NRF54L +extern uint32_t sd_app_ram_start_required; // Bluefruit54Lib +extern "C" uint32_t verify_last_err, verify_last_line; // core verify.h +#endif static BLEService meshBleService = BLEService(BLEUuid(MESH_SERVICE_UUID_16)); static BLECharacteristic fromNum = BLECharacteristic(BLEUuid(FROMNUM_UUID_16)); static BLECharacteristic fromRadio = BLECharacteristic(BLEUuid(FROMRADIO_UUID_16)); @@ -23,7 +28,7 @@ static int lastBatteryLevel = -1; // last value written to BAS, to skip redundan #ifndef BLE_DFU_SECURE static BLEDfu bledfu; // DFU software update helper service #else -static BLEDfuSecure bledfusecure; // DFU software update helper service +static BLEDfuSecure bledfusecure; // DFU software update helper service #endif // This scratch buffer is used for various bluetooth reads/writes - but it is safe because only one bt operation can be in @@ -245,8 +250,12 @@ void NRF52Bluetooth::shutdown() // Shutdown bluetooth for minimum power draw LOG_INFO("Disable NRF52 bluetooth"); Bluefruit.Security.setPairPasskeyCallback(NRF52Bluetooth::onUnwantedPairing); // Actively refuse (during factory reset) - disconnect(); + + // Clear the auto-restart flag before dropping the link: our DISCONNECTED event is only processed + // after this callback returns and would re-start advertising. startAdv()/resumeAdvertising() re-set it. + Bluefruit.Advertising.restartOnDisconnect(false); Bluefruit.Advertising.stop(); + disconnect(); } void NRF52Bluetooth::startDisabled() { @@ -283,7 +292,12 @@ void NRF52Bluetooth::setup() // current Bluefruit config. Without this check the node would silently run without BLE. // Rebuild with -DCFG_DEBUG=1 to get "SoftDevice's RAM requires: 0x..." in the log, then // raise the ORIGIN accordingly. +#ifdef ARCH_NRF54L + LOG_ERROR("Bluefruit.begin failed: status 0x%lx at line %lu, app RAM base wanted 0x%08lx", verify_last_err, + verify_last_line, sd_app_ram_start_required); +#else LOG_ERROR("Bluefruit.begin failed: SoftDevice RAM too small"); +#endif RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_UNSPECIFIED); return; } @@ -494,7 +508,7 @@ void NRF52Bluetooth::onPairingCompleted(uint16_t conn_handle, uint8_t auth_statu meshtastic::BluetoothStatus newConnectedStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); bluetoothStatus->updateStatus(&newConnectedStatus); } else { - LOG_INFO("BLE pair failed"); + LOG_INFO("BLE pair failed, status 0x%02x", auth_status); // Notify UI (or any other interested firmware components) meshtastic::BluetoothStatus newDisconnectedStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); bluetoothStatus->updateStatus(&newDisconnectedStatus); diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index 279ad75757..c3b62320da 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -51,7 +51,7 @@ #ifndef HAS_CPU_SHUTDOWN #define HAS_CPU_SHUTDOWN 1 #endif -#ifndef HAS_CUSTOM_CRYPTO_ENGINE +#if !defined(HAS_CUSTOM_CRYPTO_ENGINE) && !defined(ARCH_NRF54L) #define HAS_CUSTOM_CRYPTO_ENGINE 1 #endif @@ -198,7 +198,7 @@ // If we are not on a NRF52840 (which has built in USB-ACM serial support) and we don't have serial pins hooked up, then we MUST // use SEGGER for debug output -#if !defined(PIN_SERIAL_RX) && !defined(NRF52840_XXAA) +#if !defined(PIN_SERIAL_RX) && !defined(NRF52840_XXAA) && !defined(ARCH_NRF54L) // No serial ports on this board - ONLY use segger in memory console #define USE_SEGGER #endif diff --git a/src/platform/nrf52/hardfault.cpp b/src/platform/nrf52/hardfault.cpp index 260a0a6a6f..1f54b4486d 100644 --- a/src/platform/nrf52/hardfault.cpp +++ b/src/platform/nrf52/hardfault.cpp @@ -1,5 +1,5 @@ #include "configuration.h" -#include +#include #ifdef MESHTASTIC_ENCRYPTED_STORAGE #include "security/EncryptedStorage.h" diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index 865e1c3633..6114975513 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -1,22 +1,37 @@ #include "UptimeClock.h" #include "configuration.h" #include "mesh/Throttle.h" +#ifndef ARCH_NRF54L #include #include +#endif #include #include #include #define APP_WATCHDOG_SECS 90 +#ifdef ARCH_NRF54L +// The nRF54L core compiles the nrfx drivers itself (nrfx 3: errno-style returns, 0 is success); +// POWER/RESET registers are split differently. +#include +#include +#define NRFX_OK 0 +#define GPREGRET_REG NRF_POWER->GPREGRET[0] +#define RESETREAS_REG NRF_RESET->RESETREAS +#else #define NRFX_WDT_ENABLED 1 #define NRFX_WDT0_ENABLED 1 #define NRFX_WDT_CONFIG_NO_IRQ 1 #include "nrfx_power.h" +#include +#include +#define GPREGRET_REG NRF_POWER->GPREGRET +#define RESETREAS_REG NRF_POWER->RESETREAS +#define NRFX_OK NRFX_SUCCESS +#endif #include #include #include -#include -#include #include // #include #include "HardwareRNG.h" @@ -71,7 +86,11 @@ __attribute__((noinline)) bool variant_enableBatteryLpcompWake() return true; } +#ifdef ARCH_NRF54L +static nrfx_wdt_t nrfx_wdt = NRFX_WDT_INSTANCE(NRF_WDT31); +#else static nrfx_wdt_t nrfx_wdt = NRFX_WDT_INSTANCE(0); +#endif static nrfx_wdt_channel_id nrfx_wdt_channel_id_nrf52_main; // This is a public global so that the debugger can set it to false automatically from our gdbinit @@ -89,7 +108,11 @@ static inline void debugger_break(void) // PowerHAL NRF52 specific function implementations bool powerHAL_isVBUSConnected() { +#ifdef ARCH_NRF54L + return false; // no USB peripheral +#else return NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk; +#endif } bool powerHAL_isPowerLevelSafe() @@ -138,8 +161,10 @@ void powerHAL_platformInit() // I did experiments with bench power supply and no matter what is set to POFCON, it always triggers right below // 2.8V. I compared raw registry values with datasheet. +#ifndef ARCH_NRF54L NRF_POWER->POFCON = ((POWER_POFCON_THRESHOLD_V22 << POWER_POFCON_THRESHOLD_Pos) | (POWER_POFCON_POF_Enabled << POWER_POFCON_POF_Pos)); +#endif // remember to always match VBAT_AR_INTERNAL with AREF_VALUE in variant definition file #ifdef VBAT_AR_INTERNAL @@ -183,7 +208,8 @@ bool loopCanSleep() void __attribute__((noreturn)) __assert_func(const char *file, int line, const char *func, const char *failedexpr) { LOG_ERROR("assert failed %s: %d, %s, test=%s", file, line, func, failedexpr); - // debugger_break(); FIXME doesn't work, possibly not for segger + Serial.flush(); // the reset below would cut the message short + // debugger_break(); FIXME doesn't work, possibly for segger // Reboot cpu NVIC_SystemReset(); } @@ -203,7 +229,11 @@ bool getDeviceId(uint8_t *deviceId) { // Nordic burns a FIPS-compliant random id into each chip at the factory. We concatenate // the device address to that random id to form the 16-byte hardware identifier. +#ifdef ARCH_NRF54L + uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; +#else uint64_t device_id_start = ((uint64_t)NRF_FICR->DEVICEID[1] << 32) | NRF_FICR->DEVICEID[0]; +#endif uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; memcpy(deviceId, &device_id_start, sizeof(device_id_start)); memcpy(deviceId + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); @@ -294,9 +324,10 @@ void preFSBegin() { // The GPREGRET register keeps its value across warm boots. Check that this is a warm boot and, if GPREGRET // is set to NRF52_MAGIC_LFS_IS_CORRUPT, format LittleFS. - if (!(NRF_POWER->RESETREAS == 0 && NRF_POWER->GPREGRET == NRF52_MAGIC_LFS_IS_CORRUPT)) + if (!(RESETREAS_REG == 0 && GPREGRET_REG == NRF52_MAGIC_LFS_IS_CORRUPT)) return; - NRF_POWER->GPREGRET = 0; + GPREGRET_REG = 0; + // unset-sentinel-ok: formatted_this_boot carries the armed state, so 0 is a legal stamp last_format_ms = Time::getMillis(); formatted_this_boot = true; InternalFS.format(); @@ -333,7 +364,7 @@ extern "C" void lfs_assert(const char *reason) if (!NRF_POWER->EVENTS_POFWARN) { if (!(sd_power_gpregret_clr(0, 0xFF) == NRF_SUCCESS && sd_power_gpregret_set(0, NRF52_MAGIC_LFS_IS_CORRUPT) == NRF_SUCCESS)) { - NRF_POWER->GPREGRET = NRF52_MAGIC_LFS_IS_CORRUPT; + GPREGRET_REG = NRF52_MAGIC_LFS_IS_CORRUPT; } } @@ -343,6 +374,9 @@ extern "C" void lfs_assert(const char *reason) NVIC_SystemReset(); } +// Defined by the core's InternalFileSystem, completes a pending sd_flash_write() +extern "C" void flash_nrf5x_event_cb(uint32_t event); + void checkSDEvents() { if (useSoftDevice) { @@ -352,6 +386,21 @@ void checkSDEvents() case NRF_EVT_POWER_FAILURE_WARNING: RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_BROWNOUT); break; + // Bluefruit's SoC task polls the same queue; an event taken here must still reach the flash driver + case NRF_EVT_FLASH_OPERATION_SUCCESS: + case NRF_EVT_FLASH_OPERATION_ERROR: + flash_nrf5x_event_cb(evt); + break; +#ifdef ARCH_NRF54L + case NRF_EVT_RAND_SEED_REQUEST: { + uint8_t seed[SD_RAND_SEED_SIZE]; + nRF54Crypto.begin(); + if (nRF54Crypto.random(seed, sizeof(seed))) + sd_rand_seed_set(seed); + nRF54Crypto.end(); + break; + } +#endif default: LOG_DEBUG("Unexpected SDevt %d", evt); @@ -451,6 +500,11 @@ void nrf52Setup() // Set up nrfx watchdog. Do not enable the watchdog yet (we do that // the first time through the main loop), so that other threads can // allocate their own wdt channel to protect themselves from hangs. +#ifdef ARCH_NRF54L + // nrfx 3: behaviour is a RUN_* mask (0 = pause in sleep and halt), init takes a context argument + nrfx_wdt_config_t wdt0_config = {.behaviour = 0, .reload_value = APP_WATCHDOG_SECS * 1000}; + int r = nrfx_wdt_init(&nrfx_wdt, &wdt0_config, nullptr, nullptr); +#else nrfx_wdt_config_t wdt0_config = { .behaviour = NRF_WDT_BEHAVIOUR_PAUSE_SLEEP_HALT, .reload_value = APP_WATCHDOG_SECS * 1000, // Note: Not using wdt interrupts. @@ -459,10 +513,11 @@ void nrf52Setup() nrfx_err_t r = nrfx_wdt_init(&nrfx_wdt, &wdt0_config, nullptr // Watchdog event handler, not used, we just reset. ); - assert(r == NRFX_SUCCESS); +#endif + assert(r == NRFX_OK); r = nrfx_wdt_channel_alloc(&nrfx_wdt, &nrfx_wdt_channel_id_nrf52_main); - assert(r == NRFX_SUCCESS); + assert(r == NRFX_OK); } void cpuDeepSleep(uint32_t msecToWake) @@ -540,11 +595,16 @@ void cpuDeepSleep(uint32_t msecToWake) } #endif +#ifdef ARCH_NRF54L + // s145 has no sd_power_system_off(); REGULATORS is not SoftDevice-restricted + NRF_REGULATORS->SYSTEMOFF = 1; +#else auto ok = sd_power_system_off(); if (ok != NRF_SUCCESS) { LOG_ERROR("FIXME: Ignoring soft device (EasyDMA pending?) and forcing system-off"); NRF_POWER->SYSTEMOFF = 1; } +#endif } // The following code should not be run, because we are off diff --git a/src/platform/nrf54l15/Arduino.h b/src/platform/nrf54l15/Arduino.h deleted file mode 100644 index 0d7449e891..0000000000 --- a/src/platform/nrf54l15/Arduino.h +++ /dev/null @@ -1,835 +0,0 @@ -/** - * Arduino.h - Zephyr compatibility shim for nRF54L15 - * - * Provides the Arduino API surface expected by Meshtastic, backed by - * Zephyr primitives. Only the subset actually used by Meshtastic is - * implemented; the rest compiles as no-ops / stubs for now. - * - * Phase 2: compile only. Real GPIO / SPI / Wire implementations follow - * in Phase 3 once the build is clean. - */ - -#pragma once -#ifndef Arduino_h -#define Arduino_h - -// ── C standard headers ─────────────────────────────────────────────────────── -#include -#include -#include -#include -#include -#include -#include -#include /* strcasecmp, strncasecmp */ - -// ── Zephyr kernel ──────────────────────────────────────────────────────────── -#include -#include - -// ── Basic Arduino types ────────────────────────────────────────────────────── -typedef bool boolean; -typedef uint8_t byte; -typedef uint16_t word; - -// ── Pin / digital constants ────────────────────────────────────────────────── -#define INPUT 0u -#define OUTPUT 1u -#define INPUT_PULLUP 2u -#define INPUT_PULLDOWN 3u -#define OUTPUT_OPENDRAIN 4u - -#define HIGH 1u -#define LOW 0u - -#define CHANGE 1 -#define FALLING 2 -#define RISING 3 - -#ifndef LED_BUILTIN -#define LED_BUILTIN -1 -#endif - -// ── Math / trig constants ──────────────────────────────────────────────────── -#ifndef PI -#define PI 3.14159265358979323846 -#endif -#define HALF_PI 1.57079632679489661923 -#define TWO_PI 6.28318530717958647693 -#define DEG_TO_RAD 0.01745329251994329576 -#define RAD_TO_DEG 57.2957795130823208767 -#define EULER 2.71828182845904523536 - -// ── Bit utilities ──────────────────────────────────────────────────────────── -#define bitRead(v, b) (((v) >> (b)) & 1) -#define bitSet(v, b) ((v) |= (1UL << (b))) -#define bitClear(v, b) ((v) &= ~(1UL << (b))) -#define bitToggle(v, b) ((v) ^= (1UL << (b))) -#define bitWrite(v, b, x) ((x) ? bitSet(v, b) : bitClear(v, b)) -#define bit(b) (1UL << (b)) -#define lowByte(w) ((uint8_t)((w)&0xff)) -#define highByte(w) ((uint8_t)((w) >> 8)) -// word(h,l) - only define if not already defined (conflicts with typedef above) -#undef word -#define word(h, l) ((uint16_t)(((h) << 8) | (l))) - -// ── UART config constants ───────────────────────────────────────────────────── -#define SERIAL_8N1 0x800001cu -#define SERIAL_8N2 0x8000001eu -#define SERIAL_8E1 0x8000001eu -#define SERIAL_7E1 0x8000001cu - -// ── Integer order ──────────────────────────────────────────────────────────── -// Adafruit BusIO's SPIDevice.h has `typedef BitOrder BusIOBitOrder;` which -// requires BitOrder to be a *type*, not a macro. Mirror the ArduinoCore-API -// enum definition rather than #defines. -enum BitOrder : uint8_t { - LSBFIRST = 0, - MSBFIRST = 1, -}; - -// ── pgmspace compatibility (no-ops on Cortex-M) ────────────────────────────── -#define PROGMEM -#define PSTR(s) (s) -#define F(s) (s) -#define pgm_read_byte(addr) (*((const uint8_t *)(addr))) -#define pgm_read_word(addr) (*((const uint16_t *)(addr))) -#define pgm_read_dword(addr) (*((const uint32_t *)(addr))) -#define pgm_read_float(addr) (*((const float *)(addr))) -#define pgm_read_ptr(addr) (*((const void **)(addr))) -#define strlen_P(s) strlen(s) -#define strcpy_P(d, s) strcpy(d, s) -#define strncpy_P(d, s, n) strncpy(d, s, n) -#define strcmp_P(a, b) strcmp(a, b) -#define memcpy_P(d, s, n) memcpy(d, s, n) -#define sprintf_P sprintf -typedef const char *PGM_P; -typedef const char *PGM_VOID_P; - -// ── Arduino numeric base constants (used by Print, RadioLib, etc.) ─────────── -#define DEC 10 -#define HEX 16 -#define OCT 8 -#define BIN 2 - -// ── ulong / uint typedef (used by RadioLibInterface, etc.) ─────────────────── -typedef unsigned long ulong; -typedef unsigned int uint; - -// ── Interrupt stubs ────────────────────────────────────────────────────────── -static inline void interrupts() {} -static inline void noInterrupts() {} -#define digitalPinToInterrupt(p) (p) - -// ── portMAX_DELAY - freertosinc.h also defines this; let it win ────────────── -// We intentionally do NOT define portMAX_DELAY here. freertosinc.h defines -// it for the FreeRTOS / Meshtastic threading layer and must not be overridden. - -// ── Timing & system functions - declared with C linkage ────────────────────── -// buzz.cpp and others forward-declare delay() as extern "C"; keep linkage -// consistent by wrapping in extern "C" here. -#ifdef __cplusplus -extern "C" { -#endif -void NVIC_SystemReset(void); -uint32_t millis(void); -uint32_t micros(void); -void delay(uint32_t ms); -void delayMicroseconds(uint32_t us); -void yield(void); -#ifdef __cplusplus -} -#endif - -#ifdef __cplusplus - -#include -#include - -// ── C++ STL - include BEFORE defining any min/max helpers ─────────────────── -// Include algorithm first so its min/max templates are in scope. -// We must NOT define min/max as function-like macros: the C++ STL uses -// 3-argument versions (min(a,b,comp)) that the preprocessor would treat as -// calling a 2-arg macro with 3 args. -#include -// Bring 2-arg std::min / std::max into the global namespace as unqualified -// names so that Arduino code calling min(a,b) continues to compile. -// (Arduino convention; kept minimal to avoid surprises.) -#undef min -#undef max -using std::max; -using std::min; - -// ── Arduino math helpers (macros safe for mixed-type / C calls) ────────────── -#ifndef abs -#define abs(x) ((x) >= 0 ? (x) : -(x)) -#endif -#define constrain(x, l, h) ((x) < (l) ? (l) : ((x) > (h) ? (h) : (x))) -#define round(x) ((x) >= 0 ? (long)((x) + 0.5) : (long)((x)-0.5)) -#define radians(d) ((d)*DEG_TO_RAD) -#define degrees(r) ((r)*RAD_TO_DEG) -#define sq(x) ((x) * (x)) - -// ── Random ─────────────────────────────────────────────────────────────────── -static inline void randomSeed(unsigned long seed) -{ - srand((unsigned int)seed); -} -static inline long random(void) -{ - return (long)rand(); -} -static inline long random(long bound) -{ - return bound > 0 ? (rand() % bound) : 0; -} -static inline long random(long lo, long hi) -{ - return hi > lo ? lo + rand() % (hi - lo) : lo; -} - -// ── GPIO - real Zephyr implementation (Phase 3) ────────────────────────────── -// Implemented in nrf54l15_arduino.cpp using Zephyr GPIO/SPI APIs. -// Pin numbering: P0.n = n, P1.n = 16+n, P2.n = 32+n -void pinMode(uint32_t pin, uint32_t mode); -void digitalWrite(uint32_t pin, uint32_t value); -int digitalRead(uint32_t pin); -static inline void digitalToggle(uint32_t pin) -{ - digitalWrite(pin, !digitalRead(pin)); -} -static inline uint32_t analogRead(uint32_t) -{ - return 0; -} -static inline void analogWrite(uint32_t, uint32_t) {} -static inline void analogReadResolution(int) {} -static inline void analogWriteResolution(int) {} - -// ── __WFI - provided by CMSIS core_cm33.h; do NOT redefine here ───────────── - -// ── __FlashStringHelper - Arduino PROGMEM string class (no-op on Cortex-M) ── -class __FlashStringHelper; - -// ── attachInterrupt / detachInterrupt - real Zephyr GPIO interrupt impl ────── -typedef void (*voidFuncPtr)(void); -void attachInterrupt(uint32_t pin, voidFuncPtr cb, int mode); -void detachInterrupt(uint32_t pin); - -// ── Forward declaration of String (needed by Print / Stream) ───────────────── -class String; - -// ── Print base class ───────────────────────────────────────────────────────── -class Print -{ - public: - virtual size_t write(uint8_t c) = 0; - virtual size_t write(const uint8_t *buf, size_t n) - { - size_t written = 0; - while (n--) - written += write(*buf++); - return written; - } - size_t write(const char *s) { return s ? write((const uint8_t *)s, strlen(s)) : 0; } - size_t write(const char *s, size_t n) { return write((const uint8_t *)s, n); } - - size_t print(const char *s) { return s ? write((const uint8_t *)s, strlen(s)) : 0; } - int printf(const char *fmt, ...) __attribute__((format(printf, 2, 3))); - - size_t print(char c) { return write((uint8_t)c); } - size_t print(const String &s); - size_t print(unsigned char n, int base = 10); - size_t print(int n, int base = 10); - size_t print(long n, int base = 10); - size_t print(unsigned int n, int base = 10); - size_t print(unsigned long n, int base = 10); - size_t print(float n, int digits = 2); - size_t print(double n, int digits = 2); - size_t print(bool b) { return print(b ? "true" : "false"); } - - size_t println() { return write((uint8_t)'\n'); } - size_t println(const char *s) - { - size_t r = print(s); - return r + println(); - } - size_t println(char c) - { - size_t r = print(c); - return r + println(); - } - size_t println(const String &s); - size_t println(int n, int base = 10) - { - size_t r = print(n, base); - return r + println(); - } - size_t println(long n, int base = 10) - { - size_t r = print(n, base); - return r + println(); - } - size_t println(unsigned long n, int base = 10) - { - size_t r = print(n, base); - return r + println(); - } - size_t println(unsigned int n, int base = 10) - { - size_t r = print(n, base); - return r + println(); - } - size_t println(float n, int d = 2) - { - size_t r = print(n, d); - return r + println(); - } - size_t println(double n, int d = 2) - { - size_t r = print(n, d); - return r + println(); - } - size_t println(bool b) - { - size_t r = print(b); - return r + println(); - } - - virtual void flush() {} - virtual int availableForWrite() { return 0; } -}; - -// ── Stream base class ──────────────────────────────────────────────────────── -class Stream : public Print -{ - public: - virtual int available() = 0; - virtual int read() = 0; - virtual int peek() = 0; - virtual void setTimeout(unsigned long) {} - virtual bool find(const char *) { return false; } - String readString(); - String readStringUntil(char terminator); -}; - -// ── Minimal Arduino String class (backed by a char buffer) ─────────────────── -class String -{ - public: - String() : _buf(nullptr), _len(0), _cap(0) {} - // Implicit conversion is part of the Arduino String contract, used pervasively across the codebase. - // cppcheck-suppress noExplicitConstructor - String(const char *cstr) : _buf(nullptr), _len(0), _cap(0) - { - if (cstr) - assign(cstr, strlen(cstr)); - } - // cppcheck-suppress noExplicitConstructor - String(const String &s) : _buf(nullptr), _len(0), _cap(0) { assign(s._buf ? s._buf : "", s._len); } - // cppcheck-suppress noExplicitConstructor - String(char c) : _buf(nullptr), _len(0), _cap(0) - { - const char tmp[2] = {c, 0}; - assign(tmp, 1); - } - // cppcheck-suppress noExplicitConstructor - String(int n) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[16]; - snprintf(tmp, 16, "%d", n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(unsigned int n) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[16]; - snprintf(tmp, 16, "%u", n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(long n) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[24]; - snprintf(tmp, 24, "%ld", n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(unsigned long n) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[24]; - snprintf(tmp, 24, "%lu", n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(float n, int d = 2) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[32]; - snprintf(tmp, 32, "%.*f", d, n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(double n, int d = 2) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[32]; - snprintf(tmp, 32, "%.*f", d, (double)n); - assign(tmp, strlen(tmp)); - } - ~String() { free(_buf); } - - String &operator=(const String &s) - { - assign(s._buf ? s._buf : "", s._len); - return *this; - } - String &operator=(const char *s) - { - assign(s ? s : "", s ? strlen(s) : 0); - return *this; - } - String &operator=(char c) - { - const char tmp[2] = {c, 0}; - assign(tmp, 1); - return *this; - } - - String &operator+=(const String &s) - { - concat(s._buf ? s._buf : "", s._len); - return *this; - } - String &operator+=(const char *s) - { - if (s) - concat(s, strlen(s)); - return *this; - } - String &operator+=(char c) - { - concat(&c, 1); - return *this; - } - String &operator+=(int n) { return *this += String(n); } - String &operator+=(unsigned long n) { return *this += String(n); } - - String operator+(const String &rhs) const - { - String r(*this); - r += rhs; - return r; - } - String operator+(const char *rhs) const - { - String r(*this); - r += rhs; - return r; - } - String operator+(char rhs) const - { - String r(*this); - r += rhs; - return r; - } - - bool operator==(const String &s) const { return _len == s._len && (_len == 0 || strcmp(_buf, s._buf) == 0); } - bool operator==(const char *s) const { return s && strcmp(c_str(), s) == 0; } - bool operator!=(const String &s) const { return !(*this == s); } - bool operator!=(const char *s) const { return !(*this == s); } - bool operator<(const String &s) const { return strcmp(c_str(), s.c_str()) < 0; } - bool operator>(const String &s) const { return strcmp(c_str(), s.c_str()) > 0; } - - char operator[](unsigned int i) const { return (_buf && i < _len) ? _buf[i] : 0; } - char &operator[](unsigned int i) - { - static char dummy = 0; - return (_buf && i < _len) ? _buf[i] : dummy; - } - - const char *c_str() const { return _buf ? _buf : ""; } - unsigned int length() const { return _len; } - bool isEmpty() const { return _len == 0; } - bool equals(const String &s) const { return *this == s; } - bool equals(const char *s) const { return *this == s; } - bool equalsIgnoreCase(const String &s) const - { - if (_len != s._len) - return false; - for (unsigned i = 0; i < _len; i++) - if (std::tolower(_buf[i]) != std::tolower(s._buf[i])) - return false; - return true; - } - bool startsWith(const String &pfx) const - { - if (pfx._len > _len) - return false; - return strncmp(c_str(), pfx.c_str(), pfx._len) == 0; - } - bool startsWith(const char *pfx) const - { - if (!pfx) - return false; - size_t pl = strlen(pfx); - return pl <= _len && strncmp(c_str(), pfx, pl) == 0; - } - bool endsWith(const String &sfx) const - { - if (sfx._len > _len) - return false; - return strcmp(c_str() + _len - sfx._len, sfx.c_str()) == 0; - } - int indexOf(char c, unsigned from = 0) const - { - if (!_buf) - return -1; - const char *p = strchr(_buf + from, c); - return p ? (int)(p - _buf) : -1; - } - int indexOf(const String &s, unsigned from = 0) const - { - if (!_buf) - return -1; - const char *p = strstr(_buf + from, s.c_str()); - return p ? (int)(p - _buf) : -1; - } - int lastIndexOf(char c) const - { - if (!_buf) - return -1; - const char *p = strrchr(_buf, c); - return p ? (int)(p - _buf) : -1; - } - String substring(unsigned beginIndex) const - { - if (!_buf || beginIndex >= _len) - return String(); - return String(_buf + beginIndex); - } - String substring(unsigned beginIndex, unsigned endIndex) const - { - if (!_buf || beginIndex >= _len) - return String(); - if (endIndex > _len) - endIndex = _len; - if (endIndex <= beginIndex) - return String(); - String r; - r.assign(_buf + beginIndex, endIndex - beginIndex); - return r; - } - void toUpperCase() - { - if (_buf) - for (unsigned i = 0; i < _len; i++) - _buf[i] = (char)std::toupper(_buf[i]); - } - void toLowerCase() - { - if (_buf) - for (unsigned i = 0; i < _len; i++) - _buf[i] = (char)std::tolower(_buf[i]); - } - void trim() - { - if (!_buf || _len == 0) - return; - unsigned s = 0; - while (s < _len && std::isspace(_buf[s])) - s++; - unsigned e = _len; - while (e > s && std::isspace(_buf[e - 1])) - e--; - if (s > 0 || e < _len) { - memmove(_buf, _buf + s, e - s); - _len = e - s; - _buf[_len] = 0; - } - } - void replace(char from, char to) - { - if (_buf) - for (unsigned i = 0; i < _len; i++) - if (_buf[i] == from) - _buf[i] = to; - } - void replace(const String &from, const String &to); - bool remove(unsigned index, unsigned count = 1) - { - if (!_buf || index >= _len) - return false; - if (index + count > _len) - count = _len - index; - memmove(_buf + index, _buf + index + count, _len - index - count + 1); - _len -= count; - return true; - } - void clear() - { - _len = 0; - if (_buf) - _buf[0] = 0; - } - char charAt(unsigned i) const { return (*this)[i]; } - void setCharAt(unsigned i, char c) - { - if (_buf && i < _len) - _buf[i] = c; - } - void toCharArray(char *buf, unsigned int bufsize, unsigned int index = 0) const - { - if (!buf || bufsize == 0) - return; - unsigned int avail = (_buf && _len > index) ? (_len - index) : 0; - unsigned int copy = avail < bufsize - 1 ? avail : bufsize - 1; - if (copy > 0) - memcpy(buf, _buf + index, copy); - buf[copy] = '\0'; - } - void concat(const String &s) { *this += s; } - void concat(const char *s) { *this += s; } - long toInt() const { return _buf ? atol(_buf) : 0; } - float toFloat() const { return _buf ? (float)atof(_buf) : 0.0f; } - double toDouble() const { return _buf ? atof(_buf) : 0.0; } - - private: - char *_buf; - unsigned int _len; - unsigned int _cap; - - void assign(const char *s, unsigned int n) - { - // reserve() keeps the old (smaller) buffer on OOM, so a failed grow must abort the - // write: memcpy'ing n >= _cap bytes would overflow into adjacent heap. - if (n + 1 == 0) - return; // n + 1 would wrap - if (n >= _cap && !reserve(n + 1)) - return; - if (_buf) { - memcpy(_buf, s, n); - _buf[n] = 0; - _len = n; - } - } - void concat(const char *s, unsigned int n) - { - if (!s || n == 0) - return; - unsigned newlen = _len + n; - if (newlen < _len || newlen + 1 == 0) - return; // length arithmetic wrapped - if (newlen >= _cap && !reserve(newlen + 1)) - return; // OOM: keep the existing content intact instead of writing past the buffer - if (_buf) { - memcpy(_buf + _len, s, n); - _len = newlen; - _buf[_len] = 0; - } - } - bool reserve(unsigned int n) - { - if (n == 0) - return false; - char *b = (char *)realloc(_buf, n); - if (b) { - _buf = b; - _cap = n; - return true; - } - return false; - } -}; - -inline String operator+(const char *lhs, const String &rhs) -{ - return String(lhs) + rhs; -} -inline String operator+(char lhs, const String &rhs) -{ - return String(lhs) + rhs; -} - -// ── Print inline definitions that need String ──────────────────────────────── -inline size_t Print::print(const String &s) -{ - return write((const uint8_t *)s.c_str(), s.length()); -} -inline size_t Print::println(const String &s) -{ - size_t r = print(s); - return r + println(); -} - -// ── Stream inline definitions that need String ─────────────────────────────── -inline String Stream::readString() -{ - return String(); -} -inline String Stream::readStringUntil(char) -{ - return String(); -} - -// ── HardwareSerial ─────────────────────────────────────────────────────────── -class HardwareSerial : public Stream -{ - public: - void begin(unsigned long) {} - void begin(unsigned long, uint16_t) {} - void end() {} - void setPins(int rx, int tx) {} - void setPinout(int tx, int rx) {} - void setFIFOSize(size_t) {} - void setRxBufferSize(size_t) {} - void begin(unsigned long baud, uint32_t config, int8_t rx = -1, int8_t tx = -1, bool invert = false) {} - int available() override { return 0; } - int read() override { return -1; } - int peek() override { return -1; } - size_t write(uint8_t c) override; - size_t write(const uint8_t *buf, size_t n) override; - using Print::write; // un-hide base class write(const char*) - size_t readBytes(uint8_t *buf, size_t len) { return 0; } - size_t readBytes(char *buf, size_t len) { return 0; } - operator bool() const { return true; } - void flush() override {} - String readString() { return String(); } - String readStringUntil(char) { return String(); } -}; - -// Uart - nRF52 BSP alias for HardwareSerial (used by GPS.h when ARCH_NRF52) -typedef HardwareSerial Uart; - -extern HardwareSerial Serial; -extern HardwareSerial Serial1; -extern HardwareSerial Serial2; - -// ── map() utility ──────────────────────────────────────────────────────────── -static inline long map(long x, long in_min, long in_max, long out_min, long out_max) -{ - return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; -} - -// ── shiftIn / shiftOut stubs ───────────────────────────────────────────────── -static inline uint8_t shiftIn(uint8_t, uint8_t, uint8_t) -{ - return 0; -} -static inline void shiftOut(uint8_t, uint8_t, uint8_t, uint8_t) {} - -// ── tone / noTone stubs ────────────────────────────────────────────────────── -static inline void tone(uint8_t, unsigned int, unsigned long = 0) {} -static inline void noTone(uint8_t) {} - -// ── pulseIn stub ───────────────────────────────────────────────────────────── -static inline unsigned long pulseIn(uint8_t, uint8_t, unsigned long = 1000000UL) -{ - return 0; -} - -// ── strdup / stpcpy - POSIX extensions not in Zephyr newlib ───────────────── -#ifndef strdup -static inline char *strdup(const char *s) -{ - size_t n = strlen(s) + 1; - char *d = (char *)malloc(n); - if (d) - memcpy(d, s, n); - return d; -} -#endif -#ifndef stpcpy -static inline char *stpcpy(char *dst, const char *src) -{ - while ((*dst++ = *src++) != '\0') { - } - return dst - 1; -} -#endif - -// ── strnstr - BSD extension not in Zephyr libc; defined in meshUtils.cpp ───── -// Declare here so callers (GPS.cpp etc.) don't need ARCH_PORTDUINO. -#ifndef STRNSTR -#define STRNSTR -char *strnstr(const char *s, const char *find, size_t slen); -#endif - -// ── strlcpy - BSD extension; implementation in nrf54l15_arduino.cpp ────────── -#ifndef HAVE_STRLCPY -#define HAVE_STRLCPY -#ifdef __cplusplus -extern "C" { -#endif -size_t strlcpy(char *dst, const char *src, size_t size); -#ifdef __cplusplus -} -#endif -#endif - -// ── setenv / getenv / tzset - Zephyr stubs for timezone support ────────────── -#include -static inline int setenv(const char *, const char *, int) -{ - return 0; -} -static inline void tzset(void) {} - -// ── dbgHeapFree / dbgHeapTotal - nRF52 BSP heap diagnostics ───────────────── -// Used by memGet.cpp when ARCH_NRF52 is defined. Return 0 for Phase 2. -static inline uint32_t dbgHeapFree(void) -{ - return 0; -} -static inline uint32_t dbgHeapTotal(void) -{ - return 0; -} - -// ── WCharacter helpers ─────────────────────────────────────────────────────── -static inline bool isAlpha(char c) -{ - return std::isalpha((unsigned char)c) != 0; -} -static inline bool isAlphaNumeric(char c) -{ - return std::isalnum((unsigned char)c) != 0; -} -static inline bool isDigit(char c) -{ - return std::isdigit((unsigned char)c) != 0; -} -static inline bool isSpace(char c) -{ - return std::isspace((unsigned char)c) != 0; -} -static inline bool isUpperCase(char c) -{ - return std::isupper((unsigned char)c) != 0; -} -static inline bool isLowerCase(char c) -{ - return std::islower((unsigned char)c) != 0; -} -static inline char toUpperCase(char c) -{ - return (char)std::toupper((unsigned char)c); -} -static inline char toLowerCase(char c) -{ - return (char)std::tolower((unsigned char)c); -} - -#else /* C only */ -#ifndef min -#define min(a, b) ((a) < (b) ? (a) : (b)) -#endif -#ifndef max -#define max(a, b) ((a) > (b) ? (a) : (b)) -#endif -#ifndef abs -#define abs(x) ((x) >= 0 ? (x) : -(x)) -#endif -#define constrain(x, l, h) ((x) < (l) ? (l) : ((x) > (h) ? (h) : (x))) -#define round(x) ((x) >= 0 ? (long)((x) + 0.5) : (long)((x)-0.5)) -#endif /* __cplusplus */ - -#endif /* Arduino_h */ diff --git a/src/platform/nrf54l15/IPAddress.h b/src/platform/nrf54l15/IPAddress.h deleted file mode 100644 index 549d11d157..0000000000 --- a/src/platform/nrf54l15/IPAddress.h +++ /dev/null @@ -1,34 +0,0 @@ -// IPAddress.h - stub for nRF54L15/Zephyr -// MQTT.cpp includes this for IPv4 address representation. -// Phase 2: compile-only stub. -#pragma once -#include - -class IPAddress -{ - public: - IPAddress() : _addr(0) {} - explicit IPAddress(uint32_t addr) : _addr(addr) {} - IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) - : _addr(((uint32_t)a) | ((uint32_t)b << 8) | ((uint32_t)c << 16) | ((uint32_t)d << 24)) - { - } - - uint8_t operator[](int i) const { return reinterpret_cast(&_addr)[i]; } - operator uint32_t() const { return _addr; } - bool operator==(const IPAddress &o) const { return _addr == o._addr; } - bool operator!=(const IPAddress &o) const { return _addr != o._addr; } - - bool fromString(const char *addr) - { - unsigned a, b, c, d; - if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4 && a <= 255 && b <= 255 && c <= 255 && d <= 255) { - _addr = a | (b << 8) | (c << 16) | (d << 24); - return true; - } - return false; - } - - private: - uint32_t _addr; -}; diff --git a/src/platform/nrf54l15/InternalFileSystem.cpp b/src/platform/nrf54l15/InternalFileSystem.cpp deleted file mode 100644 index 15fdec6df6..0000000000 --- a/src/platform/nrf54l15/InternalFileSystem.cpp +++ /dev/null @@ -1,274 +0,0 @@ -// InternalFileSystem.cpp - Zephyr LittleFS backend for nRF54L15 -// -// Implements Adafruit_LittleFS_Namespace used by FSCommon.h/cpp. -// Storage: 36 KB storage_partition in nRF54L15 internal RRAM (defined in -// zephyr/dts/nordic/nrf54l15_partition.dtsi, included by the board DTS). - -#include "InternalFileSystem.h" -#include "configuration.h" - -#include -#include -#include - -using namespace Adafruit_LittleFS_Namespace; - -// ── LittleFS mount ──────────────────────────────────────────────────────── - -FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(nrf54l15_lfs_data); - -static struct fs_mount_t _lfs_mnt = { - .type = FS_LITTLEFS, - .mnt_point = NRF54L15_FS_MOUNT, - .fs_data = &nrf54l15_lfs_data, - .storage_dev = (void *)(uintptr_t)FIXED_PARTITION_ID(storage_partition), - .flags = 0, -}; - -// ── Global singleton ────────────────────────────────────────────────────── - -Adafruit_LittleFS_Namespace::InternalFileSystem InternalFS; - -// ── Path helpers ────────────────────────────────────────────────────────── - -void InternalFileSystem::toabs(const char *rel, char *abs, size_t abssz) -{ - // Root "/" maps to the mount point itself (no trailing slash) - if (rel[0] == '/' && rel[1] == '\0') { - strncpy(abs, NRF54L15_FS_MOUNT, abssz - 1); - abs[abssz - 1] = '\0'; - } else if (rel[0] == '/') { - snprintf(abs, abssz, "%s%s", NRF54L15_FS_MOUNT, rel); - } else { - snprintf(abs, abssz, "%s/%s", NRF54L15_FS_MOUNT, rel); - } -} - -// Strip mount-point prefix to get the FS-root-relative path ("/prefs/..."). -static void torel(const char *abs, char *rel, size_t relsz) -{ - const char *mp = NRF54L15_FS_MOUNT; - size_t mplen = strlen(mp); - if (strncmp(abs, mp, mplen) == 0) { - const char *suffix = abs + mplen; - if (suffix[0] == '\0') { - strncpy(rel, "/", relsz); - } else { - strncpy(rel, suffix, relsz - 1); - rel[relsz - 1] = '\0'; - } - } else { - strncpy(rel, abs, relsz - 1); - rel[relsz - 1] = '\0'; - } -} - -// ── InternalFileSystem methods ──────────────────────────────────────────── - -bool InternalFileSystem::begin() -{ - if (_mounted) - return true; - - int rc = fs_mount(&_lfs_mnt); - if (rc == 0) { - _mounted = true; - return true; - } - - // Mount failed: attempt to format (creates a fresh LittleFS) - LOG_WARN("LittleFS mount failed (%d), formatting storage partition", rc); - int fmt_rc = fs_mkfs(FS_LITTLEFS, (uintptr_t)FIXED_PARTITION_ID(storage_partition), NULL, 0); - if (fmt_rc != 0) { - LOG_ERROR("LittleFS format failed (%d)", fmt_rc); - return false; - } - - rc = fs_mount(&_lfs_mnt); - if (rc == 0) { - _mounted = true; - return true; - } - - LOG_ERROR("LittleFS mount failed after format (%d)", rc); - return false; -} - -File InternalFileSystem::open(const char *path, const char *mode) -{ - if (!_mounted) - return File(); - - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - - auto s = std::make_shared(); - if (!s) - return File(); - - strncpy(s->fullpath, abs, sizeof(s->fullpath) - 1); - torel(abs, s->relpath, sizeof(s->relpath)); - - // Check whether the path is a directory - struct fs_dirent entry; - int stat_rc = fs_stat(abs, &entry); - if (stat_rc == 0 && entry.type == FS_DIR_ENTRY_DIR) { - s->is_dir = true; - if (fs_opendir(&s->dir, abs) == 0) { - s->valid = true; - return File(s); - } - return File(); - } - - // Open as a regular file - fs_mode_t flags; - if (strcmp(mode, FILE_O_WRITE) == 0) { - // Truncate on write - unlink first to ensure a clean start - fs_unlink(abs); - flags = FS_O_WRITE | FS_O_CREATE; - } else { - flags = FS_O_READ; - } - - if (fs_open(&s->file, abs, flags) == 0) { - s->is_dir = false; - s->valid = true; - return File(s); - } - - return File(); -} - -bool InternalFileSystem::exists(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - struct fs_dirent entry; - return fs_stat(abs, &entry) == 0; -} - -bool InternalFileSystem::remove(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - return fs_unlink(abs) == 0; -} - -bool InternalFileSystem::rename(const char *from, const char *to) -{ - if (!_mounted) - return false; - char absfrom[NRF54L15_FS_PATHLEN], absto[NRF54L15_FS_PATHLEN]; - toabs(from, absfrom, sizeof(absfrom)); - toabs(to, absto, sizeof(absto)); - return fs_rename(absfrom, absto) == 0; -} - -bool InternalFileSystem::mkdir(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - int rc = fs_mkdir(abs); - return rc == 0 || rc == -EEXIST; -} - -bool InternalFileSystem::rmdir(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - return fs_unlink(abs) == 0; -} - -bool InternalFileSystem::rmdir_r(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - - struct fs_dir_t dir; - fs_dir_t_init(&dir); - if (fs_opendir(&dir, abs) != 0) { - // Not a directory - try to delete as file - return fs_unlink(abs) == 0; - } - - struct fs_dirent entry; - char child[NRF54L15_FS_PATHLEN]; - while (fs_readdir(&dir, &entry) == 0 && entry.name[0] != '\0') { - snprintf(child, sizeof(child), "%s/%s", abs, entry.name); - if (entry.type == FS_DIR_ENTRY_DIR) { - // Recurse: pass the absolute path stripped of mount prefix - char childrel[NRF54L15_FS_PATHLEN]; - torel(child, childrel, sizeof(childrel)); - rmdir_r(childrel); - } else { - fs_unlink(child); - } - } - fs_closedir(&dir); - return fs_unlink(abs) == 0; -} - -bool InternalFileSystem::format() -{ - if (_mounted) { - fs_unmount(&_lfs_mnt); - _mounted = false; - } - int rc = fs_mkfs(FS_LITTLEFS, (uintptr_t)FIXED_PARTITION_ID(storage_partition), NULL, 0); - if (rc != 0) { - LOG_ERROR("LittleFS format failed (%d)", rc); - return false; - } - return begin(); -} - -// ── File::openNextFile ──────────────────────────────────────────────────── -// Defined here because it accesses Zephyr fs_readdir/fs_open APIs. - -File File::openNextFile() -{ - if (!_s || !_s->valid || !_s->is_dir) - return File(); - - struct fs_dirent entry; - if (fs_readdir(&_s->dir, &entry) != 0) - return File(); - if (entry.name[0] == '\0') - return File(); // end of directory - - char childabs[NRF54L15_FS_PATHLEN]; - snprintf(childabs, sizeof(childabs), "%s/%s", _s->fullpath, entry.name); - - auto s = std::make_shared(); - if (!s) - return File(); - - strncpy(s->fullpath, childabs, sizeof(s->fullpath) - 1); - torel(childabs, s->relpath, sizeof(s->relpath)); - - if (entry.type == FS_DIR_ENTRY_DIR) { - s->is_dir = true; - if (fs_opendir(&s->dir, childabs) == 0) { - s->valid = true; - return File(s); - } - } else { - s->is_dir = false; - if (fs_open(&s->file, childabs, FS_O_READ) == 0) { - s->valid = true; - return File(s); - } - } - return File(); -} diff --git a/src/platform/nrf54l15/InternalFileSystem.h b/src/platform/nrf54l15/InternalFileSystem.h deleted file mode 100644 index 8eea7c1cb3..0000000000 --- a/src/platform/nrf54l15/InternalFileSystem.h +++ /dev/null @@ -1,212 +0,0 @@ -// InternalFileSystem.h - Zephyr LittleFS backend for nRF54L15 -// -// Implements the Adafruit InternalFileSystem API subset used by Meshtastic, -// backed by Zephyr's fs/littlefs on the storage_partition of the nRF54L15's -// internal RRAM. Partition size is taken from the DTS at compile time via -// FIXED_PARTITION_SIZE(storage_partition) - the DK overlay currently maps -// ~700 KB into slot1 (see zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay). -// -// Mount point: /lfs -// All paths passed to open/exists/mkdir etc. are relative to the FS root -// (e.g. "/prefs/config.proto") and are prepended with "/lfs" internally. -// -// File objects are copyable via std::shared_ptr. -// The underlying Zephyr handle is closed when the last copy is destroyed. - -#pragma once - -#include -#include -#include -#include - -#include -#include - -#ifndef FILE_O_READ -#define FILE_O_READ "r" -#define FILE_O_WRITE "w" -#endif - -#define NRF54L15_FS_MOUNT "/lfs" -#define NRF54L15_FS_PATHLEN 256 - -namespace Adafruit_LittleFS_Namespace -{ - -class InternalFileSystem; // forward - -// ── Internal file/dir state ─────────────────────────────────────────────── - -struct NRF54L15FileState { - bool valid = false; - bool is_dir = false; - - // Absolute Zephyr path, e.g. "/lfs/prefs/config.proto" - char fullpath[NRF54L15_FS_PATHLEN] = {0}; - // Path from FS root, e.g. "/prefs/config.proto" (returned by name()) - char relpath[NRF54L15_FS_PATHLEN] = {0}; - - struct fs_file_t file; - struct fs_dir_t dir; - - NRF54L15FileState() - { - fs_file_t_init(&file); - fs_dir_t_init(&dir); - } - - ~NRF54L15FileState() - { - if (valid) { - if (is_dir) - fs_closedir(&dir); - else - fs_close(&file); - valid = false; - } - } -}; - -// ── File ───────────────────────────────────────────────────────────────── - -class File -{ - public: - File() = default; - explicit File(InternalFileSystem &) {} // nRF52 compat constructor - - explicit operator bool() const { return _s && _s->valid; } - - int read(void *buf, uint16_t nbyte) - { - if (!_s || !_s->valid || _s->is_dir) - return -1; - ssize_t n = fs_read(&_s->file, buf, nbyte); - return n < 0 ? -1 : (int)n; - } - - int read() - { - uint8_t b; - return read(&b, 1) == 1 ? (int)b : -1; - } - - size_t write(const uint8_t *buf, size_t len) - { - if (!_s || !_s->valid || _s->is_dir) - return 0; - ssize_t n = fs_write(&_s->file, buf, len); - return n < 0 ? 0 : (size_t)n; - } - - size_t write(uint8_t b) { return write(&b, 1); } - - void flush() - { - if (_s && _s->valid && !_s->is_dir) - fs_sync(&_s->file); - } - - void close() { _s.reset(); } - - size_t size() - { - if (!_s || !_s->valid || _s->is_dir) - return 0; - struct fs_dirent entry; - if (fs_stat(_s->fullpath, &entry) == 0) - return (size_t)entry.size; - return 0; - } - - bool isDirectory() { return _s && _s->valid && _s->is_dir; } - - // Returns path from FS root, e.g. "/prefs/config.proto" - const char *name() { return _s ? _s->relpath : ""; } - - // Returns the next entry in a directory. Modifies the dir stream in _s. - File openNextFile(); - - void rewindDirectory() - { - if (!_s || !_s->valid || !_s->is_dir) - return; - // Zephyr has no rewinddir(); close + reopen the same handle. Skipping - // the close would leak the LittleFS dir state and the next openNextFile - // could return stale entries on some Zephyr versions. - fs_closedir(&_s->dir); - fs_dir_t_init(&_s->dir); - if (fs_opendir(&_s->dir, _s->fullpath) != 0) { - _s->valid = false; - } - } - - bool seek(uint32_t pos) - { - if (!_s || !_s->valid || _s->is_dir) - return false; - return fs_seek(&_s->file, (off_t)pos, FS_SEEK_SET) == 0; - } - - int available() - { - if (!_s || !_s->valid || _s->is_dir) - return 0; - off_t pos = fs_tell(&_s->file); - if (pos < 0) - return 0; - struct fs_dirent entry; - if (fs_stat(_s->fullpath, &entry) != 0) - return 0; - long rem = (long)entry.size - (long)pos; - return rem > 0 ? (int)rem : 0; - } - - int peek() { return -1; } - - // Internal: constructed by InternalFileSystem and openNextFile() - explicit File(std::shared_ptr s) : _s(std::move(s)) {} - - private: - std::shared_ptr _s; -}; - -// ── InternalFileSystem ──────────────────────────────────────────────────── - -class InternalFileSystem -{ - public: - bool begin(); - File open(const char *path, const char *mode); - bool exists(const char *path); - bool remove(const char *path); - bool rename(const char *from, const char *to); - bool mkdir(const char *path); - bool rmdir(const char *path); - bool rmdir_r(const char *path); // recursive delete (used by FSCommon rmDir) - uint32_t usedBytes() - { - struct fs_statvfs st = {}; - if (fs_statvfs(NRF54L15_FS_MOUNT, &st) != 0) - return 0; - // Zephyr returns block counts; convert to bytes. f_frsize is the - // fundamental fragment size (LittleFS reports it equal to the block - // size). used = (total - free) * frag_size. - if (st.f_blocks <= st.f_bfree) - return 0; - return (uint32_t)((st.f_blocks - st.f_bfree) * st.f_frsize); - } - uint32_t totalBytes() { return (uint32_t)FIXED_PARTITION_SIZE(storage_partition); } - bool format(); - - // Convert a FS-root-relative path to an absolute Zephyr path. - static void toabs(const char *rel, char *abs, size_t abssz); - - private: - bool _mounted = false; -}; - -} // namespace Adafruit_LittleFS_Namespace - -extern Adafruit_LittleFS_Namespace::InternalFileSystem InternalFS; diff --git a/src/platform/nrf54l15/NRF52Bluetooth.h b/src/platform/nrf54l15/NRF52Bluetooth.h deleted file mode 100644 index 55686d379e..0000000000 --- a/src/platform/nrf54l15/NRF52Bluetooth.h +++ /dev/null @@ -1,18 +0,0 @@ -// NRF52Bluetooth.h - stub for nRF54L15/Zephyr -// main.h includes this when ARCH_NRF52 is defined. -// Bluetooth is excluded (MESHTASTIC_EXCLUDE_BLUETOOTH=1); this satisfies the -// include chain without pulling in the nRF52 Bluefruit SDK. -#pragma once - -class NRF52Bluetooth -{ - public: - void setup() {} - void shutdown() {} - void startDisabled() {} - void resumeAdvertising() {} - void clearBonds() {} - bool isConnected() { return false; } - int getRssi() { return 0; } - void sendLog(const uint8_t *, size_t) {} -}; diff --git a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp b/src/platform/nrf54l15/NRF54L15Bluetooth.cpp deleted file mode 100644 index 9ce0326316..0000000000 --- a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp +++ /dev/null @@ -1,805 +0,0 @@ -// NRF54L15Bluetooth.cpp - Zephyr BLE GATT peripheral for Meshtastic nRF54L15 -// -// GATT profile (identical UUIDs to the nRF52 / NimBLE implementations): -// Service: 6ba1b218-15a8-461f-9fa8-5dcae273eafd -// fromNum: ed9da18c-a800-4f66-a670-aa7547e34453 READ | NOTIFY -// fromRadio: 2c55e69e-4993-11ed-b878-0242ac120002 READ -// toRadio: f75c76d2-129e-4dad-a1dd-7866124401e7 WRITE -// logRadio: 5a3d6e49-06e6-4423-9944-e9de8cdf9547 READ | NOTIFY | INDICATE -// -// Threading model: -// - BT RX thread: connected_cb / disconnected_cb / GATT read_/write_ -// callbacks -// - Meshtastic OSThread scheduler (cooperative, main thread): -// BleDeferredThread -// polls pendingToRadio and runs the zombie-connection watchdog every 100 ms -// - PhoneAPI::onNowHasData: sends fromNum notify synchronously from whichever -// thread pushed the packet (bt_gatt_notify is thread-safe in Zephyr) -// - active_conn protected by ble_mutex where needed - -#include "NRF54L15Bluetooth.h" -#include "BluetoothCommon.h" -#include "BluetoothStatus.h" -#include "PowerFSM.h" -#include "concurrency/OSThread.h" -#include "configuration.h" -#include "main.h" -#include "mesh/PhoneAPI.h" -#include "mesh/mesh-pb-constants.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -// ── UUID definitions (little-endian per Bluetooth spec) -// ─────────────────────── Syntax: replace hyphens with commas, prefix 0x - -// matches BT_UUID_128_ENCODE doc. - -#define MESH_SVC_UUID_VAL BT_UUID_128_ENCODE(0x6ba1b218, 0x15a8, 0x461f, 0x9fa8, 0x5dcae273eafd) -#define FROMNUM_UUID_VAL BT_UUID_128_ENCODE(0xed9da18c, 0xa800, 0x4f66, 0xa670, 0xaa7547e34453) -#define FROMRADIO_UUID_VAL BT_UUID_128_ENCODE(0x2c55e69e, 0x4993, 0x11ed, 0xb878, 0x0242ac120002) -#define TORADIO_UUID_VAL BT_UUID_128_ENCODE(0xf75c76d2, 0x129e, 0x4dad, 0xa1dd, 0x7866124401e7) -#define LOGRADIO_UUID_VAL BT_UUID_128_ENCODE(0x5a3d6e49, 0x06e6, 0x4423, 0x9944, 0xe9de8cdf9547) - -static const struct bt_uuid_128 mesh_svc_uuid = BT_UUID_INIT_128(MESH_SVC_UUID_VAL); -static const struct bt_uuid_128 fromnum_uuid = BT_UUID_INIT_128(FROMNUM_UUID_VAL); -static const struct bt_uuid_128 fromradio_uuid = BT_UUID_INIT_128(FROMRADIO_UUID_VAL); -static const struct bt_uuid_128 toradio_uuid = BT_UUID_INIT_128(TORADIO_UUID_VAL); -static const struct bt_uuid_128 logradio_uuid = BT_UUID_INIT_128(LOGRADIO_UUID_VAL); - -// ── Module state ───────────────────────────────────────────────────────────── - -static struct bt_conn *active_conn = nullptr; -static K_MUTEX_DEFINE(ble_mutex); - -// Take a reference to active_conn under ble_mutex. Returns nullptr if there is -// no active connection. Caller MUST bt_conn_unref() when done. -// -// Reading `active_conn` outside this lock races with disconnected_cb which can -// unref + null it on the BT RX thread - touching the freed pointer (even just -// to bt_conn_ref it) is a use-after-free. -static struct bt_conn *acquire_active_conn() -{ - struct bt_conn *conn = nullptr; - k_mutex_lock(&ble_mutex, K_FOREVER); - if (active_conn) { - conn = bt_conn_ref(active_conn); - } - k_mutex_unlock(&ble_mutex); - return conn; -} - -static bool bt_initialized = false; // bt_enable() called at most once -static bool ble_enabled = false; // set by setup(), cleared by shutdown() - -// Forward declarations - BT_GATT_SERVICE_DEFINE(mesh_svc, ...) is below, but -// read_fromradio() (defined earlier) needs to reference the service to notify -// on fromNum after each non-empty read. -#define FROMNUM_ATTR_IDX 2 -#define LOGRADIO_ATTR_IDX 9 -extern const struct bt_gatt_service_static mesh_svc; - -static void start_advertising(); // forward declaration (defined in advertising - // section below) - -// Work item for advertising restart after disconnect. -// -// disconnected_cb runs on the BT RX thread (the same thread that processes -// HCI Command Complete events). Calling bt_le_adv_start() → -// bt_hci_cmd_send_sync() directly from that thread deadlocks: the thread blocks -// on k_sem_take waiting for Command Complete, but it is the very thread that -// would process it. After 10 s the host panics with "Controller unresponsive, -// opcode 0x2006 timeout". -// -// Fix: submit a k_work item. The system workqueue runs bt_adv_restart_work_fn -// on its own thread → no deadlock. -static struct k_work adv_restart_work; - -static void adv_restart_work_fn(struct k_work *work) -{ - if (ble_enabled) { - start_advertising(); - } -} - -// CCC state: 0=off, BT_GATT_CCC_NOTIFY=notify, BT_GATT_CCC_INDICATE=indicate -static uint16_t fromnum_ccc_val = 0; -static uint16_t logradio_ccc_val = 0; - -// Scratch buffers - only one BLE operation at a time -static uint8_t fromRadioBytes[meshtastic_FromRadio_size]; -static size_t fromRadioLen = 0; -static uint8_t toRadioBytes[meshtastic_ToRadio_size]; -static uint8_t lastToRadio[MAX_TO_FROM_RADIO_SIZE]; -static uint32_t fromNumValue = 0; - -// Deferred ToRadio processing -// -// write_toradio() runs on the BT RX workqueue thread (6 KB stack). Calling -// phoneAPI->handleToRadio() directly triggers handleStartConfig → -// getFiles("/", 10) → nanopb encode, which overflows the stack on the exact -// "Client wants config" write. Instead we copy the payload into a pending -// buffer under a mutex and let BleDeferredThread (running on the Meshtastic -// OSThread scheduler, 24 KB stack) do the actual call outside the lock. -// -// The mutex makes the producer/consumer handoff race-free - producer may -// overwrite a pending buffer the consumer hasn't read yet (dropped packet), -// but partial reads / torn writes are impossible. -K_MUTEX_DEFINE(pendingToRadioMutex); -static uint8_t pendingToRadioBuf[MAX_TO_FROM_RADIO_SIZE]; -static size_t pendingToRadioLen = 0; -static bool pendingToRadio = false; - -// Zombie-connection watchdog state. -// -// The nRF54L15 Zephyr 4.2.1 SW-LL occasionally fails to forward an -// LE Disconnection Complete event to the host: when iOS tears down the link -// (either explicitly by the user or via supervision timeout), the LL layer -// drops the connection but disconnected_cb never fires, active_conn stays -// non-null and advertising never restarts - the device vanishes from scans -// until power cycle. Track the connected timestamp and the last time we -// observed ATT traffic; a long ATT idle on an "active" connection means we -// are zombied. A cold reboot is the only path that reliably recovers (any -// bt_hci_cmd_send_sync after this state, e.g. bt_le_adv_start or -// bt_conn_disconnect, hangs in k_sem_take and later panics with "Controller -// unresponsive, opcode 0x2006 timeout"). -static uint32_t connect_time_ms = 0; -static uint32_t last_att_time_ms = 0; - -// ── BluetoothPhoneAPI -// ───────────────────────────────────────────────────────── - -class BluetoothPhoneAPI : public PhoneAPI -{ - virtual void onNowHasData(uint32_t fromRadioNum) override; - virtual bool checkIsConnected() override; - - public: - BluetoothPhoneAPI() { api_type = TYPE_BLE; } -}; - -static BluetoothPhoneAPI *phoneAPI = nullptr; - -// ── CCC change callbacks -// ────────────────────────────────────────────────────── - -static void fromnum_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) -{ - fromnum_ccc_val = value; - LOG_INFO("BLE fromNum CCC: %u", value); -} - -static void logradio_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) -{ - logradio_ccc_val = value; - LOG_INFO("BLE logRadio CCC: %u", value); -} - -// ── GATT attribute callbacks -// ────────────────────────────────────────────────── - -static ssize_t read_fromnum(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) -{ - LOG_INFO("GATT read_fromnum: fromNum=%u offset=%u", fromNumValue, offset); - return bt_gatt_attr_read(conn, attr, buf, len, offset, &fromNumValue, sizeof(fromNumValue)); -} - -static ssize_t read_fromradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) -{ - if (offset == 0) { - // First chunk: pull the next packet from the queue. - // Subsequent chunks (offset > 0) are ATT_READ_BLOB continuations of the - // same value and must reuse fromRadioBytes untouched. - fromRadioLen = phoneAPI ? phoneAPI->getFromRadio(fromRadioBytes) : 0; - LOG_DEBUG("GATT read_fromradio len=%u", (unsigned)fromRadioLen); - } - last_att_time_ms = k_uptime_get_32(); - return bt_gatt_attr_read(conn, attr, buf, len, offset, fromRadioBytes, fromRadioLen); -} - -static ssize_t read_logradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) -{ - // logRadio is write-only from the device side (notify/indicate). - // Return an empty read so GATT discovery doesn't fail with NOT_PERMITTED. - return bt_gatt_attr_read(conn, attr, buf, len, offset, NULL, 0); -} - -static ssize_t write_toradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len, - uint16_t offset, uint8_t flags) -{ - // Writes >MTU-3 arrive here with offset=0 and - // flags=BT_GATT_WRITE_FLAG_EXECUTE after Zephyr reassembles the ATT Prepare - // Write fragments (CONFIG_BT_ATT_PREPARE_COUNT>0). Single writes arrive with - // flags=0. - LOG_DEBUG("GATT write_toradio len=%u flags=0x%x", len, flags); - if (offset != 0) { - return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET); - } - // Reject any write that won't fit in the dedup buffer (lastToRadio) or the - // pending handoff buffer (pendingToRadioBuf), both sized - // MAX_TO_FROM_RADIO_SIZE. Returning success while silently dropping a - // payload would let the phone believe a config write was applied. - if (len > sizeof(toRadioBytes) || len > MAX_TO_FROM_RADIO_SIZE) { - return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); - } - - // Deduplicate - drop packet if identical to the last one we processed - if (memcmp(lastToRadio, buf, len) != 0) { - memcpy(lastToRadio, buf, len); - if (len < MAX_TO_FROM_RADIO_SIZE) { - memset(lastToRadio + len, 0, MAX_TO_FROM_RADIO_SIZE - len); - } - // Defer handleToRadio() to BleDeferredThread (24 KB stack). - // Running it here on bt_workq (6 KB) overflows during handleStartConfig. - // Always overwrite pending - we already dedup'd above via lastToRadio, - // so any new write here is genuinely new data that must be delivered. - k_mutex_lock(&pendingToRadioMutex, K_FOREVER); - memcpy(pendingToRadioBuf, buf, len); - pendingToRadioLen = len; - pendingToRadio = true; - k_mutex_unlock(&pendingToRadioMutex); - } - last_att_time_ms = k_uptime_get_32(); - return (ssize_t)len; -} - -// ── GATT service definition (static, linked at compile time) -// ────────────────── -// -// Attribute indices (0-based): -// [0] Primary Service declaration -// [1] fromNum characteristic declaration -// [2] fromNum value ← notify target (FROMNUM_ATTR_IDX) -// [3] fromNum CCC descriptor -// [4] fromRadio characteristic declaration -// [5] fromRadio value -// [6] toRadio characteristic declaration -// [7] toRadio value -// [8] logRadio characteristic declaration -// [9] logRadio value ← notify target (LOGRADIO_ATTR_IDX) -// [10] logRadio CCC descriptor - -// All user characteristics require authenticated encryption (MITM passkey) -// before the client can read/write. This mirrors the nrf52 -// SECMODE_ENC_WITH_MITM service permission. The stack returns "Insufficient -// Authentication" on the first access attempt, prompting the client to pair -// with the configured PIN. -#define MESH_PERM_READ (BT_GATT_PERM_READ | BT_GATT_PERM_READ_AUTHEN) -#define MESH_PERM_WRITE (BT_GATT_PERM_WRITE | BT_GATT_PERM_WRITE_AUTHEN) - -BT_GATT_SERVICE_DEFINE(mesh_svc, BT_GATT_PRIMARY_SERVICE(&mesh_svc_uuid.uuid), - - // fromNum: READ | NOTIFY - packet-counter triggers phone to read fromRadio - BT_GATT_CHARACTERISTIC(&fromnum_uuid.uuid, BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY, MESH_PERM_READ, - read_fromnum, NULL, &fromNumValue), - BT_GATT_CCC(fromnum_ccc_changed, MESH_PERM_READ | MESH_PERM_WRITE), - - // fromRadio: READ - phone polls this after receiving a fromNum notification - BT_GATT_CHARACTERISTIC(&fromradio_uuid.uuid, BT_GATT_CHRC_READ, MESH_PERM_READ, read_fromradio, NULL, - NULL), - - // toRadio: WRITE - phone sends protobuf packets to the device - BT_GATT_CHARACTERISTIC(&toradio_uuid.uuid, BT_GATT_CHRC_WRITE, MESH_PERM_WRITE, NULL, write_toradio, NULL), - - // logRadio: READ | NOTIFY | INDICATE - log stream to phone when connected - BT_GATT_CHARACTERISTIC(&logradio_uuid.uuid, - BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY | BT_GATT_CHRC_INDICATE, MESH_PERM_READ, - read_logradio, NULL, NULL), - BT_GATT_CCC(logradio_ccc_changed, MESH_PERM_READ | MESH_PERM_WRITE), ); - -// ── Advertising -// ─────────────────────────────────────────────────────────────── -// -// Use legacy advertising (bt_le_adv_start / HCI 0x2006 path). -// -// History: we previously used bt_le_ext_adv_create (true extended advertising) -// because bt_le_adv_start() with CONFIG_BT_EXT_ADV=y was translated internally -// to the extended HCI path with LEGACY-bit (0x2036), which produced -// non-connectable PDUs on the nRF54L15 SW-LL. The true extended path -// (0x203x, AUX_ADV_IND) was connectable but caused two problems: -// 1. iOS CoreBluetooth does not reliably complete GATT after connecting via -// extended advertising (zero ATT PDUs observed in all test sessions). -// 2. After each connection the controller auto-stops the advertising set, and -// the subsequent bt_le_ext_adv_delete() sends LE Remove Advertising Set -// (0x203c) which times out → kernel oops at hci_core.c:506. -// -// With CONFIG_BT_EXT_ADV=n the host uses pure legacy HCI commands - the same -// path Nordic NCS uses in all nRF54L15 examples (peripheral_uart, -// peripheral_lbs) and which is universally iOS-compatible. The legacy data -// payload is 31 bytes: -// FLAGS (3B) + UUID128 (18B) = 21B in adv; NAME in scan-response (17B). - -static void start_advertising() -{ - // IMPORTANT: BT_DATA_BYTES() uses C99 compound literals that GCC C++ treats - // as temporaries; with -Os the compiler may elide writes, leaving stack - // uninitialized. Use static const arrays for stable data (flags, UUID) - // and a runtime pointer for the dynamic device name. - static const uint8_t adv_flags_val[] = {BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR}; - static const uint8_t adv_uuid128_val[] = {MESH_SVC_UUID_VAL}; - - const char *name = bt_get_name(); - size_t full_name_len = strlen(name); - - // Legacy scan-response payload is 31 bytes total. Each AD entry costs 2 - // bytes (length + type), leaving 29 bytes for the name. With - // CONFIG_BT_DEVICE_NAME_MAX=32 the name can exceed that - truncate and - // mark as SHORTENED so bt_le_adv_start() doesn't reject the payload. - constexpr size_t LEGACY_SCAN_RSP_NAME_MAX = 31 - 2; - bool name_shortened = full_name_len > LEGACY_SCAN_RSP_NAME_MAX; - uint8_t name_len = (uint8_t)(name_shortened ? LEGACY_SCAN_RSP_NAME_MAX : full_name_len); - - // Primary advertising data: FLAGS + Meshtastic service UUID128 (21 bytes - // total) - struct bt_data ad[] = { - {BT_DATA_FLAGS, sizeof(adv_flags_val), adv_flags_val}, - {BT_DATA_UUID128_ALL, sizeof(adv_uuid128_val), adv_uuid128_val}, - }; - // Scan response: device name (discovered after scan request) - struct bt_data sd[] = { - {(uint8_t)(name_shortened ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE), name_len, (const uint8_t *)name}, - }; - - // BT_LE_ADV_OPT_CONN = connectable legacy ADV_IND + stops after first - // connection (replaces deprecated - // CONNECTABLE|ONE_TIME in Zephyr 4.2.1; - // BT_LE_ADV_OPT_CONN = BIT(0)|BIT(1)) - // BT_LE_ADV_OPT_USE_IDENTITY = use static random identity address (stable - // across reboots) Advertising restart after disconnect is via - // adv_restart_work (system workqueue) so calling bt_le_adv_start() from the - // BT RX thread context is avoided. - int err = bt_le_adv_start(BT_LE_ADV_PARAM(BT_LE_ADV_OPT_CONN | BT_LE_ADV_OPT_USE_IDENTITY, BT_GAP_ADV_FAST_INT_MIN_2, - BT_GAP_ADV_FAST_INT_MAX_2, NULL), - ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); - - if (err == -EALREADY) { - return; - } - if (err) { - LOG_WARN("BLE adv start failed: %d", err); - } else { - LOG_INFO("BLE advertising as '%s'", bt_get_name()); - } -} - -static void stop_advertising() -{ - bt_le_adv_stop(); -} - -// ── Connection callbacks -// ────────────────────────────────────────────────────── - -static void connected_cb(struct bt_conn *conn, uint8_t err) -{ - if (err) { - LOG_WARN("BLE connection failed, err=0x%02x", err); - return; - } - - k_mutex_lock(&ble_mutex, K_FOREVER); - active_conn = bt_conn_ref(conn); - k_mutex_unlock(&ble_mutex); - - memset(lastToRadio, 0, sizeof(lastToRadio)); - connect_time_ms = k_uptime_get_32(); - last_att_time_ms = connect_time_ms; - - char addr[BT_ADDR_LE_STR_LEN]; - bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); - LOG_INFO("BLE connected: %s", addr); - - meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); - bluetoothStatus->updateStatus(&newStatus); - - // nRF54L15-DK has no screen - cannot display a PIN to the user. - // Requesting BT_SECURITY_L2 causes the OS to show a pairing dialog that - // the user dismisses, triggering disconnect + advertising restart failure. - // Skip security negotiation; the Meshtastic app works over plain GATT. - // (Security can be re-enabled once a display or NFC OOB path is available.) -} - -static void disconnected_cb(struct bt_conn *conn, uint8_t reason) -{ - LOG_INFO("BLE disconnected, reason=0x%02x", reason); - - k_mutex_lock(&ble_mutex, K_FOREVER); - if (active_conn) { - bt_conn_unref(active_conn); - active_conn = nullptr; - } - k_mutex_unlock(&ble_mutex); - - fromnum_ccc_val = 0; - logradio_ccc_val = 0; - connect_time_ms = 0; - last_att_time_ms = 0; - - if (phoneAPI) { - phoneAPI->close(); - } - memset(lastToRadio, 0, sizeof(lastToRadio)); - - meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); - bluetoothStatus->updateStatus(&newStatus); - - // Schedule advertising restart via work queue - NOT from this callback - // directly. disconnected_cb runs on the BT RX thread; calling - // bt_le_adv_start() here would deadlock (see adv_restart_work comment above). - if (ble_enabled) { - k_work_submit(&adv_restart_work); - } -} - -#if defined(CONFIG_BT_SMP) -static void security_changed_cb(struct bt_conn *conn, bt_security_t level, enum bt_security_err err) -{ - if (err == BT_SECURITY_ERR_PIN_OR_KEY_MISSING) { - // Phone has a stale bond (device was wiped/reflashed). Unpair the stale - // entry so the phone re-pairs cleanly on the next connection attempt. - LOG_WARN("BLE stale bond (key missing) - unpairing"); - bt_unpair(BT_ID_DEFAULT, bt_conn_get_dst(conn)); - bt_conn_disconnect(conn, BT_HCI_ERR_AUTH_FAIL); - } else if (err) { - LOG_WARN("BLE security change failed: level=%d err=%d", (int)level, (int)err); - } else { - LOG_INFO("BLE security level %d established", (int)level); - } -} -#endif /* CONFIG_BT_SMP */ - -BT_CONN_CB_DEFINE(conn_callbacks) = { - .connected = connected_cb, - .disconnected = disconnected_cb, -#if defined(CONFIG_BT_SMP) - .security_changed = security_changed_cb, -#endif -}; - -// ── Pairing / auth callbacks -// ────────────────────────────────────────────────── - -#if defined(CONFIG_BT_SMP) -static uint32_t configuredPasskey; - -static void auth_passkey_display(struct bt_conn *conn, unsigned int passkey) -{ - char passkey_str[7]; - snprintf(passkey_str, sizeof(passkey_str), "%06u", passkey); - configuredPasskey = passkey; - LOG_INFO("BLE pairing PIN: %s", passkey_str); - powerFSM.trigger(EVENT_BLUETOOTH_PAIR); - - std::string textkey(passkey_str); - meshtastic::BluetoothStatus pairingStatus(textkey); - bluetoothStatus->updateStatus(&pairingStatus); -} - -static void auth_cancel(struct bt_conn *conn) -{ - LOG_WARN("BLE pairing cancelled"); -} - -static struct bt_conn_auth_cb auth_cb = { - .passkey_display = auth_passkey_display, - .passkey_entry = NULL, - .cancel = auth_cancel, -}; - -static void pairing_complete_cb(struct bt_conn *conn, bool bonded) -{ - LOG_INFO("BLE pairing complete, bonded=%d", (int)bonded); - meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); - bluetoothStatus->updateStatus(&newStatus); -} - -static void pairing_failed_cb(struct bt_conn *conn, enum bt_security_err reason) -{ - LOG_WARN("BLE pairing failed, reason=%d", (int)reason); - meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); - bluetoothStatus->updateStatus(&newStatus); -} - -static struct bt_conn_auth_info_cb auth_info_cb = { - .pairing_complete = pairing_complete_cb, - .pairing_failed = pairing_failed_cb, -}; -#endif /* CONFIG_BT_SMP */ - -// ── BluetoothPhoneAPI methods -// ───────────────────────────────────────────────── - -void BluetoothPhoneAPI::onNowHasData(uint32_t fromRadioNum) -{ - PhoneAPI::onNowHasData(fromRadioNum); - fromNumValue = fromRadioNum; - - if (!(fromnum_ccc_val & BT_GATT_CCC_NOTIFY)) - return; - - // active_conn may be torn down on another thread while we're dispatching - // this notify - acquire under ble_mutex so disconnected_cb can't free the - // conn between the null check and bt_conn_ref. - struct bt_conn *conn = acquire_active_conn(); - if (!conn) - return; - bt_gatt_notify(conn, &mesh_svc.attrs[FROMNUM_ATTR_IDX], &fromNumValue, sizeof(fromNumValue)); - bt_conn_unref(conn); -} - -bool BluetoothPhoneAPI::checkIsConnected() -{ - return active_conn != nullptr; -} - -// ── Deferred ToRadio processor + zombie-connection watchdog ────────────────── -// -// write_toradio() runs on the BT RX workqueue thread (CONFIG_BT_RX_STACK_SIZE) -// and cannot execute phoneAPI->handleToRadio() directly: handleStartConfig -// recurses through nanopb encode + state machine init and overflows the RX -// stack. This thread runs on the Meshtastic OSThread scheduler (24 KB stack), -// picks up the pending ToRadio buffer flagged by write_toradio(), and calls -// handleToRadio() with plenty of headroom. -// -// Real-time fromNum notifications are sent synchronously from -// BluetoothPhoneAPI::onNowHasData() (called by PhoneAPI when new data is -// queued). -// -// Zombie-connection detection has two tiers: -// -// (1) Liveness probe. After IDLE_BEFORE_PROBE_MS without ATT traffic, send -// a bt_gatt_notify to fromNum every PROBE_INTERVAL_MS. If the -// controller replies -ENOTCONN the LL link is definitely dead but the -// host didn't forward LE Disconnection Complete → reboot. We avoid -// probing during normal activity so iOS isn't woken up unnecessarily -// (each probe wakes iOS → triggers a zero-byte FromRadio drain). -// -// (2) Hard watchdog. Absolute HARD_WATCHDOG_MS ceiling on ATT idle as a -// fallback if probes somehow don't detect the zombie. -class BleDeferredThread : public concurrency::OSThread -{ - static constexpr uint32_t IDLE_BEFORE_PROBE_MS = 30000; // 30 s: start probing - static constexpr uint32_t PROBE_INTERVAL_MS = 5000; // 5 s: between probes - static constexpr uint32_t HARD_WATCHDOG_MS = 60000; // 1 min: last resort - - uint32_t last_probe_ms = 0; - - public: - BleDeferredThread() : concurrency::OSThread("BleDeferred") {} - - protected: - int32_t runOnce() override - { - // Snapshot the pending ToRadio buffer under the mutex, then release - // the lock before calling into handleToRadio (which can be slow and - // must not block the BT RX thread producer). - uint8_t buf[MAX_TO_FROM_RADIO_SIZE]; - size_t n = 0; - bool have_pending = false; - k_mutex_lock(&pendingToRadioMutex, K_FOREVER); - if (pendingToRadio) { - memcpy(buf, pendingToRadioBuf, pendingToRadioLen); - n = pendingToRadioLen; - pendingToRadio = false; - have_pending = true; - } - k_mutex_unlock(&pendingToRadioMutex); - if (have_pending && phoneAPI) { - phoneAPI->handleToRadio(buf, n); - } - - // Take a reference to active_conn so it can't be freed underneath us - // if disconnected_cb fires on another thread while we're dispatching. - struct bt_conn *conn = acquire_active_conn(); - if (!conn || connect_time_ms == 0) { - if (conn) - bt_conn_unref(conn); - last_probe_ms = 0; - return 100; - } - - uint32_t now = k_uptime_get_32(); - uint32_t att_idle = now - last_att_time_ms; - - // Liveness probe - only when ATT has been quiet for a while. - if (att_idle > IDLE_BEFORE_PROBE_MS && (now - last_probe_ms) >= PROBE_INTERVAL_MS && - (fromnum_ccc_val & BT_GATT_CCC_NOTIFY)) { - last_probe_ms = now; - int err = bt_gatt_notify(conn, &mesh_svc.attrs[FROMNUM_ATTR_IDX], &fromNumValue, sizeof(fromNumValue)); - if (err == -ENOTCONN) { - LOG_WARN("BLE zombie (probe ENOTCONN); rebooting"); - bt_conn_unref(conn); - k_sleep(K_MSEC(50)); // flush log - sys_reboot(SYS_REBOOT_COLD); - } - } - bt_conn_unref(conn); - - // Hard ceiling - last-resort reboot if probes miss the zombie. - if (att_idle > HARD_WATCHDOG_MS && (now - connect_time_ms) > HARD_WATCHDOG_MS) { - LOG_WARN("BLE zombie (hard watchdog %us); rebooting", HARD_WATCHDOG_MS / 1000); - k_sleep(K_MSEC(50)); - sys_reboot(SYS_REBOOT_COLD); - } - return 100; - } -}; - -static BleDeferredThread *bleDeferredThread = nullptr; - -// ── BT stack pre-initializer (call from main thread before OSThreads start) ── -// -// bt_enable() requires substantially more stack than a Meshtastic OSThread -// (PowerFSMThread) provides - calling it there causes a stack overflow. -// Call this from nrf54l15Setup() (main Zephyr thread, CONFIG_MAIN_STACK_SIZE) -// so that by the time NRF54L15Bluetooth::setup() runs from PowerFSMThread, -// bt_initialized is already true and bt_enable() is skipped. - -void nrf54l15_bt_preinit() -{ - if (!bt_initialized) { - int err = bt_enable(NULL); - if (err) { - LOG_ERROR("BLE pre-init failed: %d", err); - return; - } - bt_initialized = true; - LOG_INFO("BLE stack pre-initialized on main thread"); - - // Phase 7: load bonding keys from LittleFS (/lfs/bt_settings). - // LittleFS is already mounted by fsInit() before nrf54l15Setup() runs. - // On first boot the file doesn't exist - settings_load() returns 0 (OK). - // On subsequent boots, previously bonded peers are restored so the - // phone can reconnect without re-pairing. - err = settings_load(); - if (err) { - LOG_WARN("settings_load failed: %d (OK on first boot)", err); - } else { - LOG_INFO("BT settings loaded from /lfs/bt_settings"); - } - } -} - -// ── NRF54L15Bluetooth public methods ───────────────────────────────────────── - -// Shared init: idempotent setup of work item, OSThread, auth callbacks, -// bt_enable, and device name. Leaves advertising control to the caller. -static bool nrf54l15_bt_init_common() -{ - k_work_init(&adv_restart_work, adv_restart_work_fn); - - if (!bleDeferredThread) { - bleDeferredThread = new BleDeferredThread(); - } - - if (!phoneAPI) { - phoneAPI = new BluetoothPhoneAPI(); - } - -#if defined(CONFIG_BT_SMP) - // NO_PIN is unsupported on this platform: the mesh GATT permissions are - // declared with BT_GATT_PERM_*_AUTHEN, prj.conf sets - // CONFIG_BT_SMP_ENFORCE_MITM=y, and the build pulls in the SMP/passkey path. - // If a user requested NO_PIN we'd register no auth callbacks → no display - // path for the passkey → every GATT access returns BT_ATT_ERR_AUTHENTICATION - // and the link is unusable. Fall back to RANDOM_PIN behavior with a warning - // instead of leaving BLE silently broken. - if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { - LOG_WARN("BLE: NO_PIN not supported on nRF54L15-DK (MITM-only build); " - "treat as RANDOM_PIN"); - } - - bt_conn_auth_cb_register(&auth_cb); - bt_conn_auth_info_cb_register(&auth_info_cb); - - // FIXED_PIN - register the configured passkey so the mobile app prompts - // the user for that specific number instead of a random display-only PIN. - // RANDOM_PIN (and clamped NO_PIN) keeps the default behavior: Zephyr - // generates a fresh passkey on each pairing attempt and fires - // auth_passkey_display with it. - if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN) { - configuredPasskey = config.bluetooth.fixed_pin; - int rc = bt_passkey_set(configuredPasskey); - if (rc) { - LOG_WARN("bt_passkey_set(%u) failed: %d", configuredPasskey, rc); - } else { - LOG_INFO("BLE fixed PIN: %06u", configuredPasskey); - } - } else { - bt_passkey_set(BT_PASSKEY_INVALID); // random per-pair - } -#endif /* CONFIG_BT_SMP */ - - if (!bt_initialized) { - int err = bt_enable(NULL); - if (err) { - LOG_ERROR("BLE enable failed: %d", err); - return false; - } - bt_initialized = true; - LOG_INFO("BLE stack enabled"); - } - - bt_set_name(getDeviceName()); - return true; -} - -void NRF54L15Bluetooth::setup() -{ - LOG_INFO("NRF54L15Bluetooth::setup()"); - if (!nrf54l15_bt_init_common()) { - return; - } - ble_enabled = true; - start_advertising(); -} - -void NRF54L15Bluetooth::shutdown() -{ - LOG_INFO("NRF54L15Bluetooth::shutdown()"); - ble_enabled = false; - stop_advertising(); - - struct bt_conn *conn = acquire_active_conn(); - if (conn) { - bt_conn_disconnect(conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); - bt_conn_unref(conn); - } -} - -void NRF54L15Bluetooth::startDisabled() -{ - // Initialize BT stack but leave advertising off until resumeAdvertising(). - if (!nrf54l15_bt_init_common()) { - return; - } - ble_enabled = false; - LOG_INFO("BLE initialized, adv stopped (startDisabled)"); -} - -void NRF54L15Bluetooth::resumeAdvertising() -{ - ble_enabled = true; - start_advertising(); -} - -void NRF54L15Bluetooth::clearBonds() -{ - LOG_INFO("BLE clear bonds"); - bt_unpair(BT_ID_DEFAULT, BT_ADDR_LE_ANY); -} - -bool NRF54L15Bluetooth::isConnected() -{ - return active_conn != nullptr; -} - -int NRF54L15Bluetooth::getRssi() -{ - return 0; // TODO: Zephyr has no direct bt_conn_get_rssi; use HCI RSSI read - // command -} - -void NRF54L15Bluetooth::sendLog(const uint8_t *logMessage, size_t length) -{ - if (length > 512 || logradio_ccc_val == 0) { - return; - } - // Acquire a reference under ble_mutex so disconnected_cb can't free the - // connection between the null check and bt_gatt_notify. - struct bt_conn *conn = acquire_active_conn(); - if (!conn) { - return; - } - // Send as notify regardless of whether client subscribed to NOTIFY or - // INDICATE - bt_gatt_indicate() requires a params struct with a callback; - // notify is simpler and the app accepts both. Change to indicate if - // compatibility issues arise. - bt_gatt_notify(conn, &mesh_svc.attrs[LOGRADIO_ATTR_IDX], logMessage, (uint16_t)length); - bt_conn_unref(conn); -} diff --git a/src/platform/nrf54l15/NRF54L15Bluetooth.h b/src/platform/nrf54l15/NRF54L15Bluetooth.h deleted file mode 100644 index 36d434d37c..0000000000 --- a/src/platform/nrf54l15/NRF54L15Bluetooth.h +++ /dev/null @@ -1,29 +0,0 @@ -// NRF54L15Bluetooth.h - Zephyr BLE backend for nRF54L15 -// -// Implements the same interface as NRF52Bluetooth (same method names and -// signatures) so main.cpp and AdminModule can use nrf52Bluetooth pointer -// without knowing the underlying implementation. -// -// GATT profile is identical to the nRF52 implementation: -// Service: MESH_SERVICE_UUID -// toRadio: TORADIO_UUID (WRITE) -// fromRadio: FROMRADIO_UUID (READ) -// fromNum: FROMNUM_UUID (READ | NOTIFY) -// logRadio: LOGRADIO_UUID (READ | NOTIFY | INDICATE) - -#pragma once - -#include "BluetoothCommon.h" - -class NRF54L15Bluetooth : public BluetoothApi -{ - public: - void setup(); - void shutdown(); - void startDisabled(); - void resumeAdvertising(); - void clearBonds(); - bool isConnected(); - int getRssi(); - void sendLog(const uint8_t *logMessage, size_t length); -}; diff --git a/src/platform/nrf54l15/Nrf52SaadcLock.h b/src/platform/nrf54l15/Nrf52SaadcLock.h deleted file mode 100644 index 1e0a04bc21..0000000000 --- a/src/platform/nrf54l15/Nrf52SaadcLock.h +++ /dev/null @@ -1,17 +0,0 @@ -// Nrf52SaadcLock.h - stub for nRF54L15/Zephyr -// Power.cpp includes this when ARCH_NRF52 is defined. -// Phase 2: compile-only stub. -#pragma once - -#ifdef ARCH_NRF52 - -#include "concurrency/Lock.h" - -namespace concurrency -{ -/** Shared mutex for SAADC configuration and reads (VDD + battery analog path). - * On nRF54L15 ADC is handled differently; this is a compile-only stub. */ -extern Lock *nrf52SaadcLock; -} // namespace concurrency - -#endif diff --git a/src/platform/nrf54l15/Print.h b/src/platform/nrf54l15/Print.h deleted file mode 100644 index 5db9a7406b..0000000000 --- a/src/platform/nrf54l15/Print.h +++ /dev/null @@ -1,4 +0,0 @@ -// Print.h - shim for nRF54L15/Zephyr -// Meshtastic includes separately; redirect to our Arduino.h shim. -#pragma once -#include "Arduino.h" diff --git a/src/platform/nrf54l15/SPI.h b/src/platform/nrf54l15/SPI.h deleted file mode 100644 index b35af0dc96..0000000000 --- a/src/platform/nrf54l15/SPI.h +++ /dev/null @@ -1,62 +0,0 @@ -/** - * SPI.h - Arduino SPI shim for Zephyr/nRF54L15 - * - * Provides the Arduino SPIClass interface backed by Zephyr's SPI API. The - * backing controller is SPIM00 (HP domain, 3.0 V); the implementation in - * nrf54l15_arduino.cpp binds to DEVICE_DT_GET(DT_NODELABEL(spi00)) and the - * bus is configured in zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay. - * RadioLib uses ArduinoHal which calls transfer() byte-by-byte. - * - * CS pin is handled by RadioLib via digitalWrite() - hardware CS is not used. - */ - -#pragma once - -#include "Arduino.h" -#include - -#define SPI_MODE0 0 -#define SPI_MODE1 1 -#define SPI_MODE2 2 -#define SPI_MODE3 3 - -struct SPISettings { - uint32_t clock; - uint8_t bitOrder; - uint8_t dataMode; - - // Arduino API allows `SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0))` - implicit form is intentional. - // cppcheck-suppress noExplicitConstructor - SPISettings(uint32_t clock = 4000000, uint8_t bitOrder = MSBFIRST, uint8_t dataMode = SPI_MODE0) - : clock(clock), bitOrder(bitOrder), dataMode(dataMode) - { - } -}; - -class SPIClass -{ - public: - void begin() {} - void begin(uint8_t sck, uint8_t miso, uint8_t mosi, uint8_t ss = 0xFF) {} - void end() {} - void beginTransaction(SPISettings) {} - void endTransaction() {} - void setBitOrder(uint8_t order) {} - void setDataMode(uint8_t mode) {} - void setClockDivider(uint8_t div) {} - void setFrequency(uint32_t freq) {} - - // Real Zephyr SPI implementation - defined in nrf54l15_arduino.cpp - uint8_t transfer(uint8_t data); - uint16_t transfer16(uint16_t data); - void transfer(void *buf, size_t count); - void transferBytes(const uint8_t *tx, uint8_t *rx, uint32_t count); - uint8_t transfer(uint8_t tx, uint8_t *rx, uint32_t count) - { - transferBytes(&tx, rx, count); - return rx ? rx[0] : 0; - } -}; - -extern SPIClass SPI; -extern SPIClass SPI1; diff --git a/src/platform/nrf54l15/Stream.h b/src/platform/nrf54l15/Stream.h deleted file mode 100644 index 450423cbf2..0000000000 --- a/src/platform/nrf54l15/Stream.h +++ /dev/null @@ -1,5 +0,0 @@ -// Stream.h - shim for nRF54L15/Zephyr -// StreamAPI.h and other Meshtastic headers include . -// Redirect to our Arduino.h shim which defines the Stream base class. -#pragma once -#include "Arduino.h" diff --git a/src/platform/nrf54l15/Tone.h b/src/platform/nrf54l15/Tone.h deleted file mode 100644 index 4a12310b7b..0000000000 --- a/src/platform/nrf54l15/Tone.h +++ /dev/null @@ -1,4 +0,0 @@ -// Tone.h - shim for nRF54L15/Zephyr -// Tone functions are stubbed in Arduino.h; this header satisfies direct includes. -#pragma once -#include "Arduino.h" diff --git a/src/platform/nrf54l15/WProgram.h b/src/platform/nrf54l15/WProgram.h deleted file mode 100644 index a8e3dc0d54..0000000000 --- a/src/platform/nrf54l15/WProgram.h +++ /dev/null @@ -1,5 +0,0 @@ -// WProgram.h - shim for nRF54L15/Zephyr -// ArduinoThread (and other legacy Arduino libs) include . -// Redirect to our Arduino.h shim. -#pragma once -#include "Arduino.h" diff --git a/src/platform/nrf54l15/Wire.cpp b/src/platform/nrf54l15/Wire.cpp deleted file mode 100644 index 96f952df33..0000000000 --- a/src/platform/nrf54l15/Wire.cpp +++ /dev/null @@ -1,219 +0,0 @@ -// Wire.cpp - Arduino TwoWire backed by Zephyr i2c30 (TWIM30 hardware). -// -// The pinctrl + clock-frequency are configured in -// zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay. Runtime begin()/setClock() -// are best-effort: setClock() goes through i2c_configure() to actually change -// the bus speed; begin() just verifies the device is ready. - -#include "Wire.h" -#include "configuration.h" - -#include -#include -#include -#include - -// Resolve the i2c30 node at compile time. If the overlay has not enabled -// i2c30, this evaluates to a NULL device pointer and every call short-circuits -// to a NACK return code - matching the prior compile-only stub behavior. -#define I2C_NODE DT_NODELABEL(i2c30) - -static const struct device *getI2CDevice() -{ -#if DT_NODE_HAS_STATUS(I2C_NODE, okay) - static const struct device *const dev = DEVICE_DT_GET(I2C_NODE); - return dev; -#else - return nullptr; -#endif -} - -// Wire/Wire1 instances are defined in nrf54l15_arduino.cpp alongside the -// other Arduino singletons (Serial, SPI, …). - -TwoWire::TwoWire() : txAddr(0), txLen(0), txBuf{}, rxLen(0), rxPos(0), rxBuf{} {} - -void TwoWire::begin() -{ - const struct device *dev = getI2CDevice(); - if (dev == nullptr) { - LOG_WARN("Wire.begin(): i2c30 not enabled in DT overlay"); - return; - } - if (!device_is_ready(dev)) { - LOG_WARN("Wire.begin(): i2c30 device not ready"); - return; - } - LOG_INFO("Wire.begin(): i2c30 ready"); -} - -void TwoWire::begin(uint8_t /*sda*/, uint8_t /*scl*/) -{ - // SDA/SCL fixed by overlay pinctrl - pin args ignored. - begin(); -} - -void TwoWire::begin(int /*sda*/, int /*scl*/, uint32_t freq) -{ - begin(); - if (freq) { - setClock(freq); - } -} - -void TwoWire::end() -{ - // No-op: Zephyr i2c devices stay initialized for the lifetime of the - // application. Runtime PM (zephyr,pm-device-runtime-auto in the DT) - // handles low-power transitions when idle. -} - -void TwoWire::setClock(uint32_t freq) -{ - const struct device *dev = getI2CDevice(); - if (dev == nullptr) { - return; - } - uint32_t speed; - if (freq >= 1000000U) { - speed = I2C_SPEED_FAST_PLUS; // 1 MHz - } else if (freq >= 400000U) { - speed = I2C_SPEED_FAST; // 400 kHz - } else { - speed = I2C_SPEED_STANDARD; // 100 kHz - } - uint32_t cfg = I2C_MODE_CONTROLLER | I2C_SPEED_SET(speed); - int rc = i2c_configure(dev, cfg); - if (rc) { - LOG_WARN("Wire.setClock(%u) failed: %d", (unsigned)freq, rc); - } -} - -void TwoWire::beginTransmission(uint8_t addr) -{ - txAddr = addr; - txLen = 0; -} - -size_t TwoWire::write(uint8_t data) -{ - if (txLen >= WIRE_BUFFER_LENGTH) { - return 0; // overflow - endTransmission() will return 1 - } - txBuf[txLen++] = data; - return 1; -} - -size_t TwoWire::write(const uint8_t *data, size_t n) -{ - size_t written = 0; - for (size_t i = 0; i < n; i++) { - if (write(data[i]) == 0) { - break; - } - written++; - } - return written; -} - -uint8_t TwoWire::endTransmission(bool /*stop*/) -{ - // Arduino return codes: - // 0 = success - // 1 = data-too-long (overflow caught in write()) - // 2 = NACK on address - // 3 = NACK on data - // 4 = other error - // 5 = timeout - if (txLen > WIRE_BUFFER_LENGTH) { - return 1; - } - const struct device *dev = getI2CDevice(); - if (dev == nullptr || !device_is_ready(dev)) { - return 4; - } - int rc = i2c_write(dev, txBuf, txLen, txAddr); - txLen = 0; - if (rc == 0) { - return 0; - } - if (rc == -EIO) { - return 2; // address NACK is the most common -EIO source on nrf-twim - } - if (rc == -ETIMEDOUT) { - return 5; - } - return 4; -} - -uint8_t TwoWire::requestFrom(uint8_t addr, uint8_t quantity, bool /*stop*/) -{ - rxLen = 0; - rxPos = 0; - if (quantity == 0) { - return 0; - } - if (quantity > WIRE_BUFFER_LENGTH) { - quantity = WIRE_BUFFER_LENGTH; - } - const struct device *dev = getI2CDevice(); - if (dev == nullptr || !device_is_ready(dev)) { - return 0; - } - - // If there is a pending TX (driver wrote register address via write() - // without an explicit endTransmission()), use i2c_write_read so the - // repeated-start path matches the typical "set register pointer then - // read N bytes" sensor protocol. - int rc; - if (txLen > 0) { - rc = i2c_write_read(dev, addr, txBuf, txLen, rxBuf, quantity); - txLen = 0; - } else { - rc = i2c_read(dev, rxBuf, quantity, addr); - } - if (rc) { - return 0; - } - rxLen = quantity; - return quantity; -} - -int TwoWire::available() -{ - return rxLen - rxPos; -} - -int TwoWire::read() -{ - if (rxPos >= rxLen) { - return -1; - } - return rxBuf[rxPos++]; -} - -int TwoWire::peek() -{ - if (rxPos >= rxLen) { - return -1; - } - return rxBuf[rxPos]; -} - -size_t TwoWire::readBytes(uint8_t *buf, size_t len) -{ - size_t n = 0; - while (n < len) { - int b = read(); - if (b < 0) { - break; - } - buf[n++] = (uint8_t)b; - } - return n; -} - -TwoWire::operator bool() const -{ - return getI2CDevice() != nullptr; -} diff --git a/src/platform/nrf54l15/Wire.h b/src/platform/nrf54l15/Wire.h deleted file mode 100644 index febf24527e..0000000000 --- a/src/platform/nrf54l15/Wire.h +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Wire.h - Arduino TwoWire (I2C) shim for Zephyr / nRF54L15. - * - * Bus binding: the Zephyr device tree alias `i2c30` (TWIM30 hardware - * peripheral, HP domain, 3.0 V) is resolved at compile time via - * DEVICE_DT_GET in Wire.cpp. SDA/SCL pins are configured in the board - * overlay via pinctrl - `begin(sda, scl)` overloads are accepted for - * Arduino API compatibility but the pin arguments are ignored. - * - * Buffer sizes are sized for the worst-case I2C consumer we plan to use - * (NXP SE050 secure element, ~256-byte T=1 frames). BMP280 / INA228 / - * SHT40 / INA3221 read in single-digit bytes and fit trivially. - */ - -#pragma once - -#include "Arduino.h" -#include -#include - -#ifndef WIRE_BUFFER_LENGTH -#define WIRE_BUFFER_LENGTH 256 -#endif - -class TwoWire -{ - public: - TwoWire(); - - // ── Bus lifecycle ───────────────────────────────────────────────── - // begin() variants - pin arguments are accepted for API compatibility - // but ignored: SDA/SCL are fixed by the Zephyr overlay pinctrl. freq - // is also fixed by overlay clock-frequency (use setClock() at runtime). - void begin(); - void begin(uint8_t sda, uint8_t scl); - void begin(int sda, int scl, uint32_t freq); - void end(); - - void setClock(uint32_t freq); - void setClockStretchLimit(uint32_t) {} // no-op on TWIM hardware - - // ── Master write ───────────────────────────────────────────────── - void beginTransmission(uint8_t addr); - void beginTransmission(int addr) { beginTransmission((uint8_t)addr); } - // Return codes (Arduino convention): - // 0 = success, 1 = data-too-long, 2 = NACK on addr, 3 = NACK on data, - // 4 = other error, 5 = timeout. - uint8_t endTransmission(bool stop = true); - uint8_t endTransmission(uint8_t stop) { return endTransmission(stop != 0); } - - size_t write(uint8_t data); - size_t write(const uint8_t *data, size_t n); - - // ── Master read ────────────────────────────────────────────────── - uint8_t requestFrom(uint8_t addr, uint8_t quantity, bool stop = true); - uint8_t requestFrom(uint8_t addr, uint8_t quantity, uint8_t stop) { return requestFrom(addr, quantity, stop != 0); } - uint8_t requestFrom(int addr, int quantity, int stop = 1) { return requestFrom((uint8_t)addr, (uint8_t)quantity, stop != 0); } - - int available(); - int read(); - int peek(); - size_t readBytes(uint8_t *buf, size_t len); - size_t readBytes(char *buf, size_t len) { return readBytes((uint8_t *)buf, len); } - void flush() {} - - // Slave callbacks unsupported - peripheral-only stub. - void onReceive(void (*)(int)) {} - void onRequest(void (*)(void)) {} - - operator bool() const; - - private: - uint8_t txAddr; - uint16_t txLen; - uint8_t txBuf[WIRE_BUFFER_LENGTH]; - - uint16_t rxLen; - uint16_t rxPos; - uint8_t rxBuf[WIRE_BUFFER_LENGTH]; -}; - -extern TwoWire Wire; -extern TwoWire Wire1; // alias to Wire - only one I2C bus on this board diff --git a/src/platform/nrf54l15/architecture.h b/src/platform/nrf54l15/architecture.h deleted file mode 100644 index 24b3c980dd..0000000000 --- a/src/platform/nrf54l15/architecture.h +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once - -#define ARCH_NRF54L15 - -// -// Feature flags for nRF54L15. -// -// The HAS_* macros below are Meshtastic's compile-time feature gate: every -// optional subsystem (BLE, screen, I2C, GPS, buttons, telemetry, sensors, -// radio, CPU shutdown, ...) is wrapped in `#if HAS_FOO` so a given board -// only pays for the features it actually ships. On memory-tight MCUs this -// is not cosmetic - it's the difference between a binary that fits in -// flash and one that doesn't, and between a build that links and one that -// drags in drivers for hardware the board doesn't have. Defaulting to 0 -// here (rather than inheriting nRF52 defaults) is deliberate: the -// nRF54L15-DK is a bare dev kit with no screen, no I2C sensors, no GPS, -// no user buttons - so every HAS_* flag starts off and gets flipped on -// explicitly by variants that add that hardware. -// -// Feature flags are also the cleanest way to absorb platform divergence -// without sprinkling `#ifdef ARCH_NRF54L15` across shared code. Anywhere -// a subsystem can be conditionally compiled via HAS_*, prefer that over -// per-arch guards: it keeps the core code arch-agnostic, makes it trivial -// to bring up the next board (flip the flags, don't patch call sites), -// and keeps the "does this platform support X?" question answerable by -// reading one file instead of grepping the tree. BLE in particular is -// deferred to Phase 2 on this port - the nRF54L15 uses MPSL/Zephyr BLE -// APIs rather than the Adafruit SoftDevice stack used by nRF52840 - so -// while HAS_BLUETOOTH defaults to 1, the actual implementation lives in -// NRF54L15Bluetooth.cpp behind its own Zephyr Kconfig gates. -// - -#ifndef HAS_BLUETOOTH -#define HAS_BLUETOOTH 1 -#endif -#ifndef HAS_SCREEN -#define HAS_SCREEN 0 -#endif -#ifndef HAS_WIRE -#define HAS_WIRE 0 -#endif -#ifndef HAS_GPS -#define HAS_GPS 0 -#endif -#ifndef HAS_BUTTON -#define HAS_BUTTON 0 -#endif -#ifndef HAS_TELEMETRY -#define HAS_TELEMETRY 0 -#endif -#ifndef HAS_SENSOR -#define HAS_SENSOR 0 -#endif -#ifndef HAS_RADIO -#define HAS_RADIO 1 -#endif -#ifndef HAS_CPU_SHUTDOWN -#define HAS_CPU_SHUTDOWN 0 -#endif - -// ADC reference - nRF54L15 SAADC uses VDD/4 internal ref by default -#ifndef AREF_VOLTAGE -#define AREF_VOLTAGE 3.6 -#endif -#ifndef BATTERY_SENSE_RESOLUTION_BITS -#define BATTERY_SENSE_RESOLUTION_BITS 12 -#endif - -// -// HW_VENDOR - maps build-time define to HardwareModel enum. -// PRIVATE_HW (255): the protobuf HardwareModel enum reserves DK / DIY boards -// without an SKU under this value; the nRF54L15-DK doesn't get a dedicated -// enum number. Variant manifest matches via custom_meshtastic_hw_model = 255. -// -#ifdef NRF54L15_DK -#define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW -#else -#define HW_VENDOR meshtastic_HardwareModel_UNSET -#endif diff --git a/src/platform/nrf54l15/bluefruit.h b/src/platform/nrf54l15/bluefruit.h deleted file mode 100644 index 306e810b4a..0000000000 --- a/src/platform/nrf54l15/bluefruit.h +++ /dev/null @@ -1,19 +0,0 @@ -// bluefruit.h - stub for nRF54L15/Zephyr -// NodeDB.cpp includes this when ARCH_NRF52 is defined. -// Bluetooth is excluded (MESHTASTIC_EXCLUDE_BLUETOOTH=1); this satisfies -// the include chain without pulling in the Adafruit Bluefruit SDK. -#pragma once - -struct BLEPeripheral { - void clearBonds() {} -}; -struct BLECentral { - void clearBonds() {} -}; - -struct BlueFruitClass { - BLEPeripheral Periph; - BLECentral Central; -}; - -extern BlueFruitClass Bluefruit; diff --git a/src/platform/nrf54l15/main-nrf54l15.cpp b/src/platform/nrf54l15/main-nrf54l15.cpp deleted file mode 100644 index b91fe67ee4..0000000000 --- a/src/platform/nrf54l15/main-nrf54l15.cpp +++ /dev/null @@ -1,222 +0,0 @@ -/* - * main-nrf54l15.cpp - Platform entry points for Nordic nRF54L15 - * - * Adapted from src/platform/nrf52/main-nrf52.cpp. - * SoftDevice, Adafruit BLE, and nRFCrypto are NOT available on nRF54L15. - * Phase 2 will add proper BLE via Zephyr MPSL APIs. - * - * TODO items are marked with "TODO(nrf54l15):" - */ - -#include "configuration.h" -#include -#include -#include -#include -#include - -#include "NodeDB.h" -#include "Power.h" -#include "PowerMon.h" -#include "Router.h" -#include "error.h" -#include "main.h" -#include "mesh/MeshService.h" -#include "meshUtils.h" -#include - -// ── Watchdog ────────────────────────────────────────────────────────────── -// TODO(nrf54l15): nRF54L15 has a WDT peripheral but nrfx_wdt driver support -// may differ depending on the Zephyr SDK version. Enable once confirmed. -#define APP_WATCHDOG_SECS 90 -static bool watchdog_running = false; - -static inline void watchdog_feed() {} // TODO(nrf54l15): replace with real WDT feed - -// ── Weak variant hooks ──────────────────────────────────────────────────── -void variant_shutdown() __attribute__((weak)); -void variant_shutdown() {} - -void variant_nrf54l15LoopHook(void) __attribute__((weak)); -void variant_nrf54l15LoopHook(void) {} - -// ── PowerHAL ───────────────────────────────────────────────────────────── -bool powerHAL_isVBUSConnected() -{ - // TODO(nrf54l15): nRF54L15 has a USB POWER peripheral - read USBREGSTATUS - return false; -} - -bool powerHAL_isPowerLevelSafe() -{ - // TODO(nrf54l15): implement SAADC VDD measurement similar to nRF52 - return true; -} - -void powerHAL_platformInit() -{ - // TODO(nrf54l15): configure POF comparator and analog reference if needed -} - -// ── Utilities ───────────────────────────────────────────────────────────── -bool loopCanSleep() -{ - return !Serial; -} - -void updateBatteryLevel(uint8_t level) -{ - (void)level; -} - -void __attribute__((noreturn)) __assert_func(const char *file, int line, const char *func, const char *failedexpr) -{ - LOG_ERROR("assert failed %s: %d, %s, test=%s", file, line, func, failedexpr); - NVIC_SystemReset(); -} - -void getMacAddr(uint8_t *dmac) -{ - // TODO(nrf54l15): verify FICR register layout for nRF54L15. - // nRF52840 uses NRF_FICR->DEVICEADDR[0/1]; nRF54L15 Zephyr HAL may differ. -#if defined(NRF_FICR) - const uint8_t *src = (const uint8_t *)NRF_FICR->DEVICEADDR; - dmac[5] = src[0]; - dmac[4] = src[1]; - dmac[3] = src[2]; - dmac[2] = src[3]; - dmac[1] = src[4]; - dmac[0] = src[5] | 0xc0; -#else - // Fallback: fixed placeholder until Zephyr FICR path is confirmed - dmac[0] = 0xC2; - dmac[1] = 0xA7; - dmac[2] = 0x54; - dmac[3] = 0x15; - dmac[4] = 0x00; - dmac[5] = 0x01; -#endif -} - -bool getDeviceId(uint8_t *deviceId) -{ - // nRF54L15: DEVICEID under the FICR->INFO sub-struct. Read unconditionally so a future build - // lacking NRF_FICR fails loudly rather than silently sharing getMacAddr()'s placeholder MAC. - uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; - uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; - memcpy(deviceId, &device_id_start, sizeof(device_id_start)); - memcpy(deviceId + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); - return true; -} - -// ── Bluetooth ───────────────────────────────────────────────────────────────── - -void setBluetoothEnable(bool enable) -{ - if (enable) { - static bool initialized = false; - if (!initialized) { - nrf54l15Bluetooth = new NRF54L15Bluetooth(); - nrf54l15Bluetooth->startDisabled(); - initialized = true; - } - if (nrf54l15Bluetooth) { - nrf54l15Bluetooth->resumeAdvertising(); - } - } else { - if (nrf54l15Bluetooth) { - nrf54l15Bluetooth->shutdown(); - } - } -} - -void clearBonds() -{ - if (!nrf54l15Bluetooth) { - nrf54l15Bluetooth = new NRF54L15Bluetooth(); - nrf54l15Bluetooth->setup(); - } - nrf54l15Bluetooth->clearBonds(); -} - -void enterDfuMode() -{ - // TODO(nrf54l15): nRF54L15 uses nRF Connect DFU (MCUboot/SUIT). - // Trigger via Zephyr boot_request_upgrade() or similar. - NVIC_SystemReset(); -} - -// ── printf via RTT ──────────────────────────────────────────────────────── -// TODO(nrf54l15): SEGGER_RTT may not be available with Zephyr; use printk() -// or a USB CDC console instead. Remove this override if it conflicts. -#ifdef SEGGER_RTT_PRINTF -int printf(const char *fmt, ...) -{ - va_list args; - va_start(args, fmt); - auto res = SEGGER_RTT_vprintf(0, fmt, &args); - va_end(args); - return res; -} -#endif - -// ── Deep sleep ──────────────────────────────────────────────────────────── -void cpuDeepSleep(uint32_t msecToWake) -{ -#if HAS_WIRE - Wire.end(); -#endif - SPI.end(); - if (Serial) - Serial.end(); - - variant_shutdown(); - - // TODO(nrf54l15): use Zephyr pm_system_suspend() or WFI for proper low-power - if (msecToWake != portMAX_DELAY) { - delay(msecToWake); - NVIC_SystemReset(); - } else { - // System off equivalent - halt - while (1) { - __WFI(); - } - } -} - -// ── Setup / Loop ────────────────────────────────────────────────────────── -// Forward declaration - defined in NRF54L15Bluetooth.cpp -void nrf54l15_bt_preinit(); - -void nrf54l15Setup() -{ - // nRF54L15 power peripheral layout differs from nRF52; RESETREAS not present here. - // TODO(Phase 3): use zephyr/drivers/hwinfo.h hwinfo_get_reset_cause() - LOG_DEBUG("Reset reason: (nRF54L15 power peripheral differs from nRF52, skipped)"); - - // TODO(nrf54l15): init SAADC, watchdog, and random seed via nrfx or Zephyr - // For now seed with a fixed value; replace with hardware entropy source. -#if defined(NRF_FICR) - randomSeed(analogRead(0) ^ (uint32_t)NRF_FICR->DEVICEADDR[0]); -#else - randomSeed(analogRead(0)); -#endif - - // Pre-initialize BT stack here on the main thread (CONFIG_MAIN_STACK_SIZE=8192). - // bt_enable() overflows the smaller PowerFSMThread stack when called later. - // NRF54L15Bluetooth::setup() checks bt_initialized and skips bt_enable() if true. - nrf54l15_bt_preinit(); -} - -void nrf54l15Loop() -{ - // First-call gate for the future WDT init - body will hold real init code, not just the bookkeeping flag. - // cppcheck-suppress duplicateConditionalAssign - if (!watchdog_running) { - // TODO(nrf54l15): enable WDT here - watchdog_running = true; - } - watchdog_feed(); - - variant_nrf54l15LoopHook(); -} diff --git a/src/platform/nrf54l15/nrf54l15_arduino.cpp b/src/platform/nrf54l15/nrf54l15_arduino.cpp deleted file mode 100644 index 8d8c30b323..0000000000 --- a/src/platform/nrf54l15/nrf54l15_arduino.cpp +++ /dev/null @@ -1,557 +0,0 @@ -/** - * nrf54l15_arduino.cpp - Arduino shim implementations for Zephyr/nRF54L15 - * - * Provides concrete implementations for Print, HardwareSerial, GPIO, SPI, - * and String methods declared in Arduino.h / SPI.h. - * - * Phase 3: real GPIO via Zephyr GPIO API and real SPI via Zephyr SPI API. - * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. - */ - -#include "Arduino.h" -#include "SPI.h" -#include "Wire.h" -#include -#include -#include -#include -#include -#include -#include -// ── Bluefruit singleton stub (satisfies NodeDB.cpp ARCH_NRF52 path) ────────── -#include "bluefruit.h" -BlueFruitClass Bluefruit; - -// ── _fini stub - ARM newlib's __libc_fini_array references _fini, but ──────── -// Zephyr startup doesn't provide it. Provide a weak no-op so the linker -// is satisfied when C++ global dtors or atexit() pull in __libc_fini_array. -extern "C" void __attribute__((weak)) _fini(void) {} - -// ── SPI / Wire singletons ───────────────────────────────────────────────────── -SPIClass SPI; -SPIClass SPI1; -TwoWire Wire; -TwoWire Wire1; - -// ── HardwareSerial singletons ──────────────────────────────────────────────── -HardwareSerial Serial; -HardwareSerial Serial1; -HardwareSerial Serial2; - -// ── Timing functions - C linkage to match extern "C" declarations ──────────── -extern "C" uint32_t millis(void) -{ - return (uint32_t)k_uptime_get_32(); -} -extern "C" uint32_t micros(void) -{ - return (uint32_t)(k_uptime_get() * 1000ULL); -} -extern "C" void delay(uint32_t ms) -{ - k_sleep(K_MSEC(ms)); -} -extern "C" void delayMicroseconds(uint32_t us) -{ - k_sleep(K_USEC(us)); -} -extern "C" void yield(void) -{ - k_yield(); -} - -// ── NVIC_SystemReset - wraps __NVIC_SystemReset from CMSIS core_cm33.h ─────── -// core_cm33.h has #define NVIC_SystemReset __NVIC_SystemReset, so undef it -// before defining our own implementation to prevent macro expansion collision. -#pragma push_macro("NVIC_SystemReset") -#undef NVIC_SystemReset -extern "C" void NVIC_SystemReset(void) -{ - sys_reboot(SYS_REBOOT_COLD); -} -#pragma pop_macro("NVIC_SystemReset") - -// ── HardwareSerial::write ───────────────────────────────────────────────────── -size_t HardwareSerial::write(uint8_t c) -{ - // TODO(nrf54l15 Phase 3): route through Zephyr UART / USB-CDC console - // For now use printk so we at least get something over RTT/UART0 - printk("%c", (char)c); - return 1; -} - -size_t HardwareSerial::write(const uint8_t *buf, size_t n) -{ - for (size_t i = 0; i < n; i++) - printk("%c", (char)buf[i]); - return n; -} - -// ── Print::printf ───────────────────────────────────────────────────────────── -int Print::printf(const char *fmt, ...) -{ - char buf[256]; - va_list args; - va_start(args, fmt); - int n = vsnprintf(buf, sizeof(buf), fmt, args); - va_end(args); - if (n > 0) - write((const uint8_t *)buf, (size_t)(n < (int)sizeof(buf) ? n : (int)sizeof(buf) - 1)); - return n; -} - -// ── strlcpy - BSD extension not in Zephyr newlib ──────────────────────────── -extern "C" size_t strlcpy(char *dst, const char *src, size_t size) -{ - size_t len = strlen(src); - if (size > 0) { - size_t copy = len < size - 1 ? len : size - 1; - memcpy(dst, src, copy); - dst[copy] = '\0'; - } - return len; -} - -// ── Print numeric helpers ───────────────────────────────────────────────────── -static size_t printNumber(Print &p, unsigned long n, uint8_t base) -{ - if (base == 0) - return p.write((uint8_t)n); - - char buf[8 * sizeof(long) + 1]; - char *end = buf + sizeof(buf) - 1; - *end = '\0'; - if (n == 0) { - *--end = '0'; - } else { - while (n > 0) { - unsigned long remainder = n % base; - *--end = (char)(remainder < 10 ? '0' + remainder : 'A' + remainder - 10); - n /= base; - } - } - return p.write((const uint8_t *)end, strlen(end)); -} - -static size_t printFloat(Print &p, double number, uint8_t digits) -{ - if (isnan(number)) - return p.print("nan"); - if (isinf(number)) - return p.print("inf"); - if (number > 4294967040.0 || number < -4294967040.0) - return p.print("ovf"); - - size_t n = 0; - if (number < 0.0) { - n += p.write('-'); - number = -number; - } - - // Round - double rounding = 0.5; - for (uint8_t i = 0; i < digits; i++) - rounding /= 10.0; - number += rounding; - - unsigned long int_part = (unsigned long)number; - double remainder = number - (double)int_part; - n += printNumber(p, int_part, 10); - if (digits > 0) { - n += p.write('.'); - for (uint8_t i = 0; i < digits; i++) { - remainder *= 10.0; - unsigned int d = (unsigned int)remainder; - n += p.write('0' + d); - remainder -= d; - } - } - return n; -} - -size_t Print::print(unsigned char n, int base) -{ - return printNumber(*this, n, base); -} -size_t Print::print(int n, int base) -{ - if (base == 10 && n < 0) { - size_t r = write('-'); - return r + printNumber(*this, (unsigned long)(-n), base); - } - return printNumber(*this, (unsigned long)n, base); -} -size_t Print::print(long n, int base) -{ - if (base == 10 && n < 0) { - size_t r = write('-'); - return r + printNumber(*this, (unsigned long)(-n), base); - } - return printNumber(*this, (unsigned long)n, base); -} -size_t Print::print(unsigned int n, int base) -{ - return printNumber(*this, n, base); -} -size_t Print::print(unsigned long n, int base) -{ - return printNumber(*this, n, base); -} -size_t Print::print(float n, int d) -{ - return printFloat(*this, n, d); -} -size_t Print::print(double n, int d) -{ - return printFloat(*this, n, d); -} - -// ── String::replace(String, String) ───────────────────────────────────────── -void String::replace(const String &from, const String &to) -{ - if (from.isEmpty() || !_buf) - return; - // Simple O(n²) replace - fine for typical Meshtastic string lengths - String result; - const char *p = _buf; - while (*p) { - if (strncmp(p, from.c_str(), from.length()) == 0) { - result += to; - p += from.length(); - } else { - result += *p++; - } - } - *this = result; -} - -// ═════════════════════════════════════════════════════════════════════════════ -// GPIO - Real Zephyr implementation (Phase 3) -// Pin mapping: P0.n = n (0-15), P1.n = 16+n (16-31), P2.n = 32+n (32-47) -// ═════════════════════════════════════════════════════════════════════════════ - -static const struct device *_gpio_dev_for_pin(uint32_t pin, gpio_pin_t *zpin) -{ - if (pin < 16) { - *zpin = (gpio_pin_t)pin; - return DEVICE_DT_GET(DT_NODELABEL(gpio0)); - } else if (pin < 32) { - *zpin = (gpio_pin_t)(pin - 16); - return DEVICE_DT_GET(DT_NODELABEL(gpio1)); - } else { - *zpin = (gpio_pin_t)(pin - 32); - return DEVICE_DT_GET(DT_NODELABEL(gpio2)); - } -} - -void pinMode(uint32_t pin, uint32_t mode) -{ - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - if (!device_is_ready(dev)) - return; - - gpio_flags_t flags; - switch (mode) { - case OUTPUT: - flags = GPIO_OUTPUT_INACTIVE; - break; - case INPUT_PULLUP: - flags = GPIO_INPUT | GPIO_PULL_UP; - break; - case INPUT_PULLDOWN: - flags = GPIO_INPUT | GPIO_PULL_DOWN; - break; - default: - flags = GPIO_INPUT; - break; - } - gpio_pin_configure(dev, zpin, flags); -} - -// Bring-up diagnostics for the SX1262 wiring path. Off by default - enable by -// adding `-DNRF54L15_GPIO_DEBUG` to platformio.ini build_flags. Useful when -// validating CS/NRESET toggles after a wiring change, diagnosing a "stuck HIGH" -// BUSY before the first NRESET pulse, or tracing BUSY transitions during early -// boot. In normal operation these traces are noise (they bypass LOG level -// controls and print on every GPIO touch), so they are gated at compile time. -#ifdef NRF54L15_GPIO_DEBUG -#define GPIO_LOG_MAX 20 -static uint32_t _gpio_log_count = 0; -#endif - -void digitalWrite(uint32_t pin, uint32_t value) -{ -#ifdef NRF54L15_GPIO_DEBUG - // Before the very first NRESET pulse, snapshot BUSY state. - // If BUSY is already HIGH here, the chip never completed power-on calibration. - if (pin == 32 && value == 0) { - static bool _first_nreset = true; - if (_first_nreset) { - _first_nreset = false; - const struct device *bdev = DEVICE_DT_GET(DT_NODELABEL(gpio2)); - if (device_is_ready(bdev)) { - gpio_pin_configure(bdev, 3, GPIO_INPUT); // P2.03 = BUSY - int busy_before = gpio_pin_get(bdev, 3); - printk("[nrf54l15] BUSY before first NRESET = %d%s\n", busy_before, - busy_before ? " ← STUCK HIGH (chip damaged?)" : " ← LOW (chip OK)"); - } - } - } -#endif - - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - if (!device_is_ready(dev)) { - // Genuine hardware/DTS misconfiguration - keep this regardless of the - // GPIO_DEBUG gate so it surfaces in production builds too. - printk("[GPIO] pin%u dev NOT READY\n", (unsigned)pin); - return; - } - gpio_pin_set(dev, zpin, (int)value); -#ifdef NRF54L15_GPIO_DEBUG - if ((pin == 37 || pin == 32) && _gpio_log_count < GPIO_LOG_MAX) { - // Read back the pin state to confirm it actually changed - int actual = gpio_pin_get(dev, zpin); - printk("[GPIO] pin%u → %u (read-back=%d)\n", (unsigned)pin, (unsigned)value, actual); - _gpio_log_count++; - } -#endif -} - -int digitalRead(uint32_t pin) -{ - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - if (!device_is_ready(dev)) - return 0; - int v = gpio_pin_get(dev, zpin); -#ifdef NRF54L15_GPIO_DEBUG - // Log BUSY pin (35=P2.03) state changes + periodic updates for 10 seconds - if (pin == 35) { - static uint32_t busy_log_count = 0; - static int last_busy = -1; - static uint32_t first_read_ms = 0; - if (first_read_ms == 0) - first_read_ms = k_uptime_get_32(); - uint32_t elapsed_ms = k_uptime_get_32() - first_read_ms; - - // Always log state changes - if (v != last_busy) { - printk("[BUSY] %ums: state changed %d → %d\n", (unsigned)elapsed_ms, last_busy, v); - last_busy = v; - } - // Also log every 500ms for first 10 seconds so we can see timeline - if (elapsed_ms < 10000 && busy_log_count < 20 && (elapsed_ms / 500) > (busy_log_count)) { - printk("[BUSY] %ums: pin=%d (periodic)\n", (unsigned)elapsed_ms, v); - busy_log_count = (elapsed_ms / 500) + 1; - } - } -#endif - return v; -} - -// ─── attachInterrupt - supports up to NRF54L15_MAX_IRQS pins ──────────────── -#define NRF54L15_MAX_IRQS 8 - -struct _PinIrq { - struct gpio_callback cb; - voidFuncPtr user_cb; - const struct device *dev; - gpio_pin_t zpin; - bool used; -}; - -static _PinIrq _irq_table[NRF54L15_MAX_IRQS]; - -static void _gpio_irq_dispatch(const struct device *dev, struct gpio_callback *cb, uint32_t pins) -{ - _PinIrq *irq = CONTAINER_OF(cb, _PinIrq, cb); - if (irq->user_cb) - irq->user_cb(); -} - -void attachInterrupt(uint32_t pin, voidFuncPtr cb, int mode) -{ - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - if (!device_is_ready(dev)) - return; - - // Find a free slot (or reuse existing registration for same pin) - _PinIrq *slot = nullptr; - for (int i = 0; i < NRF54L15_MAX_IRQS; i++) { - if (_irq_table[i].used && _irq_table[i].dev == dev && _irq_table[i].zpin == zpin) { - // Re-register: remove old callback first - gpio_remove_callback(dev, &_irq_table[i].cb); - slot = &_irq_table[i]; - break; - } - if (!slot && !_irq_table[i].used) - slot = &_irq_table[i]; - } - if (!slot) - return; // table full - - gpio_flags_t irq_flags; - switch (mode) { - case RISING: - irq_flags = GPIO_INT_EDGE_RISING; - break; - case FALLING: - irq_flags = GPIO_INT_EDGE_FALLING; - break; - default: - irq_flags = GPIO_INT_EDGE_BOTH; - break; - } - - slot->user_cb = cb; - slot->dev = dev; - slot->zpin = zpin; - slot->used = true; - - gpio_pin_configure(dev, zpin, GPIO_INPUT); - gpio_init_callback(&slot->cb, _gpio_irq_dispatch, BIT(zpin)); - gpio_add_callback(dev, &slot->cb); - gpio_pin_interrupt_configure(dev, zpin, irq_flags); -} - -void detachInterrupt(uint32_t pin) -{ - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - for (int i = 0; i < NRF54L15_MAX_IRQS; i++) { - if (_irq_table[i].used && _irq_table[i].dev == dev && _irq_table[i].zpin == zpin) { - gpio_pin_interrupt_configure(dev, zpin, GPIO_INT_DISABLE); - gpio_remove_callback(dev, &_irq_table[i].cb); - _irq_table[i].used = false; - break; - } - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// SPI - Real Zephyr implementation using SPIM00 (HP domain, 3.0V) -// CS is handled by RadioLib via digitalWrite() - hardware CS not used. -// Mode 0 (CPOL=0, CPHA=0), MSB first. -// ═════════════════════════════════════════════════════════════════════════════ - -// Use SPIM00 (HP domain, 3.0V) - SPIM20 is 1.8V LP domain, incompatible with SX1262. -// Lazy-init: DEVICE_DT_GET in global scope fails when the extern symbol is -// not visible in this translation unit. Use a function-local static instead. -static const struct device *_spi00(void) -{ - static const struct device *dev = nullptr; - if (!dev) { - dev = DEVICE_DT_GET(DT_NODELABEL(spi00)); - if (!device_is_ready(dev)) { - printk("[nrf54l15] spi00 NOT READY\n"); - dev = nullptr; - } else { - printk("[nrf54l15] spi00 ready\n"); - } - } - return dev; -} - -// SPI config: Mode 0, MSB first, no hardware CS (RadioLib does it manually) -// -// SPIM00 base clock = 128 MHz (nRF54L15 default when NRF_CONFIG_CPU_FREQ_MHZ -// is not set; SystemInit() applies 128 MHz). Hardware prescaler must be EVEN -// and in [4, 126] (SPIM00_PRESCALER_DIVISOR_RANGE_MIN/MAX from MDK). -// 1 MHz → prescaler = 128 > 126 → NRFX_ERROR_INVALID_PARAM → -EIO on every -// transfer. Minimum valid frequency is 2 MHz (prescaler = 64). -static const struct spi_config _spi00_cfg = { - .frequency = 2000000U, // 2 MHz - minimum valid for SPIM00 at 128 MHz base - .operation = SPI_OP_MODE_MASTER | SPI_WORD_SET(8) | SPI_TRANSFER_MSB, - .slave = 0, - .cs = {}, // CS = NULL → RadioLib handles CS via GPIO -}; - -// Static DMA buffers - stack-allocated bufs on nRF54L15 may not be reachable -// by SPIM20 EasyDMA. Static placement in .bss/.data is always in Global SRAM. -// rx_byte is pre-filled with 0xAA before every transfer so we can distinguish: -// 0xAA → DMA never wrote (EasyDMA can't reach the buffer) -// 0x00 → MISO actively driven LOW (chip in reset / bus fight) -// 0xFF → MISO floating HIGH -// other → real chip response -static uint8_t _spi_tx_byte __attribute__((aligned(4))); -static uint8_t _spi_rx_byte __attribute__((aligned(4))); - -// Dump the first SPI_DUMP_N byte exchanges so we can see what MISO returns. -#define SPI_DUMP_N 30 -static uint32_t _spi_dump_count = 0; - -uint8_t SPIClass::transfer(uint8_t data) -{ - const struct device *dev = _spi00(); - if (!dev) - return 0xFF; - - _spi_tx_byte = data; - _spi_rx_byte = 0xAA; // sentinel: if DMA doesn't write, we return 0xAA - - struct spi_buf tx_buf = {.buf = &_spi_tx_byte, .len = 1}; - struct spi_buf rx_buf = {.buf = &_spi_rx_byte, .len = 1}; - struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; - struct spi_buf_set rx_set = {.buffers = &rx_buf, .count = 1}; - - static uint32_t spi_err_count = 0; - int ret = spi_transceive(dev, &_spi00_cfg, &tx_set, &rx_set); - if (ret != 0 && spi_err_count++ < 3) - printk("[SPI] err=%d tx=0x%02x\n", ret, data); - - if (_spi_dump_count < SPI_DUMP_N) { - printk("[SPI] #%u tx=0x%02x rx=0x%02x\n", (unsigned)_spi_dump_count, data, _spi_rx_byte); - _spi_dump_count++; - } - - return _spi_rx_byte; -} - -// Static DMA-safe buffers for transfer16 - same EasyDMA reachability concern -// applies as for the byte path: stack buffers from a caller thread may sit in -// per-thread RAM regions that EasyDMA cannot reach. -static uint8_t _spi_tx16[2] __attribute__((aligned(4))); -static uint8_t _spi_rx16[2] __attribute__((aligned(4))); - -uint16_t SPIClass::transfer16(uint16_t data) -{ - const struct device *dev = _spi00(); - if (!dev) - return 0xFFFF; - - _spi_tx16[0] = (uint8_t)(data >> 8); - _spi_tx16[1] = (uint8_t)(data & 0xFF); - _spi_rx16[0] = 0xAA; - _spi_rx16[1] = 0xAA; - struct spi_buf tx_buf = {.buf = _spi_tx16, .len = 2}; - struct spi_buf rx_buf = {.buf = _spi_rx16, .len = 2}; - struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; - struct spi_buf_set rx_set = {.buffers = &rx_buf, .count = 1}; - spi_transceive(dev, &_spi00_cfg, &tx_set, &rx_set); - return ((uint16_t)_spi_rx16[0] << 8) | _spi_rx16[1]; -} - -void SPIClass::transferBytes(const uint8_t *tx, uint8_t *rx, uint32_t count) -{ - if (!count) - return; - const struct device *dev = _spi00(); - if (!dev) - return; - // Zephyr requires non-const buf pointer; cast is safe for tx-only direction - struct spi_buf tx_buf = {.buf = const_cast(tx), .len = count}; - struct spi_buf rx_buf = {.buf = rx, .len = count}; - struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; - struct spi_buf_set rx_set = {.buffers = rx_buf.buf ? &rx_buf : nullptr, .count = rx_buf.buf ? 1U : 0U}; - spi_transceive(dev, &_spi00_cfg, &tx_set, rx ? &rx_set : nullptr); -} - -void SPIClass::transfer(void *buf, size_t count) -{ - if (!count || !buf) - return; - transferBytes(reinterpret_cast(buf), reinterpret_cast(buf), (uint32_t)count); -} diff --git a/src/platform/nrf54l15/nrf54l15_main.cpp b/src/platform/nrf54l15/nrf54l15_main.cpp deleted file mode 100644 index 8dc5644e2d..0000000000 --- a/src/platform/nrf54l15/nrf54l15_main.cpp +++ /dev/null @@ -1,121 +0,0 @@ -/* - * nrf54l15_main.cpp - Zephyr entry point for Meshtastic nRF54L15 port - * - * Zephyr calls main() instead of Arduino's setup()/loop(). - * This file provides the main() that bootstraps the Arduino-style - * Meshtastic application loop. - */ - -#include -#include -#include -#include - -// Forward declarations from src/main.cpp -void setup(); -void loop(); - -// ── Crash info saved to noinit RAM (survives soft reset) ───────────────────── -// Zephyr's arch_esf does not expose the faulting SP directly; we capture PSP -// at entry to the fatal handler (the exception-basic frame lives there) and -// store xPSR alongside PC/LR for context. -struct crash_info { - uint32_t magic; - uint32_t reason; - uint32_t pc; - uint32_t psp; // stack pointer captured at fault entry - uint32_t xpsr; // saved program status (flags + exception number) - uint32_t lr; - uint32_t cfsr; // Configurable Fault Status Register -}; -static struct crash_info saved_crash __attribute__((section(".noinit"))); -#define CRASH_MAGIC 0xDEADBEEF - -// Override Zephyr's weak fatal handler to save crash info, then cold-reboot so -// main() can report the saved record on the next boot. We don't rely on -// CONFIG_RESET_ON_FATAL_ERROR (default off → k_fatal_halt would spin forever) -// - we issue sys_reboot() ourselves after flushing logs. -extern "C" void k_sys_fatal_error_handler(unsigned int reason, const struct arch_esf *esf) -{ - saved_crash.magic = CRASH_MAGIC; - saved_crash.reason = reason; - // Capture the faulting thread's stack pointer before we start using the - // handler's own stack for logging. - uint32_t psp_at_entry; - __asm__ volatile("mrs %0, psp" : "=r"(psp_at_entry)); - saved_crash.psp = psp_at_entry; - if (esf) { - saved_crash.pc = esf->basic.pc; - saved_crash.xpsr = esf->basic.xpsr; - saved_crash.lr = esf->basic.lr; - } - // Read Cortex-M33 SCB CFSR - saved_crash.cfsr = *((volatile uint32_t *)0xE000ED28U); - printk("[nrf54l15] FATAL reason=%u pc=0x%08x lr=0x%08x cfsr=0x%08x\n", reason, saved_crash.pc, saved_crash.lr, - saved_crash.cfsr); - - // Walk the failing thread's stack and print any word that looks like a - // Thumb code address (0x1000 - flash end, with the Thumb-mode low bit set). - // The Cortex-M exception frame at PSP holds r0,r1,r2,r3,r12,lr,pc,xpsr - // (8 words); deeper words are the caller's saved frame, which gives a - // crude but useful poor-man's backtrace when CONFIG_DEBUG_COREDUMP is off. - // Found the BLE-init bad_alloc → abort() chain (heap exhaustion under - // CONFIG_BT_BUF_ACL_RX_SIZE=251) when the fault dump alone showed only - // abort itself. Cheap (~150 B of code) and silent until a fault. - uint32_t psp; - __asm__ volatile("mrs %0, psp" : "=r"(psp)); - printk("[nrf54l15] PSP=0x%08x - stack walk:\n", psp); - // Validate PSP before dereferencing. Real faults frequently leave PSP - // pointing at corrupted/unmapped memory, and walking it blindly triggers a - // second fault inside this handler. Restrict to nRF54L15 SRAM (256 KB at - // 0x20000000) with 4-byte alignment, and clamp the walk so we never read - // past the end of RAM. - const uintptr_t SRAM_START = 0x20000000UL; - const uintptr_t SRAM_END = 0x20040000UL; - if (psp < SRAM_START || psp >= SRAM_END || (psp & 0x3U) != 0) { - printk("[nrf54l15] PSP out of SRAM range or unaligned, skipping walk\n"); - } else { - const uint32_t *sp = (const uint32_t *)psp; - int max_words = (int)((SRAM_END - psp) / sizeof(uint32_t)); - if (max_words > 96) - max_words = 96; - for (int i = 0; i < max_words; i++) { - uint32_t v = sp[i]; - if (v >= 0x00001000 && v < 0x00080000 && (v & 1)) { - printk("[nrf54l15] sp[%d]=0x%08x (code)\n", i, v); - } - } - } - - // Give the RTT/printk backend a chance to drain before we reset, otherwise - // the crash log line above is lost and the next boot's "Prev crash" line is - // the only forensic evidence we get. - k_busy_wait(50000); // 50 ms - sys_reboot(SYS_REBOOT_COLD); - // Unreachable; k_fatal_halt as a defensive backstop in case sys_reboot - // returns (it shouldn't). - k_fatal_halt(reason); -} - -int main(void) -{ - uint32_t reset_cause = 0; - hwinfo_get_reset_cause(&reset_cause); - hwinfo_clear_reset_cause(); - printk("[nrf54l15] Reset cause: 0x%08x\n", reset_cause); - - if (saved_crash.magic == CRASH_MAGIC) { - printk("[nrf54l15] Prev crash: reason=%u pc=0x%08x lr=0x%08x psp=0x%08x xpsr=0x%08x cfsr=0x%08x\n", saved_crash.reason, - saved_crash.pc, saved_crash.lr, saved_crash.psp, saved_crash.xpsr, saved_crash.cfsr); - saved_crash.magic = 0; - } - - printk("[nrf54l15] A: main() entry\n"); - printk("[nrf54l15] B: calling setup()\n"); - setup(); - printk("[nrf54l15] C: setup() returned\n"); - while (true) { - loop(); - } - return 0; -} diff --git a/src/platform/nrf54l15/utility/bonding.h b/src/platform/nrf54l15/utility/bonding.h deleted file mode 100644 index 951ee9e699..0000000000 --- a/src/platform/nrf54l15/utility/bonding.h +++ /dev/null @@ -1,11 +0,0 @@ -// utility/bonding.h - stub for nRF54L15/Zephyr -// NodeDB.cpp includes this when ARCH_NRF52 is defined. -// Bluetooth is excluded; this stub satisfies the include chain. -#pragma once - -// BLE role constants (from Bluefruit SDK) -#define BLE_GAP_ROLE_PERIPH 0x01 -#define BLE_GAP_ROLE_CENTRAL 0x02 - -// Stub for bond_print_list() -static inline void bond_print_list(uint8_t) {} diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 7ac778e3c4..f7aa2726ae 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -331,9 +331,9 @@ void portduinoSetup() return; #endif - if (portduino_config.force_simradio == true) { - portduino_config.lora_module = use_simradio; - } else if (configPath != nullptr) { + // An explicit -c is honored even under -s: it also carries non-radio settings + // (EnableUDP, display, GPIO) that have to survive simulated mode. + if (configPath != nullptr) { if (loadConfig(configPath)) { if (!yamlOnly && !configCheck) std::cout << "Using " << configPath << " as config file" << std::endl; @@ -343,6 +343,8 @@ void portduinoSetup() std::cout << "Unable to use " << configPath << " as config file" << std::endl; exit(EXIT_FAILURE); } + } else if (portduino_config.force_simradio) { + // -s with no -c: the simulator brings its own defaults, so skip config discovery. } else if (access("config.yaml", R_OK) == 0) { if (loadConfig("config.yaml")) { if (!yamlOnly && !configCheck) @@ -390,6 +392,12 @@ void portduinoSetup() } } + // Applied after every config source: ConfigDirectory entries can set Lora.Module + // too, and -s must win over all of them, including in --check / --output-yaml. + if (portduino_config.force_simradio) { + portduino_config.lora_module = use_simradio; + } + #ifndef ARCH_PORTDUINO_WASM // --check wins over --output-yaml: asking for validation and getting a config dump // with no report at all would be the more surprising of the two outcomes. diff --git a/src/platform/portduino/windows/WindowsService.cpp b/src/platform/portduino/windows/WindowsService.cpp index 4532ca7176..c378ca75f3 100644 --- a/src/platform/portduino/windows/WindowsService.cpp +++ b/src/platform/portduino/windows/WindowsService.cpp @@ -2,6 +2,7 @@ #if defined(ARCH_PORTDUINO) && defined(_WIN32) +#include "UptimeClock.h" #include "configuration.h" #include "main.h" @@ -65,7 +66,7 @@ static DWORD WINAPI controlHandler(DWORD control, DWORD, LPVOID, LPVOID) reportStatus(SERVICE_STOP_PENDING, PENDING_WAIT_HINT_MS); // Teardown belongs on the main thread: powerCommandsCheck() saves and exits, and the // atexit hook reports SERVICE_STOPPED on the way out. - shutdownAtMsec = millis(); + shutdownAtMsec = Time::skipZero(Time::getMillis()); return NO_ERROR; case SERVICE_CONTROL_INTERROGATE: return NO_ERROR; diff --git a/src/platform/stm32wl/architecture.h b/src/platform/stm32wl/architecture.h index 0aa59ff70c..83800af7af 100644 --- a/src/platform/stm32wl/architecture.h +++ b/src/platform/stm32wl/architecture.h @@ -33,6 +33,12 @@ "HAS_LSE is set but STM32WL_LSE_DRIVE is not defined - set it in the variant's variant.h to one of RCC_LSEDRIVE_LOW/MEDIUMLOW/MEDIUMHIGH/HIGH" #endif +#if HAS_LSE && !defined(HAS_CPU_SHUTDOWN) +#define HAS_CPU_SHUTDOWN 1 +#elif HAS_CPU_SHUTDOWN && !HAS_LSE +#error "STM32WL: HAS_CPU_SHUTDOWN requires HAS_LSE (RTC wake path)" +#endif + // // set HW_VENDOR // diff --git a/src/platform/stm32wl/littlefs/lfs_util.h b/src/platform/stm32wl/littlefs/lfs_util.h index 5c8469f88d..ef51fa8556 100644 --- a/src/platform/stm32wl/littlefs/lfs_util.h +++ b/src/platform/stm32wl/littlefs/lfs_util.h @@ -72,7 +72,13 @@ extern "C" { #ifndef LFS_NO_ASSERT #define LFS_ASSERT(test) assert(test) #else -#define LFS_ASSERT(test) +// assert() hangs forever on STM32WL (see main-stm32wl.cpp); route through a recoverable handler instead. +extern void lfs_assert(const char *reason); +#define LFS_ASSERT(test) \ + do { \ + if (!(test)) \ + lfs_assert(#test); \ + } while (0) #endif // Builtin functions, these may be replaced by more efficient diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 50f7ae3d81..efeadedfeb 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,4 +1,8 @@ +#include "FSCommon.h" +#include "UptimeClock.h" #include "configuration.h" +#include "error.h" +#include "gps/GPS.h" #include "gps/RTC.h" #include #include @@ -18,23 +22,27 @@ static bool stm32wlRtcValid = false; #endif // ─── Bootloader redirect ────────────────────────────────────────────────────── -// Uses .noinit SRAM instead of TAMP backup registers: STM32duino's clock init can wipe the -// backup domain via __HAL_RCC_BACKUPRESET_FORCE/RELEASE before setup() runs, but .noinit -// survives NVIC_SystemReset() and this constructor fires before HAL_Init() touches anything. +// Magic word in .noinit, not TAMP backup regs (STM32duino clock init can wipe those): it +// survives NVIC_SystemReset(), and the .preinit_array hook below runs before HAL_Init(). +// STM32WLxx system-memory bootloader base, AN2606 "STM32WLxx bootloader": +// https://www.st.com/resource/en/application_note/an2606-stm32-microcontroller-system-memory-boot-mode-stmicroelectronics.pdf #define BOOTLOADER_MAGIC 0xD00DB007UL #define SYS_MEM_BASE 0x1FFF0000UL -// Placed in .noinit - not zeroed at startup, survives NVIC_SystemReset(). +// .noinit - not zeroed at startup, survives NVIC_SystemReset(). __attribute__((section(".noinit"), used)) volatile uint32_t g_bootloaderMagic; -// Fires before main() / HAL_Init(). Must use only core Cortex-M registers. -__attribute__((constructor(101), used)) static void earlyBootCheck(void) +// Runs from .preinit_array (below), before every constructor incl. the core's premain()/init(), +// so RCC/SysTick/HAL are still at reset. Core Cortex-M / CMSIS registers only. +__attribute__((used)) static void earlyBootCheck(void) { if (g_bootloaderMagic != BOOTLOADER_MAGIC) return; g_bootloaderMagic = 0; + // Return SysTick/NVIC/RCC to reset state before the jump - ST's system bootloader expects it. + // https://community.st.com/t5/stm32-mcus/how-to-jump-to-system-bootloader-from-application-code-on-stm32/ta-p/49424 SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; @@ -42,20 +50,56 @@ __attribute__((constructor(101), used)) static void earlyBootCheck(void) NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } + + // Same writes CMSIS system_stm32wlxx.c SystemInit() makes, repeated here in case a hook + // between reset and .preinit_array moved SYSCLK onto the PLL (RM0461 reset values). + RCC->CR |= 0x00000061U; // MSION, MSI range 4 MHz + RCC->CFGR = 0x00070000U; // SYSCLK=MSI, AHB/APB prescalers /1, MCO off + RCC->CR = 0x00000061U; // HSEON/HSEBYP/CSSON/PLLON off + RCC->PLLCFGR = 0x22040100U; + RCC->CIER = 0x00000000U; + RCC->CICR = 0x0000033FU; // clear all RCC interrupt flags + __DSB(); __ISB(); SCB->VTOR = SYS_MEM_BASE; __set_MSP(*(volatile uint32_t *)SYS_MEM_BASE); ((void (*)(void))(*(volatile uint32_t *)(SYS_MEM_BASE + 4)))(); - // Should never be reached: the bootloader ROM does not return. A bare reset - // (rather than returning normally) avoids unwinding through this function's - // epilogue, which would restore registers relative to the now-repointed MSP. + // Not reached: the bootloader ROM does not return. Reset rather than return, to avoid + // unwinding this function's epilogue against the now-repointed MSP. NVIC_SystemReset(); } +// __libc_init_array runs .preinit_array entries before any .init_array constructor, so this +// precedes the core's premain() (constructor(101)) regardless of link order. +__attribute__((section(".preinit_array"), used)) static void (*const earlyBootCheckEntry)(void) = &earlyBootCheck; + +// Drain and release a UART before the reset, as cpuDeepSleep() does. +static void quiesceSerial(HardwareSerial &port) +{ + if (port) { + port.flush(); + port.end(); + } +} + void enterDfuMode() { g_bootloaderMagic = BOOTLOADER_MAGIC; + + // The ROM bootloader autobauds off the first byte on USART1 (PB6/PB7) or USART2 (PA2/PA3), + // and every WL UART and GPS sits on those pins. Silence them all before the reset. +#if !MESHTASTIC_EXCLUDE_GPS + if (gps) + gps->disable(); +#endif + quiesceSerial(Serial); +#ifdef ENABLE_HWSERIAL1 + quiesceSerial(Serial1); +#endif +#ifdef ENABLE_HWSERIAL2 + quiesceSerial(Serial2); +#endif HAL_NVIC_SystemReset(); } @@ -138,30 +182,59 @@ void cpuDeepSleep(uint32_t msecToWake) // Hardware can't shutdown, but firmware has already prepared itself for shutdown // Do not leave the device unresponsive, reset instead LOG_WARN("STM32WL: hardware RTC failed, can't deep sleep/shutdown"); - if (Serial) { - Serial.flush(); - Serial.end(); + quiesceSerial(Serial); + HAL_NVIC_SystemReset(); + } else { + quiesceSerial(Serial); + + if (msecToWake != portMAX_DELAY) { + LowPower.shutdown(msecToWake); + } else { + LowPower.shutdown(); } + // RTC wakes from shutdown into MCU reset, so this code should never be reached HAL_NVIC_SystemReset(); } - - if (Serial) { - Serial.flush(); - Serial.end(); - } - - if (msecToWake != portMAX_DELAY) { - LowPower.shutdown(msecToWake); - } else { - LowPower.shutdown(); - } - // RTC wakes from shutdown into MCU reset, so this code should never be reached - HAL_NVIC_SystemReset(); #endif } // ─── Linker hacks to reduce code size ───────────────────────────────────────── +// Requests a reformat-on-next-boot instead of reformatting mid-callback (see lfs_assert() below). +// Same .noinit survives-NVIC_SystemReset() trick as g_bootloaderMagic above. +#define LFS_CORRUPT_MAGIC 0xC0FFEEEEUL + +__attribute__((section(".noinit"), used)) static volatile uint32_t g_lfsCorruptMagic; + +// Not .noinit - resets every boot, so this only throttles reformats within one power-on session. +static constexpr uint32_t LFS_CORRUPTION_RETRY_DELAY_MS = 20 * 60 * 1000; +static uint32_t lastLfsFormatMs = 0; + +extern "C" void lfs_assert(const char *reason) +{ + LOG_ERROR("LittleFS corruption detected: %s", reason); + if (lastLfsFormatMs != 0 && Throttle::isWithinTimespanMs(lastLfsFormatMs, LFS_CORRUPTION_RETRY_DELAY_MS)) { + uint32_t msRemain = LFS_CORRUPTION_RETRY_DELAY_MS - (millis() - lastLfsFormatMs); + LOG_WARN("Pausing %u seconds to avoid wearing the flash with repeated reformats", msRemain / 1000); + delay(msRemain); + } + LOG_INFO("Rebooting to reformat LittleFS"); + g_lfsCorruptMagic = LFS_CORRUPT_MAGIC; + HAL_NVIC_SystemReset(); +} + +// Weak hook in FSCommon.cpp, called before FSBegin(). Reformats if the last boot's lfs_assert() requested it. +void preFSBegin() +{ + if (g_lfsCorruptMagic != LFS_CORRUPT_MAGIC) + return; + g_lfsCorruptMagic = 0; + lastLfsFormatMs = Time::skipZero(Time::getMillis()); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE); + fsFormat(); + LOG_INFO("LittleFS format complete; restoring default settings"); +} + // By default strerror has a lot of strings we probably don't use. Make it return an empty string instead. char empty = 0; extern "C" char *__wrap_strerror(int) diff --git a/src/power/SGM41562.cpp b/src/power/SGM41562.cpp index 1fce21dbb0..7835d19914 100644 --- a/src/power/SGM41562.cpp +++ b/src/power/SGM41562.cpp @@ -5,6 +5,7 @@ #include #include "Throttle.h" +#include "UptimeClock.h" SGM41562 *sgm41562 = nullptr; @@ -165,10 +166,10 @@ bool SGM41562::hasExtendedRegisterMap() const bool SGM41562::refresh() { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); if (lastRefreshMs_ != 0 && Throttle::isWithinTimespanMs(lastRefreshMs_, 250)) - return true; // cached - lastRefreshMs_ = now == 0 ? 1 : now; + return true; // cached + lastRefreshMs_ = Time::skipZero(now); // dodge the 0-sentinel case uint8_t status, fault; if (!readReg(REG_SYSTEM_STATUS, status)) diff --git a/src/security/EncryptedStorage.cpp b/src/security/EncryptedStorage.cpp index 9572f7ea07..9d697ec5c1 100644 --- a/src/security/EncryptedStorage.cpp +++ b/src/security/EncryptedStorage.cpp @@ -8,6 +8,7 @@ #include "SPILock.h" #include "SafeFile.h" #include "SecureZero.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include @@ -1273,9 +1274,9 @@ bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8 writeBackoff(reservedAttempts, 0, now); } auto onFailure = [reservedAttempts]() { - s_lastFailMillis = millis(); - if (s_lastFailMillis == 0) - s_lastFailMillis = 1; // sentinel: never 0 after a real fail + // TODO(elapsed-stamp): 0 doubles as "no failure yet" and the backoff read above is still on + // millis(); wants the sentinel's necessity and the clock split settled together, not a dodge. + s_lastFailMillis = Time::skipZero(Time::getMillis()); s_backoffSecondsRemaining = backoffDelay(reservedAttempts); LOG_WARN("EncryptedStorage: Wrong passphrase (attempt %u, next in ~%us)", (unsigned)reservedAttempts, s_backoffSecondsRemaining); diff --git a/src/sleep.cpp b/src/sleep.cpp index 6ed3084e12..5b224bae9e 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -31,9 +31,9 @@ esp_sleep_source_t wakeCause; // the reason we booted this time #endif #include "Throttle.h" -#ifdef USE_XL9555 -#include "ExtensionIOXL9555.hpp" -extern ExtensionIOXL9555 io; +#ifdef USE_PCA95X5 +#include PCA95X5_INC +extern PCA95X5_CLS io; #endif #ifdef HAS_PPM @@ -317,7 +317,9 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN #else pinMode(BUTTON_PIN, INPUT); #endif - gpio_hold_en((gpio_num_t)BUTTON_PIN); + // A held pad ignores ext1_wakeup_prepare()'s re-route to RTC, so never hold the pin we wake on. + if (config.device.button_gpio && config.device.button_gpio != BUTTON_PIN) + gpio_hold_en((gpio_num_t)BUTTON_PIN); } #endif #ifdef SENSECAP_INDICATOR @@ -477,6 +479,12 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #endif #if defined(WAKE_ON_TOUCH) gpio_wakeup_enable((gpio_num_t)SCREEN_TOUCH_INT, GPIO_INTR_LOW_LEVEL); +#endif +#ifdef MOTION_WAKE_INT_PIN + // Only arm motion wake when the user asked for it, otherwise every tilt costs a wakeup. + if (config.display.wake_on_tap_or_motion) + gpio_wakeup_enable((gpio_num_t)MOTION_WAKE_INT_PIN, + MOTION_WAKE_INT_ACTIVE_HIGH ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL); #endif enableLoraInterrupt(); #ifdef PMU_IRQ @@ -524,6 +532,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #if defined(WAKE_ON_TOUCH) gpio_wakeup_disable((gpio_num_t)SCREEN_TOUCH_INT); #endif +#ifdef MOTION_WAKE_INT_PIN + // Unconditional: the config can have changed while we were asleep. + gpio_wakeup_disable((gpio_num_t)MOTION_WAKE_INT_PIN); +#endif #if !defined(SOC_PM_SUPPORT_EXT_WAKEUP) && defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) if (radioType != RF95_RADIO) { gpio_wakeup_disable((gpio_num_t)LORA_DIO1); diff --git a/src/xmodem.cpp b/src/xmodem.cpp index 3c318f8021..3183de6385 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -124,7 +124,7 @@ void XModemAdapter::sendControl(meshtastic_XModem_Control c) packetReady.notifyObservers(packetno); } -meshtastic_XModem XModemAdapter::getForPhone() +const meshtastic_XModem &XModemAdapter::getForPhone() const { return xmodemStore; } diff --git a/src/xmodem.h b/src/xmodem.h index 6119a7b509..996b5cf109 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -49,7 +49,7 @@ class XModemAdapter XModemAdapter(); void handlePacket(meshtastic_XModem xmodemPacket); - meshtastic_XModem getForPhone(); + const meshtastic_XModem &getForPhone() const; void resetForPhone(); // True while a file transfer is in flight; lets callers avoid racing our `file` handle. diff --git a/suppressions.txt b/suppressions.txt index ca94c269d9..63c9b9208e 100644 --- a/suppressions.txt +++ b/suppressions.txt @@ -52,13 +52,8 @@ postfixOperator:*/mqtt/* missingOverride virtualCallInConstructor -passedByValue:*/RedirectablePrint.h - internalAstError:*/CrossPlatformCryptoEngine.cpp uninitMemberVar:*/AudioThread.h -// False positive -constVariableReference:*/Channels.cpp -constParameterPointer:*/unishox2.c // False positive: make_zeroizing_array() returns unique_ptr, so // .get() is uint8_t*, not void*. cppcheck can't resolve the custom-deleter alias @@ -67,4 +62,41 @@ arithOperationsOnVoidPointer:*/EncryptedStorage.cpp useStlAlgorithm -variableScope \ No newline at end of file +variableScope + +// cppcheck 2.20 (ESP32 only) +// +// The ESP32 CI images build on the pioarduino core, whose esp32 platform installs its own +// tool-cppcheck 2.20.1 and reinstalls it over any pinned version. Every other platform still +// resolves platformio/tool-cppcheck 1.21100.230717, i.e. cppcheck 2.11. 2.20 parses far more of +// this tree than 2.11 managed to, so checks that have always been enabled started firing for the +// first time - 388 defects on ESP32, none anywhere else, for identical source. +// +// Silence the checks that appeared with that jump so the gate means the same thing on every +// platform again. These are style and performance suggestions, not defects; burning them down is +// worth doing deliberately, not under a CI outage. +functionStatic +staticFunction +constParameterPointer +iterateByValue +returnByReference +passedByValue +uselessOverride + +constVariable +constVariablePointer +constVariableReference +constParameterReference + +// Single deliberate sites, scoped so a new one elsewhere still fails the gate. Router folds the +// bitfield's want_response bit into the decoded bool; Power interpolates the OCV curve in float. +bitwiseOnBoolean:*/Router.cpp +suspiciousFloatingPointCast:*/Power.cpp + +// The 2.20 successor to cstyleCast, which is already suppressed above for the same reason. +dangerousTypeCast + +// Sensor drivers hold a driver object they new in begin() and are constructed once, at global +// scope. cppcheck wants the rule of three on them; nothing ever copies one. +noCopyConstructor:*/Telemetry/Sensor/* +noOperatorEq:*/Telemetry/Sensor/* diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index 4c8f56bbb1..faaa5a0854 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -58,11 +58,13 @@ test_hop_start_policy writes=config.proto,module.proto,device.proto,channels.pro test_mesh_beacon writes=module.proto exercises the beacon's module-config save path test_mesh_module writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat module framework tests construct a NodeDB test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=1000..12000 constructs a NodeDB for node lookups in the MQTT paths +test_muted_source writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (isToUs needs nodeDB->getNodeNum(), and the DM branch looks the sender up), whose constructor persists a default set when the prefs directory is empty test_nexthop_routing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto next-hop selection reads and updates the node DB test_nodedb_blocked state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat saturates the DB with MAX_NUM_NODES-2 favourited nodes to test the protected cap; a later test's removeNodeByNum() persists that state, and the cap test depends on the fill from the test before it test_nodedb_boot_recovery state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto deliberate boot-recovery ladder: corrupts/deletes/restores the pref files and reboots a NodeDB per test to pin the DECODE_FAILED identity freeze, so each test observes the previous test's on-disk state test_nodedb_identity_hygiene writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs constructs a NodeDB; addFromContact persists the node DB after every merge, the reboot test proves the key-erasure guard survives a reload, and the should_ignore path rewrites the message store test_nodedb_legacy_migration writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat each test hand-writes a v24-format nodes.proto fixture and cold-boots a NodeDB, whose constructor persists the migrated v25 database (warm.dat via the over-cap eviction absorb) +test_nodedb_lora_slot writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty; the tests themselves only mutate config.lora in RAM and restore it in tearDown test_nodedb_v25_roundtrip writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat v25 persistence round-trips: every test saves nodes.proto and cold-boots a NodeDB whose constructor persists the default segments; warm.dat on the node-DB save cadence test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=300 needs a NodeDB holding both peers' keys for the PKI encode/decode paths test_phone_api_config_dump writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto per-test NodeDB fixture backing full PhoneAPI want_config dumps; the constructor persists a default config/channel/node set in a fresh sandbox diff --git a/test/support/userprefs_event_channel.h b/test/support/userprefs_event_channel.h new file mode 100644 index 0000000000..0ecea522b6 --- /dev/null +++ b/test/support/userprefs_event_channel.h @@ -0,0 +1,14 @@ +// Channel 0 as [env:coverage-event-policy] configures it. initDefaultChannel() applies a configured +// index as a whole, so a build setting the macros by hand supplies every field, as the generator does. + +#pragma once + +#define USERPREFS_CHANNEL_0_PSK \ + { \ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f \ + } +#define USERPREFS_CHANNEL_0_NAME "" +#define USERPREFS_CHANNEL_0_PRECISION 0 +#define USERPREFS_CHANNEL_0_IS_MUTED false +#define USERPREFS_CHANNEL_0_UPLINK_ENABLED false +#define USERPREFS_CHANNEL_0_DOWNLINK_ENABLED false diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 004037c9ad..a0cfb416f2 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -21,6 +21,8 @@ #include "TestUtil.h" #include "graphics/draw/MenuHandler.h" #include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" // crypto global: the tests swap in a stub engine to drive key derivation +#include "mesh/Router.h" // router global: allocErrorResponse() allocates the reply through it #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include // crc32Buffer(), for the my_node_num == crc32(public_key) invariant @@ -1000,6 +1002,10 @@ static meshtastic_DeviceState savedDeviceState; static meshtastic_User savedOwner; static meshtastic_LocalConfig savedConfig; static meshtastic_ChannelFile savedChannelFile; +// Only the ham dispatcher test installs a router (allocErrorResponse() allocates through it). +// Saved/torn down for every test so a failed assertion's longjmp cannot leave one dangling. +static Router *savedRouter; +static Router *hamMockRouter; // Called from setUp/tearDown for every test, not opted into by a handful. A shared NodeDB plus // unrestored config/owner/devicestate/channelFile means each test inherits whatever its @@ -1008,6 +1014,7 @@ static void replaceAdminRadioGlobals() { savedNodeDB = nodeDB; savedNodeInfoModule = nodeInfoModule; + savedRouter = router; savedDeviceState = devicestate; savedOwner = owner; savedConfig = config; @@ -1016,10 +1023,18 @@ static void replaceAdminRadioGlobals() nodeDB = replacementNodeDB; } +// Defined with the crypto stub below; tearDown must undo an install even when a failed assertion +// longjmped out of the test body before it could. +static void dropRestoreCryptoStub(); + static void restoreAdminRadioGlobals() { + dropRestoreCryptoStub(); nodeInfoModule = savedNodeInfoModule; nodeDB = savedNodeDB; + router = savedRouter; + delete hamMockRouter; + hamMockRouter = nullptr; delete replacementNodeDB; replacementNodeDB = nullptr; devicestate = savedDeviceState; @@ -1084,6 +1099,210 @@ static void test_handleSetOwner_persistsLicensedChannelSanitation() TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write"); } +// ----------------------------------------------------------------------- +// handleSetHamMode() name assembly: the ham long_name rides behind the call +// sign with the "//" separator hams already use on the air. +// ----------------------------------------------------------------------- + +// Licensing a node touches channels, the NodeDB and the owner struct; an UNSET region keeps the +// keygen/identity-migration path out of these name-only assertions. +static void primeHamModeTest() +{ + owner = meshtastic_User_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + channels.initDefaults(); + nodeInfoModule = reinterpret_cast(1); // reloadOwner(false) only checks presence + testAdmin->deferSaves(); +} + +static void test_handleSetHamMode_appendsLongNameToCallSign() +{ + primeHamModeTest(); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1); + strncpy(p.short_name, "ABC", sizeof(p.short_name) - 1); + strncpy(p.long_name, "Attic Heltec", sizeof(p.long_name) - 1); + TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p)); + + TEST_ASSERT_EQUAL_STRING("KD2ABC//Attic Heltec", owner.long_name); + TEST_ASSERT_EQUAL_STRING("ABC", owner.short_name); + TEST_ASSERT_TRUE(owner.is_licensed); +} + +// The widest pair the proto can carry (7 + 2 + 14) still has to arrive whole, or the operator +// silently loses the tail of the name they typed. +static void test_handleSetHamMode_widestPairSurvivesTheLongNameCap() +{ + primeHamModeTest(); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABCD", sizeof(p.call_sign) - 1); + strncpy(p.long_name, "Attic Heltec 3", sizeof(p.long_name) - 1); + TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p)); + + TEST_ASSERT_EQUAL_STRING("KD2ABCD//Attic Heltec 3", owner.long_name); + TEST_ASSERT_LESS_OR_EQUAL(MAX_LONG_NAME_BYTES, strlen(owner.long_name)); +} + +static void test_handleSetHamMode_omittedLongNameKeepsCallSignAlone() +{ + primeHamModeTest(); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1); + testAdmin->handleSetHamMode(p); + + TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name); + TEST_ASSERT_TRUE(owner.is_licensed); +} + +// long_name is optional both ways a client can leave it empty: a whitespace-only one is dropped +// (no dangling "//" on the air) instead of costing the operator the whole licensing request. +static void test_handleSetHamMode_blankLongNameIsIgnoredNotRejected() +{ + primeHamModeTest(); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1); + strncpy(p.long_name, " ", sizeof(p.long_name) - 1); + testAdmin->handleSetHamMode(p); + + TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name); + TEST_ASSERT_TRUE(owner.is_licensed); +} + +// The call sign is required, unlike the two optional name fields: an empty one would license a +// node that never identifies itself, and once a long_name is set it would compose to a dangling +// "//Attic Heltec". +static void test_handleSetHamMode_blankCallSignIsRejected() +{ + primeHamModeTest(); + + meshtastic_HamParameters missing = meshtastic_HamParameters_init_zero; + strncpy(missing.long_name, "Attic Heltec", sizeof(missing.long_name) - 1); + TEST_ASSERT_FALSE(testAdmin->handleSetHamMode(missing)); + + TEST_ASSERT_EQUAL_STRING("", owner.long_name); + TEST_ASSERT_FALSE(owner.is_licensed); + + primeHamModeTest(); + + meshtastic_HamParameters whitespace = meshtastic_HamParameters_init_zero; + strncpy(whitespace.call_sign, " ", sizeof(whitespace.call_sign) - 1); + TEST_ASSERT_FALSE(testAdmin->handleSetHamMode(whitespace)); + + TEST_ASSERT_EQUAL_STRING("", owner.long_name); + TEST_ASSERT_FALSE(owner.is_licensed); +} + +// short_name is optional too, so a blank one keeps whatever the node was already called instead of +// blanking it - licensing the node must not cost the operator their existing short name. +static void test_handleSetHamMode_blankShortNameKeepsTheExistingOne() +{ + for (const char *blank : {"", " "}) { + primeHamModeTest(); + strncpy(owner.short_name, "OLD", sizeof(owner.short_name) - 1); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1); + strncpy(p.short_name, blank, sizeof(p.short_name) - 1); + TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p)); + + TEST_ASSERT_EQUAL_STRING("OLD", owner.short_name); + TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name); + TEST_ASSERT_TRUE(owner.is_licensed); + } +} + +// A rejection has to reach the client, not just the log: allocErrorResponse() builds the reply +// through the router, so this is the one ham test that needs one. +class HamModeMockRouter : public Router +{ + public: + ~HamModeMockRouter() + { + delete cryptLock; // the Router ctor asserts this is clear, so a later suite can construct one + cryptLock = nullptr; + } + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } +}; + +// Pull the Routing error out of the ack/nak a handler queued in myReply. +static bool decodeRoutingError(meshtastic_MeshPacket *reply, meshtastic_Routing_Error &out) +{ + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (reply->decoded.portnum != meshtastic_PortNum_ROUTING_APP) + return false; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_Routing_msg, &routing)) + return false; + if (routing.which_variant != meshtastic_Routing_error_reason_tag) + return false; + out = routing.error_reason; + return true; +} + +// Handler-level rejection is invisible to a want_response client on its own: with no reply queued, +// handleReceivedProtobuf() falls through to its generic "ACK" and answers Routing_Error_NONE, so the +// app reports ham mode as enabled on a node that changed nothing. The dispatcher has to say +// BAD_REQUEST before that fallback runs. +static void test_handleSetHamMode_blankCallSignRepliesBadRequest() +{ + primeHamModeTest(); + hamMockRouter = new HamModeMockRouter(); + router = hamMockRouter; + + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_ham_mode_tag; + strncpy(m.set_ham_mode.long_name, "Attic Heltec", sizeof(m.set_ham_mode.long_name) - 1); + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; // local client, so the passkey gate is bypassed and the switch body runs + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.want_response = true; + testAdmin->handleReceivedProtobuf(mp, &m); + + meshtastic_Routing_Error err = meshtastic_Routing_Error_NONE; + TEST_ASSERT_TRUE_MESSAGE(decodeRoutingError(testAdmin->reply(), err), "a rejected request must queue an error reply"); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_BAD_REQUEST, err); + TEST_ASSERT_FALSE(owner.is_licensed); + testAdmin->drainReply(); +} + +// The other half of the pair: an accepted request still answers Routing_Error_NONE. Asserting both +// sides is the point - NONE is what the rejection path used to borrow, so a test that only checked +// the reject case could pass against a handler that answered NONE to everything. +static void test_handleSetHamMode_acceptedRequestAcksSuccess() +{ + primeHamModeTest(); + hamMockRouter = new HamModeMockRouter(); + router = hamMockRouter; + + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_ham_mode_tag; + strncpy(m.set_ham_mode.call_sign, "KD2ABC", sizeof(m.set_ham_mode.call_sign) - 1); + strncpy(m.set_ham_mode.long_name, "Attic Heltec", sizeof(m.set_ham_mode.long_name) - 1); + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.want_response = true; + testAdmin->handleReceivedProtobuf(mp, &m); + + meshtastic_Routing_Error err = meshtastic_Routing_Error_BAD_REQUEST; + TEST_ASSERT_TRUE_MESSAGE(decodeRoutingError(testAdmin->reply(), err), "want_response must be answered"); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, err); + TEST_ASSERT_EQUAL_STRING("KD2ABC//Attic Heltec", owner.long_name); + TEST_ASSERT_TRUE(owner.is_licensed); + testAdmin->drainReply(); +} + static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() { owner = meshtastic_User_init_zero; @@ -1490,6 +1709,241 @@ static void test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged() TEST_ASSERT_EQUAL_UINT(0, config.security.admin_key[0].size); } +// No low-entropy private key is published, so stand in for the engine to derive a blacklisted public +// key on demand. hash() is left real: the blacklist lookup runs through it. +static const uint8_t COMPROMISED_PUBLIC_KEY[32] = {0xac, 0xaf, 0x8c, 0x1c, 0x3c, 0x1c, 0x37, 0xac, 0x4f, 0x03, 0xa1, + 0xe9, 0xfc, 0x37, 0x23, 0x29, 0xc8, 0xa3, 0x5d, 0x7f, 0x05, 0x26, + 0xeb, 0x00, 0xbd, 0x26, 0xb8, 0x2e, 0xb1, 0x94, 0x7d, 0x24}; + +class RestoreDerivingCryptoEngine : public CryptoEngine +{ + public: + bool regenerateSucceeds = true; + bool derivesLowEntropy = true; + bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey) override + { + if (!regenerateSucceeds) + return false; + if (derivesLowEntropy) + memcpy(pubKey, COMPROMISED_PUBLIC_KEY, 32); + else + memset(pubKey, 0x7C, 32); + return true; + } + bool mintsLowEntropy = false; + void generateKeyPair(uint8_t *pubKey, uint8_t *privKey) override + { + if (mintsLowEntropy) + memcpy(pubKey, COMPROMISED_PUBLIC_KEY, 32); + else + memset(pubKey, 0x5E, 32); + memset(privKey, 0x5F, 32); + } +}; + +static CryptoEngine *savedCrypto; +static RestoreDerivingCryptoEngine *restoreCrypto; + +// Installed here and torn down in restoreAdminRadioGlobals(), not at the end of the test body: a failed +// TEST_ASSERT longjmps straight out, which would leave later tests running against a freed stub. +static RestoreDerivingCryptoEngine *installRestoreCrypto() +{ + savedCrypto = crypto; + restoreCrypto = new RestoreDerivingCryptoEngine(); + crypto = restoreCrypto; + return restoreCrypto; +} + +static void dropRestoreCryptoStub() +{ + if (!restoreCrypto) + return; + crypto = savedCrypto; + delete restoreCrypto; + restoreCrypto = nullptr; +} + +// Arms a bare private-key restore: region set so keygen runs, private key present, public key absent. +static meshtastic_Config makeBareKeyRestoreConfig() +{ + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + return c; +} + +static bool capturedWarningsContain(const char *needle) +{ + for (const std::string &w : capturedWarnings) + if (w.find(needle) != std::string::npos) + return true; + return false; +} + +// A restored private key deriving a blacklisted public key is rejected and rotated at set time, and +// the client is told why - not left to discover it after the next reboot. +static void test_handleSetConfig_security_lowEntropyRestoreWarnsAndRotates() +{ + installRestoreCrypto(); + + const meshtastic_Config c = makeBareKeyRestoreConfig(); + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_TRUE(nodeDB->keyIsLowEntropy); + TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size); + TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_TRUE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// A restore carrying a whole blacklisted pair must not skip validation just because it populated the +// public key too - that path reaches neither keygen branch, so the weak identity used to be kept. +static void test_handleSetConfig_security_lowEntropyFullKeypairRestoreIsRejected() +{ + installRestoreCrypto(); + + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + c.payload_variant.security.public_key.size = 32; + memcpy(c.payload_variant.security.public_key.bytes, COMPROMISED_PUBLIC_KEY, 32); + + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size); + TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_TRUE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// A blacklisted public key whose private key derives a clean one is only re-derived - the user's key +// does stick, so the "a new secure key was generated" warning would be a lie here. +static void test_handleSetConfig_security_reDerivedCleanKeyDoesNotWarn() +{ + installRestoreCrypto()->derivesLowEntropy = false; + + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + c.payload_variant.security.public_key.size = 32; + memcpy(c.payload_variant.security.public_key.bytes, COMPROMISED_PUBLIC_KEY, 32); + + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + // The supplied private key survives, and the blacklisted public key is replaced by its derivation. + uint8_t expectedPriv[32]; + memset(expectedPriv, 0x11, 32); + TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32); + TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// A replacement that is itself blacklisted leaves no identity behind - persisting a known-weak key +// would defeat the rejection this whole path exists for. +static void test_handleSetConfig_security_blacklistedMintLeavesNoKey() +{ + installRestoreCrypto()->mintsLowEntropy = true; + + const meshtastic_Config c = makeBareKeyRestoreConfig(); + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size); + TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// factory_reset_config keeps the private key and clears the public one, so the entry check sees no key +// and the boot-time derive path used to adopt whatever it produced - including a known-weak key. +static void test_generateCryptoKeyPair_derivedFromStoredPrivateIsChecked() +{ + installRestoreCrypto(); + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0x11, 32); + config.security.public_key.size = 0; // as installDefaultConfig(preserveKey = true) leaves it + + TEST_ASSERT_TRUE(nodeDB->generateCryptoKeyPair()); + + TEST_ASSERT_TRUE(nodeDB->keyIsLowEntropy); + TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key)); +} + +// Same clear-and-fail on the boot path: a stored private key that derives nothing must not leave both +// sizes at 32, claiming a pair the node never got. +static void test_generateCryptoKeyPair_failedDerivationFromStoredPrivateClearsKeySizes() +{ + installRestoreCrypto()->regenerateSucceeds = false; + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0x11, 32); + config.security.public_key.size = 0; + + TEST_ASSERT_FALSE(nodeDB->generateCryptoKeyPair()); + + TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size); +} + +// keyIsLowEntropy survives from a boot-time regeneration, and generateCryptoKeyPair returns early on +// an unset region without clearing it. The restore warning must stay gated on this keygen running. +static void test_handleSetConfig_security_staleLowEntropyFlagDoesNotWarn() +{ + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + initRegion(); + nodeDB->keyIsLowEntropy = true; + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// A private key that derives nothing usable must not leave sizes claiming a 32-byte pair behind: +// that state gets persisted, and every later keygen re-derives from the same dead key. +static void test_handleSetConfig_security_failedDerivationClearsKeySizes() +{ + installRestoreCrypto()->regenerateSucceeds = false; + + const meshtastic_Config c = makeBareKeyRestoreConfig(); + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size); + TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + static void test_regionInfo_supportsPreset() { const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); @@ -1965,6 +2419,91 @@ static void test_toggleNodeMuted_currentlyRewritesEverySegment() for (const char *f : segmentFiles) TEST_ASSERT_TRUE_MESSAGE(FSCom.exists(f), f); } + +// ----------------------------------------------------------------------- +// BaseUI region chooser preset default (graphics::menuHandler::presetForRegionSelection) +// ----------------------------------------------------------------------- +// +// Out-of-box US setup starts on LongTurbo. Each guard below is load-bearing: widening the rule past +// "first region ever chosen, US, no preset on record" re-presets nodes that already have an opinion. + +// `region` is the region still in place when the user highlights `selected`. +static meshtastic_Config_LoRaConfig loraAt(meshtastic_Config_LoRaConfig_RegionCode region, + meshtastic_Config_LoRaConfig_ModemPreset preset, bool usePreset = true) +{ + meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_default; + lora.region = region; + lora.modem_preset = preset; + lora.use_preset = usePreset; + return lora; +} + +#ifndef USERPREFS_LORACONFIG_MODEM_PRESET +static void test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); + + // Unusable unless US offers it: applyLoraRegion()'s reconciliation would throw it straight back. + TEST_ASSERT_TRUE_MESSAGE(getRegion(meshtastic_Config_LoRaConfig_RegionCode_US) + ->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO), + "US no longer supports LongTurbo"); +} +#else +// A pinned preset owns the decision outright. +static void test_presetForRegionSelection_pinnedUserprefWins() +{ + const meshtastic_Config_LoRaConfig_ModemPreset pinned = USERPREFS_LORACONFIG_MODEM_PRESET; + const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, pinned); + + TEST_ASSERT_EQUAL(pinned, graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} +#endif + +// US on a node that already has a region is a region change, not first-time setup. +static void test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} + +// The default is US-only; no other region's first selection is touched. +static void test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + for (auto region : {meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_RegionCode_ANZ, + meshtastic_Config_LoRaConfig_RegionCode_JP}) + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, region)); +} + +// A preset off the install default is a preference on record (phone app, admin, preset menu). +static void test_presetForRegionSelection_respectsAPresetAlreadyChosen() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} + +// use_preset false means raw bandwidth/SF/CR: rewriting modem_preset only misleads the preset menu. +static void test_presetForRegionSelection_ignoresNodesOnRawModemSettings() +{ + const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, /*usePreset=*/false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} #endif // HAS_SCREEN // ----------------------------------------------------------------------- @@ -2001,6 +2540,14 @@ void setup() // getRegion() RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); + RUN_TEST(test_handleSetHamMode_appendsLongNameToCallSign); + RUN_TEST(test_handleSetHamMode_widestPairSurvivesTheLongNameCap); + RUN_TEST(test_handleSetHamMode_omittedLongNameKeepsCallSignAlone); + RUN_TEST(test_handleSetHamMode_blankLongNameIsIgnoredNotRejected); + RUN_TEST(test_handleSetHamMode_blankShortNameKeepsTheExistingOne); + RUN_TEST(test_handleSetHamMode_blankCallSignIsRejected); + RUN_TEST(test_handleSetHamMode_blankCallSignRepliesBadRequest); + RUN_TEST(test_handleSetHamMode_acceptedRequestAcksSuccess); RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity); RUN_TEST(test_handleSetConfig_persistsUnlicensedFirstRegionIdentity); RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); @@ -2091,6 +2638,14 @@ void setup() RUN_TEST(test_handleSetConfig_security_acceptsSuppliedKeypair); RUN_TEST(test_handleSetConfig_security_rotationPreservesAdminKeys); RUN_TEST(test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged); + RUN_TEST(test_handleSetConfig_security_lowEntropyRestoreWarnsAndRotates); + RUN_TEST(test_handleSetConfig_security_lowEntropyFullKeypairRestoreIsRejected); + RUN_TEST(test_handleSetConfig_security_reDerivedCleanKeyDoesNotWarn); + RUN_TEST(test_handleSetConfig_security_blacklistedMintLeavesNoKey); + RUN_TEST(test_generateCryptoKeyPair_derivedFromStoredPrivateIsChecked); + RUN_TEST(test_generateCryptoKeyPair_failedDerivationFromStoredPrivateClearsKeySizes); + RUN_TEST(test_handleSetConfig_security_staleLowEntropyFlagDoesNotWarn); + RUN_TEST(test_handleSetConfig_security_failedDerivationClearsKeySizes); RUN_TEST(test_regionInfo_supportsPreset); RUN_TEST(test_checkConfigRegion_quietCheckReportsReason); RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner); @@ -2121,6 +2676,17 @@ void setup() RUN_TEST(test_toggleNodeMuted_flipsBitAndSkipsRadioReload); RUN_TEST(test_toggleNodeMuted_unknownNodeDoesNothing); RUN_TEST(test_toggleNodeMuted_currentlyRewritesEverySegment); + + // BaseUI region chooser preset default +#ifndef USERPREFS_LORACONFIG_MODEM_PRESET + RUN_TEST(test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo); +#else + RUN_TEST(test_presetForRegionSelection_pinnedUserprefWins); +#endif + RUN_TEST(test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset); + RUN_TEST(test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset); + RUN_TEST(test_presetForRegionSelection_respectsAPresetAlreadyChosen); + RUN_TEST(test_presetForRegionSelection_ignoresNodesOnRawModemSettings); #endif exit(UNITY_END()); diff --git a/test/test_airtime/test_main.cpp b/test/test_airtime/test_main.cpp index 6edab96fb3..330c9668c3 100644 --- a/test/test_airtime/test_main.cpp +++ b/test/test_airtime/test_main.cpp @@ -137,6 +137,115 @@ void test_channel_utilization_decays_once_the_60s_window_passes() TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.channelUtilizationPercent()); } +// channelUtilizationPercent() is a 60-second window, so one reading says almost nothing about +// load: a consumer that samples it every few minutes sees only the minute before each sample. +// The smoothed figure folds that window into an EMA once per crossed 10 s bucket, so it advances +// with real elapsed time rather than with how often somebody asks. + +void test_smoothed_channel_utilization_starts_from_the_raw_window() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, 30000); // 30 s of a 60 s window + + // Nothing has been folded yet. Reporting 0 here would read as an idle channel rather than as + // "no history", so the raw window stands in until the first bucket crossing. + TEST_ASSERT_FLOAT_WITHIN(0.01f, a.channelUtilizationPercent(), a.smoothedChannelUtilizationPercent()); +} + +void test_smoothed_channel_utilization_lags_a_sudden_spike() +{ + Time::setTestMillis(0); + AirTime a; + a.smoothedChannelUtilizationPercent(); // seed from an idle window + + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 30000); + + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + + const float raw = a.channelUtilizationPercent(); + const float smoothed = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_TRUE_MESSAGE(raw > 40.0f, "a 30 s burst should show up strongly in the 60 s window"); + TEST_ASSERT_TRUE_MESSAGE(smoothed < raw, "one busy bucket must not drag the smoothed figure with it"); +} + +void test_smoothed_channel_utilization_converges_on_a_sustained_level() +{ + Time::setTestMillis(0); + AirTime a; + a.smoothedChannelUtilizationPercent(); + + // Hold the channel at a steady load for well past the ~21 min time constant, refilling each + // 10 s bucket as it is cleared, and the smoothed figure should walk up to meet the window. + for (uint32_t tick = 0; tick < 400; tick++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 5000); // 5 s busy in every 10 s bucket -> 50% + a.smoothedChannelUtilizationPercent(); + } + + const float raw = a.channelUtilizationPercent(); + const float smoothed = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(5.0f, raw, smoothed, "sustained load should converge on the raw window"); +} + +// The raw window is already required to be independent of how often the scheduler runs (see +// test_channel_utilization_is_independent_of_scheduler_rate, and the rotation-on-access contract in +// src/airtime.h). The smoothed figure inherits that requirement: folding one reading for a whole +// delayed sync instead of one per crossed bucket would make the EMA a function of call frequency, +// so two identical nodes would disagree purely because one of them slept. +void test_smoothed_channel_utilization_is_independent_of_sync_rate() +{ + Time::setTestMillis(0); + AirTime stepped; + AirTime delayed; + + // Identical airtime history: one burst, then a full window of silence. Only the rate at which + // each instance is asked for the figure differs. + stepped.logAirtime(RX_LOG, 30000); + delayed.logAirtime(RX_LOG, 30000); + + for (uint32_t bucket = 0; bucket < CHANNEL_UTILIZATION_PERIODS; bucket++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + stepped.smoothedChannelUtilizationPercent(); // sampled every bucket + } + + const float steppedPct = stepped.smoothedChannelUtilizationPercent(); + const float delayedPct = delayed.smoothedChannelUtilizationPercent(); // asked once, at the end + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, steppedPct, delayedPct, + "the smoothed figure must not depend on how often it is sampled"); +} + +void test_smoothed_channel_utilization_decays_across_a_long_sleep() +{ + Time::setTestMillis(0); + AirTime a; + + for (uint32_t tick = 0; tick < 400; tick++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 5000); + a.smoothedChannelUtilizationPercent(); + } + const float busy = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_TRUE(busy > 10.0f); + + // A sleep longer than the whole window clears every bucket. The fold is capped at one window + // of steps, so the figure drops sharply without being reset outright. + Time::advanceTestMillis(10u * 60u * 1000u); + Time::serviceMonotonic(); + + const float afterSleep = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_EQUAL_FLOAT(0.0f, a.channelUtilizationPercent()); + TEST_ASSERT_TRUE_MESSAGE(afterSleep < busy, "a long idle gap must pull the smoothed figure down"); +} + void test_isTxAllowedChannelUtil_blocks_once_over_threshold() { Time::setTestMillis(0); @@ -1211,6 +1320,11 @@ void setup() RUN_TEST(test_period_history_clears_when_asleep_longer_than_the_whole_log); RUN_TEST(test_channel_utilization_reflects_recent_airtime); RUN_TEST(test_channel_utilization_decays_once_the_60s_window_passes); + RUN_TEST(test_smoothed_channel_utilization_starts_from_the_raw_window); + RUN_TEST(test_smoothed_channel_utilization_lags_a_sudden_spike); + RUN_TEST(test_smoothed_channel_utilization_converges_on_a_sustained_level); + RUN_TEST(test_smoothed_channel_utilization_is_independent_of_sync_rate); + RUN_TEST(test_smoothed_channel_utilization_decays_across_a_long_sleep); RUN_TEST(test_isTxAllowedChannelUtil_blocks_once_over_threshold); RUN_TEST(test_tx_utilization_decays_once_the_60_minute_window_passes); RUN_TEST(test_syncNow_survives_millis_wrap); diff --git a/test/test_channel_keys/test_main.cpp b/test/test_channel_keys/test_main.cpp index 34c42b3328..98fad049fd 100644 --- a/test/test_channel_keys/test_main.cpp +++ b/test/test_channel_keys/test_main.cpp @@ -286,6 +286,61 @@ void test_recursion_guard_primary_slot_marked_secondary() TEST_ASSERT_EQUAL_INT8(0, crypto->key.length); } +// ===================================================================================== +// Group 3b: use_aead consistency +// ===================================================================================== + +void test_aead_flag_changes_the_hash() +{ + // Same name and PSK on both slots, AEAD on one of them. The hashes must differ, or a + // receiver with AEAD off would match the hash and then CTR-decrypt an AEAD frame. + static const uint8_t psk[16] = {0x42}; + setSlot(1, meshtastic_Channel_Role_SECONDARY, "alpha", psk, sizeof(psk)); + const int16_t plainHash = channels.getHash(1); + TEST_ASSERT_FALSE(channels.isAEADEnabled(1)); + + setSlot(2, meshtastic_Channel_Role_SECONDARY, "alpha", psk, sizeof(psk)).settings.use_aead = true; + channels.fixupChannel(2); + TEST_ASSERT_TRUE(channels.isAEADEnabled(2)); + TEST_ASSERT_NOT_EQUAL(plainHash, channels.getHash(2)); + TEST_ASSERT_EQUAL_INT16((uint8_t)(plainHash ^ 0xAE), channels.getHash(2)); +} + +void test_aead_without_key_material_is_cleared() +{ + // PSK index 0 means encryption off. Leaving use_aead set there would still produce a + // valid-looking hash while every encode returns BAD_REQUEST and every decode drops. + static const uint8_t pskOff[1] = {0x00}; + meshtastic_Channel &ch = setSlot(0, meshtastic_Channel_Role_PRIMARY, "plain", pskOff, sizeof(pskOff)); + const int16_t plainHash = channels.getHash(0); + + ch.settings.use_aead = true; + channels.fixupChannel(0); + TEST_ASSERT_FALSE(ch.settings.use_aead); + TEST_ASSERT_FALSE(channels.isAEADEnabled(0)); + TEST_ASSERT_EQUAL_INT16(plainHash, channels.getHash(0)); // and no stray 0xAE in the hash +} + +void test_onconfigchanged_resolves_primary_before_hashing() +{ + // The primary moves to slot 2 while slot 0 becomes a keyless secondary. onConfigChanged() + // has to settle primaryIndex before it fixes anything up: hashing slot 0 against the old + // primary (itself) trips getKey()'s recursion guard, which yields a name-only hash and + // clears use_aead against key material the channel does in fact inherit. + static const uint8_t movedPsk[16] = {0x5A}; + setSlot(0, meshtastic_Channel_Role_SECONDARY, "second", nullptr, 0); + channels.getByIndex(0).settings.use_aead = true; + setSlot(2, meshtastic_Channel_Role_PRIMARY, "moved", movedPsk, sizeof(movedPsk)); + + channels.onConfigChanged(); + + TEST_ASSERT_EQUAL_UINT8(2, channels.getPrimaryIndex()); + TEST_ASSERT_TRUE(channels.isAEADEnabled(0)); // the key is inherited, not absent + TEST_ASSERT_EQUAL_INT16((uint8_t)(refHash("second", movedPsk, sizeof(movedPsk)) ^ 0xAE), channels.getHash(0)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(movedPsk, sizeof(movedPsk)); +} + // ===================================================================================== // Group 4: onConfigChanged() no-primary restore and setChannel() demotion // ===================================================================================== @@ -562,6 +617,11 @@ CK_TEST_ENTRY void setup() RUN_TEST(test_secondary_empty_psk_inherits_primary_key); RUN_TEST(test_recursion_guard_primary_slot_marked_secondary); + printf("\n=== use_aead consistency ===\n"); + RUN_TEST(test_aead_flag_changes_the_hash); + RUN_TEST(test_aead_without_key_material_is_cleared); + RUN_TEST(test_onconfigchanged_resolves_primary_before_hashing); + printf("\n=== onConfigChanged restore and setChannel ===\n"); RUN_TEST(test_onconfigchanged_promotes_demoted_primary_slot_keeping_key); RUN_TEST(test_onconfigchanged_restores_default_when_all_disabled); diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index 448942ffe6..949cbde668 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -4,6 +4,7 @@ #include "TestUtil.h" #include "aes-ccm.h" #include +#include #include void HexToBytes(uint8_t *result, const std::string hex, size_t len = 0) @@ -87,6 +88,29 @@ void test_ECB_AES256(void) crypto->aesEncrypt(plain, result); // Does 16 bytes at a time TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); } +void test_ECB_AES128(void) +{ + // https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Standards-and-Guidelines/documents/examples/AES_ECB.pdf + uint8_t key[16] = {0}; + uint8_t plain[16] = {0}; + uint8_t result[16] = {0}; + uint8_t expected[16] = {0}; + + HexToBytes(key, "2B7E151628AED2A6ABF7158809CF4F3C"); + + HexToBytes(plain, "6BC1BEE22E409F96E93D7E117393172A"); + HexToBytes(expected, "3AD77BB40D7A3660A89ECAF32466EF97"); + crypto->aesSetKey(key, 16); + crypto->aesEncrypt(plain, result); + TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); + + HexToBytes(plain, "AE2D8A571E03AC9C9EB76FAC45AF8E51"); + HexToBytes(expected, "F5D3D58503B9699DE785895A96FDBAAF"); + crypto->aesSetKey(key, 16); + crypto->aesEncrypt(plain, result); + TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); +} + void test_DH25519(void) { // test vectors from wycheproof x25519 @@ -358,6 +382,429 @@ void test_AES_CCM_partial_block_bounds(void) } } +void test_AES_CCM_rfc3610(void) +{ + // Known-answer vectors from RFC 3610 section 8. They all use L=2, which is what + // aes_ccm_ae()/aes_ccm_ad() hardcode, and each ends in a partial block. + struct CcmVector { + const char *key; + const char *nonce; + const char *aad; + const char *plain; + const char *crypt; + const char *tag; + }; + const CcmVector vectors[] = { + // Packet Vector #1, M=8 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000003020100A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E", "588C979A61C663D2F066D0C2C0F989806D5F6B61DAC384", "17E8D12CFDF926E0"}, + // Packet Vector #2, M=8 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000004030201A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", "72C91A36E135F8CF291CA894085C87E3CC15C439C9E43A3B", + "A091D56E10400916"}, + // Packet Vector #7, M=10 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000009080706A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E", "0135D1B2C95F41D5D1D4FEC185D166B8094E999DFED96C", + "048C56602C97ACBB7490"}, + }; + + for (size_t v = 0; v < sizeof(vectors) / sizeof(vectors[0]); v++) { + const CcmVector &vec = vectors[v]; + const size_t plainLen = strlen(vec.plain) / 2; + const size_t aadLen = strlen(vec.aad) / 2; + const size_t tagLen = strlen(vec.tag) / 2; + + uint8_t key[16], nonce[13], aad[8]; + uint8_t plain[32], expectedCrypt[32], expectedTag[16]; + uint8_t crypt[32], tag[16], decrypted[32]; + + HexToBytes(key, vec.key); + HexToBytes(nonce, vec.nonce); + HexToBytes(aad, vec.aad); + HexToBytes(plain, vec.plain); + HexToBytes(expectedCrypt, vec.crypt); + HexToBytes(expectedTag, vec.tag); + + TEST_ASSERT_EQUAL(0, aes_ccm_ae(key, sizeof(key), nonce, tagLen, plain, plainLen, aad, aadLen, crypt, tag)); + TEST_ASSERT_EQUAL_MEMORY(expectedCrypt, crypt, plainLen); + TEST_ASSERT_EQUAL_MEMORY(expectedTag, tag, tagLen); + + TEST_ASSERT_TRUE(aes_ccm_ad(key, sizeof(key), nonce, tagLen, crypt, plainLen, aad, aadLen, tag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plain, decrypted, plainLen); + + // The AAD is authenticated but not encrypted: corrupting it must fail the tag check + aad[0] ^= 0x01; + TEST_ASSERT_FALSE(aes_ccm_ad(key, sizeof(key), nonce, tagLen, crypt, plainLen, aad, aadLen, tag, decrypted)); + } +} + +// Helper to create a zero-initialized CryptoKey (matching Channels::getKey() behavior) +static CryptoKey makePsk(const std::string &hex) +{ + CryptoKey k; + assert(hex.length() / 2 <= sizeof(k.bytes)); + memset(k.bytes, 0, sizeof(k.bytes)); + k.length = hex.length() / 2; + HexToBytes(k.bytes, hex); + return k; +} + +void test_AES_CCM_AEAD_smoke(void) +{ + // Smoke test - encryption changes the payload and produces a tag + // (the known-answer coverage lives in test_AES_CCM_rfc3610) + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0x12345678; + uint32_t toNode = 0x0000AAAA; + uint64_t packetId = 0xAABBCCDD; + + uint8_t plaintext[10]; + HexToBytes(plaintext, "08011204746573744800"); + + uint8_t ciphertextWithTag[10 + CryptoEngine::AEAD_TAG_SIZE]; + memset(ciphertextWithTag, 0, sizeof(ciphertextWithTag)); + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 10, plaintext, ciphertextWithTag)); + + // Ciphertext should differ from plaintext + TEST_ASSERT_FALSE(memcmp(plaintext, ciphertextWithTag, 10) == 0); + + // Tag bytes (last 12) should not all be zero + bool tagAllZero = true; + for (size_t i = 0; i < CryptoEngine::AEAD_TAG_SIZE; i++) { + if (ciphertextWithTag[10 + i] != 0) { + tagAllZero = false; + break; + } + } + TEST_ASSERT_FALSE(tagAllZero); +} + +void test_AES_CCM_AEAD_roundtrip_aes256(void) +{ + // Round-trip encrypt → decrypt → compare (AES-256) + CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4"); + + uint32_t fromNode = 0xDEADBEEF; + uint32_t toNode = 0xFFFFFFFF; + uint64_t packetId = 0x0102030405060708; + + const char *msg = "Hello Meshtastic AEAD!"; + size_t msgLen = strlen(msg); + + uint8_t ciphertextWithTag[64]; + memset(ciphertextWithTag, 0, sizeof(ciphertextWithTag)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, msgLen, (const uint8_t *)msg, ciphertextWithTag)); + + uint8_t decrypted[64]; + memset(decrypted, 0, sizeof(decrypted)); + size_t totalBytes = msgLen + CryptoEngine::AEAD_TAG_SIZE; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, totalBytes, ciphertextWithTag, decrypted)); + + TEST_ASSERT_EQUAL_MEMORY(msg, decrypted, msgLen); +} + +void test_AES_CCM_AEAD_rejects_tampering(void) +{ + // Tampered ciphertext - flip a bit, verify rejection + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xABCD1234; + uint32_t toNode = 0x00000001; + uint64_t packetId = 0x11223344; + + uint8_t plaintext[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + uint8_t ciphertextWithTag[8 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 8, plaintext, ciphertextWithTag)); + + // Flip a bit in the ciphertext portion + ciphertextWithTag[3] ^= 0x01; + + uint8_t decrypted[8]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } + + // Tampered auth tag - modify tag, verify rejection + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xABCD1234; + uint32_t toNode = 0x87654321; + uint64_t packetId = 0x55667788; + + uint8_t plaintext[16] = {0}; + for (int i = 0; i < 16; i++) + plaintext[i] = (uint8_t)i; + + uint8_t ciphertextWithTag[16 + CryptoEngine::AEAD_TAG_SIZE]; + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 16, plaintext, ciphertextWithTag)); + + // Corrupt the auth tag (last byte) + ciphertextWithTag[16 + CryptoEngine::AEAD_TAG_SIZE - 1] ^= 0xFF; + + uint8_t decrypted[16]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 16 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } +} + +void test_AES_CCM_AEAD_rejects_undersized(void) +{ + // Packet too small for AEAD - totalBytes <= AEAD_TAG_SIZE + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint8_t dummy[CryptoEngine::AEAD_TAG_SIZE] = {0}; + // Sized for the whole input so a regressed length guard fails the assertion below + // instead of corrupting the stack on its way out. + uint8_t out[CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x4321, 0x5678, CryptoEngine::AEAD_TAG_SIZE, dummy, out)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x4321, 0x5678, 0, dummy, out)); +} + +void test_AES_CCM_AEAD_rejects_wrong_psk(void) +{ + // Wrong PSK - decrypt with different key, verify rejection + CryptoKey pskA = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + CryptoKey pskB = makePsk("00112233445566778899aabbccddeeff"); + + uint32_t fromNode = 0x99887766; + uint32_t toNode = 0x13579BDF; + uint64_t packetId = 0xDEADFACE; + + uint8_t plaintext[12] = "Hello World"; + uint8_t ciphertextWithTag[12 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(pskA, fromNode, toNode, packetId, 12, plaintext, ciphertextWithTag)); + + // Attempt decryption with wrong key + uint8_t decrypted[12]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(pskB, fromNode, toNode, packetId, 12 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); +} + +void test_AES_CCM_AEAD_roundtrip_aes128(void) +{ + // Round-trip with AES-128 PSK (16-byte key, true AES-128-CCM) + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0x42424242; + uint32_t toNode = 0x2468ACE0; + uint64_t packetId = 0xBEEF1234; + + uint8_t plaintext[20] = "AES128 round trip!"; + uint8_t ciphertextWithTag[20 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 20, plaintext, ciphertextWithTag)); + + uint8_t decrypted[20]; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 20 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 20); +} + +void test_AES_CCM_AEAD_tamper_sweep(void) +{ + // AES-256-CCM round-trip + per-byte tamper detection + CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4"); + + uint32_t fromNode = 0x01020304; + uint32_t toNode = 0x0BADCAFE; + uint64_t packetId = 0x0A0B0C0D0E0F1011; + + uint8_t plaintext[32]; + for (int i = 0; i < 32; i++) + plaintext[i] = (uint8_t)(i * 7 + 3); + + uint8_t ciphertextWithTag[32 + CryptoEngine::AEAD_TAG_SIZE]; + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 32, plaintext, ciphertextWithTag)); + + // Valid decrypt + uint8_t decrypted[32]; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 32); + + // Flip a bit in every byte in turn, tag included, and verify each one is rejected + for (size_t i = 0; i < 32 + CryptoEngine::AEAD_TAG_SIZE; i++) { + uint8_t tampered[32 + CryptoEngine::AEAD_TAG_SIZE]; + memcpy(tampered, ciphertextWithTag, sizeof(tampered)); + tampered[i] ^= 0x80; + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, tampered, decrypted)); + } +} + +void test_AES_CCM_AEAD_is_deterministic(void) +{ + // Deterministic - same inputs produce same output + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xCAFEBABE; + uint32_t toNode = 0x5A5A5A5A; + uint64_t packetId = 0xFEEDFACE; + + uint8_t plaintext[5] = {0xDE, 0xAD, 0xBE, 0xEF, 0x42}; + uint8_t ct1[5 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t ct2[5 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 5, plaintext, ct1)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 5, plaintext, ct2)); + + TEST_ASSERT_EQUAL_MEMORY(ct1, ct2, 5 + CryptoEngine::AEAD_TAG_SIZE); +} + +void test_AES_CCM_AEAD_binds_nonce_inputs(void) +{ + // Wrong nonce input - the nonce derives from both fromNode and packetId, + // so each one on its own must be enough to make the tag check fail + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNodeA = 0x11111111; + uint32_t fromNodeB = 0x22222222; + uint32_t toNode = 0x77777777; + uint64_t packetIdA = 0xAAAABBBB; + uint64_t packetIdB = 0xCCCCDDDD; + + uint8_t plaintext[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + uint8_t ciphertextWithTag[6 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNodeA, toNode, packetIdA, 6, plaintext, ciphertextWithTag)); + + uint8_t decrypted[6]; + // Wrong fromNode, right packetId + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeB, toNode, packetIdA, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + // Right fromNode, wrong packetId + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeA, toNode, packetIdB, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + // Both wrong + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeB, toNode, packetIdB, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + // Both right still succeeds, so the assertions above are not passing for free + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNodeA, toNode, packetIdA, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 6); +} + +void test_AES_CCM_AEAD_rejects_invalid_psk(void) +{ + // Empty PSK - must return false, not crash + CryptoKey emptyPsk; + memset(&emptyPsk, 0, sizeof(emptyPsk)); + emptyPsk.length = 0; + + uint32_t fromNode = 0xDEADBEEF; + uint32_t toNode = 0x0000BEEF; + uint64_t packetId = 0x12345678; + + uint8_t plaintext[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + uint8_t ciphertextWithTag[8 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t decrypted[8]; + + // Encrypt with empty PSK must fail gracefully + TEST_ASSERT_FALSE(crypto->encryptPacketCCM(emptyPsk, fromNode, toNode, packetId, 8, plaintext, ciphertextWithTag)); + + // Decrypt with empty PSK must fail gracefully + // (use dummy ciphertext since encrypt failed) + memset(ciphertextWithTag, 0xAA, sizeof(ciphertextWithTag)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(emptyPsk, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + + // CryptoKey uses -1 as its "invalid key - do not use" sentinel, and it would widen + // into a huge unsigned length rather than be rejected. Both directions must refuse it. + CryptoKey invalidPsk; + memset(&invalidPsk, 0, sizeof(invalidPsk)); + invalidPsk.length = -1; + + TEST_ASSERT_FALSE(crypto->encryptPacketCCM(invalidPsk, fromNode, toNode, packetId, 8, plaintext, ciphertextWithTag)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(invalidPsk, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); +} + +void test_AES_CCM_AEAD_key_size_distinction(void) +{ + // AES-128 vs AES-256 produce different ciphertexts + // Verifies that 16-byte keys use true AES-128, not AES-256 with padding. + // Same 16 bytes of key material, but one is AES-128 (16 bytes) + // and the other is AES-256 (32 bytes, zero-padded). + CryptoKey psk128 = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + CryptoKey psk256; + memset(psk256.bytes, 0, sizeof(psk256.bytes)); + HexToBytes(psk256.bytes, "d4f1bb3a20290759f0bcffabcf4e6901"); + psk256.length = 32; // same first 16 bytes, but treated as AES-256 + + uint32_t fromNode = 0x55AA55AA; + uint32_t toNode = 0x33333333; + uint64_t packetId = 0x1234ABCD; + + uint8_t plaintext[8] = {0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80}; + uint8_t ct128[8 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t ct256[8 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk128, fromNode, toNode, packetId, 8, plaintext, ct128)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk256, fromNode, toNode, packetId, 8, plaintext, ct256)); + + // AES-128 and AES-256 with the same key material must produce different output + TEST_ASSERT_FALSE(memcmp(ct128, ct256, 8 + CryptoEngine::AEAD_TAG_SIZE) == 0); + + // Both must still round-trip correctly + uint8_t dec128[8], dec256[8]; + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk128, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct128, dec128)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, dec128, 8); + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk256, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct256, dec256)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, dec256, 8); + + // Cross-key decryption must fail + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk256, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct128, dec128)); + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk128, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct256, dec256)); +} + +void test_AES_CCM_AEAD_binds_destination(void) +{ + // Rewritten destination - `to` is authenticated as associated data, so changing + // it in flight must fail the tag check even though the nonce is unaffected + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0x0A0B0C0D; + uint32_t toNode = 0x00000042; + uint32_t otherNode = 0x00000043; + uint32_t broadcast = 0xFFFFFFFF; + uint64_t packetId = 0x99887766; + + uint8_t plaintext[9] = {'t', 'o', '-', 'i', 's', '-', 'a', 'a', 'd'}; + uint8_t ciphertextWithTag[9 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t decrypted[9]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 9, plaintext, ciphertextWithTag)); + + // Redirecting the packet to another node must be rejected + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, otherNode, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + + // Promoting a unicast to a broadcast must be rejected too + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, broadcast, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + + // The unmodified destination still round-trips, so the rejections above are not vacuous + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 9); + + // A different destination must also change the tag, not just be rejected on decrypt + uint8_t otherCiphertextWithTag[9 + CryptoEngine::AEAD_TAG_SIZE]; + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, otherNode, packetId, 9, plaintext, otherCiphertextWithTag)); + TEST_ASSERT_FALSE(memcmp(ciphertextWithTag + 9, otherCiphertextWithTag + 9, CryptoEngine::AEAD_TAG_SIZE) == 0); +} + void setup() { // NOTE!!! Wait for >2 secs @@ -369,10 +816,12 @@ void setup() UNITY_BEGIN(); // IMPORTANT LINE! RUN_TEST(test_SHA256); RUN_TEST(test_SHA256_large_input); + RUN_TEST(test_ECB_AES128); RUN_TEST(test_ECB_AES256); RUN_TEST(test_DH25519); RUN_TEST(test_AES_CTR); RUN_TEST(test_AES_CCM_partial_block_bounds); + RUN_TEST(test_AES_CCM_rfc3610); RUN_TEST(test_PKC); RUN_TEST(test_XEdDSA); RUN_TEST(test_XEdDSA_cross_key_reject); @@ -380,6 +829,18 @@ void setup() RUN_TEST(test_XEdDSA_curve_to_ed_cache); RUN_TEST(test_XEdDSA_max_payload); RUN_TEST(test_XEdDSA_repeated_sign_is_randomized); + RUN_TEST(test_AES_CCM_AEAD_smoke); + RUN_TEST(test_AES_CCM_AEAD_roundtrip_aes256); + RUN_TEST(test_AES_CCM_AEAD_rejects_tampering); + RUN_TEST(test_AES_CCM_AEAD_rejects_undersized); + RUN_TEST(test_AES_CCM_AEAD_rejects_wrong_psk); + RUN_TEST(test_AES_CCM_AEAD_roundtrip_aes128); + RUN_TEST(test_AES_CCM_AEAD_tamper_sweep); + RUN_TEST(test_AES_CCM_AEAD_is_deterministic); + RUN_TEST(test_AES_CCM_AEAD_binds_nonce_inputs); + RUN_TEST(test_AES_CCM_AEAD_rejects_invalid_psk); + RUN_TEST(test_AES_CCM_AEAD_key_size_distinction); + RUN_TEST(test_AES_CCM_AEAD_binds_destination); exit(UNITY_END()); // stop unit testing } diff --git a/test/test_geofence/test_main.cpp b/test/test_geofence/test_main.cpp new file mode 100644 index 0000000000..80a0daeccb --- /dev/null +++ b/test/test_geofence/test_main.cpp @@ -0,0 +1,276 @@ +#include "TestUtil.h" +#include "modules/GeofenceModule.h" +#include + +// These cover pure geofence helpers without device globals or a fake clock. +// Store and notification plumbing are not covered here. + +using Crossing = GeofenceModule::Crossing; + +// 0.001 deg of longitude at the equator is ~111 m; 0.001 deg = 10000 in degrees x 1e-7 units. +static const int32_t kLon111m = 10000; + +static void test_insideRadius_centreIsInside() +{ + TEST_ASSERT_TRUE(GeofenceModule::insideRadius(123456, 654321, 123456, 654321, 10)); +} + +static void test_insideRadius_withinRadius() +{ + // ~111 m east of the centre, radius 200 m -> inside. + TEST_ASSERT_TRUE(GeofenceModule::insideRadius(0, kLon111m, 0, 0, 200)); +} + +static void test_insideRadius_outsideRadius() +{ + // ~111 m east of the centre, radius 50 m -> outside. + TEST_ASSERT_FALSE(GeofenceModule::insideRadius(0, kLon111m, 0, 0, 50)); +} + +static void test_insideRadius_zeroRadiusNeverInside() +{ + // radius 0 means "no circle" -> never inside, even exactly at the centre. + TEST_ASSERT_FALSE(GeofenceModule::insideRadius(123456, 654321, 123456, 654321, 0)); +} + +static meshtastic_BoundingBox makeBox() +{ + meshtastic_BoundingBox box = meshtastic_BoundingBox_init_zero; + box.longitude_west_i = -1000; + box.latitude_south_i = -2000; + box.longitude_east_i = 1000; + box.latitude_north_i = 2000; + return box; +} + +static void test_insideBox_centreInside() +{ + TEST_ASSERT_TRUE(GeofenceModule::insideBox(0, 0, makeBox())); +} + +static void test_insideBox_edgesInclusive() +{ + meshtastic_BoundingBox box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::insideBox(box.latitude_north_i, box.longitude_east_i, box)); + TEST_ASSERT_TRUE(GeofenceModule::insideBox(box.latitude_south_i, box.longitude_west_i, box)); +} + +static void test_insideBox_outsideLat() +{ + TEST_ASSERT_FALSE(GeofenceModule::insideBox(2001, 0, makeBox())); +} + +static void test_insideBox_outsideLon() +{ + TEST_ASSERT_FALSE(GeofenceModule::insideBox(0, 1001, makeBox())); +} + +static void test_inside_circleOnly() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.latitude_i = 0; + wp.longitude_i = 0; + wp.geofence_radius = 200; + wp.has_bounding_box = false; + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 0, kLon111m)); + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 0, kLon111m * 4)); // ~444 m east +} + +static void test_inside_boxOnly() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 0; + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 0, 0)); + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 5000, 0)); +} + +static void test_inside_eitherShapeCounts() +{ + // Circle is tiny (point far from centre is outside it) but the box still contains the point. + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 1; // 1 m circle + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 1500, 0)); // outside circle, inside box +} + +static void test_inside_noGeofenceNeverInside() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 0; + wp.has_bounding_box = false; + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 0, 0)); + TEST_ASSERT_FALSE(GeofenceModule::hasGeofence(wp)); +} + +static meshtastic_Waypoint makeNotifyingWaypoint() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + return wp; +} + +static void test_shouldTrack_circleWithCentre() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_circleWithoutCentreRejected() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; // circle needs a centre + wp.has_latitude_i = false; + wp.has_longitude_i = false; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_boxOnlyWithoutCentreAccepted() +{ + // A box-only geofence carries absolute corners, so it needs no waypoint centre. + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 0; + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + wp.has_latitude_i = false; + wp.has_longitude_i = false; + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_noGeofenceRejected() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); // notify set, but no geofence shape + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_noLocalPreferencesRejected() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + wp.notify_on_enter = true; + wp.notify_on_exit = true; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, 0, 0)); +} + +static void test_shouldTrack_expiredRejectedButLiveAccepted() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + wp.expire = 1000; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 2000)); // expire <= now -> expired + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 500)); // expire > now -> live + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); // no clock -> treat as live +} + +static meshtastic_Waypoint makeWireNotificationWaypoint() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.notify_on_enter = true; + wp.notify_on_exit = true; + wp.notify_favorites_only = true; + return wp; +} + +static void test_localWaypointInitializesPreferencesFromWireFields() +{ + const meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + const uint8_t preferences = WaypointStore::mergeNotificationPreferences(true, false, 0, wp); + TEST_ASSERT_EQUAL_UINT8(WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_EXIT | WAYPOINT_NOTIFY_FAVORITES_ONLY, preferences); +} + +static void test_newRemoteWaypointIgnoresWirePreferences() +{ + const meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + TEST_ASSERT_EQUAL_UINT8(0, WaypointStore::mergeNotificationPreferences(false, false, 0, wp)); +} + +static void test_remoteWaypointUpdatePreservesLocalPreferences() +{ + meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + wp.notify_on_enter = false; + const uint8_t existing = WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_FAVORITES_ONLY; + TEST_ASSERT_EQUAL_UINT8(existing, WaypointStore::mergeNotificationPreferences(false, true, existing, wp)); +} + +static void test_storedWaypoint_clearsWirePreferences() +{ + meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + WaypointStore::clearWireNotificationPreferences(wp); + TEST_ASSERT_FALSE(wp.notify_on_enter); + TEST_ASSERT_FALSE(wp.notify_on_exit); + TEST_ASSERT_FALSE(wp.notify_favorites_only); +} + +static void test_first_sighting_no_notification() +{ + // First sighting only baselines, regardless of inside state or notify flags. + TEST_ASSERT_TRUE(GeofenceModule::classify(true, false, true, true, true) == Crossing::None); + TEST_ASSERT_TRUE(GeofenceModule::classify(true, false, false, true, true) == Crossing::None); +} + +static void test_classify_noTransitionNeverNotifies() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, true, true, true) == Crossing::None); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, false, true, true) == Crossing::None); +} + +static void test_classify_enterFiresOnlyWhenEnabled() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, true, true, false) == Crossing::Enter); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, true, false, false) == Crossing::None); +} + +static void test_classify_exitFiresOnlyWhenEnabled() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, false, false, true) == Crossing::Exit); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, false, false, false) == Crossing::None); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_insideRadius_centreIsInside); + RUN_TEST(test_insideRadius_withinRadius); + RUN_TEST(test_insideRadius_outsideRadius); + RUN_TEST(test_insideRadius_zeroRadiusNeverInside); + RUN_TEST(test_insideBox_centreInside); + RUN_TEST(test_insideBox_edgesInclusive); + RUN_TEST(test_insideBox_outsideLat); + RUN_TEST(test_insideBox_outsideLon); + RUN_TEST(test_inside_circleOnly); + RUN_TEST(test_inside_boxOnly); + RUN_TEST(test_inside_eitherShapeCounts); + RUN_TEST(test_inside_noGeofenceNeverInside); + RUN_TEST(test_shouldTrack_circleWithCentre); + RUN_TEST(test_shouldTrack_circleWithoutCentreRejected); + RUN_TEST(test_shouldTrack_boxOnlyWithoutCentreAccepted); + RUN_TEST(test_shouldTrack_noGeofenceRejected); + RUN_TEST(test_shouldTrack_noLocalPreferencesRejected); + RUN_TEST(test_shouldTrack_expiredRejectedButLiveAccepted); + RUN_TEST(test_localWaypointInitializesPreferencesFromWireFields); + RUN_TEST(test_newRemoteWaypointIgnoresWirePreferences); + RUN_TEST(test_remoteWaypointUpdatePreservesLocalPreferences); + RUN_TEST(test_storedWaypoint_clearsWirePreferences); + RUN_TEST(test_first_sighting_no_notification); + RUN_TEST(test_classify_noTransitionNeverNotifies); + RUN_TEST(test_classify_enterFiresOnlyWhenEnabled); + RUN_TEST(test_classify_exitFiresOnlyWhenEnabled); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_hop_scaling/test_main.cpp b/test/test_hop_scaling/test_main.cpp index aaa4e9ad2e..88db48d10f 100644 --- a/test/test_hop_scaling/test_main.cpp +++ b/test/test_hop_scaling/test_main.cpp @@ -1,3 +1,23 @@ +// Unit tests for HopScalingModule in src/modules/HopScalingModule.{h,cpp} - the sampled hop +// histogram and the hop limit it recommends for this node's own routine broadcasts. +// +// What is pinned: +// - HopScalingModule::rollHour() walks the scaled per-hop buckets and recommends the smallest +// hop limit that still reaches default_hop_scaling_min_target_nodes, extended by at most one +// hop when the politeness envelope allows it. +// - HopScalingModule::runOnce() applies that recommendation only while the congestion gate is +// engaged, floors it by the sending node's role, and hands a hop back per hourly roll once +// congestion clears. Router.cpp reads the result through getLastRequiredHop() and only ever +// lowers a packet below the user's configured hop_limit. +// - The sampling/filtering denominator state machine, which keeps the 128-entry histogram +// bounded while leaving the population estimate invariant. +// +// The regression guarded: before the congestion gate, the recommendation was driven by node +// density alone, so a dense but idle mesh was throttled exactly as hard as a saturated one and +// remote routers on a near-idle MEDIUM_SLOW mesh went silent (meshtastic/firmware#11794). Delete +// or relax the gate assertions and that returns: scaling engages on node count, with no reference +// to whether the channel is actually busy. + #include "MeshTypes.h" #include "TestUtil.h" #include @@ -8,6 +28,7 @@ #include "gps/RTC.h" #include "mesh/NodeDB.h" #include "modules/HopScalingModule.h" +#include #include #include #include @@ -80,6 +101,20 @@ class HopScalingTestShim : public HopScalingModule } uint8_t getFilteringDenomHoldRollsRemaining() const { return filteringDenomHoldRollsRemaining; } + /// Put the congestion gate directly into a state, bypassing the confirm counter, and seed the + /// EMA to a value consistent with it so the next runOnce() does not immediately count toward + /// the opposite flip. + void forceCongestion(bool value) + { + congested = value; + congestionConfirmRuns = 0; + utilizationAvg = value ? static_cast(CONGESTION_ENGAGE_PCT) : 0.0f; + } + + /// Set the smoothed utilization directly, so a test can sit on a band boundary without + /// pumping the EMA there sample by sample. + void setSmoothedChannelUtilization(float pct) { utilizationAvg = pct; } + /// Insert an entry with an explicit hash, bypassing the sampling filter. /// Used to fill the histogram to a known state without depending on hashNodeId distribution. void forceInsertEntry(uint16_t hash, uint8_t hops) @@ -130,6 +165,11 @@ static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const { shim.setHistogramDenominator(HopScalingModule::DENOM_MIN); + // The scenario suites below all assert on an applied hop limit, which only happens while the + // congestion gate is engaged. Put the channel at a busy reading and engage it up front. + HopScalingModule::s_testChannelUtil = 45.0f; + shim.forceCongestion(true); + for (uint8_t roll = 0; roll < numRolls; ++roll) { mockTime += ONE_HOUR_MS; @@ -145,6 +185,15 @@ static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const } } +// Drive N runOnce() ticks with AirTime reporting a fixed smoothed utilization. +// The gate reads it once per tick, so this is how a test moves it through the confirm counter. +static void pumpRuns(HopScalingTestShim &shim, float channelUtilPct, int runs) +{ + HopScalingModule::s_testChannelUtil = channelUtilPct; + for (int i = 0; i < runs; i++) + shim.runOnce(); +} + static void assertCompactHistogramActive(HopScalingTestShim &shim) { TEST_ASSERT_GREATER_THAN_UINT8(0, shim.getCompactHistogramEntryCount()); @@ -494,6 +543,281 @@ void test_startup_blank_state() hopScalingModule = nullptr; } +// --------------------------------------------------------------------------- +// Tests - Congestion gate +// --------------------------------------------------------------------------- + +// Pins the fix for meshtastic/firmware#11794: hop scaling used to trigger on node density alone, +// so a dense but idle mesh was throttled exactly as hard as a saturated one. The reporter's +// MEDIUM_SLOW mesh sat at 10-15% channel utilization with spikes into the high teens and went +// silent. A node that is not congested must leave hop_limit alone no matter how dense it is. +void test_congestion_gate_idle_channel_does_not_scale() +{ + TEST_MESSAGE("=== Congestion gate: dense mesh, idle channel ==="); + TEST_MESSAGE("Topology: the dense 110-node mesh that scales to <= 3 hops when the channel is busy."); + TEST_MESSAGE("Expectation: with the channel reading 10%, nothing is applied and hop returns to HOP_MAX."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0x9E000000, distA); + + // The histogram recommendation itself is unchanged - it is the application that is gated. + shim->runOnce(); + TEST_ASSERT_TRUE(shim->isCongested()); + const uint8_t scaledWhileBusy = shim->getLastRequiredHop(); + TEST_MSG_FMT("While congested: hop=%u", scaledWhileBusy); + TEST_ASSERT_TRUE(scaledWhileBusy <= 3); + + // 10% is inside the band the reporter measured; it must release and stay released. + pumpRuns(*shim, 10.0f, HopScalingModule::RUNS_PER_HOUR * 8); + + TEST_MSG_FMT("After idle channel: congested=%u hop=%u", shim->isCongested() ? 1u : 0u, shim->getLastRequiredHop()); + TEST_ASSERT_FALSE(shim->isCongested()); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop()); + TEST_ASSERT_TRUE(shim->getLastSuggestedHop() <= 3); // recommendation still warm, just not applied + + hopScalingModule = nullptr; +} + +// The complement of the test above: the gate must still engage on a genuinely busy channel, driven +// through the real EMA and confirm counter rather than forced, or the scaler is dead code. +void test_congestion_gate_scales_on_busy_channel() +{ + TEST_MESSAGE("=== Congestion gate: dense mesh, busy channel ==="); + TEST_MESSAGE("Expectation: a sustained 45% channel reading engages the gate and applies the hop walk."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0x9F000000, distA); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + TEST_ASSERT_FALSE(shim->isCongested()); + // Pin the precondition: injectSampleTraffic() drives rollHour() directly and never runOnce(), + // so nothing has been applied yet. Without this the final assertion could pass vacuously. + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop()); + + pumpRuns(*shim, 45.0f, HopScalingModule::RUNS_PER_HOUR * 2); + + TEST_MSG_FMT("After busy channel: congested=%u hop=%u", shim->isCongested() ? 1u : 0u, shim->getLastRequiredHop()); + TEST_ASSERT_TRUE(shim->isCongested()); + TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3); + + hopScalingModule = nullptr; +} + +// HopScalingModule::updateCongestion() in src/modules/HopScalingModule.cpp. +// A mesh idling near a threshold would otherwise toggle the gate - and therefore hop_limit - on +// every roll. Smoothing lives in AirTime now, so what this pins is the confirm counter alone: +// readings that cross a threshold on alternate ticks never hold it for CONGESTION_CONFIRM_RUNS +// in a row, so the state must not flip in either direction. Delete the counter and it flaps. +void test_congestion_gate_does_not_flap_at_threshold() +{ + TEST_MESSAGE("=== Congestion gate: no flapping around the thresholds ==="); + TEST_MESSAGE("Phase 1: released gate, samples alternating either side of the engage threshold."); + TEST_MESSAGE("Phase 2: engaged gate, samples alternating either side of the release threshold."); + + // Straddle each threshold rather than hard-coding percentages, so the test follows Default.h. + constexpr float kStraddle = 4.0f; + constexpr float kEngage = static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT); + constexpr float kRelease = static_cast(HopScalingModule::CONGESTION_RELEASE_PCT); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA0000000, distA); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + for (int i = 0; i < 60; i++) { + HopScalingModule::s_testChannelUtil = (i % 2) ? kEngage - kStraddle : kEngage + kStraddle; + shim->runOnce(); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "gate engaged on samples whose average stays below the threshold"); + } + + shim->forceCongestion(true); + for (int i = 0; i < 60; i++) { + HopScalingModule::s_testChannelUtil = (i % 2) ? kRelease - kStraddle : kRelease + kStraddle; + shim->runOnce(); + TEST_ASSERT_TRUE_MESSAGE(shim->isCongested(), "gate released on a dip that never held for the confirm window"); + } + + hopScalingModule = nullptr; +} + +// HopScalingModule::updateCongestion() in src/modules/HopScalingModule.cpp. +// Both threshold tests are inclusive - at exactly CONGESTION_ENGAGE_PCT the gate engages, at +// exactly CONGESTION_RELEASE_PCT it releases - and nothing else pins that. It is worth pinning +// because the comparison is made on a float average: AirTime's EMA converging on a threshold from +// above settles one ULP off it (12.00006103515625 for a sustained 12%), so comparing at full float +// precision left an inclusive test that could never fire and a gate that never released. +// smoothedUtilPct() rounds to the whole percent the thresholds are declared in; drop that rounding +// and a node sitting exactly on the release threshold stays throttled forever. +void test_congestion_gate_thresholds_are_inclusive() +{ + TEST_MESSAGE("=== Congestion gate: engage and release thresholds are inclusive ==="); + TEST_MESSAGE("Expectation: exactly the engage percent engages; exactly the release percent releases."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA5000000, distA); + + shim->forceCongestion(false); + pumpRuns(*shim, static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT), HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_MSG_FMT("At exactly %u%%: congested=%u", HopScalingModule::CONGESTION_ENGAGE_PCT, shim->isCongested() ? 1u : 0u); + TEST_ASSERT_TRUE_MESSAGE(shim->isCongested(), "sitting exactly on the engage threshold must engage"); + + pumpRuns(*shim, static_cast(HopScalingModule::CONGESTION_RELEASE_PCT), HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_MSG_FMT("At exactly %u%%: congested=%u", HopScalingModule::CONGESTION_RELEASE_PCT, shim->isCongested() ? 1u : 0u); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "sitting exactly on the release threshold must release"); + + // The dead zone itself: one ULP above the threshold is where AirTime's EMA actually settles + // when it converges on it from above, and a raw float compare reads that as "still congested" + // forever. Rounding to the whole percent is what makes it releasable. + shim->forceCongestion(true); + const float justAbove = std::nextafterf(static_cast(HopScalingModule::CONGESTION_RELEASE_PCT), 100.0f); + pumpRuns(*shim, justAbove, HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "one ULP above the release threshold must still release"); + + hopScalingModule = nullptr; +} + +// Releasing the gate must not hand every node its full hop_limit back in the same roll - that turns +// a mesh that just quietened into a broadcast storm. Recovery is one hop per hourly roll. +void test_congestion_release_ramps_one_hop_per_roll() +{ + TEST_MESSAGE("=== Congestion gate: release ramps one hop per hourly roll ==="); + TEST_MESSAGE("Expectation: after release, hop rises by exactly 1 per rollover, not straight to HOP_MAX."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA1000000, distA); + + shim->runOnce(); + uint8_t previous = shim->getLastRequiredHop(); + TEST_MSG_FMT("Engaged at hop=%u", previous); + TEST_ASSERT_TRUE(previous < HOP_MAX); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + + for (uint8_t roll = 0; roll < 3; roll++) { + for (int run = 0; run < HopScalingModule::RUNS_PER_HOUR; run++) + shim->runOnce(); + const uint8_t now = shim->getLastRequiredHop(); + TEST_MSG_FMT("Roll %u: hop=%u", roll + 1, now); + TEST_ASSERT_EQUAL_UINT8(previous + 1, now); + previous = now; + } + + hopScalingModule = nullptr; +} + +// HopScalingModule::runOnce() in src/modules/HopScalingModule.cpp. +// The role floor is keyed on the sending node's own role, so this lets a remote site's telemetry +// travel without loosening anything for client nodes. Operators read that telemetry to know a +// mountain-top site is alive; issue #11794 is a report of exactly those routers going quiet. +// +// The floored set is the same one Router.cpp groups for zero-cost hops: ROUTER, ROUTER_LATE and +// CLIENT_BASE. REPEATER is deliberately absent - it is deprecated and AdminModule demotes it to +// CLIENT on config set, so a floor keyed on it could never fire. +void test_infrastructure_role_floor_applies_when_congested() +{ + TEST_MESSAGE("=== Role floor: infrastructure roles keep a minimum hop count ==="); + TEST_MESSAGE("Topology: 200 nodes at hop 0, so the hop walk recommends 0 for an unfloored role."); + TEST_MESSAGE("Expectation: CLIENT scales below the floor, ROUTER/ROUTER_LATE/CLIENT_BASE sit on it."); + + const uint16_t distLocal[HOP_MAX + 1] = {200, 60, 20, 5, 3, 2, 2, 1}; + const meshtastic_Config_DeviceConfig_Role savedRole = config.device.role; + + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + uint8_t clientHop = HOP_MAX; + { + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + injectSampleTraffic(*shim, 0xA2000000, distLocal); + shim->runOnce(); + clientHop = shim->getLastRequiredHop(); + hopScalingModule = nullptr; + } + TEST_MSG_FMT("CLIENT: hop=%u", clientHop); + TEST_ASSERT_TRUE(clientHop < HopScalingModule::INFRASTRUCTURE_HOP_FLOOR); + + const meshtastic_Config_DeviceConfig_Role infraRoles[] = {meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, + meshtastic_Config_DeviceConfig_Role_CLIENT_BASE}; + for (size_t i = 0; i < sizeof(infraRoles) / sizeof(infraRoles[0]); i++) { + config.device.role = infraRoles[i]; + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + injectSampleTraffic(*shim, 0xA3000000 + (static_cast(i) << 20), distLocal); + shim->runOnce(); + + TEST_MSG_FMT("Infrastructure role %u: hop=%u", static_cast(infraRoles[i]), shim->getLastRequiredHop()); + TEST_ASSERT_EQUAL_UINT8(HopScalingModule::INFRASTRUCTURE_HOP_FLOOR, shim->getLastRequiredHop()); + hopScalingModule = nullptr; + } + + config.device.role = savedRole; +} + +// The one-hop extension used to be graded by a density trend (0-2 h vs 1-3 h node counts) while the +// gate that decides whether the walk applies at all reads measured airtime. A node could therefore +// be told the mesh was filling up by node counts while the channel sat idle, which is the same +// mismatch issue #11794 reports one level up. Both now read the smoothed channel utilization. +// +// The three regimes PR #10176 defined are preserved, read from airtime instead of node counts: +// GENEROUS while the channel is quiet or clearing, DEFAULT once past the gate's engage point, and +// STRICT at the polite gate, where the radio is already withholding metadata traffic. +void test_politeness_tracks_channel_utilization() +{ + TEST_MESSAGE("=== Politeness: graded by measured utilization, not by node-count trend ==="); + TEST_MESSAGE("Expectation: 4/4 below the engage point, 2/4 from it, 1/4 from the strict point."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA4000000, distA); + + struct Band { + float util; + uint8_t numer; + }; + // forceCongestion() seeds the EMA, so each band is reached without pumping it there sample by + // sample; rollHour() then reads utilizationAvg directly. + constexpr float kEngage = static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT); + constexpr float kStrict = static_cast(HopScalingModule::CONGESTION_STRICT_PCT); + // Both band edges are inclusive, so each is probed exactly and one below. + const Band bands[] = { + {0.0f, HopScalingModule::POLITENESS_GENEROUS}, {kEngage - 1.0f, HopScalingModule::POLITENESS_GENEROUS}, + {kEngage, HopScalingModule::POLITENESS_DEFAULT}, {kStrict - 1.0f, HopScalingModule::POLITENESS_DEFAULT}, + {kStrict, HopScalingModule::POLITENESS_STRICT}, {100.0f, HopScalingModule::POLITENESS_STRICT}}; + + for (size_t i = 0; i < sizeof(bands) / sizeof(bands[0]); i++) { + shim->setSmoothedChannelUtilization(bands[i].util); + shim->rollHourTest(); + + const float expected = bands[i].numer / static_cast(HopScalingModule::POLITENESS_DENOM); + TEST_MSG_FMT("util=%u%% -> polite=%u/4", static_cast(bands[i].util), + static_cast(shim->getPoliteness() * HopScalingModule::POLITENESS_DENOM)); + TEST_ASSERT_EQUAL_FLOAT(expected, shim->getPoliteness()); + } + + hopScalingModule = nullptr; +} + // --------------------------------------------------------------------------- // Tests - Denominator state machine // --------------------------------------------------------------------------- @@ -761,6 +1085,16 @@ void setup() RUN_TEST(test_intermediate_status); RUN_TEST(test_startup_blank_state); + printf("\n=== Congestion gate ===\n"); + RUN_TEST(test_congestion_gate_idle_channel_does_not_scale); + RUN_TEST(test_congestion_gate_scales_on_busy_channel); + RUN_TEST(test_congestion_gate_does_not_flap_at_threshold); + RUN_TEST(test_congestion_gate_thresholds_are_inclusive); + RUN_TEST(test_congestion_release_ramps_one_hop_per_roll); + RUN_TEST(test_infrastructure_role_floor_applies_when_congested); + + RUN_TEST(test_politeness_tracks_channel_utilization); + printf("\n=== Denominator state machine ===\n"); RUN_TEST(test_denominator_rises_on_overflow); RUN_TEST(test_filtering_denom_hold_counts_down); diff --git a/test/test_low_battery_shutdown/test_main.cpp b/test/test_low_battery_shutdown/test_main.cpp new file mode 100644 index 0000000000..24ff195ffe --- /dev/null +++ b/test/test_low_battery_shutdown/test_main.cpp @@ -0,0 +1,146 @@ +// Unit tests for updateLowVoltageCounter() in src/Power.cpp - the gate on the low-battery deep sleep. +// +// Power::readPowerStatus() calls this once per Power thread cycle (20s) with the freshly probed +// battery state. When it returns true the device takes EVENT_LOW_BATTERY -> stateLowBattSDS -> +// doDeepSleep(config.power.sds_secs), and sds_secs defaults to UINT32_MAX, so a false positive parks +// a node for the ~24.8-day clamp with only RST or a power cycle to recover it. That asymmetry is why +// the counter has to be conservative: a missed shutdown costs a flat battery, a spurious one costs +// the whole node. +// +// The contract is that only *consecutive* confirmed-low readings count. The regression guarded is +// the original shape, where the reset lived inside the "battery present and not on USB" guard rather +// than beside it. A board with no battery reads a floating divider that drifts across the 2600mV +// battery-present threshold, so each excursion into the window bumped the counter and nothing outside +// the window ever cleared it; eleven such flickers, spread over any span at all, deep-slept a healthy +// USB-powered node. Reported for Heltec V4 on USB with no battery in meshtastic/firmware#11796. +// +// Also pins the cutoff as a pack voltage. readPowerStatus() passes OCV[NUM_OCV_POINTS-1] * NUM_CELLS; +// it previously passed the bare single-cell OCV point, which no multi-cell pack can fall below, so +// those boards would have discharged to destruction instead of shutting down. +#include "Arduino.h" +#include "TestUtil.h" +#include +#include + +// Declared here rather than via Power.h, which pulls in the ADC and telemetry sensor headers. +// A signature change breaks the link rather than silently diverging from the definition. +bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv); + +// LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN, spelled out so a change to it has to be a deliberate edit here. +static constexpr uint8_t kReadingsBeforeShutdown = 10; + +// The default LiIon curve's lowest OCV point, and a single-cell pack comfortably below it. +static constexpr uint16_t kCutoffMv = 3100; +static constexpr uint16_t kLowMv = 2900; +static constexpr uint16_t kHealthyMv = 3900; + +// One reading of a battery-backed node running off its battery - the only case that may ever count. +static bool lowReading(uint8_t &counter, uint16_t battMv = kLowMv, uint16_t cutoffMv = kCutoffMv) +{ + return updateLowVoltageCounter(counter, true, false, battMv, cutoffMv); +} + +void setUp(void) {} +void tearDown(void) {} + +void test_an_unbroken_run_of_low_readings_shuts_down(void) +{ + uint8_t counter = 0; + + for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++) + TEST_ASSERT_FALSE_MESSAGE(lowReading(counter), "must not fire before the full run is seen"); + + TEST_ASSERT_TRUE(lowReading(counter)); +} + +void test_a_healthy_reading_clears_the_run(void) +{ + uint8_t counter = 0; + for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++) + lowReading(counter); + + TEST_ASSERT_FALSE(lowReading(counter, kHealthyMv)); + TEST_ASSERT_EQUAL_UINT8(0, counter); + TEST_ASSERT_FALSE_MESSAGE(lowReading(counter), "the run restarts from zero, it does not resume"); +} + +// #11796: the battery-less board. Its floating divider reads "no battery" as often as it reads a +// phantom one, and it is never on a detectable USB rail, so the gaps are the only thing that can +// save it. Interleaving them must hold the counter at zero however long this runs. +void test_no_battery_reading_clears_the_run(void) +{ + uint8_t counter = 0; + + for (int cycle = 0; cycle < 50; cycle++) { + TEST_ASSERT_FALSE(lowReading(counter)); + TEST_ASSERT_FALSE(updateLowVoltageCounter(counter, false, false, kLowMv, kCutoffMv)); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, counter, "a reading with no battery resets, it does not skip"); + } +} + +void test_usb_power_clears_the_run(void) +{ + uint8_t counter = 0; + for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++) + lowReading(counter); + + TEST_ASSERT_FALSE(updateLowVoltageCounter(counter, true, true, kLowMv, kCutoffMv)); + TEST_ASSERT_EQUAL_UINT8(0, counter); +} + +// A pack sitting exactly on the cutoff is not below it; the OCV table's last point is a valid voltage. +void test_the_cutoff_is_exclusive(void) +{ + uint8_t counter = 0; + TEST_ASSERT_FALSE(lowReading(counter, kCutoffMv)); + TEST_ASSERT_EQUAL_UINT8(0, counter); + + TEST_ASSERT_FALSE(lowReading(counter, kCutoffMv - 1)); + TEST_ASSERT_EQUAL_UINT8(1, counter); +} + +// The caller scales by NUM_CELLS. Against the bare single-cell point a 2S pack never reads low at all. +void test_the_cutoff_is_a_pack_voltage(void) +{ + constexpr uint16_t twoCellCutoffMv = kCutoffMv * 2; + constexpr uint16_t flatTwoCellPackMv = 6000; + + uint8_t counter = 0; + for (uint8_t i = 0; i <= kReadingsBeforeShutdown; i++) + TEST_ASSERT_EQUAL(i == kReadingsBeforeShutdown, lowReading(counter, flatTwoCellPackMv, twoCellCutoffMv)); + + counter = 0; + for (uint8_t i = 0; i <= kReadingsBeforeShutdown; i++) + TEST_ASSERT_FALSE_MESSAGE(lowReading(counter, flatTwoCellPackMv, kCutoffMv), + "unscaled cutoff: the regression that never shuts a 2S pack down"); +} + +// The counter is a uint8_t and the caller keeps calling after it fires, so it must saturate. Were it +// to wrap, the node would come back up, count to 255 again and re-sleep in an unattended loop. +void test_the_counter_saturates_rather_than_wrapping(void) +{ + uint8_t counter = 0; + + for (int i = 0; i < 400; i++) { + const bool shutdown = lowReading(counter); + if (i >= kReadingsBeforeShutdown) + TEST_ASSERT_TRUE_MESSAGE(shutdown, "once tripped it stays tripped until a reading clears it"); + } + TEST_ASSERT_EQUAL_UINT8(UINT8_MAX, counter); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_an_unbroken_run_of_low_readings_shuts_down); + RUN_TEST(test_a_healthy_reading_clears_the_run); + RUN_TEST(test_no_battery_reading_clears_the_run); + RUN_TEST(test_usb_power_clears_the_run); + RUN_TEST(test_the_cutoff_is_exclusive); + RUN_TEST(test_the_cutoff_is_a_pack_voltage); + RUN_TEST(test_the_counter_saturates_rather_than_wrapping); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index bb1a0abc2f..a7b5dbe202 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -205,13 +205,14 @@ static void test_adminValidation_turboPresetOnEU868_isCleared(void) meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); - TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.has_broadcast_on_preset, "SHORT_TURBO must be cleared for EU_868"); + TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset, "SHORT_TURBO must be cleared for EU_868"); } /** @@ -223,12 +224,13 @@ static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); } /** @@ -242,13 +244,14 @@ static void test_adminValidation_turboPresetOnUS_isAccepted(void) initRegion(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_targets[0].preset); } /** @@ -260,13 +263,14 @@ static void test_adminValidation_mediumTurboPresetOnEU868_isCleared(void) resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); - TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); } /** @@ -280,13 +284,15 @@ static void test_adminValidation_mediumTurboPresetOnUS_isAccepted(void) initRegion(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, + moduleConfig.mesh_beacon.broadcast_targets[0].preset); } /** @@ -324,8 +330,7 @@ static void test_adminValidation_validOfferRegion_isPreserved(void) /** * Verify an out-of-range region in a multi-target entry is sanitised to UNSET on write. - * Important because broadcast_targets entries are validated independently of the single-target - * broadcast_on_* fields, and an invalid enum must never reach the radio-switch path. + * Important because an invalid enum must never reach the radio-switch path. */ static void test_adminValidation_targetUnknownRegion_isCleared(void) { @@ -342,8 +347,8 @@ static void test_adminValidation_targetUnknownRegion_isCleared(void) } /** - * Verify a preset that is illegal for a multi-target entry's region clears that entry's preset - * (and its channel), matching the single-target broadcast_on_preset rule. + * Verify a preset that is illegal for a broadcast target's region clears that entry's preset + * and its channel. */ static void test_adminValidation_targetInvalidPresetForRegion_isCleared(void) { @@ -647,7 +652,7 @@ static void test_broadcaster_rebuildCache_idempotent(void) // =========================================================================== /** - * Verify the 'from' field defaults to the local node number when broadcast_send_as_node is 0. + * Verify the 'from' field is the local node number. * Important for correct source attribution in peer node tables that receive the beacon. */ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) @@ -655,7 +660,6 @@ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; - moduleConfig.mesh_beacon.broadcast_send_as_node = 0; strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-local", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; @@ -665,27 +669,6 @@ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); } -/** - * Verify broadcast_send_as_node is currently disabled: 'from' is always the local node - * even when broadcast_send_as_node is set to a remote node number. - * (broadcast_send_as_node is commented out as "not suitable right now".) - */ -static void test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet(void) -{ - resetConfig(); - moduleConfig.has_mesh_beacon = true; - moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; - moduleConfig.mesh_beacon.broadcast_send_as_node = kRemoteNode; - strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-remote", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); - - MeshBeaconBroadcastModuleTestShim bcast; - bcast.sendBeacon(); - - TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); - // broadcast_send_as_node is disabled; from is always the local node - TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); -} - /** * Verify the 'to' field is always NODENUM_BROADCAST regardless of other settings. * Important because beacons are mesh-wide announcements and must never be addressed to a single peer. @@ -723,8 +706,8 @@ static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) } /** - * Verify TEXT_MESSAGE_APP portnum is used when no offer content is present, even if - * broadcast_on_preset is set (that field governs which radio config to use for TX, not portnum). + * Verify TEXT_MESSAGE_APP portnum is used when no offer content is present, even if a + * broadcast target preset is set (that governs which radio config to use for TX, not portnum). * Important so standard clients display plain-text beacons without needing a MESH_BEACON_APP decoder. */ static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) @@ -733,9 +716,10 @@ static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) moduleConfig.has_mesh_beacon = true; const char *msg = "plain-text-beacon"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); - // broadcast_on_preset set, but no offer - should still be TEXT_MESSAGE_APP - moduleConfig.mesh_beacon.has_broadcast_on_preset = true; - moduleConfig.mesh_beacon.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + // Target preset set, but no offer - should still be TEXT_MESSAGE_APP + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); @@ -1155,52 +1139,11 @@ static void test_broadcaster_legacySplit_secondPacketIsTextMessage(void) } // =========================================================================== -// Group 7: Beacon-channel PSK swap (broadcast_on_channel override) +// Group 7: Beacon-channel PSK swap (target channel-table slot) // =========================================================================== /** - * When broadcast_on_channel overrides the primary channel's name/PSK, the packet must be encrypted - * on the BEACON channel, not the primary. perhapsEncode keys off the primary slot, so sendBeaconPacket - * temporarily installs the beacon channel there for the send and restores it after. Verify both: the - * primary slot IS the beacon channel during send(), and it is restored afterwards (no leak). - */ -static void test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores(void) -{ - resetConfig(); - static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; - installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); - - moduleConfig.has_mesh_beacon = true; - moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; // gives the beacon radio content to send - moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; - moduleConfig.mesh_beacon.has_broadcast_on_channel = true; - strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, "BeaconCh", - sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); - static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, - 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; - moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconPsk); - memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconPsk, sizeof(beaconPsk)); - - MeshBeaconBroadcastModuleTestShim bcast; - bcast.sendBeacon(); - - // During send(), the primary slot must hold the BEACON channel (so encryption uses its PSK). - TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); - const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0]; - TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconCh", atSend.name, "primary must be the beacon channel during send"); - TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size); - TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the beacon channel PSK"); - - // After send(), the primary channel must be restored to the original (no leak into normal traffic). - const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings; - TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send"); - TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size); - TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]); -} - -/** - * Without a broadcast_on_channel override, the beacon must transmit on the primary channel unchanged + * With no target channel_index, the beacon must transmit on the primary channel unchanged * (no swap). Guards against the swap firing - and churning the channel table - when it isn't needed. */ static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) @@ -1213,7 +1156,7 @@ static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; - // No broadcast_on_channel override. + // No target channel_index. MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); @@ -1249,10 +1192,15 @@ static void test_broadcaster_targetChannelIndex_usesTableSlot(void) bcast.sendBeacon(); TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); - TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", mockRouter->primaryAtSend[0].name, - "beacon must be encrypted on the referenced slot's channel"); - // Primary slot restored to home after send (no leak). - TEST_ASSERT_EQUAL_STRING("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name); + const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0]; + TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", atSend.name, "beacon must be encrypted on the referenced slot's channel"); + TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the slot's PSK"); + // Primary slot restored to home after send (no leak into normal traffic). + const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings; + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send"); + TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size); + TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]); } /** @@ -1338,6 +1286,334 @@ static void test_broadcaster_distinctTargets_bothSent(void) TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "distinct targets must each be sent"); } +// --------------------------------------------------------------------------- +// Radio switch/restore re-entrancy +// --------------------------------------------------------------------------- + +/** + * Stands in for a real driver on the restore path. RadioLibInterface::reconfigure() standbys the + * chip, setStandby() calls completeSending(), and completeSending() calls back into + * reconfigureForBeaconTX(iface, nullptr) - so reconfigure() re-entering is the normal case, not an + * exotic one. Bounded, so a regression fails an assertion instead of overflowing the stack. + */ +class ReentrantRadioInterface : public RadioInterface +{ + public: + static constexpr int kReentryLimit = 16; + int reconfigureCalls = 0; + bool reenterOnReconfigure = false; + + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } + + bool reconfigure() override + { + reconfigureCalls++; + if (reenterOnReconfigure && reconfigureCalls < kReentryLimit) + MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); + return true; + } +}; + +/** + * The restore must clear its guard before reconfiguring, or completeSending() re-enters the restore + * branch and it reconfigures the radio once per level until the stack runs out. Seen in the field as + * a run of "Beacon: restore radio config after TX" with a full applyModemConfig() between each. + */ +static void test_beaconRestore_isNotReenteredByCompleteSending(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x5EED0001; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + + // Switch to the beacon config. Not the case under test, so leave re-entry off. + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "switch must leave the radio on the beacon preset"); + + // Now restore, with reconfigure() re-entering exactly as completeSending() does. + MeshBeaconModule::clearTargetRadioSettings(&pkt); + radio.reconfigureCalls = 0; + radio.reenterOnReconfigure = true; + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + + TEST_ASSERT_EQUAL_INT_MESSAGE(1, radio.reconfigureCalls, "restore must reconfigure the radio exactly once"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "restore must put the home preset back"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "restore must put the home channel back"); +} + +/** + * A second switch before the restore has run must survive the same re-entry. completeSending() calls + * in with a null packet, which reads as "restore" - so without the guard it would undo the switch that + * is still being applied, leaving the beacon to transmit on the home channel instead of its target. + */ +static void test_beaconSwitch_isNotUndoneByCompleteSending(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + ReentrantRadioInterface radio; + meshtastic_MeshPacket first = meshtastic_MeshPacket_init_zero; + first.id = 0x5EED0002; + MeshBeaconModule::setTargetRadioSettings(&first, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &first), "first switch should have applied"); + + // Second switch with the restore still outstanding, and reconfigure() re-entering. + meshtastic_MeshPacket second = meshtastic_MeshPacket_init_zero; + second.id = 0x5EED0003; + MeshBeaconModule::setTargetRadioSettings(&second, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + radio.reconfigureCalls = 0; + radio.reenterOnReconfigure = true; + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &second), "second switch should have applied"); + + TEST_ASSERT_EQUAL_INT_MESSAGE(1, radio.reconfigureCalls, "second switch must reconfigure the radio exactly once"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, config.lora.modem_preset, + "second switch must not be undone mid-flight"); + + // The home config must still be recoverable afterwards - the snapshot survives a second switch. + radio.reenterOnReconfigure = false; + MeshBeaconModule::clearTargetRadioSettings(&first); + MeshBeaconModule::clearTargetRadioSettings(&second); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "restore must return to the home preset, not the first beacon target"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "restore must return to the home channel"); +} + +/** A restore with nothing switched must do nothing at all - the guard is what makes re-entry safe. */ +static void test_beaconRestore_withoutSwitch_isNoOp(void) +{ + resetConfig(); + ReentrantRadioInterface radio; + + // reconfigureForBeaconTX() keeps its switched/not-switched state in a function-local static, so an + // earlier test that aborted mid-way can leave a switch outstanding. Drain it before asserting. + MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr); + + radio.reconfigureCalls = 0; + radio.reenterOnReconfigure = true; + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore without a switch is a no-op"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "no-op restore must not touch the radio"); +} + +/** + * The restore is driven by "our beacon finished", not by "the radio changed state". completeSending() + * clears a packet's target settings before restoring, so a caller that arrives without that - the + * pre-TX channel scan standbys the radio, and setStandby() calls completeSending() - must be refused. + * Otherwise the home config goes back under a beacon that has not keyed up yet, and it transmits on + * the wrong preset with the beacon channel hash already stamped on it. + */ +static void test_beaconRestore_deferredUntilPacketCompletes(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x5EED0004; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); + + // The packet has not been sent yet, so its target settings are still live. + radio.reconfigureCalls = 0; + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), + "restore must be refused while the beacon is still outstanding"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "a refused restore must not touch the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "the beacon preset must still be in place when the packet keys up"); + + // completeSending() clears the target settings first; only then is the restore ours to make. + MeshBeaconModule::clearTargetRadioSettings(&pkt); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "restore must put the home preset back"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "restore must put the home channel back"); +} + +// --------------------------------------------------------------------------- +// MeshBeaconTxHook (the radio driver's view of the beacon) +// --------------------------------------------------------------------------- + +/** + * Normal traffic must pass straight through the hook: no radio switch, no drop, and nothing claimed + * that would stop the driver listening on a busy channel. + */ +static void test_txHook_normalPacket_isSend(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000001; + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_SEND, RadioTxHooks::beforeTransmit(&radio, &pkt), + "a packet with no beacon target must transmit as usual"); + TEST_ASSERT_FALSE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "normal traffic must not claim the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "normal traffic must not reconfigure the radio"); +} + +/** + * A beacon asks the driver for a fresh transmit delay, because the switch leaves the radio on a + * channel the last channel scan never covered. + */ +static void test_txHook_beaconPacket_isDefer(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000002; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &pkt), + "a beacon switch must defer the transmit"); + TEST_ASSERT_TRUE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "a queued beacon must claim the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "the hook must leave the radio on the beacon preset"); + + // packetReleased() is what the driver calls once the packet is sent, cancelled or dropped. + RadioTxHooks::packetReleased(&radio, &pkt); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "releasing the packet must put the home preset back"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "releasing the packet must put the home channel back"); +} + +/** + * SHORT_TURBO is not legal on EU_868, so the beacon has nowhere valid to transmit. The driver must be + * told to drop it rather than let it fall through onto the home config. + */ +static void test_txHook_invalidTarget_isDrop(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000003; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DROP, RadioTxHooks::beforeTransmit(&radio, &pkt), + "an invalid target config must be dropped, not transmitted"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "a dropped beacon must not reconfigure the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "a dropped beacon must leave the home preset alone"); + + RadioTxHooks::packetReleased(&radio, &pkt); // the driver's drop path, so the sidecar entry is freed + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::hasTargetRadioSettings(&pkt), "the dropped packet's target must be released"); +} + +/** + * The hook list is what keeps the driver free of module includes: with nothing registered every call + * is a no-op, so a build without the beacon module behaves exactly as one with beacons idle. + */ +static void test_txHook_unregistered_isNoOp(void) +{ + resetConfig(); + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000004; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_SEND, RadioTxHooks::beforeTransmit(&radio, &pkt), + "with no hook registered even a beacon is ordinary traffic to the driver"); + TEST_ASSERT_FALSE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "with no hook registered nothing claims the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "with no hook registered the radio is never reconfigured"); + + MeshBeaconModule::clearTargetRadioSettings(&pkt); +} + +/** + * A higher-priority packet can enqueue ahead of a still-queued beacon, so the driver asks the hook about + * an untagged packet while the beacon that armed the switch is live. The restore gate is right to hold + * off a release then, and wrong to hold off this: the untagged packet is about to key up, and without + * the restore it transmits on the beacon's preset, slot and region. + */ +static void test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + + // The beacon reaches the head of the queue and the hook switches the radio for it. + meshtastic_MeshPacket beacon = meshtastic_MeshPacket_init_zero; + beacon.id = 0x7A000005; + MeshBeaconModule::setTargetRadioSettings(&beacon, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &beacon), + "the beacon switch should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "the radio must be on the beacon preset before the queue jump"); + + // An ordinary packet now jumps the queue. The beacon is still queued, so its target is still live. + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::hasTargetRadioSettings(&beacon), + "the queued beacon must still hold its target, or this is not the case under test"); + meshtastic_MeshPacket ordinary = meshtastic_MeshPacket_init_zero; + ordinary.id = 0x7A000006; + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &ordinary), + "restoring the radio owes the driver a fresh delay and scan"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "an untagged packet must never transmit on the beacon preset"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "an untagged packet must never transmit on the beacon channel"); + + // The beacon is not lost by the restore: it switches the radio back when it next reaches the head. + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &beacon), + "the beacon must switch back when it reaches the head again"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "the beacon must be back on its target preset"); + + MeshBeaconModule::clearTargetRadioSettings(&beacon); + RadioTxHooks::packetReleased(&radio, &beacon); +} + } // namespace // =========================================================================== @@ -1425,7 +1701,6 @@ BEACON_TEST_ENTRY void setup() printf("\n=== Broadcaster sendBeacon ===\n"); RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset); - RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast); RUN_TEST(test_broadcaster_sendBeacon_usesBeaconPortnum); RUN_TEST(test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum); @@ -1455,13 +1730,27 @@ BEACON_TEST_ENTRY void setup() printf("\n=== Beacon-channel PSK swap ===\n"); - RUN_TEST(test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores); RUN_TEST(test_broadcaster_noChannelOverride_doesNotSwapPrimary); RUN_TEST(test_broadcaster_targetChannelIndex_usesTableSlot); RUN_TEST(test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset); RUN_TEST(test_broadcaster_duplicateTargets_dedupedToOnePacket); RUN_TEST(test_broadcaster_distinctTargets_bothSent); + printf("\n=== Radio switch/restore re-entrancy ===\n"); + + RUN_TEST(test_beaconRestore_isNotReenteredByCompleteSending); + RUN_TEST(test_beaconSwitch_isNotUndoneByCompleteSending); + RUN_TEST(test_beaconRestore_withoutSwitch_isNoOp); + RUN_TEST(test_beaconRestore_deferredUntilPacketCompletes); + + printf("\n=== MeshBeaconTxHook ===\n"); + + RUN_TEST(test_txHook_normalPacket_isSend); + RUN_TEST(test_txHook_beaconPacket_isDefer); + RUN_TEST(test_txHook_invalidTarget_isDrop); + RUN_TEST(test_txHook_unregistered_isNoOp); + RUN_TEST(test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome); + exit(UNITY_END()); } diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index 9cc0d18116..a0a23245de 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -81,10 +81,11 @@ class MockRoutingModule : public RoutingModule { public: void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false) override + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override { (void)hopLimit; (void)ackWantsAck; + (void)relaySource; ackNaks.push_back({err, to, idFrom, chIndex}); } @@ -703,6 +704,51 @@ static void test_localAckNak_reachesPhoneViaRealRoutingModule() mockService->releaseToPool(toPhone); } +// #10767: the implicit ACK for an overheard rebroadcast of our own packet carries that copy's +// relaying node and the link metrics we heard it at, so the phone can attribute them to the relayer. +// rx_rssi has explicit presence, so has_rx_rssi has to travel with it or the reading never encodes. +static void test_localAck_carriesRelaySourceToPhone() +{ + installRealRoutingModule(); + + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = LOCAL_NODE; + overheard.to = REMOTE_NODE; + overheard.id = 0xFEEDBEEF; + overheard.relay_node = 0xAB; + overheard.has_rx_rssi = true; + overheard.rx_rssi = -93; + overheard.rx_snr = 4.75f; + + realRoutingModule->sendAckNak(meshtastic_Routing_Error_NONE, LOCAL_NODE, overheard.id, 0, /*hopLimit=*/0, + /*ackWantsAck=*/false, &overheard); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0xFEEDBEEF, toPhone->decoded.request_id); + TEST_ASSERT_EQUAL_HEX8(0xAB, toPhone->relay_node); + TEST_ASSERT_TRUE(toPhone->has_rx_rssi); + TEST_ASSERT_EQUAL_INT32(-93, toPhone->rx_rssi); + TEST_ASSERT_EQUAL_FLOAT(4.75f, toPhone->rx_snr); + mockService->releaseToPool(toPhone); +} + +// Every other ACK/NAK passes no relay source and must reach the phone with the relay fields clear, +// so the client is never told a relayer we did not hear. +static void test_localAck_withoutRelaySource_leavesRelayFieldsUnset() +{ + installRealRoutingModule(); + + realRoutingModule->sendAckNak(meshtastic_Routing_Error_NONE, LOCAL_NODE, 0x0BADF00D, 0); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0x0BADF00D, toPhone->decoded.request_id); + TEST_ASSERT_EQUAL_HEX8(NO_RELAY_NODE, toPhone->relay_node); + TEST_ASSERT_FALSE(toPhone->has_rx_rssi); + mockService->releaseToPool(toPhone); +} + // The mirror of the above: a broadcast we originated, heard back off the mesh, must not reach the // phone even though it travels the same RoutingModule path. static void test_ownBroadcastEcho_isDroppedByRealRoutingModule() @@ -855,6 +901,8 @@ void setup() RUN_TEST(test_handleFromRadio_ownPacketIsNotEchoedToPhone); RUN_TEST(test_handleFromRadio_ownPacketAddressedToUsReachesPhone); RUN_TEST(test_localAckNak_reachesPhoneViaRealRoutingModule); + RUN_TEST(test_localAck_carriesRelaySourceToPhone); + RUN_TEST(test_localAck_withoutRelaySource_leavesRelayFieldsUnset); RUN_TEST(test_ownBroadcastEcho_isDroppedByRealRoutingModule); RUN_TEST(test_phoneRequest_replyReachesPhone); RUN_TEST(test_nestedLocalSend_isDeferred_notReentrant); diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 64ea0cd4dd..4d2ea6c268 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -103,8 +103,10 @@ class MockRoutingModule : public RoutingModule { public: void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false) override + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override { + (void)ackWantsAck; + (void)relaySource; ackNacks_.emplace_back(err, to, idFrom, chIndex, hopLimit); } std::list> diff --git a/test/test_muted_source/test_main.cpp b/test/test_muted_source/test_main.cpp new file mode 100644 index 0000000000..9cc2eedeef --- /dev/null +++ b/test/test_muted_source/test_main.cpp @@ -0,0 +1,239 @@ +// isMutedForPacket() source resolution - src/mesh/Channels.cpp. A DM addressed to us reads the +// sender's mute bit; every other packet reads the mute bit of the channel it arrived on. +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isToUs, isBroadcast) +#include "TestUtil.h" +#include + +#include "mesh/Channels.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include +#include + +static constexpr NodeNum kLocalNode = 0x11111111; +static constexpr NodeNum kPeer = 0x22222222; +static constexpr NodeNum kThirdParty = 0x33333333; +static constexpr NodeNum kStranger = 0x44444444; // deliberately never added to the DB + +// isToUs() reads nodeDB->getNodeNum() and the DM branch looks the sender up, so a real NodeDB +// must be live. +static NodeDB *testNodeDB = nullptr; + +static meshtastic_MeshPacket makePacket(NodeNum from, NodeNum to, uint8_t channel) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = to; + p.channel = channel; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + return p; +} + +static void setSlot(ChannelIndex idx, meshtastic_Channel_Role role, bool muted) +{ + meshtastic_Channel &ch = channels.getByIndex(idx); + ch.index = idx; + ch.has_settings = true; + ch.role = role; + ch.settings.has_module_settings = true; + ch.settings.module_settings.is_muted = muted; +} + +// Append straight into the hot store: getOrCreateMeshNode() would drag in the cap and +// eviction machinery, which this predicate has nothing to do with. +static void setNodeMuted(NodeNum num, bool muted) +{ + meshtastic_NodeInfoLite *n = nodeDB->getMeshNode(num); + if (!n) { + nodeDB->meshNodes->resize(nodeDB->numMeshNodes + 1); + n = &nodeDB->meshNodes->at(nodeDB->numMeshNodes++); + memset(n, 0, sizeof(*n)); + n->num = num; + } + nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, muted); +} + +// --------------------------------------------------------------------------- +// Broadcast: the arrival channel decides +// --------------------------------------------------------------------------- + +void test_broadcast_on_unmuted_channel_is_not_muted() +{ + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 0))); +} + +void test_broadcast_on_muted_channel() +{ + setSlot(0, meshtastic_Channel_Role_PRIMARY, true); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 0))); +} + +// A channel with no module_settings at all has never been muted. +void test_channel_without_module_settings_is_not_muted() +{ + meshtastic_Channel &ch = channels.getByIndex(0); + ch.settings.has_module_settings = false; + ch.settings.module_settings.is_muted = true; // stale payload behind the presence flag + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 0))); +} + +// Mute is per channel, not global: a muted secondary must not silence the others. +void test_broadcast_reads_its_own_channel() +{ + setSlot(2, meshtastic_Channel_Role_SECONDARY, true); + setSlot(1, meshtastic_Channel_Role_SECONDARY, false); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 2))); + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 1))); +} + +// channel == 0 means "the primary", which is not always slot 0. +void test_channel_zero_resolves_to_primary_slot() +{ + setSlot(0, meshtastic_Channel_Role_SECONDARY, false); + setSlot(3, meshtastic_Channel_Role_PRIMARY, true); + channels.onConfigChanged(); + TEST_ASSERT_EQUAL_UINT8(3, channels.getPrimaryIndex()); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 0))); +} + +// --------------------------------------------------------------------------- +// DM addressed to us: the sender decides +// --------------------------------------------------------------------------- + +void test_dm_to_us_from_muted_sender() +{ + setNodeMuted(kPeer, true); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, kLocalNode, 0))); +} + +void test_dm_to_us_from_unmuted_sender_is_not_muted() +{ + setNodeMuted(kPeer, false); + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, kLocalNode, 0))); +} + +// The discriminator: a DM must not inherit its channel's mute state. +void test_dm_to_us_ignores_channel_mute() +{ + setSlot(0, meshtastic_Channel_Role_PRIMARY, true); + setNodeMuted(kPeer, false); + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, kLocalNode, 0))); +} + +// A sender we have never heard of has no mute bit to read. +void test_dm_to_us_from_unknown_sender_is_not_muted() +{ + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kStranger, kLocalNode, 0))); +} + +// Not addressed to us: overheard traffic falls back to the channel, sender mute is irrelevant. +void test_dm_to_third_party_uses_channel() +{ + setSlot(0, meshtastic_Channel_Role_PRIMARY, true); + setNodeMuted(kPeer, false); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, kThirdParty, 0))); + + setSlot(0, meshtastic_Channel_Role_PRIMARY, false); + setNodeMuted(kPeer, true); + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, kThirdParty, 0))); +} + +// --------------------------------------------------------------------------- +// Alert payloads, which break through a mute +// --------------------------------------------------------------------------- + +// ASCII BEL, the in-band alert marker. Numeric so no control byte sits in the source. +static const uint8_t kAsciiBell = 7; + +static meshtastic_MeshPacket withText(meshtastic_MeshPacket p, const char *text, bool bell) +{ + p.decoded.payload.size = (pb_size_t)strlen(text); + memcpy(p.decoded.payload.bytes, text, p.decoded.payload.size); + if (bell) + p.decoded.payload.bytes[p.decoded.payload.size++] = kAsciiBell; + return p; +} + +void test_bell_is_an_alert_when_a_bell_output_is_on() +{ + moduleConfig.external_notification.alert_bell = true; + TEST_ASSERT_TRUE(MeshService::isAlertPayload(withText(makePacket(kPeer, NODENUM_BROADCAST, 0), "wake up", true))); +} + +void test_bell_is_not_an_alert_when_every_bell_output_is_off() +{ + TEST_ASSERT_FALSE(MeshService::isAlertPayload(withText(makePacket(kPeer, NODENUM_BROADCAST, 0), "wake up", true))); +} + +void test_plain_text_is_never_an_alert() +{ + moduleConfig.external_notification.alert_bell = true; + TEST_ASSERT_FALSE(MeshService::isAlertPayload(withText(makePacket(kPeer, NODENUM_BROADCAST, 0), "wake up", false))); +} + +// The wake gate is "not muted, or an alert": a bell must survive a muted channel. +void test_alert_survives_a_muted_channel() +{ + moduleConfig.external_notification.alert_bell = true; + setSlot(0, meshtastic_Channel_Role_PRIMARY, true); + const meshtastic_MeshPacket p = withText(makePacket(kPeer, NODENUM_BROADCAST, 0), "wake up", true); + TEST_ASSERT_TRUE(isMutedForPacket(p)); + TEST_ASSERT_TRUE(!isMutedForPacket(p) || MeshService::isAlertPayload(p)); +} + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- + +void setUp(void) +{ + if (!testNodeDB) + testNodeDB = new NodeDB(); // its constructor overwrites my_node_num, so claim ours after + + config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + myNodeInfo.my_node_num = kLocalNode; + nodeDB = testNodeDB; + + // Start from an empty hot store so kStranger is genuinely unknown. + nodeDB->meshNodes->clear(); + nodeDB->numMeshNodes = 0; + + memset(&channelFile, 0, sizeof(channelFile)); + channels.initDefaults(); + channels.onConfigChanged(); +} + +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + + UNITY_BEGIN(); + + printf("\n=== Broadcast: channel mute ===\n"); + RUN_TEST(test_broadcast_on_unmuted_channel_is_not_muted); + RUN_TEST(test_broadcast_on_muted_channel); + RUN_TEST(test_channel_without_module_settings_is_not_muted); + RUN_TEST(test_broadcast_reads_its_own_channel); + RUN_TEST(test_channel_zero_resolves_to_primary_slot); + + printf("\n=== Direct message: sender mute ===\n"); + RUN_TEST(test_dm_to_us_from_muted_sender); + RUN_TEST(test_dm_to_us_from_unmuted_sender_is_not_muted); + RUN_TEST(test_dm_to_us_ignores_channel_mute); + RUN_TEST(test_dm_to_us_from_unknown_sender_is_not_muted); + RUN_TEST(test_dm_to_third_party_uses_channel); + + printf("\n=== Alerts break through mute ===\n"); + RUN_TEST(test_bell_is_an_alert_when_a_bell_output_is_on); + RUN_TEST(test_bell_is_not_an_alert_when_every_bell_output_is_off); + RUN_TEST(test_plain_text_is_never_an_alert); + RUN_TEST(test_alert_survives_a_muted_channel); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index c4891056cd..ecfe8ac5a8 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -281,8 +281,9 @@ class MockRoutingModule : public RoutingModule { public: void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false) override + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override { + (void)relaySource; ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); } @@ -684,6 +685,25 @@ void test_health_lru_eviction_bounds_table(void) TEST_ASSERT_NOT_NULL(shim->findRouteHealth(0x2000)); // newest present } +// A stamp of 0 is the empty-slot marker, so an arm landing on the wrap tick must be normalized: +// getOrAllocRouteHealth() would otherwise read the slot as ever-older and evict it first. +void test_health_learn_never_stores_zero_sentinel(void) +{ + shim->noteRouteLearned(DEST, 0xAB, 0); // learned exactly on the wrap tick + RouteHealth *h = shim->findRouteHealth(DEST); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_NOT_EQUAL_UINT32(0u, h->learnedAtMsec); +} + +void test_health_success_never_stores_zero_sentinel(void) +{ + shim->noteRouteLearned(DEST, 0xAB, 1000); + shim->noteRouteSuccess(DEST, 0); // refreshed exactly on the wrap tick + RouteHealth *h = shim->findRouteHealth(DEST); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_NOT_EQUAL_UINT32(0u, h->learnedAtMsec); +} + // =========================================================================== // Group 4 - shouldDecrementHopLimit favorite-router resolution (M2, site 4) // =========================================================================== @@ -987,6 +1007,25 @@ void test_rebroadcast_declined_send_releases_packet(void) TEST_ASSERT_EQUAL_MESSAGE(1, mockIface->sendCount, "the copy must have reached the mock radio"); } +// An already-encrypted packet never reaches perhapsEncode's TOO_LARGE check, so Router::send() is the +// last gate before the radio queue: MeshPacket.encrypted holds 256 bytes, the radio buffer 240. +void test_send_rejects_payload_larger_than_radio_buffer(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.id = 0x51000010; + p.encrypted.size = MAX_RADIO_PAYLOAD_LEN + 1; + + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_TOO_LARGE, shim->send(packetPool.allocCopy(p)), + "a payload larger than the radio buffer must be refused"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockIface->sendCount, "the oversized packet must never reach the radio"); + + p.id = 0x51000011; + p.encrypted.size = MAX_RADIO_PAYLOAD_LEN; + TEST_ASSERT_EQUAL_MESSAGE(ERRNO_OK, shim->send(packetPool.allocCopy(p)), "a payload at the radio ceiling must still be sent"); + TEST_ASSERT_EQUAL_MESSAGE(1, mockIface->sendCount, "the fitting packet must reach the radio"); +} + #if USERPREFS_EVENT_MODE void test_event_mode_hop_behavior(void) { @@ -1086,6 +1125,8 @@ void setup() RUN_TEST(test_health_failure_without_record_is_noop); RUN_TEST(test_health_clear); RUN_TEST(test_health_lru_eviction_bounds_table); + RUN_TEST(test_health_learn_never_stores_zero_sentinel); + RUN_TEST(test_health_success_never_stores_zero_sentinel); printf("\n=== shouldDecrementHopLimit (M2 site 4) ===\n"); RUN_TEST(test_hoplimit_preserve_unique_favorite_router); @@ -1114,6 +1155,7 @@ void setup() RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed); RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed); RUN_TEST(test_rebroadcast_declined_send_releases_packet); + RUN_TEST(test_send_rejects_payload_larger_than_radio_buffer); #if USERPREFS_EVENT_MODE RUN_TEST(test_event_mode_hop_behavior); #endif diff --git a/test/test_nodedb_boot_recovery/test_main.cpp b/test/test_nodedb_boot_recovery/test_main.cpp index 0c2f537df5..2d6466e82f 100644 --- a/test/test_nodedb_boot_recovery/test_main.cpp +++ b/test/test_nodedb_boot_recovery/test_main.cpp @@ -350,6 +350,33 @@ static void test_loadProto_classifiesFailuresDistinctly(void) FSCom.remove(scratchPath); // leave nothing behind } +// installDefaultModuleConfig() sized its memset to meshtastic_ModuleConfig (the 368-byte oneof) +// instead of the 1092-byte LocalModuleConfig, so submessages it never assigns kept their old values. +static void test_oldModuleConfig_discardClearsTailSubmessages(void) +{ + // statusmessage sits at offset 609, past the old 368-byte clear, and the defaults installer + // never assigns it - so only the full-struct memset can drop this marker. + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + moduleConfig.version = DEVICESTATE_MIN_VER - 1; + moduleConfig.has_statusmessage = true; + strncpy(moduleConfig.statusmessage.node_status, "STALE-TAIL", sizeof(moduleConfig.statusmessage.node_status) - 1); + + uint8_t buf[meshtastic_LocalModuleConfig_size]; + size_t len = pb_encode_to_bytes(buf, sizeof(buf), &meshtastic_LocalModuleConfig_msg, &moduleConfig); + TEST_ASSERT_GREATER_THAN_size_t(0, len); + writeFileBytes(moduleConfigFileName, buf, len); + + rebootNodeDB(); // decodes, sees version < DEVICESTATE_MIN_VER, discards + + // The opt-in migration re-stamps the version after the discard, so assert the floor, not equality. + TEST_ASSERT_GREATER_OR_EQUAL_UINT32_MESSAGE(DEVICESTATE_CUR_VER, moduleConfig.version, "discard did not reinstall defaults"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", moduleConfig.statusmessage.node_status, + "statusmessage survived the moduleConfig discard"); + + FSCom.remove(moduleConfigFileName); // leave the sandbox as we found it + rebootNodeDB(); +} + NBR_TEST_ENTRY void setup() { initializeTestEnvironment(); @@ -374,6 +401,9 @@ NBR_TEST_ENTRY void setup() RUN_TEST(test_oldDevicestate_recoversOwnerFromNodeDb); RUN_TEST(test_loadProto_classifiesFailuresDistinctly); + printf("\n=== ModuleConfig discard completeness ===\n"); + RUN_TEST(test_oldModuleConfig_discardClearsTailSubmessages); + exit(UNITY_END()); } diff --git a/test/test_nodedb_legacy_migration/test_main.cpp b/test/test_nodedb_legacy_migration/test_main.cpp index 3f6a645f36..6abea3b98d 100644 --- a/test/test_nodedb_legacy_migration/test_main.cpp +++ b/test/test_nodedb_legacy_migration/test_main.cpp @@ -337,6 +337,25 @@ static void test_v24RoundTrip_migratesFieldsBitfieldAndSatellites(void) // has_position=false / has_device_metrics=false entries must not seed // zero-position ghosts in the satellite maps. +// v24 assigned bits 0..10 of the bitfield; this build reads bit 11 as "heard over RF" and bits 12..23 +// as the slot it was heard on. A legacy record carrying anything up there must not arrive claiming to +// have been heard, or a never-heard node reads as reachable whenever the stray slot matches ours. +static void test_v24StrayHighBits_doNotBecomeRfHearState(void) +{ + auto n = makeLegacyNode(0xD4000001, 1000); + giveLegacyUser(n, "Stray", "ST"); + n.is_favorite = true; // a real bit 3, which must survive + n.bitfield = 0xFFFFFFFFu; // every reserved bit above 10 set + writeLegacyNodesFile(24, {n}); + coldBoot(); + + const meshtastic_NodeInfoLite *m = db->getMeshNode(0xD4000001); + TEST_ASSERT_NOT_NULL(m); + TEST_ASSERT_FALSE(nodeInfoLiteHasRfHear(m)); + TEST_ASSERT_EQUAL_UINT16(0, nodeInfoLiteHeardSlot(m)); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(m)); // the legacy bits it did own are untouched +} + static void test_absentSubmessages_noSatelliteGhostRows(void) { auto a = makeLegacyNode(0xC3000001, 1000); @@ -540,6 +559,7 @@ NDBM_TEST_ENTRY void setup() printf("\n=== Migration fidelity ===\n"); RUN_TEST(test_v24RoundTrip_migratesFieldsBitfieldAndSatellites); + RUN_TEST(test_v24StrayHighBits_doNotBecomeRfHearState); RUN_TEST(test_absentSubmessages_noSatelliteGhostRows); printf("\n=== sanitizeUtf8 firewall ===\n"); diff --git a/test/test_nodedb_lora_slot/test_main.cpp b/test/test_nodedb_lora_slot/test_main.cpp new file mode 100644 index 0000000000..adf53ad826 --- /dev/null +++ b/test/test_nodedb_lora_slot/test_main.cpp @@ -0,0 +1,338 @@ +// The "heard on the current LoRa config" mark - src/mesh/NodeDB.cpp and src/mesh/TypeConversions.cpp. +// Each node stores the slot it was last heard on; NodeInfo.heard_on_current_lora is that matching the +// slot the radio is committed to. The regression guarded is a client rolling through presets to scan +// for traffic: nothing may be swept on the way out, and returning to a slot must mark its nodes again. +#include "MeshTypes.h" // BEFORE TestUtil.h - provides WARM_NODE_COUNT / MAX_NUM_NODES via mesh-pb-constants.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NDB_TEST_ENTRY extern "C" +#else +#define NDB_TEST_ENTRY +#endif + +#include "mesh/NodeDB.h" +#include "mesh/TypeConversions.h" +#include + +// Name and global scope both fixed by the `friend class NodeDBTestShim` declaration in NodeDB.h. +class NodeDBTestShim : public NodeDB +{ + public: + void clearHot() + { + meshNodes->clear(); + numMeshNodes = 0; + } + + // A node admitted without ever being heard over RF - an all-zero bitfield, as a pre-feature + // record loaded from disk has. + void push(NodeNum num) + { + meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; + n.num = num; + n.last_heard = 1000; + meshNodes->push_back(n); + numMeshNodes = meshNodes->size(); + } +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; +meshtastic_Config_LoRaConfig savedLora; + +// Every field the snapshot reads is non-default, so changing one is a real change, not a zero swap. +meshtastic_Config_LoRaConfig baselineLora() +{ + meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_zero; + lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + lora.use_preset = true; + lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + lora.bandwidth = 250; + lora.spread_factor = 11; + lora.coding_rate = 5; + lora.override_frequency = 869.525f; + lora.channel_num = 7; + return lora; +} + +uint16_t fp(const meshtastic_Config_LoRaConfig &lora, const char *name) +{ + return loraSlotSnapshotFrom(lora, name).fingerprint(); +} + +// What a client actually sees: derived at conversion time from the slot the radio is committed to. +bool heard(NodeNum num) +{ + return TypeConversions::ConvertToNodeInfo(db->getMeshNode(num), nullptr, nullptr).heard_on_current_lora; +} + +// A decoded packet as updateFrom() sees it coming off the RX pipeline. +meshtastic_MeshPacket rxPacket(NodeNum from) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = from; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.has_rx_time = true; + mp.rx_time = 1000; + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + return mp; +} + +// Move the radio the way a client's set_config(lora) does, committing to the new slot. +void commitPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + config.lora.modem_preset = preset; + db->refreshCommittedLoraSlot(); +} + +void commitHome() +{ + config.lora = savedLora; + db->refreshCommittedLoraSlot(); +} + +} // namespace + +void setUp(void) +{ + db->clearHot(); + config.lora = savedLora; + db->setLoraSlotTransient(false); + db->refreshCommittedLoraSlot(); +} + +void tearDown(void) {} + +// ---------- the fingerprint: what counts as a different slot --------------------------------- + +static void test_fingerprint_identicalConfigMatches(void) +{ + const meshtastic_Config_LoRaConfig lora = baselineLora(); + TEST_ASSERT_EQUAL_UINT16(fp(lora, "LongFast"), fp(lora, "LongFast")); +} + +static void test_fingerprint_regionIsASlotChange(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.region = meshtastic_Config_LoRaConfig_RegionCode_US; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_presetIsASlotChange(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_channelNumChangesSlot(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.channel_num = 8; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_overrideFrequencyIsASlotChange(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.override_frequency = 869.4f; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +// Slot is the hash of the primary channel name, so a rename or a scanned QR moves the radio. +static void test_fingerprint_primaryChannelRenameIsASlotChange(void) +{ + const meshtastic_Config_LoRaConfig lora = baselineLora(); + TEST_ASSERT_NOT_EQUAL_UINT16(fp(lora, "LongFast"), fp(lora, "MyMesh")); +} + +static void test_fingerprint_usePresetToggleIsASlotChange(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.use_preset = false; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +// The dormant half of the preset/custom pair moves nothing on air; editing it must not read as a move. +static void test_fingerprint_dormantModemFieldsIgnoredWhenUsingPreset(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); // use_preset = true + other.bandwidth = 125; + other.spread_factor = 7; + other.coding_rate = 8; + TEST_ASSERT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_dormantPresetIgnoredWhenNotUsingPreset(void) +{ + meshtastic_Config_LoRaConfig base = baselineLora(); + base.use_preset = false; + meshtastic_Config_LoRaConfig other = base; + other.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + TEST_ASSERT_EQUAL_UINT16(fp(base, "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_customModemFieldsCountWhenNotUsingPreset(void) +{ + meshtastic_Config_LoRaConfig base = baselineLora(); + base.use_preset = false; + meshtastic_Config_LoRaConfig bw = base, sf = base, cr = base; + bw.bandwidth = 125; + sf.spread_factor = 7; + cr.coding_rate = 8; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(bw, "LongFast")); + TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(sf, "LongFast")); + TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(cr, "LongFast")); +} + +// ---------- storing the slot on a hear ------------------------------------------------------- + +static void test_hear_rfHearMarksNodeOnCurrentSlot(void) +{ + db->updateFrom(rxPacket(0x4444)); + TEST_ASSERT_TRUE(heard(0x4444)); +} + +// A gateway rebroadcast is TRANSPORT_LORA + via_mqtt: we heard the gateway, not the node. +static void test_hear_mqttRelayDoesNotMark(void) +{ + meshtastic_MeshPacket mp = rxPacket(0x5555); + mp.via_mqtt = true; + db->updateFrom(mp); + TEST_ASSERT_NOT_NULL(db->getMeshNode(0x5555)); // admitted... + TEST_ASSERT_FALSE(heard(0x5555)); // ...but not as an RF hear on this slot +} + +static void test_hear_mqttTransportDoesNotMark(void) +{ + meshtastic_MeshPacket mp = rxPacket(0x6666); + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + db->updateFrom(mp); + TEST_ASSERT_NOT_NULL(db->getMeshNode(0x6666)); + TEST_ASSERT_FALSE(heard(0x6666)); +} + +// The mark is about the radio, not the clock: an RF hear counts before the clock is trusted. +static void test_hear_countsWithUntrustedClock(void) +{ + meshtastic_MeshPacket mp = rxPacket(0x7777); + mp.has_rx_time = false; + db->updateFrom(mp); + TEST_ASSERT_TRUE(heard(0x7777)); +} + +// A pre-feature record has an all-zero bitfield. Without the has-RF-hear bit gating it, stored slot 0 +// would collide with whatever the radio happens to be on and mark every legacy node heard. +static void test_hear_legacyRecordReadsUnheard(void) +{ + db->push(0xAAAA); + TEST_ASSERT_FALSE(heard(0xAAAA)); +} + +// ---------- the scan: rolling through presets and back --------------------------------------- + +// The regression this design exists for. A client scanning A->B->C->A must leave A's marks intact: +// the hops sweep nothing, and coming home makes the stored slots match again on their own. +static void test_scan_roundTripRestoresTheMark(void) +{ + db->updateFrom(rxPacket(0x1111)); // heard on A + TEST_ASSERT_TRUE(heard(0x1111)); + + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); // hop to B + TEST_ASSERT_FALSE(heard(0x1111)); // unreachable while parked on B + + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); // hop to C + TEST_ASSERT_FALSE(heard(0x1111)); + + commitHome(); + TEST_ASSERT_TRUE(heard(0x1111)); +} + +// The other direction: a node heard only while parked on B must not read as reachable back on A. +static void test_scan_nodeHeardOnOtherSlotStaysUnheardAtHome(void) +{ + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + db->updateFrom(rxPacket(0x2222)); // a foreign node, heard on B + TEST_ASSERT_TRUE(heard(0x2222)); + + commitHome(); + TEST_ASSERT_FALSE(heard(0x2222)); +} + +// Re-reading the committed slot is not a sweep: it must never touch a node's stored bitfield, which +// is what keeps a scan off the flash and makes the round trip above possible at all. +static void test_scan_refreshWritesNoNode(void) +{ + db->updateFrom(rxPacket(0x3333)); + const uint32_t before = db->getMeshNode(0x3333)->bitfield; + + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + + TEST_ASSERT_EQUAL_UINT32(before, db->getMeshNode(0x3333)->bitfield); +} + +// ---------- transient switch (a beacon keyed up on another preset) --------------------------- + +// MeshBeaconModule rewrites config.lora for a beacon TX and restores it. The committed slot is pinned +// across that window, so the whole node list does not blink to unheard while we key up elsewhere. +static void test_transient_committedSlotIsPinned(void) +{ + db->updateFrom(rxPacket(0x1111)); + const uint16_t home = db->committedLoraSlot(); + + db->setLoraSlotTransient(true); + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + + TEST_ASSERT_EQUAL_UINT16(home, db->committedLoraSlot()); + TEST_ASSERT_TRUE(heard(0x1111)); +} + +// A hear while parked on the beacon's preset belongs to that preset, so it stops matching at home. +static void test_transient_hearIsStampedWithTheLiveSlot(void) +{ + db->setLoraSlotTransient(true); + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + db->updateFrom(rxPacket(0x8888)); + + db->setLoraSlotTransient(false); + commitHome(); + + TEST_ASSERT_NOT_NULL(db->getMeshNode(0x8888)); // still admitted + TEST_ASSERT_FALSE(heard(0x8888)); +} + +NDB_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + db = new NodeDBTestShim(); + nodeDB = db; + savedLora = config.lora; + + UNITY_BEGIN(); + RUN_TEST(test_fingerprint_identicalConfigMatches); + RUN_TEST(test_fingerprint_regionIsASlotChange); + RUN_TEST(test_fingerprint_presetIsASlotChange); + RUN_TEST(test_fingerprint_channelNumChangesSlot); + RUN_TEST(test_fingerprint_overrideFrequencyIsASlotChange); + RUN_TEST(test_fingerprint_primaryChannelRenameIsASlotChange); + RUN_TEST(test_fingerprint_usePresetToggleIsASlotChange); + RUN_TEST(test_fingerprint_dormantModemFieldsIgnoredWhenUsingPreset); + RUN_TEST(test_fingerprint_dormantPresetIgnoredWhenNotUsingPreset); + RUN_TEST(test_fingerprint_customModemFieldsCountWhenNotUsingPreset); + RUN_TEST(test_hear_rfHearMarksNodeOnCurrentSlot); + RUN_TEST(test_hear_mqttRelayDoesNotMark); + RUN_TEST(test_hear_mqttTransportDoesNotMark); + RUN_TEST(test_hear_countsWithUntrustedClock); + RUN_TEST(test_hear_legacyRecordReadsUnheard); + RUN_TEST(test_scan_roundTripRestoresTheMark); + RUN_TEST(test_scan_nodeHeardOnOtherSlotStaysUnheardAtHome); + RUN_TEST(test_scan_refreshWritesNoNode); + RUN_TEST(test_transient_committedSlotIsPinned); + RUN_TEST(test_transient_hearIsStampedWithTheLiveSlot); + exit(UNITY_END()); +} +NDB_TEST_ENTRY void loop() {} diff --git a/test/test_nodedb_v25_roundtrip/test_main.cpp b/test/test_nodedb_v25_roundtrip/test_main.cpp index 93b15a9551..66db1da693 100644 --- a/test/test_nodedb_v25_roundtrip/test_main.cpp +++ b/test/test_nodedb_v25_roundtrip/test_main.cpp @@ -115,6 +115,27 @@ meshtastic_StatusMessage makeStatus(const char *text) return st; } +/// A header row as a saved nodes.proto carries it. HAS_USER matters: cleanupMeshDB +/// purges a userless row on load and erases its satellites with it. +meshtastic_NodeInfoLite craftedOwner(NodeNum num, uint32_t lastHeard) +{ + meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; + n.num = num; + n.last_heard = lastHeard; + n.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; + return n; +} + +meshtastic_NodePositionEntry craftedPosition(NodeNum num, int32_t lat) +{ + meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero; + e.num = num; + e.has_position = true; + e.position.latitude_i = lat; + e.position.time = 1000 + (uint32_t)lat; + return e; +} + bool readFileBytes(const char *path, std::vector &out) { auto f = FSCom.open(path, FILE_O_READ); @@ -522,16 +543,14 @@ static void test_bootTrim_overCapSatellitesHealedOnDisk(void) const NodeNum base = 0x70000000u; // Craft a v25 nodes.proto whose position store exceeds this build's cap, as a - // larger-cap build (or a peer backup) would leave behind. + // larger-cap build (or a peer backup) would leave behind. Every entry has a hot + // owner, so the boot orphan sweep keeps all of them and only the cap trims. meshtastic_NodeDatabase crafted{}; crafted.version = DEVICESTATE_CUR_VER; for (size_t i = 0; i < (size_t)MAX_SATELLITE_NODES + overBy; i++) { - meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero; - e.num = base + (uint32_t)i; - e.has_position = true; - e.position.latitude_i = (int32_t)(1000 + i); - e.position.time = 1000 + (uint32_t)i; - crafted.positions.push_back(e); + const NodeNum num = base + (uint32_t)i; + crafted.nodes.push_back(craftedOwner(num, 1000 + (uint32_t)i)); + crafted.positions.push_back(craftedPosition(num, (int32_t)(1000 + i))); } size_t craftedSize = 0; TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); @@ -539,8 +558,7 @@ static void test_bootTrim_overCapSatellitesHealedOnDisk(void) coldBoot(); - // Trimmed in RAM to exactly the cap; all entries were orphans, so the - // lowest-recency victims (here: the lowest-numbered) went first. + // Trimmed in RAM to exactly the cap, stalest owner first. TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotPositionNodeNums(0).size()); TEST_ASSERT_TRUE(db->hasNodePosition(base + (uint32_t)MAX_SATELLITE_NODES + (uint32_t)overBy - 1)); TEST_ASSERT_FALSE(db->hasNodePosition(base)); @@ -557,6 +575,59 @@ static void test_bootTrim_overCapSatellitesHealedOnDisk(void) } #endif // !MESHTASTIC_EXCLUDE_POSITIONDB +// --- Boot-time heal of a nodes.proto carrying unowned satellite entries --- + +#if !MESHTASTIC_EXCLUDE_POSITIONDB +// Guards #11798: satellite entries whose key names no hot node are dropped on boot and the +// healed store is rewritten once; keys that cannot name a node (0, NODENUM_BROADCAST) are +// refused at decode. +static void test_bootHeal_unownedSatellitesDropped(void) +{ + const NodeNum ownedBase = 0x72000000u; + const NodeNum orphanBase = 0x73000000u; + const size_t owned = 5; + const size_t orphans = 6; + + meshtastic_NodeDatabase crafted{}; + crafted.version = DEVICESTATE_CUR_VER; + for (size_t i = 0; i < owned; i++) { + const NodeNum num = ownedBase + (uint32_t)i; + crafted.nodes.push_back(craftedOwner(num, 1000 + (uint32_t)i)); + crafted.positions.push_back(craftedPosition(num, (int32_t)(100 + i))); + } + for (size_t i = 0; i < orphans; i++) + crafted.positions.push_back(craftedPosition(orphanBase + (uint32_t)i, (int32_t)(200 + i))); + // Keys no NodeNum derivation can produce; these must never reach the map. + crafted.positions.push_back(craftedPosition(0, 300)); + crafted.positions.push_back(craftedPosition(NODENUM_BROADCAST, 301)); + + size_t craftedSize = 0; + TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); + TEST_ASSERT_TRUE(db->saveProto(nodeDatabaseFileName, craftedSize, &meshtastic_NodeDatabase_msg, &crafted, false)); + + coldBoot(); + + // In RAM: every owned entry kept, every unowned one gone. + for (size_t i = 0; i < owned; i++) + TEST_ASSERT_TRUE_MESSAGE(db->hasNodePosition(ownedBase + (uint32_t)i), "hot-owned entry must survive the sweep"); + for (size_t i = 0; i < orphans; i++) + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(orphanBase + (uint32_t)i), "orphan must be swept on boot"); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(0), "key 0 must be refused at decode"); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(NODENUM_BROADCAST), "broadcast key must be refused at decode"); + // And healed on disk: nodeDBSelfCare rewrote the store once during the boot. + meshtastic_NodeDatabase reloaded{}; + decodeNodesFile(reloaded); + size_t persisted = 0; + for (const auto &e : reloaded.positions) { + if (!e.has_position) + continue; + TEST_ASSERT_TRUE_MESSAGE(e.num >= ownedBase && e.num < ownedBase + owned, "healed store must contain only owned entries"); + persisted++; + } + TEST_ASSERT_EQUAL_UINT_MESSAGE((unsigned)owned, (unsigned)persisted, "boot must rewrite the store without the orphans"); +} +#endif // !MESHTASTIC_EXCLUDE_POSITIONDB + // --- resetNodes(keepFavorites): no ghost rows above numMeshNodes --- static void test_resetNodesKeepFavorites_compactsWithoutGhostRows(void) @@ -666,6 +737,7 @@ NDBR_TEST_ENTRY void setup() #endif #if !MESHTASTIC_EXCLUDE_POSITIONDB RUN_TEST(test_bootTrim_overCapSatellitesHealedOnDisk); + RUN_TEST(test_bootHeal_unownedSatellitesDropped); #endif printf("\n=== resetNodes ghost rows ===\n"); diff --git a/test/test_packet_history/test_main.cpp b/test/test_packet_history/test_main.cpp index 913417f4d2..bfce175df2 100644 --- a/test/test_packet_history/test_main.cpp +++ b/test/test_packet_history/test_main.cpp @@ -10,8 +10,11 @@ #include "PacketHistory.h" +#include "SerialConsole.h" #include "TestUtil.h" +#include #include +#include // --------------------------------------------------------------------------- // Constants @@ -25,6 +28,18 @@ static constexpr uint32_t SMALL_CAPACITY = 8; // --------------------------------------------------------------------------- static PacketHistory *ph = nullptr; +class RecordingPrint : public Print +{ + public: + size_t write(uint8_t value) override + { + output.push_back(value); + return 1; + } + + std::vector output; +}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -65,6 +80,19 @@ void test_init_valid_size(void) TEST_ASSERT_TRUE(h.initOk()); } +void test_init_default_size_does_not_warn(void) +{ + RecordingPrint sink; + console->setDestination(&sink); + + PacketHistory h; + + console->setDestination(&Serial); + const std::string output(sink.output.begin(), sink.output.end()); + TEST_ASSERT_TRUE(h.initOk()); + TEST_ASSERT_NULL(strstr(output.c_str(), "Packet History - Invalid size")); +} + void test_init_minimum_size(void) { PacketHistory h(4); @@ -741,6 +769,7 @@ void setup() // Group 1 - Initialization RUN_TEST(test_init_valid_size); + RUN_TEST(test_init_default_size_does_not_warn); RUN_TEST(test_init_minimum_size); RUN_TEST(test_init_too_small_falls_back); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 2bfd2b6e66..47dabdbda8 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -191,7 +191,11 @@ class AuthPipelineRouter : public ReliableRouter class AuthPipelineRoutingModule : public RoutingModule { public: - void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false) override { ackCalls++; } + void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false, + const meshtastic_MeshPacket * = nullptr) override + { + ackCalls++; + } uint32_t ackCalls = 0; }; diff --git a/test/test_phone_api_config_dump/test_main.cpp b/test/test_phone_api_config_dump/test_main.cpp index 2dff75d6ec..af6f83ecf6 100644 --- a/test/test_phone_api_config_dump/test_main.cpp +++ b/test/test_phone_api_config_dump/test_main.cpp @@ -27,6 +27,7 @@ constexpr uint32_t SECOND_NONCE = 0x0DDBA11; constexpr NodeNum SEEDED_NODE_A = 0x00000A01; constexpr NodeNum SEEDED_NODE_B = 0x00000A02; +constexpr unsigned MAX_IDLE_DRAIN_READS = 8; // replay phases left to drain after config_complete_id constexpr unsigned NUM_SINGLETON_PREFIX = 5; // my_info, deviceuiConfig, own node_info, metadata, region_presets constexpr unsigned NUM_CONFIG_MESSAGES = _meshtastic_AdminMessage_ConfigType_MAX + 1; constexpr unsigned NUM_MODULE_CONFIG_MESSAGES = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1; @@ -73,8 +74,12 @@ static_assert(sizeof(kExpectedModuleConfigVariants) / sizeof(kExpectedModuleConf /// PhoneAPI over a permanently-connected fake transport. class PhoneAPITestShim : public PhoneAPI { + public: + unsigned dataNotifications = 0; // transport wake-ups, i.e. "come and read" + protected: bool checkIsConnected() override { return true; } + void onNowHasData(uint32_t fromRadioNum) override { dataNotifications++; } }; /// Concrete Router with no radio interface: getQueueStatus() reports an all-zero queue. @@ -223,6 +228,26 @@ bool drainUntilComplete(DumpTranscript &t, unsigned maxMessages = 600) return false; } +/// What the first region set does to a live node: a minted key moves my_node_num with it. +void mintIdentity() +{ + config.security.public_key.size = 32; + memset(config.security.public_key.bytes, 0x5A, sizeof(config.security.public_key.bytes)); + TEST_ASSERT_TRUE_MESSAGE(nodeDB->createNewIdentity(), "identity did not move"); +} + +/// Read until available() reports idle; false if it never does within the cap. +bool drainToIdle() +{ + uint8_t buf[meshtastic_FromRadio_size]; + for (unsigned i = 0; i < MAX_IDLE_DRAIN_READS; i++) { + if (!api->available()) + return true; + api->getFromRadio(buf); + } + return false; +} + unsigned countVariant(const DumpTranscript &t, pb_size_t tag) { unsigned n = 0; @@ -369,6 +394,14 @@ void test_only_nodes_nonce_sends_nodes_then_complete() TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_config_tag)); TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_moduleConfig_tag)); TEST_ASSERT_EQUAL_UINT(0, t.fileInfoCount); + + // This nonce skips my_info entirely, so the trailing replay must not produce one either. + for (unsigned i = 0; i < MAX_IDLE_DRAIN_READS && api->available(); i++) { + meshtastic_FromRadio trailing; + if (readOneFromRadio(trailing)) + TEST_ASSERT_NOT_EQUAL_MESSAGE(meshtastic_FromRadio_my_info_tag, trailing.which_payload_variant, + "nodes-only sync must not emit my_info"); + } } // SPECIAL_NONCE_ONLY_CONFIG delivers the full config but skips the non-self node DB, and must @@ -490,18 +523,97 @@ void test_dump_reaches_idle_after_complete() DumpTranscript t; TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_TRUE_MESSAGE(drainToIdle(), "post-complete drain never went idle: available() stuck true"); uint8_t buf[meshtastic_FromRadio_size]; - bool idle = false; - for (unsigned i = 0; i < 8 && !idle; i++) { - if (!api->available()) - idle = true; - else - api->getFromRadio(buf); // replay drain: empty phases must advance toward idle - } - TEST_ASSERT_TRUE_MESSAGE(idle, "post-complete drain never went idle: available() stuck true"); TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf)); } +// The first region set moves my_node_num live (NodeDB::createNewIdentity()) with no reboot to force a +// re-handshake, so the stream must re-announce my_info - exactly once - on its own. +void test_node_num_change_resends_my_info() +{ + startHandshake(FULL_DUMP_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_TRUE_MESSAGE(drainToIdle(), "post-complete drain never went idle"); + + const uint32_t handshakeNodeNum = nodeDB->getNodeNum(); + const unsigned notificationsBefore = api->dataNotifications; + + mintIdentity(); + TEST_ASSERT_NOT_EQUAL_MESSAGE(handshakeNodeNum, nodeDB->getNodeNum(), "node num did not actually change"); + + service->loop(); // delivers the fromNum notify that arms the re-announce + TEST_ASSERT_GREATER_THAN_UINT_MESSAGE(notificationsBefore, api->dataNotifications, + "transport was never woken to come and read"); + + meshtastic_FromRadio msg; + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant, + "a renumber must be announced as my_info"); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), msg.my_info.my_node_num); + + // One-shot: the stream falls back to live traffic and nothing repeats. + TEST_ASSERT_FALSE_MESSAGE(api->available(), "my_info resend repeated after the client was told"); +} + +// The same move landing mid-sync: my_info is already out with the old number and there is no +// steady state to fall back from, so the dump restarts and carries the new one. +void test_node_num_change_mid_dump_restarts_sync() +{ + startHandshake(FULL_DUMP_NONCE); + + meshtastic_FromRadio msg; + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS; i++) + TEST_ASSERT_TRUE(readOneFromRadio(msg)); // through the channels, my_info long since sent + + mintIdentity(); + service->loop(); + + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant, + "a mid-sync renumber must restart the dump"); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), msg.my_info.my_node_num); + + // Everything after the my_info already read must arrive again, in order and complete. + DumpTranscript t; + TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "restarted dump never completed"); + const pb_size_t expectedPrefix[] = {meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag, + meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag}; + TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX - 1, sizeof(expectedPrefix) / sizeof(expectedPrefix[0])); + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX - 1; i++) + TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "restarted dump changed the header sequence"); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(), "restarted dump lost part of the config"); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size()); // our own record; this test seeds no remotes + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, t.completeId); +} + +// Same move during a nodes-only sync: the self record it already sent is gone from the DB, so that +// dump restarts too, even though this nonce never carries a my_info to be stale. +void test_node_num_change_mid_nodes_only_restarts_sync() +{ + seedRemoteNode(SEEDED_NODE_A); + startHandshake(SPECIAL_NONCE_ONLY_NODES); + + meshtastic_FromRadio msg; + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant); + const uint32_t staleSelf = msg.node_info.num; + + mintIdentity(); + service->loop(); + + DumpTranscript t; + TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "restarted nodes-only dump never completed"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(nodeDB->getNumMeshNodes(), t.nodeNums.size(), "restart must resend every node"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(staleSelf, t.nodeNums[0], "restart must carry the new self record"); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId); +} + } // namespace void setUp(void) @@ -567,6 +679,9 @@ void setup() RUN_TEST(test_close_mid_dump_then_reconnect_restarts_clean); RUN_TEST(test_rehandshake_mid_dump_restarts_from_my_info); RUN_TEST(test_dump_reaches_idle_after_complete); + RUN_TEST(test_node_num_change_resends_my_info); + RUN_TEST(test_node_num_change_mid_dump_restarts_sync); + RUN_TEST(test_node_num_change_mid_nodes_only_restarts_sync); exit(UNITY_END()); } diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index d9e81c8715..27e74237e8 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -64,7 +64,7 @@ class TestableRadioInterface : public RadioInterface size_t beginSendingPublic(meshtastic_MeshPacket *p) { return beginSending(p); } meshtastic_MeshPacket *getSendingPacket() const { return sendingPacket; } - size_t getRadioBufferPayloadCapacity() const { return sizeof(radioBuffer.payload); } + void clearSendingPacketForTest() { sendingPacket = nullptr; } // Override reconfigure to call the base which invokes applyModemConfig() bool reconfigure() override { return RadioInterface::reconfigure(); } @@ -430,7 +430,9 @@ static int32_t packetPoolLiveBytes() return 0; } -static void test_beginSending_oversizedPayloadAbortsSafely() +// Oversize is refused at the radio queue in Router::send(). If one ever gets this far the memcpy is +// clamped instead of failing, and the packet stays the caller's to release. +static void test_beginSending_oversizedPayloadIsClamped() { const int32_t liveBefore = packetPoolLiveBytes(); @@ -443,20 +445,34 @@ static void test_beginSending_oversizedPayloadAbortsSafely() p->to = 0x87654321; p->id = 0x10203040; p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p->encrypted.size = MAX_RADIO_PAYLOAD_LEN + 10; - // Set encrypted size larger than sizeof(radioBuffer.payload) (which is 256 - sizeof(PacketHeader)) - p->encrypted.size = testRadio->getRadioBufferPayloadCapacity() + 10; + TEST_ASSERT_EQUAL_UINT_MESSAGE(MAX_LORA_PAYLOAD_LEN, testRadio->beginSendingPublic(p), + "an oversized payload must be clamped to the PHY limit, not rejected"); + TEST_ASSERT_EQUAL_PTR_MESSAGE(p, testRadio->getSendingPacket(), "beginSending must still take the packet"); - size_t result = testRadio->beginSendingPublic(p); - - TEST_ASSERT_EQUAL_UINT(0, result); - TEST_ASSERT_NULL(testRadio->getSendingPacket()); - - // The rejected packet went back to the pool. Not pointer identity: the native pool is - // malloc-backed and ASan quarantines the freed block, so the next alloc moves. + // beginSending has no failure path that releases, so the packet is ours to free. + testRadio->clearSendingPacketForTest(); + packetPool.release(p); TEST_ASSERT_EQUAL_INT32(liveBefore, packetPoolLiveBytes()); } +// The clamp must not shorten ordinary traffic, and a maximum-size frame must still fit the PHY. +static void test_beginSending_fittingPayloadIsSentWhole() +{ + meshtastic_MeshPacket *p = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(p); + p->from = 0x12345678; + p->to = 0x87654321; + p->id = 0x10203041; + p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p->encrypted.size = MAX_RADIO_PAYLOAD_LEN; + + TEST_ASSERT_EQUAL_UINT_MESSAGE(MAX_LORA_PAYLOAD_LEN, testRadio->beginSendingPublic(p), + "the largest allowed payload must produce a frame at the PHY limit"); + testRadio->clearSendingPacketForTest(); + packetPool.release(p); +} void setUp(void) { mockMeshService = new MockMeshService(); @@ -507,7 +523,8 @@ void setup() RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds); RUN_TEST(test_regionPresetMap_matchesRegionTable); RUN_TEST(test_regionPresetMap_unsetCarriesUserprefsIntent); - RUN_TEST(test_beginSending_oversizedPayloadAbortsSafely); + RUN_TEST(test_beginSending_oversizedPayloadIsClamped); + RUN_TEST(test_beginSending_fittingPayloadIsSentWhole); exit(UNITY_END()); } diff --git a/test/test_reliable_ack_matrix/test_main.cpp b/test/test_reliable_ack_matrix/test_main.cpp index 635b979039..1dcd5ab123 100644 --- a/test/test_reliable_ack_matrix/test_main.cpp +++ b/test/test_reliable_ack_matrix/test_main.cpp @@ -164,13 +164,29 @@ class TimedCaptureRadio : public RadioInterface class MockRoutingModule : public RoutingModule { public: + // The relaying copy the caller handed us, flattened to the fields allocAckNak() forwards onto + // the ack. One entry per sendAckNak() call, so it stays aligned with ackNaks. + struct RelaySource { + bool present; + uint8_t relayNode; + bool hasRxRssi; + int32_t rxRssi; + float rxSnr; + }; + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false) override + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override { ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + if (relaySource) + relaySources.push_back( + {true, relaySource->relay_node, relaySource->has_rx_rssi, relaySource->rx_rssi, relaySource->rx_snr}); + else + relaySources.push_back({false, NO_RELAY_NODE, false, 0, 0.0f}); } std::list> ackNaks; + std::vector relaySources; }; class ScopedAirTimeFixture @@ -245,6 +261,26 @@ static void expectSingleAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI TEST_ASSERT_EQUAL(ackWantsAck, std::get<5>(ack)); } +// #10767: only the implicit ack for an overheard rebroadcast of our own packet carries a relay +// source; every other ACK/NAK must leave it unset so the phone is never told a relayer we did not +// hear. +static void expectRelaySource(uint8_t relayNode, int32_t rxRssi, float rxSnr) +{ + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->relaySources.size()); + const auto &relay = mockRoutingModule->relaySources.front(); + TEST_ASSERT_TRUE(relay.present); + TEST_ASSERT_EQUAL_HEX8(relayNode, relay.relayNode); + TEST_ASSERT_TRUE(relay.hasRxRssi); + TEST_ASSERT_EQUAL_INT32(rxRssi, relay.rxRssi); + TEST_ASSERT_EQUAL_FLOAT(rxSnr, relay.rxSnr); +} + +static void expectNoRelaySource() +{ + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->relaySources.size()); + TEST_ASSERT_FALSE(mockRoutingModule->relaySources.front().present); +} + static void configureChannels() { memset(&channelFile, 0, sizeof(channelFile)); @@ -286,6 +322,7 @@ void setUp(void) reliableShim->resetRouteHealthForTest(); radio->reset(); mockRoutingModule->ackNaks.clear(); + mockRoutingModule->relaySources.clear(); configureChannels(); } @@ -298,12 +335,16 @@ void tearDown(void) {} void test_text_dm_want_ack_gets_want_ack_ack(void) { auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.relay_node = 0x77; // this ACK travels the mesh for someone else's DM; it must claim no relayer + p.has_rx_rssi = true; + p.rx_rssi = -55; uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); TEST_ASSERT_NOT_EQUAL(0, expectedHop); // must be distinguishable from the 0-hop ACK branch reliableShim->sniffForTest(&p, nullptr); expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true); + expectNoRelaySource(); } void test_text_reply_still_gets_want_ack_ack(void) @@ -610,11 +651,17 @@ void test_overheard_own_dm_rebroadcast_mints_implicit_ack(void) overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; overheard.encrypted.size = 32; + overheard.relay_node = 0x99; + overheard.has_rx_rssi = true; + overheard.rx_rssi = -87; + overheard.rx_snr = 6.25f; reliableShim->filterForTest(&overheard); // ACK is addressed to us (so it reaches the phone) on the pending copy's channel. expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + // The overheard copy is the relay source, so the phone learns who relayed and at what quality. + expectRelaySource(0x99, -87, 6.25f); TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); } @@ -685,6 +732,10 @@ static meshtastic_MeshPacket makeOpaqueOwnOverheard(PacketId id, meshtastic_Mesh p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; p.encrypted.size = 32; memset(p.encrypted.bytes, 0xC3, p.encrypted.size); + p.relay_node = 0x4D; + p.has_rx_rssi = true; + p.rx_rssi = -112; + p.rx_snr = -3.5f; return p; } @@ -704,6 +755,8 @@ void test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries(void) ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA)); expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + // Relay attribution survives the opaque short-circuit too: the header fields are all it needs. + expectRelaySource(0x4D, -112, -3.5f); TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); } diff --git a/test/test_telemetry_payload_fit/test_main.cpp b/test/test_telemetry_payload_fit/test_main.cpp new file mode 100644 index 0000000000..5e8dd59826 --- /dev/null +++ b/test/test_telemetry_payload_fit/test_main.cpp @@ -0,0 +1,44 @@ +// Pins the static_assert in src/mesh/mesh-pb-constants.h that meshtastic_Telemetry_size fits +// meshtastic_Constants_DATA_PAYLOAD_LEN. That assert compares two generated constants and cannot +// tell whether Telemetry_size is a size a real encode reaches, so this fills the largest variant +// to its worst case and measures what nanopb emits. +// +// Regression guarded (#11797): an oversized Telemetry fails silently - pb_encode_to_bytes() +// returns 0 and a well-formed packet carrying nothing goes out. Relax this and the next protobuf +// size increase ships as empty packets again. + +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// host_metrics is the largest variant, so it sets Telemetry_size; 0xFF is its worst case, every +// optional present and every varint at full width. +void test_largest_telemetry_matches_generated_size_and_fits_payload() +{ + meshtastic_Telemetry t = meshtastic_Telemetry_init_zero; + t.time = 0xFFFFFFFF; // proto3 omits a zero scalar; force the 5 B time field to be emitted + t.which_variant = meshtastic_Telemetry_host_metrics_tag; + memset(&t.variant.host_metrics, 0xFF, sizeof(t.variant.host_metrics)); + memset(t.variant.host_metrics.user_string, 'x', sizeof(t.variant.host_metrics.user_string) - 1); + t.variant.host_metrics.user_string[sizeof(t.variant.host_metrics.user_string) - 1] = '\0'; + + size_t size = 0; + TEST_ASSERT_TRUE(pb_get_encoded_size(&size, &meshtastic_Telemetry_msg, &t)); + TEST_ASSERT_EQUAL_size_t(meshtastic_Telemetry_size, size); + TEST_ASSERT_LESS_OR_EQUAL_size_t(meshtastic_Constants_DATA_PAYLOAD_LEN, size); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_largest_telemetry_matches_generated_size_and_fits_payload); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_throttle/test_main.cpp b/test/test_throttle/test_main.cpp index e2630ba3dd..b0fe23a04c 100644 --- a/test/test_throttle/test_main.cpp +++ b/test/test_throttle/test_main.cpp @@ -148,8 +148,12 @@ void test_deadlinePassed_reads_disarmed_sentinels_as_passed() { Time::setTestMillis(6247); - TEST_ASSERT_TRUE(Throttle::deadlinePassed(0)); // "inactive" for rebootAtMsec et al - TEST_ASSERT_TRUE(Throttle::deadlinePassed(UINT32_MAX)); // "inactive" for nagCycleCutoff + TEST_ASSERT_TRUE(Throttle::deadlinePassed(0)); // "inactive" for rebootAtMsec et al + + // UINT32_MAX is not a usable "far future" either - at a low uptime it is a hair BEHIND now, so + // it reads as passed like any other past value. ExternalNotificationModule used to reserve it + // for "unarmed" and now keeps that state in its isNagging flag instead. + TEST_ASSERT_TRUE(Throttle::deadlinePassed(UINT32_MAX)); // The guarded form every caller must use. const uint32_t disarmed = 0; diff --git a/test/test_trackball_press/test_main.cpp b/test/test_trackball_press/test_main.cpp new file mode 100644 index 0000000000..717f38426f --- /dev/null +++ b/test/test_trackball_press/test_main.cpp @@ -0,0 +1,164 @@ +// Unit tests for TrackballInterruptBase::updatePress(): short/long classification, the interrupt +// latch that keeps a tap released between polls, and repeat timing across the millis() rollover. +#include "TestUtil.h" +#include "UptimeClock.h" +#include "input/TrackballInterruptBase.h" +#include + +namespace +{ +// updatePress() is protected: expose it without touching any GPIO. +class PressProbe : public TrackballInterruptBase +{ + public: + PressProbe() : TrackballInterruptBase("tbpress") {} + using TrackballInterruptBase::PressResult; + using TrackballInterruptBase::updatePress; +}; + +constexpr uint32_t LONG_PRESS_MS = 500; +constexpr uint32_t LONG_REPEAT_MS = 300; +constexpr uint32_t START_MS = 100000; +} // namespace + +void setUp(void) +{ + Time::setTestMillis(START_MS); +} + +void tearDown(void) +{ + Time::useRealClock(); +} + +void test_tap_released_before_poll_emits_short() +{ + PressProbe tb; + TEST_ASSERT_TRUE(PressProbe::PressResult::Short == tb.updatePress(true, START_MS, false)); +} + +void test_held_then_released_under_threshold_emits_short() +{ + PressProbe tb; + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(true, START_MS, true)); + + Time::advanceTestMillis(LONG_PRESS_MS - 1); + TEST_ASSERT_TRUE(PressProbe::PressResult::Short == tb.updatePress(false, 0, false)); +} + +void test_still_held_under_threshold_emits_nothing() +{ + PressProbe tb; + tb.updatePress(true, START_MS, true); + + Time::advanceTestMillis(LONG_PRESS_MS / 2); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, true)); +} + +void test_hold_emits_long_repeats_at_the_repeat_interval() +{ + PressProbe tb; + tb.updatePress(true, START_MS, true); + + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(LONG_REPEAT_MS - 1); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(1); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); +} + +void test_release_after_long_press_emits_nothing() +{ + PressProbe tb; + tb.updatePress(true, START_MS, true); + + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(10); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, false)); +} + +void test_press_after_release_is_tracked_again() +{ + PressProbe tb; + tb.updatePress(true, START_MS, true); + Time::advanceTestMillis(10); + TEST_ASSERT_TRUE(PressProbe::PressResult::Short == tb.updatePress(false, 0, false)); + + Time::advanceTestMillis(10); + const uint32_t secondIrq = Time::getMillis(); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(true, secondIrq, true)); + + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); +} + +void test_idle_poll_emits_nothing() +{ + PressProbe tb; + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, false)); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, true)); +} + +void test_hold_across_the_millis_wrap() +{ + PressProbe tb; + const uint32_t nearWrap = 0xFFFFFF00u; + Time::setTestMillis(nearWrap); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(true, nearWrap, true)); + + // Crosses the 32-bit rollover mid-press; a raw millis() compare would fire or stall here. + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); +} + +// A delayed first poll must not report a long hold as a tap just because the pin is already high. +void test_long_hold_released_before_first_poll_is_not_short() +{ + PressProbe tb; + const uint32_t irq = Time::getMillis(); + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(true, irq, false)); +} + +// A repeat emitted exactly when the clock reads 0 must not be mistaken for "no repeat sent yet". +void test_repeat_emitted_at_clock_zero_still_waits() +{ + PressProbe tb; + const uint32_t beforeWrap = 0u - LONG_PRESS_MS; // start + LONG_PRESS_MS wraps to exactly 0 + Time::setTestMillis(beforeWrap); + tb.updatePress(true, beforeWrap, true); + + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_EQUAL_UINT32(0, Time::getMillis()); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(LONG_REPEAT_MS - 1); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(1); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_tap_released_before_poll_emits_short); + RUN_TEST(test_held_then_released_under_threshold_emits_short); + RUN_TEST(test_still_held_under_threshold_emits_nothing); + RUN_TEST(test_hold_emits_long_repeats_at_the_repeat_interval); + RUN_TEST(test_release_after_long_press_emits_nothing); + RUN_TEST(test_press_after_release_is_tracked_again); + RUN_TEST(test_idle_poll_emits_nothing); + RUN_TEST(test_hold_across_the_millis_wrap); + RUN_TEST(test_long_hold_released_before_first_poll_is_not_short); + RUN_TEST(test_repeat_emitted_at_clock_zero_still_waits); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index d7d947d5a0..086f47a51d 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -2382,6 +2382,34 @@ static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); } +/** + * Verify a dropped duplicate does not re-stamp the entry: re-stamping slides the window forward on + * every repeat, muting a node that broadcasts faster than the window instead of refreshing it. + */ +static void test_tm_positionDedup_continuousDuplicatesStillRefresh(void) +{ + constexpr uint32_t kPosTickMs = 360000; // mirrors the module's private kPosTimeTickMs + constexpr uint32_t kWindowTicks = 2; + constexpr uint32_t kTicksFed = kWindowTicks * 3; // a duplicate every tick, over whole windows + constexpr uint32_t kExpectedPasses = kTicksFed / kWindowTicks; + constexpr uint32_t kExpectedDrops = kTicksFed - kExpectedPasses; + + moduleConfig.traffic_management.position_min_interval_secs = (kWindowTicks * kPosTickMs) / 1000; + installWellKnownPrimaryChannelWithPrecision(16); + TrafficManagementModuleTestShim module; + + uint32_t passed = 0; + for (uint32_t tick = 0; tick < kTicksFed; tick++) { + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + if (module.handleReceived(dup) == ProcessMessage::CONTINUE) + passed++; + TrafficManagementModule::s_testNowMs += kPosTickMs; + } + + TEST_ASSERT_EQUAL_UINT32(kExpectedPasses, passed); + TEST_ASSERT_EQUAL_UINT32(kExpectedDrops, module.getStats().position_dedup_drops); +} + /** * Verify interval=0 disables position deduplication. * Important because this is an explicit configuration escape hatch. @@ -2806,12 +2834,12 @@ static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void) /** * Verify TRACKER role caps the dedup window at 1 hour. - * A duplicate position that would normally be blocked for 11 h (default) must + * A duplicate position that would normally be blocked for 5 h (default) must * be forwarded once the 1-hour tracker cap expires. */ static void test_tm_trackerRole_capsDedupWindowAtOneHour(void) { - // Operator interval is 11 h - longer than the tracker cap. + // Operator interval is 5 h - longer than the tracker cap. moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; installWellKnownPrimaryChannelWithPrecision(16); @@ -2872,11 +2900,11 @@ static void test_tm_takTrackerRole_capsDedupWindowAtOneHour(void) * hot and warm NodeDB stores - the TMM unified cache is the third fallback. The * role is cached on the entry while NodeDB still knows the node; once NodeDB * forgets it (getNodeRole → CLIENT), the cached role must keep the 1-hour cap - * applied instead of reverting to the 11-hour default interval. + * applied instead of reverting to the 5-hour default interval. */ static void test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole(void) { - // Operator interval is 11 h - longer than the tracker cap. + // Operator interval is 5 h - longer than the tracker cap. moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; installWellKnownPrimaryChannelWithPrecision(16); @@ -2896,8 +2924,8 @@ static void test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole(void) mockNodeDB->clearCachedNode(); ProcessMessage r2 = module.handleReceived(dup); // within 1-hour cap - still drop - // Advance past the tracker cap (3600 s) but stay well under the 11-hour default. - // Without the cached-role fallback this would still be inside the 11-hour window + // Advance past the tracker cap (3600 s) but stay well under the 5-hour default. + // Without the cached-role fallback this would still be inside the 5-hour window // (CLIENT → no exception) and wrongly drop; with it, the 1-hour cap lets it pass. TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; ProcessMessage r3 = module.handleReceived(afterCap); @@ -2935,7 +2963,7 @@ static void test_tm_roleChange_viaNodeInfo_dropsTrackerException(void) meshtastic_MeshPacket info = makeNodeInfoPacketWithRole(kRemoteNode, meshtastic_Config_DeviceConfig_Role_CLIENT); module.handleReceived(info); - // Past the 1-hour tracker cap but within the 11-hour CLIENT interval. With the stale + // Past the 1-hour tracker cap but within the 5-hour CLIENT interval. With the stale // TRACKER role this would pass; after the demotion it must drop (full interval applies). TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -3042,12 +3070,12 @@ static void test_tm_trackerRole_doesNotLengthenShorterOperatorInterval(void) /** * Verify LOST_AND_FOUND role caps duplicate-position dedup at ~15 min (2 pos-ticks), - * not the old one-tick fast-announce. A configured 11-hour interval is shortened to the + * not the old one-tick fast-announce. A configured 5-hour interval is shortened to the * 15-min cap; a duplicate one tick later still drops, but one past the 2-tick cap passes. */ static void test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes(void) { - // Long interval that would normally suppress duplicates for 11 h. + // Long interval that would normally suppress duplicates for 5 h. moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; installWellKnownPrimaryChannelWithPrecision(16); @@ -3357,6 +3385,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires); + RUN_TEST(test_tm_positionDedup_continuousDuplicatesStillRefresh); RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops); RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision); RUN_TEST(test_tm_positionDedup_distinctAtClampedChannelPrecision); diff --git a/test/test_uptime_clock/test_main.cpp b/test/test_uptime_clock/test_main.cpp index f950102c24..db0adacdb0 100644 --- a/test/test_uptime_clock/test_main.cpp +++ b/test/test_uptime_clock/test_main.cpp @@ -1,8 +1,9 @@ // Unit tests for src/UptimeClock.{h,cpp} - the monotonic uptime seam. -// Covers: test-clock injection, stepping the injected clock, the real-clock fallback, and the -// single-writer wrap carry (readers derive, serviceMonotonic() publishes). getMillis() itself is a -// plain 32-bit read with no wrap handling of its own - its consumers' wrap arithmetic is tested in -// test_throttle/. +// Covers: test-clock injection, stepping the injected clock, the real-clock fallback, the +// single-writer wrap carry (readers derive, serviceMonotonic() publishes), and the 0-sentinel dodge +// helpers (skipZero/timerEndsAtMillis). getMillis() itself is a plain 32-bit read with no wrap +// handling of its own beyond those helpers - deadline/throttle wrap arithmetic built on top of it +// is tested in test_throttle/. #include "Arduino.h" #include "TestUtil.h" #include "UptimeClock.h" @@ -70,6 +71,94 @@ void test_advanceTestMillis_wraps_like_millis() TEST_ASSERT_EQUAL_UINT32(0x00000100u, Time::getMillis()); } +// --- skipZero() / timerEndsAtMillis(): dodging the wrap tick that lands on 0 --- + +void test_skipZero_maps_zero_to_one() +{ + TEST_ASSERT_EQUAL_UINT32(1u, Time::skipZero(0)); +} + +void test_skipZero_leaves_nonzero_values_alone() +{ + TEST_ASSERT_EQUAL_UINT32(5u, Time::skipZero(5)); + TEST_ASSERT_EQUAL_UINT32(0xFFFFFFFFu, Time::skipZero(0xFFFFFFFFu)); +} + +void test_timerEndsAtMillis_is_an_ordinary_sum_away_from_the_wrap() +{ + Time::setTestMillis(1000); + TEST_ASSERT_EQUAL_UINT32(1500u, Time::timerEndsAtMillis(500)); +} + +// The sum is what has to dodge 0, not the read: a non-zero getMillis() plus a delay can still land +// exactly on the wrap, which is why this is not skipZero(getMillis()) + delayMs. +void test_timerEndsAtMillis_dodges_a_sum_that_wraps_to_zero() +{ + Time::setTestMillis(0xFFFFFF00u); + TEST_ASSERT_EQUAL_UINT32(1u, Time::timerEndsAtMillis(0x100u)); // 0xFFFFFF00 + 0x100 wraps to 0 +} + +void test_timerEndsAtMillis_dodges_the_wrap_tick_itself() +{ + Time::setTestMillis(0); + TEST_ASSERT_EQUAL_UINT32(1u, Time::timerEndsAtMillis(0)); // getMillis()==0, delayMs==0: sum is 0 +} + +// --- stampMillis(): one value for storing a stamp AND measuring against it --- + +void test_stampMillis_matches_getMillis_away_from_the_wrap() +{ + Time::setTestMillis(123456u); + TEST_ASSERT_EQUAL_UINT32(123456u, Time::stampMillis()); +} + +void test_stampMillis_calls_the_zero_tick_one() +{ + Time::setTestMillis(0); + TEST_ASSERT_EQUAL_UINT32(1u, Time::stampMillis()); +} + +// The regression this helper exists for. Applying skipZero() at the STORE while a reader measures +// elapsed time against a raw clock splits the two sides apart on the wrap tick: the stamp is 1 while +// now is still 0, so `now - stamp` is UINT32_MAX and a brand new stamp reads as ~49.7 days old. Any +// elapsed-since guard - a cooldown, a debounce, a long-press threshold - then fires when it must not. +void test_store_side_dodge_alone_makes_a_fresh_stamp_read_as_ancient() +{ + Time::setTestMillis(0); + const uint32_t rawNow = Time::getMillis(); // what an un-normalised reader holds: 0 + const uint32_t stampedAtStore = Time::skipZero(rawNow); // the old shape: dodge only at the store + TEST_ASSERT_EQUAL_UINT32(0u, rawNow); + TEST_ASSERT_EQUAL_UINT32(1u, stampedAtStore); + TEST_ASSERT_EQUAL_UINT32(UINT32_MAX, (uint32_t)(rawNow - stampedAtStore)); // the whole problem +} + +void test_reading_through_stampMillis_keeps_elapsed_at_zero_on_the_wrap_tick() +{ + Time::setTestMillis(0); + const uint32_t now = Time::stampMillis(); // read once, used for the store AND the comparison + const uint32_t stamp = now; // store it as-is; no second dodge needed + TEST_ASSERT_EQUAL_UINT32(1u, now); + TEST_ASSERT_EQUAL_UINT32(0u, (uint32_t)(now - stamp)); // fresh reads as fresh + + // Once the clock moves on, the measured age is short by exactly the 1 ms the dodge introduced: + // the stamp was taken at tick 0 and recorded as 1, so 400 ticks later it reads as 399 old. That + // is the whole cost of the scheme, and it is the same 1 ms skew skipZero() already documents - + // pinned here so nobody "corrects" it to 400 and reintroduces a raw read on one side. + Time::advanceTestMillis(400u); + TEST_ASSERT_EQUAL_UINT32(399u, (uint32_t)(Time::stampMillis() - stamp)); +} + +// A stamp stored one tick BEFORE the wrap, read one tick after, must still measure 1 ms - the dodge +// must not disturb ordinary wrap-crossing arithmetic. +void test_stampMillis_measures_across_the_wrap_boundary() +{ + Time::setTestMillis(0xFFFFFFFFu); + const uint32_t stamp = Time::stampMillis(); + TEST_ASSERT_EQUAL_UINT32(0xFFFFFFFFu, stamp); + Time::advanceTestMillis(1u); // wraps to 0, which stampMillis reports as 1 + TEST_ASSERT_EQUAL_UINT32(2u, (uint32_t)(Time::stampMillis() - stamp)); +} + // --- getMillisMonotonic(): the published wrap carry --- void test_monotonic_matches_millis_before_any_wrap() @@ -337,6 +426,16 @@ void setup() RUN_TEST(test_getMillis_returns_injected_value); RUN_TEST(test_advanceTestMillis_steps_clock); RUN_TEST(test_advanceTestMillis_wraps_like_millis); + RUN_TEST(test_skipZero_maps_zero_to_one); + RUN_TEST(test_skipZero_leaves_nonzero_values_alone); + RUN_TEST(test_stampMillis_matches_getMillis_away_from_the_wrap); + RUN_TEST(test_stampMillis_calls_the_zero_tick_one); + RUN_TEST(test_store_side_dodge_alone_makes_a_fresh_stamp_read_as_ancient); + RUN_TEST(test_reading_through_stampMillis_keeps_elapsed_at_zero_on_the_wrap_tick); + RUN_TEST(test_stampMillis_measures_across_the_wrap_boundary); + RUN_TEST(test_timerEndsAtMillis_is_an_ordinary_sum_away_from_the_wrap); + RUN_TEST(test_timerEndsAtMillis_dodges_a_sum_that_wraps_to_zero); + RUN_TEST(test_timerEndsAtMillis_dodges_the_wrap_tick_itself); RUN_TEST(test_monotonic_matches_millis_before_any_wrap); RUN_TEST(test_monotonic_counts_a_wrap); RUN_TEST(test_monotonic_reader_crosses_the_wrap_without_a_publish); diff --git a/test/test_userprefs_channels/test_main.cpp b/test/test_userprefs_channels/test_main.cpp new file mode 100644 index 0000000000..653f0563cd --- /dev/null +++ b/test/test_userprefs_channels/test_main.cpp @@ -0,0 +1,179 @@ +// Channels::initDefaults() must populate every index named by USERPREFS_CHANNELS_TO_WRITE, and an +// index configured in part must keep the firmware's own value for every field left out. +// +// Under coverage-channel-table / native-windows-channel-table userprefs_fixture.h is -include'd and +// the configured cases run; under any other env the suite asserts the stock defaults instead. + +#include "Channels.h" +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isBroadcast, etc.) +#include "NodeDB.h" // channelFile +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include +#include +#include + +#if defined(ARCH_PORTDUINO) +#define UPC_TEST_ENTRY extern "C" +#else +#define UPC_TEST_ENTRY +#endif + +// The well-known default PSK in its 1-byte short form, i.e. what initDefaultChannel() leaves on an +// index no userPref touched. +static const uint8_t kDefaultPskShortForm = 0x01; + +void setUp(void) +{ + memset(&channelFile, 0, sizeof(channelFile)); + channels.initDefaults(); +} + +void tearDown(void) {} + +static void expectShortFormDefaultPsk(const meshtastic_ChannelSettings &s) +{ + TEST_ASSERT_EQUAL_UINT(1, s.psk.size); + TEST_ASSERT_EQUAL_UINT8(kDefaultPskShortForm, s.psk.bytes[0]); +} + +// An index initDefaultChannel() wrote but no userPref configured: default PSK, empty name, position +// sharing off, nothing muted, no MQTT. +static void expectStockChannel(uint8_t idx) +{ + const meshtastic_Channel &ch = channels.getByIndex(idx); + TEST_ASSERT_TRUE(ch.has_settings); + TEST_ASSERT_TRUE(ch.settings.has_module_settings); + expectShortFormDefaultPsk(ch.settings); + TEST_ASSERT_EQUAL_STRING("", ch.settings.name); + TEST_ASSERT_EQUAL_UINT(0, ch.settings.module_settings.position_precision); + TEST_ASSERT_FALSE(ch.settings.module_settings.is_muted); + TEST_ASSERT_FALSE(ch.settings.uplink_enabled); + TEST_ASSERT_FALSE(ch.settings.downlink_enabled); +} + +#ifdef USERPREFS_CHANNELS_TO_WRITE + +static const uint8_t kChannel0Psk[] = USERPREFS_CHANNEL_0_PSK; +static const uint8_t kChannel3Psk[] = USERPREFS_CHANNEL_3_PSK; + +// The bug this table replaces: USERPREFS_CHANNELS_TO_WRITE past 3 produced live secondary channels +// carrying the public default PSK instead of the vendor's, because initDefaultChannel()'s switch +// stopped at case 2. +static void test_index_beyond_two_gets_its_configured_psk() +{ + const meshtastic_ChannelSettings &s = channels.getByIndex(3).settings; + TEST_ASSERT_EQUAL_UINT(sizeof(kChannel3Psk), s.psk.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(kChannel3Psk, s.psk.bytes, sizeof(kChannel3Psk)); + TEST_ASSERT_EQUAL_STRING(USERPREFS_CHANNEL_3_NAME, s.name); +} + +static void test_index_zero_matches_its_userprefs() +{ + const meshtastic_ChannelSettings &s = channels.getByIndex(0).settings; + TEST_ASSERT_EQUAL_UINT(sizeof(kChannel0Psk), s.psk.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(kChannel0Psk, s.psk.bytes, sizeof(kChannel0Psk)); + TEST_ASSERT_EQUAL_STRING(USERPREFS_CHANNEL_0_NAME, s.name); + TEST_ASSERT_EQUAL_UINT(USERPREFS_CHANNEL_0_PRECISION, s.module_settings.position_precision); + TEST_ASSERT_TRUE(s.module_settings.is_muted); + TEST_ASSERT_TRUE(s.uplink_enabled); + TEST_ASSERT_FALSE(s.downlink_enabled); +} + +static void test_every_written_index_has_a_role() +{ + for (int i = 0; i < USERPREFS_CHANNELS_TO_WRITE; i++) { + const meshtastic_Channel &ch = channels.getByIndex(i); + TEST_ASSERT_TRUE(ch.has_settings); + TEST_ASSERT_TRUE(ch.settings.has_module_settings); + TEST_ASSERT_EQUAL(i == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY, ch.role); + } +} + +// Index 1 is inside CHANNELS_TO_WRITE but has no userPref of its own. +static void test_unconfigured_index_inside_range_is_stock() +{ + expectStockChannel(1); +} + +// Index 4 sets only a name upstream; the generator fills the rest with the values +// initDefaultChannel() would otherwise have left in place, so a partial config is not a way to +// accidentally ship uplink or a raised position precision. +static void test_partly_configured_index_keeps_firmware_defaults() +{ + const meshtastic_ChannelSettings &s = channels.getByIndex(4).settings; + TEST_ASSERT_EQUAL_STRING(USERPREFS_CHANNEL_4_NAME, s.name); + expectShortFormDefaultPsk(s); + TEST_ASSERT_EQUAL_UINT(0, s.module_settings.position_precision); + TEST_ASSERT_FALSE(s.module_settings.is_muted); + TEST_ASSERT_FALSE(s.uplink_enabled); + TEST_ASSERT_FALSE(s.downlink_enabled); +} + +// Indices past USERPREFS_CHANNELS_TO_WRITE are never handed to initDefaultChannel(), so they must +// stay zeroed rather than picking up a neighbour's key. +static void test_index_past_channels_to_write_is_untouched() +{ + for (int i = USERPREFS_CHANNELS_TO_WRITE; i < MAX_NUM_CHANNELS; i++) { + const meshtastic_ChannelSettings &s = channels.getByIndex(i).settings; + TEST_ASSERT_EQUAL_STRING("", s.name); + TEST_ASSERT_EQUAL_UINT(0, s.psk.size); + } +} + +// The switch this table replaces used strcpy() into a char[12]. +static void test_over_long_name_is_truncated_and_terminated() +{ + const meshtastic_ChannelSettings &s = channels.getByIndex(2).settings; + TEST_ASSERT_TRUE(strlen(USERPREFS_CHANNEL_2_NAME) >= sizeof(s.name)); + TEST_ASSERT_EQUAL_UINT(sizeof(s.name) - 1, strlen(s.name)); + TEST_ASSERT_EQUAL_CHAR('\0', s.name[sizeof(s.name) - 1]); + TEST_ASSERT_EQUAL_MEMORY(USERPREFS_CHANNEL_2_NAME, s.name, sizeof(s.name) - 1); +} + +#else // no channel userPrefs: the baseline the table must not have moved + +static void test_stock_build_writes_only_index_zero() +{ + expectStockChannel(0); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, channels.getByIndex(0).role); + for (int i = 1; i < MAX_NUM_CHANNELS; i++) { + const meshtastic_ChannelSettings &s = channels.getByIndex(i).settings; + TEST_ASSERT_EQUAL_STRING("", s.name); + TEST_ASSERT_EQUAL_UINT(0, s.psk.size); + } +} + +static void test_stock_build_fills_the_whole_table() +{ + TEST_ASSERT_EQUAL_UINT(MAX_NUM_CHANNELS, channelFile.channels_count); +} + +#endif + +UPC_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + +#ifdef USERPREFS_CHANNELS_TO_WRITE + printf("\n=== channel table beyond index 2 ===\n"); + RUN_TEST(test_index_beyond_two_gets_its_configured_psk); + RUN_TEST(test_index_zero_matches_its_userprefs); + RUN_TEST(test_every_written_index_has_a_role); + + printf("\n=== omitted fields and bounds ===\n"); + RUN_TEST(test_unconfigured_index_inside_range_is_stock); + RUN_TEST(test_partly_configured_index_keeps_firmware_defaults); + RUN_TEST(test_index_past_channels_to_write_is_untouched); + RUN_TEST(test_over_long_name_is_truncated_and_terminated); +#else + printf("\n=== stock defaults (no channel userPrefs) ===\n"); + RUN_TEST(test_stock_build_writes_only_index_zero); + RUN_TEST(test_stock_build_fills_the_whole_table); +#endif + + exit(UNITY_END()); +} + +UPC_TEST_ENTRY void loop() {} diff --git a/test/test_userprefs_channels/userprefs_fixture.h b/test/test_userprefs_channels/userprefs_fixture.h new file mode 100644 index 0000000000..a8d6c3f9bb --- /dev/null +++ b/test/test_userprefs_channels/userprefs_fixture.h @@ -0,0 +1,50 @@ +// What bin/platformio-custom.py emits for a userPrefs.jsonc configuring five channels. -include'd +// rather than passed as -D flags, which would overrun the Windows command-line limit. + +#pragma once + +#define USERPREFS_CHANNELS_TO_WRITE 5 + +// Every field set, with is_muted and uplink on but downlink off, so a test can tell an applied +// value from a zeroed one. +#define USERPREFS_CHANNEL_0_PSK \ + { \ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 \ + } +#define USERPREFS_CHANNEL_0_NAME "Ops" +#define USERPREFS_CHANNEL_0_PRECISION 14 +#define USERPREFS_CHANNEL_0_IS_MUTED true +#define USERPREFS_CHANNEL_0_UPLINK_ENABLED true +#define USERPREFS_CHANNEL_0_DOWNLINK_ENABLED false + +// Name longer than ChannelSettings.name (char[12]); the switch this table replaces used strcpy(). +#define USERPREFS_CHANNEL_2_PSK \ + { \ + 1 \ + } +#define USERPREFS_CHANNEL_2_NAME "LongerThanTwelve" +#define USERPREFS_CHANNEL_2_PRECISION 0 +#define USERPREFS_CHANNEL_2_IS_MUTED false +#define USERPREFS_CHANNEL_2_UPLINK_ENABLED false +#define USERPREFS_CHANNEL_2_DOWNLINK_ENABLED false + +// The index the old switch could not reach at all. +#define USERPREFS_CHANNEL_3_PSK \ + { \ + 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 \ + } +#define USERPREFS_CHANNEL_3_NAME "Three" +#define USERPREFS_CHANNEL_3_PRECISION 0 +#define USERPREFS_CHANNEL_3_IS_MUTED false +#define USERPREFS_CHANNEL_3_UPLINK_ENABLED false +#define USERPREFS_CHANNEL_3_DOWNLINK_ENABLED false + +#define USERPREFS_CHANNEL_4_PSK \ + { \ + 1 \ + } +#define USERPREFS_CHANNEL_4_NAME "Four" +#define USERPREFS_CHANNEL_4_PRECISION 0 +#define USERPREFS_CHANNEL_4_IS_MUTED false +#define USERPREFS_CHANNEL_4_UPLINK_ENABLED false +#define USERPREFS_CHANNEL_4_DOWNLINK_ENABLED false diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp index ebf47be9b2..b29c55341a 100644 --- a/test/test_utf8/test_main.cpp +++ b/test/test_utf8/test_main.cpp @@ -3,6 +3,7 @@ // would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup // would add portduino globals it otherwise never touches. Suite-level state cleanliness is still // checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. +#include "WaypointUtils.h" #include "meshUtils.h" #include #include @@ -168,6 +169,19 @@ void test_above_max_codepoint() TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); } +void test_waypoint_codepoint_encoding() +{ + TEST_ASSERT_EQUAL_STRING("A", WaypointUtils::utf8FromCodepoint('A').c_str()); + TEST_ASSERT_EQUAL_STRING("\xF0\x9F\x93\x8D", WaypointUtils::utf8FromCodepoint(0x1F4CD).c_str()); +} + +void test_waypoint_codepoint_rejects_invalid_scalars() +{ + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0).empty()); + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0xD800).empty()); + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0x110000).empty()); +} + // --- clampLongName: local 24-byte cap over wider wire buffers --- void test_clamp_long_name_short_unchanged() @@ -236,6 +250,8 @@ void setup() RUN_TEST(test_zero_size); RUN_TEST(test_valid_max_codepoint); RUN_TEST(test_above_max_codepoint); + RUN_TEST(test_waypoint_codepoint_encoding); + RUN_TEST(test_waypoint_codepoint_rejects_invalid_scalars); // clampLongName RUN_TEST(test_clamp_long_name_short_unchanged); diff --git a/test/test_waypoint_expiry/test_main.cpp b/test/test_waypoint_expiry/test_main.cpp new file mode 100644 index 0000000000..b765207199 --- /dev/null +++ b/test/test_waypoint_expiry/test_main.cpp @@ -0,0 +1,114 @@ +// Unit tests for waypointIsActive() and its caller WaypointStore::isExpired(): the expire == 0 and +// expire == 1 sentinels, ordinary expiry, and an untrusted clock. +#include "TestUtil.h" +#include "WaypointStore.h" +#include "meshUtils.h" +#include +#include + +namespace +{ +constexpr uint32_t NOW = 1767225600; // 2026-01-01 +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +void test_zero_never_expires() +{ + TEST_ASSERT_TRUE(waypointIsActive(0, NOW)); +} + +void test_one_is_the_delete_convention() +{ + TEST_ASSERT_FALSE(waypointIsActive(1, NOW)); +} + +void test_future_expiry_is_active() +{ + TEST_ASSERT_TRUE(waypointIsActive(NOW + 3600, NOW)); +} + +void test_past_expiry_is_not_active() +{ + TEST_ASSERT_FALSE(waypointIsActive(NOW - 1, NOW)); +} + +void test_expiry_exactly_now_is_not_active() +{ + TEST_ASSERT_FALSE(waypointIsActive(NOW, NOW)); +} + +void test_int32_max_is_active() +{ + // What Android sends when its expiry switch is off; it is 2038, so it must simply compare future. + TEST_ASSERT_TRUE(waypointIsActive(INT32_MAX, NOW)); +} + +void test_untrusted_clock_expires_nothing() +{ + TEST_ASSERT_TRUE(waypointIsActive(NOW - 3600, 0)); + TEST_ASSERT_TRUE(waypointIsActive(NOW + 3600, 0)); + TEST_ASSERT_TRUE(waypointIsActive(0, 0)); +} + +void test_untrusted_clock_still_honours_delete() +{ + TEST_ASSERT_FALSE(waypointIsActive(1, 0)); +} + +// The production caller: an explicit now keeps these off the wall clock. +void test_store_expiry_matches_the_predicate() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + + wp.expire = 0; + TEST_ASSERT_FALSE(WaypointStore::isExpired(wp, NOW)); + wp.expire = 1; + TEST_ASSERT_TRUE(WaypointStore::isExpired(wp, NOW)); + wp.expire = NOW + 3600; + TEST_ASSERT_FALSE(WaypointStore::isExpired(wp, NOW)); + wp.expire = NOW - 1; + TEST_ASSERT_TRUE(WaypointStore::isExpired(wp, NOW)); +} + +// rx_time carries uptime rather than an epoch when has_rx_time is false (Router::computeRxTimeStamp), +// so an already-expired waypoint must still be rejected rather than compared against seconds of uptime. +void test_packet_without_rx_time_still_expires() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.id = 4242; + wp.expire = 1600000000; // September 2020 + + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = 0x11223344; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.payload.size = (uint16_t)pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Waypoint_msg, &wp); + packet.has_rx_time = false; + packet.rx_time = 300; // uptime seconds, not an epoch + + waypointStore.clearAllWaypoints(); + TEST_ASSERT_TRUE(waypointStore.addFromPacket(packet, false)); + TEST_ASSERT_NULL(waypointStore.findWaypoint(wp.id)); + waypointStore.clearAllWaypoints(); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_zero_never_expires); + RUN_TEST(test_one_is_the_delete_convention); + RUN_TEST(test_future_expiry_is_active); + RUN_TEST(test_past_expiry_is_not_active); + RUN_TEST(test_expiry_exactly_now_is_not_active); + RUN_TEST(test_int32_max_is_active); + RUN_TEST(test_untrusted_clock_expires_nothing); + RUN_TEST(test_untrusted_clock_still_honours_delete); + RUN_TEST(test_store_expiry_matches_the_predicate); + RUN_TEST(test_packet_without_rx_time_still_expires); + exit(UNITY_END()); +} + +void loop() {} diff --git a/userPrefs.jsonc b/userPrefs.jsonc index eb9ff3faf9..f50bbaf0f8 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -1,28 +1,49 @@ { + // A value here is a FACTORY DEFAULT. It is applied on first boot and on factory reset, and never + // again - once the device has a saved config, that config wins. There is no per-field lock. To + // stop a user changing a setting, ship USERPREFS_USE_ADMIN_KEY_0 and set + // USERPREFS_CONFIG_SECURITY_IS_MANAGED. + // + // Numbers must be plain decimal or 0x-prefixed hex; a shift expression such as "(1 << 15)" is + // passed to the compiler as a string, not evaluated. + // + // Channels: indices 0 to 7 are all supported. Setting any USERPREFS_CHANNEL__* key configures + // index ; the fields you leave out keep the values the firmware would have used anyway (default + // PSK, empty name, precision 0, not muted, no uplink or downlink). USERPREFS_CHANNELS_TO_WRITE is + // how many indices are written on first boot and cannot exceed 8. // "USERPREFS_BUTTON_PIN": "36", // "USERPREFS_CHANNELS_TO_WRITE": "3", // "USERPREFS_CHANNEL_0_DOWNLINK_ENABLED": "false", + // "USERPREFS_CHANNEL_0_IS_MUTED": "false", // "USERPREFS_CHANNEL_0_NAME": "REPLACEME", // "USERPREFS_CHANNEL_0_PRECISION": "14", // "USERPREFS_CHANNEL_0_PSK": "{ 0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1 }", // "USERPREFS_CHANNEL_0_UPLINK_ENABLED": "true", // "USERPREFS_CHANNEL_1_DOWNLINK_ENABLED": "false", + // "USERPREFS_CHANNEL_1_IS_MUTED": "false", // "USERPREFS_CHANNEL_1_NAME": "NodeChat", // "USERPREFS_CHANNEL_1_PRECISION": "14", // "USERPREFS_CHANNEL_1_PSK": "{ 0x4e, 0x22, 0x1d, 0x8b, 0xc3, 0x09, 0x1b, 0xe2, 0x11, 0x9c, 0x89, 0x12, 0xf2, 0x25, 0x19, 0x5d, 0x15, 0x3e, 0x30, 0x7b, 0x86, 0xb6, 0xec, 0xc4, 0x6a, 0xc3, 0x96, 0x5e, 0x9e, 0x10, 0x9d, 0xd5 }", // "USERPREFS_CHANNEL_1_UPLINK_ENABLED": "false", // "USERPREFS_CHANNEL_2_DOWNLINK_ENABLED": "false", + // "USERPREFS_CHANNEL_2_IS_MUTED": "false", // "USERPREFS_CHANNEL_2_NAME": "YardSale", // "USERPREFS_CHANNEL_2_PRECISION": "14", // "USERPREFS_CHANNEL_2_PSK": "{ 0x15, 0x6f, 0xfe, 0x46, 0xd4, 0x56, 0x63, 0x8a, 0x54, 0x43, 0x13, 0xf2, 0xef, 0x6c, 0x63, 0x89, 0xf0, 0x06, 0x30, 0x52, 0xce, 0x36, 0x5e, 0xb1, 0xe8, 0xbb, 0x86, 0xe6, 0x26, 0x5b, 0x1d, 0x58 }", // "USERPREFS_CHANNEL_2_UPLINK_ENABLED": "false", + // "USERPREFS_CHANNEL_3_NAME": "Ops", + // "USERPREFS_CHANNEL_3_PSK": "{ 0x01 }", // "USERPREFS_CONFIG_GPS_MODE": "meshtastic_Config_PositionConfig_GpsMode_ENABLED", // "USERPREFS_CONFIG_LORA_IGNORE_MQTT": "true", + // "USERPREFS_CONFIG_LORA_CONFIG_OK_TO_MQTT": "false", // "USERPREFS_LORA_TX_DISABLED": "1", // If set, forces config.lora.tx_enabled=false during lora bootstrap // "USERPREFS_CONFIG_LORA_REGION": "meshtastic_Config_LoRaConfig_RegionCode_US", // "USERPREFS_CONFIG_OWNER_LONG_NAME": "My Long Name", // "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN", // "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted. + // "USERPREFS_CONFIG_DEVICE_REBROADCAST_MODE": "meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY", // NONE falls back to ALL on a router role + // "USERPREFS_CONFIG_DEVICE_NODE_INFO_BROADCAST_SECS": "10800", // Clamped to 3600 .. INT32_MAX + // "USERPREFS_CANNED_MESSAGES": "Hi|Bye|Yes|No|Ok", // "USERPREFS_EVENT_MODE": "1", // "USERPREFS_EVENT_MODE_HOP_LIMIT": "3", // Event-mode default and firmware-generated/relay hop cap (0-7; default 3) // "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL": "1", // Block location TX + discard inbound location on channels keyed with USERPREFS_CHANNEL_0_PSK. Defaults off, and must be set explicitly. @@ -50,6 +71,7 @@ // "USERPREFS_USE_ADMIN_KEY_0": "{ 0xcd, 0xc0, 0xb4, 0x3c, 0x53, 0x24, 0xdf, 0x13, 0xca, 0x5a, 0xa6, 0x0c, 0x0d, 0xec, 0x85, 0x5a, 0x4c, 0xf6, 0x1a, 0x96, 0x04, 0x1a, 0x3e, 0xfc, 0xbb, 0x8e, 0x33, 0x71, 0xe5, 0xfc, 0xff, 0x3c }", // "USERPREFS_USE_ADMIN_KEY_1": "{}", // "USERPREFS_USE_ADMIN_KEY_2": "{}", + // "USERPREFS_CONFIG_SECURITY_IS_MANAGED": "true", // Ignored unless an admin key is also set // "USERPREFS_OEM_TEXT": "Caterham Car Club", // "USERPREFS_OEM_FONT_SIZE": "0", // "USERPREFS_OEM_IMAGE_WIDTH": "50", @@ -74,15 +96,11 @@ // "USERPREFS_MESH_BEACON_OFFER_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_N_868", // Region advertised in the beacon payload // "USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME": "'MyChannel'", // Channel name advertised in the beacon payload // "USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK": "{ 0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1 }", // PSK for the offered channel (32-byte AES-256) - // "USERPREFS_MESH_BEACON_ON_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST", // Modem preset to use when transmitting beacons (radio temporarily switched for TX) - // "USERPREFS_MESH_BEACON_ON_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", // Region to use when transmitting beacons - // "USERPREFS_MESH_BEACON_ON_CHANNEL_NAME": "'LongFast'", // Channel name to use on the TX radio config - uses default if unset. - // "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK": "{ 0x01 }", // PSK for the TX channel (0x01 = Meshtastic default PSK) - // "USERPREFS_MESH_BEACON_ON_CHANNEL_NUM": "0", // LoRa channel/frequency slot to use on the TX radio config - zero is default, 20 is standard for US LongFast, etc. // "USERPREFS_MESH_BEACON_LEGACY_SPLIT": "true", // When both text and offer are present, split into a separate MESH_BEACON_APP (offer) and TEXT_MESSAGE_APP (text) for legacy client compatibility - // Multi-target broadcast: when any TARGET__* key is set, broadcast_targets overrides the - // single-target broadcast_on_* fields above. Each target transmits its own copy of the beacon. - // Up to 4 targets (0-3). Only TARGET_0 is used here; uncomment TARGET_1 to add a second preset. + // Broadcast targets: every beacon destination is a TARGET__* entry. With none set, the node + // beacons once on its running preset and region over the primary channel. Up to 4 targets (0-3); + // only TARGET_0 is used here, uncomment TARGET_1 to add a second preset. Targets that resolve to + // the same preset, region and channel are deduplicated. // CHANNEL_INDEX references a slot in the device's channel table (0..MAX_NUM_CHANNELS-1); that // channel must already be configured on the node (its key is needed to encrypt the beacon). // "USERPREFS_MESH_BEACON_TARGET_0_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST", diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index 723ecdf04b..0a1a3f41e7 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -13,4 +13,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index 16d562a90d..8ca9462a12 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -36,4 +36,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32/m5stack_coreink/platformio.ini b/variants/esp32/m5stack_coreink/platformio.ini index 9d31c50ed5..f727c6371d 100644 --- a/variants/esp32/m5stack_coreink/platformio.ini +++ b/variants/esp32/m5stack_coreink/platformio.ini @@ -20,8 +20,8 @@ lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 zinggjm/GxEPD2@1.6.9 - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip lib_ignore = m5stack-coreink monitor_filters = esp32_exception_decoder diff --git a/variants/esp32/tbeam/platformio.ini b/variants/esp32/tbeam/platformio.ini index 79277f840b..7669afc57f 100644 --- a/variants/esp32/tbeam/platformio.ini +++ b/variants/esp32/tbeam/platformio.ini @@ -31,4 +31,4 @@ lib_deps = # renovate: datasource=github-tags depName=meshtastic-st7796 packageName=meshtastic/st7796 https://github.com/meshtastic/st7796/archive/1.0.5.zip # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index 9a5f503210..f8a93460c9 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini index 92236f638d..53944e1f9d 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini @@ -28,4 +28,4 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 - https://github.com/meshtastic/ESP32-CH390/archive/v1.1.1.zip + https://github.com/meshtastic/ESP32-CH390/archive/v1.2.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-M5/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M5/platformio.ini index 17e2b68823..48fccb750b 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M5/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M5/platformio.ini @@ -35,5 +35,5 @@ lib_deps = ${esp32s3_base.lib_deps} https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip # renovate: datasource=custom.pio depName=PCA9557-arduino packageName=maxpromer/library/PCA9557-arduino maxpromer/PCA9557-arduino@1.0.0 - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 \ No newline at end of file + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini index c1a872f89e..9db318b15b 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -28,4 +28,4 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 - https://github.com/meshtastic/ESP32-CH390/archive/v1.1.1.zip + https://github.com/meshtastic/ESP32-CH390/archive/v1.2.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini index 7b43b40b5a..dd7dc4a09d 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini @@ -36,8 +36,8 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.26 - # renovate: datasource=custom depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip [env:thinknode_m9] extends = thinknode_m9_base @@ -91,9 +91,9 @@ build_flags = lib_deps = ${thinknode_m9_base.lib_deps} - https://github.com/meshtastic/device-ui/archive/e8a5ff337d1ead20b290307fb2159ad27fe47f86.zip ; PR314 input-policy + https://github.com/meshtastic/device-ui/archive/70a9967f202a69390460c908a1321e475d3d4cdf.zip ; PR314 input-policy https://github.com/mverch67/MultiFTPServer/archive/0e854335b9916ed9f2d3bcfe68975ce746992ccd.zip custom_sdkconfig = ${esp32s3_base.custom_sdkconfig} - ${device-ui_base.custom_sdkconfig} \ No newline at end of file + ${device-ui_base.custom_sdkconfig} diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index 800aafe8bc..2ce5951483 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -50,7 +50,7 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534 hideakitai/TCA9534@0.1.1 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 custom_sdkconfig = ${esp32s3_base.custom_sdkconfig} diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 4e38a34e77..c0e231903e 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -133,7 +133,7 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini index a984b9154d..d693cbce8e 100644 --- a/variants/esp32s3/heltec_v4_r8/platformio.ini +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -141,7 +141,7 @@ build_flags = lib_deps = ${heltec_v4_r8_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h b/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h index fb0744bc3b..42e57f9a69 100644 --- a/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h +++ b/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -88,6 +89,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h b/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h index a90500b150..60584ee58a 100644 --- a/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h +++ b/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h @@ -29,6 +29,7 @@ Different NicheGraphics UIs and different hardware variants will each have their #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -85,6 +86,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h b/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h index 9e84a541e1..d33db47f75 100644 --- a/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h +++ b/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -86,6 +87,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 771d070811..cf23f2813d 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -27,4 +27,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index 1a721a9bed..312fc6fe08 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index b86d606705..a6aafbac27 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini index dcd5973022..ca3f625ccd 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini +++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini @@ -16,7 +16,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver - https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.1.zip # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index c0b678615d..5a88b273d8 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/meshnology-w10/platformio.ini b/variants/esp32s3/meshnology-w10/platformio.ini index 73168ac569..5b0736b6a6 100644 --- a/variants/esp32s3/meshnology-w10/platformio.ini +++ b/variants/esp32s3/meshnology-w10/platformio.ini @@ -32,8 +32,11 @@ lib_deps = ; renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.21 ; PCF85063 RTC driver (PCF85063_RTC) + ; renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip + ; QMI8658 IMU driver ; renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 ; ES8311 audio codec + I2S notification tones (HAS_I2S) ; renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip diff --git a/variants/esp32s3/meshnology-w10/variant.h b/variants/esp32s3/meshnology-w10/variant.h index 232ec163db..6af82897eb 100644 --- a/variants/esp32s3/meshnology-w10/variant.h +++ b/variants/esp32s3/meshnology-w10/variant.h @@ -176,6 +176,9 @@ #define DAC_I2S_DIN 3 // pg3: record data (ES8311 -> ESP32) // AudioThread powers the NS4150 amp on/off around playback via this (opt-in) hook. #define AUDIO_AMP_ENABLE(on) mcpIoExpander.digitalWrite(EXIO_PA_CTRL, (on) ? HIGH : LOW) +// NS4150 wake-up, slower here because the enable is an I2C write to the expander, not a GPIO toggle. +// Without it the short system tones finish before the amp passes audio. +#define AUDIO_AMP_SETTLE_MS 250 // ─── On-board peripherals not wired up yet ──────────────────────────────────── // SHT41 temp/humidity (0x44) and QMI8658 IMU (0x6B): auto-detected on the I2C scan diff --git a/variants/esp32s3/mini-epaper-s3/nicheGraphics.h b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h index 0cbd6a1922..a330869d8e 100644 --- a/variants/esp32s3/mini-epaper-s3/nicheGraphics.h +++ b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/InkHUD/SystemApplet.h" // Shared NicheGraphics components @@ -67,6 +68,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet, false, false); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, false, false); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/mini-epaper-s3/platformio.ini b/variants/esp32s3/mini-epaper-s3/platformio.ini index e7daf63e09..3bd2032b26 100644 --- a/variants/esp32s3/mini-epaper-s3/platformio.ini +++ b/variants/esp32s3/mini-epaper-s3/platformio.ini @@ -32,8 +32,8 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-GxEPD2 packageName=https://github.com/meshtastic/GxEPD2 gitBranch=master https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip [env:mini-epaper-s3-inkhud] extends = esp32s3_base, inkhud @@ -51,5 +51,5 @@ build_flags = lib_deps = ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX ${esp32s3_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index 3fb177f3e2..23beed76d2 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index af5140102c..3acdf7b254 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/seeed_wio_tracker_L2/pins_arduino.h b/variants/esp32s3/seeed_wio_tracker_L2/pins_arduino.h new file mode 100644 index 0000000000..7237090ca2 --- /dev/null +++ b/variants/esp32s3/seeed_wio_tracker_L2/pins_arduino.h @@ -0,0 +1,15 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +static const uint8_t SDA = 47; +static const uint8_t SCL = 48; + +// Default SPI will be mapped to Radio +static const uint8_t SS = 21; +static const uint8_t MOSI = 6; +static const uint8_t MISO = 5; +static const uint8_t SCK = 4; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini new file mode 100644 index 0000000000..3f337f0c91 --- /dev/null +++ b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini @@ -0,0 +1,90 @@ +[env:seeed_wio_tracker_L2] +custom_meshtastic_hw_model = 137 +custom_meshtastic_hw_model_slug = SEEED_WIO_TRACKER_L2 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Seeed Wio Tracker L2 +custom_meshtastic_images = wio_tracker_l2_case.svg +custom_meshtastic_tags = Seeed +custom_meshtastic_requires_dfu = true + +extends = esp32s3_base +board_level = extra +board = seeed_wio_tracker_L2 +board_build.partitions = default_16MB.csv +upload_protocol = esptool + +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/seeed_wio_tracker_L2> + +build_flags = ${esp32s3_base.build_flags} + -I variants/esp32s3/seeed_wio_tracker_L2 + -D SEEED_WIO_TRACKER_L2 + -D CONFIG_ARDUHAL_ESP_LOG + -D CONFIG_ARDUHAL_LOG_COLORS=1 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D MESHTASTIC_EXCLUDE_WEBSERVER=1 + -D HAS_SCREEN=1 + -D HAS_SDCARD + -D HAS_SD_MMC ; SDIO mode 1-bit + -D SD_SCLK_PIN=2 + -D SD_MOSI_PIN=3 ; CMD + -D SD_MISO_PIN=1 ; D0 + -D SDCARD_CS=-1 + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.28 + # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix + https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip + # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM + earlephilhower/ESP8266SAM@1.1.0 + # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.1.zip + # renovate: datasource=github-tags depName=Adafruit_ADS1X15 packageName=adafruit/Adafruit_ADS1X15 + https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip + # renovate: datasource=github-tags depName=AW35615 packageName=meshtastic/AW35615 + https://github.com/mverch67/AW35615/archive/refs/tags/1.0.1.zip + +[env:seeed_wio_tracker_L2-tft] +extends = env:seeed_wio_tracker_L2 +board_level = release + +build_flags = ${env:seeed_wio_tracker_L2.build_flags} + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D INPUTDRIVER_BUTTON_TYPE=0 + -D LV_USE_LOG=0 + -D LV_BUILD_TEST=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D RADIOLIB_SPI_PARANOID=0 + -D HAS_TFT=1 + -D USE_I2S_BUZZER + -D RAM_SIZE=5120 + -D LGFX_BUFSIZE=153600 + -D LGFX_DRIVER=LGFX_WIO_TRACKER_L2 + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_WIO_TRACKER_L2.h\" + -D DISPLAY_SIZE=320x240 ; landscape mode + -D VIEW_320x240 + -D MAP_FULL_REDRAW + -D USE_PACKET_API + +lib_deps = + ${env:seeed_wio_tracker_L2.lib_deps} + ${device-ui_base.lib_deps} + +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + ${device-ui_base.custom_sdkconfig} + +; twice on purpose: the pre pass patches framework-espidf before the IDF-libs +; rebuild, the post pass re-patches the -libs headers a reinstall just wiped +extra_scripts = + ${esp32s3_base.extra_scripts} + pre:extra_scripts/esp32_fatfs_exfat.py + post:extra_scripts/esp32_fatfs_exfat.py diff --git a/variants/esp32s3/seeed_wio_tracker_L2/variant.h b/variants/esp32s3/seeed_wio_tracker_L2/variant.h new file mode 100644 index 0000000000..5ddad0db46 --- /dev/null +++ b/variants/esp32s3/seeed_wio_tracker_L2/variant.h @@ -0,0 +1,120 @@ +#pragma once + +#define LED_POWER 46 +#define LED_STATE_ON 1 + +#define BUTTON_PIN 0 +#define BUTTON_NEED_PULLUP + +#define I2C_SDA 47 +#define I2C_SCL 48 + +#define USE_POWERSAVE +#define SLEEP_TIME 120 + +#define HAS_ADS1115 +#define ADS1115_ADDR 0x48 + +// LED controller +#define HAS_LP5814 + +// ES8311 DAC / AMP +#define HAS_I2S +#define HAS_ES8311 +#define DAC_I2S_BCK 11 // SCLK +#define DAC_I2S_WS 12 // LRLK +#define DAC_I2S_DOUT 16 +#define DAC_I2S_DIN -1 +#define DAC_I2S_MCLK 10 + +#define HAS_ES7243E +#define ADC_I2S_BCK 11 +#define ADC_I2S_WS 12 +#define ADC_I2S_DOUT -1 +#define ADC_I2S_DIN 15 +#define ADC_I2S_MCLK 10 + +// External expansion chip TCA9555/PCA9555 +#define USE_PCA95X5 +#define PCA95X5_CLS Pca9555 +#define PCA95X5_INC "Pca9555.h" +#define BOARD_PCA9535_ADDR 0x21 +#define BOARD_PCA9535_INT 45 // wake from esp light sleep +// Button +#define EXPANDS_BTN_WAKE_UP (0) // INPUT +// I2C +#define EXPANDS_I2C_0_INT (1) // INPUT +// LED +#define EXPANDS_LED_USER (10) +// Display +#define EXPANDS_LCD_PWR_EN (5) +#define EXPANDS_LCD_RST (6) +#define EXPANDS_LCD_CS (4) +#define EXPANDS_TP_RST (8) +#define EXPANDS_TP_INT (3) // INPUT +// SD card +#define EXPANDS_SD_PWR_EN (14) +#define EXPANDS_SD_DETECT (2) // INPUT +// GNSS +#define EXPANDS_GNSS_PWR_EN (13) +#define EXPANDS_GNSS_RST (9) +// USB +#define EXPANDS_EXP_OTG_EN (11) +// Audio +#define EXPANDS_PA_PWR_EN (12) +#define AUDIO_AMP_SETTLE_MS 250 +#define AUDIO_AMP_ENABLE(on) \ + spiLock->lock(); \ + io.digitalWrite(EXPANDS_PA_PWR_EN, (on) ? HIGH : LOW); \ + spiLock->unlock(); + +// Battery +#define EXPANDS_BAT_ADC_EN (15) +// Grove +#define EXPANDS_GROVE_PWR_EN (7) + +// SX1262 LoRa Module Pins +#define USE_SX1262 +#define LORA_SCK 4 +#define LORA_MISO 5 +#define LORA_MOSI 6 +#define LORA_CS 21 +#define LORA_RESET 7 + +#define LORA_DIO1 9 +#define LORA_DIO0 -1 +#define LORA_DIO2 8 +#define LORA_DIO3 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +#define SX126X_DIO2_AS_RF_SWITCH + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// GPS L76KB +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define GPS_L76K +#ifdef GPS_L76K +#define GPS_TX_PIN 17 +#define GPS_RX_PIN 18 +#define HAS_GPS 1 +#define GPS_THREAD_INTERVAL 50 +#endif + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Display (NV3031B + QSPI via SPI3) - BaseUI adaptation +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#if HAS_TFT +#define HAS_SPI_TFT 1 +#define HAS_TOUCHSCREEN 1 +#define USE_TFTDISPLAY 1 // Enable legacy BaseUI TFTDisplay.cpp build +#define TFT_WIDTH 320 // Required by BaseUI setGeometry +#define TFT_HEIGHT 240 // Required by BaseUI setGeometry +#define USE_VIRTUAL_KEYBOARD 1 +#endif + +// Battery +#define OCV_ARRAY 4180, 4040, 3864, 3800, 3745, 3710, 3687, 3663, 3623, 3482, 3300 diff --git a/variants/esp32s3/t-connect-pro/platformio.ini b/variants/esp32s3/t-connect-pro/platformio.ini new file mode 100644 index 0000000000..378e12f941 --- /dev/null +++ b/variants/esp32s3/t-connect-pro/platformio.ini @@ -0,0 +1,37 @@ +; LilyGo T-Connect-Pro +[env:t-connect-pro] +custom_meshtastic_hw_model = 147 +custom_meshtastic_hw_model_slug = T_CONNECT_PRO +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = LILYGO T-Connect Pro +custom_meshtastic_tags = LilyGo +custom_meshtastic_partition_scheme = 16MB + +extends = esp32s3_base +board_level = release +board = t-connect-pro +board_build.partitions = default_16MB.csv +upload_protocol = esptool + +build_flags = + ${esp32s3_base.build_flags} + -I variants/esp32s3/t-connect-pro + -D T_CONNECT_PRO + -D HAS_UDP_MULTICAST=1 + -D RADIOLIB_EXCLUDE_SX127X=1 + -D RADIOLIB_EXCLUDE_SX128X=1 + -D RADIOLIB_EXCLUDE_LR11X0=1 + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.28 + # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.4.1 + +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + CONFIG_ETH_ENABLED=y + CONFIG_ARDUINO_SELECTIVE_Ethernet=y diff --git a/variants/esp32s3/t-connect-pro/variant.h b/variants/esp32s3/t-connect-pro/variant.h new file mode 100644 index 0000000000..73fb9a43ee --- /dev/null +++ b/variants/esp32s3/t-connect-pro/variant.h @@ -0,0 +1,75 @@ +// LilyGo T-Connect-Pro (ESP32-S3R8). LoRa, the ST7796 LCD and the W5500 share one SPI bus +// (SCK 12 / MISO 13 / MOSI 11), so every peripheral stays on SPI2_HOST. + +#define I2C_SDA 39 +#define I2C_SCL 40 + +#define BUTTON_PIN 0 // BOOT +#define BUTTON_NEED_PULLUP + +#define EXT_NOTIFY_OUT 8 // 10A relay + +#define GPS_DEFAULT_NOT_PRESENT 1 + +// ST7796 LCD, 2.33" 480x222 - the same panel as the T-Lora Pager +#define HAS_SPI_TFT 1 +#define ST7796_CS 21 +#define ST7796_RS 41 // DC +#define ST7796_SDA 11 // MOSI +#define ST7796_SCK 12 +#define ST7796_MISO 13 +#define ST7796_RESET -1 +#define ST7796_BUSY -1 +#define ST7796_BL 46 +#define ST7796_SPI_HOST SPI2_HOST +#define TFT_BL 46 +#define SPI_FREQUENCY 75000000 +#define SPI_READ_FREQUENCY 16000000 +#define TFT_WIDTH 222 +#define TFT_HEIGHT 480 +#define TFT_OFFSET_X 49 +#define TFT_OFFSET_Y 0 +// Landscape comes from TFTDisplay's default setRotation(3), which aligns the UI with the +// silkscreen - 180 degrees from LilyGo's test firmware. Deliberate, don't "fix" it. +#define TFT_OFFSET_ROTATION 0 +#define SCREEN_ROTATE +#define SCREEN_TRANSITION_FRAMERATE 30 +#define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness +#define USE_TFTDISPLAY 1 + +// CST226SE touch - driver in src/platform/extra_variants/tbeam_displayshield/variant.cpp +#define HAS_CST226SE 1 +#define HAS_TOUCHSCREEN 1 +#define VARIANT_TOUCHSCREEN 1 +#define USE_VIRTUAL_KEYBOARD 1 +#define TOUCH_RST 47 +#define SCREEN_TOUCH_INT 3 +#define ENABLE_TOUCH_INT + +// LoRa - HPD16A (SX1262) +#define USE_SX1262 + +#define LORA_SCK 12 +#define LORA_MISO 13 +#define LORA_MOSI 11 +#define LORA_CS 14 +#define LORA_RESET 42 +#define LORA_DIO1 45 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY 38 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// W5500 ethernet, sharing the LoRa SPI bus +#define HAS_ETHERNET 1 +#define USE_WS5500 1 +#define ETH_SHARED_SPI SPI + +#define ETH_CS_PIN 10 +#define ETH_INT_PIN 9 +#define ETH_RST_PIN 48 + +// Isolated connectors, for the Serial module: RS232 TX 4 / RX 5, RS485 TX 17 / RX 18, CAN TX 6 / RX 7. diff --git a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini index c5dc14a7cc..f1670b0023 100644 --- a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini +++ b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini @@ -15,6 +15,10 @@ board_level = release board = t-deck-pro upload_protocol = esptool +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/t-deck-pro-v1_1> + build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/t-deck-pro-v1_1 -D T_DECK_PRO diff --git a/variants/esp32s3/t-deck-pro-v1_1/variant.cpp b/variants/esp32s3/t-deck-pro-v1_1/variant.cpp new file mode 100644 index 0000000000..bb69a1d416 --- /dev/null +++ b/variants/esp32s3/t-deck-pro-v1_1/variant.cpp @@ -0,0 +1,14 @@ +#include "variant.h" +#include "Arduino.h" + +void earlyInitVariant() +{ + pinMode(LORA_EN, OUTPUT); + digitalWrite(LORA_EN, HIGH); + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, HIGH); + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); + pinMode(PIN_EINK_CS, OUTPUT); + digitalWrite(PIN_EINK_CS, HIGH); +} diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 1b443582f2..e9d0cd3418 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index ca9195786b..2246dd2df6 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,9 +22,11 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library adafruit/Adafruit DRV2605 Library@1.2.4 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix diff --git a/variants/esp32s3/t-watch-ultra/platformio.ini b/variants/esp32s3/t-watch-ultra/platformio.ini index 20702a2498..9e43f64887 100644 --- a/variants/esp32s3/t-watch-ultra/platformio.ini +++ b/variants/esp32s3/t-watch-ultra/platformio.ini @@ -24,17 +24,13 @@ custom_sdkconfig = CONFIG_LITTLEFS_MALLOC_STRATEGY_INTERNAL=y CONFIG_SPI_FLASH_SHARE_SPI1_BUS=y -build_flags = ${esp32_base.build_flags} -Ivariants/esp32s3/t-watch-ultra - ; Route flash reads through the cache/mmap path (esp_partition_read_mmap_wrap.c) - ; to dodge the IDF 5.5 manual-read regression on this board's flash. esp_flash_read - ; is wrapped too - nvs_flash reads through it directly, bypassing esp_partition_read. - -Wl,--wrap=esp_partition_read - -Wl,--wrap=esp_flash_read +; esp32s3_base, not esp32_base: the classic-ESP32 base adds -Wl,--wrap=memcpy/memset, +; whose cache probe reads a DPORT register the S3 does not map (see IramMemcpy.c). +build_flags = ${esp32s3_base.build_flags} -Ivariants/esp32s3/t-watch-ultra -D T_WATCH_ULTRA -D RADIOLIB_EXCLUDE_SX128X=1 -D RADIOLIB_EXCLUDE_SX127X=1 -D RADIOLIB_EXCLUDE_LR11X0=1 - -UMESHTASTIC_EXCLUDE_ACCELEROMETER -D HAS_SDCARD -D SDCARD_USE_SPI1 -D SD_SPI_FREQUENCY=75000000 @@ -50,7 +46,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=LovyanGFX packageName=lovyan03/LovyanGFX - https://github.com/lovyan03/LovyanGFX/archive/tags/1.2.27.zip + https://github.com/lovyan03/LovyanGFX/archive/1.2.28.zip # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library adafruit/Adafruit DRV2605 Library@1.2.4 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix @@ -58,7 +54,9 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM earlephilhower/ESP8266SAM@1.1.0 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.1 + lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip [env:t-watch-ultra-tft] board_level = extra @@ -98,4 +96,8 @@ build_flags = lib_deps = ${env:t-watch-ultra.lib_deps} - ${device-ui_base.lib_deps} \ No newline at end of file + ${device-ui_base.lib_deps} + +custom_sdkconfig = + ${env:t-watch-ultra.custom_sdkconfig} + ${device-ui_base.custom_sdkconfig} diff --git a/variants/esp32s3/t-watch-ultra/variant.h b/variants/esp32s3/t-watch-ultra/variant.h index 22f4f62d07..62af5168c7 100644 --- a/variants/esp32s3/t-watch-ultra/variant.h +++ b/variants/esp32s3/t-watch-ultra/variant.h @@ -42,7 +42,9 @@ #define SLEEP_TIME 120 // External expansion chip XL9555 -#define USE_XL9555 +#define USE_PCA95X5 +#define PCA95X5_CLS IoExpanderXL9555 +#define PCA95X5_INC "IoExpanderXL9555.hpp" // PCF85063 RTC Module #define PCF85063_RTC 0x51 diff --git a/variants/esp32s3/t5s3_epaper/nicheGraphics.h b/variants/esp32s3/t5s3_epaper/nicheGraphics.h index 032ea100ec..62c22f1ef9 100644 --- a/variants/esp32s3/t5s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/t5s3_epaper/nicheGraphics.h @@ -31,6 +31,7 @@ This is driven via the FastEPD library through the NicheGraphics ED047TC1 driver #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -83,6 +84,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // Not Active, not autoshown inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false); // Activated, not autoshown + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet, false, false); // Not Active, not autoshown inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false); // Activated, not autoshown inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // Not Active, not autoshown diff --git a/variants/esp32s3/t5s3_epaper/platformio.ini b/variants/esp32s3/t5s3_epaper/platformio.ini index d4ba001586..19361a4dd7 100644 --- a/variants/esp32s3/t5s3_epaper/platformio.ini +++ b/variants/esp32s3/t5s3_epaper/platformio.ini @@ -3,13 +3,21 @@ extends = esp32s3_base board = t5-epaper-s3 board_build.partitions = default_16MB.csv upload_protocol = esptool +custom_meshtastic_hw_model = 123 +custom_meshtastic_hw_model_slug = T5_S3_EPAPER_PRO +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_images = t5s3_epaper.svg +custom_meshtastic_tags = LilyGo +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB build_flags = -fno-strict-aliasing ${esp32s3_base.build_flags} -I variants/esp32s3/t5s3_epaper -D T5_S3_EPAPER_PRO -D USE_EINK -D USE_EINK_PARALLELDISPLAY - -D PRIVATE_HW -D TOUCH_THRESHOLD_X=40 -D TOUCH_THRESHOLD_Y=40 -D TIME_LONG_PRESS=500 @@ -23,7 +31,9 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip @@ -37,7 +47,11 @@ custom_sdkconfig = [env:t5s3_epaper_inkhud] extends = t5s3_epaper_base, inkhud -board_level = extra +board_level = release +custom_meshtastic_display_name = LilyGo T5 E-paper S3 Pro V2 InkHUD +custom_meshtastic_key = t5s3_epaper_inkhud +custom_meshtastic_variant = H752-01 (V2) InkHUD +custom_meshtastic_has_ink_hud = true build_flags = ${t5s3_epaper_base.build_flags} ${inkhud.build_flags} @@ -54,6 +68,9 @@ lib_deps = [env:t5s3-epaper-v1] ; H752 extends = t5s3_epaper_base board_level = release +custom_meshtastic_display_name = LilyGo T5 E-paper S3 Pro V1 +custom_meshtastic_key = t5s3-epaper-v1 +custom_meshtastic_variant = H752 (V1) build_flags = ${t5s3_epaper_base.build_flags} -D T5_S3_EPAPER_PRO_V1 @@ -62,6 +79,9 @@ build_flags = [env:t5s3-epaper-v2] ; H752-01 extends = t5s3_epaper_base board_level = release +custom_meshtastic_display_name = LilyGo T5 E-paper S3 Pro V2 +custom_meshtastic_key = t5s3-epaper-v2 +custom_meshtastic_variant = H752-01 (V2) build_flags = ${t5s3_epaper_base.build_flags} -D T5_S3_EPAPER_PRO_V2 diff --git a/variants/esp32s3/tbeam-s3-core/platformio.ini b/variants/esp32s3/tbeam-s3-core/platformio.ini index b0421855cd..f58d718603 100644 --- a/variants/esp32s3/tbeam-s3-core/platformio.ini +++ b/variants/esp32s3/tbeam-s3-core/platformio.ini @@ -18,8 +18,8 @@ board_build.partitions = default_8MB.csv lib_deps = ${esp32s3_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip build_flags = ${esp32s3_base.build_flags} diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index c753e8836d..1c494d8a4c 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,19 +33,19 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM earlephilhower/ESP8266SAM@1.1.0 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library adafruit/Adafruit DRV2605 Library@1.2.4 - # renovate: datasource=custom.pio depName=PCF8563 packageName=lewisxhe/library/PCF8563_Library - lewisxhe/PCF8563_Library@1.0.1 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver - https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.1.zip # TODO renovate https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip # TODO renovate diff --git a/variants/esp32s3/tlora-pager/variant.cpp b/variants/esp32s3/tlora-pager/variant.cpp index 7b0cbdfece..ddceee5555 100644 --- a/variants/esp32s3/tlora-pager/variant.cpp +++ b/variants/esp32s3/tlora-pager/variant.cpp @@ -1,6 +1,6 @@ #include "variant.h" -#include "ExtensionIOXL9555.hpp" -extern ExtensionIOXL9555 io; +#include "IoExpanderXL9555.hpp" +extern IoExpanderXL9555 io; void earlyInitVariant() { diff --git a/variants/esp32s3/tlora-pager/variant.h b/variants/esp32s3/tlora-pager/variant.h index 52a060dd10..53869947a3 100644 --- a/variants/esp32s3/tlora-pager/variant.h +++ b/variants/esp32s3/tlora-pager/variant.h @@ -88,7 +88,9 @@ #define NFC_CS 39 // External expansion chip XL9555 -#define USE_XL9555 +#define USE_PCA95X5 +#define PCA95X5_CLS IoExpanderXL9555 +#define PCA95X5_INC "IoExpanderXL9555.hpp" #define EXPANDS_DRV_EN (0) #define EXPANDS_AMP_EN (1) #define EXPANDS_KB_RST (2) diff --git a/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h b/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h index faa9a41b95..06db0f0896 100644 --- a/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h @@ -17,6 +17,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index 5acdd9130c..fbad6347d2 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -23,7 +23,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -50,7 +50,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index fb6988e711..f59048af09 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -37,7 +37,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 33e0a8b8bb..5645806321 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -26,7 +26,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/meshtastic/libch341-spi-userspace gitBranch=main https://github.com/meshtastic/libch341-spi-userspace/archive/03bf505d6e5904092c1c389c45b01098f7a302fe.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library @@ -49,6 +49,7 @@ build_flags_common = -fPIC -Isrc/platform/portduino -DRADIOLIB_EEPROM_UNSUPPORTED + -DMESHTASTIC_EXCLUDE_RTTTL ; No PWM/RTTTL ringtone playback support on this platform. -lpthread -lyaml-cpp -ljsoncpp diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 81b96f3e84..412a971b5e 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -144,11 +144,20 @@ test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s +; Channel-table userPrefs: initDefaults() must reach every index, not just 0-2. The fixture header +; stands in for what bin/platformio-custom.py emits for a vendor who configured five channels. +[env:coverage-channel-table] +extends = env:coverage +build_flags = ${env:coverage.build_flags} + -include test/test_userprefs_channels/userprefs_fixture.h +test_filter = + test_userprefs_channels + [env:coverage-event-policy] extends = env:coverage build_flags = ${env:coverage.build_flags} -DUSERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL=1 - -DUSERPREFS_CHANNEL_0_PSK='{0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f}' + -include test/support/userprefs_event_channel.h test_filter = test_position_precision test_event_channel_router @@ -284,6 +293,35 @@ build_flags = ${env:native-macos.build_flags} build_src_filter = ${env:native-macos.build_src_filter} lib_ignore = ${env:native-macos.lib_ignore} +; Native macOS MUI window. This is the normal meshtasticd application using +; the X11 driver via XQuartz; it intentionally has no simulator fixtures. +[env:native-macos-tft] +extends = env:native-macos +build_type = release +build_unflags = ${env:native-macos.build_unflags} +build_flags = ${env:native-macos.build_flags} + -I/opt/X11/include + -L/opt/X11/lib + !pkg-config --cflags --libs x11 --silence-errors || : + !pkg-config --cflags --libs libcurl --silence-errors || : + -DRAM_SIZE=16384 + -DUSE_X11=1 + -DHAS_TFT=1 + -DLV_CACHE_DEF_SIZE=6291456 + -DLV_BUILD_TEST=0 + -DLV_USE_LIBINPUT=0 + -DLV_LIBINPUT_XKB=0 + -DLV_LVGL_H_INCLUDE_SIMPLE + -DLV_CONF_INCLUDE_SIMPLE + -DUSE_LOG_DEBUG + -DLOG_DEBUG_INC=\"DebugConfiguration.h\" + -DUSE_PACKET_API + -DVIEW_320x240 +lib_deps = + ${native_base.lib_deps} + ${device-ui_base.lib_deps} +lib_ignore = ${portduino_base.lib_ignore} + ; --------------------------------------------------------------------------- ; Native build for Windows (x86_64) via the MSYS2 UCRT64 MinGW-w64 toolchain. ; Headless meshtasticd.exe running in SimRadio mode (`-s`). No BlueZ, libgpiod or @@ -362,6 +400,22 @@ lib_ignore = LovyanGFX Pine libch341-spi Userspace library +; Windows counterpart of [env:coverage-channel-table]; same flags, runnable on this host. +[env:native-windows-channel-table] +extends = env:native-windows +build_flags = ${env:native-windows.build_flags} + -include test/test_userprefs_channels/userprefs_fixture.h +test_filter = + test_userprefs_channels + +; Windows counterpart of [env:coverage-event-policy]; same flags, runnable on this host. +[env:native-windows-event-policy] +extends = env:native-windows +build_flags = ${env:native-windows.build_flags} + -DUSERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL=1 + -include test/support/userprefs_event_channel.h +test_filter = ${env:coverage-event-policy.test_filter} + ; --------------------------------------------------------------------------- ; WASM (Emscripten) - the portduino node compiled to WebAssembly, driving a real ; LoRa radio over WebUSB through a CH341 (src/platform/portduino/wasm/). The same @@ -419,6 +473,8 @@ build_flags = ${arduino_base.build_flags} -DMESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION=1 -DMESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 -DMESHTASTIC_EXCLUDE_STOREFORWARD=1 -DMESHTASTIC_EXCLUDE_SERIAL=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 -DMESHTASTIC_EXCLUDE_WAYPOINT=1 + ; No PWM/RTTTL ringtone playback support in the browser node. + -DMESHTASTIC_EXCLUDE_RTTTL=1 ; The firmware-specific emcc *link* settings (exported fns, runtime methods, the ; WebUSB Asyncify import seam, the ES-module factory name) can't ride in diff --git a/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h b/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h index 242e5ae495..da44eb47c5 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h +++ b/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -77,6 +78,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - diff --git a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini index 2b6b9aabfc..c41d415d9c 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini @@ -23,5 +23,5 @@ lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=custom.pio depName=nRF52_PWM packageName=khoih-prog/library/nRF52_PWM khoih-prog/nRF52_PWM@1.0.1 - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/nrf52840/ELECROW-ThinkNode-M4/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M4/platformio.ini index 4c6de0e4e7..654055aefc 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M4/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M4/platformio.ini @@ -12,5 +12,3 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ELECROW-ThinkNode-M4> lib_deps = ${nrf52840_base.lib_deps} - # renovate: datasource=custom.pio depName=PCF8563 packageName=lewisxhe/library/PCF8563_Library - lewisxhe/PCF8563_Library@1.0.1 diff --git a/variants/nrf52840/ELECROW-ThinkNode-M6/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M6/platformio.ini index 3166494ce5..60deb337b8 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M6/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M6/platformio.ini @@ -21,5 +21,5 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ELECROW-ThinkNode-M6> lib_deps = ${nrf52840_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini index fa1f3cf111..3ad343a4e3 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini @@ -35,7 +35,7 @@ lib_deps = https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip # renovate: datasource=custom.pio depName=nRF52_PWM packageName=khoih-prog/library/nRF52_PWM khoih-prog/nRF52_PWM@1.0.1 - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h index 0a01b613e9..37d0171563 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -74,6 +75,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, false, 2); // Default on tile 2 inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false, 1); // Default on tile 1 + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false, 0); // Default on tile 0 inkhud->addApplet("Heard", new InkHUD::HeardApplet, true); // Background diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index a72b8c61e6..6ac17146a4 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -17,6 +17,7 @@ build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo -D NRF52_PROMICRO_DIY -D EXCLUDE_EMOJI ; this variant builds 4 radio driver families and is the largest nrf52 image; emote bitmaps don't fit under the 0xEA000 warm-store cap + ; -D RADIOLIB_GODMODE=1 ; needed for some LR2021 items, but not enabled by default. build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> debug_tool = jlink diff --git a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini index 3ec2997797..65c5a6e23e 100644 --- a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini @@ -32,5 +32,4 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_mesh_node_t1> lib_deps = ${nrf52840_base.lib_deps} - lewisxhe/PCF8563_Library@^1.0.1 bodmer/TFT_eSPI@2.5.43 diff --git a/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h b/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h index ad17e74572..551f7f07b8 100644 --- a/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -75,6 +76,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, false, 2); // Default on tile 2 inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false, 1); // Default on tile 1 + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false, 0); // Default on tile 0 inkhud->addApplet("Heard", new InkHUD::HeardApplet, true); // Background diff --git a/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h b/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h index 187022ea71..02a59298ac 100644 --- a/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h b/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h index 0f41319167..c4e97827f1 100644 --- a/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/monteops_hw1/platformio.ini b/variants/nrf52840/monteops_hw1/platformio.ini index b14db28d11..685582589e 100644 --- a/variants/nrf52840/monteops_hw1/platformio.ini +++ b/variants/nrf52840/monteops_hw1/platformio.ini @@ -11,7 +11,7 @@ lib_deps = ${nrf52840_base.lib_deps} ${networking_base.lib_deps} # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip debug_tool = jlink ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) ;upload_protocol = jlink diff --git a/variants/nrf52840/nano-g2-ultra/platformio.ini b/variants/nrf52840/nano-g2-ultra/platformio.ini index ae7f7599a9..71f18eacc7 100644 --- a/variants/nrf52840/nano-g2-ultra/platformio.ini +++ b/variants/nrf52840/nano-g2-ultra/platformio.ini @@ -20,6 +20,6 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/nano-g2-ultra> lib_deps = ${nrf52840_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip ;upload_protocol = fs diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index 471db3c8d3..9192fc84a1 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -2,14 +2,14 @@ ; Instead of the standard nordicnrf52 platform, we use our fork which has our added variant files platform = # renovate: datasource=custom.pio depName=platformio/nordicnrf52 packageName=platformio/platform/nordicnrf52 - platformio/nordicnrf52@10.12.0 + platformio/nordicnrf52@11.0.0 extends = arduino_base platform_packages = ; our custom Git version with C++17 support in platform.txt # renovate: datasource=git-refs depName=meshtastic/Adafruit_nRF52_Arduino packageName=https://github.com/meshtastic/Adafruit_nRF52_Arduino gitBranch=master platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#0fd295f13203e93df19d578073646ec32f2bf45a ; Don't renovate toolchain-gccarmnoneeabi - platformio/toolchain-gccarmnoneeabi@~1.90301.0 + platformio/toolchain-gccarmnoneeabi@1.90301.200702 extra_scripts = ${env.extra_scripts} diff --git a/variants/nrf52840/r1-neo/platformio.ini b/variants/nrf52840/r1-neo/platformio.ini index 8ffd186305..805188a605 100644 --- a/variants/nrf52840/r1-neo/platformio.ini +++ b/variants/nrf52840/r1-neo/platformio.ini @@ -24,7 +24,7 @@ lib_deps = ${nrf52840_base.lib_deps} ${networking_base.lib_deps} # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=custom.pio depName=ArtronShop_RX8130CE packageName=artronshop/library/ArtronShop_RX8130CE diff --git a/variants/nrf52840/rak3401_lr2021/pa_table.h b/variants/nrf52840/rak3401_lr2021/pa_table.h new file mode 100644 index 0000000000..fd345ec3bd --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/pa_table.h @@ -0,0 +1,43 @@ +#pragma once +#include "RadioLib.h" + +// RAK13700 LF PA - mid fine-tune: 8 high (+0.7), 10 low (-0.5); rest within ~0.6 dB. +// Index = power_dBm + 9. paVal in 0.5 dB. +// HF (2.4 GHz) uses RadioLib default PA table (LORA_24 region caps at 10 dBm). + +static LR2021PaTableEntry_t lr2021_pa_table_lf[RADIOLIB_LR2021_PA_TABLE_LEN] = { + // clang-format off + { .paDutyCycle = 1, .paSlices = 1, .paVal = 8 }, // -9 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 1 }, // -8 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 3 }, // -7 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 5 }, // -6 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 13 }, // -5 + { .paDutyCycle = 2, .paSlices = 1, .paVal = 13 }, // -4 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 11 }, // -3 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 13 }, // -2 + { .paDutyCycle = 3, .paSlices = 1, .paVal = 12 }, // -1 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 18 }, // 0 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 18 }, // 1 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 16 }, // 2 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 20 }, // 3 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 22 }, // 4 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 22 }, // 5 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 24 }, // 6 + { .paDutyCycle = 1, .paSlices = 3, .paVal = 27 }, // 7 was 28 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 30 }, // 8 was 32; meas 8.7 want ~8 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 32 }, // 9 was 33 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 33 }, // 10 was 35; meas 11 want ~10 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 35 }, // 11 + { .paDutyCycle = 2, .paSlices = 3, .paVal = 35 }, // 12 + { .paDutyCycle = 2, .paSlices = 5, .paVal = 37 }, // 13 + { .paDutyCycle = 3, .paSlices = 2, .paVal = 38 }, // 14 + { .paDutyCycle = 3, .paSlices = 3, .paVal = 39 }, // 15 + { .paDutyCycle = 3, .paSlices = 6, .paVal = 38 }, // 16 + { .paDutyCycle = 4, .paSlices = 4, .paVal = 40 }, // 17 + { .paDutyCycle = 4, .paSlices = 5, .paVal = 41 }, // 18 + { .paDutyCycle = 4, .paSlices = 7, .paVal = 43 }, // 19 + { .paDutyCycle = 5, .paSlices = 4, .paVal = 44 }, // 20 + { .paDutyCycle = 5, .paSlices = 6, .paVal = 44 }, // 21 + { .paDutyCycle = 6, .paSlices = 7, .paVal = 44 }, // 22 + // clang-format on +}; diff --git a/variants/nrf52840/rak3401_lr2021/platformio.ini b/variants/nrf52840/rak3401_lr2021/platformio.ini new file mode 100644 index 0000000000..be70ebb9af --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/platformio.ini @@ -0,0 +1,37 @@ +; RAK3401 + RAK13700 (LR2021) - same WisBlock core as rak3401-1watt; LoRa is LR2021 +[env:rak3401-lr2021] +custom_meshtastic_hw_model = 117 +custom_meshtastic_hw_model_slug = RAK3401 +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = false +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = RAK3401 LR2021 +custom_meshtastic_images = rak3401.svg +custom_meshtastic_tags = RAK +custom_meshtastic_requires_dfu = true + +extends = nrf52840_base +board = wiscore_rak4631 +board_level = extra +board_check = true +build_flags = ${nrf52840_base.build_flags} + -Ivariants/nrf52840/rak3401_lr2021 + -D RAK_4631 + -D RAK3401 + -D PRIVATE_HW + -DRADIOLIB_EXCLUDE_SX126X=1 + -DRADIOLIB_EXCLUDE_SX128X=1 + -DRADIOLIB_EXCLUDE_SX127X=1 + -DRADIOLIB_EXCLUDE_LR11X0=1 +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak3401_lr2021> + +lib_deps = + ${nrf52840_base.lib_deps} + ${networking_base.lib_deps} + # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 + melopero/Melopero RV3028@1.2.0 + # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library + rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 + # renovate: datasource=custom.pio depName=RAK12035_SoilMoisture packageName=beegee-tokyo/library/RAK12035_SoilMoisture + beegee-tokyo/RAK12035_SoilMoisture@1.0.4 + # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main + https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip diff --git a/variants/nrf52840/rak3401_lr2021/rfswitch.h b/variants/nrf52840/rak3401_lr2021/rfswitch.h new file mode 100644 index 0000000000..a8bf36bc6b --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/rfswitch.h @@ -0,0 +1,22 @@ +#pragma once +#include "RadioLib.h" + +#ifndef LR20x0 +#define LR20x0 LR2021 +#endif + +// RAK13700: DIO7 = LF TX/RX (HIGH=RX, LOW=TX); DIO9 = HF TX/RX (HIGH=TX, LOW=RX) +static const uint32_t lr20x0_rfswitch_dio_pins[] = {RADIOLIB_LR2021_DIO7, RADIOLIB_LR2021_DIO9, RADIOLIB_NC, RADIOLIB_NC, + RADIOLIB_NC}; + +static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { + // clang-format off + // mode DIO7 DIO9 + {LR20x0::MODE_STBY, {LOW, LOW}}, + {LR20x0::MODE_RX, {HIGH, LOW}}, // LF RX + {LR20x0::MODE_TX, {LOW, LOW}}, // LF TX + {LR20x0::MODE_RX_HF, {LOW, LOW}}, // HF RX + {LR20x0::MODE_TX_HF, {LOW, HIGH}}, // HF TX + END_OF_MODE_TABLE, + // clang-format on +}; diff --git a/variants/nrf52840/rak3401_lr2021/variant.cpp b/variants/nrf52840/rak3401_lr2021/variant.cpp new file mode 100644 index 0000000000..414f52faf9 --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/variant.cpp @@ -0,0 +1,39 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + // P0 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; + +void initVariant() +{ + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); + + // WisBlock 3V3_S - required for IO-slot RAK13700 + pinMode(PIN_3V3_EN, OUTPUT); + digitalWrite(PIN_3V3_EN, HIGH); +} diff --git a/variants/nrf52840/rak3401_lr2021/variant.h b/variants/nrf52840/rak3401_lr2021/variant.h new file mode 100644 index 0000000000..c0cacf3ae6 --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/variant.h @@ -0,0 +1,207 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/* + RAK3401 (nRF52840 WisBlock Core) + RAK13700 (LR2021). + Core / GPS / I2C / battery / eink pin map aligned with rak3401_1watt; + radio macros are LR2021 instead of SX1262 (RAK13302). +*/ + +#ifndef _VARIANT_RAK3401_LR2021_ +#define _VARIANT_RAK3401_LR2021_ + +#define RAK4630 + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (6) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED1 (35) +#define LED_BLUE (36) +#define LED_GREEN PIN_LED1 +#define LED_NOTIFICATION LED_BLUE +#define LED_STATE_ON 1 + +/* + * Analog pins + */ +#define PIN_A0 (5) +#define PIN_A1 (31) +#define PIN_A2 (28) +#define PIN_A3 (29) +#define PIN_A4 (30) +#define PIN_A5 (31) +#define PIN_A6 (0xff) +#define PIN_A7 (0xff) + +static const uint8_t A0 = PIN_A0; +static const uint8_t A1 = PIN_A1; +static const uint8_t A2 = PIN_A2; +static const uint8_t A3 = PIN_A3; +static const uint8_t A4 = PIN_A4; +static const uint8_t A5 = PIN_A5; +static const uint8_t A6 = PIN_A6; +static const uint8_t A7 = PIN_A7; +#define ADC_RESOLUTION 14 + +// Other pins (same as rak3401_1watt) +#define WB_I2C1_SDA (13) // SENSOR_SLOT IO_SLOT +#define WB_I2C1_SCL (14) // SENSOR_SLOT IO_SLOT + +#define PIN_AREF (2) +#define PIN_NFC1 (9) +#define WB_IO5 PIN_NFC1 +#define WB_IO4 (4) +#define PIN_NFC2 (10) + +static const uint8_t AREF = PIN_AREF; + +/* + * Serial interfaces + */ +#define PIN_SERIAL1_RX (15) +#define PIN_SERIAL1_TX (16) + +// Connected to Jlink CDC +#define PIN_SERIAL2_RX (8) +#define PIN_SERIAL2_TX (6) + +/* + * SPI Interfaces + */ +#define SPI_INTERFACES_COUNT 2 + +#define PIN_SPI_MISO (45) +#define PIN_SPI_MOSI (44) +#define PIN_SPI_SCK (43) + +#define PIN_SPI1_MISO (29) +#define PIN_SPI1_MOSI (30) +#define PIN_SPI1_SCK (3) + +static const uint8_t SS = 42; +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +/* + * eink display pins - same mapping as rak3401_1watt. + * Note: CS/BUSY/SCLK/MOSI overlap IO-slot LoRa SPI pins; only safe when an + * e-ink module is present and firmware uses CS gating (same as 1watt). + */ +#define PIN_EINK_CS (0 + 26) +#define PIN_EINK_BUSY (0 + 4) +#define PIN_EINK_DC (0 + 17) +#define PIN_EINK_RES (-1) +#define PIN_EINK_SCLK (0 + 3) +#define PIN_EINK_MOSI (0 + 30) + +/* + * Wire Interfaces + */ +#define WIRE_INTERFACES_COUNT 1 +#define PIN_WIRE_SDA (WB_I2C1_SDA) +#define PIN_WIRE_SCL (WB_I2C1_SCL) + +// QSPI Pins / on-board flash +#define PIN_QSPI_SCK 3 +#define PIN_QSPI_CS 26 +#define PIN_QSPI_IO0 30 +#define PIN_QSPI_IO1 29 +#define PIN_QSPI_IO2 28 +#define PIN_QSPI_IO3 2 +#define EXTERNAL_FLASH_DEVICES IS25LP080D +#define EXTERNAL_FLASH_USE_QSPI + +/* + * RAK13700 (LR2021) on WisBlock IO slot + * GPIO numbers match RAK13302 on the same slot (rak3401_1watt). + */ +#define HW_SPI1_DEVICE 1 + +#define LORA_SCK PIN_SPI1_SCK +#define LORA_MISO PIN_SPI1_MISO +#define LORA_MOSI PIN_SPI1_MOSI +#define LORA_CS 26 +#define LORA_RESET 4 +#define LORA_DIO1 10 // IRQ (module DIO8) +#define LORA_DIO2 9 // BUSY + +#define USE_LR2021 +#define LR2021_IRQ_PIN LORA_DIO1 +#define LR2021_NRESET_PIN LORA_RESET +#define LR2021_BUSY_PIN LORA_DIO2 +#define LR2021_SPI_NSS_PIN LORA_CS +#define LR2021_SPI_SCK_PIN LORA_SCK +#define LR2021_SPI_MOSI_PIN LORA_MOSI +#define LR2021_SPI_MISO_PIN LORA_MISO +#define LR2021_IRQ_DIO_NUM 8 + +// Stable default on current samples; see docs/lr2021_tcxo_calibrate_707.md. +#define LR2021_DIO3_TCXO_VOLTAGE 1.6f +#define LR2021_DIO_AS_RF_SWITCH +#define LR2021_CUSTOM_PA_TABLE // board LF PA table - see pa_table.h + +// UNCERTAIN: same as RAK13302 SX126X_POWER_EN (WB IO3) +#define LR2021_POWER_EN (21) +#define LR2021_MAX_POWER 22 +#define LR2021_MAX_POWER_HF 12 + +#define NRF_APM + +// enables 3.3V periphery like GPS or IO Module +#define PIN_3V3_EN (34) +#define WB_IO2 PIN_3V3_EN + +// RAK1910 GPS on Port A (UART1); power stays on 3V3_S (WB_IO2) - do not use as GPS reset +#define PIN_GPS_PPS (17) +#define GPS_RX_PIN PIN_SERIAL1_RX +#define GPS_TX_PIN PIN_SERIAL1_TX + +// RAK12002 RTC Module +#define RV3028_RTC (uint8_t)0b1010010 + +// Battery +#define BATTERY_PIN PIN_A0 +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER 1.73 + +#define RAK_4631 1 + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/variants/nrf52840/rak4631_eth_gw/platformio.ini b/variants/nrf52840/rak4631_eth_gw/platformio.ini index 8c4ece2c44..4e422e3fcd 100644 --- a/variants/nrf52840/rak4631_eth_gw/platformio.ini +++ b/variants/nrf52840/rak4631_eth_gw/platformio.ini @@ -37,7 +37,7 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main diff --git a/variants/nrf52840/rak_wismeshtap/platformio.ini b/variants/nrf52840/rak_wismeshtap/platformio.ini index 46d6b5161d..bbf700c1fc 100644 --- a/variants/nrf52840/rak_wismeshtap/platformio.ini +++ b/variants/nrf52840/rak_wismeshtap/platformio.ini @@ -32,7 +32,7 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=custom.pio depName=TFT_eSPI packageName=bodmer/library/TFT_eSPI diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h b/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h index 2a2967f5ec..f118651e67 100644 --- a/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h +++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h @@ -17,6 +17,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -74,6 +75,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/t-echo-plus/nicheGraphics.h b/variants/nrf52840/t-echo-plus/nicheGraphics.h index 73067d7a75..1d408f40be 100644 --- a/variants/nrf52840/t-echo-plus/nicheGraphics.h +++ b/variants/nrf52840/t-echo-plus/nicheGraphics.h @@ -13,6 +13,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/InkHUD/InkHUD.h" #include "graphics/niche/Inputs/TwoButton.h" @@ -44,6 +45,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); diff --git a/variants/nrf52840/t-echo-plus/platformio.ini b/variants/nrf52840/t-echo-plus/platformio.ini index 8a567061f4..765c53736a 100644 --- a/variants/nrf52840/t-echo-plus/platformio.ini +++ b/variants/nrf52840/t-echo-plus/platformio.ini @@ -32,7 +32,5 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/t-echo- lib_deps = ${nrf52840_base.lib_deps} https://github.com/meshtastic/GxEPD2/archive/55f618961db45a23eff0233546430f1e5a80f63a.zip - # renovate: datasource=custom.pio depName=PCF8563 packageName=lewisxhe/library/PCF8563_Library - lewisxhe/PCF8563_Library@1.0.1 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library adafruit/Adafruit DRV2605 Library@1.2.4 diff --git a/variants/nrf52840/t-echo/nicheGraphics.h b/variants/nrf52840/t-echo/nicheGraphics.h index c0b24dea77..1f9873f054 100644 --- a/variants/nrf52840/t-echo/nicheGraphics.h +++ b/variants/nrf52840/t-echo/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -80,6 +81,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/t-echo/platformio.ini b/variants/nrf52840/t-echo/platformio.ini index fe166ccf7f..f2e1b107d6 100644 --- a/variants/nrf52840/t-echo/platformio.ini +++ b/variants/nrf52840/t-echo/platformio.ini @@ -31,8 +31,8 @@ lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-GxEPD2 packageName=https://github.com/meshtastic/GxEPD2 gitBranch=master https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip ;upload_protocol = fs [env:t-echo-inkhud] @@ -51,5 +51,5 @@ build_src_filter = lib_deps = ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX ${nrf52840_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/nrf52840/t-impulse-plus/variant.h b/variants/nrf52840/t-impulse-plus/variant.h index e3a6c7ee63..33cfaff247 100644 --- a/variants/nrf52840/t-impulse-plus/variant.h +++ b/variants/nrf52840/t-impulse-plus/variant.h @@ -157,6 +157,8 @@ static const uint8_t SCL = PIN_WIRE_SCL; // IMU (ICM20948 on Wire1) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #define HAS_ICM20948 +// D27, not (0 + 7): pinMode/attachInterrupt index g_ADigitalPinMap, where 7 is P1.13 (RF_VC1). +#define ICM_20948_INT_PIN D27 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Charger (SGM41562 on Wire1 @ 0x03) diff --git a/variants/nrf54l15/cpp_overrides/lfs_assert.h b/variants/nrf54l15/cpp_overrides/lfs_assert.h new file mode 100644 index 0000000000..8c53ba570a --- /dev/null +++ b/variants/nrf54l15/cpp_overrides/lfs_assert.h @@ -0,0 +1,18 @@ +#pragma once +// Force-included so littlefs v2 (nRF54L core) routes assertions and logs through Meshtastic +// (lfs_assert() in main-nrf52.cpp formats the filesystem on corruption). +#ifdef __cplusplus +extern "C" { +#endif +void lfs_assert(const char *reason); +void logLegacy(const char *level, const char *fmt, ...); +#ifdef __cplusplus +} +#endif +#define LFS_ASSERT(test) \ + if (!(test)) \ + lfs_assert(#test) +#define LFS_LOG_(level, fmt, ...) logLegacy(level, "lfs:%d: " fmt "%s\n", __LINE__, __VA_ARGS__) +#define LFS_DEBUG(...) LFS_LOG_("DEBUG", __VA_ARGS__, "") +#define LFS_WARN(...) LFS_LOG_("WARN", __VA_ARGS__, "") +#define LFS_ERROR(...) LFS_LOG_("ERROR", __VA_ARGS__, "") diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 45e997271e..0c1fea75fb 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -1,87 +1,49 @@ [nrf54l15_base] -platform = https://github.com/Seeed-Studio/platform-seeedboards.git -; Pin the Zephyr package explicitly. Seeed's platform script only maps their -; own "seeed-xiao-*" board ids to a framework-zephyr package; any other board -; -- nrf54l15dk included -- falls back to whatever platform.json declares as -; the default, which is now framework-zephyr-nrf54lm20 (Zephyr 4.4.0). Its -; west manifest pulls a CMSIS_6 whose cmsis_gcc.h calls the ACLE builtins -; __sxtb16/__sxtab16, and none of the GCC ARM toolchains PlatformIO ships -; (8.2.1/9.2.1/9.3.1) declare them in arm_acle.h. In C that is only an -; implicit-declaration warning, so the pure-C Zephyr core never notices; in -; C++ it is a hard error, and any .cpp pulling in zephyr/kernel.h hits it. -; Without the pin a fresh package cache breaks this build with nothing in the -; tree having changed. -platform_packages = - platformio/framework-zephyr-nrf54lm20@https://dl.registry.platformio.org/download/platformio/tool/framework-zephyr/3.40201.251021/framework-zephyr-3.40201.251021.tar.gz -framework = zephyr +; Out-of-tree platform (meshtastic/platform-nordicnrf54) with the s145 SoftDevice Arduino core; +; the platform pulls the Arduino core and the DFU bootloader from their own repositories. +platform = https://github.com/meshtastic/platform-nordicnrf54.git#v0.3.1 extends = arduino_base build_type = release build_flags = + -include variants/nrf54l15/cpp_overrides/lfs_assert.h ${arduino_base.build_flags} - -Isrc/platform/nrf54l15 + -Wno-unused-variable + -Isrc/platform/nrf52 + ; The nRF54L port rides on the nRF52 platform layer; ARCH_NRF54L marks the few divergent spots. + -DARCH_NRF54L + -DPRIVATE_HW + -DLOOP_STACK_SZ=2048 + -DCFG_BLE_TASK_STACKSIZE=2048 + -DNRF_USE_SERIAL_DFU -DMESHTASTIC_EXCLUDE_AUDIO=1 - -DMESHTASTIC_EXCLUDE_GPS=1 - -DMESHTASTIC_EXCLUDE_MQTT=1 - -DHAS_WIRE=1 - -DHAS_SENSOR=1 - -DHAS_BUTTON=0 - -DHAS_TELEMETRY=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 - -DARDUINO=100 - -DMESHTASTIC_EXCLUDE_ACCELEROMETER=1 - -DMAX_NUM_NODES=40 - -fpermissive - # Libraries that Zephyr LDF misses; add include paths explicitly - -I.pio/libdeps/${PIOENV}/Crypto - -I.pio/libdeps/${PIOENV}/ArduinoThread - -I".pio/libdeps/${PIOENV}/ESP8266 and ESP32 OLED driver for SSD1306 displays/src" - -I.pio/libdeps/${PIOENV}/OneButton/src - -I.pio/libdeps/${PIOENV}/arduino-fsm - -I.pio/libdeps/${PIOENV}/TinyGPSPlus/src - -I.pio/libdeps/${PIOENV}/ErriezCRC32/src - -I.pio/libdeps/${PIOENV}/NonBlockingRTTTL/src - -I.pio/libdeps/${PIOENV}/RadioLib/src + -DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1 + -DOLEDDISPLAY_REDUCE_MEMORY + -Os + -std=gnu++17 +build_unflags = + -Ofast + -Og + -ggdb3 + -ggdb2 + -g3 + -g2 + -g + -g1 + -g0 + -std=c++11 + -std=gnu++11 build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - - - - - - - - - - - - - + - -lib_compat_mode = off + ${arduino_base.build_src_filter} + - - - - - - - - - - lib_deps = ${arduino_base.lib_deps} ${radiolib_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip - ; Cherry-picked sensor libs from environmental_base. The full - ; environmental_base pulls Adafruit_SSD1306 / GFX which need Arduino - ; pin macros (digitalPinToPort / portOutputRegister) that the Zephyr - ; Arduino shim does not implement. - https://github.com/adafruit/Adafruit_BusIO/archive/refs/tags/1.17.4.zip - https://github.com/adafruit/Adafruit_Sensor/archive/refs/tags/1.1.15.zip - https://github.com/adafruit/Adafruit_BMP280_Library/archive/refs/tags/3.0.0.zip - https://github.com/adafruit/Adafruit_BME280_Library/archive/refs/tags/2.3.0.zip - https://github.com/adafruit/Adafruit_INA260/archive/refs/tags/1.5.3.zip - https://github.com/adafruit/Adafruit_INA219/archive/refs/tags/1.2.3.zip - https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip - https://github.com/RobTillaart/INA226/archive/refs/tags/0.6.6.zip - ; SHTXXSensor gates on __has_include(), a header shipped by - ; Sensirion/arduino-sht. Adafruit_SHT4X ships Adafruit_SHT4X.h instead and - ; has no consumer in src/, so it left the SHT40 driver out of the build. - https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip lib_ignore = BluetoothOTA lvgl - Adafruit_nRFCrypto diff --git a/variants/nrf54l15/nrf54l15dk/README.md b/variants/nrf54l15/nrf54l15dk/README.md deleted file mode 100644 index 053fddff45..0000000000 --- a/variants/nrf54l15/nrf54l15dk/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# nRF54L15-DK - EBYTE E22-900M30S Wiring Guide - -Board: **Nordic nRF54L15-DK (PCA10156)** -Radio: **EBYTE E22-900M30S** (SX1262, 30 dBm, 868/915 MHz) - ---- - -## Why P2 (HP domain) and not P1 - -The nRF54L15 splits its GPIOs across three supply domains: - -- **P0** - Main domain, **3.0 V** - usable -- **P1** - LP domain, **1.8 V** - **not compatible** with the SX1262 -- **P2** - HP domain, **3.0 V** - usable - -The SX1262 requires VIH ≥ 0.7 × VDD (≈ 2.31 V at VDD = 3.3 V). P1's 1.8 V output -leaves the chip stuck in reset with `BUSY` never going LOW. All E22 signals -therefore live on **P2** and are driven by **SPIM00**. - -> `P2.09` is normally wired to LED0 on the DK; we ignore the LED and use -> SPIM00's default MISO pin. The on-board MX25R64 NOR flash also sat on SPIM00 -> -> - it is deleted in the device-tree overlay to free the bus. - ---- - -## Connections - J2 header, P2 bank - -| E22-900M30S | GPIO | DK pin | Function | -| ----------- | ----- | ------ | ---------------------------------------------- | -| MISO | P2.04 | 36 | SPIM00 data in | -| NSS / CS | P2.05 | 37 | SPI chip-select (driven by RadioLib as a GPIO) | -| DIO1 | P2.06 | 38 | IRQ - modem interrupt (routed via gpiote30) | -| BUSY | P2.03 | 35 | Module busy (GPIO input) | -| NRESET | P2.00 | 32 | Module reset (GPIO output, active LOW) | -| RXEN | P2.07 | 39 | LNA enable - held HIGH via `SX126X_ANT_SW` | -| MOSI | P2.02 | 34 | SPIM00 data out | -| SCK | P2.01 | 33 | SPIM00 clock | -| GND | - | GND | Common ground | -| VCC | - | VDD | 3.3 V | - -> **Numbering convention**: `P0.n = n`, `P1.n = 16+n`, `P2.n = 32+n`. -> Example: `P2.04` → 32 + 4 = **36**. - ---- - -## DIO2 → TXEN bridge (required) - -The E22-900M30S does **not** connect DIO2 to TXEN internally. A physical bridge on the module is required: - -1. Locate the `DIO2` and `TXEN` pads on the underside of the E22 module. -2. Solder a wire bridge or a 0 Ω resistor between the two pads. -3. With this bridge, the SX1262 drives the PA automatically via `SX126X_DIO2_AS_RF_SWITCH`. - -Without this bridge the module **will not transmit** (PA is never enabled). - ---- - -## RXEN - LNA always on - -`RXEN` (P2.07) is held HIGH permanently via `SX126X_ANT_SW 39` in `variant.h`. -**Do not use** `SX126X_RXEN` - RadioLib would drive it LOW in IDLE state and -the LNA would stay disabled (radio deaf in RX). - ---- - -## Reserved DK pins - do not reuse - -| Pins | Reserved function | -| ----------- | -------------------------------------------------------- | -| P0.00-P0.03 | IMCU debug UART (uart30, J-Link VCOM - used by RTT host) | -| P0.04 | BTN3 | -| P1.00-P1.01 | 32 kHz crystal | -| P1.02-P1.03 | NFC antenna | -| P1.10 | LED1 (status LED - kept) | -| P1.13 | BTN0 (only remaining user button) | -| P1.14 | LED3 | -| P2.01-P2.05 | SPIM00 / E22 (see connection table above) | -| P2.08-P2.10 | Trace ETM / LED2 (avoid) | - ---- - -## Build and flash - -```bash -# Build -pio run -e nrf54l15dk - -# Flash (requires a J-Link connected via the DK's on-board IMCU) -pio run -e nrf54l15dk -t upload - -# Monitor RTT (channel 1 = Meshtastic logs) -JLinkRTTLogger -device nRF54L15_xxAA -if SWD -speed 4000 -RTTChannel 1 boot.log -``` - -Expected boot log: - -```text -*** Booting Zephyr OS build zephyr-v40201 *** -[nrf54l15] Reset cause: ... -[nrf54l15] B: calling setup() -INFO | ... SX1262 -INFO | ... lora.begin() = 0 ← RADIOLIB_ERR_NONE -[nrf54l15] C: setup() returned -``` - -If you see `Record critical error 3` (`NO_RADIO`), check: DIO2→TXEN bridge, -supply voltages (the E22 must see 3.0-3.3 V on P2, not 1.8 V), and SPI wiring -continuity. diff --git a/variants/nrf54l15/nrf54l15dk/platformio.ini b/variants/nrf54l15/nrf54l15dk/platformio.ini index 8f570f7448..52e25525be 100644 --- a/variants/nrf54l15/nrf54l15dk/platformio.ini +++ b/variants/nrf54l15/nrf54l15dk/platformio.ini @@ -11,14 +11,16 @@ custom_meshtastic_display_name = Nordic nRF54L15-DK extends = nrf54l15_base board = nrf54l15dk board_level = extra -debug_tool = jlink upload_protocol = jlink -board_runner_args_jlink = --device nRF54L15_xxAA --speed 4000 +debug_tool = jlink build_flags = ${nrf54l15_base.build_flags} -Ivariants/nrf54l15/nrf54l15dk -DNRF54L15_DK - -DMESHTASTIC_EXCLUDE_FILES_MANIFEST=1 + -DMESHTASTIC_EXCLUDE_GPS=1 + -DHAS_GPS=0 + -DHAS_SCREEN=0 + -DMESHTASTIC_EXCLUDE_SCREEN=1 build_src_filter = ${nrf54l15_base.build_src_filter} +<../variants/nrf54l15/nrf54l15dk> diff --git a/variants/nrf54l15/nrf54l15dk/variant.cpp b/variants/nrf54l15/nrf54l15dk/variant.cpp index b2982e7c55..2e82825ee0 100644 --- a/variants/nrf54l15/nrf54l15dk/variant.cpp +++ b/variants/nrf54l15/nrf54l15dk/variant.cpp @@ -1,9 +1,19 @@ #include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +// Arduino pin index == physical GPIO number: P0.n = n, P1.n = 32 + n, P2.n = 64 + n +const uint32_t g_ADigitalPinMap[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, + 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95}; void initVariant() { - // Minimal board init for nRF54L15-DK. - // GPIO/SPI peripheral setup is handled by the Zephyr device tree overlay - // (zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay). - // Add any board-level power sequencing here if needed. + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); + pinMode(PIN_LED2, OUTPUT); + ledOff(PIN_LED2); } diff --git a/variants/nrf54l15/nrf54l15dk/variant.h b/variants/nrf54l15/nrf54l15dk/variant.h index b8417b68b7..7d263cfd46 100644 --- a/variants/nrf54l15/nrf54l15dk/variant.h +++ b/variants/nrf54l15/nrf54l15dk/variant.h @@ -1,86 +1,88 @@ #pragma once /* - * Nordic nRF54L15-DK (PCA10156) - Meshtastic variant + * Nordic nRF54L15-DK (PCA10156) with an EBYTE E22-900M30S (SX1262) on the J2 header. * - * ── GPIO voltage domains ───────────────────────────────────────────────────── - * P0 (gpio0 @ 0x10A000) Main domain 3.0 V ← usable - * P1 (gpio1 @ 0xd8200 ) LP domain 1.8 V ← NOT compatible with E22 - * P2 (gpio2 @ 0x50400 ) HP domain 3.0 V ← usable + * This header shadows the framework's variants/nrf54l15dk/variant.h, so it carries the core + * pin table definitions as well. Arduino pin = physical GPIO: P0.n = n, P1.n = 32+n, P2.n = 64+n. * - * The SX1262 needs VIH ≥ 0.7 × VDD = 2.31 V (VDD = 3.3 V). - * P1 outputs only 1.8 V → chip stays in reset, BUSY never goes LOW. - * All E22 signals are therefore on P2 (3.0 V), driven by SPIM00. + * GPIO supply domains: P0 3.0 V, P1 1.8 V (too low for the SX1262), P2 3.0 V. + * Serial peripherals are port bound: SERIAL00 (UARTE00/SPIM00) -> P2, SERIAL2x -> P1, SERIAL30 -> P0. * - * EBYTE E22-900M30S (SX1262) wiring - J2 header, all P2: - * - * E22 pin GPIO pin# Notes - * ───────────────────────────────────────────────────────────────────── - * MISO → P2.04 36 SPIM00 data in - * NSS/CS → P2.05 37 SPI chip-select (RadioLib GPIO) - * DIO1 → P2.06 38 IRQ - interrupt via gpiote30 - * BUSY → P2.03 35 GPIO input - * NRESET → P2.00 32 GPIO output - * RXEN → P2.07 39 Held HIGH via ANT_SW (LNA always active) - * MOSI → P2.02 34 SPIM00 data out - * SCK → P2.01 33 SPIM00 clock - * - * DIO2 → TXEN bridge required on E22 module (solder bridge / wire). - * DIO3 drives TCXO reference (1.8 V). - * - * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. - * - * Reserved / do-not-use DK pins: - * P0.00-P0.02 IMCU VCOM TX/RX/RTS pads (uart30 disabled; pads idle) - * P0.03 I2C SDA (TWIM30) - sensor bus - * P0.04 I2C SCL (TWIM30) - sensor bus; SW3 button on this pad, - * DO NOT press SW3 while I2C is active - * P1.00-P1.01 32 kHz crystal - * P1.02-P1.03 NFC antenna - * P1.10 LED1 (status LED - keep) - * P1.13 BTN0 - main user button - * P1.14 LED3 - * P2.01-P2.05 SPIM00 / E22 (see above) - * P2.08-P2.10 Trace pins (avoid) + * E22 wiring (all P2, SPIM00): + * SCK P2.01, MOSI P2.02, BUSY P2.03, MISO P2.04, NSS P2.05, DIO1 P2.06, RXEN P2.07, NRESET P2.00 + * DIO2 -> TXEN bridge on the module, DIO3 drives the TCXO (1.8 V). */ -#ifndef NRF54L15_DK -#define NRF54L15_DK +#define VARIANT_MCK (128000000ul) +#define USE_LFXO + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { #endif -// ── SX1262 / E22-900M30S - all P2, HP domain (3.0 V) ──────────────────────── -#define USE_SX1262 -#define SX126X_CS 37 // P2.05 - chip-select -#define SX126X_DIO1 38 // P2.06 - IRQ (gpiote30 capable) -#define SX126X_BUSY 35 // P2.03 - BUSY -#define SX126X_RESET 32 // P2.00 - NRESET +#define PINS_COUNT (96) +#define NUM_DIGITAL_PINS (96) +#define NUM_ANALOG_INPUTS (8) +#define NUM_ANALOG_OUTPUTS (0) +#define ADC_RESOLUTION 14 -// RXEN (P2.07) held HIGH permanently - LNA always active. -// RadioLib must NOT toggle it; ANT_SW drives it HIGH before lora.begin(). -#define SX126X_ANT_SW 39 // P2.07 - RXEN driven HIGH at init +// LEDs (active low): LED1 P1.10 status, LED0 P2.09 +#define PIN_LED1 42 +#define PIN_LED2 73 +#define LED_BUILTIN PIN_LED1 +#define LED_STATE_ON 0 -// DIO2 controls TXEN via bridge on E22 module. -// DIO3 provides 1.8 V TCXO reference. -#define SX126X_DIO2_AS_RF_SWITCH -#define SX126X_DIO3_TCXO_VOLTAGE 1.8f - -// ── LEDs (active HIGH) ─────────────────────────────────────────────────────── -#define PIN_LED1 26 // P1.10 - LED1 (status LED, LP domain - output only, OK) -#define PIN_LED2 41 // P2.09 - LED0 on DK (remapped; P2.07 now used for RXEN) -#define LED_STATE_ON 1 - -// ── Buttons (active LOW, internal pull-up) ─────────────────────────────────── -// BTN1 (P1.09), BTN2 (P1.08) and BTN3 (P0.04) deleted from DTS - only BTN0 -// remains. BTN3's pad (P0.04) is now I2C SCL. -#define PIN_BUTTON1 29 // P1.13 - BTN0 +// BTN0 P1.13 (active low) +#define PIN_BUTTON1 45 #define BUTTON_NEED_PULLUP -// ── I2C bus (TWIM30, HP domain, 3.0 V) ────────────────────────────────────── -// SDA=P0.03, SCL=P0.04. Pinctrl + clock-frequency live in the board overlay. -// External 4.7 kΩ pull-ups required on both lines. Meshtastic's Arduino -// TwoWire layer (src/platform/nrf54l15/Wire.cpp) resolves the device at -// compile time via DT_NODELABEL(i2c30); these PIN_WIRE_* defines are kept -// for parity with the Arduino convention used by other variants. -#define PIN_WIRE_SDA 3 // P0.03 -#define PIN_WIRE_SCL 4 // P0.04 +// Serial1: VCOM0 of the on-board J-Link (UARTE20): TX P1.04, RX P1.05 +#define PIN_SERIAL1_RX 37 +#define PIN_SERIAL1_TX 36 +#define SERIAL1_UARTE NRF_UARTE20 +#define SERIAL1_IRQN SERIAL20_IRQn +#define SERIAL1_IRQ_HANDLER SERIAL20_IRQHandler + +// Serial2 (UARTE21, serial module): RX P1.15, TX P1.16; only P1 pins can be assigned to it +#define PIN_SERIAL2_RX 47 +#define PIN_SERIAL2_TX 48 +#define SERIAL2_UARTE NRF_UARTE21 +#define SERIAL2_IRQN SERIAL21_IRQn +#define SERIAL2_IRQ_HANDLER SERIAL21_IRQHandler + +// SPI (SPIM00) for the E22 +#define SPI_INTERFACES_COUNT 1 +#define PIN_SPI_MISO 68 +#define PIN_SPI_MOSI 66 +#define PIN_SPI_SCK 65 +static const uint8_t SS = 69; +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +// I2C (TWIM30): SDA P0.03, SCL P0.04, external 4.7k pull-ups required #define WIRE_INTERFACES_COUNT 1 +#define PIN_WIRE_SDA 3 +#define PIN_WIRE_SCL 4 +#define WIRE_TWIM NRF_TWIM30 +#define WIRE_TWIS NRF_TWIS30 +#define WIRE_IRQN SERIAL30_IRQn +#define WIRE_IRQ_HANDLER SERIAL30_IRQHandler + +#ifdef __cplusplus +} +#endif + +// SX1262 / E22-900M30S +#define USE_SX1262 +#define SX126X_CS 69 +#define SX126X_DIO1 70 +#define SX126X_BUSY 67 +#define SX126X_RESET 64 +// RXEN is held high permanently (LNA always on); TXEN follows DIO2. +#define SX126X_ANT_SW 71 +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8f diff --git a/variants/nrf54l15/xiao_nrf54l15/platformio.ini b/variants/nrf54l15/xiao_nrf54l15/platformio.ini new file mode 100644 index 0000000000..c47b865f88 --- /dev/null +++ b/variants/nrf54l15/xiao_nrf54l15/platformio.ini @@ -0,0 +1,35 @@ +[env:xiao_nrf54l15] +# PRIVATE_HW (255) until the protobuf HardwareModel enum gets an entry for this board. +custom_meshtastic_hw_model = 255 +custom_meshtastic_hw_model_slug = XIAO_NRF54L15 +custom_meshtastic_architecture = nrf54l15 +custom_meshtastic_actively_supported = false +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = Seeed XIAO nRF54L15 + +extends = nrf54l15_base +board = xiao_nrf54l15 +board_level = extra + +build_flags = ${nrf54l15_base.build_flags} + -Ivariants/nrf54l15/xiao_nrf54l15 + -DXIAO_NRF54L15 + -DMESHTASTIC_EXCLUDE_GPS=1 + -DHAS_GPS=0 + +build_src_filter = ${nrf54l15_base.build_src_filter} + +<../variants/nrf54l15/xiao_nrf54l15> + +[env:xiao_nrf54l15_lr2021] +# Seeed LoRa Plus expansion board: Wio-LR2021, SSD1306 OLED and Grove I2C on D4/D5, K1 on D14. +custom_meshtastic_hw_model = 255 +custom_meshtastic_hw_model_slug = XIAO_NRF54L15_LR2021 +custom_meshtastic_architecture = nrf54l15 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 2 +custom_meshtastic_display_name = Seeed XIAO nRF54L15 LoRa Plus + +extends = env:xiao_nrf54l15 +board_level = pr +build_flags = ${env:xiao_nrf54l15.build_flags} + -DXIAO_NRF54L15_LR2021 diff --git a/variants/nrf54l15/xiao_nrf54l15/variant.cpp b/variants/nrf54l15/xiao_nrf54l15/variant.cpp new file mode 100644 index 0000000000..a1387cd7e9 --- /dev/null +++ b/variants/nrf54l15/xiao_nrf54l15/variant.cpp @@ -0,0 +1,22 @@ +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + // D0..D5 (P1 header pins) + 36, 37, 38, 39, 42, 43, + // D6..D10 (P2 header pins): TX, RX, SCK, MISO, MOSI + 72, 71, 65, 68, 66, + // 11..15: P0.03, P0.04, P2.10, P2.09, P2.06 + 3, 4, 74, 73, 70, + // 16 LED P2.00, 17 button P0.00, 18 SAMD11 RX (nRF TX) P1.09, 19 SAMD11 TX (nRF RX) P1.08 + 64, 0, 41, 40, + // 20 IMU/mic power P0.01, 21 RF switch power P2.03, 22 RF path select P2.05, 23 VBAT divider enable P1.15 + 1, 67, 69, 47}; + +void initVariant() +{ + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); +} diff --git a/variants/nrf54l15/xiao_nrf54l15/variant.h b/variants/nrf54l15/xiao_nrf54l15/variant.h new file mode 100644 index 0000000000..dae47b2dbd --- /dev/null +++ b/variants/nrf54l15/xiao_nrf54l15/variant.h @@ -0,0 +1,140 @@ +#pragma once + +/* + * Seeed XIAO nRF54L15 with a Wio-SX1262 for XIAO (SKU 113010003), or with XIAO_NRF54L15_LR2021 the + * Wio-LR2021 on the LoRa Plus expansion board (SKU 100039980, SSD1306 OLED and Grove I2C on D4/D5). + * The two modules share D1..D3 with different roles, so they are separate build environments. + * + * This header shadows the framework's variants/xiao_nrf54l15/variant.h, so it carries the core + * pin table definitions as well. Arduino pins 0..10 are the XIAO header D0..D10, 11..23 are + * internal signals (see variant.cpp for the physical GPIO of each index). + */ + +#define VARIANT_MCK (128000000ul) +#define USE_LFXO + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PINS_COUNT (24) +#define NUM_DIGITAL_PINS (24) +#define NUM_ANALOG_INPUTS (8) +#define NUM_ANALOG_OUTPUTS (0) +#define ADC_RESOLUTION 14 + +#define D0 (0ul) +#define D1 (1ul) +#define D2 (2ul) +#define D3 (3ul) +#define D4 (4ul) +#define D5 (5ul) +#define D6 (6ul) +#define D7 (7ul) +#define D8 (8ul) +#define D9 (9ul) +#define D10 (10ul) + +#define PIN_A0 D0 +#define PIN_A1 D1 +#define PIN_A2 D2 +#define PIN_A3 D3 +#define PIN_A4 D4 +#define PIN_A5 D5 +static const uint8_t A0 = PIN_A0; +static const uint8_t A1 = PIN_A1; +static const uint8_t A2 = PIN_A2; +static const uint8_t A3 = PIN_A3; +static const uint8_t A4 = PIN_A4; +static const uint8_t A5 = PIN_A5; + +// User LED P2.00 (active low) +#define PIN_LED1 16 +#define LED_BUILTIN PIN_LED1 +#define LED_STATE_ON 0 + +// User button P0.00 (active low) +#define PIN_BUTTON1 17 +#define BUTTON_NEED_PULLUP +#ifdef XIAO_NRF54L15_LR2021 +// K1 on the LoRa Plus expansion board, D14 (P2.09) +#define PIN_BUTTON2 14 +#endif + +// Serial1: the SAMD11 USB-CDC bridge (UARTE20): nRF TX P1.09, nRF RX P1.08 +#define PIN_SERIAL1_TX 18 +#define PIN_SERIAL1_RX 19 +#define SERIAL1_UARTE NRF_UARTE20 +#define SERIAL1_IRQN SERIAL20_IRQn +#define SERIAL1_IRQ_HANDLER SERIAL20_IRQHandler + +// Serial2: header D6 (TX) / D7 (RX) on UARTE21 +#define PIN_SERIAL2_TX D6 +#define PIN_SERIAL2_RX D7 +#define SERIAL2_UARTE NRF_UARTE21 +#define SERIAL2_IRQN SERIAL21_IRQn +#define SERIAL2_IRQ_HANDLER SERIAL21_IRQHandler + +// SPI (SPIM00): D8 SCK, D9 MISO, D10 MOSI +#define SPI_INTERFACES_COUNT 1 +#define PIN_SPI_MISO D9 +#define PIN_SPI_MOSI D10 +#define PIN_SPI_SCK D8 +#ifdef XIAO_NRF54L15_LR2021 +static const uint8_t SS = D3; +#else +static const uint8_t SS = D4; +#endif +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +#ifdef XIAO_NRF54L15_LR2021 +// Wire (TWIM22): header I2C on D4 (SDA, P1.10) / D5 (SCL, P1.11) +#define PIN_WIRE_SDA D4 +#define PIN_WIRE_SCL D5 +#define WIRE_TWIM NRF_TWIM22 +#define WIRE_TWIS NRF_TWIS22 +#define WIRE_IRQN SERIAL22_IRQn +#define WIRE_IRQ_HANDLER SERIAL22_IRQHandler +#else +// Wire (TWIM30): the Sense variant's internal sensor bus, SDA P0.04, SCL P0.03; D4/D5 belong to the radio +#define PIN_WIRE_SDA 12 +#define PIN_WIRE_SCL 11 +#define WIRE_TWIM NRF_TWIM30 +#define WIRE_TWIS NRF_TWIS30 +#define WIRE_IRQN SERIAL30_IRQn +#define WIRE_IRQ_HANDLER SERIAL30_IRQHandler +#endif +#define WIRE_INTERFACES_COUNT 1 + +#ifdef __cplusplus +} +#endif + +#ifdef XIAO_NRF54L15_LR2021 +// Wio-LR2021: NSS D3, IRQ on DIO8 D0, NRESET D2, BUSY D1, switchless RF, 32 MHz crystal (no TCXO), +// DIO7/DIO11 reach D6/D7 through 470R and stay unused +#define USE_LR2021 +#define LR2021_SPI_NSS_PIN D3 +#define LR2021_IRQ_PIN D0 +#define LR2021_NRESET_PIN D2 +#define LR2021_BUSY_PIN D1 +#define LR2021_SPI_SCK_PIN PIN_SPI_SCK +#define LR2021_SPI_MOSI_PIN PIN_SPI_MOSI +#define LR2021_SPI_MISO_PIN PIN_SPI_MISO +#define IRQ_DIO_NUM 8 +#else +// Wio-SX1262 for XIAO +#define USE_SX1262 +#define SX126X_CS D4 +#define SX126X_DIO1 D1 +#define SX126X_BUSY D3 +#define SX126X_RESET D2 +#define SX126X_RXEN D5 +#define SX126X_TXEN RADIOLIB_NC +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +#endif diff --git a/variants/rp2040/rak11310/platformio.ini b/variants/rp2040/rak11310/platformio.ini index f3496023f3..5f5bc19956 100644 --- a/variants/rp2040/rak11310/platformio.ini +++ b/variants/rp2040/rak11310/platformio.ini @@ -28,6 +28,6 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip debug_build_flags = ${rp2040_base.build_flags}, -g debug_tool = cmsis-dap ; for e.g. Picotool diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index 7be150e922..1a061c29c1 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -2,12 +2,12 @@ [rp2040_base] platform = # TODO renovate - https://github.com/maxgerhardt/platform-raspberrypi#aa70b802be8851668053d4f09734e4089fe41932 + https://github.com/maxgerhardt/platform-raspberrypi#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1 ; For arduino-pico >= 5.6.1 extends = arduino_base platform_packages = # TODO renovate - arduino-pico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip + framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip board_build.core = earlephilhower board_build.filesystem_size = 0.5m @@ -18,6 +18,8 @@ build_flags = -Isrc/platform/rp2xx0/pico_sleep/include -D__PLAT_RP2040__ -D__FREERTOS=1 + -Wl,-u,_printf_float + -Wl,-u,_scanf_float # -D _POSIX_THREADS build_src_filter = ${arduino_base.build_src_filter} + - - - - - - @@ -25,6 +27,9 @@ build_src_filter = lib_ignore = BluetoothOTA lvgl + ; Ships its own Preferences.h, which the LDF matches against the ARCH_ESP32-only + ; include in NodeDB.cpp; compiling it then trips its own "not a Challenger" #error. + iLabs Hearth lib_deps = ${arduino_base.lib_deps} diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index 0705ed8eca..f81b99f15f 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -2,12 +2,12 @@ [rp2350_base] platform = # TODO renovate - https://github.com/maxgerhardt/platform-raspberrypi#aa70b802be8851668053d4f09734e4089fe41932 + https://github.com/maxgerhardt/platform-raspberrypi#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1 ; For arduino-pico >= 5.6.1 extends = arduino_base platform_packages = # TODO renovate - arduino-pico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip + framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip board_build.core = earlephilhower board_build.filesystem_size = 0.5m @@ -16,12 +16,17 @@ build_flags = -Isrc/platform/rp2xx0 -D__PLAT_RP2350__ -D__FREERTOS=1 + -Wl,-u,_printf_float + -Wl,-u,_scanf_float build_src_filter = ${arduino_base.build_src_filter} + - - - - - - - - lib_ignore = BluetoothOTA lvgl + ; Ships its own Preferences.h, which the LDF matches against the ARCH_ESP32-only + ; include in NodeDB.cpp; compiling it then trips its own "not a Challenger" #error. + iLabs Hearth lib_deps = ${arduino_base.lib_deps} diff --git a/variants/stm32/CDEBYTE_E77-MBL/variant.h b/variants/stm32/CDEBYTE_E77-MBL/variant.h index 686326137a..b2f84f2353 100644 --- a/variants/stm32/CDEBYTE_E77-MBL/variant.h +++ b/variants/stm32/CDEBYTE_E77-MBL/variant.h @@ -22,4 +22,11 @@ Do not expect a working Meshtastic device with this target. #define SERIAL_PRINT_PORT 1 #define EBYTE_E77_MBL + +// LoRa +// Hardware varies by unit: SN >= 3202995 has a TCXO, older units have XTAL only - +// https://github.com/olliw42/mLRS-docu/blob/main/docs/EBYTE_E77_MBL.md +#define TCXO_OPTIONAL +#define SX126X_DIO3_TCXO_VOLTAGE 1.7 + #endif diff --git a/variants/stm32/rak3172/variant.h b/variants/stm32/rak3172/variant.h index b7afefc3f7..f18448f0d9 100644 --- a/variants/stm32/rak3172/variant.h +++ b/variants/stm32/rak3172/variant.h @@ -27,4 +27,10 @@ Do not expect a working Meshtastic device with this target. #define HAS_LSE 1 #define STM32WL_LSE_DRIVE RCC_LSEDRIVE_LOW +// LoRa +// RAK3172: no TCXO, RAK3172-T: 3.0V TCXO - +// https://github.com/RAKWireless/RAK-STM32-RUI/blob/e5a28be8fab1a492bd9223dd425ca33a8a297d90/variants/WisDuo_RAK3172-T_Board/radio_conf.h#L91 +#define TCXO_OPTIONAL +#define SX126X_DIO3_TCXO_VOLTAGE 3.0 + #endif diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 02955ff2a0..81f01720e5 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -2,7 +2,7 @@ extends = arduino_base platform = # renovate: datasource=custom.pio depName=platformio/ststm32 packageName=platformio/platform/ststm32 - platformio/ststm32@19.7.1 + platformio/ststm32@20.0.0 platform_packages = # renovate: datasource=github-tags depName=Arduino_Core_STM32 packageName=stm32duino/Arduino_Core_STM32 platformio/framework-arduinoststm32@https://github.com/stm32duino/Arduino_Core_STM32/archive/2.10.1.zip @@ -16,19 +16,20 @@ build_flags = ${arduino_base.build_flags} -flto -Isrc/platform/stm32wl -g - -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_ATAK=1 ; ATAK is quite big, disable it for big flash savings. - -DMESHTASTIC_EXCLUDE_INPUTBROKER=1 - -DMESHTASTIC_EXCLUDE_POWERMON=1 - -DMESHTASTIC_EXCLUDE_SCREEN=1 - -DMESHTASTIC_EXCLUDE_MQTT=1 + -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_BLUETOOTH=1 - -DMESHTASTIC_EXCLUDE_WIFI=1 - -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. - -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; Individual STM32WL variants opt in after size validation. + -DMESHTASTIC_EXCLUDE_INPUTBROKER=1 + -DMESHTASTIC_EXCLUDE_MQTT=1 -DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1 - -DMESHTASTIC_EXCLUDE_WAYPOINT=1 + -DMESHTASTIC_EXCLUDE_POWERMON=1 -DMESHTASTIC_EXCLUDE_POWER_TELEMETRY=1 + -DMESHTASTIC_EXCLUDE_RTTTL=1 ; No PWM/RTTTL ringtone playback support on this platform. + -DMESHTASTIC_EXCLUDE_SCREEN=1 + -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. + -DMESHTASTIC_EXCLUDE_WAYPOINT=1 + -DMESHTASTIC_EXCLUDE_WIFI=1 + -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; Individual STM32WL variants opt in after size validation. -DSERIAL_RX_BUFFER_SIZE=256 ; For GPS - the default of 64 is too small. -DHAS_SCREEN=0 ; Always disable screen for STM32, it is not supported. ;-DPIO_FRAMEWORK_ARDUINO_NANOLIB_FLOAT_PRINTF ; Enable this if enabling debugg logging. It is REQUIRED for at least traceroute debug prints - without it the length returned by printf ends up uninitialized. @@ -43,6 +44,7 @@ build_flags = -DMESHTASTIC_DYNAMIC_SBRK_HEAP -DHAL_DAC_MODULE_ONLY -DHAL_RNG_MODULE_ENABLED + -DLFS_NO_ASSERT ; Recoverable lfs_assert() instead of assert() (hangs forever here). Same as nRF52, see #3818 -Wl,--wrap=__assert_func -Wl,--wrap=strerror -Wl,--wrap=_tzset_unlocked_r diff --git a/variants/stm32/wio-e5/variant.h b/variants/stm32/wio-e5/variant.h index da2c623fb3..ad733ff5f4 100644 --- a/variants/stm32/wio-e5/variant.h +++ b/variants/stm32/wio-e5/variant.h @@ -19,4 +19,8 @@ Do not expect a working Meshtastic device with this target. #define WIO_E5 +// LoRa +// https://github.com/Seeed-Studio/LoRaWan-E5-Node/blob/163c05379b1805dd8f2c061d4557a69985acc953/Middlewares/Third_Party/SubGHz_Phy/stm32_radio_driver/radio_driver.c#L94 +#define SX126X_DIO3_TCXO_VOLTAGE 1.7 + #endif diff --git a/version.properties b/version.properties index c08db24b82..01ea06ed73 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ [VERSION] major = 2 minor = 8 -build = 0 +build = 1 diff --git a/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay b/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay deleted file mode 100644 index 4c861312c1..0000000000 --- a/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay +++ /dev/null @@ -1,117 +0,0 @@ -#include - -/* - * Zephyr device tree overlay — Nordic nRF54L15-DK + EBYTE E22-900M30S (SX1262) - * - * P1 GPIO bank runs at 1.8V VDDIO (LP domain) — NOT compatible with SX1262 - * which needs VIH ≥ 2.31V (0.7 × 3.3V). All E22 signals therefore use P2, - * which is in the HP domain (3.0V VDDIO), via SPIM00. - * - * SPIM00 (HP domain, 3.0V) replaces SPIM20 (LP domain, 1.8V). - * Default pinctrl from cpuapp_common.dtsi already maps SPIM00 to P2 pins: - * MISO = P2.04, MOSI = P2.02, SCK = P2.01 - * - * The on-board MX25R64 NOR flash was attached to SPIM00; it is not used by - * Meshtastic (LittleFS lives in internal RRAM), so we delete its DTS node to - * free the bus and P2.05 (its CS pin) for our use. - * - * Physical wiring (all P2, HP domain): - * E22 MISO → P2.04 (SPIM00 MISO) - * E22 NSS → P2.05 (freed from MX25R64 CS, RadioLib GPIO) - * E22 DIO1 → P2.06 (IRQ — interrupt capable via gpiote30) - * E22 BUSY → P2.03 (GPIO input) - * E22 RST → P2.00 (GPIO output) - * E22 RXEN → P2.07 (held HIGH via ANT_SW; replaces LED2 on DK) - * E22 MOSI → P2.02 (SPIM00 MOSI) - * E22 SCK → P2.01 (SPIM00 SCK) - * - * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. - */ - -/* Disable uart20 — no longer needed for LoRa (P1 domain abandoned). */ -&uart20 { - status = "disabled"; -}; - -/* Disable uart30 to free P0.00–P0.03 from the uart30_default pinctrl (which - * also reserves P0.03 as UART30 CTS). The peripheral instance 30 is shared - * with i2c30/spi30 — only one of {UARTE30, TWIM30, SPIM30} can be enabled at - * a time. We pick TWIM30 (i2c30) for sensors (SHT40 / INA3221 / SE050 on the - * custom PCB; BMP280 / INA228 on the DK for bring-up). Console stays on RTT - * via CONFIG_RTT_CONSOLE=y. */ -&uart30 { - status = "disabled"; -}; - -/* I2C bus via TWIM30 (HP domain, 3.0 V), SDA=P0.03 / SCL=P0.04. - * P0.03 was the UART30 CTS; freed by disabling uart30 above. - * P0.04 was button SW3 on the DK; deleted below. The pad still routes to - * the SW3 button on the board — DO NOT press SW3 during I2C use, it will - * short SCL to GND mid-transaction. - * 400 kHz is the highest standard rate supported by all three target sensors - * (BMP280, INA228, SE050). External 4.7 kΩ pull-ups required on both lines. */ -&pinctrl { - i2c30_default: i2c30_default { - group1 { - psels = , - ; - bias-pull-up; - }; - }; - - i2c30_sleep: i2c30_sleep { - group1 { - psels = , - ; - low-power-enable; - }; - }; -}; - -&i2c30 { - status = "okay"; - clock-frequency = ; /* 400 kHz */ - pinctrl-0 = <&i2c30_default>; - pinctrl-1 = <&i2c30_sleep>; - pinctrl-names = "default", "sleep"; -}; - -/ { - buttons { - /* button1 (P1.09) and button2 (P1.08) no longer repurposed — - * E22 now uses P2. button3 (P0.04) is now I2C SCL — its node - * must be removed before the i2c30 pinctrl can claim the pad. */ - /delete-node/ button_1; - /delete-node/ button_2; - /delete-node/ button_3; - }; - - aliases { - /delete-property/ sw1; - /delete-property/ sw2; - /delete-property/ sw3; - }; -}; - -/* - * Override SPIM00 to remove the MX25R64 NOR flash child. - * The SPIM00 peripheral itself stays enabled (status = "okay" from - * cpuapp_common.dtsi) with its existing pinctrl (P2.01/P2.02/P2.04). - * RadioLib drives CS (P2.05) as a plain GPIO — no hardware CS needed. - */ -&spi00 { - /delete-node/ mx25r6435f@0; -}; - -/* - * Grow storage_partition from 36 KB to 700 KB by reclaiming slot1_partition. - * slot1 (image-1) is the MCUboot secondary slot for dual-bank OTA, which we - * don't use (flashing is direct via J-Link). With only 9 blocks (36 KB / 4 KB) - * LittleFS ran out of space during COW writes of config.proto. - * New layout: storage_partition spans 0xb6000..0x165000 (700 KB, ~175 blocks). - */ -/delete-node/ &slot1_partition; - -&storage_partition { - reg = <0xb6000 0xaf000>; -}; diff --git a/zephyr/prj.conf b/zephyr/prj.conf deleted file mode 100644 index 0723d0cd67..0000000000 --- a/zephyr/prj.conf +++ /dev/null @@ -1,299 +0,0 @@ -# Zephyr project configuration for nRF54L15 Meshtastic port -# -# NOTE: this prj.conf is shared by ALL Zephyr PlatformIO environments -# in this project. Keep it compatible with any future Zephyr targets. - -# ── C++ support (required by Meshtastic) ────────────────────────────────────── -CONFIG_CPP=y -CONFIG_STD_CPP17=y -# Full libstdc++ — provides , , , , etc. -# Works with either newlib or picolibc (Zephyr auto-selects based on board). -CONFIG_REQUIRES_FULL_LIBCPP=y -# Disable C++ exceptions — not needed by Meshtastic and saves RAM/ROM -CONFIG_CPP_EXCEPTIONS=n - -# ── Peripheral subsystems ───────────────────────────────────────────────────── -CONFIG_SPI=y -CONFIG_I2C=y -CONFIG_GPIO=y - -# sys_reboot() used by BLE zombie-connection watchdog (BleDeferredThread). -# nRF54L15 SW-LL occasionally drops the BLE link without forwarding -# LE Disconnection Complete to the host; cold reboot is the only reliable -# recovery path. -CONFIG_REBOOT=y - -# ── ATT Prepare/Execute Write (LONG WRITE) ─────────────────────────────────── -# iOS CoreBluetooth automatically fragments writes >MTU-3 via ATT Prepare Write -# (opcode 0x16). Default CONFIG_BT_ATT_PREPARE_COUNT=0 makes Zephyr reject with -# "Request Not Supported" (0x06), which iOS surfaces as a write error → -# disconnect. With MTU=65 any ToRadio write >62 bytes triggers this path. -# Enabling this allocates N prep_pool buffers (each BT_ATT_BUF_SIZE = 65 bytes) -# and reassembles fragments into a single write_toradio() call on execute. -# 4 × 65 = 260 B max assembled value — enough for typical iOS NodeInfo/admin -# writes after config stream completes. -CONFIG_BT_ATT_PREPARE_COUNT=4 - -# ── Filesystem — LittleFS on storage_partition (RRAM) ─────────────────────── -# Size is set by the board overlay (the nRF54L15-DK overlay reclaims slot1 to -# expand storage_partition to ~700 KB). Capacity is reported at runtime via -# FIXED_PARTITION_SIZE(storage_partition) in InternalFileSystem::totalBytes(). -CONFIG_FLASH=y -CONFIG_FLASH_MAP=y -CONFIG_FLASH_PAGE_LAYOUT=y -CONFIG_FILE_SYSTEM=y -CONFIG_FILE_SYSTEM_LITTLEFS=y -CONFIG_FILE_SYSTEM_MKFS=y -# Disable SPI NOR flash driver — MX25R64 node deleted from DTS, SPIM00 used -# exclusively by RadioLib (SX1262). Without this, the spi_nor driver claims -# SPIM00 at boot and tries to read MX25R64 ID (gets garbage since the chip is -# not wired), producing "Device id a8 a8 a8 does not match config c2 28 17". -CONFIG_SPI_NOR=n -# Disable runtime PM — keeps SPI initialization path simple; avoids any -# interaction between PM auto-suspend/resume cycles and the SPIM00 clock -# request mechanism (CONFIG_CLOCK_CONTROL_NRF_HSFLL_GLOBAL). -CONFIG_PM_DEVICE_RUNTIME=n -# Suppress Zephyr FS subsystem's internal error/warning logs (ENOENT on -# missing files and EEXIST on duplicate mkdir are expected and handled). -CONFIG_FS_LOG_LEVEL_OFF=y - -# ── Console / logging ───────────────────────────────────────────────────────── -# Use SEGGER RTT for console — does not require COM3 (CDC UART), reads via SWD -CONFIG_UART_CONSOLE=n -CONFIG_USE_SEGGER_RTT=y -CONFIG_RTT_CONSOLE=y -CONFIG_LOG=y -CONFIG_LOG_BACKEND_RTT=y -CONFIG_LOG_DEFAULT_LEVEL=2 -# Immediate mode: log writes go directly to RTT backend without a separate thread. -# Deferred mode requires the log thread to run (lowest priority — never gets CPU -# in heavy setup()/loop() workloads), leaving the RTT buffer empty indefinitely. -CONFIG_LOG_MODE_IMMEDIATE=y -# Force RTT control block re-init on every boot — prevents stale/corrupted CB after crash -CONFIG_SEGGER_RTT_INIT_MODE_ALWAYS=y -# Use RTT channel 1 for the LOG backend, channel 0 (Terminal) for direct printk. -# Sharing channel 0 forces LOG_PRINTK=y (deferred) to avoid corruption. -CONFIG_LOG_BACKEND_RTT_BUFFER=1 -# Buffer sizes shrunk from 24576 → 4096 to free ~40 KB of BSS for newlib heap. -# At 24576 the BSS pushed _end up so far that newlib heap was only ~25 KB, -# and BUF_ACL_RX_SIZE=152 + BLE/PhoneAPI lazy init ran out of malloc space. -# 4 KB still gives several seconds of log retention before host attaches. -CONFIG_LOG_BACKEND_RTT_BUFFER_SIZE=4096 -# Overwrite oldest data if buffer fills — never stalls -CONFIG_LOG_BACKEND_RTT_MODE_BLOCK=n -CONFIG_LOG_BACKEND_RTT_MODE_OVERWRITE=y -CONFIG_LOG_BACKEND_RTT_OUTPUT_BUFFER_SIZE=256 - -# ── LFXO clock source — use RC oscillator to avoid ~2s crystal stabilization -# disrupting the GRTC timer and hanging k_sleep -CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y - -# ── Stack sizes — Meshtastic setup() is heavy (RadioLib, NodeDB, printf) ────── -# bt_enable() called from nrf54l15Setup() needs >8KB. -# Phase 7: CONFIG_BT_SETTINGS=y causes bt_set_name() → settings_save_one() → -# settings_file_save() → LittleFS I/O. The I/O chain needs ~3 KB of stack -# headroom beyond what the BT init alone requires. Increase main stack to 24KB -# and system workqueue to 8KB to cover both the cooperative-OSThread call path -# (which runs on the main thread) and any async flash work items. -CONFIG_MAIN_STACK_SIZE=24576 -CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=8192 -# Log processing thread stack — default 768 overflows when processing RTT fault dump -CONFIG_LOG_PROCESS_THREAD_STACK_SIZE=2048 - -# ── Fault/exception diagnostics — identify ~2000ms crash ───────────────────── -CONFIG_FAULT_DUMP=2 -CONFIG_EXCEPTION_DEBUG=y -CONFIG_STACK_SENTINEL=y -# Thread names + extra exception info — fault dumps then identify the failing -# thread (otherwise "Current thread: 0x... (unknown)") and include r4-r11 + psp -# so the custom k_sys_fatal_error_handler can walk the stack to show the caller -# chain. Cheap (~32 B/thread for names, no perf hit) and very useful when a -# crash recurs in the field. -CONFIG_THREAD_NAME=y -CONFIG_EXTRA_EXCEPTION_INFO=y -# SEGGER RTT buffer — keep modest (4 KB) to leave RAM for newlib heap. -CONFIG_SEGGER_RTT_BUFFER_SIZE_UP=4096 -# Report reset reason from previous crash -CONFIG_HWINFO=y - -# ── Bluetooth ───────────────────────────────────────────────────────────────── -# Zephyr BT host + LL SW controller (MPSL) — peripheral role only -CONFIG_BT=y -CONFIG_BT_PERIPHERAL=y -# SMP / LE Encryption enabled so the Meshtastic app pairs with a PIN before -# accessing the GATT service. -CONFIG_BT_SMP=y -# Enforce MITM so clients must complete passkey exchange — without this Just -# Works pairings complete silently without prompting the user for a PIN. -CONFIG_BT_SMP_ENFORCE_MITM=y -# Fixed-passkey path so the device (no display) can advertise a known PIN via -# bt_passkey_set() when config.bluetooth.mode == FIXED_PIN. -CONFIG_BT_FIXED_PASSKEY=y -# Allow legacy pairing as fallback. SC_PAIR_ONLY=y has been observed to cause -# some clients to abort pairing with reason 0x01 within 150 ms of the pairing -# request, before any PIN dialog appears. Accepting legacy lets the same -# clients fall through to Passkey Entry successfully. -CONFIG_BT_SMP_SC_PAIR_ONLY=n -# BT_LL_SW_SPLIT is auto-selected from DT (zephyr,bt-hci-ll-sw-split node in nRF54L15 DTS) -# Do NOT set CONFIG_BT_CTLR=y (deprecated — radio silently non-functional) -# Dynamic device name so bt_set_name() can embed the node short ID at runtime -CONFIG_BT_DEVICE_NAME_DYNAMIC=y -CONFIG_BT_DEVICE_NAME_MAX=32 -# Only need one simultaneous central connection -CONFIG_BT_MAX_CONN=1 -# BT subsystem logging — INF for connection/service diagnostics -CONFIG_BT_LOG_LEVEL_INF=y -# BT thread stacks — defaults are too small for nRF54L15 SW-LL init. -# prio_recv_thread overflows at 2048; bump all BT stacks to safe sizes. -# BT RX thread runs ALL our GATT write callbacks (rx_work_handler → hci_acl → -# bt_conn_recv → bt_l2cap_recv → bt_att_recv → write_toradio_cb → -# PhoneAPI::handleStartConfig → getFiles("/", 10) recursion + nanopb encode). -# 4096 overflows on "Client wants config" → abort() / kernel panic (reason 4). -CONFIG_BT_RX_STACK_SIZE=4096 -CONFIG_BT_HCI_TX_STACK_SIZE=1024 -# bt_long_wq runs bt_pub_key_gen (ECC P256 keygen) on this thread. -# Defaults (prio=10, stack=1400) leave it starved by Meshtastic app threads -# at boot: pub_key gen never completes, so smp_public_key() defers -# indefinitely waiting for sc_public_key, and every SC pairing attempt -# stalls right after exchanging public keys (no PIN prompt on iOS, every -# AUTHEN-gated char rejects with ATT error 0x05). -# Prio 0 = highest preemptible, ties with main; stack 4096 clears P256M -# driver frames with margin. -CONFIG_BT_LONG_WQ_PRIO=0 -CONFIG_BT_LONG_WQ_STACK_SIZE=4096 -# Use legacy advertising (bt_le_adv_start / HCI 0x2006 path). -# With CONFIG_BT_EXT_ADV=y, bt_le_adv_start() is internally translated to the -# extended HCI path with LEGACY-bit (0x2036), which produces non-connectable PDUs -# on the nRF54L15 SW-LL. With CONFIG_BT_EXT_ADV=n the host uses pure legacy HCI -# commands (0x2006/0x2008/0x200a) — the same path Nordic uses in all nRF54L15 -# NCS examples (peripheral_uart, peripheral_lbs), which is iOS-compatible and -# avoids the LE Remove Advertising Set (0x203c) controller timeout crash. -CONFIG_BT_EXT_ADV=n - -# ── Phase 7: BT bond persistence ────────────────────────────────────────────── -# CONFIG_BT_SETTINGS enables the BT host settings integration: the stack -# automatically calls settings_save_subtree("bt/keys") after pairing, and -# settings_load() on boot restores previously bonded peers. -# -# Backend: SETTINGS_FILE stores all key-value pairs in a single flat file in -# LittleFS. No new partition needed — the existing storage_partition (mounted -# at /lfs, size set by the board overlay) is used. File path: /lfs/bt_settings. -# -# Ordering guarantee: LittleFS is mounted by fsInit() BEFORE nrf54l15Setup() -# calls nrf54l15_bt_preinit(), so the file backend is always available when -# settings_load() is called after bt_enable(). -CONFIG_BT_SETTINGS=y -CONFIG_SETTINGS=y -CONFIG_SETTINGS_FILE=y -CONFIG_SETTINGS_FILE_PATH="/lfs/bt_settings" -# BT_MAX_PAIRED default is 1 — first bond (e.g. iOS) blocks every subsequent -# peer's SMP pairing request with "Unable to get keys" because there is no free -# bt_keys slot to allocate. Raise to 4 so the device can simultaneously hold -# iOS, Windows, Linux, and one spare bond. Add OVERWRITE_OLDEST so that when -# the table fills, the LRU peer is evicted instead of rejecting the new pair. -CONFIG_BT_MAX_PAIRED=4 -CONFIG_BT_KEYS_OVERWRITE_OLDEST=y -# Disable GATT database caching and Service Changed characteristic. -# CONFIG_BT_GATT_CACHING (default y with BT_SETTINGS) marks every new client as -# "not change-aware" and returns ATT_ERR_DB_OUT_OF_SYNC (0x12) on every GATT -# request until the client reads the DB-hash characteristic. The Meshtastic app -# does not implement GATT caching and silently aborts service discovery on 0x12, -# causing the connection to stall with zero GATT activity. -# CONFIG_BT_GATT_SERVICE_CHANGED (default y) adds the Generic Attribute Profile -# service; disabling it is required before BT_GATT_CACHING can be disabled. -CONFIG_BT_GATT_SERVICE_CHANGED=n -CONFIG_BT_GATT_CACHING=n -# Disable automatic PHY update (1M→2M) after connection. -# The nRF54L15 SW-LL fails the LL_PHY_REQ/RSP exchange and disconnects -# exactly 1.786s after connection — before any ATT/GATT operations. -CONFIG_BT_AUTO_PHY_UPDATE=n -# ATT/GATT/L2CAP debug logging — see exactly what happens after connection -CONFIG_BT_ATT_LOG_LEVEL_DBG=y -CONFIG_BT_GATT_LOG_LEVEL_DBG=y -CONFIG_BT_SMP_LOG_LEVEL_DBG=y -# L2CAP DBG: shows recv on fixed ATT channel — confirms whether iOS sends any data -CONFIG_BT_L2CAP_LOG_LEVEL_DBG=y -# Keep bt_conn at INF — DBG floods RTT buffer every ~150µs (tx_processor loop), -# overwriting all ATT/GATT messages before they can be read. -# Connection events (connected/disconnected) are logged at INF level. -CONFIG_BT_CONN_LOG_LEVEL_INF=y -# Keep HCI logs at INF to save RAM (log thread processing buffers, etc.). -# (Earlier DBG was used to diagnose the hci_acl → L2CAP stall — fix applied.) -CONFIG_BT_HCI_CORE_LOG_LEVEL_INF=y -CONFIG_BT_HCI_DRIVER_LOG_LEVEL_INF=y -# Fix: ACL packets reach hci_acl() but never reach bt_l2cap_recv(). -# Root cause: bt_conn_recv() calls bt_conn_tx_notify(conn, true) which submits -# tx_complete_work to k_sys_work_q and blocks on k_work_flush(). The BT rx -# workqueue (bt_workq) is stuck in k_work_flush waiting for the system -# workqueue, which is busy with LittleFS I/O / other work → dead stall until -# iOS supervision timeout fires (5s) and disconnects with reason 0x13. -# Solution: dedicate a separate workqueue for TX notify processing so it is -# independent from the system workqueue. -CONFIG_BT_CONN_TX_NOTIFY_WQ=y -# Dedicated workqueue only runs tx_notify_process() (iterates tx_complete list, -# calls short callbacks). Default 8192 is overkill and eats malloc heap needed -# by PowerFSM init → realloc() returns NULL → bus fault during FSM::add_transition. -CONFIG_BT_CONN_TX_NOTIFY_WQ_STACK_SIZE=2048 - -# ── ATT/L2CAP MTU — larger payloads for Meshtastic packets ─────────────────── -# TX side: controller sends up to L2CAP_TX_MTU bytes per ATT operation. -# RX side: server ATT MTU is min(BT_L2CAP_TX_MTU, BT_BUF_ACL_RX_SIZE - 4). -# Both set to 247 / 251 → ATT MTU = 247 in each direction, matching Zephyr's -# samples/bluetooth/mtu_update reference. This means typical iOS ToRadio -# writes (NodeInfo, channel settings, common admin packets) fit in a single -# ATT_WRITE_REQ and avoid the ATT Prepare/Execute Write path entirely. -# CONFIG_BT_ATT_PREPARE_COUNT=4 (above) still backstops oversized writes. -# -# Heap dependency: bumping BUF_ACL_RX_SIZE > default (~69) grows the BT host -# net_buf pools in BSS, which proportionally shrinks the newlib heap arena -# (MAX_HEAP_SIZE = SRAM_SIZE - (_end - SRAM_BASE), so any BSS growth steals -# from the heap directly). Empirically the lazy BLE init path -# (setBluetoothEnable → startDisabled → bt_set_name → settings_save → -# LittleFS) needs ~12 KB of newlib heap to run without bad_alloc. At -# BUF_ACL_RX_SIZE=251 with the previous 24 KB RTT buffers (LOG_BACKEND_RTT + -# SEGGER_RTT_BUFFER_SIZE_UP), the heap collapsed to ~4 KB free at -# transition time → `new char[]` in RedirectablePrint::log returned NULL → -# libstdc++ called abort() from main thread. Shrinking both RTT buffers to -# 4 KB (above) frees ~40 KB of BSS for the heap and resolves it. -# -# DLE stays off (BT_DATA_LEN_UPDATE=n below): the LLCP remote table at -# ull_llcp_remote.c:878 is guarded by #ifdef CONFIG_BT_CTLR_DATA_LENGTH, so -# the controller answers iOS's LL_LENGTH_REQ with LL_UNKNOWN_RSP and falls -# back to 27-byte LL PDUs. The host reassembles LL PDUs into L2CAP frames -# up to BT_BUF_ACL_RX_SIZE before dispatching to ATT. -CONFIG_BT_L2CAP_TX_MTU=247 -# Server ATT MTU = BUF_ACL_RX_SIZE - 4 = 247 (matches L2CAP_TX_MTU) -CONFIG_BT_BUF_ACL_RX_SIZE=251 - -# ── Fix: LL Feature Exchange collision (ROOT CAUSE of iOS GATT hang) ───────── -# On connection, Zephyr host calls bt_hci_le_read_remote_features() because -# BT_CTLR_PER_INIT_FEAT_XCHG=y makes can_initiate_feature_exchange() return -# true for peripheral role. This makes the controller send LL_PER_INIT_FEAT_XCHG -# to iOS right after connecting. -# iOS (as central) simultaneously sends LL_FEATURE_REQ to the peripheral. -# The nRF54L15 SW-LL mishandles this COLLISION: iOS waits for LL_FEATURE_RSP -# to its LL_FEATURE_REQ, never gets it, and stalls — sending zero L2CAP bytes. -# BT_CTLR_PER_INIT_FEAT_XCHG=n: host does NOT send HCI_LE_Read_Remote_Features -# as peripheral → no LL_PER_INIT_FEAT_XCHG sent → no collision → iOS feature -# exchange completes → iOS proceeds to L2CAP/ATT. -CONFIG_BT_CTLR_PER_INIT_FEAT_XCHG=n -# Fix: LL Connection Parameter Request handling. -# BT_CTLR_CONN_PARAM_REQ=n was ineffective: the LLCP remote decode table in -# ull_llcp_remote.c hardcodes PDU_DATA_LLCTRL_TYPE_CONN_PARAM_REQ → PROC_CONN_PARAM_REQ -# regardless of Kconfig. With =n the handler is compiled out → controller asserts / -# enters broken state when iOS sends LL_CONN_PARAM_REQ (which is optional from Central). -# Fix: =y so the procedure is actually handled. To avoid host/peripheral vs Central -# collision at 5 s (deferred_work → send_conn_le_param_update), disable auto-update -# below so the host never initiates HCI_LE_Connection_Update. -CONFIG_BT_CTLR_CONN_PARAM_REQ=y -# Prevent Zephyr host from initiating connection parameter update 5 s after connect. -# With CONN_PARAM_REQ=y, if iOS (Central) already issued LL_CONN_PARAM_REQ and the -# SW-LL is mid-procedure, a simultaneous host-initiated HCI_LE_Connection_Update -# creates an LL collision. Disabling the auto-update avoids the collision entirely. -CONFIG_BT_GAP_AUTO_UPDATE_CONN_PARAMS=n -# Disable optional LL procedures (belt-and-suspenders while debugging): -# BT_PHY_UPDATE=n + BT_CTLR_PHY_2M=n: no PHY update procedure → iOS doesn't attempt LL_PHY_REQ -# BT_DATA_LEN_UPDATE=n: no DLE → controller sends LL_UNKNOWN_RSP to LL_LENGTH_REQ -CONFIG_BT_CTLR_PHY_2M=n -CONFIG_BT_PHY_UPDATE=n -CONFIG_BT_DATA_LEN_UPDATE=n