diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..2a23a20a082 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -e native_radio_chips -e native_variant_target -e native_variant_target_override -e native_companion -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/.gitignore b/.gitignore index a0ad5f6ea94..2afa40ec290 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ cmake-* compile_commands.json .venv/ venv/ +__pycache__/ +*.pyc platformio.local.ini diff --git a/README.md b/README.md index 6cc45d7f43d..d94d65549e2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,22 @@ +> ## About this fork +> +> This is a fork of [meshcore-dev/MeshCore](https://github.com/meshcore-dev/MeshCore) +> that refactors the firmware so new features are cheaper to add, without changing +> how it behaves on air, towards the companion apps, on the CLI or on flash. +> +> - **What and why:** [docs/refactoring/](docs/refactoring/README.md) explains the +> series, the rules every step follows (characterization tests first, no behaviour +> change, full build matrix, hardware upgrade and downgrade tests) and the +> [architecture review of v1.18.0](docs/refactoring/architecture-review-v1.18.md) +> the steps are based on. +> - **Branches:** each step lives on its own `refactor/...` branch based on upstream +> `dev` and is prepared as a separate pull request against upstream. This branch, +> `integration`, combines all finished steps on top of upstream and is the base +> for further work in this fork. +> - **Status:** see the table in [docs/refactoring/README.md](docs/refactoring/README.md#steps). +> +> Everything below this box is the unchanged upstream README. + ## About MeshCore MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet. diff --git a/docs/fixes/af-nan-bit-check.md b/docs/fixes/af-nan-bit-check.md new file mode 100644 index 00000000000..9a91051915b --- /dev/null +++ b/docs/fixes/af-nan-bit-check.md @@ -0,0 +1,232 @@ +# "set af nan" was accepted + +## Context + +`set af` parses its argument and then checks the range: + +```cpp +float af = strtof(&command[7], &end); +if (end == &command[7] || af < 0 || af > 9) { + strcpy(reply, "ERROR: af must be 0-9"); +} else { + setAirtimeFactor(af); + strcpy(reply, "OK"); +} +``` + +(`src/helpers/CommonRadioPrefs.cpp:69-79` on `upstream/dev` 428a95c6.) + +`strtof("nan")` consumes the token, so the parse guard does not fire, and under +IEEE 754 every ordered comparison with a NaN is false, so neither `af < 0` nor +`af > 9` fires either. The else branch stores the NaN and answers `OK`. + +`airtime_factor` is the duty cycle budget. The roles hand it to the airtime +accounting, for the repeater through `examples/simple_repeater/MyMesh.h:157` +with matching getters in the room server and the sensor, so a NaN there is not +cosmetic. `set dutycycle` writes the same field through `100/dc - 1` and had the +same hole. + +Step 3 of the refactoring series already knew about this and pinned it as +current behaviour rather than changing it: `test/mocks/RadioPrefsChecks.h:238` +on `integration` carries `EXPECT_EQ("OK", accepted("set af nan"))` behind +`#ifndef __FAST_MATH__`, with a comment calling it odd. + +### Why only these two commands + +The other float commands in the same file phrase their guard the other way +round, `if (value >= low && value <= high) { accept } else { error }`, and a NaN +makes that condition false, so it lands in the error branch. Measured on the +host with both shapes: + +| token | guard as in `set af` | guard as in `set rxdelay` | +|---|---|---| +| `nan` | **accepted** | error | +| `-nan` | **accepted** | error | +| `inf` | error | error | +| `1e40` | error | error | +| `0.5` | accepted | accepted | + +So on `upstream/dev` the two commands that are reachable with a NaN are exactly +`set af` and `set dutycycle`, the two written as "reject if out of range". That +is also why the board measurement found `af` and nothing else. + +The positively phrased guards are safe by accident of phrasing, not by design: +their result still depends on the compiler once `-ffast-math` is in play, which +is why step 3 put a bit check behind all of them on `integration` +(`inRange`, `src/helpers/CommonRadioPrefs.cpp:65`). + +## Decision + +Keep the parse guard, keep both comparisons, keep both reply strings, and add a +check that the value is a finite number at all. The check reads the bits: + +```cpp +bool isFinite(float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7F800000u) != 0x7F800000u; +} +``` + +That is integer arithmetic, so no optimisation can fold it away, and it answers +the same on every platform with any flags. The same guard goes in front of +`set dutycycle`, whose range stays 1..100. + +### Why not a comparison or a library call + +nRF52 firmware is built with `-Ofast`, which includes `-ffast-math`, and that +lets the compiler assume NaN and infinity never occur. The outcome then depends +on the compiler, and the two disagree. Measured with the same test file: + +| | `set af nan` on `upstream/dev` | +|---|---| +| host, IEEE comparisons | accepted | +| host, `-Ofast` (Apple clang 21) | rejected | +| board, nRF52 with `arm-none-eabi-gcc -Ofast` | accepted | + +A host run with `-Ofast` alone would therefore have reported this defect as +absent. `std::isnan` is no better: it is the same comparison in a different +spelling and is equally free to be folded away. + +## Alternatives considered + +- **Compile this file without fast math.** Rejected. It would hide the problem + behind a build flag that any variant can change, and the defect exists under + plain IEEE rules as well, which is where it was measured. +- **Take the table driven `inRange` from step 3.** Not available here: this + branch starts from `upstream/dev`, where the radio CLI is still written out by + hand. The two will meet when this branch is merged, and the shape of the check + is deliberately the same so they can be unified. +- **Refuse the value one layer down, in the parser.** That is the right place + for a value read from `/prefs.json` and is what the range check fix does. It + is the wrong place here: this value comes from a CLI command and the caller + has to be told that its command was refused. +- **Also rewrite the positively phrased guards.** Not done, see above: they + reject a NaN under IEEE, and on `integration` step 3 has already put the bit + check behind them. Changing them here would widen a fix whose defect is the + two commands measured above. + +## Compatibility + +- Every value that was accepted before is still accepted, including the 99 that + `set dutycycle 1` stores, and every rejected value is still rejected with the + same message. Only values that are not finite numbers change from accepted to + refused. +- Server roles and the companion share this handler + (`src/helpers/CommonCLI.cpp:184` and + `examples/companion_radio/NodePrefs.h`), so one change covers every role. + +## Verification + +### Host tests + +New `test/test_radio_prefs_af` drives `CommonRadioPrefs::handleCommand` through +a test double. Nine cases: every spelling of not a number, infinity and +overflow, the garbage and out of range cases that must stay rejected with their +old message, the range that must stay accepted, `get af`, and the duty cycle +range including the 99 that step 3 pinned. + +The suite runs twice, in `native` with IEEE comparisons and in the new +`native_ofast` with the flags the nRF52 firmware uses: + +| | before the change | after | +|---|---|---| +| `native` (IEEE) | 2 of 9 red | 9 of 9 green | +| `native_ofast` | 9 of 9 green | 9 of 9 green | + +The second row is the point of the environment, not a contradiction: on this +host `-Ofast` already rejects the NaN, so an `-Ofast` run alone would have +shown nothing. Both rows green afterwards is what says the result no longer +depends on the flags. + +All suites of this branch: `pio test -e native -e native_kiss_modem -e +native_ofast` reports 70 of 70. + +### Mutations + +Five deliberate errors, each had to turn the suite red. Two of them are only +caught by the IEEE run, which is worth recording: + +| Mutation | `native` (IEEE) | `native_ofast` | +|---|---|---| +| `set af` without the finite check | red | green | +| `set dutycycle` without the finite check | red | green | +| bit comparison inverted | red (4) | red (2) | +| wrong mask | red | red | +| mask compared against zero | red (3) | red | + +### Builds + +All 20 environments of the matrix build on this commit, and so does +`upstream/dev` 428a95c6 in its own worktree with the same toolchain. +`Heltec_v3_terminal_chat` was built as well, because it is the one role with its +own prefs structure, to show it is untouched. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +32 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +32 | +| `Heltec_v3_repeater` | OK | +0 | +32 | +| `Heltec_v3_room_server` | OK | +0 | +28 | +| `Heltec_v3_sensor` | OK | +0 | +28 | +| `LilyGo_T-Echo_kiss_modem` | OK | +0 | +0 | +| `LilyGo_T-Echo_room_server` | OK | +0 | +32 | +| `PicoW_repeater` | OK | +0 | +24 | +| `ProMicro_companion_radio_usb` | OK | +0 | +32 | +| `ProMicro_repeater` | OK | +0 | +32 | +| `ProMicro_room_server` | OK | +0 | +32 | +| `ProMicro_sensor` | OK | +0 | +32 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +32 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +32 | +| `RAK_4631_repeater` | OK | +0 | +32 | +| `RAK_4631_room_server` | OK | +0 | +32 | +| `RAK_4631_sensor` | OK | +0 | +32 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +24 | +| `wio-e5-mini_repeater` | OK | +0 | +24 | +| `wio-e5-mini_sensor` | OK | +0 | +24 | +| `Heltec_v3_terminal_chat` | OK | +0 | +0 | + +RAM is unchanged everywhere, and flash grows by 24 to 32 bytes in every build +that compiles this handler. Exactly two builds come out byte identical to dev, +and both are explained rather than suspicious: `LilyGo_T-Echo_kiss_modem` adds +only `examples/kiss_modem/` to its sources, and `Heltec_v3_terminal_chat` runs +`examples/simple_secure_chat`, which keeps its own prefs structure and never +reaches `CommonRadioPrefs`. + +### Hardware + +techo-b43b, `LilyGo_T-Echo_room_server`, both images marked and each marker +confirmed in the image before flashing +(`unzip -p firmware.zip '*.bin' | grep -c -a ` = 1) and reported by `ver` +in the same run that produced the readings. + +| command | `dev-428a95c6` | `af-nan-wt` | +|---|---|---| +| `set af nan` | `OK` | `ERROR: af must be 0-9` | +| `get af` afterwards | `> 0` | `> 1.0`, unchanged | +| `set af -nan` | not measured | `ERROR: af must be 0-9` | +| `set af inf` | not measured | `ERROR: af must be 0-9` | +| `set af abc` | not measured | `ERROR: af must be 0-9` | +| `set dutycycle nan` | `OK - 0.0%` | `ERROR: dutycycle must be 1-100` | +| `get af` afterwards | `> 0` | `> 2.0`, unchanged | +| `set af 2` | not measured | `OK`, `get af` `> 2.0` | +| `set dutycycle 1` | not measured | `OK - 1.0%`, `get af` `> 99.0` | + +Worth recording: on `dev` the poisoned field reads back as `> 0`, so nothing on +the CLI showed that it held a NaN. The board was left on the values it came +with, `af 1.0` and `dutycycle 50.0%`. + +## Follow-ups + +- **The merge into `integration` needs a hand.** Two things there are not a + textual conflict and must not be resolved by taking one side: + `test/mocks/RadioPrefsChecks.h:238-242` expects `set af nan` to be accepted + and has to be turned into a rejection, and `integration` already has an + environment called `native_ofast` with a different `test_filter`, which has to + become the union of both rather than either one. +- `isFinite` now exists twice with the same shape, here and in + `ConfigSerializer.cpp` from the range check fix. Worth unifying once both are + on `integration`. +- `set radio` parses `freq` and `bw` with `strtof` and discards the end pointer + entirely, so `set radio abc def 8 5` is read as zeros and rejected by the + range check rather than by the parse. Harmless today, and a separate change. diff --git a/docs/fixes/companion-channel-txt-min-len.md b/docs/fixes/companion-channel-txt-min-len.md new file mode 100644 index 00000000000..606636704ec --- /dev/null +++ b/docs/fixes/companion-channel-txt-min-len.md @@ -0,0 +1,232 @@ +# A short CMD_SEND_CHANNEL_TXT_MSG frame stops the node + +Status: fixed, verified on hardware. Behaviour change: a frame below 7 bytes is +answered with an error instead of being parsed. + +## Symptom + +A companion frame `03 00 00` (CMD_SEND_CHANNEL_TXT_MSG, 3 bytes) makes the node +stop answering and it never comes back. Measured on a LilyGo T-Echo running `dev` +428a95c6 over the USB companion interface: + +``` +CH_TXT len=7 -> RESP_CODE_OK +GET_STATS core -> uptime=78s +CH_TXT len=3 -> NO REPLY +DEVICE_QUERY -> NO REPLY (three times over 8 s) +``` + +USB stayed enumerated under the application id `0x239a:0x8029` while the main +loop was gone. Neither a 1200 baud touch nor the reset button brought it back, +only unplugging the cable, so the fault state is not something a user recovers +from by restarting the app. + +## Cause + +`handleCmdFrame()` reads the fields of this command at fixed offsets: `txt_type` +at 1, `channel_idx` at 2, a 4 byte timestamp at 3, and the text from 7 on. The +branch had no length guard, so the text length is computed as `len - i` with +`i == 7` (`examples/companion_radio/MyMesh.cpp:1178`). + +`len` is a `size_t`. For `len == 3` the subtraction wraps, and the result reaches +`BaseChatMesh::sendGroupMessage()` through an `int` parameter as `-4`. There the +clamp is + +```c +if (text_len + prefix_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN - prefix_len; +memcpy(ep, text, text_len); +``` + +A negative value passes that test, and `memcpy` converts the length back to a +`size_t`: the call asks for 4294967292 bytes into a 197 byte stack buffer +(`src/helpers/BaseChatMesh.cpp:518,526-527`). + +The same chain reproduces on the host, away from the radio and the channel table: + +``` +$ bugdemo 5 calling sendGroupMessage(text_len=5) ... returned false exit=0 +$ bugdemo -4 calling sendGroupMessage(text_len=-4) ... exit=139 +``` + +### Root versus proximate + +The missing `len >= 7` is the proximate cause. One layer down, the frame handler +derives payload lengths by subtraction without ever testing that the frame is +long enough; 35 of its 62 branches state their minimum in the condition and this +one did not. The other 14 are the subject of +`docs/fixes/companion-cmd-frame-min-len.md`. + +One layer further down, `sendGroupMessage()` trusts a length its caller computed, +although its own sibling `sendGroupData()` in the same file already refuses a +negative one (`src/helpers/BaseChatMesh.cpp:538-541`). The library had no +invariant of its own, so any caller could do the same damage. + +## Fix + +Two guards, one per mistake. + +- `examples/companion_radio/MyMesh.cpp`: the branch condition gains `len >= 7`, + the way the neighbouring `CMD_SEND_TXT_MSG` branch states `len >= 14`. A + shorter frame falls through to the existing final `else` and is answered with + `ERR_CODE_UNSUPPORTED_CMD`, which is what the 35 already guarded branches + answer for a frame that is too short. A frame of exactly 7 bytes keeps working + and sends an empty message. +- `src/helpers/BaseChatMesh.cpp`: `sendGroupMessage()` refuses `text_len < 0`, + mirroring `sendGroupData()`. + +### Alternatives considered + +- **Only the frame guard.** Rejected: it leaves the library able to corrupt + memory for any other caller, and it cannot be tested on the host, because + `MyMesh.cpp` needs `target.h`, the board, the radio driver, the sensors, the + telemetry and the store. +- **Only the library guard.** Rejected: the frame handler would still read a + timestamp out of the tail of an earlier frame and send a message with it. +- **A central table of minimum lengths**, checked once at the top of + `handleCmdFrame()` and answering `ERR_CODE_ILLEGAL_ARG`. This is the better + long term shape: the minimums would live in one place, the next command could + not forget one, and the table would be testable on the host. It is not part of + this fix because it changes the answer for the 35 branches that already guard, + from `ERR_CODE_UNSUPPORTED_CMD` to `ERR_CODE_ILLEGAL_ARG`, which is a visible + protocol change and does not belong in the same change as a crash fix. +- **Clamping a negative length to 0** instead of refusing. Rejected: it would + send an empty message for a frame the sender got wrong, and silence is worse + than an error code here. + +## Verification + +### Host tests + +`test/test_chat_group_msg` runs in the new `native_chat` environment, which +builds the real `Utils.cpp`, `Packet.cpp`, `Dispatcher.cpp`, `Mesh.cpp`, +`BaseChatMesh.cpp`, `AdvertDataHelpers.cpp` and `TxtDataHelpers.cpp` against +fakes for radio, clock, RNG and packet manager, and against the link time +identity stubs the board crypto would otherwise provide. The packet manager keeps +every packet that was queued, so "refused before sending" and "sent" are told +apart by whether a packet was queued at all, and the payload length the chat +layer produced is observable. + +The suite was written first and is red on the unchanged code: + +``` +$ pio test -e native_chat +Program received signal SIGBUS (Bus error: 10) + +$ pio test -e native_chat -a "--gtest_filter=-GroupMessageTest.Refuses*" +6 test cases: 6 succeeded in 00:00:03.471 +``` + +The six cases that describe today's behaviour pass before the fix, so the +expectations are right; only the two negative lengths bring the process down. +After the fix, on the committed tree with nothing uncommitted: + +``` +native test_mesh_tables PASSED +native test_config_serializer PASSED +native test_utils PASSED +native test_utf8_helpers PASSED +native test_routing_policy PASSED +native_chat test_chat_group_msg PASSED +native_kiss_modem test_kiss_modem PASSED +62 test cases: 62 succeeded in 00:00:33.664 +``` + +`test_companion_node_prefs` is reported SKIPPED. It is also SKIPPED on unchanged +`dev` (46 of 46 there), so that is not a change from this fix. + +`native_chat` is added to the environment list in +`.github/workflows/run-unit-tests.yml`; without that the workflow would never run +the suite, because it names its environments explicitly. + +### Mutation tests + +Each fault was introduced on its own and the suite had to fail: + +| Mutation | Result | +|----------|--------| +| guard removed | SIGBUS | +| guard only below -10 | SIGBUS | +| guard returns `true` | 2 cases fail | +| clamp forgets the prefix | `ClampsTextThatIsTooLong` fails | +| clamp `>` becomes `>=` | not caught, see below | +| datagram length off by one | `SendsTextAcrossABlockBoundary` fails | +| guard refuses 0 as well | `SendsEmptyText` fails | + +The clamp mutation is an equivalent mutant, not a gap: `>` and `>=` differ only +when `text_len + prefix_len == MAX_TEXT_LEN`, and in that case the clamp assigns +`MAX_TEXT_LEN - prefix_len`, which is the value `text_len` already has. The +behaviour is identical for every input. + +The suite cannot cover the frame guard, and that was measured rather than +assumed: with `len >= 7` mutated to `len >= 1`, `native_chat` still reports 8 of +8 passing, because the environment does not build `MyMesh.cpp`. That half of the +fix rests on the hardware check below. + +### Builds + +The 18 environments of the review series plus both T-Echo companion environments +were built from `dev` (428a95c6) and from this branch with the same toolchain. All +20 build on both sides. RAM is unchanged everywhere. Only the environments that +build the companion example change size at all: + +| Environment | Flash `dev` | Flash fix | Delta | +|---|---:|---:|---:| +| `Heltec_v3_companion_radio_ble` | 1,666,437 | 1,666,457 | +20 | +| `Heltec_v3_companion_radio_wifi` | 1,102,677 | 1,102,693 | +16 | +| `RAK_4631_companion_radio_ble` | 462,548 | 462,532 | -16 | +| `ProMicro_companion_radio_usb` | 357,300 | 357,284 | -16 | +| `LilyGo_T-Echo_companion_radio_ble` | 374,096 | 374,080 | -16 | +| `LilyGo_T-Echo_companion_radio_usb` | 366,704 | 366,688 | -16 | + +The other 14 environments, all repeater, room server and sensor roles, report the +same flash and RAM size as `dev`. Two independent readings say why, and the +second corrects the obvious guess: + +- None of the 14 has `+<../examples/companion_radio/*.cpp>` in its + `build_src_filter`, so `MyMesh.cpp` is not part of those builds. Exactly the + six environments that do have it are the six whose size changed. +- `BaseChatMesh.cpp` *is* compiled in all of them (the object file is there), but + `sendGroupMessage` is not linked into their images. `nm` finds the symbol once + in `RAK_4631_companion_radio_ble` and not at all in `Heltec_v3_repeater` or + `RAK_4631_repeater`, on both sides of the change: the roles never call it, so + the linker drops it. + +Their images are not byte identical: the difference is 69 bytes of build +metadata, located by comparing the two binaries and reading the differing +regions. For `Heltec_v3_repeater` they are the 32 byte ELF SHA-256 in the ESP32 +app descriptor at offset 177, the `__TIME__` string (`"22:32:04"` against +`"23:05:08"`) at offset 274217, and the 33 byte image hash esptool appends at the +end. The stock environments do not set `SOURCE_DATE_EPOCH`, so two builds of the +same source always differ in those bytes. + +### Hardware + +LilyGo T-Echo `techo-3dcf`, USB companion build, images marked through +`FIRMWARE_BUILD_DATE` and confirmed by `ver` before each run. + +Before, on `dev` reporting `v1.18.0 (Build: f1-dev)`: see the symptom above. + +After, on this branch reporting `v1.18.0 (Build: f1-fix1)`: + +``` +CH_TXT len=1 .. len=6 -> ERR UNSUPPORTED_CMD (each one) +uptime -> 23 s before, 35 s after, so no restart +CH_TXT len=7 (empty) -> RESP_CODE_OK +CH_TXT len=12 (text) -> RESP_CODE_OK, node transmits +CH_TXT txt_type=1 -> ERR UNSUPPORTED_CMD, unchanged +DEVICE_QUERY -> answers, before and after +``` + +Downgrading to the `dev` image afterwards left every stored value unchanged: +tx_power 22, freq 869.618, bw 62.5, sf 8, cr 8, rx_delay 0, airtime_factor 1.0, +name `android_dev_3`, BLE pin, path hash mode 2, 68 contacts. + +Two observations from the runs that this fix does not change, noted because they +look like findings and are not: + +- A message addressed to a channel index that is in range but has no channel + configured is accepted. `BaseChatMesh::getChannel()` returns true for every + index below `MAX_GROUP_CHANNELS` and hands out a zeroed channel, so the node + sends on a zero secret. That is unchanged upstream behaviour. +- The crash leaves USB enumerated while the main loop is dead, so a host sees a + device that cannot be opened. That is a property of the fault, not of the fix. diff --git a/docs/fixes/companion-cmd-frame-min-len.md b/docs/fixes/companion-cmd-frame-min-len.md new file mode 100644 index 00000000000..17542fa0bac --- /dev/null +++ b/docs/fixes/companion-cmd-frame-min-len.md @@ -0,0 +1,246 @@ +# Fourteen companion commands act on the previous frame's bytes + +Status: fixed, measured on hardware for 8 of the 14 commands. Behaviour change: +a frame too short for its command is answered with an error instead of being +parsed. + +## Symptom + +`handleCmdFrame()` reads the fields of each command at fixed offsets derived from +the command code. Thirty-five of its sixty-two branches state the length they +need in the condition, for example `CMD_SEND_TXT_MSG && len >= 14`. Fourteen do +not, or test `cmd_frame[1]` before the length. Those branches read fields that +the frame does not contain. + +What they read is not uninitialised memory. `cmd_frame` is a member of `MyMesh` +(`examples/companion_radio/MyMesh.h:267`), `ArduinoSerialInterface::rx_buf` is a +member too (`src/helpers/ArduinoSerialInterface.h:12`), and `checkRecvFrame()` +copies only `_frame_len` bytes out (`.cpp:66`). Everything above `len` is +therefore the tail of an earlier frame, and that is what the handler acts on. + +For these fourteen commands no read leaves the buffer: their largest +unconditional offset is 135 and the buffer holds `MAX_FRAME_SIZE + 1 = 177` +bytes. `checkRecvFrame()` also clamps `len` to `MAX_FRAME_SIZE` and never returns +0, so `len` is always between 1 and 176. For them this is a stale data bug, not +an out of bounds bug. + +Two related defects in the same function are deliberately not part of this +change, because they are a different fault and each deserves its own review: + +- `CMD_SEND_CHANNEL_TXT_MSG` turns the length itself into the argument of a + `memcpy`. That one is `docs/fixes/companion-channel-txt-min-len.md`. +- `CMD_SET_DEFAULT_FLOOD_SCOPE` calls `strlen((char *) &cmd_frame[1])` with no + bound. `len` is clamped to 176, so a 176 byte frame of non-zero bytes fills + offsets 0 to 175, and if the stale byte at 176 is non-zero too there is no zero + left in `cmd_frame`. The scan then runs past the array into `out_frame`, which + is the next member, and stops at the first zero there. The `strcpy` that follows + is safe, because `n < 31` is tested and the field is `char[31]`, and `n` is + never returned, so nothing leaks and nothing is written out of bounds. It is + still an unbounded read of data the host controls, and a length check cannot fix + it: it needs `strnlen` or a scan limited to `len`. + +## The minimums + +Each is the offset just past the last byte its branch reads without first testing +`len`. + +| Command | Code | Minimum | What it reads unconditionally | +|---|---:|---:|---| +| `CMD_SEND_CHANNEL_DATA` | 62 | 3 + path + 2 | `channel_idx`, `path_len`, the path, then the 2 byte `data_type` | +| `CMD_ADD_UPDATE_CONTACT` | 9 | 136 | `updateContactFromFrame()` reads to offset 135 | +| `CMD_REMOVE_CONTACT` | 15 | 33 | `pub_key[1..32]` | +| `CMD_SHARE_CONTACT` | 16 | 33 | `pub_key[1..32]` | +| `CMD_GET_CONTACT_BY_KEY` | 30 | 33 | `pub_key[1..32]` | +| `CMD_SET_RADIO_PARAMS` | 11 | 11 | `freq`, `bw`, `sf`, `cr`; the repeat flag stays optional | +| `CMD_SET_RADIO_TX_POWER` | 12 | 2 | `power` | +| `CMD_SET_TUNING_PARAMS` | 21 | 9 | `rx_delay_base`, `airtime_factor`, neither range checked | +| `CMD_SET_OTHER_PARAMS` | 38 | 2 | `manual_add_contacts`; the rest stays optional | +| `CMD_SET_PATH_HASH_MODE` | 61 | 3 | operand order only, the constant was already there | +| `CMD_REBOOT` | 19 | 7 | `memcmp` against `"reboot"` | +| `CMD_FACTORY_RESET` | 51 | 6 | `memcmp` against `"reset"` | +| `CMD_SET_AUTOADD_CONFIG` | 58 | 2 | `autoadd_config`; `autoadd_max_hops` stays optional | +| `CMD_SEND_PATH_DISCOVERY_REQ` | 52 | 34 | operand order only, the constant was already there | + +`CMD_ADD_UPDATE_CONTACT` is the only branch whose existing constant is raised, 36 +to 136. `updateContactFromFrame()` reads `type`, `flags`, `out_path_len`, 64 bytes +of `out_path`, 32 bytes of `name` and the 4 byte `last_advert_timestamp` without +testing `len`; only `gps_lat`, `gps_lon` and `lastmod` are optional +(`MyMesh.cpp:2048-2070`). No valid frame can be shorter: the reverse direction +`writeContactRespFrame()` writes the same fields in the same order and sends 148 +bytes. + +## Fix + +Twelve branches gain `&& len >= N` in the condition, so a short frame falls +through to the existing final `else` and is answered the way the 35 already +guarded branches answer it. `CMD_SET_PATH_HASH_MODE` and +`CMD_SEND_PATH_DISCOVERY_REQ` only have their existing length test moved in front +of the `cmd_frame[1]` test. `CMD_SEND_CHANNEL_DATA` keeps its explicit `len < 4` +check and gains a second one for the `data_type` behind the path, whose offset is +only known once `path_len` has been parsed; that is the shape +`CMD_SEND_RAW_DATA` already uses in the same function. + +### Alternatives considered + +- **A central table of minimum lengths**, checked once at the top of + `handleCmdFrame()` and answering `ERR_CODE_ILLEGAL_ARG`. The better long term + shape: one place for the minimums, nothing for the next command to forget, and + host testable. Left out of this fix because it changes the answer for the 35 + branches that already guard, from `ERR_CODE_UNSUPPORTED_CMD` to + `ERR_CODE_ILLEGAL_ARG`, which is a visible protocol change and does not belong + in the same change as the guards themselves. Worth proposing on its own. +- **Clearing `cmd_frame` between frames.** Rejected: it hides the bug instead of + fixing it. A command would then act on zeros rather than on the previous + frame, which for `CMD_SET_TUNING_PARAMS` means writing 0 to the prefs and for + `CMD_REMOVE_CONTACT` means a lookup for the all zero key. Both are still wrong, + and the cost is a memset per frame. +- **Answering `ERR_CODE_ILLEGAL_ARG` from each new guard** through an explicit + check and early return, as `CMD_SEND_CHANNEL_DATA` does. Rejected for the + twelve: it adds twelve new code paths where extending the condition adds none, + and it makes this change inconsistent with the 35 neighbours. + +## Verification + +### No host test, and that was measured + +There is no host test for this change. The `native_chat` environment added for +the group message length does not build `MyMesh.cpp`, and compiling it on the +host would need stand-ins for `target.h`, the board, the radio driver, the +sensors, the telemetry and the store. + +That the host suite cannot see this file was measured rather than assumed: with +`len >= 7` in the neighbouring branch mutated to `len >= 1`, `native_chat` still +reports 8 of 8 passing. The suites that do exist stay green: + +``` +native test_mesh_tables PASSED +native test_config_serializer PASSED +native test_utils PASSED +native test_utf8_helpers PASSED +native test_routing_policy PASSED +native_kiss_modem test_kiss_modem PASSED +54 test cases: 54 succeeded in 00:00:51.743 +``` + +### Hardware, before and after + +LilyGo T-Echo `techo-3dcf`, USB companion build, images marked through +`FIRMWARE_BUILD_DATE` and confirmed by `ver` before each run: `f1-dev` for +`dev` 428a95c6, `f1-fix2` for this branch. Every step first sends a longer frame, +so the stale tail is known, and then a frame too short for the command. + +| Step | On `dev` | On this branch | +|---|---|---| +| `[21]` after a CLI frame carrying `"get name"` | tuning (0, 1000) becomes (544499136, 1701667200) | `ERR_CODE_UNSUPPORTED_CMD`, tuning stays (0, 1000) | +| `[12]` after `GET_CHANNEL 5` | tx_power 22 becomes 5 | error, tx_power stays 22 | +| `[38]` after `GET_CHANNEL 0` | manual_add 1 becomes 0 | error, manual_add stays 1 | +| `[58]` after `GET_CHANNEL 0` | autoadd (5, 0) becomes (0, 0) | error, autoadd stays (5, 0) | +| `[30]` after a full keyed frame | a complete contact record is returned | error, nothing returned | +| `[62]` with len 4 | `RESP_CODE_OK`, a datagram goes out with a `data_type` built from a stale byte | `ERR_CODE_ILLEGAL_ARG` | +| `[9]` with len 36 after a 141 byte CLI frame of `"Z"` | a contact is created with `out_path` = 5a5a..., `name` = "ZZZZ...Z", `last_advert_timestamp` = 0x5a5a5a5a | error, no contact created | +| `[19]` after a CLI frame carrying `"reboot"` | uptime 258 s becomes 19 s, the node rebooted | error, uptime 68 s becomes 73 s, no restart | +| `[61]`, `[52]` with len 1 | `ERR_CODE_UNSUPPORTED_CMD` | unchanged, as expected: the only fault there is one read above `len` | + +The `[21]` numbers are worth spelling out, because they identify the source +exactly. The stale bytes are `"get "` and `"name"`, which as little endian +uint32 are 544499047 and 1701667182. The prefs hold them as float and the read +multiplies by 1000 again; the same round trip on the host gives 544499072 and +1701667200. `"name"` matches to the byte, `"get "` is one float32 step out, which +is what the nRF52 build allows because it compiles with `-Ofast` +(KB `nrf52-ofast-nan.md`). + +`CMD_FACTORY_RESET` was deliberately not triggered on hardware. It formats the +file system and takes identity, prefs, contacts and channels with it. It is the +same mechanism as `CMD_REBOOT`, which is shown above: a frame carrying the text +at offset 1, then a one byte frame with the command code. + +Valid frames still work on this branch, checked at the exact minimum where the +command allows it: `[21]` with 9 bytes, `[12]` with 2, `[58]` with 3, `[61]` with +3, `[15]` with 33 and `[30]` with 33 all answered as before, and the node stayed +up throughout. + +Afterwards every value was set back and read again: tuning (0, 1000), tx_power +22, manual_add 1, freq 869618, sf 8, cr 8, autoadd (5, 0), path hash mode 2, BLE +pin 207263, default scope "hansemesh" with its key, 68 contacts, and all three +test contacts removed. Fifteen checks, all matching the state recorded before the +runs. + +### What the hardware runs do not cover, and how it is covered instead + +The runs show that a short frame is refused. They do not show, for every command, +that a frame of exactly the minimum length is still accepted; that was exercised +for six of them (above) and not for the rest, because the board had to go back to +another session. Three further readings close that gap: + +- Two of the fourteen are not new constants at all. `CMD_SET_PATH_HASH_MODE` + already required `len >= 3` and `CMD_SEND_PATH_DISCOVERY_REQ` already required + `len >= 2 + PUB_KEY_SIZE`; only the operand order changed, so no number can be + wrong there. +- Every sender in this repository was checked against the minimums. + `tools/refactoring/nodetool.py:141` sends `CMD_REBOOT` as exactly seven bytes, + `[19]` followed by `"reboot"`, which is exactly the minimum: a guard one higher + would have broken it. `docs/companion_protocol.md` gives + `3E 01 FF FF FF A1 B2 C3` for `CMD_SEND_CHANNEL_DATA` (8 bytes against a + minimum of 5) and `03 00 01 D2 02 96 49 48 65 6C 6C 6F` for + `CMD_SEND_CHANNEL_TXT_MSG` (12 against 7). +- For the rest the constant is, by construction, the offset just past the last + byte the branch reads without testing `len`. A frame that contains the + command's own mandatory fields satisfies it. The one raised constant, + `CMD_ADD_UPDATE_CONTACT`, is additionally pinned by the reverse direction + described above. + +### Builds + +The 18 environments of the review series plus both T-Echo companion environments +were built from `dev` (428a95c6) and from this branch with the same toolchain. + +All 20 build on both sides. 14 report the same flash and RAM size as `dev`. + +| Environment | Flash `dev` | Flash fix | Flash delta | RAM delta | +|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | 1,174,449 | 1,174,449 | +0 | +0 | +| `Heltec_v3_room_server` | 1,169,705 | 1,169,705 | +0 | +0 | +| `Heltec_v3_companion_radio_ble` | 1,666,437 | 1,666,393 | -44 | +0 | +| `Heltec_v3_companion_radio_wifi` | 1,102,677 | 1,102,633 | -44 | +0 | +| `Heltec_v3_sensor` | 1,180,593 | 1,180,593 | +0 | +0 | +| `RAK_4631_repeater` | 440,952 | 440,952 | +0 | +0 | +| `RAK_4631_room_server` | 434,088 | 434,088 | +0 | +0 | +| `RAK_4631_companion_radio_ble` | 462,548 | 462,372 | -176 | +0 | +| `RAK_4631_sensor` | 437,544 | 437,544 | +0 | +0 | +| `PicoW_repeater` | 491,972 | 491,972 | +0 | +0 | +| `wio-e5-mini_repeater` | 203,552 | 203,552 | +0 | +0 | +| `wio-e5-mini_sensor` | 202,904 | 202,904 | +0 | +0 | +| `RAK_3112_repeater_bridge_espnow` | 1,175,717 | 1,175,717 | +0 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | 240,368 | 240,368 | +0 | +0 | +| `ProMicro_repeater` | 345,424 | 345,424 | +0 | +0 | +| `ProMicro_room_server` | 338,456 | 338,456 | +0 | +0 | +| `ProMicro_companion_radio_usb` | 357,300 | 357,124 | -176 | +0 | +| `ProMicro_sensor` | 341,900 | 341,900 | +0 | +0 | +| `LilyGo_T-Echo_companion_radio_ble` | 374,096 | 373,920 | -176 | +0 | +| `LilyGo_T-Echo_companion_radio_usb` | 366,704 | 366,528 | -176 | +0 | + + +The six that changed are exactly the six that build `MyMesh.cpp`, read from their +`build_src_filter` rather than guessed from the numbers. The other fourteen are +repeater, room server and sensor roles, which do not build the companion example +at all. Their images are not byte identical even so, because the stock +environments do not set `SOURCE_DATE_EPOCH`; the differing bytes are the compile +time string and the two hashes. + +Those 20 cover two of the five platform entries that build `MyMesh.cpp`. Measured +with `pio project config --json-output`, the file is compiled by 222 companion +environments across `nordicnrf52` (93), `platformio/espressif32@6.11.0` (112), +`maxgerhardt/platform-raspberrypi` (7), +`pioarduino/platform-espressif32` 53.03.13-1 (6) and `ststm32@19.7.1` (4). The +20 above only reach the first two, and the fork CI builds the other three +toolchains with repeater environments, which do not compile this file. One +companion environment per missing platform was therefore built as well. + +One companion environment per platform that the 20 above do not cover. All 3 build on both sides. + +| Environment | Flash `dev` | Flash fix | Flash delta | RAM delta | +|---|---:|---:|---:|---:| +| `PicoW_companion_radio_ble` | 633,124 | 633,028 | -96 | -32 | +| `LilyGo_Tlora_C6_companion_radio_ble_` | 1,446,483 | 1,446,549 | +66 | +0 | +| `wio-e5-mini_companion_radio_usb` | 208,024 | 207,992 | -32 | +0 | + diff --git a/docs/fixes/companion-ui-robustness.md b/docs/fixes/companion-ui-robustness.md new file mode 100644 index 00000000000..b5aa4440309 --- /dev/null +++ b/docs/fixes/companion-ui-robustness.md @@ -0,0 +1,174 @@ +# A node without a display faults in the ui-tiny status bar + +Status: fixed. NOT verified at runtime, see the limits at the end. Behaviour +change: a companion with no working display starts instead of faulting. + +## Symptom + +`UITask::begin()` in `ui-tiny` dereferences `_display` one line below the guard +that was written for exactly that pointer +(`examples/companion_radio/ui-tiny/UITask.cpp:447-450`): + +```c + if (_display != NULL) { + _display->turnOn(); + } + _statusBar.begin(_display->width()); +``` + +`main.cpp` sets `disp` to NULL and only assigns it when the driver starts +(`examples/companion_radio/main.cpp:143-146`), then passes it in unchanged +(`main.cpp:285`). So on a node whose panel does not answer, `_display` is NULL +and `setup()` faults in the status bar call. The node never joins the mesh at +all, and a user cannot tell a dead display apart from a dead node. + +`ui-orig` has a second, much smaller fault in the same function: the `strdup` of +`FIRMWARE_VERSION` at `ui-orig/UITask.cpp:55` is never freed. `begin()` runs once +per boot, so nothing grows; it is a handful of bytes that stay allocated. + +## Reachability + +The display fault needs `display.begin()` to return false. Measured, not assumed: + +- `U8g2Display::begin()` returns what `_u8g2.begin()` returns + (`src/helpers/ui/U8g2Display.h:45-57`), so an unpopulated, dead or + wrongly addressed I2C panel is enough. +- `variants/lilygo_techo_card/platformio.ini:20` sets + `DISPLAY_CLASS=U8g2Display`, and that variant is the only one that compiles + `ui-tiny`. +- `NullDisplayDriver::begin()` always returns false + (`src/helpers/ui/NullDisplayDriver.h:8`) and 16 variants use it, which would be + a second path into the same fault. None of those 16 compiles `ui-tiny`, so that + path does not reach this line. `ui-orig`, which several of them do compile, has + the guard right. + +So the fault is reachable on a LilyGo T-Echo Card whose panel fails to +initialise, and nowhere else today. What makes it an oversight rather than a +decision is that every other `_display` use in the same file tests the pointer +first: lines 517, 532, 699, 746, 760, 775 and 783. + +## Fix + +The status bar call moves inside the guard that is already there. Nothing is lost +when there is no display: + +- The status bar is only ever drawn through `*_display`, inside + `if (_display != NULL && _display->isOn())` in `loop()`. +- `ScrollingStatusBar`'s constructor initialises every member, including + `_display_width(72)` (`ui-tiny/ScrollingStatusBar.h:35`), so the object stays in + a defined state when `begin()` is not called. Nothing reads uninitialised + memory in the headless case. + +Checked by walking the block depth of all four `_statusBar` uses rather than by +eye: each one now sits under an `if (_display != NULL ...)`. + +For `ui-orig`, the `strdup` becomes a local array sized to the field it feeds. +The copy only has to outlive the `sprintf` two lines later. `StrHelper::strzcpy` +bounds it, which also removes the chance of a long `FIRMWARE_VERSION` walking +past `_version_info[32]`. + +### Alternatives considered + +- **Use `NullDisplayDriver` for the headless case** instead of a NULL pointer, + and drop the guards. That is the shape the codebase is reaching for; the driver + exists and already returns false from `begin()`. Rejected here: it changes + `main.cpp` and every UI task for all 222 companion environments, which is a + refactor and not a fix, and it would hide this defect rather than close it. + Worth proposing on its own. +- **Guard inside `ScrollingStatusBar::begin()`** by passing the pointer instead + of the width. Rejected: it moves the NULL question into a class that otherwise + knows nothing about the display, and the other three call sites would still + need the guard. +- **Free the `strdup`** instead of removing it. Rejected: there is no reason to + allocate at all for a copy that lives for two lines. + +## Verification + +### Builds + +All 26 environments that compile `ui-tiny` or `ui-orig` were built from `dev` +(428a95c6) and from this branch with the same toolchain. That is every +environment that contains the changed code, not a sample: measured with +`pio project config --json-output`, the two files are compiled by 18 +`nordicnrf52` environments, 7 `platformio/espressif32@6.11.0` and 1 +`ststm32@19.7.1`, and all 26 are in the list below. + +All 26 build on both sides, and RAM is unchanged everywhere. + +| Environment | Flash `dev` | Flash fix | Flash delta | RAM delta | +|---|---:|---:|---:|---:| +| `LilyGo_T-Echo_Card_companion_radio_ble` | 347,840 | 347,840 | +0 | +0 | +| `LilyGo_T-Echo_Card_companion_radio_usb` | 342,096 | 342,096 | +0 | +0 | +| `MeshTracker_X1_companion_radio_ble` | 323,004 | 322,940 | -64 | +0 | +| `MeshTracker_X1_companion_radio_usb` | 317,324 | 317,276 | -48 | +0 | +| `Minewsemi_me25ls01_companion_radio_ble` | 317,516 | 317,452 | -64 | +0 | +| `Minewsemi_me25ls01_companion_radio_usb` | 311,804 | 311,756 | -48 | +0 | +| `R1Neo_companion_radio_ble` | 383,688 | 383,640 | -48 | +0 | +| `R1Neo_companion_radio_usb` | 376,280 | 376,232 | -48 | +0 | +| `RAK_3112_companion_radio_ble` | 1,649,285 | 1,649,285 | +0 | +0 | +| `RAK_3112_companion_radio_usb` | 638,353 | 638,353 | +0 | +0 | +| `RAK_3112_companion_radio_wifi` | 1,087,681 | 1,087,681 | +0 | +0 | +| `RAK_WisMesh_Tag_companion_radio_ble` | 383,992 | 383,928 | -64 | +0 | +| `RAK_WisMesh_Tag_companion_radio_usb` | 376,568 | 376,520 | -48 | +0 | +| `ThinkNode_M3_companion_radio_ble` | 318,048 | 318,000 | -48 | +0 | +| `ThinkNode_M3_companion_radio_usb` | 312,344 | 312,296 | -48 | +0 | +| `ThinkNode_M4_companion_radio_ble` | 318,416 | 318,368 | -48 | +0 | +| `ThinkNode_M4_companion_radio_usb` | 312,712 | 312,648 | -64 | +0 | +| `ThinkNode_M7_companion_radio_ble` | 1,624,645 | 1,624,649 | +4 | +0 | +| `ThinkNode_M7_companion_radio_ethernet` | 1,057,533 | 1,057,537 | +4 | +0 | +| `ThinkNode_M7_companion_radio_usb` | 584,381 | 584,381 | +0 | +0 | +| `ThinkNode_M7_companion_radio_wifi` | 1,023,861 | 1,023,861 | +0 | +0 | +| `Xiao_nrf52_companion_radio_ble` | 378,152 | 378,088 | -64 | +0 | +| `Xiao_nrf52_companion_radio_usb` | 372,440 | 372,392 | -48 | +0 | +| `t1000e_companion_radio_ble` | 309,180 | 309,116 | -64 | +0 | +| `t1000e_companion_radio_usb` | 303,508 | 303,460 | -48 | +0 | +| `wio-e5-mini_companion_radio_usb` | 208,024 | 207,976 | -48 | +0 | + +The sizes split by platform, and the split was chased down rather than waved +away. The 18 `nordicnrf52` environments lose 48 to 64 bytes, the ESP32 ones are +unchanged, and two of them gain 4 bytes. That is not image padding: comparing the +ELF sections instead of the padded image shows `.text` identical on both sides for +`RAK_3112_companion_radio_ble` (1,318,998) and genuinely 4 bytes larger for +`ThinkNode_M7_companion_radio_ble` (1,297,174 to 1,297,178). + +`nm` explains it. On `t1000e_companion_radio_ble` the `strdup` symbol is present +on `dev` and gone on this branch: this call site was its only user, so the linker +drops the function and its dependencies. On `RAK_3112_companion_radio_ble` the +symbol is present on both sides, because something else in the ESP32 build still +calls it, so removing one call site frees nothing and the replacement costs a few +bytes. The `ui-tiny` change, which only moves a call inside a guard that is +already there, leaves `.text` identical (346,648 on both sides). + +The 18 environments of the review series plus both T-Echo companion environments +were built as well, to show that nothing else moved. None of them compiles +`ui-tiny` or `ui-orig`. + +All 20 build on both sides and every one of them reports the same flash and +RAM size as `dev`, with not a single byte of difference. That is the expected +result and it was stated before the builds ran: none of the 20 has `ui-tiny` or +`ui-orig` in its `build_src_filter`, so none of them contains the changed code. +Any deviation would have meant something else moved. + +### What is not verified + +- **No automated test.** `UITask` pulls in the display driver, the board and the + mesh, so it does not build in a native environment. A host harness for it would + be a great deal larger than the change it guards. +- **No hardware run.** The display path needs a LilyGo T-Echo Card whose panel + fails to initialise. No board within reach of this work runs `ui-tiny` at all: + the T-Echo used for the other two fixes in this series builds `ui-new`. + The fix is therefore **NOT verified at runtime**. The evidence is the source + chain above, the reachability check, and the builds. +- The `ui-orig` change is verified only in the sense that it produces the same + string: both implementations were run against each other on the host for twelve + inputs, including a leading dash, several dashes and none, and the resulting + `_version_info` is identical in every case. A leak of a few bytes once per boot + has no runtime symptom to observe. + +### Observation, not fixed here + +`sprintf(_version_info, "%s (%s)", version, FIRMWARE_BUILD_DATE)` at +`ui-orig/UITask.cpp:62` can still write past `_version_info[32]` if +`FIRMWARE_VERSION` and `FIRMWARE_BUILD_DATE` are long enough together. This +change bounds the version part; the format as a whole is unchanged and out of +scope here. diff --git a/docs/fixes/espnow-rxgain-return.md b/docs/fixes/espnow-rxgain-return.md new file mode 100644 index 00000000000..caa41c0eb3e --- /dev/null +++ b/docs/fixes/espnow-rxgain-return.md @@ -0,0 +1,260 @@ +# Fix: ESPNOWRadio::setRxBoostedGainMode returns no value + +Status: ready for review. Behaviour change: none observable today, see Reach below. + +## Context + +`src/helpers/esp32/ESPNOWRadio.h:41` declares a `bool` and never returns one: + +```cpp +virtual bool setRxBoostedGainMode(bool) { } +``` + +Flowing off the end of a value returning function is undefined behaviour. The +comment above it says both functions "do nothing for ESP-NOW", which is the right +intent, but the neighbour on the next line gets it right (`getRxBoostedGainMode() +const { return false; }`) and so does the same no-op in the LoRa base class, +`RadioLibWrappers.h:78`. + +The value is read. `src/helpers/CommonRadioPrefs.cpp:126` handles the CLI command +`set radio.rxgain on|off` and decides the reply from it: + +```cpp +if (radio_driver.setRxBoostedGainMode(enabled)) { + strcpy(reply, "OK"); +} else { + strcpy(reply, "Error: unsupported"); +} +``` + +`mesh::Radio` (`src/Dispatcher.h:22`) does not declare `setRxBoostedGainMode`, so +this call binds statically to whichever type `radio_driver` has in the variant. +Two variants give it the ESP-NOW driver: + +- `variants/generic_espnow/target.cpp:7` +- `variants/sensecap_indicator-espnow/target.cpp:7` + +On those, `set radio.rxgain on` answers from whatever happens to be in the return +register. Three other callers ignore the result, so they are unaffected in +practice: `examples/companion_radio/MyMesh.cpp:1007`, +`examples/simple_repeater/MyMesh.cpp:983`, +`examples/simple_room_server/MyMesh.cpp:727`. + +## How it got there + +Commit 1f9bb674 (26 June 2026, "return bool when setting rx boost") changed the +return type across the radio drivers. In this file it changed exactly one line: + +``` +- virtual void setRxBoostedGainMode(bool) { } ++ virtual bool setRxBoostedGainMode(bool) { } +``` + +The signature became a `bool` and the empty body stayed empty. The same commit +gave the other drivers a real return value, and `CommonCLI.h` the branch that +reads it. So this is not an oversight in a new file, it is one line of a sweep +that the compiler could not flag, because the framework builds project sources +with `-w` (measured under Verification). + +## Decision + +Return `false`, character for character the same as the no-op in +`RadioLibWrappers.h:78`: + +```cpp +virtual bool setRxBoostedGainMode(bool) { return false; } +``` + +`false` is the truthful answer: ESP-NOW has no boosted gain mode to switch on, so +`set radio.rxgain on` should report `Error: unsupported` rather than claim success. + +## Alternatives considered + +- **Return `true`.** It would silence the compiler just as well and is what an + author optimising for a quiet CLI might pick. Rejected: it would report success + for something that did not happen. Note that no compile check can tell these two + apart, see the gap in the mutation table below. +- **Make the function `void`.** Honest about doing nothing, but then the caller in + `CommonRadioPrefs.cpp` no longer compiles (mutation 3 below shows exactly that + error), so it would have to grow a driver specific branch. More code for no gain. +- **Declare the pair in `mesh::Radio` and give the base a `false` default.** This + is the real cure for the underlying shape, since every driver currently invents + the same two functions. Rejected here: it touches the central radio interface + and every driver, which is a refactoring step and not a fix for undefined + behaviour in one line. + +## Reach: compiled widely, emitted nowhere, reachable in nothing that builds + +The file is compiled in many environments, the changed function reaches no image, +and the one call site that reads its value belongs to two variants that do not +build. Each of those three steps is measured below, because the easy version of +this claim is wrong: unchanged binary sizes do not mean a file was not compiled. + +1. **The two ESP-NOW-only variants do not compile.** + `src/helpers/ESP32Board.h:73` returns `P_LORA_DIO_1` from `getIRQGpio()` + unconditionally, and `src/helpers/ESP32Board.cpp:62` uses `P_LORA_NSS`. Neither + variant defines any `P_LORA_*` pin, because neither has a LoRa radio. Measured + on unmodified `upstream/dev` 428a95c6: + + ``` + Generic_ESPNOW_repeatr: 1 failed, 0 succeeded + SenseCapIndicator-ESPNow_comp_radio_usb: 1 failed, 0 succeeded + both: src/helpers/ESP32Board.h:73:12: error: 'P_LORA_DIO_1' was not declared in this scope + ``` + +2. **`ESPNOWRadio.cpp` does not compile against ESP-IDF 5.x.** On the C6 platform + the ESP-NOW receive callback changed shape: + + ``` + src/helpers/esp32/ESPNOWRadio.cpp:40:28: error: invalid conversion from + 'void (*)(const uint8_t*, const uint8_t*, int)' to 'esp_now_recv_cb_t' + {aka 'void (*)(const esp_now_recv_info*, const unsigned char*, int)'} + src/helpers/esp32/ESPNOWRadio.cpp:68:3: error: 'esp_efuse_mac_get_default' was not declared in this scope + ``` + + That is why every C6 environment that pulls in `helpers/esp32/*.cpp` excludes + this one file again: `variants/lilygo_tlora_c6/platformio.ini:82`, + `variants/m5stack_unit_c6l/platformio.ini:86` and `:104`, + `variants/xiao_c6/platformio.ini:66`, `:121` and `:183`. + +`arduino_base.build_src_filter` matches `helpers/*.cpp` and does not recurse, so +an environment picks the file up only if it asks for `helpers/esp32/*.cpp`. 39 +variant files do exactly that, so the file is compiled in a great many ESP32 +environments, two of them inside this fix's own build matrix: + +``` +.pio/build/Heltec_v3_companion_radio_ble/src/helpers/esp32/ESPNOWRadio.cpp.o +.pio/build/Heltec_v3_companion_radio_wifi/src/helpers/esp32/ESPNOWRadio.cpp.o +``` + +What those builds do not contain is the function. It is an inline member, and in +those variants `radio_driver` is a LoRa wrapper, so nothing calls the ESP-NOW one +and the linker never emits it. `xtensa-esp32-elf-nm` on the finished +`Heltec_v3_companion_radio_ble` image finds no `ESPNOWRadio` symbol at all, and +the only boosted gain symbols present belong to the SX1262 path: + +``` +ESPNOWRadio symbols in the image: 0 +42009d04 W _ZN19CustomSX1262Wrapper20setRxBoostedGainModeEb +42015c28 T _ZN6SX126x20setRxBoostedGainModeEbb +``` + +That, and not an absent compile, is why both environments are byte identical with +and without this change (1666437 / 194836 and 1102677 / 172376 on each side). + +The one call site that reads the value, `CommonRadioPrefs.cpp:126`, binds to +`ESPNOWRadio` only where `radio_driver` is one, which is the two ESP-NOW-only +variants, and those do not build. So the defect is undefined behaviour that sits +in many translation units, reaches no image, and is reachable in nothing that +builds today. + +It is still worth fixing: the file is the ESP-NOW driver, whoever repairs the two +defects above should not inherit this one, and until then every ESP32 companion +build carries a translation unit the standard calls undefined. + +## Compatibility + +None affected. The two environments of the build matrix that compile the file are +byte identical with and without the change, because the inline member is never +called there and so never emitted; the other 16 do not compile it at all. Both +facts are measured under Reach, and the matrix figures are under Verification. + +## Verification + +### Compile check + +Two obstacles had to be cleared to get a deterministic signal, and both are +interesting in their own right. + +The framework compiles project sources with `-w`, which suppresses every warning. +Read from the generated `compile_commands.json` for `ESPNOWRadio.cpp`: + +``` +-w -Wno-error=unused-function -Wno-error=unused-variable ... -Wwrite-strings +``` + +A later `-Werror=return-type` does not win against it. Measured with the exact +target compiler on the reduced construct: + +``` +riscv32-esp-elf-g++ -w -Werror=return-type -> rc=0, silent +riscv32-esp-elf-g++ -Werror=return-type -> rc=1, error: no return statement + in function returning non-void +``` + +So this defect was invisible to every build that ever ran, by configuration. + +The diagnostic patch `tools/refactoring/diag-espnow-rxgain.patch` therefore does +three things to `variants/generic_espnow/platformio.ini`: it defines the two LoRa +pins that `ESP32Board` needs, drops `-w` through `build_unflags`, and promotes +`return-type` to an error. It is a test aid and part of no fix. + +Before the fix, `Generic_ESPNOW_repeatr` with that patch: + +``` +src/helpers/esp32/ESPNOWRadio.h:41:45: error: no return statement in function returning non-void [-Werror=return-type] +==================== 1 failed, 0 succeeded in 00:00:20.442 ==================== +``` + +After the fix, same patch, same environment: + +``` +========================= 1 succeeded in 00:00:40.118 ========================= +``` + +with no `return-type` diagnostic left anywhere in the build, and no other error +surfacing from dropping `-w` in that environment. + +### Mutation tests + +Five deliberate errors, each built with the diagnostic patch. + +| # | Mutation | Result | +|---|----------|--------| +| 1 | no return at all (the original defect) | caught: `error: no return statement in function returning non-void` | +| 2 | `return true;` | **not caught**, see below | +| 3 | return type changed to `void` | caught: `error: could not convert 'radio_driver.ESPNOWRadio::setRxBoostedGainMode(...)'` | +| 4 | function removed | caught: `error: 'class ESPNOWRadio' has no member named 'setRxBoostedGainMode'` | +| 5 | `if (false) return false;` | caught: `error: control reaches end of non-void function` | + +Mutations 3 and 4 are worth more than their pass marks: they prove by measurement +that `CommonRadioPrefs.cpp:126` really does consume this value in this build, +rather than leaving that as a reading of the source. + +Mutation 2 is a genuine blind spot. A compile check can prove that a value is +returned, not that it is the right one. Catching it would need a host test that +instantiates `ESPNOWRadio` and asserts `false`, which needs stand-ins for the +twelve member functions that live in `ESPNOWRadio.cpp`. That is not justified for +asserting one constant, so the argument for `false` rather than `true` rests on +the reasoning under Decision, not on a test. + +### Host tests + +`pio test -e native -e native_kiss_modem`: 54 of 54 test cases passed. The suites +`native_core` and `native_ofast` do not exist on this base. + +### Builds + +The 18 environment matrix on this commit against a `dev` worktree with the same +toolchain, including `RAK_3112_repeater_bridge_espnow` (which compiles +`helpers/bridges/ESPNowBridge.cpp`, not this driver). Flash and RAM identical +everywhere: in the 16 environments that never compile the file, and in the two +that do, because the changed member is not emitted there. + +### Hardware + +Not applicable, and not merely unavailable: there is no board here whose only +radio is ESP-NOW, and the two variants that would instantiate this driver do not +build at all. No image contains the changed function, which the symbol check +under Reach establishes, so there is nothing to observe at runtime. + +## Follow-ups + +Not part of this fix, listed because they were measured while verifying it: + +- `ESP32Board` reaches for `P_LORA_*` unconditionally, which makes an ESP-NOW-only + variant impossible to build. +- `ESPNOWRadio.cpp` needs updating for the ESP-IDF 5.x `esp_now_recv_cb_t` + signature and a missing include for `esp_efuse_mac_get_default`. +- Project sources are compiled with `-w`. A defect like this one cannot be caught + while that holds. diff --git a/docs/fixes/gps-millis-deadline-wrap.md b/docs/fixes/gps-millis-deadline-wrap.md new file mode 100644 index 00000000000..1d13275fc36 --- /dev/null +++ b/docs/fixes/gps-millis-deadline-wrap.md @@ -0,0 +1,271 @@ +# GPS update deadline is not wrap-safe in seven variants + +Status: ready for review. Behaviour change: only at the millis() wrap, see below. + +## Context + +Seven variants schedule their GPS cache refresh the same way: + +```c++ +static long next_gps_update = 0; +... +if (millis() > next_gps_update) { + ... + next_gps_update = millis() + 1000; +} +``` + +`variants/heltec_mesh_solar`, `heltec_tracker`, `meshadventurer`, `meshtracker_x1`, +`nano_g2_ultra`, `t1000-e` and `thinknode_m1`, each in `target.cpp`. + +The shared `EnvironmentSensorManager` had the same shape and was changed in 63731d3f +to an unsigned deadline with `(long)(millis() - deadline) > 0`. These seven copies +were not part of that change. + +### What the old code actually does + +63731d3f describes the defect as a sign flip after about 24.8 days, after which the +block "never runs again" and the GPS time sync stalls "permanently until reboot". +Measured, that is not what happens on these targets. + +`millis()` returns an unsigned 32-bit value on every platform here (`unsigned long` +on ESP32, `uint32_t` on nRF52). In `millis() > next_gps_update` the signed operand is +converted to `unsigned long`, so the comparison is performed unsigned, and storing +`millis() + 1000` into a `long` and reading it back is bit-preserving. Checked with +every installed toolchain that can build these targets (arm-none-eabi gcc 7.2.1, +12.3.1 and 14.2.1, xtensa-esp32 and xtensa-esp32s3 gcc 8.4.0), each with a +positive and a negative control: + +``` +sizeof(long) == 4 && sizeof(unsigned long) == 4 holds +((unsigned long)1 > (long)-1) == false holds, so unsigned +(unsigned long)(long)(unsigned long)0x800003E8 == 0x800003E8 holds +``` + +So there is no sign flip to trip over at 2^31. The counter wrap at 2^32 is a real +problem, but a different one. Driving the loop pass by pass over 3000 s of loop time, +1 ms per pass, with the target's widths: + +| Window | Shape | Runs | Longest gap | Longest run of consecutive passes | +|---|---|---:|---:|---:| +| across 2^31, 24.8 days | shipped, 32-bit `long` | 2998 | 1001 ms | 1 | +| across 2^31, 24.8 days | 64-bit `long` | 2998 | 1001 ms | 1 | +| across 2^31, 24.8 days | wrap-safe | 2998 | 1001 ms | 1 | +| across 2^32, 49.7 days | shipped, 32-bit `long` | 3497 | 1001 ms | 501 | +| across 2^32, 49.7 days | 64-bit `long` | 1499 | 1500500 ms | 1 | +| across 2^32, 49.7 days | wrap-safe | 2998 | 1001 ms | 1 | + +In the last interval before the counter reaches 2^32, `millis() + 1000` wraps to a +small number while `millis()` is still large, so the comparison is true on every pass. +The block then runs on each loop pass instead of once per second for up to 1000 ms, +and resumes its normal period once the counter wraps as well. The longest gap does not +grow, so nothing stalls. + +The permanent stall 63731d3f describes does occur, but only where `long` is 64 bits +wide, which is the test host and none of the firmware targets. The fix in 63731d3f is +right; its stated reason is not. + +What the burst costs is small. The guarded block only copies the cached latitude, +longitude and altitude out of the location provider and, in debug builds, prints them; +it is idempotent. This is read from the source of all seven blocks, not measured at +runtime: reaching the wrap takes 49.7 days of uptime. + +## Decision + +A new header, `src/helpers/MillisDeadline.h`, holds the question as one inline +function: + +```c++ +inline bool millisHasPassed(uint32_t now, uint32_t deadline) { + const uint32_t elapsed = now - deadline; + return elapsed != 0 && elapsed < 0x80000000u; +} +``` + +All seven variants call it. Three properties are deliberate: + +- **Fixed widths, not `long`.** `long` is 32 bits on the firmware targets and 64 bits + on the test host. A host test written with `long` would exercise the 64-bit shape, + which does not merely keep the behaviour at the wrap but turns the burst into a + stall, as the table above shows. With `uint32_t` the host performs the arithmetic + the target performs, so the test means something. +- **Strict comparison.** The shipped loops fired strictly after the deadline, so the + period is unchanged away from the wrap (2998 runs, 1001 ms, in the table above). +- **No out-of-range conversion.** `Dispatcher::millisHasNowPassed` casts a difference + above `LONG_MAX` to `long`, which the standard leaves to the implementation before + C++20. The two spellings agree on every input, which `test_millis_deadline` pins, and + they compile to the same instructions, so the well-defined spelling costs nothing. + +`thinknode_m1` has a second timer in the same function, `last_switch_check`. It uses +the elapsed form, `millis() - last_switch_check > 1000`, which is already wrap-safe. +Its type changes from `long` to `uint32_t` so that no millis() reading is stored in a +signed variable, and that is all: driven over both boundaries, every single comparison +is identical before and after (2997 runs, longest gap 1002 ms, longest run 1, in both +windows). It is a type cleanup, not a behaviour fix. + +## Alternatives considered + +- **Write the idiom inline at each of the seven places, as 63731d3f did.** Rejected. + Seven hand-written copies are what produced this defect, and `variants/*/target.cpp` + does not build on the host, so an inline form cannot be covered by a test. A shared + function can, and the embedded builds then show the seven call sites compile. +- **Reuse `Dispatcher::millisHasNowPassed`.** Rejected: it is a member function and + reads the clock through `_ms`, and a `SensorManager` in a variant has no + `Dispatcher`. +- **Put the helper on `SensorManager`.** Rejected: all seven callers happen to be + `SensorManager` subclasses, but a deadline comparison is not a sensor concern. +- **Also change `Dispatcher::millisHasNowPassed` to call the new helper.** Left out of + this change: it is separable, it touches the core that every role uses, and the two + forms are pinned to each other by a test either way. +- **Fix the remaining naive deadline comparisons too.** Left out. 28 further + `millis() >` and `millis() >=` comparisons exist in the UI tasks, the simple + examples and `SerialBLEInterface`. They use `unsigned long` deadlines, so they are a + different shape from this one, and each would need its own reading before being + called a defect. Not classified here. + +## Compatibility + +- No stored format, no CLI reply and no protocol field is touched. +- The period and the behaviour of all seven loops are unchanged except in the last + interval before the millis() counter wraps. +- The new header is self-contained and includes only `stdint.h`. + +## Verification + +### Host tests + +`test/test_millis_deadline/` runs in the `native` environment and covers: + +| Test | What it pins | +|---|---| +| `FiresOnlyStrictlyAfterTheDeadline` | the strict comparison the shipped loops used | +| `MatchesTheSignedDifferenceIdiom` | agreement with `(int32_t)(now - deadline) > 0` over 70 pairs, including differences of 0, 1, 2^31 - 1, 2^31 and 2^32 - 1 | +| `TreatsADeadlineThatWrappedAsStillAhead` | a deadline that wrapped to 500 while `millis()` is 0xFFFFFF00 reads as 756 ms in the future, where the old form read it as long past | +| `PeriodIsUnchangedByTheSignFlipAndTheWrap` | runs, longest gap and longest consecutive run are identical in a quiet window, across 2^31 and across 2^32 | +| `LooksAheadHalfTheCounterAtMost` | the documented horizon of 2^31 - 1 | + +Written against the shipped shape first: with the naive body in the header, four of +the five tests fail. With the wrap-safe body all five pass. + +Mutation tests, each applied to the helper alone, with the header restored and +re-checked by its sha256 afterwards: + +| Mutation | Failing tests | +|---|---:| +| the naive comparison, as shipped | 4 | +| zero guard dropped, fires on the deadline itself | 3 | +| half-plane inclusive, `<=` instead of `<` | 2 | +| half-plane inverted, `>` instead of `<` | 5 | +| arithmetic in `unsigned long` instead of `uint32_t` | 4 | +| horizon off by one, `0x7FFFFFFF` instead of `0x80000000` | 2 | + +The fifth row is the reason for the fixed widths: that mutation is invisible on a +32-bit target and wrong on a 64-bit host. + +All native suites pass: `pio test -e native -e native_kiss_modem`, 59 of 59. + +### Warnings and generated code + +The firmware builds with `-w`, so a warning in this header would never surface there. +Compiled on its own with `-Wall -Wextra -Wconversion -Wsign-conversion -Wshadow +-Wold-style-cast -Wpedantic`, driven both with an `unsigned long` and with a `uint32_t` +reading to cover the implicit conversion at the call sites: + +| Toolchain | Warnings | +|---|---:| +| arm-none-eabi gcc 7.2.1 | 0 | +| arm-none-eabi gcc 12.3.1 | 0 | +| arm-none-eabi gcc 12.3.1, `-Ofast` | 0 | +| arm-none-eabi gcc 14.2.1 | 0 | +| xtensa-esp32 gcc 8.4.0 | 0 | +| xtensa-esp32s3 gcc 8.4.0 | 0 | + +`-Ofast` is included because the nRF52 firmware is built with it and it has caught a +difference between host and target before. + +The two spellings also generate identical code: + +| Target | `millisHasPassed` | `(int32_t)(now - deadline) > 0` | +|---|---|---| +| nRF52, `-Ofast`, gcc 12.3.1 | `sub r0,r0,r1; cmp r0,#0; movle r0,#0; movgt r0,#1; bx lr` | identical | +| ESP32-S3, `-Os`, gcc 8.4.0 | `entry sp,32; sub a2,a2,a3; srai a8,a2,31; sub a2,a8,a2; extui a2,a2,31,1; retw.n` | identical | + +### Builds + +All 45 environments of the seven variants were built from this change. 42 build. The +three that do not, fail the same way on unchanged `dev` 428a95c6, each checked in a +separate worktree that `git status` reports as clean at that commit: + +| Environment | Error | Cause | On unchanged `dev` | +|---|---|---|---| +| `Heltec_mesh_solar_companion_radio_usb` | `'USB' is not a member of 'InterfaceType'` | the `meshsolar` dependency defines `NONE` as a macro in `src/utils/logger.h:18`, which replaces the `NONE` enumerator of `InterfaceType` in `src/helpers/MultiSerialInterface.h:11` and makes the enum body invalid | fails, 57 s | +| `Heltec_mesh_solar_companion_radio_ble` | `'Bluetooth' is not a member of 'InterfaceType'` | the same macro collision | fails, 89 s | +| `MeshTracker_X1_kiss_modem` | `MicroNMEA.h: No such file or directory` | the `kiss_modem` environment of this variant does not inherit `stevemarple/MicroNMEA`, while the four other environments of the variant list it and `target.cpp` includes the provider unconditionally | fails, 85 s | + +Neither cause is in the files this change touches, and neither is related to it. Both +are handled separately. + +The 18 environments of the regression matrix were built from this change and from +`dev` in two worktrees with the same toolchain. All 36 builds succeed, and flash and +RAM are identical in every one of the 18: + +| Environment | Platform | Flash | Flash vs `dev` | RAM | RAM vs `dev` | +|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32-S3 | 1174449 | +0 | 62512 | +0 | +| `Heltec_v3_room_server` | ESP32-S3 | 1169705 | +0 | 66200 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32-S3 | 1666437 | +0 | 194836 | +0 | +| `Heltec_v3_companion_radio_wifi` | ESP32-S3 | 1102677 | +0 | 172376 | +0 | +| `Heltec_v3_sensor` | ESP32-S3 | 1180593 | +0 | 60560 | +0 | +| `RAK_4631_repeater` | nRF52840 | 440952 | +0 | 37240 | +0 | +| `RAK_4631_room_server` | nRF52840 | 434088 | +0 | 40928 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52840 | 462548 | +0 | 149760 | +0 | +| `RAK_4631_sensor` | nRF52840 | 437544 | +0 | 35288 | +0 | +| `PicoW_repeater` | RP2040 | 491972 | +0 | 85368 | +0 | +| `wio-e5-mini_repeater` | STM32WL | 203552 | +0 | 17172 | +0 | +| `wio-e5-mini_sensor` | STM32WL | 202904 | +0 | 15236 | +0 | +| `RAK_3112_repeater_bridge_espnow` | ESP32-S3 | 1175717 | +0 | 64080 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | 240368 | +0 | 26704 | +0 | +| `ProMicro_repeater` | nRF52840 | 345424 | +0 | 33892 | +0 | +| `ProMicro_room_server` | nRF52840 | 338456 | +0 | 37588 | +0 | +| `ProMicro_companion_radio_usb` | nRF52840 | 357300 | +0 | 99460 | +0 | +| `ProMicro_sensor` | nRF52840 | 341900 | +0 | 31948 | +0 | + +That they are identical is expected: none of these targets includes the new header. +A difference would have been a finding. + +### In the built firmware + +`t1000e_repeater` was built from this change and from unchanged `dev`, and +`T1000SensorManager::loop` disassembled in both. The deadline test changes from a +register compare with an unsigned branch to the difference form: + +``` +dev cmp r0, r3 fix subs r0, r0, r3 + bls ... cmp r0, #0 +``` + +Counted over the whole function, register-to-register compares followed by an +unsigned branch drop from 2 to 1, and the instruction count rises by 1 (279 to 280). +The compare that remains comes from other inlined code, not from a deadline. The +`subs`, `cmp #0`, signed-branch sequence that appears unchanged earlier in the same +function is the already corrected test in `MicroNMEALocationProvider::loop`, inlined +through `_nmea->loop()`. Section sizes are unchanged (text 292956, data 932, bss +31944 in both), so the extra instruction fits in existing alignment padding and costs +no flash here. + +### Hardware + +Not done. The counter wrap is 49.7 days of uptime away, so it cannot be reached on a +board at all; what a board run could show is that normal GPS operation is unaffected. +The T1000-E available for this work was not connected while the change was made, so +there is no runtime evidence either way. The host tests and the 42 builds are the +whole of the verification. + +## Follow-ups + +- Decide whether `Dispatcher::millisHasNowPassed` should call `millisHasPassed`, so + the idiom exists once. +- Classify the 28 remaining naive deadline comparisons listed above. +- None of the 15 environments the pull request check builds covers any of these seven + variants, so a compile error in them would not be caught by it. Consider adding one + variant with a GPS loop to that matrix. diff --git a/docs/fixes/identity-store-save-result.md b/docs/fixes/identity-store-save-result.md new file mode 100644 index 00000000000..aa37d395453 --- /dev/null +++ b/docs/fixes/identity-store-save-result.md @@ -0,0 +1,197 @@ +# IdentityStore::save reported success for a failed write + +## Context + +`IdentityStore` is the only place that persists a node's keypair. Each role +loads it at boot and, when nothing is there, generates a new one and saves it: + +| Role | Generates and saves at boot | +|---|---| +| Repeater | `examples/simple_repeater/main.cpp:94` | +| Room server | `examples/simple_room_server/main.cpp:75` | +| Sensor | `examples/simple_sensor/main.cpp:101` | +| Companion | `examples/companion_radio/MyMesh.cpp:955` | +| KISS modem | `examples/kiss_modem/main.cpp:56` | +| Secure chat | `examples/simple_secure_chat/main.cpp:327` | + +Both overloads of `save()` ended in `return true` as long as the file could be +opened. The two argument form computed the result of `id.writeTo(file)`, logged +it and then threw it away (`src/helpers/IdentityStore.cpp:58-61` on +`upstream/dev` 428a95c6). The form that also stores a display name did not even +look at the result of its two writes. + +A node whose write fails therefore keeps running under a keypair that never +reached the medium, and comes back with a different public key after the next +boot. Everything that identifies the node to the mesh changes with it. + +## Decision + +Return what actually happened. In the two argument form that is the result of +`writeTo()`; in the form with a display name both writes count, because the +name is written after the keypair and a medium that fills up in between leaves +a file with an identity and no name. + +The form with a display name also gets the debug line the other form already +had, so a failure is visible in both. + +### Why the result of a failed write is safe to report + +The caller contract already expects an honest result. `CMD_IMPORT_PRIVATE_KEY` +in `examples/companion_radio/MyMesh.cpp:1544` adopts the imported key and +answers `OK` when `save()` reports success, and answers +`ERR_CODE_FILE_IO_ERROR` when it does not. That error path existed before this +change and was unreachable. + +All nine call sites were checked for what a `false` now triggers: + +| Call site | Reads the result | Effect of a false | +|---|---|---| +| `companion_radio/MyMesh.cpp:1544` (import private key) | yes | answers `ERR_CODE_FILE_IO_ERROR`, keeps the old key. The path already existed | +| `companion_radio/MyMesh.cpp:955` (boot) | no | unchanged | +| `companion_radio/MyMesh.cpp:2245` (`rebuild` command) | no | unchanged | +| `simple_repeater/main.cpp:94` (boot) | no | unchanged | +| `simple_room_server/main.cpp:75` (boot) | no | unchanged | +| `simple_sensor/main.cpp:101` (boot) | no | unchanged | +| `kiss_modem/main.cpp:56` (boot) | no | unchanged | +| `simple_secure_chat/main.cpp:327` (boot) | no | unchanged | +| `simple_repeater/MyMesh.cpp:1189`, `simple_room_server/MyMesh.cpp:858`, `simple_sensor/SensorMesh.cpp:896` (`saveIdentity`) | no | `void` wrappers behind `CommonCLI.h:265`, so they cannot propagate it | + +No boot path reads the result, so a failing write adds no error cascade at +startup. That was the condition for this change. + +The overload with a display name has no caller in this repository. It is part +of the public interface of the class and carried the same defect, so it is +fixed in the same place rather than left as the one that still lies. + +## Alternatives considered + +- **Remove the half written file when the write fails.** Not done. A truncated + `.id` file fails to load (`readFrom()` comes up short), so the node behaves + as if no file existed, and the next `save()` removes the file first anyway on + the platforms that take that branch. Deleting it would be a second behaviour + change in a fix whose point is to stop reporting a false success. +- **Retry the write inside `save()`.** Not done. The caller knows whether a + retry is useful; `save()` does not. The companion already decides this. +- **Only fix the two argument form, the one with a caller.** Not done. Both + overloads carry the identical defect in the identical place. +- **Leave the display name write unchecked.** Not done. It is the second half + of the same file; a file with a keypair and no name is not a saved identity. + +## Compatibility + +- `/prefs.json`, the identity file format and the success path are unchanged. + Nothing differs while the filesystem accepts the write. +- The only behaviour change on a failing write is in + `CMD_IMPORT_PRIVATE_KEY`, which now takes the error path that was written for + it. + +## Verification + +### Host tests + +New environment `native_identity_store` runs `test/test_identity_store` against +a mock filesystem (`test/mocks/Adafruit_LittleFS.h`) whose file opens and then +runs out of write budget, which is how a full or worn flash behaves. +`Print::write()` loops over the single byte write, so an exhausted budget +returns a short count, exactly as a partial write does on the real driver. The +mock serialisation of `mesh::Identity` and `mesh::LocalIdentity` is copied from +`src/Identity.cpp`, so a short write is a failure in the double as well. + +The environment defines `NRF52_PLATFORM`, so the covered branch of `save()` is +the one the nRF52 and STM32 firmware take (`remove()` then +`open(..., FILE_O_WRITE)`). The ESP32 and RP2040 branches differ only in the +`open()` call and are not covered by a host test. + +Four of the nine tests are red on `upstream/dev` 428a95c6 and green with the +change, each with `Actual: true, Expected: false`: + +| Test | On dev | With the fix | +|---|---|---| +| `IdentityStoreSave.ReportsFailureWhenNoByteCanBeWritten` | red | green | +| `IdentityStoreSave.ReportsFailureWhenThePrivateKeyIsCutOff` | red | green | +| `IdentityStoreSaveWithName.ReportsFailureWhenTheKeypairIsCutOff` | red | green | +| `IdentityStoreSaveWithName.ReportsFailureWhenOnlyTheNameIsCutOff` | red | green | + +The other five pin behaviour that must not change: the two success paths, the +two "file cannot be opened" paths and a save and load round trip. + +All suites of this branch: `pio test -e native -e native_kiss_modem -e +native_identity_store` reports 63 of 63. `native_core` and `native_ofast` do +not exist on `upstream/dev`; they run on `integration` after the merge. + +### Mutations + +Five deliberate errors in the changed code, each had to turn the suite red: + +| Mutation | Caught by | +|---|---| +| Two argument form returns `true` again (the original defect) | `ReportsFailureWhenNoByteCanBeWritten`, `ReportsFailureWhenThePrivateKeyIsCutOff` | +| Form with display name returns `true` again | `ReportsFailureWhenTheKeypairIsCutOff`, `ReportsFailureWhenOnlyTheNameIsCutOff` | +| Result of the display name write not checked | `ReportsFailureWhenOnlyTheNameIsCutOff` | +| Result of `writeTo()` not read in the two argument form | `ReportsFailureWhenNoByteCanBeWritten`, `ReportsFailureWhenThePrivateKeyIsCutOff` | +| The two writes joined with OR instead of AND | `ReportsSuccessWhenKeypairAndNameFit`, `ReportsFailureWhenOnlyTheNameIsCutOff` | + +### Builds + +All 21 environments build on this commit, and so does `upstream/dev` 428a95c6 in +its own worktree with the same toolchain. The 18 of the review matrix are joined +by three builds this change reaches through a call site of their own: +`LilyGo_T-Echo_room_server`, the build the board run used, +`LilyGo_T-Echo_kiss_modem` (`examples/kiss_modem/main.cpp:56`) and +`Heltec_v3_terminal_chat` (`examples/simple_secure_chat/main.cpp:327`), the one +role that keeps its own prefs structure and still uses `IdentityStore`. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +4 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +4 | +| `Heltec_v3_repeater` | OK | +0 | +4 | +| `Heltec_v3_room_server` | OK | +0 | +4 | +| `Heltec_v3_sensor` | OK | +0 | +4 | +| `LilyGo_T-Echo_kiss_modem` | OK | +0 | +0 | +| `LilyGo_T-Echo_room_server` | OK | +0 | +0 | +| `PicoW_repeater` | OK | +0 | +8 | +| `ProMicro_companion_radio_usb` | OK | +0 | +16 | +| `ProMicro_repeater` | OK | +0 | +16 | +| `ProMicro_room_server` | OK | +0 | +0 | +| `ProMicro_sensor` | OK | +0 | +0 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +4 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +0 | +| `RAK_4631_repeater` | OK | +0 | +0 | +| `RAK_4631_room_server` | OK | +0 | +0 | +| `RAK_4631_sensor` | OK | +0 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +8 | +| `wio-e5-mini_repeater` | OK | +0 | +0 | +| `wio-e5-mini_sensor` | OK | +0 | +0 | +| `Heltec_v3_terminal_chat` | OK | +0 | +4 | + +RAM is unchanged in all 21 environments. Flash grows by 0 to 16 bytes: the +function returns a value it used to throw away, and the second overload gained a +debug line. The cost does not follow the chip family, it follows what the +compiler did with the old constant return. Both ProMicro server roles come out +at 0 while the ProMicro repeater and the USB companion pay 16, all four being +nRF52840. + +### Hardware + +techo-b43b, `LilyGo_T-Echo_room_server`, image marked `save-result-b160f39a` +and the marker confirmed in the image before flashing +(`unzip -p firmware.zip '*.bin' | grep -c -a save-result-b160f39a` = 1) and +reported by `ver` on the board. + +The node boots on the changed firmware and keeps its settings and its identity +across a reboot, so the success path is intact on real hardware. + +The path this change alters, a write that fails, was deliberately **not** +provoked on the board. Reaching it means filling or wearing out the flash, and +the only CLI command that writes an identity is `set prv.key`, which would put +the identity of a node in the test rack at risk for no gain: on healthy +hardware the success path returns true both before and after this change, so +there is nothing to compare. That path is covered by the host tests, where the +mock filesystem lets a file open and then refuses the write. + +## Follow-ups + +- `src/helpers/IdentityStore.cpp:85` compares a signed `int` with + `sizeof(tmp)-1`. Left untouched; it is not part of this defect and the + firmware builds suppress the warning. diff --git a/docs/fixes/llcc68-reset-agc-args.md b/docs/fixes/llcc68-reset-agc-args.md new file mode 100644 index 00000000000..679a44c1e3d --- /dev/null +++ b/docs/fixes/llcc68-reset-agc-args.md @@ -0,0 +1,154 @@ +# Fix: LLCC68 wrapper calls sx126xResetAGC with one argument + +Status: ready for review. Behaviour change: none, no environment compiles the file. + +## Context + +`sx126xResetAGC()` in `src/helpers/radiolib/SX126xReset.h` takes two arguments, +the radio and the boosted gain mode to re-apply after calibration: + +```cpp +inline void sx126xResetAGC(SX126x* radio, bool rx_boost_gain) +``` + +Three of the four SX126x family wrappers call it that way: + +| Wrapper | Line | Call | +|---------|------|------| +| `CustomSX1262Wrapper.h` | 51 | `sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode());` | +| `CustomSX1268Wrapper.h` | 48 | `sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode());` | +| `CustomSTM32WLxWrapper.h` | 38 | `sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode());` | +| `CustomLLCC68Wrapper.h` | 38 | `sx126xResetAGC((SX126x *)_radio);` | + +The fourth does not compile. It never had to: nothing in the tree includes +`CustomLLCC68Wrapper.h`. `LLCC68` appears only in `CustomLLCC68.h`, in the +wrapper itself and in a comment in `SX126xReset.h`. No variant sets +`WRAPPER_CLASS` to `CustomLLCC68Wrapper`, so no environment pulls the header into +a translation unit and no compiler ever looks at it. + +The history is in the log rather than a guess. Commit 6f491f30 (9 August 2026, +"RX Boosted Gain was set back to the compiled in value rather than the +current/flash value set by the user when the ACG reset is triggered") added the +`rx_boost_gain` parameter and updated five files with it: +`CustomLR1110Wrapper.h`, `CustomSTM32WLxWrapper.h`, `CustomSX1262Wrapper.h`, +`CustomSX1268Wrapper.h` and `LR11x0Reset.h`. `CustomLLCC68Wrapper.h` is not among +them. Nothing reported it, because nothing compiles it. + +## Decision + +Call it the way the other three do: + +```cpp +void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } +``` + +The line is now character for character the same as in the other three wrappers, +which is the point: the four members of one chip family should not differ here. + +## Upstream + +Do not propose this as its own upstream pull request. The identical change is +already in the open PR meshcore-dev/MeshCore#3496, "KISS modem: AGC, FEM gain and +RX boosted gain control", character for character: + +``` +- void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } ++ void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } +``` + +That PR is open, not merged, so `dev` still carries the defect and this fork still +needs the fix. But #3496 changes 24 files, and a separate one line PR for a line +it already contains would compete with it. If the fix is wanted upstream sooner, +the useful move is a comment on #3496, not a new pull request. + +Found by searching the upstream repository for the function name rather than the +file: the API text search covers only the pull request text, not the files a pull +request changes, so a search by file name misses this. + +## Alternatives considered + +- **Pass a literal `false`.** Shorter, but wrong: the other three re-apply the + mode the radio is actually in, and `SX126xReset.h` re-applies it only when + `SX126X_RX_BOOSTED_GAIN` is defined. A literal would silently clear boosted + gain after every AGC reset on any board that enables it. +- **Delete the dead files.** `CustomLLCC68.h` and `CustomLLCC68Wrapper.h` have no + users at all, so deleting them would also remove the defect. Rejected: the + LLCC68 is a real chip in the SX126x family, the driver looks complete and + someone adding an LLCC68 board would want it. Deleting it is a separate + decision that does not belong in a one line fix. +- **Add an LLCC68 variant so the file is built.** That is the real cure for the + blind spot, but it needs hardware nobody has here, and inventing a variant to + satisfy the compiler is worse than the compile check below. + +## Compatibility + +None affected, and the claim rests on the include graph rather than on binary +sizes. Nothing includes `CustomLLCC68Wrapper.h`, so no translation unit contains +it: on `integration` the name appears only in the header itself, in the +architecture review that records this defect, in this note and in the diagnostic +patch. The artefact side agrees: an `xtensa-esp32-elf-nm` of a built +`Heltec_v3_companion_radio_ble` image finds zero LLCC68 symbols, and nothing +under that environment's build directory references the wrapper. + +Identical flash and RAM across the build matrix is listed below as corroboration, +not as the proof. Unchanged size does not by itself mean a file is not compiled: +a file can be compiled while the linker drops its unused code, which is exactly +what happens to `ESPNOWRadio.cpp` in the ESP32 companion environments. + +## Verification + +### Compile check + +Since no environment compiles the header, the check has to create one. The +diagnostic patch `tools/refactoring/diag-llcc68-wrapper.patch` adds a single +include to `variants/heltec_v3/target.cpp`, which pulls the wrapper into a real +environment. It is a test aid and part of no fix. + +Before the fix, `Heltec_v3_repeater` with that patch: + +``` +src/helpers/radiolib/CustomLLCC68Wrapper.h:38:63: error: too few arguments to function 'void sx126xResetAGC(SX126x*, bool)' +*** [.pio/build/Heltec_v3_repeater/variants/heltec_v3/target.cpp.o] Error 1 +==================== 1 failed, 0 succeeded in 00:01:30.621 ==================== +``` + +After the fix, same patch, same environment: + +``` +========================= 1 succeeded in 00:00:52.124 ========================= +``` + +### Mutation tests + +Five deliberate errors in the fixed line, each built with the diagnostic patch. +Every one has to fail, otherwise the compile check above proves nothing. + +| # | Mutation | Result | +|---|----------|--------| +| 1 | second argument dropped (the original defect) | `error: too few arguments to function 'void sx126xResetAGC(SX126x*, bool)'` | +| 2 | a third argument added | `error: too many arguments to function 'void sx126xResetAGC(SX126x*, bool)'` | +| 3 | second argument replaced by a `PacketMillis` | `error: cannot convert 'PacketMillis' to 'bool'` | +| 4 | first argument replaced by `_board` | `error: cannot convert 'mesh::MainBoard*' to 'SX126x*'` | +| 5 | function name misspelled | `error: 'sx126xResetAgc' was not declared in this scope` | + +All five failed to build. + +### Host tests + +`pio test -e native -e native_kiss_modem`: 54 of 54 test cases passed. The +suites `native_core` and `native_ofast` do not exist on this base. + +### Builds + +The 18 environment matrix on this commit against a `dev` worktree with the same +toolchain: all 18 built, and flash and RAM are identical to `dev` in every one of +them, to the byte. + +That result is consistent with the file reaching no binary, and it rules out a +regression, but on its own it does not establish it. The include graph and the +symbol check under Compatibility are what establish it. + +### Hardware + +Not applicable. There is no board with an LLCC68 radio in this tree, and no +firmware contains the changed code, so there is nothing to observe at runtime. diff --git a/docs/fixes/lr-packet-score-sf.md b/docs/fixes/lr-packet-score-sf.md new file mode 100644 index 00000000000..c6e37a2678a --- /dev/null +++ b/docs/fixes/lr-packet-score-sf.md @@ -0,0 +1,313 @@ +# Fix: the packet score assumed spreading factor 10 on LR radios + +Status: ready for review. Behaviour change: yes on LR1110, LR1121 and LR2021 +boards, scoped under Behaviour change below. + +## Context + +`Dispatcher::checkRecv()` scores every received packet and uses the score to +decide how long to sit on a flood packet before rebroadcasting it. A node that +received a packet well rebroadcasts sooner, a node that barely heard it waits, so +the better placed neighbour gets the air. `src/Dispatcher.cpp:207` fetches the +score, `:240` restricts it to flood routed packets, `:243` turns it into a delay +and `:244` processes immediately below 50 ms. + +The score depends on the spreading factor, because the SNR a packet needs to be +decodable rises as the spreading factor falls. `RadioLibWrapper::packetScoreInt()` +holds one threshold per factor, from -7.5 dB at SF7 to -20 dB at SF12. + +The base class did not ask for the factor. `RadioLibWrappers.h:76`: + +```cpp +float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 +``` + +Five of the eight wrappers overrode it, all five in the same shape: read the +factor off the radio, then call `packetScoreInt`. Three did not. + +| Wrapper | overrides `packetScore` | overrides `getSpreadingFactor` | +|---------|------------------------|-------------------------------| +| `CustomLLCC68Wrapper.h` | yes, line 32 | yes | +| `CustomSTM32WLxWrapper.h` | yes, line 32 | yes | +| `CustomSX1262Wrapper.h` | yes, line 35 | yes | +| `CustomSX1268Wrapper.h` | yes, line 35 | yes | +| `CustomSX1276Wrapper.h` | yes, line 31 | yes | +| `CustomLR1110Wrapper.h` | **no** | yes, line 44 | +| `CustomLR1121Wrapper.h` | **no** | yes, line 45 | +| `CustomLR2021Wrapper.h` | **no** | yes, line 94 | + +These are all eight subclasses of `RadioLibWrapper`, and `ESPNOWRadio` (which +returns 0) is the only other radio in the tree. + +The decisive detail is in the right hand column: all eight already report the +factor through `getSpreadingFactor()`, and in the five overriding wrappers that +function reads exactly the same member the override reads. For example +`CustomSX1262Wrapper.h:36` uses `((CustomSX1262 *)_radio)->spreadingFactor` inside +`packetScore` and line 39 returns the same expression from `getSpreadingFactor()`. +So the five overrides were, line for line, what the base class could have computed +by asking. + +So the three LR wrappers scored every packet as if the radio ran at SF10, on every +role, while the information needed was one virtual call away. + +## Decision + +The base class asks instead of assuming, and the five overrides that said the same +thing go away: + +```cpp +float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, getSpreadingFactor(), packet_len); } +``` + +Net effect on the tree: five insertions, 21 deletions. + +Two reasons for fixing it in the base rather than adding three overrides: + +- A default that guesses reproduces this defect for the next driver. A default + that asks cannot: a new wrapper that reports its factor is scored correctly + without doing anything, and one that reports nothing falls back to the build's + `LORA_SF`, which is at least that build's actual configuration rather than an + unrelated constant. +- It is what makes the five existing chips provably unchanged. Each removed + override read the same member that `getSpreadingFactor()` on the same wrapper + returns, so the value fed into `packetScoreInt` is identical. Their code is not + byte identical afterwards, because a direct member read becomes a virtual call, + and the build matrix shows that as a handful of bytes. What is unchanged is the + score, and the host test pins that at SF10 and across the range. + +## Alternatives considered + +- **Add the missing override to the three LR wrappers.** The smallest diff that + fixes the symptom, and the shape upstream already uses. Rejected: it leaves the + guessing default in place as a trap, and leaves the same four lines copied eight + times. +- **Keep the literal but make it `LORA_SF`.** Cheaper than a virtual call and + correct for boards that never retune. Rejected: the spreading factor is a + runtime setting (`set radio ...`), so a compile-time constant is wrong as soon + as an operator changes it, which is precisely the case on the board measured + below (configured SF7, build default SF8). +- **Make `packetScore` non-virtual in the base.** Tidier still, but + `mesh::Radio::packetScore` is pure virtual and `ESPNOWRadio` needs its own + answer, so the hook has to stay. +- **Return 0 when no factor is known, to force every driver to report one.** Would + surface future omissions loudly. Rejected: a zero score means maximum delay, so + the failure mode is a node that stops rebroadcasting, which is worse than a + slightly wrong score. + +## Behaviour change + +On LR1110, LR1121 and LR2021 boards the score changes whenever the radio does not +run at SF10. Below SF10 the old code scored too high, because the SF10 threshold +of -15 dB is more generous than the real one, so those nodes rebroadcast too +eagerly. Above SF10 it scored too low and they held back too long. At SF10 nothing +changes, which is the probe on the arithmetic. + +What the score feeds into matters for how far this reaches. `calcRxDelay` in +`src/Dispatcher.cpp:55` uses a base of 10, but **all four roles override it** and +use the configured `rxdelay` as the base instead: + +```cpp +if (_prefs.rx_delay_base <= 0.0f) return 0; +return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); +``` + +(`examples/companion_radio/MyMesh.cpp:273`, `examples/simple_repeater/MyMesh.cpp:542`, +`examples/simple_room_server/MyMesh.cpp:277`, `examples/simple_sensor/SensorMesh.cpp:325`.) + +And `rx_delay_base` is **0 by default in every role**: companion +`NodePrefs.h:29`, repeater `MyMesh.cpp:892` ("turn off by default, was 10.0"), +room server `MyMesh.cpp:648`, sensor `SensorMesh.cpp:800`. The companion even has +the old default commented out at `MyMesh.cpp:934` with "enable once new algo +fixed". + +So on a default configuration the score reaches only the logging +(`Dispatcher.cpp:224` and the `logRx` hook at `:238`) and the delay stays 0. The +rebroadcast timing of an LR board changes only where an operator has set +`rxdelay` above 0. That is a narrower claim than "LR boards rebroadcast at the +wrong time", and it is the one the measurements support. + +With `rxdelay` at 10, the historical default, for a 64 byte packet and 500 ms +airtime: + +| SF | SNR | score old | delay old | score new | delay new | +|----|-----|-----------|-----------|-----------|-----------| +| 7 | 0 dB | 1.000 | 0 ms | 0.562 | 469 ms | +| 7 | -5 dB | 0.750 | 129 ms | 0.188 | 1799 ms | +| 8 | -5 dB | 0.750 | 129 ms | 0.375 | 993 ms | +| 9 | -5 dB | 0.750 | 129 ms | 0.562 | 469 ms | +| 10 | any | unchanged | unchanged | unchanged | unchanged | +| 11 | -15 dB | 0.000 | 3040 ms | 0.188 | 1799 ms | +| 12 | -10 dB | 0.375 | 993 ms | 0.750 | 129 ms | + +The 18 affected variants' environments are listed under Builds. + +## Compatibility + +`/prefs.json` and the CLI are untouched. No setting changes meaning, so upgrade +and downgrade are unaffected. The five SX126x and SX127x family wrappers compute +the same score as before, because the removed overrides read the same member that +`getSpreadingFactor()` returns on those wrappers. Their binaries do shift by a few +bytes, since the base class now reaches the factor through a virtual call; the +build matrix lists the exact figures per environment. + +## Verification + +### Host tests + +The existing native environments cover prefs and tables, not the radio wrappers, +so this fix adds one. `native_radio_wrappers` builds the real +`src/helpers/radiolib/RadioLibWrappers.cpp` against a minimal stand-in for +RadioLib's `PhysicalLayer` in `test/mocks/RadioLib.h`, the same approach the +`native` environments already take for the Arduino core and `Mesh`. The code under +test is the shipping code, not a copy of it: the test subclass only supplies the +three pure virtuals and reports a spreading factor. + +`LORA_SF` is deliberately set to 9 in that environment, so a score that silently +assumes 10 fails rather than passes by coincidence. + +`test/mocks/Arduino.h` gains `min`, `max` and the two argument `random()`, which +the wrapper needs and the existing suites never reached. `test/mocks/RadioLib.h` +includes `Arduino.h`, mirroring the real `RadioLib.h`. + +Before the fix, `pio test -e native_radio_wrappers`: + +``` +RadioWrapperFixture.ScoreFollowsTheReportedSpreadingFactor [FAILED] +RadioWrapperFixture.DoesNotAssumeSpreadingFactorTen [FAILED] +RadioWrapperFixture.AgreesWithTheOldAssumptionAtSpreadingFactorTen [PASSED] +RadioWrapperFixture.FallsBackToTheBuildsSpreadingFactor [FAILED] +RadioWrapperFixture.LongerPacketsScoreLower [PASSED] +RadioWrapperFixture.BelowThresholdScoresZero [FAILED] +``` + +The two that pass are the ones that must: SF10 is where old and new agree, and the +length penalty is independent of the factor. + +After the fix, all seven pass, and the three existing suites with them: + +``` +native test_mesh_tables PASSED +native test_config_serializer PASSED +native test_utils PASSED +native test_utf8_helpers PASSED +native test_routing_policy PASSED +native_radio_wrappers test_radio_wrappers PASSED +native_kiss_modem test_kiss_modem PASSED +================= 61 test cases: 61 succeeded ================= +``` + +`native_core` and `native_ofast` do not exist on this base. + +### Mutation tests + +Eight deliberate errors against the suite above. + +| # | Mutation | Result | +|---|----------|--------| +| 1 | `getSpreadingFactor() + 1` | caught, 6 failures | +| 2 | `getSpreadingFactor() - 1` | caught, 4 failures | +| 3 | back to the literal `10` (the original defect) | caught, 5 failures | +| 4 | SF8 threshold moved from -10 to -11 | caught, 1 failure | +| 5 | length penalty replaced by 1.0 | caught, 2 failures | +| 6 | clamp removed | caught, 1 failure | +| 7 | threshold guard loosened by 1 dB | not caught, equivalent mutant | +| 8 | threshold guard tightened by 1 dB | caught, 1 failure | + +Mutation 7 is not a gap. Loosening the guard lets an SNR between the threshold and +1 dB below it through, where the rate is negative and the clamp turns it back into +zero, so the mutant is behaviourally identical: + +``` +snr=-10.5: rate=-0.050 product=-0.0375 after clamp=0.0 +snr=-10.2: rate=-0.020 product=-0.0150 after clamp=0.0 +``` + +Tightening the guard, mutation 8, is a real change and is caught. Mutations 4 and 8 +were only caught after the suite gained absolute expected values worked out by +hand; the first version compared the score against itself and would have let both +through. That weakness was found by mutation 8 and fixed before this was written. + +### Builds + +`RadioLibWrappers.h` is built by 612 environments across five toolchains, counted +from the resolved configuration (`pio project config --json-output`, environments +carrying `WRAPPER_CLASS`): 315 on espressif32@6.11.0, 240 on nordicnrf52, 25 on +rp2040, 16 on ststm32 and 16 on pioarduino-esp32. The first draft of this change +was built on four of those five, which left the pioarduino platform, with its own +ESP-IDF, entirely untested. All five are covered now. + +Built on this commit against a `dev` worktree with the same toolchain: the 18 +environment matrix, all 67 environments of the 11 affected variants, and all 16 +pioarduino environments. + +LR1110: `minewsemi_me25ls01`, `t1000-e`, `thinknode_m3`, `thinknode_m4`, +`thinknode_m7`, `thinknode_m9`, `wio_wm1110`. LR1121: `muzi_base`, +`waveshare_esp32s3_lr1121`. LR2021: `meshnology_w12`, `meshtracker_x1`. + +Of the 85 environments in the first two groups, 84 built on both sides. Flash +moves between -8 and +48 bytes, and RAM does not move at all in any of them: + +| Change | Environments | +|--------|--------------| +| +48 bytes | 10 | +| +32 bytes | 37 | +| +24 bytes | 1 | +| +16 bytes | 3 | +| +12 bytes | 1 | +| +8 bytes | 28 | +| +4 bytes | 2 | +| unchanged | 1, `wio-e5-mini_sensor` | +| -8 bytes | 1, `wio-e5-mini_repeater` | + +That spread is what replacing a direct member read with a virtual call costs, and +it is the measured answer to the question the first draft of this note got wrong +by claiming the five existing chips stayed byte identical. + +All 16 pioarduino environments built on both sides, with flash moving +12 to +16 +bytes and RAM again not moving: + +| Change | Environments | +|--------|--------------| +| +16 bytes | 1, `LilyGo_Tlora_C6_kiss_modem` | +| +14 bytes | 14 | +| +12 bytes | 1, `M5Stack_Unit_C6L_kiss_modem` | + +`MeshTracker_X1_kiss_modem` is the one environment that does not build, and it +fails identically on both sides with `fatal error: MicroNMEA.h: No such file or +directory`. That is a pre-existing defect of the variant, where only this one of +its five environments does not list the dependency; it is unrelated to this change +and is fixed separately. + +### Hardware + +Measured on a Seeed T1000-E, which carries an LR1110, with marked +`t1000e_companion_radio_usb` images so the running artefact is not in doubt. The +board reports its configured radio settings as 869.4321289 MHz, 62.5 kHz, **SF7**, +CR5. SF7 is the most affected case, and it is not the build default (869.618, +SF8), which also confirms the stored settings were really loaded. + +A diagnostic command, `tools/refactoring/diag-packet-score.patch`, prints the +configured factor, the factor the radio reports and the score at several SNR +values, scaled by 1000. It is a test aid and part of no fix. + +``` +dev: ver => v1.18.0 (Build: f2dev-428a95c6) + diag score => cfg=7 rep=7 s0=1000 s5=750 s10=375 s15=0 rx5=129 +fix: ver => v1.18.0 (Build: f2fix6-score-sf) + diag score => cfg=7 rep=7 s0=562 s5=187 s10=0 s15=0 rx5=1798 +``` + +Three things are settled by this, which no host test could settle: + +- `rep=7` on both images: the LR1110 really does report its configured spreading + factor at runtime. The fix rests on that, and it is now measured rather than + argued from the source. +- The scores match the hand calculation for SF7 exactly: 0.5625, 0.1875 and 0 + against the old 1.0, 0.75 and 0.375. +- `rx5` was 0 on both images until `rxdelay` was raised from its stored 0 to 10, + which is how the scoping above was found rather than assumed. At `rxdelay` 10 the + rebroadcast delay at -5 dB SNR goes from 129 ms to 1798 ms, matching the table. + +`rxdelay` was set back to 0 afterwards and all 15 settings read back identical to +the values read before the measurement. The board was then returned to its stock +`companion_radio_ble` v1.17.1 image. diff --git a/docs/fixes/nrf52-i2c-bus-clear.md b/docs/fixes/nrf52-i2c-bus-clear.md new file mode 100644 index 00000000000..0560c8140f0 --- /dev/null +++ b/docs/fixes/nrf52-i2c-bus-clear.md @@ -0,0 +1,171 @@ +# T-Echo boot hangs after a soft reset because a slave holds SDA low + +## Context + +After a soft reset (DFU by 1200 baud touch, the CLI `reboot`, the reset button, +a watchdog) a LilyGo T-Echo sometimes never finishes booting. The USB port +enumerates, the console stays silent, the radio never starts. + +The boot path on `upstream/dev` 428a95c6: + +```cpp +void TechoBoard::begin() { + NRF52Board::begin(); + Wire.begin(); // variants/lilygo_techo/TechoBoard.cpp:11 + ... +} + +bool radio_init() { + rtc_clock.begin(Wire); // variants/lilygo_techo/target.cpp:29 + return radio.std_init(&SPI); +} +``` + +`AutoDiscoverRTCClock::begin()` probes up to four RTC addresses, and a probe is +`beginTransmission()` plus `endTransmission()` +(`src/helpers/AutoDiscoverRTCClock.cpp:23-26`, first probe at `:31`). + +The Adafruit nRF52 `TwoWire` driver (the pinned fork, commit d541301) waits for +the TWIM peripheral in loops that have no timeout: + +```cpp +while(!_p_twim->EVENTS_TXSTARTED && !_p_twim->EVENTS_ERROR); // Wire_nRF52.cpp:230 +while(!_p_twim->EVENTS_LASTTX && !_p_twim->EVENTS_ERROR); // :234 +while(!_p_twim->EVENTS_STOPPED); // :241, no error exit +``` + +`requestFrom()` has the same pattern (`:166`, `:169`, `:175`). `begin()` only +configures the pins (`:50-70`); nothing in the driver or in the MeshCore board +code clears a stuck bus. + +### What the board showed + +A reset does not reset the I2C slaves. The T-Echo has a PCF8563 RTC and, on +the sensor build, a BME280 on the same bus (P0.26 SDA, P0.27 SCL, +`variants/lilygo_techo/variant.h:48-49`). If the reset lands in the middle of +a transfer, a slave can be left holding SDA low and waiting for more clocks. + +Measured on a T-Echo sensor build with a log line before and after each boot +step: + +- Without any bus handling, the reset path through DFU ended in a hang at + "before RTC autodiscovery" in 4 of 4 boots of an upstream `dev` build. The + same hang was reported for a 1.17.1 build; that one was not measured again + for this note. +- Reading the pins before `Wire.begin()` showed SDA low in the hanging boots. +- One SCL clock was enough to release the bus in the cases seen, and the boot + then completed. + +What was not measured: which of the three loops the firmware sits in, and +which slave holds SDA. Neither matters for the fix. + +### Why the failure is intermittent + +Whether SDA is low depends on where in a transfer the reset lands. Plain DFU +cycles of the sensor build produced no hang at all in a later series (8 of 8 +and 20 of 20 boots, SDA high every time), so the state is not reproducible on +demand with a plain image. To force it, a test image polls the PCF8563 in a +tight loop after boot, so that nearly every reset lands inside a transfer. +That is a test device, not part of this change. + +## Decision + +Run the bus clear of the I2C specification (UM10204 section 3.1.16) before +`Wire.begin()`. It lives in a small header, `src/helpers/nrf52/I2cBusClear.h`, +so other boards can reuse it, and `TechoBoard::begin()` is the only caller. + +The sequence: + +1. Release both lines (input with pull-up) and wait 2 ms for the pull-ups. +2. SCL low after the wait: give up without touching anything. +3. SDA high: do nothing. No clocks, no STOP. +4. SDA low: up to nine SCL clocks at about 100 kHz, stop as soon as SDA reads + high, then generate a STOP (SDA low, SCL high, SDA released). +5. SDA still low after nine clocks: no STOP, report failure, continue the boot. + +Both lines are only ever driven low or released (`OUTPUT_S0D1` and +`INPUT_PULLUP`), never driven high. A slave that stretches the clock is not +overridden, and a wait for SCL to rise is bounded to 1 ms. + +The pin access is a template parameter, so the clock sequence runs unchanged +in a host test against an open-drain bus model. + +### Why not the alternatives + +- **A timeout in the driver** (adafruit/Adafruit_nRF52_Arduino#873, open) + bounds the hang but leaves the bus stuck, so the probe fails and the RTC is + not found. It is a separate defect and worth having, but it does not remove + the cause. +- **Skipping the RTC probe** on a bare board (the approach of #3331 for the + Xiao) hides the symptom on boards without an RTC and does nothing for boards + that have one. +- **Doing it inside `NRF52Board::begin()`.** The I2C pins differ per board + (the Xiao moves Wire to other pins in #2499) and boards without I2C should + not pay for it. A per board call with the pins as arguments is explicit. +- **Copying the sequence into `TechoBoard.cpp`** (#3455). The same logic would + be copied into every board that needs it, with no way to test it on the + host. + +## Verification + +Host: + +- `pio test -e native -e native_kiss_modem`: 64 of 64 test cases. +- `test/test_i2c_bus_clear` has ten tests against a bus model: healthy bus + (no clock, no STOP), release after 1, 5 and 9 clocks, SDA stuck forever (nine + clocks, no STOP), SCL stuck low (nothing touched, bounded), clock + stretching, stretch beyond the limit, SDA low again after the STOP, clock + rate. + +Mutation tests (each defect built into the header, the suite must go red): + +| mutation | result | +|---|---| +| STOP removed | red | +| nine clocks reduced to eight | red | +| healthy bus is clocked anyway | red | +| bound on the SCL wait removed | red | +| keep clocking after SDA is free | red | +| success flag always true | red | +| STOP issued with SDA stuck | red | +| abort on SCL held low removed | red | + +Board, LilyGo T-Echo sensor build, DFU reset before each boot, a test image +that polls the PCF8563 continuously: + +| image | boots that completed | SDA low at start | outcome | +|---|---|---|---| +| without the bus clear | 2 of 10 | 8 of 10 | all 8 hangs at "before RTC autodiscovery" | +| with the bus clear | 12 of 12 | 5 of 12 | cleared with 1, 3, 1, 1, 1 clocks, boot completed every time | + +Without the continuous polling the failure did not occur (8 of 8 and 20 of 20 +boots, SDA high), so those runs show only that the helper does no harm on a +healthy bus. The board was returned to the original 1.17.1 build and all 31 +reference values matched the state before the test. + +Build matrix of 23 environments against `dev`, same toolchain: all build, flash +grows by 192 bytes in the five T-Echo environments (repeater, room server, +companion BLE, companion USB, KISS modem) and by 0 bytes in all others. + +## Not covered + +- Other nRF52 boards. Many call `Wire.begin()` in their own `Board::begin()` + and can take the helper with one line (`i2cBusClearNrf52(PIN_WIRE_SDA, + PIN_WIRE_SCL)`). They are not changed because no hardware was available to + verify them. The Lite and Card variants of the T-Echo use other pins and are + also unchanged. +- ESP32. Its driver is a different code path; whether it can hang the same way + was not checked. +- How often the state occurs in normal operation. The stress image forces it. + +## Related upstream work + +- #3455 (draft) proposes the same recovery for the T-Echo only, as a static + function in `TechoBoard.cpp`. Both patches change the same lines of + `TechoBoard::begin()`, so they conflict. The helper here is meant to replace + that local function. +- adafruit/Adafruit_nRF52_Arduino#873 adds timeouts to the TWIM wait loops. It + is complementary: with it a stuck bus no longer blocks the boot, with this + change the bus is released in the first place. +- #3331 skips the RTC probe on the Xiao repeater for the same driver defect. + It does not touch the T-Echo. diff --git a/docs/fixes/prefs-extra-sf-disc.md b/docs/fixes/prefs-extra-sf-disc.md new file mode 100644 index 00000000000..142ebdc6b27 --- /dev/null +++ b/docs/fixes/prefs-extra-sf-disc.md @@ -0,0 +1,224 @@ +# extra_sf and the discovery timestamp were never persisted + +## Context + +`NodePrefs` holds two fields that no loader and no saver ever touched: + +| Field | Written by | Read by | +|---|---|---| +| `discovery_mod_timestamp` | `src/helpers/CommonCLI.cpp:914`, whenever the node is modified | `examples/simple_repeater/MyMesh.cpp:806` and `examples/simple_sensor/SensorMesh.cpp:730`, to answer "has this node changed since" | +| `extra_sf[4]` | `src/helpers/CommonCLI.cpp:687`, the `extra.sf` command | `:826`, to print the value back | + +Neither appears in `NodePrefs::structure()` or in any of its sub objects +(`src/helpers/CommonCLI.h:209-225` on `upstream/dev` 428a95c6), so both are lost +on every reboot. + +For `discovery_mod_timestamp` that is a regression rather than an oversight. The +binary loader still reads it, at offset 162 (`src/helpers/CommonCLI.cpp:97`), so +the field survived as long as a node booted from `/com_prefs`. Since 1.17 every +node boots from `/prefs.json`, which means every server role has been reporting +itself as never modified since then. + +`extra_sf` was never persisted in either format. + +## Decision + +Both fields are serialised: + +- `discovery_mod_timestamp` as `disc_mod`, at the top level next to the advert + intervals, which is where it belongs: it is a property of the node, not of its + radio. +- `extra_sf` as `extra_sf` inside the `radio` object, hex encoded through the + `def(key, void*, len)` overload. That overload exists for exactly this, a + fixed size byte array, and the companion already uses it for its default scope + key (`examples/companion_radio/NodePrefs.h:159`). + +The constructor zeroes `extra_sf`. It is a plain array member with no +initialiser, and now that it is written out and read back it needs a defined +starting value. + +### Why both keys are written on every board + +`extra.sf` is compiled in only for `USE_LR2021` boards and implemented only by +the repeater, so most builds can never set the field. The keys are written +anyway, which keeps `/prefs.json` portable between roles and boards. That is +also how the bridge settings are handled: `BridgePrefs::structure()` writes its +six keys unconditionally although the feature sits behind `WITH_BRIDGE`. + +## Alternatives considered + +- **Four separate `uint8` keys, or a comma separated string.** Rejected. The + hex blob is the overload the serializer already offers for a fixed size array, + and it is what the one precedent in the tree uses. +- **Guard the keys with `#if defined(USE_LR2021)`.** Rejected. It would make the + shape of `/prefs.json` depend on the board, which is exactly what the + downgrade test relies on not happening, and it would differ from how the + bridge keys are treated. +- **Also apply the side detectors at boot.** Out of scope here and reported + separately, see the follow-up below. It is a behaviour change in the repeater + boot path, which by the rules of this series belongs in its own branch. +- **Sanitise the two new fields in the range check.** Not done. A timestamp has + no meaningful range, and the spread factors are validated where they are + applied, against the primary spread factor + (`src/helpers/radiolib/CustomLR2021Wrapper.h:37-39`), so a limit in the loader + would have to be recomputed whenever `sf` changes. + +## Compatibility + +- **Upgrade.** A `/prefs.json` without the two keys loads as before: the + serializer leaves a field untouched when its key is absent, so both keep + whatever the firmware put there. +- **Downgrade.** A firmware that does not know the keys skips them, the way it + skips any unknown key. +- The file grows by one number and one eight character string. + +## Verification + +### Host tests + +New environment `native_node_prefs` covers the serialisation of the real +`NodePrefs` against stream doubles. `NodePrefs` lives in `CommonCLI.h`, whose +filesystem types only exist for a platform, so the environment selects the nRF52 +branch and takes the types from a mock filesystem. + +Six of the nine cases are red on `upstream/dev` 428a95c6 and green with the +change: both save cases, both load cases, the round trip, and the case that +mixes the new keys with unknown ones. The other three pin what must not change: + +| Case | Pins | +|---|---| +| `ExtraSfStartsZeroed` | a fresh `NodePrefs` starts from a defined `extra_sf` | +| `LoadWithoutTheNewKeysLeavesThemAlone` | the upgrade path, and that the keys which are present still arrive | +| `UnknownKeysAreSkipped` | the downgrade path, from the reading side | + +`ExtraSfStartsZeroed` is listed as pinning rather than as red, because before +the change the array is uninitialised: the result there is undefined, not +reliably a failure. + +`pio test -e native -e native_kiss_modem -e native_node_prefs` reports 62 of 62. + +### Mutations + +Five deliberate errors, each had to turn the suite red: + +| Mutation | Caught by | +|---|---| +| `extra_sf` not serialised | both `extra_sf` cases, the round trip, the unknown key case | +| `disc_mod` not serialised | both timestamp cases, the round trip, the unknown key case | +| blob one byte short | the save and load of `extra_sf`, the unknown key case | +| constructor fills `extra_sf` with ones | `ExtraSfStartsZeroed` | +| timestamp under a different JSON key | both timestamp cases, the unknown key case | + +### Builds + +All 20 environments of the matrix build on this commit, and so does +`upstream/dev` 428a95c6 in its own worktree with the same toolchain. +`Heltec_v3_terminal_chat` was built as well, to show the one role with its own +prefs structure is untouched. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +0 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +0 | +| `Heltec_v3_repeater` | OK | +0 | +188 | +| `Heltec_v3_room_server` | OK | +0 | +152 | +| `Heltec_v3_sensor` | OK | +0 | +176 | +| `LilyGo_T-Echo_kiss_modem` | OK | +0 | +0 | +| `LilyGo_T-Echo_room_server` | OK | +0 | +224 | +| `PicoW_repeater` | OK | +0 | +184 | +| `ProMicro_companion_radio_usb` | OK | +0 | +0 | +| `ProMicro_repeater` | OK | +0 | +208 | +| `ProMicro_room_server` | OK | +0 | +224 | +| `ProMicro_sensor` | OK | +0 | +208 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +192 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +0 | +| `RAK_4631_repeater` | OK | +0 | +208 | +| `RAK_4631_room_server` | OK | +0 | +208 | +| `RAK_4631_sensor` | OK | +0 | +208 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +184 | +| `wio-e5-mini_repeater` | OK | +0 | +184 | +| `wio-e5-mini_sensor` | OK | +0 | +176 | +| `Heltec_v3_terminal_chat` | OK | +0 | +0 | + +RAM is unchanged in all 21. Flash splits cleanly along which builds use this +`NodePrefs`, which is a useful check that the change lands exactly where it +should: + +- The 15 server role builds, every repeater, room server and sensor, grow by 152 + to 224 bytes. That is the hex encoding and decoding of the blob plus the + second key. +- The other 6 are byte identical to dev: the four companion builds, which have + their own `NodePrefs` in `examples/companion_radio/NodePrefs.h`, + `LilyGo_T-Echo_kiss_modem`, which adds only `examples/kiss_modem/` to its + sources, and `Heltec_v3_terminal_chat`, whose + `examples/simple_secure_chat` keeps a `NodePrefs` struct of its own. + +So no build that cannot reach these fields pays for them. + +### Hardware + +techo-b43b as a room server, both images marked and each marker confirmed in the +image before flashing and reported by `ver` on the board. Ten settings were read +back at each step. + +**Upgrade.** Values set on `dev-428a95c6`, which writes a `/prefs.json` without +the new keys, then rebooted, then the new build flashed onto the same file: + +| | `dev-428a95c6` | `extra-sf-disc-wt` | +|---|---|---| +| `get af` | `> 3.0` | `> 3.0` | +| `get dutycycle` | `> 25.0%` | `> 25.0%` | +| `get txdelay` | `> 0.6999999` | `> 0.6999999` | +| `get direct.txdelay` | `> 0.3999999` | `> 0.3999999` | +| `get flood.max` | `> 32` | `> 32` | +| `get multi.acks` | `> 1` | `> 1` | +| `get allow.read.only` | `> on` | `> on` | +| `get name` | `> T-Echo Sensor` | `> T-Echo Sensor` | +| `get radio` | `> 869.6179809,62.5,8,8` | `> 869.6179809,62.5,8,8` | +| `get tx` | `> 22` | `> 22` | + +**Downgrade.** Then other values set on the new build and rebooted, so the file +was rewritten with both new keys, and `dev-428a95c6` flashed back onto it: + +| | `extra-sf-disc-wt` | `dev-428a95c6` | +|---|---|---| +| `get af` | `> 4.0` | `> 4.0` | +| `get dutycycle` | `> 20.0%` | `> 20.0%` | +| `get txdelay` | `> 0.8` | `> 0.8` | +| `get direct.txdelay` | `> 0.5` | `> 0.5` | +| `get flood.max` | `> 48` | `> 48` | +| `get multi.acks` | `> 0` | `> 0` | +| `get allow.read.only` | `> off` | `> off` | +| `get name` | `> T-Echo Sensor` | `> T-Echo Sensor` | +| `get radio` | `> 869.6179809,62.5,8,8` | `> 869.6179809,62.5,8,8` | +| `get tx` | `> 22` | `> 22` | + +Identical in both directions, `ver` being the only difference. + +**What the board does not show.** Neither new field can be observed through the +CLI on this hardware, so the round trip of the values themselves rests on the +host tests: + +- `extra.sf` is compiled in only for `USE_LR2021` boards, and `USE_LR2021` + appears in two variants in the tree, `meshnology_w12` and `meshtracker_x1`. + Neither was available, and a T-Echo is an SX1262 board. +- `discovery_mod_timestamp` has no CLI reader at all. + +What the board does show is the part that only hardware can show: that a file +written by one firmware is read without complaint by the other, in both +directions, with every readable setting intact. + +## Follow-ups + +- **The side detectors are never applied at boot.** + `configSideDetectors()` is called from one place only, the `extra.sf` command + (`src/helpers/CommonCLI.cpp:686`, through + `examples/simple_repeater/MyMesh.cpp:1087` into the radio driver). No boot path + calls it, so a persisted `extra_sf` is not put back on the radio after a + restart. Persisting it is still right, because `get extra.sf` now answers + truthfully and the value is there to be applied, but the apply is missing and + wants its own change: it is a behaviour change in the repeater boot path, and + it cannot be verified on hardware without an LR2021 board. +- The stored spread factors can go stale. `CustomLR2021Wrapper` validates them + against the primary `sf` when they are applied, so a file that was valid for + one `sf` can hold values that are refused after `sf` changes. diff --git a/docs/fixes/prefs-json-range-check.md b/docs/fixes/prefs-json-range-check.md new file mode 100644 index 00000000000..f30d4f81b66 --- /dev/null +++ b/docs/fixes/prefs-json-range-check.md @@ -0,0 +1,217 @@ +# /prefs.json was loaded without a range check + +## Context + +Server roles read their settings from `/prefs.json`: + +```cpp +if (file) { + _prefs->loadSerial(file); // new Serial prefs + file.close(); +} +``` + +That was the whole of it (`src/helpers/CommonCLI.cpp:31-41` on `upstream/dev` +428a95c6). Whatever the file said became the running configuration, and the +file is plain text on a filesystem a user can write. The values reach the radio +setup directly: `freq`, `bw`, `sf`, `cr`, `tx_power_dbm`, the delay factors and +the bridge settings. + +The binary loader right below it tells a different story. It ends with a block +headed `// sanitise bad pref values` and about two dozen `constrain()` calls +(`:108-137`), written in `01d84d5d` on 2025-03-05 with the commit line "Now +sanitise bad prefs values". The move to `/prefs.json` did not carry it over, so +the protection depended on which file a node happened to boot from. Since 1.17 +that is always `/prefs.json`, so in practice it was gone. + +## Decision + +Both loaders call the same code. The block moved into +`CommonCLI::sanitisePrefs()`, the binary loader calls it where the block used to +sit, and the JSON path calls it after `loadSerial()`. + +Values that are not finite numbers are handled one layer down, in +`ConfigSerializer`: a token that does not parse to a finite float or double +leaves the field alone. `loadPrefs()` also logs it when `loadSerial()` reports a +parse error; that result used to be discarded. + +### Why non-finite values cannot be clamped + +`constrain()` cannot catch a NaN. Measured on the host: + +| token | `atof` | `constrain(v, 0, 9)` | +|---|---|---| +| `nan` | nan | **nan**, passed through | +| `-nan` | nan | **nan**, passed through | +| `inf` | inf | 9 | +| `1e40` | inf | 9 | +| `abc` | 0 | 0 | + +Both comparisons inside `constrain()` are false for a NaN, so it returns the +NaN unchanged. Infinity is clamped, NaN is not. + +Clamping a NaN to a bound has no sensible answer either. The firmware sets its +defaults before it reads the file (for the repeater at +`examples/simple_repeater/MyMesh.cpp:891` onwards, with `loadPrefs()` at +`:949`), so refusing the assignment keeps the build default, which is the right +value. Clamping to the nearest bound would put a node whose file says +`freq:nan` on 150 MHz. + +The check reads the bits rather than comparing, because nRF52 builds use +`-Ofast`: `-ffast-math` lets float comparisons assume that NaN and infinity +never occur. For floats it runs after the narrowing, since a value that is +finite as a double can overflow to infinity as a float. + +### The one limit that changed + +`airtime_factor` is clamped to 0..99, not to the 0..9 of the old block. +`set dutycycle 1` stores `100/1 - 1 = 99`, a value `set af` itself refuses. +The limit of 9 predates that command by a year (`01d84d5d` 2025-03-05 against +`0aa0ec1f` 2026-03-07), so it was never adjusted for it, and keeping 9 would +throw away every duty cycle below 10 percent at the next boot. Verified on the +board: `set dutycycle 1` gives `get af` `> 99.0`. + +`set tx` and `set multi.acks` have no check in the CLI at all. For those the +loader's limit is the only guard, which is its job, so they keep the values the +binary loader used. + +## Alternatives considered + +- **Derive every limit from the CLI checks.** Rejected. The CLI validates the + value a user types while the file stores a scaled one: `advert_interval` is + minutes divided by two and `agc_reset_interval` is seconds divided by four, + so the mapping would have to be written by hand per field. It would also + newly clamp values the binary loader accepted. The one place where CLI and + loader genuinely disagree is bandwidth, and there the loader is right: the + CLI takes anything from 7.0 while 7.8 kHz is the smallest bandwidth the + radios offer. +- **Clamp a NaN to the nearest bound.** Rejected, see above. +- **Reject the whole file when it does not parse.** Not done. A partially + parsed file is still applied, as before, and now sanitised. Refusing it would + be a second behaviour change, and the right answer (keep defaults, or keep + what parsed) is a product decision. +- **Leave `airtime_factor` out of the block.** Rejected. It would be the only + float without a range check, and a file saying `af:1000000` would come + through untouched. + +## Compatibility + +- The binary loader keeps its behaviour except for `airtime_factor`, which it + now leaves alone up to 99 instead of cutting it to 9. +- On the `/prefs.json` path a value outside the limits is clamped at boot where + it used to be taken as is. That is the point of the change. +- A file holding a non-finite number leaves that one field at its default + instead of poisoning it. Every other key in the file is still applied. +- The `ConfigSerializer` change also covers the companion `NodePrefs`, which + uses the same serializer. + +## Verification + +### Host tests + +Five new cases in `test_config_serializer`. Three are red on `upstream/dev` +428a95c6 and green with the change: a file with `nan`, a file with `inf` and +`-inf`, and a float that overflows to infinity. Two pin behaviour that must not +change: finite values are stored, and garbage still parses as zero. + +`pio test -e native -e native_kiss_modem` reports 59 of 59. `native_core` and +`native_ofast` do not exist on `upstream/dev`; they run on `integration` after +the merge. + +`sanitisePrefs()` itself has **no host test**: no native environment on this +branch compiles `CommonCLI.cpp`, and building that scaffolding would duplicate +what the refactoring steps already added on `integration`. It is covered by the +build of every role and by the board run below, which is the check the task +asked for. + +### Mutations + +Five deliberate errors in the parser check, each had to turn the suite red: + +| Mutation | Caught by | +|---|---| +| float path stores without checking | all three non-finite cases | +| double path stores without checking | the `nan` and the `inf` case | +| float path checks before the narrowing | the overflow case | +| bit comparison inverted | all five cases | +| doubles use the float mask | the `nan` and the `inf` case | + +### Builds + +All 20 environments build on this commit, and so does `upstream/dev` 428a95c6 +in its own worktree with the same toolchain. The 18 of the review matrix are +joined by `LilyGo_T-Echo_room_server`, the build the board run used, and +`LilyGo_T-Echo_kiss_modem`. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +28 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +24 | +| `Heltec_v3_repeater` | OK | +0 | +44 | +| `Heltec_v3_room_server` | OK | +0 | +40 | +| `Heltec_v3_sensor` | OK | +0 | +40 | +| `LilyGo_T-Echo_kiss_modem` | OK | +0 | +0 | +| `LilyGo_T-Echo_room_server` | OK | +0 | +48 | +| `PicoW_repeater` | OK | +0 | +40 | +| `ProMicro_companion_radio_usb` | OK | +0 | +32 | +| `ProMicro_repeater` | OK | +0 | +48 | +| `ProMicro_room_server` | OK | +0 | +48 | +| `ProMicro_sensor` | OK | +0 | +48 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +44 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +16 | +| `RAK_4631_repeater` | OK | +0 | +48 | +| `RAK_4631_room_server` | OK | +0 | +48 | +| `RAK_4631_sensor` | OK | +0 | +48 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +40 | +| `wio-e5-mini_repeater` | OK | +0 | +32 | +| `wio-e5-mini_sensor` | OK | +0 | +40 | + +RAM is unchanged in all 20. Flash grows by 0 to 48 bytes, and the spread lines +up with what each build actually compiles, which is a useful check that the +change lands where it should: + +- The server roles pay the most (40 to 48 bytes): they get both the bit check in + the serializer and the second call site of the sanitising block. +- The companion builds pay 16 to 32 bytes. They have their own `NodePrefs` and + never call `CommonCLI::sanitisePrefs()`, so only the serializer change + reaches them. +- `LilyGo_T-Echo_kiss_modem` pays nothing. Its `build_src_filter` adds only + `examples/kiss_modem/`, so it compiles neither loader and references neither + change. + +The sanitising block itself only moved; it was already linked into every build +that has `CommonCLI.cpp`. + +### Hardware + +techo-b43b, `LilyGo_T-Echo_room_server`, both images marked and each marker +confirmed in the image before flashing and reported by `ver` on the board. + +The out of range values were produced with the CLI itself, through the two +setters upstream does not check, and through the NaN hole in `set af` that this +branch does not touch: + +| reading | `dev-428a95c6` | `range-check-bc5708f7` | +|---|---|---| +| `get multi.acks` after `set multi.acks 5`, reboot | `> 5` | `> 1` | +| `get af` after `set af nan`, reboot | `> 0` (the field holds a NaN) | `> 1.0` (build default) | +| `get dutycycle` | `> 0.0%` | `> 50.0%` | +| `get tx` | `> 22` | `> 22` | +| `get txdelay` | `> 0.8` | `> 0.8` | + +Same board, same file: the out of range value is clamped, the NaN falls back to +the default the firmware set before reading, and the two values that were +inside their limits are untouched. + +## Follow-ups + +- `isFinite` now exists twice in the tree with the same shape, here and in + `CommonRadioPrefs.cpp` from the airtime factor fix. Worth unifying once both + are on `integration`. +- `extra_sf` is not in the block. Its values are validated where they are + applied (`CustomLR2021Wrapper.h:37-39`, against the primary spread factor), + and that check depends on `sf`, so a loader side limit would have to be + recomputed whenever `sf` changes. +- CLI and loader still disagree about bandwidth (7.0 against 7.8) and bridge + channel (1..14 against 0..14). Both are harmless, and unifying them is a + change to the CLI, not to the loader. diff --git a/docs/fixes/region-code0-deny.md b/docs/fixes/region-code0-deny.md new file mode 100644 index 00000000000..b903b1acef2 --- /dev/null +++ b/docs/fixes/region-code0-deny.md @@ -0,0 +1,232 @@ +# A scoped flood with transport code 0 ignored `region denyf *` + +## Context + +`RegionMap::findMatch()` decides which Region a `ROUTE_TYPE_TRANSPORT_FLOOD` +packet belongs to. The three server roles call it from `onRecvPacket()` with +`REGION_DENY_FLOOD` as the mask and keep the result in `recv_pkt_region` +(`examples/simple_repeater/MyMesh.cpp:558`, +`examples/simple_room_server/MyMesh.cpp:311`, +`examples/simple_sensor/SensorMesh.cpp:525`, all on `upstream/dev` 428a95c6). + +An un-scoped flood (`ROUTE_TYPE_FLOOD`) never goes through `findMatch()`. The +same `onRecvPacket()` looks at the wildcard itself: + +```cpp +} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { + recv_pkt_region = NULL; + } else { + recv_pkt_region = ®ion_map.getWildcard(); + } +} +``` + +So `region denyf *` takes the Region away from an un-scoped flood. Since +`01f9293b` ("treat transport code 0 the same as un-scoped", meshcore-dev/MeshCore +PR 3568, merged 13 Oct 2026 and part of the v1.18.0 releases) `findMatch()` starts +with + +```cpp +if (packet->transport_codes[0] == 0) return &wildcard; +``` + +(`src/helpers/RegionMap.cpp:191`). The wildcard is not in `regions[]`, so the +mask is applied to every Region but never to it, and a scoped flood that carries +code 0 keeps the wildcard as its Region however the wildcard is set. Before that +commit code 0 fell through the loop, no Region owns it, and the result was NULL. + +The commit message is the title alone, the pull request has no description and +no discussion, and nothing else explains the difference. The title says "the +same as un-scoped", and an un-scoped flood respects the denial. The CLI +reference says the same about the setting: `region denyf *` "drops packets +without region transport codes" (`docs/cli_commands.md:796`), and +`flood.max.unscoped` applies to "a packet without a scope" (`:696`). + +### What the Region is used for + +Only the repeater acts on it when forwarding: `allowPacketForward()` refuses a +flood whose `recv_pkt_region` is NULL (`examples/simple_repeater/MyMesh.cpp:440`). +The room server and the sensor forward without looking at the Region +(`simple_room_server/MyMesh.cpp:300`, `SensorMesh.cpp:319`). In all three roles +`sendFloodReply()` uses it to pick the scope of a flooded reply, through +`chooseReplyScope()` (`src/helpers/RoutingPolicy.h:60`): a request that arrived +on the wildcard is answered un-scoped, one that arrived on no Region is answered +on the default scope when there is one. + +### Who sends code 0 + +The firmware does not build such a frame itself. `TransportKey::calcTransportCode()` +maps a computed 0x0000 to 0x0001 (`src/helpers/TransportKeyStore.cpp:12`), and +the four places that call `sendFlood(pkt, codes, ...)` all sit behind it +(`companion_radio/MyMesh.cpp:498`, `simple_repeater/MyMesh.cpp:1006`, +`simple_room_server/MyMesh.cpp:748`, `simple_sensor/SensorMesh.cpp:916`). +`CMD_SEND_RAW_PACKET` of the companion (`companion_radio/MyMesh.cpp:2045`) hands a +host supplied frame to `sendPacket()` after `tryParsePacket()`, which reads the +four code bytes of a route type 0x00 frame as they are. Any companion client can +therefore put a code 0 flood on the air. Whether anyone does is not measured. + +This is not a security boundary. Region keys come from the Region name, so +anyone can scope a flood to a Region that is allowed. What the code 0 route +changes is that no name has to be known: it gets past `denyf *` unscoped. + +## Decision + +Keep the shortcut and let the mask apply to it, the way it applies to every +Region in the loop: + +```cpp +if (packet->transport_codes[0] == 0) { // no scope: handled like an un-scoped flood... + return (wildcard.flags & mask) == 0 ? &wildcard : NULL; // ...so the wildcard's own flags decide +} +``` + +With the wildcard allowing flood (the default) nothing changes. With +`denyf *` a code 0 flood now ends up where an un-scoped flood does: no Region. +The mask is the one the caller passed, not a fixed `REGION_DENY_FLOOD`, so the +function keeps meaning "does this Region allow what the caller asks about". + +## Alternatives considered + +- **Check the wildcard in each role's `onRecvPacket()`.** Rejected. It is three + copies on `upstream/dev` (identical apart from the class name), the callers + already pass the mask for exactly this purpose, and the next role would have + to remember it. +- **Remove the shortcut again.** Rejected. Code 0 would be dropped by every + repeater whatever the wildcard says, which is the opposite of what the title + of `01f9293b` asks for. +- **One predicate for "is un-scoped" used by `findMatch()`, the hop limit and + the reply scope.** The right shape in the long run, because "un-scoped" is + decided in three places independently (see below). It needs a new method on + `Packet` and changes in every role, which is a larger change than the defect + asks for. Not done here. + +## Compatibility + +- Settings, files and the protocol are untouched, so there is nothing to migrate + and a downgrade needs nothing. +- Repeaters with the wildcard allowing flood behave as before. On a repeater + with `region denyf *` a flood with code 0 is no longer forwarded. +- Room servers and sensors forward as before. Their reply to a code 0 request + under `denyf *` goes out on the default scope when one is set, which is what a + reply to an un-scoped request does today, instead of un-scoped. +- In a mixed mesh a repeater with this change drops what v1.18.0 repeaters with + `denyf *` still forward, which is what happens to un-scoped floods already. +- The companion does not call `findMatch()` and is not affected. + +## Verification + +### Host tests + +New `test/test_region_map` drives `RegionMap::findMatch()` with the real +`RegionMap.cpp`, `TransportKeyStore.cpp` and `Packet.cpp`. Eight cases: code 0 +with the wildcard allowing and denying flood, code 0 with other masks, an unknown +code with and without a Region in the table, the code of an allowed Region, a +Region that denies flood, and a denied wildcard in front of an allowed scope. +It has its own small stubs (`test/test_region_map/stubs`), listed first on the +include path, because the shared SHA256 mock leaves `finalizeHMAC()` empty and +would hand `calcTransportCode()` an uninitialised code. + +| | before the change | after | +|---|---|---| +| `native_region` | 2 of 8 red | 8 of 8 green | + +The six cases that pass on the old code are guards: they pin what the change must +not touch (the loop, the mask, an allowed wildcard). + +All suites of this branch, `pio test -e native -e native_kiss_modem -e native_region`, +come out green (PlatformIO reports 62 of 62 cases). The workflow now runs `native_region` too; without that the new +suite would not have run in CI. + +### Mutations + +Seven deliberate errors, each had to turn the suite red: + +| Mutation | Red test | +|---|---| +| shortcut ignores the mask again (the old code) | `CodeZeroHasNoRegionWhenTheWildcardDeniesFlood`, `CodeZeroIsOnlyHeldBackByTheFlagTheMaskNames` | +| mask test inverted | three tests | +| code 0 never matches | `CodeZeroArrivesOnTheWildcardWhenItAllowsFlood`, `CodeZeroIsOnlyHeldBackByTheFlagTheMaskNames` | +| fixed `REGION_DENY_FLOOD` instead of the given mask | `CodeZeroIsOnlyHeldBackByTheFlagTheMaskNames` | +| wildcard denial applied to every scoped flood | `ADeniedWildcardDoesNotHoldBackAScopeThatIsAllowed` | +| loop matches any code | `ACodeThatBelongsToNoRegionHasNoRegionWhileRegionsExist` | +| loop ignores the mask | `ARegionThatDeniesFloodIsSkippedForItsOwnCode` | + +### Effect on the server base series + +The server base series (`refactor/server-base`, `refactor/sensor-align`) pins the +old behaviour in `test/test_server_base/test_server_base.cpp`, +`RegionResolutionTest.TransportCodeZeroIgnoresTheWildcardsFloodDenial`. With this +change applied to a copy of `refactor/sensor-align` 0a93bc40, `native_server` +runs 33 tests, 32 green and that one red (the second expectation, "scoped flood +with code 0 does not"); `native`, `native_core`, `native_kiss_modem`, `native_ofast` +and `native_sensor` stay green (PlatformIO reports 90 of 90 cases). That test has to be turned around when +the two lines of work meet: it should expect no Region for the scoped flood, and +its name no longer fits. `RegionMap.cpp` is the same blob on `upstream/dev`, +`integration`, `refactor/sensor-align` and `refactor/server-base`, so the change +merges without a text conflict in that file. + +### Builds + +All 18 environments of the matrix build with this change, and so does +`upstream/dev` 428a95c6 in its own worktree with the same toolchain. They are the +roles that link `RegionMap.cpp` (repeater, room server, sensor, companion) on every +platform of the matrix. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +0 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +0 | +| `Heltec_v3_repeater` | OK | +0 | +12 | +| `Heltec_v3_room_server` | OK | +0 | +8 | +| `Heltec_v3_sensor` | OK | +0 | +8 | +| `PicoW_repeater` | OK | +0 | +16 | +| `ProMicro_companion_radio_usb` | OK | +0 | +0 | +| `ProMicro_repeater` | OK | +0 | +0 | +| `ProMicro_room_server` | OK | +0 | +0 | +| `ProMicro_sensor` | OK | +0 | +0 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +8 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +0 | +| `RAK_4631_repeater` | OK | +0 | +0 | +| `RAK_4631_room_server` | OK | +0 | +0 | +| `RAK_4631_sensor` | OK | +0 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +16 | +| `wio-e5-mini_repeater` | OK | +0 | +8 | +| `wio-e5-mini_sensor` | OK | +0 | +8 | + +RAM is unchanged everywhere. The ESP32, RP2040 and STM32 server builds grow by 8 +to 16 bytes (`findMatch()` itself, 0x70 to 0x79 bytes on the ESP32-S3). Two groups +come out at +0, for different reasons: + +- The companion builds never call `findMatch()`; the symbol is not in their images + at all, before or after (`nm` finds none in `Heltec_v3_companion_radio_wifi` and + `RAK_4631_companion_radio_ble`). +- The nRF52 server builds (`RAK_4631_*`, `ProMicro_*`) contain the change, but the + function is 0xda bytes before and after. The disassembly of `findMatch()` in + `RAK_4631_repeater` differs from the fifth instruction on (the mask is kept in + `r8` instead of `r9`), so the extra compare fits into the same size. + +### What was not measured + +- Nothing was run on a board or on the air. The repeater's forwarding gate + (`simple_repeater/MyMesh.cpp:440`) is read from the source; there is no host + harness for the repeater role. The choice `findMatch()` makes is covered by + the host test, and what a role does with it is covered for the room server by + the series' `native_server` suite. +- The reply scope change for the room server and the sensor is read from + `chooseReplyScope()`, not measured. +- Whether any client sends code 0 floods on the air is not known. + +## Related + +- **The hop limit has the same hole.** `isFloodHopLimitExceeded()` counts only + `ROUTE_TYPE_FLOOD` as un-scoped (`src/helpers/RoutingPolicy.h:19`), so a code 0 + flood also passes `flood.max.unscoped`, the alternative to `denyf *` that + `docs/cli_commands.md:700` recommends. Same cause, different function, left + alone here. +- **Open pull request 2960** (runtime region gating by duty cycle) replaces the + flag test in this loop and in the un-scoped branch with `effectiveFlags()`. A + merge will not conflict with this change, so the wildcard check added here has to + use `effectiveFlags()` too, or code 0 will walk past that gate the same way. +- **Open pull request 1791** (a default "any" Region) changes the end of + `findMatch()` and does not overlap. diff --git a/docs/refactoring/0001-shared-radio-gps-prefs.md b/docs/refactoring/0001-shared-radio-gps-prefs.md new file mode 100644 index 00000000000..5e5a5aaa366 --- /dev/null +++ b/docs/refactoring/0001-shared-radio-gps-prefs.md @@ -0,0 +1,185 @@ +# 0001: Share the radio and GPS settings between both NodePrefs + +Status: ready for review. Behaviour change: none. + +## Context + +MeshCore has two classes called `NodePrefs`: + +- `src/helpers/CommonCLI.h` (now `src/helpers/CommonNodePrefs.h`): the settings of + the repeater, room server and sensor, edited through `CommonCLI`. +- `examples/companion_radio/NodePrefs.h`: the settings of the companion radio. + +Both persist themselves as `/prefs.json` through `ConfigSerializer`, and both +declared the same 17 radio and 3 GPS fields. Each also had a private `RadioPrefs` +class that implemented the 34 pure virtual getters and setters of +`CommonRadioPrefs` by forwarding to its parent, and a private `GPSPrefs` class. +The companion copies were marked "COPIED from CommonCLI (for now)". + +Copies drift. In this release cycle the companion copy needed three fixes for +defects the server copy did not have: + +- `6dad3d5a`: `setSpreadFactor()` evaluated `_parent->sf;` without assigning it. +- `e3d0f9fc`: `isCadEnabled()` returned `false` and `setCadEnabled()` did nothing. +- `5d82ed35`: `setIntThresh()`, `setAgcResetInt()`, `setFloodTxDelay()`, + `setDirectTxDelay()` did nothing, `getFloodTxDelay()` returned a constant. + +Adding one radio setting meant touching both classes, both forwarding classes and +the abstract interface. + +## Decision + +1. `RadioPrefsFields` (in `CommonRadioPrefs.h`) and `GPSPrefsFields` (in the new + `CommonGPSPrefs.h`) declare the radio and GPS fields once, with type, default + and unit. The fields are ordered by size so the structs contain no padding. +2. Both `NodePrefs` classes inherit these two structs instead of declaring the + fields themselves. +3. `CommonRadioPrefs` becomes the one concrete binding of a `RadioPrefsFields`: + it owns the `radio` JSON object, the radio CLI commands and the key/value + access boards use. It is constructed with the fields it binds to. The 34 pure + virtuals and both forwarding classes are gone. +4. `CommonGPSPrefs` is the binding for the `gps` JSON object and replaces the two + identical private `GPSPrefs` classes. + +The step is split into two commits so each can be reviewed on its own: + +- `refactor(prefs): move server NodePrefs into its own header and pin it with host tests` + moves the server `NodePrefs` verbatim out of `CommonCLI.h` (which pulls in + `Mesh.h`, `IdentityStore.h`, `ClientACL.h` and `RegionMap.h` and therefore + cannot be compiled on the host) and adds the characterization tests. +- `refactor(prefs): share the radio and GPS settings between server and companion NodePrefs` + is the change itself; the tests from the first commit pass unchanged. + +## Alternatives considered + +- **Hold the groups as members** (`prefs.radio.freq`). Rejected: it would change + every access in roles, UI, bridges, boards and downstream forks. Inheriting + keeps `prefs.freq` working everywhere. +- **A template binding over the parent class** (`RadioPrefsBinding`). + Rejected: it removes the duplicated code but keeps the duplicated fields, and + the binding would rely on matching member names rather than on a type. +- **Keep `CommonRadioPrefs` abstract** and add one concrete subclass. Rejected: + with one data layout left there is nothing to vary, so the 34 virtuals would + stay pure forwarding. + +## Compatibility + +- `/prefs.json`: unchanged byte for byte (same keys, same order, same number + formatting). The `def()` order, not the field order, defines the file. +- CLI: unchanged replies and side effects. +- Legacy binary prefs (`/com_prefs`, `/new_prefs`) are read field by field, so + the changed in-memory layout does not matter to them. No code copies a + `NodePrefs` as a raw block. +- Source compatibility: every `#include ` keeps working + (it includes `CommonNodePrefs.h`), and every field keeps its name and type. + `CommonRadioPrefs`, introduced in this release cycle, is now a concrete class; + code that subclassed it to implement the pure virtuals has to drop that + subclass and construct it from a `RadioPrefsFields`. + +## Verification + +### Host tests + +`test/test_common_node_prefs` (new) and `test/test_companion_node_prefs` (until +now only a disabled case) run the same checks against both classes, shared +through `test/mocks/RadioPrefsChecks.h`: + +- the exact `/prefs.json` text for a fully populated `NodePrefs`, +- loading that text restores every persisted field, +- every radio CLI command: reply text, the field it writes, that an accepted + command marks the prefs dirty and a rejected one does not, and the TX power / + RX gain push to the radio driver (`test/mocks/target.h` records the calls), +- FEM gain access through the `custom` key/value store. + +The expected values were captured from the code before the change. The tests +pass on both commits. + +The tests were checked against deliberately broken code (mutation tests), each +of which must fail: + +| Mutation | Result | +|----------|--------| +| swap the `sf` and `cr` JSON keys | 2 tests fail | +| `setFloodTxDelay()` writes the direct delay | 2 tests fail | +| `setAirtimeFactor()` does not mark the prefs dirty | 2 tests fail | +| swap the GPS `en` and `int` JSON keys | 4 tests fail | + +### Builds + +18 environments covering every role and platform were built from `dev` (`428a95c6`) +and from each commit of this step, with the same toolchain: + +- All 18 build on both commits. +- The first commit (move and tests) produces firmware of exactly the same flash + and RAM size as `dev`, as expected for a verbatim move. +- The second commit makes every firmware smaller and leaves RAM unchanged + (the forwarding classes and their 34 virtual methods are gone; the field + order keeps the structs free of padding): + +| Environment | Platform | Flash `dev` | Flash this step | Flash delta | RAM delta | +|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32-S3 | 1,174,449 | 1,173,485 | -964 | +0 | +| `Heltec_v3_room_server` | ESP32-S3 | 1,169,705 | 1,168,769 | -936 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32-S3 | 1,666,437 | 1,665,533 | -904 | +0 | +| `Heltec_v3_companion_radio_wifi` | ESP32-S3 | 1,102,677 | 1,101,745 | -932 | +0 | +| `Heltec_v3_sensor` | ESP32-S3 | 1,180,593 | 1,179,633 | -960 | +0 | +| `RAK_4631_repeater` | nRF52840 | 440,952 | 440,456 | -496 | +0 | +| `RAK_4631_room_server` | nRF52840 | 434,088 | 433,576 | -512 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52840 | 462,548 | 462,068 | -480 | +0 | +| `RAK_4631_sensor` | nRF52840 | 437,544 | 437,016 | -528 | +0 | +| `PicoW_repeater` | RP2040 | 491,972 | 491,316 | -656 | +0 | +| `wio-e5-mini_repeater` | STM32WL | 203,552 | 203,000 | -552 | +0 | +| `wio-e5-mini_sensor` | STM32WL | 202,904 | 202,336 | -568 | +0 | +| `RAK_3112_repeater_bridge_espnow` | ESP32-S3 | 1,175,717 | 1,174,761 | -956 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | 240,368 | 239,720 | -648 | +0 | +| `ProMicro_repeater` | nRF52840 | 345,424 | 344,912 | -512 | +0 | +| `ProMicro_room_server` | nRF52840 | 338,456 | 337,960 | -496 | +0 | +| `ProMicro_companion_radio_usb` | nRF52840 | 357,300 | 356,820 | -480 | +0 | +| `ProMicro_sensor` | nRF52840 | 341,900 | 341,372 | -528 | +0 | + +### Hardware + +Run on two LilyGo T-Echo boards (nRF52840, SX1262), one per `NodePrefs` class, +starting from real data written by v1.17.1: + +| Board | Firmware role | Settings class | +|-------|---------------|----------------| +| T-Echo A | room server (`LilyGo_T-Echo_room_server`) | server roles' `NodePrefs` | +| T-Echo B | companion radio over USB (`LilyGo_T-Echo_companion_radio_usb`) | companion `NodePrefs` | + +Sequence on each board: + +1. Flash `dev`. It migrates the v1.17.1 binary prefs to `/prefs.json`; all radio + values read back as before. +2. With `dev`, change 11 to 15 settings through the CLI (room server: serial + console; companion: `CMD_RUN_CLI_COMMAND` frames), reboot, read them back. +3. Flash this step. Read all settings again: identical to step 2. +4. With this step, change the settings to other values, reboot, read back: all + changes persisted. +5. Flash `dev` again. Read all settings: identical to step 4. + +Step 1 ran once per board. Steps 2 to 5 ran on the final revision of this step, +after an earlier run on a previous revision of it had passed the same way. + +The room server reads 30 values (`get ...` for every radio setting plus name, +role, location, advert and flood settings, owner info); the companion reads 16 +values plus the `APP_START` self info and the tuning parameters. Every +comparison was identical except for the version line. To prove which firmware +was running, the board images were built with `FIRMWARE_BUILD_DATE` set to the +commit (for example `ver` answered `v1.18.0 (Build: B-a59561ff)`); this define is +the only difference to the images in the build table. + +Not covered on hardware: the GPS commands (the T-Echo room server has no GPS +toggle; the host tests cover the `gps` JSON object), the repeater and sensor +roles (same class as the room server, built only), the BLE and WiFi companions, +and the ESP32, RP2040 and STM32 platforms (built only). + +## Follow-ups (not in this step) + +- One table per settings group (name, type, range, JSON key, CLI key, roles), so + a new setting is one row; the CLI could then answer "unsupported" for settings + a role does not use instead of accepting them silently. +- Let the core read its settings from the groups instead of a virtual getter per + setting in every role. +- Remove the `target.h` include from `CommonRadioPrefs.cpp` by giving it the radio + driver through an interface. diff --git a/docs/refactoring/0002-core-reads-radio-prefs.md b/docs/refactoring/0002-core-reads-radio-prefs.md new file mode 100644 index 00000000000..25be79f544b --- /dev/null +++ b/docs/refactoring/0002-core-reads-radio-prefs.md @@ -0,0 +1,170 @@ +# 0002: Let the core read its tuning values from the radio settings + +Status: ready for review. Behaviour change: none. Builds on 0001. + +## Context + +The core needs a handful of values that users set through the CLI or the +companion app: the duty cycle budget, the RX delay base, the flood and direct +retransmit delay factors, the interference threshold, CAD, the AGC reset interval +and the number of extra ACKs. It asked for each of them through a virtual getter +(`src/Dispatcher.h`, `src/Mesh.h`), and every role answered with a one-line +override that returned its own `_prefs` field: + +| Hook | Repeater | Room server | Companion | Sensor | +|------|----------|-------------|-----------|--------| +| `getAirtimeBudgetFactor()` | same | same | same | same | +| `calcRxDelay()` | same | same | same | same | +| `getInterferenceThreshold()` | same | same | same | same | +| `getCADEnabled()` | same | same | same | same | +| `getAGCResetInterval()` | same | same | same | same | +| `getRetransmitDelay()` | same | same | same | **own formula** | +| `getDirectRetransmitDelay()` | same | same | same | **own formula** | +| `getExtraAckTransmitCount()` | same | same | same | **not overridden** | + +31 overrides, 29 of them identical. A new core setting needed a new virtual and +an override in every role: making CAD a setting (`7c8e0924`) touched 15 files. + +Since 0001 both `NodePrefs` classes share these fields through +`RadioPrefsFields`, so the core can read them from one place. + +## Decision + +1. `RadioPrefsFields` moves into the core as `src/RadioPrefsFields.h`, a header + without dependencies. `helpers/CommonRadioPrefs.h` includes it. +2. `Dispatcher::useRadioPrefs(const RadioPrefsFields*)` attaches a node's + settings. The default implementations of the eight hooks read them on every + call, so a CLI change applies at once, exactly like the role overrides did. + The formulas moved verbatim from the role overrides. +3. Without attached settings, every hook returns the built-in default it returned + before. All hooks stay virtual. +4. Repeater, room server, sensor and companion attach their `_prefs` in their + constructor and drop the 29 identical overrides. +5. The sensor keeps what differs: its two retransmit delay formulas (it draws + from six discrete steps where the other roles draw from a continuous range), + and it now states explicitly that it sends no extra ACKs. It never did, + although the CLI accepts `multi.acks` there. Aligning the sensor with the other + roles changes its behaviour on air and is left to a separate change. + +## Alternatives considered + +- **Pass the settings to the constructor of `Dispatcher` / `Mesh`.** Rejected: + it changes the constructor every downstream subclass calls. A setter is + additive. +- **Copy the values into the core on change.** Rejected: every code path that + changes a setting (CLI, companion frames, UI, board key/value access) would + have to notify the core, and a missed one would silently keep the old value. + Reading through a pointer keeps the current behaviour of the overrides. +- **Remove the virtual hooks.** Rejected: downstream firmware overrides them. + +## Compatibility + +- Behaviour of all roles is unchanged (see verification). +- Downstream subclasses that override any of the hooks keep working. Code that + does not call `useRadioPrefs()` gets the same defaults as before. +- `RadioPrefsFields` is now declared in `src/RadioPrefsFields.h`; it is still + available through `helpers/CommonRadioPrefs.h`. + +## Verification + +### Host tests + +A new native environment, `native_core`, builds the real `src/Dispatcher.cpp` and +`src/Mesh.cpp` for the host for the first time, against fakes for the radio, +clock, RNG, packet manager and tables, and link-time stand-ins for the four +`Identity` members `Mesh.cpp` references (they need the board crypto libraries; +the tests never call them). The unit test workflow runs it. + +The step is two commits. The first only adds the tests; they pin what the core +returns without settings and pass on the code before the change: + +- airtime budget factor 1.0, RX delay with base 10, retransmit delay from the raw + packet length, no direct delay, no extra ACKs, interference threshold, CAD and + AGC reset off. + +The second commit adds tests that, with settings attached, the core returns +exactly what the v1.18.0 role overrides returned (their formulas are copied into +the test with the source lines), for four sets of settings, two kinds of packets +(with and without transport codes) and a range of scores and airtimes; that +settings changed at run time apply immediately; and that detaching restores the +defaults. The tests from the first commit pass unchanged. + +Mutation tests, each of which must fail: + +| Mutation | Result | +|----------|--------| +| airtime budget factor ignores the settings | 2 tests fail | +| RX delay without the "base <= 0 disables" check | 1 test fails | +| flood retransmit delay counts the raw packet length | 1 test fails | +| direct retransmit delay uses the flood factor | 1 test fails | +| extra ACKs ignore the settings | 2 tests fail | + +### Builds + +The same 18 environments as in 0001 were built from this step and from 0001, +with the same toolchain. All build. Compared with 0001, the step is between 136 +bytes smaller and 464 bytes larger in flash, and RAM grows by 0 or 8 bytes. The +only new data member is the settings pointer in `Dispatcher`; the core now holds +both the built-in default and the settings-based formula of each hook, while +the identical overrides are gone from the roles. Together with 0001, every firmware is still +smaller than `dev`. + +| Environment | Platform | Flash vs 0001 | RAM vs 0001 | Flash vs `dev` | RAM vs `dev` | +|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32-S3 | -100 | +0 | -1064 | +0 | +| `Heltec_v3_room_server` | ESP32-S3 | -76 | +0 | -1012 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32-S3 | -136 | +8 | -1040 | +8 | +| `Heltec_v3_companion_radio_wifi` | ESP32-S3 | -116 | +8 | -1048 | +8 | +| `Heltec_v3_sensor` | ESP32-S3 | +160 | +8 | -800 | +8 | +| `RAK_4631_repeater` | nRF52840 | +160 | +0 | -336 | +0 | +| `RAK_4631_room_server` | nRF52840 | +208 | +0 | -304 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52840 | +192 | +8 | -288 | +8 | +| `RAK_4631_sensor` | nRF52840 | +464 | +8 | -64 | +8 | +| `PicoW_repeater` | RP2040 | -32 | +0 | -688 | +0 | +| `wio-e5-mini_repeater` | STM32WL | -32 | +0 | -584 | +0 | +| `wio-e5-mini_sensor` | STM32WL | +120 | +8 | -448 | +8 | +| `RAK_3112_repeater_bridge_espnow` | ESP32-S3 | -92 | +0 | -1048 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | -32 | +0 | -680 | +0 | +| `ProMicro_repeater` | nRF52840 | +160 | +0 | -352 | +0 | +| `ProMicro_room_server` | nRF52840 | +208 | +0 | -288 | +0 | +| `ProMicro_companion_radio_usb` | nRF52840 | +192 | +8 | -288 | +8 | +| `ProMicro_sensor` | nRF52840 | +464 | +8 | -64 | +8 | + +The RX delay formula moved from the roles into `Dispatcher.cpp`, which is compiled +with different headers. In all 18 environments the role object files of 0001 +call `powf` for it, and `Dispatcher.cpp` of this step calls `powf` too (checked +with `nm` on every object file), so the computed delays are bit for bit the same. +`Dispatcher.cpp` still calls `pow` (ESP32, RP2040, STM32) or `exp` (nRF52) for +the built-in default, as before. + +### Hardware + +The hooks only change values inside the running core, which no command shows. +For the hardware run, one identical diagnostic patch (a `diag tuning` CLI command +that prints what the running core returns for the duty cycle budget, the RX delay +at score 0.3 and 1000 ms airtime, the interference threshold, CAD, the AGC reset +interval and the extra ACKs; not part of any commit) was applied to `dev` and to +this step, and both were run on two LilyGo T-Echo boards (nRF52840, SX1262): +room server (server roles' `NodePrefs`) and USB companion (companion `NodePrefs`). + +Sequence on each board: flash `dev`, change the settings through the CLI, read +the core values, reboot, read; flash this step, read; change the settings again +without a reboot, read; reboot, read; flash `dev`, read. On both boards: + +- `dev` and this step returned the same core values for the same settings, in + both directions: `af100=200 rx=991 thr=12 cad=1 agc=40000 acks=1` for the first + set and `af100=150 rx=562 thr=7 cad=0 agc=24000 acks=0` for the second. +- These are the values the settings imply (for example `rx=991` is + `(3.5^0.55 - 1) x 1000` for an RX delay base of 3.5). +- A change through the CLI showed up in the core immediately, without a reboot. + +The retransmit delays are random and were covered by the host tests only. The +sensor role, the BLE and WiFi companions and the ESP32, RP2040 and STM32 +platforms were built, not run on hardware. + +## Follow-ups + +- Decide whether the sensor should use the same retransmit delays and honour + `multi.acks` like the other roles (behaviour change, own pull request). +- Remove the 2 second polling of CAD and the interference threshold in + `Dispatcher::loop()` by pushing changes to the radio when they happen. diff --git a/docs/refactoring/0003-radio-settings-table.md b/docs/refactoring/0003-radio-settings-table.md new file mode 100644 index 00000000000..1841b168953 --- /dev/null +++ b/docs/refactoring/0003-radio-settings-table.md @@ -0,0 +1,163 @@ +# 0003: Drive the radio settings from one table + +Status: ready for review. Behaviour change: none. Builds on 0001 and 0002. + +## Context + +After 0001 and 0002, all radio settings live in `RadioPrefsFields`, and +`CommonRadioPrefs` is the one place that persists them and handles their CLI. In +that place each setting was still spelled out up to three times: a `def()` line in +`structure()` for `/prefs.json`, a `get` branch and a `set` branch in +`handleCommand()` with their own parsing, range check and reply (27 branches for +14 settings), plus a branch in the key/value store for the two FEM gains. + +## Decision + +`src/helpers/CommonRadioPrefs.cpp` gets a table with one row per radio setting: + +| Column | Meaning | +|--------|---------| +| JSON key | key in the `radio` object of `/prefs.json`; the row order is the key order | +| field, type | the field in `RadioPrefsFields` (`offsetof`), float, uint8_t or int8_t | +| CLI name, CLI kind | `get ` / `set `, and how the value is parsed | +| max, range error | upper limit and the reply for an out of range value | +| key/value | whether boards may read and write it through the key/value store | + +CLI kinds, each reproducing the parsing of the branches they replace: + +- get only: `> value` +- float: `atof()`, accepted if `0 <= value <= max`; NaN and infinity are rejected + by looking at the bits (see below) +- uint8_t: `atoi()` into `uint8_t`, accepted if `value <= max` (no limit if none) +- on/off: on if the value starts with `on` + +The table drives `structure()`, the key/value store and the CLI of the eight +settings that fit: `freq` (get only), `cad`, `int.thresh`, `rxdelay`, `txdelay`, +`direct.txdelay`, `path.hash.mode`, `multi.acks`. Six settings keep their +hand-written branches, moved verbatim, because their CLI does not fit a kind: +`radio` (four values at once), `af` (a stricter parser that rejects garbage), +`dutycycle` (derived from `af`), `radio.rxgain` and `tx` (pushed to the radio +driver; `get tx` also matches with trailing text), `agc.reset.interval` (unit +conversion and its own reply). + +A new setting of a common kind is now a field and one row. + +## Alternatives considered + +- **Make the six special settings fit generic kinds too.** Rejected for this step: + each would need its own kind or a hook, and some of their quirks (NaN passes + `set af`, `get tx foo` reads the TX power, `dutycycle 1` sets an airtime factor + of 99 that `set af` would refuse) are visible to users. Changing them is a + behaviour change. +- **A table in the header with pointer-to-member fields.** Rejected: members of + different types do not fit one array without a type tag anyway, and `offsetof` + on the standard-layout `RadioPrefsFields` keeps the table plain data. +- **Generate the CLI documentation from the table.** Left for later. + +## Compatibility + +- `/prefs.json`, all CLI replies, the dirty marking and the key/value store are + unchanged. +- The public interface of `CommonRadioPrefs` is unchanged; its getters and setters + stay for source compatibility. `structure()` is now defined in the `.cpp`. + +## Verification + +### Host tests + +Before the change, a new test (`RadioCliEdgeCases`, run for both `NodePrefs` +classes) pinned about 70 edge cases of the radio CLI on the old code: boundaries, +out of range values, values that do not fit the field (`int.thresh 300` is stored +as 44, `tx -5` reads back as 251), garbage (read as 0 by `atof`/`atoi`), NaN +(accepted by `set af`, rejected by `set rxdelay`), prefix look-alikes and commands +other handlers own (`set freq`). Together with the characterization tests of 0001 +(exact `/prefs.json`, every command's reply and side effect) they pass unchanged +after the change. + +A new native environment, `native_ofast`, runs the prefs tests again with `-Ofast`, +the optimisation the nRF52 firmware is built with (see "NaN and -Ofast" below). +The unit test workflow runs it. + +Mutation tests (number of failing tests; `native` and `native_ofast` on the +author's machine use clang, the CI uses GCC): + +| Mutation | `native` | `native_ofast` (clang) | +|----------|---------:|-----------------------:| +| swap the `sf` and `cr` rows (JSON order) | 2 | 2 | +| float check as `v < 0 \|\| v > max` (lets NaN through) | 2 | 0 | +| uint8_t limit off by one | 2 | 2 | +| on/off compares three characters | 2 | 2 | +| `tx` exposed in the key/value store | 2 | 2 | +| `set ` without checking the space after the name | 2 | 2 | +| bit check removed, plain `!(v >= 0 && v <= max)` | 0 | 0 | + +The last row is the defect the hardware run found (below). Clang keeps rejecting +NaN with that comparison even under `-Ofast`; GCC does not. Pushed to a temporary +branch, the same mutation made the CI's `native_ofast` (GCC) fail with 2 failing +tests, while the unmutated branch passed. + +### NaN and -Ofast + +nRF52 firmware is compiled with `-Ofast` (set by PlatformIO's Adafruit nRF52 +builder), which includes `-ffast-math`: the compiler may assume that floats are +never NaN or infinite. The old hand-written branch `if (db >= 0 && db <= 20.0f)` +happened to reject NaN on the boards; the first version of the table, with an +equivalent comparison written differently, accepted `set rxdelay nan` on a T-Echo. +The table now recognises NaN and infinity by their bits, which rejects them with +any compiler and flags, as the old branches did on the boards and in IEEE builds. +`set af`, whose branch did not change, accepts `nan` on the boards before and after +this step (measured); how it behaves elsewhere depends on the compiler. + +### Builds + +The same 18 environments were built from this step and from 0002. All build. Every +firmware is 8 to 96 bytes smaller than with 0002; RAM is unchanged. + +| Environment | Platform | Flash vs 0002 | RAM vs 0002 | Flash vs `dev` | RAM vs `dev` | +|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32-S3 | -84 | +0 | -1148 | +0 | +| `Heltec_v3_room_server` | ESP32-S3 | -68 | +0 | -1080 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32-S3 | -8 | +0 | -1048 | +8 | +| `Heltec_v3_companion_radio_wifi` | ESP32-S3 | -68 | +0 | -1116 | +8 | +| `Heltec_v3_sensor` | ESP32-S3 | -56 | +0 | -856 | +8 | +| `RAK_4631_repeater` | nRF52840 | -96 | +0 | -432 | +0 | +| `RAK_4631_room_server` | nRF52840 | -80 | +0 | -384 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52840 | -64 | +0 | -352 | +8 | +| `RAK_4631_sensor` | nRF52840 | -64 | +0 | -128 | +8 | +| `PicoW_repeater` | RP2040 | -40 | +0 | -728 | +0 | +| `wio-e5-mini_repeater` | STM32WL | -24 | +0 | -608 | +0 | +| `wio-e5-mini_sensor` | STM32WL | -32 | +0 | -480 | +8 | +| `RAK_3112_repeater_bridge_espnow` | ESP32-S3 | -60 | +0 | -1108 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | -40 | +0 | -720 | +0 | +| `ProMicro_repeater` | nRF52840 | -64 | +0 | -416 | +0 | +| `ProMicro_room_server` | nRF52840 | -96 | +0 | -384 | +0 | +| `ProMicro_companion_radio_usb` | nRF52840 | -48 | +0 | -336 | +8 | +| `ProMicro_sensor` | nRF52840 | -64 | +0 | -128 | +8 | + +### Hardware + +Two LilyGo T-Echo boards (nRF52840, SX1262), room server and USB companion, each +running the 0002 image and the image of this step in turn (marked builds, see +0001). A script of 58 radio commands (edge cases that are safe on air: no +frequency change, no extreme TX power) ran on both images from the same starting +values, through the serial console (room server) and `CMD_RUN_CLI_COMMAND` frames +(companion). + +- All 58 replies were identical between 0002 and this step on both boards, + including garbage, out of range values, NaN and infinity, and look-alikes. +- `set af nan` behaved the same on both images (accepted). +- Each image read the settings the other had written (upgrade and downgrade). + +The first version of this step failed this run: it answered `OK` to +`set rxdelay nan` and `set txdelay nan` where 0002 answered with an error. That +led to the bit check and to `native_ofast` described above. + +On the room server, the first read right after flashing the 0002 image twice +returned a reply for a garbled command (`??: txdelay`); repeated reads were +identical. It was not observed with the image of this step in these runs. + +## Follow-ups + +- Generate the radio part of `docs/cli_commands.md` from the table. +- Decide whether the quirks listed above should be fixed (behaviour change, own + pull request). diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md new file mode 100644 index 00000000000..2c9aa7e8415 --- /dev/null +++ b/docs/refactoring/0004-shared-server-base.md @@ -0,0 +1,297 @@ +# 0004: A shared base for the three server roles + +Status: proposal, not implemented. This note is the analysis and the cut plan for +steps 4a to 4f; each step gets its own branch and its own note on landing. + +## Context + +Repeater, room server and sensor each carry their own copy of the same server +underpinnings. Candidate 2 of the v1.18 architecture review estimated 350 to 400 +duplicated lines. This note replaces that estimate with a measurement. + +Measured on `refactor/radio-cli-table` (the base of this step), by extracting every +top level member function of the three role implementations and comparing the bodies +after stripping comments, whitespace and the class name: + +| file | lines | +|---|---| +| `examples/simple_repeater/MyMesh.cpp` | 1328 | +| `examples/simple_room_server/MyMesh.cpp` | 1069 | +| `examples/simple_sensor/SensorMesh.cpp` | 1102 | + +### Identical in all three roles + +73 lines per role, so 146 lines exist only as copies: + +| function | lines per role | +|---|---| +| `onRecvPacket` | 14 | +| `sendFloodReply` | 20 | +| `sendFloodScoped` | 10 | +| `startRegionsLoad` | 6 | +| `saveRegions` | 3 | +| `onDefaultRegionChanged` | 7 | +| `formatStatsReply` | 3 | +| `formatRadioStatsReply` | 3 | +| `formatPacketStatsReply` | 4 | +| `setTxPower` | 3 | + +### Identical in all three apart from cosmetics or one role value + +83 lines per role, so 166 more lines exist only as copies. The differences are +pointer star placement, whitespace and redundant parentheses, with three exceptions +listed under "Real differences" below: + +`applyTempRadioParams` (9), `getPeerSharedSecret` (9), `saveIdentity` (12), +`sendSelfAdvertisement` (12), `updateAdvertTimer` (7), `updateFloodAdvertTimer` (7), +`formatFileSystem` (12), `searchPeersByHash` (9), `createSelfAdvert` (6). + +### Identical in repeater and room server, absent from the sensor + +50 lines per role, so 50 more lines exist only as copies, and the sensor lacks the +capability altogether: `clearStats` (5), `dumpLogFile` (15), `getLogDateTime` (8), +`logRxRaw` (8), `logTxFail` (11), `setRxBoostedGain` (3). Near identical in the same +two roles: `logRx` (25 and 19), `logTx` (24 and 18), `openAppend` (9 and 9). + +### The boot sequence + +`begin()` is 53, 44 and 43 lines and 87 to 96 percent identical. The only real +differences are the repeater's `WITH_BRIDGE` block, the sensor's missing RX boosted +gain apply and a debug print the room server does not have. + +### Role specific, not shared + +`handleRequest` (167, 67, 111 lines; 23 to 55 percent similar), `onPeerDataRecv` +(102, 167, 98), `onAnonDataRecv` (51, 88, 28) and `loop` (44, 85, 116) differ by +role and are out of scope. `handleCommand` (90, 93, 106; 78 to 88 percent) looks +shareable but mixes role commands into one dispatcher, so it is deferred. + +So the measurement supports the review: 312 lines of the three role files are pure +copies of each other, rising to about 420 if the logging block the sensor lacks is +counted, and about 500 if the boot sequence is unified too. + +## Real differences found while comparing + +Three differences are behaviour, not formatting. They are the reason this is a +series of small steps and not one move. + +1. `searchPeersByHash`: the sensor bounds its loop with `n < MAX_SEARCH_RESULTS` + because its `matching_peer_indexes` holds 24 entries, while the repeater and the + room server size the array `MAX_CLIENTS` (32) and need no bound. Neither can + overflow: `ClientACL` holds `clients[MAX_CLIENTS]` and caps `num_clients` at + MAX_CLIENTS in both `load()` and `putClient()`, so `getNumClients()` never + exceeds 32. The sensor can, however, report only 24 of up to 32 matching + clients. One array of `MAX_CLIENTS` in the shared base removes the truncation. +2. `formatFileSystem`: the repeater and the sensor erase with `InternalFS.format()` + under `NRF52_PLATFORM || STM32_PLATFORM`, the room server only under + `NRF52_PLATFORM`, so an STM32 room server would hit the `#error` branch. This is + latent today: the two STM32 variants (`wio-e5-mini`, `wio-e5-dev`) define no + room server environment, so the branch is never compiled. +3. `createSelfAdvert`: differs only by the advert type the role builds + (`ADV_TYPE_REPEATER`, `ADV_TYPE_ROOM`, `ADV_TYPE_SENSOR`), the classic role + parameter. + +## The cut + +A new class `ServerMesh` in `src/helpers/`, between `mesh::Mesh` and the three role +classes, holding the members all three already declare separately (the ACL, the +region map and its temp instance, the key store, the default scope, the received +packet's region, the advert timers, the temp radio state) and the shared methods. +Role differences become either a constructor argument (the advert type) or a small +virtual hook. + +Each step is a branch of its own, stacked in order, each a candidate for a separate +upstream pull request: + +- **4a Region and reply scope plumbing.** Move `onRecvPacket`, `sendFloodReply`, + `sendFloodScoped`, `startRegionsLoad`, `saveRegions`, `onDefaultRegionChanged`. + All six are byte identical in all three roles, so the move is provable by diff. + 63 lines per role. +- **4b Advert schedule.** Move `createSelfAdvert` (advert type as a constructor + argument), `sendSelfAdvertisement`, `updateAdvertTimer`, `updateFloodAdvertTimer`. + 32 lines per role. +- **4c Temp radio.** Move `applyTempRadioParams` and the revert branch of `loop()`. + The revert lives inside three different `loop()` bodies, so this step reads the + three loops carefully rather than moving a whole function. +- **4d Identity, file system and stats.** Move `saveIdentity`, `formatFileSystem` + (with the STM32 guard of the repeater and the sensor, which fixes difference 2), + `formatStatsReply`, `formatRadioStatsReply`, `formatPacketStatsReply`, + `setTxPower`, `clearStats`. +- **4e ACL peer lookup.** Move `searchPeersByHash` and `getPeerSharedSecret` with + one array of `MAX_CLIENTS` and one bound, which fixes difference 1. +- **4f Boot sequence.** Move `begin()` with a hook for the repeater's bridge block, + after the sensor's RX gain gap is closed (see below), so the three boot paths are + the same code. + +The packet logging block (`logRx`, `logTx`, `logRxRaw`, `logTxFail`, +`getLogDateTime`, `dumpLogFile`, `openAppend`) is left for a later step: giving it to +the sensor is a behaviour change, not a move. + +## The sensor, decided separately + +Marcel's decision of 19 October is to align the sensor with the other two roles, on +its own branch and with its own note, because it changes behaviour. Checking the +upstream history first, as that decision required, found no role specific reason for +any of the four deviations; each one is a change that reached the repeater and the +room server and skipped the sensor. Details are in the ledger. + +Step 2 already made two of the deviations explicit, so on this base the alignment is +small: + +- `getExtraAckTransmitCount`: delete the sensor's override. The core returns + `_radio_prefs->multi_acks` since step 2, so deleting three lines makes + `multi.acks` effective and brings the already present branch in + `SensorMesh::sendAckTo` to life. +- `getRetransmitDelay` and `getDirectRetransmitDelay`: delete both overrides. The + core applies `nextInt(0, 5*t + 1)`, the formula of the other roles. This is the + one open point, see below. +- `allowPacketForward`: use `mesh::isFloodHopLimitExceeded`, so + `flood.max.unscoped` and `flood.max.advert` take effect as on the other roles. +- RX boosted gain: initialise the pref under the same guards as the other roles and + apply it in `begin()`, mirroring upstream's own room server fix. + +## What the series achieved + +Steps 4a to 4e have landed. Measured on the role implementations, against the state +before the series (`98ffa5c1`): + +| file | before | after | +|---|---|---| +| `examples/simple_repeater/MyMesh.cpp` | 1328 | 1137 | +| `examples/simple_room_server/MyMesh.cpp` | 1069 | 879 | +| `examples/simple_sensor/SensorMesh.cpp` | 1102 | 919 | +| `src/helpers/ServerMesh.h` and `.cpp` | 0 | 328 | + +564 lines left the roles, 328 arrived in the base, and what remains exists once +instead of three times. The host suite grew from 82 to 115 test cases. + +What is still shared between all three roles, and why it stayed: + +- `begin()` was step 4f and has landed, see [0007](0007-boot-sequence.md): one body in + `ServerMesh` with three hooks, the order unchanged. +- `allowPacketForward()` is a candidate. The room server's and the sensor's bodies + are identical only on the branch that also carries the flood limit alignment + (`refactor/sensor-flood-limits`, 8 lines each there); on the branch without it the + sensor still checks `flood_max` alone (5 lines). The repeater adds a Region check + and loop detection on top of the same two leading checks. A later step could share + the common part behind a hook; it was not part of this cut. +- `createSelfAdvert()` is a role hook by design now: only the advert type differs. +- `handleCommand()`, `handleRequest()`, `onAnonDataRecv()`, `onPeerDataRecv()` and + `loop()` differ by role and are out of scope, as stated above. +- `onPeerPathRecv()` is 90 percent shared between the repeater and the room server + but only about 30 percent with the sensor, so it was never part of this cut. + +## The build matrix + +Run on 20 October, all 18 environments in two worktrees on the exact commits, same +toolchain, sequentially. **36 of 36 builds succeeded, none failed.** Measured against +the series base `98ffa5c1`, so the numbers isolate steps 4a to 4e; the state measured +also carried both parts of the sensor alignment, which is the content of +`refactor/sensor-flood-limits`. + +| environment | RAM | delta | Flash | delta | +|---|---|---|---|---| +| `Heltec_v3_repeater` | 62512 | +0 | 1173301 | +552 | +| `Heltec_v3_room_server` | 66200 | +0 | 1168625 | +528 | +| `Heltec_v3_companion_radio_ble` | 194844 | +0 | 1665389 | +616 | +| `Heltec_v3_companion_radio_wifi` | 172384 | +0 | 1101561 | +616 | +| `Heltec_v3_sensor` | 60568 | +32 | 1179737 | +664 | +| `RAK_4631_repeater` | 37240 | +0 | 440520 | +336 | +| `RAK_4631_room_server` | 40928 | +0 | 433704 | +336 | +| `RAK_4631_companion_radio_ble` | 149768 | +0 | 462196 | +0 | +| `RAK_4631_sensor` | 35296 | +32 | 437416 | +256 | +| `PicoW_repeater` | 85368 | +0 | 491244 | +912 | +| `wio-e5-mini_repeater` | 17172 | +0 | 202944 | +664 | +| `wio-e5-mini_sensor` | 15244 | +32 | 202424 | +688 | +| `RAK_3112_repeater_bridge_espnow` | 64080 | -8 | 1174609 | +516 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | 26704 | +0 | 239648 | +920 | +| `ProMicro_repeater` | 33892 | +0 | 345008 | +304 | +| `ProMicro_room_server` | 37588 | +0 | 338072 | +352 | +| `ProMicro_companion_radio_usb` | 99468 | +0 | 356964 | +0 | +| `ProMicro_sensor` | 31956 | +32 | 341772 | +240 | + +### What those numbers mean, and what they do not + +The flash column mixes two things, so it is an upper bound rather than a measure of +added code. Separating them with the section sizes, on the Heltec v3 family: + +| environment | `.flash.text` | `.flash.rodata` | `.dram0.bss` | +|---|---|---|---| +| `Heltec_v3_repeater` | +316 | +236 | 0 | +| `Heltec_v3_room_server` | +292 | +236 | 0 | +| `Heltec_v3_sensor` | +372 | +292 | +32 | +| `Heltec_v3_companion_radio_ble` | **0** | +616 | 0 | + +- **The three server roles gained real code**, about 300 to 370 bytes of text each. + That is the price of the refactoring: calls that went straight into the role now + go through the base, and some of them virtually. +- **The companion gained no code at all.** Its `.flash.text` is byte for byte + identical, which is what it should be: a companion build compiles + `examples/companion_radio/`, and nothing it compiles changed. Its `.flash.rodata` + still moved by 616 bytes, more than any role's did, and the only semantic + difference between the two binaries is the build timestamp. That is linker layout, + caused by one more object file in the link; `ServerMesh` itself is absent from the + companion image (checked with `nm`: 37 symbols in the repeater, 0 in the + companion). Two of the four companion environments report +0 flash, which fits. +- So of the flash deltas in the first table, roughly 240 to 370 bytes per server role + is code and the rest is layout, and for every non-role environment all of it is + layout. + +### Which commit the numbers belong to + +The matrix built `e37ef13e`. The branch tips carry one further source change that it +did not build: an explicit `#include ` in `ServerMesh.h`, plus +comment rewrites. Rather than assume that is size neutral, `Heltec_v3_repeater` was +rebuilt on the current tip and its sections compared with the recorded ones: +`.flash.text` 802931, `.flash.rodata` 274096, `.dram0.bss` 39064, byte for byte the +same in both. So the numbers below apply to the tips as they stand. + +### The RAM figures + +`+32` on each of the four sensor environments, `+0` everywhere else but one. The +sensor's is exact and expected: step 4e replaced its 24 entry peer index array with +one sized `MAX_CLIENTS`, which is 32, so 8 more ints at 4 bytes each. That is the +cost of the sensor no longer truncating its peer search at 24 matches. + +`RAK_3112_repeater_bridge_espnow` reports **-8** bytes of RAM. Not explained; +most likely padding after the fields moved into the base. Noted rather than claimed. + +## Verification of the steps + +Marcel's decision of 19 October: the 18 environment build matrix and the flash and +RAM comparison against `dev` run **once, at the end of the 4a to 4f series**, not per +step. Every step is a move inside the same three roles, so one matrix at the end +covers what six would. Each step still carries its host tests, its mutation table and +its diff proof, and a step is not called verified until the matrix has run. + +## Open points + +1. **The retransmit formula: decided, the sensor keeps its own.** Marcel's decision + of 19 October, after the upstream history was checked: the sensor stays on the six + slot scheme, and the retransmit delay becomes the one role parameter of the shared + base. The reason is that upstream moved the repeater and the room server off that + scheme in `8cbcd227`, whose subject calls itself experimental, and never + revisited or confirmed it in the eleven and a half months since. Adopting it for + the sensor would change radio timing on battery nodes on an experimental basis, + which is exactly the case the decision reserved for a role profile. + + So the sensor alignment covers `multi.acks`, the flood limits and the RX boosted + gain, and leaves `getRetransmitDelay()` and `getDirectRetransmitDelay()` as + documented role overrides. +2. **How deep the characterization goes: settled by measuring it.** The question was + whether the real role classes can be driven on the host at all, or whether the + steps have to lean on the diff alone. Probing the compiler rather than estimating + showed the gap was small: three headers the native build lacked (a file system, an + RTC, the LittleFS global) and a handful of methods and globals missing from the + existing mocks. That is now built, and all three role implementations compile + natively; `native_server` runs the real room server, and the first six + characterization tests pin the temporary radio parameters. + + What remains open is only how far to carry it: + - The repeater and the sensor each need an environment of their own, because the + repeater and the room server both define `MyMesh`. + - The deeper paths (`handleRequest`, `onPeerDataRecv`) need packets and a + populated ACL, which is more harness than the steps in this note require. + + This also answers review finding F6 ("Little of this can be tested on the host") + for the server roles, which was not the goal of this step but is the larger gain. diff --git a/docs/refactoring/0005-sensor-alignment.md b/docs/refactoring/0005-sensor-alignment.md new file mode 100644 index 00000000000..19540fd493e --- /dev/null +++ b/docs/refactoring/0005-sensor-alignment.md @@ -0,0 +1,129 @@ +# 0005: Align the sensor, part one: multi.acks and RX boosted gain + +Status: implemented. The third part of the alignment, the flood hop limits, is +[0006](0006-sensor-flood-limits.md); why it is a change of its own is in "What is +deliberately held back" below. + +This is a change of behaviour, not a move, so it is a branch of its own with a note +of its own. + +## Context + +The sensor sketch was copied from the repeater in July 2025 and then missed three +changes that reached the repeater and the room server. The commit by commit history +is in `architecture-review-v1.18.md` under finding F2. None of the deviations has a +role specific reason such as battery life: each one is a change the sensor was left +out of. + +Marcel's decision of 19 October is to align the sensor, with the retransmit delay +excepted and the flood limits deferred. + +## What changes + +- **`multi.acks` takes effect.** The sensor's `getExtraAckTransmitCount()` override + returned 0 with a comment saying it had never sent extra ACKs. Deleting it lets + the core report the stored setting, as it does for the other roles since step 2. + `SensorMesh::sendAckTo()` already had a branch guarded by that count, so the + branch stops being dead code. +- **RX boosted gain is applied at boot.** The sensor initialises the pref under the + same `USE_SX1262 || USE_SX1268` and `SX126X_RX_BOOSTED_GAIN` guards as the other + roles, and `begin()` pushes the stored value to the radio. Before, a value set + over the CLI took effect at once and was gone after a restart. This mirrors + upstream's own fix for the room server, `a71cc238`. + +Neither change can alter what a node that has not been reconfigured does: an extra +ACK is only sent when `multi.acks` is on, which is off by default, and the gain +value pushed at boot is whatever was already stored. + +## What deliberately does not change + +The retransmit delay. The sensor draws from six discrete multiples of the airtime +while the other roles draw uniformly from a continuous range. Upstream moved them +in `8cbcd227`, whose subject calls itself experimental, and has neither revisited +nor confirmed it since. Aligning the sensor would adopt that experiment and change +radio timing on battery nodes, so the two overrides stay and the formula becomes the +one role parameter of the shared base. Pinned by a test, so it cannot be lost by +accident. + +## Not added on purpose + +`setRxBoostedGain()`, which upstream's room server fix also added. The virtual has a +default in `helpers/CommonCLI.h` and overrides in the repeater and the room server, +and **nothing calls it**: the only `radio.rxgain` command handler +(`helpers/CommonRadioPrefs.cpp`) reaches the radio driver directly, and the settings +table lists the field as `CLI_NONE`, which the generic loop skips. Adding it to the +sensor would add dead code. The two existing overrides are vestigial and are left +for a separate clean-up (ledger issue 11). + +## What is deliberately held back + +The flood hop limits. `allowPacketForward()` still checks `flood_max` only, so +`flood.max.unscoped` and `flood.max.advert` remain without effect on the sensor. +The work exists on `refactor/sensor-flood-limits`, with its tests, and is not merged +because of an upgrade hazard that was measured rather than assumed: + +1. A hop limit of zero is written to `prefs.json` as an explicit `0`, not left + empty. +2. Loading such a file applies the 0, because `/prefs.json` is read through + `loadSerial()`, which has no range checking (Fix 9 of the F4 session). +3. The limits are compared with `>=`, so 0 means "never forward". A node booting on + such a file would refuse every un-scoped flood and every advert at hop 0. + +The sensor set no default for either limit, so every sensor that has ever saved its +settings carries `f_max_uns:0, f_max_adv:0`. Giving the constructor the values the +other roles use is not enough, because the stored zero beats the default. + +How far it reaches: forwarding is off by default on the sensor +(`_prefs.disable_fwd = true` in its constructor, as on the room server; only the +repeater forwards out of the box). The hazard therefore only affects sensors where +an operator switched forwarding on, and those are exactly the ones where it matters. + +Marcel's decision of 20 October was first to wait for Fix 9, on the assumption that +range checking `/prefs.json` would remove the cause. It does not: 0 is a legal value +for these limits, so Fix 9 does not clamp them. The second decision, the same day, is +to lift a stored 0 once on upgrade, behind a version in `/prefs.json`. That is +[0006](0006-sensor-flood-limits.md). + +A separate observation, worth its own issue either way (ledger issue 12): because a +0 is stored and applied verbatim, `set flood.max.unscoped 0` is persistable on every +role and the constructor default does not protect against it. That is the setting +upstream's own comment describes repeaters as running. + +## Verification + +The sensor role had no host environment before this. `native_sensor` and +`test/test_sensor_role` were added first, and the behaviour was recorded there +before anything changed (see the preceding commit); the two tests for these changes +now carry the new expectations, so the diff of the test file is the record of what +changed. `OnlyFloodMaxIsAppliedToday` still passes, which is the evidence that the +flood behaviour is untouched on this branch. + +- `pio test` over all six environments: 123 of 123 test cases pass. (The branch + that also carries the flood limits has 127; the four extra cases are its own.) +- Hardware: done on `techo-b43b` (LilyGo T-Echo, SX1262, sensor role), with marked + images of the merge into `integration` (`ae0b9f22`, "new") and of its first parent + (`cc1e6c3b`, "base"), both built with + `tools/refactoring/diag-sensor-rxgain-acks.patch`. `get radio.rxgain` only reads the + stored pref, so the patch adds `diag sensor`, which prints the pref, the radio's RX + gain register (`CustomSX1262::getRxBoostedGainMode()`) and + `getExtraAckTransmitCount()`. The T-Echo turns boosted gain on during radio init + (`SX126X_RX_BOOSTED_GAIN=1`), so only a stored `off` shows the difference. Values + below are pref / register / extra ACKs. + 1. Original 1.17.1 to base: all 30 settings identical, `1 / 1 / 0`. + 2. Base, `set radio.rxgain off` and `set multi.acks 1`: `0 / 0 / 0` at once, and + `0 / 1 / 0` after a reboot. The stored gain is lost at boot and `multi.acks` has + no effect: the two defects this change removes. + 3. Base to new: all settings identical, `0 / 0 / 1`. + 4. New, `set radio.rxgain on` and reboot: `1 / 1`. Then `set radio.rxgain off`, + `set multi.acks 0` and reboot: `0 / 0 / 0`. + 5. New to base (downgrade): all settings identical, `0 / 1 / 0`, the old boot + behaviour again. + 6. Back to the original 1.17.1 with the original values: 31 of 31 lines identical to + the reading taken before the check. + + Two findings on the way, neither caused by this change (ledger issues 13 and 14): + `set multi.acks 2` is accepted, but the range check after loading `/prefs.json` + clamps the field to 0..1, so it reads back as 1 after a reboot. And a soft reset + sometimes leaves this board's I2C bus with SDA held low, after which every role + blocks in the RTC probe before any radio or mesh code runs; the diagnostic patch + frees the bus before `board.begin()` so the check could run at all. diff --git a/docs/refactoring/0006-sensor-flood-limits.md b/docs/refactoring/0006-sensor-flood-limits.md new file mode 100644 index 00000000000..b44cc0c0db1 --- /dev/null +++ b/docs/refactoring/0006-sensor-flood-limits.md @@ -0,0 +1,197 @@ +# 0006: Align the sensor, part two: the flood hop limits + +Status: implemented. Stacked on step 4f ([0007](0007-boot-sequence.md)), whose shared +`begin()` carries the one-time upgrade this change needs. Part one of the alignment +is [0005](0005-sensor-alignment.md). + +This is a change of behaviour on the sensor, and it adds a version to `/prefs.json` +for every server role, so it is a branch and a note of its own. + +## Context + +`SensorMesh::allowPacketForward()` checked `flood_max` only. The repeater and the +room server also apply `flood.max.unscoped` and `flood.max.advert` through +`mesh::isFloodHopLimitExceeded()`. The sensor missed that change when it reached the +other roles; the history is in `architecture-review-v1.18.md` under finding F2. + +Aligning the check is two lines. What makes it more is the state every existing +sensor is in. + +## The upgrade hazard + +Measured on the host, not assumed. Three tests in `test_sensor_role` hold it: + +1. `AZeroHopLimitIsWrittenOutAsAZero`: a limit of 0 goes into `prefs.json` as an + explicit `0`, not as an empty value. +2. `ASensorUpgradedOntoZeroHopLimitsKeepsForwarding` (before this change it asserted + the opposite): `/prefs.json` is read through `loadSerial()`, which applies a stored + value as it is, and since the limits are compared with `>=`, a 0 refuses every + un-scoped flood and every advert at hop 0. +3. `AMissingKeyLeavesTheNewDefaultStanding`: a key absent from the file overwrites + nothing, so only files that carry the key are affected. + +The sensor set no default for either limit and never applied them, so every sensor +that has ever saved its settings stores `f_max_uns:0, f_max_adv:0`. On the board used +for the hardware check below, stock v1.17.1 reports exactly that. Giving the +constructor the other roles' values (64 and 8) is not enough: the stored zero beats +the default. A sensor with forwarding switched on would have stopped forwarding both +kinds of flood after the update, without any sign. + +How far it reaches: forwarding is off by default on the sensor +(`_prefs.disable_fwd = true` in its constructor, as on the room server). So only +sensors where an operator switched forwarding on are affected, and those are exactly +the ones where it matters. + +### Why the range check does not resolve it + +This change was first parked to wait for Fix 9, the range checking of `/prefs.json`. +Once Fix 9 had landed, its `CommonCLI::sanitisePrefs()` turned out to clamp nineteen +fields but not `flood_max`, `flood_max_unscoped` or `flood_max_advert`. That is +consistent: 0 is a legal value for these limits ("do not forward"), so there is no +range to clamp it into. The reason for parking was an assumption about Fix 9 that +reading it would have refuted. + +## What changes + +- **The sensor applies both limits.** `allowPacketForward()` calls + `mesh::isFloodHopLimitExceeded()` like the other roles, and the constructor sets + `flood_max_unscoped` to 64 and `flood_max_advert` to 8. After it, + `allowPacketForward()` is the same text in the room server and the sensor. +- **`/prefs.json` has a version.** `NodePrefs::prefs_version` is written as the first + key, `ver`. A file without it reads as version 0. `NODE_PREFS_VERSION` is 1. +- **`ServerMesh::begin()` upgrades an older file once.** Right after `loadPrefs()`, + and only when the stored version is older than `NODE_PREFS_VERSION`, it calls the + new hook `upgradePrefs(from)`, records the current version, and saves at once if + the hook changed a value. Otherwise booting stays read only, as before. A newer + stored version, which a downgrade meets, is left as it is. +- **The sensor's upgrade step.** From version 0, a stored `flood_max_unscoped` of 0 + becomes 64 and a stored `flood_max_advert` of 0 becomes 8. A value other than 0 was + set on purpose and is kept. The repeater and the room server do not override the + hook: on those roles a stored 0 has always been applied, so it was a choice. + +The version is what makes the lift run once. Without it, a 0 that an operator sets +on purpose after the update would be lifted again at every boot. + +## Alternatives + +- **Ship it and document it** ("run `set flood.max.unscoped 64` after the update"). + Rejected: a node whose operator misses the note stops forwarding without a sign. +- **Leave the sensor ignoring the two limits.** Rejected: it keeps the sensor apart + from the other roles for good, and `allowPacketForward()` could not be shared. +- **A one-off marker for these two fields** instead of a version. Rejected: the next + setting whose meaning changes would need another marker; a version takes any later + step. +- **Upgrade inside `CommonCLI::loadPrefs()`.** Rejected: the CLI does not know the + role, and only the role knows which stored values meant something else before. +- **Lift a stored 0 at every boot, without a version.** Rejected: it takes away a + setting the other roles offer. + +## Compatibility + +- **Older firmware ignores `ver`.** `ConfigSerializer::loadSerial()` only takes the + keys its `structure()` names. Shown on the board for stock v1.17.1 and for a v1.18 + build without the version (below). +- **A downgrade that saves drops the key**, and the next upgrade runs the step again. + For the sensor that is harmless: the older firmware never applied these limits, so + a 0 it stored was never chosen either. +- **The file of every server role gains one key.** The pinned `prefs.json` of + `test_common_node_prefs` changes by exactly `ver:1,` in front. + +## Verification + +### Tests first + +The forwarding cases and the three measurements of the hazard were written against +the old code. The upgrade cases live in `test/mocks/BootSequenceChecks.h`, so the +repeater, the room server and the sensor all run them; a suite that defines +`BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS` (the sensor's) gets the lift cases, the +others get "a stored 0 is kept and the file is not rewritten". + +With the version field in place but no upgrade, 7 tests failed, 14 cases across the +environments, where expected: the sensor lift, the version after boot and after save, +the pinned `prefs.json`. With the upgrade, all pass. + +| suite | new or changed cases | +|---|---| +| `BootSequenceChecks.h`, all three roles | a fresh node is at the current version and writes nothing; a stored file gets the version with the next save; an older file that needs nothing is not rewritten; the role is asked to upgrade only an older file; zeros stored at the current version are kept; a newer version is left as it is | +| same, sensor only | unversioned zeros are lifted to 64 and 8; the lift is saved with the version; only a 0 is lifted; a 0 set after the upgrade survives the next boot | +| same, repeater and room server | unversioned zeros are kept, file untouched | +| `test_sensor_role` | both limits applied; an upgraded sensor goes on forwarding floods and adverts; the step acts only from version 0 | +| `test_common_node_prefs` | the pinned file with `ver` in front; a v1.18.0 file reads as version 0; the version is read back | + +`pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -e native_repeater -e native_repeater_full`: +251 of 251 cases pass. Merged onto the `integration` of 20 October (2e456f74, which +also carries Fix 8, Fix 9 and issue 10) in a trial merge without conflicts, all 13 +environments of the CI test run: 307 of 307. + +### Mutation tests + +Each against the committed change, the tree restored by copy after every run and +checked clean at the end. `pio test -e native -e native_sensor -e native_server -e native_repeater`. + +| # | mutation | caught by | +|---|---|---| +| M1 | no save after the hook changed a value | the lift is saved with the version (sensor) | +| M2 | upgrade also at the current version (`<=`) | the role is asked to upgrade only an older file (all roles) | +| M3 | current version not recorded | fresh node at current version, version after save, a 0 set after the upgrade survives, lift saved with version | +| M4 | save after every upgrade, changed or not | boot writes nothing on an empty file system, an older file is not rewritten, the repeater's and room server's file untouched (14 cases) | +| M5 | a newer version is lowered (`!=`) | a newer version is left as it is; the role is asked only for an older file | +| M6 | the sensor lifts any value, not only 0 | only a 0 is lifted, and four boot cases that see an unexpected save | +| M7 | the sensor's step also runs from version 1 | the step acts only from version 0 | +| M8 | the sensor reports no change | the lift is saved; the step acts only from version 0 | +| M9 | `ver` not persisted | the pinned file, the version read back, and six boot cases | + +All nine are caught, each by a different set of tests. In the first round M2 and M7 +survived: each was masked by the other guard (`begin()` only asks for older versions, +the sensor's step only acts from version 0). Neither was an equivalent mutant; a test +for each guard on its own closed both. + +### Build matrix + +The 18 environments of the series, built on this change and on its base (step 4f, +`78e7c02f`) with the same toolchain: 36 of 36 succeed. + +| | RAM | flash | +|---|---|---| +| sensors (Heltec v3, RAK 4631, wio-e5-mini, ProMicro) | +0 | +168 to +208 | +| repeaters and room servers (all others) | +0 | +72 to +128 | +| companions (four environments) | +0 | +0 | + +The companion numbers are equal sizes, not a byte comparison: `ServerMesh` is not part +of a companion image (shown for 4a to 4e in 0004). Why RAM stays the same with one +more byte in `NodePrefs` was not examined (padding is the likely reason, unverified). + +GCC, which this machine does not have: the fork's CI ran the host suites and fourteen +firmware builds on this change, 15 of 15 checks green. + +### Hardware + +`techo-b43b`, a T-Echo running stock simple_sensor v1.17.1, with marked images +(`FIRMWARE_BUILD_DATE`) and the diagnostic patch from `tools/refactoring` for the +I2C bus that a soft reset can leave hanging (ledger issue 14). Values read with +`tools/refactoring/scripts/server_gets.txt`, 31 lines. + +| step | image | result | +|---|---|---| +| 0 | stock v1.17.1 | `flood.max.unscoped` 0, `flood.max.advert` 0, forwarding off; `set flood.max 64` (unchanged value) to make sure the file is written | +| 1 | this change (`fm-3b58ebd7`) | the two limits read 64 and 8; the other 28 settings identical to step 0 | +| 2 | same, after `reboot` | identical to step 1 | +| 3 | same, `set flood.max.unscoped 0`, `reboot` | the 0 stays, so the version reached the flash and the step did not run again; everything else identical | +| 4 | step 4f without this change (`fmbase-78e7c02f`) | all 30 settings identical to step 3: a v1.18 build without the version reads the file | +| 5 | stock v1.17.1 | all 30 settings identical to step 3: the downgrade reads the file with `ver` in it | +| 6 | stock v1.17.1, `set flood.max.advert 0` | all 31 lines identical to step 0, the board is back where it started | + +The first try at step 5 met the hanging I2C bus: after the DFU the stock image stayed +silent. Step 4 was the remedy (an image that frees the bus), then the stock image a +second time. Which is why step 4 exists at all; it is useful as a data point anyway. + +Forwarding itself was not exercised on the air: the board is not to forward in the +real mesh. That an upgraded sensor goes on forwarding is shown by the host tests; the +board shows the values it forwards with. + +## What was not verified + +- The upgrade on any board other than the T-Echo, and on a sensor with forwarding + switched on (the step does not read `disable_fwd`, and the host tests cover both). +- Whether the ESP32 and RP2040 file systems behave like the nRF52's here; the code + path is the same `ConfigSerializer` on all of them. diff --git a/docs/refactoring/0007-boot-sequence.md b/docs/refactoring/0007-boot-sequence.md new file mode 100644 index 00000000000..beed239deef --- /dev/null +++ b/docs/refactoring/0007-boot-sequence.md @@ -0,0 +1,423 @@ +# 0007: The boot sequence moves into the shared base + +Status: implemented. This is step 4f, the last of the series 4a to 4f described in +[0004](0004-shared-server-base.md). + +Of everything the three server roles carried in triplicate, `begin()` was the +largest body left. It is a move, not a change: the order of every call is kept, and +the tests that pin it passed before the move and after it without being touched. + +## Context + +`begin()` is the boot sequence of a node: it starts the dispatcher, loads what the +node stored, establishes the default scope, gives the radio and the board the +settings it holds, and starts the advert timers. Before this step each role had its +own copy, 53 lines in the repeater and 44 in the room server and in the sensor. + +After the sensor alignment ([0005](0005-sensor-alignment.md)) closed the RX boosted +gain gap, the room server's and the sensor's bodies are the same text, and the +repeater's differs in two blocks. Measured on the commit this step started from, +comparing each old body with the shared one by `diff -w -B` (blank lines and +whitespace ignored, see "Diff proof" below): + +| role | lines | differs from the shared body by | +|---|---|---| +| room server | 44 | class name, `loadPrefs()`, one comment, two hook calls | +| sensor | 44 | the same | +| repeater | 53 | class name, `loadPrefs()`, and two blocks replaced by hook calls | + +The two repeater blocks are the `WITH_BRIDGE` start of the bridge and one debug +print of the gain mode the radio ended up in. + +## What changes + +`ServerMesh` gets a public `begin(FILESYSTEM*)` with the sequence, and the roles lose +theirs. Everything a role must say for itself becomes a protected virtual on the +base: + +| hook | default | the role that overrides it | +|---|---|---| +| `loadPrefs()` | none, pure virtual | all three: `_cli.loadPrefs(_fs)`, the line each had. A role owns its `CommonCLI`, so the base cannot call it | +| `beginBridge()` | does nothing | the repeater, under `WITH_BRIDGE`: starts the bridge if `bridge_enabled`, the block it had | +| `onRadioConfigured()` | does nothing | the repeater: the debug print of the applied gain mode | + +`applyGpsPrefs()` goes with the body. It was the same inline line in all three role +headers (`sensors.setSettingValue("gps", ...)`, checked by grep: three matches across +the three old headers and one in the base), and `begin()` was its only caller, so it +moves to `ServerMesh` as a protected method behind `ENV_INCLUDE_GPS`. + +The order, which the tests pin, is: + +1. `mesh::Mesh::begin()`, which starts the dispatcher and the radio driver +2. the file system pointer is stored +3. the prefs are loaded (`loadPrefs()`) +4. the ACL is loaded +5. the Regions are loaded +6. the default scope is established from the stored default Region, or from + `DEFAULT_FLOOD_SCOPE_NAME` if the build names one +7. the bridge is started (`beginBridge()`) +8. the radio gets its parameters, TX power and boosted gain mode +9. `onRadioConfigured()` +10. the board gets its dynamic prefs +11. both advert timers are set +12. the board gets its ADC multiplier +13. the GPS toggle is pushed to the sensor manager (`ENV_INCLUDE_GPS`) + +What does not change: + +- **The order.** Every call is where it was. Sections of it are load bearing; see + "The order is part of the behaviour" below. +- **Visibility.** `begin()` stays public because each role's `main.cpp` calls it. The + hooks are protected on the base and overridden in the roles' non-public sections, + so nothing outside the three roles sees a new name. This is the lesson of step 4b, + where an access level nearly broke the firmware build in a place the host harness + does not compile. +- **The roles' behaviour.** The room server and the sensor gain two empty virtual + calls; nothing else. +- **One comment.** `// TODO: key_store.begin();` stood in the repeater's copy only. + It is true of all three roles, since no role starts the key store, so it now stands + in the shared body. + +## Alternatives + +- **Each role keeps its `begin()` and calls shared steps.** Rejected. The order of the + steps is the thing worth sharing, and this keeps it written out three times; a + future reordering would again have to be made in three places. +- **One virtual hook with a stage argument** (`onBootStage(AFTER_LOADING)` and so on) + instead of two. Rejected for readability: two named hooks say where they run. +- **Move the `CommonCLI` into the base**, which removes `loadPrefs()` and the three + copies of `savePrefs()` together. The three constructions are already identical + (`_cli(board, rtc, sensors, region_map, acl, &_prefs, this)` in all three), so this + is possible, but the base would need the board in its constructor, which changes + the constructor of `ServerMesh` and of the three roles. That is more than a move. + Listed as a next candidate. +- **Log the gain mode in all three roles** by putting the print into the shared body. + It would change what the room server and the sensor print in a debug build, and a + step that promises no change does not take that decision. The hook keeps the print + where it was; making it shared is one line if it is wanted. + +## The order is part of the behaviour + +Writing the tests turned up an ordering that a reader of the three copies would not +notice and that a careless move could have changed: `mesh::Mesh::begin()` runs +**before** the prefs are loaded. + +`Dispatcher::begin()` sizes the transmit budget from the airtime factor +(`src/Dispatcher.cpp:25-28`). At that point the node holds the factor its constructor +set (1.0), not the one on flash, so the budget starts at the size that factor gives +(half of the one hour window). `updateTxBudget()` reads the stored factor, and every +budget decision calls it first (`Dispatcher.cpp:96` and `:282`): if the stored factor +is stricter than 1.0 the budget is capped to the stored maximum before it is used, and +if it is looser the budget starts below its cap and refills at the stored rate. So +the effect is a start-up level, not a lasting setting. + +This is read from the code. It is not measured on a board, and it is probably +harmless, which is why the test pins it as observed behaviour without endorsing it, +and why it is in the ledger as an observation and not as a fix (issue 15). The point +for this step is the order: the first two lines of `begin()` cannot be swapped in a +move without changing what the dispatcher starts with. + +## Verification + +### Characterization first + +The tests were written and run on the three old `begin()` bodies, in their own +commit, before anything moved. One set of expectations, +`test/mocks/BootSequenceChecks.h`, is included by the suite of each role, so the +three are held to the same behaviour. They watch the boot from outside. + +To make the order observable the mocks keep one ordered event log +(`test/mocks/EventLog.h`): the file system lookups, the calls on the radio, the board +and the sensor manager, and a stand-in RS232 bridge all append a tag when called. The +sensor manager mock now records the settings pushed to it. + +The repeater had no host environment, because it shares the name `MyMesh` with the +room server. `native_repeater` adds it. `native_repeater_full` runs the same suite +with every optional block of `begin()` compiled in: a bridge, the GPS toggle, a +configured default scope and the debug log. Both are in the CI test run. + +| | what is pinned | environments | +|---|---|---| +| order | the dispatcher first; prefs, then ACL, then Regions, all before the radio; params, power, gain, then the board's prefs and ADC multiplier; each exactly once | all | +| the quirk | the dispatcher sizes its budget from the constructor's airtime factor | all | +| values | stored radio settings, gain, ADC multiplier and advert intervals reach the radio, the board and the timers; the stored ACL (with its secrets recalculated) and Regions load | all | +| scope | a stored default Region becomes the default scope; none without one | all | +| scope, configured | the named Region is created with flood allowed, reused with its flags, loses to a stored default, and is not written to flash | `native_repeater_full` | +| GPS | the stored setting is pushed last, on and off; nothing is pushed without GPS support | `native_repeater_full`, the others | +| repeater only | the bridge starts after loading and before the radio, and not when disabled; the debug build logs the applied mode, after applying it | `native_repeater_full` | +| housekeeping | booting an empty file system writes nothing; the role can save its prefs right after `begin()` | all | + +20 boot cases run in each of the three default environments and 28 in +`native_repeater_full`. + +### Diff proof + +Each old body extracted from the commit before the move and compared with the shared +one (`diff -w -B`): + +``` +OLD repeater vs NEW shared +1c1 void MyMesh::begin(FILESYSTEM *fs) { -> void ServerMesh::begin(FILESYSTEM* fs) { +5c5 _cli.loadPrefs(_fs); -> loadPrefs(); +30-34 #if defined(WITH_BRIDGE) ... bridge.begin(); ... #endif -> beginBridge(); +40-41 MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: ...); -> onRadioConfigured(); + +OLD room server vs NEW shared (the sensor's diff is the same) +1c1 void MyMesh::begin(FILESYSTEM *fs) { -> void ServerMesh::begin(FILESYSTEM* fs) { +5-6 _cli.loadPrefs(_fs); -> loadPrefs(); +7a7 (nothing) -> // TODO: key_store.begin(); +29a30 (nothing) -> beginBridge(); +32a36 (nothing) -> onRadioConfigured(); +``` + +Every line is accounted for. The repeater's replaced blocks are the bodies of its +overrides in `examples/simple_repeater/MyMesh.h`, character for character (the +`MESH_DEBUG_PRINTLN` call and the `if (_prefs.bridge_enabled) { bridge.begin(); }`). + +### Mutation tests + +Fifteen deliberate faults in the committed code, one at a time, run against the four +environments they can affect. Every one turns the suite red. The faults were applied +to a copy and restored from it after each run, never with `git checkout`, because +that would also have removed any uncommitted work. + +| # | fault | caught by | +|---|---|---| +| M1 | ACL and Regions loaded in the other order | `PrefsAreLoadedBeforeTheAclAndTheAclBeforeTheRegions`, all four | +| M2 | `Mesh::begin()` moved after loading the prefs | `TheDispatcherStartsBeforeAnythingIsLoaded`, `TheDispatcherSizesItsBudget...`, `PrefsAreLoadedBefore...` | +| M3 | TX power set before the radio parameters | `TheRadioIsSetUpInTheOrderParamsPowerGainThenTheBoard` | +| M4 | ADC multiplier no longer applied | `EachStepOfTheSetUpHappensExactlyOnce`, `TheStoredAdcMultiplierReachesTheBoard`, the order test | +| M5 | advert timers computed before the prefs are loaded | `AdvertTimersCountFromTheStoredIntervals`, `ZeroIntervalsLeaveBothTimersOff` | +| M6 | bridge started after the radio is configured | `TheBridgeStartsAfterTheLoadingAndBeforeTheRadioIsConfigured` | +| M7 | `onRadioConfigured()` before the gain is applied | `TheRepeaterLogsTheGainModeAfterApplyingIt` | +| M8 | a stored default Region no longer yields the default scope | `AStoredDefaultRegionBecomesTheDefaultScope`, all four | +| M9 | the auto-created default Region left denying flood | `TheConfiguredDefaultScopeIsCreatedWhenNoneIsStored` | +| M10 | the GPS setting pushed with inverted polarity | `AStoredGpsOffIsPushedAsOff`, `TheStoredGpsSettingIsPushedToTheSensorManagerLast` | +| M11 | the bridge started although it is disabled | `ADisabledBridgeIsNotStarted` | +| M12 | the sensor loads its prefs from nowhere | seven boot cases and `SensorRoleTest.AStoredBoostedGainSurvivesAReboot` | +| M13 | the room server loads its prefs from nowhere | seven boot cases | +| M14 | the repeater loads its prefs from nowhere | ten boot cases, both repeater environments | +| M15 | the file system pointer is null while the prefs load | segmentation fault in all four environments | + +M5 was first written as an extra, earlier call to the timers with the original +calls left in place. That mutant survived, correctly: the later calls overwrite the +earlier ones, so it changes nothing observable. It was rewritten as a real move of +the calls and then turned the suite red. Of the fifteen, M12 to M14 are the ones +that show the shared checks reaching each role's own hook. + +### Host suites + +`pio test -e native -e native_ofast -e native_server -e native_sensor -e native_core +-e native_kiss_modem -e native_repeater -e native_repeater_full`: 211 of 211 test +cases pass, the same count before and after the move. The CI runs all eight; its GCC +build is the only run of the tests under GCC, since this machine has clang only. + +As a partial substitute, the boot test files of the three suites and of +`native_repeater_full` were syntax checked (`-fsyntax-only`, host mocks and the +googletest headers) with the `arm-none-eabi-g++` of three PlatformIO toolchains: +7.2.1, 12.3.1 and 14.2.1. Zero errors in all twelve combinations, and a positive +control (a role type that does not exist) gives 18 errors on the same command. That +is a type check against newlib, not a run on Linux, so it does not replace the CI. + +## The build matrix + +Run on 20 October in three worktrees at exact commits, the same toolchains, one +`pio run` per environment: `dev` (`428a95c6`), the commit this step started from +(`0a93bc40`, steps 4a to 4e and the first half of the sensor alignment, called 4e in +the table) and the commit that carries the code of this step (`133fe68b`). The commits +after it change documentation only. **66 of 66 builds succeeded, none failed.** + +The 18 environments of the series matrix, plus four that close gaps in it: the 18 +contain no RP2040 room server, nothing on the pioarduino ESP-IDF 5 toolchain (which +also compiles these files), and no RS232 bridge on nRF52. That makes 22 per commit. + +| environment | RAM | vs 4e | vs dev | Flash | vs 4e | vs dev | +|---|---|---|---|---|---|---| +| `Heltec_v3_repeater` | 62512 | +0 | +0 | 1173909 | +56 | -540 | +| `Heltec_v3_room_server` | 66200 | +0 | +0 | 1169205 | +52 | -500 | +| `Heltec_v3_companion_radio_ble` | 194844 | +0 | +8 | 1666053 | +48 | -384 | +| `Heltec_v3_companion_radio_wifi` | 172384 | +0 | +8 | 1102225 | +48 | -452 | +| `Heltec_v3_sensor` | 60600 | +0 | +40 | 1180417 | +84 | -176 | +| `RAK_4631_repeater` | 37240 | +0 | +0 | 441048 | +192 | +96 | +| `RAK_4631_room_server` | 40928 | +0 | +0 | 434232 | +192 | +144 | +| `RAK_4631_companion_radio_ble` | 149768 | +0 | +8 | 462196 | +0 | -352 | +| `RAK_4631_sensor` | 35328 | +0 | +40 | 437816 | +208 | +272 | +| `PicoW_repeater` | 85368 | +0 | +0 | 492132 | -24 | +160 | +| `wio-e5-mini_repeater` | 17172 | +0 | +0 | 203584 | -24 | +32 | +| `wio-e5-mini_sensor` | 15276 | +0 | +40 | 203040 | -24 | +136 | +| `RAK_3112_repeater_bridge_espnow` | 64072 | +0 | -8 | 1175209 | +84 | -508 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | 26704 | +0 | +0 | 240544 | -24 | +176 | +| `ProMicro_repeater` | 33892 | +0 | +0 | 345504 | +192 | +80 | +| `ProMicro_room_server` | 37588 | +0 | +0 | 338584 | +160 | +128 | +| `ProMicro_companion_radio_usb` | 99468 | +0 | +8 | 356964 | +0 | -336 | +| `ProMicro_sensor` | 31988 | +0 | +40 | 342140 | +192 | +240 | +| `waveshare_rp2040_lora_room_server` | 28532 | +0 | +0 | 232084 | -32 | +112 | +| `M5Stack_Unit_C6L_repeater` | 29752 | +0 | +0 | 552061 | -64 | -36 | +| `M5Stack_Unit_C6L_room_server` | 33448 | +0 | +0 | 546661 | -70 | -62 | +| `Heltec_t096_repeater_bridge_rs232` | 37148 | +0 | +0 | 415824 | +208 | +64 | + +RAM did not move in any of the 22 environments from 4e to 4f. Against `dev` the +deltas are those of the earlier steps and nothing of this one: `+32` for the sensor +from step 4e (its peer index grew from 24 to `MAX_CLIENTS` entries), `+8` on the +companion and the sensor from step 2 (recorded in [0002](0002-core-reads-radio-prefs.md)), and `-8` on `RAK_3112_repeater_bridge_espnow`, +which 0004 already lists as not explained. The flash column against `dev` is the sum of +all steps and is shown for orientation; the column to read for this step is "vs 4e". + +### Reproducing the numbers recorded in 0004 + +0004 prints, per environment, the series base's RAM and flash and the delta to the +tip. Rebuilt, the 4e column here equals base plus delta for RAM in all 18 +environments and for flash in 14 of 18. The four that differ are the sensors, whose +recorded state also carried the flood limit half of the alignment +(`refactor/sensor-flood-limits`, not on this branch). Building those four sensor +environments on that branch's tip reproduces the recorded numbers exactly: + +| environment | RAM | Flash | +|---|---|---| +| `Heltec_v3_sensor` | 60600 | 1180401 | +| `RAK_4631_sensor` | 35328 | 437672 | +| `wio-e5-mini_sensor` | 15276 | 203112 | +| `ProMicro_sensor` | 31988 | 342012 | + +(Recorded in 0004: Heltec 60568 + 32 and 1179737 + 664, RAK 35296 + 32 and +437416 + 256, wio-e5-mini 15244 + 32 and 202424 + 688, ProMicro 31956 + 32 and +341772 + 240, which sum to the values above.) + +### What the flash deltas of this step are + +RAM is unchanged, so the whole difference between 4e and 4f is flash. Symbol sizes +(`nm -S`) and section sizes (`size -A`) from the ELF files, per toolchain: + +| group | flash vs 4e | what the symbols show | +|---|---|---| +| ESP32-S3 servers (Heltec v3 x3, RAK 3112 bridge) | +52 to +84 | Code shrinks: `.flash.text` -8, -28 and -24 on repeater, room server and sensor. The three old `begin()` bodies (263 to 275 B each) become one of 176 B, plus `loadPrefs()` 16 B, `applyGpsPrefs()` 28 B and the empty hooks at 5 B. `.flash.rodata` grows by 64, 80 and 108: 24 B of that are the two vtables (12 B each, three new virtual slots), the rest (40 to 72 B) is **not attributed**; the rodata of the whole image shifts, and no new string or symbol accounts for it | +| nRF52 servers (RAK 4631 x3, ProMicro x3, Heltec t096 bridge) | +160 to +208 | The shared `begin()` is 456 to 488 B where each old one was 316 to 352 B. The disassembly of `RAK_4631_repeater` shows why: the two advert timer updates, which the role copies called through the vtable, are inlined in the shared body (two calls to `Dispatcher::futureMillis` appear, none before). That is the compiler's decision in a new translation unit under nRF52's `-Ofast`, probably speculative devirtualization; **not verified** with `-fno-devirtualize-speculatively`. On top: `loadPrefs()` 16 B, vtables 24 B, empty hooks 4 to 6 B. For `RAK_4631_repeater` that is 186 of the 192 B, the other 6 are not attributed; `ProMicro_room_server` shows +160 for a symbol sum of +184, a 24 B residual that is **not attributed** | +| RP2040, STM32 servers | -24 to -32 | `.text` -48 and `.rodata` +24 (the two vtables), measured on `PicoW_repeater` and `wio-e5-mini_repeater`. The shared body is 212 B (RP2040) and 168 B (STM32) against 292 and 244 for the old ones | +| ESP32-C6 servers (pioarduino) | -64 and -70 | `.flash.text` -80, `.flash.rodata` +16 on the repeater; the unwind tables (`.eh_frame` +64) are not part of the flash figure | +| Companion, nRF52 (RAK 4631, ProMicro) | +0 | No section differs at all | +| Companion, ESP32-S3 (Heltec v3 x2) | +48 | `.flash.text` is byte for byte the same size; only `.flash.rodata` moves, which is layout, as in 0004: `ServerMesh` is not in a companion image | + +The pattern in the table is the expected one for a move that routes the +role-specific parts through three new virtual calls: a shared body that comes out +bigger or smaller depending on how the compiler handles it, plus 24 B of vtable per +image. The `+160` to `+208` on nRF52 is the one a reviewer should +look at; it is a one time cost of 0.04 percent of a 440 kB image. + +### Other examples that compile `ServerMesh.cpp` + +`helpers/*.cpp` is in the base source filter of every firmware environment, so +`ServerMesh.cpp` is compiled for the kiss modem and the terminal chat too, and this +step added `board.attachDynamicPrefs()` and `sensors.setSettingValue()` to it. +Whether those compile for every variant's board was checked in two ways. + +First by argument: every one of the 91 variant directories with a `-I variants/` +flag in some environment has at least one environment that compiles a server role or +the companion, and both already call `board.attachDynamicPrefs()` on that variant's +board, so the board types are proven. (Computed from `pio project config +--json-output`.) + +Second by building one kiss modem or terminal chat environment per platform on `dev`, +4e and 4f. All 27 builds succeeded. + +| environment | Flash dev | Flash 4e | Flash 4f | 4f vs 4e | RAM 4f vs 4e | +|---|---|---|---|---|---| +| `RAK_4631_kiss_modem` | 371560 | 371560 | 371560 | +0 | +0 | +| `Heltec_v3_kiss_modem` | 579993 | 580561 | 580609 | +48 | +0 | +| `PicoW_kiss_modem` | 439420 | 439420 | 439420 | +0 | +0 | +| `wio-e5-mini_kiss_modem` | 151032 | 151032 | 151032 | +0 | +0 | +| `M5Stack_Unit_C6L_kiss_modem` | 479807 | 479807 | 479807 | +0 | +0 | +| `PicoW_terminal_chat` | 468108 | 468908 | 468908 | +0 | +0 | +| `ProMicro_terminal_chat` | 295312 | 298112 | 298112 | +0 | +0 | +| `Heltec_v3_terminal_chat` | 600685 | 601517 | 601565 | +48 | +0 | +| `RAK_4631_terminal_chat` | 393416 | 393960 | 393960 | +0 | +0 | + +Step 4f changes nothing in seven of the nine and moves the two ESP32-S3 ones by +48, the same layout shift as in the companion. Against `dev` the terminal chat +environments are larger by 544 to 2800 B and the ESP32-S3 kiss modem by 568 B, already +at 4e. That comes from the steps before this one; these environments were not part of +the series matrix. **Not examined and not explained** here; it is outside this step +and listed under "What was not verified". + +### The debug line + +`onRadioConfigured()` keeps the repeater's one debug print. Built with `-D MESH_DEBUG=1` +for `RAK_4631_repeater`, `RAK_4631_room_server` and `RAK_4631_sensor` at 4e and at 4f, +(nRF52 only) the sorted set of strings containing `DEBUG: ` or `Boosted` is identical in each pair +(117, 112 and 103 strings, empty diff), `RX Boosted Gain Mode` occurs once in the +repeater image and not in the room server or the sensor, before and after. The +room server and the sensor did not gain a log line. + +## What was not verified + +- **No board test.** The host environments model the boot sequence, not the radio, the + board or the flash. See the next section. +- **GCC on Linux.** Only the CI runs the tests under GCC; locally there is a syntax + check with three arm GCC versions, see above. +- **The debug line on the host for the room server and the sensor.** Their host + environments do not define `MESH_DEBUG`; the firmware string comparison above is the + evidence that they print nothing new. +- **The effect of the budget ordering** (ledger issue 15) is read from the code. +- **The unattributed bytes** named in the flash table: 40 to 72 B of rodata on + ESP32-S3 and a 24 B residual on one nRF52 environment. +- **The growth of the terminal chat and kiss modem images** before this step. +- **Variants other than those built.** Argued from the variant directories, built for + the 22 + 9 environments above, not for all 626. + +## Board test + +### Done, sensor role only + +Run on `techo-b43b` (LilyGo T-Echo, SX1262, sensor role) after the merge into +`integration`, with marked images of the merge commit (`4cf3161b`, "4f") and of its +first parent (`3e685785`, "before"), both built with +`tools/refactoring/diag-sensor-rxgain-acks.patch` for `diag sensor` (stored RX gain, +the radio's RX gain register, extra ACK count) and for freeing a stuck I2C bus at boot +(ledger issue 14). Each step read all 30 server settings plus `region default` and +`diag sensor`: + +1. Original 1.17.1 to "before": all settings identical. +2. "Before", `set radio.rxgain off`, `set multi.acks 1`, reboot: the register reads + off and one extra ACK is used, as the sensor alignment intends. +3. "Before" to "4f": every reading identical, the register still off. +4. Reboot on "4f": identical to step 3, so the moved boot path reproduces itself. +5. "4f" to "before" (downgrade): identical. +6. Back to the original 1.17.1 with the original values: 31 of 31 lines identical to + the reading taken before. + +So the moved `begin()` loads the same prefs and pushes the stored RX gain to the radio +on real hardware. The I2C bus was found stuck at two of these boots and freed by the +diagnostic patch; that is ledger issue 14 and independent of this step. + +### Not covered yet + +The repeater and the room server were not run on a board, and the sensor run used +default values for everything but the RX gain and `multi.acks`. The rest of the plan +below is still open: + +- **Images.** Two per role, same toolchain, marked with + `PLATFORMIO_BUILD_FLAGS='-D FIRMWARE_BUILD_DATE=\"\"'` so `ver` shows which + one runs: the 4e commit (`0a93bc40`) and the 4f commit (`133fe68b`). The T-Echo has + `LilyGo_T-Echo_repeater` and `LilyGo_T-Echo_room_server`; there is no T-Echo sensor + environment in the tree, so a sensor test needs the image the sensor alignment test + uses, built at both commits. +- **What to compare.** Set non-default values first (a small TX power change, the two + advert intervals, the ADC multiplier; no frequency change), reboot, and read them + back (`get tx`, `get radio`, `get advert.interval`, `get flood.advert.interval`, + `get adc.multiplier`, `get radio.rxgain` on an SX126x board). The reads must be the + same on both images. Then check that the first scheduled advert comes after the + stored interval, and that the file list on flash is the same before and after + (`ls`), since booting must write nothing. +- **Upgrade and downgrade.** 4e to 4f and back, with the values set on the first. This + step changes no persisted format, so the files must carry over untouched. +- **What only a board shows.** That the real `board.attachDynamicPrefs()`, the radio + driver's `setRxBoostedGainMode()` and `setAdcMultiplier()` run in the same order on + the real driver, which the host mocks record but cannot prove for hardware. + +## Next candidates + +- **`allowPacketForward()`.** Done in step 4g, [0008](0008-forward-policy.md), once + the sensor applied the flood hop limits. +- **The `CommonCLI _cli` member.** Constructed with the same arguments in all three + roles. Moving it into the base removes `loadPrefs()` and the three copies of + `savePrefs()`, at the price of the board in `ServerMesh`'s constructor. +- **Sharing the gain log line**, if it is wanted in the room server and the sensor. + diff --git a/docs/refactoring/0008-forward-policy.md b/docs/refactoring/0008-forward-policy.md new file mode 100644 index 00000000000..1babda5917a --- /dev/null +++ b/docs/refactoring/0008-forward-policy.md @@ -0,0 +1,140 @@ +# 0008: The forwarding checks move into the shared base + +Status: implemented. Step 4g of the series described in +[0004](0004-shared-server-base.md), stacked on the sensor's flood hop limits +([0006](0006-sensor-flood-limits.md)), which made this step possible. + +It is a move, not a change: the characterization tests written before it pass after +it without being touched. + +## Context + +`allowPacketForward()` is the hook `mesh::Mesh` asks before it passes a received +packet on. Each server role had its own copy. Once the sensor applied the flood hop +limits like the other roles (0006), the room server's and the sensor's bodies were +the same six lines, and the repeater's began with the same five: + +| role | lines | made of | +|---|---|---| +| room server | 6 | forwarding switched off; the flood hop limits | +| sensor | 6 | the same text | +| repeater | 24 | the same first five lines; the Region a flood arrived on; loop detection | + +This is how the sensor fell behind before: the hop limits reached the repeater and +the room server and not the third copy. + +## What changes + +- `ServerMesh` overrides `allowPacketForward()` with the two checks every role makes: + `disable_fwd`, and `mesh::isFloodHopLimitExceeded()` for floods. +- The room server and the sensor lose their overrides. +- The repeater keeps its override, calls the shared one first, and then checks the + Region and loops exactly as before. + +What does not change: + +- **The order.** The shared checks run first in the repeater, as they did. A debug + build test pins that the Region check is not reached when forwarding is off. +- **Visibility.** The override is protected in `ServerMesh`, as it is in + `mesh::Mesh` and was in the three roles. No `main.cpp` calls it (checked by grep: + no match in any of the three). +- **The companion radio**, which has its own `allowPacketForward()` and is not a + `ServerMesh`. + +## Alternatives + +- **A hook for the repeater's extra checks** (`allowFloodForward()` or similar, + called by the shared body) instead of an override that calls the base. Rejected for + now: one role has extra checks, and an override that calls its base first says the + same with less machinery. If a second role ever needs its own checks, the hook is a + small next step. +- **Move the Region check into the base too.** Rejected: it would change what the + room server and the sensor do with a flood that arrives without a Region (today + they forward it, see the tests). That is a decision about behaviour, not a move. +- **Leave it.** Rejected: the three copies are the reason the sensor drifted. + +## Verification + +### Characterization first + +`test/mocks/ForwardPolicyChecks.h`, in its own commit before the move, included by the +suite of each role after the boot checks, so one set of expectations holds all three: + +| | what is pinned | roles | +|---|---|---| +| switch | forwarding off refuses every route type; a fresh node forwards only on the repeater | all | +| limits | a fresh flood of either kind is forwarded; `flood.max` stops both kinds at the limit (`>=`); `flood.max.unscoped` only un-scoped floods; `flood.max.advert` only adverts; no hop limit applies to a direct route | all | +| Region | a flood without a Region is refused, a direct route is not | repeater | +| loops | minimal loop detection refuses a flood holding this node's hash four times, not three; off by default | repeater | +| order | with forwarding off the Region check is not reached (debug log) | repeater, debug build | +| the others | no Region needed for a flood; loop detection not applied | room server, sensor | + +Green on the old bodies: 9 cases in each of `native_server`, `native_sensor` and +`native_repeater`, 10 in `native_repeater_full`. + +### Diff proof + +Function bodies extracted from the commit before the move and from the move (an awk +range anchored at `^bool ::allowPacketForward(`): + +- the room server's and the sensor's removed bodies are identical to the new + `ServerMesh` body (`diff -w`, no output); +- the repeater's old lines 1 to 5 are identical to the shared body's first five; its + old lines 6 to 24 are identical to its new lines 2 to 20, after the new first line + `if (!ServerMesh::allowPacketForward(packet)) return false;`. + +The first attempt at the extraction was wrong: an unanchored pattern took the new +call line for the start of a function and skipped it. Caught because the result +showed the repeater's body starting at the Region check. + +### Mutation tests + +Each against the committed move, the tree restored by copy after every run and +checked clean at the end. `pio test -e native_server -e native_sensor -e native_repeater -e native_repeater_full`. + +| # | mutation | caught by | +|---|---|---| +| G1 | the base ignores `disable_fwd` | forwarding off refuses every route, a fresh node forwards only on the repeater, the order test, the sensor's own switch test (all four environments) | +| G2 | the base ignores the hop limits | the three limit tests and the sensor's three (all four) | +| G3 | the hop limits also apply to direct routes | no hop limit on a direct route (all four) | +| G4 | the un-scoped and the advert limit swapped | the un-scoped and the advert tests, shared and sensor | +| G5 | `flood.max` ignored | `flood.max` stops both kinds, and the sensor's own | +| G6 | the repeater does not call the shared checks | five shared tests, repeater environments only | +| G7 | the repeater checks the Region before the shared checks | the order test, `native_repeater_full` only | + +All seven are caught, each by a different set of tests. G7 only by the debug build, +which is what that test is for: without the log the two orders refuse the same +packets. + +### Host suites + +`pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -e native_repeater -e native_repeater_full`: +288 of 288 cases pass, 37 of them new. + +### Build matrix + +The 18 environments of the series, built on the move (`e5553e1d`) and on its base +(`24ce8b5f`, the flood hop limits) with the same toolchain: 36 of 36 succeed. RAM is +unchanged everywhere. + +| | flash | +|---|---| +| repeaters, bridges included (seven environments, four platforms) | +32 to +56 | +| room servers and sensors (seven environments) | -4 to +8 | +| companions (four environments) | +0 | + +The repeater pays for one extra call, now a call into the base rather than inline +code; the room server and the sensor lose their override and use the base's, which +costs or saves a few bytes depending on the platform. The companion numbers are equal +sizes, not a byte comparison. + +GCC: the fork's CI ran the host suites and fourteen firmware builds on the move, +15 of 15 checks green. + +## Next candidates + +- **The `CommonCLI _cli` member**, constructed with the same arguments in all three + roles (see 0007). +- **A Region check for floods in the room server and the sensor**, if they should + refuse a flood without a Region as the repeater does. A behaviour decision, not a + move. diff --git a/docs/refactoring/0009-radio-chip-adapter.md b/docs/refactoring/0009-radio-chip-adapter.md new file mode 100644 index 00000000000..344b21c2923 --- /dev/null +++ b/docs/refactoring/0009-radio-chip-adapter.md @@ -0,0 +1,1094 @@ +# 0009: One RadioLib adapter per chip family, part one: the receive detection + +Status: steps 9a and 9b implemented. Behaviour change: none. Steps 9c and 9d are +planned below and need their own branches. + +## Context + +Finding F4 of the review: the chip classes in `src/helpers/radiolib` are near +copies. Measured on `integration` 2e456f74 with a script that cuts every method +out of `Custom.h` and `CustomWrapper.h`, removes comments and white +space, replaces the chip name with a placeholder and folds the family prefixes of +the IRQ macros (`RADIOLIB_SX126X_`, `RADIOLIB_LR11X0_`, `RADIOLIB_LR2021_`) into +one. Equal hashes mean equal code after that normalization. + +| Method | Groups of identical copies (lines) | +|--------|------------------------------------| +| chip `isReceiving()` | SX1262, SX1268, LLCC68, STM32WLx (42) / LR1110, LR1121 (41) / LR2021 (41) / SX1276 (6, a different method) | +| chip `startReceive()` | the six SX126x and LR11x0 chips / LR2021 | +| chip `setPreambleMillis()`, `setMaxPayloadMillis()` | all seven chips with a preamble IRQ | +| chip `begin()`, `getPacketLength()` | LR1110, LR1121 | +| wrapper `setParams()` | the six SX126x and LR11x0 chips / LR2021 / SX1276 | +| wrapper `isReceivingPacket()`, `getLastRSSI()`, `getLastSNR()` | all eight | +| wrapper `getCurrentRSSI()`, `getSpreadingFactor()` | SX126x family and SX1276 / LR1110, LR1121, LR2021 | +| wrapper `doResetAGC()` | SX126x family / LR11x0 family | +| wrapper `onSendFinished()` | LR1110, LR1121, LR2021 | + +A plain diff of the three `isReceiving()` groups shows that they are one state +machine. They differ only in how they reach the chip: the getter +(`getIrqFlags()` or `getIrqStatus()`), the clearer (`clearIrqFlags()` or +`clearIrqState()`), the three IRQ bits, and the set of bits cleared when a +reception ends (the SX126x copies also clear the sync word bit). The history +shows the cost: upstream fixed the logic in SX1262 alone (#3000, #3062), then +copied it by hand into SX1268, LLCC68, STM32WLx and LR1110 (#3063, "Unify IRQ +timeout logic across all applicable radio types"), fixed LR1110 on its own +afterwards (#3076), and added LR2021 as one more copy (#3146). + +The full chip classes are not identical. Among the SX126x copies, `std_init()` of +SX1268 and LLCC68 lacks the LDO regulator option, the Heltec register patch and two +debug lines that SX1262 has, and STM32WLx has no `std_init()` at all. Merging +those is a behaviour change for some boards and is kept out of this step. + +## What changes (step 9a) + +`src/helpers/radiolib/RxActivityTracker.h` holds the state machine. It sees three +booleans (preamble, header valid, header error) and the time, and returns one of +`IDLE`, `RECEIVING`, `CLEAR_PREAMBLE`, `CLEAR_ALL`. It also owns the two deadlines +and the setters the wrappers call. Each of the seven chip classes keeps its own +register access: + +```cpp +bool isReceiving() { + uint32_t irq = getIrqFlags(); + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { + clearIrqFlags(/* the chip's own set, unchanged */); + } + return r == RxActivityTracker::RECEIVING; +} +``` + +`setPreambleMillis()` and `setMaxPayloadMillis()` forward to the tracker. In +`src`, 340 lines go and 161 come in. + +## What deliberately does not change + +- Every quirk of the state machine is kept, including that a timer value of 0 + means "not started", so a preamble first seen at `millis() == 0` starts its + timer one poll later. A test pins it. +- Each chip clears exactly the bits it cleared before. Whether the LR chips should + also clear their sync word bit, as the SX126x chips do, is a question for a + separate fix, not for a move. +- SX1276 has no preamble IRQ and reads the modem status instead. It does not use + the tracker. +- `std_init()`, `begin()`, the wrappers and `RadioLibWrapper` are untouched. + +The one observable difference is in `MESH_DEBUG` builds: both "Clearing ... IRQ" +lines are now printed before the SPI call that clears the bits; the preamble line +used to come after it. Release builds contain neither line. + +## Alternatives considered + +- **A CRTP mixin** (`RxActivity` calling `Chip::getIrq()` and + `Chip::clearIrq()` through the derived class). It would also absorb the getter + and clearer, but needs a traits block per chip for the bit masks and the method + names, ties the logic to RadioLib types, and cannot be tested without a fake + chip class. The plain tracker does the same with less machinery and is tested + with three booleans. +- **A template on the RadioLib base class** (`CustomSX126x` and so on) + for the whole chip class. That is the goal of step 9b, but done now it would + mix the move with the `std_init()` differences above, which are behaviour. +- **A common base class with virtual IRQ accessors.** Adds a vtable call per poll + in the radio loop and a second inheritance path next to RadioLib's own; nothing + gained over the tracker. +- **A `.cpp` file for the tracker.** A firmware links one chip class, so the code + exists once either way. Header only lets the compiler inline it as before. + +## Compatibility + +Source compatible. The public methods of the chip classes keep their names and +signatures. The removed members `_preambleMillis`, `_maxPayloadMillis`, +`_activityAt`, `_headerSeen` were private. A downstream class derived from a chip +class that read them would no longer compile; none in the tree does. + +## Verification + +### Characterization tests + +`test/test_radio_chips` builds the seven real chip classes on the host against a +stand-in for the RadioLib chip classes (`test/test_radio_chips/stubs/RadioLib.h`, +IRQ bit values copied from RadioLib 7.7.1). One typed suite runs 16 expectations +against every chip, 112 cases. It was committed first and passes unchanged +before and after the move: + +```sh +pio test -e native_radio_chips +``` + +Covered: both deadlines including the deadline millisecond itself, the timer +restart after a clear, a quiet poll resetting the timers, the payload deadline +starting at the header and not the preamble, a header error with and without a +header, a header flag cleared elsewhere, the setters, the `millis()` wrap, the +zero quirk, the bits each chip clears, and the IRQ flags `startReceive()` asks +for. + +All host suites pass on the step commit: `native`, `native_ofast`, +`native_server`, `native_sensor`, `native_repeater`, `native_repeater_full`, +`native_core`, `native_chat`, `native_identity_store`, `native_node_prefs`, +`native_radio_wrappers`, `native_region`, `native_kiss_modem`, +`native_radio_chips`, 379 cases. + +### Mutation tests + +Each mutation was applied to the step, the suite run, and the change reverted. + +| # | Mutation | Failing cases | +|---|----------|---------------| +| M1 | payload deadline `>` becomes `>=` | 28, all seven chips | +| M2 | preamble deadline `>` becomes `>=` | 49, all seven chips | +| M3 | preamble timeout keeps its timer | 7, all seven chips | +| M4 | header error clears only the preamble bit | 14, all seven chips | +| M5 | a header flag cleared elsewhere goes unnoticed | 7, all seven chips | +| M6 | a quiet poll keeps the timers | 7, all seven chips | +| M7 | payload timer starts at the preamble | 7, all seven chips | +| M8 | timer value 0 no longer means "not started" | 48, all seven chips | +| M9 | SX1268 forgets the sync word bit in its clear | 3, SX1268 only | +| M10 | LR2021 swaps its preamble and header bits | 7, LR2021 only | +| M11 | default preamble deadline 66 becomes 67 ms | 28, all seven chips | + +### Build matrix + +The 18 environments of the standard matrix plus six for the chip classes the +step touches (SX1268, LR1110, LR1121, LR2021), each built twice with the same +toolchain: in a worktree on `integration` 2e456f74 and in one on the step commit ebb14737. All 48 builds succeed. Flash and +RAM as PlatformIO reports them, in bytes: + +| Environment | Platform, chip | Flash before | Flash after | Flash | RAM | +|---|---|---|---|---|---| +| Heltec_v3_repeater | ESP32-S3, SX1262 | 1174193 | 1174225 | +32 | +0 | +| Heltec_v3_room_server | ESP32-S3, SX1262 | 1169481 | 1169513 | +32 | +0 | +| Heltec_v3_companion_radio_ble | ESP32-S3, SX1262 | 1666081 | 1666113 | +32 | +0 | +| Heltec_v3_companion_radio_wifi | ESP32-S3, SX1262 | 1102253 | 1102285 | +32 | +0 | +| Heltec_v3_sensor | ESP32-S3, SX1262 | 1180701 | 1180733 | +32 | +0 | +| RAK_4631_repeater | nRF52, SX1262 | 441384 | 441336 | -48 | +0 | +| RAK_4631_room_server | nRF52, SX1262 | 434552 | 434520 | -32 | +0 | +| RAK_4631_companion_radio_ble | nRF52, SX1262 | 462084 | 462036 | -48 | +0 | +| RAK_4631_sensor | nRF52, SX1262 | 438152 | 438104 | -48 | +0 | +| PicoW_repeater | RP2040, SX1262 | 492420 | 492388 | -32 | +0 | +| wio-e5-mini_repeater | STM32WL, STM32WLx | 203832 | 203824 | -8 | +0 | +| wio-e5-mini_sensor | STM32WL, STM32WLx | 203304 | 203296 | -8 | +0 | +| RAK_3112_repeater_bridge_espnow | ESP32-S3, SX1262 | 1175509 | 1175537 | +28 | +0 | +| waveshare_rp2040_lora_repeater_bridge_rs232 | RP2040, SX1262 | 240832 | 240800 | -32 | +0 | +| ProMicro_repeater | nRF52, SX1262 | 345824 | 345776 | -48 | +0 | +| ProMicro_room_server | nRF52, SX1262 | 338920 | 338888 | -32 | +0 | +| ProMicro_companion_radio_usb | nRF52, SX1262 | 356836 | 356804 | -32 | +0 | +| ProMicro_sensor | nRF52, SX1262 | 342476 | 342428 | -48 | +0 | +| Generic_E22_sx1268_repeater | ESP32, SX1268 | 1142769 | 1142797 | +28 | +0 | +| t1000e_repeater | nRF52, LR1110 | 294360 | 294280 | -80 | +0 | +| t1000e_companion_radio_ble | nRF52, LR1110 | 308668 | 308588 | -80 | +8 | +| Waveshare_S3_LR1121_repeater | ESP32-S3, LR1121 | 1084309 | 1084357 | +48 | +0 | +| meshnology_w12_repeater | ESP32, LR2021 | 1182665 | 1182697 | +32 | +8 | +| MeshTracker_X1_repeater | nRF52, LR2021 | 300752 | 300704 | -48 | +0 | + +Flash moves by -80 to +48 bytes: the compiler inlines and orders the code a +little differently, smaller on the ARM builds, larger on the Xtensa ones. +RAM is unchanged except in two LR builds (+8). The reason is padding: the +tracker is a 16 byte object of its own, and the `bool` members the LR classes +declare after it no longer share its last word. Measured on the host with +`sizeof` against the stubs, the LR1110, LR1121 and LR2021 classes grow by 8 bytes +(16 to 24 above the RadioLib base), the SX126x classes not at all. A firmware +holds one chip object, so the cost is one padding word, accepted rather than +bending the tracker's layout to the members of its users. Why the other four LR +builds report no change was not examined; a likely cause is that the reported +size is rounded to the alignment of the data sections, which is unverified. + +### Board test + +Two boards that each run a USB companion image, marked with +`FIRMWARE_BUILD_DATE`: a Seeed T1000-E (LR1110) and a LilyGo T-Echo (SX1262), +on the same desk, 869.618 MHz, 62.5 kHz, SF8, CR8, as stored on the boards. +Each board in turn sent a zero hop advert (`CMD_SEND_SELF_ADVERT`) while the +other listened for `PUSH_CODE_LOG_RX_DATA` and `PUSH_CODE_NEW_ADVERT` carrying the +sender's public key. Three rounds per direction, first with the step +(`step9a-ebb14737`), then with `integration` (`step9a-before-2e456f74`): + +| Image | Direction | Sent | Received with sender key | SNR / RSSI per round | +|-------|-----------|------|--------------------------|----------------------| +| step | LR1110 to SX1262 | 3/3 | 3/3 | 12.5/-5, 12.5/-5, 13.0/-5 | +| step | SX1262 to LR1110 | 3/3 | 3/3 | 14.0/-4, 14.0/-4, 14.0/-4 | +| integration | LR1110 to SX1262 | 3/3 | 3/3 | 12.5/-5, 12.25/-5, 12.75/-5 | +| integration | SX1262 to LR1110 | 3/3 | 3/3 | 14.25/-4, 14.0/-4, 14.0/-4 | + +Settings, the 16 `get` values of `tools/refactoring/scripts/comp_gets.txt` and +the contact lists (350 and 68 keys) read the same at the start and at the end. +Both boards went back to the official 1.18.0 BLE companion release, and both +paired phones reconnected over BLE. + +This shows that reception and transmission work on both families with the step. +It does not show the receive detection itself at work on air (a transmission held +back while a packet arrives); that part rests on the host tests. + +### What was not verified on hardware + +No hardware evidence for SX1268, LLCC68, STM32WLx, LR1121 and LR2021. They +build (matrix above, except LLCC68, which no variant in the tree uses and which +only the host suite compiles) and pass the host suite. + +## Upstream collisions + +Checked on 20 October 2026 against open pull requests on `meshcore-dev/MeshCore` +that touch `src/helpers/radiolib`: + +- **#3512** (draft, LR2021 continuous mode race) edits the LR2021 copy of + `isReceiving()`: it drops the header error branch and the early return after a + cleared header flag. If it lands, LR2021 diverges from the other chips on + purpose, and the tracker needs either an option for it or LR2021 keeps its own + copy. Semantic collision with this step. +- **#3638** (dual radio profiles) adds about 380 lines to `CustomSX1262.h` and + about 240 to `CustomLR1110.h` but leaves `isReceiving()` alone. Textual + collision here, a heavy one for steps 9b to 9d. +- **#2131** (error aware SX126x receive hook) adds a helper to all four SX126x + chip classes. Textual here, relevant to 9b. +- **#3496** (KISS modem gain control) changes `setRxBoostedGainMode()` in the LR + chips and the reset helpers. Relevant to 9c and 9d. +- **#2682**, **#2999**, **#2944** add further copies of the four members in new + or older chip classes; they would need the tracker instead. + +## Cut plan for the rest of F4 + +Each step is its own branch with characterization first, as here. + +- **9b: SX126x chip family.** One template `CustomSX126x` for SX1262, + SX1268, LLCC68, with `std_init()` taken from SX1262. The LDO option, register + patch and debug lines that SX1268 and LLCC68 lack are a behaviour change for + those boards and need a decision first; until then the template keeps them + per chip behind a flag. `CustomSTM32WLx` stays a class of its own (different + module type, no `std_init()`), using the tracker as now. +- **9c: one wrapper per family.** `setParams()`, `isReceivingPacket()`, + `getLastRSSI()`, `getLastSNR()`, `getCurrentRSSI()`, `getSpreadingFactor()`, + `doResetAGC()` are identical per family. A wrapper template on the chip class + replaces the casts in every method. Needs host tests through the wrappers, + which `test/test_radio_chips/stubs` can carry. +- **9d: LR11x0 chip family.** `begin()` and `getPacketLength()` of LR1110 and + LR1121 are identical; LR1121 adds its band dependent power handling. A template + for the shared part, LR1121 deriving from it. + +Flash cost expected for 9b to 9d: none or a few bytes, because a firmware still +links exactly one chip class; the gain is in the source, not in the image. + +## Step 9b: one template for SX1262, SX1268 and LLCC68 + +Status: implemented. Behaviour change: none. + +### Measured inventory + +`tools/refactoring/scripts/sx126x_inventory.py` cuts every member out of +`CustomSX1262.h`, `CustomSX1268.h` and `CustomLLCC68.h`, removes comments and +white space, replaces the chip name with a placeholder and compares the copies. +On `integration` 68e0fcdc: + +| Member | Lines (normalized, SX1262) | Groups of identical copies | +|--------|----------------------------|----------------------------| +| includes | 4 | SX1262 / SX1268, LLCC68 | +| `_rxActivity`, constructor | 1 each | all three | +| `std_init()` | 75 | SX1262 / SX1268, LLCC68 | +| `startReceive()` | 3 | all three | +| `isReceiving()` | 14 | all three | +| `setPreambleMillis()`, `setMaxPayloadMillis()` | 1 each | all three | +| `getRxBoostedGainMode()` | 5 | all three | + +SX1268 and LLCC68 are identical to each other in every member. Against SX1262 +they differ in two places only, both in or for `std_init()`: + +1. `#include "MeshCore.h"` (SX1262 only; it brings `MESH_DEBUG_PRINTLN`). +2. In `std_init()`, SX1262 alone has + - the `SX126X_USE_REGULATOR_LDO` option, passed as the last argument of both + `begin()` calls; SX1268 and LLCC68 call `begin()` with eight arguments, so + RadioLib's default `useRegulatorLDO = false` applies. At the commit + `platformio.ini` pins (6d8934836678d8894e3d556550475b37dce3e2b6, version + 7.7.1), the eight argument `begin()` of all three ends in + `float tcxoVoltage = ..., bool useRegulatorLDO = false)`: + `src/modules/SX126x/SX1262.h:58`, `src/modules/SX126x/SX1268.h:56`, + `src/modules/LLCC68/LLCC68.h:52`; + - the `SX126X_REGISTER_PATCH` block (read register 0x8B5, set bit 0, write + it back); + - three `MESH_DEBUG_PRINTLN` lines: regulator requested, the retry with the + TCXO at 0 V, and status and device errors at the end. + +Everything else in `std_init()` (TCXO voltage, coding rate, SPI pins per +platform, the retry on -706/-707, `setCRC(1)`, current limit, DIO2, boosted +gain, RF switch pins) is the same text in all three. + +In the tree, no variant that builds SX1268 (`generic-e22`, `meshadventurer`, +`tenstar_c3`) sets `SX126X_USE_REGULATOR_LDO` or `SX126X_REGISTER_PATCH`, and no +variant uses LLCC68 at all. So taking SX1262's `std_init()` for all three would +change no in-tree release image; it would change debug builds (three more lines) +and any downstream board that sets one of the two macros with an SX1268 or +LLCC68. That is still a behaviour change and needs a decision of its own, so +9b keeps the differences. + +### Template form + +A new header `src/helpers/radiolib/CustomSX126x.h`: + +```cpp +template +class CustomSX126x : public Base { + RxActivityTracker _rxActivity; +public: + explicit CustomSX126x(Module* mod) : Base(mod) { } + bool std_init(SPIClass* spi = NULL); // SX1262's body, see below + int16_t startReceive() override; // Base::startReceive(...) + bool isReceiving(); + void setPreambleMillis(uint32_t); + void setMaxPayloadMillis(uint32_t); + bool getRxBoostedGainMode(); +}; +``` + +The three chip headers keep their class, now a thin subclass: + +```cpp +struct CustomSX1262Traits { + static constexpr bool regulatorOption = true; // honour SX126X_USE_REGULATOR_LDO + static constexpr bool registerPatch = true; // honour SX126X_REGISTER_PATCH + static constexpr bool initDebug = true; // the three MESH_DEBUG lines + static const char* name() { return "SX1262"; } +}; +class CustomSX1262 : public CustomSX126x { +public: + CustomSX1262(Module* mod) : CustomSX126x(mod) { } +}; +``` + +`CustomSX1268Traits` and `CustomLLCC68Traits` set all three flags to `false`. In +`std_init()` the flags are plain `if (Traits::flag)` on compile time constants +(C++11, no `if constexpr`: the firmware environments set no `-std` flag, so each +toolchain's default applies), so the compiler drops the dead branches: + +- regulator: `constexpr bool useRegulatorLDO = Traits::regulatorOption && SX126X_USE_REGULATOR_LDO;` + and `begin()` always gets it as the ninth argument. For SX1268 and LLCC68 that + is `false`, the value RadioLib's default gave them before. +- register patch: the `#ifdef SX126X_REGISTER_PATCH` block stays, its body under + `if (Traits::registerPatch)`. +- debug lines: under `if (Traits::initDebug)`, printing `Traits::name()` through + `%s`, which prints the same text as the literal "SX1262" before. + +`CustomSX126x.h` includes `MeshCore.h` for all three; for SX1268 and LLCC68 that +is one more include, no new symbol a firmware uses. The wrappers, `SX126xReset.h` +and `CustomSTM32WLx` are untouched (wrappers are step 9c; STM32WLx has a different +module type and no `std_init()`, and keeps its own class with the tracker). + +Deciding later to give SX1268 and LLCC68 the LDO option, the patch or the debug +lines is then a one line change of a trait, reviewable as the behaviour change +it is. + +### Alternatives considered for 9b + +- **Take SX1262's `std_init()` as is for all three.** Smallest code, but the + behaviour change described above. Rejected for this step, offered as a + separate decision. +- **`using CustomSX1262 = CustomSX126x;` instead of a subclass.** + Breaks a downstream forward declaration `class CustomSX1262;`, and puts every + SX1262 only addition (such as #3638's) into the shared template. The thin + subclass keeps the class name a real class and gives chip only code a place. +- **One `bool` template parameter instead of a traits struct.** The three + differences happen to coincide today, but one flag would make "give SX1268 + the LDO option" impossible without also giving it the patch. Three named + flags say what each one guards. +- **A non template base class over `SX126x`.** `begin()` is not virtual in + `SX1268` (it is in `SX1262`), so `std_init()` must call the concrete chip's + `begin()`; that needs the chip type, i.e. a template. +- **A macro that stamps out the class body.** Unreadable in a debugger and in + compiler errors; nothing gained over a template. + +### Upstream collisions (checked 21 October 2026) + +All open pull requests on `meshcore-dev/MeshCore` (499) were listed with their +files; these touch `CustomSX1262.h`, `CustomSX1268.h` or `CustomLLCC68.h`: + +| PR | Touches | Collision with 9b | +|----|---------|-------------------| +| #3638 dual radio profiles | `CustomSX1262.h` +380 -2: new members (profile switching, `standby()`/`sleep()`/`setBandwidth()`... overrides, TCXO restore) and a new `startReceive()` body; `std_init()` and `isReceiving()` unchanged | Heavy textual, light semantic. Its members are SX1262 only and land in the thin `CustomSX1262` subclass unchanged; its `startReceive()` there overrides the template's. A rebase moves its hunks from the old class body into the subclass. No line of the shared template needs changing for it. | +| #2131 error aware receive hook | +6 -1 in each of the three | Textual; with the template it becomes one helper in `CustomSX126x` instead of three. | +| #2869 nRF52 radio init recovery | +27 -2 in each of the three `std_init()` | Textual; again one edit instead of three after 9b. | +| #2912 bump RadioLib to 7.7.1 | +14 to +17 in each `std_init()` (`ConfigLoRa_t`) | Textual; one edit instead of three. | +| #2933 noise floor | `CustomSX1262.h` +3 -1, the status debug line | Textual, one line in the template. | +| #3530 nRF54L platform | `CustomSX1262.h` +2, one more SPI branch | Textual, two lines in the template; would then also reach SX1268 and LLCC68 boards on that platform, which is that PR's concern. | +| #2624 DX-LR20 variant | `CustomLLCC68.h` +5 -5 | Textual; to be checked on rebase whether its change is LLCC68 only (then a trait or the subclass) or general. | + +#2682, #2999, #2944 (named in the 9a section) do not touch these three files. +#3638 is the heavy one, but because 9b keeps `CustomSX1262` as a class and its +additions are SX1262 only, the collision is a hunk move, not a redesign. This +does not make 9b pointless; four of the seven PRs edit three copies identically +and would get smaller with it. + +### What changed + +`src/helpers/radiolib/CustomSX126x.h` holds the template as designed above. +`CustomSX1262.h`, `CustomSX1268.h` and `CustomLLCC68.h` are each a traits struct +and a subclass with the constructor. In `src`, 339 lines go and 188 come in. +Callers, the wrappers, `SX126xReset.h` and `CustomSTM32WLx.h` are unchanged. + +### Characterization tests + +Committed first (61d3a4f4), passing unchanged before and after the move: + +- `test/test_radio_chips` gets a second typed suite over the three classes, + built without any option macro: the `begin()` arguments (coding rate 5, sync + word 0x12, preamble 16, TCXO 1.6 V, LDO off), `setCRC(1)` as the only further + call, a passed SPI left alone without `P_LORA_SCLK`, the retry with TCXO 0 V on + -707 and on -706, the error line after a failed retry and after another error + (no retry), and `getRxBoostedGainMode()` reading register 0x8AC. 21 cases. +- `test/test_radio_chips_init` is a separate test program in the same + environment (the classes are header only, so a build with other macro values + must not be linked into the same binary). It sets `MESH_DEBUG`, + `SX126X_USE_REGULATOR_LDO`, `SX126X_REGISTER_PATCH`, the TCXO voltage, `LORA_CR`, + current limit, DIO2, boosted gain, RX/TX enable pins and the SPI pins, and pins + the full ordered call log, the register 0x8B5 content and the debug output per + chip: SX1262 passes LDO, patches 0x8B5 and prints its three lines; SX1268 and + LLCC68 pass `false`, leave 0x8B5 alone and print nothing. 7 cases. + +The stubs in `test/test_radio_chips/stubs/RadioLib.h` record the calls for this: +a `begin()` with RadioLib's parameters and defaults for the SX126x family, a +register map, and a call log; `getStatus()` and `getDeviceErrors()` return +distinct values so the status line is pinned with them. + +All 17 host environments pass, 605 cases, on the test commit 61d3a4f4 and on the +code commit (577 before, 28 new): + +```sh +pio test -e native_radio_chips +``` + +### Mutation tests + +Each mutation was applied to the code commit, `native_radio_chips` run, and the +change reverted. + +| # | Mutation | Failing cases | +|---|----------|---------------| +| M1 | SX1268 `regulatorOption` true | 2, SX1268 only | +| M2 | LLCC68 `registerPatch` true | 2, LLCC68 only | +| M3 | SX1268 `initDebug` true | 2, SX1268 only | +| M4 | SX1262 `regulatorOption` false | 3, SX1262 only | +| M5 | SX1262 `registerPatch` false | 2, SX1262 only | +| M6 | SX1262 `initDebug` false | 3, SX1262 only | +| M7 | no retry on -706 | 3, all three chips | +| M8 | retry keeps the TCXO voltage | 12, all three chips | +| M9 | `setCRC(2)` instead of `setCRC(1)` | 15, all three chips | +| M10 | register patch sets bit 1 instead of bit 0 | 2, SX1262 only | +| M11 | boosted gain check inverted | 3, all three chips | +| M12 | SPI pins passed in the wrong order | 3, all three chips | +| M13 | LLCC68 `regulatorOption` true | 2, LLCC68 only | +| M14 | SX1268 `registerPatch` true | 2, SX1268 only | + +### Build matrix + +Every firmware environment whose `RADIO_CLASS` is `CustomSX1262` (493) or +`CustomSX1268` (12), plus 22 controls that do not use the three classes: all 16 +`CustomSTM32WLx` environments (STM32), `t1000e_repeater` (nRF52, LR1110), +`MeshTracker_X1_repeater` (nRF52, LR2021), `Waveshare_S3_LR1121_repeater` and +`meshnology_w12_repeater` (ESP32, LR1121 and LR2021), `Heltec_v2_repeater` and +`Heltec_v2_repeater_bridge_espnow` (ESP32, SX1276). No environment uses +`CustomLLCC68`. 527 environments, each built in the same worktree path on +`integration` 68e0fcdc and on the code commit 2228a5d6, same toolchain. + +512 build on both sides. The same 15 fail on both sides, none in a radio header: +the `setTxBufferSize` error of the ESP32 `kiss_modem` builds (`Heltec_E213`, +`Heltec_E290`, `Heltec_T190`, `LilyGo_TBeam_1W`, `Station_G3_ESP32`, +`heltec_tracker_v2`), the ESP-NOW callback signature on ESP32-C3 +(`Heltec_ct62`, `Tenstar_C3_sx1262`, `Tenstar_C3_sx1268`, each +`_repeater_bridge_espnow`), image too large (`Station_G2_companion_radio_ble`, +`Station_G3_ESP32_companion_radio_ble`), `D7` undeclared +(`ThinkNode_M5_companion_radio_serial`) and the `EnvironmentSensorManager` link +error (`ThinkNode_M8_repeater`, `_room_server`, `_kiss_modem`). + +RAM is unchanged in all 512. Flash, after minus before, in bytes: + +| Platform | Radio class | Environments | Flash difference (environments) | +|----------|-------------|--------------|---------------------------------| +| ESP32 | SX1262 | 252 | +0 (92), +4 (144), +8 (8), -2 (6), -16 (1), -40 (1) | +| ESP32 | SX1268 | 11 | +0 (2), +4 (9) | +| nRF52 | SX1262 | 202 | +0 (197), +16 (5) | +| RP2040 | SX1262 | 25 | +0 (25) | +| STM32 | STM32WLx (control) | 16 | +0 (16) | +| ESP32, nRF52 | LR1110, LR1121, LR2021, SX1276 (controls) | 6 | +0 (6) | + +Measured for `Heltec_v3_repeater` (+4) with `nm` and `size -A` on both ELF files: +`.flash.text` grows by 4 bytes, and the only code symbol that changes size is +`_GLOBAL__sub_I_board`, the static initializer of the variant's globals, from 0x194 +to 0x198 bytes, where the radio object is now built through one more base class +constructor. Every chip and wrapper method has the same size as before (compared +with the template name folded into `CustomSX1262`). The -2 builds are the ESP32-C3 +and C6 ones. + +#### Image comparison and where the bytes go + +The image hashes of the matrix above differ in all 512, controls included, +because RadioLib puts the build time into every image: `Module.cpp:47` keeps +`static volatile const char rlb_info[] = RADIOLIB_INFO;`, and `BuildOpt.h:647` +ends `RADIOLIB_INFO` with `__DATE__ " " __TIME__`. Two builds of the same commit +one minute apart differ in exactly those three bytes. With `SOURCE_DATE_EPOCH` +set, which GCC uses for both macros, two builds of the same commit are byte +identical. + +65 environments were then built again on both sides with +`SOURCE_DATE_EPOCH=1792540800`, keeping the ELF files: all 22 controls, all 21 +environments with a flash difference other than +0 or +4, and 22 picked from the ++0 and +4 groups across the platforms and both chips. + +- 23 images are byte identical: all 22 controls and `RAK_4631_repeater`. +- 42 differ. In each of them, `nm -S` of both ELF files was compared symbol by + symbol, with the template name folded back into the class name. In 12 every + symbol has the same size; only the order of the template members in flash + changed (in `PicoW_repeater`, 23 of 2132 code symbols moved, for example + `isReceiving()` from 0x10006214 to 0x100063b0). In the other 30 only these + change size: `_GLOBAL__sub_I_board`, the static initializer of the variant + globals that builds the radio object (22 environments, +1 to +8 bytes); + `radio_init()`, into which `std_init()` is inlined (12, -2 to +12); and in a + few environments functions the step does not touch (`sx126xResetAGC()` -4, + `MicroNMEALocationProvider::waitFor()` -1, `::loop()` +4, `start_cpu0` -8, + `esp_vfs_open` +4, `ESP32Board::getResetReasonString()` -2). + +Three of them were disassembled and compared instruction by instruction: + +- `_GLOBAL__sub_I_board` in `Heltec_v3_repeater` (+4): 156 instructions on both + sides, the same stores in a different order, and four of them emitted in the + wide instead of the narrow Xtensa encoding (`s32i.n` to `s32i`, `mov.n` to + `or`), one byte each. +- `std_init()` of SX1268 in `Generic_E22_sx1268_repeater` (+4): the same + instruction sequence; one `ill` padding word after a jump and one `mov.n` + emitted as `or`, both alignment of a branch target. +- `MicroNMEALocationProvider::waitFor()` (-1): identical up to its final + `retw.n`; the difference is the padding after it, which depends on where the + function lands. + +So the flash differences are scheduling, instruction width and alignment around +the object construction and the inlined `std_init()`. The -40 of +`RAK_3112_room_server` is not a code change of its own: its changed symbols add +up to -6 (`_GLOBAL__sub_I_board` +3, `start_cpu0` -8, `waitFor` -1), the rest +is section padding. + +
+Flash and RAM per environment (512) + +| Environment | Platform | Radio class | Flash before | Flash after | Flash | RAM | +|---|---|---|---|---|---|---| +| Ebyte_EoRa-S3_Repeater | ESP32 | SX1262 | 1108433 | 1108433 | +0 | +0 | +| Ebyte_EoRa-S3_companion_radio_ble | ESP32 | SX1262 | 1215629 | 1215629 | +0 | +0 | +| Ebyte_EoRa-S3_companion_radio_usb | ESP32 | SX1262 | 590197 | 590197 | +0 | +0 | +| Ebyte_EoRa-S3_kiss_modem | ESP32 | SX1262 | 507821 | 507821 | +0 | +0 | +| Ebyte_EoRa-S3_room_server | ESP32 | SX1262 | 1103745 | 1103745 | +0 | +0 | +| Ebyte_EoRa-S3_terminal_chat | ESP32 | SX1262 | 535737 | 535737 | +0 | +0 | +| GAT562_30S_Mesh_Kit_companion_radio_ble | nRF52 | SX1262 | 436052 | 436052 | +0 | +0 | +| GAT562_30S_Mesh_Kit_companion_radio_usb | nRF52 | SX1262 | 427028 | 427028 | +0 | +0 | +| GAT562_30S_Mesh_Kit_kiss_modem | nRF52 | SX1262 | 341280 | 341280 | +0 | +0 | +| GAT562_30S_Mesh_Kit_repeater | nRF52 | SX1262 | 411568 | 411568 | +0 | +0 | +| GAT562_30S_Mesh_Kit_room_server | nRF52 | SX1262 | 404656 | 404656 | +0 | +0 | +| GAT562_Mesh_EVB_Pro_kiss_modem | nRF52 | SX1262 | 310916 | 310916 | +0 | +0 | +| GAT562_Mesh_EVB_Pro_repeater | nRF52 | SX1262 | 365764 | 365764 | +0 | +0 | +| GAT562_Mesh_EVB_Pro_room_server | nRF52 | SX1262 | 359260 | 359260 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_companion_radio_ble | nRF52 | SX1262 | 403224 | 403224 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_companion_radio_usb | nRF52 | SX1262 | 394216 | 394216 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_kiss_modem | nRF52 | SX1262 | 310900 | 310900 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_repeater | nRF52 | SX1262 | 381276 | 381276 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_room_server | nRF52 | SX1262 | 374364 | 374364 | +0 | +0 | +| GAT562_Mesh_Watch13_companion_radio_ble | nRF52 | SX1262 | 399608 | 399608 | +0 | +0 | +| GAT562_Mesh_Watch13_kiss_modem | nRF52 | SX1262 | 304048 | 304048 | +0 | +0 | +| Generic_E22_sx1262_repeater | ESP32 | SX1262 | 1143573 | 1143573 | +0 | +0 | +| Generic_E22_sx1262_repeater_bridge_espnow | ESP32 | SX1262 | 1154277 | 1154277 | +0 | +0 | +| Generic_E22_sx1268_repeater | ESP32 | SX1268 | 1143357 | 1143361 | +4 | +0 | +| Generic_E22_sx1268_repeater_bridge_espnow | ESP32 | SX1268 | 1154053 | 1154057 | +4 | +0 | +| Heltec_E213_companion_radio_ble | ESP32 | SX1262 | 1272953 | 1272957 | +4 | +0 | +| Heltec_E213_companion_radio_usb | ESP32 | SX1262 | 668529 | 668533 | +4 | +0 | +| Heltec_E213_repeater | ESP32 | SX1262 | 1162229 | 1162233 | +4 | +0 | +| Heltec_E213_repeater_bridge_espnow | ESP32 | SX1262 | 1172673 | 1172677 | +4 | +0 | +| Heltec_E213_room_server | ESP32 | SX1262 | 1157473 | 1157477 | +4 | +0 | +| Heltec_E290_companion_ble | ESP32 | SX1262 | 1270781 | 1270789 | +8 | +0 | +| Heltec_E290_companion_usb | ESP32 | SX1262 | 666265 | 666269 | +4 | +0 | +| Heltec_E290_repeater | ESP32 | SX1262 | 1160181 | 1160185 | +4 | +0 | +| Heltec_E290_repeater_bridge_espnow | ESP32 | SX1262 | 1170609 | 1170613 | +4 | +0 | +| Heltec_E290_room_server | ESP32 | SX1262 | 1155397 | 1155401 | +4 | +0 | +| Heltec_T190_companion_radio_ble_ | ESP32 | SX1262 | 1250085 | 1250085 | +0 | +0 | +| Heltec_T190_companion_radio_usb_ | ESP32 | SX1262 | 646181 | 646181 | +0 | +0 | +| Heltec_T190_repeater_ | ESP32 | SX1262 | 1139397 | 1139397 | +0 | +0 | +| Heltec_T190_repeater_bridge_espnow_ | ESP32 | SX1262 | 1149833 | 1149833 | +0 | +0 | +| Heltec_T190_room_server_ | ESP32 | SX1262 | 1134653 | 1134653 | +0 | +0 | +| Heltec_WSL3_companion_radio_ble | ESP32 | SX1262 | 1647705 | 1647709 | +4 | +0 | +| Heltec_WSL3_companion_radio_usb | ESP32 | SX1262 | 636669 | 636673 | +4 | +0 | +| Heltec_WSL3_companion_radio_wifi | ESP32 | SX1262 | 1086181 | 1086185 | +4 | +0 | +| Heltec_WSL3_repeater | ESP32 | SX1262 | 1161333 | 1161337 | +4 | +0 | +| Heltec_WSL3_repeater_bridge_espnow | ESP32 | SX1262 | 1171761 | 1171765 | +4 | +0 | +| Heltec_WSL3_repeater_bridge_rs232 | ESP32 | SX1262 | 1163781 | 1163785 | +4 | +0 | +| Heltec_WSL3_room_server | ESP32 | SX1262 | 1156817 | 1156821 | +4 | +0 | +| Heltec_WSL3_sensor | ESP32 | SX1262 | 1168229 | 1168233 | +4 | +0 | +| Heltec_Wireless_Paper_companion_radio_ble | ESP32 | SX1262 | 1614793 | 1614793 | +0 | +0 | +| Heltec_Wireless_Paper_companion_radio_usb | ESP32 | SX1262 | 625189 | 625189 | +0 | +0 | +| Heltec_Wireless_Paper_kiss_modem | ESP32 | SX1262 | 517461 | 517461 | +0 | +0 | +| Heltec_Wireless_Paper_repeater | ESP32 | SX1262 | 1121873 | 1121873 | +0 | +0 | +| Heltec_Wireless_Paper_repeater_bridge_espnow | ESP32 | SX1262 | 1132325 | 1132325 | +0 | +0 | +| Heltec_Wireless_Paper_room_server | ESP32 | SX1262 | 1117125 | 1117125 | +0 | +0 | +| Heltec_Wireless_Tracker_companion_radio_ble | ESP32 | SX1262 | 1264633 | 1264637 | +4 | +0 | +| Heltec_Wireless_Tracker_companion_radio_usb | ESP32 | SX1262 | 661373 | 661377 | +4 | +0 | +| Heltec_Wireless_Tracker_kiss_modem | ESP32 | SX1262 | 526705 | 526709 | +4 | +0 | +| Heltec_Wireless_Tracker_repeater | ESP32 | SX1262 | 1157617 | 1157621 | +4 | +0 | +| Heltec_Wireless_Tracker_repeater_bridge_espnow | ESP32 | SX1262 | 1168105 | 1168109 | +4 | +0 | +| Heltec_Wireless_Tracker_room_server | ESP32 | SX1262 | 1152889 | 1152893 | +4 | +0 | +| Heltec_ct62_companion_radio_ble | ESP32 | SX1262 | 1275586 | 1275586 | +0 | +0 | +| Heltec_ct62_companion_radio_usb | ESP32 | SX1262 | 563516 | 563516 | +0 | +0 | +| Heltec_ct62_kiss_modem | ESP32 | SX1262 | 497028 | 497028 | +0 | +0 | +| Heltec_ct62_repeater | ESP32 | SX1262 | 1151874 | 1151874 | +0 | +0 | +| Heltec_ct62_sensor | ESP32 | SX1262 | 1157740 | 1157740 | +0 | +0 | +| Heltec_mesh_solar_companion_radio_ble | nRF52 | SX1262 | 374060 | 374060 | +0 | +0 | +| Heltec_mesh_solar_companion_radio_usb | nRF52 | SX1262 | 369060 | 369060 | +0 | +0 | +| Heltec_mesh_solar_kiss_modem | nRF52 | SX1262 | 309024 | 309024 | +0 | +0 | +| Heltec_mesh_solar_repeater | nRF52 | SX1262 | 365600 | 365600 | +0 | +0 | +| Heltec_mesh_solar_room_server | nRF52 | SX1262 | 359072 | 359072 | +0 | +0 | +| Heltec_t096_companion_radio_ble | nRF52 | SX1262 | 433556 | 433556 | +0 | +0 | +| Heltec_t096_companion_radio_usb | nRF52 | SX1262 | 426820 | 426820 | +0 | +0 | +| Heltec_t096_kiss_modem | nRF52 | SX1262 | 340776 | 340776 | +0 | +0 | +| Heltec_t096_repeater | nRF52 | SX1262 | 414464 | 414464 | +0 | +0 | +| Heltec_t096_repeater_bridge_rs232 | nRF52 | SX1262 | 416736 | 416736 | +0 | +0 | +| Heltec_t096_room_server | nRF52 | SX1262 | 407584 | 407584 | +0 | +0 | +| Heltec_t096_sensor | nRF52 | SX1262 | 411348 | 411348 | +0 | +0 | +| Heltec_t114_companion_radio_ble | nRF52 | SX1262 | 417624 | 417624 | +0 | +0 | +| Heltec_t114_companion_radio_usb | nRF52 | SX1262 | 410888 | 410888 | +0 | +0 | +| Heltec_t114_kiss_modem | nRF52 | SX1262 | 311268 | 311268 | +0 | +0 | +| Heltec_t114_repeater | nRF52 | SX1262 | 398460 | 398460 | +0 | +0 | +| Heltec_t114_repeater_bridge_rs232 | nRF52 | SX1262 | 368532 | 368532 | +0 | +0 | +| Heltec_t114_room_server | nRF52 | SX1262 | 391580 | 391580 | +0 | +0 | +| Heltec_t114_without_display_companion_radio_ble | nRF52 | SX1262 | 391872 | 391872 | +0 | +0 | +| Heltec_t114_without_display_companion_radio_usb | nRF52 | SX1262 | 385040 | 385040 | +0 | +0 | +| Heltec_t114_without_display_repeater | nRF52 | SX1262 | 366260 | 366260 | +0 | +0 | +| Heltec_t114_without_display_repeater_bridge_rs232 | nRF52 | SX1262 | 368532 | 368532 | +0 | +0 | +| Heltec_t114_without_display_room_server | nRF52 | SX1262 | 359740 | 359740 | +0 | +0 | +| Heltec_t1_companion_radio_ble | nRF52 | SX1262 | 435056 | 435056 | +0 | +0 | +| Heltec_t1_companion_radio_usb | nRF52 | SX1262 | 428352 | 428352 | +0 | +0 | +| Heltec_t1_kiss_modem | nRF52 | SX1262 | 339884 | 339884 | +0 | +0 | +| Heltec_t1_repeater | nRF52 | SX1262 | 413508 | 413508 | +0 | +0 | +| Heltec_t1_room_server | nRF52 | SX1262 | 406644 | 406644 | +0 | +0 | +| Heltec_tower_v2_companion_radio_ble | nRF52 | SX1262 | 334924 | 334924 | +0 | +0 | +| Heltec_tower_v2_companion_radio_usb | nRF52 | SX1262 | 328084 | 328084 | +0 | +0 | +| Heltec_tower_v2_kiss_modem | nRF52 | SX1262 | 251864 | 251864 | +0 | +0 | +| Heltec_tower_v2_repeater | nRF52 | SX1262 | 309528 | 309528 | +0 | +0 | +| Heltec_tower_v2_room_server | nRF52 | SX1262 | 303000 | 303000 | +0 | +0 | +| Heltec_v2_repeater | ESP32 | SX1276 | 1151913 | 1151913 | +0 | +0 | +| Heltec_v2_repeater_bridge_espnow | ESP32 | SX1276 | 1162757 | 1162757 | +0 | +0 | +| Heltec_v3_companion_radio_ble | ESP32 | SX1262 | 1670229 | 1670233 | +4 | +0 | +| Heltec_v3_companion_radio_usb | ESP32 | SX1262 | 656829 | 656833 | +4 | +0 | +| Heltec_v3_companion_radio_wifi | ESP32 | SX1262 | 1106381 | 1106385 | +4 | +0 | +| Heltec_v3_kiss_modem | ESP32 | SX1262 | 581249 | 581253 | +4 | +0 | +| Heltec_v3_repeater | ESP32 | SX1262 | 1175021 | 1175025 | +4 | +0 | +| Heltec_v3_repeater_bridge_espnow | ESP32 | SX1262 | 1185489 | 1185493 | +4 | +0 | +| Heltec_v3_repeater_bridge_rs232 | ESP32 | SX1262 | 1177489 | 1177493 | +4 | +0 | +| Heltec_v3_room_server | ESP32 | SX1262 | 1170169 | 1170173 | +4 | +0 | +| Heltec_v3_sensor | ESP32 | SX1262 | 1181557 | 1181561 | +4 | +0 | +| Heltec_v3_terminal_chat | ESP32 | SX1262 | 602377 | 602381 | +4 | +0 | +| KeepteenLT1_companion_radio_ble | nRF52 | SX1262 | 352656 | 352672 | +16 | +0 | +| KeepteenLT1_companion_radio_usb | nRF52 | SX1262 | 334760 | 334760 | +0 | +0 | +| KeepteenLT1_kiss_modem | nRF52 | SX1262 | 248868 | 248868 | +0 | +0 | +| KeepteenLT1_repeater | nRF52 | SX1262 | 322028 | 322028 | +0 | +0 | +| KeepteenLT1_room_server | nRF52 | SX1262 | 315108 | 315108 | +0 | +0 | +| LilyGo_T-Echo-Lite_companion_radio_ble | nRF52 | SX1262 | 356456 | 356456 | +0 | +0 | +| LilyGo_T-Echo-Lite_companion_radio_usb | nRF52 | SX1262 | 351552 | 351552 | +0 | +0 | +| LilyGo_T-Echo-Lite_kiss_modem | nRF52 | SX1262 | 276892 | 276892 | +0 | +0 | +| LilyGo_T-Echo-Lite_non_shell_companion_radio_ble | nRF52 | SX1262 | 315544 | 315544 | +0 | +0 | +| LilyGo_T-Echo-Lite_non_shell_companion_radio_usb | nRF52 | SX1262 | 310416 | 310416 | +0 | +0 | +| LilyGo_T-Echo-Lite_repeater | nRF52 | SX1262 | 335772 | 335772 | +0 | +0 | +| LilyGo_T-Echo-Lite_room_server | nRF52 | SX1262 | 328964 | 328964 | +0 | +0 | +| LilyGo_T-Echo_Card_companion_radio_ble | nRF52 | SX1262 | 349248 | 349248 | +0 | +0 | +| LilyGo_T-Echo_Card_companion_radio_usb | nRF52 | SX1262 | 343488 | 343488 | +0 | +0 | +| LilyGo_T-Echo_Card_kiss_modem | nRF52 | SX1262 | 261536 | 261536 | +0 | +0 | +| LilyGo_T-Echo_Card_repeater | nRF52 | SX1262 | 322368 | 322368 | +0 | +0 | +| LilyGo_T-Echo_Card_room_server | nRF52 | SX1262 | 314112 | 314112 | +0 | +0 | +| LilyGo_T-Echo_companion_radio_ble | nRF52 | SX1262 | 375680 | 375680 | +0 | +0 | +| LilyGo_T-Echo_companion_radio_usb | nRF52 | SX1262 | 368288 | 368288 | +0 | +0 | +| LilyGo_T-Echo_kiss_modem | nRF52 | SX1262 | 291496 | 291496 | +0 | +0 | +| LilyGo_T-Echo_repeater | nRF52 | SX1262 | 350096 | 350096 | +0 | +0 | +| LilyGo_T-Echo_room_server | nRF52 | SX1262 | 343288 | 343288 | +0 | +0 | +| LilyGo_T3S3_sx1262_companion_radio_ble | ESP32 | SX1262 | 1215677 | 1215677 | +0 | +0 | +| LilyGo_T3S3_sx1262_companion_radio_usb | ESP32 | SX1262 | 590245 | 590245 | +0 | +0 | +| LilyGo_T3S3_sx1262_kiss_modem | ESP32 | SX1262 | 507869 | 507869 | +0 | +0 | +| LilyGo_T3S3_sx1262_repeater | ESP32 | SX1262 | 1108533 | 1108533 | +0 | +0 | +| LilyGo_T3S3_sx1262_repeater_bridge_espnow | ESP32 | SX1262 | 1118825 | 1118825 | +0 | +0 | +| LilyGo_T3S3_sx1262_room_server | ESP32 | SX1262 | 1103801 | 1103801 | +0 | +0 | +| LilyGo_T3S3_sx1262_terminal_chat | ESP32 | SX1262 | 535785 | 535785 | +0 | +0 | +| LilyGo_TBeam_1W_companion_radio_ble | ESP32 | SX1262 | 1266201 | 1266205 | +4 | +0 | +| LilyGo_TBeam_1W_companion_radio_usb | ESP32 | SX1262 | 641217 | 641221 | +4 | +0 | +| LilyGo_TBeam_1W_companion_radio_wifi | ESP32 | SX1262 | 1087349 | 1087353 | +4 | +0 | +| LilyGo_TBeam_1W_repeater | ESP32 | SX1262 | 1154969 | 1154973 | +4 | +0 | +| LilyGo_TBeam_1W_repeater_bridge_espnow | ESP32 | SX1262 | 1165457 | 1165461 | +4 | +0 | +| LilyGo_TBeam_1W_room_server | ESP32 | SX1262 | 1150137 | 1150141 | +4 | +0 | +| LilyGo_TDeck_companion_radio_ble | ESP32 | SX1262 | 1251765 | 1251769 | +4 | +0 | +| LilyGo_TDeck_companion_radio_usb | ESP32 | SX1262 | 648409 | 648413 | +4 | +0 | +| LilyGo_TDeck_kiss_modem | ESP32 | SX1262 | 563321 | 563325 | +4 | +0 | +| LilyGo_TDeck_repeater | ESP32 | SX1262 | 1144729 | 1144733 | +4 | +0 | +| LilyGo_TETH_Elite_sx1262_companion_radio_ble | ESP32 | SX1262 | 1177209 | 1177209 | +0 | +0 | +| LilyGo_TETH_Elite_sx1262_companion_radio_usb | ESP32 | SX1262 | 552553 | 552553 | +0 | +0 | +| LilyGo_TETH_Elite_sx1262_kiss_modem | ESP32 | SX1262 | 489269 | 489269 | +0 | +0 | +| LilyGo_TETH_Elite_sx1262_repeater | ESP32 | SX1262 | 1076289 | 1076289 | +0 | +0 | +| LilyGo_TETH_Elite_sx1262_room_server | ESP32 | SX1262 | 1071653 | 1071653 | +0 | +0 | +| LilyGo_T_Impulse_Plus_companion_radio_ble | nRF52 | SX1262 | 405988 | 406004 | +16 | +0 | +| LilyGo_T_Impulse_Plus_kiss_modem | nRF52 | SX1262 | 311364 | 311364 | +0 | +0 | +| LilyGo_T_Impulse_Plus_repeater | nRF52 | SX1262 | 366508 | 366508 | +0 | +0 | +| LilyGo_Tlora_C6_companion_radio_ble_ | ESP32 | SX1262 | 1444915 | 1444915 | +0 | +0 | +| LilyGo_Tlora_C6_kiss_modem | ESP32 | SX1262 | 472809 | 472809 | +0 | +0 | +| LilyGo_Tlora_C6_repeater_ | ESP32 | SX1262 | 542449 | 542447 | -2 | +0 | +| LilyGo_Tlora_C6_room_server_ | ESP32 | SX1262 | 536981 | 536981 | +0 | +0 | +| M5Stack_Unit_C6L_companion_radio_ble | ESP32 | SX1262 | 1452475 | 1452475 | +0 | +0 | +| M5Stack_Unit_C6L_companion_radio_usb | ESP32 | SX1262 | 544593 | 544593 | +0 | +0 | +| M5Stack_Unit_C6L_kiss_modem | ESP32 | SX1262 | 480175 | 480175 | +0 | +0 | +| M5Stack_Unit_C6L_repeater | ESP32 | SX1262 | 552823 | 552821 | -2 | +0 | +| M5Stack_Unit_C6L_room_server | ESP32 | SX1262 | 547355 | 547355 | +0 | +0 | +| MeshTracker_X1_repeater | nRF52 | LR2021 | 301152 | 301152 | +0 | +0 | +| Mesh_pocket_companion_radio_ble | nRF52 | SX1262 | 345036 | 345036 | +0 | +0 | +| Mesh_pocket_companion_radio_usb | nRF52 | SX1262 | 338220 | 338220 | +0 | +0 | +| Mesh_pocket_kiss_modem | nRF52 | SX1262 | 268408 | 268408 | +0 | +0 | +| Mesh_pocket_repeater | nRF52 | SX1262 | 326840 | 326840 | +0 | +0 | +| Mesh_pocket_room_server | nRF52 | SX1262 | 320096 | 320096 | +0 | +0 | +| Meshadventurer_sx1262_companion_radio_ble | ESP32 | SX1262 | 1440789 | 1440797 | +8 | +0 | +| Meshadventurer_sx1262_companion_radio_usb | ESP32 | SX1262 | 601921 | 601925 | +4 | +0 | +| Meshadventurer_sx1262_kiss_modem | ESP32 | SX1262 | 529925 | 529933 | +8 | +0 | +| Meshadventurer_sx1262_repeater | ESP32 | SX1262 | 1162093 | 1162097 | +4 | +0 | +| Meshadventurer_sx1262_repeater_bridge_espnow | ESP32 | SX1262 | 1172877 | 1172881 | +4 | +0 | +| Meshadventurer_sx1262_room_server | ESP32 | SX1262 | 1157349 | 1157353 | +4 | +0 | +| Meshadventurer_sx1262_terminal_chat | ESP32 | SX1262 | 558453 | 558457 | +4 | +0 | +| Meshadventurer_sx1268_companion_radio_ble | ESP32 | SX1268 | 1440541 | 1440545 | +4 | +0 | +| Meshadventurer_sx1268_companion_radio_usb | ESP32 | SX1268 | 601697 | 601701 | +4 | +0 | +| Meshadventurer_sx1268_kiss_modem | ESP32 | SX1268 | 529697 | 529701 | +4 | +0 | +| Meshadventurer_sx1268_repeater | ESP32 | SX1268 | 1161865 | 1161869 | +4 | +0 | +| Meshadventurer_sx1268_repeater_bridge_espnow | ESP32 | SX1268 | 1172657 | 1172661 | +4 | +0 | +| Meshadventurer_sx1268_room_server | ESP32 | SX1268 | 1157129 | 1157133 | +4 | +0 | +| Meshadventurer_sx1268_terminal_chat | ESP32 | SX1268 | 558229 | 558233 | +4 | +0 | +| Meshimi_companion_radio_ble_ | ESP32 | SX1262 | 1444987 | 1444987 | +0 | +0 | +| Meshimi_repeater_ | ESP32 | SX1262 | 542537 | 542535 | -2 | +0 | +| Meshtiny_companion_radio_ble | nRF52 | SX1262 | 337552 | 337568 | +16 | +0 | +| Meshtiny_companion_radio_usb | nRF52 | SX1262 | 330152 | 330168 | +16 | +0 | +| Meshtiny_kiss_modem | nRF52 | SX1262 | 243268 | 243268 | +0 | +0 | +| Nano_G2_Ultra_companion_radio_ble | nRF52 | SX1262 | 345724 | 345724 | +0 | +0 | +| Nano_G2_Ultra_companion_radio_usb | nRF52 | SX1262 | 338316 | 338316 | +0 | +0 | +| Nano_G2_Ultra_kiss_modem | nRF52 | SX1262 | 249476 | 249476 | +0 | +0 | +| Nano_G2_Ultra_repeater | nRF52 | SX1262 | 319500 | 319500 | +0 | +0 | +| Nano_G2_Ultra_room_server | nRF52 | SX1262 | 312580 | 312580 | +0 | +0 | +| PicoW_companion_radio | RP2040 | SX1262 | 665800 | 665800 | +0 | +0 | +| PicoW_companion_radio_ble | RP2040 | SX1262 | 633604 | 633604 | +0 | +0 | +| PicoW_companion_radio_usb | RP2040 | SX1262 | 495284 | 495284 | +0 | +0 | +| PicoW_companion_radio_wifi | RP2040 | SX1262 | 528732 | 528732 | +0 | +0 | +| PicoW_kiss_modem | RP2040 | SX1262 | 439572 | 439572 | +0 | +0 | +| PicoW_repeater | RP2040 | SX1262 | 492668 | 492668 | +0 | +0 | +| PicoW_room_server | RP2040 | SX1262 | 488612 | 488612 | +0 | +0 | +| PicoW_terminal_chat | RP2040 | SX1262 | 469148 | 469148 | +0 | +0 | +| ProMicro_companion_radio_ble | nRF52 | SX1262 | 367860 | 367860 | +0 | +0 | +| ProMicro_companion_radio_usb | nRF52 | SX1262 | 358836 | 358836 | +0 | +0 | +| ProMicro_kiss_modem | nRF52 | SX1262 | 273400 | 273400 | +0 | +0 | +| ProMicro_repeater | nRF52 | SX1262 | 346240 | 346240 | +0 | +0 | +| ProMicro_repeater_bridge_rs232_serial1 | nRF52 | SX1262 | 342544 | 342544 | +0 | +0 | +| ProMicro_room_server | nRF52 | SX1262 | 339288 | 339288 | +0 | +0 | +| ProMicro_sensor | nRF52 | SX1262 | 342956 | 342956 | +0 | +0 | +| ProMicro_terminal_chat | nRF52 | SX1262 | 298384 | 298384 | +0 | +0 | +| R1Neo_companion_radio_ble | nRF52 | SX1262 | 385336 | 385336 | +0 | +0 | +| R1Neo_companion_radio_usb | nRF52 | SX1262 | 377928 | 377928 | +0 | +0 | +| R1Neo_kiss_modem | nRF52 | SX1262 | 312020 | 312020 | +0 | +0 | +| R1Neo_repeater | nRF52 | SX1262 | 368916 | 368916 | +0 | +0 | +| R1Neo_room_server | nRF52 | SX1262 | 361420 | 361420 | +0 | +0 | +| R1Neo_sensor | nRF52 | SX1262 | 365104 | 365104 | +0 | +0 | +| R1Neo_terminal_chat | nRF52 | SX1262 | 334572 | 334572 | +0 | +0 | +| RAK_11310_companion_radio_usb | RP2040 | SX1262 | 238796 | 238796 | +0 | +0 | +| RAK_11310_kiss_modem | RP2040 | SX1262 | 183084 | 183084 | +0 | +0 | +| RAK_11310_repeater | RP2040 | SX1262 | 236172 | 236172 | +0 | +0 | +| RAK_11310_repeater_bridge_rs232 | RP2040 | SX1262 | 240664 | 240664 | +0 | +0 | +| RAK_11310_room_server | RP2040 | SX1262 | 232124 | 232124 | +0 | +0 | +| RAK_11310_terminal_chat | RP2040 | SX1262 | 212668 | 212668 | +0 | +0 | +| RAK_3112_companion_radio_ble | ESP32 | SX1262 | 1653089 | 1653093 | +4 | +0 | +| RAK_3112_companion_radio_usb | ESP32 | SX1262 | 642157 | 642161 | +4 | +0 | +| RAK_3112_companion_radio_wifi | ESP32 | SX1262 | 1091457 | 1091461 | +4 | +0 | +| RAK_3112_kiss_modem | ESP32 | SX1262 | 586069 | 586073 | +4 | +0 | +| RAK_3112_repeater | ESP32 | SX1262 | 1165825 | 1165829 | +4 | +0 | +| RAK_3112_repeater_bridge_espnow | ESP32 | SX1262 | 1176329 | 1176333 | +4 | +0 | +| RAK_3112_repeater_bridge_rs232 | ESP32 | SX1262 | 1168325 | 1168329 | +4 | +0 | +| RAK_3112_room_server | ESP32 | SX1262 | 1161381 | 1161341 | -40 | +0 | +| RAK_3112_sensor | ESP32 | SX1262 | 1172769 | 1172777 | +8 | +0 | +| RAK_3112_terminal_chat | ESP32 | SX1262 | 607189 | 607193 | +4 | +0 | +| RAK_3401_companion_radio_ble | nRF52 | SX1262 | 432956 | 432956 | +0 | +0 | +| RAK_3401_companion_radio_usb | nRF52 | SX1262 | 423948 | 423948 | +0 | +0 | +| RAK_3401_kiss_modem | nRF52 | SX1262 | 340928 | 340928 | +0 | +0 | +| RAK_3401_repeater | nRF52 | SX1262 | 411040 | 411040 | +0 | +0 | +| RAK_3401_room_server | nRF52 | SX1262 | 404192 | 404192 | +0 | +0 | +| RAK_3401_sensor | nRF52 | SX1262 | 407888 | 407888 | +0 | +0 | +| RAK_3401_terminal_chat | nRF52 | SX1262 | 363424 | 363424 | +0 | +0 | +| RAK_3x72_companion_radio_usb | STM32 | STM32WLx | 197544 | 197544 | +0 | +0 | +| RAK_3x72_kiss_modem | STM32 | STM32WLx | 143164 | 143164 | +0 | +0 | +| RAK_3x72_repeater | STM32 | STM32WLx | 196776 | 196776 | +0 | +0 | +| RAK_3x72_sensor | STM32 | STM32WLx | 196376 | 196376 | +0 | +0 | +| RAK_4631_companion_radio_ble | nRF52 | SX1262 | 464212 | 464212 | +0 | +0 | +| RAK_4631_companion_radio_ethernet | nRF52 | SX1262 | 469752 | 469752 | +0 | +0 | +| RAK_4631_companion_radio_usb | nRF52 | SX1262 | 455188 | 455188 | +0 | +0 | +| RAK_4631_kiss_modem | nRF52 | SX1262 | 371976 | 371976 | +0 | +0 | +| RAK_4631_repeater | nRF52 | SX1262 | 441944 | 441944 | +0 | +0 | +| RAK_4631_repeater_bridge_rs232_serial1 | nRF52 | SX1262 | 408052 | 408052 | +0 | +0 | +| RAK_4631_repeater_bridge_rs232_serial2 | nRF52 | SX1262 | 408052 | 408052 | +0 | +0 | +| RAK_4631_repeater_ethernet | nRF52 | SX1262 | 459588 | 459588 | +0 | +0 | +| RAK_4631_room_server | nRF52 | SX1262 | 435096 | 435096 | +0 | +0 | +| RAK_4631_room_server_ethernet | nRF52 | SX1262 | 452732 | 452732 | +0 | +0 | +| RAK_4631_sensor | nRF52 | SX1262 | 438776 | 438776 | +0 | +0 | +| RAK_4631_terminal_chat | nRF52 | SX1262 | 394376 | 394376 | +0 | +0 | +| RAK_WisMesh_Tag_companion_radio_ble | nRF52 | SX1262 | 385640 | 385640 | +0 | +0 | +| RAK_WisMesh_Tag_companion_radio_usb | nRF52 | SX1262 | 378216 | 378216 | +0 | +0 | +| RAK_WisMesh_Tag_kiss_modem | nRF52 | SX1262 | 312180 | 312180 | +0 | +0 | +| RAK_WisMesh_Tag_repeater | nRF52 | SX1262 | 369076 | 369076 | +0 | +0 | +| RAK_WisMesh_Tag_room_server | nRF52 | SX1262 | 361548 | 361548 | +0 | +0 | +| RAK_WisMesh_Tag_sensor | nRF52 | SX1262 | 365232 | 365232 | +0 | +0 | +| SenseCap_Solar_companion_radio_ble | nRF52 | SX1262 | 374080 | 374080 | +0 | +0 | +| SenseCap_Solar_companion_radio_usb | nRF52 | SX1262 | 368944 | 368944 | +0 | +0 | +| SenseCap_Solar_kiss_modem | nRF52 | SX1262 | 311028 | 311028 | +0 | +0 | +| SenseCap_Solar_repeater | nRF52 | SX1262 | 366108 | 366108 | +0 | +0 | +| SenseCap_Solar_room_server | nRF52 | SX1262 | 359372 | 359372 | +0 | +0 | +| Station_G2_companion_radio_usb | ESP32 | SX1262 | 714561 | 714565 | +4 | +0 | +| Station_G2_companion_radio_wifi | ESP32 | SX1262 | 1139593 | 1139597 | +4 | +0 | +| Station_G2_kiss_modem | ESP32 | SX1262 | 598645 | 598649 | +4 | +0 | +| Station_G2_logging_repeater | ESP32 | SX1262 | 1207905 | 1207909 | +4 | +0 | +| Station_G2_logging_repeater_bridge_espnow | ESP32 | SX1262 | 1218361 | 1218365 | +4 | +0 | +| Station_G2_repeater | ESP32 | SX1262 | 1207881 | 1207885 | +4 | +0 | +| Station_G2_repeater_bridge_espnow | ESP32 | SX1262 | 1218337 | 1218341 | +4 | +0 | +| Station_G2_room_server | ESP32 | SX1262 | 1203065 | 1203069 | +4 | +0 | +| Station_G3_ESP32_companion_radio_usb | ESP32 | SX1262 | 716137 | 716141 | +4 | +0 | +| Station_G3_ESP32_companion_radio_wifi | ESP32 | SX1262 | 1141217 | 1141221 | +4 | +0 | +| Station_G3_ESP32_logging_repeater | ESP32 | SX1262 | 1209505 | 1209509 | +4 | +0 | +| Station_G3_ESP32_repeater | ESP32 | SX1262 | 1209465 | 1209469 | +4 | +0 | +| Station_G3_ESP32_room_server | ESP32 | SX1262 | 1204677 | 1204681 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_companion_radio_ble | ESP32 | SX1262 | 1262721 | 1262725 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_companion_radio_wifi | ESP32 | SX1262 | 1084233 | 1084241 | +8 | +0 | +| T_Beam_S3_Supreme_SX1262_kiss_modem | ESP32 | SX1262 | 570317 | 570321 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_repeater | ESP32 | SX1262 | 1155513 | 1155517 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_repeater_bridge_espnow | ESP32 | SX1262 | 1165997 | 1166001 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_room_server | ESP32 | SX1262 | 1150713 | 1150717 | +4 | +0 | +| Tbeam_SX1262_companion_radio_ble | ESP32 | SX1262 | 1483501 | 1483501 | +0 | +0 | +| Tbeam_SX1262_kiss_modem | ESP32 | SX1262 | 567437 | 567437 | +0 | +0 | +| Tbeam_SX1262_repeater | ESP32 | SX1262 | 1205213 | 1205213 | +0 | +0 | +| Tbeam_SX1262_repeater_bridge_espnow | ESP32 | SX1262 | 1216165 | 1216165 | +0 | +0 | +| Tbeam_SX1262_room_server | ESP32 | SX1262 | 1200065 | 1200065 | +0 | +0 | +| Tenstar_C3_sx1262_kiss_modem | ESP32 | SX1262 | 497480 | 497480 | +0 | +0 | +| Tenstar_C3_sx1262_repeater | ESP32 | SX1262 | 1152392 | 1152392 | +0 | +0 | +| Tenstar_C3_sx1268_kiss_modem | ESP32 | SX1268 | 497412 | 497412 | +0 | +0 | +| Tenstar_C3_sx1268_repeater | ESP32 | SX1268 | 1152294 | 1152294 | +0 | +0 | +| ThinkNode_M1_companion_radio_ble | nRF52 | SX1262 | 364572 | 364572 | +0 | +0 | +| ThinkNode_M1_companion_radio_usb | nRF52 | SX1262 | 353788 | 353788 | +0 | +0 | +| ThinkNode_M1_kiss_modem | nRF52 | SX1262 | 249444 | 249444 | +0 | +0 | +| ThinkNode_M1_repeater | nRF52 | SX1262 | 306180 | 306180 | +0 | +0 | +| ThinkNode_M1_room_server | nRF52 | SX1262 | 299636 | 299636 | +0 | +0 | +| ThinkNode_M2_Repeater | ESP32 | SX1262 | 1110177 | 1110181 | +4 | +0 | +| ThinkNode_M2_Repeater_bridge_espnow | ESP32 | SX1262 | 1120505 | 1120509 | +4 | +0 | +| ThinkNode_M2_companion_radio_ble | ESP32 | SX1262 | 1218593 | 1218597 | +4 | +0 | +| ThinkNode_M2_companion_radio_serial | ESP32 | SX1262 | 615829 | 615833 | +4 | +0 | +| ThinkNode_M2_companion_radio_usb | ESP32 | SX1262 | 615753 | 615757 | +4 | +0 | +| ThinkNode_M2_companion_radio_wifi | ESP32 | SX1262 | 1043577 | 1043581 | +4 | +0 | +| ThinkNode_M2_kiss_modem | ESP32 | SX1262 | 520613 | 520617 | +4 | +0 | +| ThinkNode_M2_room_server | ESP32 | SX1262 | 1105469 | 1105477 | +8 | +0 | +| ThinkNode_M2_terminal_chat | ESP32 | SX1262 | 548565 | 548569 | +4 | +0 | +| ThinkNode_M5_Repeater | ESP32 | SX1262 | 1127461 | 1127465 | +4 | +0 | +| ThinkNode_M5_Repeater_bridge_espnow | ESP32 | SX1262 | 1137965 | 1137969 | +4 | +0 | +| ThinkNode_M5_companion_radio_ble | ESP32 | SX1262 | 1236461 | 1236465 | +4 | +0 | +| ThinkNode_M5_companion_radio_usb | ESP32 | SX1262 | 633545 | 633549 | +4 | +0 | +| ThinkNode_M5_companion_radio_wifi | ESP32 | SX1262 | 1061209 | 1061213 | +4 | +0 | +| ThinkNode_M5_kiss_modem | ESP32 | SX1262 | 538409 | 538413 | +4 | +0 | +| ThinkNode_M5_room_server | ESP32 | SX1262 | 1122809 | 1122813 | +4 | +0 | +| ThinkNode_M5_terminal_chat | ESP32 | SX1262 | 566681 | 566685 | +4 | +0 | +| ThinkNode_M6_companion_radio_ble | nRF52 | SX1262 | 376464 | 376464 | +0 | +0 | +| ThinkNode_M6_companion_radio_usb | nRF52 | SX1262 | 368432 | 368432 | +0 | +0 | +| ThinkNode_M6_kiss_modem | nRF52 | SX1262 | 311444 | 311444 | +0 | +0 | +| ThinkNode_M6_repeater | nRF52 | SX1262 | 366212 | 366212 | +0 | +0 | +| ThinkNode_M6_room_server | nRF52 | SX1262 | 359676 | 359676 | +0 | +0 | +| ThinkNode_M8_companion_radio_ble | nRF52 | SX1262 | 370108 | 370124 | +16 | +0 | +| ThinkNode_M8_companion_radio_usb | nRF52 | SX1262 | 345896 | 345896 | +0 | +0 | +| Tiny_Relay_companion_radio_usb | STM32 | STM32WLx | 197552 | 197552 | +0 | +0 | +| Tiny_Relay_kiss_modem | STM32 | STM32WLx | 143164 | 143164 | +0 | +0 | +| Tiny_Relay_repeater | STM32 | STM32WLx | 196784 | 196784 | +0 | +0 | +| Tiny_Relay_sensor | STM32 | STM32WLx | 196376 | 196376 | +0 | +0 | +| WHY2025_badge_companion_radio_ble_ | ESP32 | SX1262 | 1444887 | 1444887 | +0 | +0 | +| WHY2025_badge_repeater_ | ESP32 | SX1262 | 542437 | 542435 | -2 | +0 | +| Waveshare_S3_LR1121_repeater | ESP32 | LR1121 | 1084869 | 1084869 | +0 | +0 | +| WioTrackerL1-1W_companion_radio_ble | nRF52 | SX1262 | 407772 | 407772 | +0 | +0 | +| WioTrackerL1-1W_companion_radio_usb | nRF52 | SX1262 | 398092 | 398092 | +0 | +0 | +| WioTrackerL1-1W_kiss_modem | nRF52 | SX1262 | 312084 | 312084 | +0 | +0 | +| WioTrackerL1-1W_repeater | nRF52 | SX1262 | 378716 | 378716 | +0 | +0 | +| WioTrackerL1-1W_room_server | nRF52 | SX1262 | 371812 | 371812 | +0 | +0 | +| WioTrackerL1Eink_companion_radio_ble | nRF52 | SX1262 | 424180 | 424180 | +0 | +0 | +| WioTrackerL1Eink_kiss_modem | nRF52 | SX1262 | 311236 | 311236 | +0 | +0 | +| WioTrackerL1_companion_radio_ble | nRF52 | SX1262 | 406716 | 406716 | +0 | +0 | +| WioTrackerL1_companion_radio_usb | nRF52 | SX1262 | 397036 | 397036 | +0 | +0 | +| WioTrackerL1_kiss_modem | nRF52 | SX1262 | 311140 | 311140 | +0 | +0 | +| WioTrackerL1_repeater | nRF52 | SX1262 | 377724 | 377724 | +0 | +0 | +| WioTrackerL1_room_server | nRF52 | SX1262 | 370804 | 370804 | +0 | +0 | +| Xiao_C3_companion_radio_ble | ESP32 | SX1262 | 1255482 | 1255482 | +0 | +0 | +| Xiao_C3_companion_radio_usb | ESP32 | SX1262 | 543422 | 543422 | +0 | +0 | +| Xiao_C3_companion_radio_wifi | ESP32 | SX1262 | 1010858 | 1010858 | +0 | +0 | +| Xiao_C3_kiss_modem | ESP32 | SX1262 | 473692 | 473692 | +0 | +0 | +| Xiao_C3_repeater | ESP32 | SX1262 | 1158048 | 1158046 | -2 | +0 | +| Xiao_C3_room_server | ESP32 | SX1262 | 1152902 | 1152902 | +0 | +0 | +| Xiao_C6_companion_radio_ble_ | ESP32 | SX1262 | 1444931 | 1444931 | +0 | +0 | +| Xiao_C6_kiss_modem | ESP32 | SX1262 | 472835 | 472835 | +0 | +0 | +| Xiao_C6_repeater_ | ESP32 | SX1262 | 542481 | 542479 | -2 | +0 | +| Xiao_S3_WIO_companion_radio_ble | ESP32 | SX1262 | 1642437 | 1642437 | +0 | +0 | +| Xiao_S3_WIO_companion_radio_serial | ESP32 | SX1262 | 671473 | 671473 | +0 | +0 | +| Xiao_S3_WIO_companion_radio_usb | ESP32 | SX1262 | 652653 | 652653 | +0 | +0 | +| Xiao_S3_WIO_companion_radio_wifi | ESP32 | SX1262 | 1054393 | 1054393 | +0 | +0 | +| Xiao_S3_WIO_kiss_modem | ESP32 | SX1262 | 549837 | 549837 | +0 | +0 | +| Xiao_S3_WIO_repeater | ESP32 | SX1262 | 1127969 | 1127969 | +0 | +0 | +| Xiao_S3_WIO_repeater_bridge_espnow | ESP32 | SX1262 | 1138525 | 1138525 | +0 | +0 | +| Xiao_S3_WIO_room_server | ESP32 | SX1262 | 1123353 | 1123353 | +0 | +0 | +| Xiao_S3_WIO_sensor | ESP32 | SX1262 | 1134745 | 1134745 | +0 | +0 | +| Xiao_S3_WIO_terminal_chat | ESP32 | SX1262 | 571045 | 571045 | +0 | +0 | +| Xiao_S3_companion_radio_ble | ESP32 | SX1262 | 1645153 | 1645153 | +0 | +0 | +| Xiao_S3_companion_radio_usb | ESP32 | SX1262 | 655381 | 655381 | +0 | +0 | +| Xiao_S3_kiss_modem | ESP32 | SX1262 | 559569 | 559569 | +0 | +0 | +| Xiao_S3_repeater | ESP32 | SX1262 | 1137605 | 1137605 | +0 | +0 | +| Xiao_S3_repeater_bridge_espnow | ESP32 | SX1262 | 1148121 | 1148121 | +0 | +0 | +| Xiao_S3_room_server | ESP32 | SX1262 | 1132949 | 1132949 | +0 | +0 | +| Xiao_S3_sensor | ESP32 | SX1262 | 1144397 | 1144397 | +0 | +0 | +| Xiao_nrf52_companion_radio_ble | nRF52 | SX1262 | 379608 | 379608 | +0 | +0 | +| Xiao_nrf52_companion_radio_usb | nRF52 | SX1262 | 373912 | 373912 | +0 | +0 | +| Xiao_nrf52_kiss_modem | nRF52 | SX1262 | 305840 | 305840 | +0 | +0 | +| Xiao_nrf52_repeater | nRF52 | SX1262 | 361800 | 361800 | +0 | +0 | +| Xiao_nrf52_room_server | nRF52 | SX1262 | 354264 | 354264 | +0 | +0 | +| Xiao_rp2040_companion_radio_usb | RP2040 | SX1262 | 238412 | 238412 | +0 | +0 | +| Xiao_rp2040_kiss_modem | RP2040 | SX1262 | 182708 | 182708 | +0 | +0 | +| Xiao_rp2040_repeater | RP2040 | SX1262 | 235796 | 235796 | +0 | +0 | +| Xiao_rp2040_room_server | RP2040 | SX1262 | 231740 | 231740 | +0 | +0 | +| Xiao_rp2040_terminal_chat | RP2040 | SX1262 | 212284 | 212284 | +0 | +0 | +| heltec_rc32_companion_radio_ble | ESP32 | SX1262 | 1280369 | 1280373 | +4 | +0 | +| heltec_rc32_companion_radio_usb | ESP32 | SX1262 | 675521 | 675525 | +4 | +0 | +| heltec_rc32_companion_radio_wifi | ESP32 | SX1262 | 1103413 | 1103417 | +4 | +0 | +| heltec_rc32_kiss_modem | ESP32 | SX1262 | 582193 | 582197 | +4 | +0 | +| heltec_rc32_repeater | ESP32 | SX1262 | 1169065 | 1169069 | +4 | +0 | +| heltec_rc32_repeater_bridge_espnow | ESP32 | SX1262 | 1179561 | 1179565 | +4 | +0 | +| heltec_rc32_room_server | ESP32 | SX1262 | 1164397 | 1164401 | +4 | +0 | +| heltec_rc32_sensor | ESP32 | SX1262 | 1175809 | 1175813 | +4 | +0 | +| heltec_rc32_terminal_chat | ESP32 | SX1262 | 603281 | 603285 | +4 | +0 | +| heltec_rc32_without_display_companion_radio_ble | ESP32 | SX1262 | 1275521 | 1275525 | +4 | +0 | +| heltec_rc32_without_display_companion_radio_usb | ESP32 | SX1262 | 670313 | 670317 | +4 | +0 | +| heltec_rc32_without_display_companion_radio_wifi | ESP32 | SX1262 | 1098265 | 1098269 | +4 | +0 | +| heltec_rc32_without_display_repeater | ESP32 | SX1262 | 1162165 | 1162169 | +4 | +0 | +| heltec_rc32_without_display_repeater_bridge_espnow | ESP32 | SX1262 | 1172541 | 1172545 | +4 | +0 | +| heltec_rc32_without_display_room_server | ESP32 | SX1262 | 1157581 | 1157585 | +4 | +0 | +| heltec_rc32_without_display_sensor | ESP32 | SX1262 | 1169053 | 1169053 | +0 | +0 | +| heltec_tracker_v2_companion_radio_ble | ESP32 | SX1262 | 1337821 | 1337825 | +4 | +0 | +| heltec_tracker_v2_companion_radio_usb | ESP32 | SX1262 | 711273 | 711277 | +4 | +0 | +| heltec_tracker_v2_companion_radio_wifi | ESP32 | SX1262 | 1157789 | 1157793 | +4 | +0 | +| heltec_tracker_v2_repeater | ESP32 | SX1262 | 1226253 | 1226257 | +4 | +0 | +| heltec_tracker_v2_repeater_bridge_espnow | ESP32 | SX1262 | 1236709 | 1236713 | +4 | +0 | +| heltec_tracker_v2_room_server | ESP32 | SX1262 | 1221501 | 1221505 | +4 | +0 | +| heltec_tracker_v2_sensor | ESP32 | SX1262 | 1232873 | 1232881 | +8 | +0 | +| heltec_tracker_v2_terminal_chat | ESP32 | SX1262 | 628189 | 628193 | +4 | +0 | +| heltec_v4_companion_radio_ble | ESP32 | SX1262 | 1668529 | 1668533 | +4 | +0 | +| heltec_v4_companion_radio_usb | ESP32 | SX1262 | 655233 | 655237 | +4 | +0 | +| heltec_v4_companion_radio_wifi | ESP32 | SX1262 | 1104737 | 1104741 | +4 | +0 | +| heltec_v4_expansionkit_repeater | ESP32 | SX1262 | 1173185 | 1173189 | +4 | +0 | +| heltec_v4_kiss_modem | ESP32 | SX1262 | 579641 | 579645 | +4 | +0 | +| heltec_v4_r8_companion_radio_ble | ESP32 | SX1262 | 1672049 | 1672053 | +4 | +0 | +| heltec_v4_r8_companion_radio_usb | ESP32 | SX1262 | 658709 | 658713 | +4 | +0 | +| heltec_v4_r8_companion_radio_wifi | ESP32 | SX1262 | 1108197 | 1108201 | +4 | +0 | +| heltec_v4_r8_kiss_modem | ESP32 | SX1262 | 583085 | 583089 | +4 | +0 | +| heltec_v4_r8_repeater | ESP32 | SX1262 | 1176625 | 1176629 | +4 | +0 | +| heltec_v4_r8_room_server | ESP32 | SX1262 | 1171909 | 1171913 | +4 | +0 | +| heltec_v4_r8_sensor | ESP32 | SX1262 | 1183241 | 1183245 | +4 | +0 | +| heltec_v4_r8_terminal_chat | ESP32 | SX1262 | 604185 | 604189 | +4 | +0 | +| heltec_v4_r8_tft_companion_radio_ble | ESP32 | SX1262 | 1289081 | 1289085 | +4 | +0 | +| heltec_v4_r8_tft_companion_radio_usb | ESP32 | SX1262 | 662557 | 662561 | +4 | +0 | +| heltec_v4_r8_tft_companion_radio_wifi | ESP32 | SX1262 | 1112049 | 1112053 | +4 | +0 | +| heltec_v4_r8_tft_kiss_modem | ESP32 | SX1262 | 583477 | 583481 | +4 | +0 | +| heltec_v4_r8_tft_repeater | ESP32 | SX1262 | 1178733 | 1178737 | +4 | +0 | +| heltec_v4_r8_tft_room_server | ESP32 | SX1262 | 1173917 | 1173921 | +4 | +0 | +| heltec_v4_r8_tft_sensor | ESP32 | SX1262 | 1185293 | 1185297 | +4 | +0 | +| heltec_v4_r8_tft_terminal_chat | ESP32 | SX1262 | 604573 | 604577 | +4 | +0 | +| heltec_v4_repeater | ESP32 | SX1262 | 1173133 | 1173137 | +4 | +0 | +| heltec_v4_repeater_bridge_espnow | ESP32 | SX1262 | 1183669 | 1183673 | +4 | +0 | +| heltec_v4_room_server | ESP32 | SX1262 | 1168417 | 1168421 | +4 | +0 | +| heltec_v4_sensor | ESP32 | SX1262 | 1179781 | 1179785 | +4 | +0 | +| heltec_v4_terminal_chat | ESP32 | SX1262 | 600733 | 600737 | +4 | +0 | +| heltec_v4_tft_companion_radio_ble | ESP32 | SX1262 | 1283401 | 1283405 | +4 | +0 | +| heltec_v4_tft_companion_radio_usb | ESP32 | SX1262 | 656917 | 656921 | +4 | +0 | +| heltec_v4_tft_companion_radio_wifi | ESP32 | SX1262 | 1106325 | 1106329 | +4 | +0 | +| heltec_v4_tft_repeater | ESP32 | SX1262 | 1174865 | 1174869 | +4 | +0 | +| heltec_v4_tft_repeater_bridge_espnow | ESP32 | SX1262 | 1185345 | 1185349 | +4 | +0 | +| heltec_v4_tft_room_server | ESP32 | SX1262 | 1170029 | 1170013 | -16 | +0 | +| heltec_v4_tft_sensor | ESP32 | SX1262 | 1181433 | 1181441 | +8 | +0 | +| heltec_v4_tft_terminal_chat | ESP32 | SX1262 | 600733 | 600737 | +4 | +0 | +| ikoka_handheld_nrf_e22_30dbm_096_companion_radio_ble | nRF52 | SX1262 | 397616 | 397616 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_096_companion_radio_usb | nRF52 | SX1262 | 390224 | 390224 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_ble | nRF52 | SX1262 | 397664 | 397664 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_usb | nRF52 | SX1262 | 390288 | 390288 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_repeater | nRF52 | SX1262 | 358296 | 358296 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_room_server | nRF52 | SX1262 | 351752 | 351752 | +0 | +0 | +| ikoka_handheld_nrf_kiss_modem | nRF52 | SX1262 | 304272 | 304272 | +0 | +0 | +| ikoka_nano_nrf_22dbm_companion_radio_ble | nRF52 | SX1262 | 386752 | 386752 | +0 | +0 | +| ikoka_nano_nrf_22dbm_companion_radio_usb | nRF52 | SX1262 | 379824 | 379824 | +0 | +0 | +| ikoka_nano_nrf_22dbm_kiss_modem | nRF52 | SX1262 | 305264 | 305264 | +0 | +0 | +| ikoka_nano_nrf_22dbm_repeater | nRF52 | SX1262 | 361176 | 361176 | +0 | +0 | +| ikoka_nano_nrf_22dbm_room_server | nRF52 | SX1262 | 353720 | 353720 | +0 | +0 | +| ikoka_nano_nrf_30dbm_companion_radio_ble | nRF52 | SX1262 | 386752 | 386752 | +0 | +0 | +| ikoka_nano_nrf_30dbm_companion_radio_usb | nRF52 | SX1262 | 379824 | 379824 | +0 | +0 | +| ikoka_nano_nrf_30dbm_kiss_modem | nRF52 | SX1262 | 305264 | 305264 | +0 | +0 | +| ikoka_nano_nrf_30dbm_repeater | nRF52 | SX1262 | 361176 | 361176 | +0 | +0 | +| ikoka_nano_nrf_30dbm_room_server | nRF52 | SX1262 | 353720 | 353720 | +0 | +0 | +| ikoka_nano_nrf_33dbm_companion_radio_ble | nRF52 | SX1262 | 386752 | 386752 | +0 | +0 | +| ikoka_nano_nrf_33dbm_companion_radio_usb | nRF52 | SX1262 | 379824 | 379824 | +0 | +0 | +| ikoka_nano_nrf_33dbm_kiss_modem | nRF52 | SX1262 | 305264 | 305264 | +0 | +0 | +| ikoka_nano_nrf_33dbm_repeater | nRF52 | SX1262 | 361176 | 361176 | +0 | +0 | +| ikoka_nano_nrf_33dbm_room_server | nRF52 | SX1262 | 353720 | 353720 | +0 | +0 | +| ikoka_stick_nrf_22dbm_companion_radio_ble | nRF52 | SX1262 | 397680 | 397680 | +0 | +0 | +| ikoka_stick_nrf_22dbm_companion_radio_usb | nRF52 | SX1262 | 390880 | 390880 | +0 | +0 | +| ikoka_stick_nrf_22dbm_kiss_modem | nRF52 | SX1262 | 318112 | 318112 | +0 | +0 | +| ikoka_stick_nrf_22dbm_repeater | nRF52 | SX1262 | 373768 | 373768 | +0 | +0 | +| ikoka_stick_nrf_22dbm_room_server | nRF52 | SX1262 | 366816 | 366816 | +0 | +0 | +| ikoka_stick_nrf_30dbm_companion_radio_ble | nRF52 | SX1262 | 397680 | 397680 | +0 | +0 | +| ikoka_stick_nrf_30dbm_companion_radio_usb | nRF52 | SX1262 | 390880 | 390880 | +0 | +0 | +| ikoka_stick_nrf_30dbm_kiss_modem | nRF52 | SX1262 | 318112 | 318112 | +0 | +0 | +| ikoka_stick_nrf_30dbm_repeater | nRF52 | SX1262 | 373768 | 373768 | +0 | +0 | +| ikoka_stick_nrf_30dbm_room_server | nRF52 | SX1262 | 366816 | 366816 | +0 | +0 | +| ikoka_stick_nrf_33dbm_companion_radio_ble | nRF52 | SX1262 | 397680 | 397680 | +0 | +0 | +| ikoka_stick_nrf_33dbm_companion_radio_usb | nRF52 | SX1262 | 390880 | 390880 | +0 | +0 | +| ikoka_stick_nrf_33dbm_kiss_modem | nRF52 | SX1262 | 318112 | 318112 | +0 | +0 | +| ikoka_stick_nrf_33dbm_repeater | nRF52 | SX1262 | 373768 | 373768 | +0 | +0 | +| ikoka_stick_nrf_33dbm_room_server | nRF52 | SX1262 | 366816 | 366816 | +0 | +0 | +| meshnology_w12_repeater | ESP32 | LR2021 | 1183449 | 1183449 | +0 | +0 | +| muzi_base_uno_companion_radio_ble | nRF52 | SX1262 | 330220 | 330220 | +0 | +0 | +| muzi_base_uno_companion_radio_usb | nRF52 | SX1262 | 325180 | 325180 | +0 | +0 | +| muzi_base_uno_repeater | nRF52 | SX1262 | 301236 | 301236 | +0 | +0 | +| muzi_base_uno_room_server | nRF52 | SX1262 | 294724 | 294724 | +0 | +0 | +| muzi_base_uno_superIO_companion_radio_ble | nRF52 | SX1262 | 349280 | 349280 | +0 | +0 | +| nibble_screen_connect_companion_radio_ble_ | ESP32 | SX1262 | 1210293 | 1210293 | +0 | +0 | +| nibble_screen_connect_companion_radio_usb_ | ESP32 | SX1262 | 584941 | 584941 | +0 | +0 | +| nibble_screen_connect_companion_radio_wifi_ | ESP32 | SX1262 | 1033905 | 1033905 | +0 | +0 | +| nibble_screen_connect_kiss_modem_ | ESP32 | SX1262 | 495625 | 495625 | +0 | +0 | +| nibble_screen_connect_repeater_ | ESP32 | SX1262 | 1103205 | 1103205 | +0 | +0 | +| nibble_screen_connect_repeater_bridge_espnow_ | ESP32 | SX1262 | 1113529 | 1113529 | +0 | +0 | +| nibble_screen_connect_room_server_ | ESP32 | SX1262 | 1098497 | 1098497 | +0 | +0 | +| nibble_screen_connect_terminal_chat_ | ESP32 | SX1262 | 523457 | 523457 | +0 | +0 | +| nibble_zero_connect_companion_radio_ble_ | ESP32 | SX1262 | 1210537 | 1210537 | +0 | +0 | +| nibble_zero_connect_companion_radio_usb_ | ESP32 | SX1262 | 585149 | 585149 | +0 | +0 | +| nibble_zero_connect_companion_radio_wifi_ | ESP32 | SX1262 | 1034133 | 1034133 | +0 | +0 | +| nibble_zero_connect_repeater_ | ESP32 | SX1262 | 1103309 | 1103309 | +0 | +0 | +| nibble_zero_connect_repeater_bridge_espnow_ | ESP32 | SX1262 | 1113613 | 1113613 | +0 | +0 | +| nibble_zero_connect_room_server_ | ESP32 | SX1262 | 1098589 | 1098589 | +0 | +0 | +| nibble_zero_connect_terminal_chat_ | ESP32 | SX1262 | 523537 | 523537 | +0 | +0 | +| t1000e_repeater | nRF52 | LR1110 | 294712 | 294712 | +0 | +0 | +| waveshare_rp2040_lora_companion_radio_usb | RP2040 | SX1262 | 239212 | 239212 | +0 | +0 | +| waveshare_rp2040_lora_kiss_modem | RP2040 | SX1262 | 183500 | 183500 | +0 | +0 | +| waveshare_rp2040_lora_repeater | RP2040 | SX1262 | 236596 | 236596 | +0 | +0 | +| waveshare_rp2040_lora_repeater_bridge_rs232 | RP2040 | SX1262 | 241080 | 241080 | +0 | +0 | +| waveshare_rp2040_lora_room_server | RP2040 | SX1262 | 232548 | 232548 | +0 | +0 | +| waveshare_rp2040_lora_terminal_chat | RP2040 | SX1262 | 213084 | 213084 | +0 | +0 | +| wio-e5-mini_companion_radio_usb | STM32 | STM32WLx | 209016 | 209016 | +0 | +0 | +| wio-e5-mini_kiss_modem | STM32 | STM32WLx | 151208 | 151208 | +0 | +0 | +| wio-e5-mini_repeater | STM32 | STM32WLx | 204136 | 204136 | +0 | +0 | +| wio-e5-mini_sensor | STM32 | STM32WLx | 203664 | 203664 | +0 | +0 | +| wio-e5-repeater_bridge_rs232 | STM32 | STM32WLx | 199064 | 199064 | +0 | +0 | +| wio-e5_companion_radio_usb | STM32 | STM32WLx | 197688 | 197688 | +0 | +0 | +| wio-e5_kiss_modem | STM32 | STM32WLx | 143248 | 143248 | +0 | +0 | +| wio-e5_repeater | STM32 | STM32WLx | 196848 | 196848 | +0 | +0 | + +
+ +### What was not verified on hardware + +No board test for 9b. The release images of SX1262 and SX1268 boards run the same +`std_init()` calls in the same order as before (host suites), and differ in flash +by the few bytes above; the debug build output is pinned on the host. LLCC68 has no +firmware environment and is covered by the host suites only. diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md new file mode 100644 index 00000000000..d2842db9388 --- /dev/null +++ b/docs/refactoring/0010-companion-command-table.md @@ -0,0 +1,955 @@ +# 0010: A command table for the companion protocol + +Status: steps 10a (host harness and characterization), 10b (the table) and 10c +(a bounded frame reader) ready for review; 10d is a proposal. +Behaviour change: none. + +## Context + +The companion firmware speaks to the app (BLE, USB serial, WiFi) in frames of up +to `MAX_FRAME_SIZE` (176) bytes. The first byte selects the command. +`MyMesh::handleCmdFrame()` (`examples/companion_radio/MyMesh.cpp`, about 1,020 +lines) is one chain of 62 branches of the form + +```c +} else if (cmd_frame[0] == CMD_X && len >= N && ) { + ... reads cmd_frame[i] at fixed offsets ... +``` + +and a final `else` that answers `ERR_CODE_UNSUPPORTED_CMD`. Each branch states +its own minimum length, or forgets to. Two fixes in this fork came from exactly +that: `docs/fixes/companion-channel-txt-min-len.md` (a 3 byte frame stopped the +node) and `docs/fixes/companion-cmd-frame-min-len.md` (fourteen branches acted on +the previous frame's bytes). The architecture review lists the function as F5. + +Three things make the function expensive to extend: + +- The minimum length of a command is not data. Nothing can list, test or check + the minimums, and a new branch can leave its own out. +- The command codes are only reachable through the chain, so the protocol cannot + be enumerated (for documentation, a test sweep or a client generator). +- The branches cannot be called on their own, so none of it was testable on the + host until this step. + +## Inventory + +Every code the chain handles, read from the source on `integration` (which has +both length fixes) and checked against the frame sweep of step 10a. "Short" is +what a frame below the minimum gets today. All lengths include the code byte. + +| Code | Command | Min | Further condition | Reply | Short | +|---:|---|---:|---|---|---| +| 1 | `APP_START` | 8 | | `SELF_INFO` | unsupported | +| 2 | `SEND_TXT_MSG` | 14 | | `SENT`, or `NOT_FOUND`, `UNSUPPORTED` for a txt type | unsupported | +| 3 | `SEND_CHANNEL_TXT_MSG` | 7 | | OK, `NOT_FOUND`, `UNSUPPORTED` for a txt type | unsupported | +| 4 | `GET_CONTACTS` | 1 | `since` from 5 bytes | `CONTACTS_START`, then one `CONTACT` per loop, then `END_OF_CONTACTS` | | +| 5 | `GET_DEVICE_TIME` | 1 | | `CURR_TIME` | | +| 6 | `SET_DEVICE_TIME` | 5 | | OK, `ILLEGAL_ARG` if earlier | unsupported | +| 7 | `SEND_SELF_ADVERT` | 1 | flood if byte 1 is 1 | OK, `TABLE_FULL` | | +| 8 | `SET_ADVERT_NAME` | 2 | | OK | unsupported | +| 9 | `ADD_UPDATE_CONTACT` | 136 | lat, lon, lastmod optional | OK, `TABLE_FULL` | unsupported | +| 10 | `SYNC_NEXT_MESSAGE` | 1 | | queued frame or `NO_MORE_MESSAGES` | | +| 11 | `SET_RADIO_PARAMS` | 11 | repeat flag optional | OK, `ILLEGAL_ARG` | unsupported | +| 12 | `SET_RADIO_TX_POWER` | 2 | | OK, `ILLEGAL_ARG` | unsupported | +| 13 | `RESET_PATH` | 33 | | OK, `NOT_FOUND` | unsupported | +| 14 | `SET_ADVERT_LATLON` | 9 | altitude optional, ignored | OK, `ILLEGAL_ARG` | unsupported | +| 15 | `REMOVE_CONTACT` | 33 | | OK, `NOT_FOUND` | unsupported | +| 16 | `SHARE_CONTACT` | 33 | | OK, `TABLE_FULL`, `NOT_FOUND` | unsupported | +| 17 | `EXPORT_CONTACT` | 1 | below 33 exports self | `EXPORT_CONTACT`, `NOT_FOUND`, `TABLE_FULL` | | +| 18 | `IMPORT_CONTACT` | 99 | | OK, `ILLEGAL_ARG` | unsupported | +| 19 | `REBOOT` | 7 | bytes 1 to 6 are `reboot` | none, the board restarts | unsupported | +| 20 | `GET_BATT_AND_STORAGE` | 1 | | `BATT_AND_STORAGE` | | +| 21 | `SET_TUNING_PARAMS` | 9 | | OK | unsupported | +| 22 | `DEVICE_QUERY` | 2 | | `DEVICE_INFO` | unsupported | +| 23 | `EXPORT_PRIVATE_KEY` | 1 | build option | `PRIVATE_KEY` or `DISABLED` | | +| 24 | `IMPORT_PRIVATE_KEY` | 65 | build option | OK, `ILLEGAL_ARG`, `FILE_IO_ERROR`, `DISABLED` | unsupported | +| 25 | `SEND_RAW_DATA` | 6 | path plus 4 payload bytes, else `ILLEGAL_ARG`; flood path unsupported | OK, `TABLE_FULL` | unsupported | +| 26 | `SEND_LOGIN` | 33 | | `SENT`, `NOT_FOUND`, `TABLE_FULL` | unsupported | +| 27 | `SEND_STATUS_REQ` | 33 | | `SENT`, `NOT_FOUND`, `TABLE_FULL` | unsupported | +| 28 | `HAS_CONNECTION` | 33 | | OK, `NOT_FOUND` | unsupported | +| 29 | `LOGOUT` | 33 | | OK | unsupported | +| 30 | `GET_CONTACT_BY_KEY` | 33 | | `CONTACT`, `NOT_FOUND` | unsupported | +| 31 | `GET_CHANNEL` | 2 | | `CHANNEL_INFO`, `NOT_FOUND` | unsupported | +| 32 | `SET_CHANNEL` | 50 | 66 and more: unsupported (256 bit secret) | OK, `NOT_FOUND` | unsupported | +| 33 | `SIGN_START` | 1 | | `SIGN_START` | | +| 34 | `SIGN_DATA` | 2 | | OK, `BAD_STATE`, `TABLE_FULL` | unsupported | +| 35 | `SIGN_FINISH` | 1 | | `SIGNATURE`, `BAD_STATE` | | +| 36 | `SEND_TRACE_PATH` | 11 | path length below `MAX_PACKET_PAYLOAD - 5`; a multiple of the hash size | `SENT`, `ILLEGAL_ARG`, `TABLE_FULL` | unsupported | +| 37 | `SET_DEVICE_PIN` | 5 | | OK, `ILLEGAL_ARG` | unsupported | +| 38 | `SET_OTHER_PARAMS` | 2 | bytes 2 to 4 optional | OK | unsupported | +| 39 | `SEND_TELEMETRY_REQ` | 4 | exactly 4: self; 36 and more: a peer; 5 to 35: unsupported | `TELEMETRY_RESPONSE` push, or `SENT`, `NOT_FOUND` | unsupported | +| 40 | `GET_CUSTOM_VARS` | 1 | | `CUSTOM_VARS` | | +| 41 | `SET_CUSTOM_VAR` | 4 | | OK, `ILLEGAL_ARG` | unsupported | +| 42 | `GET_ADVERT_PATH` | 34 | | `ADVERT_PATH`, `NOT_FOUND` | unsupported | +| 43 | `GET_TUNING_PARAMS` | 1 | | `TUNING_PARAMS` | | +| 50 | `SEND_BINARY_REQ` | 34 | | `SENT`, `NOT_FOUND`, `TABLE_FULL` | unsupported | +| 51 | `FACTORY_RESET` | 6 | bytes 1 to 5 are `reset` | OK, then restart; `FILE_IO_ERROR` | unsupported | +| 52 | `SEND_PATH_DISCOVERY_REQ` | 34 | byte 1 is 0 | `SENT`, `NOT_FOUND`, `TABLE_FULL` | unsupported | +| 54 | `SET_FLOOD_SCOPE_KEY` | 2 | byte 1: 0 sets (key from 18 bytes, else clears), 1 unscoped, others unsupported | OK | unsupported | +| 55 | `SEND_CONTROL_DATA` | 2 | high bit of byte 1 set | OK, `TABLE_FULL` | unsupported | +| 56 | `GET_STATS` | 2 | sub type 0, 1, 2; others `ILLEGAL_ARG` | `STATS` | unsupported | +| 57 | `SEND_ANON_REQ` | 34 | | `SENT`, `TABLE_FULL` | unsupported | +| 58 | `SET_AUTOADD_CONFIG` | 2 | max hops optional | OK | unsupported | +| 59 | `GET_AUTOADD_CONFIG` | 1 | | `AUTOADD_CONFIG` | | +| 60 | `GET_ALLOWED_REPEAT_FREQ` | 1 | | `ALLOWED_REPEAT_FREQ` | | +| 61 | `SET_PATH_HASH_MODE` | 3 | byte 1 is 0 | OK, `ILLEGAL_ARG` | unsupported | +| 62 | `SEND_CHANNEL_DATA` | 1 | checks its own length: below 4, or no room for `data_type` behind the path: `ILLEGAL_ARG` | OK, `NOT_FOUND`, `ILLEGAL_ARG`, `TABLE_FULL` | (`ILLEGAL_ARG`) | +| 63 | `SET_DEFAULT_FLOOD_SCOPE` | 1 | below 48 clears the scope | OK, `ILLEGAL_ARG` | | +| 64 | `GET_DEFAULT_FLOOD_SCOPE` | 1 | | `DEFAULT_FLOOD_SCOPE` | | +| 65 | `SEND_RAW_PACKET` | 4 | | OK, `ILLEGAL_ARG`, `TABLE_FULL` | unsupported | +| 66 | `RUN_CLI_COMMAND` | 3 | | `CLI_REPLY` | unsupported | + +Codes 0, 44 to 49, 53 and 67 to 255 are not commands and are answered +`ERR_CODE_UNSUPPORTED_CMD` at every length. + +Observations that the inventory makes visible, recorded and not changed: + +- "Too short" and "not a command" are the same answer for 54 codes. Only + `SEND_CHANNEL_DATA` answers `ILLEGAL_ARG`. The minimums are therefore not + observable as such by a client, which is why the table below may not change + the answer. +- Three codes split into sub commands by length or by byte 1 (39, 32, 54), and + four more refuse a frame by a byte value after the length test (19, 51, 52, + 55, 61). A refusal there is also `UNSUPPORTED`, so the table needs a way for a + handler to say "not mine" and fall through to the same answer. +- A key of 32 zero bytes is not "no contact". The contact table starts with + eight slots reserved for anonymous requests, zeroed by `resetContacts()`, and + `lookupContactByPubKey()` searches them too. `REMOVE_CONTACT` with a zero key + therefore answers OK and removes a reserved slot, eight times in a row, and + `GET_CONTACTS` then reports `num_contacts - 8`: on an empty node the count + wraps to `0xFFFFFFFF`, on the board of the hardware check it dropped from 350 + to 342. The slots return on the next boot, and the stored contacts are not + affected (`saveContacts()` skips type `ADV_TYPE_NONE`); both were checked on + the board. Pinned in `AZeroKeyNamesAReservedSlot`, not changed here. +- Senders in this workspace were checked against the minimums: the KiekR command + builders (`core-lib/src/companion/cmd.rs`), `docs/companion_protocol.md` and + `tools/refactoring/nodetool.py`. None sends below a minimum, none depends on a + fall through. Official apps and meshcore-cli are not in the workspace and were + not checked; for them the length fixes, not this step, are the change that + matters, and this step keeps their minimums byte for byte. + +## Decision + +### Step 10b: one row per command + +`MyMesh` gets a table, a private static member so its rows can name private +methods: + +```c++ +struct CmdHandler { + uint8_t code; + uint8_t min_len; // whole frame, code byte included + bool (MyMesh::*handle)(size_t len); // false: "not this command", falls through +}; +static const CmdHandler CMD_TABLE[]; +``` + +`handleCmdFrame()` becomes the dispatcher: + +```c++ +void MyMesh::handleCmdFrame(size_t len) { + for (const CmdHandler& c : CMD_TABLE) { + if (c.code != cmd_frame[0]) continue; + if (len >= c.min_len && (this->*c.handle)(len)) return; + break; + } + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); +} +``` + +Each branch body moves, unchanged apart from the outer test, into a method +`cmdAppStart(size_t len)` and so on, which returns `true`. Branches with a further +condition test it first and return `false`, which reproduces the fall through: + +```c++ +bool MyMesh::cmdSetPathHashMode(size_t) { + if (cmd_frame[1] != 0) return false; + if (cmd_frame[2] >= 3) { + ... + return true; +} +``` + +The codes with two branches (39, 32, 54) get one row and one method that +contains both, in the old order. The minimum in the row is the smaller of the +two, and the method returns `false` where neither branch matched. + +How the minimum is enforced: in one place, before any handler runs, with the +answer the chain gives today. A command without a minimum has 1 in its row +(`checkRecvFrame()` never returns 0). `SEND_CHANNEL_DATA` keeps 1 and its own +`ILLEGAL_ARG` checks, because that is its answer today. + +A new command is then one method and one row, and a forgotten minimum is a +visible `1` in a table rather than an absent clause. + +### Cost on the targets + +Measured in 10b, see "Verification of step 10b" for the table. RAM is unchanged +in every environment (the table is `const`, 59 rows). Flash grows by about +1.6 to 1.7 kB on nRF52, 1.5 kB on STM32, 1.2 to 1.9 kB on RP2040, 2.6 kB on +ESP32-C3 and 3.5 kB on ESP32-S3, and shrinks by 1.3 kB on ESP32-C6. The design +set 2 kB on nRF52 as the bound beyond which the cheaper `switch` variant would be +used instead; the measured cost is inside it. The extra flash is the 59 function +prologues and epilogues the chain did not need, plus the table (on ARM a pointer +to member function is 8 bytes, so a row is 12). + +### Steps + +| Step | Content | Behaviour | +|---|---|---| +| 10a | Host harness for `MyMesh.cpp`, frame sweep, per command tests | none | +| 10b | `CMD_TABLE` and one method per command, bodies moved unchanged | none, the sweep must stay byte identical | +| 10c | A bounded frame reader (`FrameReader r(cmd_frame, len); r.u8(); r.u32(); r.bytes(n)`) in the handlers, replacing the hand counted offsets | none; a reader that runs out reports it instead of reading the tail | +| 10d | Optional, a protocol change: answer a too short frame with `ILLEGAL_ARG` | visible to apps, needs its own proposal | + +10b and 10c are kept apart because 10b is a pure move whose diff can be read with +`git diff --color-moved`, while 10c rewrites the parsing in every handler and +needs a reviewer to read each one. + +## Alternatives considered + +- **A `switch` on the code, plus a separate array of minimums.** Cheaper in + flash (a jump table, no member pointers), but the code list then exists twice, + in the `switch` and in the array, and they can disagree. Kept as the fallback + if the measured cost of the table is too high. +- **Only a minimum length table in front of the existing chain**, leaving the 62 + branches in place. The smallest diff, and the guard would be central. Rejected + as the end state: the codes are still not enumerable, the bodies still cannot be + tested or called on their own, and every branch keeps a length clause that now + has to agree with the table. +- **Static functions taking `MyMesh&`.** Four byte pointers instead of eight, but + every body would change from `_prefs` to `self._prefs`, which turns a move into + a rewrite. Member functions keep the bodies textually identical. +- **An X macro list of commands.** Generates the table and the declarations from + one list, but is harder to read and to step through in a debugger than a plain + array, for the saving of one declaration per command. +- **Answer `ILLEGAL_ARG` from the central check.** Rejected for this series: it + changes the answer of 54 commands, which the length fixes already rejected for + the same reason. Listed as 10d. +- **The frame reader first (10c before 10b).** It fixes the class of bug more + directly, but it rewrites every branch inside a 1,000 line function that cannot + be reviewed piece by piece. Moving first makes each handler a unit the reader + step can change and test one at a time. + +## Upstream overlap + +`upstream/dev` was 428a95c6 on 20 October 2026, the same as this fork's base. No +upstream pull request proposes a table, a dispatcher or central length +validation for `handleCmdFrame()`. 19 open pull requests change lines inside it, +mostly 2 to 32 lines each; 10b moves every one of those lines, so each of them +would need to be re-applied to the method its branch moved to. The largest are +meshcore-dev/MeshCore#3523 (single key DM), #2964 (chunked contacts), #2682 +(ThinkNode M4) and #3638 (dual radio profiles). Some need the table's shape +specifically: #2843 and #2670 add optional trailing bytes (a minimum, not an +exact length, which the table has), #2717 and #2682 add commands (both claim code +66, which upstream itself has to resolve; this fork already has 66 as +`RUN_CLI_COMMAND`), and #3454 adds a `GET_STATS` sub type (a sub dispatch inside +one handler, which the table allows). #3643 is the channel text length guard of +this fork, already in `integration`. + +Step 10a touches none of those lines: it adds tests and a native environment. + +## Step 10c: a bounded frame reader + +### What the handlers read today + +Measured on `integration` at `68e0fcdc` in two ways. + +`tools/refactoring/cmd_frame_offsets.py` lists every access to `cmd_frame` in the +59 handlers of `CMD_TABLE`. 48 handlers touch the frame, 11 read nothing past the +code byte. Between them they use 68 constant offsets (`cmd_frame[1]`, +`&cmd_frame[1 + PUB_KEY_SIZE]`), 30 accesses through a running index `i`, 7 +lengths derived from `len` that go to a callee together with a pointer into the +frame, and 6 writes into the frame: the terminator at `len` (`cmd_frame[len] = 0` +or `text[tlen] = 0`) in `APP_START`, +`RUN_CLI_COMMAND`, `SEND_TXT_MSG`, `SEND_LOGIN` and `SET_CUSTOM_VAR` (the in place +terminator; `cmd_frame` is `MAX_FRAME_SIZE + 1` bytes, so index `len` is always +inside it) and the `':'` that `SET_CUSTOM_VAR` replaces. Every constant offset that +is read without a further length test lies below the row's minimum; the reads +above it (`alt` of `SET_ADVERT_LATLON`, the tail of `SET_OTHER_PARAMS`, the repeat +flag, `since`, the 256 bit scope key) each sit behind their own `len` test. + +The static list cannot see what a callee does with a pointer, and a running index +can be driven by a byte of the frame. So the second measurement is dynamic: the +host harness built with AddressSanitizer (`tools/refactoring/companion_asan_probe.patch`), +the bytes of `cmd_frame` above the frame poisoned before each frame is handled, +and every code 0 to 69 sent at every length 1 to 176 with ten fills (`00 01 02 3f +40 7f 81 bf ff 5a`, which include the path length bytes of every hash size), each +to a freshly booted node. 123,200 frames. Reads above `len`: + +| Handler | Where | Lengths | What it reads | Answer, either way | +|---|---|---|---|---| +| `SEND_CHANNEL_DATA` | `Packet::writePath()` | 4 to 64 | the path the length byte announces, before the test whether path and `data_type` fit | `ILLEGAL_ARG` | +| `SEND_RAW_DATA` | `Packet::writePath()` | 6 to 63 | the same, before the test for 4 payload bytes | `ILLEGAL_ARG` | +| `SEND_RAW_PACKET` | `Dispatcher::tryParsePacket()` | 4 to 6 | the 4 transport code bytes and the path length byte, before its length test | `ILLEGAL_ARG` | +| `RUN_CLI_COMMAND` | `CommonRadioPrefs::handleCommand()`, `MyMesh::handleCommand()` | 3 to 22 | `memcmp()` of a command prefix longer than the text, past the terminator | the CLI reply | +| `SET_DEFAULT_FLOOD_SCOPE` | `strlen()` in the handler | 48 to 175 | the byte at `len`, and on a board onwards while no terminator comes | `ILLEGAL_ARG` | + +The last row only shows up with the byte at `len` poisoned too (`PROBE_SKIP=0`): in +the probe that byte is always 0 (every node is fresh), on a board it is left over +from the previous frame, and `strlen()` runs on to the next zero. Reads of the byte +at `len` by the five handlers that just wrote the terminator there are not listed; +with the poison starting at `len + 1` (`PROBE_SKIP=1`) the first four rows remain +and nothing else. + +None of the five changes an answer. For the first three, a path or header that +runs past the frame always fails the length test that follows, because the cursor +is then already beyond `len`; the stale bytes go into a local buffer that is +discarded. For `SET_DEFAULT_FLOOD_SCOPE`, `strlen()` only runs past the frame when +bytes 1 to `len - 1` (at least 47) hold no zero, and then it returns at least 47, +which is refused as at least 31. For the CLI, `memcmp()` stops being decisive at +the terminator, which differs from the prefix. The first two and the fifth are in +handlers and go away with the reader without changing an answer; the CLI and +`tryParsePacket()` are callees that 10c does not touch (the latter also parses +every received radio packet), recorded here as findings for their own fixes. + +A side observation of the ASan build: the frame sweep's packet digests differ for +codes 2, 3, 26, 27 and 39 (encrypted requests and messages) while every reply +stays the same. Explained later, under "Open findings": an artefact of the host's +cipher mock, not of the firmware. + +### The reader + +A header only class in `src/helpers/FrameReader.h`: + +```c++ +class FrameReader { +public: + FrameReader(uint8_t* buf, size_t len, size_t pos = 0); + uint8_t u8(); // 0 when it runs out + uint16_t u16(); // memcpy, as the handlers do today + uint32_t u32(); + int32_t i32(); + uint8_t* bytes(size_t n); // n bytes at the cursor, nullptr when they are not all there + void skip(size_t n); + bool has(size_t n) const; // n more bytes are in the frame + size_t remaining() const; + size_t pos() const; + bool ok() const; // false once a read ran out; stays false + char* restAsCString(); // the rest as a C string: writes 0 at buf[len], as today +}; +``` + +Each handler starts with `FrameReader r(cmd_frame, len, 1);` and reads its fields in +order instead of counting offsets: + +```c++ +bool MyMesh::cmdSetTuningParams(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint32_t rx = r.u32(); + uint32_t af = r.u32(); + ... +``` + +Optional trailing fields keep their own test, written as `r.has(n)` (or +`r.remaining()`, where the old test compared `len`). Reads of several bytes use +`memcpy` into the value, exactly as the handlers do today, so the byte order is +the same on every target by construction rather than by a little endian +assumption. + +`restAsCString()` writes the terminator at `buf[len]`, the same byte the five +handlers write today; its precondition (the buffer has room for `len + 1` bytes) +is what `cmd_frame` already guarantees. `SET_CUSTOM_VAR` keeps editing its text in +place after that. + +### Why the answers stay byte for byte the same + +- **The minimum stays in the table.** A frame below a row's minimum never reaches + the handler and gets the dispatcher's `UNSUPPORTED` as before. The reader does + not add a second check that could answer differently. +- **Inside the minimum, nothing runs out.** Every unconditional read is below the + row's minimum (measured above), so for every frame a handler sees, the reader + returns the same bytes the offsets did. A read that would run out yields 0 and + clears `ok()`; in 10c that can only happen in the places listed next, each of + which already tests and refuses. +- **The handlers that check their own length keep the exact test.** `len < 4` in + `SEND_CHANNEL_DATA`, the 4 payload bytes of `SEND_RAW_DATA`, `len == 4` and + `len >= 36` of `SEND_TELEMETRY_REQ`, the two lengths of `SET_CHANNEL`, `len < 33` + of `EXPORT_CONTACT`, `len >= 48` of `SET_DEFAULT_FLOOD_SCOPE`, `len >= 18` of + `SET_FLOOD_SCOPE_KEY` and the upper bound of `SEND_TRACE_PATH` stay the same + comparisons, on `len` or on `r.remaining()` with the same value. Where a test + follows a path copy today (`SEND_CHANNEL_DATA`, `SEND_RAW_DATA`), the copy becomes + `r.bytes(n)`, and "did not fit" (`!r.ok()`) joins the existing test with `||`; + both lead to the `ILLEGAL_ARG` the over-read case answers today. The number of + bytes a path length byte announces comes from a small helper next to the table, + `pathByteLen()`, the same product `Packet::writePath()` copies. +- **Byte order stays as written.** Where a handler composed a value by shifting + (the `data_type` of `SEND_CHANNEL_DATA`, low byte first), it still does, from two + `u8()` reads; everything that used `memcpy` now uses `u16()`/`u32()`/`i32()`, + which use `memcpy`. +- **The unbounded `strlen()`** of `SET_DEFAULT_FLOOD_SCOPE` becomes a length + bounded to the frame (`strnlen(p, r.remaining())`); argued above, the answer is + the same for every frame. +- **Callees are not changed.** `updateContactFromFrame()`, `importContact()`, + `tryParsePacket()`, `createControlData()`, `sendAnonReq()` and the CLI parser get + the same pointer and length as today (`r.bytes()`/`r.remaining()` instead of + `&cmd_frame[n]`/`len - n`). + +On the host the reader counts every read that runs out (`-D +FRAME_READER_COUNT_OVERRUNS`, set only in `native_companion`; on the targets the +counter does not exist), so a test can assert that no frame makes a handler run +out, apart from the two path cases where running out is the refusal. + +### Tests and evidence (plan; results under "Verification of step 10c") + +- The sweep and the 49 command tests of 10a, unchanged, before and after. +- New tests first, on the unchanged handlers: for every row, the frame one byte + below the minimum (`UNSUPPORTED`) and exactly at it (accepted); every optional + field one byte short and complete (`SET_ADVERT_LATLON` 12/13, `SET_RADIO_PARAMS` + 11/12, `SET_OTHER_PARAMS` 2 to 5, `SET_AUTOADD_CONFIG` 2/3, `GET_CONTACTS` 4/5, + `SET_FLOOD_SCOPE_KEY` 17/18, `SET_CHANNEL` 49/50/65/66, `SEND_TELEMETRY_REQ` + 4/5/35/36, `EXPORT_CONTACT` 32/33, `SET_DEFAULT_FLOOD_SCOPE` 47/48, + `SEND_TRACE_PATH` at its upper bound); and each measured over-read with a + stale tail that would change the outcome if it were used (a path length byte + announcing more than the frame holds, behind a previous frame whose bytes form a + valid path and payload; a 47 byte scope name without terminator behind a tail + with a zero after 3 more bytes). +- Unit tests of the reader itself (each read at the end of the frame, past it, + `ok()` staying false, `restAsCString()` writing exactly `buf[len]`). +- Mutations of the converted handlers (a field read with the wrong width, an + optional field read without `has()`, `has(n)` off by one, a lost `!r.ok()`). +- Builds of every companion environment (BLE, USB, WiFi) per platform plus control + environments, flash and RAM against `68e0fcdc`; all 17 host environments; + `companion_session.py` on a board if one can be claimed, otherwise recorded as + not run. + +The cost to expect: the bounds test in every read. Target: at most 1 kB of flash on +nRF52 for the whole step; if it is more, the reads inside the minimum get an +unchecked variant (the table has already checked them) and the checked reads stay +for the optional fields. + +### One pull request or several + +One. The reader is useless without users, and the handlers are independent of one +another, so the step is cut into commits instead: the new boundary and stale tail +tests on the unchanged handlers; the reader with its unit tests; the 34 handlers +that read their fields one after the other without a length test of their own; +the 14 with optional fields, sub commands, a path or their own length tests, +where a reviewer has to compare each test. Each commit passes all suites on its +own. Splitting into two pull requests along the last line would double the build +matrix and the review overhead without making either half easier to read. + +### Alternatives considered for 10c + +- **The dispatcher passes the reader** (`bool (MyMesh::*)(FrameReader&)`). Makes it + harder to bypass the reader, but changes 59 declarations, the table type and + every handler that only wants `len`, while a handler could still index + `cmd_frame`. Not worth a diff that touches the 11 handlers that read nothing. +- **Reading into packed structs** (`memcpy(&frame, cmd_frame, sizeof frame)`). + The fewest lines, but the layout then depends on the compiler's packing rules on + five platform packages, and variable length fields (paths, text) do not fit a + struct. +- **A macro per handler (`NEED(n)`)** that returns `ILLEGAL_ARG` when the frame is + short. That is step 10d, a protocol change; in 10c the table's minimum is the + only length answer. +- **Upstream's `ByteReader`.** meshcore-dev/MeshCore#2864 (OTA over LoRa, open, + 12,000+ lines) brings `mesh::ota::ByteReader` in `src/helpers/ota/OtaByteIO.h`: + sticky `ok`, `u8()`, `u32()`, `take(n)`, `skip(n)`, little endian by shifting. + Close to this design, which takes the same method names where they mean the same + (`u8`, `u32`, `skip`, `ok`). Not reused: it is not merged, lives in the OTA + namespace, has no in place terminator and decodes by shifting (the same bytes on + the five platforms, all little endian, but a different expression than the + handlers' `memcpy`). If both land, merging them is one rename. +- **Converting `updateContactFromFrame()` and `importContact()` too.** They parse + frames as well, but they are shared with the contact store and the advert path; + out of the scope of the handlers, a candidate for later. + +### Upstream overlap for 10c + +`upstream/dev` is `dec8ad6e` on 21 October 2026; since `428a95c6` it only adds WiFi +to two variants and changes nothing in `examples/companion_radio`. Of 499 open +pull requests, 74 change `examples/companion_radio/MyMesh.cpp`; 10 of them change +lines that touch `cmd_frame`, and so do the handlers 10c rewrites: #2670 and #2843 +(each a new optional byte 5 in `SET_OTHER_PARAMS`), #2682 (`SEND_RAW_DATA`, +`RUN_CLI_COMMAND`), #2717 and #1374 (new commands), #1923 (`SEND_TRACE_PATH`), +#1551 (the timestamp of `SEND_TXT_MSG` and `SEND_CHANNEL_TXT_MSG`), #3523 +(`ADD_UPDATE_CONTACT`), #3643 (this fork's channel text fix, already in +`integration`). Each would be re-applied as reader calls; the two optional byte 5 +proposals become one `if (r.has(1))`. None proposes a reader for the companion +frames; #2864 is the only reader-like change and is discussed above. + +## Verification of step 10a + +### What the harness is + +`[env:native_companion]` builds the real `MyMesh.cpp` and `DataStore.cpp` with the +core, the chat layer and the helpers they use, against the mocks in `test/mocks` +(in-memory file system, radio, board, clock, sensor manager). Two mocks gained +what the companion needs: `FSInfo` and a directory listing in `FS.h`, and the +declaration of `radio_new_identity()` in `target.h`. The build options are the +ones companion environments set: `MAX_CONTACTS` and `MAX_GROUP_CHANNELS` for the +whole build, and private key export and import compiled in. + +`MAX_CONTACTS` for the whole build is not cosmetic. With it only in `MyMesh.h`, +as a first version of the harness had it, `BaseChatMesh.cpp` was compiled with 32 +contacts and `MyMesh.cpp` with 100, the two disagreed about the layout of +`BaseChatMesh`, and `GET_CONTACTS` reported zero contacts after two were added. +Every firmware environment sets it in its build flags. + +A test drives the node only through the protocol: a fake serial interface hands +`MyMesh::loop()` one frame, and the test gets every frame written back. State +that the protocol does not report is observed on the collaborators: what was +written to flash, which packets were queued for the air, whether the board +restarted, whether the radio or the clock were set. Each node is built in zeroed +storage, which is how a global starts on a board, so the bytes above a frame's +length are the same on every run. + +### The tests + +- `test_frame_sweep.cpp`: for each code 0 to 255, each length 1 to 176 and five + fills (four byte values, and zeros after a full length frame of `0x5A`, so a + read above `len` would show), a freshly booted node gets one frame. The replies, + byte for byte, and the side effects form one line; consecutive lengths with the + same line fold into a range, and one more line per code and fill carries a + digest of every packet queued and every file stored. 225,280 frames, compared + with `frame_sweep.golden` (about 2,000 lines). The golden file was checked for + hidden state by generating it once with the codes in ascending and once in + descending order; the two are identical. A first run was not: the global mock + clock kept the last unique timestamp of the previous node, so a request's tag + depended on the order. The harness now resets the clock completely. +- `test_commands.cpp`: 49 tests that send the frames an app sends and check the + meaning: the fields of each reply, the state a setter changed (read back through + the protocol where it can be), the boundary at each minimum with a real frame, + and the sub commands. + +The sweep also scrubs the stack with a fixed byte before each frame. Without +that, a run of the sweep after the other tests disagreed with a run on its own, +and the difference was real: `CMD_SET_CHANNEL` builds its record in a +`ChannelDetails` on the stack, `StrHelper::strncpy()` copies the name only up to +its terminator, and `DataStore::saveChannels()` writes all 32 name bytes, so up to +31 bytes of whatever the stack held go to `/channels2`, on a board as well. +Nothing reads them back (the name is used as a C string, and `GET_CHANNEL` copies +it with `strcpy`), so no reply and no packet carries them; it is flash content +only. Recorded here and not changed. The sweep zeroes those bytes before taking +its digest and nothing else. + +The golden file says nothing about whether today's behaviour is right; it records +it. Places where it looks odd are named in the tests and above (for example, a +contact added through the protocol cannot be shared, because sharing resends a +stored advert and there is none). + +### Mutation tests + +Each fault was put into `MyMesh.cpp` on its own, the suite run, the file +restored: + +| Mutation | Caught by | +|---|---| +| `SET_TUNING_PARAMS` minimum 9 becomes 8 | sweep, `TuningParamsRoundTrip` | +| `ADD_UPDATE_CONTACT` minimum 136 becomes 135 | sweep, `AddContactNeedsEveryMandatoryField` | +| `SEND_CHANNEL_DATA` `len < 4` becomes `len < 3` | not caught, equivalent (below) | +| `SET_PATH_HASH_MODE` loses its `cmd_frame[1] == 0` | sweep, `PathHashMode` | +| self telemetry `len == 4` becomes `len >= 4` | sweep, `TelemetryHasTwoForms` | +| the final `else` answers `ILLEGAL_ARG` | sweep and 38 command tests | +| the 66 byte `SET_CHANNEL` branch moves behind the 50 byte one | sweep, `Channels` | +| `SEND_LOGIN` no longer terminates the password at `len` | sweep only (the packet digest) | +| unscoped `SET_FLOOD_SCOPE_KEY` needs 3 bytes | sweep, `FloodScopeKey` | +| `SET_TUNING_PARAMS` stores `rx` into the airtime factor | `TuningParamsRoundTrip` only | + +The third is an equivalent mutant: with `len == 3` the branch goes on to its +second check, whether the two `data_type` bytes fit behind the path, which fails +for every path length and answers the same `ILLEGAL_ARG`. The `len < 4` test is +covered by that second one and cannot be observed on its own. The last two rows +are why both suites exist: the sweep sends zeros, under which `rx` and `af` are +the same, and the command tests do not look at packet contents. + +### Host suites + +All host environments, on the committed tree, including the new one: + +``` +native_companion test_companion_frames PASSED +... (the other 21 suites in 13 environments) PASSED +================ 414 test cases: 414 succeeded ================ +``` + +`native_companion` is added to `.github/workflows/run-unit-tests.yml`, which +names its environments explicitly. + +### Builds + +Step 10a changes no firmware source: its diff against its base, limited to +`src`, `examples` and `variants`, is empty. The changes to shared test files (`test/mocks/FS.h`, +`test/mocks/target.h`) only add members and a declaration, and every existing +host suite passes unchanged. The fork CI builds its 15 firmware environments on +the pull request. + +### Hardware + +The point of the hardware check for 10a is to show that the host harness models +the firmware, so that 10b can rely on it. LilyGo T-Echo `techo-2`, a companion +with 350 contacts and 40 channels, normally on the official 1.18.0 BLE image. The +USB companion image was built from the base of this change (which has the same +firmware source) with the marker `c10a-int`, and `ver` answered +`v1.18.0 (Build: c10a-int)` before the runs. + +`tools/refactoring/companion_sweep.py` sends the frames of the golden file's all +zero pass that cannot change the board: every frame the host refuses, every +length of the commands that only report, and four lengths of every code that is +not a command. Error and OK replies are compared byte for byte; for report +commands, whose content is the board's own, the reply code is compared. + +``` +6502 frames in 729 s: 6502 as on the host, 0 different, 0 without reply +(compared byte for byte: 4083, by reply code: 2419) +codes covered: 251 +``` + +The first run was not clean, and that is how the zero key finding above came to +light: the tool still counted `REMOVE_CONTACT` with a zero key as harmless. On +the host every frame meets a freshly booted node, so each one removes the first +reserved slot and answers OK. On the board the frames meet the same node one +after another: lengths 33 to 40 answered OK and removed the eight slots, every +later frame answered `NOT_FOUND`, and `SHARE_CONTACT` and `GET_CONTACT_BY_KEY` +with a zero key then found nothing either. Those were 424 of 6,790 frames, all in +codes 15, 16 and 30, and `GET_CONTACTS` reported 342 contacts. After a reboot it +reported 350 again and every value below was as before. The tool now leaves +`REMOVE_CONTACT` and `RESET_PATH` out, and the second run, after the reboot, is +the one above. + +State before the runs, after the reboot and after the second run, read through +the protocol: self info (key, name, radio, TX power, location, telemetry modes), +device info (including the BLE PIN and path hash mode), tuning parameters, +auto add configuration, default flood scope and key, all 40 channels (digest), +contact count 350. All three readings identical. + +Afterwards the official image was flashed back +(`LilyGo_T-Echo_companion_radio_ble-v1.18.0-81dbae5.zip`, SHA-256 prefix +`6d602a54`, the release digest), and the phone that uses this board as its +companion reconnected over BLE 17 seconds later. + +Not covered on hardware: frames the host accepts for commands that store, +transmit, restart or format (setters, sends, `REBOOT`, `FACTORY_RESET`, private +key import, contact and channel changes). For those the host tests are the +evidence; the length fixes recorded hardware runs for some of them. Only +nRF52 was run; ESP32 and RP2040 companions compile the same `MyMesh.cpp`, and +their build is checked by CI, not their behaviour. + +## Verification of step 10b + +### What changed + +`handleCmdFrame()` is the dispatcher above, followed by 59 handlers in the order +of the old chain. The bodies were moved by a script, not retyped: of the 863 +lines the diff removes from `MyMesh.cpp`, 796 reappear with the same text, and +the 67 without a counterpart are exactly the 62 branch heads, the final `else` +(now the dispatcher's fallback) and the three early `return;` of +`SEND_CHANNEL_DATA`, now `return true;`. `MyMesh.h` gains the row type, the +table and the 59 declarations, all private. + +### Host + +The suites of 10a pass unchanged, test files and golden file untouched: the frame +sweep (every code, every length, five fills, replies byte for byte and a digest of +every queued packet and stored file) and the 49 command tests. All host suites on +the committed tree: 414 of 414. + +The 10a mutations were aimed at the chain. These are aimed at the table, each on +its own: + +| Mutation | Caught by | +|---|---| +| `SET_TUNING_PARAMS` row minimum 9 becomes 8 | sweep, `TuningParamsRoundTrip` | +| `SEND_TELEMETRY_REQ` row minimum 4 becomes 5 | sweep, `TelemetryHasTwoForms` | +| `ADD_UPDATE_CONTACT` row minimum loses the 4 timestamp bytes | sweep, `AddContactNeedsEveryMandatoryField` | +| the row of `GET_TUNING_PARAMS` is missing | sweep, `TuningParamsRoundTrip` | +| the dispatcher ignores a handler that declines | sweep and 6 command tests | +| the dispatcher skips the minimum | 32 tests fail | +| `cmdSetPathHashMode` no longer declines byte 1 other than 0 | sweep, `PathHashMode` | +| `cmdSendControlData` declines the wrong flag value | sweep, `ControlData` | + +### Builds + +15 environments built from 10a (`2f5cf53c`) and from 10b with the same +toolchain, each in its own worktree. All build on both sides. + +| Environment | Flash 10a | Flash 10b | Delta | RAM delta | +|---|---:|---:|---:|---:| +| `Heltec_v3_companion_radio_ble` | 1,666,365 | 1,669,897 | +3,532 | +0 | +| `Heltec_v3_companion_radio_wifi` | 1,102,533 | 1,105,985 | +3,452 | +0 | +| `Heltec_v3_companion_radio_usb` | 652,925 | 656,449 | +3,524 | +0 | +| `RAK_4631_companion_radio_ble` | 462,228 | 463,956 | +1,728 | +0 | +| `RAK_4631_companion_radio_usb` | 453,188 | 454,916 | +1,728 | +0 | +| `ProMicro_companion_radio_usb` | 356,980 | 358,708 | +1,728 | +0 | +| `LilyGo_T-Echo_companion_radio_ble` | 373,776 | 375,360 | +1,584 | +0 | +| `LilyGo_T-Echo_companion_radio_usb` | 366,384 | 367,968 | +1,584 | +0 | +| `PicoW_companion_radio_ble` | 632,364 | 633,580 | +1,216 | +0 | +| `PicoW_companion_radio_wifi` | 526,780 | 528,708 | +1,928 | +0 | +| `LilyGo_Tlora_C6_companion_radio_ble_` | 1,446,171 | 1,444,861 | -1,310 | +0 | +| `wio-e5-mini_companion_radio_usb` | 207,472 | 208,944 | +1,472 | +0 | +| `Xiao_C3_companion_radio_ble` | 1,252,846 | 1,255,424 | +2,578 | +0 | +| `Heltec_v3_repeater` | 1,174,625 | 1,174,625 | +0 | +0 | +| `RAK_4631_repeater` | 441,656 | 441,656 | +0 | +0 | + +The thirteen companions cover the five platform packages that compile +`MyMesh.cpp` (nRF52, Espressif 32 for S3 and C3, pioarduino for C6, RP2040, +STM32) and the BLE, USB and WiFi interfaces. The two repeaters do not compile the +file and are there as the control. The tightest flash budget among them is the +STM32WLE5 of `wio-e5-mini` (256 kB), which goes from 79.1 % to 79.7 %. + +### Hardware + +The same T-Echo, `techo-2`, with two USB companion images built from 10a and from +10b, marked `c10a-int` and `c10b-tab` and confirmed by `ver` before each run. + +- `tools/refactoring/companion_session.py` sends 39 frames the commands accept: + every setter with the value the board already holds (TX power, radio, tuning, + other parameters, auto add, path hash mode, name, location, PIN, default scope), + the declining sub commands, out of range values, stats, self telemetry, a whole + signing round, channels, two CLI commands and the wrong reboot and reset words. + Run once per image and compared: the only two differing lines are the build + marker inside `DEVICE_QUERY` and the reply to `ver`. +- `companion_sweep.py` against the 10b image: 6502 of 6502 frames as on the host. +- State before and after, read as in 10a: identical apart from the build marker. +- Afterwards the official 1.18.0 BLE image went back on, and the phone that uses + the board reconnected 4 seconds later. + +Not covered on hardware: the setters with new values, sends on air, `REBOOT`, +`FACTORY_RESET`, private key import, contact and channel changes. Their handlers +are moved text, pinned on the host by the sweep and the command tests. Only nRF52 +ran on hardware. + +## Verification of step 10c + +### What changed + +48 handlers start with `FrameReader r(cmd_frame, len, 1);` and read through it; +the 11 that read nothing past the code byte are untouched. In `MyMesh.cpp` the +file changes by 217 lines added and 171 removed (34 handlers in one commit, 14 in +the next, the include and `pathByteLen()` among them). After the step, +`tools/refactoring/cmd_frame_offsets.py` finds no constant offset, no running +index and no length derived from `len` in any handler; the one remaining write +into the frame is the `':'` that `SET_CUSTOM_VAR` replaces in its own text. The +dispatcher, the table and every callee are unchanged. + +`SET_AUTOADD_CONFIG` reads its hop count into a local before `min()`, because +`min()` is a macro on some cores and would read twice. + +### New tests + +All written and run on the unchanged handlers first (commit `29e35f06`), and run +again against the handlers of `68e0fcdc` after the step (60 of 60 pass there too). + +- `test_frame_bounds.cpp`, `NoCommandReadsThePreviousFrame`: a catalog of 98 + frames as an app sends them, each command at its minimum, both sides of every + optional field, the measured reads past the end (an announced path longer than + the frame in two hash sizes for `SEND_RAW_DATA` and `SEND_CHANNEL_DATA`, an + unterminated scope name, transport codes past the end in `SEND_RAW_PACKET`), and + the text commands. Each goes to a fresh node with a contact and a started + signing round, once as it is and once behind each of four tails a previous + frame leaves in `cmd_frame` (zeros, `0x5A`, `0xFF`, and a pattern with a zero + every eighth byte). Replies, queued packets, stored files (except the name tails + in `/channels2`, see 10a), calls to board, radio, clock and sensors, and what 12 + more loops, a `GET_CHANNEL` and a channel flood show afterwards, must be + identical. Encrypted packets are compared by header, route, path and payload + length, since the host's cipher mock leaves their content undefined (open + finding 3; since the mock was fixed, every packet is compared byte for byte). +- `OneByteBelowEachMinimum`: for each of the 44 rows with a minimum above 1, the + catalog frame at the minimum minus one byte answers `UNSUPPORTED` + (`SEND_CHANNEL_DATA`: `ILLEGAL_ARG`) behind every tail and queues nothing; and the + catalog has a frame of exactly each minimum. +- `OnlyAnAnnouncedPathRunsOut`: every code 0 to 69 at every length with six + fills, and every catalog frame; the reader may run out only for an announced + path in `SEND_RAW_DATA` and `SEND_CHANNEL_DATA`. +- `TheReadsPastTheEndThatStep10cRemoved`: the three handler cases of the + inventory (`SEND_CHANNEL_DATA` and `SEND_RAW_DATA` with a path length byte that + announces 8 bytes, `SET_DEFAULT_FLOOD_SCOPE` with 47 bytes and no zero), each + behind four tails chosen so that the stale bytes would complete the path and + payload or end the name early. Every time: `ILLEGAL_ARG`, nothing queued, no file + changed (the prefs file holds the default scope). Passes on the handlers of + `68e0fcdc` and on the converted ones; a mutation that accepts the 47 byte name + fails it. +- `FloodScopeKeyNeedsAllSixteenBytes`: added after a mutation survived (below). + The key's effect only shows in the next flood, which carries a transport code + with 16 key bytes and none with 15. +- `test_frame_reader.cpp`: six tests of the reader alone. + +One finding of writing the catalog: the upper bound of `SEND_TRACE_PATH` +(`len - 10 >= MAX_PACKET_PAYLOAD - 5`, so 189 bytes) lies above `MAX_FRAME_SIZE` +(176) and is never reached; its `return false` is dead code today. Kept as it is. + +### Mutation tests + +23 faults, each on its own against the converted code, the whole +`native_companion` suite run, the file restored; 19 caught: + +| Mutation | Caught by | +|---|---| +| `SET_TUNING_PARAMS` reads `af` with `u16()` | sweep | +| `SET_TUNING_PARAMS` reads `af` before `rx` | `TuningParamsRoundTrip` | +| `RUN_CLI_COMMAND` text starts one byte late | `RunCliCommand` | +| `SET_OTHER_PARAMS` reads the location policy without `has(1)` | `OnlyAnAnnouncedPathRunsOut` | +| `SET_AUTOADD_CONFIG` hop count behind `has(0)` | `AutoAddConfig`, `OnlyAnAnnouncedPathRunsOut` | +| `SET_CHANNEL` 256 bit test `has(64)` instead of `has(65)` | sweep | +| `SET_FLOOD_SCOPE_KEY` key behind `has(17)` | `FloodScopeKeyNeedsAllSixteenBytes` (not caught before that test existed) | +| `SET_FLOOD_SCOPE_KEY` key behind `has(15)` | crash (null pointer from `bytes(16)`) | +| `SET_DEFAULT_FLOOD_SCOPE` behind `has(46)` | crash (null pointer from `bytes(16)`) | +| `SEND_TELEMETRY_REQ` self form also at 5 bytes | `TelemetryHasTwoForms`, sweep | +| `SEND_RAW_DATA` payload test `has(3)` | `SendRawData`, sweep | +| `pathByteLen()` ignores the hash size | sweep | +| `SEND_TRACE_PATH` reads `flags` before `auth` | `TracePath` | +| `SET_ADVERT_LATLON` takes `lon` from `lat` | `SetAdvertLatLon` | +| reader: `has()` off by one (`<` for `<=`) | crash | +| reader: `has()` allows one byte past the end | 10 tests, among them the sweep and `NoCommandReadsThePreviousFrame` | +| reader: terminator one byte late | `NoCommandReadsThePreviousFrame`, `RestAsCStringWritesTheTerminatorAtLen` | +| reader: terminator not written | the same and the sweep | +| reader: `ok()` not sticky | `AReadPastTheEndYieldsZeroAndStops`, sweep | + +Not caught (4), and why: + +- `SET_CHANNEL` with `has(48)` instead of `has(49)`: equivalent. The row's + minimum is 50, so the handler never sees 49 bytes; the handler's own test is + redundant with the table. +- `SEND_RAW_DATA` and `SEND_CHANNEL_DATA` without `!r.ok()`: equivalent. Once a + read ran out, `has()` is false as well, so the `has()` test alone refuses the + frame. The `!r.ok()` stays because it says what is meant. +- `SEND_CHANNEL_DATA` with the two `data_type` bytes swapped: not observable on the + host. The mock cipher leaves no plaintext in the queued packet, and + `sendGroupData()` is not virtual, so the harness cannot see the value. The code + reads low byte, then high byte, as the shift expression it replaces did; checked + by review only. + +### Host suites + +All 17 host environments on the committed tree, the environment list passed as a +bash array: + +``` +native_companion test_companion_frames PASSED +... (the other 26 suites in 16 environments) PASSED +================ 588 test cases: 588 succeeded ================ +``` + +577 before the step, 11 new: five in `test_frame_bounds.cpp`, six in +`test_frame_reader.cpp`. + +### Builds + +19 environments built from `68e0fcdc` and from the code commit `90b5da2e`, each in +its own worktree with the same toolchain. All build on both sides. + +| Environment | Flash before | Flash after | Delta | RAM delta | +|---|---:|---:|---:|---:| +| `Heltec_v3_companion_radio_ble` | 1,670,229 | 1,671,653 | +1,424 | +0 | +| `Heltec_v3_companion_radio_wifi` | 1,106,381 | 1,107,821 | +1,440 | +0 | +| `Heltec_v3_companion_radio_usb` | 656,829 | 658,193 | +1,364 | +0 | +| `RAK_4631_companion_radio_ble` | 464,212 | 465,172 | +960 | +0 | +| `RAK_4631_companion_radio_usb` | 455,188 | 456,148 | +960 | +0 | +| `ProMicro_companion_radio_usb` | 358,836 | 359,796 | +960 | +0 | +| `LilyGo_T-Echo_companion_radio_ble` | 375,680 | 376,640 | +960 | +0 | +| `LilyGo_T-Echo_companion_radio_usb` | 368,288 | 369,248 | +960 | +0 | +| `PicoW_companion_radio_ble` | 633,604 | 635,332 | +1,728 | +0 | +| `PicoW_companion_radio_wifi` | 528,732 | 530,428 | +1,696 | +0 | +| `PicoW_companion_radio_usb` | 495,284 | 497,012 | +1,728 | +0 | +| `LilyGo_Tlora_C6_companion_radio_ble_` | 1,444,915 | 1,446,667 | +1,752 | +0 | +| `M5Stack_Unit_C6L_companion_radio_usb` | 544,593 | 546,345 | +1,752 | +0 | +| `wio-e5-mini_companion_radio_usb` | 209,016 | 211,296 | +2,280 | +0 | +| `Xiao_C3_companion_radio_ble` | 1,255,482 | 1,256,962 | +1,480 | +0 | +| `Xiao_C3_companion_radio_usb` | 543,422 | 544,902 | +1,480 | +0 | +| `Xiao_C3_companion_radio_wifi` | 1,010,858 | 1,012,330 | +1,472 | +0 | +| `Heltec_v3_repeater` | 1,175,021 | 1,175,021 | +0 | +0 | +| `RAK_4631_repeater` | 441,944 | 441,944 | +0 | +0 | + +The seventeen companions cover the five platform packages that compile +`MyMesh.cpp` and every interface each of them builds (ESP32-S3 BLE, USB and WiFi; +ESP32-C3 BLE, USB and WiFi; ESP32-C6 BLE and USB; nRF52 BLE and USB; RP2040 BLE, +USB and WiFi; STM32 USB). The repeaters do not compile the file and are the +control. + +The design's bound, 1 kB on nRF52, holds: +960 bytes. The other cores pay more +for the inlined bounds tests: about 1.4 kB on ESP32-S3 and C3, 1.7 kB on RP2040 +and C6, 2.3 kB on STM32, where `wio-e5-mini` goes from 79.7 % to 80.6 % of its +256 kB. Making the reader's two checking functions `noinline` was tried and does +not help (T-Echo +2,240 instead of +960, `wio-e5-mini` unchanged), so the reader +stays inline. An unchecked variant for the reads inside the minimum, which the +design kept in reserve, was not needed for the bound and would give back the +guarantee the step is for. + +### Hardware + +LilyGo T-Echo `techo-3dcf` (claimed with `kb device claim`), a companion with its +contacts and channels, normally on the official 1.18.0 BLE image. Two USB +companion images built from `68e0fcdc` and from `90b5da2e`, marked `c10c-base` and +`c10c-head` and confirmed by `ver` before each run. + +- `companion_session.py` once per image (39 accepted frames: every setter with the + value the board holds, declining sub commands, out of range values, stats, self + telemetry, a signing round, channels, CLI, the wrong reboot and reset words) and + compared: the only two differing lines are the build marker inside + `DEVICE_QUERY` and the reply to `ver`. +- `companion_sweep.py` against the 10c image: + + ``` + 6502 frames in 751 s: 6502 as on the host, 0 different, 0 without reply + (compared byte for byte: 4083, by reply code: 2419) + codes covered: 251 + ``` + +- State read through the protocol (`nodetool.py compinfo`) before the first run and + after the sweep: identical. +- Afterwards the official image went back on + (`LilyGo_T-Echo_companion_radio_ble-v1.18.0-81dbae5.zip`, SHA-256 prefix + `6d602a54`); the DFU reported success and the board enumerated again. The phone + this board serves was not connected, so the BLE reconnect was not checked. + +Not covered on hardware: the setters with new values, sends on air, `REBOOT`, +`FACTORY_RESET`, private key import, contact and channel changes, and the stale +tail cases (a board cannot be made to hold a chosen tail without sending that +frame first, which the host tests do). For those the host tests are the evidence. +Only nRF52 ran on hardware. + +### Open findings, not changed in 10c + +Each needs its own fix and proposal; 10c leaves the code as it is. + +1. **`Dispatcher::tryParsePacket()` reads before its length test.** For a route + type with transport codes it reads the 4 code bytes and the path length byte + before it compares with `len`. Reached from `SEND_RAW_PACKET` with frames of 4 + to 6 bytes, and on every received radio packet. The answer is unchanged + (`ILLEGAL_ARG`, the later test fails). Reproduce: apply + `tools/refactoring/companion_asan_probe.patch`, run it with `PROBE_SKIP=1`, and + `companion_asan_probe_summary.py` lists `MyMesh::cmdSendRawPacket` with + `mesh::Dispatcher::tryParsePacket` for code 65, fills `00` and `3f`, lengths 4 + to 6 (see `tools/refactoring/README.md`). +2. **The CLI parser compares prefixes past the terminator.** + `CommonRadioPrefs::handleCommand()`, `MyMesh::handleCommand()` and `isGet()` use + `memcmp()` with prefixes longer than the command text. The result is decided at + the terminator, so no reply changes. Reproduce: the same probe lists + `MyMesh::cmdRunCliCommand` with those three callers for code 66, lengths 3 to 22. +3. **Encrypted packets on the host carry leftover memory (resolved, a mock + artefact).** The ASan build changed the frame sweep's packet digests for codes + 2, 3, 26, 27 and 39, and the first CI run of 10c failed + `NoCommandReadsThePreviousFrame` on Linux only, with bytes of earlier frames in + the payload of a queued channel message. Cause, checked: the host's `AES128` + mock (`test/mocks/AES.h`) writes nothing in `encryptBlock()`, and + `Utils::encrypt()` encrypts straight into the packet buffer, so the "ciphertext" + (and the MAC and transport code computed over it) is whatever that buffer held + before, which depends on the allocator. With a mock that copies its input, the + test passes on Linux and only the sweep differs. Not a firmware issue. The frame + sweep's digests of encrypted packets rest on the same leftovers and are stable + only because the sweep allocates the same way on macOS and on the CI's Linux; + giving the mock a deterministic output means regenerating the golden file, a + change of its own. `test_frame_bounds.cpp` compares encrypted packets by + header, route, path and payload length only. + + **Fixed after 10c** (branch `test/deterministic-aes-mock`): the mock XORs + each block with the key, so the output depends only on key and input, and + `decryptBlock()` undoes `encryptBlock()`. Measured before the fix: with every + fresh `malloc` block prefilled (macOS: an interposed `malloc`, fill `a5` and + `3c`; Linux container, glibc 2.41: `MALLOC_PERTURB_=165` and `60`) the sweep + differs in 20 lines, all of them `packets` digests, for every sending command + that encrypts: codes 2, 3, 26, 27, 39 and also 50, 52, 57, 62 (`02 03 1a 1b + 27 32 34 39 3e` in the golden file). Only two environments reach the mock at + run time (checked with an `abort()` in it): `native_companion` and + `native_chat`, whose group message test asserts payload lengths only. The + golden file was regenerated; its diff is exactly those 20 `packets` digests, + with code, pass and `files` digest unchanged and no reply line touched. + After the fix the sweep is identical under every fill on both systems, and + `test_frame_bounds.cpp` compares every packet byte for byte again + (`packetLine()` is gone), on macOS and Linux and under every fill. That + comparison does see the leftovers: with the old mock it fails + `NoCommandReadsThePreviousFrame` in the Linux container, as the first CI run + of 10c did. All 17 host environments: 616 of 616 on macOS and on Linux. +4. **`SEND_TRACE_PATH`'s upper bound is dead code.** `len - 10 >= MAX_PACKET_PAYLOAD - 5` + needs 189 bytes; a frame has at most 176. diff --git a/docs/refactoring/0011-variant-target.md b/docs/refactoring/0011-variant-target.md new file mode 100644 index 00000000000..b163c540967 --- /dev/null +++ b/docs/refactoring/0011-variant-target.md @@ -0,0 +1,1080 @@ +# 0011: One generic variant target built from a board description + +Status: cut A (one shared `radio_new_identity()`) and cut B (one shared FEM RX gain +handler) implemented, each its own pull request, B stacked on A. Cut C (one default +`radio_init()` for 31 nRF52 variants) implemented, its own pull request, on top of +A and B. Cuts D and E are planned and need their own review before code. Behaviour change: none. + +## Context + +Every board is a directory in `variants/` with a `target.h`, which declares the +globals the roles use (`board`, `radio_driver`, `rtc_clock`, `sensors`, and +`display` and `user_btn` where there is a screen), and a `target.cpp`, which +defines them and the functions `radio_init()` and `radio_new_identity()`. Each new +board starts as a copy of the closest existing one, so a change to what a variant +provides has to be made in every copy. The review lists the cost of that in +release 1.18: the remote RX gain toggle touched 162 variant files, moving it into +the radio wrappers 23 days later touched 156 again, and one line added to every +`radio_init()` touched 64 (see [architecture-review-v1.18.md](architecture-review-v1.18.md), F3). + +### Measured on the commit this step started from + +Measured with `tools/refactoring/variant_duplication.py` (in this step), which +extracts every function a `target.cpp` defines and compares the bodies twice: with +only runs of spaces collapsed ("strict"), and with comments and all white space +removed ("normalized"). Run it from the repository root to repeat the numbers. + +- 92 variant directories, 89 with a `target.cpp`, 4,690 lines in all of them. + The three without one (`m5stack_unit_c6l`, `xiao_c6`, `wio-tracker-l1-eink`) + are covered under "Observations"; the two C6 ones carry two more copies of the + same body in their board file. +- 618 firmware environments (631 with the host test environments). + +| function | files | strict bodies | normalized bodies | largest identical group | +|---|---:|---:|---:|---:| +| `radio_new_identity()` | 89 | 4 | 2 | 87 | +| `radio_init()` | 89 | 39 | 29 | 28 | +| `radio_set_tx_power()` | 3 | 1 | 1 | 3 | +| `radio_get_rng_seed()` | 2 | 1 | 1 | 2 | +| `radio_set_params()` | 2 | 1 | 1 | 2 | + +`radio_new_identity()` in detail: + +| copies | body | +|---:|---| +| 76 | `RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng);` with the comment `// create new random identity` | +| 10 | the same without the comment | +| 1 | the same with the opening brace on its own line | +| 2 | `ESP_RNG rng; return mesh::LocalIdentity(&rng);` (`generic_espnow`, `sensecap_indicator-espnow`, which have no LoRa radio and use the ESP32 hardware RNG) | + +The review counted 74 identical copies "ignoring repeated spaces"; the strict count +here is 76, and 87 once the comment and line breaks are ignored. Either way the 87 +compile to the same thing. + +The FEM gain handlers of the board classes, compared with the board class name +replaced by a placeholder: + +| function | HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | TBeam1W | StationG3 | WioTrackerL1 | +|---|---|---|---|---| +| `attachDynamicPrefs()` | one body (217 characters normalized) | own (fan prefs) | own | own | +| `handleCommand()` | one body (999 characters normalized) | own | own | own | + +What else repeats across `target.cpp`, by normalized line (each counted once per +file): `WRAPPER_CLASS radio_driver(radio, board);` in 87 files, +`AutoDiscoverRTCClock rtc_clock(fallback_clock);` in 79, `rtc_clock.begin(Wire);` in +76, `#ifdef DISPLAY_CLASS` in 72, `DISPLAY_CLASS display;` in 57, +`MomentaryButton user_btn(PIN_USER_BTN, 1000, true);` in 47, the GPS sensor manager +pair in 42 or 43. Replacing the board class name, 73 of the 89 files are still +different from each other; the largest group of whole-file copies is 4 +(`lilygo_t_impulse_plus`, `lilygo_techo`, `lilygo_techo_lite`, `thinknode_m6`). So +the copies are of building blocks, not of whole files, and the blocks are what a +generic target can share. + +## Goal and cut plan + +The goal from the [README](README.md): one generic variant target built from a board +description, so a new board says what it has (radio chip and pins, RTC, sensors, +display, button) and writes code only for what is really its own. + +Each cut is one concern, one pull request, and keeps every existing and downstream +variant compiling. The shared code goes into `src/helpers/radiolib/RadioLibTarget.cpp` +(later cuts may add a sibling for non-RadioLib parts) and uses weak definitions, so +a variant that keeps its own definition still links and its own wins (see "Why +weak" below). + +| cut | what | files touched | status | +|---|---|---|---| +| A | one `radio_new_identity()` for all RadioLib variants | 87 `target.cpp`, 1 new source, 1 header | implemented, see "Cut A" | +| B | one class for the FEM gain handlers of HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | 4 board classes, 1 new helper | implemented, see "Cut B" | +| C | a shared default `radio_init()` for the bodies that need no new description (28 + 3 of 89, all nRF52) | 31 `target.cpp`, 1 source, 1 header | implemented, see "Cut C" | +| D | the radio, wrapper and RTC globals from the board's build flags | about 87 `target.cpp` | planned (description: build flags, decided) | +| E | sensors, display and button from the board description | the rest | planned | + +Cut B in more detail: the four bodies are the same once the class name is replaced, +and they all read and write the `fem_rxgain` key through `_prefs`. The open upstream +pull request meshcore-dev/MeshCore#3496 changes exactly those `setByKey` lines to +`if (_prefs) _prefs->setByKey(...)`, so a shared class has to accept a null prefs +pointer and the note for cut B has to say which of the two lands first. + +Cut C: the 28 bodies of the largest group are `rtc_clock.begin(Wire); return +radio.std_init(&SPI);`. The groups differ in whether there is a `fallback_clock` to +start and which SPI object is passed, which is board description, so cut C and D +are related: C can share the bodies that need no new description, D introduces the +description. + +What "board description" means is the open question for D and E. The options as +they look today: (1) the build flags and `target.h` macros the variants already use +(`RADIO_CLASS`, `WRAPPER_CLASS`, `P_LORA_*`, `DISPLAY_CLASS`) plus a few new ones, +read by the generic target; (2) a small `constexpr` struct per board in `target.h`; +(3) a data file per board from which the build generates `target.cpp`. Option 1 +changes the least and keeps the PlatformIO files as the one place a board is +described; option 3 would be a new build step. + +Decision (Marcel, 20.10.2026): option 1. The board description is the build flags +and `target.h` macros a variant already has, plus the few new ones cuts D and E +need. Options 2 and 3 are rejected: option 2 rewrites every variant, option 3 adds +a build step. + +## Cut A: one `radio_new_identity()` + +### What changes + +- `src/helpers/radiolib/RadioLibTarget.cpp` (new) defines `radio_new_identity()` + once, as a weak function, compiled only where `WRAPPER_CLASS` is set: + ```cpp + __attribute__((weak)) mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio_driver); + return mesh::LocalIdentity(&rng); + } + ``` +- `RadioNoiseListener` gets a constructor from a `RadioLibWrapper`, and + `RadioLibWrapper` names it a friend, so the listener can read the wrapped radio + without a new public accessor. +- The 87 identical copies are deleted from `variants/*/target.cpp`. The diff in + `variants/` is deletions only: 484 lines (each copy plus the blank line that + separated it), no line added or changed, line endings kept (3 of the files use + CRLF). +- The declaration stays in every `target.h`; the roles get it from there. +- The two ESP-NOW variants keep their own definition and are not touched. + +### Why it is the same function + +- `RadioLibTarget.cpp` is built in every firmware environment: `helpers/radiolib/*.cpp` + is part of `arduino_base.build_src_filter`, which every variant extends. Checked + with `pio project config --json-output` over all 631 environments: the 612 that set + `WRAPPER_CLASS` all build `helpers/radiolib/*.cpp`. +- `WRAPPER_CLASS` is what tells a RadioLib variant apart. The six environments that + build `helpers/radiolib/*.cpp` without it are the five ESP-NOW ones, which define + their own `radio_new_identity()`, and `Generic_E22_kiss_modem` (see + "Observations"). +- The copies used the variant's global `radio`, which no `target.h` declares, so the + shared code cannot name it. It uses `radio_driver` instead, which every `target.h` + declares. In all 87 variants (and only there) `target.cpp` contains + `WRAPPER_CLASS radio_driver(radio, board);`, so `radio_driver._radio` is `&radio` + and the listener reads the same `PhysicalLayer` as before. Checked by script: the + set of files with `RadioNoiseListener rng(radio)` and the set with that + constructor line are the same 87. +- No copy was inside an `#if` block (checked by script), so no environment loses a + definition it had or gains one it did not have. + +### Why weak + +A strong shared definition would make every remaining copy a duplicate symbol +error. Remaining copies exist in three places: the ESP-NOW variants, the variants in +downstream forks, and the open upstream pull requests that add a board (about 50 of +the 120 open pull requests whose diff was read carry a new `target.cpp` with its own +copy, see "Upstream"). With a weak definition all of them keep linking and their own +definition takes precedence. MeshCore already uses weak definitions for the same +reason in `src/armstubs.cpp`. + +The cost: a variant that defines the function by mistake silently replaces the +shared one instead of failing the build. This is the same situation as today, where +each variant has its own. + +Limit: weak precedence works because a variant's `target.cpp` is built as an object +of the firmware, as all variants are. A strong definition placed in a library +archive would not be pulled in, because the weak one already satisfies the +reference. No variant does that. + +### Alternatives considered + +- **A helper the variants call** (`return new_identity_from(radio);` in each + `target.cpp`). Rejected: it keeps 87 definitions, and the next change to the + signature touches 87 files again. +- **A strong definition and delete every copy.** Rejected: it breaks every downstream + variant and every open pull request that adds a board, which rule 4 of the series + (source compatible) excludes. +- **An opt-in macro per variant** (`#define TARGET_SHARED_NEW_IDENTITY`). Rejected: + it touches every `target.h` as well, and a variant that forgets it fails to link + with an error that does not say why. +- **Declare `extern RADIO_CLASS radio;` in the shared file.** Rejected: it relies on + a name that is not part of any variant's declared interface; `radio_driver` is. +- **A public `getRadio()` on `RadioLibWrapper`.** Rejected for now: it widens the + wrapper's interface for one caller. A friend keeps the change local; if cut D needs + the radio from the driver in more places, an accessor can be proposed there. +- **Move identity creation into the wrapper** (`radio_driver.newIdentity()`). + Rejected: identities are not the radio driver's concern. + +### Host tests + +`native_variant_target` (new) builds the real `RadioLibWrappers.cpp` and +`RadioLibTarget.cpp` against the RadioLib stand-in, with `test/mocks/radiolib_variant` +standing in for the `target.h` of a RadioLib variant. The Ed25519 constructor is +replaced by one that keeps the seed, which is the only input the real key derivation +has, so a test sees exactly what was drawn. + +The first commit pins the variants' body (copied verbatim into the test) before +anything moves: + +- the seed is 32 bytes, each the radio's random byte XOR the low byte of Arduino's + `random(0, 256)`, in order, +- two radios with different noise give different identities. + +The second commit adds, with the first two tests unchanged: + +- the shared definition draws the same seed as the variants' copy, +- it listens on the radio that `radio_driver` wraps and not on another one, +- a listener built from the driver produces the same bytes as one built from the + radio. + +`native_variant_target_override` (new) defines its own `radio_new_identity()` and +checks that it is the one called; it does not link if the shared definition is not +weak. + +The `PhysicalLayer` stand-in gets a per instance `random_byte` (default `0x5A`, the +value it returned so far), so a test can tell two radios apart. All 15 host +environments pass on both commits: 269 test cases on the first, 273 on the second. + +Mutation tests, each of which must fail: + +| mutation | result | +|---|---| +| M1: shared definition uses Arduino `random()` only, not the radio | 2 tests fail | +| M2: shared definition not weak | `native_variant_target_override` does not link (`duplicate symbol 'radio_new_identity()'`) | +| M3: shared definition guarded by `RADIO_CLASS` instead of `WRAPPER_CLASS` | `native_variant_target` does not link (`radio_new_identity()` undefined) | +| M4: `RadioNoiseListener` drops the `random()` mix | 2 tests fail | +| M5: `RadioNoiseListener` fills one byte less | 2 tests fail | + +### Builds + +102 environments were built from the commit this step started from (`2e456f74`, +`integration`) and from the code commit of this step (`248b1d12`), each in its own +worktree, with the same toolchain. 618 firmware environments is too many to build +twice for a change that is the same in every variant, so the set is chosen to cover +every file this step touches, every way the shared file can compile, and the usual +matrix of the series: + +- the 18 environments every step of the series is built for (all roles, all + platforms), +- one environment for each of the 87 variants whose `target.cpp` this step edits + (a repeater where the variant has one), so every edited file is compiled and + linked, +- one environment for each of the 13 pairs of `WRAPPER_CLASS` and platform that + occur in the 612 RadioLib environments, so `RadioLibTarget.cpp` and the changed + `RadioLibWrappers.h` are compiled against every wrapper class on every toolchain + (only `Generic_E22_sx1268_repeater` had to be added for that), +- `Xiao_C6_repeater_` and `M5Stack_Unit_C6L_repeater`, the variants without a + `target.cpp` (see below), and `Generic_ESPNOW_repeatr` for the variants without + `WRAPPER_CLASS`. + +Platforms in the set: 45 ESP32 (S3, C3, C6 and classic), 45 nRF52, 5 STM32WL, 7 RP2040. + +Result: + +- 100 build on both commits. 2 fail on both commits with the same errors, so not + because of this step: + - `ThinkNode_M8_repeater` does not link (`EnvironmentSensorManager` undefined, the + variant does not build `helpers/sensors`). Its `target.cpp` compiles on both + commits, and `radio_new_identity()` is not among the undefined symbols, so the + shared definition is what it links. It is the only edited variant without an + environment that links; the defect is the subject of a separate fix. + - `Generic_ESPNOW_repeatr` does not compile (`P_LORA_DIO_1` and `P_LORA_NSS` + undeclared in `ESP32Board.h`). The other three ESP-NOW environments tried + (`Generic_ESPNOW_room_svr`, `Generic_ESPNOW_comp_radio_usb`, + `SenseCapIndicator-ESPNow_comp_radio_usb`) fail the same way on both commits. + That `RadioLibTarget.cpp` stays empty there rests on the configuration check + above (none of the ESP-NOW environments sets `WRAPPER_CLASS`), not on a build. +- RAM is unchanged in all 100. +- Flash changes by -16 to +16 bytes: 38 environments +0, 11 smaller (-8 or -16), 51 + larger (+4 to +16). The function now loads the radio from `radio_driver` instead + of taking the address of `radio`, which is one load more, and it sits in another + translation unit, so the alignment padding around it moves. Measured with `nm`: + `radio_new_identity()` is 0x24 bytes on `RAK_4631_repeater` before and after, + 0x16 and 0x1b bytes on `Heltec_v3_repeater`, and it is a weak symbol (`W`) in the + new firmware where it was a normal one (`T`). +- The two C6 variants define `radio_new_identity()` in their board file, so they have + their own definition next to the weak shared one. Both build to exactly the same + size as before, and `nm` shows their own definition (`T`) in the firmware: the + precedence the override test checks on the host, on the RISC-V toolchain. + +
+All 102 environments + +| Environment | Platform | Wrapper | Flash before | Flash after | Flash delta | RAM delta | +|---|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | espressif32 | CustomSX1262Wrapper | 1,174,193 | 1,174,209 | +16 | +0 | +| `Heltec_v3_room_server` | espressif32 | CustomSX1262Wrapper | 1,169,481 | 1,169,497 | +16 | +0 | +| `Heltec_v3_companion_radio_ble` | espressif32 | CustomSX1262Wrapper | 1,666,081 | 1,666,097 | +16 | +0 | +| `Heltec_v3_companion_radio_wifi` | espressif32 | CustomSX1262Wrapper | 1,102,253 | 1,102,269 | +16 | +0 | +| `Heltec_v3_sensor` | espressif32 | CustomSX1262Wrapper | 1,180,701 | 1,180,717 | +16 | +0 | +| `RAK_4631_repeater` | nordicnrf52 | CustomSX1262Wrapper | 441,384 | 441,368 | -16 | +0 | +| `RAK_4631_room_server` | nordicnrf52 | CustomSX1262Wrapper | 434,552 | 434,552 | +0 | +0 | +| `RAK_4631_companion_radio_ble` | nordicnrf52 | CustomSX1262Wrapper | 462,084 | 462,068 | -16 | +0 | +| `RAK_4631_sensor` | nordicnrf52 | CustomSX1262Wrapper | 438,152 | 438,136 | -16 | +0 | +| `PicoW_repeater` | platform-raspberrypi.git | CustomSX1262Wrapper | 492,420 | 492,420 | +0 | +0 | +| `wio-e5-mini_repeater` | ststm32 | CustomSTM32WLxWrapper | 203,832 | 203,832 | +0 | +0 | +| `wio-e5-mini_sensor` | ststm32 | CustomSTM32WLxWrapper | 203,304 | 203,304 | +0 | +0 | +| `RAK_3112_repeater_bridge_espnow` | espressif32 | CustomSX1262Wrapper | 1,175,509 | 1,175,521 | +12 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | platform-raspberrypi.git | CustomSX1262Wrapper | 240,832 | 240,832 | +0 | +0 | +| `ProMicro_repeater` | nordicnrf52 | CustomSX1262Wrapper | 345,824 | 345,824 | +0 | +0 | +| `ProMicro_room_server` | nordicnrf52 | CustomSX1262Wrapper | 338,920 | 338,920 | +0 | +0 | +| `ProMicro_companion_radio_usb` | nordicnrf52 | CustomSX1262Wrapper | 356,836 | 356,836 | +0 | +0 | +| `ProMicro_sensor` | nordicnrf52 | CustomSX1262Wrapper | 342,476 | 342,476 | +0 | +0 | +| `Ebyte_EoRa-S3_Repeater` | espressif32 | CustomSX1262Wrapper | 1,107,901 | 1,107,913 | +12 | +0 | +| `GAT562_30S_Mesh_Kit_repeater` | nordicnrf52 | CustomSX1262Wrapper | 410,992 | 410,992 | +0 | +0 | +| `GAT562_Mesh_EVB_Pro_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,220 | 365,220 | +0 | +0 | +| `GAT562_Mesh_Tracker_Pro_repeater` | nordicnrf52 | CustomSX1262Wrapper | 380,684 | 380,700 | +16 | +0 | +| `GAT562_Mesh_Watch13_companion_radio_ble` | nordicnrf52 | CustomSX1262Wrapper | 397,480 | 397,480 | +0 | +0 | +| `Generic_E22_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,142,973 | 1,142,985 | +12 | +0 | +| `Heltec_ct62_repeater` | espressif32 | CustomSX1262Wrapper | 1,151,498 | 1,151,490 | -8 | +0 | +| `Heltec_E213_repeater` | espressif32 | CustomSX1262Wrapper | 1,161,701 | 1,161,713 | +12 | +0 | +| `Heltec_E290_repeater` | espressif32 | CustomSX1262Wrapper | 1,159,617 | 1,159,629 | +12 | +0 | +| `Heltec_mesh_solar_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,184 | 365,184 | +0 | +0 | +| `heltec_rc32_repeater` | espressif32 | CustomSX1262Wrapper | 1,168,277 | 1,168,293 | +16 | +0 | +| `Heltec_t096_repeater` | nordicnrf52 | CustomSX1262Wrapper | 413,888 | 413,904 | +16 | +0 | +| `Heltec_t1_repeater` | nordicnrf52 | CustomSX1262Wrapper | 412,948 | 412,964 | +16 | +0 | +| `Heltec_t114_repeater` | nordicnrf52 | CustomSX1262Wrapper | 397,884 | 397,884 | +0 | +0 | +| `Heltec_T190_repeater_` | espressif32 | CustomSX1262Wrapper | 1,138,889 | 1,138,897 | +8 | +0 | +| `Heltec_tower_v2_repeater` | nordicnrf52 | CustomSX1262Wrapper | 309,128 | 309,144 | +16 | +0 | +| `Heltec_Wireless_Tracker_repeater` | espressif32 | CustomSX1262Wrapper | 1,157,053 | 1,157,065 | +12 | +0 | +| `heltec_tracker_v2_repeater` | espressif32 | CustomSX1262Wrapper | 1,225,429 | 1,225,437 | +8 | +0 | +| `Heltec_v2_repeater` | espressif32 | CustomSX1276Wrapper | 1,151,425 | 1,151,437 | +12 | +0 | +| `heltec_v4_expansionkit_repeater` | espressif32 | CustomSX1262Wrapper | 1,172,333 | 1,172,341 | +8 | +0 | +| `heltec_v4_r8_repeater` | espressif32 | CustomSX1262Wrapper | 1,175,789 | 1,175,793 | +4 | +0 | +| `Heltec_Wireless_Paper_repeater` | espressif32 | CustomSX1262Wrapper | 1,121,353 | 1,121,365 | +12 | +0 | +| `ikoka_handheld_nrf_e22_30dbm_repeater` | nordicnrf52 | CustomSX1262Wrapper | 357,720 | 357,720 | +0 | +0 | +| `ikoka_nano_nrf_22dbm_repeater` | nordicnrf52 | CustomSX1262Wrapper | 360,616 | 360,616 | +0 | +0 | +| `ikoka_stick_nrf_22dbm_repeater` | nordicnrf52 | CustomSX1262Wrapper | 373,192 | 373,192 | +0 | +0 | +| `KeepteenLT1_repeater` | nordicnrf52 | CustomSX1262Wrapper | 321,612 | 321,628 | +16 | +0 | +| `LilyGo_T3S3_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,107,949 | 1,107,961 | +12 | +0 | +| `LilyGo_T3S3_sx1276_repeater` | espressif32 | CustomSX1276Wrapper | 1,105,021 | 1,105,033 | +12 | +0 | +| `LilyGo_T_Impulse_Plus_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,932 | 365,948 | +16 | +0 | +| `LilyGo_TBeam_1W_repeater` | espressif32 | CustomSX1262Wrapper | 1,154,409 | 1,154,425 | +16 | +0 | +| `Tbeam_SX1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,204,605 | 1,204,617 | +12 | +0 | +| `Tbeam_SX1276_repeater` | espressif32 | CustomSX1276Wrapper | 1,200,645 | 1,200,653 | +8 | +0 | +| `T_Beam_S3_Supreme_SX1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,154,977 | 1,154,993 | +16 | +0 | +| `LilyGo_TDeck_repeater` | espressif32 | CustomSX1262Wrapper | 1,144,121 | 1,144,137 | +16 | +0 | +| `LilyGo_T-Echo_repeater` | nordicnrf52 | CustomSX1262Wrapper | 349,488 | 349,488 | +0 | +0 | +| `LilyGo_T-Echo_Card_repeater` | nordicnrf52 | CustomSX1262Wrapper | 321,952 | 321,952 | +0 | +0 | +| `LilyGo_T-Echo-Lite_repeater` | nordicnrf52 | CustomSX1262Wrapper | 335,340 | 335,340 | +0 | +0 | +| `LilyGo_TETH_Elite_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,075,753 | 1,075,765 | +12 | +0 | +| `LilyGo_Tlora_C6_repeater_` | platform-espressif32.zip | CustomSX1262Wrapper | 542,067 | 542,077 | +10 | +0 | +| `LilyGo_TLora_V2_1_1_6_repeater` | espressif32 | CustomSX1276Wrapper | 1,662,685 | 1,662,693 | +8 | +0 | +| `Mesh_pocket_repeater` | nordicnrf52 | CustomSX1262Wrapper | 326,456 | 326,456 | +0 | +0 | +| `Meshadventurer_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,161,541 | 1,161,553 | +12 | +0 | +| `meshnology_w12_repeater` | espressif32 | CustomLR2021Wrapper | 1,182,665 | 1,182,677 | +12 | +0 | +| `Meshtiny_companion_radio_ble` | nordicnrf52 | CustomSX1262Wrapper | 335,568 | 335,584 | +16 | +0 | +| `MeshTracker_X1_repeater` | nordicnrf52 | CustomLR2021Wrapper | 300,752 | 300,736 | -16 | +0 | +| `Minewsemi_me25ls01_repeater` | nordicnrf52 | CustomLR1110Wrapper | 306,592 | 306,592 | +0 | +0 | +| `muzi_base_duo_repeater` | nordicnrf52 | CustomLR1121Wrapper | 296,948 | 296,932 | -16 | +0 | +| `R1Neo_repeater` | nordicnrf52 | CustomSX1262Wrapper | 368,356 | 368,356 | +0 | +0 | +| `Nano_G2_Ultra_repeater` | nordicnrf52 | CustomSX1262Wrapper | 319,084 | 319,084 | +0 | +0 | +| `nibble_screen_connect_repeater_` | espressif32 | CustomSX1262Wrapper | 1,102,645 | 1,102,657 | +12 | +0 | +| `nibble_zero_connect_repeater_` | espressif32 | CustomSX1262Wrapper | 1,102,745 | 1,102,757 | +12 | +0 | +| `RAK_11310_repeater` | platform-raspberrypi.git | CustomSX1262Wrapper | 235,924 | 235,924 | +0 | +0 | +| `RAK_3401_repeater` | nordicnrf52 | CustomSX1262Wrapper | 410,464 | 410,464 | +0 | +0 | +| `RAK_3x72_repeater` | ststm32 | CustomSTM32WLxWrapper | 196,472 | 196,472 | +0 | +0 | +| `RAK_WisMesh_Tag_repeater` | nordicnrf52 | CustomSX1262Wrapper | 368,516 | 368,516 | +0 | +0 | +| `SenseCap_Solar_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,532 | 365,548 | +16 | +0 | +| `Station_G2_logging_repeater` | espressif32 | CustomSX1262Wrapper | 1,207,117 | 1,207,129 | +12 | +0 | +| `Station_G3_ESP32_logging_repeater` | espressif32 | CustomSX1262Wrapper | 1,208,705 | 1,208,721 | +16 | +0 | +| `t1000e_repeater` | nordicnrf52 | CustomLR1110Wrapper | 294,360 | 294,344 | -16 | +0 | +| `Tenstar_C3_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,152,016 | 1,152,016 | +0 | +0 | +| `ThinkNode_M1_repeater` | nordicnrf52 | CustomSX1262Wrapper | 305,780 | 305,764 | -16 | +0 | +| `ThinkNode_M2_Repeater` | espressif32 | CustomSX1262Wrapper | 1,109,625 | 1,109,637 | +12 | +0 | +| `ThinkNode_M3_repeater` | nordicnrf52 | CustomLR1110Wrapper | 301,908 | 301,908 | +0 | +0 | +| `ThinkNode_M4_repeater` | nordicnrf52 | CustomLR1110Wrapper | 301,884 | 301,900 | +16 | +0 | +| `ThinkNode_M5_Repeater` | espressif32 | CustomSX1262Wrapper | 1,126,905 | 1,126,917 | +12 | +0 | +| `ThinkNode_M6_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,652 | 365,668 | +16 | +0 | +| `ThinkNode_M7_repeater` | espressif32 | CustomLR1110Wrapper | 1,088,661 | 1,088,673 | +12 | +0 | +| `ThinkNode_M8_repeater` | nordicnrf52 | CustomSX1262Wrapper | FAIL | FAIL | | | +| `ThinkNode_M9_repeater_` | espressif32 | CustomLR1110Wrapper | 1,182,137 | 1,182,153 | +16 | +0 | +| `Tiny_Relay_repeater` | ststm32 | CustomSTM32WLxWrapper | 196,480 | 196,480 | +0 | +0 | +| `Waveshare_S3_LR1121_repeater` | espressif32 | CustomLR1121Wrapper | 1,084,309 | 1,084,317 | +8 | +0 | +| `wio-e5-repeater_bridge_rs232` | ststm32 | CustomSTM32WLxWrapper | 198,760 | 198,768 | +8 | +0 | +| `WioTrackerL1_repeater` | nordicnrf52 | CustomSX1262Wrapper | 377,148 | 377,148 | +0 | +0 | +| `WioTrackerL1-1W_repeater` | nordicnrf52 | CustomSX1262Wrapper | 378,172 | 378,172 | +0 | +0 | +| `wio_wm1110_repeater` | nordicnrf52 | CustomLR1110Wrapper | 344,828 | 344,828 | +0 | +0 | +| `Xiao_C3_repeater` | espressif32 | CustomSX1262Wrapper | 1,157,598 | 1,157,590 | -8 | +0 | +| `Xiao_nrf52_repeater` | nordicnrf52 | CustomSX1262Wrapper | 361,224 | 361,224 | +0 | +0 | +| `Xiao_rp2040_repeater` | platform-raspberrypi.git | CustomSX1262Wrapper | 235,548 | 235,548 | +0 | +0 | +| `Xiao_S3_repeater` | espressif32 | CustomSX1262Wrapper | 1,136,809 | 1,136,821 | +12 | +0 | +| `Xiao_S3_WIO_repeater` | espressif32 | CustomSX1262Wrapper | 1,127,189 | 1,127,201 | +12 | +0 | +| `Generic_E22_sx1268_repeater` | espressif32 | CustomSX1268Wrapper | 1,142,769 | 1,142,781 | +12 | +0 | +| `Generic_ESPNOW_repeatr` | espressif32 | none | FAIL | FAIL | | | +| `Xiao_C6_repeater_` | platform-espressif32.zip | CustomSX1262Wrapper | 542,095 | 542,095 | +0 | +0 | +| `M5Stack_Unit_C6L_repeater` | platform-espressif32.zip | CustomSX1262Wrapper | 552,445 | 552,445 | +0 | +0 | + +
+ +### Hardware + +No hardware evidence. The change moves a function whose body is unchanged into +another translation unit, and the builds show the firmware links the shared +definition. A board run would only show that a new identity is created on first +boot, which nothing in this step can change differently per board. If a reviewer +wants one, the check is: erase the identity on a board (`/identity` on the file +system), boot this firmware, see a new public key in `ver`/advert. + +## Cut B: one FEM RX gain handler + +### What changes + +- `src/helpers/FemRxGain.h` (new) holds the bodies of `attachDynamicPrefs()` and + `handleCommand()` that HeltecV4Board, HeltecV4R8Board, T096Board and + HeltecTrackerV2Board carried, as a class template on the board class: + ```cpp + template + class FemRxGain { + KeyValueStore* _prefs = NULL; + public: + void attachPrefs(BOARD& board, KeyValueStore* prefs); + bool handleCommand(BOARD& board, const char* command, char* reply); + }; + ``` + The bodies are the boards' own, with `board.` in front of the board members they + use (`loRaFEMControl`, `setLoRaFemLnaEnabled()`, `isLoRaFemLnaEnabled()`) and one + difference, the null check described below. +- Each of the four board headers replaces `KeyValueStore* _prefs = NULL;` with + ```cpp + friend class FemRxGain; + FemRxGain fem_rxgain; + ``` + and includes the new header. Each board source keeps both functions, as one line + that forwards to `fem_rxgain`. The diff in `variants/` is 8 files, 20 lines added, + 156 removed. +- Unchanged: the boards' base classes, their public interface (both functions keep + their signature, `handleCommand()` still overrides `MainBoard`), their own + `setLoRaFemLnaEnabled()` and `isLoRaFemLnaEnabled()`, which stay private + (protected in T096Board), and their `LoRaFEMControl` classes. + +Measured with `tools/refactoring/variant_duplication.py` after the step: the four +boards' `attachDynamicPrefs()` is 94 characters normalized (was 217), +`handleCommand()` 146 (was 999), still one body for the four, now a forwarding line. +TBeam1W, StationG3 and WioTrackerL1 keep their own handlers (fan settings, other +commands), as planned. + +### Why it is the same code + +- The bodies were compared as text before the move (one body for the four once the + class name is replaced, see "Measured on the commit this step started from"). The + host tests run a verbatim copy of the HeltecV4Board bodies and the shared template + against the same cases, with the same expectations (see "Host tests"). +- `_prefs` keeps its place: it was the first data member of each board and + `fem_rxgain` is now, and `FemRxGain` holds nothing but that pointer (a test checks + `sizeof(FemRxGain<...>) == sizeof(KeyValueStore*)`). So the boards keep size and + layout, and RAM is unchanged in every build (see "Builds"). +- The callers are unchanged: `ServerMesh::begin()` and the companion's `begin()` call + `board.attachDynamicPrefs(_prefs.getCustom())`, `CommonCLI` calls + `_board->handleCommand(...)` (the three server roles). Both go through the board's + own functions as before. + +### The null store, and how this relates to meshcore-dev/MeshCore#3496 + +The open upstream pull request #3496 (KISS modem AGC and FEM gain control) changes +the two `setByKey` lines in each of the four boards to +`if (_prefs) _prefs->setByKey(...)`, because its KISS modem calls the board's +`handleCommand()` without ever calling `attachDynamicPrefs()`, and the copies then +dereference NULL. The shared code has the same check, in the same two places: + +```cpp +if (_prefs) _prefs->setByKey("fem_rxgain", "1"); +``` + +- Behaviour in this tree: unchanged. The KISS modem here does not call + `handleCommand()`, and every role that does (the three server roles through + `CommonCLI`) attaches the store in `ServerMesh::begin()` first. So no reply and no + store write changes; only the case that dereferenced NULL before is now defined + (the LNA is switched, the reply is the same, nothing is stored). A test pins that. +- `attachPrefs(board, NULL)` is not guarded, as in the copies and in #3496: no caller + passes NULL, and what a board should do without a store at boot (LNA on, off or + unchanged) is a product decision this step does not make. +- Order: the check is taken over now so that the shared code is correct for either + order. If this step lands first, #3496 drops its four variant hunks on rebase (they + edit lines that no longer exist and the shared code already has the check); the + rest of #3496 is not touched by this step. If #3496 lands first, this step's + variant diff deletes the guarded lines instead of the unguarded ones and nothing + else changes. Checked on 20.10.2026: the variant hunks of #3496 are exactly those + two lines per board, 8 in all (`git diff upstream/dev pull/3496/head` per file), and + applied to this step's tree with `git apply --check -3` all four conflict, as + expected. + +The other two upstream pull requests that touch these four files: + +- **#3453** (standardized `powerOff()`, `shutdownPeripherals()`, `enterDeepSleep()`, + open) edits `powerOff()` in all four sources and headers and removes + `HeltecV4R8Board::enterDeepSleep()`. It does not change the FEM handlers. Applied to + this step's tree with `git apply --check -3`: six of the eight files apply cleanly, + `HeltecV4Board.cpp` and `HeltecTrackerV2Board.cpp` conflict on one hunk that only + re-indents the closing brace of `handleCommand()`, a brace this step replaces. A + rebase resolution, no logic. +- **#2682** (ThinkNode M4 variant, open) lists the four files, but its head is + identical to `upstream/dev` in all of them (0 changed lines, + `git diff upstream/dev pull/2682/head` per file): the entries come from a merge of + `dev` into the branch. No conflict. + +### Alternatives considered + +- **A base class with virtual calls** (`class FemLnaBoard { virtual bool + setLoRaFemLnaEnabled(bool) = 0; ... }` and a non-template handler compiled once). + Rejected: it adds a vtable pointer to each board object (RAM) and indirect calls + for code that exists once per firmware anyway, because a firmware contains exactly + one board. Upstream had such virtuals on `MainBoard` (`setLoRaFemLnaEnabled()`, + `canControlLoRaFemLna()`, `isLoRaFemLnaEnabled()`, with the commands in `CommonCLI`) + and removed them in August 2026 (`47b0b7b3`, `a1cf5bd8`), moving the commands into + the boards; going back to them is not this step's call. +- **A mixin between the board and its base** (`class HeltecV4Board : public + FemRxGainBoard`). Rejected: it changes each board's base + class, and T096Board initializes its virtual base `NRF52Board` directly, which a + mixin template would have to forward. The forwarding lines it saves are two per + board. +- **Move `setLoRaFemLnaEnabled()` and `isLoRaFemLnaEnabled()` into the shared code + as well.** Not in this step: they are identical in the four, but they belong with + the boards' `LoRaFEMControl`, which is its own class per variant (the four boards' + `LoRaFEMControl.cpp` all differ, as do those of HeltecTowerV2 and StationG3). That + is a separate concern for a later step. +- **Template on the FEM control class instead of the board.** Rejected: the handler + needs the board's `setLoRaFemLnaEnabled()`, which also puts the FEM back into RX + mode, not only the FEM control. + +### Host tests + +`test_fem_rxgain` (new, in the `native` environment, header only) is a typed suite +that runs the same cases against two boards built on a stand-in for the boards' +common parts (public `loRaFEMControl`, the boards' own `setLoRaFemLnaEnabled()` and +`isLoRaFemLnaEnabled()`, plus a switch that makes a switch fail): + +- the first commit: a verbatim copy of the HeltecV4Board bodies, class name replaced, +- the second commit adds the shared `FemRxGain`, with the cases unchanged. + +The store is a recording `KeyValueStore` whose `getByKey()` copies like +`DynamicConfigSerializer` (strncpy, terminator at `max_len`). Commands are passed in a +zero filled buffer, as `CommonCLI` passes them. Pinned: + +- attach reads `fem_rxgain` once with `max_len` 7, switches the LNA on only for exactly + `"1"` (off for `"0"`, `""`, `"on"`, `"10"`, `" 1"`, `"1 "`, `"true"`, a missing key and a + value cut to seven characters), tries even when the LNA cannot be controlled, and + writes nothing, +- `get radio.fem.rxgain`: `> on`, `> off`, or `Error: unsupported`, +- `set radio.fem.rxgain on|off`: switches the LNA, writes `"1"` or `"0"` to the store + attached last, replies `OK - LoRa FEM RX gain on|off`, ignores the store's return + value; the state matches by its first two or three characters (`onward`, `offline`); + any other state is `Error: state must be on or off`; `Error: unsupported` when the + LNA cannot be controlled; `Error: failed to apply LoRa FEM RX gain` and nothing + stored when the switch fails, +- every other command (`get radio.fem.rxgain ` with a trailing space, `set + radio.fem.rxgain` without one, other keys, upper case, empty) is not handled and + the reply is left as it was. + +The second commit adds two tests for the shared code only: `set` without an attached +store switches the LNA and replies as with one, and `FemRxGain` is the size of a +pointer. + +All 15 host environments pass on both commits: 289 test cases on the first, 307 on +the second (273 on cut A). + +Mutation tests on the shared code, each of which must fail: + +| mutation | result | +|---|---| +| M1: drop the null check before the `"1"` write | the test without a store crashes (SIGSEGV), the suite fails | +| M2: store `"on"` instead of `"1"` | 1 test fails | +| M3: attach switches on for anything but `"1"` (`!= 0`) | 6 tests fail | +| M4: match the `set` prefix with 20 instead of 21 characters | 1 test fails (`set radio.fem.rxgain` without the space is handled) | +| M5: read the stored value with `max_len` 8 | the terminator lands outside the 8 byte buffer, the stack protector aborts the suite | +| M6: write `"0"` before switching the LNA off | 3 tests fail | +| M7: match `off` by two characters | 1 test fails (`of` is taken as off) | +| M8: reply `>on` instead of `> on` | 2 tests fail | +| M9: drop the `unsupported` check of `set` | 1 test fails | + +### Builds + +68 environments were built from cut A (`a3d3961e`, the commit this step started +from; the test commit changes no firmware source) and from the code commit of this +step (`2884fd34`), each side in its own worktrees, with the same toolchain: + +- the 18 environments every step of the series is built for (all roles, all + platforms), +- all 50 environments of the four boards (every environment whose + `build_src_filter` names `variants/heltec_v4`, `heltec_v4_r8`, `heltec_t096` or + `heltec_tracker_v2`, from `pio project config`): repeater, room server, sensor, + terminal chat, companion USB, BLE and WiFi, KISS modem and the bridges, OLED and + TFT. No other source file includes the four board headers (checked with `grep`). + +Platforms in the set: 49 ESP32, 15 nRF52, 2 STM32WL, 2 RP2040. Of the four boards, +T096 is nRF52 (7 environments) and the other three are ESP32-S3 (43). + +Result: + +- 67 build on both commits. `heltec_tracker_v2_kiss_modem` fails on both with the + same error (`examples/kiss_modem/main.cpp:118`, `USBCDC` has no + `setTxBufferSize`), in the KISS modem, not in the board; the other three boards' + KISS modem environments build and link the shared code. +- Five builds were stopped by a signal (exit 143) on the first run, by another + session's `pkill` on the same machine, and were rebuilt from clean: all five + build. +- RAM is unchanged in all 67. +- Flash: unchanged in the 18 series environments (none builds one of the four + boards). In the 49 environments of the four boards it grows by 16 to 72 bytes: + - T096 (nRF52), 7 environments: +16. Measured with `nm`: `T096Board::handleCommand()` + and its virtual thunk (the board has a virtual base) are each 8 bytes larger + (0x148 to 0x150, 0x14c to 0x150), the two null checks; the template is inlined. + - ESP32, 42 environments: +24 to +72, most +56. On `heltec_v4_repeater` (+56): + `.flash.text` +32, of which `FemRxGain::handleCommand()` is 8 bytes + larger than the board's own body was (0xe2 to 0xea, the null checks) and the + board's `handleCommand()` is now a forwarder of 0x15 bytes, with alignment + between functions; `.flash.rodata` +24, not attributed further. + `attachDynamicPrefs()` is inlined into the board's function and keeps its size + (0x52). + +The growth is the price of the null check and, on ESP32, of one forwarding function +that the compiler does not merge with the template. A board without a virtual base +could avoid the forwarder only by making `handleCommand()` the template's own +override (the rejected mixin). + +
+All 68 environments + +| Environment | Flash before | Flash after | Flash delta | RAM before | RAM after | RAM delta | +|---|---:|---:|---:|---:|---:|---:| +| `Heltec_v3_repeater` | 1,174,209 | 1,174,209 | +0 | 62,512 | 62,512 | +0 | +| `Heltec_v3_room_server` | 1,169,497 | 1,169,497 | +0 | 66,200 | 66,200 | +0 | +| `Heltec_v3_companion_radio_ble` | 1,666,097 | 1,666,097 | +0 | 194,844 | 194,844 | +0 | +| `Heltec_v3_companion_radio_wifi` | 1,102,269 | 1,102,269 | +0 | 172,384 | 172,384 | +0 | +| `Heltec_v3_sensor` | 1,180,717 | 1,180,717 | +0 | 60,600 | 60,600 | +0 | +| `RAK_4631_repeater` | 441,368 | 441,368 | +0 | 37,240 | 37,240 | +0 | +| `RAK_4631_room_server` | 434,552 | 434,552 | +0 | 40,928 | 40,928 | +0 | +| `RAK_4631_companion_radio_ble` | 462,068 | 462,068 | +0 | 149,768 | 149,768 | +0 | +| `RAK_4631_sensor` | 438,136 | 438,136 | +0 | 35,328 | 35,328 | +0 | +| `PicoW_repeater` | 492,420 | 492,420 | +0 | 85,368 | 85,368 | +0 | +| `wio-e5-mini_repeater` | 203,832 | 203,832 | +0 | 17,172 | 17,172 | +0 | +| `wio-e5-mini_sensor` | 203,304 | 203,304 | +0 | 15,276 | 15,276 | +0 | +| `RAK_3112_repeater_bridge_espnow` | 1,175,521 | 1,175,521 | +0 | 64,072 | 64,072 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | 240,832 | 240,832 | +0 | 26,704 | 26,704 | +0 | +| `ProMicro_repeater` | 345,824 | 345,824 | +0 | 33,892 | 33,892 | +0 | +| `ProMicro_room_server` | 338,920 | 338,920 | +0 | 37,588 | 37,588 | +0 | +| `ProMicro_companion_radio_usb` | 356,836 | 356,836 | +0 | 99,468 | 99,468 | +0 | +| `ProMicro_sensor` | 342,476 | 342,476 | +0 | 31,988 | 31,988 | +0 | +| `heltec_v4_r8_repeater` | 1,175,793 | 1,175,857 | +64 | 62,400 | 62,400 | +0 | +| `heltec_v4_r8_room_server` | 1,171,153 | 1,171,209 | +56 | 66,088 | 66,088 | +0 | +| `heltec_v4_r8_terminal_chat` | 603,329 | 603,389 | +60 | 95,752 | 95,752 | +0 | +| `heltec_v4_r8_companion_radio_usb` | 654,449 | 654,509 | +60 | 103,288 | 103,288 | +0 | +| `heltec_v4_r8_companion_radio_ble` | 1,667,785 | 1,667,845 | +60 | 194,732 | 194,732 | +0 | +| `heltec_v4_r8_companion_radio_wifi` | 1,103,937 | 1,103,993 | +56 | 172,272 | 172,272 | +0 | +| `heltec_v4_r8_sensor` | 1,182,385 | 1,182,441 | +56 | 60,488 | 60,488 | +0 | +| `heltec_v4_r8_tft_repeater` | 1,177,885 | 1,177,941 | +56 | 62,496 | 62,496 | +0 | +| `heltec_v4_r8_tft_room_server` | 1,173,109 | 1,173,165 | +56 | 66,192 | 66,192 | +0 | +| `heltec_v4_r8_tft_terminal_chat` | 603,721 | 603,777 | +56 | 95,752 | 95,752 | +0 | +| `heltec_v4_r8_tft_companion_radio_usb` | 658,313 | 658,369 | +56 | 103,440 | 103,440 | +0 | +| `heltec_v4_r8_tft_companion_radio_ble` | 1,284,809 | 1,284,869 | +60 | 172,044 | 172,044 | +0 | +| `heltec_v4_r8_tft_companion_radio_wifi` | 1,107,789 | 1,107,845 | +56 | 172,424 | 172,424 | +0 | +| `heltec_v4_r8_tft_sensor` | 1,184,369 | 1,184,429 | +60 | 60,592 | 60,592 | +0 | +| `heltec_v4_r8_kiss_modem` | 582,409 | 582,465 | +56 | 24,464 | 24,464 | +0 | +| `heltec_v4_r8_tft_kiss_modem` | 582,797 | 582,853 | +56 | 24,464 | 24,464 | +0 | +| `Heltec_t096_repeater` | 413,904 | 413,920 | +16 | 35,564 | 35,564 | +0 | +| `Heltec_t096_repeater_bridge_rs232` | 416,144 | 416,160 | +16 | 37,148 | 37,148 | +0 | +| `Heltec_t096_room_server` | 407,040 | 407,056 | +16 | 39,260 | 39,260 | +0 | +| `Heltec_t096_sensor` | 410,724 | 410,740 | +16 | 33,660 | 33,660 | +0 | +| `Heltec_t096_companion_radio_ble` | 431,524 | 431,540 | +16 | 148,052 | 148,052 | +0 | +| `Heltec_t096_companion_radio_usb` | 424,788 | 424,804 | +16 | 101,140 | 101,140 | +0 | +| `Heltec_t096_kiss_modem` | 340,360 | 340,376 | +16 | 21,700 | 21,700 | +0 | +| `heltec_v4_repeater` | 1,172,273 | 1,172,329 | +56 | 62,476 | 62,476 | +0 | +| `heltec_v4_expansionkit_repeater` | 1,172,341 | 1,172,393 | +52 | 62,476 | 62,476 | +0 | +| `heltec_v4_repeater_bridge_espnow` | 1,182,797 | 1,182,857 | +60 | 64,132 | 64,132 | +0 | +| `heltec_v4_room_server` | 1,167,677 | 1,167,745 | +68 | 66,164 | 66,164 | +0 | +| `heltec_v4_terminal_chat` | 599,877 | 599,933 | +56 | 95,832 | 95,832 | +0 | +| `heltec_v4_companion_radio_usb` | 651,001 | 651,057 | +56 | 103,368 | 103,368 | +0 | +| `heltec_v4_companion_radio_ble` | 1,664,305 | 1,664,357 | +52 | 194,808 | 194,808 | +0 | +| `heltec_v4_companion_radio_wifi` | 1,100,445 | 1,100,501 | +56 | 172,348 | 172,348 | +0 | +| `heltec_v4_sensor` | 1,178,909 | 1,178,965 | +56 | 60,564 | 60,564 | +0 | +| `heltec_v4_tft_repeater` | 1,174,021 | 1,174,077 | +56 | 62,572 | 62,572 | +0 | +| `heltec_v4_tft_repeater_bridge_espnow` | 1,184,529 | 1,184,581 | +52 | 64,228 | 64,228 | +0 | +| `heltec_v4_tft_room_server` | 1,169,221 | 1,169,277 | +56 | 66,268 | 66,268 | +0 | +| `heltec_v4_tft_terminal_chat` | 599,877 | 599,933 | +56 | 95,832 | 95,832 | +0 | +| `heltec_v4_tft_companion_radio_usb` | 652,677 | 652,733 | +56 | 103,464 | 103,464 | +0 | +| `heltec_v4_tft_companion_radio_ble` | 1,279,125 | 1,279,181 | +56 | 172,056 | 172,056 | +0 | +| `heltec_v4_tft_companion_radio_wifi` | 1,102,173 | 1,102,229 | +56 | 172,444 | 172,444 | +0 | +| `heltec_v4_tft_sensor` | 1,180,513 | 1,180,569 | +56 | 60,668 | 60,668 | +0 | +| `heltec_v4_kiss_modem` | 578,941 | 578,997 | +56 | 24,544 | 24,544 | +0 | +| `heltec_tracker_v2_repeater` | 1,225,437 | 1,225,501 | +64 | 74,344 | 74,344 | +0 | +| `heltec_tracker_v2_repeater_bridge_espnow` | 1,235,873 | 1,235,917 | +44 | 76,000 | 76,000 | +0 | +| `heltec_tracker_v2_room_server` | 1,220,737 | 1,220,793 | +56 | 78,040 | 78,040 | +0 | +| `heltec_tracker_v2_terminal_chat` | 627,357 | 627,413 | +56 | 107,480 | 107,480 | +0 | +| `heltec_tracker_v2_companion_radio_usb` | 707,033 | 707,105 | +72 | 115,272 | 115,272 | +0 | +| `heltec_tracker_v2_companion_radio_ble` | 1,333,625 | 1,333,681 | +56 | 183,836 | 183,836 | +0 | +| `heltec_tracker_v2_companion_radio_wifi` | 1,153,609 | 1,153,633 | +24 | 184,224 | 184,224 | +0 | +| `heltec_tracker_v2_sensor` | 1,231,965 | 1,232,021 | +56 | 72,440 | 72,440 | +0 | +| `heltec_tracker_v2_kiss_modem` | FAIL | FAIL | | | | | + +
+ +### Hardware + +No hardware evidence. None of the four boards (Heltec V4, V4 R8, T096, Tracker V2) is +in the device pool (`kb device list`, 20.10.2026). The handlers' code is unchanged +apart from the null check, the host tests show the same replies and store writes, and +the builds show every environment of the four boards compiles and links the shared +code with unchanged RAM. If a reviewer wants a board run, the check on any of the +four: `get radio.fem.rxgain`, `set radio.fem.rxgain off`, reboot, `get radio.fem.rxgain` +shows `> off`; the same with `on`; on a board whose FEM cannot switch the LNA both +reply `Error: unsupported`. + +## Cut C: one default `radio_init()` + +The design (up to "Upstream collisions") was written and committed before any code +of this cut; the results follow from "Host tests" on. + +### Inventory of the `radio_init()` bodies + +Measured on `e5a8f164` (`integration`, cut B merged) with +`tools/refactoring/variant_duplication.py` (the bodies compared normalized: +comments and white space removed), plus `pio project config` for the environments +of each variant (an environment belongs to a variant when its `build_src_filter` +names `variants/`). 89 `target.cpp`, 29 different bodies. The groups with +three or more files: + +| files | envs | body (normalized) | platform, chip | variants | +|---:|---:|---|---|---| +| 28 | 172 | `rtc_clock.begin(Wire); return radio.std_init(&SPI);` | nRF52, SX1262 | `gat562_30s_mesh_kit`, `gat562_mesh_evb_pro`, `gat562_mesh_tracker_pro`, `gat562_mesh_watch13`, `heltec_mesh_solar`, `heltec_tower_v2`, `ikoka_handheld_nrf`, `ikoka_nano_nrf`, `ikoka_stick_nrf`, `keepteen_lt1`, `lilygo_t_impulse_plus`, `lilygo_techo`, `lilygo_techo_card`, `lilygo_techo_lite`, `meshtiny`, `muziworks_r1_neo`, `nano_g2_ultra`, `promicro`, `rak3401`, `rak4631`, `rak_wismesh_tag`, `sensecap_solar`, `thinknode_m1`, `thinknode_m6`, `thinknode_m8`, `wio-tracker-l1-1w`, `wio-tracker-l1`, `xiao_nrf52` | +| 18 | 168 | `fallback_clock.begin(); rtc_clock.begin(Wire);` then `std_init(&spi)` if `P_LORA_SCLK`, else `std_init()` | ESP32, SX1262/SX1268/SX1276 | `generic-e22`, `heltec_e213`, `heltec_e290`, `heltec_rc32`, `heltec_t190`, `heltec_tracker`, `heltec_tracker_v2`, `heltec_v2`, `heltec_v3`, `heltec_v4`, `heltec_v4_r8`, `heltec_wireless_paper`, `lilygo_tbeam_SX1276`, `lilygo_tlora_c6`, `lilygo_tlora_v2_1`, `meshadventurer`, `rak3112`, `tenstar_c3` | +| 5 | 32 | `fallback_clock.begin(); rtc_clock.begin(Wire); return radio.std_init(&spi);` | ESP32, SX1262 | `lilygo_t3s3`, `lilygo_tbeam_SX1262`, `lilygo_teth_elite`, `nibble_screen_connect`, `nibble_zero_connect` | +| 4 | 29 | as the 18, with `spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI)` before `std_init(&spi)` | ESP32, SX1262/SX1276 | `lilygo_t3s3_sx1276`, `station_g2`, `station_g3_esp32`, `xiao_c3` | +| 3 | 23 | `rtc_clock.begin(Wire);` then `std_init(&SPI)` if `P_LORA_SCLK`, else `std_init()` | nRF52, SX1262 | `heltec_t096`, `heltec_t1`, `heltec_t114` | +| 3 | 26 | as the 4, with `pinMode(21, INPUT); pinMode(48, OUTPUT);` first | ESP32, SX1262 | `thinknode_m2`, `xiao_s3`, `xiao_s3_wio` | + +The other 25 bodies occur once or twice; most of them are their own radio setup +(LR11x0, STM32WL, own `begin()` arguments), the rest differ in the SPI object +(`SPI1` on `rak11310` and `rpi_picow`) or leave out the RTC (`mesh_pocket`, +`meshtracker_x1`). + +### What cut C shares, and why exactly that + +Cut C shares the 28 and the 3 nRF52 bodies (31 files, 195 environments) and +nothing else. The criterion is the one the cut plan set: share only what needs no +new board description. + +- **The 28 and the 3 are one function.** The 3 differ from the 28 only in passing + nothing instead of `&SPI` when `P_LORA_SCLK` is not defined. `std_init()` of + every chip class that has one (`CustomSX1262`, `CustomSX1268`, `CustomSX1276`, + `CustomLLCC68`, `CustomLR2021`) reads its `spi` argument only inside + `#if defined(P_LORA_SCLK)`, so without `P_LORA_SCLK` the argument is unused and + both calls do the same; with it, both pass `&SPI`. Checked by reading the five + headers (`grep -n "P_LORA_SCLK\|spi" src/helpers/radiolib/Custom*.h`). +- **Everything the body names is already declared for the shared code.** All 31 + `target.h` declare `extern AutoDiscoverRTCClock rtc_clock;`, `Wire` and `SPI` are + the Arduino globals, and the radio is reached through `radio_driver` (see + below). Checked by script over the 31 files: each has exactly the declaration, + defines `RADIO_CLASS radio = new Module(...)` and `WRAPPER_CLASS + radio_driver(radio, board);`, uses `VolatileRTCClock fallback_clock` (which + needs no `begin()`, which is why none of them calls it), and has its + `radio_init()` outside any `#if`. None uses CRLF. +- **All 195 environments are nRF52 with `CustomSX1262` and `CustomSX1262Wrapper`** + (from `pio project config`), so one platform and one chip class to verify. +- **The 18, 5, 4 and 3 ESP32 bodies are left for cut D.** They start + `fallback_clock`, which is an `ESP32RTCClock` there and a file local global no + `target.h` declares, and most pass `spi`, a file local `SPIClass` of the + variant. Sharing them needs exactly the new description cut D introduces (which + fallback clock, which SPI object). `heltec_ct62` (`fallback_clock.begin()` and + `&SPI`) and the two `SPI1` variants belong to D for the same reason. + +### What changes + +- `src/helpers/radiolib/RadioLibTarget.cpp` gets a second weak definition, next + to `radio_new_identity()`: + ```cpp + #if defined(NRF52_PLATFORM) && defined(USE_SX1262) + __attribute__((weak)) bool radio_init() { + rtc_clock.begin(Wire); + return RadioLibTargetAccess::radio(radio_driver).std_init(&SPI); + } + #endif + ``` +- `RadioLibWrapper` names one more friend, a small accessor the shared target code + uses to reach the radio it wraps, as `RadioNoiseListener` does for cut A: + `static_cast(*driver._radio)`. The cast is the one every chip + wrapper already makes (`((CustomSX1262 *)_radio)`), and `radio_driver` wraps + `radio` in all 31 files (checked above). +- The 31 copies are deleted from `variants/*/target.cpp`, deletions only, as in + cut A. The declaration stays in every `target.h`. + +The guard: `NRF52_PLATFORM` and `USE_SX1262` (which `CustomSX1262Wrapper.h` +defines and every `target.h` of the 31 includes) are existing macros, so no new +description. It is that narrow on purpose: + +- The default is only correct where it was the copy. On ESP32 every RadioLib + variant starts its `fallback_clock`, on RP2040 they pass `SPI1` or `spi`. A new + ESP32 board that forgets its `radio_init()` should fail to link, not silently get + an nRF52 default that never starts its clock. +- The weak function is compiled in every environment where the guard holds, also + in variants that keep their own. Among the nRF52 variants with another chip, + `meshtracker_x1` (LR2021) declares `rtc_clock` as `VolatileRTCClock`, which has + no `begin(Wire)`, and the LR11x0 classes have no `std_init()`, so a wider guard + would not compile there. The two nRF52 SX1262 variants that keep their own + body (`mesh_pocket`, `muzi_base`) both declare the same `rtc_clock` and have + `std_init()`, so the default compiles there and is not used. + +### Alternatives considered + +- **Share the ESP32 groups now as well, with `fallback_clock` and `spi` declared + `extern` in the shared file.** Rejected: both are file local names of the + variant that no `target.h` declares, and whether there is a `spi` and which + clock class it is, is board description. That is cut D. +- **`extern RADIO_CLASS radio;` in the shared file.** Rejected for the reason given + in cut A: `radio` is not part of any variant's declared interface, + `radio_driver` is. Also, the weak default is compiled where the variant keeps + its own, and would then reference `radio` from a function that is not used. +- **A public `getRadio()` on `RadioLibWrapper`.** Rejected for now, as in cut A: it + widens the wrapper's interface for the shared target code only. With two users + (cut A's listener and this default) a friend accessor is still local; if cut D + constructs the radio in the shared code, the accessor goes away again. +- **A virtual `std_init()` on the wrapper.** Rejected: a vtable entry and an indirect + call in every RadioLib wrapper for a function called once at boot, and the + LR11x0 and STM32WL wrappers have no such function to override. +- **One default for every platform with `#if` branches per platform inside.** + Rejected: it would write the ESP32 and RP2040 board description as `#if` chains + into the shared code, which is what cut D replaces with the build flags. +- **Leave out the 3 (`heltec_t096`, `heltec_t1`, `heltec_t114`).** Not chosen: they + are the same function (see above), and deleting their copy is the same edit. + +### Verification plan + +- Host tests in `native_variant_target`, as for cut A. A first commit pins the + variants' body, copied verbatim into the test, with a recording RTC clock and a + recording radio: the RTC is started first, with `Wire`; then `std_init()` is + called once on the radio that `radio_driver` wraps, with `&SPI`; its result is + returned unchanged (`true` and `false`). The second commit runs the same cases + against the shared default. `native_variant_target_override` gets a variant + `radio_init()` of its own and checks it is the one called (does not link if the + shared one is not weak). +- Mutation tests on the shared default (examples: RTC started after the radio, + `std_init()` without `&SPI`, result inverted or dropped, not weak, guarded wider + or narrower). +- Builds from `e5a8f164` and from the code commit, each in its own worktree: one + environment for each of the 31 edited variants, `mesh_pocket` and `muzi_base` + (compile the default, keep their own), `meshtracker_x1` and an LR1110 nRF52 + environment (guard false), the 18 environments of the series' matrix (ESP32, + nRF52, RP2040, STM32), and the two C6 variants without a `target.cpp`. Flash and + RAM compared per environment; on one environment `nm` shows `radio_init()` weak + (`W`) and a variant that keeps its own shows it strong (`T`). +- No hardware unless a board from the 31 is free in the pool (`kb device claim`); + otherwise recorded as without board. + +### Upstream collisions + +Checked on 20.10.2026 against meshcore-dev/MeshCore: the file lists of all 500 +open pull requests (GraphQL, 100 files each; the four with more than 100 files +read through the REST file list), and the diffs of every open pull request that +touches one of the 31 `target.cpp`, checked hunk by hunk for changed lines in or +around `radio_init()`. + +- **No open pull request changes a line of the 31 `radio_init()` bodies.** +- **#1962** (keep time across warm resets on nRF52, open, updated 2026-10-12) + touches 19 of the 31 files, but only the line `VolatileRTCClock fallback_clock;` + (to `NRF52RTCClock`). No conflict with this cut. It is relevant for cut D: it + makes the nRF52 fallback clock a board choice as well. +- Context only (a hunk that shows `radio_init()` lines without changing them, so a + rebase without logic): #1799 (5 files), #1913, #1937, #2185, #2794, #3331, + #3374, #3527, #3613 (1 file each). +- **#2682** (ThinkNode M4) lists `thinknode_m8` and `wio-tracker-l1-1w` as changed, + but its head is identical to `upstream/dev` in both (`git diff upstream/dev + pull/2682/head`, 0 lines), as found for cut B: a merge of `dev`, not a change. +- Open pull requests that add an nRF52 SX1262 board carry their own + `radio_init()`; with the weak default they keep linking unchanged. + +### Host tests + +`native_variant_target` builds the real `RadioLibTarget.cpp` against the variant +stand-in, which gets a recording radio (`TestRadio`, a `PhysicalLayer` with +`std_init()`), a recording RTC clock with `begin(TwoWire&)`, and stand-ins for +`Wire.h` and `SPI.h` (only the types and the globals), all in +`test/mocks/radiolib_variant`. The environment sets `RADIO_CLASS=TestRadio` and +`NRF52_PLATFORM`, as the 195 environments do; the stand-in `target.h` defines +`USE_SX1262`, as `CustomSX1262Wrapper.h` does. + +The first commit (`da1589cf`) pins the variants' body, copied verbatim into the +test, as a parameterized suite: + +- the RTC is started with `Wire`, then `std_init(&SPI)` is called, in that order + and nothing else, +- `std_init()` is called once, +- its result is returned unchanged, `true` and `false` (on `false` the RTC is still + started first), +- a second radio that `radio_driver` does not wrap is not touched. + +The code commit (`2b7122c6`) runs the same five cases against the shared default, +with the cases unchanged, and `native_variant_target_override` defines a +`radio_init()` of its own and checks it is the one called. + +All 17 host environments pass on both commits: 571 test cases on the first, 577 on +the second (566 on `e5a8f164`). + +Mutation tests on the shared default, each of which must fail: + +| mutation | result | +|---|---| +| M1: start the radio before the RTC | 2 tests fail | +| M2: `std_init()` without `&SPI` | 2 tests fail | +| M3: return `true` whatever `std_init()` returned | 1 test fails | +| M4: do not start the RTC | 2 tests fail | +| M5: call `std_init()` twice | 3 tests fail | +| M6: shared default not weak | `native_variant_target_override` does not link (`duplicate symbol 'radio_init()'`) | +| M7: guard `USE_SX1268` instead of `USE_SX1262` | `native_variant_target` does not link (`radio_init()` undefined) | + +### What the code commit changes + +As designed above: the weak default and `RadioLibTargetAccess` in +`RadioLibTarget.cpp` (24 lines), one friend line in `RadioLibWrappers.h`, and in +`variants/` deletions only: 31 files, 183 lines (each copy and the blank line before +it), no line added or changed. `tools/refactoring/variant_duplication.py` after the +step: `radio_init()` in 58 files with 27 normalized bodies (was 89 and 29), the +largest group is the 18 ESP32 bodies left for cut D. + +### Builds + +53 environments were built from `e5a8f164` and from the code commit `2b7122c6`, each +side in its own worktree, with the same toolchain: + +- the 18 environments every step of the series is built for (all roles; ESP32, + nRF52, RP2040, STM32), +- one environment for each of the 31 edited variants (a repeater where there is + one; `rak4631` and `promicro` are among the 18), +- `Mesh_pocket_repeater` and `muzi_base_uno_repeater` (nRF52, SX1262, own body: + the default is compiled and not used), +- `MeshTracker_X1_repeater` (nRF52, LR2021) and `t1000e_repeater` (nRF52, LR1110), + where the guard is false, +- `Xiao_C6_repeater_` and `M5Stack_Unit_C6L_repeater`, the variants without a + `target.cpp`. + +Platforms in the set: 41 nRF52, 8 ESP32, 2 RP2040, 2 STM32WL. + +Result: + +- 52 build on both commits. `ThinkNode_M8_repeater` fails on both with the same + link errors (`EnvironmentSensorManager` undefined, known since cut A); + `radio_init()` is not among the undefined symbols, so it links the shared + default. +- RAM is unchanged in all 52. +- Flash is unchanged in every environment outside the 31 variants (ESP32, RP2040, + STM32, the two C6, and the four nRF52 controls). +- In the 31 variants' environments flash grows by 0 to 32 bytes: 30 by +16, 2 by + +32 (`ProMicro_repeater`, `ProMicro_sensor`), 4 by +0. Measured with `nm`: + `radio_init()` is 16 bytes larger than the variant's own (0xe8 to 0xf8 on + `RAK_4631_repeater`, 0xf4 to 0x104 on `ProMicro_repeater` and on the +0 ones), + because the radio is now loaded from `radio_driver` and cast, instead of being a + fixed address; on the +0 environments `.text` keeps its size, the 16 bytes fall + into alignment padding. `radio_init()` is a weak symbol (`W`) in the new firmware + where it was a normal one (`T`). +- `Mesh_pocket_repeater` keeps its own definition: same size, and `nm` shows its + `radio_init()` as `T` with the same size (0xd8) and address as before. The + precedence the override test checks on the host, on the nRF52 toolchain. + +
+All 53 environments + +| Environment | Platform | Flash before | Flash after | Flash delta | RAM before | RAM after | RAM delta | +|---|---|---:|---:|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32 | 1,174,765 | 1,174,765 | +0 | 62,512 | 62,512 | +0 | +| `Heltec_v3_room_server` | ESP32 | 1,169,913 | 1,169,913 | +0 | 66,200 | 66,200 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32 | 1,669,989 | 1,669,989 | +0 | 194,844 | 194,844 | +0 | +| `Heltec_v3_companion_radio_wifi` | ESP32 | 1,106,129 | 1,106,129 | +0 | 172,384 | 172,384 | +0 | +| `Heltec_v3_sensor` | ESP32 | 1,181,293 | 1,181,293 | +0 | 60,600 | 60,600 | +0 | +| `RAK_4631_repeater` | nRF52 | 441,768 | 441,784 | +16 | 37,240 | 37,240 | +0 | +| `RAK_4631_room_server` | nRF52 | 434,920 | 434,936 | +16 | 40,928 | 40,928 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52 | 464,036 | 464,052 | +16 | 149,768 | 149,768 | +0 | +| `RAK_4631_sensor` | nRF52 | 438,616 | 438,632 | +16 | 35,328 | 35,328 | +0 | +| `PicoW_repeater` | RP2040 | 492,668 | 492,668 | +0 | 85,368 | 85,368 | +0 | +| `wio-e5-mini_repeater` | STM32 | 204,136 | 204,136 | +0 | 17,172 | 17,172 | +0 | +| `wio-e5-mini_sensor` | STM32 | 203,664 | 203,664 | +0 | 15,276 | 15,276 | +0 | +| `RAK_3112_repeater_bridge_espnow` | ESP32 | 1,176,065 | 1,176,065 | +0 | 64,072 | 64,072 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | 241,080 | 241,080 | +0 | 26,704 | 26,704 | +0 | +| `ProMicro_repeater` | nRF52 | 346,208 | 346,240 | +32 | 33,892 | 33,892 | +0 | +| `ProMicro_room_server` | nRF52 | 339,272 | 339,288 | +16 | 37,588 | 37,588 | +0 | +| `ProMicro_companion_radio_usb` | nRF52 | 358,820 | 358,836 | +16 | 99,468 | 99,468 | +0 | +| `ProMicro_sensor` | nRF52 | 342,924 | 342,956 | +32 | 31,988 | 31,988 | +0 | +| `GAT562_30S_Mesh_Kit_repeater` | nRF52 | 411,392 | 411,408 | +16 | 35,932 | 35,932 | +0 | +| `GAT562_Mesh_EVB_Pro_repeater` | nRF52 | 365,588 | 365,604 | +16 | 35,124 | 35,124 | +0 | +| `GAT562_Mesh_Tracker_Pro_repeater` | nRF52 | 381,100 | 381,116 | +16 | 35,492 | 35,492 | +0 | +| `GAT562_Mesh_Watch13_companion_radio_ble` | nRF52 | 399,432 | 399,448 | +16 | 147,888 | 147,888 | +0 | +| `Heltec_mesh_solar_repeater` | nRF52 | 365,584 | 365,600 | +16 | 34,284 | 34,284 | +0 | +| `Heltec_tower_v2_repeater` | nRF52 | 309,512 | 309,528 | +16 | 33,292 | 33,292 | +0 | +| `ikoka_handheld_nrf_e22_30dbm_repeater` | nRF52 | 358,136 | 358,136 | +0 | 34,468 | 34,468 | +0 | +| `ikoka_nano_nrf_22dbm_repeater` | nRF52 | 361,016 | 361,016 | +0 | 34,556 | 34,556 | +0 | +| `ikoka_stick_nrf_22dbm_repeater` | nRF52 | 373,608 | 373,608 | +0 | 34,708 | 34,708 | +0 | +| `KeepteenLT1_repeater` | nRF52 | 322,012 | 322,028 | +16 | 33,436 | 33,436 | +0 | +| `LilyGo_T_Impulse_Plus_repeater` | nRF52 | 366,332 | 366,348 | +16 | 35,236 | 35,236 | +0 | +| `LilyGo_T-Echo_repeater` | nRF52 | 350,080 | 350,096 | +16 | 38,484 | 38,484 | +0 | +| `LilyGo_T-Echo_Card_repeater` | nRF52 | 322,352 | 322,368 | +16 | 34,004 | 34,004 | +0 | +| `LilyGo_T-Echo-Lite_repeater` | nRF52 | 335,756 | 335,772 | +16 | 37,716 | 37,716 | +0 | +| `Meshtiny_companion_radio_usb` | nRF52 | 330,136 | 330,152 | +16 | 141,364 | 141,364 | +0 | +| `R1Neo_repeater` | nRF52 | 368,740 | 368,756 | +16 | 34,892 | 34,892 | +0 | +| `Nano_G2_Ultra_repeater` | nRF52 | 319,484 | 319,500 | +16 | 33,332 | 33,332 | +0 | +| `RAK_3401_repeater` | nRF52 | 410,864 | 410,880 | +16 | 35,636 | 35,636 | +0 | +| `RAK_WisMesh_Tag_repeater` | nRF52 | 368,900 | 368,916 | +16 | 34,916 | 34,916 | +0 | +| `SenseCap_Solar_repeater` | nRF52 | 365,932 | 365,948 | +16 | 34,876 | 34,876 | +0 | +| `ThinkNode_M1_repeater` | nRF52 | 306,164 | 306,180 | +16 | 32,980 | 32,980 | +0 | +| `ThinkNode_M6_repeater` | nRF52 | 366,036 | 366,052 | +16 | 34,932 | 34,932 | +0 | +| `ThinkNode_M8_repeater` | nRF52 | FAIL | FAIL | | | | | +| `WioTrackerL1-1W_repeater` | nRF52 | 378,556 | 378,556 | +0 | 35,436 | 35,436 | +0 | +| `WioTrackerL1_repeater` | nRF52 | 377,548 | 377,564 | +16 | 35,428 | 35,428 | +0 | +| `Xiao_nrf52_repeater` | nRF52 | 361,624 | 361,640 | +16 | 34,596 | 34,596 | +0 | +| `Heltec_t096_repeater` | nRF52 | 414,288 | 414,304 | +16 | 35,564 | 35,564 | +0 | +| `Heltec_t1_repeater` | nRF52 | 413,332 | 413,348 | +16 | 35,204 | 35,204 | +0 | +| `Heltec_t114_without_display_repeater` | nRF52 | 366,084 | 366,100 | +16 | 35,140 | 35,140 | +0 | +| `Mesh_pocket_repeater` | nRF52 | 326,840 | 326,840 | +0 | 36,820 | 36,820 | +0 | +| `muzi_base_uno_repeater` | nRF52 | 301,236 | 301,236 | +0 | 32,932 | 32,932 | +0 | +| `MeshTracker_X1_repeater` | nRF52 | 301,152 | 301,152 | +0 | 32,956 | 32,956 | +0 | +| `t1000e_repeater` | nRF52 | 294,712 | 294,712 | +0 | 32,876 | 32,876 | +0 | +| `Xiao_C6_repeater_` | ESP32 | 542,481 | 542,481 | +0 | 29,856 | 29,856 | +0 | +| `M5Stack_Unit_C6L_repeater` | ESP32 | 552,823 | 552,823 | +0 | 29,752 | 29,752 | +0 | + +
+ +### Hardware + +No hardware evidence (without board). Of the boards in the device pool +(`kb device list`, 20.10.2026), four are of the 31 variants and free: two are the +FUOTA proof of concept boards (`faketec-6e71`, ProMicro, and `p1-4134`, SenseCAP +Solar) with their own flash layout, one is a running sensor node with its own +build (`techo-b43b`, T-Echo), and the T114 is the companion of a test phone. +Flashing a plain repeater onto any of them would destroy what they are kept for, +so none was used. The change moves a function whose body is unchanged into another +translation unit, and the builds show every edited variant links the shared +definition. If a reviewer wants a board run, the check on any of the 31: flash the +repeater firmware, see it boot past `radio_init()` (no `Radio init failed`), and +`get radio` answers with the stored settings. + +## Upstream + +Searched meshcore-dev/MeshCore on 20.10.2026: text queries over all pull requests +(open and closed) for `target.cpp`, `radio_new_identity`, `RadioNoiseListener`, +board abstraction, generic target, FEM and the board names; the file lists of all +497 open pull requests; and the diffs of the 120 that touch a `target.*`, the +radio wrappers or the four FEM boards. + +- No pull request proposes a board abstraction, a generic target, or a shared + `radio_new_identity()`. The only hit for `radio_new_identity` in text is the + closed, stale #1164. +- **#2280** (ascon based PRNG, open, last updated 2026-08) removes the callers of + `radio_new_identity()` from `examples/` and changes `RadioNoiseListener::random()` + to drop the `random()` mix. If it lands, cut A's function has no caller left, and + the test pinning the mix would fail by design. Whichever lands second has to + follow the other. +- **#3496** (KISS modem AGC and FEM gain, open) edits the `fem_rxgain` lines of all + four FEM boards and adds a virtual to `RadioLibWrapper` in a different place than + this step's friend line. Relevant for cut B. +- **#3638** (dual radio profiles, open) rewrites a large part of `RadioLibWrapper` + (members after `_radio`, not the class head this step touches) and 10 + `target.cpp`, among them `heltec_v4`, where it changes how the radio is + constructed. Relevant for cut D. +- Deleting the copies in cut A conflicts by context with open pull requests that + have `radio_new_identity` lines in their hunks (#1799, #1850, #1913, #2152, #2682, + #2927, #3213, #3472, #3638). Each is a trivial rebase (drop the function). + Four pull requests edit the declaration line in a `target.h` (#1885, #2152, #2568, + #3100); cut A does not touch any `target.h`, so they do not conflict. +- New board pull requests (about 50) carry their own copy; with the weak definition + they keep linking unchanged. +- Merged churn in `target.*` since 2026-06-01: 25 pull requests, at most 5 such files + each, mostly new boards. No merged mass edit; the mass edits are open pull + requests only (#1962 nRF52 RTC, 27 target files; #1799 GPS, 15; #2912 RadioLib + 7.7.1, 9 `radio_init()` bodies), which are the ones to watch for cuts C and D. + +## Observations (not part of this step) + +- `m5stack_unit_c6l` and `xiao_c6` have no `target.cpp`; their board file + (`UnitC6LBoard.cpp`, `XiaoC6Board.cpp`) defines the target globals and functions, + including a copy of `radio_new_identity()`. Those two copies are left for the cut + that touches the board files, so this step edits `target.cpp` files only. + `wio-tracker-l1-eink` builds the sources of `wio-tracker-l1`. +- All ESP-NOW environments fail to compile on `integration` (see "Builds"). +- `Generic_E22_kiss_modem` sets neither `RADIO_CLASS` nor `WRAPPER_CLASS`, unlike + every other environment of `generic-e22`. diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md new file mode 100644 index 00000000000..939c63c1161 --- /dev/null +++ b/docs/refactoring/README.md @@ -0,0 +1,67 @@ +# Refactoring series: make MeshCore easier to extend + +This folder documents a series of small, behaviour-preserving refactors of the +MeshCore firmware. Each step is its own pull request against `dev`, can be +reviewed on its own, and comes with the evidence that it changes nothing a user, +an app or the mesh can observe. + +## Why + +Adding a feature to MeshCore today touches far more files than the feature +itself, because the same logic exists in several copies (per role, per board, +per radio chip) and settings can only reach the code that needs them through +hand-written forwarding. A few measured examples from v1.18.0: + +- Making CAD a separate setting (`7c8e0924`) touched 15 files. +- The remote LNA toggle (`71136671`) touched 162 variant files; moving it into + the radio wrappers 23 days later (`9a95e25e`) touched 156 again. +- The radio and GPS settings existed twice, once in the server roles' NodePrefs + and once in the companion's NodePrefs, and the companion copy needed three + separate fixes in this release cycle that the other copy did not. + +The full analysis, with file and line references for every finding, is in +[architecture-review-v1.18.md](architecture-review-v1.18.md). + +## Rules every step follows + +1. **No behaviour change.** On-air packets, companion protocol frames, CLI + replies and the files on flash stay byte for byte the same. Where a step + would need a behaviour change, that is split out and proposed separately. +2. **Tests first.** Before code moves, characterization tests pin down the + current behaviour on the host (`pio test -e native`). The same tests must + pass unchanged before and after the step. The tests are checked against + deliberately broken code (mutation tests) to show they would catch a + regression. +3. **Builds and hardware.** Every step is built for a matrix covering every + role (repeater, room server, sensor, companion BLE/USB/WiFi, bridges) and + every platform (ESP32, nRF52, RP2040, STM32), with flash and RAM compared to + `dev`. Steps that touch persisted data are run on real boards, including an + upgrade from the previous firmware and a downgrade back to it. +4. **Source compatible.** Existing code, including downstream forks, keeps + compiling. Where a public header changes in an incompatible way, the step + says so explicitly. +5. **One concern per step**, documented in a design note in this folder: what + changes, why, which alternatives were rejected, and how it was verified. + +## Steps + +| # | Step | Design note | Status | +|---|------|-------------|--------| +| 1 | Share the radio and GPS settings between the server roles' and the companion's NodePrefs | [0001](0001-shared-radio-gps-prefs.md) | ready for review | +| 2 | Let the core read its tuning values from the radio settings instead of a getter per role | [0002](0002-core-reads-radio-prefs.md) | ready for review | +| 3 | Drive the radio settings (JSON, CLI, key/value store) from one table | [0003](0003-radio-settings-table.md) | ready for review | +| 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4g; the boot sequence and the forwarding checks are the last two) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md), [0008](0008-forward-policy.md) | ready for review | +| 9 | One RadioLib adapter per chip family: the receive detection the seven chip classes shared as copies (9a), one template for the SX126x chip classes SX1262, SX1268 and LLCC68 (9b); 9c and 9d planned in the note | [0009](0009-radio-chip-adapter.md) | 9a and 9b ready for review | +| 10 | A command table for the companion protocol: a host harness that pins every frame (10a), one row per command code instead of the if chain (10b), then a bounded frame reader in the handlers instead of hand counted offsets (10c); 10d proposed in the note | [0010](0010-companion-command-table.md) | 10a to 10c ready for review | +| 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()`; cut B: one FEM RX gain handler for four Heltec boards; cut C: one default `radio_init()` for 31 nRF52 boards (cuts D and E planned in the note) | [0011](0011-variant-target.md) | cuts A, B and C ready for review | + +Steps are numbered after their design note, so the numbers have gaps: 5 to 8 +are notes of fixes and of step 4. See the review for the reasoning behind the +order. + +## Verifying a step yourself + +```sh +pio test -e native # host tests, including the characterization tests +pio run -e RAK_4631_repeater # any firmware environment +``` diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md new file mode 100644 index 00000000000..14e8f0e9a3c --- /dev/null +++ b/docs/refactoring/architecture-review-v1.18.md @@ -0,0 +1,157 @@ +# Architecture review of v1.18.0 + +Read-only review of MeshCore v1.18.0 (`companion-v1.18.0` = `repeater-v1.18.0` = +`room-server-v1.18.0`, commit `81dbae59`) and of its history since v1.14.0. +The question was: **why is adding a feature expensive, and which refactors +would make it cheaper without changing behaviour?** + +Every number below was measured on the source tree or with `git` and can be +reproduced with the command given. File and line references point into v1.18.0; +later steps of this series move some of that code. Findings that came only from reading code +and were not reproduced on hardware say so. + +## How the cost shows up + +| Change | Commit | Files touched | Reproduce | +|--------|--------|---------------|-----------| +| CAD as its own setting (`set cad on/off`) | `7c8e0924` | 15 | `git show --stat 7c8e0924` | +| Remote LNA / RX gain toggle | `71136671` | 171 (162 in `variants/`) | `git show --stat 71136671` | +| Same feature moved into the radio wrappers, 23 days later | `9a95e25e` | 164 (156 in `variants/`) | `git show --stat 9a95e25e` | +| One line added to every `radio_init()` (preamble) | `f0ec5d2a` | 66 (64 in `variants/`) | `git show --stat f0ec5d2a` | +| Build flag for USB companion firmwares | `e7f2c717` | 70 | `git show --stat e7f2c717` | + +Churn between v1.14.0 and v1.18.0 (`git log --no-merges --oneline companion-v1.14.0..companion-v1.18.0 -- | wc -l`): +`examples/companion_radio/MyMesh.cpp` 80, `src/helpers/CommonCLI.cpp` 43, +`examples/simple_repeater/MyMesh.cpp` 35, `examples/simple_room_server/MyMesh.cpp` 29. + +## Findings + +Each finding names the place where the same knowledge exists more than once, +or where a value cannot reach the code that needs it. + +### F1. Settings are spelled out in many places + +- Two different classes are both called `NodePrefs`: `src/helpers/CommonCLI.h:25` + (repeater, room server, sensor) and `examples/companion_radio/NodePrefs.h:14` + (companion). Their radio adapter (`RadioPrefs`, 60 lines) was a copy that differed + only in one comment (`CommonCLI.h:77-137` vs `NodePrefs.h:63-124`). +- The core reads settings only through virtual getters that every role + re-implements as `return _prefs.x;` (`src/Dispatcher.h:166-173`, `src/Mesh.h:72`). + `Dispatcher::loop()` pushes CAD and the interference threshold to the radio every + 2 seconds (`src/Dispatcher.cpp:16`, `:67-71`). +- `CommonRadioPrefs.cpp:4` includes the variant's `target.h` and calls the global + `radio_driver` directly (`:129`, `:143`), so the radio CLI could not be built + on the host. +- The role callbacks `setTxPower()` and `setRxBoostedGain()` declared in + `CommonCLICallbacks` have no caller anywhere in `src/`, `examples/` or `variants/`. + +Step 1 shares the radio and GPS settings between both classes; step 2 lets the +core read its tuning values from them instead of a getter per role. The +remaining points are candidates for the next steps. + +### F2. Server roles carry the same code three times + +Repeater, room server and sensor each implement the same plumbing. +For example `sendFloodReply()` is identical in all three except for whitespace +in one comment (`diff -w` is empty), and the tested `mesh::chooseReplyRoute()` +(`src/helpers/RoutingPolicy.h:39`) is used in one place only +(`examples/simple_repeater/MyMesh.cpp:600`). +The copies have drifted (found by reading the code, not reproduced on hardware): + +- The sensor's `allowPacketForward()` checks only `flood_max` + (`examples/simple_sensor/SensorMesh.cpp:319-323`); the repeater also applies + `flood.max.unscoped`, `flood.max.advert`, the region rule and loop detection + (`examples/simple_repeater/MyMesh.cpp:434-460`). +- The sensor does not override `getExtraAckTransmitCount()`, whose base returns 0 + (`src/Mesh.cpp:25`), so `set multi.acks` is accepted but has no effect there. + +Since step 2 the sensor states this explicitly (`getExtraAckTransmitCount()` +returning 0 with a comment), so the gap is documented rather than accidental, and +`SensorMesh::sendAckTo()` still carries a branch that the base's 0 makes +unreachable. + +#### Why the sensor drifted + +Checked with `git log -S` and the commit history of `upstream/dev` 428a95c6 before +deciding whether to align the sensor. No deviation has a role specific reason such +as battery life. Each one is a change that reached the repeater and the room server +and skipped the sensor. The history below is from the commits; the run time effects +are read from the source and not reproduced on a board: + +| deviation | history | +|---|---| +| Retransmit delay `nextInt(0, 6)*t` | The sensor sketch copied it verbatim from the repeater at its birth (`7fb7b69b`, 7 July 2025, where the repeater had the same line). The repeater and the room server left the six slot scheme in `8cbcd227` (29 October 2025), whose subject calls itself experimental, and got the divide by zero fix `96e786fa` a day later. The sensor keeps the old shared behaviour. The lines were touched once more, in `3e76161e` (19 February 2026, `path_len` to `getPathByteLen()`), in all three roles including the sensor, so only the formula itself was never revisited. | +| Flood limits only `flood_max` | `fad11c90` (3 August 2026) moved the repeater and the room server to `mesh::isFloodHopLimitExceeded()`, with the shared helper in `src/helpers/RoutingPolicy.h` and `test/test_routing_policy`. The sensor was not touched. | +| `multi.acks` without effect | `6bc8dd28` and `5881b04a` (16 July 2025) added the setting and the companion's use of it. `136f733d` (27 August 2025) added a consumer to `SensorMesh::sendAckTo()`, but no role side provider, so the base's 0 keeps that branch dead. | +| RX boosted gain not applied at boot | The room server had the same gap until `a71cc238` (21 July 2026) fixed it by mirroring the repeater. The sensor still initialises no default and applies nothing in `begin()`, so a value set through the CLI applies at once but is lost on the next boot. | + +`nextInt(_min, _max)` returns `(num % (_max - _min)) + _min` (`src/Utils.cpp:18`), so +the upper bound is exclusive: the sensor draws from the six multiples 0 to 5 times +`t`, the other roles uniformly from 0 to `5*t`. Both have the same maximum. The +`+ 1` of `96e786fa` exists because `t == 0` made the bound 0 and `num % 0` crashed, +which the sensor's form could not do. + +Status: the shared plumbing is in `ServerMesh` since the steps 4a to 4f, the last of +which moved the boot sequence ([0007](0007-boot-sequence.md)). Two candidates are left +that were not part of the cut: the `CommonCLI _cli` member, constructed with +identical arguments in all three roles, and `allowPacketForward()`, whose `disable_fwd` +check is the same in all three and whose whole body is identical in the room server +and the sensor only once the parked flood limit alignment +(`refactor/sensor-flood-limits`) lands; today the sensor still checks `flood_max` alone. + +### F3. Board variants are near copies + +89 `variants/*/target.cpp` files; `radio_new_identity()` is identical (ignoring +repeated spaces) in 74 of them. The FEM gain handlers (`attachDynamicPrefs()` + `handleCommand()`) of +HeltecV4, HeltecV4R8, T096 and HeltecTrackerV2 are identical once the class name is +normalized. 618 PlatformIO environments; the pull request build check builds 14. + +### F4. Radio chip classes are near copies + +`isReceiving()` exists in seven copies of about 41 lines; after normalizing chip +names, the SX1262, SX1268, LLCC68 and STM32WLx copies are identical, and the +LR1110 and LR1121 copies are identical (`src/helpers/radiolib/Custom*.h`). + +### F5. The companion protocol is one long function + +`MyMesh::handleCmdFrame()` (`examples/companion_radio/MyMesh.cpp:1055-2063`) is +1009 lines with 62 `if (cmd_frame[0] == CMD_...)` branches. Length checks are +written per branch. + +### F6. Little of this can be tested on the host + +The native test environment builds four source files (`platformio.ini`, +`[env:native]`). None of the role code, the persistence code or the radio CLI was +covered before step 1. + +Status: the radio CLI and the prefs are covered since steps 1 to 3, and the server +roles themselves since the harness of step 4: `test/mocks` now carries an in-memory +file system, an RTC and the globals a variant's `target.h` declares, so all three +role implementations compile natively and `native_server` drives the real room +server. The gap that is left is the deeper request handling, which needs packets and +a populated ACL. Step 4f added `native_repeater`, so all three server roles now run on +the host, and `native_repeater_full`, which compiles in the bridge, GPS and +configured-scope blocks of `begin()` as well. + +## Issues noticed while reading (not part of this series) + +Found by reading v1.18.0, not reproduced on hardware. Each should get its own +issue or fix. + +| # | Issue | Where | +|---|-------|-------| +| 1 | `CMD_SEND_CHANNEL_TXT_MSG` has no minimum length check; with a 3 to 5 byte frame, `len - i` is negative and reaches `memcpy` in `sendGroupMessage()` | `examples/companion_radio/MyMesh.cpp:1178`, `src/helpers/BaseChatMesh.cpp:527-528` | +| 2 | `CustomLLCC68Wrapper::doResetAGC()` calls `sx126xResetAGC()` with one argument; the function takes two. No environment builds this wrapper | `src/helpers/radiolib/CustomLLCC68Wrapper.h:38`, `SX126xReset.h:8` | +| 3 | LR1110, LR1121 and LR2021 wrappers do not override `packetScore()`, so the base assumes SF10 | `src/helpers/radiolib/RadioLibWrappers.h:76` | +| 4 | `ESPNOWRadio::setRxBoostedGainMode()` returns `bool` but has no `return` | `src/helpers/esp32/ESPNOWRadio.h:41` | +| 5 | Seven variant GPS loops keep `next_gps_update` in a signed `long` and compare `millis() > next_gps_update`; the shared sensor manager fixed exactly this stall after the ~24.8 day sign flip in `63731d3f`, the variant copies did not get the fix | e.g. `variants/heltec_tracker/target.cpp:74-85` | +| 6 | `extra_sf` and `discovery_mod_timestamp` are not part of `/prefs.json` | `src/helpers/CommonCLI.h:65`, `:74` | +| 7 | The server roles load `/prefs.json` without range checks (only the legacy loader clamps) | `src/helpers/CommonCLI.cpp:38` | +| 8 | Role fallback radio defaults (915 / 250 / SF10) differ from the root `platformio.ini` (869.618 / 62.5 / SF8) | `examples/simple_repeater/MyMesh.cpp:7-13`, `platformio.ini` | +| 9 | nRF52 firmware is built with `-Ofast` (`-ffast-math`), so float comparisons may assume no NaN; whether a range check rejects `nan` then depends on how it is written. `set af nan` is accepted on the boards. (Found during step 3, measured on a T-Echo) | `src/helpers/CommonRadioPrefs.cpp` | +| 10 | The two flood route types disagree about a denied Region. `onRecvPacket()` checks `REGION_DENY_FLOOD` itself for an un-scoped flood, while a scoped flood goes through `RegionMap::findMatch()`, whose transport-code-0 shortcut returns the wildcard before the mask is applied. So denying flood on the wildcard stops an un-scoped flood but not a scoped one carrying no code. (Found during step 4a, pinned in test_server_base). Only the repeater turns a missing Region into a dropped packet (`allowPacketForward()`); the room server and the sensor only take their reply scope from it. The shortcut came with 01f9293b ("treat transport code 0 the same as un-scoped", meshcore-dev/MeshCore#3568), which did so for the lookup but not for the wildcard's flood denial, and `docs/cli_commands.md` says `denyf *` drops packets without a Region transport code. **Fixed** on `fix/region-code0-deny` (note `docs/fixes/region-code0-deny.md`), merged into `integration`; the repeater's side is covered by `test_repeater_role/test_region_forwarding.cpp` | `src/helpers/ServerMesh.cpp`, `src/helpers/RegionMap.cpp:findMatch()` | +| 11 | `setRxBoostedGain()` is a virtual nobody calls: a default in `helpers/CommonCLI.h`, an override in the repeater and in the room server, and no caller anywhere. The only `radio.rxgain` handler reaches the radio driver directly, and the settings table lists the field as `CLI_NONE`, which the generic loop skips. Two dead overrides. (Found during the sensor alignment) | `src/helpers/CommonCLI.h`, `examples/simple_repeater/MyMesh.cpp`, `examples/simple_room_server/MyMesh.cpp` | +| 12 | A hop limit of zero is written to `prefs.json` as an explicit `0` and applied verbatim on load, so `set flood.max.unscoped 0` persists and the constructor default cannot protect against it. Since the limits are compared with `>=`, zero means "never forward", which is what upstream's own comment describes repeaters as running. Combined with issue 7 (no range checking on `/prefs.json`) this is how a node can boot into a state that silently forwards nothing. Measured in `test_sensor_role`. Status: on the sensor, where these zeros were never chosen, a stored 0 is lifted once on upgrade behind a new `/prefs.json` version (0006); on the repeater and the room server a stored 0 stays a valid choice | `src/helpers/ConfigSerializer.cpp`, `src/helpers/RoutingPolicy.h` | +| 13 | `set multi.acks` accepts any byte (the settings table gives it no maximum), but the range check after loading `/prefs.json` clamps the field to 0..1. A stored `2` therefore reads back as `1` after a reboot, and the CLI never says so. The CLI and the loader should agree on one range. (Found in the hardware check of the sensor alignment) | `src/helpers/CommonRadioPrefs.cpp` (`multi_ack` row), `src/helpers/CommonCLI.cpp` (`sanitisePrefs()`) | +| 14 | After a soft reset (serial DFU, `reboot`) the I2C bus is sometimes left with SDA held low by a device that was interrupted mid transfer, and the next boot blocks for good in the RTC probe, before any radio or mesh code runs. Measured on a T-Echo: without freeing the bus 4 of 4 boots stopped there; with nine clock pulses before `Wire.begin()` SDA was low in 2 of 3 boots, one pulse released it and all 3 boots completed. Since the blocked probe is itself mid transfer, the next soft reset tends to leave the bus stuck again, so only a power cycle breaks the loop. (Found in the hardware check of the sensor alignment) | `src/helpers/AutoDiscoverRTCClock.cpp` (`i2c_probe()`), the variants' `Wire.begin()` | +| 15 | `Dispatcher::begin()` runs before the prefs are loaded, so the transmit budget starts at the size the constructor's airtime factor gives (1.0, half of the one hour window), not the stored one. Every budget decision calls `updateTxBudget()` first, which caps it at the stored factor's maximum or refills it at the stored rate, so this is a start-up level and probably harmless. Read from the code, not measured on a board. (Found while pinning the boot sequence in step 4f; pinned in `BootSequenceChecks.h`) | `src/Dispatcher.cpp:19-31`, `src/helpers/ServerMesh.cpp` | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 99a9aa30d6e..a566a5374d3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include #include // needed for PlatformIO #ifdef ENABLE_WIFI_INTERFACE @@ -259,34 +260,6 @@ int MyMesh::getFromOfflineQueue(uint8_t frame[]) { return 0; // queue is empty } -float MyMesh::getAirtimeBudgetFactor() const { - return _prefs.airtime_factor; -} - -int MyMesh::getInterferenceThreshold() const { - return _prefs.interference_threshold; -} -bool MyMesh::getCADEnabled() const { - return _prefs.cad_enabled; -} - -int MyMesh::calcRxDelay(float score, uint32_t air_time) const { - if (_prefs.rx_delay_base <= 0.0f) return 0; - return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); -} - -uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} -uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} - -uint8_t MyMesh::getExtraAckTransmitCount() const { - return _prefs.multi_acks; -} void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) { @@ -900,6 +873,7 @@ void MyMesh::onSendTimeout() {} MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store) : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables), _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _listener(NULL), _iter(0) { + useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings _iter_started = false; _cli_rescue = false; cli_command[0] = 0; @@ -1052,249 +1026,484 @@ void MyMesh::startInterface(BaseSerialInterface &serial) { serial.enable(); } +// The bytes a path length byte announces; Packet::writePath() copies as many. +static size_t pathByteLen(uint8_t path_len) { + return (path_len & 63) * ((path_len >> 6) + 1); +} + +// One row per command: the code, the shortest frame the command accepts (code byte +// included), and its handler. A frame below the minimum, a code without a row, and +// a handler that returns false ("not this command") all get the answer the if chain +// this table replaced gave them: ERR_CODE_UNSUPPORTED_CMD. +const MyMesh::CmdHandler MyMesh::CMD_TABLE[] = { + { CMD_DEVICE_QUERY, 2, &MyMesh::cmdDeviceQuery }, + { CMD_APP_START, 8, &MyMesh::cmdAppStart }, + { CMD_RUN_CLI_COMMAND, 3, &MyMesh::cmdRunCliCommand }, + { CMD_SEND_TXT_MSG, 14, &MyMesh::cmdSendTxtMsg }, + { CMD_SEND_CHANNEL_TXT_MSG, 7, &MyMesh::cmdSendChannelTxtMsg }, + { CMD_SEND_CHANNEL_DATA, 1, &MyMesh::cmdSendChannelData }, + { CMD_GET_CONTACTS, 1, &MyMesh::cmdGetContacts }, + { CMD_SET_ADVERT_NAME, 2, &MyMesh::cmdSetAdvertName }, + { CMD_SET_ADVERT_LATLON, 9, &MyMesh::cmdSetAdvertLatlon }, + { CMD_GET_DEVICE_TIME, 1, &MyMesh::cmdGetDeviceTime }, + { CMD_SET_DEVICE_TIME, 5, &MyMesh::cmdSetDeviceTime }, + { CMD_SEND_SELF_ADVERT, 1, &MyMesh::cmdSendSelfAdvert }, + { CMD_RESET_PATH, 1 + 32, &MyMesh::cmdResetPath }, + { CMD_ADD_UPDATE_CONTACT, 1 + PUB_KEY_SIZE + 3 + MAX_PATH_SIZE + 32 + 4, &MyMesh::cmdAddUpdateContact }, + { CMD_REMOVE_CONTACT, 1 + PUB_KEY_SIZE, &MyMesh::cmdRemoveContact }, + { CMD_SHARE_CONTACT, 1 + PUB_KEY_SIZE, &MyMesh::cmdShareContact }, + { CMD_GET_CONTACT_BY_KEY, 1 + PUB_KEY_SIZE, &MyMesh::cmdGetContactByKey }, + { CMD_EXPORT_CONTACT, 1, &MyMesh::cmdExportContact }, + { CMD_IMPORT_CONTACT, 2 + 32 + 64 + 1, &MyMesh::cmdImportContact }, + { CMD_SYNC_NEXT_MESSAGE, 1, &MyMesh::cmdSyncNextMessage }, + { CMD_SET_RADIO_PARAMS, 11, &MyMesh::cmdSetRadioParams }, + { CMD_SET_RADIO_TX_POWER, 2, &MyMesh::cmdSetRadioTxPower }, + { CMD_SET_TUNING_PARAMS, 9, &MyMesh::cmdSetTuningParams }, + { CMD_GET_TUNING_PARAMS, 1, &MyMesh::cmdGetTuningParams }, + { CMD_SET_OTHER_PARAMS, 2, &MyMesh::cmdSetOtherParams }, + { CMD_SET_PATH_HASH_MODE, 3, &MyMesh::cmdSetPathHashMode }, + { CMD_REBOOT, 7, &MyMesh::cmdReboot }, + { CMD_GET_BATT_AND_STORAGE, 1, &MyMesh::cmdGetBattAndStorage }, + { CMD_EXPORT_PRIVATE_KEY, 1, &MyMesh::cmdExportPrivateKey }, + { CMD_IMPORT_PRIVATE_KEY, 65, &MyMesh::cmdImportPrivateKey }, + { CMD_SEND_RAW_DATA, 6, &MyMesh::cmdSendRawData }, + { CMD_SEND_LOGIN, 1 + PUB_KEY_SIZE, &MyMesh::cmdSendLogin }, + { CMD_SEND_ANON_REQ, 1 + PUB_KEY_SIZE + 1, &MyMesh::cmdSendAnonReq }, + { CMD_SEND_STATUS_REQ, 1 + PUB_KEY_SIZE, &MyMesh::cmdSendStatusReq }, + { CMD_SEND_PATH_DISCOVERY_REQ, 2 + PUB_KEY_SIZE, &MyMesh::cmdSendPathDiscoveryReq }, + { CMD_SEND_TELEMETRY_REQ, 4, &MyMesh::cmdSendTelemetryReq }, + { CMD_SEND_BINARY_REQ, 2 + PUB_KEY_SIZE, &MyMesh::cmdSendBinaryReq }, + { CMD_HAS_CONNECTION, 1 + PUB_KEY_SIZE, &MyMesh::cmdHasConnection }, + { CMD_LOGOUT, 1 + PUB_KEY_SIZE, &MyMesh::cmdLogout }, + { CMD_GET_CHANNEL, 2, &MyMesh::cmdGetChannel }, + { CMD_SET_CHANNEL, 2 + 32 + 16, &MyMesh::cmdSetChannel }, + { CMD_SIGN_START, 1, &MyMesh::cmdSignStart }, + { CMD_SIGN_DATA, 1 + 1, &MyMesh::cmdSignData }, + { CMD_SIGN_FINISH, 1, &MyMesh::cmdSignFinish }, + { CMD_SEND_TRACE_PATH, 10 + 1, &MyMesh::cmdSendTracePath }, + { CMD_SET_DEVICE_PIN, 5, &MyMesh::cmdSetDevicePin }, + { CMD_GET_CUSTOM_VARS, 1, &MyMesh::cmdGetCustomVars }, + { CMD_SET_CUSTOM_VAR, 4, &MyMesh::cmdSetCustomVar }, + { CMD_GET_ADVERT_PATH, PUB_KEY_SIZE+2, &MyMesh::cmdGetAdvertPath }, + { CMD_GET_STATS, 2, &MyMesh::cmdGetStats }, + { CMD_FACTORY_RESET, 6, &MyMesh::cmdFactoryReset }, + { CMD_SET_FLOOD_SCOPE_KEY, 2, &MyMesh::cmdSetFloodScopeKey }, + { CMD_SET_DEFAULT_FLOOD_SCOPE, 1, &MyMesh::cmdSetDefaultFloodScope }, + { CMD_GET_DEFAULT_FLOOD_SCOPE, 1, &MyMesh::cmdGetDefaultFloodScope }, + { CMD_SEND_CONTROL_DATA, 2, &MyMesh::cmdSendControlData }, + { CMD_SET_AUTOADD_CONFIG, 2, &MyMesh::cmdSetAutoaddConfig }, + { CMD_GET_AUTOADD_CONFIG, 1, &MyMesh::cmdGetAutoaddConfig }, + { CMD_GET_ALLOWED_REPEAT_FREQ, 1, &MyMesh::cmdGetAllowedRepeatFreq }, + { CMD_SEND_RAW_PACKET, 4, &MyMesh::cmdSendRawPacket }, +}; + void MyMesh::handleCmdFrame(size_t len) { - if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection - app_target_ver = cmd_frame[1]; // which version of protocol does app understand + for (const CmdHandler& c : CMD_TABLE) { + if (c.code != cmd_frame[0]) continue; + if (len >= c.min_len && (this->*c.handle)(len)) return; + break; + } + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); +} - int i = 0; - out_frame[i++] = RESP_CODE_DEVICE_INFO; - out_frame[i++] = FIRMWARE_VER_CODE; - out_frame[i++] = MAX_CONTACTS / 2; // v3+ - out_frame[i++] = MAX_GROUP_CHANNELS; // v3+ - memcpy(&out_frame[i], &_prefs.ble_pin, 4); - i += 4; - memset(&out_frame[i], 0, 12); - strcpy((char *)&out_frame[i], FIRMWARE_BUILD_DATE); - i += 12; - StrHelper::strzcpy((char *)&out_frame[i], board.getManufacturerName(), 40); - i += 40; - StrHelper::strzcpy((char *)&out_frame[i], FIRMWARE_VERSION, 20); - i += 20; - out_frame[i++] = _prefs.isRepeatEn() ? 1 : 0; // v9+ - out_frame[i++] = _prefs.path_hash_mode; // v10+ - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_APP_START && - len >= 8) { // sent when app establishes connection, respond with node ID - // cmd_frame[1..7] reserved future - char *app_name = (char *)&cmd_frame[8]; - cmd_frame[len] = 0; // make app_name null terminated - MESH_DEBUG_PRINTLN("App %s connected", app_name); - - _iter_started = false; // stop any left-over ContactsIterator - int i = 0; - out_frame[i++] = RESP_CODE_SELF_INFO; - out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-) - out_frame[i++] = _prefs.tx_power_dbm; - out_frame[i++] = MAX_LORA_TX_POWER; - memcpy(&out_frame[i], self_id.pub_key, PUB_KEY_SIZE); - i += PUB_KEY_SIZE; - - int32_t lat, lon; - lat = (sensors.node_lat * 1000000.0); - lon = (sensors.node_lon * 1000000.0); - memcpy(&out_frame[i], &lat, 4); - i += 4; - memcpy(&out_frame[i], &lon, 4); - i += 4; - out_frame[i++] = _prefs.multi_acks; // new v7+ - out_frame[i++] = _prefs.advert_loc_policy; - out_frame[i++] = (_prefs.telemetry_mode_env << 4) | (_prefs.telemetry_mode_loc << 2) | - (_prefs.telemetry_mode_base); // v5+ - out_frame[i++] = _prefs.manual_add_contacts; - - uint32_t freq = _prefs.freq * 1000; - memcpy(&out_frame[i], &freq, 4); - i += 4; - uint32_t bw = _prefs.bw * 1000; - memcpy(&out_frame[i], &bw, 4); - i += 4; - out_frame[i++] = _prefs.sf; - out_frame[i++] = _prefs.cr; +// sent when app establishes connection +bool MyMesh::cmdDeviceQuery(size_t len) { + FrameReader r(cmd_frame, len, 1); + app_target_ver = r.u8(); // which version of protocol does app understand - int tlen = strlen(_prefs.node_name); // revisit: UTF_8 ?? - memcpy(&out_frame[i], _prefs.node_name, tlen); - i += tlen; - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_RUN_CLI_COMMAND && len >= 3) { // V14+ - int i = 1; - char *text = (char *)&cmd_frame[i]; - int tlen = len - i; - text[tlen] = 0; // ensure null + int i = 0; + out_frame[i++] = RESP_CODE_DEVICE_INFO; + out_frame[i++] = FIRMWARE_VER_CODE; + out_frame[i++] = MAX_CONTACTS / 2; // v3+ + out_frame[i++] = MAX_GROUP_CHANNELS; // v3+ + memcpy(&out_frame[i], &_prefs.ble_pin, 4); + i += 4; + memset(&out_frame[i], 0, 12); + strcpy((char *)&out_frame[i], FIRMWARE_BUILD_DATE); + i += 12; + StrHelper::strzcpy((char *)&out_frame[i], board.getManufacturerName(), 40); + i += 40; + StrHelper::strzcpy((char *)&out_frame[i], FIRMWARE_VERSION, 20); + i += 20; + out_frame[i++] = _prefs.isRepeatEn() ? 1 : 0; // v9+ + out_frame[i++] = _prefs.path_hash_mode; // v10+ + _serial->writeFrame(out_frame, i); + return true; +} - reply_buf[0] = 0; - if (!handleCommand(text, 0, reply_buf)) { - strcat(reply_buf, "Unknown command"); // reply_buf may have cmd prefix from 'text' - } - out_frame[0] = RESP_CODE_CLI_REPLY; - int rlen = strlen(reply_buf); - memcpy(&out_frame[1], reply_buf, rlen); - _serial->writeFrame(out_frame, 1 + rlen); - } else if (cmd_frame[0] == CMD_SEND_TXT_MSG && len >= 14) { - int i = 1; - uint8_t txt_type = cmd_frame[i++]; - uint8_t attempt = cmd_frame[i++]; - uint32_t msg_timestamp; - memcpy(&msg_timestamp, &cmd_frame[i], 4); - i += 4; - uint8_t *pub_key_prefix = &cmd_frame[i]; - i += 6; - ContactInfo *recipient = lookupContactByPubKey(pub_key_prefix, 6); - if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)) { - char *text = (char *)&cmd_frame[i]; - int tlen = len - i; - uint32_t est_timeout; - text[tlen] = 0; // ensure null - int result; - uint32_t expected_ack; - if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { - msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection - result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type, text, est_timeout); - expected_ack = 0; // no Ack expected - } else { - result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout); - } - // TODO: add expected ACK to table - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - if (expected_ack) { - expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); // add to circular table - expected_ack_table[next_ack_idx].ack = expected_ack; - expected_ack_table[next_ack_idx].contact = recipient; - next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; - } +// sent when app establishes connection, respond with node ID +bool MyMesh::cmdAppStart(size_t len) { + FrameReader r(cmd_frame, len, 1); + r.skip(7); // reserved future + char *app_name = r.restAsCString(); + MESH_DEBUG_PRINTLN("App %s connected", app_name); - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &expected_ack, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } + _iter_started = false; // stop any left-over ContactsIterator + int i = 0; + out_frame[i++] = RESP_CODE_SELF_INFO; + out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-) + out_frame[i++] = _prefs.tx_power_dbm; + out_frame[i++] = MAX_LORA_TX_POWER; + memcpy(&out_frame[i], self_id.pub_key, PUB_KEY_SIZE); + i += PUB_KEY_SIZE; + + int32_t lat, lon; + lat = (sensors.node_lat * 1000000.0); + lon = (sensors.node_lon * 1000000.0); + memcpy(&out_frame[i], &lat, 4); + i += 4; + memcpy(&out_frame[i], &lon, 4); + i += 4; + out_frame[i++] = _prefs.multi_acks; // new v7+ + out_frame[i++] = _prefs.advert_loc_policy; + out_frame[i++] = (_prefs.telemetry_mode_env << 4) | (_prefs.telemetry_mode_loc << 2) | + (_prefs.telemetry_mode_base); // v5+ + out_frame[i++] = _prefs.manual_add_contacts; + + uint32_t freq = _prefs.freq * 1000; + memcpy(&out_frame[i], &freq, 4); + i += 4; + uint32_t bw = _prefs.bw * 1000; + memcpy(&out_frame[i], &bw, 4); + i += 4; + out_frame[i++] = _prefs.sf; + out_frame[i++] = _prefs.cr; + + int tlen = strlen(_prefs.node_name); // revisit: UTF_8 ?? + memcpy(&out_frame[i], _prefs.node_name, tlen); + i += tlen; + _serial->writeFrame(out_frame, i); + return true; +} + +// V14+ +bool MyMesh::cmdRunCliCommand(size_t len) { + FrameReader r(cmd_frame, len, 1); + char *text = r.restAsCString(); + + reply_buf[0] = 0; + if (!handleCommand(text, 0, reply_buf)) { + strcat(reply_buf, "Unknown command"); // reply_buf may have cmd prefix from 'text' + } + out_frame[0] = RESP_CODE_CLI_REPLY; + int rlen = strlen(reply_buf); + memcpy(&out_frame[1], reply_buf, rlen); + _serial->writeFrame(out_frame, 1 + rlen); + return true; +} + +bool MyMesh::cmdSendTxtMsg(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t txt_type = r.u8(); + uint8_t attempt = r.u8(); + uint32_t msg_timestamp = r.u32(); + uint8_t *pub_key_prefix = r.bytes(6); + ContactInfo *recipient = lookupContactByPubKey(pub_key_prefix, 6); + if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)) { + char *text = r.restAsCString(); + uint32_t est_timeout; + int result; + uint32_t expected_ack; + if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { + msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection + result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type, text, est_timeout); + expected_ack = 0; // no Ack expected } else { - writeErrFrame(recipient == NULL - ? ERR_CODE_NOT_FOUND - : ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_* + result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout); } - } else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG) { // send GroupChannel text msg - int i = 1; - uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN - uint8_t channel_idx = cmd_frame[i++]; - uint32_t msg_timestamp; - memcpy(&msg_timestamp, &cmd_frame[i], 4); - i += 4; - const char *text = (char *)&cmd_frame[i]; - - if (txt_type != TXT_TYPE_PLAIN) { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + // TODO: add expected ACK to table + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - ChannelDetails channel; - bool success = getChannel(channel_idx, channel); - if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) { - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx + if (expected_ack) { + expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); // add to circular table + expected_ack_table[next_ack_idx].ack = expected_ack; + expected_ack_table[next_ack_idx].contact = recipient; + next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; } - } - } else if (cmd_frame[0] == CMD_SEND_CHANNEL_DATA) { // send GroupChannel datagram - if (len < 4) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; - } - int i = 1; - uint8_t channel_idx = cmd_frame[i++]; - uint8_t path_len = cmd_frame[i++]; - - // validate path len, allowing 0xFF for flood - if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) { - MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA invalid path size: %d", path_len); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; - } - // parse provided path if not flood - uint8_t path[MAX_PATH_SIZE]; - if (path_len != OUT_PATH_UNKNOWN) { - i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &expected_ack, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } + } else { + writeErrFrame(recipient == NULL + ? ERR_CODE_NOT_FOUND + : ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_* + } + return true; +} - uint16_t data_type = ((uint16_t)cmd_frame[i]) | (((uint16_t)cmd_frame[i + 1]) << 8); - i += 2; - const uint8_t *payload = &cmd_frame[i]; - int payload_len = (len > (size_t)i) ? (int)(len - i) : 0; +// send GroupChannel text msg +bool MyMesh::cmdSendChannelTxtMsg(size_t len) { + // 7 = code, txt_type, channel_idx and the 4 byte timestamp; the text may be empty. + // Without this the text length below is computed as len - 7 and turns negative. + FrameReader r(cmd_frame, len, 1); + uint8_t txt_type = r.u8(); // should be TXT_TYPE_PLAIN + uint8_t channel_idx = r.u8(); + uint32_t msg_timestamp = r.u32(); + int text_len = r.remaining(); + const char *text = (char *)r.bytes(text_len); + if (txt_type != TXT_TYPE_PLAIN) { + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + } else { ChannelDetails channel; - if (!getChannel(channel_idx, channel)) { - writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx - } else if (data_type == DATA_TYPE_RESERVED) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (payload_len > MAX_GROUP_DATA_LENGTH) { - MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (sendGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { + bool success = getChannel(channel_idx, channel); + if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, text_len)) { writeOKFrame(); } else { - writeErrFrame(ERR_CODE_TABLE_FULL); + writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx } - } else if (cmd_frame[0] == CMD_GET_CONTACTS) { // get Contact list - if (_iter_started) { - writeErrFrame(ERR_CODE_BAD_STATE); // iterator is currently busy - } else { - if (len >= 5) { // has optional 'since' param - memcpy(&_iter_filter_since, &cmd_frame[1], 4); - } else { - _iter_filter_since = 0; - } + } + return true; +} - uint8_t reply[5]; - reply[0] = RESP_CODE_CONTACTS_START; - uint32_t count = getNumContacts(); // total, NOT filtered count - memcpy(&reply[1], &count, 4); - _serial->writeFrame(reply, 5); +// send GroupChannel datagram +bool MyMesh::cmdSendChannelData(size_t len) { + if (len < 4) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return true; + } + FrameReader r(cmd_frame, len, 1); + uint8_t channel_idx = r.u8(); + uint8_t path_len = r.u8(); + + // validate path len, allowing 0xFF for flood + if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) { + MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA invalid path size: %d", path_len); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return true; + } + + // parse provided path if not flood + uint8_t path[MAX_PATH_SIZE]; + if (path_len != OUT_PATH_UNKNOWN) { + const uint8_t *src = r.bytes(pathByteLen(path_len)); + if (src) mesh::Packet::writePath(path, src, path_len); + } + + if (!r.ok() || !r.has(2)) { // the path and the 2 byte data_type must be inside the frame + MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA frame too short for data_type: %d", (uint32_t)len); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return true; + } + + uint16_t data_type = r.u8(); + data_type |= ((uint16_t)r.u8()) << 8; + int payload_len = r.remaining(); + const uint8_t *payload = r.bytes(payload_len); + + ChannelDetails channel; + if (!getChannel(channel_idx, channel)) { + writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx + } else if (data_type == DATA_TYPE_RESERVED) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (payload_len > MAX_GROUP_DATA_LENGTH) { + MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (sendGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + return true; +} - // start iterator - _iter = startContactsIterator(); - _iter_started = true; - _most_recent_lastmod = 0; +// get Contact list +bool MyMesh::cmdGetContacts(size_t len) { + if (_iter_started) { + writeErrFrame(ERR_CODE_BAD_STATE); // iterator is currently busy + } else { + FrameReader r(cmd_frame, len, 1); + if (r.has(4)) { // has optional 'since' param + _iter_filter_since = r.u32(); + } else { + _iter_filter_since = 0; } - } else if (cmd_frame[0] == CMD_SET_ADVERT_NAME && len >= 2) { - int nlen = len - 1; - if (nlen > sizeof(_prefs.node_name) - 1) nlen = sizeof(_prefs.node_name) - 1; // max len - memcpy(_prefs.node_name, &cmd_frame[1], nlen); - _prefs.node_name[nlen] = 0; // null terminator + + uint8_t reply[5]; + reply[0] = RESP_CODE_CONTACTS_START; + uint32_t count = getNumContacts(); // total, NOT filtered count + memcpy(&reply[1], &count, 4); + _serial->writeFrame(reply, 5); + + // start iterator + _iter = startContactsIterator(); + _iter_started = true; + _most_recent_lastmod = 0; + } + return true; +} + +bool MyMesh::cmdSetAdvertName(size_t len) { + FrameReader r(cmd_frame, len, 1); + int nlen = r.remaining(); + if (nlen > sizeof(_prefs.node_name) - 1) nlen = sizeof(_prefs.node_name) - 1; // max len + memcpy(_prefs.node_name, r.bytes(nlen), nlen); + _prefs.node_name[nlen] = 0; // null terminator + savePrefs(); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdSetAdvertLatlon(size_t len) { + FrameReader r(cmd_frame, len, 1); + int32_t lat = r.i32(); + int32_t lon = r.i32(); + int32_t alt = 0; + if (r.has(4)) { + alt = r.i32(); // for FUTURE support + } + if (lat <= 90 * 1E6 && lat >= -90 * 1E6 && lon <= 180 * 1E6 && lon >= -180 * 1E6) { + sensors.node_lat = ((double)lat) / 1000000.0; + sensors.node_lon = ((double)lon) / 1000000.0; savePrefs(); writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_ADVERT_LATLON && len >= 9) { - int32_t lat, lon, alt = 0; - memcpy(&lat, &cmd_frame[1], 4); - memcpy(&lon, &cmd_frame[5], 4); - if (len >= 13) { - memcpy(&alt, &cmd_frame[9], 4); // for FUTURE support + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid geo coordinate + } + return true; +} + +bool MyMesh::cmdGetDeviceTime(size_t) { + uint8_t reply[5]; + reply[0] = RESP_CODE_CURR_TIME; + uint32_t now = getRTCClock()->getCurrentTime(); + memcpy(&reply[1], &now, 4); + _serial->writeFrame(reply, 5); + return true; +} + +bool MyMesh::cmdSetDeviceTime(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint32_t secs = r.u32(); + uint32_t curr = getRTCClock()->getCurrentTime(); + if (secs >= curr) { + getRTCClock()->setCurrentTime(secs); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdSendSelfAdvert(size_t len) { + FrameReader r(cmd_frame, len, 1); + mesh::Packet* pkt; + if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { + pkt = createSelfAdvert(_prefs.node_name); + } else { + pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); + } + if (pkt) { + if (r.has(1) && r.u8() == 1) { // optional param (1 = flood, 0 = zero hop) + unsigned long delay_millis = 0; + TransportKey default_scope; + memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + sendFloodScoped(default_scope, pkt, delay_millis); + } else { + sendZeroHop(pkt); } - if (lat <= 90 * 1E6 && lat >= -90 * 1E6 && lon <= 180 * 1E6 && lon >= -180 * 1E6) { - sensors.node_lat = ((double)lat) / 1000000.0; - sensors.node_lon = ((double)lon) / 1000000.0; - savePrefs(); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + return true; +} + +bool MyMesh::cmdResetPath(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + recipient->out_path_len = OUT_PATH_UNKNOWN; + // recipient->lastmod = ?? shouldn't be needed, app already has this version of contact + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // unknown contact + } + return true; +} + +bool MyMesh::cmdAddUpdateContact(size_t len) { + // updateContactFromFrame() reads every field up to last_advert_timestamp without + // testing len; only the trailing lat/lon and lastmod are optional. The reverse + // direction (writeContactRespFrame) writes the same fields in the same order. + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame + if (recipient) { + updateContactFromFrame(*recipient, last_mod, cmd_frame, len); + recipient->lastmod = last_mod; + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + writeOKFrame(); + } else { + ContactInfo contact; + updateContactFromFrame(contact, last_mod, cmd_frame, len); + contact.lastmod = last_mod; + contact.sync_since = 0; + if (addContact(contact)) { + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid geo coordinate + writeErrFrame(ERR_CODE_TABLE_FULL); } - } else if (cmd_frame[0] == CMD_GET_DEVICE_TIME) { - uint8_t reply[5]; - reply[0] = RESP_CODE_CURR_TIME; - uint32_t now = getRTCClock()->getCurrentTime(); - memcpy(&reply[1], &now, 4); - _serial->writeFrame(reply, 5); - } else if (cmd_frame[0] == CMD_SET_DEVICE_TIME && len >= 5) { - uint32_t secs; - memcpy(&secs, &cmd_frame[1], 4); - uint32_t curr = getRTCClock()->getCurrentTime(); - if (secs >= curr) { - getRTCClock()->setCurrentTime(secs); + } + return true; +} + +bool MyMesh::cmdRemoveContact(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient && removeContact(*recipient)) { + _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove + } + return true; +} + +bool MyMesh::cmdShareContact(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + if (shareContactZeroHop(*recipient)) { writeOKFrame(); } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); + writeErrFrame(ERR_CODE_TABLE_FULL); // unable to send } - } else if (cmd_frame[0] == CMD_SEND_SELF_ADVERT) { + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + return true; +} + +bool MyMesh::cmdGetContactByKey(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (contact) { + writeContactRespFrame(RESP_CODE_CONTACT, *contact); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // not found + } + return true; +} + +bool MyMesh::cmdExportContact(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (!r.has(PUB_KEY_SIZE)) { + // export SELF mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { pkt = createSelfAdvert(_prefs.node_name); @@ -1302,383 +1511,371 @@ void MyMesh::handleCmdFrame(size_t len) { pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); } if (pkt) { - if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop) - unsigned long delay_millis = 0; - TransportKey default_scope; - memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); - sendFloodScoped(default_scope, pkt, delay_millis); - } else { - sendZeroHop(pkt); - } - writeOKFrame(); + pkt->header |= ROUTE_TYPE_FLOOD; // would normally be sent in this mode + + out_frame[0] = RESP_CODE_EXPORT_CONTACT; + uint8_t out_len = pkt->writeTo(&out_frame[1]); + releasePacket(pkt); // undo the obtainNewPacket() + _serial->writeFrame(out_frame, out_len + 1); } else { - writeErrFrame(ERR_CODE_TABLE_FULL); + writeErrFrame(ERR_CODE_TABLE_FULL); // Error } - } else if (cmd_frame[0] == CMD_RESET_PATH && len >= 1 + 32) { - uint8_t *pub_key = &cmd_frame[1]; + } else { + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - recipient->out_path_len = OUT_PATH_UNKNOWN; - // recipient->lastmod = ?? shouldn't be needed, app already has this version of contact - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - writeOKFrame(); + uint8_t out_len; + if (recipient && (out_len = exportContact(*recipient, &out_frame[1])) > 0) { + out_frame[0] = RESP_CODE_EXPORT_CONTACT; + _serial->writeFrame(out_frame, out_len + 1); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // unknown contact + writeErrFrame(ERR_CODE_NOT_FOUND); // not found } - } else if (cmd_frame[0] == CMD_ADD_UPDATE_CONTACT && len >= 1 + 32 + 2 + 1) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame - if (recipient) { - updateContactFromFrame(*recipient, last_mod, cmd_frame, len); - recipient->lastmod = last_mod; - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - writeOKFrame(); + } + return true; +} + +bool MyMesh::cmdImportContact(size_t len) { + FrameReader r(cmd_frame, len, 1); + size_t n = r.remaining(); + if (importContact(r.bytes(n), n)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdSyncNextMessage(size_t) { + int out_len; + if ((out_len = getFromOfflineQueue(out_frame)) > 0) { + _serial->writeFrame(out_frame, out_len); + if (_listener) _listener->onQueueSizeChanged(offline_queue_len); + } else { + out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; + _serial->writeFrame(out_frame, 1); + } + return true; +} + +// freq, bw, sf, cr; the repeat flag stays optional +bool MyMesh::cmdSetRadioParams(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint32_t freq = r.u32(); + uint32_t bw = r.u32(); + uint8_t sf = r.u8(); + uint8_t cr = r.u8(); + uint8_t repeat = 0; // default - false + if (r.has(1)) { + repeat = r.u8(); // FIRMWARE_VER_CODE 9+ + } + + if (repeat && !isValidClientRepeatFreq(freq)) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && + bw <= 500000) { + _prefs.sf = sf; + _prefs.cr = cr; + _prefs.freq = (float)freq / 1000.0; + _prefs.bw = (float)bw / 1000.0; + _prefs.setRepeatEn(repeat != 0); + savePrefs(); + + radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, + (uint32_t)cr); + + writeOKFrame(); + } else { + MESH_DEBUG_PRINTLN("Error: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, + (uint32_t)cr); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdSetRadioTxPower(size_t len) { + FrameReader r(cmd_frame, len, 1); + int8_t power = (int8_t)r.u8(); + if (power < -9 || power > MAX_LORA_TX_POWER) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + _prefs.tx_power_dbm = power; + savePrefs(); + radio_driver.setTxPower(_prefs.tx_power_dbm); + writeOKFrame(); + } + return true; +} + +// two 32 bit values, neither range checked +bool MyMesh::cmdSetTuningParams(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint32_t rx = r.u32(); + uint32_t af = r.u32(); + _prefs.rx_delay_base = ((float)rx) / 1000.0f; + _prefs.airtime_factor = ((float)af) / 1000.0f; + savePrefs(); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdGetTuningParams(size_t) { + uint32_t rx = _prefs.rx_delay_base * 1000, af = _prefs.airtime_factor * 1000; + int i = 0; + out_frame[i++] = RESP_CODE_TUNING_PARAMS; + memcpy(&out_frame[i], &rx, 4); i += 4; + memcpy(&out_frame[i], &af, 4); i += 4; + _serial->writeFrame(out_frame, i); + return true; +} + +// the fields after manual_add stay optional +bool MyMesh::cmdSetOtherParams(size_t len) { + FrameReader r(cmd_frame, len, 1); + _prefs.manual_add_contacts = r.u8(); + if (r.has(1)) { + uint8_t modes = r.u8(); + _prefs.telemetry_mode_base = modes & 0x03; // v5+ + _prefs.telemetry_mode_loc = (modes >> 2) & 0x03; + _prefs.telemetry_mode_env = (modes >> 4) & 0x03; + + if (r.has(1)) { + _prefs.advert_loc_policy = r.u8(); + if (r.has(1)) { + _prefs.multi_acks = r.u8(); + } + } + } + savePrefs(); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdSetPathHashMode(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (r.u8() != 0) return false; + uint8_t mode = r.u8(); + if (mode >= 3) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + _prefs.path_hash_mode = mode; + savePrefs(); + writeOKFrame(); + } + return true; +} + +bool MyMesh::cmdReboot(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (memcmp(r.bytes(6), "reboot", 6) != 0) return false; + if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? + saveContacts(); + } + board.reboot(); + return true; +} + +bool MyMesh::cmdGetBattAndStorage(size_t) { + uint8_t reply[11]; + int i = 0; + reply[i++] = RESP_CODE_BATT_AND_STORAGE; + uint16_t battery_millivolts = board.getBattMilliVolts(); + uint32_t used = _store->getStorageUsedKb(); + uint32_t total = _store->getStorageTotalKb(); + memcpy(&reply[i], &battery_millivolts, 2); i += 2; + memcpy(&reply[i], &used, 4); i += 4; + memcpy(&reply[i], &total, 4); i += 4; + _serial->writeFrame(reply, i); + return true; +} + +bool MyMesh::cmdExportPrivateKey(size_t) { +#if ENABLE_PRIVATE_KEY_EXPORT + uint8_t reply[65]; + reply[0] = RESP_CODE_PRIVATE_KEY; + self_id.writeTo(&reply[1], 64); + _serial->writeFrame(reply, 65); +#else + writeDisabledFrame(); +#endif + return true; +} + +bool MyMesh::cmdImportPrivateKey(size_t len) { +#if ENABLE_PRIVATE_KEY_IMPORT + FrameReader r(cmd_frame, len, 1); + uint8_t *key = r.bytes(64); + if (!mesh::LocalIdentity::validatePrivateKey(key)) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key + } else { + mesh::LocalIdentity identity; + identity.readFrom(key, 64); + if (_store->saveMainIdentity(identity)) { + self_id = identity; + writeOKFrame(); + // re-load contacts, to invalidate ecdh shared_secrets + resetContacts(); + _store->loadContacts(this); + } else { + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } + } +#else + writeDisabledFrame(); +#endif + return true; +} + +bool MyMesh::cmdSendRawData(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t path_len = r.u8(); + if (path_len >= 0 && mesh::Packet::isValidPathLen(path_len)) { + uint8_t path[MAX_PATH_SIZE]; + const uint8_t *src = r.bytes(pathByteLen(path_len)); + if (src) mesh::Packet::writePath(path, src, path_len); + if (!r.ok() || !r.has(4)) { // the path and a payload of at least 4 bytes + writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { - ContactInfo contact; - updateContactFromFrame(contact, last_mod, cmd_frame, len); - contact.lastmod = last_mod; - contact.sync_since = 0; - if (addContact(contact)) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + size_t n = r.remaining(); + auto pkt = createRawData(r.bytes(n), n); + if (pkt) { + sendDirect(pkt, path, path_len); writeOKFrame(); } else { writeErrFrame(ERR_CODE_TABLE_FULL); } } - } else if (cmd_frame[0] == CMD_REMOVE_CONTACT) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient && removeContact(*recipient)) { - _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // flood, not supported (yet) + } + return true; +} + +bool MyMesh::cmdSendLogin(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + char *password = r.restAsCString(); + if (recipient) { + uint32_t est_timeout; + int result = sendLogin(*recipient, password, est_timeout); + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove + clearPendingReqs(); + memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse() + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &pending_login, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_SHARE_CONTACT) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - if (shareContactZeroHop(*recipient)) { - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); // unable to send - } + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + } + return true; +} + +bool MyMesh::cmdSendAnonReq(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + ContactInfo anon; + if (recipient == NULL) { // FIRMWARE_VER_CODE 13+, allow non-contact requests + memset(&anon, 0, sizeof(anon)); + memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE); + anon.out_path_len = 0; // default to zero-hop direct + anon.type = ADV_TYPE_NONE; // unknown + anon.lastmod = getRTCClock()->getCurrentTime(); + + if (addContact(anon)) recipient = &anon; + } + size_t data_len = r.remaining(); + uint8_t *data = r.bytes(data_len); + if (recipient) { + uint32_t tag, est_timeout; + int result = sendAnonReq(*recipient, data, data_len, tag, est_timeout); + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); + clearPendingReqs(); + pending_req = tag; // match this to onContactResponse() + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (contact) { - writeContactRespFrame(RESP_CODE_CONTACT, *contact); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); // contacts full + } + return true; +} + +bool MyMesh::cmdSendStatusReq(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + uint32_t tag, est_timeout; + int result = sendRequest(*recipient, REQ_TYPE_GET_STATUS, tag, est_timeout); + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // not found + clearPendingReqs(); + // FUTURE: pending_status = tag; // match this in onContactResponse() + memcpy(&pending_status, recipient->id.pub_key, 4); // legacy matching scheme + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_EXPORT_CONTACT) { - if (len < 1 + PUB_KEY_SIZE) { - // export SELF - mesh::Packet* pkt; - if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { - pkt = createSelfAdvert(_prefs.node_name); - } else { - pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); - } - if (pkt) { - pkt->header |= ROUTE_TYPE_FLOOD; // would normally be sent in this mode - - out_frame[0] = RESP_CODE_EXPORT_CONTACT; - uint8_t out_len = pkt->writeTo(&out_frame[1]); - releasePacket(pkt); // undo the obtainNewPacket() - _serial->writeFrame(out_frame, out_len + 1); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); // Error - } - } else { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - uint8_t out_len; - if (recipient && (out_len = exportContact(*recipient, &out_frame[1])) > 0) { - out_frame[0] = RESP_CODE_EXPORT_CONTACT; - _serial->writeFrame(out_frame, out_len + 1); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // not found - } - } - } else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) { - if (importContact(&cmd_frame[1], len - 1)) { - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } - } else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) { - int out_len; - if ((out_len = getFromOfflineQueue(out_frame)) > 0) { - _serial->writeFrame(out_frame, out_len); - if (_listener) _listener->onQueueSizeChanged(offline_queue_len); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + } + return true; +} + +bool MyMesh::cmdSendPathDiscoveryReq(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (r.u8() != 0) return false; + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + uint32_t tag, est_timeout; + // 'Path Discovery' is just a special case of flood + Telemetry req + uint8_t req_data[9]; + req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; + req_data[1] = ~(TELEM_PERM_BASE); // NEW: inverse permissions mask (ie. we only want BASE telemetry) + memset(&req_data[2], 0, 3); // reserved + getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique + auto save = recipient->out_path_len; // temporarily force sendRequest() to flood + recipient->out_path_len = OUT_PATH_UNKNOWN; + int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout); + recipient->out_path_len = save; + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; - _serial->writeFrame(out_frame, 1); + clearPendingReqs(); + pending_discovery = tag; // match this in onContactResponse() + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) { - int i = 1; - uint32_t freq; - memcpy(&freq, &cmd_frame[i], 4); - i += 4; - uint32_t bw; - memcpy(&bw, &cmd_frame[i], 4); - i += 4; - uint8_t sf = cmd_frame[i++]; - uint8_t cr = cmd_frame[i++]; - uint8_t repeat = 0; // default - false - if (len > i) { - repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+ - } - - if (repeat && !isValidClientRepeatFreq(freq)) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && - bw <= 500000) { - _prefs.sf = sf; - _prefs.cr = cr; - _prefs.freq = (float)freq / 1000.0; - _prefs.bw = (float)bw / 1000.0; - _prefs.setRepeatEn(repeat != 0); - savePrefs(); - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, - (uint32_t)cr); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + } + return true; +} - writeOKFrame(); - } else { - MESH_DEBUG_PRINTLN("Error: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, - (uint32_t)cr); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } - } else if (cmd_frame[0] == CMD_SET_RADIO_TX_POWER) { - int8_t power = (int8_t)cmd_frame[1]; - if (power < -9 || power > MAX_LORA_TX_POWER) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else { - _prefs.tx_power_dbm = power; - savePrefs(); - radio_driver.setTxPower(_prefs.tx_power_dbm); - writeOKFrame(); - } - } else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) { - int i = 1; - uint32_t rx, af; - memcpy(&rx, &cmd_frame[i], 4); - i += 4; - memcpy(&af, &cmd_frame[i], 4); - i += 4; - _prefs.rx_delay_base = ((float)rx) / 1000.0f; - _prefs.airtime_factor = ((float)af) / 1000.0f; - savePrefs(); - writeOKFrame(); - } else if (cmd_frame[0] == CMD_GET_TUNING_PARAMS) { - uint32_t rx = _prefs.rx_delay_base * 1000, af = _prefs.airtime_factor * 1000; - int i = 0; - out_frame[i++] = RESP_CODE_TUNING_PARAMS; - memcpy(&out_frame[i], &rx, 4); i += 4; - memcpy(&out_frame[i], &af, 4); i += 4; - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_SET_OTHER_PARAMS) { - _prefs.manual_add_contacts = cmd_frame[1]; - if (len >= 3) { - _prefs.telemetry_mode_base = cmd_frame[2] & 0x03; // v5+ - _prefs.telemetry_mode_loc = (cmd_frame[2] >> 2) & 0x03; - _prefs.telemetry_mode_env = (cmd_frame[2] >> 4) & 0x03; - - if (len >= 4) { - _prefs.advert_loc_policy = cmd_frame[3]; - if (len >= 5) { - _prefs.multi_acks = cmd_frame[4]; - } - } - } - savePrefs(); - writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && cmd_frame[1] == 0 && len >= 3) { - if (cmd_frame[2] >= 3) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else { - _prefs.path_hash_mode = cmd_frame[2]; - savePrefs(); - writeOKFrame(); - } - } else if (cmd_frame[0] == CMD_REBOOT && memcmp(&cmd_frame[1], "reboot", 6) == 0) { - if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? - saveContacts(); - } - board.reboot(); - } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { - uint8_t reply[11]; - int i = 0; - reply[i++] = RESP_CODE_BATT_AND_STORAGE; - uint16_t battery_millivolts = board.getBattMilliVolts(); - uint32_t used = _store->getStorageUsedKb(); - uint32_t total = _store->getStorageTotalKb(); - memcpy(&reply[i], &battery_millivolts, 2); i += 2; - memcpy(&reply[i], &used, 4); i += 4; - memcpy(&reply[i], &total, 4); i += 4; - _serial->writeFrame(reply, i); - } else if (cmd_frame[0] == CMD_EXPORT_PRIVATE_KEY) { -#if ENABLE_PRIVATE_KEY_EXPORT - uint8_t reply[65]; - reply[0] = RESP_CODE_PRIVATE_KEY; - self_id.writeTo(&reply[1], 64); - _serial->writeFrame(reply, 65); -#else - writeDisabledFrame(); -#endif - } else if (cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY && len >= 65) { -#if ENABLE_PRIVATE_KEY_IMPORT - if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key - } else { - mesh::LocalIdentity identity; - identity.readFrom(&cmd_frame[1], 64); - if (_store->saveMainIdentity(identity)) { - self_id = identity; - writeOKFrame(); - // re-load contacts, to invalidate ecdh shared_secrets - resetContacts(); - _store->loadContacts(this); - } else { - writeErrFrame(ERR_CODE_FILE_IO_ERROR); - } - } -#else - writeDisabledFrame(); -#endif - } else if (cmd_frame[0] == CMD_SEND_RAW_DATA && len >= 6) { - int i = 1; - uint8_t path_len = cmd_frame[i++]; - if (path_len >= 0 && mesh::Packet::isValidPathLen(path_len)) { - uint8_t path[MAX_PATH_SIZE]; - i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); - if (i + 4 > len) { // min payload 4 bytes - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else { - auto pkt = createRawData(&cmd_frame[i], len - i); - if (pkt) { - sendDirect(pkt, path, path_len); - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); - } - } - } else { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // flood, not supported (yet) - } - } else if (cmd_frame[0] == CMD_SEND_LOGIN && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - char *password = (char *)&cmd_frame[1 + PUB_KEY_SIZE]; - cmd_frame[len] = 0; // ensure null terminator in password - if (recipient) { - uint32_t est_timeout; - int result = sendLogin(*recipient, password, est_timeout); - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse() - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &pending_login, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found - } - } else if (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - ContactInfo anon; - if (recipient == NULL) { // FIRMWARE_VER_CODE 13+, allow non-contact requests - memset(&anon, 0, sizeof(anon)); - memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE); - anon.out_path_len = 0; // default to zero-hop direct - anon.type = ADV_TYPE_NONE; // unknown - anon.lastmod = getRTCClock()->getCurrentTime(); - - if (addContact(anon)) recipient = &anon; - } - uint8_t *data = &cmd_frame[1 + PUB_KEY_SIZE]; - if (recipient) { - uint32_t tag, est_timeout; - int result = sendAnonReq(*recipient, data, len - (1 + PUB_KEY_SIZE), tag, est_timeout); - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - pending_req = tag; // match this to onContactResponse() - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); // contacts full - } - } else if (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - uint32_t tag, est_timeout; - int result = sendRequest(*recipient, REQ_TYPE_GET_STATUS, tag, est_timeout); - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - // FUTURE: pending_status = tag; // match this in onContactResponse() - memcpy(&pending_status, recipient->id.pub_key, 4); // legacy matching scheme - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found - } - } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && cmd_frame[1] == 0 && len >= 2 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[2]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - uint32_t tag, est_timeout; - // 'Path Discovery' is just a special case of flood + Telemetry req - uint8_t req_data[9]; - req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; - req_data[1] = ~(TELEM_PERM_BASE); // NEW: inverse permissions mask (ie. we only want BASE telemetry) - memset(&req_data[2], 0, 3); // reserved - getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique - auto save = recipient->out_path_len; // temporarily force sendRequest() to flood - recipient->out_path_len = OUT_PATH_UNKNOWN; - int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout); - recipient->out_path_len = save; - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - pending_discovery = tag; // match this in onContactResponse() - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found - } - } else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len >= 4 + PUB_KEY_SIZE) { // can deprecate, in favour of CMD_SEND_BINARY_REQ - uint8_t *pub_key = &cmd_frame[4]; +bool MyMesh::cmdSendTelemetryReq(size_t len) { + FrameReader r(cmd_frame, len, 1); + r.skip(3); // reserved + if (r.has(PUB_KEY_SIZE)) { // can deprecate, in favour of CMD_SEND_BINARY_REQ + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { uint32_t tag, est_timeout; @@ -1697,7 +1894,9 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found } - } else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len == 4) { // 'self' telemetry request + return true; + } + if (r.remaining() == 0) { // 'self' telemetry request telemetry.reset(); telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); float temperature = board.getMCUTemperature(); @@ -1717,349 +1916,456 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], telemetry.getBuffer(), tlen); i += tlen; _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_SEND_BINARY_REQ && len >= 2 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - uint8_t *req_data = &cmd_frame[1 + PUB_KEY_SIZE]; - uint32_t tag, est_timeout; - int result = sendRequest(*recipient, req_data, len - (1 + PUB_KEY_SIZE), tag, est_timeout); - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - pending_req = tag; // match this in onContactResponse() - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found - } - } else if (cmd_frame[0] == CMD_HAS_CONNECTION && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - if (hasConnectionTo(pub_key)) { - writeOKFrame(); + return true; + } + return false; +} + +bool MyMesh::cmdSendBinaryReq(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + size_t req_len = r.remaining(); + uint8_t *req_data = r.bytes(req_len); + uint32_t tag, est_timeout; + int result = sendRequest(*recipient, req_data, req_len, tag, est_timeout); + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); + clearPendingReqs(); + pending_req = tag; // match this in onContactResponse() + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_LOGOUT && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - stopConnection(pub_key); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + } + return true; +} + +bool MyMesh::cmdHasConnection(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + if (hasConnectionTo(pub_key)) { writeOKFrame(); - } else if (cmd_frame[0] == CMD_GET_CHANNEL && len >= 2) { - uint8_t channel_idx = cmd_frame[1]; - ChannelDetails channel; - if (getChannel(channel_idx, channel)) { - int i = 0; - out_frame[i++] = RESP_CODE_CHANNEL_INFO; - out_frame[i++] = channel_idx; - strcpy((char *)&out_frame[i], channel.name); - i += 32; - memcpy(&out_frame[i], channel.channel.secret, 16); - i += 16; // NOTE: only 128-bit supported - _serial->writeFrame(out_frame, i); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); - } - } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32) { + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + return true; +} + +bool MyMesh::cmdLogout(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + stopConnection(pub_key); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdGetChannel(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t channel_idx = r.u8(); + ChannelDetails channel; + if (getChannel(channel_idx, channel)) { + int i = 0; + out_frame[i++] = RESP_CODE_CHANNEL_INFO; + out_frame[i++] = channel_idx; + strcpy((char *)&out_frame[i], channel.name); + i += 32; + memcpy(&out_frame[i], channel.channel.secret, 16); + i += 16; // NOTE: only 128-bit supported + _serial->writeFrame(out_frame, i); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + return true; +} + +bool MyMesh::cmdSetChannel(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (r.has(1 + 32 + 32)) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // not supported (yet) - } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) { - uint8_t channel_idx = cmd_frame[1]; + return true; + } + if (r.has(1 + 32 + 16)) { + uint8_t channel_idx = r.u8(); ChannelDetails channel; - StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); + StrHelper::strncpy(channel.name, (char *)r.bytes(32), 32); memset(channel.channel.secret, 0, sizeof(channel.channel.secret)); - memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported + memcpy(channel.channel.secret, r.bytes(16), 16); // NOTE: only 128-bit supported if (setChannel(channel_idx, channel)) { saveChannels(); writeOKFrame(); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx } - } else if (cmd_frame[0] == CMD_SIGN_START) { - out_frame[0] = RESP_CODE_SIGN_START; - out_frame[1] = 0; // reserved - uint32_t len = MAX_SIGN_DATA_LEN; - memcpy(&out_frame[2], &len, 4); - _serial->writeFrame(out_frame, 6); - - if (sign_data) { - free(sign_data); - } - sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN); - sign_data_len = 0; - } else if (cmd_frame[0] == CMD_SIGN_DATA && len > 1) { - if (sign_data == NULL || sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) { - writeErrFrame(sign_data == NULL ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL); // error: too long - } else { - memcpy(&sign_data[sign_data_len], &cmd_frame[1], len - 1); - sign_data_len += (len - 1); - writeOKFrame(); - } - } else if (cmd_frame[0] == CMD_SIGN_FINISH) { - if (sign_data) { - self_id.sign(&out_frame[1], sign_data, sign_data_len); + return true; + } + return false; +} - free(sign_data); // don't need sign_data now - sign_data = NULL; +bool MyMesh::cmdSignStart(size_t) { + out_frame[0] = RESP_CODE_SIGN_START; + out_frame[1] = 0; // reserved + uint32_t len = MAX_SIGN_DATA_LEN; + memcpy(&out_frame[2], &len, 4); + _serial->writeFrame(out_frame, 6); - out_frame[0] = RESP_CODE_SIGNATURE; - _serial->writeFrame(out_frame, 1 + SIGNATURE_SIZE); - } else { - writeErrFrame(ERR_CODE_BAD_STATE); - } - } else if (cmd_frame[0] == CMD_SEND_TRACE_PATH && len > 10 && len - 10 < MAX_PACKET_PAYLOAD-5) { - uint8_t path_len = len - 10; - uint8_t flags = cmd_frame[9]; - uint8_t path_sz = flags & 0x03; // NEW v1.11+ - if ((path_len >> path_sz) > MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { // make sure is multiple of path_sz - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else { - uint32_t tag, auth; - memcpy(&tag, &cmd_frame[1], 4); - memcpy(&auth, &cmd_frame[5], 4); - auto pkt = createTrace(tag, auth, flags); - if (pkt) { - sendDirect(pkt, &cmd_frame[10], path_len); + if (sign_data) { + free(sign_data); + } + sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN); + sign_data_len = 0; + return true; +} + +bool MyMesh::cmdSignData(size_t len) { + FrameReader r(cmd_frame, len, 1); + size_t n = r.remaining(); + if (sign_data == NULL || sign_data_len + n > MAX_SIGN_DATA_LEN) { + writeErrFrame(sign_data == NULL ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL); // error: too long + } else { + memcpy(&sign_data[sign_data_len], r.bytes(n), n); + sign_data_len += n; + writeOKFrame(); + } + return true; +} - uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); - uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len >> path_sz); +bool MyMesh::cmdSignFinish(size_t) { + if (sign_data) { + self_id.sign(&out_frame[1], sign_data, sign_data_len); - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); - } - } - } else if (cmd_frame[0] == CMD_SET_DEVICE_PIN && len >= 5) { + free(sign_data); // don't need sign_data now + sign_data = NULL; - // get pin from command frame - uint32_t pin; - memcpy(&pin, &cmd_frame[1], 4); + out_frame[0] = RESP_CODE_SIGNATURE; + _serial->writeFrame(out_frame, 1 + SIGNATURE_SIZE); + } else { + writeErrFrame(ERR_CODE_BAD_STATE); + } + return true; +} - // ensure pin is zero, or a valid 6 digit pin - if (pin == 0 || (pin >= 100000 && pin <= 999999)) { - _prefs.ble_pin = pin; - savePrefs(); - writeOKFrame(); +bool MyMesh::cmdSendTracePath(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint32_t tag = r.u32(); + uint32_t auth = r.u32(); + uint8_t flags = r.u8(); + if (r.remaining() >= MAX_PACKET_PAYLOAD-5) return false; + uint8_t path_len = r.remaining(); + uint8_t *path = r.bytes(path_len); + uint8_t path_sz = flags & 0x03; // NEW v1.11+ + if ((path_len >> path_sz) > MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { // make sure is multiple of path_sz + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + auto pkt = createTrace(tag, auth, flags); + if (pkt) { + sendDirect(pkt, path, path_len); + + uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); + uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len >> path_sz); + + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); + writeErrFrame(ERR_CODE_TABLE_FULL); } - } else if (cmd_frame[0] == CMD_GET_CUSTOM_VARS) { - out_frame[0] = RESP_CODE_CUSTOM_VARS; - char *dp = (char *)&out_frame[1]; - for (int i = 0; i < sensors.getNumSettings() && dp - (char *)&out_frame[1] < 140; i++) { - if (i > 0) { - *dp++ = ','; - } - strcpy(dp, sensors.getSettingName(i)); - dp = strchr(dp, 0); - *dp++ = ':'; - strcpy(dp, sensors.getSettingValue(i)); - dp = strchr(dp, 0); + } + return true; +} + +bool MyMesh::cmdSetDevicePin(size_t len) { + + // get pin from command frame + FrameReader r(cmd_frame, len, 1); + uint32_t pin = r.u32(); + + // ensure pin is zero, or a valid 6 digit pin + if (pin == 0 || (pin >= 100000 && pin <= 999999)) { + _prefs.ble_pin = pin; + savePrefs(); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdGetCustomVars(size_t) { + out_frame[0] = RESP_CODE_CUSTOM_VARS; + char *dp = (char *)&out_frame[1]; + for (int i = 0; i < sensors.getNumSettings() && dp - (char *)&out_frame[1] < 140; i++) { + if (i > 0) { + *dp++ = ','; } - _serial->writeFrame(out_frame, dp - (char *)out_frame); - } else if (cmd_frame[0] == CMD_SET_CUSTOM_VAR && len >= 4) { - cmd_frame[len] = 0; - char *sp = (char *)&cmd_frame[1]; - char *np = strchr(sp, ':'); // look for separator char - if (np) { - *np++ = 0; // modify 'cmd_frame', replace ':' with null - bool success = sensors.setSettingValue(sp, np); - if (success) { - #if ENV_INCLUDE_GPS == 1 - // Update node preferences for GPS settings - if (strcmp(sp, "gps") == 0) { - _prefs.gps_enabled = (np[0] == '1') ? 1 : 0; - savePrefs(); - } else if (strcmp(sp, "gps_interval") == 0) { - uint32_t interval_seconds = atoi(np); - _prefs.gps_interval = constrain(interval_seconds, 0, 86400); - savePrefs(); - } - #endif - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); + strcpy(dp, sensors.getSettingName(i)); + dp = strchr(dp, 0); + *dp++ = ':'; + strcpy(dp, sensors.getSettingValue(i)); + dp = strchr(dp, 0); + } + _serial->writeFrame(out_frame, dp - (char *)out_frame); + return true; +} + +bool MyMesh::cmdSetCustomVar(size_t len) { + FrameReader r(cmd_frame, len, 1); + char *sp = r.restAsCString(); + char *np = strchr(sp, ':'); // look for separator char + if (np) { + *np++ = 0; // modify 'cmd_frame', replace ':' with null + bool success = sensors.setSettingValue(sp, np); + if (success) { + #if ENV_INCLUDE_GPS == 1 + // Update node preferences for GPS settings + if (strcmp(sp, "gps") == 0) { + _prefs.gps_enabled = (np[0] == '1') ? 1 : 0; + savePrefs(); + } else if (strcmp(sp, "gps_interval") == 0) { + uint32_t interval_seconds = atoi(np); + _prefs.gps_interval = constrain(interval_seconds, 0, 86400); + savePrefs(); } + #endif + writeOKFrame(); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } - } else if (cmd_frame[0] == CMD_GET_ADVERT_PATH && len >= PUB_KEY_SIZE+2) { - // FUTURE use: uint8_t reserved = cmd_frame[1]; - uint8_t *pub_key = &cmd_frame[2]; - AdvertPath* found = NULL; - for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { - auto p = &advert_paths[i]; - if (memcmp(p->pubkey_prefix, pub_key, sizeof(p->pubkey_prefix)) == 0) { - found = p; - break; - } - } - if (found) { - int i = 0; - out_frame[i++] = RESP_CODE_ADVERT_PATH; - memcpy(&out_frame[i], &found->recv_timestamp, 4); i += 4; - out_frame[i++] = found->path_len; - i += mesh::Packet::writePath(&out_frame[i], found->path, found->path_len); - _serial->writeFrame(out_frame, i); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); - } - } else if (cmd_frame[0] == CMD_GET_STATS && len >= 2) { - uint8_t stats_type = cmd_frame[1]; - if (stats_type == STATS_TYPE_CORE) { - int i = 0; - out_frame[i++] = RESP_CODE_STATS; - out_frame[i++] = STATS_TYPE_CORE; - uint16_t battery_mv = board.getBattMilliVolts(); - uint32_t uptime_secs = _ms->getMillis() / 1000; - uint8_t queue_len = (uint8_t)_mgr->getOutboundTotal(); - memcpy(&out_frame[i], &battery_mv, 2); i += 2; - memcpy(&out_frame[i], &uptime_secs, 4); i += 4; - memcpy(&out_frame[i], &_err_flags, 2); i += 2; - out_frame[i++] = queue_len; - _serial->writeFrame(out_frame, i); - } else if (stats_type == STATS_TYPE_RADIO) { - int i = 0; - out_frame[i++] = RESP_CODE_STATS; - out_frame[i++] = STATS_TYPE_RADIO; - int16_t noise_floor = (int16_t)_radio->getNoiseFloor(); - int8_t last_rssi = (int8_t)radio_driver.getLastRSSI(); - int8_t last_snr = (int8_t)(radio_driver.getLastSNR() * 4); // scaled by 4 for 0.25 dB precision - uint32_t tx_air_secs = getTotalAirTime() / 1000; - uint32_t rx_air_secs = getReceiveAirTime() / 1000; - memcpy(&out_frame[i], &noise_floor, 2); i += 2; - out_frame[i++] = last_rssi; - out_frame[i++] = last_snr; - memcpy(&out_frame[i], &tx_air_secs, 4); i += 4; - memcpy(&out_frame[i], &rx_air_secs, 4); i += 4; - _serial->writeFrame(out_frame, i); - } else if (stats_type == STATS_TYPE_PACKETS) { - int i = 0; - out_frame[i++] = RESP_CODE_STATS; - out_frame[i++] = STATS_TYPE_PACKETS; - uint32_t recv = radio_driver.getPacketsRecv(); - uint32_t sent = radio_driver.getPacketsSent(); - uint32_t n_sent_flood = getNumSentFlood(); - uint32_t n_sent_direct = getNumSentDirect(); - uint32_t n_recv_flood = getNumRecvFlood(); - uint32_t n_recv_direct = getNumRecvDirect(); - uint32_t n_recv_errors = radio_driver.getPacketsRecvErrors(); - memcpy(&out_frame[i], &recv, 4); i += 4; - memcpy(&out_frame[i], &sent, 4); i += 4; - memcpy(&out_frame[i], &n_sent_flood, 4); i += 4; - memcpy(&out_frame[i], &n_sent_direct, 4); i += 4; - memcpy(&out_frame[i], &n_recv_flood, 4); i += 4; - memcpy(&out_frame[i], &n_recv_direct, 4); i += 4; - memcpy(&out_frame[i], &n_recv_errors, 4); i += 4; - _serial->writeFrame(out_frame, i); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type - } - } else if (cmd_frame[0] == CMD_FACTORY_RESET && memcmp(&cmd_frame[1], "reset", 5) == 0) { - if (_serial) { - MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)"); - _serial->disable(); // Phone app disconnects before we can send OK frame so it's safe here - } - bool success = _store->formatFileSystem(); - if (success) { - writeOKFrame(); - delay(1000); - board.reboot(); // doesn't return - } else { - writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdGetAdvertPath(size_t len) { + FrameReader r(cmd_frame, len, 1); + r.skip(1); // FUTURE use: reserved + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); + AdvertPath* found = NULL; + for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { + auto p = &advert_paths[i]; + if (memcmp(p->pubkey_prefix, pub_key, sizeof(p->pubkey_prefix)) == 0) { + found = p; + break; } - } else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 0) { - if (len >= 2 + 16) { - memcpy(send_scope.key, &cmd_frame[2], sizeof(send_scope.key)); // set scope override TransportKey + } + if (found) { + int i = 0; + out_frame[i++] = RESP_CODE_ADVERT_PATH; + memcpy(&out_frame[i], &found->recv_timestamp, 4); i += 4; + out_frame[i++] = found->path_len; + i += mesh::Packet::writePath(&out_frame[i], found->path, found->path_len); + _serial->writeFrame(out_frame, i); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + return true; +} + +bool MyMesh::cmdGetStats(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t stats_type = r.u8(); + if (stats_type == STATS_TYPE_CORE) { + int i = 0; + out_frame[i++] = RESP_CODE_STATS; + out_frame[i++] = STATS_TYPE_CORE; + uint16_t battery_mv = board.getBattMilliVolts(); + uint32_t uptime_secs = _ms->getMillis() / 1000; + uint8_t queue_len = (uint8_t)_mgr->getOutboundTotal(); + memcpy(&out_frame[i], &battery_mv, 2); i += 2; + memcpy(&out_frame[i], &uptime_secs, 4); i += 4; + memcpy(&out_frame[i], &_err_flags, 2); i += 2; + out_frame[i++] = queue_len; + _serial->writeFrame(out_frame, i); + } else if (stats_type == STATS_TYPE_RADIO) { + int i = 0; + out_frame[i++] = RESP_CODE_STATS; + out_frame[i++] = STATS_TYPE_RADIO; + int16_t noise_floor = (int16_t)_radio->getNoiseFloor(); + int8_t last_rssi = (int8_t)radio_driver.getLastRSSI(); + int8_t last_snr = (int8_t)(radio_driver.getLastSNR() * 4); // scaled by 4 for 0.25 dB precision + uint32_t tx_air_secs = getTotalAirTime() / 1000; + uint32_t rx_air_secs = getReceiveAirTime() / 1000; + memcpy(&out_frame[i], &noise_floor, 2); i += 2; + out_frame[i++] = last_rssi; + out_frame[i++] = last_snr; + memcpy(&out_frame[i], &tx_air_secs, 4); i += 4; + memcpy(&out_frame[i], &rx_air_secs, 4); i += 4; + _serial->writeFrame(out_frame, i); + } else if (stats_type == STATS_TYPE_PACKETS) { + int i = 0; + out_frame[i++] = RESP_CODE_STATS; + out_frame[i++] = STATS_TYPE_PACKETS; + uint32_t recv = radio_driver.getPacketsRecv(); + uint32_t sent = radio_driver.getPacketsSent(); + uint32_t n_sent_flood = getNumSentFlood(); + uint32_t n_sent_direct = getNumSentDirect(); + uint32_t n_recv_flood = getNumRecvFlood(); + uint32_t n_recv_direct = getNumRecvDirect(); + uint32_t n_recv_errors = radio_driver.getPacketsRecvErrors(); + memcpy(&out_frame[i], &recv, 4); i += 4; + memcpy(&out_frame[i], &sent, 4); i += 4; + memcpy(&out_frame[i], &n_sent_flood, 4); i += 4; + memcpy(&out_frame[i], &n_sent_direct, 4); i += 4; + memcpy(&out_frame[i], &n_recv_flood, 4); i += 4; + memcpy(&out_frame[i], &n_recv_direct, 4); i += 4; + memcpy(&out_frame[i], &n_recv_errors, 4); i += 4; + _serial->writeFrame(out_frame, i); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type + } + return true; +} + +bool MyMesh::cmdFactoryReset(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (memcmp(r.bytes(5), "reset", 5) != 0) return false; + if (_serial) { + MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)"); + _serial->disable(); // Phone app disconnects before we can send OK frame so it's safe here + } + bool success = _store->formatFileSystem(); + if (success) { + writeOKFrame(); + delay(1000); + board.reboot(); // doesn't return + } else { + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } + return true; +} + +bool MyMesh::cmdSetFloodScopeKey(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t mode = r.u8(); + if (mode == 0) { + if (r.has(16)) { + memcpy(send_scope.key, r.bytes(sizeof(send_scope.key)), sizeof(send_scope.key)); // set scope override TransportKey } else { memset(send_scope.key, 0, sizeof(send_scope.key)); // reset scope override } send_unscoped = false; writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 1) { // ver 12+ + return true; + } + if (mode == 1) { // ver 12+ send_unscoped = true; writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_DEFAULT_FLOOD_SCOPE && len >= 1) { - if (len >= 1+31+16) { - int n = strlen((char *) &cmd_frame[1]); - if (n > 0 && n < 31) { - strcpy(_prefs.default_scope_name, (char *) &cmd_frame[1]); - memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); - savePrefs(); - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } - } else { - memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // set default scope to null - memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); + return true; + } + return false; +} + +bool MyMesh::cmdSetDefaultFloodScope(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (r.has(31 + 16)) { + size_t avail = r.remaining(); + const char *name = (const char *) r.bytes(31); + const uint8_t *key = r.bytes(16); + int n = strnlen(name, avail); // may run on into the key, never past the frame + if (n > 0 && n < 31) { + strcpy(_prefs.default_scope_name, name); + memcpy(_prefs.default_scope_key, key, 16); savePrefs(); writeOKFrame(); - } - } else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) { - out_frame[0] = RESP_CODE_DEFAULT_FLOOD_SCOPE; - if (strlen(_prefs.default_scope_name) > 0) { - memcpy(&out_frame[1], _prefs.default_scope_name, 31); - memcpy(&out_frame[1+31], _prefs.default_scope_key, 16); - _serial->writeFrame(out_frame, 1+31+16); } else { - _serial->writeFrame(out_frame, 1); // no name or key means null - } - } else if (cmd_frame[0] == CMD_SEND_CONTROL_DATA && len >= 2 && (cmd_frame[1] & 0x80) != 0) { - auto resp = createControlData(&cmd_frame[1], len - 1); - if (resp) { - sendZeroHop(resp); - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); - } - } else if (cmd_frame[0] == CMD_SET_AUTOADD_CONFIG) { - _prefs.autoadd_config = cmd_frame[1]; - if (len >= 3) { - _prefs.autoadd_max_hops = min(cmd_frame[2], (uint8_t)64); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); } + } else { + memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // set default scope to null + memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); savePrefs(); writeOKFrame(); - } else if (cmd_frame[0] == CMD_GET_AUTOADD_CONFIG) { - int i = 0; - out_frame[i++] = RESP_CODE_AUTOADD_CONFIG; - out_frame[i++] = _prefs.autoadd_config; - out_frame[i++] = _prefs.autoadd_max_hops; - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_GET_ALLOWED_REPEAT_FREQ) { - int i = 0; - out_frame[i++] = RESP_ALLOWED_REPEAT_FREQ; - for (int k = 0; k < sizeof(repeat_freq_ranges)/sizeof(repeat_freq_ranges[0]) && i + 8 < sizeof(out_frame); k++) { - auto r = &repeat_freq_ranges[k]; - memcpy(&out_frame[i], &r->lower_freq, 4); i += 4; - memcpy(&out_frame[i], &r->upper_freq, 4); i += 4; - } - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_SEND_RAW_PACKET && len >= 4) { - auto pkt = obtainNewPacket(); - if (pkt) { - uint8_t priority = cmd_frame[1]; - if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) { - sendPacket(pkt, priority, 0); - writeOKFrame(); - } else { - releasePacket(pkt); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } + } + return true; +} + +bool MyMesh::cmdGetDefaultFloodScope(size_t) { + out_frame[0] = RESP_CODE_DEFAULT_FLOOD_SCOPE; + if (strlen(_prefs.default_scope_name) > 0) { + memcpy(&out_frame[1], _prefs.default_scope_name, 31); + memcpy(&out_frame[1+31], _prefs.default_scope_key, 16); + _serial->writeFrame(out_frame, 1+31+16); + } else { + _serial->writeFrame(out_frame, 1); // no name or key means null + } + return true; +} + +bool MyMesh::cmdSendControlData(size_t len) { + FrameReader r(cmd_frame, len, 1); + size_t n = r.remaining(); + uint8_t *data = r.bytes(n); + if ((data[0] & 0x80) == 0) return false; + auto resp = createControlData(data, n); + if (resp) { + sendZeroHop(resp); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + return true; +} + +// autoadd_max_hops stays optional +bool MyMesh::cmdSetAutoaddConfig(size_t len) { + FrameReader r(cmd_frame, len, 1); + _prefs.autoadd_config = r.u8(); + if (r.has(1)) { + uint8_t hops = r.u8(); // read once: min() may be a macro + _prefs.autoadd_max_hops = min(hops, (uint8_t)64); + } + savePrefs(); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdGetAutoaddConfig(size_t) { + int i = 0; + out_frame[i++] = RESP_CODE_AUTOADD_CONFIG; + out_frame[i++] = _prefs.autoadd_config; + out_frame[i++] = _prefs.autoadd_max_hops; + _serial->writeFrame(out_frame, i); + return true; +} + +bool MyMesh::cmdGetAllowedRepeatFreq(size_t) { + int i = 0; + out_frame[i++] = RESP_ALLOWED_REPEAT_FREQ; + for (int k = 0; k < sizeof(repeat_freq_ranges)/sizeof(repeat_freq_ranges[0]) && i + 8 < sizeof(out_frame); k++) { + auto r = &repeat_freq_ranges[k]; + memcpy(&out_frame[i], &r->lower_freq, 4); i += 4; + memcpy(&out_frame[i], &r->upper_freq, 4); i += 4; + } + _serial->writeFrame(out_frame, i); + return true; +} + +bool MyMesh::cmdSendRawPacket(size_t len) { + auto pkt = obtainNewPacket(); + if (pkt) { + FrameReader r(cmd_frame, len, 1); + uint8_t priority = r.u8(); + int raw_len = r.remaining(); + if (tryParsePacket(pkt, r.bytes(raw_len), raw_len)) { + sendPacket(pkt, priority, 0); + writeOKFrame(); } else { - writeErrFrame(ERR_CODE_TABLE_FULL); + releasePacket(pkt); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); } } else { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); - MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); + writeErrFrame(ERR_CODE_TABLE_FULL); } + return true; } static bool save_filter(const ContactInfo& c) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 944790df922..d2e4b6bdeca 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -127,16 +127,6 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { int getRecentlyHeard(AdvertPath dest[], int max_num); protected: - float getAirtimeBudgetFactor() const override; - int getInterferenceThreshold() const override; - bool getCADEnabled() const override; - int getAGCResetInterval() const override { - return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds - } - int calcRxDelay(float score, uint32_t air_time) const override; - uint32_t getRetransmitDelay(const mesh::Packet *packet) override; - uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override; - uint8_t getExtraAckTransmitCount() const override; bool filterRecvFloodPacket(mesh::Packet* packet) override; bool allowPacketForward(const mesh::Packet* packet) override; @@ -230,6 +220,73 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { return _store->putBlobByKey(key, key_len, src_buf, len); } + // The companion protocol, see CMD_TABLE in MyMesh.cpp: one handler per command. + struct CmdHandler { + uint8_t code; + uint8_t min_len; // whole frame, the code byte included + bool (MyMesh::*handle)(size_t len); // false: not this command after all + }; + static const CmdHandler CMD_TABLE[]; + bool cmdDeviceQuery(size_t len); + bool cmdAppStart(size_t len); + bool cmdRunCliCommand(size_t len); + bool cmdSendTxtMsg(size_t len); + bool cmdSendChannelTxtMsg(size_t len); + bool cmdSendChannelData(size_t len); + bool cmdGetContacts(size_t len); + bool cmdSetAdvertName(size_t len); + bool cmdSetAdvertLatlon(size_t len); + bool cmdGetDeviceTime(size_t len); + bool cmdSetDeviceTime(size_t len); + bool cmdSendSelfAdvert(size_t len); + bool cmdResetPath(size_t len); + bool cmdAddUpdateContact(size_t len); + bool cmdRemoveContact(size_t len); + bool cmdShareContact(size_t len); + bool cmdGetContactByKey(size_t len); + bool cmdExportContact(size_t len); + bool cmdImportContact(size_t len); + bool cmdSyncNextMessage(size_t len); + bool cmdSetRadioParams(size_t len); + bool cmdSetRadioTxPower(size_t len); + bool cmdSetTuningParams(size_t len); + bool cmdGetTuningParams(size_t len); + bool cmdSetOtherParams(size_t len); + bool cmdSetPathHashMode(size_t len); + bool cmdReboot(size_t len); + bool cmdGetBattAndStorage(size_t len); + bool cmdExportPrivateKey(size_t len); + bool cmdImportPrivateKey(size_t len); + bool cmdSendRawData(size_t len); + bool cmdSendLogin(size_t len); + bool cmdSendAnonReq(size_t len); + bool cmdSendStatusReq(size_t len); + bool cmdSendPathDiscoveryReq(size_t len); + bool cmdSendTelemetryReq(size_t len); + bool cmdSendBinaryReq(size_t len); + bool cmdHasConnection(size_t len); + bool cmdLogout(size_t len); + bool cmdGetChannel(size_t len); + bool cmdSetChannel(size_t len); + bool cmdSignStart(size_t len); + bool cmdSignData(size_t len); + bool cmdSignFinish(size_t len); + bool cmdSendTracePath(size_t len); + bool cmdSetDevicePin(size_t len); + bool cmdGetCustomVars(size_t len); + bool cmdSetCustomVar(size_t len); + bool cmdGetAdvertPath(size_t len); + bool cmdGetStats(size_t len); + bool cmdFactoryReset(size_t len); + bool cmdSetFloodScopeKey(size_t len); + bool cmdSetDefaultFloodScope(size_t len); + bool cmdGetDefaultFloodScope(size_t len); + bool cmdSendControlData(size_t len); + bool cmdSetAutoaddConfig(size_t len); + bool cmdGetAutoaddConfig(size_t len); + bool cmdGetAllowedRepeatFreq(size_t len); + bool cmdSendRawPacket(size_t len); + void checkCLIRescueCmd(); bool handleCommand(const char* text, uint32_t sender_timestamp, char* reply); void checkSerialInterface(); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 3cceed56668..634ef67feb5 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -2,6 +2,7 @@ #include // For uint8_t, uint32_t #include #include +#include #include #define TELEM_MODE_DENY 0 @@ -11,40 +12,27 @@ #define ADVERT_LOC_NONE 0 #define ADVERT_LOC_SHARE 1 -class NodePrefs : public ConfigSerializer { // persisted to file +/** + * \brief Persisted settings of the companion radio, stored as /prefs.json. + * + * The radio and GPS settings are inherited from RadioPrefsFields and GPSPrefsFields, + * which the server roles' NodePrefs (helpers/CommonNodePrefs.h) shares, so both read + * and write them the same way. Every other setting is companion specific. + */ +class NodePrefs : public ConfigSerializer, public RadioPrefsFields, public GPSPrefsFields { // persisted to file public: - float airtime_factor = 0; char node_name[32]; double node_lat = 0, node_lon = 0; - float freq = 0; - uint8_t sf = 0; - uint8_t cr = 0; - uint8_t multi_acks = 0; uint8_t manual_add_contacts = 0; - float bw = 0; - int8_t tx_power_dbm = 0; uint8_t telemetry_mode_base = 0; uint8_t telemetry_mode_loc = 0; uint8_t telemetry_mode_env = 0; - float rx_delay_base = 0; - float tx_delay_factor = 0; - float direct_tx_delay_factor = 0; uint32_t ble_pin = 0; - uint8_t advert_loc_policy = 0; uint8_t buzzer_quiet = 0; uint8_t vibe_quiet = 0; - uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled) - uint32_t gps_interval = 0; // GPS read interval in seconds uint8_t autoadd_config = 0; // bitmask for auto-add contacts config - uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted) - uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) - uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain (low by default) uint8_t _client_repeat = 0; // DEPRECATED -> use repeat.disable_fwd - uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t autoadd_max_hops = 0; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) - uint8_t cad_enabled = 0; - uint8_t interference_threshold = 0; - uint8_t agc_reset_interval = 0; // secs / 4 char default_scope_name[31]; uint8_t default_scope_key[16]; float tz_offset = 0; @@ -60,81 +48,9 @@ class NodePrefs : public ConfigSerializer { // persisted to file #endif private: - class RadioPrefs : public CommonRadioPrefs { - NodePrefs* _parent; - protected: - void structure() override { - def("freq", _parent->freq); - def("bw", _parent->bw); - def("sf", _parent->sf); - def("cr", _parent->cr); - def("cad", _parent->cad_enabled); - def("int_thr", _parent->interference_threshold); - def("rxgain", _parent->rx_boosted_gain); - def("fem_rxgain", _parent->radio_fem_rxgain); // fem_rxgain WAS mapped to wrong JSON property previously - def("fem_txgain", _parent->radio_fem_txgain); - def("tx", _parent->tx_power_dbm); - def("af", _parent->airtime_factor); - def("rxdelay", _parent->rx_delay_base); - def("f_txdelay", _parent->tx_delay_factor); - def("d_txdelay", _parent->direct_tx_delay_factor); - def("agc_int", _parent->agc_reset_interval); - def("hash_mode", _parent->path_hash_mode); - def("multi_ack", _parent->multi_acks); - } - public: - RadioPrefs(NodePrefs* parent) : _parent(parent) { } - - // CommonRadioPrefs interface - float getFreq() const override { return _parent->freq; } - void setFreq(float f) override { _parent->freq = f; markDirty(); } - float getBandwidth() const override { return _parent->bw; } - void setBandwidth(float bw) override { _parent->bw = bw; markDirty(); } - uint8_t getSpreadFactor() const override { return _parent->sf; } - void setSpreadFactor(uint8_t sf) override { _parent->sf = sf; markDirty(); } - uint8_t getCodingRate() const override { return _parent->cr; } - void setCodingRate(uint8_t cr) override { _parent->cr = cr; markDirty(); } - float getAirtimeFactor() const override { return _parent->airtime_factor; } - void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); } - bool isCadEnabled() const override { return _parent->cad_enabled; } - void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); } - uint8_t getIntThresh() const override { return _parent->interference_threshold; } - void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } - uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } - void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } - uint8_t getTxPower() const override { return _parent->tx_power_dbm; } - void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } - float getRxDelay() const override { return _parent->rx_delay_base; } - void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } - uint16_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } - void setAgcResetInt(uint16_t secs) override { _parent->agc_reset_interval = secs / 4; markDirty(); } - uint8_t getHashMode() const override { return _parent->path_hash_mode; } - void setHashMode(uint8_t m) override { _parent->path_hash_mode = m; markDirty(); } - uint8_t getMultiAcks() const override { return _parent->multi_acks; } - void setMultiAcks(uint8_t m) override { _parent->multi_acks = m; markDirty(); } - float getFloodTxDelay() const override { return _parent->tx_delay_factor; } - void setFloodTxDelay(float d) override { _parent->tx_delay_factor = d; markDirty(); } - float getDirectTxDelay() const override { return _parent->direct_tx_delay_factor; } - void setDirectTxDelay(float d) override { _parent->direct_tx_delay_factor = d; markDirty(); } - uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } - void setFEMRxGain(uint8_t g) override { _parent->radio_fem_rxgain = g; markDirty(); } - uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } - void setFEMTxGain(uint8_t g) override { _parent->radio_fem_txgain = g; markDirty(); } - }; - RadioPrefs radio; + CommonRadioPrefs radio; - class GPSPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now) - NodePrefs* _parent; - protected: - void structure() override { - def("en", _parent->gps_enabled); // boolean - def("int", _parent->gps_interval); // interval in seconds - def("adv_loc", _parent->advert_loc_policy); - } - public: - GPSPrefs(NodePrefs* parent) : _parent(parent) { } - }; - GPSPrefs gps; + CommonGPSPrefs gps; class RepeatPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now) public: @@ -206,7 +122,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file #endif } public: - NodePrefs() : radio(this), gps(this), companion(this), custom(&radio) + NodePrefs() : radio(*this), gps(*this), companion(this), custom(&radio) #ifdef ENABLE_WIFI_INTERFACE , wifi(this) #endif diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index db8772f46e6..8bc5462eae7 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -52,7 +52,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 - char *version = strdup(FIRMWARE_VERSION); + // A local copy, because this runs once at boot and the strdup it replaces was + // never freed; the buffer only has to outlive the sprintf below. + char version[sizeof(_version_info)]; + StrHelper::strzcpy(version, FIRMWARE_VERSION, sizeof(version)); char *dash = strchr(version, '-'); if (dash) { *dash = 0; diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 7b70f83686c..acab2c5bd7d 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -446,8 +446,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no if (_display != NULL) { _display->turnOn(); + _statusBar.begin(_display->width()); // only ever drawn through *_display, see loop() } - _statusBar.begin(_display->width()); #ifdef PIN_BUZZER diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 61b6c149e98..2a0df0a6867 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -410,33 +410,8 @@ bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) return n >= max_counters[hash_size]; } -void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - TransportKey req_scope; - bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); - bool req_scope_known = recv_pkt_region != NULL && !is_wildcard - && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; - - switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { - case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request - break; - case mesh::REPLY_SCOPE_DEFAULT: - // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. - // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; - case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; - } -} - bool MyMesh::allowPacketForward(const mesh::Packet *packet) { - if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() - && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { - return false; - } + if (!ServerMesh::allowPacketForward(packet)) return false; if (packet->isRouteFlood() && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); return false; @@ -539,35 +514,6 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { } } -int MyMesh::calcRxDelay(float score, uint32_t air_time) const { - if (_prefs.rx_delay_base <= 0.0f) return 0; - return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); -} - -uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} -uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} - -mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { - recv_pkt_region = NULL; - } else { - recv_pkt_region = ®ion_map.getWildcard(); - } - } else { - recv_pkt_region = NULL; - } - return Mesh::onRecvPacket(pkt); -} - void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, uint8_t *data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin @@ -620,26 +566,6 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m } } -int MyMesh::searchPeersByHash(const uint8_t *hash) { - int n = 0; - for (int i = 0; i < acl.getNumClients(); i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) - } - } - return n; -} - -void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - // lookup pre-calculated shared_secret - memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); - } else { - MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); - } -} - static bool isShare(const mesh::Packet *packet) { if (packet->hasTransportCodes()) { return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' @@ -861,8 +787,7 @@ void MyMesh::sendNodeDiscoverReq() { MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, mesh::RTCClock &rtc, mesh::MeshTables &tables) - : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), - region_map(key_store), temp_map(key_store), + : ServerMesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4), discover_limiter(4, 120), // max 4 every 2 minutes @@ -874,14 +799,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc , bridge(&_prefs, _mgr, &rtc) #endif { + useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings last_millis = 0; uptime_millis = 0; - next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; - set_radio_at = revert_radio_at = 0; _logging = false; - region_load_active = false; - recv_pkt_region = NULL; #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); @@ -939,124 +861,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc pending_discover_tag = 0; pending_discover_until = 0; - memset(default_scope.key, 0, sizeof(default_scope.key)); -} - -void MyMesh::begin(FILESYSTEM *fs) { - mesh::Mesh::begin(); - _fs = fs; - // load persisted prefs - _cli.loadPrefs(_fs); - acl.load(_fs, self_id); - // TODO: key_store.begin(); - region_map.load(_fs); - - // establish default-scope - { - RegionEntry* r = region_map.getDefaultRegion(); - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { -#ifdef DEFAULT_FLOOD_SCOPE_NAME - r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME); - if (r == NULL) { - r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0); // auto-create the default scope region - if (r) { r->flags = 0; } // Allow-flood - } - if (r) { - region_map.setDefaultRegion(r); - region_map.getTransportKeysFor(*r, &default_scope, 1); - } -#endif - } - } - -#if defined(WITH_BRIDGE) - if (_prefs.bridge_enabled) { - bridge.begin(); - } -#endif - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); - - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); - MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", - radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); - - board.attachDynamicPrefs(_prefs.getCustom()); - - updateAdvertTimer(); - updateFloodAdvertTimer(); - - board.setAdcMultiplier(_prefs.adc_multiplier); - -#if ENV_INCLUDE_GPS == 1 - applyGpsPrefs(); -#endif -} - -void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { - if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); - } else { - uint16_t codes[2]; - codes[0] = scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); - } -} - -void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; - - revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params -} - -bool MyMesh::formatFileSystem() { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - return InternalFS.format(); -#elif defined(RP2040_PLATFORM) - return LittleFS.format(); -#elif defined(ESP32) - return SPIFFS.format(); -#else -#error "need to implement file system erase" - return false; -#endif -} - -void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { - mesh::Packet *pkt = createSelfAdvert(); - if (pkt) { - if (flood) { - sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); - } else { - sendZeroHop(pkt, delay_millis); - } - } else { - MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); - } -} - -void MyMesh::updateAdvertTimer() { - if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); - } else { - next_local_advert = 0; // stop the timer - } -} - -void MyMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer - next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; // stop the timer - } } void MyMesh::dumpLogFile() { @@ -1075,10 +879,6 @@ void MyMesh::dumpLogFile() { } } -void MyMesh::setTxPower(int8_t power_dbm) { - radio_driver.setTxPower(power_dbm); -} - bool MyMesh::setRxBoostedGain(bool enable) { return radio_driver.setRxBoostedGainMode(enable); } @@ -1144,57 +944,6 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { #endif } -void MyMesh::startRegionsLoad() { - temp_map.resetFrom(region_map); // rebuild regions in a temp instance - memset(load_stack, 0, sizeof(load_stack)); - load_stack[0] = &temp_map.getWildcard(); - region_load_active = true; -} - -bool MyMesh::saveRegions() { - return region_map.save(_fs); -} - -void MyMesh::onDefaultRegionChanged(const RegionEntry* r) { - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { - memset(default_scope.key, 0, sizeof(default_scope.key)); - } -} - -void MyMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); -} - -void MyMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); -} - -void MyMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect()); -} - -void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - IdentityStore store(*_fs, ""); -#elif defined(ESP32) - IdentityStore store(*_fs, "/identity"); -#elif defined(RP2040_PLATFORM) - IdentityStore store(*_fs, "/identity"); -#else -#error "need to define saveIdentity()" -#endif - store.save("_main", new_id); -} - -void MyMesh::clearStats() { - radio_driver.resetStats(); - resetStats(); - ((SimpleMeshTables *)getTables())->resetStats(); -} - void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation @@ -1307,17 +1056,7 @@ void MyMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - MESH_DEBUG_PRINTLN("Temp radio params"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("Radio params restored"); - } + checkPendingRadioParams(); // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index dc89325861c..50b0d61cf9a 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -34,6 +34,7 @@ #include #include #include +#include #include #include "RateLimiter.h" @@ -78,38 +79,22 @@ struct NeighbourInfo { #define PACKET_LOG_FILE "/packet_log" -class MyMesh : public mesh::Mesh, public CommonCLICallbacks { - FILESYSTEM* _fs; +class MyMesh : public ServerMesh { uint32_t last_millis; uint64_t uptime_millis; - unsigned long next_local_advert, next_flood_advert; bool _logging; - NodePrefs _prefs; - ClientACL acl; CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; uint8_t reply_path[MAX_PATH_SIZE]; uint8_t reply_path_len; - TransportKeyStore key_store; - RegionMap region_map, temp_map; - RegionEntry* load_stack[8]; - RegionEntry* recv_pkt_region; - TransportKey default_scope; RateLimiter discover_limiter, anon_limiter; uint32_t pending_discover_tag; unsigned long pending_discover_until; - bool region_load_active; unsigned long dirty_contacts_expiry; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif CayenneLPP telemetry; - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; - uint8_t pending_cr; - int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_RS232_BRIDGE) RS232Bridge bridge; #elif defined(WITH_ESPNOW_BRIDGE) @@ -122,16 +107,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); - mesh::Packet* createSelfAdvert(); + mesh::Packet* createSelfAdvert() override; File openAppend(const char* fname); bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); protected: - float getAirtimeBudgetFactor() const override { - return _prefs.airtime_factor; - } - bool allowPacketForward(const mesh::Packet* packet) override; const char* getLogDateTime() override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; @@ -139,46 +120,30 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void logRx(mesh::Packet* pkt, int len, float score) override; void logTx(mesh::Packet* pkt, int len) override; void logTxFail(mesh::Packet* pkt, int len) override; - int calcRxDelay(float score, uint32_t air_time) const override; - - uint32_t getRetransmitDelay(const mesh::Packet* packet) override; - uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - - int getInterferenceThreshold() const override { - return _prefs.interference_threshold; - } - bool getCADEnabled() const override { - return _prefs.cad_enabled; - } - int getAGCResetInterval() const override { - return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds - } - uint8_t getExtraAckTransmitCount() const override { - return _prefs.multi_acks; - } -#if ENV_INCLUDE_GPS == 1 - void applyGpsPrefs() { - sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); + // ServerMesh hooks for begin() + void loadPrefs() override { _cli.loadPrefs(_fs); } +#if defined(WITH_BRIDGE) + void beginBridge() override { + if (_prefs.bridge_enabled) { + bridge.begin(); + } } #endif - - mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; + void onRadioConfigured() override { + MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", + radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); + } void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; - int searchPeersByHash(const uint8_t* hash) override; - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); - public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(FILESYSTEM* fs); void sendNodeDiscoverReq(); const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } @@ -195,14 +160,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { _cli.savePrefs(_fs); } - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - // CommonCLICallbacks - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; - bool formatFileSystem() override; - void sendSelfAdvertisement(int delay_millis, bool flood) override; - void updateAdvertTimer() override; - void updateFloodAdvertTimer() override; void setLoggingOn(bool enable) override { _logging = enable; } @@ -211,21 +169,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { } void dumpLogFile() override; - void setTxPower(int8_t power_dbm) override; void formatNeighborsReply(char *reply) override; void removeNeighbor(const uint8_t* pubkey, int key_len) override; - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; - void startRegionsLoad() override; - bool saveRegions() override; - void onDefaultRegionChanged(const RegionEntry* r) override; mesh::LocalIdentity& getSelfId() override { return self_id; } - void saveIdentity(const mesh::LocalIdentity& new_id) override; - void clearStats() override; - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index da4c776c0ea..23d2d4ae762 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -274,11 +274,6 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { } } -int MyMesh::calcRxDelay(float score, uint32_t air_time) const { - if (_prefs.rx_delay_base <= 0.0f) return 0; - return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); -} - const char *MyMesh::getLogDateTime() { static char tmp[32]; uint32_t now = getRTCClock()->getCurrentTime(); @@ -288,39 +283,6 @@ const char *MyMesh::getLogDateTime() { return tmp; } -uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} -uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} - -bool MyMesh::allowPacketForward(const mesh::Packet *packet) { - if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() - && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { - return false; - } - return true; -} - -mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { - recv_pkt_region = NULL; - } else { - recv_pkt_region = ®ion_map.getWildcard(); - } - } else { - recv_pkt_region = NULL; - } - return Mesh::onRecvPacket(pkt); -} - void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, uint8_t *data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin @@ -410,26 +372,6 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m } } -int MyMesh::searchPeersByHash(const uint8_t *hash) { - int n = 0; - for (int i = 0; i < acl.getNumClients(); i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) - } - } - return n; -} - -void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - // lookup pre-calculated shared_secret - memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); - } else { - MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); - } -} - void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; @@ -629,19 +571,15 @@ void MyMesh::onAckRecv(mesh::Packet *packet, uint32_t ack_crc) { MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, mesh::RTCClock &rtc, mesh::MeshTables &tables) - : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), - region_map(key_store), temp_map(key_store), + : ServerMesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4) { + useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings last_millis = 0; uptime_millis = 0; - next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; _logging = false; - region_load_active = false; - set_radio_at = revert_radio_at = 0; - recv_pkt_region = NULL; // defaults _prefs.airtime_factor = 1.0; @@ -690,135 +628,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc memset(posts, 0, sizeof(posts)); _num_posted = _num_post_pushes = 0; - memset(default_scope.key, 0, sizeof(default_scope.key)); -} - -void MyMesh::begin(FILESYSTEM *fs) { - mesh::Mesh::begin(); - _fs = fs; - // load persisted prefs - _cli.loadPrefs(_fs); - - acl.load(_fs, self_id); - region_map.load(_fs); - - // establish default-scope - { - RegionEntry* r = region_map.getDefaultRegion(); - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { -#ifdef DEFAULT_FLOOD_SCOPE_NAME - r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME); - if (r == NULL) { - r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0); // auto-create the default scope region - if (r) { r->flags = 0; } // Allow-flood - } - if (r) { - region_map.setDefaultRegion(r); - region_map.getTransportKeysFor(*r, &default_scope, 1); - } -#endif - } - } - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); - - board.attachDynamicPrefs(_prefs.getCustom()); - - updateAdvertTimer(); - updateFloodAdvertTimer(); - - board.setAdcMultiplier(_prefs.adc_multiplier); - -#if ENV_INCLUDE_GPS == 1 - applyGpsPrefs(); -#endif -} - -void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { - if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); - } else { - uint16_t codes[2]; - codes[0] = scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); - } -} - -void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - TransportKey req_scope; - bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); - bool req_scope_known = recv_pkt_region != NULL && !is_wildcard - && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; - - switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { - case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request - break; - case mesh::REPLY_SCOPE_DEFAULT: - // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. - // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; - case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; - } -} - -void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; - - revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params -} - -bool MyMesh::formatFileSystem() { -#if defined(NRF52_PLATFORM) - return InternalFS.format(); -#elif defined(RP2040_PLATFORM) - return LittleFS.format(); -#elif defined(ESP32) - return SPIFFS.format(); -#else -#error "need to implement file system erase" - return false; -#endif -} - -void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { - mesh::Packet *pkt = createSelfAdvert(); - if (pkt) { - if (flood) { - sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); - } else { - sendZeroHop(pkt, delay_millis); - } - } else { - MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); - } -} - -void MyMesh::updateAdvertTimer() { - if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis((uint32_t)_prefs.advert_interval * 2 * 60 * 1000); - } else { - next_local_advert = 0; // stop the timer - } -} -void MyMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer - next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; // stop the timer - } } void MyMesh::dumpLogFile() { @@ -837,65 +646,10 @@ void MyMesh::dumpLogFile() { } } -void MyMesh::setTxPower(int8_t power_dbm) { - radio_driver.setTxPower(power_dbm); -} - bool MyMesh::setRxBoostedGain(bool enable) { return radio_driver.setRxBoostedGainMode(enable); } -void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - IdentityStore store(*_fs, ""); -#elif defined(ESP32) - IdentityStore store(*_fs, "/identity"); -#elif defined(RP2040_PLATFORM) - IdentityStore store(*_fs, "/identity"); -#else -#error "need to define saveIdentity()" -#endif - store.save("_main", new_id); -} - -void MyMesh::startRegionsLoad() { - temp_map.resetFrom(region_map); // rebuild regions in a temp instance - memset(load_stack, 0, sizeof(load_stack)); - load_stack[0] = &temp_map.getWildcard(); - region_load_active = true; -} - -bool MyMesh::saveRegions() { - return region_map.save(_fs); -} - -void MyMesh::onDefaultRegionChanged(const RegionEntry* r) { - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { - memset(default_scope.key, 0, sizeof(default_scope.key)); - } -} - -void MyMesh::clearStats() { - radio_driver.resetStats(); - resetStats(); - ((SimpleMeshTables *)getTables())->resetStats(); -} - -void MyMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); -} - -void MyMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); -} - -void MyMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect()); -} - void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation @@ -1054,17 +808,7 @@ void MyMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - MESH_DEBUG_PRINTLN("Temp radio params"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("Radio params restored"); - } + checkPendingRadioParams(); // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 4dee8bdbf40..931f8de3cef 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -89,17 +90,10 @@ struct PostInfo { char text[MAX_POST_TEXT_LEN+1]; }; -class MyMesh : public mesh::Mesh, public CommonCLICallbacks { - FILESYSTEM* _fs; +class MyMesh : public ServerMesh { uint32_t last_millis; uint64_t uptime_millis; - unsigned long next_local_advert, next_flood_advert; bool _logging; - bool region_load_active; - NodePrefs _prefs; - TransportKeyStore key_store; - RegionMap region_map, temp_map; - ClientACL acl; CommonCLI _cli; unsigned long dirty_contacts_expiry; uint8_t reply_data[MAX_PACKET_PAYLOAD]; @@ -109,75 +103,35 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int next_post_idx; PostInfo posts[MAX_UNSYNCED_POSTS]; // cyclic queue CayenneLPP telemetry; - RegionEntry* load_stack[8]; - RegionEntry* recv_pkt_region; - TransportKey default_scope; - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; - uint8_t pending_cr; - int matching_peer_indexes[MAX_CLIENTS]; void addPost(ClientInfo* client, const char* postData); void storePost(const mesh::Identity& author, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t *data); - mesh::Packet* createSelfAdvert(); + mesh::Packet* createSelfAdvert() override; File openAppend(const char* fname); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); protected: - float getAirtimeBudgetFactor() const override { - return _prefs.airtime_factor; - } - void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; void logRx(mesh::Packet* pkt, int len, float score) override; void logTx(mesh::Packet* pkt, int len) override; void logTxFail(mesh::Packet* pkt, int len) override; - int calcRxDelay(float score, uint32_t air_time) const override; const char* getLogDateTime() override; - uint32_t getRetransmitDelay(const mesh::Packet* packet) override; - uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - - int getInterferenceThreshold() const override { - return _prefs.interference_threshold; - } - bool getCADEnabled() const override { - return _prefs.cad_enabled; - } - int getAGCResetInterval() const override { - return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds - } - uint8_t getExtraAckTransmitCount() const override { - return _prefs.multi_acks; - } - - mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; - bool allowPacketForward(const mesh::Packet* packet) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; - int searchPeersByHash(const uint8_t* hash) override ; - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; -#if ENV_INCLUDE_GPS == 1 - void applyGpsPrefs() { - sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); - } -#endif - - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); + // ServerMesh hook for begin() + void loadPrefs() override { _cli.loadPrefs(_fs); } public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(FILESYSTEM* fs); void addSystemPost(const char* postData); const char* getFirmwareVer() override { return FIRMWARE_VERSION; } @@ -192,14 +146,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { _cli.savePrefs(_fs); } - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - // CommonCLICallbacks - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; - bool formatFileSystem() override; - void sendSelfAdvertisement(int delay_millis, bool flood) override; - void updateAdvertTimer() override; - void updateFloodAdvertTimer() override; void setLoggingOn(bool enable) override { _logging = enable; } @@ -208,25 +155,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { } void dumpLogFile() override; - void setTxPower(int8_t power_dbm) override; bool setRxBoostedGain(bool enable) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; - void startRegionsLoad() override; - bool saveRegions() override; - void onDefaultRegionChanged(const RegionEntry* r) override; mesh::LocalIdentity& getSelfId() override { return self_id; } static bool saveFilter(ClientInfo* client); - void saveIdentity(const mesh::LocalIdentity& new_id) override; - void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); }; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b186066810d..52c615941f9 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -312,19 +312,22 @@ void SensorMesh::alertIf(bool condition, Trigger& t, AlertPriority pri, const ch } } -float SensorMesh::getAirtimeBudgetFactor() const { - return _prefs.airtime_factor; -} - -bool SensorMesh::allowPacketForward(const mesh::Packet* packet) { - if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false; - return true; -} +// The hop limits the repeater and the room server start with. The constructor and +// the prefs upgrade both need them. +static const uint8_t DEFAULT_FLOOD_MAX_UNSCOPED = 64; +static const uint8_t DEFAULT_FLOOD_MAX_ADVERT = 8; -int SensorMesh::calcRxDelay(float score, uint32_t air_time) const { - if (_prefs.rx_delay_base <= 0.0f) return 0; - return (int) ((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); +bool SensorMesh::upgradePrefs(uint8_t from) { + bool changed = false; + if (from < 1) { + // Before version 1 the sensor ignored these two limits and set no default for + // them, so every saved file holds a zero that nobody chose and that never took + // effect. Applied as it is, a zero refuses every un-scoped flood and every advert. + // A value other than zero was set on purpose and is kept. + if (_prefs.flood_max_unscoped == 0) { _prefs.flood_max_unscoped = DEFAULT_FLOOD_MAX_UNSCOPED; changed = true; } + if (_prefs.flood_max_advert == 0) { _prefs.flood_max_advert = DEFAULT_FLOOD_MAX_ADVERT; changed = true; } + } + return changed; } uint32_t SensorMesh::getRetransmitDelay(const mesh::Packet* packet) { @@ -335,35 +338,6 @@ uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 6)*t; } -int SensorMesh::getInterferenceThreshold() const { - return _prefs.interference_threshold; -} -bool SensorMesh::getCADEnabled() const { - return _prefs.cad_enabled; -} -int SensorMesh::getAGCResetInterval() const { - return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds -} - -void SensorMesh::startRegionsLoad() { - temp_map.resetFrom(region_map); // rebuild regions in a temp instance - memset(load_stack, 0, sizeof(load_stack)); - load_stack[0] = &temp_map.getWildcard(); - region_load_active = true; -} - -bool SensorMesh::saveRegions() { - return region_map.save(_fs); -} - -void SensorMesh::onDefaultRegionChanged(const RegionEntry* r) { - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { - memset(default_scope.key, 0, sizeof(default_scope.key)); - } -} - uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) { ClientInfo* client; if (data[0] == 0) { // blank password, just check if sender is in ACL @@ -520,21 +494,6 @@ void SensorMesh::handleCommand(ClientInfo* from, uint32_t sender_timestamp, char } } -mesh::DispatcherAction SensorMesh::onRecvPacket(mesh::Packet* pkt) { - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { - recv_pkt_region = NULL; - } else { - recv_pkt_region = ®ion_map.getWildcard(); - } - } else { - recv_pkt_region = NULL; - } - return Mesh::onRecvPacket(pkt); -} - void SensorMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin client (unknown at this stage) uint32_t timestamp; @@ -564,26 +523,6 @@ void SensorMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, con } } -int SensorMesh::searchPeersByHash(const uint8_t* hash) { - int n = 0; - for (int i = 0; i < acl.getNumClients() && n < MAX_SEARCH_RESULTS; i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) - } - } - return n; -} - -void SensorMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - // lookup pre-calculated shared_secret - memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); - } else { - MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); - } -} - void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size) { if (dest.out_path_len == OUT_PATH_UNKNOWN) { mesh::Packet* ack = createAck(ack_hash); @@ -782,18 +721,14 @@ void SensorMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { } SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables) - : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), - region_map(key_store), temp_map(key_store), + : ServerMesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4) { - next_local_advert = next_flood_advert = 0; + useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings dirty_contacts_expiry = 0; last_read_time = 0; num_alert_tasks = 0; - set_radio_at = revert_radio_at = 0; - recv_pkt_region = NULL; - region_load_active = false; // defaults _prefs.airtime_factor = 1.0; @@ -813,6 +748,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.flood_advert_interval = 0; // disabled _prefs.disable_fwd = true; _prefs.flood_max = 64; + _prefs.flood_max_unscoped = DEFAULT_FLOOD_MAX_UNSCOPED; + _prefs.flood_max_advert = DEFAULT_FLOOD_MAX_ADVERT; _prefs.interference_threshold = 0; // disabled _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') @@ -820,167 +757,17 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.gps_enabled = 0; _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; - _prefs.radio_fem_rxgain = 1; - _prefs.radio_fem_txgain = 0; - - memset(default_scope.key, 0, sizeof(default_scope.key)); -} - -void SensorMesh::begin(FILESYSTEM* fs) { - mesh::Mesh::begin(); - _fs = fs; - // load persisted prefs - _cli.loadPrefs(_fs); - - acl.load(_fs, self_id); - region_map.load(_fs); - - // establish default-scope - { - RegionEntry* r = region_map.getDefaultRegion(); - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { -#ifdef DEFAULT_FLOOD_SCOPE_NAME - r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME); - if (r == NULL) { - r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0); // auto-create the default scope region - if (r) { r->flags = 0; } // Allow-flood - } - if (r) { - region_map.setDefaultRegion(r); - region_map.getTransportKeysFor(*r, &default_scope, 1); - } -#endif - } - } - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); - - board.attachDynamicPrefs(_prefs.getCustom()); - updateAdvertTimer(); - updateFloodAdvertTimer(); - - board.setAdcMultiplier(_prefs.adc_multiplier); - -#if ENV_INCLUDE_GPS == 1 - applyGpsPrefs(); -#endif -} - -bool SensorMesh::formatFileSystem() { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - return InternalFS.format(); -#elif defined(RP2040_PLATFORM) - return LittleFS.format(); -#elif defined(ESP32) - return SPIFFS.format(); +#if defined(USE_SX1262) || defined(USE_SX1268) +#ifdef SX126X_RX_BOOSTED_GAIN + _prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN; #else - #error "need to implement file system erase" - return false; + _prefs.rx_boosted_gain = 1; // enabled by default; #endif -} - -void SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - IdentityStore store(*_fs, ""); -#elif defined(ESP32) - IdentityStore store(*_fs, "/identity"); -#elif defined(RP2040_PLATFORM) - IdentityStore store(*_fs, "/identity"); -#else - #error "need to define saveIdentity()" #endif - store.save("_main", new_id); -} - -void SensorMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; - - revert_radio_at = futureMillis(2000 + timeout_mins*60*1000); // schedule when to revert radio params -} - -void SensorMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { - if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); - } else { - uint16_t codes[2]; - codes[0] = scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); - } -} - -void SensorMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - TransportKey req_scope; - bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); - bool req_scope_known = recv_pkt_region != NULL && !is_wildcard - && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; - - switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { - case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request - break; - case mesh::REPLY_SCOPE_DEFAULT: - // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. - // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; - case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; - } -} - -void SensorMesh::sendSelfAdvertisement(int delay_millis, bool flood) { - mesh::Packet* pkt = createSelfAdvert(); - if (pkt) { - if (flood) { - sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); - } else { - sendZeroHop(pkt, delay_millis); - } - } else { - MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); - } -} - -void SensorMesh::updateAdvertTimer() { - if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis( ((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); - } else { - next_local_advert = 0; // stop the timer - } -} -void SensorMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer - next_flood_advert = futureMillis( ((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; // stop the timer - } -} - -void SensorMesh::setTxPower(int8_t power_dbm) { - radio_driver.setTxPower(power_dbm); -} - -void SensorMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); -} - -void SensorMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); -} + _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_txgain = 0; -void SensorMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect()); } float SensorMesh::getTelemValue(uint8_t channel, uint8_t type) { @@ -1015,17 +802,7 @@ void SensorMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - MESH_DEBUG_PRINTLN("Temp radio params"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("Radio params restored"); - } + checkPendingRadioParams(); uint32_t curr = getRTCClock()->getCurrentTime(); if (curr >= last_read_time + SENSOR_READ_INTERVAL_SECS) { diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 590ca0c7657..589092e9a7a 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -43,13 +44,11 @@ #define FIRMWARE_ROLE "sensor" -#define MAX_SEARCH_RESULTS 24 #define MAX_CONCURRENT_ALERTS 4 -class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { +class SensorMesh : public ServerMesh { public: SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(FILESYSTEM* fs); void loop(); void handleCommand(ClientInfo* from, uint32_t sender_timestamp, char* command, char* reply); @@ -60,33 +59,17 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } void savePrefs() override { _cli.savePrefs(_fs); } - bool formatFileSystem() override; - void sendSelfAdvertisement(int delay_millis, bool flood) override; - void updateAdvertTimer() override; - void updateFloodAdvertTimer() override; void setLoggingOn(bool enable) override { } void eraseLogFile() override { } void dumpLogFile() override { } - void setTxPower(int8_t power_dbm) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; mesh::LocalIdentity& getSelfId() override { return self_id; } - void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override { } - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; - void startRegionsLoad() override; - bool saveRegions() override; - void onDefaultRegionChanged(const RegionEntry* r) override; float getTelemValue(uint8_t channel, uint8_t type); - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); - protected: // current telemetry data queries float getVoltage(uint8_t channel) { return getTelemValue(channel, LPP_VOLTAGE); } @@ -119,19 +102,17 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { virtual int querySeriesData(uint32_t start_secs_ago, uint32_t end_secs_ago, MinMaxAvg dest[], int max_num) = 0; // for app to implement virtual bool handleCustomCommand(uint32_t sender_timestamp, char* command, char* reply) { return false; } - // Mesh overrides - float getAirtimeBudgetFactor() const override; - bool allowPacketForward(const mesh::Packet* packet) override; - int calcRxDelay(float score, uint32_t air_time) const override; + // ServerMesh hook for begin(). Protected rather than private, unlike loadPrefs(), + // so that a subclass can run the step on its own. + bool upgradePrefs(uint8_t from) override; + + // Mesh overrides (allowPacketForward() is the shared one in ServerMesh) + // The sensor keeps its own retransmit delays: it draws from six discrete steps + // while the other roles draw from a continuous range, and upstream still calls + // that change experimental (see docs/refactoring/0005-sensor-alignment.md). uint32_t getRetransmitDelay(const mesh::Packet* packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - int getInterferenceThreshold() const override; - bool getCADEnabled() const override; - int getAGCResetInterval() const override; - mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; - int searchPeersByHash(const uint8_t* hash) override; - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; @@ -140,40 +121,21 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { virtual bool handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t* data, uint8_t flags, size_t len); void sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size=1); private: - FILESYSTEM* _fs; - unsigned long next_local_advert, next_flood_advert; - NodePrefs _prefs; - ClientACL acl; CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long dirty_contacts_expiry; CayenneLPP telemetry; - TransportKeyStore key_store; - RegionMap region_map, temp_map; - RegionEntry* recv_pkt_region; - RegionEntry* load_stack[8]; - TransportKey default_scope; uint32_t last_read_time; - int matching_peer_indexes[MAX_SEARCH_RESULTS]; int num_alert_tasks; Trigger* alert_tasks[MAX_CONCURRENT_ALERTS]; - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; - uint8_t pending_cr; - bool region_load_active; bool telemHasChanged(ClientInfo* c); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleRequest(ClientInfo* from, uint32_t sender_timestamp, uint8_t req_type, uint8_t* payload, size_t payload_len); - mesh::Packet* createSelfAdvert(); + mesh::Packet* createSelfAdvert() override; void sendAlert(const ClientInfo* c, Trigger* t); - #if ENV_INCLUDE_GPS == 1 - void applyGpsPrefs() { - sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); - } -#endif + // ServerMesh hook for begin() + void loadPrefs() override { _cli.loadPrefs(_fs); } }; diff --git a/platformio.ini b/platformio.ini index f296f838213..4bbc58bb5fc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,13 +167,380 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_core_tuning + test_chat_group_msg + test_identity_store + test_node_prefs + test_radio_wrappers + test_radio_chips + test_radio_chips_init + test_server_base + test_sensor_role + test_repeater_role + test_region_map + test_variant_target + test_variant_target_override + test_companion_frames build_src_filter = -<*> +<../src/Utils.cpp> +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/TxtDataHelpers.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_ofast] +; the prefs tests again with -Ofast, the optimisation nRF52 builds use +; (it includes -ffast-math, under which float comparisons may assume no NaN) +platform = native +test_framework = googletest +build_flags = ${env:native.build_flags} + -Ofast +test_build_src = yes +test_filter = + test_common_node_prefs + test_companion_node_prefs +build_src_filter = ${env:native.build_src_filter} +lib_deps = + google/googletest @ 1.17.0 + +[env:native_server] +; The server roles on the host: the real role implementation against the mocks in +; test/mocks, so the shared underpinnings can be characterized before they move. +; RP2040_PLATFORM selects the plain string file modes of the helpers, which the +; in-memory file system mock implements. One role per environment: the repeater and +; the room server both define `MyMesh`. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I src + -I test/mocks + -I examples/simple_room_server +test_build_src = yes +test_filter = test_server_base +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/ClientACL.cpp> + +<../src/helpers/CommonCLI.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/ServerMesh.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../examples/simple_room_server/MyMesh.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_sensor] +; The sensor role on the host, in its own environment: each role needs a separate +; binary, and the sensor is the one whose behaviour differs from the other two. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I src + -I test/mocks + -I examples/simple_sensor +test_build_src = yes +test_filter = test_sensor_role +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/ClientACL.cpp> + +<../src/helpers/CommonCLI.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/ServerMesh.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../examples/simple_sensor/SensorMesh.cpp> + +<../examples/simple_sensor/TimeSeriesData.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_repeater] +; The repeater on the host, in its own environment: the repeater and the room server +; both define `MyMesh`, so each needs a binary of its own. Built the way most +; firmware is, with none of the optional blocks that begin() contains. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I src + -I test/mocks + -I examples/simple_repeater +test_build_src = yes +test_filter = test_repeater_role +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/ClientACL.cpp> + +<../src/helpers/CommonCLI.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/ServerMesh.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../examples/simple_repeater/MyMesh.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_repeater_full] +; The same repeater with every optional block of begin() compiled in: a bridge (the +; stand-in in test/mocks/bridge, which has to come ahead of src on the include +; path), the GPS toggle, a configured default flood scope, and the debug log (which +; the Arduino core only compiles in when ARDUINO is set). Runs the same suite. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I test/mocks/bridge + -I src + -I test/mocks + -I examples/simple_repeater + -D WITH_RS232_BRIDGE=0 + -D ENV_INCLUDE_GPS=1 + -D DEFAULT_FLOOD_SCOPE_NAME='"scope-test"' + -D MESH_DEBUG=1 + -D ARDUINO=1 +test_build_src = yes +test_filter = test_repeater_role +build_src_filter = ${env:native_repeater.build_src_filter} +lib_deps = + google/googletest @ 1.17.0 + +[env:native_companion] +; The companion on the host: the real MyMesh.cpp and DataStore.cpp against the mocks in +; test/mocks, so the frame protocol (handleCmdFrame) can be characterized before it +; is restructured (docs/refactoring/0010-companion-command-table.md). RP2040_PLATFORM +; selects the plain string file modes of the data store, which the in-memory file +; system mock implements. Private key export and import are compiled in, so both +; sides of those two branches are reachable. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + ; the frame reader counts reads that ran out, for test_frame_bounds.cpp + -D FRAME_READER_COUNT_OVERRUNS + -D ENABLE_PRIVATE_KEY_EXPORT=1 + -D ENABLE_PRIVATE_KEY_IMPORT=1 + -D MAX_LORA_TX_POWER=22 + -D MAX_GROUP_CHANNELS=8 + ; set for the whole build, as every companion env does: BaseChatMesh.h sizes its + ; contact table from it, and a default in one file only would give two layouts + -D MAX_CONTACTS=100 + -I src + -I test/mocks + -I examples/companion_radio + ; the chat layer decodes channel secrets with this header-only library (lib_deps + ; below); the dependency finder does not scan sources pulled in from outside src_dir + -I ${platformio.libdeps_dir}/${this.__env__}/base64/src +test_build_src = yes +test_filter = test_companion_frames +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/BaseChatMesh.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../examples/companion_radio/DataStore.cpp> + +<../examples/companion_radio/MyMesh.cpp> +lib_deps = + google/googletest @ 1.17.0 + densaugeo/base64 @ ~1.4.0 + +[env:native_core] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks +test_build_src = yes +test_filter = test_core_tuning +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_chat] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks +test_build_src = yes +test_filter = test_chat_group_msg +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/BaseChatMesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/TxtDataHelpers.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_identity_store] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src + -D NRF52_PLATFORM +test_build_src = yes +test_filter = test_identity_store +build_src_filter = + -<*> + +<../src/helpers/IdentityStore.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_node_prefs] +; NodePrefs lives in CommonCLI.h, whose filesystem types only exist for a +; platform, so this environment picks the nRF52 branch and uses the mock +; filesystem for the types. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks + -D NRF52_PLATFORM +test_build_src = yes +test_filter = test_node_prefs +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/TxtDataHelpers.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_radio_wrappers] +; The real RadioLibWrapper against a minimal RadioLib stand-in, so the packet +; score can be checked on the host. LORA_SF is deliberately not 10, so a score +; that silently assumes sf=10 shows up as a failure. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks + -D LORA_SF=9 +test_build_src = yes +test_filter = test_radio_wrappers +build_src_filter = + -<*> + +<../src/helpers/radiolib/RadioLibWrappers.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_variant_target] +; What a board variant's target.cpp gives the roles, on the host: the real +; RadioLibWrapper and RadioNoiseListener against the RadioLib stand-in, and the +; shared definitions in RadioLibTarget.cpp against the target.h of a RadioLib +; variant (test/mocks/radiolib_variant, ahead of test/mocks). LORA_SF, +; WRAPPER_CLASS and RADIO_CLASS are what every RadioLib variant's build flags +; set, NRF52_PLATFORM what every nRF52 one sets (the default radio_init() is +; only built there); the LORA_SF value does not matter here. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks/radiolib_variant + -I src + -I test/mocks + -D LORA_SF=8 + -D WRAPPER_CLASS=TestRadioWrapper + -D RADIO_CLASS=TestRadio + -D NRF52_PLATFORM +test_build_src = yes +test_filter = test_variant_target +build_src_filter = + -<*> + +<../src/helpers/radiolib/RadioLibWrappers.cpp> + +<../src/helpers/radiolib/RadioLibTarget.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_variant_target_override] +; The same, with a test that defines its own radio_new_identity(), as a variant +; that keeps its copy does. One binary per case, since the definition is global. +platform = native +test_framework = googletest +build_flags = ${env:native_variant_target.build_flags} +test_build_src = yes +test_filter = test_variant_target_override +build_src_filter = ${env:native_variant_target.build_src_filter} +lib_deps = + google/googletest @ 1.17.0 + +[env:native_region] +; RegionMap on the host. It reads packets and keys and writes nothing but its own +; table, so it builds against the small stubs in test/test_region_map/stubs, which are +; listed first so they take the place of the shared mocks for this suite only. +; RP2040_PLATFORM selects the Arduino FS.h file system type that RegionMap names. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I test/test_region_map/stubs + -I src + -I test/mocks +test_build_src = yes +test_filter = test_region_map +build_src_filter = + -<*> + +<../src/Packet.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> lib_deps = google/googletest @ 1.17.0 @@ -191,3 +558,28 @@ build_src_filter = +<../examples/kiss_modem/KissModem.cpp> lib_deps = google/googletest @ 1.17.0 + +[env:native_radio_chips] +; The real chip classes in src/helpers/radiolib against a host stand-in for the +; RadioLib chip classes (test/test_radio_chips/stubs, listed first so it takes the +; place of the PhysicalLayer mock), so their receive detection can be checked on +; the host. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/test_radio_chips/stubs + -I src + -I test/mocks + -D LORA_FREQ=869.525 + -D LORA_BW=250 + -D LORA_SF=11 + -D LORA_TX_POWER=22 + -D LR2021_IRQ_DIO=8 +test_build_src = yes +test_filter = + test_radio_chips + test_radio_chips_init +build_src_filter = + -<*> +lib_deps = + google/googletest @ 1.17.0 diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c0610b7f8a7..09761dfe9f0 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -32,7 +32,7 @@ void Dispatcher::begin() { } float Dispatcher::getAirtimeBudgetFactor() const { - return 1.0; + return _radio_prefs ? _radio_prefs->airtime_factor : 1.0; } void Dispatcher::updateTxBudget() { @@ -53,7 +53,11 @@ void Dispatcher::updateTxBudget() { } int Dispatcher::calcRxDelay(float score, uint32_t air_time) const { - return (int) ((pow(10, 0.85f - score) - 1.0) * air_time); + if (_radio_prefs == NULL) { + return (int) ((pow(10, 0.85f - score) - 1.0) * air_time); + } + if (_radio_prefs->rx_delay_base <= 0.0f) return 0; // RX delay disabled + return (int)((pow(_radio_prefs->rx_delay_base, 0.85f - score) - 1.0) * air_time); } uint32_t Dispatcher::getCADFailRetryDelay() const { diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec2..5e7c65c0332 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -137,9 +138,10 @@ class Dispatcher { Radio* _radio; MillisecondClock* _ms; uint16_t _err_flags; + const RadioPrefsFields* _radio_prefs; // see useRadioPrefs(); NULL = built-in tuning defaults Dispatcher(Radio& radio, MillisecondClock& ms, PacketManager& mgr) - : _radio(&radio), _ms(&ms), _mgr(&mgr) + : _radio(&radio), _ms(&ms), _mgr(&mgr), _radio_prefs(NULL) { outbound = NULL; total_air_time = rx_air_time = 0; @@ -163,19 +165,37 @@ class Dispatcher { virtual void logTxFail(Packet* packet, int len) { } virtual const char* getLogDateTime() { return ""; } - virtual float getAirtimeBudgetFactor() const; - virtual int calcRxDelay(float score, uint32_t air_time) const; + // Tuning hooks. Once settings are attached with useRadioPrefs(), the implementations + // below read them; without settings they return the built-in defaults noted here. + // Subclasses can still override any of them. + virtual float getAirtimeBudgetFactor() const; // default: 1.0 + virtual int calcRxDelay(float score, uint32_t air_time) const; // default: base 10 virtual uint32_t getCADFailRetryDelay() const; virtual uint32_t getCADFailMaxDuration() const; - virtual int getInterferenceThreshold() const { return 0; } // disabled by default - virtual bool getCADEnabled() const { return false; } // hardware CAD disabled by default - virtual int getAGCResetInterval() const { return 0; } // disabled by default + virtual int getInterferenceThreshold() const { // default: disabled + return _radio_prefs ? _radio_prefs->interference_threshold : 0; + } + virtual bool getCADEnabled() const { // default: hardware CAD disabled + return _radio_prefs ? _radio_prefs->cad_enabled : false; + } + virtual int getAGCResetInterval() const { // milliseconds, default: disabled + return _radio_prefs ? ((int)_radio_prefs->agc_reset_interval) * 4000 : 0; + } virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } public: void begin(); void loop(); + /** + * \brief Attaches the node's radio settings. From then on the tuning hooks + * (duty cycle budget, RX delay, interference threshold, CAD, AGC reset, and in + * Mesh the retransmit delays and extra ACKs) read their values from `prefs` at + * the time of each call, so changes made through the CLI apply right away. + * `prefs` must outlive this object. Without it the built-in defaults apply. + */ + void useRadioPrefs(const RadioPrefsFields* prefs) { _radio_prefs = prefs; } + Packet* obtainNewPacket(); void releasePacket(Packet* packet); void sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis=0); diff --git a/src/Identity.cpp b/src/Identity.cpp index 51a01ae71d8..ab54929143d 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -125,7 +125,7 @@ void LocalIdentity::printTo(Stream& s) const { s.print("prv_key: "); Utils::printHex(s, prv_key, PRV_KEY_SIZE); s.println(); } -size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) { +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const { if (max_len < PRV_KEY_SIZE) return 0; // not big enough if (max_len < PRV_KEY_SIZE + PUB_KEY_SIZE) { // only room for prv_key diff --git a/src/Identity.h b/src/Identity.h index 008f7b5bf63..7f2b1b8c885 100644 --- a/src/Identity.h +++ b/src/Identity.h @@ -90,7 +90,7 @@ class LocalIdentity : public Identity { bool readFrom(Stream& s); bool writeTo(Stream& s) const; void printTo(Stream& s) const; - size_t writeTo(uint8_t* dest, size_t max_len); + size_t writeTo(uint8_t* dest, size_t max_len) const; void readFrom(const uint8_t* src, size_t len); }; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 9dc42f0b04b..010fec590f0 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -15,15 +15,23 @@ bool Mesh::allowPacketForward(const mesh::Packet* packet) { return false; // by default, Transport NOT enabled } uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) { + if (_radio_prefs) { // random delay of up to 5 x the scaled airtime of the packet + uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _radio_prefs->tx_delay_factor); + return _rng->nextInt(0, 5*t + 1); + } uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2; return _rng->nextInt(0, 5)*t; } uint32_t Mesh::getDirectRetransmitDelay(const Packet* packet) { + if (_radio_prefs) { // as getRetransmitDelay(), with the direct delay factor + uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _radio_prefs->direct_tx_delay_factor); + return _rng->nextInt(0, 5*t + 1); + } return 0; // by default, no delay } uint8_t Mesh::getExtraAckTransmitCount() const { - return 0; + return _radio_prefs ? _radio_prefs->multi_acks : 0; } uint32_t Mesh::getCADFailRetryDelay() const { diff --git a/src/Mesh.h b/src/Mesh.h index 22bb5c6d592..796a161f6f4 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -58,16 +58,19 @@ class Mesh : public Dispatcher { /** * \returns number of milliseconds delay to apply to retransmitting the given packet. + * With radio settings attached (useRadioPrefs()), random up to 5 x airtime x tx_delay_factor. */ virtual uint32_t getRetransmitDelay(const Packet* packet); /** * \returns number of milliseconds delay to apply to retransmitting the given packet, for DIRECT mode. + * With radio settings attached, as above with direct_tx_delay_factor; otherwise no delay. */ virtual uint32_t getDirectRetransmitDelay(const Packet* packet); /** * \returns number of extra (Direct) ACK transmissions wanted. + * With radio settings attached, their multi_acks; otherwise none. */ virtual uint8_t getExtraAckTransmitCount() const; diff --git a/src/RadioPrefsFields.h b/src/RadioPrefsFields.h new file mode 100644 index 00000000000..8702ab9badb --- /dev/null +++ b/src/RadioPrefsFields.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +/** + * \brief The radio settings every node role persists: LoRa modulation, TX power, + * timing factors and radio front-end options. + * + * Defined once here and inherited by both NodePrefs classes (the server roles' + * one in helpers/CommonNodePrefs.h and the companion radio's one), so a radio setting has + * exactly one name, type and default for all firmwares. Inheriting, rather than + * holding it as a member, keeps the existing `prefs.freq` style of access working + * for every caller. + * + * The core reads the tuning values it needs from here once a node attaches its + * settings with Dispatcher::useRadioPrefs(). + */ +struct RadioPrefsFields { + // ordered by size, so the struct has no padding inside + float freq = 0; // MHz + float bw = 0; // kHz + float airtime_factor = 0; // duty cycle budget: 100% / (airtime_factor + 1) + float rx_delay_base = 0; + float tx_delay_factor = 0; // flood retransmit delay factor + float direct_tx_delay_factor = 0; // direct retransmit delay factor + uint8_t sf = 0; // spreading factor + uint8_t cr = 0; // coding rate (4/cr) + int8_t tx_power_dbm = 0; + uint8_t interference_threshold = 0; + uint8_t agc_reset_interval = 0; // seconds / 4 + uint8_t rx_boosted_gain = 0; // RX boosted gain mode (0 = power saving, 1 = boosted) + uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) + uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain + uint8_t path_hash_mode = 0; // which path hash size to use when sending + uint8_t multi_acks = 0; + uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) +}; diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 28592415ca9..2637bc327ab 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -516,6 +516,11 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest } bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len) { + if (text_len < 0) { // memcpy() below would read this as a size_t, so clamping cannot catch it + MESH_DEBUG_PRINTLN("sendGroupMessage: invalid negative text_len=%d", text_len); + return false; + } + uint8_t temp[5+MAX_TEXT_LEN+32]; memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = 0; // TXT_TYPE_PLAIN diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 04199e24d1d..95a56bbf66a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -27,6 +27,49 @@ static bool isValidName(const char *n) { return true; } +// The binary loader has sanitised these values since before /prefs.json existed. +// Both loaders call it now, so a file that was edited by hand or written by +// another tool cannot leave the node on values its own CLI would refuse. +// Non-finite floats never reach this point: ConfigSerializer keeps the default +// in place for those, and constrain() would pass a NaN through unchanged. +void CommonCLI::sanitisePrefs() { + // sanitise bad pref values + _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); + _prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0, 2.0f); + _prefs->direct_tx_delay_factor = constrain(_prefs->direct_tx_delay_factor, 0, 2.0f); + // 99 is what the CLI can produce, not the 9 of "set af": "set dutycycle 1" + // stores 100/1 - 1. The old limit of 9 predates that command by a year + // (01d84d5d 2025-03-05 against 0aa0ec1f 2026-03-07) and would throw away a + // duty cycle below 10 percent at the next boot. + _prefs->airtime_factor = constrain(_prefs->airtime_factor, 0, 99.0f); + _prefs->freq = constrain(_prefs->freq, 150.0f, 2500.0f); + _prefs->bw = constrain(_prefs->bw, 7.8f, 500.0f); + _prefs->sf = constrain(_prefs->sf, 5, 12); + _prefs->cr = constrain(_prefs->cr, 5, 8); + _prefs->tx_power_dbm = constrain(_prefs->tx_power_dbm, -9, 30); + _prefs->multi_acks = constrain(_prefs->multi_acks, 0, 1); + _prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f); + _prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2); // NOTE: mode 3 reserved for future + + // sanitise bad bridge pref values + _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); + _prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000); + _prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1); + _prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD); + _prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14); + + _prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1); + + _prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1); + _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); + + // sanitise settings + _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean + _prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean + _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean +} + void CommonCLI::loadPrefs(FILESYSTEM* fs) { if (fs->exists("/prefs.json")) { #if defined(RP2040_PLATFORM) @@ -35,8 +78,11 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { File file = fs->open("/prefs.json"); #endif if (file) { - _prefs->loadSerial(file); // new Serial prefs + if (!_prefs->loadSerial(file)) { + MESH_DEBUG_PRINTLN("loadPrefs: /prefs.json did not parse cleanly"); + } file.close(); + sanitisePrefs(); } } else if (fs->exists("/com_prefs")) { loadPrefsInt(fs, "/com_prefs"); @@ -104,39 +150,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 // next: 295 - // sanitise bad pref values - _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); - _prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0, 2.0f); - _prefs->direct_tx_delay_factor = constrain(_prefs->direct_tx_delay_factor, 0, 2.0f); - _prefs->airtime_factor = constrain(_prefs->airtime_factor, 0, 9.0f); - _prefs->freq = constrain(_prefs->freq, 150.0f, 2500.0f); - _prefs->bw = constrain(_prefs->bw, 7.8f, 500.0f); - _prefs->sf = constrain(_prefs->sf, 5, 12); - _prefs->cr = constrain(_prefs->cr, 5, 8); - _prefs->tx_power_dbm = constrain(_prefs->tx_power_dbm, -9, 30); - _prefs->multi_acks = constrain(_prefs->multi_acks, 0, 1); - _prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f); - _prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2); // NOTE: mode 3 reserved for future - - // sanitise bad bridge pref values - _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); - _prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000); - _prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1); - _prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD); - _prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14); - - _prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1); - - _prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1); - _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); - - // sanitise settings - _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean - _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean - _prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean - _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean file.close(); + sanitisePrefs(); } } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f0e..6907271ac13 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -8,238 +8,12 @@ #include #include #include +#include #if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) #define WITH_BRIDGE #endif -#define ADVERT_LOC_NONE 0 -#define ADVERT_LOC_SHARE 1 -#define ADVERT_LOC_PREFS 2 - -#define LOOP_DETECT_OFF 0 -#define LOOP_DETECT_MINIMAL 1 -#define LOOP_DETECT_MODERATE 2 -#define LOOP_DETECT_STRICT 3 - -class NodePrefs : public ConfigSerializer { -public: - // in-memory backing data - float airtime_factor = 0; - char node_name[32]; - double node_lat = 0, node_lon = 0; - char password[16]; - float freq = 0; - int8_t tx_power_dbm = 0; - uint8_t disable_fwd = 0; - uint8_t advert_interval = 0; // minutes / 2 - uint8_t flood_advert_interval = 0; // hours - float rx_delay_base = 0; - float tx_delay_factor = 0; - char guest_password[16]; - float direct_tx_delay_factor = 0; - uint32_t guard; - uint8_t sf = 0; - uint8_t cr = 0; - uint8_t allow_read_only = 0; - uint8_t multi_acks = 0; - float bw = 0; - uint8_t flood_max = 0; - uint8_t flood_max_unscoped = 0; - uint8_t flood_max_advert = 0; - uint8_t interference_threshold = 0; - uint8_t agc_reset_interval = 0; // secs / 4 - // Bridge settings - uint8_t bridge_enabled = 0; // boolean - uint16_t bridge_delay = 0; // milliseconds (default 500 ms) - uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logTx) - uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200) - uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) - char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) - // Power setting - uint8_t powersaving_enabled = 0; // boolean - // Gps settings - uint8_t gps_enabled = 0; - uint32_t gps_interval = 0; // in seconds - uint8_t advert_loc_policy = 0; - uint32_t discovery_mod_timestamp = 0; - float adc_multiplier = 0; - char owner_info[120]; - uint8_t rx_boosted_gain = 0; // power settings - uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting - uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain setting - uint8_t path_hash_mode = 0; // which path mode to use when sending - uint8_t loop_detect = 0; - uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) - uint8_t extra_sf[4]; - -private: - class RadioPrefs : public CommonRadioPrefs { - NodePrefs* _parent; - protected: - void structure() override { - def("freq", _parent->freq); - def("bw", _parent->bw); - def("sf", _parent->sf); - def("cr", _parent->cr); - def("cad", _parent->cad_enabled); - def("int_thr", _parent->interference_threshold); - def("rxgain", _parent->rx_boosted_gain); - def("fem_rxgain", _parent->radio_fem_rxgain); - def("fem_txgain", _parent->radio_fem_txgain); - def("tx", _parent->tx_power_dbm); - def("af", _parent->airtime_factor); - def("rxdelay", _parent->rx_delay_base); - def("f_txdelay", _parent->tx_delay_factor); - def("d_txdelay", _parent->direct_tx_delay_factor); - def("agc_int", _parent->agc_reset_interval); - def("hash_mode", _parent->path_hash_mode); - def("multi_ack", _parent->multi_acks); - } - public: - RadioPrefs(NodePrefs* parent) : _parent(parent) { } - // CommonRadioPrefs interface - float getFreq() const override { return _parent->freq; } - void setFreq(float f) override { _parent->freq = f; markDirty(); } - float getBandwidth() const override { return _parent->bw; } - void setBandwidth(float bw) override { _parent->bw = bw; markDirty(); } - uint8_t getSpreadFactor() const override { return _parent->sf; } - void setSpreadFactor(uint8_t sf) override { _parent->sf = sf; markDirty(); } - uint8_t getCodingRate() const override { return _parent->cr; } - void setCodingRate(uint8_t cr) override { _parent->cr = cr; markDirty(); } - float getAirtimeFactor() const override { return _parent->airtime_factor; } - void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); } - bool isCadEnabled() const override { return _parent->cad_enabled; } - void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); } - uint8_t getIntThresh() const override { return _parent->interference_threshold; } - void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } - uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } - void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } - uint8_t getTxPower() const override { return _parent->tx_power_dbm; } - void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } - float getRxDelay() const override { return _parent->rx_delay_base; } - void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } - uint16_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } - void setAgcResetInt(uint16_t secs) override { _parent->agc_reset_interval = secs / 4; markDirty(); } - uint8_t getHashMode() const override { return _parent->path_hash_mode; } - void setHashMode(uint8_t m) override { _parent->path_hash_mode = m; markDirty(); } - uint8_t getMultiAcks() const override { return _parent->multi_acks; } - void setMultiAcks(uint8_t m) override { _parent->multi_acks = m; markDirty(); } - float getFloodTxDelay() const override { return _parent->tx_delay_factor; } - void setFloodTxDelay(float d) override { _parent->tx_delay_factor = d; markDirty(); } - float getDirectTxDelay() const override { return _parent->direct_tx_delay_factor; } - void setDirectTxDelay(float d) override { _parent->direct_tx_delay_factor = d; markDirty(); } - uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } - void setFEMRxGain(uint8_t g) override { _parent->radio_fem_rxgain = g; markDirty(); } - uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } - void setFEMTxGain(uint8_t g) override { _parent->radio_fem_txgain = g; markDirty(); } - }; - RadioPrefs radio; - - class BridgePrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("en", _parent->bridge_enabled); // boolean - def("delay", _parent->bridge_delay); // milliseconds (default 500 ms) - def("src", _parent->bridge_pkt_src); // 0 = logTx, 1 = logRx (default logTx) - def("baud", _parent->bridge_baud); // 9600, 19200, 38400, 57600, 115200 (default 115200) - def("ch", _parent->bridge_channel); // 1-14 (ESP-NOW only) - def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret)); // for XOR encryption of bridge packets (ESP-NOW only) - } - public: - BridgePrefs(NodePrefs* parent) : _parent(parent) { } - }; - BridgePrefs bridge; - - class GPSPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("en", _parent->gps_enabled); // boolean - def("int", _parent->gps_interval); // interval in seconds - def("adv_loc", _parent->advert_loc_policy); - } - public: - GPSPrefs(NodePrefs* parent) : _parent(parent) { } - }; - GPSPrefs gps; - - class PowerPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("adc_mult", _parent->adc_multiplier); - def("pwr_sav_en", _parent->powersaving_enabled); - } - public: - PowerPrefs(NodePrefs* parent) : _parent(parent) { } - }; - PowerPrefs power; - - class RepeatPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("disable", _parent->disable_fwd); - def("f_max", _parent->flood_max); - def("f_max_uns", _parent->flood_max_unscoped); - def("f_max_adv", _parent->flood_max_advert); - def("loop", _parent->loop_detect); - } - public: - RepeatPrefs(NodePrefs* parent) : _parent(parent) { } - }; - RepeatPrefs repeat; - - class RoomPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("rd_only", _parent->allow_read_only); - } - public: - RoomPrefs(NodePrefs* parent) : _parent(parent) { } - }; - RoomPrefs room; - - DynamicConfigSerializer custom; - -protected: - void structure() override { - def("name", node_name, sizeof(node_name)); - def("pass", password, sizeof(password)); - def("guest", guest_password, sizeof(guest_password)); - def("owner", owner_info, sizeof(owner_info)); - def("adv_int", advert_interval); - def("f_adv_int", flood_advert_interval); - def("lat", node_lat); - def("lon", node_lon); - def("radio", radio); - def("bridge", bridge); - def("gps", gps); - def("repeat", repeat); - def("room", room); - def("power", power); - def("custom", custom); - } - -public: - NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this), custom(&radio) { - node_name[0] = 0; - password[0] = 0; - guest_password[0] = 0; - bridge_secret[0] = 0; - owner_info[0] = 0; - } - - CommonRadioPrefs* getRadioPrefs() { return &radio; } - KeyValueStore* getCustom() { return &custom; } - - bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } - void clearDirty() override { ConfigSerializer::clearDirty(); radio.clearDirty(); custom.clearDirty(); } -}; - class CommonCLICallbacks { public: virtual void savePrefs() = 0; @@ -308,6 +82,7 @@ class CommonCLI { mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); + void sanitisePrefs(); void handleRegionCmd(char* command, char* reply); void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply); diff --git a/src/helpers/CommonGPSPrefs.h b/src/helpers/CommonGPSPrefs.h new file mode 100644 index 00000000000..e36b8c727cd --- /dev/null +++ b/src/helpers/CommonGPSPrefs.h @@ -0,0 +1,33 @@ +#pragma once +#include "ConfigSerializer.h" + +/** + * \brief The GPS settings every node role persists. + * + * Defined once here and inherited by both NodePrefs classes (see RadioPrefsFields + * in CommonRadioPrefs.h for why inheritance is used). + */ +struct GPSPrefsFields { + // ordered by size, so the struct has no padding inside + uint32_t gps_interval = 0; // read interval in seconds + uint8_t gps_enabled = 0; // boolean + uint8_t advert_loc_policy = 0; // ADVERT_LOC_* : which location adverts carry +}; + +/** + * \brief The "gps" object of /prefs.json, bound to one GPSPrefsFields. + */ +class CommonGPSPrefs : public ConfigSerializer { + GPSPrefsFields* _f; + +protected: + // JSON keys and their order are the persisted format: do not rename or reorder. + void structure() override { + def("en", _f->gps_enabled); + def("int", _f->gps_interval); + def("adv_loc", _f->advert_loc_policy); + } + +public: + explicit CommonGPSPrefs(GPSPrefsFields& fields) : _f(&fields) { } +}; diff --git a/src/helpers/CommonNodePrefs.h b/src/helpers/CommonNodePrefs.h new file mode 100644 index 00000000000..0c5c97b2d64 --- /dev/null +++ b/src/helpers/CommonNodePrefs.h @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include + +#define ADVERT_LOC_NONE 0 +#define ADVERT_LOC_SHARE 1 +#define ADVERT_LOC_PREFS 2 + +#define LOOP_DETECT_OFF 0 +#define LOOP_DETECT_MINIMAL 1 +#define LOOP_DETECT_MODERATE 2 +#define LOOP_DETECT_STRICT 3 + +// The version of the layout and meaning of /prefs.json. A file without the key is +// version 0. ServerMesh::begin() brings an older file up to this version once, on the +// first boot of a firmware that knows a newer one; see ServerMesh::upgradePrefs(). +#define NODE_PREFS_VERSION 1 + +/** + * \brief Persisted settings of the server roles (repeater, room server, sensor), + * stored as /prefs.json and edited through CommonCLI. + * + * The radio and GPS settings are inherited from RadioPrefsFields and GPSPrefsFields, + * which the companion radio's NodePrefs shares, so both read and write them the + * same way. Every other setting is specific to the server roles and lives below. + */ +class NodePrefs : public ConfigSerializer, public RadioPrefsFields, public GPSPrefsFields { +public: + // in-memory backing data (radio and GPS fields are inherited, see above) + char node_name[32]; + double node_lat = 0, node_lon = 0; + char password[16]; + uint8_t disable_fwd = 0; + uint8_t advert_interval = 0; // minutes / 2 + uint8_t flood_advert_interval = 0; // hours + char guest_password[16]; + uint32_t guard; + uint8_t allow_read_only = 0; + uint8_t flood_max = 0; + uint8_t flood_max_unscoped = 0; + uint8_t flood_max_advert = 0; + // Bridge settings + uint8_t bridge_enabled = 0; // boolean + uint16_t bridge_delay = 0; // milliseconds (default 500 ms) + uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logTx) + uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200) + uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) + char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) + // Power setting + uint8_t powersaving_enabled = 0; // boolean + uint32_t discovery_mod_timestamp = 0; + float adc_multiplier = 0; + char owner_info[120]; + uint8_t loop_detect = 0; + uint8_t extra_sf[4]; + uint8_t prefs_version = 0; // 0 until loaded or upgraded, see NODE_PREFS_VERSION + +private: + CommonRadioPrefs radio; + + class BridgePrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("en", _parent->bridge_enabled); // boolean + def("delay", _parent->bridge_delay); // milliseconds (default 500 ms) + def("src", _parent->bridge_pkt_src); // 0 = logTx, 1 = logRx (default logTx) + def("baud", _parent->bridge_baud); // 9600, 19200, 38400, 57600, 115200 (default 115200) + def("ch", _parent->bridge_channel); // 1-14 (ESP-NOW only) + def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret)); // for XOR encryption of bridge packets (ESP-NOW only) + } + public: + BridgePrefs(NodePrefs* parent) : _parent(parent) { } + }; + BridgePrefs bridge; + + CommonGPSPrefs gps; + + class PowerPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("adc_mult", _parent->adc_multiplier); + def("pwr_sav_en", _parent->powersaving_enabled); + } + public: + PowerPrefs(NodePrefs* parent) : _parent(parent) { } + }; + PowerPrefs power; + + class RepeatPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("disable", _parent->disable_fwd); + def("f_max", _parent->flood_max); + def("f_max_uns", _parent->flood_max_unscoped); + def("f_max_adv", _parent->flood_max_advert); + def("loop", _parent->loop_detect); + } + public: + RepeatPrefs(NodePrefs* parent) : _parent(parent) { } + }; + RepeatPrefs repeat; + + class RoomPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("rd_only", _parent->allow_read_only); + } + public: + RoomPrefs(NodePrefs* parent) : _parent(parent) { } + }; + RoomPrefs room; + + DynamicConfigSerializer custom; + +protected: + void structure() override { + def("ver", prefs_version); + def("name", node_name, sizeof(node_name)); + def("pass", password, sizeof(password)); + def("guest", guest_password, sizeof(guest_password)); + def("owner", owner_info, sizeof(owner_info)); + def("adv_int", advert_interval); + def("f_adv_int", flood_advert_interval); + def("disc_mod", discovery_mod_timestamp); + // A fixed size byte array, encoded as hex like the companion's scope key. + // Top level rather than in the "radio" object, because that object is bound + // to RadioPrefsFields and extra_sf is a field of NodePrefs. + def("extra_sf", (void *) extra_sf, sizeof(extra_sf)); + def("lat", node_lat); + def("lon", node_lon); + def("radio", radio); + def("bridge", bridge); + def("gps", gps); + def("repeat", repeat); + def("room", room); + def("power", power); + def("custom", custom); + } + +public: + NodePrefs() : ConfigSerializer(), bridge(this), gps(*this), radio(*this), power(this), repeat(this), room(this), custom(&radio) { + node_name[0] = 0; + password[0] = 0; + guest_password[0] = 0; + bridge_secret[0] = 0; + owner_info[0] = 0; + memset(extra_sf, 0, sizeof(extra_sf)); + } + + CommonRadioPrefs* getRadioPrefs() { return &radio; } + KeyValueStore* getCustom() { return &custom; } + + bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } + void clearDirty() override { ConfigSerializer::clearDirty(); radio.clearDirty(); custom.clearDirty(); } +}; diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932a5..c8709351d46 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -2,33 +2,135 @@ #include "TxtDataHelpers.h" #include "Utils.h" #include "target.h" +#include +#include -bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { - if (strcmp(key, "fem_rxgain") == 0) { - snprintf(value, max_len, "%d", (uint32_t)getFEMRxGain()); - return true; +namespace { + +enum FieldType : uint8_t { FIELD_F32, FIELD_U8, FIELD_I8 }; + +// How the generic code handles a setting on the CLI. +enum CliKind : uint8_t { + CLI_NONE, // no generic command (none at all, or a special one in handleCommand()) + CLI_GET_ONLY, // "get " only + CLI_FLOAT, // "set ": atof(), accepted if 0 <= value <= max (NaN and infinity rejected) + CLI_UINT8, // "set ": atoi() into uint8_t, accepted if value <= max (max < 0: no limit) + CLI_ON_OFF, // "set ": on if the value starts with "on", off otherwise +}; + +struct RadioSetting { + const char* json_key; // key in the "radio" object of /prefs.json (persisted format, never rename) + uint8_t offset; // the field in RadioPrefsFields + FieldType type; + const char* cli_name; // command name, or NULL + CliKind cli; + float max; // upper limit, see CliKind + const char* range_error; // reply when a value is out of range + bool key_value; // readable and writable through the key/value store (used by boards) +}; + +#define FIELD(f) offsetof(RadioPrefsFields, f) + +/** + * One row per radio setting. The order of the rows is the order of the keys in the + * "radio" object of /prefs.json. A new setting needs a field in RadioPrefsFields and a + * row here; settings whose CLI does not fit a CliKind get CLI_NONE and a branch in + * handleCommand(). + */ +const RadioSetting SETTINGS[] = { + // json key field type CLI name CLI kind max range error + { "freq", FIELD(freq), FIELD_F32, "freq", CLI_GET_ONLY, -1, NULL, false }, + { "bw", FIELD(bw), FIELD_F32, NULL, CLI_NONE, -1, NULL, false }, // "radio" + { "sf", FIELD(sf), FIELD_U8, NULL, CLI_NONE, -1, NULL, false }, // "radio" + { "cr", FIELD(cr), FIELD_U8, NULL, CLI_NONE, -1, NULL, false }, // "radio" + { "cad", FIELD(cad_enabled), FIELD_U8, "cad", CLI_ON_OFF, -1, NULL, false }, + { "int_thr", FIELD(interference_threshold), FIELD_U8, "int.thresh", CLI_UINT8, -1, NULL, false }, + { "rxgain", FIELD(rx_boosted_gain), FIELD_U8, NULL, CLI_NONE, -1, NULL, false }, // "radio.rxgain" + { "fem_rxgain", FIELD(radio_fem_rxgain), FIELD_U8, NULL, CLI_NONE, -1, NULL, true }, // board CLI + { "fem_txgain", FIELD(radio_fem_txgain), FIELD_U8, NULL, CLI_NONE, -1, NULL, true }, // board CLI + { "tx", FIELD(tx_power_dbm), FIELD_I8, NULL, CLI_NONE, -1, NULL, false }, // "tx" + { "af", FIELD(airtime_factor), FIELD_F32, NULL, CLI_NONE, -1, NULL, false }, // "af", "dutycycle" + { "rxdelay", FIELD(rx_delay_base), FIELD_F32, "rxdelay", CLI_FLOAT, 20, "Error, must be 0-20", false }, + { "f_txdelay", FIELD(tx_delay_factor), FIELD_F32, "txdelay", CLI_FLOAT, 2, "Error, must be 0-2", false }, + { "d_txdelay", FIELD(direct_tx_delay_factor), FIELD_F32, "direct.txdelay", CLI_FLOAT, 2, "Error, must be 0-2", false }, + { "agc_int", FIELD(agc_reset_interval), FIELD_U8, NULL, CLI_NONE, -1, NULL, false }, // "agc.reset.interval" + { "hash_mode", FIELD(path_hash_mode), FIELD_U8, "path.hash.mode", CLI_UINT8, 2, "Error, must be 0,1, or 2", false }, + { "multi_ack", FIELD(multi_acks), FIELD_U8, "multi.acks", CLI_UINT8, -1, NULL, false }, +}; + +const int NUM_SETTINGS = sizeof(SETTINGS) / sizeof(SETTINGS[0]); + +// strtof() and atof() also read "nan" and "inf" (any case, also "infinity" and +// "nan(...)"). A NaN cannot be refused by a range check: no ordered comparison is +// true for it, and under -ffast-math, which nRF52 builds use, the compiler may +// assume it never occurs and fold any test on the parsed value away (clang does +// that even with a test on the bits). So the text is checked: after optional +// white space and sign, those are the only spellings that start with 'n' or 'i'. +// Anything else keeps its old meaning, including garbage that reads as 0. +bool spellsNanOrInf(const char* s) { + while (isspace((unsigned char)*s)) s++; + if (*s == '+' || *s == '-') s++; + char c = (char) tolower((unsigned char)*s); + return c == 'n' || c == 'i'; +} + +// True if text is a number in [0, max]; v is its parsed value. An infinity from +// digits ("1e40") fails the comparison, which the compiler cannot fold because +// the value is only known at run time. +bool inRange(const char* text, float v, float max) { + return !spellsNanOrInf(text) && v >= 0 && v <= max; +} + +// "get " +bool isGet(const char* command, const char* name) { + return memcmp(command, "get ", 4) == 0 && strcmp(&command[4], name) == 0; +} + +// "set ": returns the value, or NULL if the command is something else +const char* setValue(const char* command, const char* name) { + size_t len = strlen(name); + if (memcmp(command, "set ", 4) != 0 || memcmp(&command[4], name, len) != 0 || command[4 + len] != ' ') return NULL; + return &command[5 + len]; +} + +} // namespace + +void CommonRadioPrefs::structure() { + for (int i = 0; i < NUM_SETTINGS; i++) { + const RadioSetting& s = SETTINGS[i]; + uint8_t* field = (uint8_t *) _f + s.offset; + switch (s.type) { + case FIELD_F32: def(s.json_key, *(float *) field); break; + case FIELD_U8: def(s.json_key, *(uint8_t *) field); break; + case FIELD_I8: def(s.json_key, *(int8_t *) field); break; + } } - if (strcmp(key, "fem_txgain") == 0) { - snprintf(value, max_len, "%d", (uint32_t)getFEMTxGain()); - return true; +} + +bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { + for (int i = 0; i < NUM_SETTINGS; i++) { + const RadioSetting& s = SETTINGS[i]; + if (s.key_value && s.type == FIELD_U8 && strcmp(key, s.json_key) == 0) { + snprintf(value, max_len, "%d", (uint32_t) *((uint8_t *) _f + s.offset)); + return true; + } } return false; } bool CommonRadioPrefs::setByKey(const char* key, const char* value) { - if (strcmp(key, "fem_rxgain") == 0) { - setFEMRxGain(atoi(value)); - markDirty(); - return true; - } - if (strcmp(key, "fem_txgain") == 0) { - setFEMTxGain(atoi(value)); - markDirty(); - return true; + for (int i = 0; i < NUM_SETTINGS; i++) { + const RadioSetting& s = SETTINGS[i]; + if (s.key_value && s.type == FIELD_U8 && strcmp(key, s.json_key) == 0) { + *((uint8_t *) _f + s.offset) = atoi(value); + markDirty(); + return true; + } } return false; } bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + // Settings whose CLI does not fit a CliKind. if (strcmp(command, "get radio") == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(getFreq())); @@ -57,11 +159,6 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } - if (strcmp(command, "get freq") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(getFreq())); - return true; - } - if (strcmp(command, "get af") == 0) { sprintf(reply, "> %s", StrHelper::ftoa(getAirtimeFactor())); return true; @@ -69,7 +166,10 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest if (memcmp(command, "set af ", 7) == 0) { char* end; float af = strtof(&command[7], &end); - if (end == &command[7] || af < 0 || af > 9) { + // inRange() refuses "nan" and "inf" by the text. Two comparisons alone + // cannot: no ordered comparison is true for a NaN, so "af < 0 || af > 9" + // let "set af nan" through, which a T-Echo accepted and reported back as 0. + if (end == &command[7] || !inRange(&command[7], af, 9)) { strcpy(reply, "ERROR: af must be 0-9"); } else { setAirtimeFactor(af); @@ -87,7 +187,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest } if (memcmp(command, "set dutycycle ", 14) == 0) { float dc = atof(&command[14]); - if (dc < 1 || dc > 100) { + if (!inRange(&command[14], dc, 100) || dc < 1) { strcpy(reply, "ERROR: dutycycle must be 1-100"); } else { setAirtimeFactor((100.0f / dc) - 1.0f); @@ -99,26 +199,6 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } - if (strcmp(command, "get int.thresh") == 0) { - sprintf(reply, "> %d", (uint32_t) getIntThresh()); - return true; - } - if (memcmp(command, "set int.thresh ", 15) == 0) { - setIntThresh(atoi(&command[15])); - strcpy(reply, "OK"); - return true; - } - - if (strcmp(command, "get cad") == 0) { - sprintf(reply, "> %s", isCadEnabled() ? "on" : "off"); - return true; - } - if (memcmp(command, "set cad ", 8) == 0) { - setCadEnabled(memcmp(&command[8], "on", 2) == 0); - strcpy(reply, "OK"); - return true; - } - if (strcmp(command, "get radio.rxgain") == 0) { sprintf(reply, "> %s", getRxGain() != 0 ? "on" : "off"); return true; @@ -145,21 +225,6 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } - if (strcmp(command, "get rxdelay") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(getRxDelay())); - return true; - } - if (memcmp(command, "set rxdelay ", 12) == 0) { - float db = atof(&command[12]); - if (db >= 0 && db <= 20.0f) { - setRxDelay(db); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0-20"); - } - return true; - } - if (strcmp(command, "get agc.reset.interval") == 0) { sprintf(reply, "> %d", (uint32_t) getAgcResetInt()); return true; @@ -172,59 +237,45 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } - if (strcmp(command, "get path.hash.mode") == 0) { - sprintf(reply, "> %d", (uint32_t)getHashMode()); - return true; - } - if (memcmp(command, "set path.hash.mode ", 19) == 0) { - const char* config = command + 19; - uint8_t mode = atoi(config); - if (mode < 3) { - setHashMode(mode); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0,1, or 2"); - } - return true; - } - - if (strcmp(command, "get multi.acks") == 0) { - sprintf(reply, "> %d", (uint32_t) getMultiAcks()); - return true; - } - if (memcmp(command, "set multi.acks ", 15) == 0) { - setMultiAcks(atoi(&command[15])); - strcpy(reply, "OK"); - return true; - } + // Every other setting, driven by SETTINGS. + for (int i = 0; i < NUM_SETTINGS; i++) { + const RadioSetting& s = SETTINGS[i]; + if (s.cli == CLI_NONE) continue; + uint8_t* field = (uint8_t *) _f + s.offset; - if (strcmp(command, "get txdelay") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(getFloodTxDelay())); - return true; - } - if (memcmp(command, "set txdelay ", 12) == 0) { - float f = atof(&command[12]); - if (f >= 0 && f <= 2.0f) { - setFloodTxDelay(f); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0-2"); + if (isGet(command, s.cli_name)) { + if (s.cli == CLI_ON_OFF) { + sprintf(reply, "> %s", *field ? "on" : "off"); + } else if (s.type == FIELD_F32) { + sprintf(reply, "> %s", StrHelper::ftoa(*(float *) field)); + } else { + sprintf(reply, "> %d", (uint32_t) *field); + } + return true; } - return true; - } + if (s.cli == CLI_GET_ONLY) continue; - if (strcmp(command, "get direct.txdelay") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(getDirectTxDelay())); - return true; - } - if (memcmp(command, "set direct.txdelay ", 19) == 0) { - float f = atof(&command[19]); - if (f >= 0 && f <= 2.0f) { - setDirectTxDelay(f); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0-2"); + const char* value = setValue(command, s.cli_name); + if (value == NULL) continue; + if (s.cli == CLI_FLOAT) { + float v = atof(value); + if (!inRange(value, v, s.max)) { + strcpy(reply, s.range_error); + return true; + } + *(float *) field = v; + } else if (s.cli == CLI_UINT8) { + uint8_t v = atoi(value); + if (s.max >= 0 && v > s.max) { + strcpy(reply, s.range_error); + return true; + } + *field = v; + } else { // CLI_ON_OFF + *field = memcmp(value, "on", 2) == 0; } + markDirty(); + strcpy(reply, "OK"); return true; } diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 0e83ca9dfdb..0dff2a26de1 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -1,66 +1,81 @@ #pragma once #include "ConfigSerializer.h" #include "KeyValueStore.h" - +#include + +/** + * \brief The "radio" object of /prefs.json, the radio commands of the CLI and the + * key/value access boards use, all bound to one RadioPrefsFields. + * + * NodePrefs owns the fields and an instance of this class bound to them. Setters + * mark this object dirty, which NodePrefs::isDirty() reports, so the owner knows + * the prefs need saving. + */ class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { + RadioPrefsFields* _f; bool _is_dirty = false; + protected: - CommonRadioPrefs() { } + void structure() override; // the "radio" object of /prefs.json, see SETTINGS in CommonRadioPrefs.cpp + public: + explicit CommonRadioPrefs(RadioPrefsFields& fields) : _f(&fields) { } + void markDirty() { _is_dirty = true; } - void clearDirty() { _is_dirty = false; } - bool isDirty() const { return _is_dirty; } + void clearDirty() override { _is_dirty = false; } + bool isDirty() const override { return _is_dirty; } - virtual float getFreq() const = 0; - virtual void setFreq(float f) = 0; + float getFreq() const { return _f->freq; } + void setFreq(float f) { _f->freq = f; markDirty(); } - virtual float getBandwidth() const = 0; - virtual void setBandwidth(float bw) = 0; + float getBandwidth() const { return _f->bw; } + void setBandwidth(float bw) { _f->bw = bw; markDirty(); } - virtual uint8_t getSpreadFactor() const = 0; - virtual void setSpreadFactor(uint8_t sf) = 0; + uint8_t getSpreadFactor() const { return _f->sf; } + void setSpreadFactor(uint8_t sf) { _f->sf = sf; markDirty(); } - virtual uint8_t getCodingRate() const = 0; - virtual void setCodingRate(uint8_t cr) = 0; + uint8_t getCodingRate() const { return _f->cr; } + void setCodingRate(uint8_t cr) { _f->cr = cr; markDirty(); } - virtual float getAirtimeFactor() const = 0; - virtual void setAirtimeFactor(float af) = 0; + float getAirtimeFactor() const { return _f->airtime_factor; } + void setAirtimeFactor(float af) { _f->airtime_factor = af; markDirty(); } - virtual bool isCadEnabled() const = 0; - virtual void setCadEnabled(bool en) = 0; + bool isCadEnabled() const { return _f->cad_enabled; } + void setCadEnabled(bool en) { _f->cad_enabled = en; markDirty(); } - virtual uint8_t getIntThresh() const = 0; - virtual void setIntThresh(uint8_t t) = 0; + uint8_t getIntThresh() const { return _f->interference_threshold; } + void setIntThresh(uint8_t t) { _f->interference_threshold = t; markDirty(); } - virtual uint8_t getRxGain() const = 0; - virtual void setRxGain(uint8_t g) = 0; + uint8_t getRxGain() const { return _f->rx_boosted_gain; } + void setRxGain(uint8_t g) { _f->rx_boosted_gain = g; markDirty(); } - virtual uint8_t getTxPower() const = 0; - virtual void setTxPower(uint8_t dbm) = 0; + uint8_t getTxPower() const { return _f->tx_power_dbm; } + void setTxPower(uint8_t dbm) { _f->tx_power_dbm = dbm; markDirty(); } - virtual float getRxDelay() const = 0; - virtual void setRxDelay(float d) = 0; + float getRxDelay() const { return _f->rx_delay_base; } + void setRxDelay(float d) { _f->rx_delay_base = d; markDirty(); } - virtual uint16_t getAgcResetInt() const = 0; - virtual void setAgcResetInt(uint16_t secs) = 0; + // stored in units of 4 seconds + uint16_t getAgcResetInt() const { return _f->agc_reset_interval * 4; } + void setAgcResetInt(uint16_t secs) { _f->agc_reset_interval = secs / 4; markDirty(); } - virtual uint8_t getHashMode() const = 0; - virtual void setHashMode(uint8_t m) = 0; + uint8_t getHashMode() const { return _f->path_hash_mode; } + void setHashMode(uint8_t m) { _f->path_hash_mode = m; markDirty(); } - virtual uint8_t getMultiAcks() const = 0; - virtual void setMultiAcks(uint8_t m) = 0; + uint8_t getMultiAcks() const { return _f->multi_acks; } + void setMultiAcks(uint8_t m) { _f->multi_acks = m; markDirty(); } - virtual float getFloodTxDelay() const = 0; - virtual void setFloodTxDelay(float d) = 0; + float getFloodTxDelay() const { return _f->tx_delay_factor; } + void setFloodTxDelay(float d) { _f->tx_delay_factor = d; markDirty(); } - virtual float getDirectTxDelay() const = 0; - virtual void setDirectTxDelay(float d) = 0; + float getDirectTxDelay() const { return _f->direct_tx_delay_factor; } + void setDirectTxDelay(float d) { _f->direct_tx_delay_factor = d; markDirty(); } - virtual uint8_t getFEMRxGain() const = 0; - virtual void setFEMRxGain(uint8_t g) = 0; + uint8_t getFEMRxGain() const { return _f->radio_fem_rxgain; } + void setFEMRxGain(uint8_t g) { _f->radio_fem_rxgain = g; markDirty(); } - virtual uint8_t getFEMTxGain() const = 0; - virtual void setFEMTxGain(uint8_t g) = 0; + uint8_t getFEMTxGain() const { return _f->radio_fem_txgain; } + void setFEMTxGain(uint8_t g) { _f->radio_fem_txgain = g; markDirty(); } bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index daf834b2b10..4e1913b4690 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -1,5 +1,27 @@ #include "ConfigSerializer.h" #include // atoi/atol/atof (Arduino.h pulls this in on-device, native builds do not) +#include // memcpy, for the bit check below +#include + +namespace { + +// True if the value is a finite number. NaN and infinity are recognised by +// their bits, not by a comparison: nRF52 builds compile with -Ofast, whose +// -ffast-math lets float comparisons assume that NaN and infinity never +// occur, so a comparison alone may let them through. +bool isFinite(float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7F800000u) != 0x7F800000u; +} + +bool isFinite(double v) { + uint64_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7FF0000000000000ull) != 0x7FF0000000000000ull; +} + +} // namespace bool ConfigSerializer::saveSerial(Stream& s) { Context context(&s, OP::WRITE); @@ -294,7 +316,11 @@ void ConfigSerializer::def(const char* key, double& value) { } } else { if (_context->keyMatch(_depth, key)) { - value = atof(_context->getToken()); + // A token that is not a finite number is no value: leave what is already + // in the field, which is the default the firmware set up before reading + // the file. + double parsed = atof(_context->getToken()); + if (isFinite(parsed)) value = parsed; } } } @@ -311,7 +337,10 @@ void ConfigSerializer::def(const char* key, float& value) { } } else { if (_context->keyMatch(_depth, key)) { - value = (float) atof(_context->getToken()); + // Checked after the narrowing to float, since a value that is finite as + // a double can overflow to infinity as a float. + float parsed = (float) atof(_context->getToken()); + if (isFinite(parsed)) value = parsed; } } } diff --git a/src/helpers/FemRxGain.h b/src/helpers/FemRxGain.h new file mode 100644 index 00000000000..adbffef3cff --- /dev/null +++ b/src/helpers/FemRxGain.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include + +/* + * The RX gain (LNA) setting of a LoRa front end module: the `fem_rxgain` key in + * the key/value store the role attaches, and the CLI commands + * `get radio.fem.rxgain` and `set radio.fem.rxgain on|off`. + * + * BOARD is the board class. It needs a public `loRaFEMControl` with + * isLnaCanControl(), and the two members setLoRaFemLnaEnabled(bool), which + * returns whether the LNA was switched, and isLoRaFemLnaEnabled(). These may be + * private if the board names this class a friend. The board keeps one FemRxGain + * member and forwards attachDynamicPrefs() and handleCommand() to it: + * + * class MyBoard : public ESP32Board { + * friend class FemRxGain; + * FemRxGain fem_rxgain; + * ... + * void MyBoard::attachDynamicPrefs(KeyValueStore* prefs) { fem_rxgain.attachPrefs(*this, prefs); } + * + * The store is NULL until attachPrefs() is called. A role that calls + * handleCommand() without attaching one gets the same replies, and `set` then + * switches the LNA and stores nothing. + */ +template +class FemRxGain { + KeyValueStore* _prefs = NULL; + +public: + void attachPrefs(BOARD& board, KeyValueStore* prefs) { + _prefs = prefs; + + char radio_fem_rxgain[8] = { 0 }; + _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values + + board.setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + } + + bool handleCommand(BOARD& board, const char* command, char* reply) { + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!board.loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", board.isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!board.loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else if (memcmp(&command[21], "on", 2) == 0) { + if (board.setLoRaFemLnaEnabled(true)) { + if (_prefs) _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (memcmp(&command[21], "off", 3) == 0) { + if (board.setLoRaFemLnaEnabled(false)) { + if (_prefs) _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled + } +}; diff --git a/src/helpers/FrameReader.h b/src/helpers/FrameReader.h new file mode 100644 index 00000000000..807e7b4f392 --- /dev/null +++ b/src/helpers/FrameReader.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +// Reads the fields of a frame in order, and never past its end. +// +// FrameReader r(cmd_frame, len, 1); // the fields start behind the code byte +// uint32_t secs = r.u32(); +// uint8_t* key = r.bytes(PUB_KEY_SIZE); +// if (r.has(4)) alt = r.i32(); // an optional field +// +// A read that would go past `len` returns 0 (or nullptr), leaves the cursor where it +// is and clears ok(), which stays false. Values of more than one byte are copied +// with memcpy, so they come out in the target's byte order, as the code that read +// cmd_frame at fixed offsets got them. +// +// The buffer must have room for len + 1 bytes when restAsCString() is used: it +// writes the terminator at buf[len]. The companion's cmd_frame is +// MAX_FRAME_SIZE + 1 bytes for that reason. + +#ifdef FRAME_READER_COUNT_OVERRUNS +extern int frame_reader_overruns; // host tests count the reads that ran out +#endif + +class FrameReader { +public: + FrameReader(uint8_t* buf, size_t len, size_t pos = 0) + : _buf(buf), _len(len), _pos(pos <= len ? pos : len), _ok(pos <= len) { } + + uint8_t u8() { return fits(1) ? _buf[_pos++] : 0; } + uint16_t u16() { uint16_t v = 0; copy(&v, sizeof(v)); return v; } + uint32_t u32() { uint32_t v = 0; copy(&v, sizeof(v)); return v; } + int32_t i32() { int32_t v = 0; copy(&v, sizeof(v)); return v; } + + // n bytes at the cursor, inside the frame; nullptr if they are not all there. + uint8_t* bytes(size_t n) { + if (!fits(n)) return nullptr; + uint8_t* p = &_buf[_pos]; + _pos += n; + return p; + } + void skip(size_t n) { if (fits(n)) _pos += n; } + + // The rest of the frame as a C string: writes 0 at buf[len] and consumes it all. + char* restAsCString() { + _buf[_len] = 0; + char* p = (char*)&_buf[_pos]; + _pos = _len; + return p; + } + + bool has(size_t n) const { return _ok && n <= _len - _pos; } + size_t remaining() const { return _len - _pos; } + size_t pos() const { return _pos; } + bool ok() const { return _ok; } + +private: + uint8_t* _buf; + size_t _len; + size_t _pos; + bool _ok; + + bool fits(size_t n) { + if (has(n)) return true; + _ok = false; +#ifdef FRAME_READER_COUNT_OVERRUNS + frame_reader_overruns++; +#endif + return false; + } + void copy(void* dest, size_t n) { + if (fits(n)) { + memcpy(dest, &_buf[_pos], n); + _pos += n; + } + } +}; diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index dc85d69cddc..42b84069e64 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -42,6 +42,56 @@ bool IdentityStore::load(const char *name, mesh::LocalIdentity& id, char display return loaded; } +// Compares two identities over all 96 bytes. prv_key is private, so the +// comparison runs over the buffer serialisation that LocalIdentity offers +// publicly. Its layout does not have to match the file, it only has to be the +// same on both sides. +static bool sameIdentity(const mesh::LocalIdentity& a, const mesh::LocalIdentity& b) { + uint8_t ab[PRV_KEY_SIZE + PUB_KEY_SIZE]; + uint8_t bb[PRV_KEY_SIZE + PUB_KEY_SIZE]; + return a.writeTo(ab, sizeof(ab)) == sizeof(ab) + && b.writeTo(bb, sizeof(bb)) == sizeof(bb) + && memcmp(ab, bb, sizeof(ab)) == 0; +} + +// A write that reports success is not proof that the medium took the data, so +// both overloads read the file back after closing it. File::close() is void in +// every platform branch of this file and drops the error it is handed, which +// is where the gap is. +// +// Measured against the two filesystem stacks this file talks to, with the 96 +// bytes of an identity: +// +// littlefs, on nRF52 with prog_size 128 and on STM32 with prog_size 2048: a +// full filesystem and a failing erase are reported by lfs_file_write() itself, +// as LFS_ERR_NOSPC and LFS_ERR_IO. A failing program is not. The two writes +// return 32 and 64 and the error turns up in lfs_file_close(), whose result +// Adafruit_LittleFS' void close() drops. +// +// ESP32: nothing reaches the caller at write time at all. The core opens files +// fully buffered, setvbuf(_IOFBF, 4096) in libraries/FS/src/vfs_api.cpp, and +// VFSFileImpl::write() is fwrite(), so the 96 bytes stay in that buffer until +// close() calls fclose() and throws its result away. On a full filesystem +// fwrite() returns 32 and then 64 and fclose() returns -1. Both ESP32 +// filesystems are affected, LittleFSImpl and SPIFFSImpl derive from VFSImpl. +// +// A read back that cannot be done at all counts as a failed save. That is +// deliberate: an identity nobody can read is of no use to the next boot. +bool IdentityStore::verifySaved(const char *name, const mesh::LocalIdentity& id) { + mesh::LocalIdentity stored; + if (!load(name, stored)) return false; // file gone, empty or too short + return sameIdentity(id, stored); +} + +bool IdentityStore::verifySaved(const char *name, const mesh::LocalIdentity& id, const uint8_t expected_name[32]) { + mesh::LocalIdentity stored; + char stored_name[32]; + memset(stored_name, 0, sizeof(stored_name)); // load() fills at most 32 bytes + if (!load(name, stored, stored_name, (int) sizeof(stored_name))) return false; + if (!sameIdentity(id, stored)) return false; + return memcmp(stored_name, expected_name, sizeof(stored_name)) == 0; +} + bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { char filename[40]; sprintf(filename, "%s/%s.id", _dir, name); @@ -58,7 +108,11 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { bool success = id.writeTo(file); file.close(); MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); - return true; + if (success && !verifySaved(name, id)) { + MESH_DEBUG_PRINTLN("IdentityStore::save() verify - Err"); + success = false; + } + return success; } MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); return false; @@ -77,17 +131,23 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const File file = _fs->open(filename, "w", true); #endif if (file) { - id.writeTo(file); + bool success = id.writeTo(file); uint8_t tmp[32]; memset(tmp, 0, sizeof(tmp)); int n = strlen(display_name); if (n > sizeof(tmp)-1) n = sizeof(tmp)-1; memcpy(tmp, display_name, n); - file.write(tmp, sizeof(tmp)); + success = success && (file.write(tmp, sizeof(tmp)) == sizeof(tmp)); file.close(); - return true; + MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); + if (success && !verifySaved(name, id, tmp)) { + MESH_DEBUG_PRINTLN("IdentityStore::save() verify - Err"); + success = false; + } + return success; } + MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); return false; } diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index d0d7ee457ef..29d404f8c69 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -23,4 +23,11 @@ class IdentityStore { bool load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz); bool save(const char *name, const mesh::LocalIdentity& id); bool save(const char *name, const mesh::LocalIdentity& id, const char display_name[]); + +private: + // Reads back what save() just wrote and compares it. The comment above the + // definition in IdentityStore.cpp says why the return value of write() is + // not enough on its own. + bool verifySaved(const char *name, const mesh::LocalIdentity& id); + bool verifySaved(const char *name, const mesh::LocalIdentity& id, const uint8_t expected_name[32]); }; diff --git a/src/helpers/MillisDeadline.h b/src/helpers/MillisDeadline.h new file mode 100644 index 00000000000..0100eaec027 --- /dev/null +++ b/src/helpers/MillisDeadline.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +namespace mesh { + +/** + * \brief Has a scheduled millis() deadline passed? + * \param now the current millis() reading + * \param deadline a deadline previously formed as 'millis() + interval' + * \returns true once now is strictly past deadline + * + * Periodic work is scheduled by storing 'millis() + interval' and asking once per loop + * pass whether that moment has arrived. Asking with a plain 'now > deadline' breaks in + * the last interval before the 32-bit counter reaches 2^32: the stored deadline has + * already wrapped to a small number while now is still large, so the compare is true on + * every pass and the block runs on each pass instead of once per interval, until the + * counter wraps as well. Comparing the wrapped difference instead stays correct, because + * for two readings less than 2^31 apart the modular subtraction is their real distance. + * + * The horizon is therefore half the counter: a deadline up to 2^31 - 1 ahead reads as + * future, and beyond that future and past are no longer distinguishable. Every interval + * scheduled in this firmware is orders of magnitude inside that horizon. + * + * The widths are fixed deliberately rather than written as long. long is 32 bits on the + * firmware targets but 64 bits on the test host, and a 64-bit deadline does not preserve + * the behaviour at the wrap, it changes it into a stall for a whole counter period. Fixed + * widths give the host test the same arithmetic the target performs. + * + * This is the question Dispatcher::millisHasNowPassed answers for code that holds a + * Dispatcher, written here for code that does not, and spelled without that version's + * conversion of an out-of-range unsigned value to a signed one, which the standard leaves + * to the implementation. test_millis_deadline pins the two forms to each other. + */ +inline bool millisHasPassed(uint32_t now, uint32_t deadline) { + const uint32_t elapsed = now - deadline; + return elapsed != 0 && elapsed < 0x80000000u; +} + +} // namespace mesh diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index d8d773803b1..4b62d419748 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -188,7 +188,9 @@ int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[], } RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) { - if (packet->transport_codes[0] == 0) return &wildcard; + if (packet->transport_codes[0] == 0) { // no scope: handled like an un-scoped flood... + return (wildcard.flags & mask) == 0 ? &wildcard : NULL; // ...so the wildcard's own flags decide + } for (int i = 0; i < num_regions; i++) { auto region = ®ions[i]; if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param) diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp new file mode 100644 index 00000000000..30ea87f0c24 --- /dev/null +++ b/src/helpers/ServerMesh.cpp @@ -0,0 +1,287 @@ +#include "ServerMesh.h" + +#include "target.h" // the variant's radio_driver, as helpers/CommonRadioPrefs.cpp reaches it + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include +#elif defined(RP2040_PLATFORM) +#include +#elif defined(ESP32) +#include +#endif + +#include "SimpleMeshTables.h" +#include "StatsFormatHelper.h" + +// The bodies below moved here unchanged from the three roles, where they were +// identical. Verified by extracting each one from all three role implementations and +// comparing after normalising only the class name and the pointer star spacing. + +bool ServerMesh::allowPacketForward(const mesh::Packet* packet) { + if (_prefs.disable_fwd) return false; + if (packet->isRouteFlood() + && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { + return false; + } + return true; +} + +mesh::DispatcherAction ServerMesh::onRecvPacket(mesh::Packet* pkt) { + if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); + } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { + recv_pkt_region = NULL; + } else { + recv_pkt_region = ®ion_map.getWildcard(); + } + } else { + recv_pkt_region = NULL; + } + return Mesh::onRecvPacket(pkt); +} + +void ServerMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { + TransportKey req_scope; + bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); + bool req_scope_known = recv_pkt_region != NULL && !is_wildcard + && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; + + switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { + case mesh::REPLY_SCOPE_REQUEST: + sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request + break; + case mesh::REPLY_SCOPE_DEFAULT: + // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. + // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 + sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_NONE: + sendFlood(packet, delay_millis, path_hash_size); // send un-scoped + break; + } +} + +void ServerMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { + if (scope.isNull()) { + sendFlood(pkt, delay_millis, path_hash_size); + } else { + uint16_t codes[2]; + codes[0] = scope.calcTransportCode(pkt); + codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? + sendFlood(pkt, codes, delay_millis, path_hash_size); + } +} + +void ServerMesh::startRegionsLoad() { + temp_map.resetFrom(region_map); // rebuild regions in a temp instance + memset(load_stack, 0, sizeof(load_stack)); + load_stack[0] = &temp_map.getWildcard(); + region_load_active = true; +} + +bool ServerMesh::saveRegions() { + return region_map.save(_fs); +} + +void ServerMesh::onDefaultRegionChanged(const RegionEntry* r) { + if (r) { + region_map.getTransportKeysFor(*r, &default_scope, 1); + } else { + memset(default_scope.key, 0, sizeof(default_scope.key)); + } +} + + +void ServerMesh::sendSelfAdvertisement(int delay_millis, bool flood) { + mesh::Packet* pkt = createSelfAdvert(); + if (pkt) { + if (flood) { + sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); + } else { + sendZeroHop(pkt, delay_millis); + } + } else { + MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); + } +} + +void ServerMesh::updateAdvertTimer() { + if (_prefs.advert_interval > 0) { // schedule local advert timer + next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); + } else { + next_local_advert = 0; // stop the timer + } +} + +void ServerMesh::updateFloodAdvertTimer() { + if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer + next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); + } else { + next_flood_advert = 0; // stop the timer + } +} + +void ServerMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params + pending_freq = freq; + pending_bw = bw; + pending_sf = sf; + pending_cr = cr; + + revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params +} + +void ServerMesh::checkPendingRadioParams() { + if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params + set_radio_at = 0; // clear timer + radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + MESH_DEBUG_PRINTLN("Temp radio params"); + } + + if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig + revert_radio_at = 0; // clear timer + radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + MESH_DEBUG_PRINTLN("Radio params restored"); + } +} + +void ServerMesh::setTxPower(int8_t power_dbm) { + radio_driver.setTxPower(power_dbm); +} + +bool ServerMesh::formatFileSystem() { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return InternalFS.format(); +#elif defined(RP2040_PLATFORM) + return LittleFS.format(); +#elif defined(ESP32) + return SPIFFS.format(); +#else +#error "need to implement file system erase" + return false; +#endif +} + +void ServerMesh::saveIdentity(const mesh::LocalIdentity& new_id) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + IdentityStore store(*_fs, ""); +#elif defined(ESP32) + IdentityStore store(*_fs, "/identity"); +#elif defined(RP2040_PLATFORM) + IdentityStore store(*_fs, "/identity"); +#else +#error "need to define saveIdentity()" +#endif + store.save("_main", new_id); +} + +void ServerMesh::clearStats() { + radio_driver.resetStats(); + resetStats(); + ((SimpleMeshTables *)getTables())->resetStats(); +} + +void ServerMesh::formatStatsReply(char* reply) { + StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); +} + +void ServerMesh::formatRadioStatsReply(char* reply) { + StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); +} + +void ServerMesh::formatPacketStatsReply(char* reply) { + StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect()); +} + +int ServerMesh::searchPeersByHash(const uint8_t* hash) { + int n = 0; + // ClientACL caps its own count at MAX_CLIENTS, so the bound can never bite; it is + // here so the array's size and the loop that fills it are stated in one place. + for (int i = 0; i < acl.getNumClients() && n < MAX_CLIENTS; i++) { + if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { + matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) + } + } + return n; +} + +void ServerMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + // lookup pre-calculated shared_secret + memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); + } else { + MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); + } +} + +// The boot sequence. The three roles each had this as their own begin(); the bodies +// were identical except for four things, which are now hooks or shared code: +// - the repeater's bridge start (beginBridge) +// - the repeater's log line about the gain mode it applied (onRadioConfigured) +// - where the prefs are loaded from, because a role owns its CommonCLI (loadPrefs) +// - the GPS toggle, three copies of one line (applyGpsPrefs, now here) +// The order below is the order they ran in, and the tests pin it. +void ServerMesh::begin(FILESYSTEM* fs) { + mesh::Mesh::begin(); + _fs = fs; + // load persisted prefs + loadPrefs(); + // A file from an older firmware is brought up to date once. Booting writes nothing + // unless a value changed, and a newer version (after a downgrade) is left alone. + if (_prefs.prefs_version < NODE_PREFS_VERSION) { + bool changed = upgradePrefs(_prefs.prefs_version); + _prefs.prefs_version = NODE_PREFS_VERSION; + if (changed) savePrefs(); + } + acl.load(_fs, self_id); + // TODO: key_store.begin(); + region_map.load(_fs); + + // establish default-scope + { + RegionEntry* r = region_map.getDefaultRegion(); + if (r) { + region_map.getTransportKeysFor(*r, &default_scope, 1); + } else { +#ifdef DEFAULT_FLOOD_SCOPE_NAME + r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME); + if (r == NULL) { + r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0); // auto-create the default scope region + if (r) { r->flags = 0; } // Allow-flood + } + if (r) { + region_map.setDefaultRegion(r); + region_map.getTransportKeysFor(*r, &default_scope, 1); + } +#endif + } + } + + beginBridge(); + + radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + radio_driver.setTxPower(_prefs.tx_power_dbm); + + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + onRadioConfigured(); + + board.attachDynamicPrefs(_prefs.getCustom()); + + updateAdvertTimer(); + updateFloodAdvertTimer(); + + board.setAdcMultiplier(_prefs.adc_multiplier); + +#if ENV_INCLUDE_GPS == 1 + applyGpsPrefs(); +#endif +} + +#if ENV_INCLUDE_GPS == 1 +void ServerMesh::applyGpsPrefs() { + sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); +} +#endif diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h new file mode 100644 index 00000000000..f42394578a0 --- /dev/null +++ b/src/helpers/ServerMesh.h @@ -0,0 +1,185 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +/** + * \brief The underpinnings every server role shares: the repeater, the room server + * and the sensor. + * + * The three roles grew as separate sketches and ended up carrying the same code + * three times. What lives here was identical in all three, or identical apart from + * formatting: + * + * - the Region a packet arrived on, and the scope a flooded reply goes out with + * - loading, saving and applying the Region table + * - the advertisement schedule, with the advert itself left to the role + * - the temporary radio parameters a 'set radio ...' command installs + * - the identity file, the file system erase, the TX power and the stats replies + * - the client ACL and the peer lookup a packet's sender hash goes through + * - the boot sequence: begin() loads what the node stored, then gives the radio and + * the board the settings it holds, always in the same order + * + * A role keeps whatever is genuinely its own: what it announces, how it answers a + * request, and in the repeater's case its bridge and loop detection. + * + * The members are protected rather than private because the role code reaches them + * directly, as it did when they were its own fields. + * + * See docs/refactoring/0004-shared-server-base.md for the measurement and the order + * of the steps. + */ +class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { +protected: + FILESYSTEM* _fs; + + // Declared before the Region maps, which are constructed with it. + TransportKeyStore key_store; + RegionMap region_map, temp_map; + + ClientACL acl; // the clients this node knows and what they may do + NodePrefs _prefs; // this node's persisted settings + + // Filled by searchPeersByHash(), read by getPeerSharedSecret() right after. Sized + // for the whole ACL, which is what ClientACL can hold. + int matching_peer_indexes[MAX_CLIENTS]; + unsigned long next_local_advert, next_flood_advert; + + // A 'set radio ...' command parks its values here until the reply has gone out, + // and schedules the return to the node's own settings. + unsigned long set_radio_at, revert_radio_at; + float pending_freq; + float pending_bw; + uint8_t pending_sf; + uint8_t pending_cr; + + RegionEntry* load_stack[8]; + RegionEntry* recv_pkt_region; // the Region the packet being handled arrived on + TransportKey default_scope; // this node's own scope, for replies it cannot scope otherwise + bool region_load_active; + + ServerMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, + mesh::PacketManager& mgr, mesh::MeshTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, mgr, tables), + region_map(key_store), temp_map(key_store) + { + _fs = NULL; + next_local_advert = next_flood_advert = 0; + set_radio_at = revert_radio_at = 0; + recv_pkt_region = NULL; + region_load_active = false; + memset(default_scope.key, 0, sizeof(default_scope.key)); + } + + /** + * \brief The hooks begin() calls into the role. A role owns its CommonCLI, so it + * says where the prefs are loaded from; the other two are for the one role + * that has more to do at those points, and do nothing by default. + */ + virtual void loadPrefs() = 0; + + /** + * \brief Bring prefs that an older firmware stored up to NODE_PREFS_VERSION. + * Called by begin() right after loadPrefs(), and only when the stored + * version is older; begin() then records the current version. + * \param from the stored version, 0 for a file written before versions existed + * \returns true if a value was changed, so begin() saves the result at once and + * the upgrade does not run again on the next boot + */ + virtual bool upgradePrefs(uint8_t from) { return false; } + + /** + * \brief Start the role's bridge, if it has one and it is enabled. Called after + * everything is loaded and before the radio is configured. + */ + virtual void beginBridge() { } + + /** + * \brief Called once the radio has been given the node's parameters, TX power + * and boosted gain mode. + */ + virtual void onRadioConfigured() { } + +#if ENV_INCLUDE_GPS == 1 + /** + * \brief Push the stored GPS on/off setting to the sensor manager. + */ + void applyGpsPrefs(); +#endif + + /** + * \brief Build this node's own advertisement. + * + * The only part of advertising that differs by role: each one announces its own + * type (repeater, room server, sensor), so the packet itself stays with the role + * while the schedule and the send paths live here. + */ + virtual mesh::Packet* createSelfAdvert() = 0; + + /** + * \brief Send a flooded reply to the request being handled, scoped the way the + * request was, or with this node's own scope when that is unknowable. + */ + void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); + + /** + * \brief Flood a packet inside a transport scope, or un-scoped if the scope is null. + */ + void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); + + /** + * \brief Hand the radio any pending temporary parameters, and take them back + * again once they have timed out. Call this from the role's loop(). + */ + void checkPendingRadioParams(); + + // Mesh + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; + /** + * \brief The checks every server role makes before passing a packet on: + * forwarding switched off, and the flood hop limits (flood.max, + * flood.max.unscoped, flood.max.advert). A role with checks of its own + * overrides this and calls it first. + */ + bool allowPacketForward(const mesh::Packet* packet) override; + int searchPeersByHash(const uint8_t* hash) override; + void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + + // CommonCLICallbacks + void startRegionsLoad() override; + bool saveRegions() override; + void onDefaultRegionChanged(const RegionEntry* r) override; + +public: + /** + * \brief Bring the node up: start the dispatcher, load the stored prefs, ACL and + * Regions, establish the default scope, then configure the radio and the + * board from the prefs and start the advert timers. + * + * The order is part of the behaviour and is pinned by test/mocks/BootSequenceChecks.h. + * In particular the dispatcher starts first, so it sizes its duty cycle budget from + * the constructor's airtime factor and not the stored one. + */ + void begin(FILESYSTEM* fs); + + // These were public on all three roles and stay public, so nothing outside the + // roles changes. begin() and sendSelfAdvertisement() need it: each role's main.cpp + // calls them once after boot. The rest are reached through the + // CommonCLICallbacks pointer, where public access comes from that interface. + void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; + void setTxPower(int8_t power_dbm) override; + bool formatFileSystem() override; + void saveIdentity(const mesh::LocalIdentity& new_id) override; + void clearStats() override; + void formatStatsReply(char* reply) override; + void formatRadioStatsReply(char* reply) override; + void formatPacketStatsReply(char* reply) override; + void sendSelfAdvertisement(int delay_millis, bool flood) override; + void updateAdvertTimer() override; + void updateFloodAdvertTimer() override; +}; diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index f474215a155..bda44a7ebb4 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -38,7 +38,7 @@ class ESPNOWRadio : public mesh::Radio { * These two functions do nothing for ESP-NOW, but are needed for the * Radio interface. */ - virtual bool setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } uint32_t intID(); diff --git a/src/helpers/nrf52/I2cBusClear.h b/src/helpers/nrf52/I2cBusClear.h new file mode 100644 index 00000000000..fa1738a1b2b --- /dev/null +++ b/src/helpers/nrf52/I2cBusClear.h @@ -0,0 +1,104 @@ +#pragma once + +#include + +// I2C bus clear (NXP UM10204 section 3.1.16), for the first-boot case where a +// soft reset (DFU, watchdog, reset button) caught a slave mid-byte. Slaves are +// not reset by the MCU, so such a slave keeps holding SDA low and waits for +// more SCL clocks. The Adafruit nRF52 TwoWire driver then busy-waits forever +// on the first transfer (no timeout, no bus clear), which looks like a dead +// firmware before radio_init(). +// +// The logic is a template over a pin interface so the clock sequence can be +// tested on the host. Both lines are only ever driven low or released (never +// driven high), so a slave that stretches the clock is not overridden. +// +// Pin interface requirements: +// void sdaLow(); void sdaRelease(); bool sdaRead(); +// void sclLow(); void sclRelease(); bool sclRead(); +// void delayUs(uint32_t us); +// "Release" means high-impedance with the pull-up active. + +struct I2cBusClearResult { + bool sda_low_at_start; // SDA was held low when the clear began + uint8_t pulses; // SCL clocks issued + bool ok; // both lines high on return +}; + +#define I2C_BUS_CLEAR_MAX_PULSES 9 +#define I2C_BUS_CLEAR_HALF_PERIOD_US 5 // ~100 kHz +#define I2C_BUS_CLEAR_STRETCH_LIMIT_US 1000 // max wait for SCL to rise +#define I2C_BUS_CLEAR_SETTLE_US 2000 // let the pull-ups charge the lines after reset + +template +static bool i2cBusClearWaitScl(Pins& p) { + for (uint32_t waited = 0; !p.sclRead(); waited += 10) { + if (waited >= I2C_BUS_CLEAR_STRETCH_LIMIT_US) return false; + p.delayUs(10); + } + return true; +} + +template +I2cBusClearResult i2cBusClear(Pins& p) { + I2cBusClearResult r = {false, 0, false}; + + p.sdaRelease(); + p.sclRelease(); + p.delayUs(I2C_BUS_CLEAR_SETTLE_US); + + // SCL held low (by a slave or a short): clocking is impossible, give up + // without touching anything. + if (!i2cBusClearWaitScl(p)) return r; + + r.sda_low_at_start = !p.sdaRead(); + if (!r.sda_low_at_start) { + r.ok = true; // healthy bus: do nothing, no STOP either + return r; + } + + while (r.pulses < I2C_BUS_CLEAR_MAX_PULSES && !p.sdaRead()) { + p.sclLow(); + p.delayUs(I2C_BUS_CLEAR_HALF_PERIOD_US); + p.sclRelease(); + r.pulses++; + if (!i2cBusClearWaitScl(p)) return r; + p.delayUs(I2C_BUS_CLEAR_HALF_PERIOD_US); + } + + // Still stuck after nine clocks: not a mid-byte slave, a STOP would not help. + if (!p.sdaRead()) return r; + + // STOP: SDA low -> high while SCL is high, so the slave's state machine ends + // in idle. + p.sdaLow(); + p.delayUs(I2C_BUS_CLEAR_HALF_PERIOD_US); + p.sdaRelease(); + p.delayUs(I2C_BUS_CLEAR_HALF_PERIOD_US); + + r.ok = p.sdaRead() && p.sclRead(); + return r; +} + +#ifdef NRF52_PLATFORM +#include + +// Open-drain pin access on the nRF52 Arduino core: OUTPUT_S0D1 sinks but +// never sources, INPUT_PULLUP releases. +struct Nrf52I2cPins { + uint8_t sda, scl; + void sdaLow() { pinMode(sda, OUTPUT_S0D1); digitalWrite(sda, LOW); } + void sdaRelease() { pinMode(sda, INPUT_PULLUP); } + bool sdaRead() { return digitalRead(sda) != LOW; } + void sclLow() { pinMode(scl, OUTPUT_S0D1); digitalWrite(scl, LOW); } + void sclRelease() { pinMode(scl, INPUT_PULLUP); } + bool sclRead() { return digitalRead(scl) != LOW; } + void delayUs(uint32_t us) { delayMicroseconds(us); } +}; + +// Call before Wire.begin(). Leaves both pins released. +static inline I2cBusClearResult i2cBusClearNrf52(uint8_t sda, uint8_t scl) { + Nrf52I2cPins pins = {sda, scl}; + return i2cBusClear(pins); +} +#endif diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index 1dcd916fa1e..360261e0414 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -1,145 +1,18 @@ #pragma once -#include - -class CustomLLCC68 : public LLCC68 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; - +#include "CustomSX126x.h" + +// LLCC68 has never had the SX1262 only parts of std_init(): it ignores +// SX126X_USE_REGULATOR_LDO and SX126X_REGISTER_PATCH and prints no init debug +// lines. Giving it any of them is a behaviour change of its own. +struct CustomLLCC68Traits { + static constexpr bool regulatorOption = false; + static constexpr bool registerPatch = false; + static constexpr bool initDebug = false; + static const char* name() { return "LLCC68"; } +}; + +class CustomLLCC68 : public CustomSX126x { public: - CustomLLCC68(Module *mod) : LLCC68(mod) { } - - #ifdef RP2040_PLATFORM - bool std_init(SPIClassRP2040* spi = NULL) - #else - bool std_init(SPIClass* spi = NULL) - #endif - { - #ifdef SX126X_DIO3_TCXO_VOLTAGE - float tcxo = SX126X_DIO3_TCXO_VOLTAGE; - #else - float tcxo = 1.6f; - #endif - - #ifdef LORA_CR - uint8_t cr = LORA_CR; - #else - uint8_t cr = 5; - #endif - - #if defined(P_LORA_SCLK) - #ifdef NRF52_PLATFORM - if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } - #elif defined(RP2040_PLATFORM) - if (spi) { - spi->setMISO(P_LORA_MISO); - //spi->setCS(P_LORA_NSS); // Setting CS results in freeze - spi->setSCK(P_LORA_SCLK); - spi->setMOSI(P_LORA_MOSI); - spi->begin(); - } - #else - if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); - #endif - #endif - int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f - if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { - tcxo = 0.0f; - status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - } - if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); - return false; // fail - } - - setCRC(1); - - #ifdef SX126X_CURRENT_LIMIT - setCurrentLimit(SX126X_CURRENT_LIMIT); - #endif - #ifdef SX126X_DIO2_AS_RF_SWITCH - setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); - #endif - #ifdef SX126X_RX_BOOSTED_GAIN - setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); - #endif - #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX126X_RXEN - #define SX126X_RXEN RADIOLIB_NC - #endif - #ifndef SX126X_TXEN - #define SX126X_TXEN RADIOLIB_NC - #endif - setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); - #endif - - return true; // success - } - - int16_t startReceive() override { - // include the PREAMBLE_DETECTED irq bit in reported flags - return LLCC68::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - } - - bool isReceiving() { - uint32_t irq = getIrqFlags(); - bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 - bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 - bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 - uint32_t now = millis(); - if (hdrErr) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; - _headerSeen = false; - return false; - } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; - } - - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } - - bool getRxBoostedGainMode() { - uint8_t rxGain = 0; - readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); - return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); - } -}; \ No newline at end of file + CustomLLCC68(Module *mod) : CustomSX126x(mod) { } +}; diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index ae0fe0a2530..8857c343c65 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -29,13 +29,9 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomLLCC68 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomLLCC68 *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomLLCC68 *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomLLCC68 *)_radio)->spreadingFactor; } - void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } + void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } bool setRxBoostedGainMode(bool en) override { return ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index f0fde435400..2dc96206d57 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -2,12 +2,10 @@ #include #include "MeshCore.h" +#include "RxActivityTracker.h" class CustomLR1110 : public LR1110 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; bool _rx_boosted = false; public: @@ -52,54 +50,21 @@ class CustomLR1110 : public LR1110 { bool isReceiving() { uint32_t irq = getIrqStatus(); - bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 - bool header = irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; // bit 5 - bool hdrErr = irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR; // bit 6 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID, + irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } uint8_t getSpreadingFactor() const { return spreadingFactor; } }; diff --git a/src/helpers/radiolib/CustomLR1121.h b/src/helpers/radiolib/CustomLR1121.h index 7182d1d5c3e..6a91b8ad7af 100644 --- a/src/helpers/radiolib/CustomLR1121.h +++ b/src/helpers/radiolib/CustomLR1121.h @@ -2,12 +2,10 @@ #include #include "MeshCore.h" +#include "RxActivityTracker.h" class CustomLR1121 : public LR1121 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; bool _rx_boosted = false; int8_t _txPower = 0; bool _txPowerSet = false; @@ -81,54 +79,21 @@ class CustomLR1121 : public LR1121 { bool isReceiving() { uint32_t irq = getIrqStatus(); - bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 - bool header = irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; // bit 5 - bool hdrErr = irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR; // bit 6 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID, + irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } uint8_t getSpreadingFactor() const { return spreadingFactor; } }; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 0255b93cf29..1a7dfb1059c 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -2,12 +2,10 @@ #include #include "MeshCore.h" +#include "RxActivityTracker.h" class CustomLR2021 : public LR2021 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; bool _rx_boosted = false; public: @@ -77,54 +75,21 @@ class CustomLR2021 : public LR2021 { bool isReceiving() { uint32_t irq = getIrqStatus(); - bool preamble = irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED; // bit 5 - bool header = irq & RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID; // bit 6 - bool hdrErr = irq & RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR; // bit 9 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID, + irq & RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID | RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID | RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } uint8_t getSpreadingFactor() const { return spreadingFactor; } diff --git a/src/helpers/radiolib/CustomSTM32WLx.h b/src/helpers/radiolib/CustomSTM32WLx.h index e312a5ee470..77c9d350e21 100644 --- a/src/helpers/radiolib/CustomSTM32WLx.h +++ b/src/helpers/radiolib/CustomSTM32WLx.h @@ -1,12 +1,10 @@ #pragma once #include +#include "RxActivityTracker.h" class CustomSTM32WLx : public STM32WLx { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; public: CustomSTM32WLx(STM32WLx_Module *mod) : STM32WLx(mod) { } @@ -18,54 +16,20 @@ class CustomSTM32WLx : public STM32WLx { bool isReceiving() { uint32_t irq = getIrqFlags(); - bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 - bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 - bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } }; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index a4ba4971d04..521a451e384 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -29,10 +29,6 @@ class CustomSTM32WLxWrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomSTM32WLx *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomSTM32WLx *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomSTM32WLx *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomSTM32WLx *)_radio)->spreadingFactor; } void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b4ee6c97aa2..31ae6eccdd7 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -1,165 +1,18 @@ #pragma once -#include -#include "MeshCore.h" - -class CustomSX1262 : public SX1262 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; - +#include "CustomSX126x.h" + +// SX1262 is the chip std_init() was written for: it honours +// SX126X_USE_REGULATOR_LDO and SX126X_REGISTER_PATCH and prints its init debug +// lines. +struct CustomSX1262Traits { + static constexpr bool regulatorOption = true; + static constexpr bool registerPatch = true; + static constexpr bool initDebug = true; + static const char* name() { return "SX1262"; } +}; + +class CustomSX1262 : public CustomSX126x { public: - CustomSX1262(Module *mod) : SX1262(mod) { } - - #ifdef RP2040_PLATFORM - bool std_init(SPIClassRP2040* spi = NULL) - #else - bool std_init(SPIClass* spi = NULL) - #endif - { - #ifdef SX126X_DIO3_TCXO_VOLTAGE - float tcxo = SX126X_DIO3_TCXO_VOLTAGE; - #else - float tcxo = 1.6f; - #endif - - #ifdef LORA_CR - uint8_t cr = LORA_CR; - #else - uint8_t cr = 5; - #endif - - #ifdef SX126X_USE_REGULATOR_LDO - constexpr bool useRegulatorLDO = SX126X_USE_REGULATOR_LDO; - #else - constexpr bool useRegulatorLDO = false; - #endif - - MESH_DEBUG_PRINTLN("SX1262 regulator requested: %s", useRegulatorLDO ? "LDO" : "DC-DC"); - - #if defined(P_LORA_SCLK) - #ifdef NRF52_PLATFORM - if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } - #elif defined(RP2040_PLATFORM) - if (spi) { - spi->setMISO(P_LORA_MISO); - //spi->setCS(P_LORA_NSS); // Setting CS results in freeze - spi->setSCK(P_LORA_SCLK); - spi->setMOSI(P_LORA_MOSI); - spi->begin(); - } - #else - if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); - #endif - #endif - int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); - // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f - if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { - MESH_DEBUG_PRINTLN("SX1262 init failed with error %d, retrying with TCXO at 0.0V", status); - tcxo = 0.0f; - status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); - } - if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); - return false; // fail - } - - setCRC(1); - - #ifdef SX126X_CURRENT_LIMIT - setCurrentLimit(SX126X_CURRENT_LIMIT); - #endif - #ifdef SX126X_DIO2_AS_RF_SWITCH - setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); - #endif - #ifdef SX126X_RX_BOOSTED_GAIN - setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); - #endif - #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX126X_RXEN - #define SX126X_RXEN RADIOLIB_NC - #endif - #ifndef SX126X_TXEN - #define SX126X_TXEN RADIOLIB_NC - #endif - setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); - #endif - - // for improved RX with Heltec v4 - #ifdef SX126X_REGISTER_PATCH - uint8_t r_data = 0; - readRegister(0x8B5, &r_data, 1); - r_data |= 0x01; - writeRegister(0x8B5, &r_data, 1); - #endif - - MESH_DEBUG_PRINTLN("SX1262 status=0x%02X device_errors=0x%04X", getStatus(), getDeviceErrors()); - - return true; // success - } - - int16_t startReceive() override { - // include the PREAMBLE_DETECTED irq bit in reported flags - return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - } - - bool isReceiving() { - uint32_t irq = getIrqFlags(); - bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 - bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 - bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 - uint32_t now = millis(); - if (hdrErr) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; - _headerSeen = false; - return false; - } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; - } - - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } - - bool getRxBoostedGainMode() { - uint8_t rxGain = 0; - readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); - return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); - } -}; \ No newline at end of file + CustomSX1262(Module *mod) : CustomSX126x(mod) { } +}; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index be3144716bf..c6af5fdbeaa 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -32,10 +32,6 @@ class CustomSX1262Wrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomSX1262 *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } virtual void powerOff() override { ((CustomSX1262 *)_radio)->sleep(false); diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index f915332dcef..8172dc96142 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -1,146 +1,18 @@ #pragma once -#include - -class CustomSX1268 : public SX1268 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; - +#include "CustomSX126x.h" + +// SX1268 has never had the SX1262 only parts of std_init(): it ignores +// SX126X_USE_REGULATOR_LDO and SX126X_REGISTER_PATCH and prints no init debug +// lines. Giving it any of them is a behaviour change of its own. +struct CustomSX1268Traits { + static constexpr bool regulatorOption = false; + static constexpr bool registerPatch = false; + static constexpr bool initDebug = false; + static const char* name() { return "SX1268"; } +}; + +class CustomSX1268 : public CustomSX126x { public: - CustomSX1268(Module *mod) : SX1268(mod) { } - - #ifdef RP2040_PLATFORM - bool std_init(SPIClassRP2040* spi = NULL) - #else - bool std_init(SPIClass* spi = NULL) - #endif - { - #ifdef SX126X_DIO3_TCXO_VOLTAGE - float tcxo = SX126X_DIO3_TCXO_VOLTAGE; - #else - float tcxo = 1.6f; - #endif - - #ifdef LORA_CR - uint8_t cr = LORA_CR; - #else - uint8_t cr = 5; - #endif - - #if defined(P_LORA_SCLK) - #ifdef NRF52_PLATFORM - if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } - #elif defined(RP2040_PLATFORM) - if (spi) { - spi->setMISO(P_LORA_MISO); - //spi->setCS(P_LORA_NSS); // Setting CS results in freeze - spi->setSCK(P_LORA_SCLK); - spi->setMOSI(P_LORA_MOSI); - spi->begin(); - } - #else - if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); - #endif - #endif - int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f - if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { - tcxo = 0.0f; - status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - } - if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); - return false; // fail - } - - setCRC(1); - - #ifdef SX126X_CURRENT_LIMIT - setCurrentLimit(SX126X_CURRENT_LIMIT); - #endif - #ifdef SX126X_DIO2_AS_RF_SWITCH - setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); - #endif - #ifdef SX126X_RX_BOOSTED_GAIN - setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); - #endif - #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX126X_RXEN - #define SX126X_RXEN RADIOLIB_NC - #endif - #ifndef SX126X_TXEN - #define SX126X_TXEN RADIOLIB_NC - #endif - setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); - #endif - - return true; // success - } - - int16_t startReceive() override { - // include the PREAMBLE_DETECTED irq bit in reported flags - return SX1268::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - } - - bool isReceiving() { - uint32_t irq = getIrqFlags(); - bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 - bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 - bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 - uint32_t now = millis(); - if (hdrErr) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; - _headerSeen = false; - return false; - } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; - } - - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } - - - bool getRxBoostedGainMode() { - uint8_t rxGain = 0; - readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); - return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); - } -}; \ No newline at end of file + CustomSX1268(Module *mod) : CustomSX126x(mod) { } +}; diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 70f5dabdc6e..0fdc6a98bb3 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -32,10 +32,6 @@ class CustomSX1268Wrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomSX1268 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomSX1268 *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomSX1268 *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomSX1268 *)_radio)->spreadingFactor; } bool setRxBoostedGainMode(bool en) override { diff --git a/src/helpers/radiolib/CustomSX126x.h b/src/helpers/radiolib/CustomSX126x.h new file mode 100644 index 00000000000..1831c588767 --- /dev/null +++ b/src/helpers/radiolib/CustomSX126x.h @@ -0,0 +1,143 @@ +#pragma once + +#include +#include "MeshCore.h" +#include "RxActivityTracker.h" + +// The chip class shared by CustomSX1262, CustomSX1268 and CustomLLCC68: Base is +// the RadioLib chip class, Traits says which of the SX1262 only parts of +// std_init() a chip gets (see CustomSX1262.h for the members and +// docs/refactoring/0009-radio-chip-adapter.md, step 9b, for why they differ). +template +class CustomSX126x : public Base { + RxActivityTracker _rxActivity; + + public: + CustomSX126x(Module *mod) : Base(mod) { } + + #ifdef RP2040_PLATFORM + bool std_init(SPIClassRP2040* spi = NULL) + #else + bool std_init(SPIClass* spi = NULL) + #endif + { + #ifdef SX126X_DIO3_TCXO_VOLTAGE + float tcxo = SX126X_DIO3_TCXO_VOLTAGE; + #else + float tcxo = 1.6f; + #endif + + #ifdef LORA_CR + uint8_t cr = LORA_CR; + #else + uint8_t cr = 5; + #endif + + // without the option, begin() gets false, RadioLib's default for it + #ifdef SX126X_USE_REGULATOR_LDO + constexpr bool useRegulatorLDO = Traits::regulatorOption && SX126X_USE_REGULATOR_LDO; + #else + constexpr bool useRegulatorLDO = false; + #endif + + if (Traits::initDebug) { + MESH_DEBUG_PRINTLN("%s regulator requested: %s", Traits::name(), useRegulatorLDO ? "LDO" : "DC-DC"); + } + + #if defined(P_LORA_SCLK) + #ifdef NRF52_PLATFORM + if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } + #elif defined(RP2040_PLATFORM) + if (spi) { + spi->setMISO(P_LORA_MISO); + //spi->setCS(P_LORA_NSS); // Setting CS results in freeze + spi->setSCK(P_LORA_SCLK); + spi->setMOSI(P_LORA_MOSI); + spi->begin(); + } + #else + if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + #endif + #endif + int status = this->begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); + // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f + if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { + if (Traits::initDebug) { + MESH_DEBUG_PRINTLN("%s init failed with error %d, retrying with TCXO at 0.0V", Traits::name(), status); + } + tcxo = 0.0f; + status = this->begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); + } + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + this->setCRC(1); + + #ifdef SX126X_CURRENT_LIMIT + this->setCurrentLimit(SX126X_CURRENT_LIMIT); + #endif + #ifdef SX126X_DIO2_AS_RF_SWITCH + this->setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); + #endif + #ifdef SX126X_RX_BOOSTED_GAIN + this->setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); + #endif + #if defined(SX126X_RXEN) || defined(SX126X_TXEN) + #ifndef SX126X_RXEN + #define SX126X_RXEN RADIOLIB_NC + #endif + #ifndef SX126X_TXEN + #define SX126X_TXEN RADIOLIB_NC + #endif + this->setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); + #endif + + // for improved RX with Heltec v4 + #ifdef SX126X_REGISTER_PATCH + if (Traits::registerPatch) { + uint8_t r_data = 0; + this->readRegister(0x8B5, &r_data, 1); + r_data |= 0x01; + this->writeRegister(0x8B5, &r_data, 1); + } + #endif + + if (Traits::initDebug) { + MESH_DEBUG_PRINTLN("%s status=0x%02X device_errors=0x%04X", Traits::name(), this->getStatus(), this->getDeviceErrors()); + } + + return true; // success + } + + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return Base::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + + bool isReceiving() { + uint32_t irq = this->getIrqFlags(); + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + this->clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { + this->clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + } + return r == RxActivityTracker::RECEIVING; + } + + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } + + bool getRxBoostedGainMode() { + uint8_t rxGain = 0; + this->readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); + return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); + } +}; diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index 9d75ce12a1b..71a19962a95 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -28,9 +28,5 @@ class CustomSX1276Wrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomSX1276 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomSX1276 *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomSX1276 *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomSX1276 *)_radio)->spreadingFactor; } }; diff --git a/src/helpers/radiolib/RadioLibTarget.cpp b/src/helpers/radiolib/RadioLibTarget.cpp new file mode 100644 index 00000000000..a9259e2b180 --- /dev/null +++ b/src/helpers/radiolib/RadioLibTarget.cpp @@ -0,0 +1,43 @@ +// The parts of a variant's target.cpp that every board with a RadioLib radio +// writes the same way, defined once here. They are weak: a variant (or a +// downstream fork's variant) that still defines its own keeps compiling, and its +// definition takes precedence. WRAPPER_CLASS is what tells a RadioLib variant +// apart; the ESP-NOW variants have no LoRa radio and define their own. +#include "target.h" + +#ifdef WRAPPER_CLASS + +#include + +// A new random identity, from the noise of the radio radio_driver wraps (which +// is the variant's global radio). Slow, so only for creating the node's identity. +__attribute__((weak)) mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio_driver); + return mesh::LocalIdentity(&rng); +} + +// The radio_init() of the nRF52 variants with an SX1262: start the RTC, then the +// radio on the default SPI bus. Only there, because elsewhere the variants also +// start a fallback clock or use another SPI bus, which they still write out +// themselves; a board there without its own radio_init() fails to link instead +// of getting this one. +#if defined(NRF52_PLATFORM) && defined(USE_SX1262) + +#include +#include + +// The radio a driver wraps, as the variant's RADIO_CLASS (which it is, since the +// variant constructs radio_driver from its global radio). +struct RadioLibTargetAccess { + template + static RADIO& radio(RadioLibWrapper& driver) { return static_cast(*driver._radio); } +}; + +__attribute__((weak)) bool radio_init() { + rtc_clock.begin(Wire); + return RadioLibTargetAccess::radio(radio_driver).std_init(&SPI); +} + +#endif + +#endif diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116b5..715c3c65ffa 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -12,6 +12,8 @@ struct PacketMillis { }; class RadioLibWrapper : public mesh::Radio { + friend class RadioNoiseListener; // listens on _radio, without a public accessor for it + friend struct RadioLibTargetAccess; // RadioLibTarget.cpp starts _radio in the default radio_init() protected: PhysicalLayer* _radio; mesh::MainBoard* _board; @@ -73,7 +75,11 @@ class RadioLibWrapper : public mesh::Radio { virtual float getLastRSSI() const override; virtual float getLastSNR() const override; - float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 + // The score depends on the spreading factor, because the SNR a packet needs + // to be decodable rises as the spreading factor falls. Ask the driver for it + // rather than assume one: a guess here is wrong on every radio that does not + // happen to run at that factor, and it silently stays wrong. + float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, getSpreadingFactor(), packet_len); } virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } @@ -89,6 +95,9 @@ class RadioNoiseListener : public mesh::RNG { PhysicalLayer* _radio; public: RadioNoiseListener(PhysicalLayer& radio): _radio(&radio) { } + // The radio a driver wraps, for code that has only the driver (radio_driver), + // not the variant's global radio object. + explicit RadioNoiseListener(RadioLibWrapper& driver): _radio(driver._radio) { } void random(uint8_t* dest, size_t sz) override { #ifdef USE_CC310_HW_CRYPTO diff --git a/src/helpers/radiolib/RxActivityTracker.h b/src/helpers/radiolib/RxActivityTracker.h new file mode 100644 index 00000000000..fa208e767f6 --- /dev/null +++ b/src/helpers/radiolib/RxActivityTracker.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include "MeshCore.h" + +// Decides from a chip's preamble and header IRQ bits whether a packet is on the +// air, which the dispatcher uses to hold back a transmission. A preamble counts +// for preambleMillis, a valid header for maxPayloadMillis (both set by the +// wrapper from the modem settings); past a deadline the reception is given up so +// a stuck flag cannot block the transmitter for good. +// +// Shared by every chip class with a preamble IRQ. It only sees three booleans and +// the time, so the chips keep their own register access and IRQ bit names, and +// the logic can be tested on the host without a radio. +class RxActivityTracker { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; // 0 means "not started" + bool _headerSeen = false; + +public: + enum Result : uint8_t { + IDLE, // nothing on the air + RECEIVING, // a packet is on the air + CLEAR_PREAMBLE, // not receiving; the caller clears the preamble bit + CLEAR_ALL, // not receiving; the caller clears the preamble and header bits + }; + + Result update(bool preamble, bool header, bool headerErr, uint32_t now) { + if (headerErr) { + _activityAt = 0; + _headerSeen = false; + return CLEAR_ALL; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return IDLE; + } + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; } + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + _activityAt = 0; _headerSeen = false; + return CLEAR_ALL; + } + return RECEIVING; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + return CLEAR_PREAMBLE; + } + return RECEIVING; + } + _activityAt = 0; _headerSeen = false; + return IDLE; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); + } +}; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 0dc892c3a52..f6c50d84185 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -499,6 +499,11 @@ static void bsec_save_state() { static uint8_t init_bme680_bsec(TwoWire* wire, uint8_t addr) { bsec_iaq.begin(addr, *wire); + // bsec_init() is a pure software init and reports BSEC_OK even when nothing + // answers on the bus. Only bme68xStatus tells us a BME68x replied, so without + // this check BSEC claims any Bosch sensor sharing 0x76/0x77 (BME280, BMP280) + // and that sensor never reaches its own driver. Issue #3634. + if (bsec_iaq.bme68xStatus != BME68X_OK) return 0; if (bsec_iaq.bsecStatus != BSEC_OK) return 0; bsec_iaq.setConfig(bsec_config_iaq); diff --git a/test/mocks/AES.h b/test/mocks/AES.h index 678b7c13b13..22997c435af 100644 --- a/test/mocks/AES.h +++ b/test/mocks/AES.h @@ -2,12 +2,26 @@ #include #include +#include // Mock AES128 class for testing -// Provides minimal interface to allow Utils.cpp to compile +// Not a cipher: each block is XORed with the key, so the output depends only on +// key and input. Utils::encrypt() writes straight into the packet buffer, and a +// mock that left the output untouched gave packets whatever that buffer held +// before (docs/refactoring/0010-companion-command-table.md, open finding 3). +// XOR is its own inverse, so decryptBlock() undoes encryptBlock(). class AES128 { + uint8_t _key[16] = {}; + + void xorBlock(uint8_t* output, const uint8_t* input) const { + for (int i = 0; i < 16; i++) output[i] = input[i] ^ _key[i]; + } + public: - void setKey(const uint8_t* key, size_t keySize) {} - void encryptBlock(uint8_t* output, const uint8_t* input) {} - void decryptBlock(uint8_t* output, const uint8_t* input) {} + void setKey(const uint8_t* key, size_t keySize) { + memset(_key, 0, sizeof(_key)); + memcpy(_key, key, keySize < sizeof(_key) ? keySize : sizeof(_key)); + } + void encryptBlock(uint8_t* output, const uint8_t* input) { xorBlock(output, input); } + void decryptBlock(uint8_t* output, const uint8_t* input) { xorBlock(output, input); } }; diff --git a/test/mocks/Adafruit_LittleFS.h b/test/mocks/Adafruit_LittleFS.h new file mode 100644 index 00000000000..c8a5fd3e41d --- /dev/null +++ b/test/mocks/Adafruit_LittleFS.h @@ -0,0 +1,147 @@ +#pragma once + +// Mock of the nRF52 filesystem API for native tests. Only what IdentityStore +// touches is modelled: open, exists, remove, mkdir, and a File that is a +// Stream. +// +// Two failure modes are modelled, because the platforms differ in which one +// they show. +// +// The write budget: a real flash lets a file open and then refuses the write +// once the medium is full or worn out, so a test needs a file that is valid but +// whose writes come up short. Print::write() loops over write(uint8_t), so a +// budget that runs out mid buffer returns a short count, which is how littlefs +// reports it on nRF52 and STM32. +// +// The commit budget: on ESP32 the core opens files fully buffered, so write() +// reports success for bytes that are still in a stdio buffer, and only close() +// hands them over. File::close() is void there and drops the error, which +// leaves a short file behind that no return value ever mentioned. A commit +// budget below the number of bytes written reproduces exactly that, and +// corrupt_on_commit covers the worn cell that keeps the length but changes a +// byte. + +#include +#include +#include +#include +#include +#include + +#include "Stream.h" + +#define FILE_O_READ 0 +#define FILE_O_WRITE 1 + +namespace Adafruit_LittleFS_Namespace { + +struct FileData { + std::vector bytes; + size_t write_budget = std::numeric_limits::max(); + size_t commit_budget = std::numeric_limits::max(); // what close() keeps + size_t corrupt_on_commit = std::numeric_limits::max(); // index close() flips +}; + +class File : public Stream { + FileData* _data; + size_t _read_pos; + bool _open; + bool _writing; + +public: + File() : _data(nullptr), _read_pos(0), _open(false), _writing(false) { } + explicit File(FileData* data, bool writing = false) + : _data(data), _read_pos(0), _open(data != nullptr), _writing(writing) { } + + // The driver signals "could not open" through the bool conversion, not an + // exception or a null return. + operator bool() const { return _open; } + + // Only a file that was opened for writing commits on close. A read never + // changes what is on the medium. + void close() { + if (_open && _writing && _data != nullptr) { + if (_data->bytes.size() > _data->commit_budget) { + _data->bytes.resize(_data->commit_budget); + } + if (_data->corrupt_on_commit < _data->bytes.size()) { + _data->bytes[_data->corrupt_on_commit] ^= 0xFF; + } + } + _open = false; + } + + // Overriding the single byte write would otherwise hide the buffer overloads + // of Print, which is what the callers use. + using Print::write; + + size_t write(uint8_t b) override { + if (!_open || _data == nullptr || _data->write_budget == 0) return 0; + _data->bytes.push_back(b); + _data->write_budget--; + return 1; + } + + int available() override { + if (!_open || _data == nullptr) return 0; + return (int) (_data->bytes.size() - _read_pos); + } + + int read() override { + if (available() <= 0) return -1; + return _data->bytes[_read_pos++]; + } + + int peek() override { + if (available() <= 0) return -1; + return _data->bytes[_read_pos]; + } + + // The driver's File offers a buffer read next to the Stream interface, and + // IdentityStore uses it to pick up the display name. + size_t read(uint8_t* buffer, size_t size) { + size_t n = 0; + while (n < size && available() > 0) buffer[n++] = (uint8_t) read(); + return n; + } +}; + +} // namespace Adafruit_LittleFS_Namespace + +class Adafruit_LittleFS { + std::map _files; + +public: + // Test knobs. + size_t write_budget = std::numeric_limits::max(); + size_t commit_budget = std::numeric_limits::max(); + size_t corrupt_on_commit = std::numeric_limits::max(); + bool fail_open = false; + + bool exists(const char* path) { return _files.count(path) > 0; } + bool remove(const char* path) { return _files.erase(path) > 0; } + bool mkdir(const char* path) { return true; } + + Adafruit_LittleFS_Namespace::File open(const char* path, uint8_t mode = FILE_O_READ) { + if (fail_open) return Adafruit_LittleFS_Namespace::File(); + if (mode == FILE_O_WRITE) { + // std::map keeps its values at a stable address, so handing out a + // pointer stays valid while other files are added. + Adafruit_LittleFS_Namespace::FileData& data = _files[path]; + data.bytes.clear(); + data.write_budget = write_budget; + data.commit_budget = commit_budget; + data.corrupt_on_commit = corrupt_on_commit; + return Adafruit_LittleFS_Namespace::File(&data, true); + } + auto found = _files.find(path); + if (found == _files.end()) return Adafruit_LittleFS_Namespace::File(); + return Adafruit_LittleFS_Namespace::File(&found->second); + } + + // Lets a test see what actually reached the medium. + const std::vector* contents(const char* path) { + auto found = _files.find(path); + return found == _files.end() ? nullptr : &found->second.bytes; + } +}; diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4145..b83502d41fc 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -1,7 +1,13 @@ #pragma once +#include #include +#include +#include #include +#include +#include +#include #include "Stream.h" inline uint32_t g_mock_millis = 0; @@ -15,3 +21,88 @@ inline uint32_t millis() { inline void delay(uint32_t ms) { g_mock_millis += ms; } + +#ifndef constrain +#define constrain(amt, low, high) ((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt))) +#endif + +// Arduino core extension (not in the C standard library). +inline char* ltoa(long value, char* buf, int base) { + char tmp[sizeof(long) * 8 + 1]; + char* t = tmp; + unsigned long v = (value < 0 && base == 10) ? -(unsigned long)value : (unsigned long)value; + do { + int digit = v % base; + *t++ = (char)(digit < 10 ? '0' + digit : 'a' + digit - 10); + v /= base; + } while (v); + char* p = buf; + if (value < 0 && base == 10) *p++ = '-'; + while (t > tmp) *p++ = *--t; + *p = 0; + return buf; +} + +// Arduino's random API, used by helpers/ArduinoHelpers.h. The sequence is +// deterministic, so a test that exercises code drawing from it stays repeatable. +inline uint32_t g_mock_random_state = 1; + +inline void randomSeed(long seed) { + g_mock_random_state = (uint32_t)(seed ? seed : 1); +} + +inline long random(long howsmall, long howbig) { + if (howbig <= howsmall) return howsmall; + g_mock_random_state = g_mock_random_state * 1103515245u + 12345u; + uint32_t span = (uint32_t)(howbig - howsmall); + return howsmall + (long)((g_mock_random_state >> 16) % span); +} + +inline long random(long howbig) { return random(0, howbig); } + +// Arduino's min() and max(), as functions rather than macros. They must not be +// macros: production code uses those words as ordinary identifiers, and +// helpers/CommonRadioPrefs.cpp has both a struct field 'float max' and a parameter +// named 'max', which a function-like macro turns into a syntax error. Two type +// parameters, because the call sites mix types, for example +// min(sp - hex, PUB_KEY_SIZE*2) with a pointer difference and an int. +template +inline auto min(T a, U b) -> decltype(a < b ? a : b) { return a < b ? a : b; } + +template +inline auto max(T a, U b) -> decltype(a > b ? a : b) { return a > b ? a : b; } + +// Arduino's Serial. It keeps what the firmware printed, so a test can assert on +// output the roles write there (the packet log dump, for example). +class MockSerial : public Stream { +public: + std::string out; + + size_t write(uint8_t b) override { out.push_back((char)b); return 1; } + + size_t print(const char* s) { out += s; return strlen(s); } + size_t print(char c) { out.push_back(c); return 1; } + size_t print(int v, int r = DEC) { char b[24]; snprintf(b, sizeof(b), "%d", v); out += b; return strlen(b); } + size_t print(unsigned int v, int r = DEC) { char b[24]; snprintf(b, sizeof(b), "%u", v); out += b; return strlen(b); } + size_t print(long v, int r = DEC) { char b[32]; snprintf(b, sizeof(b), "%ld", v); out += b; return strlen(b); } + size_t print(unsigned long v, int r = DEC) { char b[32]; snprintf(b, sizeof(b), "%lu", v); out += b; return strlen(b); } + size_t print(double v, int p = 2) { char b[40]; snprintf(b, sizeof(b), "%.*f", p, v); out += b; return strlen(b); } + + size_t println() { out.push_back('\n'); return 1; } + template size_t println(T v) { size_t n = print(v); return n + println(); } + + size_t printf(const char* fmt, ...) { + char buf[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + if (n < 0) return 0; + out += buf; + return (size_t)n; + } + + void reset() { out.clear(); } +}; + +inline MockSerial Serial; diff --git a/test/mocks/BootSequenceChecks.h b/test/mocks/BootSequenceChecks.h new file mode 100644 index 00000000000..7e3eb54b977 --- /dev/null +++ b/test/mocks/BootSequenceChecks.h @@ -0,0 +1,676 @@ +#pragma once + +// What a server role's begin() does, in the order it does it. Included by the suite +// of each role (repeater, room server, sensor) after it has named the concrete role +// class as `BootRole`, so the same expectations run against all three. That is the +// point of the boot sequence step in docs/refactoring/0007-boot-sequence.md: the +// three begin() bodies were meant to be one body, and one set of checks says whether +// they still are. +// +// Everything is observed from outside: the file system lookups, the calls on the +// radio, the board and the sensor manager (all recorded in g_events, see EventLog.h), +// and the role's state as the base class holds it. A test builds a node, stores some +// state, then "reboots" by building a second node on the same file system, which is +// what the firmware does between runs. +// +// Compile-time options the sequence depends on are covered where they are compiled +// in: DEFAULT_FLOOD_SCOPE_NAME, ENV_INCLUDE_GPS and, for the repeater, WITH_BRIDGE +// and MESH_DEBUG (the repeater's one log line). The default environments leave them +// out, as most firmware builds do; the "full" repeater environment turns all of them +// on. A suite defines BOOT_ROLE_LOGS_RX_GAIN before including this file if its role +// logs the gain mode it applied. + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace boot_checks { + +constexpr uint32_t T0 = 1000; + +// The role with the shared base's state opened up. Nothing else in the firmware +// reports any of it. +class BootProbe : public BootRole { +public: + using BootRole::BootRole; + + FILESYSTEM* fileSystem() const { return _fs; } + ClientACL& clients() { return acl; } + RegionMap& regions() { return region_map; } + NodePrefs& prefs() { return _prefs; } + const TransportKey& defaultScope() const { return default_scope; } + unsigned long nextLocalAdvert() const { return next_local_advert; } + unsigned long nextFloodAdvert() const { return next_flood_advert; } + const mesh::LocalIdentity& identity() const { return self_id; } + + // Dispatcher::begin() sizes the duty cycle budget from this, so the value it is + // asked for first is the value the node booted its dispatcher with. + float getAirtimeBudgetFactor() const override { + float f = BootRole::getAirtimeBudgetFactor(); + if (!asked) { asked = true; first_airtime_factor = f; } + return f; + } + mutable bool asked = false; + mutable float first_airtime_factor = -1.0f; + + // Which stored versions begin() asked the role to upgrade from. + bool upgradePrefs(uint8_t from) override { + upgrades_from.push_back(from); + return BootRole::upgradePrefs(from); + } + std::vector upgrades_from; +}; + +class BootSequenceTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + g_events.clear(); + } + + std::unique_ptr build() { + return std::unique_ptr(new BootProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + } + + // Boot a node on the shared file system, let `store` change what it holds, then + // write all of it to flash the way the CLI and the role do at run time. + template + void storeState(Fn store) { + auto node = build(); + node->begin(&fs); + store(*node); + node->savePrefs(); + node->clients().save(&fs); + node->regions().save(&fs); + } + + // Boot the node under test and keep only the events begin() itself produced. + std::unique_ptr bootFresh() { + auto node = build(); + g_events.clear(); + node->begin(&fs); + return node; + } + + mesh::Identity identityStarting(uint8_t first, uint8_t tag) { + uint8_t pub[PUB_KEY_SIZE]; + memset(pub, tag, sizeof(pub)); + pub[0] = first; + return mesh::Identity(pub); + } + + // The first time the named file was looked at. + static int firstLookupOf(const char* file) { + return eventIndex(std::string("exists:") + file); + } +}; + +// ---- the order ------------------------------------------------------------- + +TEST_F(BootSequenceTest, TheDispatcherStartsBeforeAnythingIsLoaded) { + bootFresh(); + + ASSERT_EQ(eventIndex("radio.begin"), 0) << "Mesh::begin() is the first thing begin() does"; + EXPECT_EQ(radio_driver.begin_calls, 1); +} + +TEST_F(BootSequenceTest, TheDispatcherSizesItsBudgetFromTheConstructorDefaultsNotTheStoredPrefs) { + // Recorded as observed behaviour, not endorsed: Mesh::begin() runs before the + // stored prefs are loaded, so the duty cycle budget is computed from the airtime + // factor the constructor set and the stored one only counts from then on. It is the + // reason the order of the first two lines of begin() cannot be changed in a move. + storeState([](BootProbe& n) { n.prefs().airtime_factor = 3.0f; }); + + auto node = bootFresh(); + + ASSERT_TRUE(node->asked); + EXPECT_FLOAT_EQ(node->first_airtime_factor, 1.0f) << "the constructor default"; + EXPECT_FLOAT_EQ(node->prefs().airtime_factor, 3.0f) << "the stored value, loaded afterwards"; +} + +TEST_F(BootSequenceTest, PrefsAreLoadedBeforeTheAclAndTheAclBeforeTheRegions) { + storeState([&](BootProbe& n) { + n.clients().putClient(identityStarting(0xAA, 1), 1); + n.regions().putRegion("#alpha", 0); + }); + + bootFresh(); + + const int prefs_at = firstLookupOf("/prefs.json"); + const int acl_at = firstLookupOf("/s_contacts"); + const int regions_at = firstLookupOf("/regions2"); + ASSERT_GE(prefs_at, 0); + ASSERT_GE(acl_at, 0); + ASSERT_GE(regions_at, 0); + EXPECT_LT(prefs_at, acl_at); + EXPECT_LT(acl_at, regions_at); + EXPECT_LT(eventIndex("radio.begin"), prefs_at); +} + +TEST_F(BootSequenceTest, TheRadioIsConfiguredOnlyAfterEverythingIsLoaded) { + bootFresh(); + + const int regions_at = firstLookupOf("/regions2"); + const int params_at = eventIndex("radio.setParams"); + ASSERT_GE(regions_at, 0); + ASSERT_GE(params_at, 0); + EXPECT_LT(regions_at, params_at); +} + +TEST_F(BootSequenceTest, TheRadioIsSetUpInTheOrderParamsPowerGainThenTheBoard) { + bootFresh(); + + const int params = eventIndex("radio.setParams"); + const int power = eventIndex("radio.setTxPower"); + const int gain = eventIndex("radio.setRxBoostedGainMode"); + const int attach = eventIndex("board.attachDynamicPrefs"); + const int adc = eventIndex("board.setAdcMultiplier"); + ASSERT_GE(params, 0); + ASSERT_GE(power, 0); + ASSERT_GE(gain, 0); + ASSERT_GE(attach, 0); + ASSERT_GE(adc, 0); + EXPECT_LT(params, power); + EXPECT_LT(power, gain); + EXPECT_LT(gain, attach); + EXPECT_LT(attach, adc); +} + +TEST_F(BootSequenceTest, EachStepOfTheSetUpHappensExactlyOnce) { + bootFresh(); + + EXPECT_EQ(eventCount("radio.begin"), 1); + EXPECT_EQ(eventCount("radio.setParams"), 1); + EXPECT_EQ(eventCount("radio.setTxPower"), 1); + EXPECT_EQ(eventCount("radio.setRxBoostedGainMode"), 1); + EXPECT_EQ(eventCount("board.attachDynamicPrefs"), 1); + EXPECT_EQ(eventCount("board.setAdcMultiplier"), 1); +} + +// ---- what is loaded and what it is used for -------------------------------- + +TEST_F(BootSequenceTest, TheFileSystemIsTheOneBeginWasGiven) { + auto node = bootFresh(); + + EXPECT_EQ(node->fileSystem(), &fs); +} + +TEST_F(BootSequenceTest, TheRoleCanSaveItsPrefsRightAfterBegin) { + // Saving goes through the file system pointer begin() stored, so this fails if + // that assignment is lost or comes too late. + auto node = bootFresh(); + ASSERT_FALSE(fs.exists("/prefs.json")); + + node->savePrefs(); + + EXPECT_TRUE(fs.exists("/prefs.json")); +} + +TEST_F(BootSequenceTest, BeginWritesNothingOnAnEmptyFileSystem) { + bootFresh(); + + EXPECT_TRUE(fs.files.empty()) << "booting is read only, nothing is created"; +} + +TEST_F(BootSequenceTest, StoredRadioSettingsReachTheRadio) { + storeState([](BootProbe& n) { + n.prefs().freq = 869.525f; + n.prefs().bw = 250.0f; + n.prefs().sf = 9; + n.prefs().cr = 6; + n.prefs().tx_power_dbm = 11; + n.prefs().rx_boosted_gain = 0; // the opposite of the default + }); + radio_driver.reset(); + + bootFresh(); + + EXPECT_FLOAT_EQ(radio_driver.freq, 869.525f); + EXPECT_FLOAT_EQ(radio_driver.bw, 250.0f); + EXPECT_EQ(radio_driver.sf, 9); + EXPECT_EQ(radio_driver.cr, 6); + EXPECT_EQ(radio_driver.tx_power, 11); + EXPECT_FALSE(radio_driver.rx_boosted); +} + +TEST_F(BootSequenceTest, StoredBoostedGainIsAppliedWhenItIsOn) { + storeState([](BootProbe& n) { n.prefs().rx_boosted_gain = 1; }); + radio_driver.reset(); + + bootFresh(); + + EXPECT_TRUE(radio_driver.rx_boosted); + EXPECT_EQ(radio_driver.rx_boost_calls, 1); +} + +TEST_F(BootSequenceTest, TheStoredAdcMultiplierReachesTheBoard) { + storeState([](BootProbe& n) { n.prefs().adc_multiplier = 1.5f; }); + board.reset(); + + bootFresh(); + + EXPECT_FLOAT_EQ(board.adc_multiplier, 1.5f); +} + +TEST_F(BootSequenceTest, AdvertTimersCountFromTheStoredIntervals) { + storeState([](BootProbe& n) { + n.prefs().advert_interval = 7; // units of two minutes + n.prefs().flood_advert_interval = 5; // hours + }); + + auto node = bootFresh(); + + EXPECT_EQ(node->nextLocalAdvert(), T0 + 7UL * 2 * 60 * 1000); + EXPECT_EQ(node->nextFloodAdvert(), T0 + 5UL * 60 * 60 * 1000); +} + +TEST_F(BootSequenceTest, AdvertTimersOfAFreshNodeFollowWhateverTheConstructorSet) { + // The roles do not share their advert defaults (the sensor starts without a flood + // advert), so this does not name them: it checks that begin() schedules from the + // prefs the node actually holds, which on a fresh file system are the constructor's. + auto node = bootFresh(); + + const NodePrefs& p = node->prefs(); + const unsigned long local = + p.advert_interval > 0 ? T0 + (unsigned long)p.advert_interval * 2 * 60 * 1000 : 0; + const unsigned long flood = + p.flood_advert_interval > 0 ? T0 + (unsigned long)p.flood_advert_interval * 60 * 60 * 1000 : 0; + EXPECT_EQ(node->nextLocalAdvert(), local); + EXPECT_EQ(node->nextFloodAdvert(), flood); +} + +TEST_F(BootSequenceTest, ZeroIntervalsLeaveBothTimersOff) { + storeState([](BootProbe& n) { + n.prefs().advert_interval = 0; + n.prefs().flood_advert_interval = 0; + }); + + auto node = bootFresh(); + + EXPECT_EQ(node->nextLocalAdvert(), 0UL); + EXPECT_EQ(node->nextFloodAdvert(), 0UL); +} + +TEST_F(BootSequenceTest, TheStoredAclIsLoaded) { + storeState([&](BootProbe& n) { + ClientInfo* c = n.clients().putClient(identityStarting(0xAA, 1), 3); + ASSERT_NE(c, nullptr); + memset(c->shared_secret, 7, PUB_KEY_SIZE); + }); + + auto node = bootFresh(); + + ASSERT_EQ(node->clients().getNumClients(), 1); + const ClientInfo* c = node->clients().getClientByIdx(0); + EXPECT_EQ(c->id.pub_key[0], 0xAA); + EXPECT_EQ(c->permissions, 3); + + // The secret is recalculated from this node's own identity, not read back. + uint8_t expected[PUB_KEY_SIZE]; + node->identity().calcSharedSecret(expected, c->id.pub_key); + EXPECT_EQ(memcmp(c->shared_secret, expected, PUB_KEY_SIZE), 0); +} + +TEST_F(BootSequenceTest, TheStoredRegionsAreLoaded) { + storeState([](BootProbe& n) { + n.regions().putRegion("#alpha", 0); + n.regions().putRegion("#beta", 0); + }); + + auto node = bootFresh(); + + EXPECT_NE(node->regions().findByName("#alpha"), nullptr); + EXPECT_NE(node->regions().findByName("#beta"), nullptr); +} + +// ---- the default scope ----------------------------------------------------- + +TEST_F(BootSequenceTest, AStoredDefaultRegionBecomesTheDefaultScope) { + storeState([](BootProbe& n) { + RegionEntry* r = n.regions().putRegion("#probe", 0); + ASSERT_NE(r, nullptr); + n.regions().setDefaultRegion(r); + }); + + auto node = bootFresh(); + + RegionEntry* r = node->regions().findByName("#probe"); + ASSERT_NE(r, nullptr); + ASSERT_EQ(node->regions().getDefaultRegion(), r); + TransportKey expected; + ASSERT_EQ(node->regions().getTransportKeysFor(*r, &expected, 1), 1); + EXPECT_FALSE(node->defaultScope().isNull()); + EXPECT_EQ(memcmp(node->defaultScope().key, expected.key, sizeof(expected.key)), 0); +} + +#ifndef DEFAULT_FLOOD_SCOPE_NAME + +TEST_F(BootSequenceTest, WithoutAStoredDefaultRegionThereIsNoDefaultScope) { + auto node = bootFresh(); + + EXPECT_TRUE(node->defaultScope().isNull()); + EXPECT_EQ(node->regions().getDefaultRegion(), nullptr); + EXPECT_EQ(node->regions().getCount(), 0); +} + +#else // a build that names a default scope + +TEST_F(BootSequenceTest, TheConfiguredDefaultScopeIsCreatedWhenNoneIsStored) { + auto node = bootFresh(); + + RegionEntry* r = node->regions().findByName(DEFAULT_FLOOD_SCOPE_NAME); + ASSERT_NE(r, nullptr) << "created on first boot"; + EXPECT_EQ(r->flags, 0) << "allow flood"; + EXPECT_EQ(r->parent, 0); + EXPECT_EQ(node->regions().getDefaultRegion(), r); + + TransportKey expected; + ASSERT_EQ(node->regions().getTransportKeysFor(*r, &expected, 1), 1); + EXPECT_FALSE(node->defaultScope().isNull()); + EXPECT_EQ(memcmp(node->defaultScope().key, expected.key, sizeof(expected.key)), 0); + EXPECT_EQ(node->regions().getCount(), 1); +} + +TEST_F(BootSequenceTest, TheConfiguredDefaultScopeIsNotWrittenToFlashByBooting) { + bootFresh(); + + EXPECT_FALSE(fs.exists("/regions2")); +} + +TEST_F(BootSequenceTest, AStoredDefaultRegionWinsOverTheConfiguredName) { + storeState([](BootProbe& n) { + // The first boot on an empty file system already created the configured Region, + // so take it out again: what is stored is #probe, and #probe is the default. + RegionEntry* configured = n.regions().findByName(DEFAULT_FLOOD_SCOPE_NAME); + ASSERT_NE(configured, nullptr); + ASSERT_TRUE(n.regions().removeRegion(*configured)); + RegionEntry* r = n.regions().putRegion("#probe", 0); + ASSERT_NE(r, nullptr); + n.regions().setDefaultRegion(r); + }); + + auto node = bootFresh(); + + EXPECT_EQ(node->regions().findByName(DEFAULT_FLOOD_SCOPE_NAME), nullptr) << "not created"; + EXPECT_EQ(node->regions().getDefaultRegion(), node->regions().findByName("#probe")); + EXPECT_EQ(node->regions().getCount(), 1); +} + +TEST_F(BootSequenceTest, AStoredRegionOfTheConfiguredNameIsReusedWithItsFlags) { + storeState([](BootProbe& n) { + // Stored as a Region that denies flood and is not the default. + RegionEntry* r = n.regions().findByName(DEFAULT_FLOOD_SCOPE_NAME); + ASSERT_NE(r, nullptr); + r->flags = REGION_DENY_FLOOD; + n.regions().setDefaultRegion(nullptr); + }); + + auto node = bootFresh(); + + RegionEntry* r = node->regions().findByName(DEFAULT_FLOOD_SCOPE_NAME); + ASSERT_NE(r, nullptr); + EXPECT_EQ(node->regions().getCount(), 1) << "reused, not duplicated"; + EXPECT_EQ(node->regions().getDefaultRegion(), r) << "and made the default again"; + EXPECT_TRUE(r->flags & REGION_DENY_FLOOD) << "only a region created at boot is opened up"; +} + +#endif // DEFAULT_FLOOD_SCOPE_NAME + +// ---- optional blocks ------------------------------------------------------- + +#if ENV_INCLUDE_GPS == 1 + +TEST_F(BootSequenceTest, TheStoredGpsSettingIsPushedToTheSensorManagerLast) { + storeState([](BootProbe& n) { n.prefs().gps_enabled = 1; }); + + bootFresh(); + + ASSERT_EQ(eventCount("sensors.setSettingValue gps=1"), 1); + EXPECT_EQ(eventCount("sensors.setSettingValue gps=0"), 0); + EXPECT_EQ(eventIndex("sensors.setSettingValue gps=1"), (int)g_events.size() - 1) + << "the last thing begin() does"; + EXPECT_GT(eventIndex("sensors.setSettingValue gps=1"), eventIndex("board.setAdcMultiplier")); +} + +TEST_F(BootSequenceTest, AStoredGpsOffIsPushedAsOff) { + storeState([](BootProbe& n) { n.prefs().gps_enabled = 0; }); + + bootFresh(); + + EXPECT_EQ(eventCount("sensors.setSettingValue gps=0"), 1); + EXPECT_EQ(eventCount("sensors.setSettingValue gps=1"), 0); +} + +#else + +TEST_F(BootSequenceTest, WithoutGpsSupportNothingIsPushedToTheSensorManager) { + bootFresh(); + + for (const std::string& e : g_events) { + EXPECT_NE(e.rfind("sensors.", 0), 0u) << e; + } +} + +#endif // ENV_INCLUDE_GPS + +#if defined(BOOT_ROLE_LOGS_RX_GAIN) && MESH_DEBUG + +// Only the repeater says what it did to the radio, and only in a debug build. The +// line reports the mode the radio is in at that moment, so it also shows that the +// message comes after the gain was applied and not before. +TEST_F(BootSequenceTest, TheRepeaterLogsTheGainModeAfterApplyingIt) { + storeState([](BootProbe& n) { n.prefs().rx_boosted_gain = 1; }); + radio_driver.reset(); // a radio that starts with the boost off + Serial.reset(); + + bootFresh(); + + EXPECT_EQ(Serial.out.find("DEBUG: RX Boosted Gain Mode: Enabled\n"), 0u) + << "the first thing the debug build prints, and it reports the applied mode"; + size_t lines = 0; + for (size_t at = Serial.out.find("RX Boosted Gain Mode"); at != std::string::npos; + at = Serial.out.find("RX Boosted Gain Mode", at + 1)) { + lines++; + } + EXPECT_EQ(lines, 1u) << "and only once"; +} + +TEST_F(BootSequenceTest, TheRepeaterLogsADisabledGainModeAsDisabled) { + storeState([](BootProbe& n) { n.prefs().rx_boosted_gain = 0; }); + radio_driver.reset(); + Serial.reset(); + + bootFresh(); + + EXPECT_NE(Serial.out.find("DEBUG: RX Boosted Gain Mode: Disabled\n"), std::string::npos); +} + +#endif // BOOT_ROLE_LOGS_RX_GAIN + +#ifdef WITH_BRIDGE + +TEST_F(BootSequenceTest, TheBridgeStartsAfterTheLoadingAndBeforeTheRadioIsConfigured) { + bootFresh(); + + const int started = eventIndex("bridge.begin"); + ASSERT_GE(started, 0); + EXPECT_EQ(eventCount("bridge.begin"), 1); + EXPECT_GT(started, firstLookupOf("/regions2")); + EXPECT_LT(started, eventIndex("radio.setParams")); +} + +TEST_F(BootSequenceTest, ADisabledBridgeIsNotStarted) { + storeState([](BootProbe& n) { n.prefs().bridge_enabled = 0; }); + + bootFresh(); + + EXPECT_EQ(eventCount("bridge.begin"), 0); + EXPECT_EQ(eventCount("radio.setParams"), 1) << "the rest of the sequence still runs"; +} + +#endif // WITH_BRIDGE + +// ---- settings stored by an older firmware ----------------------------------- +// +// /prefs.json carries a version since the sensor alignment, so that a setting whose +// meaning changed can be brought up to date once, on the first boot of the newer +// firmware, and never again. See docs/refactoring/0006-sensor-flood-limits.md. A +// suite defines BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS if its role is the one +// whose stored zero limits were never applied before version 1. + +class StoredPrefsTest : public BootSequenceTest { +protected: + void putPrefs(const char* text) { + const std::string t(text); + fs.files["/prefs.json"] = std::vector(t.begin(), t.end()); + } + std::string prefsText() { + const auto& bytes = fs.files["/prefs.json"]; + return std::string(bytes.begin(), bytes.end()); + } +}; + +// A file from before version 1 that carries both limits at zero, with forwarding on. +static const char* const UNVERSIONED_ZERO_LIMITS = + "{repeat:{disable:0,f_max:64,f_max_uns:0,f_max_adv:0}}"; + +TEST_F(StoredPrefsTest, AFreshNodeIsAtTheCurrentVersion) { + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().prefs_version, NODE_PREFS_VERSION); + EXPECT_TRUE(fs.files.empty()) << "and booting still writes nothing"; +} + +TEST_F(StoredPrefsTest, AStoredFileGetsTheCurrentVersionWithTheNextSave) { + putPrefs("{repeat:{disable:0,f_max:64,f_max_uns:64,f_max_adv:8}}"); + + auto node = bootFresh(); + EXPECT_EQ(node->prefs().prefs_version, NODE_PREFS_VERSION); + + node->savePrefs(); + EXPECT_NE(prefsText().find("ver:" + std::to_string(NODE_PREFS_VERSION)), std::string::npos) + << prefsText(); +} + +TEST_F(StoredPrefsTest, AnOlderFileThatNeedsNothingIsNotRewrittenAtBoot) { + const char* text = "{repeat:{disable:0,f_max:64,f_max_uns:64,f_max_adv:8}}"; + putPrefs(text); + + bootFresh(); + + EXPECT_EQ(prefsText(), text) << "booting stays read only when nothing is upgraded"; +} + +TEST_F(StoredPrefsTest, TheRoleIsAskedToUpgradeOnlyAnOlderFile) { + putPrefs("{repeat:{f_max:64}}"); + EXPECT_EQ(bootFresh()->upgrades_from, std::vector({0})) << "once, from version 0"; + + putPrefs("{ver:1,repeat:{f_max:64}}"); + EXPECT_TRUE(bootFresh()->upgrades_from.empty()) << "not at the current version"; + + putPrefs("{ver:200,repeat:{f_max:64}}"); + EXPECT_TRUE(bootFresh()->upgrades_from.empty()) << "nor at a newer one"; +} + +TEST_F(StoredPrefsTest, ZeroLimitsStoredAtTheCurrentVersionAreKept) { + // A zero chosen on a firmware that applies the limits is deliberate, on every role. + putPrefs("{ver:1,repeat:{disable:0,f_max:64,f_max_uns:0,f_max_adv:0}}"); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 0); + EXPECT_EQ(node->prefs().flood_max_advert, 0); +} + +TEST_F(StoredPrefsTest, ANewerVersionIsLeftAsItIs) { + // A downgrade onto a file a later firmware wrote: nothing is upgraded, and the + // version is not lowered, so the later firmware does not run its steps twice. + putPrefs("{ver:200,repeat:{disable:0,f_max:64,f_max_uns:0,f_max_adv:0}}"); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().prefs_version, 200); + EXPECT_EQ(node->prefs().flood_max_unscoped, 0); +} + +#ifdef BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS + +TEST_F(StoredPrefsTest, UnversionedZeroHopLimitsAreLiftedToTheRoleDefaults) { + putPrefs(UNVERSIONED_ZERO_LIMITS); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 64); + EXPECT_EQ(node->prefs().flood_max_advert, 8); + EXPECT_EQ(node->prefs().disable_fwd, 0) << "nothing else is touched"; + EXPECT_EQ(node->prefs().flood_max, 64); +} + +TEST_F(StoredPrefsTest, TheLiftIsSavedWithTheVersionSoItRunsOnce) { + putPrefs(UNVERSIONED_ZERO_LIMITS); + + bootFresh(); + + const std::string text = prefsText(); + EXPECT_NE(text.find("ver:1"), std::string::npos) << text; + EXPECT_NE(text.find("f_max_uns:64,"), std::string::npos) << text; + EXPECT_NE(text.find("f_max_adv:8,"), std::string::npos) << text; +} + +TEST_F(StoredPrefsTest, OnlyAZeroIsLifted) { + putPrefs("{repeat:{disable:0,f_max:64,f_max_uns:5,f_max_adv:0}}"); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 5) << "an operator's value is kept"; + EXPECT_EQ(node->prefs().flood_max_advert, 8); +} + +TEST_F(StoredPrefsTest, AZeroSetAfterTheUpgradeSurvivesTheNextBoot) { + putPrefs(UNVERSIONED_ZERO_LIMITS); + { + auto node = bootFresh(); + node->prefs().flood_max_unscoped = 0; // what "set flood.max.unscoped 0" stores + node->savePrefs(); + } + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 0) << "the upgrade does not run a second time"; +} + +#else + +TEST_F(StoredPrefsTest, UnversionedZeroHopLimitsAreKept) { + // This role has always applied the limits, so a stored zero was a choice. + putPrefs(UNVERSIONED_ZERO_LIMITS); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 0); + EXPECT_EQ(node->prefs().flood_max_advert, 0); + EXPECT_EQ(prefsText(), UNVERSIONED_ZERO_LIMITS) << "and the file is not rewritten"; +} + +#endif // BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS + +} // namespace boot_checks diff --git a/test/mocks/CayenneLPP.h b/test/mocks/CayenneLPP.h index 9d51c0fcfcc..7f44d6df524 100644 --- a/test/mocks/CayenneLPP.h +++ b/test/mocks/CayenneLPP.h @@ -3,9 +3,40 @@ #include #include +#define LPP_DIGITAL_INPUT 0 +#define LPP_DIGITAL_OUTPUT 1 +#define LPP_ANALOG_INPUT 2 +#define LPP_ANALOG_OUTPUT 3 +#define LPP_GENERIC_SENSOR 100 +#define LPP_LUMINOSITY 101 +#define LPP_PRESENCE 102 +#define LPP_TEMPERATURE 103 +#define LPP_RELATIVE_HUMIDITY 104 +#define LPP_ACCELEROMETER 113 +#define LPP_BAROMETRIC_PRESSURE 115 +#define LPP_VOLTAGE 116 +#define LPP_CURRENT 117 +#define LPP_FREQUENCY 118 +#define LPP_PERCENTAGE 120 +#define LPP_ALTITUDE 121 +#define LPP_POWER 128 +#define LPP_DISTANCE 130 +#define LPP_ENERGY 131 +#define LPP_DIRECTION 132 +#define LPP_GYROMETER 134 +#define LPP_GPS 136 + class CayenneLPP { public: explicit CayenneLPP(size_t) {} const uint8_t* getBuffer() const { return nullptr; } uint16_t getSize() const { return 0; } + + // The roles build a telemetry frame from these; the tests here do not inspect + // the encoding, so recording the call count is enough. + void reset() { adds = 0; } + uint8_t addVoltage(uint8_t channel, float value) { adds++; return 0; } + uint8_t addTemperature(uint8_t channel, float value) { adds++; return 0; } + + int adds = 0; }; diff --git a/test/mocks/EventLog.h b/test/mocks/EventLog.h new file mode 100644 index 00000000000..de023f81182 --- /dev/null +++ b/test/mocks/EventLog.h @@ -0,0 +1,31 @@ +#pragma once + +// One ordered record of the calls a role makes on its collaborators while it boots: +// the file system lookups, the radio and board set-up, the sensor manager and (in the +// repeater with a bridge) the bridge. Each mock appends a short tag when it is +// called, so a test can say which call came before which, which no single mock can +// tell on its own. A test clears the log before the step it wants to watch. + +#include +#include + +inline std::vector g_events; + +inline void logEvent(const std::string& what) { g_events.push_back(what); } + +// Position of the first event that equals `what`, or -1 if it never happened. +inline int eventIndex(const std::string& what) { + for (size_t i = 0; i < g_events.size(); i++) { + if (g_events[i] == what) return (int)i; + } + return -1; +} + +// How many times `what` was logged. +inline int eventCount(const std::string& what) { + int n = 0; + for (const std::string& e : g_events) { + if (e == what) n++; + } + return n; +} diff --git a/test/mocks/FS.h b/test/mocks/FS.h new file mode 100644 index 00000000000..e919724a3c4 --- /dev/null +++ b/test/mocks/FS.h @@ -0,0 +1,179 @@ +#pragma once + +// An in-memory stand-in for a variant's file system, so the server roles and the +// helpers that persist their state (CommonCLI, ClientACL, RegionMap, IdentityStore) +// run in the native env. It implements the calls the firmware actually makes, in +// the forms the native build reaches: the platform guards for NRF52, STM32 and +// RP2040 are not set there, so the "w"/"a"/"r" modes with the optional create flag +// are the ones that matter. +// +// Files live in one map that survives a close, so a test can write, reopen and read +// back, and reboot a role by constructing it again against the same instance. + +#include +#include +#include +#include +#include +#include +#include + +#include "EventLog.h" +#include "Stream.h" + +#ifndef FILE_O_WRITE + #define FILE_O_WRITE 1 +#endif + +namespace fs { class FS; } +using MockFS = fs::FS; + +class File : public Stream { + fs::FS* _fs = nullptr; + std::string _name; + size_t _pos = 0; + bool _valid = false; + bool _writable = false; + + std::vector* data() const; + +public: + File() { } + File(fs::FS* fsp, const std::string& name, bool writable) + : _fs(fsp), _name(name), _pos(0), _valid(true), _writable(writable) { } + + operator bool() const { return _valid; } + + // Stream + int available() override; + int read() override; + int peek() override { return -1; } + + // Print + size_t write(uint8_t b) override { return write(&b, (size_t)1); } + size_t write(const uint8_t* src, size_t len) override; + + // The cores accept any buffer type here, and the firmware relies on that. + int read(void* dest, size_t len); + size_t write(const void* src, size_t len) { + return write(static_cast(src), len); + } + + void close() { _valid = false; } + + // Directory listing, reached only by the companion's rescue CLI. Directories are + // not modelled (see FS::mkdir), so a listing is always empty. + File openNextFile() { return File(); } + bool isDirectory() const { return false; } + const char* name() const { return _name.c_str(); } + size_t size() const; + + // Test helper: the position reached so far. + size_t position() const { return _pos; } +}; + +// The RP2040 core's LittleFS reports its usage through this, and the companion's +// data store reads it for CMD_GET_BATT_AND_STORAGE. +struct FSInfo { + size_t totalBytes = 0; + size_t usedBytes = 0; +}; + +namespace fs { + +class FS { +public: + std::map> files; + + File open(const char* name) { return open(name, "r"); } + + File open(const char* name, const char* mode, bool create = false) { + logEvent(std::string("open:") + name); + std::string n(name); + bool writing = (mode[0] == 'w' || mode[0] == 'a'); + if (!writing) { + if (files.find(n) == files.end()) return File(); // missing: a falsy File + return File(this, n, false); + } + if (mode[0] == 'w') files[n].clear(); // truncate + else files[n]; // append: create if absent + File f(this, n, true); + if (mode[0] == 'a') f = File(this, n, true); + return f; + } + + // The NRF52 and STM32 branches reach this form. + File open(const char* name, int flags) { + return open(name, flags == FILE_O_WRITE ? "a" : "r", true); + } + + // Directories are not modelled: the firmware only ever creates one for the + // identity store, and a flat map needs no directory to hold "/dir/file". + bool mkdir(const char* path) { return true; } + + bool exists(const char* name) { + logEvent(std::string("exists:") + name); + return files.find(std::string(name)) != files.end(); + } + bool remove(const char* name) { return files.erase(std::string(name)) > 0; } + bool format() { files.clear(); return true; } + + // A fixed capacity, and the bytes the files hold as the usage, so a reply that + // carries them is repeatable. + size_t total_bytes = 512 * 1024; + bool info(FSInfo& out) { + out.totalBytes = total_bytes; + out.usedBytes = 0; + for (auto& f : files) out.usedBytes += f.second.size(); + return true; + } + + // Test helpers. + void reset() { files.clear(); } + size_t sizeOf(const char* name) { + auto it = files.find(std::string(name)); + return it == files.end() ? 0 : it->second.size(); + } +}; + +} // namespace fs + +inline std::vector* File::data() const { + if (!_fs || !_valid) return nullptr; + auto it = _fs->files.find(_name); + return it == _fs->files.end() ? nullptr : &it->second; +} + +inline size_t File::size() const { + auto* d = data(); + return d ? d->size() : 0; +} + +inline int File::read(void* dest, size_t len) { + auto* d = data(); + if (!d) return 0; + size_t n = d->size() > _pos ? d->size() - _pos : 0; + if (n > len) n = len; + if (n) memcpy(dest, d->data() + _pos, n); + _pos += n; + return (int)n; +} + +inline int File::read() { + uint8_t b; + return read(&b, (size_t)1) == 1 ? (int)b : -1; +} + +inline size_t File::write(const uint8_t* src, size_t len) { + auto* d = data(); + if (!d || !_writable) return 0; + d->insert(d->end(), src, src + len); + _pos = d->size(); + return len; +} + +inline int File::available() { + auto* d = data(); + if (!d) return 0; + return (int)(d->size() > _pos ? d->size() - _pos : 0); +} diff --git a/test/mocks/ForwardPolicyChecks.h b/test/mocks/ForwardPolicyChecks.h new file mode 100644 index 00000000000..dfead19463b --- /dev/null +++ b/test/mocks/ForwardPolicyChecks.h @@ -0,0 +1,181 @@ +#pragma once + +// Which packets a server role passes on, the decision allowPacketForward() makes. +// Included by the suite of each role (repeater, room server, sensor) right after +// BootSequenceChecks.h, so it uses the role the suite named as `BootRole` and the +// fixture defined there, and the same expectations run against all three. +// +// The room server and the sensor check two things, forwarding switched off and the +// flood hop limits. The repeater checks the same two first and then two of its own, +// the Region a flood arrived on and loop detection. A suite defines +// FORWARD_ROLE_CHECKS_REGION_AND_LOOPS if its role does the latter, and +// FORWARD_ROLE_FORWARDS_BY_DEFAULT if a fresh node of the role forwards. + +#include + +namespace boot_checks { + +class ForwardProbe : public BootProbe { +public: + using BootProbe::BootProbe; + + bool forwards(const mesh::Packet* pkt) { return allowPacketForward(pkt); } + void arrivedOn(RegionEntry* r) { recv_pkt_region = r; } +}; + +class ForwardPolicyTest : public BootSequenceTest { +protected: + std::unique_ptr node; + RegionEntry* region = nullptr; + + void SetUp() override { + BootSequenceTest::SetUp(); + node.reset(new ForwardProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + node->begin(&fs); + // A node that forwards, with limits that stop nothing in the tests below and a + // Region the packet arrived on, so each test changes the one thing it is about. + node->prefs().disable_fwd = 0; + node->prefs().flood_max = 64; + node->prefs().flood_max_unscoped = 64; + node->prefs().flood_max_advert = 64; + node->prefs().loop_detect = LOOP_DETECT_OFF; + region = node->regions().putRegion("#fwd", 0); + ASSERT_NE(region, nullptr); + node->arrivedOn(region); + } + + static mesh::Packet packet(uint8_t route_type, uint8_t hops, uint8_t payload_type = 0) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = route_type | (payload_type << PH_TYPE_SHIFT); + pkt.path_len = hops; // one byte per hash at the default path hash size + return pkt; + } +}; + +TEST_F(ForwardPolicyTest, AFreshNodeForwardsOnlyIfTheRoleDoesByDefault) { + auto fresh = std::unique_ptr(new ForwardProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + fresh->begin(&fs); + fresh->arrivedOn(region); + mesh::Packet pkt = packet(ROUTE_TYPE_FLOOD, 0); +#ifdef FORWARD_ROLE_FORWARDS_BY_DEFAULT + EXPECT_TRUE(fresh->forwards(&pkt)); +#else + EXPECT_FALSE(fresh->forwards(&pkt)) << "forwarding is off until an operator switches it on"; +#endif +} + +TEST_F(ForwardPolicyTest, ForwardingOffRefusesEveryRoute) { + node->prefs().disable_fwd = 1; + for (uint8_t route : {ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_FLOOD, ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT}) { + mesh::Packet pkt = packet(route, 0); + EXPECT_FALSE(node->forwards(&pkt)) << "route type " << int(route); + } +} + +TEST_F(ForwardPolicyTest, AFreshFloodOfEitherKindIsForwarded) { + mesh::Packet unscoped = packet(ROUTE_TYPE_FLOOD, 0); + mesh::Packet scoped = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 0); + EXPECT_TRUE(node->forwards(&unscoped)); + EXPECT_TRUE(node->forwards(&scoped)); +} + +TEST_F(ForwardPolicyTest, FloodMaxStopsBothKindsOfFloodAtTheLimit) { + node->prefs().flood_max = 3; + mesh::Packet under = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 2); + mesh::Packet at_unscoped = packet(ROUTE_TYPE_FLOOD, 3); + mesh::Packet at_scoped = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 3); + EXPECT_TRUE(node->forwards(&under)); + EXPECT_FALSE(node->forwards(&at_unscoped)) << "the limit is compared with >="; + EXPECT_FALSE(node->forwards(&at_scoped)); +} + +TEST_F(ForwardPolicyTest, TheUnscopedLimitStopsOnlyUnscopedFloods) { + node->prefs().flood_max_unscoped = 2; + mesh::Packet unscoped = packet(ROUTE_TYPE_FLOOD, 2); + mesh::Packet scoped = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 2); + EXPECT_FALSE(node->forwards(&unscoped)); + EXPECT_TRUE(node->forwards(&scoped)); +} + +TEST_F(ForwardPolicyTest, TheAdvertLimitStopsOnlyAdverts) { + node->prefs().flood_max_advert = 2; + mesh::Packet advert = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 2, PAYLOAD_TYPE_ADVERT); + mesh::Packet other = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 2); + EXPECT_FALSE(node->forwards(&advert)); + EXPECT_TRUE(node->forwards(&other)); +} + +TEST_F(ForwardPolicyTest, HopLimitsDoNotApplyToDirectRoutes) { + node->prefs().flood_max = 0; + node->prefs().flood_max_unscoped = 0; + node->prefs().flood_max_advert = 0; + mesh::Packet direct = packet(ROUTE_TYPE_DIRECT, 5, PAYLOAD_TYPE_ADVERT); + mesh::Packet transport_direct = packet(ROUTE_TYPE_TRANSPORT_DIRECT, 5); + EXPECT_TRUE(node->forwards(&direct)); + EXPECT_TRUE(node->forwards(&transport_direct)); +} + +#ifdef FORWARD_ROLE_CHECKS_REGION_AND_LOOPS + +TEST_F(ForwardPolicyTest, AFloodWithoutARegionIsRefused) { + node->arrivedOn(nullptr); + mesh::Packet flood = packet(ROUTE_TYPE_FLOOD, 0); + mesh::Packet direct = packet(ROUTE_TYPE_DIRECT, 0); + EXPECT_FALSE(node->forwards(&flood)); + EXPECT_TRUE(node->forwards(&direct)) << "a direct route needs no Region"; +} + +TEST_F(ForwardPolicyTest, LoopDetectionCountsThisNodeInThePath) { + // Minimal loop detection refuses a flood whose path already holds this node's + // one byte hash four times. + mesh::Packet pkt = packet(ROUTE_TYPE_FLOOD, 4); + memset(pkt.path, node->identity().pub_key[0], 4); + + EXPECT_TRUE(node->forwards(&pkt)) << "off by default"; + node->prefs().loop_detect = LOOP_DETECT_MINIMAL; + EXPECT_FALSE(node->forwards(&pkt)); + + pkt.path[0] ^= 0xFF; // three times only + EXPECT_TRUE(node->forwards(&pkt)); +} + +#ifdef MESH_DEBUG + +TEST_F(ForwardPolicyTest, TheSharedChecksComeBeforeTheRegionCheck) { + // Only the debug log shows which check refused a packet. With forwarding off, the + // Region check must not run at all. + node->arrivedOn(nullptr); + mesh::Packet flood = packet(ROUTE_TYPE_FLOOD, 0); + const char* region_line = "allowPacketForward: unknown transport code"; + + Serial.out.clear(); + EXPECT_FALSE(node->forwards(&flood)); + EXPECT_NE(Serial.out.find(region_line), std::string::npos) << "the control: forwarding on"; + + node->prefs().disable_fwd = 1; + Serial.out.clear(); + EXPECT_FALSE(node->forwards(&flood)); + EXPECT_EQ(Serial.out.find(region_line), std::string::npos) << Serial.out; +} + +#endif // MESH_DEBUG + +#else + +TEST_F(ForwardPolicyTest, ARegionIsNotNeededToForwardAFlood) { + node->arrivedOn(nullptr); + mesh::Packet flood = packet(ROUTE_TYPE_FLOOD, 0); + EXPECT_TRUE(node->forwards(&flood)) << "only the repeater refuses a flood without one"; +} + +TEST_F(ForwardPolicyTest, LoopDetectionIsNotApplied) { + node->prefs().loop_detect = LOOP_DETECT_STRICT; + mesh::Packet pkt = packet(ROUTE_TYPE_FLOOD, 4); + memset(pkt.path, node->identity().pub_key[0], 4); + EXPECT_TRUE(node->forwards(&pkt)); +} + +#endif // FORWARD_ROLE_CHECKS_REGION_AND_LOOPS + +} // namespace boot_checks diff --git a/test/mocks/Identity.h b/test/mocks/Identity.h index 5c3d25e94e2..7a793cd233b 100644 --- a/test/mocks/Identity.h +++ b/test/mocks/Identity.h @@ -2,6 +2,7 @@ #include #include +#include "Stream.h" #include "Utils.h" namespace mesh { @@ -21,11 +22,25 @@ class Identity { bool verify(const uint8_t*, const uint8_t*, int) const { return true; } + + // Copies of the real serialisation in src/Identity.cpp: a short read or a + // short write is a failure, which is what the file based tests rely on. + bool readFrom(Stream& s) { + return s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + } + + bool writeTo(Stream& s) const { + return s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + } }; class LocalIdentity : public Identity { public: - LocalIdentity() : Identity() {} + uint8_t prv_key[PRV_KEY_SIZE]; + + LocalIdentity() : Identity() { + std::memset(prv_key, 0, sizeof(prv_key)); + } void sign(uint8_t* sig, const uint8_t*, int) const { std::memset(sig, 0x5A, SIGNATURE_SIZE); @@ -34,6 +49,33 @@ class LocalIdentity : public Identity { void calcSharedSecret(uint8_t* secret, const uint8_t*) const { std::memset(secret, 0x11, PUB_KEY_SIZE); } + + bool readFrom(Stream& s) { + bool success = s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + success = success && s.readBytes(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE; + return success; + } + + bool writeTo(Stream& s) const { + bool success = s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + success = success && s.write(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE; + return success; + } + + // Copy of the buffer serialisation in src/Identity.cpp, private key first. + // IdentityStore compares two identities with it, which keeps it off prv_key, + // private in the real class. + size_t writeTo(uint8_t* dest, size_t max_len) const { + if (max_len < PRV_KEY_SIZE) return 0; // not big enough + + if (max_len < PRV_KEY_SIZE + PUB_KEY_SIZE) { // only room for prv_key + std::memcpy(dest, prv_key, PRV_KEY_SIZE); + return PRV_KEY_SIZE; + } + std::memcpy(dest, prv_key, PRV_KEY_SIZE); + std::memcpy(&dest[PRV_KEY_SIZE], pub_key, PUB_KEY_SIZE); + return PRV_KEY_SIZE + PUB_KEY_SIZE; + } }; } diff --git a/test/mocks/LittleFS.h b/test/mocks/LittleFS.h new file mode 100644 index 00000000000..f8f07ee3032 --- /dev/null +++ b/test/mocks/LittleFS.h @@ -0,0 +1,9 @@ +#pragma once + +// Stand-in for the RP2040 LittleFS global, which the roles call to erase the file +// system. It is the same in-memory instance a test hands to the role, so a format +// through this global is visible to the test. + +#include "FS.h" + +inline fs::FS LittleFS; diff --git a/test/mocks/PrefsStreams.h b/test/mocks/PrefsStreams.h new file mode 100644 index 00000000000..d0bcd50924e --- /dev/null +++ b/test/mocks/PrefsStreams.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#include "Stream.h" + +// Feeds a fixed text to ConfigSerializer::loadSerial(). +class ReplayStream : public Stream { + const char* _text; + int _pos = 0; + int _len; + +public: + explicit ReplayStream(const char* text) : _text(text), _len(strlen(text)) { } + + int available() override { return _len - _pos; } + int read() override { return _pos < _len ? _text[_pos++] : -1; } + int peek() override { return _pos < _len ? _text[_pos] : -1; } +}; + +// Captures what ConfigSerializer::saveSerial() writes. Numbers are printed the +// way Arduino's Print does it (floats with a fixed number of decimals). +class CaptureStream : public Stream { + std::string _text; + + size_t emit(long long value) { + char text[24]; + int length = snprintf(text, sizeof(text), "%lld", value); + return write(reinterpret_cast(text), length); + } + +public: + size_t write(uint8_t value) override { + _text.push_back(static_cast(value)); + return 1; + } + + size_t write(const uint8_t* buffer, size_t size) override { + _text.append(reinterpret_cast(buffer), size); + return size; + } + + size_t print(unsigned char value, int = DEC) override { return emit(value); } + size_t print(int value, int = DEC) override { return emit(value); } + size_t print(unsigned int value, int = DEC) override { return emit(value); } + size_t print(long value, int = DEC) override { return emit(value); } + size_t print(unsigned long value, int = DEC) override { return emit(value); } + size_t print(long long value, int = DEC) override { return emit(value); } + size_t print(unsigned long long value, int = DEC) override { return emit(value); } + size_t print(double value, int digits = 2) override { + char text[48]; + int length = snprintf(text, sizeof(text), "%.*f", digits, value); + return write(reinterpret_cast(text), length); + } + using Print::print; + + const std::string& text() const { return _text; } +}; diff --git a/test/mocks/RTClib.h b/test/mocks/RTClib.h new file mode 100644 index 00000000000..d592ac95953 --- /dev/null +++ b/test/mocks/RTClib.h @@ -0,0 +1,51 @@ +#pragma once + +// Stand-in for RTClib's DateTime, so the roles and CommonCLI build in the native +// env. It follows RTClib: the uint32_t constructor takes seconds since +// 2000-01-01T00:00:00. Only the accessors the firmware uses are implemented, and +// no test characterizes date formatting through this mock. + +#include + +class DateTime { + uint16_t _year = 2000; + uint8_t _month = 1, _day = 1, _hour = 0, _minute = 0, _second = 0; + +public: + DateTime() { } + + explicit DateTime(uint32_t secs_since_2000) { + uint32_t days = secs_since_2000 / 86400UL; + uint32_t rem = secs_since_2000 % 86400UL; + _hour = (uint8_t)(rem / 3600); + _minute = (uint8_t)((rem % 3600) / 60); + _second = (uint8_t)(rem % 60); + + uint16_t y = 2000; + for (;;) { + bool leap = (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)); + uint32_t len = leap ? 366 : 365; + if (days < len) break; + days -= len; + y++; + } + _year = y; + bool leap = (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)); + static const uint8_t lengths[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + uint8_t m = 0; + for (; m < 12; m++) { + uint32_t len = lengths[m] + ((m == 1 && leap) ? 1 : 0); + if (days < len) break; + days -= len; + } + _month = (uint8_t)(m + 1); + _day = (uint8_t)(days + 1); + } + + uint16_t year() const { return _year; } + uint8_t month() const { return _month; } + uint8_t day() const { return _day; } + uint8_t hour() const { return _hour; } + uint8_t minute() const { return _minute; } + uint8_t second() const { return _second; } +}; diff --git a/test/mocks/RadioLib.h b/test/mocks/RadioLib.h new file mode 100644 index 00000000000..68e9bd5c5dd --- /dev/null +++ b/test/mocks/RadioLib.h @@ -0,0 +1,48 @@ +#pragma once + +// Minimal stand-in for RadioLib's PhysicalLayer, so the real RadioLibWrapper +// can be built and exercised on the host. Only the members the wrapper calls +// are present; each one records or returns test-visible state instead of +// touching hardware. Same idea as the Arduino and Mesh mocks next to it. + +// The real RadioLib.h pulls in the Arduino core, and the wrapper relies on +// that for millis(), min(), max() and random(). Mirror it here. +#include "Arduino.h" + +#include +#include + +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_CHANNEL_FREE (-2) + +class PhysicalLayer { +public: + virtual ~PhysicalLayer() = default; + + // State a test can set up or inspect. + float rssi = -100.0f; + float snr = 0.0f; + uint32_t time_on_air = 1000; + size_t packet_length = 0; + int16_t scan_result = RADIOLIB_CHANNEL_FREE; + size_t preamble_length = 0; + int8_t output_power = 0; + uint8_t random_byte = 0x5A; // what randomByte() returns; set per radio to tell two apart + + int16_t sleep() { return RADIOLIB_ERR_NONE; } + int16_t standby() { return RADIOLIB_ERR_NONE; } + int16_t startReceive() { return RADIOLIB_ERR_NONE; } + int16_t startTransmit(uint8_t*, size_t) { return RADIOLIB_ERR_NONE; } + int16_t finishTransmit() { return RADIOLIB_ERR_NONE; } + size_t getPacketLength() { return packet_length; } + int16_t readData(uint8_t*, size_t) { return RADIOLIB_ERR_NONE; } + float getRSSI() { return rssi; } + float getSNR() { return snr; } + uint32_t getTimeOnAir(size_t) { return time_on_air; } + int16_t scanChannel() { return scan_result; } + int16_t setOutputPower(int8_t dbm) { output_power = dbm; return RADIOLIB_ERR_NONE; } + void setPacketReceivedAction(void (*)(void)) { } + int16_t setPreambleLength(size_t len) { preamble_length = len; return RADIOLIB_ERR_NONE; } + uint8_t randomByte() { return random_byte; } + int32_t random(int32_t upper) { return upper / 2; } +}; diff --git a/test/mocks/RadioPrefsChecks.h b/test/mocks/RadioPrefsChecks.h new file mode 100644 index 00000000000..65dea6d46be --- /dev/null +++ b/test/mocks/RadioPrefsChecks.h @@ -0,0 +1,365 @@ +#pragma once + +// Shared expectations for the radio and GPS settings of a NodePrefs class. +// The server roles (repeater, room server, sensor) and the companion radio each +// have their own NodePrefs; both must expose the radio/GPS settings the same +// way, so the same checks run against both. + +#include +#include +#include +#include + +#include "target.h" + +// Distinct, non-default values for every radio and GPS field. +template +void fillRadioAndGps(P& p) { + p.freq = 869.618f; + p.bw = 62.5f; + p.sf = 8; + p.cr = 6; + p.cad_enabled = 1; + p.interference_threshold = 14; + p.rx_boosted_gain = 1; + p.radio_fem_rxgain = 1; + p.radio_fem_txgain = 2; + p.tx_power_dbm = 21; + p.airtime_factor = 1.5f; + p.rx_delay_base = 3.25f; + p.tx_delay_factor = 0.75f; + p.direct_tx_delay_factor = 0.25f; + p.agc_reset_interval = 5; + p.path_hash_mode = 2; + p.multi_acks = 1; + + p.gps_enabled = 1; + p.gps_interval = 900; + p.advert_loc_policy = 1; +} + +template +void expectRadioAndGpsEqual(const P& expected, const P& actual) { + EXPECT_FLOAT_EQ(expected.freq, actual.freq); + EXPECT_FLOAT_EQ(expected.bw, actual.bw); + EXPECT_EQ(expected.sf, actual.sf); + EXPECT_EQ(expected.cr, actual.cr); + EXPECT_EQ(expected.cad_enabled, actual.cad_enabled); + EXPECT_EQ(expected.interference_threshold, actual.interference_threshold); + EXPECT_EQ(expected.rx_boosted_gain, actual.rx_boosted_gain); + EXPECT_EQ(expected.radio_fem_rxgain, actual.radio_fem_rxgain); + EXPECT_EQ(expected.radio_fem_txgain, actual.radio_fem_txgain); + EXPECT_EQ(expected.tx_power_dbm, actual.tx_power_dbm); + EXPECT_FLOAT_EQ(expected.airtime_factor, actual.airtime_factor); + EXPECT_FLOAT_EQ(expected.rx_delay_base, actual.rx_delay_base); + EXPECT_FLOAT_EQ(expected.tx_delay_factor, actual.tx_delay_factor); + EXPECT_FLOAT_EQ(expected.direct_tx_delay_factor, actual.direct_tx_delay_factor); + EXPECT_EQ(expected.agc_reset_interval, actual.agc_reset_interval); + EXPECT_EQ(expected.path_hash_mode, actual.path_hash_mode); + EXPECT_EQ(expected.multi_acks, actual.multi_acks); + + EXPECT_EQ(expected.gps_enabled, actual.gps_enabled); + EXPECT_EQ(expected.gps_interval, actual.gps_interval); + EXPECT_EQ(expected.advert_loc_policy, actual.advert_loc_policy); +} + +// Runs one CLI command through the prefs' radio handler and returns the reply. +template +std::string radioCli(P& p, const char* command) { + char reply[160]; + reply[0] = 0; + EXPECT_TRUE(p.getRadioPrefs()->handleCommand(command, 0, reply)) << command; + return reply; +} + +// The radio CLI must read and write the node's own fields, mark the prefs +// dirty, and push TX power / RX gain to the radio driver. +template +void expectRadioCliDrivesNodeFields(P& p) { + radio_driver.reset(); + fillRadioAndGps(p); + p.clearDirty(); + + EXPECT_EQ("> 869.6179809,62.5,8,6", radioCli(p, "get radio")); + EXPECT_EQ("> 869.6179809", radioCli(p, "get freq")); + EXPECT_EQ("> 1.5", radioCli(p, "get af")); + EXPECT_EQ("> 40.0%", radioCli(p, "get dutycycle")); + EXPECT_EQ("> 14", radioCli(p, "get int.thresh")); + EXPECT_EQ("> on", radioCli(p, "get cad")); + EXPECT_EQ("> on", radioCli(p, "get radio.rxgain")); + EXPECT_EQ("> 21", radioCli(p, "get tx")); + EXPECT_EQ("> 3.25", radioCli(p, "get rxdelay")); + EXPECT_EQ("> 20", radioCli(p, "get agc.reset.interval")); + EXPECT_EQ("> 2", radioCli(p, "get path.hash.mode")); + EXPECT_EQ("> 1", radioCli(p, "get multi.acks")); + EXPECT_EQ("> 0.75", radioCli(p, "get txdelay")); + EXPECT_EQ("> 0.25", radioCli(p, "get direct.txdelay")); + EXPECT_FALSE(p.isDirty()); + + // A command that changes a setting must mark the prefs dirty (otherwise the + // change is never saved); a rejected command must leave them clean. + auto accepted = [&p](const char* command) { + p.clearDirty(); + std::string reply = radioCli(p, command); + EXPECT_TRUE(p.isDirty()) << command; + return reply; + }; + auto rejected = [&p](const char* command) { + p.clearDirty(); + std::string reply = radioCli(p, command); + EXPECT_FALSE(p.isDirty()) << command; + return reply; + }; + + EXPECT_EQ("OK - reboot to apply", accepted("set radio 433.5,125,9,5")); + EXPECT_FLOAT_EQ(433.5f, p.freq); + EXPECT_FLOAT_EQ(125.0f, p.bw); + EXPECT_EQ(9, p.sf); + EXPECT_EQ(5, p.cr); + + EXPECT_EQ("Error, invalid radio params", rejected("set radio 433.5,6,9,5")); + EXPECT_FLOAT_EQ(125.0f, p.bw); + + EXPECT_EQ("OK", accepted("set af 2")); + EXPECT_FLOAT_EQ(2.0f, p.airtime_factor); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af 10")); + EXPECT_FLOAT_EQ(2.0f, p.airtime_factor); + EXPECT_EQ("OK - 25.0%", accepted("set dutycycle 25")); + EXPECT_FLOAT_EQ(3.0f, p.airtime_factor); + EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle 0")); + + EXPECT_EQ("OK", accepted("set int.thresh 9")); + EXPECT_EQ(9, p.interference_threshold); + EXPECT_EQ("OK", accepted("set cad off")); + EXPECT_EQ(0, p.cad_enabled); + + EXPECT_EQ("OK", accepted("set radio.rxgain off")); + EXPECT_EQ(0, p.rx_boosted_gain); + EXPECT_EQ(1, radio_driver.rx_boost_calls); + EXPECT_FALSE(radio_driver.rx_boosted); + // The setting is stored even when the radio cannot apply it. + radio_driver.rx_boost_supported = false; + EXPECT_EQ("Error: unsupported", accepted("set radio.rxgain on")); + EXPECT_EQ(1, p.rx_boosted_gain); + + EXPECT_EQ("OK", accepted("set tx 17")); + EXPECT_EQ(17, p.tx_power_dbm); + EXPECT_EQ(1, radio_driver.tx_power_calls); + EXPECT_EQ(17, radio_driver.tx_power); + + EXPECT_EQ("OK", accepted("set rxdelay 4.5")); + EXPECT_FLOAT_EQ(4.5f, p.rx_delay_base); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay 21")); + + EXPECT_EQ("OK - interval rounded to 36", accepted("set agc.reset.interval 38")); + EXPECT_EQ(9, p.agc_reset_interval); + + EXPECT_EQ("OK", accepted("set path.hash.mode 1")); + EXPECT_EQ(1, p.path_hash_mode); + EXPECT_EQ("Error, must be 0,1, or 2", rejected("set path.hash.mode 3")); + + EXPECT_EQ("OK", accepted("set multi.acks 0")); + EXPECT_EQ(0, p.multi_acks); + + EXPECT_EQ("OK", accepted("set txdelay 1.5")); + EXPECT_FLOAT_EQ(1.5f, p.tx_delay_factor); + EXPECT_EQ("Error, must be 0-2", rejected("set txdelay 3")); + EXPECT_EQ("OK", accepted("set direct.txdelay 0.5")); + EXPECT_FLOAT_EQ(0.5f, p.direct_tx_delay_factor); + EXPECT_EQ("Error, must be 0-2", rejected("set direct.txdelay 3")); + + char reply[16]; + EXPECT_FALSE(p.getRadioPrefs()->handleCommand("get name", 0, reply)); +} + +// Board code reaches the FEM gains only through the "custom" key/value store, +// which falls back to the radio settings. +template +void expectCustomStoreReachesFemGains(P& p) { + fillRadioAndGps(p); + p.clearDirty(); + + char value[8]; + ASSERT_TRUE(p.getCustom()->getByKey("fem_rxgain", value, sizeof(value))); + EXPECT_STREQ("1", value); + ASSERT_TRUE(p.getCustom()->getByKey("fem_txgain", value, sizeof(value))); + EXPECT_STREQ("2", value); + + ASSERT_TRUE(p.getCustom()->setByKey("fem_rxgain", "0")); + EXPECT_EQ(0, p.radio_fem_rxgain); + EXPECT_TRUE(p.isDirty()); +} + +// NaN test that does not rely on float comparisons, which -ffast-math may assume +// never see a NaN (nRF52 builds use -Ofast). +inline bool isNanBits(float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7F800000) == 0x7F800000 && (bits & 0x007FFFFF) != 0; +} + +// Edge cases of the radio CLI: boundaries, out of range values, values that do not +// fit the field, garbage, "nan", prefix look-alikes. The expected replies and field +// values are what the code returned before the CLI became table driven; some of them +// are odd (noted inline) and are kept on purpose, because changing them would change +// what users see. +template +void expectRadioCliEdgeCases(P& p) { + radio_driver.reset(); + fillRadioAndGps(p); + + auto accepted = [&p](const char* command) { + p.clearDirty(); + std::string reply = radioCli(p, command); + EXPECT_TRUE(p.isDirty()) << command; + return reply; + }; + auto rejected = [&p](const char* command) { + p.clearDirty(); + std::string reply = radioCli(p, command); + EXPECT_FALSE(p.isDirty()) << command; + return reply; + }; + auto unhandled = [&p](const char* command) { + char reply[160]; + reply[0] = 0; + EXPECT_FALSE(p.getRadioPrefs()->handleCommand(command, 0, reply)) << command; + }; + + // af: strtof with an end check, range 0..9 + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af abc")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af -1")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af 9.01")); + EXPECT_FLOAT_EQ(1.5f, p.airtime_factor); + EXPECT_EQ("OK", accepted("set af 9")); + EXPECT_FLOAT_EQ(9.0f, p.airtime_factor); + EXPECT_EQ("OK", accepted("set af 0")); + EXPECT_FLOAT_EQ(0.0f, p.airtime_factor); + // "set af nan" used to be accepted, and the result depended on the compiler: + // no ordered comparison is true for a NaN, and -ffast-math (nRF52 builds use + // -Ofast) may additionally assume that NaN never occurs. inRange() reads the + // text, so the answer is now the same on every build and needs no guard here. + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af nan")); + EXPECT_FALSE(isNanBits(p.airtime_factor)); + EXPECT_FLOAT_EQ(0.0f, p.airtime_factor); + + // dutycycle: derived from af, range 1..100 + EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle 0.5")); + EXPECT_EQ("OK - 100.0%", accepted("set dutycycle 100")); + EXPECT_FLOAT_EQ(0.0f, p.airtime_factor); + EXPECT_EQ("> 100.0%", radioCli(p, "get dutycycle")); + EXPECT_EQ("OK - 1.0%", accepted("set dutycycle 1")); + EXPECT_FLOAT_EQ(99.0f, p.airtime_factor); // odd: beyond the 0..9 of "set af" + EXPECT_EQ("OK - 33.0%", accepted("set dutycycle 33")); + + // Neither command takes a value that is not a finite number. inRange() + // decides that on the text, so the answer does not depend on -ffast-math and + // the cases need no guard. "set dutycycle" writes airtime_factor through a + // division and had the same hole as "set af". + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af -nan")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af inf")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af 1e40")); + EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle nan")); + EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle inf")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af +nan")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af infinity")); + EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle nan")); + EXPECT_FALSE(isNanBits(p.airtime_factor)); + + // int.thresh: atoi, no range, stored as uint8_t + EXPECT_EQ("OK", accepted("set int.thresh 300")); + EXPECT_EQ(44, p.interference_threshold); + EXPECT_EQ("OK", accepted("set int.thresh -1")); + EXPECT_EQ(255, p.interference_threshold); + EXPECT_EQ("OK", accepted("set int.thresh abc")); + EXPECT_EQ(0, p.interference_threshold); + + // cad: on only if the value starts with "on" + EXPECT_EQ("OK", accepted("set cad onx")); + EXPECT_EQ(1, p.cad_enabled); + EXPECT_EQ("OK", accepted("set cad ON")); + EXPECT_EQ(0, p.cad_enabled); + EXPECT_EQ("OK", accepted("set cad 1")); + EXPECT_EQ(0, p.cad_enabled); + + // radio.rxgain: same rule, pushed to the radio + EXPECT_EQ("OK", accepted("set radio.rxgain onfoo")); + EXPECT_EQ(1, p.rx_boosted_gain); + EXPECT_TRUE(radio_driver.rx_boosted); + + // tx: "get tx" also matches with trailing text; values wrap into int8_t + EXPECT_EQ("> 21", radioCli(p, "get tx foo")); + EXPECT_EQ("OK", accepted("set tx -5")); + EXPECT_EQ(-5, p.tx_power_dbm); + EXPECT_EQ(-5, radio_driver.tx_power); + EXPECT_EQ("> 251", radioCli(p, "get tx")); // odd: printed as unsigned + EXPECT_EQ("OK", accepted("set tx 300")); + EXPECT_EQ(44, p.tx_power_dbm); + + // rxdelay: atof, range 0..20, NaN rejected, garbage reads as 0 + EXPECT_EQ("OK", accepted("set rxdelay 20")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay 20.01")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay -0.01")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay nan")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay inf")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay +nan")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay INF")); + EXPECT_FLOAT_EQ(20.0f, p.rx_delay_base); + EXPECT_EQ("OK", accepted("set rxdelay abc")); + EXPECT_FLOAT_EQ(0.0f, p.rx_delay_base); + + // agc.reset.interval: seconds, clamped to 0..1020, stored in 4 s units + EXPECT_EQ("OK - interval rounded to 1020", accepted("set agc.reset.interval 1023")); + EXPECT_EQ(255, p.agc_reset_interval); + EXPECT_EQ("OK - interval rounded to 0", accepted("set agc.reset.interval -5")); + EXPECT_EQ("OK - interval rounded to 0", accepted("set agc.reset.interval 3")); + EXPECT_EQ("OK - interval rounded to 0", accepted("set agc.reset.interval abc")); + EXPECT_EQ("> 0", radioCli(p, "get agc.reset.interval")); + + // path.hash.mode: atoi into uint8_t, must be < 3 + EXPECT_EQ("OK", accepted("set path.hash.mode 2")); + EXPECT_EQ("Error, must be 0,1, or 2", rejected("set path.hash.mode -1")); + EXPECT_EQ(2, p.path_hash_mode); + EXPECT_EQ("OK", accepted("set path.hash.mode abc")); + EXPECT_EQ(0, p.path_hash_mode); + + // multi.acks: atoi, no range, stored as uint8_t + EXPECT_EQ("OK", accepted("set multi.acks 300")); + EXPECT_EQ(44, p.multi_acks); + + // txdelay / direct.txdelay: atof, range 0..2, NaN rejected + EXPECT_EQ("OK", accepted("set txdelay 2")); + EXPECT_EQ("Error, must be 0-2", rejected("set txdelay 2.01")); + EXPECT_EQ("Error, must be 0-2", rejected("set txdelay nan")); + EXPECT_EQ("Error, must be 0-2", rejected("set txdelay inf")); + EXPECT_EQ("Error, must be 0-2", rejected("set direct.txdelay nan")); + EXPECT_EQ("OK", accepted("set direct.txdelay 0")); + EXPECT_EQ("Error, must be 0-2", rejected("set direct.txdelay -1")); + EXPECT_FLOAT_EQ(2.0f, p.tx_delay_factor); + EXPECT_FLOAT_EQ(0.0f, p.direct_tx_delay_factor); + + // radio: all four parts must be in range + EXPECT_EQ("Error, invalid radio params", rejected("set radio 869")); + EXPECT_EQ("Error, invalid radio params", rejected("set radio 149.9,125,9,5")); + EXPECT_EQ("Error, invalid radio params", rejected("set radio 433,6.9,9,5")); + EXPECT_EQ("Error, invalid radio params", rejected("set radio 433,125,13,5")); + EXPECT_EQ("Error, invalid radio params", rejected("set radio 433,125,9,9")); + EXPECT_EQ("OK - reboot to apply", accepted("set radio 150,7,5,5")); + EXPECT_EQ("> 150.0,7,5,5", radioCli(p, "get radio")); + EXPECT_EQ("OK - reboot to apply", accepted("set radio 2500,500,12,8")); + EXPECT_EQ("> 2500.0,500,12,8", radioCli(p, "get radio")); + + // look-alikes and commands other handlers own are not taken + unhandled("get radiox"); + unhandled("get af2"); + unhandled("set afx 1"); + unhandled("set txdelayx 1"); + unhandled("get txdelay2"); + unhandled("set freq 869"); + unhandled("get name"); + + // the key/value store only exposes the FEM gains + char value[16]; + EXPECT_FALSE(p.getRadioPrefs()->getByKey("af", value, sizeof(value))); + EXPECT_FALSE(p.getRadioPrefs()->setByKey("tx", "5")); +} diff --git a/test/mocks/SHA256.h b/test/mocks/SHA256.h index 0cefe5c6e3c..9cb593f5bdc 100644 --- a/test/mocks/SHA256.h +++ b/test/mocks/SHA256.h @@ -24,12 +24,24 @@ class SHA256 { } } - void finalize(uint8_t* hash, size_t hashLen) { + void finalize(void* hash, size_t hashLen) { + uint8_t* out = static_cast(hash); for (size_t i = 0; i < hashLen; i++) { - hash[i] = _state[i % 32]; + out[i] = _state[i % 32]; } } - void resetHMAC(const uint8_t* key, size_t keyLen) {} - void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {} + // The HMAC path is what transport codes are derived from, so it has to produce + // deterministic output that depends on the key and the data; an empty body would + // leave the caller's code word uninitialised. + void resetHMAC(const void* key, size_t keyLen) { + _len = 0; + memset(_state, 0, sizeof(_state)); + update(key, keyLen); + } + + void finalizeHMAC(const void* key, size_t keyLen, void* hash, size_t hashLen) { + update(key, keyLen); + finalize(hash, hashLen); + } }; diff --git a/test/mocks/Stream.h b/test/mocks/Stream.h index 56675607e19..95baf265aa6 100644 --- a/test/mocks/Stream.h +++ b/test/mocks/Stream.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include // Mock Stream class for native testing // Provides minimal interface needed by Utils.h @@ -33,14 +35,36 @@ class Print return write((const uint8_t *) buffer, size); } - virtual size_t print(unsigned char b, int r = DEC) { return 0; } - virtual size_t print(int v, int r = DEC) { return 0; } - virtual size_t print(unsigned int v, int r = DEC) { return 0; } - virtual size_t print(long v, int r = DEC) { return 0; } - virtual size_t print(unsigned long v, int r = DEC) { return 0; } - virtual size_t print(long long v, int r = DEC) { return 0; } - virtual size_t print(unsigned long long v, int r = DEC) { return 0; } - virtual size_t print(double v, int p = 2) { return 0; } + // Arduino's Print formats numbers itself and hands the bytes to write(), so a + // subclass only has to implement write(). These used to return 0 without + // writing anything, which silently produced files with empty numeric values. +protected: + size_t emitSigned(long long v, int base) { + char text[24]; + int n = (base == HEX) ? snprintf(text, sizeof(text), "%llx", (unsigned long long) v) + : snprintf(text, sizeof(text), "%lld", v); + return write((const uint8_t *) text, (size_t) n); + } + size_t emitUnsigned(unsigned long long v, int base) { + char text[24]; + int n = (base == HEX) ? snprintf(text, sizeof(text), "%llx", v) + : snprintf(text, sizeof(text), "%llu", v); + return write((const uint8_t *) text, (size_t) n); + } + +public: + virtual size_t print(unsigned char b, int r = DEC) { return emitUnsigned(b, r); } + virtual size_t print(int v, int r = DEC) { return emitSigned(v, r); } + virtual size_t print(unsigned int v, int r = DEC) { return emitUnsigned(v, r); } + virtual size_t print(long v, int r = DEC) { return emitSigned(v, r); } + virtual size_t print(unsigned long v, int r = DEC) { return emitUnsigned(v, r); } + virtual size_t print(long long v, int r = DEC) { return emitSigned(v, r); } + virtual size_t print(unsigned long long v, int r = DEC) { return emitUnsigned(v, r); } + virtual size_t print(double v, int p = 2) { + char text[48]; + int n = snprintf(text, sizeof(text), "%.*f", p, v); + return write((const uint8_t *) text, (size_t) n); + } size_t print(char c) { return write(c); } size_t print(const char* str) { return write(str); } @@ -48,6 +72,18 @@ class Print //size_t println(void) { return 0; } virtual void flush() { /* Empty implementation for backward compatibility */ } + + // The ESP32 and RP2040 cores add printf() to Print; helpers/RegionMap.cpp uses it. + size_t printf(const char* fmt, ...) { + char buf[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + if (n < 0) return 0; + if ((size_t) n >= sizeof(buf)) n = sizeof(buf) - 1; + return write((const uint8_t *) buf, (size_t) n); + } }; class Stream: public Print diff --git a/test/mocks/Utils.h b/test/mocks/Utils.h index 9bb6b060b03..260ee505238 100644 --- a/test/mocks/Utils.h +++ b/test/mocks/Utils.h @@ -10,6 +10,11 @@ #define SIGNATURE_SIZE 64 #define CIPHER_MAC_SIZE 16 +// Stands in for the same no-op definitions in src/MeshCore.h, which is the +// header the real Utils.h pulls these constants from. +#define MESH_DEBUG_PRINT(...) {} +#define MESH_DEBUG_PRINTLN(...) {} + namespace mesh { class RNG { diff --git a/test/mocks/bridge/helpers/bridges/RS232Bridge.h b/test/mocks/bridge/helpers/bridges/RS232Bridge.h new file mode 100644 index 00000000000..238c8f2b463 --- /dev/null +++ b/test/mocks/bridge/helpers/bridges/RS232Bridge.h @@ -0,0 +1,33 @@ +#pragma once + +// Stand-in for the repeater's RS232 bridge, so the repeater builds on the host with +// WITH_RS232_BRIDGE set. It keeps the real bridge's interface and does nothing but +// remember that it was started, in the shared event log, so a test can see when the +// repeater's begin() starts the bridge relative to the rest of the boot. +// +// It sits in a directory of its own that the "full" repeater environment puts ahead +// of src on the include path, so `helpers/bridges/RS232Bridge.h` resolves here and +// nowhere else sees it. + +#include +#include +#include +#include + +#include + +class RS232Bridge : public AbstractBridge { + bool _running = false; + +public: + // WITH_RS232_BRIDGE names a serial port on a board; on the host it is just a + // number, and the stand-in does not use it. + RS232Bridge(NodePrefs* prefs, int serial, mesh::PacketManager* mgr, mesh::RTCClock* rtc) { } + + void begin() override { _running = true; logEvent("bridge.begin"); } + void end() override { _running = false; logEvent("bridge.end"); } + bool isRunning() const override { return _running; } + void loop() override { } + void sendPacket(mesh::Packet* packet) override { } + void onPacketReceived(mesh::Packet* packet) override { } +}; diff --git a/test/mocks/radiolib_variant/SPI.h b/test/mocks/radiolib_variant/SPI.h new file mode 100644 index 00000000000..6bfbe442e22 --- /dev/null +++ b/test/mocks/radiolib_variant/SPI.h @@ -0,0 +1,7 @@ +#pragma once + +// Stand-in for the Arduino SPI library: only the type and the global, so a test +// can see which bus the radio is started on. +class SPIClass { }; + +inline SPIClass SPI; diff --git a/test/mocks/radiolib_variant/Wire.h b/test/mocks/radiolib_variant/Wire.h new file mode 100644 index 00000000000..4e387fa7436 --- /dev/null +++ b/test/mocks/radiolib_variant/Wire.h @@ -0,0 +1,7 @@ +#pragma once + +// Stand-in for the Arduino Wire library: only the type and the global, so a +// test can see which bus the RTC clock is started on. +class TwoWire { }; + +inline TwoWire Wire; diff --git a/test/mocks/radiolib_variant/target.h b/test/mocks/radiolib_variant/target.h new file mode 100644 index 00000000000..65dcede0fb6 --- /dev/null +++ b/test/mocks/radiolib_variant/target.h @@ -0,0 +1,65 @@ +#pragma once + +// Stand-in for the target.h of a variant with a RadioLib radio, for the suites +// that build the shared variant code (src/helpers/radiolib/RadioLibTarget.cpp). +// Listed ahead of test/mocks, whose target.h stands in for a variant without one. +// As on a board, the environment sets WRAPPER_CLASS and the variant's target.cpp +// (here: the test) defines the globals declared below. + +#include +#include +#include + +#include +#include + +// What the stand-ins below were asked to do, in order, for the tests to compare. +inline std::vector target_calls; + +// Stand-in for a chip class with std_init() (CustomSX1262 and its siblings): +// records the call and the SPI bus it was given, and counts the calls per radio. +class TestRadio : public PhysicalLayer { +public: + bool std_init_result = true; + int std_init_calls = 0; + + bool std_init(SPIClass* spi = nullptr) { + target_calls.push_back(spi == &SPI ? "std_init(&SPI)" : spi ? "std_init(other SPI)" : "std_init()"); + std_init_calls++; + return std_init_result; + } +}; + +// Stand-in for AutoDiscoverRTCClock: records which bus it was started on. +class TestRTCClock { +public: + void begin(TwoWire& wire) { target_calls.push_back(&wire == &Wire ? "rtc_clock.begin(Wire)" : "rtc_clock.begin(other)"); } +}; + +// What CustomSX1262Wrapper.h defines, which every target.h of an SX1262 variant +// includes. +#ifndef USE_SX1262 +#define USE_SX1262 +#endif + +class TestRadioWrapper : public RadioLibWrapper { +public: + TestRadioWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + void setParams(float, float, uint8_t, uint8_t) override { } + bool isReceivingPacket() override { return false; } + float getCurrentRSSI() override { return -100.0f; } +}; + +class TestBoard : public mesh::MainBoard { +public: + uint16_t getBattMilliVolts() override { return 4200; } + const char* getManufacturerName() const override { return "test"; } + void reboot() override { } + uint8_t getStartupReason() const override { return 0; } +}; + +extern WRAPPER_CLASS radio_driver; +extern TestRTCClock rtc_clock; + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/test/mocks/target.h b/test/mocks/target.h new file mode 100644 index 00000000000..00fb87f5660 --- /dev/null +++ b/test/mocks/target.h @@ -0,0 +1,158 @@ +#pragma once + +#include + +#include + +#include "EventLog.h" + +// A variant's target.h brings in the platform file system; the native env gets the +// in-memory stand-in, so FILESYSTEM and File are defined for the server helpers. +#include "FS.h" + +// Stand-in for a variant's target.h, so code that reaches the global +// radio_driver (e.g. CommonRadioPrefs.cpp) builds and runs in the native env. +// On a board, radio_driver is a RadioLibWrapper, which is a mesh::Radio, and the +// role is constructed with it. Deriving here keeps that true in the native env, so +// a test can build a role exactly the way the role's main.cpp does. +struct MockRadioDriver : public mesh::Radio { + int8_t tx_power = 0; + int tx_power_calls = 0; + bool rx_boosted = false; + int rx_boost_calls = 0; + bool rx_boost_supported = true; + + float freq = 0, bw = 0; + uint8_t sf = 0, cr = 0; + int set_params_calls = 0; + float last_rssi = -90.0f, last_snr = 5.0f; + uint32_t packets_recv = 0, packets_sent = 0, packets_recv_errors = 0; + + int begin_calls = 0; + void begin() override { begin_calls++; logEvent("radio.begin"); } + + void setTxPower(int8_t dbm) { tx_power = dbm; tx_power_calls++; logEvent("radio.setTxPower"); } + bool setRxBoostedGainMode(bool en) { + rx_boosted = en; rx_boost_calls++; + logEvent("radio.setRxBoostedGainMode"); + return rx_boost_supported; + } + bool getRxBoostedGainMode() const { return rx_boosted; } + + void setParams(float f, float b, uint8_t s, uint8_t c) { + freq = f; bw = b; sf = s; cr = c; set_params_calls++; + logEvent("radio.setParams"); + } + + float getLastRSSI() const { return last_rssi; } + float getLastSNR() const { return last_snr; } + uint32_t getPacketsRecv() const { return packets_recv; } + uint32_t getPacketsSent() const { return packets_sent; } + uint32_t getPacketsRecvErrors() const { return packets_recv_errors; } + void resetStats() { packets_recv = packets_sent = packets_recv_errors = 0; } + bool configSideDetectors(const uint8_t[], uint8_t, float) { return false; } + + // mesh::Radio, as in test_core_tuning: airtime grows with the length, so a wrong + // length in a formula changes the result. + int recvRaw(uint8_t* bytes, int sz) override { return 0; } + uint32_t getEstAirtimeFor(int len_bytes) override { return 11 + 37 * (uint32_t)len_bytes; } + float packetScore(float snr, int packet_len) override { return 0; } + // A radio that accepts the frame, so a test can see that something was actually + // transmitted and when. Set send_succeeds to false to model a refusing radio. + bool send_succeeds = true; + int send_calls = 0; + int last_send_len = 0; + uint8_t last_send[256] = {0}; + + bool startSendRaw(const uint8_t* bytes, int len) override { + if (!send_succeeds) return false; + send_calls++; + last_send_len = len; + if (len > 0 && len <= (int)sizeof(last_send)) memcpy(last_send, bytes, len); + return true; + } + bool isSendComplete() override { return true; } + void onSendFinished() override { } + bool isInRecvMode() const override { return true; } + + void reset() { + tx_power = 0; tx_power_calls = 0; + rx_boosted = false; rx_boost_calls = 0; rx_boost_supported = true; + freq = bw = 0; sf = cr = 0; set_params_calls = 0; + begin_calls = 0; + send_succeeds = true; send_calls = 0; last_send_len = 0; + last_rssi = -90.0f; last_snr = 5.0f; + packets_recv = packets_sent = packets_recv_errors = 0; + } +}; + +inline MockRadioDriver radio_driver; + +#include +#include +#include + +// A variant's target.h declares the board and the sensor manager as globals the +// role code reaches directly. The native env provides the same two names, so the +// real role implementations compile and run unchanged. + +class MockBoard : public mesh::MainBoard { +public: + uint16_t batt_milli_volts = 4100; + float mcu_temperature = 25.0f; + float adc_multiplier = 1.0f; + uint32_t gpio = 0; + int reboot_calls = 0; + + uint16_t getBattMilliVolts() override { return batt_milli_volts; } + float getMCUTemperature() override { return mcu_temperature; } + bool setAdcMultiplier(float m) override { + adc_multiplier = m; + logEvent("board.setAdcMultiplier"); + return true; + } + float getAdcMultiplier() const override { return adc_multiplier; } + const char* getManufacturerName() const override { return "Mock"; } + void reboot() override { reboot_calls++; } + uint8_t getStartupReason() const override { return 0; } + uint32_t getGpio() override { return gpio; } + void setGpio(uint32_t values) override { gpio = values; } + + // Not on MainBoard: the concrete variant boards declare it, and the role code + // calls it on the concrete type (see helpers/NRF52Board.h and its siblings). + void attachDynamicPrefs(KeyValueStore* prefs) { logEvent("board.attachDynamicPrefs"); } + + void reset() { + batt_milli_volts = 4100; + mcu_temperature = 25.0f; + adc_multiplier = 1.0f; + gpio = 0; + reboot_calls = 0; + } +}; + +class MockRTCClock : public mesh::RTCClock { +public: + uint32_t now = 1760000000; // a fixed point in time, so replies stay repeatable + uint32_t getCurrentTime() override { return now; } + void setCurrentTime(uint32_t time) override { now = time; } +}; + +inline MockBoard board; + +// A variant's target.cpp defines this; the companion calls it in begin() when no +// identity is stored yet. A suite that boots a companion defines it. +mesh::LocalIdentity radio_new_identity(); +inline MockRTCClock rtc_clock; + +// SensorManager is concrete, so the real class serves as the stand-in; this one only +// adds a record of the settings the role pushes to it (the GPS toggle at boot). +class MockSensorManager : public SensorManager { +public: + bool setSettingValue(const char* name, const char* value) override { + logEvent(std::string("sensors.setSettingValue ") + name + "=" + value); + return true; + } +}; + +inline MockSensorManager sensors; diff --git a/test/test_chat_group_msg/identity_stubs.cpp b/test/test_chat_group_msg/identity_stubs.cpp new file mode 100644 index 00000000000..d387e421204 --- /dev/null +++ b/test/test_chat_group_msg/identity_stubs.cpp @@ -0,0 +1,28 @@ +// Link-time stand-ins for the parts of mesh::Identity that BaseChatMesh.cpp and +// Mesh.cpp reference. The real implementations need the board crypto libraries, +// which do not exist on the host. These tests never sign, verify or derive a +// shared secret, so any call is a test failure. + +#include +#include + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + ADD_FAILURE() << "Identity::verify must not be called by the group message tests"; + return false; +} + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + ADD_FAILURE() << "LocalIdentity::sign must not be called by the group message tests"; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + ADD_FAILURE() << "LocalIdentity::calcSharedSecret must not be called by the group message tests"; +} + +} // namespace mesh diff --git a/test/test_chat_group_msg/test_group_msg_len.cpp b/test/test_chat_group_msg/test_group_msg_len.cpp new file mode 100644 index 00000000000..2afd3605650 --- /dev/null +++ b/test/test_chat_group_msg/test_group_msg_len.cpp @@ -0,0 +1,206 @@ +// Tests for the text length that BaseChatMesh::sendGroupMessage() accepts. +// +// The companion firmware derives that length by subtracting a fixed header size +// from the length of the frame the app sent, so a frame that is too short turns +// the length negative. sendGroupMessage() hands the value to memcpy(), which +// reads it as a size_t, so a negative length asks for about 4 GB. The sibling +// sendGroupData(), added later for CMD_SEND_CHANNEL_DATA, already refuses a +// negative length; these tests pin the same behaviour for sendGroupMessage() +// and keep the lengths that must still work. +// +// They run the real BaseChatMesh.cpp, Mesh.cpp, Dispatcher.cpp and Packet.cpp +// against fakes for the radio, clock, RNG and packet manager, and against the +// link-time identity stubs in identity_stubs.cpp. + +#include + +#include +#include + +#include +#include + +namespace { + +class FakeRadio : public mesh::Radio { +public: + int recvRaw(uint8_t* bytes, int sz) override { return 0; } + uint32_t getEstAirtimeFor(int len_bytes) override { return 10; } + float packetScore(float snr, int packet_len) override { return 0; } + bool startSendRaw(const uint8_t* bytes, int len) override { return false; } + bool isSendComplete() override { return true; } + void onSendFinished() override { } + bool isInRecvMode() const override { return true; } +}; + +class FakeClock : public mesh::MillisecondClock { +public: + unsigned long getMillis() override { return 0; } +}; + +class FakeRTC : public mesh::RTCClock { +public: + uint32_t getCurrentTime() override { return 0; } + void setCurrentTime(uint32_t time) override { } +}; + +class ZeroRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { memset(dest, 0, sz); } +}; + +// Hands out packets from a small pool and keeps every packet that was queued for +// sending, so a test can tell "refused before sending" from "sent" and can read +// the payload length the chat layer produced. +class CapturingPacketManager : public mesh::PacketManager { + static const int POOL = 4; + mesh::Packet _pool[POOL]; + bool _in_use[POOL] = { false, false, false, false }; +public: + std::vector queued; + + mesh::Packet* allocNew() override { + for (int i = 0; i < POOL; i++) { + if (!_in_use[i]) { + _in_use[i] = true; + return &_pool[i]; + } + } + return nullptr; + } + void free(mesh::Packet* packet) override { + for (int i = 0; i < POOL; i++) { + if (&_pool[i] == packet) _in_use[i] = false; + } + } + void queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override { + queued.push_back(packet); + } + mesh::Packet* getNextOutbound(uint32_t now) override { return nullptr; } + int getOutboundCount(uint32_t now) const override { return (int)queued.size(); } + int getOutboundTotal() const override { return (int)queued.size(); } + int getFreeCount() const override { return POOL; } + mesh::Packet* getOutboundByIdx(int i) override { return nullptr; } + mesh::Packet* removeOutboundByIdx(int i) override { return nullptr; } + void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override { } + mesh::Packet* getNextInbound(uint32_t now) override { return nullptr; } +}; + +// The chat layer is abstract; the callbacks are not reached by these tests. +class TestChatMesh : public BaseChatMesh { +public: + TestChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, + mesh::PacketManager& mgr, mesh::MeshTables& tables) + : BaseChatMesh(radio, ms, rng, rtc, mgr, tables) { } + + void onDiscoveredContact(ContactInfo& c, bool is_new, uint8_t path_len, const uint8_t* path) override { } + ContactInfo* processAck(const uint8_t* data) override { return nullptr; } + void onContactPathUpdated(const ContactInfo& c) override { } + void onMessageRecv(const ContactInfo& c, mesh::Packet* p, uint32_t ts, const char* text) override { } + void onCommandDataRecv(const ContactInfo& c, mesh::Packet* p, uint32_t ts, const char* text) override { } + void onCLICommandRecv(const ContactInfo& c, mesh::Packet* p, uint32_t ts, const char* text, char* reply) override { } + void onSignedMessageRecv(const ContactInfo& c, mesh::Packet* p, uint32_t ts, const uint8_t* prefix, + const char* text) override { } + uint32_t calcFloodTimeoutMillisFor(uint32_t airtime) const override { return 0; } + uint32_t calcDirectTimeoutMillisFor(uint32_t airtime, uint8_t path_len) const override { return 0; } + void onSendTimeout() override { } + void onChannelMessageRecv(const mesh::GroupChannel& ch, mesh::Packet* p, uint32_t ts, const char* text) override { } + uint8_t onContactRequest(const ContactInfo& c, uint32_t ts, const uint8_t* data, uint8_t len, + uint8_t* reply) override { return 0; } + void onContactResponse(const ContactInfo& c, const uint8_t* data, uint8_t len) override { } +}; + +const char* SENDER = "node"; // the prefix becomes "node: ", so 6 bytes +const int PREFIX_LEN = 6; +const int HEADER_LEN = 5; // 4 byte timestamp plus the TXT_TYPE byte + +// A group datagram carries the channel hash, the MAC and the ciphertext, and the +// cipher pads to whole blocks. That makes the payload length a function of the +// text length alone, which is what these tests observe. +int expectedPayloadLen(int text_len) { + int plain = HEADER_LEN + PREFIX_LEN + text_len; + int blocks = (plain + CIPHER_BLOCK_SIZE - 1) / CIPHER_BLOCK_SIZE; + return PATH_HASH_SIZE + CIPHER_MAC_SIZE + blocks * CIPHER_BLOCK_SIZE; +} + +class GroupMessageTest : public ::testing::Test { +protected: + FakeRadio radio; + FakeClock ms; + ZeroRNG rng; + FakeRTC rtc; + SimpleMeshTables tables; + CapturingPacketManager mgr; + TestChatMesh mesh_node { radio, ms, rng, rtc, mgr, tables }; + mesh::GroupChannel channel; + // Long enough that a clamped length still reads inside this buffer. + char text[256]; + + void SetUp() override { + memset(&channel, 0, sizeof(channel)); + memset(text, 'x', sizeof(text)); + text[sizeof(text) - 1] = 0; + } +}; + +// A frame of 3 bytes makes the companion compute 3 - 7 = -4 (MyMesh.cpp, +// CMD_SEND_CHANNEL_TXT_MSG). Measured on a T-Echo running dev 428a95c6: the node +// stopped answering and never came back. +TEST_F(GroupMessageTest, RefusesTheLengthAThreeByteFrameProduces) { + EXPECT_FALSE(mesh_node.sendGroupMessage(0, channel, SENDER, text, -4)); + EXPECT_TRUE(mgr.queued.empty()) << "nothing may be sent for a negative text length"; +} + +TEST_F(GroupMessageTest, RefusesMinusOne) { + EXPECT_FALSE(mesh_node.sendGroupMessage(0, channel, SENDER, text, -1)); + EXPECT_TRUE(mgr.queued.empty()); +} + +TEST_F(GroupMessageTest, SendsEmptyText) { + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, 0)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(0), mgr.queued[0]->payload_len); +} + +TEST_F(GroupMessageTest, SendsShortText) { + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, 5)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(5), mgr.queued[0]->payload_len); +} + +// One byte past a cipher block, to catch an off-by-one in the length arithmetic. +TEST_F(GroupMessageTest, SendsTextAcrossABlockBoundary) { + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, 6)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(6), mgr.queued[0]->payload_len); +} + +// Too long is clamped, not refused: that is the behaviour the app relies on. +TEST_F(GroupMessageTest, ClampsTextThatIsTooLong) { + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, 200)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(MAX_TEXT_LEN - PREFIX_LEN), mgr.queued[0]->payload_len); +} + +// The exact boundary of the clamp still goes out unchanged. +TEST_F(GroupMessageTest, SendsTheLongestTextThatFits) { + const int longest = MAX_TEXT_LEN - PREFIX_LEN; + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, longest)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(longest), mgr.queued[0]->payload_len); +} + +// Characterisation of the sibling this fix follows, so the symmetry stays visible. +TEST_F(GroupMessageTest, GroupDataAlreadyRefusesNegativeLength) { + uint8_t path[1] = { 0 }; + uint8_t data[4] = { 1, 2, 3, 4 }; + EXPECT_FALSE(mesh_node.sendGroupData(channel, path, OUT_PATH_UNKNOWN, 0x0102, data, -1)); + EXPECT_TRUE(mgr.queued.empty()); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_common_node_prefs/test_common_node_prefs.cpp b/test/test_common_node_prefs/test_common_node_prefs.cpp new file mode 100644 index 00000000000..7202fca59a0 --- /dev/null +++ b/test/test_common_node_prefs/test_common_node_prefs.cpp @@ -0,0 +1,141 @@ +// Characterization tests for the NodePrefs of the server roles (repeater, +// room server, sensor). They pin down what /prefs.json contains and what the +// radio CLI does today, so a refactor of the prefs classes can prove it keeps +// the on-flash format and the CLI behaviour unchanged. + +#include + +#include +#include + +#include + +#include "PrefsStreams.h" +#include "RadioPrefsChecks.h" + +namespace { + +void fillAll(NodePrefs& p) { + fillRadioAndGps(p); + strcpy(p.node_name, "Test Repeater"); + strcpy(p.password, "admin-pw"); + strcpy(p.guest_password, "guest-pw"); + strcpy(p.owner_info, "owner \"quoted\" info"); + p.advert_interval = 60; + p.flood_advert_interval = 12; + p.discovery_mod_timestamp = 1760000000; + p.extra_sf[0] = 9; p.extra_sf[1] = 10; p.extra_sf[2] = 11; p.extra_sf[3] = 0; + p.node_lat = 53.551086; + p.node_lon = 9.993682; + p.bridge_enabled = 1; + p.bridge_delay = 500; + p.bridge_pkt_src = 1; + p.bridge_baud = 115200; + p.bridge_channel = 6; + strcpy(p.bridge_secret, "bridge-secret"); + p.disable_fwd = 1; + p.flood_max = 32; + p.flood_max_unscoped = 16; + p.flood_max_advert = 8; + p.loop_detect = 2; + p.allow_read_only = 1; + p.adc_multiplier = 1.25f; + p.powersaving_enabled = 1; +} + +// /prefs.json as written by v1.18.0 for the values of fillAll(). +const char* kPrefsJson = + "{name:\"Test Repeater\",pass:\"admin-pw\",guest:\"guest-pw\"," + "owner:\"owner \\\"quoted\\\" info\",adv_int:60,f_adv_int:12," + "disc_mod:1760000000,extra_sf:\"090A0B00\",lat:53.551086," + "lon:9.993682,radio:{freq:869.6180,bw:62.5000,sf:8,cr:6,cad:1," + "int_thr:14,rxgain:1,fem_rxgain:1,fem_txgain:2,tx:21,af:1.5000," + "rxdelay:3.2500,f_txdelay:0.7500,d_txdelay:0.2500,agc_int:5,hash_mode:2," + "multi_ack:1},bridge:{en:1,delay:500,src:1,baud:115200,ch:6,secret:\"bridge-secret\"}," + "gps:{en:1,int:900,adv_loc:1},repeat:{disable:1,f_max:32,f_max_uns:16," + "f_max_adv:8,loop:2},room:{rd_only:1},power:{adc_mult:1.2500,pwr_sav_en:1}," + "custom:{}}"; + +} // namespace + +TEST(CommonNodePrefs, WritesTheKnownPrefsJson) { + // The v1.18.0 file with the prefs version in front, the only key added since. + NodePrefs prefs; + fillAll(prefs); + prefs.prefs_version = NODE_PREFS_VERSION; + + CaptureStream out; + ASSERT_TRUE(prefs.saveSerial(out)); + EXPECT_EQ("{ver:" + std::to_string(NODE_PREFS_VERSION) + "," + std::string(kPrefsJson + 1), + out.text()); +} + +TEST(CommonNodePrefs, AFileWithoutAVersionReadsAsVersionZero) { + NodePrefs loaded; + ReplayStream in(kPrefsJson); + ASSERT_TRUE(loaded.loadSerial(in)); + EXPECT_EQ(loaded.prefs_version, 0) << "v1.18.0 wrote no version"; +} + +TEST(CommonNodePrefs, TheVersionIsReadBack) { + NodePrefs loaded; + ReplayStream in("{ver:7,repeat:{f_max:3}}"); + ASSERT_TRUE(loaded.loadSerial(in)); + EXPECT_EQ(loaded.prefs_version, 7); + EXPECT_EQ(loaded.flood_max, 3) << "and the keys after it as before"; +} + +TEST(CommonNodePrefs, ReadsEveryPersistedFieldBack) { + NodePrefs expected; + fillAll(expected); + + NodePrefs loaded; + ReplayStream in(kPrefsJson); + ASSERT_TRUE(loaded.loadSerial(in)); + + expectRadioAndGpsEqual(expected, loaded); + EXPECT_STREQ(expected.node_name, loaded.node_name); + EXPECT_STREQ(expected.password, loaded.password); + EXPECT_STREQ(expected.guest_password, loaded.guest_password); + EXPECT_STREQ(expected.owner_info, loaded.owner_info); + EXPECT_EQ(expected.advert_interval, loaded.advert_interval); + EXPECT_EQ(expected.flood_advert_interval, loaded.flood_advert_interval); + EXPECT_EQ(expected.discovery_mod_timestamp, loaded.discovery_mod_timestamp); + EXPECT_EQ(0, memcmp(expected.extra_sf, loaded.extra_sf, sizeof(expected.extra_sf))); + EXPECT_DOUBLE_EQ(expected.node_lat, loaded.node_lat); + EXPECT_DOUBLE_EQ(expected.node_lon, loaded.node_lon); + EXPECT_EQ(expected.bridge_enabled, loaded.bridge_enabled); + EXPECT_EQ(expected.bridge_delay, loaded.bridge_delay); + EXPECT_EQ(expected.bridge_pkt_src, loaded.bridge_pkt_src); + EXPECT_EQ(expected.bridge_baud, loaded.bridge_baud); + EXPECT_EQ(expected.bridge_channel, loaded.bridge_channel); + EXPECT_STREQ(expected.bridge_secret, loaded.bridge_secret); + EXPECT_EQ(expected.disable_fwd, loaded.disable_fwd); + EXPECT_EQ(expected.flood_max, loaded.flood_max); + EXPECT_EQ(expected.flood_max_unscoped, loaded.flood_max_unscoped); + EXPECT_EQ(expected.flood_max_advert, loaded.flood_max_advert); + EXPECT_EQ(expected.loop_detect, loaded.loop_detect); + EXPECT_EQ(expected.allow_read_only, loaded.allow_read_only); + EXPECT_FLOAT_EQ(expected.adc_multiplier, loaded.adc_multiplier); + EXPECT_EQ(expected.powersaving_enabled, loaded.powersaving_enabled); +} + +TEST(CommonNodePrefs, RadioCliReadsAndWritesTheNodeFields) { + NodePrefs prefs; + expectRadioCliDrivesNodeFields(prefs); +} + +TEST(CommonNodePrefs, RadioCliEdgeCases) { + NodePrefs prefs; + expectRadioCliEdgeCases(prefs); +} + +TEST(CommonNodePrefs, CustomStoreReachesTheFemGains) { + NodePrefs prefs; + expectCustomStoreReachesFemGains(prefs); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_companion_frames/CompanionHarness.h b/test/test_companion_frames/CompanionHarness.h new file mode 100644 index 00000000000..07bb4fa7b05 --- /dev/null +++ b/test/test_companion_frames/CompanionHarness.h @@ -0,0 +1,214 @@ +#pragma once + +// The companion on the host, driven through its frame protocol. A test hands a +// command frame to a fake serial interface, runs one MyMesh::loop() (which is where +// the firmware picks frames up), and gets back every frame the node wrote. Nothing +// reaches into the node: what the protocol cannot report is observed on its +// collaborators (the file system, the radio, the board, the clock, the packets +// queued for the air). +// +// Each node is built in zeroed storage. On a board, MyMesh is a global and starts +// zeroed, and the bytes of cmd_frame above the current frame's length are whatever +// an earlier frame left there. Zeroed storage makes that tail the same on every run, +// so a reply never depends on what the heap held before. + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "MyMesh.h" + +namespace companion { + +using Bytes = std::vector; + +// Stands in for the BLE, USB or WiFi interface. checkRecvFrame() returns what +// ArduinoSerialInterface returns for a complete frame: its bytes, copied into the +// node's cmd_frame, and its length. +class FakeSerial : public BaseSerialInterface { +public: + std::deque inbound; + std::vector written; + bool enabled = false; + int disable_calls = 0; + + void enable() override { enabled = true; } + void disable() override { enabled = false; disable_calls++; } + bool isEnabled() const override { return enabled; } + bool isConnected() const override { return true; } + bool isWriteBusy() const override { return false; } + + size_t writeFrame(const uint8_t src[], size_t len) override { + written.emplace_back(src, src + len); + return len; + } + + size_t checkRecvFrame(uint8_t dest[]) override { + if (inbound.empty()) return 0; + Bytes f = inbound.front(); + inbound.pop_front(); + memcpy(dest, f.data(), f.size()); + return f.size(); + } +}; + +// Opens up what the destructor needs: MyMesh allocates its clock and packet manager +// with new and never frees them, which is fine for a global and not for thousands +// of nodes in one test binary. +class Node : public MyMesh { +public: + using MyMesh::MyMesh; + int outboundTotal() const { return _mgr->getOutboundTotal(); } + // The packets waiting for the air, as they would be transmitted. + std::vector outbound() { + std::vector out; + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + uint8_t raw[MAX_TRANS_UNIT + 8]; + int n = _mgr->getOutboundByIdx(i)->writeTo(raw); + out.emplace_back(raw, raw + n); + } + return out; + } + void freeOwned() { + delete static_cast(_mgr); + delete static_cast(_ms); + } +}; + +constexpr uint32_t T0 = 1000; +constexpr uint32_t RTC0 = 1760000000; + +class Harness { +public: + // The RP2040 data store formats the LittleFS global (CMD_FACTORY_RESET), so the + // node runs on that instance. + fs::FS& fs = LittleFS; + StdRNG rng; + SimpleMeshTables tables; + FakeSerial serial; + std::unique_ptr store; + + Harness() { reset(); } + ~Harness() { destroy(); } + + void reset() { + destroy(); + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock = MockRTCClock(); // also clears the base's last unique timestamp + rtc_clock.now = RTC0; + sensors.node_lat = sensors.node_lon = 0; + fs.reset(); + rng.begin(1); + g_events.clear(); + serial = FakeSerial(); + } + + // Boots a node the way main.cpp does: the data store, then the mesh, then the + // interface. The file system is left as it is, so building a node again after + // a test stored something is a reboot. + Node& boot() { + destroy(); + store.reset(new DataStore(fs, rtc_clock)); + storage = std::unique_ptr(new unsigned char[sizeof(Node) + alignof(Node)]); + memset(storage.get(), 0, sizeof(Node) + alignof(Node)); + void* p = storage.get(); + size_t space = sizeof(Node) + alignof(Node); + p = std::align(alignof(Node), sizeof(Node), p, space); + node = new (p) Node(radio_driver, rng, rtc_clock, tables, *store); + store->begin(); + node->begin(); + node->startInterface(serial); + serial.written.clear(); + g_events.clear(); + return *node; + } + + Node& mesh() { return *node; } + + // One frame in, the frames the node wrote while handling it out. + std::vector send(const Bytes& frame) { + EXPECT_GE(frame.size(), 1u); + EXPECT_LE(frame.size(), (size_t)MAX_FRAME_SIZE); + serial.written.clear(); + serial.inbound.push_back(frame); + node->loop(); + return serial.written; + } + + // The only reply to a frame, or an empty frame if there was not exactly one. + Bytes reply(const Bytes& frame) { + auto out = send(frame); + EXPECT_EQ(out.size(), 1u) << "expected one reply frame"; + return out.size() == 1 ? out[0] : Bytes(); + } + +private: + std::unique_ptr storage; + Node* node = nullptr; + + void destroy() { + if (node) { + node->freeOwned(); + node->~Node(); + node = nullptr; + } + storage.reset(); + } +}; + +// ---- frame building ----------------------------------------------------------- + +inline Bytes frame(std::initializer_list b) { return Bytes(b); } + +inline void put(Bytes& f, const void* src, size_t n) { + const uint8_t* s = static_cast(src); + f.insert(f.end(), s, s + n); +} +inline void putU8(Bytes& f, uint8_t v) { f.push_back(v); } +inline void putU16(Bytes& f, uint16_t v) { put(f, &v, 2); } +inline void putU32(Bytes& f, uint32_t v) { put(f, &v, 4); } +inline void putI32(Bytes& f, int32_t v) { put(f, &v, 4); } +inline void putFill(Bytes& f, size_t n, uint8_t v) { f.insert(f.end(), n, v); } +inline void putStr(Bytes& f, const char* s) { put(f, s, strlen(s)); } +// A fixed width, zero padded text field. +inline void putField(Bytes& f, const char* s, size_t width) { + Bytes b(width, 0); + memcpy(b.data(), s, std::min(strlen(s), width)); + put(f, b.data(), width); +} + +inline uint32_t u32At(const Bytes& b, size_t at) { + uint32_t v = 0; + if (at + 4 <= b.size()) memcpy(&v, &b[at], 4); + return v; +} +inline uint16_t u16At(const Bytes& b, size_t at) { + uint16_t v = 0; + if (at + 2 <= b.size()) memcpy(&v, &b[at], 2); + return v; +} + +inline std::string hex(const Bytes& b) { + static const char* digits = "0123456789abcdef"; + std::string s; + for (uint8_t c : b) { s += digits[c >> 4]; s += digits[c & 15]; } + return s; +} + +} // namespace companion diff --git a/test/test_companion_frames/frame_sweep.golden b/test/test_companion_frames/frame_sweep.golden new file mode 100644 index 00000000000..08048221bf2 --- /dev/null +++ b/test/test_companion_frames/frame_sweep.golden @@ -0,0 +1,3271 @@ +00 00 1-176: 0101 +00 00 packets b5fd26618458b725 files f195e1bfb415df25 +00 01 1-176: 0101 +00 01 packets b5fd26618458b725 files f195e1bfb415df25 +00 81 1-176: 0101 +00 81 packets b5fd26618458b725 files f195e1bfb415df25 +00 ff 1-176: 0101 +00 ff packets b5fd26618458b725 files f195e1bfb415df25 +00 00/5a 1-176: 0101 +00 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +01 00 1-7: 0101 +01 00 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 00 packets b5fd26618458b725 files f195e1bfb415df25 +01 01 1-7: 0101 +01 01 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 01 packets b5fd26618458b725 files f195e1bfb415df25 +01 81 1-7: 0101 +01 81 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 81 packets b5fd26618458b725 files f195e1bfb415df25 +01 ff 1-7: 0101 +01 ff 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 ff packets b5fd26618458b725 files f195e1bfb415df25 +01 00/5a 1-7: 0101 +01 00/5a 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +02 00 1-13: 0101 +02 00 14-176: 0600e661e360bc190000 [Q] +02 00 packets 106eca7be92fcae3 files f195e1bfb415df25 +02 01 1-13: 0101 +02 01 14-176: 0102 +02 01 packets b5fd26618458b725 files f195e1bfb415df25 +02 81 1-13: 0101 +02 81 14-176: 0102 +02 81 packets b5fd26618458b725 files f195e1bfb415df25 +02 ff 1-13: 0101 +02 ff 14-176: 0102 +02 ff packets b5fd26618458b725 files f195e1bfb415df25 +02 00/5a 1-13: 0101 +02 00/5a 14-176: 0600e661e360bc190000 [Q] +02 00/5a packets 106eca7be92fcae3 files f195e1bfb415df25 +03 00 1-6: 0101 +03 00 7-176: 00 [Q] +03 00 packets 367544479ace75c1 files f195e1bfb415df25 +03 01 1-176: 0101 +03 01 packets b5fd26618458b725 files f195e1bfb415df25 +03 81 1-176: 0101 +03 81 packets b5fd26618458b725 files f195e1bfb415df25 +03 ff 1-176: 0101 +03 ff packets b5fd26618458b725 files f195e1bfb415df25 +03 00/5a 1-6: 0101 +03 00/5a 7-176: 00 [Q] +03 00/5a packets 367544479ace75c1 files f195e1bfb415df25 +04 00 1-176: 0200000000 +04 00 packets b5fd26618458b725 files f195e1bfb415df25 +04 01 1-176: 0200000000 +04 01 packets b5fd26618458b725 files f195e1bfb415df25 +04 81 1-176: 0200000000 +04 81 packets b5fd26618458b725 files f195e1bfb415df25 +04 ff 1-176: 0200000000 +04 ff packets b5fd26618458b725 files f195e1bfb415df25 +04 00/5a 1-176: 0200000000 +04 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +05 00 1-176: 090078e768 +05 00 packets b5fd26618458b725 files f195e1bfb415df25 +05 01 1-176: 090078e768 +05 01 packets b5fd26618458b725 files f195e1bfb415df25 +05 81 1-176: 090078e768 +05 81 packets b5fd26618458b725 files f195e1bfb415df25 +05 ff 1-176: 090078e768 +05 ff packets b5fd26618458b725 files f195e1bfb415df25 +05 00/5a 1-176: 090078e768 +05 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +06 00 1-4: 0101 +06 00 5-176: 0106 +06 00 packets b5fd26618458b725 files f195e1bfb415df25 +06 01 1-4: 0101 +06 01 5-176: 0106 +06 01 packets b5fd26618458b725 files f195e1bfb415df25 +06 81 1-4: 0101 +06 81 5-176: 00 [T] +06 81 packets b5fd26618458b725 files f195e1bfb415df25 +06 ff 1-4: 0101 +06 ff 5-176: 00 [T] +06 ff packets b5fd26618458b725 files f195e1bfb415df25 +06 00/5a 1-4: 0101 +06 00/5a 5-176: 0106 +06 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +07 00 1-176: 00 [Q] +07 00 packets 8200a34eb6e907e5 files f195e1bfb415df25 +07 01 1-176: 00 [Q] +07 01 packets f4d08250bd74ae66 files f195e1bfb415df25 +07 81 1-176: 00 [Q] +07 81 packets 8200a34eb6e907e5 files f195e1bfb415df25 +07 ff 1-176: 00 [Q] +07 ff packets 8200a34eb6e907e5 files f195e1bfb415df25 +07 00/5a 1-176: 00 [Q] +07 00/5a packets 8200a34eb6e907e5 files f195e1bfb415df25 +08 00 1: 0101 +08 00 2-176: 00 [F] +08 00 packets b5fd26618458b725 files c7dcd4ae421fbfe6 +08 01 1: 0101 +08 01 2-176: 00 [F] +08 01 packets b5fd26618458b725 files adbb082f9a3cda76 +08 81 1: 0101 +08 81 2-176: 00 [F] +08 81 packets b5fd26618458b725 files d1e96f24ee203876 +08 ff 1: 0101 +08 ff 2-176: 00 [F] +08 ff packets b5fd26618458b725 files 9894030443024b26 +08 00/5a 1: 0101 +08 00/5a 2-176: 00 [F] +08 00/5a packets b5fd26618458b725 files c7dcd4ae421fbfe6 +09 00 1-135: 0101 +09 00 136-176: 00 +09 00 packets b5fd26618458b725 files f195e1bfb415df25 +09 01 1-135: 0101 +09 01 136-176: 00 +09 01 packets b5fd26618458b725 files f195e1bfb415df25 +09 81 1-135: 0101 +09 81 136-176: 00 +09 81 packets b5fd26618458b725 files f195e1bfb415df25 +09 ff 1-135: 0101 +09 ff 136-176: 00 +09 ff packets b5fd26618458b725 files f195e1bfb415df25 +09 00/5a 1-135: 0101 +09 00/5a 136-176: 00 +09 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +0a 00 1-176: 0a +0a 00 packets b5fd26618458b725 files f195e1bfb415df25 +0a 01 1-176: 0a +0a 01 packets b5fd26618458b725 files f195e1bfb415df25 +0a 81 1-176: 0a +0a 81 packets b5fd26618458b725 files f195e1bfb415df25 +0a ff 1-176: 0a +0a ff packets b5fd26618458b725 files f195e1bfb415df25 +0a 00/5a 1-176: 0a +0a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +0b 00 1-10: 0101 +0b 00 11-176: 0106 +0b 00 packets b5fd26618458b725 files f195e1bfb415df25 +0b 01 1-10: 0101 +0b 01 11-176: 0106 +0b 01 packets b5fd26618458b725 files f195e1bfb415df25 +0b 81 1-10: 0101 +0b 81 11-176: 0106 +0b 81 packets b5fd26618458b725 files f195e1bfb415df25 +0b ff 1-10: 0101 +0b ff 11-176: 0106 +0b ff packets b5fd26618458b725 files f195e1bfb415df25 +0b 00/5a 1-10: 0101 +0b 00/5a 11-176: 0106 +0b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +0c 00 1: 0101 +0c 00 2-176: 00 [FW] +0c 00 packets b5fd26618458b725 files e50d92948a033b33 +0c 01 1: 0101 +0c 01 2-176: 00 [FW] +0c 01 packets b5fd26618458b725 files bbd3e4d2730e5290 +0c 81 1: 0101 +0c 81 2-176: 0106 +0c 81 packets b5fd26618458b725 files f195e1bfb415df25 +0c ff 1: 0101 +0c ff 2-176: 00 [FW] +0c ff packets b5fd26618458b725 files 35799e75500886fe +0c 00/5a 1: 0101 +0c 00/5a 2-176: 00 [FW] +0c 00/5a packets b5fd26618458b725 files e50d92948a033b33 +0d 00 1-32: 0101 +0d 00 33-176: 00 +0d 00 packets b5fd26618458b725 files f195e1bfb415df25 +0d 01 1-32: 0101 +0d 01 33-176: 0102 +0d 01 packets b5fd26618458b725 files f195e1bfb415df25 +0d 81 1-32: 0101 +0d 81 33-176: 0102 +0d 81 packets b5fd26618458b725 files f195e1bfb415df25 +0d ff 1-32: 0101 +0d ff 33-176: 0102 +0d ff packets b5fd26618458b725 files f195e1bfb415df25 +0d 00/5a 1-32: 0101 +0d 00/5a 33-176: 00 +0d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +0e 00 1-8: 0101 +0e 00 9-176: 00 [F] +0e 00 packets b5fd26618458b725 files fd4cd485ec94d53d +0e 01 1-8: 0101 +0e 01 9-176: 00 [F] +0e 01 packets b5fd26618458b725 files 048e87c2142cb11d +0e 81 1-8: 0101 +0e 81 9-176: 0106 +0e 81 packets b5fd26618458b725 files f195e1bfb415df25 +0e ff 1-8: 0101 +0e ff 9-176: 00 [F] +0e ff packets b5fd26618458b725 files ee302cd8197b759d +0e 00/5a 1-8: 0101 +0e 00/5a 9-176: 00 [F] +0e 00/5a packets b5fd26618458b725 files fd4cd485ec94d53d +0f 00 1-32: 0101 +0f 00 33-176: 00 +0f 00 packets b5fd26618458b725 files f195e1bfb415df25 +0f 01 1-32: 0101 +0f 01 33-176: 0102 +0f 01 packets b5fd26618458b725 files f195e1bfb415df25 +0f 81 1-32: 0101 +0f 81 33-176: 0102 +0f 81 packets b5fd26618458b725 files f195e1bfb415df25 +0f ff 1-32: 0101 +0f ff 33-176: 0102 +0f ff packets b5fd26618458b725 files f195e1bfb415df25 +0f 00/5a 1-32: 0101 +0f 00/5a 33-176: 00 +0f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +10 00 1-32: 0101 +10 00 33-176: 0103 +10 00 packets b5fd26618458b725 files f195e1bfb415df25 +10 01 1-32: 0101 +10 01 33-176: 0102 +10 01 packets b5fd26618458b725 files f195e1bfb415df25 +10 81 1-32: 0101 +10 81 33-176: 0102 +10 81 packets b5fd26618458b725 files f195e1bfb415df25 +10 ff 1-32: 0101 +10 ff 33-176: 0102 +10 ff packets b5fd26618458b725 files f195e1bfb415df25 +10 00/5a 1-32: 0101 +10 00/5a 33-176: 0103 +10 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +11 00 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 00 33-176: 0102 +11 00 packets b5fd26618458b725 files f195e1bfb415df25 +11 01 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 01 33-176: 0102 +11 01 packets b5fd26618458b725 files f195e1bfb415df25 +11 81 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 81 33-176: 0102 +11 81 packets b5fd26618458b725 files f195e1bfb415df25 +11 ff 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 ff 33-176: 0102 +11 ff packets b5fd26618458b725 files f195e1bfb415df25 +11 00/5a 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 00/5a 33-176: 0102 +11 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +12 00 1-98: 0101 +12 00 99-176: 0106 +12 00 packets b5fd26618458b725 files f195e1bfb415df25 +12 01 1-98: 0101 +12 01 99-176: 0106 +12 01 packets b5fd26618458b725 files f195e1bfb415df25 +12 81 1-98: 0101 +12 81 99-176: 0106 +12 81 packets b5fd26618458b725 files f195e1bfb415df25 +12 ff 1-98: 0101 +12 ff 99-176: 0106 +12 ff packets b5fd26618458b725 files f195e1bfb415df25 +12 00/5a 1-98: 0101 +12 00/5a 99-176: 0106 +12 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +13 00 1-176: 0101 +13 00 packets b5fd26618458b725 files f195e1bfb415df25 +13 01 1-176: 0101 +13 01 packets b5fd26618458b725 files f195e1bfb415df25 +13 81 1-176: 0101 +13 81 packets b5fd26618458b725 files f195e1bfb415df25 +13 ff 1-176: 0101 +13 ff packets b5fd26618458b725 files f195e1bfb415df25 +13 00/5a 1-176: 0101 +13 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +14 00 1-176: 0c04100000000000020000 +14 00 packets b5fd26618458b725 files f195e1bfb415df25 +14 01 1-176: 0c04100000000000020000 +14 01 packets b5fd26618458b725 files f195e1bfb415df25 +14 81 1-176: 0c04100000000000020000 +14 81 packets b5fd26618458b725 files f195e1bfb415df25 +14 ff 1-176: 0c04100000000000020000 +14 ff packets b5fd26618458b725 files f195e1bfb415df25 +14 00/5a 1-176: 0c04100000000000020000 +14 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +15 00 1-8: 0101 +15 00 9-176: 00 [F] +15 00 packets b5fd26618458b725 files e6c8083d5c7fa2dd +15 01 1-8: 0101 +15 01 9-176: 00 [F] +15 01 packets b5fd26618458b725 files 2e96465e78984c3d +15 81 1-8: 0101 +15 81 9-176: 00 [F] +15 81 packets b5fd26618458b725 files 7bbd7ec3a51439bd +15 ff 1-8: 0101 +15 ff 9-176: 00 [F] +15 ff packets b5fd26618458b725 files e96ba48c6c34d11d +15 00/5a 1-8: 0101 +15 00/5a 9-176: 00 [F] +15 00/5a packets b5fd26618458b725 files e6c8083d5c7fa2dd +16 00 1: 0101 +16 00 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 00 packets b5fd26618458b725 files f195e1bfb415df25 +16 01 1: 0101 +16 01 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 01 packets b5fd26618458b725 files f195e1bfb415df25 +16 81 1: 0101 +16 81 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 81 packets b5fd26618458b725 files f195e1bfb415df25 +16 ff 1: 0101 +16 ff 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 ff packets b5fd26618458b725 files f195e1bfb415df25 +16 00/5a 1: 0101 +16 00/5a 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +17 00 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 00 packets b5fd26618458b725 files f195e1bfb415df25 +17 01 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 01 packets b5fd26618458b725 files f195e1bfb415df25 +17 81 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 81 packets b5fd26618458b725 files f195e1bfb415df25 +17 ff 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 ff packets b5fd26618458b725 files f195e1bfb415df25 +17 00/5a 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +18 00 1-64: 0101 +18 00 65-176: 00 [F] +18 00 packets b5fd26618458b725 files 53e1cf9bb9c40a85 +18 01 1-64: 0101 +18 01 65-176: 00 [F] +18 01 packets b5fd26618458b725 files 4230f4c26f12ea85 +18 81 1-64: 0101 +18 81 65-176: 00 [F] +18 81 packets b5fd26618458b725 files b025383cc18e6a85 +18 ff 1-64: 0101 +18 ff 65-176: 00 [F] +18 ff packets b5fd26618458b725 files 63767c7047cbb285 +18 00/5a 1-64: 0101 +18 00/5a 65-176: 00 [F] +18 00/5a packets b5fd26618458b725 files 53e1cf9bb9c40a85 +19 00 1-5: 0101 +19 00 6-176: 00 [Q] +19 00 packets e362c0b056690971 files f195e1bfb415df25 +19 01 1-5: 0101 +19 01 6: 0106 +19 01 7-176: 00 [Q] +19 01 packets fed9c7f995ac7792 files f195e1bfb415df25 +19 81 1-5: 0101 +19 81 6-8: 0106 +19 81 9-176: 00 [Q] +19 81 packets 1a7cefb61ee8c8a1 files f195e1bfb415df25 +19 ff 1-176: 0101 +19 ff packets b5fd26618458b725 files f195e1bfb415df25 +19 00/5a 1-5: 0101 +19 00/5a 6-176: 00 [Q] +19 00/5a packets e362c0b056690971 files f195e1bfb415df25 +1a 00 1-32: 0101 +1a 00 33-176: 0600000000009e340000 [Q] +1a 00 packets e9816169697575c5 files f195e1bfb415df25 +1a 01 1-32: 0101 +1a 01 33-176: 0102 +1a 01 packets b5fd26618458b725 files f195e1bfb415df25 +1a 81 1-32: 0101 +1a 81 33-176: 0102 +1a 81 packets b5fd26618458b725 files f195e1bfb415df25 +1a ff 1-32: 0101 +1a ff 33-176: 0102 +1a ff packets b5fd26618458b725 files f195e1bfb415df25 +1a 00/5a 1-32: 0101 +1a 00/5a 33-176: 0600000000009e340000 [Q] +1a 00/5a packets e9816169697575c5 files f195e1bfb415df25 +1b 00 1-32: 0101 +1b 00 33-176: 06000078e768bc190000 [Q] +1b 00 packets c1a82a1e1bae0905 files f195e1bfb415df25 +1b 01 1-32: 0101 +1b 01 33-176: 0102 +1b 01 packets b5fd26618458b725 files f195e1bfb415df25 +1b 81 1-32: 0101 +1b 81 33-176: 0102 +1b 81 packets b5fd26618458b725 files f195e1bfb415df25 +1b ff 1-32: 0101 +1b ff 33-176: 0102 +1b ff packets b5fd26618458b725 files f195e1bfb415df25 +1b 00/5a 1-32: 0101 +1b 00/5a 33-176: 06000078e768bc190000 [Q] +1b 00/5a packets c1a82a1e1bae0905 files f195e1bfb415df25 +1c 00 1-32: 0101 +1c 00 33-176: 0102 +1c 00 packets b5fd26618458b725 files f195e1bfb415df25 +1c 01 1-32: 0101 +1c 01 33-176: 0102 +1c 01 packets b5fd26618458b725 files f195e1bfb415df25 +1c 81 1-32: 0101 +1c 81 33-176: 0102 +1c 81 packets b5fd26618458b725 files f195e1bfb415df25 +1c ff 1-32: 0101 +1c ff 33-176: 0102 +1c ff packets b5fd26618458b725 files f195e1bfb415df25 +1c 00/5a 1-32: 0101 +1c 00/5a 33-176: 0102 +1c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +1d 00 1-32: 0101 +1d 00 33-176: 00 +1d 00 packets b5fd26618458b725 files f195e1bfb415df25 +1d 01 1-32: 0101 +1d 01 33-176: 00 +1d 01 packets b5fd26618458b725 files f195e1bfb415df25 +1d 81 1-32: 0101 +1d 81 33-176: 00 +1d 81 packets b5fd26618458b725 files f195e1bfb415df25 +1d ff 1-32: 0101 +1d ff 33-176: 00 +1d ff packets b5fd26618458b725 files f195e1bfb415df25 +1d 00/5a 1-32: 0101 +1d 00/5a 33-176: 00 +1d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +1e 00 1-32: 0101 +1e 00 33-176: 03000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +1e 00 packets b5fd26618458b725 files f195e1bfb415df25 +1e 01 1-32: 0101 +1e 01 33-176: 0102 +1e 01 packets b5fd26618458b725 files f195e1bfb415df25 +1e 81 1-32: 0101 +1e 81 33-176: 0102 +1e 81 packets b5fd26618458b725 files f195e1bfb415df25 +1e ff 1-32: 0101 +1e ff 33-176: 0102 +1e ff packets b5fd26618458b725 files f195e1bfb415df25 +1e 00/5a 1-32: 0101 +1e 00/5a 33-176: 03000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +1e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +1f 00 1: 0101 +1f 00 2-176: 12005075626c696300000000000000000000000000000000000000000000000000008b3387e9c5cdea6ac9e5edbaa115cd72 +1f 00 packets b5fd26618458b725 files f195e1bfb415df25 +1f 01 1: 0101 +1f 01 2-176: 1201000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +1f 01 packets b5fd26618458b725 files f195e1bfb415df25 +1f 81 1: 0101 +1f 81 2-176: 0102 +1f 81 packets b5fd26618458b725 files f195e1bfb415df25 +1f ff 1: 0101 +1f ff 2-176: 0102 +1f ff packets b5fd26618458b725 files f195e1bfb415df25 +1f 00/5a 1: 0101 +1f 00/5a 2-176: 12005075626c696300000000000000000000000000000000000000000000000000008b3387e9c5cdea6ac9e5edbaa115cd72 +1f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +20 00 1-49: 0101 +20 00 50-65: 00 [F] +20 00 66-176: 0101 +20 00 packets b5fd26618458b725 files 434dff1aba15f905 +20 01 1-49: 0101 +20 01 50-65: 00 [F] +20 01 66-176: 0101 +20 01 packets b5fd26618458b725 files 6848feb295978b45 +20 81 1-49: 0101 +20 81 50-65: 0102 +20 81 66-176: 0101 +20 81 packets b5fd26618458b725 files f195e1bfb415df25 +20 ff 1-49: 0101 +20 ff 50-65: 0102 +20 ff 66-176: 0101 +20 ff packets b5fd26618458b725 files f195e1bfb415df25 +20 00/5a 1-49: 0101 +20 00/5a 50-65: 00 [F] +20 00/5a 66-176: 0101 +20 00/5a packets b5fd26618458b725 files 434dff1aba15f905 +21 00 1-176: 130000200000 +21 00 packets b5fd26618458b725 files f195e1bfb415df25 +21 01 1-176: 130000200000 +21 01 packets b5fd26618458b725 files f195e1bfb415df25 +21 81 1-176: 130000200000 +21 81 packets b5fd26618458b725 files f195e1bfb415df25 +21 ff 1-176: 130000200000 +21 ff packets b5fd26618458b725 files f195e1bfb415df25 +21 00/5a 1-176: 130000200000 +21 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +22 00 1: 0101 +22 00 2-176: 0104 +22 00 packets b5fd26618458b725 files f195e1bfb415df25 +22 01 1: 0101 +22 01 2-176: 0104 +22 01 packets b5fd26618458b725 files f195e1bfb415df25 +22 81 1: 0101 +22 81 2-176: 0104 +22 81 packets b5fd26618458b725 files f195e1bfb415df25 +22 ff 1: 0101 +22 ff 2-176: 0104 +22 ff packets b5fd26618458b725 files f195e1bfb415df25 +22 00/5a 1: 0101 +22 00/5a 2-176: 0104 +22 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +23 00 1-176: 0104 +23 00 packets b5fd26618458b725 files f195e1bfb415df25 +23 01 1-176: 0104 +23 01 packets b5fd26618458b725 files f195e1bfb415df25 +23 81 1-176: 0104 +23 81 packets b5fd26618458b725 files f195e1bfb415df25 +23 ff 1-176: 0104 +23 ff packets b5fd26618458b725 files f195e1bfb415df25 +23 00/5a 1-176: 0104 +23 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +24 00 1-10: 0101 +24 00 11: 0600000000003c190000 [Q] +24 00 12: 0600000000007a270000 [Q] +24 00 13: 06000000000074370000 [Q] +24 00 14: 0600000000002a490000 [Q] +24 00 15: 0600000000009c5c0000 [Q] +24 00 16: 060000000000ca710000 [Q] +24 00 17: 060000000000b4880000 [Q] +24 00 18: 0600000000005aa10000 [Q] +24 00 19: 060000000000bcbb0000 [Q] +24 00 20: 060000000000dad70000 [Q] +24 00 21: 060000000000b4f50000 [Q] +24 00 22: 0600000000004a150100 [Q] +24 00 23: 0600000000009c360100 [Q] +24 00 24: 060000000000aa590100 [Q] +24 00 25: 060000000000747e0100 [Q] +24 00 26: 060000000000faa40100 [Q] +24 00 27: 0600000000003ccd0100 [Q] +24 00 28: 0600000000003af70100 [Q] +24 00 29: 060000000000f4220200 [Q] +24 00 30: 0600000000006a500200 [Q] +24 00 31: 0600000000009c7f0200 [Q] +24 00 32: 0600000000008ab00200 [Q] +24 00 33: 06000000000034e30200 [Q] +24 00 34: 0600000000009a170300 [Q] +24 00 35: 060000000000bc4d0300 [Q] +24 00 36: 0600000000009a850300 [Q] +24 00 37: 06000000000034bf0300 [Q] +24 00 38: 0600000000008afa0300 [Q] +24 00 39: 0600000000009c370400 [Q] +24 00 40: 0600000000006a760400 [Q] +24 00 41: 060000000000f4b60400 [Q] +24 00 42: 0600000000003af90400 [Q] +24 00 43: 0600000000003c3d0500 [Q] +24 00 44: 060000000000fa820500 [Q] +24 00 45: 06000000000074ca0500 [Q] +24 00 46: 060000000000aa130600 [Q] +24 00 47: 0600000000009c5e0600 [Q] +24 00 48: 0600000000004aab0600 [Q] +24 00 49: 060000000000b4f90600 [Q] +24 00 50: 060000000000da490700 [Q] +24 00 51: 060000000000bc9b0700 [Q] +24 00 52: 0600000000005aef0700 [Q] +24 00 53: 060000000000b4440800 [Q] +24 00 54: 060000000000ca9b0800 [Q] +24 00 55: 0600000000009cf40800 [Q] +24 00 56: 0600000000002a4f0900 [Q] +24 00 57: 06000000000074ab0900 [Q] +24 00 58: 0600000000007a090a00 [Q] +24 00 59: 0600000000003c690a00 [Q] +24 00 60: 060000000000baca0a00 [Q] +24 00 61: 060000000000f42d0b00 [Q] +24 00 62: 060000000000ea920b00 [Q] +24 00 63: 0600000000009cf90b00 [Q] +24 00 64: 0600000000000a620c00 [Q] +24 00 65: 06000000000034cc0c00 [Q] +24 00 66: 0600000000001a380d00 [Q] +24 00 67: 060000000000bca50d00 [Q] +24 00 68: 0600000000001a150e00 [Q] +24 00 69: 06000000000034860e00 [Q] +24 00 70: 0600000000000af90e00 [Q] +24 00 71: 0600000000009c6d0f00 [Q] +24 00 72: 060000000000eae30f00 [Q] +24 00 73: 060000000000f45b1000 [Q] +24 00 74: 0600000000003a440000 [Q] +24 00 75-176: 0106 +24 00 packets 4d57cbd52ed197a5 files f195e1bfb415df25 +24 01 1-10: 0101 +24 01 11: 0106 +24 01 12: 060001010101f81a0000 [Q] +24 01 13: 0106 +24 01 14: 060001010101ae2c0000 [Q] +24 01 15: 0106 +24 01 16: 060001010101dc410000 [Q] +24 01 17: 0106 +24 01 18: 060001010101825a0000 [Q] +24 01 19: 0106 +24 01 20: 060001010101a0760000 [Q] +24 01 21: 0106 +24 01 22: 06000101010136960000 [Q] +24 01 23: 0106 +24 01 24: 06000101010144b90000 [Q] +24 01 25: 0106 +24 01 26: 060001010101cadf0000 [Q] +24 01 27: 0106 +24 01 28: 060001010101c8090100 [Q] +24 01 29: 0106 +24 01 30: 0600010101013e370100 [Q] +24 01 31: 0106 +24 01 32: 0600010101012c680100 [Q] +24 01 33: 0106 +24 01 34: 060001010101929c0100 [Q] +24 01 35: 0106 +24 01 36: 06000101010170d40100 [Q] +24 01 37: 0106 +24 01 38: 060001010101c60f0200 [Q] +24 01 39: 0106 +24 01 40: 060001010101944e0200 [Q] +24 01 41: 0106 +24 01 42: 060001010101da900200 [Q] +24 01 43: 0106 +24 01 44: 06000101010198d60200 [Q] +24 01 45: 0106 +24 01 46: 060001010101ce1f0300 [Q] +24 01 47: 0106 +24 01 48: 0600010101017c6c0300 [Q] +24 01 49: 0106 +24 01 50: 060001010101a2bc0300 [Q] +24 01 51: 0106 +24 01 52: 06000101010140100400 [Q] +24 01 53: 0106 +24 01 54: 06000101010156670400 [Q] +24 01 55: 0106 +24 01 56: 060001010101e4c10400 [Q] +24 01 57: 0106 +24 01 58: 060001010101ea1f0500 [Q] +24 01 59: 0106 +24 01 60: 06000101010168810500 [Q] +24 01 61: 0106 +24 01 62: 0600010101015ee60500 [Q] +24 01 63: 0106 +24 01 64: 060001010101cc4e0600 [Q] +24 01 65: 0106 +24 01 66: 060001010101b2ba0600 [Q] +24 01 67: 0106 +24 01 68: 060001010101102a0700 [Q] +24 01 69: 0106 +24 01 70: 060001010101e69c0700 [Q] +24 01 71: 0106 +24 01 72: 06000101010134130800 [Q] +24 01 73: 0106 +24 01 74: 060001010101fa8c0800 [Q] +24 01 75: 0106 +24 01 76: 060001010101380a0900 [Q] +24 01 77: 0106 +24 01 78: 060001010101ee8a0900 [Q] +24 01 79: 0106 +24 01 80: 0600010101011c0f0a00 [Q] +24 01 81: 0106 +24 01 82: 060001010101c2960a00 [Q] +24 01 83: 0106 +24 01 84: 060001010101e0210b00 [Q] +24 01 85: 0106 +24 01 86: 06000101010176b00b00 [Q] +24 01 87: 0106 +24 01 88: 06000101010184420c00 [Q] +24 01 89: 0106 +24 01 90: 0600010101010ad80c00 [Q] +24 01 91: 0106 +24 01 92: 06000101010108710d00 [Q] +24 01 93: 0106 +24 01 94: 0600010101017e0d0e00 [Q] +24 01 95: 0106 +24 01 96: 0600010101016cad0e00 [Q] +24 01 97: 0106 +24 01 98: 060001010101d2500f00 [Q] +24 01 99: 0106 +24 01 100: 060001010101b0f70f00 [Q] +24 01 101: 0106 +24 01 102: 06000101010106a21000 [Q] +24 01 103: 0106 +24 01 104: 060001010101d44f1100 [Q] +24 01 105: 0106 +24 01 106: 0600010101011a011200 [Q] +24 01 107: 0106 +24 01 108: 060001010101d8b51200 [Q] +24 01 109: 0106 +24 01 110: 0600010101010e6e1300 [Q] +24 01 111: 0106 +24 01 112: 060001010101bc291400 [Q] +24 01 113: 0106 +24 01 114: 060001010101e2e81400 [Q] +24 01 115: 0106 +24 01 116: 06000101010180ab1500 [Q] +24 01 117: 0106 +24 01 118: 06000101010196711600 [Q] +24 01 119: 0106 +24 01 120: 060001010101243b1700 [Q] +24 01 121: 0106 +24 01 122: 0600010101012a081800 [Q] +24 01 123: 0106 +24 01 124: 060001010101a8d81800 [Q] +24 01 125: 0106 +24 01 126: 0600010101019eac1900 [Q] +24 01 127: 0106 +24 01 128: 0600010101010c841a00 [Q] +24 01 129: 0106 +24 01 130: 060001010101f25e1b00 [Q] +24 01 131: 0106 +24 01 132: 060001010101503d1c00 [Q] +24 01 133: 0106 +24 01 134: 060001010101261f1d00 [Q] +24 01 135: 0106 +24 01 136: 06000101010174041e00 [Q] +24 01 137: 0106 +24 01 138: 060001010101ba7b0000 [Q] +24 01 139-176: 0106 +24 01 packets 48df205980681825 files f195e1bfb415df25 +24 81 1-10: 0101 +24 81 11: 0106 +24 81 12: 060081818181f81a0000 [Q] +24 81 13: 0106 +24 81 14: 060081818181ae2c0000 [Q] +24 81 15: 0106 +24 81 16: 060081818181dc410000 [Q] +24 81 17: 0106 +24 81 18: 060081818181825a0000 [Q] +24 81 19: 0106 +24 81 20: 060081818181a0760000 [Q] +24 81 21: 0106 +24 81 22: 06008181818136960000 [Q] +24 81 23: 0106 +24 81 24: 06008181818144b90000 [Q] +24 81 25: 0106 +24 81 26: 060081818181cadf0000 [Q] +24 81 27: 0106 +24 81 28: 060081818181c8090100 [Q] +24 81 29: 0106 +24 81 30: 0600818181813e370100 [Q] +24 81 31: 0106 +24 81 32: 0600818181812c680100 [Q] +24 81 33: 0106 +24 81 34: 060081818181929c0100 [Q] +24 81 35: 0106 +24 81 36: 06008181818170d40100 [Q] +24 81 37: 0106 +24 81 38: 060081818181c60f0200 [Q] +24 81 39: 0106 +24 81 40: 060081818181944e0200 [Q] +24 81 41: 0106 +24 81 42: 060081818181da900200 [Q] +24 81 43: 0106 +24 81 44: 06008181818198d60200 [Q] +24 81 45: 0106 +24 81 46: 060081818181ce1f0300 [Q] +24 81 47: 0106 +24 81 48: 0600818181817c6c0300 [Q] +24 81 49: 0106 +24 81 50: 060081818181a2bc0300 [Q] +24 81 51: 0106 +24 81 52: 06008181818140100400 [Q] +24 81 53: 0106 +24 81 54: 06008181818156670400 [Q] +24 81 55: 0106 +24 81 56: 060081818181e4c10400 [Q] +24 81 57: 0106 +24 81 58: 060081818181ea1f0500 [Q] +24 81 59: 0106 +24 81 60: 06008181818168810500 [Q] +24 81 61: 0106 +24 81 62: 0600818181815ee60500 [Q] +24 81 63: 0106 +24 81 64: 060081818181cc4e0600 [Q] +24 81 65: 0106 +24 81 66: 060081818181b2ba0600 [Q] +24 81 67: 0106 +24 81 68: 060081818181102a0700 [Q] +24 81 69: 0106 +24 81 70: 060081818181e69c0700 [Q] +24 81 71: 0106 +24 81 72: 06008181818134130800 [Q] +24 81 73: 0106 +24 81 74: 060081818181fa8c0800 [Q] +24 81 75: 0106 +24 81 76: 060081818181380a0900 [Q] +24 81 77: 0106 +24 81 78: 060081818181ee8a0900 [Q] +24 81 79: 0106 +24 81 80: 0600818181811c0f0a00 [Q] +24 81 81: 0106 +24 81 82: 060081818181c2960a00 [Q] +24 81 83: 0106 +24 81 84: 060081818181e0210b00 [Q] +24 81 85: 0106 +24 81 86: 06008181818176b00b00 [Q] +24 81 87: 0106 +24 81 88: 06008181818184420c00 [Q] +24 81 89: 0106 +24 81 90: 0600818181810ad80c00 [Q] +24 81 91: 0106 +24 81 92: 06008181818108710d00 [Q] +24 81 93: 0106 +24 81 94: 0600818181817e0d0e00 [Q] +24 81 95: 0106 +24 81 96: 0600818181816cad0e00 [Q] +24 81 97: 0106 +24 81 98: 060081818181d2500f00 [Q] +24 81 99: 0106 +24 81 100: 060081818181b0f70f00 [Q] +24 81 101: 0106 +24 81 102: 06008181818106a21000 [Q] +24 81 103: 0106 +24 81 104: 060081818181d44f1100 [Q] +24 81 105: 0106 +24 81 106: 0600818181811a011200 [Q] +24 81 107: 0106 +24 81 108: 060081818181d8b51200 [Q] +24 81 109: 0106 +24 81 110: 0600818181810e6e1300 [Q] +24 81 111: 0106 +24 81 112: 060081818181bc291400 [Q] +24 81 113: 0106 +24 81 114: 060081818181e2e81400 [Q] +24 81 115: 0106 +24 81 116: 06008181818180ab1500 [Q] +24 81 117: 0106 +24 81 118: 06008181818196711600 [Q] +24 81 119: 0106 +24 81 120: 060081818181243b1700 [Q] +24 81 121: 0106 +24 81 122: 0600818181812a081800 [Q] +24 81 123: 0106 +24 81 124: 060081818181a8d81800 [Q] +24 81 125: 0106 +24 81 126: 0600818181819eac1900 [Q] +24 81 127: 0106 +24 81 128: 0600818181810c841a00 [Q] +24 81 129: 0106 +24 81 130: 060081818181f25e1b00 [Q] +24 81 131: 0106 +24 81 132: 060081818181503d1c00 [Q] +24 81 133: 0106 +24 81 134: 060081818181261f1d00 [Q] +24 81 135: 0106 +24 81 136: 06008181818174041e00 [Q] +24 81 137: 0106 +24 81 138: 060081818181ba7b0000 [Q] +24 81 139-176: 0106 +24 81 packets 10b7b541e9b0c725 files f195e1bfb415df25 +24 ff 1-10: 0101 +24 ff 11-17: 0106 +24 ff 18: 0600ffffffff60250000 [Q] +24 ff 19-25: 0106 +24 ff 26: 0600ffffffffe64b0000 [Q] +24 ff 27-33: 0106 +24 ff 34: 0600ffffffff4c800000 [Q] +24 ff 35-41: 0106 +24 ff 42: 0600ffffffff92c20000 [Q] +24 ff 43-49: 0106 +24 ff 50: 0600ffffffffb8120100 [Q] +24 ff 51-57: 0106 +24 ff 58: 0600ffffffffbe700100 [Q] +24 ff 59-65: 0106 +24 ff 66: 0600ffffffffa4dc0100 [Q] +24 ff 67-73: 0106 +24 ff 74: 0600ffffffff6a560200 [Q] +24 ff 75-81: 0106 +24 ff 82: 0600ffffffff10de0200 [Q] +24 ff 83-89: 0106 +24 ff 90: 0600ffffffff96730300 [Q] +24 ff 91-97: 0106 +24 ff 98: 0600fffffffffc160400 [Q] +24 ff 99-105: 0106 +24 ff 106: 0600ffffffff42c80400 [Q] +24 ff 107-113: 0106 +24 ff 114: 0600ffffffff68870500 [Q] +24 ff 115-121: 0106 +24 ff 122: 0600ffffffff6e540600 [Q] +24 ff 123-129: 0106 +24 ff 130: 0600ffffffff542f0700 [Q] +24 ff 131-137: 0106 +24 ff 138: 0600ffffffff1a180800 [Q] +24 ff 139-145: 0106 +24 ff 146: 0600ffffffffc00e0900 [Q] +24 ff 147-153: 0106 +24 ff 154: 0600ffffffff46130a00 [Q] +24 ff 155-161: 0106 +24 ff 162: 0600ffffffffac250b00 [Q] +24 ff 163-169: 0106 +24 ff 170: 0600fffffffff2450c00 [Q] +24 ff 171-176: 0106 +24 ff packets 8a5cb1e2a44f0ca9 files f195e1bfb415df25 +24 00/5a 1-10: 0101 +24 00/5a 11: 0600000000003c190000 [Q] +24 00/5a 12: 0600000000007a270000 [Q] +24 00/5a 13: 06000000000074370000 [Q] +24 00/5a 14: 0600000000002a490000 [Q] +24 00/5a 15: 0600000000009c5c0000 [Q] +24 00/5a 16: 060000000000ca710000 [Q] +24 00/5a 17: 060000000000b4880000 [Q] +24 00/5a 18: 0600000000005aa10000 [Q] +24 00/5a 19: 060000000000bcbb0000 [Q] +24 00/5a 20: 060000000000dad70000 [Q] +24 00/5a 21: 060000000000b4f50000 [Q] +24 00/5a 22: 0600000000004a150100 [Q] +24 00/5a 23: 0600000000009c360100 [Q] +24 00/5a 24: 060000000000aa590100 [Q] +24 00/5a 25: 060000000000747e0100 [Q] +24 00/5a 26: 060000000000faa40100 [Q] +24 00/5a 27: 0600000000003ccd0100 [Q] +24 00/5a 28: 0600000000003af70100 [Q] +24 00/5a 29: 060000000000f4220200 [Q] +24 00/5a 30: 0600000000006a500200 [Q] +24 00/5a 31: 0600000000009c7f0200 [Q] +24 00/5a 32: 0600000000008ab00200 [Q] +24 00/5a 33: 06000000000034e30200 [Q] +24 00/5a 34: 0600000000009a170300 [Q] +24 00/5a 35: 060000000000bc4d0300 [Q] +24 00/5a 36: 0600000000009a850300 [Q] +24 00/5a 37: 06000000000034bf0300 [Q] +24 00/5a 38: 0600000000008afa0300 [Q] +24 00/5a 39: 0600000000009c370400 [Q] +24 00/5a 40: 0600000000006a760400 [Q] +24 00/5a 41: 060000000000f4b60400 [Q] +24 00/5a 42: 0600000000003af90400 [Q] +24 00/5a 43: 0600000000003c3d0500 [Q] +24 00/5a 44: 060000000000fa820500 [Q] +24 00/5a 45: 06000000000074ca0500 [Q] +24 00/5a 46: 060000000000aa130600 [Q] +24 00/5a 47: 0600000000009c5e0600 [Q] +24 00/5a 48: 0600000000004aab0600 [Q] +24 00/5a 49: 060000000000b4f90600 [Q] +24 00/5a 50: 060000000000da490700 [Q] +24 00/5a 51: 060000000000bc9b0700 [Q] +24 00/5a 52: 0600000000005aef0700 [Q] +24 00/5a 53: 060000000000b4440800 [Q] +24 00/5a 54: 060000000000ca9b0800 [Q] +24 00/5a 55: 0600000000009cf40800 [Q] +24 00/5a 56: 0600000000002a4f0900 [Q] +24 00/5a 57: 06000000000074ab0900 [Q] +24 00/5a 58: 0600000000007a090a00 [Q] +24 00/5a 59: 0600000000003c690a00 [Q] +24 00/5a 60: 060000000000baca0a00 [Q] +24 00/5a 61: 060000000000f42d0b00 [Q] +24 00/5a 62: 060000000000ea920b00 [Q] +24 00/5a 63: 0600000000009cf90b00 [Q] +24 00/5a 64: 0600000000000a620c00 [Q] +24 00/5a 65: 06000000000034cc0c00 [Q] +24 00/5a 66: 0600000000001a380d00 [Q] +24 00/5a 67: 060000000000bca50d00 [Q] +24 00/5a 68: 0600000000001a150e00 [Q] +24 00/5a 69: 06000000000034860e00 [Q] +24 00/5a 70: 0600000000000af90e00 [Q] +24 00/5a 71: 0600000000009c6d0f00 [Q] +24 00/5a 72: 060000000000eae30f00 [Q] +24 00/5a 73: 060000000000f45b1000 [Q] +24 00/5a 74: 0600000000003a440000 [Q] +24 00/5a 75-176: 0106 +24 00/5a packets 4d57cbd52ed197a5 files f195e1bfb415df25 +25 00 1-4: 0101 +25 00 5-176: 00 [F] +25 00 packets b5fd26618458b725 files b06f83b83abc3241 +25 01 1-4: 0101 +25 01 5-176: 0106 +25 01 packets b5fd26618458b725 files f195e1bfb415df25 +25 81 1-4: 0101 +25 81 5-176: 0106 +25 81 packets b5fd26618458b725 files f195e1bfb415df25 +25 ff 1-4: 0101 +25 ff 5-176: 0106 +25 ff packets b5fd26618458b725 files f195e1bfb415df25 +25 00/5a 1-4: 0101 +25 00/5a 5-176: 00 [F] +25 00/5a packets b5fd26618458b725 files b06f83b83abc3241 +26 00 1: 0101 +26 00 2-176: 00 [F] +26 00 packets b5fd26618458b725 files 77dbac27de29b1ca +26 01 1: 0101 +26 01 2-176: 00 [F] +26 01 packets b5fd26618458b725 files 34cec5c4b7293b4e +26 81 1: 0101 +26 81 2-176: 00 [F] +26 81 packets b5fd26618458b725 files 10e015bb537a01f8 +26 ff 1: 0101 +26 ff 2-176: 00 [F] +26 ff packets b5fd26618458b725 files ec5902fa4fea213c +26 00/5a 1: 0101 +26 00/5a 2-176: 00 [F] +26 00/5a packets b5fd26618458b725 files 77dbac27de29b1ca +27 00 1-3: 0101 +27 00 4: 8b00a0a1a2a3a4a5 +27 00 5-35: 0101 +27 00 36-176: 06000078e768bc190000 [Q] +27 00 packets c2f2f6c684c175e7 files f195e1bfb415df25 +27 01 1-3: 0101 +27 01 4: 8b00a0a1a2a3a4a5 +27 01 5-35: 0101 +27 01 36-176: 0102 +27 01 packets b5fd26618458b725 files f195e1bfb415df25 +27 81 1-3: 0101 +27 81 4: 8b00a0a1a2a3a4a5 +27 81 5-35: 0101 +27 81 36-176: 0102 +27 81 packets b5fd26618458b725 files f195e1bfb415df25 +27 ff 1-3: 0101 +27 ff 4: 8b00a0a1a2a3a4a5 +27 ff 5-35: 0101 +27 ff 36-176: 0102 +27 ff packets b5fd26618458b725 files f195e1bfb415df25 +27 00/5a 1-3: 0101 +27 00/5a 4: 8b00a0a1a2a3a4a5 +27 00/5a 5-35: 0101 +27 00/5a 36-176: 06000078e768bc190000 [Q] +27 00/5a packets c2f2f6c684c175e7 files f195e1bfb415df25 +28 00 1-176: 15 +28 00 packets b5fd26618458b725 files f195e1bfb415df25 +28 01 1-176: 15 +28 01 packets b5fd26618458b725 files f195e1bfb415df25 +28 81 1-176: 15 +28 81 packets b5fd26618458b725 files f195e1bfb415df25 +28 ff 1-176: 15 +28 ff packets b5fd26618458b725 files f195e1bfb415df25 +28 00/5a 1-176: 15 +28 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +29 00 1-3: 0101 +29 00 4-176: 0106 +29 00 packets b5fd26618458b725 files f195e1bfb415df25 +29 01 1-3: 0101 +29 01 4-176: 0106 +29 01 packets b5fd26618458b725 files f195e1bfb415df25 +29 81 1-3: 0101 +29 81 4-176: 0106 +29 81 packets b5fd26618458b725 files f195e1bfb415df25 +29 ff 1-3: 0101 +29 ff 4-176: 0106 +29 ff packets b5fd26618458b725 files f195e1bfb415df25 +29 00/5a 1-3: 0101 +29 00/5a 4-176: 0106 +29 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2a 00 1-33: 0101 +2a 00 34-176: 160000000000 +2a 00 packets b5fd26618458b725 files f195e1bfb415df25 +2a 01 1-33: 0101 +2a 01 34-176: 0102 +2a 01 packets b5fd26618458b725 files f195e1bfb415df25 +2a 81 1-33: 0101 +2a 81 34-176: 0102 +2a 81 packets b5fd26618458b725 files f195e1bfb415df25 +2a ff 1-33: 0101 +2a ff 34-176: 0102 +2a ff packets b5fd26618458b725 files f195e1bfb415df25 +2a 00/5a 1-33: 0101 +2a 00/5a 34-176: 160000000000 +2a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2b 00 1-176: 1700000000e8030000 +2b 00 packets b5fd26618458b725 files f195e1bfb415df25 +2b 01 1-176: 1700000000e8030000 +2b 01 packets b5fd26618458b725 files f195e1bfb415df25 +2b 81 1-176: 1700000000e8030000 +2b 81 packets b5fd26618458b725 files f195e1bfb415df25 +2b ff 1-176: 1700000000e8030000 +2b ff packets b5fd26618458b725 files f195e1bfb415df25 +2b 00/5a 1-176: 1700000000e8030000 +2b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2c 00 1-176: 0101 +2c 00 packets b5fd26618458b725 files f195e1bfb415df25 +2c 01 1-176: 0101 +2c 01 packets b5fd26618458b725 files f195e1bfb415df25 +2c 81 1-176: 0101 +2c 81 packets b5fd26618458b725 files f195e1bfb415df25 +2c ff 1-176: 0101 +2c ff packets b5fd26618458b725 files f195e1bfb415df25 +2c 00/5a 1-176: 0101 +2c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2d 00 1-176: 0101 +2d 00 packets b5fd26618458b725 files f195e1bfb415df25 +2d 01 1-176: 0101 +2d 01 packets b5fd26618458b725 files f195e1bfb415df25 +2d 81 1-176: 0101 +2d 81 packets b5fd26618458b725 files f195e1bfb415df25 +2d ff 1-176: 0101 +2d ff packets b5fd26618458b725 files f195e1bfb415df25 +2d 00/5a 1-176: 0101 +2d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2e 00 1-176: 0101 +2e 00 packets b5fd26618458b725 files f195e1bfb415df25 +2e 01 1-176: 0101 +2e 01 packets b5fd26618458b725 files f195e1bfb415df25 +2e 81 1-176: 0101 +2e 81 packets b5fd26618458b725 files f195e1bfb415df25 +2e ff 1-176: 0101 +2e ff packets b5fd26618458b725 files f195e1bfb415df25 +2e 00/5a 1-176: 0101 +2e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2f 00 1-176: 0101 +2f 00 packets b5fd26618458b725 files f195e1bfb415df25 +2f 01 1-176: 0101 +2f 01 packets b5fd26618458b725 files f195e1bfb415df25 +2f 81 1-176: 0101 +2f 81 packets b5fd26618458b725 files f195e1bfb415df25 +2f ff 1-176: 0101 +2f ff packets b5fd26618458b725 files f195e1bfb415df25 +2f 00/5a 1-176: 0101 +2f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +30 00 1-176: 0101 +30 00 packets b5fd26618458b725 files f195e1bfb415df25 +30 01 1-176: 0101 +30 01 packets b5fd26618458b725 files f195e1bfb415df25 +30 81 1-176: 0101 +30 81 packets b5fd26618458b725 files f195e1bfb415df25 +30 ff 1-176: 0101 +30 ff packets b5fd26618458b725 files f195e1bfb415df25 +30 00/5a 1-176: 0101 +30 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +31 00 1-176: 0101 +31 00 packets b5fd26618458b725 files f195e1bfb415df25 +31 01 1-176: 0101 +31 01 packets b5fd26618458b725 files f195e1bfb415df25 +31 81 1-176: 0101 +31 81 packets b5fd26618458b725 files f195e1bfb415df25 +31 ff 1-176: 0101 +31 ff packets b5fd26618458b725 files f195e1bfb415df25 +31 00/5a 1-176: 0101 +31 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +32 00 1-33: 0101 +32 00 34-45: 06000078e768bc190000 [Q] +32 00 46-61: 06000078e7689c270000 [Q] +32 00 62-77: 06000078e7687c350000 [Q] +32 00 78-93: 06000078e7685c430000 [Q] +32 00 94-109: 06000078e7683c510000 [Q] +32 00 110-125: 06000078e7681c5f0000 [Q] +32 00 126-141: 06000078e768fc6c0000 [Q] +32 00 142-157: 06000078e768dc7a0000 [Q] +32 00 158-173: 06000078e768bc880000 [Q] +32 00 174-176: 06000078e7689c960000 [Q] +32 00 packets c741a700ce968c41 files f195e1bfb415df25 +32 01 1-33: 0101 +32 01 34-176: 0102 +32 01 packets b5fd26618458b725 files f195e1bfb415df25 +32 81 1-33: 0101 +32 81 34-176: 0102 +32 81 packets b5fd26618458b725 files f195e1bfb415df25 +32 ff 1-33: 0101 +32 ff 34-176: 0102 +32 ff packets b5fd26618458b725 files f195e1bfb415df25 +32 00/5a 1-33: 0101 +32 00/5a 34-45: 06000078e768bc190000 [Q] +32 00/5a 46-61: 06000078e7689c270000 [Q] +32 00/5a 62-77: 06000078e7687c350000 [Q] +32 00/5a 78-93: 06000078e7685c430000 [Q] +32 00/5a 94-109: 06000078e7683c510000 [Q] +32 00/5a 110-125: 06000078e7681c5f0000 [Q] +32 00/5a 126-141: 06000078e768fc6c0000 [Q] +32 00/5a 142-157: 06000078e768dc7a0000 [Q] +32 00/5a 158-173: 06000078e768bc880000 [Q] +32 00/5a 174-176: 06000078e7689c960000 [Q] +32 00/5a packets c741a700ce968c41 files f195e1bfb415df25 +33 00 1-176: 0101 +33 00 packets b5fd26618458b725 files f195e1bfb415df25 +33 01 1-176: 0101 +33 01 packets b5fd26618458b725 files f195e1bfb415df25 +33 81 1-176: 0101 +33 81 packets b5fd26618458b725 files f195e1bfb415df25 +33 ff 1-176: 0101 +33 ff packets b5fd26618458b725 files f195e1bfb415df25 +33 00/5a 1-176: 0101 +33 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +34 00 1-33: 0101 +34 00 34-176: 06010078e768c43e0000 [Q] +34 00 packets 78703c0eba1ea63e files f195e1bfb415df25 +34 01 1-176: 0101 +34 01 packets b5fd26618458b725 files f195e1bfb415df25 +34 81 1-176: 0101 +34 81 packets b5fd26618458b725 files f195e1bfb415df25 +34 ff 1-176: 0101 +34 ff packets b5fd26618458b725 files f195e1bfb415df25 +34 00/5a 1-33: 0101 +34 00/5a 34-176: 06010078e768c43e0000 [Q] +34 00/5a packets 78703c0eba1ea63e files f195e1bfb415df25 +35 00 1-176: 0101 +35 00 packets b5fd26618458b725 files f195e1bfb415df25 +35 01 1-176: 0101 +35 01 packets b5fd26618458b725 files f195e1bfb415df25 +35 81 1-176: 0101 +35 81 packets b5fd26618458b725 files f195e1bfb415df25 +35 ff 1-176: 0101 +35 ff packets b5fd26618458b725 files f195e1bfb415df25 +35 00/5a 1-176: 0101 +35 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +36 00 1: 0101 +36 00 2-176: 00 +36 00 packets b5fd26618458b725 files f195e1bfb415df25 +36 01 1: 0101 +36 01 2-176: 00 +36 01 packets b5fd26618458b725 files f195e1bfb415df25 +36 81 1-176: 0101 +36 81 packets b5fd26618458b725 files f195e1bfb415df25 +36 ff 1-176: 0101 +36 ff packets b5fd26618458b725 files f195e1bfb415df25 +36 00/5a 1: 0101 +36 00/5a 2-176: 00 +36 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +37 00 1-176: 0101 +37 00 packets b5fd26618458b725 files f195e1bfb415df25 +37 01 1-176: 0101 +37 01 packets b5fd26618458b725 files f195e1bfb415df25 +37 81 1: 0101 +37 81 2-176: 00 [Q] +37 81 packets 815d342b1e6f51eb files f195e1bfb415df25 +37 ff 1: 0101 +37 ff 2-176: 00 [Q] +37 ff packets 212d91109c8dd2db files f195e1bfb415df25 +37 00/5a 1-176: 0101 +37 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +38 00 1: 0101 +38 00 2-176: 1800041001000000000000 +38 00 packets b5fd26618458b725 files f195e1bfb415df25 +38 01 1: 0101 +38 01 2-176: 18010000a6140000000000000000 +38 01 packets b5fd26618458b725 files f195e1bfb415df25 +38 81 1: 0101 +38 81 2-176: 0106 +38 81 packets b5fd26618458b725 files f195e1bfb415df25 +38 ff 1: 0101 +38 ff 2-176: 0106 +38 ff packets b5fd26618458b725 files f195e1bfb415df25 +38 00/5a 1: 0101 +38 00/5a 2-176: 1800041001000000000000 +38 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +39 00 1-33: 0101 +39 00 34-45: 06000078e7689e340000 [Q] +39 00 46-61: 06000078e7687e420000 [Q] +39 00 62-77: 06000078e7685e500000 [Q] +39 00 78-93: 06000078e7683e5e0000 [Q] +39 00 94-109: 06000078e7681e6c0000 [Q] +39 00 110-125: 06000078e768fe790000 [Q] +39 00 126-141: 06000078e768de870000 [Q] +39 00 142-157: 06000078e768be950000 [Q] +39 00 158-165: 06000078e7689ea30000 [Q] +39 00 166-176: 0103 +39 00 packets 08dd0aa1fcee62ed files f195e1bfb415df25 +39 01 1-33: 0101 +39 01 34-45: 06000078e7689e340000 [Q] +39 01 46-61: 06000078e7687e420000 [Q] +39 01 62-77: 06000078e7685e500000 [Q] +39 01 78-93: 06000078e7683e5e0000 [Q] +39 01 94-109: 06000078e7681e6c0000 [Q] +39 01 110-125: 06000078e768fe790000 [Q] +39 01 126-141: 06000078e768de870000 [Q] +39 01 142-157: 06000078e768be950000 [Q] +39 01 158-165: 06000078e7689ea30000 [Q] +39 01 166-176: 0103 +39 01 packets f0f32abcc77b7cb1 files f195e1bfb415df25 +39 81 1-33: 0101 +39 81 34-45: 06000078e7689e340000 [Q] +39 81 46-61: 06000078e7687e420000 [Q] +39 81 62-77: 06000078e7685e500000 [Q] +39 81 78-93: 06000078e7683e5e0000 [Q] +39 81 94-109: 06000078e7681e6c0000 [Q] +39 81 110-125: 06000078e768fe790000 [Q] +39 81 126-141: 06000078e768de870000 [Q] +39 81 142-157: 06000078e768be950000 [Q] +39 81 158-165: 06000078e7689ea30000 [Q] +39 81 166-176: 0103 +39 81 packets 6dae462d85136c31 files f195e1bfb415df25 +39 ff 1-33: 0101 +39 ff 34-45: 06000078e7689e340000 [Q] +39 ff 46-61: 06000078e7687e420000 [Q] +39 ff 62-77: 06000078e7685e500000 [Q] +39 ff 78-93: 06000078e7683e5e0000 [Q] +39 ff 94-109: 06000078e7681e6c0000 [Q] +39 ff 110-125: 06000078e768fe790000 [Q] +39 ff 126-141: 06000078e768de870000 [Q] +39 ff 142-157: 06000078e768be950000 [Q] +39 ff 158-165: 06000078e7689ea30000 [Q] +39 ff 166-176: 0103 +39 ff packets 12d26746829f9b71 files f195e1bfb415df25 +39 00/5a 1-33: 0101 +39 00/5a 34-45: 06000078e7689e340000 [Q] +39 00/5a 46-61: 06000078e7687e420000 [Q] +39 00/5a 62-77: 06000078e7685e500000 [Q] +39 00/5a 78-93: 06000078e7683e5e0000 [Q] +39 00/5a 94-109: 06000078e7681e6c0000 [Q] +39 00/5a 110-125: 06000078e768fe790000 [Q] +39 00/5a 126-141: 06000078e768de870000 [Q] +39 00/5a 142-157: 06000078e768be950000 [Q] +39 00/5a 158-165: 06000078e7689ea30000 [Q] +39 00/5a 166-176: 0103 +39 00/5a packets 08dd0aa1fcee62ed files f195e1bfb415df25 +3a 00 1: 0101 +3a 00 2-176: 00 [F] +3a 00 packets b5fd26618458b725 files 77dbac27de29b1ca +3a 01 1: 0101 +3a 01 2-176: 00 [F] +3a 01 packets b5fd26618458b725 files b298525f87cb9bb3 +3a 81 1: 0101 +3a 81 2-176: 00 [F] +3a 81 packets b5fd26618458b725 files 4a341421d4fedde6 +3a ff 1: 0101 +3a ff 2-176: 00 [F] +3a ff packets b5fd26618458b725 files 4858dd4e662df412 +3a 00/5a 1: 0101 +3a 00/5a 2-176: 00 [F] +3a 00/5a packets b5fd26618458b725 files 77dbac27de29b1ca +3b 00 1-176: 190000 +3b 00 packets b5fd26618458b725 files f195e1bfb415df25 +3b 01 1-176: 190000 +3b 01 packets b5fd26618458b725 files f195e1bfb415df25 +3b 81 1-176: 190000 +3b 81 packets b5fd26618458b725 files f195e1bfb415df25 +3b ff 1-176: 190000 +3b ff packets b5fd26618458b725 files f195e1bfb415df25 +3b 00/5a 1-176: 190000 +3b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +3c 00 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c 00 packets b5fd26618458b725 files f195e1bfb415df25 +3c 01 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c 01 packets b5fd26618458b725 files f195e1bfb415df25 +3c 81 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c 81 packets b5fd26618458b725 files f195e1bfb415df25 +3c ff 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c ff packets b5fd26618458b725 files f195e1bfb415df25 +3c 00/5a 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +3d 00 1-2: 0101 +3d 00 3-176: 00 [F] +3d 00 packets b5fd26618458b725 files 60613ecd903e87d3 +3d 01 1-176: 0101 +3d 01 packets b5fd26618458b725 files f195e1bfb415df25 +3d 81 1-176: 0101 +3d 81 packets b5fd26618458b725 files f195e1bfb415df25 +3d ff 1-176: 0101 +3d ff packets b5fd26618458b725 files f195e1bfb415df25 +3d 00/5a 1-2: 0101 +3d 00/5a 3-176: 00 [F] +3d 00/5a packets b5fd26618458b725 files 60613ecd903e87d3 +3e 00 1-176: 0106 +3e 00 packets b5fd26618458b725 files f195e1bfb415df25 +3e 01 1-5: 0106 +3e 01 6-171: 00 [Q] +3e 01 172-176: 0106 +3e 01 packets 13b7d8f1947de6f6 files f195e1bfb415df25 +3e 81 1-7: 0106 +3e 81 8-176: 0102 +3e 81 packets b5fd26618458b725 files f195e1bfb415df25 +3e ff 1-4: 0106 +3e ff 5-176: 0102 +3e ff packets b5fd26618458b725 files f195e1bfb415df25 +3e 00/5a 1-176: 0106 +3e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +3f 00 1-47: 00 [F] +3f 00 48-176: 0106 +3f 00 packets b5fd26618458b725 files d6801517bf6cf4f6 +3f 01 1-47: 00 [F] +3f 01 48-176: 0106 +3f 01 packets b5fd26618458b725 files d6801517bf6cf4f6 +3f 81 1-47: 00 [F] +3f 81 48-176: 0106 +3f 81 packets b5fd26618458b725 files d6801517bf6cf4f6 +3f ff 1-47: 00 [F] +3f ff 48-176: 0106 +3f ff packets b5fd26618458b725 files d6801517bf6cf4f6 +3f 00/5a 1-47: 00 [F] +3f 00/5a 48-176: 0106 +3f 00/5a packets b5fd26618458b725 files d6801517bf6cf4f6 +40 00 1-176: 1c +40 00 packets b5fd26618458b725 files f195e1bfb415df25 +40 01 1-176: 1c +40 01 packets b5fd26618458b725 files f195e1bfb415df25 +40 81 1-176: 1c +40 81 packets b5fd26618458b725 files f195e1bfb415df25 +40 ff 1-176: 1c +40 ff packets b5fd26618458b725 files f195e1bfb415df25 +40 00/5a 1-176: 1c +40 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +41 00 1-3: 0101 +41 00 4-7: 0106 +41 00 8-176: 00 [Q] +41 00 packets 508d0b6c5fa8e95d files f195e1bfb415df25 +41 01 1-3: 0101 +41 01 4: 0106 +41 01 5-176: 00 [Q] +41 01 packets 1d6d5a7c3341c9cf files f195e1bfb415df25 +41 81 1-3: 0101 +41 81 4-176: 0106 +41 81 packets b5fd26618458b725 files f195e1bfb415df25 +41 ff 1-3: 0101 +41 ff 4-176: 0106 +41 ff packets b5fd26618458b725 files f195e1bfb415df25 +41 00/5a 1-3: 0101 +41 00/5a 4-7: 0106 +41 00/5a 8-176: 00 [Q] +41 00/5a packets 508d0b6c5fa8e95d files f195e1bfb415df25 +42 00 1-2: 0101 +42 00 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 00 packets b5fd26618458b725 files f195e1bfb415df25 +42 01 1-2: 0101 +42 01 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 01 packets b5fd26618458b725 files f195e1bfb415df25 +42 81 1-2: 0101 +42 81 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 81 packets b5fd26618458b725 files f195e1bfb415df25 +42 ff 1-2: 0101 +42 ff 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 ff packets b5fd26618458b725 files f195e1bfb415df25 +42 00/5a 1-2: 0101 +42 00/5a 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +43 00 1-176: 0101 +43 00 packets b5fd26618458b725 files f195e1bfb415df25 +43 01 1-176: 0101 +43 01 packets b5fd26618458b725 files f195e1bfb415df25 +43 81 1-176: 0101 +43 81 packets b5fd26618458b725 files f195e1bfb415df25 +43 ff 1-176: 0101 +43 ff packets b5fd26618458b725 files f195e1bfb415df25 +43 00/5a 1-176: 0101 +43 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +44 00 1-176: 0101 +44 00 packets b5fd26618458b725 files f195e1bfb415df25 +44 01 1-176: 0101 +44 01 packets b5fd26618458b725 files f195e1bfb415df25 +44 81 1-176: 0101 +44 81 packets b5fd26618458b725 files f195e1bfb415df25 +44 ff 1-176: 0101 +44 ff packets b5fd26618458b725 files f195e1bfb415df25 +44 00/5a 1-176: 0101 +44 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +45 00 1-176: 0101 +45 00 packets b5fd26618458b725 files f195e1bfb415df25 +45 01 1-176: 0101 +45 01 packets b5fd26618458b725 files f195e1bfb415df25 +45 81 1-176: 0101 +45 81 packets b5fd26618458b725 files f195e1bfb415df25 +45 ff 1-176: 0101 +45 ff packets b5fd26618458b725 files f195e1bfb415df25 +45 00/5a 1-176: 0101 +45 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +46 00 1-176: 0101 +46 00 packets b5fd26618458b725 files f195e1bfb415df25 +46 01 1-176: 0101 +46 01 packets b5fd26618458b725 files f195e1bfb415df25 +46 81 1-176: 0101 +46 81 packets b5fd26618458b725 files f195e1bfb415df25 +46 ff 1-176: 0101 +46 ff packets b5fd26618458b725 files f195e1bfb415df25 +46 00/5a 1-176: 0101 +46 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +47 00 1-176: 0101 +47 00 packets b5fd26618458b725 files f195e1bfb415df25 +47 01 1-176: 0101 +47 01 packets b5fd26618458b725 files f195e1bfb415df25 +47 81 1-176: 0101 +47 81 packets b5fd26618458b725 files f195e1bfb415df25 +47 ff 1-176: 0101 +47 ff packets b5fd26618458b725 files f195e1bfb415df25 +47 00/5a 1-176: 0101 +47 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +48 00 1-176: 0101 +48 00 packets b5fd26618458b725 files f195e1bfb415df25 +48 01 1-176: 0101 +48 01 packets b5fd26618458b725 files f195e1bfb415df25 +48 81 1-176: 0101 +48 81 packets b5fd26618458b725 files f195e1bfb415df25 +48 ff 1-176: 0101 +48 ff packets b5fd26618458b725 files f195e1bfb415df25 +48 00/5a 1-176: 0101 +48 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +49 00 1-176: 0101 +49 00 packets b5fd26618458b725 files f195e1bfb415df25 +49 01 1-176: 0101 +49 01 packets b5fd26618458b725 files f195e1bfb415df25 +49 81 1-176: 0101 +49 81 packets b5fd26618458b725 files f195e1bfb415df25 +49 ff 1-176: 0101 +49 ff packets b5fd26618458b725 files f195e1bfb415df25 +49 00/5a 1-176: 0101 +49 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4a 00 1-176: 0101 +4a 00 packets b5fd26618458b725 files f195e1bfb415df25 +4a 01 1-176: 0101 +4a 01 packets b5fd26618458b725 files f195e1bfb415df25 +4a 81 1-176: 0101 +4a 81 packets b5fd26618458b725 files f195e1bfb415df25 +4a ff 1-176: 0101 +4a ff packets b5fd26618458b725 files f195e1bfb415df25 +4a 00/5a 1-176: 0101 +4a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4b 00 1-176: 0101 +4b 00 packets b5fd26618458b725 files f195e1bfb415df25 +4b 01 1-176: 0101 +4b 01 packets b5fd26618458b725 files f195e1bfb415df25 +4b 81 1-176: 0101 +4b 81 packets b5fd26618458b725 files f195e1bfb415df25 +4b ff 1-176: 0101 +4b ff packets b5fd26618458b725 files f195e1bfb415df25 +4b 00/5a 1-176: 0101 +4b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4c 00 1-176: 0101 +4c 00 packets b5fd26618458b725 files f195e1bfb415df25 +4c 01 1-176: 0101 +4c 01 packets b5fd26618458b725 files f195e1bfb415df25 +4c 81 1-176: 0101 +4c 81 packets b5fd26618458b725 files f195e1bfb415df25 +4c ff 1-176: 0101 +4c ff packets b5fd26618458b725 files f195e1bfb415df25 +4c 00/5a 1-176: 0101 +4c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4d 00 1-176: 0101 +4d 00 packets b5fd26618458b725 files f195e1bfb415df25 +4d 01 1-176: 0101 +4d 01 packets b5fd26618458b725 files f195e1bfb415df25 +4d 81 1-176: 0101 +4d 81 packets b5fd26618458b725 files f195e1bfb415df25 +4d ff 1-176: 0101 +4d ff packets b5fd26618458b725 files f195e1bfb415df25 +4d 00/5a 1-176: 0101 +4d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4e 00 1-176: 0101 +4e 00 packets b5fd26618458b725 files f195e1bfb415df25 +4e 01 1-176: 0101 +4e 01 packets b5fd26618458b725 files f195e1bfb415df25 +4e 81 1-176: 0101 +4e 81 packets b5fd26618458b725 files f195e1bfb415df25 +4e ff 1-176: 0101 +4e ff packets b5fd26618458b725 files f195e1bfb415df25 +4e 00/5a 1-176: 0101 +4e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4f 00 1-176: 0101 +4f 00 packets b5fd26618458b725 files f195e1bfb415df25 +4f 01 1-176: 0101 +4f 01 packets b5fd26618458b725 files f195e1bfb415df25 +4f 81 1-176: 0101 +4f 81 packets b5fd26618458b725 files f195e1bfb415df25 +4f ff 1-176: 0101 +4f ff packets b5fd26618458b725 files f195e1bfb415df25 +4f 00/5a 1-176: 0101 +4f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +50 00 1-176: 0101 +50 00 packets b5fd26618458b725 files f195e1bfb415df25 +50 01 1-176: 0101 +50 01 packets b5fd26618458b725 files f195e1bfb415df25 +50 81 1-176: 0101 +50 81 packets b5fd26618458b725 files f195e1bfb415df25 +50 ff 1-176: 0101 +50 ff packets b5fd26618458b725 files f195e1bfb415df25 +50 00/5a 1-176: 0101 +50 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +51 00 1-176: 0101 +51 00 packets b5fd26618458b725 files f195e1bfb415df25 +51 01 1-176: 0101 +51 01 packets b5fd26618458b725 files f195e1bfb415df25 +51 81 1-176: 0101 +51 81 packets b5fd26618458b725 files f195e1bfb415df25 +51 ff 1-176: 0101 +51 ff packets b5fd26618458b725 files f195e1bfb415df25 +51 00/5a 1-176: 0101 +51 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +52 00 1-176: 0101 +52 00 packets b5fd26618458b725 files f195e1bfb415df25 +52 01 1-176: 0101 +52 01 packets b5fd26618458b725 files f195e1bfb415df25 +52 81 1-176: 0101 +52 81 packets b5fd26618458b725 files f195e1bfb415df25 +52 ff 1-176: 0101 +52 ff packets b5fd26618458b725 files f195e1bfb415df25 +52 00/5a 1-176: 0101 +52 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +53 00 1-176: 0101 +53 00 packets b5fd26618458b725 files f195e1bfb415df25 +53 01 1-176: 0101 +53 01 packets b5fd26618458b725 files f195e1bfb415df25 +53 81 1-176: 0101 +53 81 packets b5fd26618458b725 files f195e1bfb415df25 +53 ff 1-176: 0101 +53 ff packets b5fd26618458b725 files f195e1bfb415df25 +53 00/5a 1-176: 0101 +53 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +54 00 1-176: 0101 +54 00 packets b5fd26618458b725 files f195e1bfb415df25 +54 01 1-176: 0101 +54 01 packets b5fd26618458b725 files f195e1bfb415df25 +54 81 1-176: 0101 +54 81 packets b5fd26618458b725 files f195e1bfb415df25 +54 ff 1-176: 0101 +54 ff packets b5fd26618458b725 files f195e1bfb415df25 +54 00/5a 1-176: 0101 +54 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +55 00 1-176: 0101 +55 00 packets b5fd26618458b725 files f195e1bfb415df25 +55 01 1-176: 0101 +55 01 packets b5fd26618458b725 files f195e1bfb415df25 +55 81 1-176: 0101 +55 81 packets b5fd26618458b725 files f195e1bfb415df25 +55 ff 1-176: 0101 +55 ff packets b5fd26618458b725 files f195e1bfb415df25 +55 00/5a 1-176: 0101 +55 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +56 00 1-176: 0101 +56 00 packets b5fd26618458b725 files f195e1bfb415df25 +56 01 1-176: 0101 +56 01 packets b5fd26618458b725 files f195e1bfb415df25 +56 81 1-176: 0101 +56 81 packets b5fd26618458b725 files f195e1bfb415df25 +56 ff 1-176: 0101 +56 ff packets b5fd26618458b725 files f195e1bfb415df25 +56 00/5a 1-176: 0101 +56 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +57 00 1-176: 0101 +57 00 packets b5fd26618458b725 files f195e1bfb415df25 +57 01 1-176: 0101 +57 01 packets b5fd26618458b725 files f195e1bfb415df25 +57 81 1-176: 0101 +57 81 packets b5fd26618458b725 files f195e1bfb415df25 +57 ff 1-176: 0101 +57 ff packets b5fd26618458b725 files f195e1bfb415df25 +57 00/5a 1-176: 0101 +57 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +58 00 1-176: 0101 +58 00 packets b5fd26618458b725 files f195e1bfb415df25 +58 01 1-176: 0101 +58 01 packets b5fd26618458b725 files f195e1bfb415df25 +58 81 1-176: 0101 +58 81 packets b5fd26618458b725 files f195e1bfb415df25 +58 ff 1-176: 0101 +58 ff packets b5fd26618458b725 files f195e1bfb415df25 +58 00/5a 1-176: 0101 +58 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +59 00 1-176: 0101 +59 00 packets b5fd26618458b725 files f195e1bfb415df25 +59 01 1-176: 0101 +59 01 packets b5fd26618458b725 files f195e1bfb415df25 +59 81 1-176: 0101 +59 81 packets b5fd26618458b725 files f195e1bfb415df25 +59 ff 1-176: 0101 +59 ff packets b5fd26618458b725 files f195e1bfb415df25 +59 00/5a 1-176: 0101 +59 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5a 00 1-176: 0101 +5a 00 packets b5fd26618458b725 files f195e1bfb415df25 +5a 01 1-176: 0101 +5a 01 packets b5fd26618458b725 files f195e1bfb415df25 +5a 81 1-176: 0101 +5a 81 packets b5fd26618458b725 files f195e1bfb415df25 +5a ff 1-176: 0101 +5a ff packets b5fd26618458b725 files f195e1bfb415df25 +5a 00/5a 1-176: 0101 +5a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5b 00 1-176: 0101 +5b 00 packets b5fd26618458b725 files f195e1bfb415df25 +5b 01 1-176: 0101 +5b 01 packets b5fd26618458b725 files f195e1bfb415df25 +5b 81 1-176: 0101 +5b 81 packets b5fd26618458b725 files f195e1bfb415df25 +5b ff 1-176: 0101 +5b ff packets b5fd26618458b725 files f195e1bfb415df25 +5b 00/5a 1-176: 0101 +5b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5c 00 1-176: 0101 +5c 00 packets b5fd26618458b725 files f195e1bfb415df25 +5c 01 1-176: 0101 +5c 01 packets b5fd26618458b725 files f195e1bfb415df25 +5c 81 1-176: 0101 +5c 81 packets b5fd26618458b725 files f195e1bfb415df25 +5c ff 1-176: 0101 +5c ff packets b5fd26618458b725 files f195e1bfb415df25 +5c 00/5a 1-176: 0101 +5c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5d 00 1-176: 0101 +5d 00 packets b5fd26618458b725 files f195e1bfb415df25 +5d 01 1-176: 0101 +5d 01 packets b5fd26618458b725 files f195e1bfb415df25 +5d 81 1-176: 0101 +5d 81 packets b5fd26618458b725 files f195e1bfb415df25 +5d ff 1-176: 0101 +5d ff packets b5fd26618458b725 files f195e1bfb415df25 +5d 00/5a 1-176: 0101 +5d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5e 00 1-176: 0101 +5e 00 packets b5fd26618458b725 files f195e1bfb415df25 +5e 01 1-176: 0101 +5e 01 packets b5fd26618458b725 files f195e1bfb415df25 +5e 81 1-176: 0101 +5e 81 packets b5fd26618458b725 files f195e1bfb415df25 +5e ff 1-176: 0101 +5e ff packets b5fd26618458b725 files f195e1bfb415df25 +5e 00/5a 1-176: 0101 +5e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5f 00 1-176: 0101 +5f 00 packets b5fd26618458b725 files f195e1bfb415df25 +5f 01 1-176: 0101 +5f 01 packets b5fd26618458b725 files f195e1bfb415df25 +5f 81 1-176: 0101 +5f 81 packets b5fd26618458b725 files f195e1bfb415df25 +5f ff 1-176: 0101 +5f ff packets b5fd26618458b725 files f195e1bfb415df25 +5f 00/5a 1-176: 0101 +5f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +60 00 1-176: 0101 +60 00 packets b5fd26618458b725 files f195e1bfb415df25 +60 01 1-176: 0101 +60 01 packets b5fd26618458b725 files f195e1bfb415df25 +60 81 1-176: 0101 +60 81 packets b5fd26618458b725 files f195e1bfb415df25 +60 ff 1-176: 0101 +60 ff packets b5fd26618458b725 files f195e1bfb415df25 +60 00/5a 1-176: 0101 +60 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +61 00 1-176: 0101 +61 00 packets b5fd26618458b725 files f195e1bfb415df25 +61 01 1-176: 0101 +61 01 packets b5fd26618458b725 files f195e1bfb415df25 +61 81 1-176: 0101 +61 81 packets b5fd26618458b725 files f195e1bfb415df25 +61 ff 1-176: 0101 +61 ff packets b5fd26618458b725 files f195e1bfb415df25 +61 00/5a 1-176: 0101 +61 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +62 00 1-176: 0101 +62 00 packets b5fd26618458b725 files f195e1bfb415df25 +62 01 1-176: 0101 +62 01 packets b5fd26618458b725 files f195e1bfb415df25 +62 81 1-176: 0101 +62 81 packets b5fd26618458b725 files f195e1bfb415df25 +62 ff 1-176: 0101 +62 ff packets b5fd26618458b725 files f195e1bfb415df25 +62 00/5a 1-176: 0101 +62 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +63 00 1-176: 0101 +63 00 packets b5fd26618458b725 files f195e1bfb415df25 +63 01 1-176: 0101 +63 01 packets b5fd26618458b725 files f195e1bfb415df25 +63 81 1-176: 0101 +63 81 packets b5fd26618458b725 files f195e1bfb415df25 +63 ff 1-176: 0101 +63 ff packets b5fd26618458b725 files f195e1bfb415df25 +63 00/5a 1-176: 0101 +63 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +64 00 1-176: 0101 +64 00 packets b5fd26618458b725 files f195e1bfb415df25 +64 01 1-176: 0101 +64 01 packets b5fd26618458b725 files f195e1bfb415df25 +64 81 1-176: 0101 +64 81 packets b5fd26618458b725 files f195e1bfb415df25 +64 ff 1-176: 0101 +64 ff packets b5fd26618458b725 files f195e1bfb415df25 +64 00/5a 1-176: 0101 +64 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +65 00 1-176: 0101 +65 00 packets b5fd26618458b725 files f195e1bfb415df25 +65 01 1-176: 0101 +65 01 packets b5fd26618458b725 files f195e1bfb415df25 +65 81 1-176: 0101 +65 81 packets b5fd26618458b725 files f195e1bfb415df25 +65 ff 1-176: 0101 +65 ff packets b5fd26618458b725 files f195e1bfb415df25 +65 00/5a 1-176: 0101 +65 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +66 00 1-176: 0101 +66 00 packets b5fd26618458b725 files f195e1bfb415df25 +66 01 1-176: 0101 +66 01 packets b5fd26618458b725 files f195e1bfb415df25 +66 81 1-176: 0101 +66 81 packets b5fd26618458b725 files f195e1bfb415df25 +66 ff 1-176: 0101 +66 ff packets b5fd26618458b725 files f195e1bfb415df25 +66 00/5a 1-176: 0101 +66 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +67 00 1-176: 0101 +67 00 packets b5fd26618458b725 files f195e1bfb415df25 +67 01 1-176: 0101 +67 01 packets b5fd26618458b725 files f195e1bfb415df25 +67 81 1-176: 0101 +67 81 packets b5fd26618458b725 files f195e1bfb415df25 +67 ff 1-176: 0101 +67 ff packets b5fd26618458b725 files f195e1bfb415df25 +67 00/5a 1-176: 0101 +67 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +68 00 1-176: 0101 +68 00 packets b5fd26618458b725 files f195e1bfb415df25 +68 01 1-176: 0101 +68 01 packets b5fd26618458b725 files f195e1bfb415df25 +68 81 1-176: 0101 +68 81 packets b5fd26618458b725 files f195e1bfb415df25 +68 ff 1-176: 0101 +68 ff packets b5fd26618458b725 files f195e1bfb415df25 +68 00/5a 1-176: 0101 +68 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +69 00 1-176: 0101 +69 00 packets b5fd26618458b725 files f195e1bfb415df25 +69 01 1-176: 0101 +69 01 packets b5fd26618458b725 files f195e1bfb415df25 +69 81 1-176: 0101 +69 81 packets b5fd26618458b725 files f195e1bfb415df25 +69 ff 1-176: 0101 +69 ff packets b5fd26618458b725 files f195e1bfb415df25 +69 00/5a 1-176: 0101 +69 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6a 00 1-176: 0101 +6a 00 packets b5fd26618458b725 files f195e1bfb415df25 +6a 01 1-176: 0101 +6a 01 packets b5fd26618458b725 files f195e1bfb415df25 +6a 81 1-176: 0101 +6a 81 packets b5fd26618458b725 files f195e1bfb415df25 +6a ff 1-176: 0101 +6a ff packets b5fd26618458b725 files f195e1bfb415df25 +6a 00/5a 1-176: 0101 +6a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6b 00 1-176: 0101 +6b 00 packets b5fd26618458b725 files f195e1bfb415df25 +6b 01 1-176: 0101 +6b 01 packets b5fd26618458b725 files f195e1bfb415df25 +6b 81 1-176: 0101 +6b 81 packets b5fd26618458b725 files f195e1bfb415df25 +6b ff 1-176: 0101 +6b ff packets b5fd26618458b725 files f195e1bfb415df25 +6b 00/5a 1-176: 0101 +6b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6c 00 1-176: 0101 +6c 00 packets b5fd26618458b725 files f195e1bfb415df25 +6c 01 1-176: 0101 +6c 01 packets b5fd26618458b725 files f195e1bfb415df25 +6c 81 1-176: 0101 +6c 81 packets b5fd26618458b725 files f195e1bfb415df25 +6c ff 1-176: 0101 +6c ff packets b5fd26618458b725 files f195e1bfb415df25 +6c 00/5a 1-176: 0101 +6c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6d 00 1-176: 0101 +6d 00 packets b5fd26618458b725 files f195e1bfb415df25 +6d 01 1-176: 0101 +6d 01 packets b5fd26618458b725 files f195e1bfb415df25 +6d 81 1-176: 0101 +6d 81 packets b5fd26618458b725 files f195e1bfb415df25 +6d ff 1-176: 0101 +6d ff packets b5fd26618458b725 files f195e1bfb415df25 +6d 00/5a 1-176: 0101 +6d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6e 00 1-176: 0101 +6e 00 packets b5fd26618458b725 files f195e1bfb415df25 +6e 01 1-176: 0101 +6e 01 packets b5fd26618458b725 files f195e1bfb415df25 +6e 81 1-176: 0101 +6e 81 packets b5fd26618458b725 files f195e1bfb415df25 +6e ff 1-176: 0101 +6e ff packets b5fd26618458b725 files f195e1bfb415df25 +6e 00/5a 1-176: 0101 +6e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6f 00 1-176: 0101 +6f 00 packets b5fd26618458b725 files f195e1bfb415df25 +6f 01 1-176: 0101 +6f 01 packets b5fd26618458b725 files f195e1bfb415df25 +6f 81 1-176: 0101 +6f 81 packets b5fd26618458b725 files f195e1bfb415df25 +6f ff 1-176: 0101 +6f ff packets b5fd26618458b725 files f195e1bfb415df25 +6f 00/5a 1-176: 0101 +6f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +70 00 1-176: 0101 +70 00 packets b5fd26618458b725 files f195e1bfb415df25 +70 01 1-176: 0101 +70 01 packets b5fd26618458b725 files f195e1bfb415df25 +70 81 1-176: 0101 +70 81 packets b5fd26618458b725 files f195e1bfb415df25 +70 ff 1-176: 0101 +70 ff packets b5fd26618458b725 files f195e1bfb415df25 +70 00/5a 1-176: 0101 +70 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +71 00 1-176: 0101 +71 00 packets b5fd26618458b725 files f195e1bfb415df25 +71 01 1-176: 0101 +71 01 packets b5fd26618458b725 files f195e1bfb415df25 +71 81 1-176: 0101 +71 81 packets b5fd26618458b725 files f195e1bfb415df25 +71 ff 1-176: 0101 +71 ff packets b5fd26618458b725 files f195e1bfb415df25 +71 00/5a 1-176: 0101 +71 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +72 00 1-176: 0101 +72 00 packets b5fd26618458b725 files f195e1bfb415df25 +72 01 1-176: 0101 +72 01 packets b5fd26618458b725 files f195e1bfb415df25 +72 81 1-176: 0101 +72 81 packets b5fd26618458b725 files f195e1bfb415df25 +72 ff 1-176: 0101 +72 ff packets b5fd26618458b725 files f195e1bfb415df25 +72 00/5a 1-176: 0101 +72 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +73 00 1-176: 0101 +73 00 packets b5fd26618458b725 files f195e1bfb415df25 +73 01 1-176: 0101 +73 01 packets b5fd26618458b725 files f195e1bfb415df25 +73 81 1-176: 0101 +73 81 packets b5fd26618458b725 files f195e1bfb415df25 +73 ff 1-176: 0101 +73 ff packets b5fd26618458b725 files f195e1bfb415df25 +73 00/5a 1-176: 0101 +73 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +74 00 1-176: 0101 +74 00 packets b5fd26618458b725 files f195e1bfb415df25 +74 01 1-176: 0101 +74 01 packets b5fd26618458b725 files f195e1bfb415df25 +74 81 1-176: 0101 +74 81 packets b5fd26618458b725 files f195e1bfb415df25 +74 ff 1-176: 0101 +74 ff packets b5fd26618458b725 files f195e1bfb415df25 +74 00/5a 1-176: 0101 +74 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +75 00 1-176: 0101 +75 00 packets b5fd26618458b725 files f195e1bfb415df25 +75 01 1-176: 0101 +75 01 packets b5fd26618458b725 files f195e1bfb415df25 +75 81 1-176: 0101 +75 81 packets b5fd26618458b725 files f195e1bfb415df25 +75 ff 1-176: 0101 +75 ff packets b5fd26618458b725 files f195e1bfb415df25 +75 00/5a 1-176: 0101 +75 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +76 00 1-176: 0101 +76 00 packets b5fd26618458b725 files f195e1bfb415df25 +76 01 1-176: 0101 +76 01 packets b5fd26618458b725 files f195e1bfb415df25 +76 81 1-176: 0101 +76 81 packets b5fd26618458b725 files f195e1bfb415df25 +76 ff 1-176: 0101 +76 ff packets b5fd26618458b725 files f195e1bfb415df25 +76 00/5a 1-176: 0101 +76 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +77 00 1-176: 0101 +77 00 packets b5fd26618458b725 files f195e1bfb415df25 +77 01 1-176: 0101 +77 01 packets b5fd26618458b725 files f195e1bfb415df25 +77 81 1-176: 0101 +77 81 packets b5fd26618458b725 files f195e1bfb415df25 +77 ff 1-176: 0101 +77 ff packets b5fd26618458b725 files f195e1bfb415df25 +77 00/5a 1-176: 0101 +77 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +78 00 1-176: 0101 +78 00 packets b5fd26618458b725 files f195e1bfb415df25 +78 01 1-176: 0101 +78 01 packets b5fd26618458b725 files f195e1bfb415df25 +78 81 1-176: 0101 +78 81 packets b5fd26618458b725 files f195e1bfb415df25 +78 ff 1-176: 0101 +78 ff packets b5fd26618458b725 files f195e1bfb415df25 +78 00/5a 1-176: 0101 +78 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +79 00 1-176: 0101 +79 00 packets b5fd26618458b725 files f195e1bfb415df25 +79 01 1-176: 0101 +79 01 packets b5fd26618458b725 files f195e1bfb415df25 +79 81 1-176: 0101 +79 81 packets b5fd26618458b725 files f195e1bfb415df25 +79 ff 1-176: 0101 +79 ff packets b5fd26618458b725 files f195e1bfb415df25 +79 00/5a 1-176: 0101 +79 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7a 00 1-176: 0101 +7a 00 packets b5fd26618458b725 files f195e1bfb415df25 +7a 01 1-176: 0101 +7a 01 packets b5fd26618458b725 files f195e1bfb415df25 +7a 81 1-176: 0101 +7a 81 packets b5fd26618458b725 files f195e1bfb415df25 +7a ff 1-176: 0101 +7a ff packets b5fd26618458b725 files f195e1bfb415df25 +7a 00/5a 1-176: 0101 +7a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7b 00 1-176: 0101 +7b 00 packets b5fd26618458b725 files f195e1bfb415df25 +7b 01 1-176: 0101 +7b 01 packets b5fd26618458b725 files f195e1bfb415df25 +7b 81 1-176: 0101 +7b 81 packets b5fd26618458b725 files f195e1bfb415df25 +7b ff 1-176: 0101 +7b ff packets b5fd26618458b725 files f195e1bfb415df25 +7b 00/5a 1-176: 0101 +7b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7c 00 1-176: 0101 +7c 00 packets b5fd26618458b725 files f195e1bfb415df25 +7c 01 1-176: 0101 +7c 01 packets b5fd26618458b725 files f195e1bfb415df25 +7c 81 1-176: 0101 +7c 81 packets b5fd26618458b725 files f195e1bfb415df25 +7c ff 1-176: 0101 +7c ff packets b5fd26618458b725 files f195e1bfb415df25 +7c 00/5a 1-176: 0101 +7c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7d 00 1-176: 0101 +7d 00 packets b5fd26618458b725 files f195e1bfb415df25 +7d 01 1-176: 0101 +7d 01 packets b5fd26618458b725 files f195e1bfb415df25 +7d 81 1-176: 0101 +7d 81 packets b5fd26618458b725 files f195e1bfb415df25 +7d ff 1-176: 0101 +7d ff packets b5fd26618458b725 files f195e1bfb415df25 +7d 00/5a 1-176: 0101 +7d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7e 00 1-176: 0101 +7e 00 packets b5fd26618458b725 files f195e1bfb415df25 +7e 01 1-176: 0101 +7e 01 packets b5fd26618458b725 files f195e1bfb415df25 +7e 81 1-176: 0101 +7e 81 packets b5fd26618458b725 files f195e1bfb415df25 +7e ff 1-176: 0101 +7e ff packets b5fd26618458b725 files f195e1bfb415df25 +7e 00/5a 1-176: 0101 +7e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7f 00 1-176: 0101 +7f 00 packets b5fd26618458b725 files f195e1bfb415df25 +7f 01 1-176: 0101 +7f 01 packets b5fd26618458b725 files f195e1bfb415df25 +7f 81 1-176: 0101 +7f 81 packets b5fd26618458b725 files f195e1bfb415df25 +7f ff 1-176: 0101 +7f ff packets b5fd26618458b725 files f195e1bfb415df25 +7f 00/5a 1-176: 0101 +7f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +80 00 1-176: 0101 +80 00 packets b5fd26618458b725 files f195e1bfb415df25 +80 01 1-176: 0101 +80 01 packets b5fd26618458b725 files f195e1bfb415df25 +80 81 1-176: 0101 +80 81 packets b5fd26618458b725 files f195e1bfb415df25 +80 ff 1-176: 0101 +80 ff packets b5fd26618458b725 files f195e1bfb415df25 +80 00/5a 1-176: 0101 +80 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +81 00 1-176: 0101 +81 00 packets b5fd26618458b725 files f195e1bfb415df25 +81 01 1-176: 0101 +81 01 packets b5fd26618458b725 files f195e1bfb415df25 +81 81 1-176: 0101 +81 81 packets b5fd26618458b725 files f195e1bfb415df25 +81 ff 1-176: 0101 +81 ff packets b5fd26618458b725 files f195e1bfb415df25 +81 00/5a 1-176: 0101 +81 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +82 00 1-176: 0101 +82 00 packets b5fd26618458b725 files f195e1bfb415df25 +82 01 1-176: 0101 +82 01 packets b5fd26618458b725 files f195e1bfb415df25 +82 81 1-176: 0101 +82 81 packets b5fd26618458b725 files f195e1bfb415df25 +82 ff 1-176: 0101 +82 ff packets b5fd26618458b725 files f195e1bfb415df25 +82 00/5a 1-176: 0101 +82 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +83 00 1-176: 0101 +83 00 packets b5fd26618458b725 files f195e1bfb415df25 +83 01 1-176: 0101 +83 01 packets b5fd26618458b725 files f195e1bfb415df25 +83 81 1-176: 0101 +83 81 packets b5fd26618458b725 files f195e1bfb415df25 +83 ff 1-176: 0101 +83 ff packets b5fd26618458b725 files f195e1bfb415df25 +83 00/5a 1-176: 0101 +83 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +84 00 1-176: 0101 +84 00 packets b5fd26618458b725 files f195e1bfb415df25 +84 01 1-176: 0101 +84 01 packets b5fd26618458b725 files f195e1bfb415df25 +84 81 1-176: 0101 +84 81 packets b5fd26618458b725 files f195e1bfb415df25 +84 ff 1-176: 0101 +84 ff packets b5fd26618458b725 files f195e1bfb415df25 +84 00/5a 1-176: 0101 +84 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +85 00 1-176: 0101 +85 00 packets b5fd26618458b725 files f195e1bfb415df25 +85 01 1-176: 0101 +85 01 packets b5fd26618458b725 files f195e1bfb415df25 +85 81 1-176: 0101 +85 81 packets b5fd26618458b725 files f195e1bfb415df25 +85 ff 1-176: 0101 +85 ff packets b5fd26618458b725 files f195e1bfb415df25 +85 00/5a 1-176: 0101 +85 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +86 00 1-176: 0101 +86 00 packets b5fd26618458b725 files f195e1bfb415df25 +86 01 1-176: 0101 +86 01 packets b5fd26618458b725 files f195e1bfb415df25 +86 81 1-176: 0101 +86 81 packets b5fd26618458b725 files f195e1bfb415df25 +86 ff 1-176: 0101 +86 ff packets b5fd26618458b725 files f195e1bfb415df25 +86 00/5a 1-176: 0101 +86 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +87 00 1-176: 0101 +87 00 packets b5fd26618458b725 files f195e1bfb415df25 +87 01 1-176: 0101 +87 01 packets b5fd26618458b725 files f195e1bfb415df25 +87 81 1-176: 0101 +87 81 packets b5fd26618458b725 files f195e1bfb415df25 +87 ff 1-176: 0101 +87 ff packets b5fd26618458b725 files f195e1bfb415df25 +87 00/5a 1-176: 0101 +87 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +88 00 1-176: 0101 +88 00 packets b5fd26618458b725 files f195e1bfb415df25 +88 01 1-176: 0101 +88 01 packets b5fd26618458b725 files f195e1bfb415df25 +88 81 1-176: 0101 +88 81 packets b5fd26618458b725 files f195e1bfb415df25 +88 ff 1-176: 0101 +88 ff packets b5fd26618458b725 files f195e1bfb415df25 +88 00/5a 1-176: 0101 +88 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +89 00 1-176: 0101 +89 00 packets b5fd26618458b725 files f195e1bfb415df25 +89 01 1-176: 0101 +89 01 packets b5fd26618458b725 files f195e1bfb415df25 +89 81 1-176: 0101 +89 81 packets b5fd26618458b725 files f195e1bfb415df25 +89 ff 1-176: 0101 +89 ff packets b5fd26618458b725 files f195e1bfb415df25 +89 00/5a 1-176: 0101 +89 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8a 00 1-176: 0101 +8a 00 packets b5fd26618458b725 files f195e1bfb415df25 +8a 01 1-176: 0101 +8a 01 packets b5fd26618458b725 files f195e1bfb415df25 +8a 81 1-176: 0101 +8a 81 packets b5fd26618458b725 files f195e1bfb415df25 +8a ff 1-176: 0101 +8a ff packets b5fd26618458b725 files f195e1bfb415df25 +8a 00/5a 1-176: 0101 +8a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8b 00 1-176: 0101 +8b 00 packets b5fd26618458b725 files f195e1bfb415df25 +8b 01 1-176: 0101 +8b 01 packets b5fd26618458b725 files f195e1bfb415df25 +8b 81 1-176: 0101 +8b 81 packets b5fd26618458b725 files f195e1bfb415df25 +8b ff 1-176: 0101 +8b ff packets b5fd26618458b725 files f195e1bfb415df25 +8b 00/5a 1-176: 0101 +8b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8c 00 1-176: 0101 +8c 00 packets b5fd26618458b725 files f195e1bfb415df25 +8c 01 1-176: 0101 +8c 01 packets b5fd26618458b725 files f195e1bfb415df25 +8c 81 1-176: 0101 +8c 81 packets b5fd26618458b725 files f195e1bfb415df25 +8c ff 1-176: 0101 +8c ff packets b5fd26618458b725 files f195e1bfb415df25 +8c 00/5a 1-176: 0101 +8c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8d 00 1-176: 0101 +8d 00 packets b5fd26618458b725 files f195e1bfb415df25 +8d 01 1-176: 0101 +8d 01 packets b5fd26618458b725 files f195e1bfb415df25 +8d 81 1-176: 0101 +8d 81 packets b5fd26618458b725 files f195e1bfb415df25 +8d ff 1-176: 0101 +8d ff packets b5fd26618458b725 files f195e1bfb415df25 +8d 00/5a 1-176: 0101 +8d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8e 00 1-176: 0101 +8e 00 packets b5fd26618458b725 files f195e1bfb415df25 +8e 01 1-176: 0101 +8e 01 packets b5fd26618458b725 files f195e1bfb415df25 +8e 81 1-176: 0101 +8e 81 packets b5fd26618458b725 files f195e1bfb415df25 +8e ff 1-176: 0101 +8e ff packets b5fd26618458b725 files f195e1bfb415df25 +8e 00/5a 1-176: 0101 +8e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8f 00 1-176: 0101 +8f 00 packets b5fd26618458b725 files f195e1bfb415df25 +8f 01 1-176: 0101 +8f 01 packets b5fd26618458b725 files f195e1bfb415df25 +8f 81 1-176: 0101 +8f 81 packets b5fd26618458b725 files f195e1bfb415df25 +8f ff 1-176: 0101 +8f ff packets b5fd26618458b725 files f195e1bfb415df25 +8f 00/5a 1-176: 0101 +8f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +90 00 1-176: 0101 +90 00 packets b5fd26618458b725 files f195e1bfb415df25 +90 01 1-176: 0101 +90 01 packets b5fd26618458b725 files f195e1bfb415df25 +90 81 1-176: 0101 +90 81 packets b5fd26618458b725 files f195e1bfb415df25 +90 ff 1-176: 0101 +90 ff packets b5fd26618458b725 files f195e1bfb415df25 +90 00/5a 1-176: 0101 +90 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +91 00 1-176: 0101 +91 00 packets b5fd26618458b725 files f195e1bfb415df25 +91 01 1-176: 0101 +91 01 packets b5fd26618458b725 files f195e1bfb415df25 +91 81 1-176: 0101 +91 81 packets b5fd26618458b725 files f195e1bfb415df25 +91 ff 1-176: 0101 +91 ff packets b5fd26618458b725 files f195e1bfb415df25 +91 00/5a 1-176: 0101 +91 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +92 00 1-176: 0101 +92 00 packets b5fd26618458b725 files f195e1bfb415df25 +92 01 1-176: 0101 +92 01 packets b5fd26618458b725 files f195e1bfb415df25 +92 81 1-176: 0101 +92 81 packets b5fd26618458b725 files f195e1bfb415df25 +92 ff 1-176: 0101 +92 ff packets b5fd26618458b725 files f195e1bfb415df25 +92 00/5a 1-176: 0101 +92 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +93 00 1-176: 0101 +93 00 packets b5fd26618458b725 files f195e1bfb415df25 +93 01 1-176: 0101 +93 01 packets b5fd26618458b725 files f195e1bfb415df25 +93 81 1-176: 0101 +93 81 packets b5fd26618458b725 files f195e1bfb415df25 +93 ff 1-176: 0101 +93 ff packets b5fd26618458b725 files f195e1bfb415df25 +93 00/5a 1-176: 0101 +93 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +94 00 1-176: 0101 +94 00 packets b5fd26618458b725 files f195e1bfb415df25 +94 01 1-176: 0101 +94 01 packets b5fd26618458b725 files f195e1bfb415df25 +94 81 1-176: 0101 +94 81 packets b5fd26618458b725 files f195e1bfb415df25 +94 ff 1-176: 0101 +94 ff packets b5fd26618458b725 files f195e1bfb415df25 +94 00/5a 1-176: 0101 +94 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +95 00 1-176: 0101 +95 00 packets b5fd26618458b725 files f195e1bfb415df25 +95 01 1-176: 0101 +95 01 packets b5fd26618458b725 files f195e1bfb415df25 +95 81 1-176: 0101 +95 81 packets b5fd26618458b725 files f195e1bfb415df25 +95 ff 1-176: 0101 +95 ff packets b5fd26618458b725 files f195e1bfb415df25 +95 00/5a 1-176: 0101 +95 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +96 00 1-176: 0101 +96 00 packets b5fd26618458b725 files f195e1bfb415df25 +96 01 1-176: 0101 +96 01 packets b5fd26618458b725 files f195e1bfb415df25 +96 81 1-176: 0101 +96 81 packets b5fd26618458b725 files f195e1bfb415df25 +96 ff 1-176: 0101 +96 ff packets b5fd26618458b725 files f195e1bfb415df25 +96 00/5a 1-176: 0101 +96 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +97 00 1-176: 0101 +97 00 packets b5fd26618458b725 files f195e1bfb415df25 +97 01 1-176: 0101 +97 01 packets b5fd26618458b725 files f195e1bfb415df25 +97 81 1-176: 0101 +97 81 packets b5fd26618458b725 files f195e1bfb415df25 +97 ff 1-176: 0101 +97 ff packets b5fd26618458b725 files f195e1bfb415df25 +97 00/5a 1-176: 0101 +97 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +98 00 1-176: 0101 +98 00 packets b5fd26618458b725 files f195e1bfb415df25 +98 01 1-176: 0101 +98 01 packets b5fd26618458b725 files f195e1bfb415df25 +98 81 1-176: 0101 +98 81 packets b5fd26618458b725 files f195e1bfb415df25 +98 ff 1-176: 0101 +98 ff packets b5fd26618458b725 files f195e1bfb415df25 +98 00/5a 1-176: 0101 +98 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +99 00 1-176: 0101 +99 00 packets b5fd26618458b725 files f195e1bfb415df25 +99 01 1-176: 0101 +99 01 packets b5fd26618458b725 files f195e1bfb415df25 +99 81 1-176: 0101 +99 81 packets b5fd26618458b725 files f195e1bfb415df25 +99 ff 1-176: 0101 +99 ff packets b5fd26618458b725 files f195e1bfb415df25 +99 00/5a 1-176: 0101 +99 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9a 00 1-176: 0101 +9a 00 packets b5fd26618458b725 files f195e1bfb415df25 +9a 01 1-176: 0101 +9a 01 packets b5fd26618458b725 files f195e1bfb415df25 +9a 81 1-176: 0101 +9a 81 packets b5fd26618458b725 files f195e1bfb415df25 +9a ff 1-176: 0101 +9a ff packets b5fd26618458b725 files f195e1bfb415df25 +9a 00/5a 1-176: 0101 +9a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9b 00 1-176: 0101 +9b 00 packets b5fd26618458b725 files f195e1bfb415df25 +9b 01 1-176: 0101 +9b 01 packets b5fd26618458b725 files f195e1bfb415df25 +9b 81 1-176: 0101 +9b 81 packets b5fd26618458b725 files f195e1bfb415df25 +9b ff 1-176: 0101 +9b ff packets b5fd26618458b725 files f195e1bfb415df25 +9b 00/5a 1-176: 0101 +9b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9c 00 1-176: 0101 +9c 00 packets b5fd26618458b725 files f195e1bfb415df25 +9c 01 1-176: 0101 +9c 01 packets b5fd26618458b725 files f195e1bfb415df25 +9c 81 1-176: 0101 +9c 81 packets b5fd26618458b725 files f195e1bfb415df25 +9c ff 1-176: 0101 +9c ff packets b5fd26618458b725 files f195e1bfb415df25 +9c 00/5a 1-176: 0101 +9c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9d 00 1-176: 0101 +9d 00 packets b5fd26618458b725 files f195e1bfb415df25 +9d 01 1-176: 0101 +9d 01 packets b5fd26618458b725 files f195e1bfb415df25 +9d 81 1-176: 0101 +9d 81 packets b5fd26618458b725 files f195e1bfb415df25 +9d ff 1-176: 0101 +9d ff packets b5fd26618458b725 files f195e1bfb415df25 +9d 00/5a 1-176: 0101 +9d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9e 00 1-176: 0101 +9e 00 packets b5fd26618458b725 files f195e1bfb415df25 +9e 01 1-176: 0101 +9e 01 packets b5fd26618458b725 files f195e1bfb415df25 +9e 81 1-176: 0101 +9e 81 packets b5fd26618458b725 files f195e1bfb415df25 +9e ff 1-176: 0101 +9e ff packets b5fd26618458b725 files f195e1bfb415df25 +9e 00/5a 1-176: 0101 +9e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9f 00 1-176: 0101 +9f 00 packets b5fd26618458b725 files f195e1bfb415df25 +9f 01 1-176: 0101 +9f 01 packets b5fd26618458b725 files f195e1bfb415df25 +9f 81 1-176: 0101 +9f 81 packets b5fd26618458b725 files f195e1bfb415df25 +9f ff 1-176: 0101 +9f ff packets b5fd26618458b725 files f195e1bfb415df25 +9f 00/5a 1-176: 0101 +9f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a0 00 1-176: 0101 +a0 00 packets b5fd26618458b725 files f195e1bfb415df25 +a0 01 1-176: 0101 +a0 01 packets b5fd26618458b725 files f195e1bfb415df25 +a0 81 1-176: 0101 +a0 81 packets b5fd26618458b725 files f195e1bfb415df25 +a0 ff 1-176: 0101 +a0 ff packets b5fd26618458b725 files f195e1bfb415df25 +a0 00/5a 1-176: 0101 +a0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a1 00 1-176: 0101 +a1 00 packets b5fd26618458b725 files f195e1bfb415df25 +a1 01 1-176: 0101 +a1 01 packets b5fd26618458b725 files f195e1bfb415df25 +a1 81 1-176: 0101 +a1 81 packets b5fd26618458b725 files f195e1bfb415df25 +a1 ff 1-176: 0101 +a1 ff packets b5fd26618458b725 files f195e1bfb415df25 +a1 00/5a 1-176: 0101 +a1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a2 00 1-176: 0101 +a2 00 packets b5fd26618458b725 files f195e1bfb415df25 +a2 01 1-176: 0101 +a2 01 packets b5fd26618458b725 files f195e1bfb415df25 +a2 81 1-176: 0101 +a2 81 packets b5fd26618458b725 files f195e1bfb415df25 +a2 ff 1-176: 0101 +a2 ff packets b5fd26618458b725 files f195e1bfb415df25 +a2 00/5a 1-176: 0101 +a2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a3 00 1-176: 0101 +a3 00 packets b5fd26618458b725 files f195e1bfb415df25 +a3 01 1-176: 0101 +a3 01 packets b5fd26618458b725 files f195e1bfb415df25 +a3 81 1-176: 0101 +a3 81 packets b5fd26618458b725 files f195e1bfb415df25 +a3 ff 1-176: 0101 +a3 ff packets b5fd26618458b725 files f195e1bfb415df25 +a3 00/5a 1-176: 0101 +a3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a4 00 1-176: 0101 +a4 00 packets b5fd26618458b725 files f195e1bfb415df25 +a4 01 1-176: 0101 +a4 01 packets b5fd26618458b725 files f195e1bfb415df25 +a4 81 1-176: 0101 +a4 81 packets b5fd26618458b725 files f195e1bfb415df25 +a4 ff 1-176: 0101 +a4 ff packets b5fd26618458b725 files f195e1bfb415df25 +a4 00/5a 1-176: 0101 +a4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a5 00 1-176: 0101 +a5 00 packets b5fd26618458b725 files f195e1bfb415df25 +a5 01 1-176: 0101 +a5 01 packets b5fd26618458b725 files f195e1bfb415df25 +a5 81 1-176: 0101 +a5 81 packets b5fd26618458b725 files f195e1bfb415df25 +a5 ff 1-176: 0101 +a5 ff packets b5fd26618458b725 files f195e1bfb415df25 +a5 00/5a 1-176: 0101 +a5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a6 00 1-176: 0101 +a6 00 packets b5fd26618458b725 files f195e1bfb415df25 +a6 01 1-176: 0101 +a6 01 packets b5fd26618458b725 files f195e1bfb415df25 +a6 81 1-176: 0101 +a6 81 packets b5fd26618458b725 files f195e1bfb415df25 +a6 ff 1-176: 0101 +a6 ff packets b5fd26618458b725 files f195e1bfb415df25 +a6 00/5a 1-176: 0101 +a6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a7 00 1-176: 0101 +a7 00 packets b5fd26618458b725 files f195e1bfb415df25 +a7 01 1-176: 0101 +a7 01 packets b5fd26618458b725 files f195e1bfb415df25 +a7 81 1-176: 0101 +a7 81 packets b5fd26618458b725 files f195e1bfb415df25 +a7 ff 1-176: 0101 +a7 ff packets b5fd26618458b725 files f195e1bfb415df25 +a7 00/5a 1-176: 0101 +a7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a8 00 1-176: 0101 +a8 00 packets b5fd26618458b725 files f195e1bfb415df25 +a8 01 1-176: 0101 +a8 01 packets b5fd26618458b725 files f195e1bfb415df25 +a8 81 1-176: 0101 +a8 81 packets b5fd26618458b725 files f195e1bfb415df25 +a8 ff 1-176: 0101 +a8 ff packets b5fd26618458b725 files f195e1bfb415df25 +a8 00/5a 1-176: 0101 +a8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a9 00 1-176: 0101 +a9 00 packets b5fd26618458b725 files f195e1bfb415df25 +a9 01 1-176: 0101 +a9 01 packets b5fd26618458b725 files f195e1bfb415df25 +a9 81 1-176: 0101 +a9 81 packets b5fd26618458b725 files f195e1bfb415df25 +a9 ff 1-176: 0101 +a9 ff packets b5fd26618458b725 files f195e1bfb415df25 +a9 00/5a 1-176: 0101 +a9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +aa 00 1-176: 0101 +aa 00 packets b5fd26618458b725 files f195e1bfb415df25 +aa 01 1-176: 0101 +aa 01 packets b5fd26618458b725 files f195e1bfb415df25 +aa 81 1-176: 0101 +aa 81 packets b5fd26618458b725 files f195e1bfb415df25 +aa ff 1-176: 0101 +aa ff packets b5fd26618458b725 files f195e1bfb415df25 +aa 00/5a 1-176: 0101 +aa 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ab 00 1-176: 0101 +ab 00 packets b5fd26618458b725 files f195e1bfb415df25 +ab 01 1-176: 0101 +ab 01 packets b5fd26618458b725 files f195e1bfb415df25 +ab 81 1-176: 0101 +ab 81 packets b5fd26618458b725 files f195e1bfb415df25 +ab ff 1-176: 0101 +ab ff packets b5fd26618458b725 files f195e1bfb415df25 +ab 00/5a 1-176: 0101 +ab 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ac 00 1-176: 0101 +ac 00 packets b5fd26618458b725 files f195e1bfb415df25 +ac 01 1-176: 0101 +ac 01 packets b5fd26618458b725 files f195e1bfb415df25 +ac 81 1-176: 0101 +ac 81 packets b5fd26618458b725 files f195e1bfb415df25 +ac ff 1-176: 0101 +ac ff packets b5fd26618458b725 files f195e1bfb415df25 +ac 00/5a 1-176: 0101 +ac 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ad 00 1-176: 0101 +ad 00 packets b5fd26618458b725 files f195e1bfb415df25 +ad 01 1-176: 0101 +ad 01 packets b5fd26618458b725 files f195e1bfb415df25 +ad 81 1-176: 0101 +ad 81 packets b5fd26618458b725 files f195e1bfb415df25 +ad ff 1-176: 0101 +ad ff packets b5fd26618458b725 files f195e1bfb415df25 +ad 00/5a 1-176: 0101 +ad 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ae 00 1-176: 0101 +ae 00 packets b5fd26618458b725 files f195e1bfb415df25 +ae 01 1-176: 0101 +ae 01 packets b5fd26618458b725 files f195e1bfb415df25 +ae 81 1-176: 0101 +ae 81 packets b5fd26618458b725 files f195e1bfb415df25 +ae ff 1-176: 0101 +ae ff packets b5fd26618458b725 files f195e1bfb415df25 +ae 00/5a 1-176: 0101 +ae 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +af 00 1-176: 0101 +af 00 packets b5fd26618458b725 files f195e1bfb415df25 +af 01 1-176: 0101 +af 01 packets b5fd26618458b725 files f195e1bfb415df25 +af 81 1-176: 0101 +af 81 packets b5fd26618458b725 files f195e1bfb415df25 +af ff 1-176: 0101 +af ff packets b5fd26618458b725 files f195e1bfb415df25 +af 00/5a 1-176: 0101 +af 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b0 00 1-176: 0101 +b0 00 packets b5fd26618458b725 files f195e1bfb415df25 +b0 01 1-176: 0101 +b0 01 packets b5fd26618458b725 files f195e1bfb415df25 +b0 81 1-176: 0101 +b0 81 packets b5fd26618458b725 files f195e1bfb415df25 +b0 ff 1-176: 0101 +b0 ff packets b5fd26618458b725 files f195e1bfb415df25 +b0 00/5a 1-176: 0101 +b0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b1 00 1-176: 0101 +b1 00 packets b5fd26618458b725 files f195e1bfb415df25 +b1 01 1-176: 0101 +b1 01 packets b5fd26618458b725 files f195e1bfb415df25 +b1 81 1-176: 0101 +b1 81 packets b5fd26618458b725 files f195e1bfb415df25 +b1 ff 1-176: 0101 +b1 ff packets b5fd26618458b725 files f195e1bfb415df25 +b1 00/5a 1-176: 0101 +b1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b2 00 1-176: 0101 +b2 00 packets b5fd26618458b725 files f195e1bfb415df25 +b2 01 1-176: 0101 +b2 01 packets b5fd26618458b725 files f195e1bfb415df25 +b2 81 1-176: 0101 +b2 81 packets b5fd26618458b725 files f195e1bfb415df25 +b2 ff 1-176: 0101 +b2 ff packets b5fd26618458b725 files f195e1bfb415df25 +b2 00/5a 1-176: 0101 +b2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b3 00 1-176: 0101 +b3 00 packets b5fd26618458b725 files f195e1bfb415df25 +b3 01 1-176: 0101 +b3 01 packets b5fd26618458b725 files f195e1bfb415df25 +b3 81 1-176: 0101 +b3 81 packets b5fd26618458b725 files f195e1bfb415df25 +b3 ff 1-176: 0101 +b3 ff packets b5fd26618458b725 files f195e1bfb415df25 +b3 00/5a 1-176: 0101 +b3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b4 00 1-176: 0101 +b4 00 packets b5fd26618458b725 files f195e1bfb415df25 +b4 01 1-176: 0101 +b4 01 packets b5fd26618458b725 files f195e1bfb415df25 +b4 81 1-176: 0101 +b4 81 packets b5fd26618458b725 files f195e1bfb415df25 +b4 ff 1-176: 0101 +b4 ff packets b5fd26618458b725 files f195e1bfb415df25 +b4 00/5a 1-176: 0101 +b4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b5 00 1-176: 0101 +b5 00 packets b5fd26618458b725 files f195e1bfb415df25 +b5 01 1-176: 0101 +b5 01 packets b5fd26618458b725 files f195e1bfb415df25 +b5 81 1-176: 0101 +b5 81 packets b5fd26618458b725 files f195e1bfb415df25 +b5 ff 1-176: 0101 +b5 ff packets b5fd26618458b725 files f195e1bfb415df25 +b5 00/5a 1-176: 0101 +b5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b6 00 1-176: 0101 +b6 00 packets b5fd26618458b725 files f195e1bfb415df25 +b6 01 1-176: 0101 +b6 01 packets b5fd26618458b725 files f195e1bfb415df25 +b6 81 1-176: 0101 +b6 81 packets b5fd26618458b725 files f195e1bfb415df25 +b6 ff 1-176: 0101 +b6 ff packets b5fd26618458b725 files f195e1bfb415df25 +b6 00/5a 1-176: 0101 +b6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b7 00 1-176: 0101 +b7 00 packets b5fd26618458b725 files f195e1bfb415df25 +b7 01 1-176: 0101 +b7 01 packets b5fd26618458b725 files f195e1bfb415df25 +b7 81 1-176: 0101 +b7 81 packets b5fd26618458b725 files f195e1bfb415df25 +b7 ff 1-176: 0101 +b7 ff packets b5fd26618458b725 files f195e1bfb415df25 +b7 00/5a 1-176: 0101 +b7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b8 00 1-176: 0101 +b8 00 packets b5fd26618458b725 files f195e1bfb415df25 +b8 01 1-176: 0101 +b8 01 packets b5fd26618458b725 files f195e1bfb415df25 +b8 81 1-176: 0101 +b8 81 packets b5fd26618458b725 files f195e1bfb415df25 +b8 ff 1-176: 0101 +b8 ff packets b5fd26618458b725 files f195e1bfb415df25 +b8 00/5a 1-176: 0101 +b8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b9 00 1-176: 0101 +b9 00 packets b5fd26618458b725 files f195e1bfb415df25 +b9 01 1-176: 0101 +b9 01 packets b5fd26618458b725 files f195e1bfb415df25 +b9 81 1-176: 0101 +b9 81 packets b5fd26618458b725 files f195e1bfb415df25 +b9 ff 1-176: 0101 +b9 ff packets b5fd26618458b725 files f195e1bfb415df25 +b9 00/5a 1-176: 0101 +b9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ba 00 1-176: 0101 +ba 00 packets b5fd26618458b725 files f195e1bfb415df25 +ba 01 1-176: 0101 +ba 01 packets b5fd26618458b725 files f195e1bfb415df25 +ba 81 1-176: 0101 +ba 81 packets b5fd26618458b725 files f195e1bfb415df25 +ba ff 1-176: 0101 +ba ff packets b5fd26618458b725 files f195e1bfb415df25 +ba 00/5a 1-176: 0101 +ba 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +bb 00 1-176: 0101 +bb 00 packets b5fd26618458b725 files f195e1bfb415df25 +bb 01 1-176: 0101 +bb 01 packets b5fd26618458b725 files f195e1bfb415df25 +bb 81 1-176: 0101 +bb 81 packets b5fd26618458b725 files f195e1bfb415df25 +bb ff 1-176: 0101 +bb ff packets b5fd26618458b725 files f195e1bfb415df25 +bb 00/5a 1-176: 0101 +bb 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +bc 00 1-176: 0101 +bc 00 packets b5fd26618458b725 files f195e1bfb415df25 +bc 01 1-176: 0101 +bc 01 packets b5fd26618458b725 files f195e1bfb415df25 +bc 81 1-176: 0101 +bc 81 packets b5fd26618458b725 files f195e1bfb415df25 +bc ff 1-176: 0101 +bc ff packets b5fd26618458b725 files f195e1bfb415df25 +bc 00/5a 1-176: 0101 +bc 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +bd 00 1-176: 0101 +bd 00 packets b5fd26618458b725 files f195e1bfb415df25 +bd 01 1-176: 0101 +bd 01 packets b5fd26618458b725 files f195e1bfb415df25 +bd 81 1-176: 0101 +bd 81 packets b5fd26618458b725 files f195e1bfb415df25 +bd ff 1-176: 0101 +bd ff packets b5fd26618458b725 files f195e1bfb415df25 +bd 00/5a 1-176: 0101 +bd 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +be 00 1-176: 0101 +be 00 packets b5fd26618458b725 files f195e1bfb415df25 +be 01 1-176: 0101 +be 01 packets b5fd26618458b725 files f195e1bfb415df25 +be 81 1-176: 0101 +be 81 packets b5fd26618458b725 files f195e1bfb415df25 +be ff 1-176: 0101 +be ff packets b5fd26618458b725 files f195e1bfb415df25 +be 00/5a 1-176: 0101 +be 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +bf 00 1-176: 0101 +bf 00 packets b5fd26618458b725 files f195e1bfb415df25 +bf 01 1-176: 0101 +bf 01 packets b5fd26618458b725 files f195e1bfb415df25 +bf 81 1-176: 0101 +bf 81 packets b5fd26618458b725 files f195e1bfb415df25 +bf ff 1-176: 0101 +bf ff packets b5fd26618458b725 files f195e1bfb415df25 +bf 00/5a 1-176: 0101 +bf 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c0 00 1-176: 0101 +c0 00 packets b5fd26618458b725 files f195e1bfb415df25 +c0 01 1-176: 0101 +c0 01 packets b5fd26618458b725 files f195e1bfb415df25 +c0 81 1-176: 0101 +c0 81 packets b5fd26618458b725 files f195e1bfb415df25 +c0 ff 1-176: 0101 +c0 ff packets b5fd26618458b725 files f195e1bfb415df25 +c0 00/5a 1-176: 0101 +c0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c1 00 1-176: 0101 +c1 00 packets b5fd26618458b725 files f195e1bfb415df25 +c1 01 1-176: 0101 +c1 01 packets b5fd26618458b725 files f195e1bfb415df25 +c1 81 1-176: 0101 +c1 81 packets b5fd26618458b725 files f195e1bfb415df25 +c1 ff 1-176: 0101 +c1 ff packets b5fd26618458b725 files f195e1bfb415df25 +c1 00/5a 1-176: 0101 +c1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c2 00 1-176: 0101 +c2 00 packets b5fd26618458b725 files f195e1bfb415df25 +c2 01 1-176: 0101 +c2 01 packets b5fd26618458b725 files f195e1bfb415df25 +c2 81 1-176: 0101 +c2 81 packets b5fd26618458b725 files f195e1bfb415df25 +c2 ff 1-176: 0101 +c2 ff packets b5fd26618458b725 files f195e1bfb415df25 +c2 00/5a 1-176: 0101 +c2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c3 00 1-176: 0101 +c3 00 packets b5fd26618458b725 files f195e1bfb415df25 +c3 01 1-176: 0101 +c3 01 packets b5fd26618458b725 files f195e1bfb415df25 +c3 81 1-176: 0101 +c3 81 packets b5fd26618458b725 files f195e1bfb415df25 +c3 ff 1-176: 0101 +c3 ff packets b5fd26618458b725 files f195e1bfb415df25 +c3 00/5a 1-176: 0101 +c3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c4 00 1-176: 0101 +c4 00 packets b5fd26618458b725 files f195e1bfb415df25 +c4 01 1-176: 0101 +c4 01 packets b5fd26618458b725 files f195e1bfb415df25 +c4 81 1-176: 0101 +c4 81 packets b5fd26618458b725 files f195e1bfb415df25 +c4 ff 1-176: 0101 +c4 ff packets b5fd26618458b725 files f195e1bfb415df25 +c4 00/5a 1-176: 0101 +c4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c5 00 1-176: 0101 +c5 00 packets b5fd26618458b725 files f195e1bfb415df25 +c5 01 1-176: 0101 +c5 01 packets b5fd26618458b725 files f195e1bfb415df25 +c5 81 1-176: 0101 +c5 81 packets b5fd26618458b725 files f195e1bfb415df25 +c5 ff 1-176: 0101 +c5 ff packets b5fd26618458b725 files f195e1bfb415df25 +c5 00/5a 1-176: 0101 +c5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c6 00 1-176: 0101 +c6 00 packets b5fd26618458b725 files f195e1bfb415df25 +c6 01 1-176: 0101 +c6 01 packets b5fd26618458b725 files f195e1bfb415df25 +c6 81 1-176: 0101 +c6 81 packets b5fd26618458b725 files f195e1bfb415df25 +c6 ff 1-176: 0101 +c6 ff packets b5fd26618458b725 files f195e1bfb415df25 +c6 00/5a 1-176: 0101 +c6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c7 00 1-176: 0101 +c7 00 packets b5fd26618458b725 files f195e1bfb415df25 +c7 01 1-176: 0101 +c7 01 packets b5fd26618458b725 files f195e1bfb415df25 +c7 81 1-176: 0101 +c7 81 packets b5fd26618458b725 files f195e1bfb415df25 +c7 ff 1-176: 0101 +c7 ff packets b5fd26618458b725 files f195e1bfb415df25 +c7 00/5a 1-176: 0101 +c7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c8 00 1-176: 0101 +c8 00 packets b5fd26618458b725 files f195e1bfb415df25 +c8 01 1-176: 0101 +c8 01 packets b5fd26618458b725 files f195e1bfb415df25 +c8 81 1-176: 0101 +c8 81 packets b5fd26618458b725 files f195e1bfb415df25 +c8 ff 1-176: 0101 +c8 ff packets b5fd26618458b725 files f195e1bfb415df25 +c8 00/5a 1-176: 0101 +c8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c9 00 1-176: 0101 +c9 00 packets b5fd26618458b725 files f195e1bfb415df25 +c9 01 1-176: 0101 +c9 01 packets b5fd26618458b725 files f195e1bfb415df25 +c9 81 1-176: 0101 +c9 81 packets b5fd26618458b725 files f195e1bfb415df25 +c9 ff 1-176: 0101 +c9 ff packets b5fd26618458b725 files f195e1bfb415df25 +c9 00/5a 1-176: 0101 +c9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ca 00 1-176: 0101 +ca 00 packets b5fd26618458b725 files f195e1bfb415df25 +ca 01 1-176: 0101 +ca 01 packets b5fd26618458b725 files f195e1bfb415df25 +ca 81 1-176: 0101 +ca 81 packets b5fd26618458b725 files f195e1bfb415df25 +ca ff 1-176: 0101 +ca ff packets b5fd26618458b725 files f195e1bfb415df25 +ca 00/5a 1-176: 0101 +ca 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +cb 00 1-176: 0101 +cb 00 packets b5fd26618458b725 files f195e1bfb415df25 +cb 01 1-176: 0101 +cb 01 packets b5fd26618458b725 files f195e1bfb415df25 +cb 81 1-176: 0101 +cb 81 packets b5fd26618458b725 files f195e1bfb415df25 +cb ff 1-176: 0101 +cb ff packets b5fd26618458b725 files f195e1bfb415df25 +cb 00/5a 1-176: 0101 +cb 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +cc 00 1-176: 0101 +cc 00 packets b5fd26618458b725 files f195e1bfb415df25 +cc 01 1-176: 0101 +cc 01 packets b5fd26618458b725 files f195e1bfb415df25 +cc 81 1-176: 0101 +cc 81 packets b5fd26618458b725 files f195e1bfb415df25 +cc ff 1-176: 0101 +cc ff packets b5fd26618458b725 files f195e1bfb415df25 +cc 00/5a 1-176: 0101 +cc 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +cd 00 1-176: 0101 +cd 00 packets b5fd26618458b725 files f195e1bfb415df25 +cd 01 1-176: 0101 +cd 01 packets b5fd26618458b725 files f195e1bfb415df25 +cd 81 1-176: 0101 +cd 81 packets b5fd26618458b725 files f195e1bfb415df25 +cd ff 1-176: 0101 +cd ff packets b5fd26618458b725 files f195e1bfb415df25 +cd 00/5a 1-176: 0101 +cd 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ce 00 1-176: 0101 +ce 00 packets b5fd26618458b725 files f195e1bfb415df25 +ce 01 1-176: 0101 +ce 01 packets b5fd26618458b725 files f195e1bfb415df25 +ce 81 1-176: 0101 +ce 81 packets b5fd26618458b725 files f195e1bfb415df25 +ce ff 1-176: 0101 +ce ff packets b5fd26618458b725 files f195e1bfb415df25 +ce 00/5a 1-176: 0101 +ce 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +cf 00 1-176: 0101 +cf 00 packets b5fd26618458b725 files f195e1bfb415df25 +cf 01 1-176: 0101 +cf 01 packets b5fd26618458b725 files f195e1bfb415df25 +cf 81 1-176: 0101 +cf 81 packets b5fd26618458b725 files f195e1bfb415df25 +cf ff 1-176: 0101 +cf ff packets b5fd26618458b725 files f195e1bfb415df25 +cf 00/5a 1-176: 0101 +cf 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d0 00 1-176: 0101 +d0 00 packets b5fd26618458b725 files f195e1bfb415df25 +d0 01 1-176: 0101 +d0 01 packets b5fd26618458b725 files f195e1bfb415df25 +d0 81 1-176: 0101 +d0 81 packets b5fd26618458b725 files f195e1bfb415df25 +d0 ff 1-176: 0101 +d0 ff packets b5fd26618458b725 files f195e1bfb415df25 +d0 00/5a 1-176: 0101 +d0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d1 00 1-176: 0101 +d1 00 packets b5fd26618458b725 files f195e1bfb415df25 +d1 01 1-176: 0101 +d1 01 packets b5fd26618458b725 files f195e1bfb415df25 +d1 81 1-176: 0101 +d1 81 packets b5fd26618458b725 files f195e1bfb415df25 +d1 ff 1-176: 0101 +d1 ff packets b5fd26618458b725 files f195e1bfb415df25 +d1 00/5a 1-176: 0101 +d1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d2 00 1-176: 0101 +d2 00 packets b5fd26618458b725 files f195e1bfb415df25 +d2 01 1-176: 0101 +d2 01 packets b5fd26618458b725 files f195e1bfb415df25 +d2 81 1-176: 0101 +d2 81 packets b5fd26618458b725 files f195e1bfb415df25 +d2 ff 1-176: 0101 +d2 ff packets b5fd26618458b725 files f195e1bfb415df25 +d2 00/5a 1-176: 0101 +d2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d3 00 1-176: 0101 +d3 00 packets b5fd26618458b725 files f195e1bfb415df25 +d3 01 1-176: 0101 +d3 01 packets b5fd26618458b725 files f195e1bfb415df25 +d3 81 1-176: 0101 +d3 81 packets b5fd26618458b725 files f195e1bfb415df25 +d3 ff 1-176: 0101 +d3 ff packets b5fd26618458b725 files f195e1bfb415df25 +d3 00/5a 1-176: 0101 +d3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d4 00 1-176: 0101 +d4 00 packets b5fd26618458b725 files f195e1bfb415df25 +d4 01 1-176: 0101 +d4 01 packets b5fd26618458b725 files f195e1bfb415df25 +d4 81 1-176: 0101 +d4 81 packets b5fd26618458b725 files f195e1bfb415df25 +d4 ff 1-176: 0101 +d4 ff packets b5fd26618458b725 files f195e1bfb415df25 +d4 00/5a 1-176: 0101 +d4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d5 00 1-176: 0101 +d5 00 packets b5fd26618458b725 files f195e1bfb415df25 +d5 01 1-176: 0101 +d5 01 packets b5fd26618458b725 files f195e1bfb415df25 +d5 81 1-176: 0101 +d5 81 packets b5fd26618458b725 files f195e1bfb415df25 +d5 ff 1-176: 0101 +d5 ff packets b5fd26618458b725 files f195e1bfb415df25 +d5 00/5a 1-176: 0101 +d5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d6 00 1-176: 0101 +d6 00 packets b5fd26618458b725 files f195e1bfb415df25 +d6 01 1-176: 0101 +d6 01 packets b5fd26618458b725 files f195e1bfb415df25 +d6 81 1-176: 0101 +d6 81 packets b5fd26618458b725 files f195e1bfb415df25 +d6 ff 1-176: 0101 +d6 ff packets b5fd26618458b725 files f195e1bfb415df25 +d6 00/5a 1-176: 0101 +d6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d7 00 1-176: 0101 +d7 00 packets b5fd26618458b725 files f195e1bfb415df25 +d7 01 1-176: 0101 +d7 01 packets b5fd26618458b725 files f195e1bfb415df25 +d7 81 1-176: 0101 +d7 81 packets b5fd26618458b725 files f195e1bfb415df25 +d7 ff 1-176: 0101 +d7 ff packets b5fd26618458b725 files f195e1bfb415df25 +d7 00/5a 1-176: 0101 +d7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d8 00 1-176: 0101 +d8 00 packets b5fd26618458b725 files f195e1bfb415df25 +d8 01 1-176: 0101 +d8 01 packets b5fd26618458b725 files f195e1bfb415df25 +d8 81 1-176: 0101 +d8 81 packets b5fd26618458b725 files f195e1bfb415df25 +d8 ff 1-176: 0101 +d8 ff packets b5fd26618458b725 files f195e1bfb415df25 +d8 00/5a 1-176: 0101 +d8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d9 00 1-176: 0101 +d9 00 packets b5fd26618458b725 files f195e1bfb415df25 +d9 01 1-176: 0101 +d9 01 packets b5fd26618458b725 files f195e1bfb415df25 +d9 81 1-176: 0101 +d9 81 packets b5fd26618458b725 files f195e1bfb415df25 +d9 ff 1-176: 0101 +d9 ff packets b5fd26618458b725 files f195e1bfb415df25 +d9 00/5a 1-176: 0101 +d9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +da 00 1-176: 0101 +da 00 packets b5fd26618458b725 files f195e1bfb415df25 +da 01 1-176: 0101 +da 01 packets b5fd26618458b725 files f195e1bfb415df25 +da 81 1-176: 0101 +da 81 packets b5fd26618458b725 files f195e1bfb415df25 +da ff 1-176: 0101 +da ff packets b5fd26618458b725 files f195e1bfb415df25 +da 00/5a 1-176: 0101 +da 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +db 00 1-176: 0101 +db 00 packets b5fd26618458b725 files f195e1bfb415df25 +db 01 1-176: 0101 +db 01 packets b5fd26618458b725 files f195e1bfb415df25 +db 81 1-176: 0101 +db 81 packets b5fd26618458b725 files f195e1bfb415df25 +db ff 1-176: 0101 +db ff packets b5fd26618458b725 files f195e1bfb415df25 +db 00/5a 1-176: 0101 +db 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +dc 00 1-176: 0101 +dc 00 packets b5fd26618458b725 files f195e1bfb415df25 +dc 01 1-176: 0101 +dc 01 packets b5fd26618458b725 files f195e1bfb415df25 +dc 81 1-176: 0101 +dc 81 packets b5fd26618458b725 files f195e1bfb415df25 +dc ff 1-176: 0101 +dc ff packets b5fd26618458b725 files f195e1bfb415df25 +dc 00/5a 1-176: 0101 +dc 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +dd 00 1-176: 0101 +dd 00 packets b5fd26618458b725 files f195e1bfb415df25 +dd 01 1-176: 0101 +dd 01 packets b5fd26618458b725 files f195e1bfb415df25 +dd 81 1-176: 0101 +dd 81 packets b5fd26618458b725 files f195e1bfb415df25 +dd ff 1-176: 0101 +dd ff packets b5fd26618458b725 files f195e1bfb415df25 +dd 00/5a 1-176: 0101 +dd 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +de 00 1-176: 0101 +de 00 packets b5fd26618458b725 files f195e1bfb415df25 +de 01 1-176: 0101 +de 01 packets b5fd26618458b725 files f195e1bfb415df25 +de 81 1-176: 0101 +de 81 packets b5fd26618458b725 files f195e1bfb415df25 +de ff 1-176: 0101 +de ff packets b5fd26618458b725 files f195e1bfb415df25 +de 00/5a 1-176: 0101 +de 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +df 00 1-176: 0101 +df 00 packets b5fd26618458b725 files f195e1bfb415df25 +df 01 1-176: 0101 +df 01 packets b5fd26618458b725 files f195e1bfb415df25 +df 81 1-176: 0101 +df 81 packets b5fd26618458b725 files f195e1bfb415df25 +df ff 1-176: 0101 +df ff packets b5fd26618458b725 files f195e1bfb415df25 +df 00/5a 1-176: 0101 +df 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e0 00 1-176: 0101 +e0 00 packets b5fd26618458b725 files f195e1bfb415df25 +e0 01 1-176: 0101 +e0 01 packets b5fd26618458b725 files f195e1bfb415df25 +e0 81 1-176: 0101 +e0 81 packets b5fd26618458b725 files f195e1bfb415df25 +e0 ff 1-176: 0101 +e0 ff packets b5fd26618458b725 files f195e1bfb415df25 +e0 00/5a 1-176: 0101 +e0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e1 00 1-176: 0101 +e1 00 packets b5fd26618458b725 files f195e1bfb415df25 +e1 01 1-176: 0101 +e1 01 packets b5fd26618458b725 files f195e1bfb415df25 +e1 81 1-176: 0101 +e1 81 packets b5fd26618458b725 files f195e1bfb415df25 +e1 ff 1-176: 0101 +e1 ff packets b5fd26618458b725 files f195e1bfb415df25 +e1 00/5a 1-176: 0101 +e1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e2 00 1-176: 0101 +e2 00 packets b5fd26618458b725 files f195e1bfb415df25 +e2 01 1-176: 0101 +e2 01 packets b5fd26618458b725 files f195e1bfb415df25 +e2 81 1-176: 0101 +e2 81 packets b5fd26618458b725 files f195e1bfb415df25 +e2 ff 1-176: 0101 +e2 ff packets b5fd26618458b725 files f195e1bfb415df25 +e2 00/5a 1-176: 0101 +e2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e3 00 1-176: 0101 +e3 00 packets b5fd26618458b725 files f195e1bfb415df25 +e3 01 1-176: 0101 +e3 01 packets b5fd26618458b725 files f195e1bfb415df25 +e3 81 1-176: 0101 +e3 81 packets b5fd26618458b725 files f195e1bfb415df25 +e3 ff 1-176: 0101 +e3 ff packets b5fd26618458b725 files f195e1bfb415df25 +e3 00/5a 1-176: 0101 +e3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e4 00 1-176: 0101 +e4 00 packets b5fd26618458b725 files f195e1bfb415df25 +e4 01 1-176: 0101 +e4 01 packets b5fd26618458b725 files f195e1bfb415df25 +e4 81 1-176: 0101 +e4 81 packets b5fd26618458b725 files f195e1bfb415df25 +e4 ff 1-176: 0101 +e4 ff packets b5fd26618458b725 files f195e1bfb415df25 +e4 00/5a 1-176: 0101 +e4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e5 00 1-176: 0101 +e5 00 packets b5fd26618458b725 files f195e1bfb415df25 +e5 01 1-176: 0101 +e5 01 packets b5fd26618458b725 files f195e1bfb415df25 +e5 81 1-176: 0101 +e5 81 packets b5fd26618458b725 files f195e1bfb415df25 +e5 ff 1-176: 0101 +e5 ff packets b5fd26618458b725 files f195e1bfb415df25 +e5 00/5a 1-176: 0101 +e5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e6 00 1-176: 0101 +e6 00 packets b5fd26618458b725 files f195e1bfb415df25 +e6 01 1-176: 0101 +e6 01 packets b5fd26618458b725 files f195e1bfb415df25 +e6 81 1-176: 0101 +e6 81 packets b5fd26618458b725 files f195e1bfb415df25 +e6 ff 1-176: 0101 +e6 ff packets b5fd26618458b725 files f195e1bfb415df25 +e6 00/5a 1-176: 0101 +e6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e7 00 1-176: 0101 +e7 00 packets b5fd26618458b725 files f195e1bfb415df25 +e7 01 1-176: 0101 +e7 01 packets b5fd26618458b725 files f195e1bfb415df25 +e7 81 1-176: 0101 +e7 81 packets b5fd26618458b725 files f195e1bfb415df25 +e7 ff 1-176: 0101 +e7 ff packets b5fd26618458b725 files f195e1bfb415df25 +e7 00/5a 1-176: 0101 +e7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e8 00 1-176: 0101 +e8 00 packets b5fd26618458b725 files f195e1bfb415df25 +e8 01 1-176: 0101 +e8 01 packets b5fd26618458b725 files f195e1bfb415df25 +e8 81 1-176: 0101 +e8 81 packets b5fd26618458b725 files f195e1bfb415df25 +e8 ff 1-176: 0101 +e8 ff packets b5fd26618458b725 files f195e1bfb415df25 +e8 00/5a 1-176: 0101 +e8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e9 00 1-176: 0101 +e9 00 packets b5fd26618458b725 files f195e1bfb415df25 +e9 01 1-176: 0101 +e9 01 packets b5fd26618458b725 files f195e1bfb415df25 +e9 81 1-176: 0101 +e9 81 packets b5fd26618458b725 files f195e1bfb415df25 +e9 ff 1-176: 0101 +e9 ff packets b5fd26618458b725 files f195e1bfb415df25 +e9 00/5a 1-176: 0101 +e9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ea 00 1-176: 0101 +ea 00 packets b5fd26618458b725 files f195e1bfb415df25 +ea 01 1-176: 0101 +ea 01 packets b5fd26618458b725 files f195e1bfb415df25 +ea 81 1-176: 0101 +ea 81 packets b5fd26618458b725 files f195e1bfb415df25 +ea ff 1-176: 0101 +ea ff packets b5fd26618458b725 files f195e1bfb415df25 +ea 00/5a 1-176: 0101 +ea 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +eb 00 1-176: 0101 +eb 00 packets b5fd26618458b725 files f195e1bfb415df25 +eb 01 1-176: 0101 +eb 01 packets b5fd26618458b725 files f195e1bfb415df25 +eb 81 1-176: 0101 +eb 81 packets b5fd26618458b725 files f195e1bfb415df25 +eb ff 1-176: 0101 +eb ff packets b5fd26618458b725 files f195e1bfb415df25 +eb 00/5a 1-176: 0101 +eb 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ec 00 1-176: 0101 +ec 00 packets b5fd26618458b725 files f195e1bfb415df25 +ec 01 1-176: 0101 +ec 01 packets b5fd26618458b725 files f195e1bfb415df25 +ec 81 1-176: 0101 +ec 81 packets b5fd26618458b725 files f195e1bfb415df25 +ec ff 1-176: 0101 +ec ff packets b5fd26618458b725 files f195e1bfb415df25 +ec 00/5a 1-176: 0101 +ec 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ed 00 1-176: 0101 +ed 00 packets b5fd26618458b725 files f195e1bfb415df25 +ed 01 1-176: 0101 +ed 01 packets b5fd26618458b725 files f195e1bfb415df25 +ed 81 1-176: 0101 +ed 81 packets b5fd26618458b725 files f195e1bfb415df25 +ed ff 1-176: 0101 +ed ff packets b5fd26618458b725 files f195e1bfb415df25 +ed 00/5a 1-176: 0101 +ed 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ee 00 1-176: 0101 +ee 00 packets b5fd26618458b725 files f195e1bfb415df25 +ee 01 1-176: 0101 +ee 01 packets b5fd26618458b725 files f195e1bfb415df25 +ee 81 1-176: 0101 +ee 81 packets b5fd26618458b725 files f195e1bfb415df25 +ee ff 1-176: 0101 +ee ff packets b5fd26618458b725 files f195e1bfb415df25 +ee 00/5a 1-176: 0101 +ee 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ef 00 1-176: 0101 +ef 00 packets b5fd26618458b725 files f195e1bfb415df25 +ef 01 1-176: 0101 +ef 01 packets b5fd26618458b725 files f195e1bfb415df25 +ef 81 1-176: 0101 +ef 81 packets b5fd26618458b725 files f195e1bfb415df25 +ef ff 1-176: 0101 +ef ff packets b5fd26618458b725 files f195e1bfb415df25 +ef 00/5a 1-176: 0101 +ef 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f0 00 1-176: 0101 +f0 00 packets b5fd26618458b725 files f195e1bfb415df25 +f0 01 1-176: 0101 +f0 01 packets b5fd26618458b725 files f195e1bfb415df25 +f0 81 1-176: 0101 +f0 81 packets b5fd26618458b725 files f195e1bfb415df25 +f0 ff 1-176: 0101 +f0 ff packets b5fd26618458b725 files f195e1bfb415df25 +f0 00/5a 1-176: 0101 +f0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f1 00 1-176: 0101 +f1 00 packets b5fd26618458b725 files f195e1bfb415df25 +f1 01 1-176: 0101 +f1 01 packets b5fd26618458b725 files f195e1bfb415df25 +f1 81 1-176: 0101 +f1 81 packets b5fd26618458b725 files f195e1bfb415df25 +f1 ff 1-176: 0101 +f1 ff packets b5fd26618458b725 files f195e1bfb415df25 +f1 00/5a 1-176: 0101 +f1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f2 00 1-176: 0101 +f2 00 packets b5fd26618458b725 files f195e1bfb415df25 +f2 01 1-176: 0101 +f2 01 packets b5fd26618458b725 files f195e1bfb415df25 +f2 81 1-176: 0101 +f2 81 packets b5fd26618458b725 files f195e1bfb415df25 +f2 ff 1-176: 0101 +f2 ff packets b5fd26618458b725 files f195e1bfb415df25 +f2 00/5a 1-176: 0101 +f2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f3 00 1-176: 0101 +f3 00 packets b5fd26618458b725 files f195e1bfb415df25 +f3 01 1-176: 0101 +f3 01 packets b5fd26618458b725 files f195e1bfb415df25 +f3 81 1-176: 0101 +f3 81 packets b5fd26618458b725 files f195e1bfb415df25 +f3 ff 1-176: 0101 +f3 ff packets b5fd26618458b725 files f195e1bfb415df25 +f3 00/5a 1-176: 0101 +f3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f4 00 1-176: 0101 +f4 00 packets b5fd26618458b725 files f195e1bfb415df25 +f4 01 1-176: 0101 +f4 01 packets b5fd26618458b725 files f195e1bfb415df25 +f4 81 1-176: 0101 +f4 81 packets b5fd26618458b725 files f195e1bfb415df25 +f4 ff 1-176: 0101 +f4 ff packets b5fd26618458b725 files f195e1bfb415df25 +f4 00/5a 1-176: 0101 +f4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f5 00 1-176: 0101 +f5 00 packets b5fd26618458b725 files f195e1bfb415df25 +f5 01 1-176: 0101 +f5 01 packets b5fd26618458b725 files f195e1bfb415df25 +f5 81 1-176: 0101 +f5 81 packets b5fd26618458b725 files f195e1bfb415df25 +f5 ff 1-176: 0101 +f5 ff packets b5fd26618458b725 files f195e1bfb415df25 +f5 00/5a 1-176: 0101 +f5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f6 00 1-176: 0101 +f6 00 packets b5fd26618458b725 files f195e1bfb415df25 +f6 01 1-176: 0101 +f6 01 packets b5fd26618458b725 files f195e1bfb415df25 +f6 81 1-176: 0101 +f6 81 packets b5fd26618458b725 files f195e1bfb415df25 +f6 ff 1-176: 0101 +f6 ff packets b5fd26618458b725 files f195e1bfb415df25 +f6 00/5a 1-176: 0101 +f6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f7 00 1-176: 0101 +f7 00 packets b5fd26618458b725 files f195e1bfb415df25 +f7 01 1-176: 0101 +f7 01 packets b5fd26618458b725 files f195e1bfb415df25 +f7 81 1-176: 0101 +f7 81 packets b5fd26618458b725 files f195e1bfb415df25 +f7 ff 1-176: 0101 +f7 ff packets b5fd26618458b725 files f195e1bfb415df25 +f7 00/5a 1-176: 0101 +f7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f8 00 1-176: 0101 +f8 00 packets b5fd26618458b725 files f195e1bfb415df25 +f8 01 1-176: 0101 +f8 01 packets b5fd26618458b725 files f195e1bfb415df25 +f8 81 1-176: 0101 +f8 81 packets b5fd26618458b725 files f195e1bfb415df25 +f8 ff 1-176: 0101 +f8 ff packets b5fd26618458b725 files f195e1bfb415df25 +f8 00/5a 1-176: 0101 +f8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f9 00 1-176: 0101 +f9 00 packets b5fd26618458b725 files f195e1bfb415df25 +f9 01 1-176: 0101 +f9 01 packets b5fd26618458b725 files f195e1bfb415df25 +f9 81 1-176: 0101 +f9 81 packets b5fd26618458b725 files f195e1bfb415df25 +f9 ff 1-176: 0101 +f9 ff packets b5fd26618458b725 files f195e1bfb415df25 +f9 00/5a 1-176: 0101 +f9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fa 00 1-176: 0101 +fa 00 packets b5fd26618458b725 files f195e1bfb415df25 +fa 01 1-176: 0101 +fa 01 packets b5fd26618458b725 files f195e1bfb415df25 +fa 81 1-176: 0101 +fa 81 packets b5fd26618458b725 files f195e1bfb415df25 +fa ff 1-176: 0101 +fa ff packets b5fd26618458b725 files f195e1bfb415df25 +fa 00/5a 1-176: 0101 +fa 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fb 00 1-176: 0101 +fb 00 packets b5fd26618458b725 files f195e1bfb415df25 +fb 01 1-176: 0101 +fb 01 packets b5fd26618458b725 files f195e1bfb415df25 +fb 81 1-176: 0101 +fb 81 packets b5fd26618458b725 files f195e1bfb415df25 +fb ff 1-176: 0101 +fb ff packets b5fd26618458b725 files f195e1bfb415df25 +fb 00/5a 1-176: 0101 +fb 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fc 00 1-176: 0101 +fc 00 packets b5fd26618458b725 files f195e1bfb415df25 +fc 01 1-176: 0101 +fc 01 packets b5fd26618458b725 files f195e1bfb415df25 +fc 81 1-176: 0101 +fc 81 packets b5fd26618458b725 files f195e1bfb415df25 +fc ff 1-176: 0101 +fc ff packets b5fd26618458b725 files f195e1bfb415df25 +fc 00/5a 1-176: 0101 +fc 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fd 00 1-176: 0101 +fd 00 packets b5fd26618458b725 files f195e1bfb415df25 +fd 01 1-176: 0101 +fd 01 packets b5fd26618458b725 files f195e1bfb415df25 +fd 81 1-176: 0101 +fd 81 packets b5fd26618458b725 files f195e1bfb415df25 +fd ff 1-176: 0101 +fd ff packets b5fd26618458b725 files f195e1bfb415df25 +fd 00/5a 1-176: 0101 +fd 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fe 00 1-176: 0101 +fe 00 packets b5fd26618458b725 files f195e1bfb415df25 +fe 01 1-176: 0101 +fe 01 packets b5fd26618458b725 files f195e1bfb415df25 +fe 81 1-176: 0101 +fe 81 packets b5fd26618458b725 files f195e1bfb415df25 +fe ff 1-176: 0101 +fe ff packets b5fd26618458b725 files f195e1bfb415df25 +fe 00/5a 1-176: 0101 +fe 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ff 00 1-176: 0101 +ff 00 packets b5fd26618458b725 files f195e1bfb415df25 +ff 01 1-176: 0101 +ff 01 packets b5fd26618458b725 files f195e1bfb415df25 +ff 81 1-176: 0101 +ff 81 packets b5fd26618458b725 files f195e1bfb415df25 +ff ff 1-176: 0101 +ff ff packets b5fd26618458b725 files f195e1bfb415df25 +ff 00/5a 1-176: 0101 +ff 00/5a packets b5fd26618458b725 files f195e1bfb415df25 diff --git a/test/test_companion_frames/identity_stubs.cpp b/test/test_companion_frames/identity_stubs.cpp new file mode 100644 index 00000000000..7e79b6ab2c9 --- /dev/null +++ b/test/test_companion_frames/identity_stubs.cpp @@ -0,0 +1,116 @@ +// Link-time stand-ins for the parts of mesh::Identity that the core, the chat layer +// and the companion reference. The real implementations need the board crypto +// libraries, which do not exist on the host. +// +// The stream and buffer forms copy bytes exactly as src/Identity.cpp does, so the +// identity file and CMD_EXPORT_PRIVATE_KEY / CMD_IMPORT_PRIVATE_KEY carry the same +// layout as on a board. The cryptographic operations are deterministic byte mixes, +// NOT cryptography: a round trip is self consistent and a test stays repeatable, but +// a passing signature here says nothing about Ed25519, and no test may assert a +// cryptographic property. + +#include +#include + +#include + +namespace { + +// A stable byte mix, so output depends on every input byte. +void derive(uint8_t* dest, size_t dest_len, const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + uint8_t acc = 0x5A; + for (size_t i = 0; i < a_len; i++) acc = (uint8_t)(acc * 31 + a[i]); + for (size_t i = 0; i < b_len; i++) acc = (uint8_t)(acc * 31 + b[i]); + for (size_t i = 0; i < dest_len; i++) { + acc = (uint8_t)(acc * 31 + (uint8_t)i); + dest[i] = acc; + } +} + +// Stands in for ed25519_derive_pub(). +void derivePub(uint8_t* pub, const uint8_t* prv) { + derive(pub, PUB_KEY_SIZE, prv, PRV_KEY_SIZE, nullptr, 0); +} + +} // namespace + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +bool Identity::readFrom(Stream& s) { + return (s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE); +} + +bool Identity::writeTo(Stream& s) const { + return (s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE); +} + +void Identity::printTo(Stream& s) const { + Utils::printHex(s, pub_key, PUB_KEY_SIZE); +} + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + // Cannot be checked without the private key, so accept anything. A test that + // needs to tell a good signature from a bad one must not use this stub. + return true; +} + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +// The real check derives the public key and refuses a 00 or FF prefix, and a known +// test key pair. Here the derived stand-in key is checked for the same prefixes. +bool LocalIdentity::validatePrivateKey(const uint8_t prv[64]) { + uint8_t pub[PUB_KEY_SIZE]; + derivePub(pub, prv); + return pub[0] != 0x00 && pub[0] != 0xFF; +} + +bool LocalIdentity::readFrom(Stream& s) { + bool success = (s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE); + success = success && (s.readBytes(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE); + return success; +} + +bool LocalIdentity::writeTo(Stream& s) const { + bool success = (s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE); + success = success && (s.write(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE); + return success; +} + +void LocalIdentity::printTo(Stream& s) const { + s.print("pub_key: "); Utils::printHex(s, pub_key, PUB_KEY_SIZE); s.print("\n"); + s.print("prv_key: "); Utils::printHex(s, prv_key, PRV_KEY_SIZE); s.print("\n"); +} + +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const { + if (max_len < PRV_KEY_SIZE) return 0; + if (max_len < PRV_KEY_SIZE + PUB_KEY_SIZE) { + memcpy(dest, prv_key, PRV_KEY_SIZE); + return PRV_KEY_SIZE; + } + memcpy(dest, prv_key, PRV_KEY_SIZE); + memcpy(&dest[PRV_KEY_SIZE], pub_key, PUB_KEY_SIZE); + return PRV_KEY_SIZE + PUB_KEY_SIZE; +} + +void LocalIdentity::readFrom(const uint8_t* src, size_t len) { + if (len == PRV_KEY_SIZE + PUB_KEY_SIZE) { + memcpy(prv_key, src, PRV_KEY_SIZE); + memcpy(pub_key, &src[PRV_KEY_SIZE], PUB_KEY_SIZE); + } else if (len == PRV_KEY_SIZE) { + memcpy(prv_key, src, PRV_KEY_SIZE); + derivePub(pub_key, prv_key); + } +} + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + derive(sig, SIGNATURE_SIZE, prv_key, PRV_KEY_SIZE, message, (size_t)msg_len); +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + derive(secret, PUB_KEY_SIZE, prv_key, PRV_KEY_SIZE, other_pub_key, PUB_KEY_SIZE); +} + +} // namespace mesh diff --git a/test/test_companion_frames/test_commands.cpp b/test/test_companion_frames/test_commands.cpp new file mode 100644 index 00000000000..faf63e25b28 --- /dev/null +++ b/test/test_companion_frames/test_commands.cpp @@ -0,0 +1,796 @@ +// What each companion command does with a well formed frame, and where its minimum +// length is. The frame sweep (test_frame_sweep.cpp) pins the replies at every +// length but only with filler bytes; these tests send the frames an app sends and +// check the meaning: the fields of a reply, and the state a setter changed, read +// back through the protocol where the protocol can read it. +// +// Expectations describe the firmware as it is (the current upstream behaviour plus +// the length guards of docs/fixes/companion-cmd-frame-min-len.md and +// docs/fixes/companion-channel-txt-min-len.md), including what looks odd; such +// places say so. + +#include "CompanionHarness.h" + +using namespace companion; + +namespace { + +// Command codes, as in examples/companion_radio/MyMesh.cpp. They are wire values, +// so the tests spell them out instead of borrowing the firmware's names. +enum : uint8_t { + APP_START = 1, SEND_TXT_MSG = 2, SEND_CHANNEL_TXT_MSG = 3, GET_CONTACTS = 4, + GET_DEVICE_TIME = 5, SET_DEVICE_TIME = 6, SEND_SELF_ADVERT = 7, SET_ADVERT_NAME = 8, + ADD_UPDATE_CONTACT = 9, SYNC_NEXT_MESSAGE = 10, SET_RADIO_PARAMS = 11, + SET_RADIO_TX_POWER = 12, RESET_PATH = 13, SET_ADVERT_LATLON = 14, REMOVE_CONTACT = 15, + SHARE_CONTACT = 16, EXPORT_CONTACT = 17, IMPORT_CONTACT = 18, REBOOT = 19, + GET_BATT_AND_STORAGE = 20, SET_TUNING_PARAMS = 21, DEVICE_QUERY = 22, + EXPORT_PRIVATE_KEY = 23, IMPORT_PRIVATE_KEY = 24, SEND_RAW_DATA = 25, SEND_LOGIN = 26, + SEND_STATUS_REQ = 27, HAS_CONNECTION = 28, LOGOUT = 29, GET_CONTACT_BY_KEY = 30, + GET_CHANNEL = 31, SET_CHANNEL = 32, SIGN_START = 33, SIGN_DATA = 34, SIGN_FINISH = 35, + SEND_TRACE_PATH = 36, SET_DEVICE_PIN = 37, SET_OTHER_PARAMS = 38, + SEND_TELEMETRY_REQ = 39, GET_CUSTOM_VARS = 40, SET_CUSTOM_VAR = 41, + GET_ADVERT_PATH = 42, GET_TUNING_PARAMS = 43, SEND_BINARY_REQ = 50, + FACTORY_RESET = 51, SEND_PATH_DISCOVERY_REQ = 52, SET_FLOOD_SCOPE_KEY = 54, + SEND_CONTROL_DATA = 55, GET_STATS = 56, SEND_ANON_REQ = 57, SET_AUTOADD_CONFIG = 58, + GET_AUTOADD_CONFIG = 59, GET_ALLOWED_REPEAT_FREQ = 60, SET_PATH_HASH_MODE = 61, + SEND_CHANNEL_DATA = 62, SET_DEFAULT_FLOOD_SCOPE = 63, GET_DEFAULT_FLOOD_SCOPE = 64, + SEND_RAW_PACKET = 65, RUN_CLI_COMMAND = 66, +}; + +enum : uint8_t { + RESP_OK = 0, RESP_ERR = 1, RESP_CONTACTS_START = 2, RESP_CONTACT = 3, + RESP_END_OF_CONTACTS = 4, RESP_SELF_INFO = 5, RESP_SENT = 6, RESP_CURR_TIME = 9, + RESP_NO_MORE_MESSAGES = 10, RESP_EXPORT_CONTACT = 11, RESP_BATT_AND_STORAGE = 12, + RESP_DEVICE_INFO = 13, RESP_PRIVATE_KEY = 14, RESP_CHANNEL_INFO = 18, + RESP_SIGN_START = 19, RESP_SIGNATURE = 20, RESP_CUSTOM_VARS = 21, + RESP_TUNING_PARAMS = 23, RESP_STATS = 24, RESP_AUTOADD_CONFIG = 25, + RESP_ALLOWED_REPEAT_FREQ = 26, RESP_DEFAULT_FLOOD_SCOPE = 28, RESP_CLI_REPLY = 29, + PUSH_TELEMETRY_RESPONSE = 0x8B, +}; + +enum : uint8_t { + ERR_UNSUPPORTED_CMD = 1, ERR_NOT_FOUND = 2, ERR_TABLE_FULL = 3, ERR_BAD_STATE = 4, + ERR_FILE_IO_ERROR = 5, ERR_ILLEGAL_ARG = 6, +}; + +const Bytes OK = {RESP_OK}; +Bytes err(uint8_t code) { return {RESP_ERR, code}; } + +// The node's own key, from radio_new_identity() in test_companion_frames.cpp. +Bytes selfPubKey() { + Bytes k; + for (int i = 0; i < PUB_KEY_SIZE; i++) k.push_back((uint8_t)(0xA0 + i)); + return k; +} + +// A peer that the tests add as a contact. Its first byte is neither 00 nor FF. +Bytes peerKey(uint8_t tag = 0x31) { + Bytes k; + for (int i = 0; i < PUB_KEY_SIZE; i++) k.push_back((uint8_t)(tag + i)); + return k; +} + +// CMD_ADD_UPDATE_CONTACT in the layout writeContactRespFrame() sends back: +// key, type, flags, out_path_len, out_path[64], name[32], last_advert, then the +// optional lat, lon and lastmod. +Bytes contactFrame(const Bytes& key, const char* name, uint8_t out_path_len = 0xFF, + bool with_optional = true) { + Bytes f = {ADD_UPDATE_CONTACT}; + put(f, key.data(), key.size()); + putU8(f, 1); // ADV_TYPE_CHAT + putU8(f, 0); // flags + putU8(f, out_path_len); // 0xFF: no path known, so messages flood + putFill(f, MAX_PATH_SIZE, 0); + putField(f, name, 32); + putU32(f, RTC0 - 100); // last_advert_timestamp + if (with_optional) { + putI32(f, 52500000); // lat + putI32(f, 13400000); // lon + putU32(f, RTC0 - 50); // lastmod + } + return f; +} + +Bytes withKey(uint8_t code, const Bytes& key) { + Bytes f = {code}; + put(f, key.data(), key.size()); + return f; +} + +Bytes prefix(const Bytes& f, size_t n) { return Bytes(f.begin(), f.begin() + n); } + +class CommandTest : public ::testing::Test { +protected: + Harness h; + + void SetUp() override { h.boot(); } + + void addPeer(const Bytes& key = peerKey(), uint8_t out_path_len = 0xFF) { + ASSERT_EQ(h.reply(contactFrame(key, "peer", out_path_len)), OK); + } + + // The SELF_INFO reply that APP_START returns, which carries most of the prefs. + Bytes selfInfo() { + Bytes f = {APP_START, 3}; + putFill(f, 6, 0); + putStr(f, "test"); + return h.reply(f); + } +}; + +// ---- connection and identity ------------------------------------------------- + +TEST_F(CommandTest, AppStartReportsTheNodeAndItsRadio) { + Bytes r = selfInfo(); + ASSERT_GE(r.size(), 58u); + EXPECT_EQ(r[0], RESP_SELF_INFO); + EXPECT_EQ(r[1], 1); // ADV_TYPE_CHAT + EXPECT_EQ(r[2], LORA_TX_POWER); + EXPECT_EQ(r[3], MAX_LORA_TX_POWER); + EXPECT_EQ(Bytes(r.begin() + 4, r.begin() + 36), selfPubKey()); + EXPECT_EQ(u32At(r, 36), 0u); // lat + EXPECT_EQ(u32At(r, 40), 0u); // lon + EXPECT_EQ(u32At(r, 48), (uint32_t)(LORA_FREQ * 1000)); + EXPECT_EQ(u32At(r, 52), (uint32_t)(LORA_BW * 1000)); + EXPECT_EQ(r[56], LORA_SF); + EXPECT_EQ(r[57], LORA_CR); + // The default name is the hex of the first four key bytes. + EXPECT_EQ(std::string(r.begin() + 58, r.end()), "A0A1A2A3"); +} + +TEST_F(CommandTest, AppStartNeedsTheReservedBytes) { + Bytes f = {APP_START, 3}; + putFill(f, 6, 0); + EXPECT_EQ(h.reply(prefix(f, 7)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f)[0], RESP_SELF_INFO); // 8 bytes, no app name +} + +TEST_F(CommandTest, DeviceQueryReportsTheFirmware) { + EXPECT_EQ(h.reply({DEVICE_QUERY}), err(ERR_UNSUPPORTED_CMD)); + Bytes r = h.reply({DEVICE_QUERY, 3}); + ASSERT_EQ(r.size(), 82u); + EXPECT_EQ(r[0], RESP_DEVICE_INFO); + EXPECT_EQ(r[1], FIRMWARE_VER_CODE); + EXPECT_EQ(r[2], MAX_CONTACTS / 2); + EXPECT_EQ(r[3], MAX_GROUP_CHANNELS); + EXPECT_EQ(u32At(r, 4), 0u); // ble pin + EXPECT_EQ(std::string((const char*)&r[8]), FIRMWARE_BUILD_DATE); + EXPECT_EQ(std::string((const char*)&r[20]), "Mock"); + EXPECT_EQ(std::string((const char*)&r[60]), FIRMWARE_VERSION); + EXPECT_EQ(r[80], 0); // client repeat off + EXPECT_EQ(r[81], 0); // path hash mode +} + +TEST_F(CommandTest, PrivateKeyExportAndImport) { + Bytes r = h.reply({EXPORT_PRIVATE_KEY}); + ASSERT_EQ(r.size(), 65u); + EXPECT_EQ(r[0], RESP_PRIVATE_KEY); + for (int i = 0; i < 64; i++) ASSERT_EQ(r[1 + i], (uint8_t)(0x40 + i)); + + Bytes f = {IMPORT_PRIVATE_KEY}; + putFill(f, 64, 0x11); + EXPECT_EQ(h.reply(prefix(f, 64)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + Bytes after = h.reply({EXPORT_PRIVATE_KEY}); + EXPECT_EQ(after[1], 0x11); + Bytes info = selfInfo(); + EXPECT_NE(Bytes(info.begin() + 4, info.begin() + 36), selfPubKey()); +} + +TEST_F(CommandTest, SetAdvertNameIsStoredAndTruncated) { + EXPECT_EQ(h.reply({SET_ADVERT_NAME}), err(ERR_UNSUPPORTED_CMD)); + Bytes f = {SET_ADVERT_NAME}; + putStr(f, "a-name-that-is-longer-than-thirty-one-bytes"); + EXPECT_EQ(h.reply(f), OK); + Bytes r = selfInfo(); + EXPECT_EQ(std::string(r.begin() + 58, r.end()), "a-name-that-is-longer-than-thir"); +} + +TEST_F(CommandTest, SetAdvertLatLon) { + Bytes f = {SET_ADVERT_LATLON}; + putI32(f, 52500000); + putI32(f, -13400000); + EXPECT_EQ(h.reply(prefix(f, 8)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + Bytes r = selfInfo(); + EXPECT_EQ((int32_t)u32At(r, 36), 52500000); + EXPECT_EQ((int32_t)u32At(r, 40), -13400000); + + Bytes bad = {SET_ADVERT_LATLON}; + putI32(bad, 91000000); + putI32(bad, 0); + EXPECT_EQ(h.reply(bad), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, DevicePin) { + Bytes f = {SET_DEVICE_PIN}; + putU32(f, 123456); + EXPECT_EQ(h.reply(prefix(f, 4)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(u32At(h.reply({DEVICE_QUERY, 3}), 4), 123456u); + + Bytes bad = {SET_DEVICE_PIN}; + putU32(bad, 12345); + EXPECT_EQ(h.reply(bad), err(ERR_ILLEGAL_ARG)); +} + +// ---- time ------------------------------------------------------------------ + +TEST_F(CommandTest, DeviceTime) { + Bytes r = h.reply({GET_DEVICE_TIME}); + ASSERT_EQ(r.size(), 5u); + EXPECT_EQ(r[0], RESP_CURR_TIME); + EXPECT_EQ(u32At(r, 1), RTC0); + + Bytes f = {SET_DEVICE_TIME}; + putU32(f, RTC0 + 60); + EXPECT_EQ(h.reply(prefix(f, 4)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(rtc_clock.now, RTC0 + 60); + + Bytes back = {SET_DEVICE_TIME}; + putU32(back, RTC0); + EXPECT_EQ(h.reply(back), err(ERR_ILLEGAL_ARG)); // the clock never goes back + EXPECT_EQ(rtc_clock.now, RTC0 + 60); +} + +// ---- radio and tuning -------------------------------------------------------- + +Bytes radioParams(uint32_t freq, uint32_t bw, uint8_t sf, uint8_t cr, int repeat = -1) { + Bytes f = {SET_RADIO_PARAMS}; + putU32(f, freq); + putU32(f, bw); + putU8(f, sf); + putU8(f, cr); + if (repeat >= 0) putU8(f, (uint8_t)repeat); + return f; +} + +TEST_F(CommandTest, SetRadioParams) { + Bytes f = radioParams(869525, 250000, 11, 5); + int at_boot = radio_driver.set_params_calls; // begin() applies the stored ones + EXPECT_EQ(h.reply(prefix(f, 10)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(radio_driver.set_params_calls, at_boot); + EXPECT_EQ(h.reply(f), OK); + EXPECT_FLOAT_EQ(radio_driver.freq, 869.525f); + EXPECT_FLOAT_EQ(radio_driver.bw, 250.0f); + EXPECT_EQ(radio_driver.sf, 11); + EXPECT_EQ(radio_driver.cr, 5); + Bytes r = selfInfo(); + EXPECT_EQ(u32At(r, 48), 869525u); + EXPECT_EQ(r[56], 11); + + EXPECT_EQ(h.reply(radioParams(869525, 250000, 13, 5)), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply(radioParams(100000, 250000, 11, 5)), err(ERR_ILLEGAL_ARG)); + // Client repeat is only allowed on the listed frequencies. + EXPECT_EQ(h.reply(radioParams(869525, 250000, 11, 5, 1)), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply(radioParams(869495, 250000, 11, 5, 1)), OK); + EXPECT_EQ(h.reply({DEVICE_QUERY, 3})[80], 1); +} + +TEST_F(CommandTest, SetTxPower) { + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER, 10}), OK); + EXPECT_EQ(radio_driver.tx_power, 10); + EXPECT_EQ(selfInfo()[2], 10); + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER, MAX_LORA_TX_POWER + 1}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER, (uint8_t)-10}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER, (uint8_t)-9}), OK); +} + +TEST_F(CommandTest, TuningParamsRoundTrip) { + Bytes r = h.reply({GET_TUNING_PARAMS}); + ASSERT_EQ(r.size(), 9u); + EXPECT_EQ(r[0], RESP_TUNING_PARAMS); + EXPECT_EQ(u32At(r, 1), 0u); + EXPECT_EQ(u32At(r, 5), 1000u); + + Bytes f = {SET_TUNING_PARAMS}; + putU32(f, 2500); + putU32(f, 3000); + EXPECT_EQ(h.reply(prefix(f, 8)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + r = h.reply({GET_TUNING_PARAMS}); + EXPECT_EQ(u32At(r, 1), 2500u); + EXPECT_EQ(u32At(r, 5), 3000u); +} + +TEST_F(CommandTest, SetOtherParamsHasOptionalTail) { + EXPECT_EQ(h.reply({SET_OTHER_PARAMS}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_OTHER_PARAMS, 1}), OK); + Bytes r = selfInfo(); + EXPECT_EQ(r[47], 1); // manual_add_contacts + EXPECT_EQ(r[46], 0); // telemetry modes untouched + + EXPECT_EQ(h.reply({SET_OTHER_PARAMS, 0, 0x26, 1, 1}), OK); + r = selfInfo(); + EXPECT_EQ(r[44], 1); // multi_acks + EXPECT_EQ(r[45], 1); // advert_loc_policy + EXPECT_EQ(r[46], 0x26); // env 2, loc 1, base 2 + EXPECT_EQ(r[47], 0); +} + +TEST_F(CommandTest, AutoAddConfig) { + EXPECT_EQ(h.reply({SET_AUTOADD_CONFIG}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_AUTOADD_CONFIG, 0x1E, 100}), OK); + EXPECT_EQ(h.reply({GET_AUTOADD_CONFIG}), Bytes({RESP_AUTOADD_CONFIG, 0x1E, 64})); + EXPECT_EQ(h.reply({SET_AUTOADD_CONFIG, 0x02}), OK); // max hops stays + EXPECT_EQ(h.reply({GET_AUTOADD_CONFIG}), Bytes({RESP_AUTOADD_CONFIG, 0x02, 64})); +} + +TEST_F(CommandTest, PathHashMode) { + EXPECT_EQ(h.reply({SET_PATH_HASH_MODE, 0}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_PATH_HASH_MODE, 1, 2}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_PATH_HASH_MODE, 0, 3}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply({SET_PATH_HASH_MODE, 0, 2}), OK); + EXPECT_EQ(h.reply({DEVICE_QUERY, 3})[81], 2); +} + +TEST_F(CommandTest, AllowedRepeatFrequencies) { + Bytes r = h.reply({GET_ALLOWED_REPEAT_FREQ}); + ASSERT_EQ(r.size(), 25u); + EXPECT_EQ(r[0], RESP_ALLOWED_REPEAT_FREQ); + EXPECT_EQ(u32At(r, 1), 433000u); + EXPECT_EQ(u32At(r, 9), 869495u); + EXPECT_EQ(u32At(r, 17), 918000u); +} + +TEST_F(CommandTest, Stats) { + EXPECT_EQ(h.reply({GET_STATS}), err(ERR_UNSUPPORTED_CMD)); + Bytes core = h.reply({GET_STATS, 0}); + ASSERT_EQ(core.size(), 11u); + EXPECT_EQ(core[0], RESP_STATS); + EXPECT_EQ(core[1], 0); + EXPECT_EQ(u16At(core, 2), 4100); // battery + EXPECT_EQ(u32At(core, 4), T0 / 1000); // uptime + Bytes radio = h.reply({GET_STATS, 1}); + ASSERT_EQ(radio.size(), 14u); + EXPECT_EQ((int8_t)radio[4], -90); // last rssi + EXPECT_EQ((int8_t)radio[5], 20); // last snr * 4 + EXPECT_EQ(h.reply({GET_STATS, 2}).size(), 30u); + EXPECT_EQ(h.reply({GET_STATS, 3}), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, BatteryAndStorage) { + Bytes r = h.reply({GET_BATT_AND_STORAGE}); + ASSERT_EQ(r.size(), 11u); + EXPECT_EQ(r[0], RESP_BATT_AND_STORAGE); + EXPECT_EQ(u16At(r, 1), 4100); + EXPECT_EQ(u32At(r, 7), 512u); // total kB of the mock +} + +TEST_F(CommandTest, CustomVars) { + EXPECT_EQ(h.reply({GET_CUSTOM_VARS}), Bytes({RESP_CUSTOM_VARS})); + Bytes f = {SET_CUSTOM_VAR}; + putStr(f, "a:1"); + EXPECT_EQ(h.reply(prefix(f, 3)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); // the mock accepts any name + Bytes nocolon = {SET_CUSTOM_VAR}; + putStr(nocolon, "abc"); + EXPECT_EQ(h.reply(nocolon), err(ERR_ILLEGAL_ARG)); +} + +// ---- contacts -------------------------------------------------------------- + +TEST_F(CommandTest, AddContactNeedsEveryMandatoryField) { + Bytes f = contactFrame(peerKey(), "peer", 0xFF, false); + ASSERT_EQ(f.size(), 136u); + EXPECT_EQ(h.reply(prefix(f, 135)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); +} + +TEST_F(CommandTest, ContactRoundTrip) { + addPeer(); + Bytes r = h.reply(withKey(GET_CONTACT_BY_KEY, peerKey())); + ASSERT_EQ(r.size(), 148u); + EXPECT_EQ(r[0], RESP_CONTACT); + // The reply is the add frame with its code changed, which is the layout the app + // relies on when it edits a contact and sends it back. + Bytes sent = contactFrame(peerKey(), "peer"); + sent[0] = RESP_CONTACT; + EXPECT_EQ(r, sent); + + EXPECT_EQ(h.reply(prefix(withKey(GET_CONTACT_BY_KEY, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey(0x50))), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, AddContactWithoutLastmodUsesTheClock) { + Bytes f = contactFrame(peerKey(), "peer"); + f.resize(f.size() - 4); + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(u32At(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey())), 144), RTC0); +} + +TEST_F(CommandTest, ContactListing) { + addPeer(peerKey(0x31)); + addPeer(peerKey(0x51)); + Bytes start = h.reply({GET_CONTACTS}); + EXPECT_EQ(start, Bytes({RESP_CONTACTS_START, 2, 0, 0, 0})); + // The iterator runs in the following loops, one contact each, then the end + // marker with the newest lastmod, so the app can ask for "since" next time. + for (int i = 0; i < 4; i++) h.mesh().loop(); + ASSERT_EQ(h.serial.written.size(), 4u); + EXPECT_EQ(h.serial.written[0], start); + EXPECT_EQ(h.serial.written[1][0], RESP_CONTACT); + EXPECT_EQ(h.serial.written[2][0], RESP_CONTACT); + Bytes end = {RESP_END_OF_CONTACTS}; + putU32(end, RTC0 - 50); + EXPECT_EQ(h.serial.written[3], end); + // A second request while one runs is refused. + h.reply({GET_CONTACTS}); + EXPECT_EQ(h.reply({GET_CONTACTS}), err(ERR_BAD_STATE)); +} + +TEST_F(CommandTest, ContactListingSince) { + addPeer(); + Bytes f = {GET_CONTACTS}; + putU32(f, RTC0); // newer than the contact + EXPECT_EQ(h.reply(f), Bytes({RESP_CONTACTS_START, 1, 0, 0, 0})); + h.serial.written.clear(); + for (int i = 0; i < 3; i++) h.mesh().loop(); + ASSERT_EQ(h.serial.written.size(), 1u); + EXPECT_EQ(h.serial.written[0][0], RESP_END_OF_CONTACTS); +} + +TEST_F(CommandTest, ResetPathAndRemove) { + addPeer(peerKey(), 0); + EXPECT_EQ(h.reply(prefix(withKey(RESET_PATH, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(RESET_PATH, peerKey())), OK); + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey()))[35], 0xFF); + EXPECT_EQ(h.reply(withKey(RESET_PATH, peerKey(0x50))), err(ERR_NOT_FOUND)); + + EXPECT_EQ(h.reply(prefix(withKey(REMOVE_CONTACT, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, peerKey())), OK); + EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, peerKey())), err(ERR_NOT_FOUND)); + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey())), err(ERR_NOT_FOUND)); +} + +// The contact table starts with eight reserved slots for anonymous requests, all +// zeros until one is used, and the lookup searches them too. A key of zeros +// therefore names a reserved slot, and REMOVE_CONTACT removes it. The reported +// count, num_contacts minus the eight, then wraps. Recorded as it is; the slots +// come back on the next boot (resetContacts()). Also seen on a board, see +// docs/refactoring/0010-companion-command-table.md. +TEST_F(CommandTest, AZeroKeyNamesAReservedSlot) { + Bytes zero(PUB_KEY_SIZE, 0); + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, zero))[0], RESP_CONTACT); + EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, zero)), OK); + Bytes start = h.reply({GET_CONTACTS}); + EXPECT_EQ(start, Bytes({RESP_CONTACTS_START, 0xFF, 0xFF, 0xFF, 0xFF})); + for (int i = 0; i < 7; i++) EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, zero)), OK) << i; + EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, zero)), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, ShareContact) { + addPeer(); + EXPECT_EQ(h.reply(prefix(withKey(SHARE_CONTACT, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + // Sharing resends the contact's stored advert; one added by hand has none, which + // the firmware reports as "unable to send". + EXPECT_EQ(h.reply(withKey(SHARE_CONTACT, peerKey())), err(ERR_TABLE_FULL)); + EXPECT_EQ(h.mesh().outboundTotal(), 0); + EXPECT_EQ(h.reply(withKey(SHARE_CONTACT, peerKey(0x50))), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, ExportSelfThenSendItAsRawPacket) { + Bytes r = h.reply({EXPORT_CONTACT}); + ASSERT_GT(r.size(), 1u + 2 + PUB_KEY_SIZE); + EXPECT_EQ(r[0], RESP_EXPORT_CONTACT); + EXPECT_EQ(r[1] & 0x03, 1); // ROUTE_TYPE_FLOOD + EXPECT_EQ(h.mesh().outboundTotal(), 0); // exported, not sent + + // The advert is a whole packet, so the raw packet command takes it as is. + Bytes raw = {SEND_RAW_PACKET, 1}; + put(raw, &r[1], r.size() - 1); + EXPECT_EQ(h.reply(prefix(raw, 3)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(raw), OK); + EXPECT_GT(h.mesh().outboundTotal(), 0); + EXPECT_EQ(h.reply({SEND_RAW_PACKET, 1, 0xFF, 0xFF}), err(ERR_ILLEGAL_ARG)); + + EXPECT_EQ(h.reply(withKey(EXPORT_CONTACT, peerKey())), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, ExportAndImportAContact) { + addPeer(); + Bytes r = h.reply(withKey(EXPORT_CONTACT, peerKey())); + // A contact that was added by hand has no advert stored to export. + EXPECT_EQ(r, err(ERR_NOT_FOUND)); + + // Importing needs more than 98 bytes; a packet that is not an advert is refused. + Bytes f = {IMPORT_CONTACT}; + putFill(f, 97, 0); + EXPECT_EQ(h.reply(f), err(ERR_UNSUPPORTED_CMD)); + putU8(f, 0); + EXPECT_EQ(h.reply(f), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, AdvertPath) { + Bytes f = {GET_ADVERT_PATH, 0}; + put(f, peerKey().data(), PUB_KEY_SIZE); + EXPECT_EQ(h.reply(prefix(f, 33)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), err(ERR_NOT_FOUND)); +} + +// ---- messages and requests ------------------------------------------------- + +Bytes textMsg(uint8_t txt_type, const Bytes& key, const char* text) { + Bytes f = {SEND_TXT_MSG, txt_type, 0}; + putU32(f, RTC0); + put(f, key.data(), 6); + putStr(f, text); + return f; +} + +TEST_F(CommandTest, SendTextMessage) { + addPeer(); + Bytes f = textMsg(0, peerKey(), "hi"); + EXPECT_EQ(h.reply(prefix(f, 13)), err(ERR_UNSUPPORTED_CMD)); + Bytes r = h.reply(f); + ASSERT_EQ(r.size(), 10u); + EXPECT_EQ(r[0], RESP_SENT); + EXPECT_EQ(r[1], 1); // flooded, no path known + EXPECT_NE(u32At(r, 2), 0u); // the ack to expect + EXPECT_GT(h.mesh().outboundTotal(), 0); + + EXPECT_EQ(h.reply(textMsg(0, peerKey(0x50), "hi")), err(ERR_NOT_FOUND)); + EXPECT_EQ(h.reply(textMsg(2, peerKey(), "hi")), err(ERR_UNSUPPORTED_CMD)); + // A CLI command expects no ack. + EXPECT_EQ(u32At(h.reply(textMsg(1, peerKey(), "ver")), 2), 0u); +} + +Bytes channelMsg(uint8_t txt_type, uint8_t idx, const char* text) { + Bytes f = {SEND_CHANNEL_TXT_MSG, txt_type, idx}; + putU32(f, RTC0); + putStr(f, text); + return f; +} + +TEST_F(CommandTest, SendChannelText) { + EXPECT_EQ(h.reply(prefix(channelMsg(0, 0, ""), 6)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(channelMsg(0, 0, "")), OK); + EXPECT_EQ(h.reply(channelMsg(0, 0, "hello")), OK); + EXPECT_GT(h.mesh().outboundTotal(), 0); + EXPECT_EQ(h.reply(channelMsg(1, 0, "hello")), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(channelMsg(0, MAX_GROUP_CHANNELS, "hello")), err(ERR_NOT_FOUND)); +} + +Bytes channelData(uint8_t idx, uint8_t path_len, uint16_t data_type, size_t payload) { + Bytes f = {SEND_CHANNEL_DATA, idx, path_len}; + if (path_len != 0xFF) putFill(f, path_len, 0x22); + putU16(f, data_type); + putFill(f, payload, 0x33); + return f; +} + +TEST_F(CommandTest, SendChannelData) { + // This command answers a short frame with ILLEGAL_ARG, not UNSUPPORTED_CMD. + EXPECT_EQ(h.reply({SEND_CHANNEL_DATA, 0, 0xFF}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply({SEND_CHANNEL_DATA, 0, 0xFF, 0x34}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply(channelData(0, 0xFF, 0x1234, 0)), OK); + EXPECT_EQ(h.reply(channelData(0, 2, 0x1234, 3)), OK); + EXPECT_EQ(h.reply(channelData(0, 0xFF, 0, 3)), err(ERR_ILLEGAL_ARG)); // reserved type + EXPECT_EQ(h.reply(channelData(MAX_GROUP_CHANNELS, 0xFF, 0x1234, 3)), err(ERR_NOT_FOUND)); + EXPECT_EQ(h.reply({SEND_CHANNEL_DATA, 0, 0x7F, 0, 0}), err(ERR_ILLEGAL_ARG)); // bad path len +} + +TEST_F(CommandTest, SendRawData) { + Bytes f = {SEND_RAW_DATA, 1, 0x44, 1, 2, 3, 4}; + EXPECT_EQ(h.reply(prefix(f, 5)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(prefix(f, 6)), err(ERR_ILLEGAL_ARG)); // payload below 4 + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(h.reply({SEND_RAW_DATA, 0xFF, 1, 2, 3, 4}), err(ERR_UNSUPPORTED_CMD)); // flood +} + +// The four requests that answer RESP_SENT with a tag, against a known peer, and +// NOT_FOUND for an unknown one. +TEST_F(CommandTest, RequestsToAPeer) { + addPeer(); + Bytes login = withKey(SEND_LOGIN, peerKey()); + putStr(login, "pw"); + EXPECT_EQ(h.reply(prefix(login, 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(login)[0], RESP_SENT); + EXPECT_EQ(h.reply(withKey(SEND_LOGIN, peerKey(0x50))), err(ERR_NOT_FOUND)); + + EXPECT_EQ(h.reply(prefix(withKey(SEND_STATUS_REQ, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(SEND_STATUS_REQ, peerKey()))[0], RESP_SENT); + EXPECT_EQ(h.reply(withKey(SEND_STATUS_REQ, peerKey(0x50))), err(ERR_NOT_FOUND)); + + Bytes bin = withKey(SEND_BINARY_REQ, peerKey()); + EXPECT_EQ(h.reply(bin), err(ERR_UNSUPPORTED_CMD)); // needs at least one data byte + putU8(bin, 0x07); + EXPECT_EQ(h.reply(bin)[0], RESP_SENT); + + Bytes disc = {SEND_PATH_DISCOVERY_REQ, 0}; + put(disc, peerKey().data(), PUB_KEY_SIZE); + EXPECT_EQ(h.reply(prefix(disc, 33)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(disc)[0], RESP_SENT); + disc[1] = 1; + EXPECT_EQ(h.reply(disc), err(ERR_UNSUPPORTED_CMD)); +} + +TEST_F(CommandTest, TelemetryHasTwoForms) { + addPeer(); + Bytes self = {SEND_TELEMETRY_REQ, 0, 0, 0}; + Bytes r = h.reply(self); + ASSERT_GE(r.size(), 8u); + EXPECT_EQ(r[0], PUSH_TELEMETRY_RESPONSE); + EXPECT_EQ(Bytes(r.begin() + 2, r.begin() + 8), prefix(selfPubKey(), 6)); + + Bytes peer = {SEND_TELEMETRY_REQ, 0, 0, 0}; + put(peer, peerKey().data(), PUB_KEY_SIZE); + EXPECT_EQ(h.reply(peer)[0], RESP_SENT); + // Between the two forms the frame matches neither. + EXPECT_EQ(h.reply(prefix(peer, 5)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(prefix(peer, 35)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(prefix(peer, 3)), err(ERR_UNSUPPORTED_CMD)); +} + +TEST_F(CommandTest, AnonRequestAddsATransientContact) { + Bytes f = withKey(SEND_ANON_REQ, peerKey(0x50)); + EXPECT_EQ(h.reply(f), err(ERR_UNSUPPORTED_CMD)); // needs at least one data byte + putU8(f, 0x01); + Bytes r = h.reply(f); + ASSERT_EQ(r.size(), 10u); + EXPECT_EQ(r[0], RESP_SENT); + EXPECT_EQ(r[1], 0); // zero hop direct + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey(0x50)))[33], 0); // ADV_TYPE_NONE +} + +TEST_F(CommandTest, ConnectionsAndLogout) { + EXPECT_EQ(h.reply(prefix(withKey(HAS_CONNECTION, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(HAS_CONNECTION, peerKey())), err(ERR_NOT_FOUND)); + EXPECT_EQ(h.reply(prefix(withKey(LOGOUT, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(LOGOUT, peerKey())), OK); +} + +TEST_F(CommandTest, TracePath) { + Bytes f = {SEND_TRACE_PATH}; + putU32(f, 0x11223344); + putU32(f, 0); + putU8(f, 0); // one byte hashes + EXPECT_EQ(h.reply(f), err(ERR_UNSUPPORTED_CMD)); // no path + putU8(f, 0x7A); + Bytes r = h.reply(f); + ASSERT_EQ(r.size(), 10u); + EXPECT_EQ(r[0], RESP_SENT); + EXPECT_EQ(u32At(r, 2), 0x11223344u); + f[9] = 1; // two byte hashes: 1 is not a multiple + EXPECT_EQ(h.reply(f), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, ControlData) { + EXPECT_EQ(h.reply({SEND_CONTROL_DATA}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SEND_CONTROL_DATA, 0x10}), err(ERR_UNSUPPORTED_CMD)); // high bit needed + EXPECT_EQ(h.reply({SEND_CONTROL_DATA, 0x80, 1, 2}), OK); + EXPECT_GT(h.mesh().outboundTotal(), 0); +} + +TEST_F(CommandTest, SelfAdvert) { + EXPECT_EQ(h.reply({SEND_SELF_ADVERT}), OK); + EXPECT_EQ(h.reply({SEND_SELF_ADVERT, 1}), OK); + EXPECT_EQ(h.mesh().outboundTotal(), 2); +} + +TEST_F(CommandTest, SyncWithNothingQueued) { + EXPECT_EQ(h.reply({SYNC_NEXT_MESSAGE}), Bytes({RESP_NO_MORE_MESSAGES})); +} + +// ---- channels, scopes, signing ------------------------------------------------- + +Bytes setChannel(uint8_t idx, const char* name, uint8_t secret_byte) { + Bytes f = {SET_CHANNEL, idx}; + putField(f, name, 32); + putFill(f, 16, secret_byte); + return f; +} + +TEST_F(CommandTest, Channels) { + EXPECT_EQ(h.reply({GET_CHANNEL}), err(ERR_UNSUPPORTED_CMD)); + Bytes pub = h.reply({GET_CHANNEL, 0}); + ASSERT_EQ(pub.size(), 50u); + EXPECT_EQ(pub[0], RESP_CHANNEL_INFO); + EXPECT_EQ(std::string((const char*)&pub[2]), "Public"); + + Bytes f = setChannel(1, "#test", 0x5C); + EXPECT_EQ(h.reply(prefix(f, 49)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + Bytes back = h.reply({GET_CHANNEL, 1}); + f[0] = RESP_CHANNEL_INFO; + EXPECT_EQ(back, f); + EXPECT_EQ(h.reply(setChannel(MAX_GROUP_CHANNELS, "x", 1)), err(ERR_NOT_FOUND)); + // A 256 bit secret is not supported, and says so. + Bytes wide = setChannel(1, "#wide", 1); + putFill(wide, 16, 1); + EXPECT_EQ(h.reply(wide), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({GET_CHANNEL, MAX_GROUP_CHANNELS}), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, FloodScopeKey) { + EXPECT_EQ(h.reply({SET_FLOOD_SCOPE_KEY}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_FLOOD_SCOPE_KEY, 0}), OK); + Bytes f = {SET_FLOOD_SCOPE_KEY, 0}; + putFill(f, 16, 0x42); + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(h.reply({SET_FLOOD_SCOPE_KEY, 1}), OK); + EXPECT_EQ(h.reply({SET_FLOOD_SCOPE_KEY, 2}), err(ERR_UNSUPPORTED_CMD)); +} + +TEST_F(CommandTest, DefaultFloodScope) { + EXPECT_EQ(h.reply({GET_DEFAULT_FLOOD_SCOPE}), Bytes({RESP_DEFAULT_FLOOD_SCOPE})); + Bytes f = {SET_DEFAULT_FLOOD_SCOPE}; + putField(f, "hansemesh", 31); + putFill(f, 16, 0x24); + EXPECT_EQ(h.reply(f), OK); + Bytes r = h.reply({GET_DEFAULT_FLOOD_SCOPE}); + Bytes expected = f; + expected[0] = RESP_DEFAULT_FLOOD_SCOPE; + EXPECT_EQ(r, expected); + + // A frame shorter than name and key clears the scope, whatever it holds. + EXPECT_EQ(h.reply(prefix(f, 47)), OK); + EXPECT_EQ(h.reply({GET_DEFAULT_FLOOD_SCOPE}), Bytes({RESP_DEFAULT_FLOOD_SCOPE})); + Bytes empty = {SET_DEFAULT_FLOOD_SCOPE}; + putFill(empty, 47, 0); + EXPECT_EQ(h.reply(empty), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, Signing) { + EXPECT_EQ(h.reply({SIGN_FINISH}), err(ERR_BAD_STATE)); + EXPECT_EQ(h.reply({SIGN_DATA, 1}), err(ERR_BAD_STATE)); + Bytes start = h.reply({SIGN_START}); + ASSERT_EQ(start.size(), 6u); + EXPECT_EQ(start[0], RESP_SIGN_START); + EXPECT_EQ(u32At(start, 2), 8u * 1024); + EXPECT_EQ(h.reply({SIGN_DATA}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SIGN_DATA, 1, 2, 3}), OK); + Bytes sig = h.reply({SIGN_FINISH}); + ASSERT_EQ(sig.size(), 1u + SIGNATURE_SIZE); + EXPECT_EQ(sig[0], RESP_SIGNATURE); + EXPECT_EQ(h.reply({SIGN_FINISH}), err(ERR_BAD_STATE)); +} + +// ---- CLI, reboot, reset ---------------------------------------------------- + +TEST_F(CommandTest, RunCliCommand) { + EXPECT_EQ(h.reply({RUN_CLI_COMMAND, 'x'}), err(ERR_UNSUPPORTED_CMD)); + Bytes f = {RUN_CLI_COMMAND}; + putStr(f, "get name"); + Bytes r = h.reply(f); + EXPECT_EQ(r[0], RESP_CLI_REPLY); + EXPECT_EQ(std::string(r.begin() + 1, r.end()), "> A0A1A2A3"); +} + +TEST_F(CommandTest, RebootNeedsTheWord) { + Bytes f = {REBOOT}; + putStr(f, "reboot"); + EXPECT_EQ(h.reply(prefix(f, 6)), err(ERR_UNSUPPORTED_CMD)); + Bytes other = {REBOOT}; + putStr(other, "rebooX"); + EXPECT_EQ(h.reply(other), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(board.reboot_calls, 0); + EXPECT_TRUE(h.send(f).empty()); // no reply, the board restarts + EXPECT_EQ(board.reboot_calls, 1); +} + +TEST_F(CommandTest, FactoryResetNeedsTheWord) { + Bytes f = {FACTORY_RESET}; + putStr(f, "reset"); + EXPECT_EQ(h.reply(prefix(f, 5)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_FALSE(h.fs.files.empty()); + EXPECT_EQ(h.reply(f), OK); + EXPECT_TRUE(h.fs.files.empty()); + EXPECT_EQ(h.serial.disable_calls, 1); + EXPECT_EQ(board.reboot_calls, 1); +} + +// ---- codes without a command -------------------------------------------------- + +TEST_F(CommandTest, UnknownCodes) { + for (int code : {0, 44, 45, 46, 47, 48, 49, 53, 67, 0x7F, 0x80, 0xFF}) { + Bytes f = {(uint8_t)code}; + putFill(f, 40, 0); + EXPECT_EQ(h.reply(prefix(f, 1)), err(ERR_UNSUPPORTED_CMD)) << code; + EXPECT_EQ(h.reply(f), err(ERR_UNSUPPORTED_CMD)) << code; + } +} + +} // namespace diff --git a/test/test_companion_frames/test_companion_frames.cpp b/test/test_companion_frames/test_companion_frames.cpp new file mode 100644 index 00000000000..1e7909ebcb5 --- /dev/null +++ b/test/test_companion_frames/test_companion_frames.cpp @@ -0,0 +1,36 @@ +// The companion's frame protocol on the host: the real MyMesh.cpp, driven through +// handleCmdFrame() the way the BLE, USB and WiFi interfaces drive it. The suites +// pin the protocol before handleCmdFrame() is restructured +// (docs/refactoring/0010-companion-command-table.md): +// +// - test_frame_sweep.cpp: every code at every length, against a recorded transcript +// - test_commands.cpp: what each command does with a valid frame, and where its +// minimum length is +// - test_frame_bounds.cpp: that no command reads the bytes a previous frame left +// above the end of the current one (step 10c) +// - test_frame_reader.cpp: the bounded reader the handlers use since step 10c + +#include + +#include +#include + +// A variant's target.cpp creates the identity from the board's RNG. Here it is a +// fixed key pair, so the node's public key, and every reply that carries it, is the +// same on every run. The first byte is neither 00 nor FF, which begin() rejects. +mesh::LocalIdentity radio_new_identity() { + uint8_t keys[PRV_KEY_SIZE + PUB_KEY_SIZE]; + for (int i = 0; i < PRV_KEY_SIZE; i++) keys[i] = (uint8_t)(0x40 + i); + for (int i = 0; i < PUB_KEY_SIZE; i++) keys[PRV_KEY_SIZE + i] = (uint8_t)(0xA0 + i); + mesh::LocalIdentity id; + id.readFrom(keys, sizeof(keys)); + return id; +} + +// FrameReader counts the reads that ran out (FRAME_READER_COUNT_OVERRUNS). +int frame_reader_overruns = 0; + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_companion_frames/test_frame_bounds.cpp b/test/test_companion_frames/test_frame_bounds.cpp new file mode 100644 index 00000000000..dccd6589c1c --- /dev/null +++ b/test/test_companion_frames/test_frame_bounds.cpp @@ -0,0 +1,408 @@ +// Where each command's frame ends, with frames an app sends. +// +// The handlers read their fields at offsets into cmd_frame. On a board the bytes +// above a frame's length are left over from the previous, longer frame, so a +// handler that reads past `len` acts on them. The frame sweep shows that for +// filler frames behind one tail; these tests do it for a catalog of real frames: +// for each command at its minimum and one byte below it, at both sides of every +// optional field, and at the places where a handler, or a function it calls, is +// known to read past the frame (docs/refactoring/0010-companion-command-table.md, +// step 10c). Each frame goes to a freshly booted node once as it is and once +// behind each of four tails, and everything the node did must be the same: the +// replies byte for byte, the packets queued for the air, the files stored, the +// calls to the board, radio, clock and sensors. +// +// A second test pins the answer one byte below each minimum, the case step 10d +// would change and step 10c must not. + +#include "CompanionHarness.h" +#include + +#include +#include + +using namespace companion; + +namespace { + +enum : uint8_t { + APP_START = 1, SEND_TXT_MSG = 2, SEND_CHANNEL_TXT_MSG = 3, GET_CONTACTS = 4, + GET_DEVICE_TIME = 5, SET_DEVICE_TIME = 6, SEND_SELF_ADVERT = 7, SET_ADVERT_NAME = 8, + ADD_UPDATE_CONTACT = 9, SET_RADIO_PARAMS = 11, SET_RADIO_TX_POWER = 12, + RESET_PATH = 13, SET_ADVERT_LATLON = 14, REMOVE_CONTACT = 15, SHARE_CONTACT = 16, + EXPORT_CONTACT = 17, IMPORT_CONTACT = 18, REBOOT = 19, SET_TUNING_PARAMS = 21, + DEVICE_QUERY = 22, IMPORT_PRIVATE_KEY = 24, SEND_RAW_DATA = 25, SEND_LOGIN = 26, + SEND_STATUS_REQ = 27, HAS_CONNECTION = 28, LOGOUT = 29, GET_CONTACT_BY_KEY = 30, + GET_CHANNEL = 31, SET_CHANNEL = 32, SIGN_START = 33, SIGN_DATA = 34, + SEND_TRACE_PATH = 36, SET_DEVICE_PIN = 37, SET_OTHER_PARAMS = 38, + SEND_TELEMETRY_REQ = 39, SET_CUSTOM_VAR = 41, GET_ADVERT_PATH = 42, + SEND_BINARY_REQ = 50, FACTORY_RESET = 51, SEND_PATH_DISCOVERY_REQ = 52, + SET_FLOOD_SCOPE_KEY = 54, SEND_CONTROL_DATA = 55, GET_STATS = 56, SEND_ANON_REQ = 57, + SET_AUTOADD_CONFIG = 58, SET_PATH_HASH_MODE = 61, SEND_CHANNEL_DATA = 62, + SET_DEFAULT_FLOOD_SCOPE = 63, SEND_RAW_PACKET = 65, RUN_CLI_COMMAND = 66, +}; + +constexpr uint8_t ERR_UNSUPPORTED_CMD = 1, ERR_ILLEGAL_ARG = 6; + +Bytes peerKey() { + Bytes k; + for (int i = 0; i < PUB_KEY_SIZE; i++) k.push_back((uint8_t)(0x31 + i)); + return k; +} + +Bytes contactFrame(size_t optional_bytes) { + Bytes f = {ADD_UPDATE_CONTACT}; + Bytes key = peerKey(); + put(f, key.data(), key.size()); + putU8(f, 1); // ADV_TYPE_CHAT + putU8(f, 0); // flags + putU8(f, 0xFF); // no path known + putFill(f, MAX_PATH_SIZE, 0); + putField(f, "peer", 32); + putU32(f, RTC0 - 100); // last_advert_timestamp + Bytes opt; + putI32(opt, 52500000); + putI32(opt, 13400000); + putU32(opt, RTC0 - 50); + put(f, opt.data(), optional_bytes); + return f; +} + +// One entry: a name for the failure message, and the frame. +struct Case { + std::string name; + Bytes frame; +}; + +Bytes cat(std::initializer_list parts) { + Bytes f; + for (const Bytes& p : parts) put(f, p.data(), p.size()); + return f; +} +Bytes u32le(uint32_t v) { Bytes b; putU32(b, v); return b; } +Bytes fill(size_t n, uint8_t v) { return Bytes(n, v); } +Bytes str(const char* s) { return Bytes(s, s + strlen(s)); } +Bytes key() { return peerKey(); } +Bytes prefixN(size_t n) { Bytes k = peerKey(); return Bytes(k.begin(), k.begin() + n); } +Bytes prefix6() { return prefixN(6); } +Bytes fieldOf(const char* s, size_t width) { Bytes f; putField(f, s, width); return f; } + +std::vector catalog() { + std::vector c; + auto add = [&](const char* name, Bytes f) { c.push_back({name, f}); }; + + add("DEVICE_QUERY min", {DEVICE_QUERY, 3}); + add("APP_START min", cat({{APP_START, 3}, fill(6, 0)})); + add("APP_START name", cat({{APP_START, 3}, fill(6, 0), str("app")})); + add("RUN_CLI_COMMAND min", cat({{RUN_CLI_COMMAND}, str("ve")})); + add("RUN_CLI_COMMAND ver", cat({{RUN_CLI_COMMAND}, str("ver")})); + add("RUN_CLI_COMMAND get radio", cat({{RUN_CLI_COMMAND}, str("get radio")})); + add("SEND_TXT_MSG min", cat({{SEND_TXT_MSG, 0, 0}, u32le(RTC0), prefix6(), str("h")})); + add("SEND_TXT_MSG text", cat({{SEND_TXT_MSG, 0, 0}, u32le(RTC0), prefix6(), str("hello")})); + add("SEND_TXT_MSG cli", cat({{SEND_TXT_MSG, 1, 0}, u32le(RTC0), prefix6(), str("ver")})); + add("SEND_CHANNEL_TXT_MSG min", cat({{SEND_CHANNEL_TXT_MSG, 0, 0}, u32le(RTC0)})); + add("SEND_CHANNEL_TXT_MSG text", cat({{SEND_CHANNEL_TXT_MSG, 0, 0}, u32le(RTC0), str("hi")})); + add("SEND_CHANNEL_DATA min", {SEND_CHANNEL_DATA, 0, 0xFF, 0x34}); + add("SEND_CHANNEL_DATA flood", cat({{SEND_CHANNEL_DATA, 0, 0xFF, 0x34, 0x12}, str("ab")})); + add("SEND_CHANNEL_DATA path", cat({{SEND_CHANNEL_DATA, 0, 2, 7, 8, 0x34, 0x12}, str("ab")})); + add("SEND_CHANNEL_DATA no data_type", {SEND_CHANNEL_DATA, 0, 2, 7, 8, 0x34}); + // the path length byte announces 8 bytes, the frame holds 2 + add("SEND_CHANNEL_DATA path past end", {SEND_CHANNEL_DATA, 0, 8, 7, 8}); + add("SEND_CHANNEL_DATA 3 byte hashes past end", {SEND_CHANNEL_DATA, 0, 0x85, 7, 8, 9}); + add("GET_CONTACTS min", {GET_CONTACTS}); + add("GET_CONTACTS since short", {GET_CONTACTS, 1, 2, 3}); + add("GET_CONTACTS since", cat({{GET_CONTACTS}, u32le(RTC0 - 200)})); + add("SET_ADVERT_NAME min", {SET_ADVERT_NAME, 'a'}); + add("SET_ADVERT_NAME", cat({{SET_ADVERT_NAME}, str("node")})); + add("SET_ADVERT_LATLON min", cat({{SET_ADVERT_LATLON}, u32le(52500000), u32le(13400000)})); + add("SET_ADVERT_LATLON alt short", cat({{SET_ADVERT_LATLON}, u32le(52500000), u32le(13400000), fill(3, 1)})); + add("SET_ADVERT_LATLON alt", cat({{SET_ADVERT_LATLON}, u32le(52500000), u32le(13400000), u32le(30)})); + add("SET_DEVICE_TIME min", cat({{SET_DEVICE_TIME}, u32le(RTC0 + 10)})); + add("SEND_SELF_ADVERT min", {SEND_SELF_ADVERT}); + add("SEND_SELF_ADVERT flood", {SEND_SELF_ADVERT, 1}); + add("RESET_PATH min", cat({{RESET_PATH}, key()})); + add("ADD_UPDATE_CONTACT min", contactFrame(0)); + add("ADD_UPDATE_CONTACT latlon", contactFrame(8)); + add("ADD_UPDATE_CONTACT lastmod", contactFrame(12)); + add("REMOVE_CONTACT min", cat({{REMOVE_CONTACT}, key()})); + add("SHARE_CONTACT min", cat({{SHARE_CONTACT}, key()})); + add("GET_CONTACT_BY_KEY min", cat({{GET_CONTACT_BY_KEY}, key()})); + add("EXPORT_CONTACT self", {EXPORT_CONTACT}); + add("EXPORT_CONTACT self, 32", cat({{EXPORT_CONTACT}, fill(31, 0x31)})); + add("EXPORT_CONTACT key", cat({{EXPORT_CONTACT}, key()})); + add("IMPORT_CONTACT min", cat({{IMPORT_CONTACT}, fill(98, 0x11)})); + add("SET_RADIO_PARAMS min", cat({{SET_RADIO_PARAMS}, u32le(869525), u32le(250000), {11, 5}})); + add("SET_RADIO_PARAMS repeat", cat({{SET_RADIO_PARAMS}, u32le(869525), u32le(250000), {11, 5, 1}})); + add("SET_RADIO_TX_POWER min", {SET_RADIO_TX_POWER, 20}); + add("SET_TUNING_PARAMS min", cat({{SET_TUNING_PARAMS}, u32le(1500), u32le(2000)})); + add("SET_OTHER_PARAMS min", {SET_OTHER_PARAMS, 1}); + add("SET_OTHER_PARAMS 3", {SET_OTHER_PARAMS, 1, 0x15}); + add("SET_OTHER_PARAMS 4", {SET_OTHER_PARAMS, 1, 0x15, 2}); + add("SET_OTHER_PARAMS 5", {SET_OTHER_PARAMS, 1, 0x15, 2, 1}); + add("SET_PATH_HASH_MODE min", {SET_PATH_HASH_MODE, 0, 1}); + add("REBOOT min", cat({{REBOOT}, str("reboot")})); + add("IMPORT_PRIVATE_KEY min", cat({{IMPORT_PRIVATE_KEY}, fill(64, 0)})); + add("SEND_RAW_DATA min", {SEND_RAW_DATA, 0, 1, 2, 3, 4}); + add("SEND_RAW_DATA path", {SEND_RAW_DATA, 1, 0x44, 1, 2, 3, 4}); + add("SEND_RAW_DATA payload short", {SEND_RAW_DATA, 1, 0x44, 1, 2, 3}); + // the path length byte announces 8 bytes, the frame holds 4 + add("SEND_RAW_DATA path past end", {SEND_RAW_DATA, 8, 1, 2, 3, 4}); + add("SEND_RAW_DATA 2 byte hashes past end", {SEND_RAW_DATA, 0x43, 1, 2, 3, 4, 5}); + add("SEND_LOGIN min", cat({{SEND_LOGIN}, key()})); + add("SEND_LOGIN password", cat({{SEND_LOGIN}, key(), str("pw")})); + add("SEND_ANON_REQ min", cat({{SEND_ANON_REQ}, key(), {7}})); + add("SEND_STATUS_REQ min", cat({{SEND_STATUS_REQ}, key()})); + add("SEND_PATH_DISCOVERY_REQ min", cat({{SEND_PATH_DISCOVERY_REQ, 0}, key()})); + add("SEND_TELEMETRY_REQ self", {SEND_TELEMETRY_REQ, 0, 0, 0}); + add("SEND_TELEMETRY_REQ 5", {SEND_TELEMETRY_REQ, 0, 0, 0, 0x31}); + add("SEND_TELEMETRY_REQ 35", cat({{SEND_TELEMETRY_REQ, 0, 0, 0}, prefixN(31)})); + add("SEND_TELEMETRY_REQ peer", cat({{SEND_TELEMETRY_REQ, 0, 0, 0}, key()})); + add("SEND_BINARY_REQ min", cat({{SEND_BINARY_REQ}, key(), {7}})); + add("HAS_CONNECTION min", cat({{HAS_CONNECTION}, key()})); + add("LOGOUT min", cat({{LOGOUT}, key()})); + add("GET_CHANNEL min", {GET_CHANNEL, 0}); + add("SET_CHANNEL min", cat({{SET_CHANNEL, 1}, fieldOf("#test", 32), fill(16, 0x21)})); + add("SET_CHANNEL 65", cat({{SET_CHANNEL, 1}, fieldOf("#test", 32), fill(31, 0x21)})); + add("SET_CHANNEL 256 bit", cat({{SET_CHANNEL, 1}, fieldOf("#test", 32), fill(32, 0x21)})); + add("SIGN_START then DATA", {SIGN_DATA, 1}); + add("SEND_TRACE_PATH min", cat({{SEND_TRACE_PATH}, u32le(7), u32le(9), {0, 0x44}})); + add("SEND_TRACE_PATH longest", cat({{SEND_TRACE_PATH}, u32le(7), u32le(9), {0}, fill(MAX_PATH_SIZE, 0x44)})); + // the longest frame; the handler's own upper bound (len - 10 >= MAX_PACKET_PAYLOAD - 5) + // lies above MAX_FRAME_SIZE and is never reached + add("SEND_TRACE_PATH longest frame", cat({{SEND_TRACE_PATH}, u32le(7), u32le(9), {0}, fill(MAX_FRAME_SIZE - 10, 0x44)})); + add("SET_DEVICE_PIN min", cat({{SET_DEVICE_PIN}, u32le(123456)})); + add("SET_CUSTOM_VAR min", cat({{SET_CUSTOM_VAR}, str("a:1")})); + add("SET_CUSTOM_VAR gps", cat({{SET_CUSTOM_VAR}, str("gps:1")})); + add("SET_CUSTOM_VAR no separator", cat({{SET_CUSTOM_VAR}, str("abc")})); + add("GET_ADVERT_PATH min", cat({{GET_ADVERT_PATH, 0}, key()})); + add("GET_STATS core", {GET_STATS, 0}); + add("GET_STATS radio", {GET_STATS, 1}); + add("GET_STATS packets", {GET_STATS, 2}); + add("GET_STATS other", {GET_STATS, 3}); + add("FACTORY_RESET min", cat({{FACTORY_RESET}, str("reset")})); + add("SET_FLOOD_SCOPE_KEY min", {SET_FLOOD_SCOPE_KEY, 0}); + // a key read one byte early would not show here: the mock HMAC behind the + // transport code folds only two bytes of its state into the code + add("SET_FLOOD_SCOPE_KEY 17", cat({{SET_FLOOD_SCOPE_KEY, 0}, fill(15, 0x42)})); + add("SET_FLOOD_SCOPE_KEY key", cat({{SET_FLOOD_SCOPE_KEY, 0}, fill(16, 0x42)})); + add("SET_FLOOD_SCOPE_KEY unscoped", {SET_FLOOD_SCOPE_KEY, 1}); + add("SEND_CONTROL_DATA min", {SEND_CONTROL_DATA, 0x80}); + add("SET_AUTOADD_CONFIG min", {SET_AUTOADD_CONFIG, 1}); + add("SET_AUTOADD_CONFIG hops", {SET_AUTOADD_CONFIG, 1, 3}); + add("SET_DEFAULT_FLOOD_SCOPE min", {SET_DEFAULT_FLOOD_SCOPE}); + add("SET_DEFAULT_FLOOD_SCOPE 47", cat({{SET_DEFAULT_FLOOD_SCOPE}, fieldOf("scope", 31), fill(15, 0x24)})); + add("SET_DEFAULT_FLOOD_SCOPE", cat({{SET_DEFAULT_FLOOD_SCOPE}, fieldOf("scope", 31), fill(16, 0x24)})); + // no zero anywhere in the frame: the name runs into the key and past the end + add("SET_DEFAULT_FLOOD_SCOPE unterminated", cat({{SET_DEFAULT_FLOOD_SCOPE}, fill(47, 0x61)})); + add("SEND_RAW_PACKET flood", {SEND_RAW_PACKET, 0, 0x11, 0, 0x55}); + // route type 0 carries 4 transport code bytes, which tryParsePacket() reads + // before it tests the length + add("SEND_RAW_PACKET transport past end", {SEND_RAW_PACKET, 0, 0x00, 0}); + return c; +} + +// What one frame did, as text: replies, packets, files, and the counters of the +// collaborators, then what a few later steps show. /channels2 is left out, because CMD_SET_CHANNEL writes stack +// bytes behind the channel name there (see test_frame_sweep.cpp); the channel's +// content is read back through GET_CHANNEL below instead. +std::string outcome(Harness& h, const Bytes& f) { + g_events.clear(); + int reboots = board.reboot_calls; + int params = radio_driver.set_params_calls, power = radio_driver.tx_power_calls; + uint32_t rtc = rtc_clock.now; + std::ostringstream o; + for (const Bytes& r : h.send(f)) o << "reply " << hex(r) << "\n"; + for (const Bytes& p : h.mesh().outbound()) o << "packet " << hex(p) << "\n"; + for (auto& file : h.fs.files) + if (file.first != "/channels2") o << "file " << file.first << " " << hex(file.second) << "\n"; + // State that only shows later: a contact listing still running (it writes one + // contact per loop), the channel a setter stored, and the scope the next flood + // goes out with. + for (int i = 0; i < 12; i++) { + h.serial.written.clear(); + h.mesh().loop(); + for (const Bytes& r : h.serial.written) o << "later " << hex(r) << "\n"; + } + for (const Bytes& r : h.send({GET_CHANNEL, 1})) o << "channel " << hex(r) << "\n"; + Bytes chat = {SEND_CHANNEL_TXT_MSG, 0, 0}; + putU32(chat, RTC0); + putStr(chat, "x"); + for (const Bytes& r : h.send(chat)) o << "flood " << hex(r) << "\n"; + for (const Bytes& p : h.mesh().outbound()) o << "queued " << hex(p) << "\n"; + for (auto& e : g_events) o << "event " << e << "\n"; + o << "reboot " << board.reboot_calls - reboots << " params " << radio_driver.set_params_calls - params + << " power " << radio_driver.tx_power_calls - power << " rtc " << rtc_clock.now - rtc + << " disable " << h.serial.disable_calls << "\n"; + return o.str(); +} + +// A node with the peer as a contact and a signing round started, which is what +// the catalog's frames expect to find. +void prepare(Harness& h) { + h.reset(); + h.boot(); + h.send(contactFrame(12)); + h.send({SIGN_START}); +} + +// The previous frame: a CMD_GET_DEVICE_TIME (which reads nothing past its code) of +// full length, so every byte above the next frame's length is one of these. +Bytes staleFrame(const Bytes& tail) { + Bytes f = {GET_DEVICE_TIME}; + for (size_t i = 1; i < MAX_FRAME_SIZE; i++) f.push_back(tail[i % tail.size()]); + return f; +} + +// Tails that would change an outcome if a handler used them: zeros, a byte that +// is no terminator and no valid path length, all ones, and a pattern with a zero +// at every eighth byte, which ends an unterminated string at a different place. +const std::vector TAILS = { + fill(1, 0x00), fill(1, 0x5A), fill(1, 0xFF), + {0x00, 0x41, 0x42, 0x02, 0x01, 0x30, 0x3A, 0x31}, +}; + +} // namespace + +TEST(FrameBounds, NoCommandReadsThePreviousFrame) { + Harness h; + for (const Case& c : catalog()) { + prepare(h); + std::string fresh = outcome(h, c.frame); + for (const Bytes& tail : TAILS) { + prepare(h); + h.send(staleFrame(tail)); + EXPECT_EQ(outcome(h, c.frame), fresh) << c.name << ", tail " << hex(tail); + } + } +} + +// One byte below each command's minimum the answer is UNSUPPORTED_CMD, as for a +// code that is not a command (SEND_CHANNEL_DATA, without a minimum in the table, +// answers ILLEGAL_ARG itself); the minimum itself is accepted, which the catalog +// above shows with a real frame of exactly that length for each of these codes. +TEST(FrameBounds, OneByteBelowEachMinimum) { + const struct { uint8_t code; size_t min; } MINIMUMS[] = { + {APP_START, 8}, {SEND_TXT_MSG, 14}, {SEND_CHANNEL_TXT_MSG, 7}, {SET_DEVICE_TIME, 5}, + {SET_ADVERT_NAME, 2}, {ADD_UPDATE_CONTACT, 136}, {SET_RADIO_PARAMS, 11}, + {SET_RADIO_TX_POWER, 2}, {RESET_PATH, 33}, {SET_ADVERT_LATLON, 9}, + {REMOVE_CONTACT, 33}, {SHARE_CONTACT, 33}, {IMPORT_CONTACT, 99}, {REBOOT, 7}, + {SET_TUNING_PARAMS, 9}, {DEVICE_QUERY, 2}, {IMPORT_PRIVATE_KEY, 65}, + {SEND_RAW_DATA, 6}, {SEND_LOGIN, 33}, {SEND_STATUS_REQ, 33}, {HAS_CONNECTION, 33}, + {LOGOUT, 33}, {GET_CONTACT_BY_KEY, 33}, {GET_CHANNEL, 2}, {SET_CHANNEL, 50}, + {SIGN_DATA, 2}, {SEND_TRACE_PATH, 11}, {SET_DEVICE_PIN, 5}, {SET_OTHER_PARAMS, 2}, + {SEND_TELEMETRY_REQ, 4}, {SET_CUSTOM_VAR, 4}, {GET_ADVERT_PATH, 34}, + {SEND_BINARY_REQ, 34}, {FACTORY_RESET, 6}, {SEND_PATH_DISCOVERY_REQ, 34}, + {SET_FLOOD_SCOPE_KEY, 2}, {SEND_CONTROL_DATA, 2}, {GET_STATS, 2}, {SEND_ANON_REQ, 34}, + {SET_AUTOADD_CONFIG, 2}, {SET_PATH_HASH_MODE, 3}, {SEND_RAW_PACKET, 4}, + {RUN_CLI_COMMAND, 3}, {SEND_CHANNEL_DATA, 4}, + }; + Harness h; + for (const Case& c : catalog()) { + for (auto m : MINIMUMS) { + if (c.frame[0] != m.code || c.frame.size() != m.min) continue; + for (const Bytes& tail : TAILS) { + prepare(h); + h.send(staleFrame(tail)); + Bytes shorter(c.frame.begin(), c.frame.end() - 1); + uint8_t expected = m.code == SEND_CHANNEL_DATA ? ERR_ILLEGAL_ARG : ERR_UNSUPPORTED_CMD; + EXPECT_EQ(h.reply(shorter), Bytes({1, expected})) << c.name << " minus one, tail " << hex(tail); + EXPECT_EQ(h.mesh().outboundTotal(), 0) << c.name << " minus one"; + } + } + } + // every minimum has a catalog frame of exactly that length + for (auto m : MINIMUMS) { + bool found = false; + for (const Case& c : catalog()) found |= c.frame[0] == m.code && c.frame.size() == m.min; + EXPECT_TRUE(found) << "no catalog frame at the minimum of code " << (int)m.code; + } +} + +// Since step 10c the handlers read through FrameReader, which counts every read +// that would have gone past the frame. Only the two commands whose path length +// byte can announce more than the frame holds may run out, and they refuse the +// frame when they do; every other command reads inside its frame at every length +// and fill, and every catalog frame except those two kinds reads inside it. +TEST(FrameBounds, OnlyAnAnnouncedPathRunsOut) { + Harness h; + const uint8_t fills[] = {0x00, 0x01, 0x3F, 0x43, 0x81, 0xFF}; + for (int code = 0; code < 70; code++) { + int overruns = 0; + for (uint8_t fill : fills) { + for (size_t len = 1; len <= MAX_FRAME_SIZE; len++) { + h.reset(); + h.boot(); + Bytes f(1, (uint8_t)code); + putFill(f, len - 1, fill); + int before = frame_reader_overruns; + h.send(f); + overruns += frame_reader_overruns - before; + } + } + if (code != SEND_RAW_DATA && code != SEND_CHANNEL_DATA) { + EXPECT_EQ(overruns, 0) << "code " << code; + } + } + for (const Case& c : catalog()) { + prepare(h); + int before = frame_reader_overruns; + h.send(c.frame); + bool may_run_out = c.name.find("past end") != std::string::npos && + (c.frame[0] == SEND_RAW_DATA || c.frame[0] == SEND_CHANNEL_DATA); + if (!may_run_out) EXPECT_EQ(frame_reader_overruns, before) << c.name; + } +} + +// The scope key of SET_FLOOD_SCOPE_KEY is set only by a frame that carries all 16 +// bytes; one byte less clears it. Which of the two happened shows in the next +// flood: with a key it carries a transport code (route type TRANSPORT_FLOOD, 0), +// without one it does not (FLOOD, 1). +TEST(FrameBounds, FloodScopeKeyNeedsAllSixteenBytes) { + Harness h; + for (size_t key_bytes : {15, 16}) { + h.reset(); + h.boot(); + Bytes f = {SET_FLOOD_SCOPE_KEY, 0}; + putFill(f, key_bytes, 0x42); + ASSERT_EQ(h.reply(f), Bytes({0})); + Bytes chat = {SEND_CHANNEL_TXT_MSG, 0, 0}; + putU32(chat, RTC0); + putStr(chat, "x"); + ASSERT_EQ(h.reply(chat), Bytes({0})); + auto queued = h.mesh().outbound(); + ASSERT_EQ(queued.size(), 1u); + EXPECT_EQ(queued[0][0] & 0x03, key_bytes == 16 ? 0 : 1) << key_bytes << " key bytes"; + } +} + +// The three handlers that read past the frame before step 10c, each with a frame +// that makes them do it and behind tails that would matter if the bytes were used: +// a previous frame whose bytes complete the announced path and the payload (or end +// the unterminated scope name early). The answer is ILLEGAL_ARG every time, +// nothing is queued for the air, and no file changes (the prefs hold the default +// flood scope). +TEST(FrameBounds, TheReadsPastTheEndThatStep10cRemoved) { + const struct { const char* name; Bytes frame; } CASES[] = { + // path length byte 8: eight 1 byte hashes, the frame holds 2 of them + {"SEND_CHANNEL_DATA", {SEND_CHANNEL_DATA, 0, 8, 7, 8}}, + // path length byte 8 again, the frame holds 4 bytes after it + {"SEND_RAW_DATA", {SEND_RAW_DATA, 8, 1, 2, 3, 4}}, + // 47 bytes without a zero: name and key run together + {"SET_DEFAULT_FLOOD_SCOPE", cat({{SET_DEFAULT_FLOOD_SCOPE}, fill(47, 0x61)})}, + }; + const std::vector tails = { + fill(1, 0x00), fill(1, 0x34), fill(1, 0x61), + {0x61, 0x62, 0x00, 0x34, 0x12, 0x01, 0x02, 0x03}, + }; + Harness h; + for (const auto& c : CASES) { + for (const Bytes& tail : tails) { + prepare(h); + h.send(staleFrame(tail)); + auto files_before = h.fs.files; + EXPECT_EQ(h.reply(c.frame), Bytes({1, ERR_ILLEGAL_ARG})) << c.name << ", tail " << hex(tail); + EXPECT_EQ(h.mesh().outboundTotal(), 0) << c.name << ", tail " << hex(tail); + EXPECT_TRUE(h.fs.files == files_before) << c.name << ", tail " << hex(tail); + } + } +} diff --git a/test/test_companion_frames/test_frame_reader.cpp b/test/test_companion_frames/test_frame_reader.cpp new file mode 100644 index 00000000000..0822cb99078 --- /dev/null +++ b/test/test_companion_frames/test_frame_reader.cpp @@ -0,0 +1,103 @@ +// FrameReader (src/helpers/FrameReader.h) on its own: each read at the end of a +// frame and past it, the cursor, ok() staying false, and the terminator. + +#include + +#include + +namespace { + +TEST(FrameReader, ReadsFieldsInOrder) { + uint8_t buf[16] = {0x16, 0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0xFE, 0xFF, 0xFF, 0xFF, 0x77}; + FrameReader r(buf, 13, 1); + EXPECT_EQ(r.u8(), 0xAA); + uint32_t expected32; + memcpy(&expected32, &buf[2], 4); + EXPECT_EQ(r.u32(), expected32); + uint16_t expected16; + memcpy(&expected16, &buf[6], 2); + EXPECT_EQ(r.u16(), expected16); + int32_t expected_i; + memcpy(&expected_i, &buf[8], 4); + EXPECT_EQ(r.i32(), expected_i); + EXPECT_EQ(r.pos(), 12u); + EXPECT_EQ(r.remaining(), 1u); + EXPECT_EQ(r.bytes(1), &buf[12]); + EXPECT_EQ(r.remaining(), 0u); + EXPECT_TRUE(r.ok()); +} + +TEST(FrameReader, AReadAtTheEndFitsExactly) { + uint8_t buf[8] = {1, 2, 3, 4, 5}; + FrameReader r(buf, 5, 1); + EXPECT_TRUE(r.has(4)); + EXPECT_FALSE(r.has(5)); + r.u32(); + EXPECT_TRUE(r.ok()); + EXPECT_FALSE(r.has(1)); + EXPECT_TRUE(r.has(0)); + EXPECT_NE(r.bytes(0), nullptr); +} + +TEST(FrameReader, AReadPastTheEndYieldsZeroAndStops) { + uint8_t buf[8] = {9, 0x11, 0x22, 0x33, 0x5A, 0x5A, 0x5A, 0x5A}; // a stale tail behind len 4 + { + FrameReader r(buf, 4, 1); + EXPECT_EQ(r.u32(), 0u); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.pos(), 1u); // the cursor did not move + EXPECT_EQ(r.u8(), 0); // and stays failed, even for a byte that is there + EXPECT_FALSE(r.ok()); + } + { + FrameReader r(buf, 4, 1); + EXPECT_EQ(r.bytes(4), nullptr); + EXPECT_FALSE(r.has(0)); + } + { + FrameReader r(buf, 4, 1); + r.skip(4); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.remaining(), 3u); + } + { + FrameReader r(buf, 4, 3); + EXPECT_EQ(r.u16(), 0); + EXPECT_EQ(r.i32(), 0); + EXPECT_FALSE(r.ok()); + } +} + +TEST(FrameReader, AStartBeyondTheFrameIsAnOverrun) { + uint8_t buf[4] = {1, 2, 3, 4}; + FrameReader r(buf, 2, 3); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.remaining(), 0u); +} + +TEST(FrameReader, RestAsCStringWritesTheTerminatorAtLen) { + uint8_t buf[8] = {66, 'v', 'e', 'r', 'X', 'Y', 'Z', 'W'}; + FrameReader r(buf, 4, 1); + char* s = r.restAsCString(); + EXPECT_STREQ(s, "ver"); + EXPECT_EQ(buf[4], 0); + EXPECT_EQ(buf[5], 'Y'); // nothing behind it is touched + EXPECT_EQ(r.remaining(), 0u); + EXPECT_TRUE(r.ok()); + + FrameReader empty(buf, 1, 1); + EXPECT_STREQ(empty.restAsCString(), ""); + EXPECT_EQ(buf[1], 0); +} + +TEST(FrameReader, CountsOverruns) { + uint8_t buf[4] = {1, 2, 3, 4}; + int before = frame_reader_overruns; + FrameReader r(buf, 2, 1); + r.u8(); + EXPECT_EQ(frame_reader_overruns, before); + r.u8(); + EXPECT_EQ(frame_reader_overruns, before + 1); +} + +} // namespace diff --git a/test/test_companion_frames/test_frame_sweep.cpp b/test/test_companion_frames/test_frame_sweep.cpp new file mode 100644 index 00000000000..646cee8995c --- /dev/null +++ b/test/test_companion_frames/test_frame_sweep.cpp @@ -0,0 +1,219 @@ +// Every command code at every frame length, against a recorded transcript. +// +// For each code 0 to 255, each length 1 to MAX_FRAME_SIZE and four fill bytes, a +// freshly booted node gets one frame: the code, then the fill byte repeated. What +// comes back (every reply frame, byte for byte) and what changed on the node's +// collaborators is one line of the transcript; consecutive lengths with the same +// line are folded into a range. A last line per code and fill carries a digest of +// every packet the node queued and of the file system after each frame, so content +// that the reply does not show is pinned too. The result has to match +// test/test_companion_frames/frame_sweep.golden exactly. +// +// The four fills reach the sub-commands a code selects by its second byte: 0x00 and +// 0x01 are the two flood scope modes and the path hash mode's reserved byte, 0x81 +// has the bit CMD_SEND_CONTROL_DATA requires, and 0xFF is the flood path length of +// CMD_SEND_CHANNEL_DATA and an out of range index everywhere else. +// +// A fifth pass sends zeros again, but only after a full length frame of 0x5A bytes +// (a CMD_GET_DEVICE_TIME, which reads nothing past its code). Whatever a branch +// reads above the frame's own length then comes from that earlier frame, as it +// does on a board, so a branch that reads past `len` shows up as a difference +// between the "00" and the "00/5a" lines of the same code. +// +// The transcript records the protocol as it is, including what is odd about it; it +// is the reference a restructured handleCmdFrame() must reproduce. To regenerate it +// after an intended protocol change, run the suite with COMPANION_SWEEP_UPDATE=1 and +// review the diff of the golden file like code. + +#include "CompanionHarness.h" + +#include +#include +#include +#include +#include +#include + +using namespace companion; + +namespace { + +const char* GOLDEN = "test/test_companion_frames/frame_sweep.golden"; +std::string strprintf(const char* fmt, ...) { + char buf[160]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + return buf; +} + +struct Pass { + uint8_t fill; + bool stale_tail; // a full length frame of 0x5A bytes goes first + const char* name; +}; +const Pass PASSES[] = { + {0x00, false, "00"}, {0x01, false, "01"}, {0x81, false, "81"}, {0xFF, false, "ff"}, + {0x00, true, "00/5a"}, +}; + +// FNV-1a, so a long run of content folds into one comparable number. +struct Digest { + uint64_t h = 0xcbf29ce484222325ull; + void add(const uint8_t* p, size_t n) { + for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } + } + void add(const std::string& s) { add((const uint8_t*)s.data(), s.size()); } + void add(const Bytes& b) { add(b.data(), b.size()); } + std::string str() const { + char buf[20]; + snprintf(buf, sizeof(buf), "%016llx", (unsigned long long)h); + return buf; + } +}; + +// /channels2 holds records of 4 reserved bytes, a 32 byte name and a 32 byte +// secret. CMD_SET_CHANNEL builds the record in a ChannelDetails on the stack and +// copies the name with StrHelper::strncpy(), which stops at the terminator, so the +// bytes behind the name are whatever the stack held and are written to flash as +// they are (on a board as well). Nothing reads them back, since the name is used +// as a C string, but they would make the digest depend on what ran before. They +// are zeroed here, and only here. +Bytes withoutNameTails(const std::string& name, Bytes data) { + if (name != "/channels2") return data; + for (size_t rec = 0; rec + 68 <= data.size(); rec += 68) { + uint8_t* n = &data[rec + 4]; + size_t end = 0; + while (end < 32 && n[end]) end++; + for (size_t i = end; i < 32; i++) n[i] = 0; + } + return data; +} + +std::string fsDigest(const fs::FS& fs) { + std::string d; + for (auto& f : fs.files) { + d += f.first; + d += ':'; + d += hex(withoutNameTails(f.first, f.second)); + d += ';'; + } + return d; +} + +// Fills the stack below the caller with one byte value. The handlers build structs +// on the stack and not all of them initialise every byte, so without this a reply +// or a file could depend on whatever ran before, and the sweep would pass or fail +// with the test order. With it, any such byte is the same 0xC3 on every run and is +// pinned like any other. +__attribute__((noinline)) void scrubStack() { + volatile uint8_t junk[16 * 1024]; + for (size_t i = 0; i < sizeof(junk); i++) junk[i] = 0xC3; +} + +// What one frame did, as one line. The reply bytes are exact; the side effects are +// flags, because their content is pinned by the per command tests. +std::string outcome(Harness& h, const Bytes& f, Digest& packets, Digest& files) { + std::string before = fsDigest(h.fs); + uint32_t rtc_before = rtc_clock.now; + int params_before = radio_driver.set_params_calls; + int power_before = radio_driver.tx_power_calls; + + scrubStack(); + auto replies = h.send(f); + + std::string line; + for (size_t i = 0; i < replies.size(); i++) { + if (i) line += ' '; + line += replies[i].empty() ? "-" : hex(replies[i]); + } + if (replies.empty()) line = "(none)"; + + std::string flags; + if (fsDigest(h.fs) != before) flags += 'F'; // something was written to flash + if (h.mesh().outboundTotal() > 0) flags += 'Q'; // a packet was queued for the air + if (board.reboot_calls) flags += 'B'; // the board was rebooted + if (radio_driver.set_params_calls != params_before) flags += 'P'; // radio parameters applied + if (radio_driver.tx_power_calls != power_before) flags += 'W'; // TX power applied + if (rtc_clock.now != rtc_before) flags += 'T'; // the clock was set + if (h.serial.disable_calls) flags += 'D'; // the interface was switched off + if (!flags.empty()) line += " [" + flags + "]"; + + // The content behind the Q and F flags, folded per code and pass: which packet + // was queued and what was stored, at every length. + for (const Bytes& p : h.mesh().outbound()) packets.add(p); + packets.add("|"); + files.add(fsDigest(h.fs)); + return line; +} + +std::string sweep() { + Harness h; + std::ostringstream out; + for (int code = 0; code < 256; code++) { + for (const Pass& pass : PASSES) { + Digest packets, files; + std::string prev; + int from = 0; + auto flush = [&](int to) { + char head[40]; + if (from == to) snprintf(head, sizeof(head), "%02x %s %d", code, pass.name, from); + else snprintf(head, sizeof(head), "%02x %s %d-%d", code, pass.name, from, to); + out << head << ": " << prev << "\n"; + }; + for (int len = 1; len <= MAX_FRAME_SIZE; len++) { + h.reset(); + h.boot(); + if (pass.stale_tail) { + Bytes tail(1, 5); // CMD_GET_DEVICE_TIME + putFill(tail, MAX_FRAME_SIZE - 1, 0x5A); + h.send(tail); + } + Bytes f(1, (uint8_t)code); + putFill(f, len - 1, pass.fill); + std::string o = outcome(h, f, packets, files); + if (len == 1) { prev = o; from = 1; continue; } + if (o != prev) { flush(len - 1); prev = o; from = len; } + } + flush(MAX_FRAME_SIZE); + out << strprintf("%02x %s packets %s files %s\n", code, pass.name, + packets.str().c_str(), files.str().c_str()); + } + } + return out.str(); +} + +} // namespace + +TEST(FrameSweep, MatchesTheRecordedTranscript) { + std::string actual = sweep(); + + if (getenv("COMPANION_SWEEP_UPDATE")) { + std::ofstream(GOLDEN) << actual; + GTEST_SKIP() << "wrote " << GOLDEN; + } + + std::ifstream in(GOLDEN); + ASSERT_TRUE(in.good()) << GOLDEN << " is missing; run with COMPANION_SWEEP_UPDATE=1"; + std::stringstream golden; + golden << in.rdbuf(); + + if (actual == golden.str()) return; + + // Name the first lines that differ, so a failure says which code and length. + std::istringstream a(actual), g(golden.str()); + std::string la, lg; + int shown = 0, lineno = 0; + while (shown < 10) { + bool ha = (bool)std::getline(a, la), hg = (bool)std::getline(g, lg); + lineno++; + if (!ha && !hg) break; + if (la != lg) { + ADD_FAILURE() << "line " << lineno << "\n golden: " << (hg ? lg : "(end)") + << "\n actual: " << (ha ? la : "(end)"); + shown++; + } + } + FAIL() << "the frame sweep differs from " << GOLDEN; +} diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 433c9710601..4bdad781cd8 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -6,49 +6,97 @@ #include "../../examples/companion_radio/NodePrefs.h" -class ReplayStream : public Stream { - const char* _text; - int _pos = 0; - int _len; - -public: - explicit ReplayStream(const char* text) : _text(text), _len(strlen(text)) { } - - int available() override { return _len - _pos; } - int read() override { return _pos < _len ? _text[_pos++] : -1; } - int peek() override { return _pos < _len ? _text[_pos] : -1; } -}; - -class CaptureStream : public Stream { - std::string _text; - - size_t emit(long long value) { - char text[24]; - int length = snprintf(text, sizeof(text), "%lld", value); - return write(reinterpret_cast(text), length); - } - -public: - size_t write(uint8_t value) override { - _text.push_back(static_cast(value)); - return 1; - } - - size_t write(const uint8_t* buffer, size_t size) override { - _text.append(reinterpret_cast(buffer), size); - return size; - } - - size_t print(unsigned char value, int = DEC) override { return emit(value); } - size_t print(int value, int = DEC) override { return emit(value); } - size_t print(unsigned int value, int = DEC) override { return emit(value); } - size_t print(long value, int = DEC) override { return emit(value); } - size_t print(unsigned long value, int = DEC) override { return emit(value); } - size_t print(long long value, int = DEC) override { return emit(value); } - size_t print(unsigned long long value, int = DEC) override { return emit(value); } - - const std::string& text() const { return _text; } -}; +#include "PrefsStreams.h" +#include "RadioPrefsChecks.h" + +// Characterization tests for the companion radio's NodePrefs. They pin down +// what /prefs.json contains and what the radio CLI does today, so a +// refactor of the prefs classes can prove it keeps both unchanged. + +namespace { + +void fillAll(NodePrefs& p) { + fillRadioAndGps(p); + strcpy(p.node_name, "Test Companion"); + p.node_lat = 53.551086; + p.node_lon = 9.993682; + p.setRepeatEn(true); + p.autoadd_max_hops = 3; + strcpy(p.default_scope_name, "de-hh"); + for (int i = 0; i < 16; i++) p.default_scope_key[i] = (uint8_t)(0xA0 + i); + p.ble_pin = 123456; + p.buzzer_quiet = 1; + p.vibe_quiet = 1; + p.autoadd_config = 0x05; + p.manual_add_contacts = 1; + p.telemetry_mode_base = 2; + p.telemetry_mode_loc = 1; + p.telemetry_mode_env = 2; + p.tz_offset = 1.5f; +} + +// Prefs file as written by v1.18.0 for the values of fillAll(). +const char* kPrefsJson = + "{name:\"Test Companion\",lat:53.551086,lon:9.993682,radio:{freq:869.6180," + "bw:62.5000,sf:8,cr:6,cad:1,int_thr:14,rxgain:1,fem_rxgain:1,fem_txgain:2," + "tx:21,af:1.5000,rxdelay:3.2500,f_txdelay:0.7500,d_txdelay:0.2500," + "agc_int:5,hash_mode:2,multi_ack:1},gps:{en:1,int:900,adv_loc:1}," + "repeat:{disable:0},comp:{auto_max:3,defs_nm:\"de-hh\",defs_key:\"A0A1A2A3A4A5A6A7A8A9AAABACADAEAF\"," + "pin:123456,buzz_q:1,vibe_q:1,auto_add:5,man_add:1,tel_base:2," + "tel_loc:1,tel_env:2,tz_offset:1.5000},custom:{}}"; + +} // namespace + +TEST(CompanionNodePrefs, WritesTheKnownPrefsJson) { + NodePrefs prefs; + fillAll(prefs); + + CaptureStream out; + ASSERT_TRUE(prefs.saveSerial(out)); + EXPECT_EQ(kPrefsJson, out.text()); +} + +TEST(CompanionNodePrefs, ReadsEveryPersistedFieldBack) { + NodePrefs expected; + fillAll(expected); + + NodePrefs loaded; + ReplayStream in(kPrefsJson); + ASSERT_TRUE(loaded.loadSerial(in)); + + expectRadioAndGpsEqual(expected, loaded); + EXPECT_STREQ(expected.node_name, loaded.node_name); + EXPECT_DOUBLE_EQ(expected.node_lat, loaded.node_lat); + EXPECT_DOUBLE_EQ(expected.node_lon, loaded.node_lon); + EXPECT_EQ(expected.isRepeatEn(), loaded.isRepeatEn()); + EXPECT_EQ(expected.autoadd_max_hops, loaded.autoadd_max_hops); + EXPECT_STREQ(expected.default_scope_name, loaded.default_scope_name); + EXPECT_EQ(0, memcmp(expected.default_scope_key, loaded.default_scope_key, sizeof(loaded.default_scope_key))); + EXPECT_EQ(expected.ble_pin, loaded.ble_pin); + EXPECT_EQ(expected.buzzer_quiet, loaded.buzzer_quiet); + EXPECT_EQ(expected.vibe_quiet, loaded.vibe_quiet); + EXPECT_EQ(expected.autoadd_config, loaded.autoadd_config); + EXPECT_EQ(expected.manual_add_contacts, loaded.manual_add_contacts); + EXPECT_EQ(expected.telemetry_mode_base, loaded.telemetry_mode_base); + EXPECT_EQ(expected.telemetry_mode_loc, loaded.telemetry_mode_loc); + EXPECT_EQ(expected.telemetry_mode_env, loaded.telemetry_mode_env); + EXPECT_FLOAT_EQ(expected.tz_offset, loaded.tz_offset); +} + +TEST(CompanionNodePrefs, RadioCliReadsAndWritesTheNodeFields) { + NodePrefs prefs; + expectRadioCliDrivesNodeFields(prefs); +} + +TEST(CompanionNodePrefs, RadioCliEdgeCases) { + NodePrefs prefs; + expectRadioCliEdgeCases(prefs); +} + +TEST(CompanionNodePrefs, CustomStoreReachesTheFemGains) { + NodePrefs prefs; + expectCustomStoreReachesFemGains(prefs); +} #if 0 // Re-enable test once we can SET fem_ values in companion diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index 80d5e78086d..0cfc4cfe244 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -74,6 +74,17 @@ class TestStruct : public ConfigSerializer { uint8_t flags; }; +class TestNumbers : public ConfigSerializer { + protected: + void structure() override { + def("ratio", ratio); + def("lat", lat); + } + public: + float ratio = 1.5f; + double lat = 2.5; +}; + // ── saveSerial: basic ─────────────────────────────────────────────────────── TEST(ConfigSerializer, SaveSerial_Basic) { @@ -313,6 +324,64 @@ TEST(DynamicConfigSerializer, LoadCustom_Basic) { // ── main ─────────────────────────────────────────────────────── +// ── loadSerial: numbers that are not finite ──────────────────────────────── + +// The values a node runs on are set up before /prefs.json is read, so a token +// that is not a finite number has to leave the value that is already there. +// Clamping it into range is no help: there is no sensible nearest value for a +// NaN, and for a frequency the nearest bound would put the radio on 150 MHz. + +TEST(ConfigSerializer, LoadSerial_KeepsDefaultOnNotANumber) { + MockInputStream s("{ratio:nan,lat:nan}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(1.5f, data.ratio); + EXPECT_DOUBLE_EQ(2.5, data.lat); +} + +TEST(ConfigSerializer, LoadSerial_KeepsDefaultOnInfinity) { + MockInputStream s("{ratio:inf,lat:-inf}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(1.5f, data.ratio); + EXPECT_DOUBLE_EQ(2.5, data.lat); +} + +// 1e40 is finite as a double and overflows to infinity as a float, so the +// check has to look at the value that is about to be stored. +TEST(ConfigSerializer, LoadSerial_KeepsDefaultWhenFloatOverflows) { + MockInputStream s("{ratio:1e40}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(1.5f, data.ratio); +} + +TEST(ConfigSerializer, LoadSerial_AcceptsFiniteNumbers) { + MockInputStream s("{ratio:-6.125,lat:12.5}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(-6.125f, data.ratio); + EXPECT_DOUBLE_EQ(12.5, data.lat); +} + +// Garbage parses as zero and is stored, which is what the old code did. +TEST(ConfigSerializer, LoadSerial_StoresZeroForGarbage) { + MockInputStream s("{ratio:abc}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(0.0f, data.ratio); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_core_tuning/identity_stubs.cpp b/test/test_core_tuning/identity_stubs.cpp new file mode 100644 index 00000000000..eec1e3437c9 --- /dev/null +++ b/test/test_core_tuning/identity_stubs.cpp @@ -0,0 +1,28 @@ +// Link-time stand-ins for the parts of mesh::Identity that Mesh.cpp references. +// The real implementations need the board crypto libraries, which do not exist on +// the host. The tuning tests never sign, verify or derive secrets, so any call is a +// test failure. + +#include +#include + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + ADD_FAILURE() << "Identity::verify must not be called by the tuning tests"; + return false; +} + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + ADD_FAILURE() << "LocalIdentity::sign must not be called by the tuning tests"; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + ADD_FAILURE() << "LocalIdentity::calcSharedSecret must not be called by the tuning tests"; +} + +} // namespace mesh diff --git a/test/test_core_tuning/test_core_tuning.cpp b/test/test_core_tuning/test_core_tuning.cpp new file mode 100644 index 00000000000..22902051b74 --- /dev/null +++ b/test/test_core_tuning/test_core_tuning.cpp @@ -0,0 +1,271 @@ +// Tests for the tuning hooks of mesh::Dispatcher and mesh::Mesh: the values the +// core uses for duty cycle, RX delay, retransmit delays, extra ACKs, interference +// threshold, CAD and AGC reset when no role overrides them, without and with radio +// settings attached through useRadioPrefs(). They run +// the real Dispatcher.cpp and Mesh.cpp against fakes for the radio, clock, RNG, +// packet manager and tables. + +#include + +#include +#include +#include + +#include + +namespace { + +// Airtime grows with the length, so a wrong length in a formula changes the result. +class FakeRadio : public mesh::Radio { +public: + int recvRaw(uint8_t* bytes, int sz) override { return 0; } + uint32_t getEstAirtimeFor(int len_bytes) override { return 11 + 37 * (uint32_t)len_bytes; } + float packetScore(float snr, int packet_len) override { return 0; } + bool startSendRaw(const uint8_t* bytes, int len) override { return false; } + bool isSendComplete() override { return true; } + void onSendFinished() override { } + bool isInRecvMode() const override { return true; } +}; + +class FakeClock : public mesh::MillisecondClock { +public: + unsigned long getMillis() override { return 0; } +}; + +class FakeRTC : public mesh::RTCClock { +public: + uint32_t getCurrentTime() override { return 0; } + void setCurrentTime(uint32_t time) override { } +}; + +// Returns a fixed sequence of 32 bit values; two instances with the same sequence +// let a test compute the expected nextInt() result independently of the core. +class SequenceRNG : public mesh::RNG { + std::vector _values; + size_t _next = 0; +public: + explicit SequenceRNG(std::vector values) : _values(values) { } + void random(uint8_t* dest, size_t sz) override { + uint32_t v = _values[_next++ % _values.size()]; + memset(dest, 0, sz); + memcpy(dest, &v, sz < sizeof(v) ? sz : sizeof(v)); + } +}; + +class NullPacketManager : public mesh::PacketManager { +public: + mesh::Packet* allocNew() override { return nullptr; } + void free(mesh::Packet* packet) override { } + void queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override { } + mesh::Packet* getNextOutbound(uint32_t now) override { return nullptr; } + int getOutboundCount(uint32_t now) const override { return 0; } + int getOutboundTotal() const override { return 0; } + int getFreeCount() const override { return 0; } + mesh::Packet* getOutboundByIdx(int i) override { return nullptr; } + mesh::Packet* removeOutboundByIdx(int i) override { return nullptr; } + void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override { } + mesh::Packet* getNextInbound(uint32_t now) override { return nullptr; } +}; + +class NullTables : public mesh::MeshTables { +public: + bool wasSeen(const mesh::Packet* packet) override { return false; } + void markSeen(const mesh::Packet* packet) override { } + void clear(const mesh::Packet* packet) override { } +}; + +// A Mesh with nothing overridden, so the core's own hook implementations run. +class CoreMesh : public mesh::Mesh { +public: + CoreMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, + mesh::PacketManager& mgr, mesh::MeshTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) { } + + using mesh::Mesh::getAirtimeBudgetFactor; + using mesh::Mesh::calcRxDelay; + using mesh::Mesh::getInterferenceThreshold; + using mesh::Mesh::getCADEnabled; + using mesh::Mesh::getAGCResetInterval; + using mesh::Mesh::getRetransmitDelay; + using mesh::Mesh::getDirectRetransmitDelay; + using mesh::Mesh::getExtraAckTransmitCount; +}; + +const std::vector kRandom = { 0, 1, 7, 12345, 0x7FFFFFFF, 0xFFFFFFFF, 999983 }; + +// Packets with a 3 x 2 byte path and a 40 byte payload: one plain flood packet and +// one with transport codes (4 more bytes on air), so formulas that differ only in +// how they count the length give different results. +std::vector makePackets() { + std::vector pkts; + for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD}) { + mesh::Packet pkt; + pkt.header = route | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT); + pkt.setPathHashSizeAndCount(2, 3); + pkt.payload_len = 40; + pkts.push_back(pkt); + } + return pkts; +} + +struct Harness { + FakeRadio radio; + FakeClock clock; + FakeRTC rtc; + SequenceRNG rng{kRandom}; + NullPacketManager mgr; + NullTables tables; + CoreMesh mesh{radio, clock, rng, rtc, mgr, tables}; +}; + +} // namespace + +// Without any settings attached, the core keeps its built-in defaults +// (src/Dispatcher.cpp and src/Mesh.cpp as of v1.18.0). +TEST(CoreTuningDefaults, DutyCycleInterferenceCadAgcAndAcks) { + Harness h; + EXPECT_FLOAT_EQ(1.0f, h.mesh.getAirtimeBudgetFactor()); + EXPECT_EQ(0, h.mesh.getInterferenceThreshold()); + EXPECT_FALSE(h.mesh.getCADEnabled()); + EXPECT_EQ(0, h.mesh.getAGCResetInterval()); + EXPECT_EQ(0, h.mesh.getExtraAckTransmitCount()); +} + +TEST(CoreTuningDefaults, RxDelayUsesBaseTen) { + Harness h; + // Dispatcher::calcRxDelay(): (pow(10, 0.85f - score) - 1.0) * air_time + for (float score : {0.0f, 0.3f, 0.85f, 1.0f}) { + for (uint32_t air : {0u, 100u, 1234u}) { + int expected = (int) ((pow(10, 0.85f - score) - 1.0) * air); + EXPECT_EQ(expected, h.mesh.calcRxDelay(score, air)) << "score " << score << " air " << air; + } + } + // pinned values + EXPECT_EQ(6079, h.mesh.calcRxDelay(0.0f, 1000)); + EXPECT_EQ(0, h.mesh.calcRxDelay(0.85f, 1000)); +} + +TEST(CoreTuningDefaults, RetransmitDelaysUseRawLength) { + for (mesh::Packet pkt : makePackets()) { + Harness h; + SequenceRNG expected_rng(kRandom); + for (size_t i = 0; i < kRandom.size(); i++) { + // Mesh::getRetransmitDelay(): t = (airtime(raw length) * 52 / 50) / 2, nextInt(0, 5) * t + uint32_t t = (h.radio.getEstAirtimeFor(pkt.getRawLength()) * 52 / 50) / 2; + uint32_t expected = expected_rng.nextInt(0, 5) * t; + EXPECT_EQ(expected, h.mesh.getRetransmitDelay(&pkt)) << "draw " << i; + } + EXPECT_EQ(0u, h.mesh.getDirectRetransmitDelay(&pkt)); + } +} + +// With settings attached, the core returns what the role overrides of the repeater, +// room server and companion returned in v1.18.0 (all three were identical), e.g. +// examples/simple_repeater/MyMesh.h:131-158 and MyMesh.cpp:542-554. +namespace { + +RadioPrefsFields makePrefs(float af, float rx_base, float tx_f, float direct_f, + uint8_t thr, uint8_t agc, uint8_t cad, uint8_t acks) { + RadioPrefsFields p; + p.airtime_factor = af; + p.rx_delay_base = rx_base; + p.tx_delay_factor = tx_f; + p.direct_tx_delay_factor = direct_f; + p.interference_threshold = thr; + p.agc_reset_interval = agc; + p.cad_enabled = cad; + p.multi_acks = acks; + return p; +} + +// v1.18.0 role override: calcRxDelay() +int roleRxDelay(const RadioPrefsFields& prefs, float score, uint32_t air_time) { + if (prefs.rx_delay_base <= 0.0f) return 0; + return (int)((pow(prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); +} + +// v1.18.0 role override: getRetransmitDelay() / getDirectRetransmitDelay() +uint32_t roleRetransmitDelay(mesh::Radio& radio, mesh::RNG& rng, const mesh::Packet& pkt, float factor) { + uint32_t t = (radio.getEstAirtimeFor(pkt.getPathByteLen() + pkt.payload_len + 2) * factor); + return rng.nextInt(0, 5*t + 1); +} + +const RadioPrefsFields kPrefsSets[] = { + makePrefs(1.0f, 0.0f, 0.5f, 0.2f, 0, 0, 0, 0), // typical repeater defaults + makePrefs(2.0f, 3.25f, 0.75f, 0.3f, 14, 5, 1, 1), + makePrefs(9.0f, 20.0f, 2.0f, 2.0f, 255, 255, 1, 2), + makePrefs(0.0f, -1.0f, 0.0f, 0.0f, 1, 1, 0, 0), +}; + +} // namespace + +TEST(CoreTuningWithPrefs, DutyCycleInterferenceCadAgcAndAcks) { + for (const RadioPrefsFields& prefs : kPrefsSets) { + Harness h; + h.mesh.useRadioPrefs(&prefs); + EXPECT_FLOAT_EQ(prefs.airtime_factor, h.mesh.getAirtimeBudgetFactor()); + EXPECT_EQ((int)prefs.interference_threshold, h.mesh.getInterferenceThreshold()); + EXPECT_EQ((bool)prefs.cad_enabled, h.mesh.getCADEnabled()); + EXPECT_EQ(((int)prefs.agc_reset_interval) * 4000, h.mesh.getAGCResetInterval()); + EXPECT_EQ(prefs.multi_acks, h.mesh.getExtraAckTransmitCount()); + } +} + +TEST(CoreTuningWithPrefs, RxDelayMatchesTheRoleFormula) { + for (const RadioPrefsFields& prefs : kPrefsSets) { + Harness h; + h.mesh.useRadioPrefs(&prefs); + for (float score : {0.0f, 0.3f, 0.85f, 1.0f}) { + for (uint32_t air : {0u, 100u, 1234u, 60000u}) { + EXPECT_EQ(roleRxDelay(prefs, score, air), h.mesh.calcRxDelay(score, air)) + << "rx_delay_base " << prefs.rx_delay_base << " score " << score << " air " << air; + } + } + } +} + +TEST(CoreTuningWithPrefs, RetransmitDelaysMatchTheRoleFormula) { + for (mesh::Packet pkt : makePackets()) { + for (const RadioPrefsFields& prefs : kPrefsSets) { + Harness h; + h.mesh.useRadioPrefs(&prefs); + SequenceRNG expected_rng(kRandom); + for (size_t i = 0; i < kRandom.size(); i++) { + EXPECT_EQ(roleRetransmitDelay(h.radio, expected_rng, pkt, prefs.tx_delay_factor), h.mesh.getRetransmitDelay(&pkt)); + EXPECT_EQ(roleRetransmitDelay(h.radio, expected_rng, pkt, prefs.direct_tx_delay_factor), h.mesh.getDirectRetransmitDelay(&pkt)); + } + } + } +} + +// The roles read their prefs on every call, so a CLI change applied immediately. +// The core keeps that: it holds a pointer, not a copy. +TEST(CoreTuningWithPrefs, ChangesApplyImmediately) { + RadioPrefsFields prefs = kPrefsSets[0]; + Harness h; + h.mesh.useRadioPrefs(&prefs); + EXPECT_FALSE(h.mesh.getCADEnabled()); + prefs.cad_enabled = 1; + prefs.airtime_factor = 4.0f; + prefs.multi_acks = 1; + EXPECT_TRUE(h.mesh.getCADEnabled()); + EXPECT_FLOAT_EQ(4.0f, h.mesh.getAirtimeBudgetFactor()); + EXPECT_EQ(1, h.mesh.getExtraAckTransmitCount()); +} + +// Detaching the settings returns to the built-in defaults. +TEST(CoreTuningWithPrefs, DetachingRestoresTheDefaults) { + RadioPrefsFields prefs = kPrefsSets[1]; + Harness h; + h.mesh.useRadioPrefs(&prefs); + h.mesh.useRadioPrefs(NULL); + EXPECT_FLOAT_EQ(1.0f, h.mesh.getAirtimeBudgetFactor()); + EXPECT_EQ(6079, h.mesh.calcRxDelay(0.0f, 1000)); + EXPECT_FALSE(h.mesh.getCADEnabled()); + EXPECT_EQ(0, h.mesh.getExtraAckTransmitCount()); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_fem_rxgain/test_fem_rxgain.cpp b/test/test_fem_rxgain/test_fem_rxgain.cpp new file mode 100644 index 00000000000..e07f46e3db4 --- /dev/null +++ b/test/test_fem_rxgain/test_fem_rxgain.cpp @@ -0,0 +1,393 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +// The RX gain (LNA) setting of the boards whose LoRa front end module can switch +// its LNA: Heltec V4, V4 R8, T096 and Tracker V2. Their board classes carry the +// same attachDynamicPrefs() and handleCommand() (the board class name aside, +// see tools/refactoring/variant_duplication.py), which read the `fem_rxgain` key +// from the store the role attaches, answer `get/set radio.fem.rxgain` and write +// the key back. These tests pin what the CLI replies and what lands in the store, +// for a verbatim copy of those bodies and for the shared FemRxGain the boards +// forward to now. + +namespace { + +// The board's FEM control as far as the handlers see it (the variants' +// LoRaFEMControl), recording what the board does with it. +struct FakeFEMControl { + bool can_control = true; + bool lna_enabled = false; + int rx_mode_calls = 0; + + bool isLnaCanControl() const { return can_control; } + bool isLNAEnabled() const { return lna_enabled; } + void setLNAEnable(bool enabled) { lna_enabled = enabled; } + void setRxModeEnable() { rx_mode_calls++; } +}; + +// A key/value store that records every access. getByKey() copies the value the +// way DynamicConfigSerializer does (strncpy, then a terminator at max_len). +class RecordingStore : public KeyValueStore { +public: + std::map values; + std::vector> reads; + std::vector> writes; + bool accept_writes = true; + + bool setByKey(const char* key, const char* value) override { + writes.emplace_back(key, value); + if (!accept_writes) return false; + values[key] = value; + return true; + } + bool getByKey(const char* key, char* value, size_t max_len) override { + reads.emplace_back(key, max_len); + auto it = values.find(key); + if (it == values.end()) return false; + strncpy(value, it->second.c_str(), max_len); + value[max_len] = 0; + return true; + } +}; + +// What the four boards have around the handlers: the public FEM control and the +// two private helpers, whose bodies are the boards' own (HeltecV4Board.cpp), plus +// a switch that makes a switch of the LNA fail although it can be controlled, +// which no real board does but which reaches the handlers' error branch. +class FakeBoardBase { +public: + FakeFEMControl loRaFEMControl; + bool fail_set = false; + std::vector set_calls; + +protected: + bool setLoRaFemLnaEnabled(bool enable) { + set_calls.push_back(enable); + if (!loRaFEMControl.isLnaCanControl()) { + return false; + } + if (fail_set) { + return false; + } + + loRaFEMControl.setLNAEnable(enable); + loRaFEMControl.setRxModeEnable(); + return true; + } + + bool isLoRaFemLnaEnabled() const { + return loRaFEMControl.isLNAEnabled(); + } +}; + +// The handlers as the four boards have them. +class VariantCopyBoard : public FakeBoardBase { + KeyValueStore* _prefs = NULL; + +public: + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); +}; + +// Copied verbatim from variants/heltec_v4/HeltecV4Board.cpp, class name replaced. +void VariantCopyBoard::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + + char radio_fem_rxgain[8] = { 0 }; + _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values + + setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); +} + +bool VariantCopyBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else if (memcmp(&command[21], "on", 2) == 0) { + if (setLoRaFemLnaEnabled(true)) { + _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (memcmp(&command[21], "off", 3) == 0) { + if (setLoRaFemLnaEnabled(false)) { + _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled +} + +// A board as the four are now: the handlers forward to the shared FemRxGain, +// which reaches the private helpers as a friend. +class SharedBoard : public FakeBoardBase { + friend class FemRxGain; + FemRxGain fem_rxgain; + +public: + void attachDynamicPrefs(KeyValueStore* prefs) { fem_rxgain.attachPrefs(*this, prefs); } + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + return fem_rxgain.handleCommand(*this, command, reply); + } +}; + +template +class FemRxGainTest : public ::testing::Test { +protected: + BOARD board; + RecordingStore store; + char reply[160]; + + // Runs a CLI command the way CommonCLI hands it over: in a zero filled buffer, + // so the handlers' fixed length compares stay inside it. + bool run(const char* command) { + char buf[80] = { 0 }; + strcpy(buf, command); + strcpy(reply, "untouched"); + return board.handleCommand(buf, 1234, reply); + } + + void attach(const char* stored) { + if (stored) store.values["fem_rxgain"] = stored; + board.attachDynamicPrefs(&store); + board.set_calls.clear(); + store.reads.clear(); + } +}; + +using Implementations = ::testing::Types; +TYPED_TEST_SUITE(FemRxGainTest, Implementations); + +TYPED_TEST(FemRxGainTest, AttachReadsTheKeyOnceAndSwitchesTheLnaOn) { + this->store.values["fem_rxgain"] = "1"; + this->board.attachDynamicPrefs(&this->store); + + ASSERT_EQ(this->store.reads.size(), 1u); + EXPECT_EQ(this->store.reads[0].first, "fem_rxgain"); + EXPECT_EQ(this->store.reads[0].second, 7u); + EXPECT_EQ(this->board.set_calls, std::vector{ true }); + EXPECT_TRUE(this->board.loRaFEMControl.lna_enabled); + EXPECT_EQ(this->board.loRaFEMControl.rx_mode_calls, 1); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, AttachSwitchesTheLnaOffForAnyOtherValue) { + for (const char* stored : { "0", "", "on", "10", " 1", "1 ", "true" }) { + TypeParam board; + board.loRaFEMControl.lna_enabled = true; + RecordingStore store; + store.values["fem_rxgain"] = stored; + board.attachDynamicPrefs(&store); + + EXPECT_EQ(board.set_calls, std::vector{ false }) << "stored: '" << stored << "'"; + EXPECT_FALSE(board.loRaFEMControl.lna_enabled) << "stored: '" << stored << "'"; + EXPECT_TRUE(store.writes.empty()); + } +} + +TYPED_TEST(FemRxGainTest, AttachSwitchesTheLnaOffWhenTheKeyIsMissing) { + this->board.loRaFEMControl.lna_enabled = true; + this->board.attachDynamicPrefs(&this->store); + + EXPECT_EQ(this->store.reads.size(), 1u); + EXPECT_EQ(this->board.set_calls, std::vector{ false }); + EXPECT_FALSE(this->board.loRaFEMControl.lna_enabled); +} + +TYPED_TEST(FemRxGainTest, AttachReadsOnlySevenCharacters) { + // "1" followed by spaces is cut to seven characters and is still not "1". + this->store.values["fem_rxgain"] = "1 "; + this->board.attachDynamicPrefs(&this->store); + EXPECT_EQ(this->board.set_calls, std::vector{ false }); +} + +TYPED_TEST(FemRxGainTest, AttachTriesTheLnaEvenWhenItCannotBeControlled) { + this->board.loRaFEMControl.can_control = false; + this->store.values["fem_rxgain"] = "1"; + this->board.attachDynamicPrefs(&this->store); + + EXPECT_EQ(this->board.set_calls, std::vector{ true }); + EXPECT_FALSE(this->board.loRaFEMControl.lna_enabled); + EXPECT_EQ(this->board.loRaFEMControl.rx_mode_calls, 0); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, GetReportsTheLnaState) { + this->attach("1"); + EXPECT_TRUE(this->run("get radio.fem.rxgain")); + EXPECT_STREQ(this->reply, "> on"); + + this->board.loRaFEMControl.lna_enabled = false; + EXPECT_TRUE(this->run("get radio.fem.rxgain")); + EXPECT_STREQ(this->reply, "> off"); + + EXPECT_TRUE(this->board.set_calls.empty()); + EXPECT_TRUE(this->store.reads.empty()); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, GetIsUnsupportedWhenTheLnaCannotBeControlled) { + this->attach("1"); + this->board.loRaFEMControl.can_control = false; + EXPECT_TRUE(this->run("get radio.fem.rxgain")); + EXPECT_STREQ(this->reply, "Error: unsupported"); +} + +TYPED_TEST(FemRxGainTest, SetOnSwitchesTheLnaAndStoresOne) { + this->attach("0"); + EXPECT_TRUE(this->run("set radio.fem.rxgain on")); + + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain on"); + EXPECT_EQ(this->board.set_calls, std::vector{ true }); + EXPECT_TRUE(this->board.loRaFEMControl.lna_enabled); + ASSERT_EQ(this->store.writes.size(), 1u); + EXPECT_EQ(this->store.writes[0], std::make_pair(std::string("fem_rxgain"), std::string("1"))); + EXPECT_TRUE(this->store.reads.empty()); +} + +TYPED_TEST(FemRxGainTest, SetOffSwitchesTheLnaAndStoresZero) { + this->attach("1"); + EXPECT_TRUE(this->run("set radio.fem.rxgain off")); + + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain off"); + EXPECT_EQ(this->board.set_calls, std::vector{ false }); + EXPECT_FALSE(this->board.loRaFEMControl.lna_enabled); + ASSERT_EQ(this->store.writes.size(), 1u); + EXPECT_EQ(this->store.writes[0], std::make_pair(std::string("fem_rxgain"), std::string("0"))); +} + +TYPED_TEST(FemRxGainTest, SetMatchesOnlyTheStartOfTheState) { + this->attach("0"); + EXPECT_TRUE(this->run("set radio.fem.rxgain onward")); + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain on"); + EXPECT_TRUE(this->run("set radio.fem.rxgain offline")); + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain off"); + EXPECT_EQ(this->board.set_calls, (std::vector{ true, false })); + EXPECT_EQ(this->store.writes.size(), 2u); +} + +TYPED_TEST(FemRxGainTest, SetRejectsAnyOtherState) { + this->attach("0"); + for (const char* command : { "set radio.fem.rxgain 1", "set radio.fem.rxgain 0", + "set radio.fem.rxgain on", "set radio.fem.rxgain of", + "set radio.fem.rxgain ON", "set radio.fem.rxgain o", + "set radio.fem.rxgain " }) { + EXPECT_TRUE(this->run(command)) << command; + EXPECT_STREQ(this->reply, "Error: state must be on or off") << command; + } + EXPECT_TRUE(this->board.set_calls.empty()); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, SetIsUnsupportedWhenTheLnaCannotBeControlled) { + this->attach("0"); + this->board.loRaFEMControl.can_control = false; + for (const char* command : { "set radio.fem.rxgain on", "set radio.fem.rxgain off", + "set radio.fem.rxgain x" }) { + EXPECT_TRUE(this->run(command)) << command; + EXPECT_STREQ(this->reply, "Error: unsupported") << command; + } + EXPECT_TRUE(this->board.set_calls.empty()); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, SetReportsAFailedSwitchAndStoresNothing) { + this->attach("0"); + this->board.fail_set = true; + EXPECT_TRUE(this->run("set radio.fem.rxgain on")); + EXPECT_STREQ(this->reply, "Error: failed to apply LoRa FEM RX gain"); + EXPECT_TRUE(this->run("set radio.fem.rxgain off")); + EXPECT_STREQ(this->reply, "Error: failed to apply LoRa FEM RX gain"); + EXPECT_EQ(this->board.set_calls, (std::vector{ true, false })); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, SetIgnoresWhetherTheStoreTookTheValue) { + this->attach("0"); + this->store.accept_writes = false; + EXPECT_TRUE(this->run("set radio.fem.rxgain on")); + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain on"); + EXPECT_EQ(this->store.writes.size(), 1u); +} + +TYPED_TEST(FemRxGainTest, SetWritesToTheStoreAttachedLast) { + RecordingStore first; + this->board.attachDynamicPrefs(&first); + this->board.attachDynamicPrefs(&this->store); + EXPECT_TRUE(this->run("set radio.fem.rxgain on")); + EXPECT_TRUE(first.writes.empty()); + EXPECT_EQ(this->store.writes.size(), 1u); +} + +TYPED_TEST(FemRxGainTest, OtherCommandsAreNotHandled) { + this->attach("1"); + for (const char* command : { "get radio.fem.rxgain ", "get radio.fem.rxgain on", + "set radio.fem.rxgain", "set radio.fem.rxgainon", + "get radio.rxgain", "set radio.fem.txgain on", + "SET radio.fem.rxgain on", "", "ver" }) { + EXPECT_FALSE(this->run(command)) << command; + EXPECT_STREQ(this->reply, "untouched") << command; + } + EXPECT_TRUE(this->board.set_calls.empty()); + EXPECT_TRUE(this->store.reads.empty()); + EXPECT_TRUE(this->store.writes.empty()); +} + +// Without a store the boards' copies dereference NULL on `set` (the change +// meshcore-dev/MeshCore#3496 makes to them); the shared code stores nothing and +// replies as with a store. No role in this tree calls handleCommand() before it +// attaches the store, so this case is new, not changed. +TEST(FemRxGainWithoutStore, SetSwitchesTheLnaAndStoresNothing) { + SharedBoard board; + char reply[160]; + char on[80] = "set radio.fem.rxgain on"; + char off[80] = "set radio.fem.rxgain off"; + char get[80] = "get radio.fem.rxgain"; + + EXPECT_TRUE(board.handleCommand(on, 0, reply)); + EXPECT_STREQ(reply, "OK - LoRa FEM RX gain on"); + EXPECT_TRUE(board.loRaFEMControl.lna_enabled); + EXPECT_TRUE(board.handleCommand(get, 0, reply)); + EXPECT_STREQ(reply, "> on"); + EXPECT_TRUE(board.handleCommand(off, 0, reply)); + EXPECT_STREQ(reply, "OK - LoRa FEM RX gain off"); + EXPECT_FALSE(board.loRaFEMControl.lna_enabled); + EXPECT_EQ(board.set_calls, (std::vector{ true, false })); +} + +// The shared object holds nothing but the store pointer, so the boards keep +// their size and layout (the pointer replaces their own `_prefs`). +TEST(FemRxGainWithoutStore, IsTheSizeOfAPointer) { + EXPECT_EQ(sizeof(FemRxGain), sizeof(KeyValueStore*)); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_i2c_bus_clear/test_i2c_bus_clear.cpp b/test/test_i2c_bus_clear/test_i2c_bus_clear.cpp new file mode 100644 index 00000000000..ef05bbbc671 --- /dev/null +++ b/test/test_i2c_bus_clear/test_i2c_bus_clear.cpp @@ -0,0 +1,144 @@ +#include +#include +#include +#include "helpers/nrf52/I2cBusClear.h" + +// Open-drain bus model: a line is low if the master or a slave pulls it low. +// The interface only offers low/release, so "drive high" cannot be expressed. +struct FakeBus { + // slave holds SDA low until it has seen this many SCL rising edges + int slave_sda_clocks_needed = 0; + // slave holds SCL low (clock stretching) for this many delayUs() calls after each release + int stretch_calls = 0; + bool scl_stuck_low = false; // SCL shorted/held forever + bool sda_stuck_low = false; // SDA held forever (not a mid-byte slave) + bool relow_after_stop = false; // slave pulls SDA low again once a STOP was seen + int sda_low_calls = 0; // how often the master drove SDA low + int stops = 0; + + bool master_sda_low = false, master_scl_low = false; + int clocks_seen = 0; + int stretch_left = 0; + uint32_t elapsed_us = 0; + std::string log; // sequence of S/P and edges, for assertions + + bool sda() const { + bool slave_low = sda_stuck_low || clocks_seen < slave_sda_clocks_needed || + (relow_after_stop && stops > 0); + return !(master_sda_low || slave_low); + } + bool scl() const { return !(master_scl_low || scl_stuck_low || stretch_left > 0); } + + void sdaLow() { sda_low_calls++; bool was = sda(), c = scl(); master_sda_low = true; if (was && c) log += 'S'; } + void sdaRelease() { bool was = sda(), c = scl(), drove = master_sda_low; master_sda_low = false; if (!was && sda() && c) log += 'P'; if (c && drove) stops++; } + bool sdaRead() { return sda(); } + void sclLow() { master_scl_low = true; } + void sclRelease() { + bool was = master_scl_low; + master_scl_low = false; + if (was) { clocks_seen++; stretch_left = stretch_calls; log += 'c'; } + } + bool sclRead() { return scl(); } + void delayUs(uint32_t us) { + elapsed_us += us; + // a correct clear is bounded; fail instead of hanging the test run + if (elapsed_us > 100000) throw std::runtime_error("bus clear did not terminate"); if (stretch_left > 0) stretch_left--; + } +}; + +TEST(I2cBusClear, HealthyBusDoesNothing) { + FakeBus b; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_FALSE(r.sda_low_at_start); + EXPECT_EQ(0, r.pulses); + EXPECT_EQ("", b.log); // no clocks, no START, no STOP +} + +TEST(I2cBusClear, OneClockReleasesAndEndsWithStop) { + FakeBus b; + b.slave_sda_clocks_needed = 1; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_TRUE(r.sda_low_at_start); + EXPECT_EQ(1, r.pulses); + EXPECT_EQ("cSP", b.log); // one clock, then START-shape low, release = STOP +} + +TEST(I2cBusClear, StopsClockingAsSoonAsSdaIsFree) { + FakeBus b; + b.slave_sda_clocks_needed = 5; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_EQ(5, r.pulses); +} + +TEST(I2cBusClear, NineClocksSuffice) { + FakeBus b; + b.slave_sda_clocks_needed = 9; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_EQ(9, r.pulses); +} + +TEST(I2cBusClear, SdaStuckForeverGivesUpAfterNineClocksWithoutStop) { + FakeBus b; + b.sda_stuck_low = true; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_FALSE(r.ok); + EXPECT_TRUE(r.sda_low_at_start); + EXPECT_EQ(9, r.pulses); + EXPECT_EQ(std::string(9, 'c'), b.log); // no STOP attempted + EXPECT_EQ(0, b.sda_low_calls); // SDA never driven by the master +} + +TEST(I2cBusClear, ReportsNotOkWhenSdaFallsAgainAfterStop) { + FakeBus b; + b.slave_sda_clocks_needed = 2; + b.relow_after_stop = true; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_FALSE(r.ok); +} + +TEST(I2cBusClear, SclHeldLowTouchesNothing) { + FakeBus b; + b.scl_stuck_low = true; + b.slave_sda_clocks_needed = 1; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_FALSE(r.ok); + EXPECT_EQ(0, r.pulses); + EXPECT_EQ("", b.log); + EXPECT_LE(b.elapsed_us, 5000u); // bounded wait, no endless loop +} + +TEST(I2cBusClear, ClockStretchingSlaveIsWaitedFor) { + FakeBus b; + b.slave_sda_clocks_needed = 3; + b.stretch_calls = 20; // 200 us of stretching per clock + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_EQ(3, r.pulses); +} + +TEST(I2cBusClear, StretchBeyondLimitGivesUp) { + FakeBus b; + b.slave_sda_clocks_needed = 3; + b.stretch_calls = 1000; // far beyond the 1 ms limit + I2cBusClearResult r = i2cBusClear(b); + EXPECT_FALSE(r.ok); + EXPECT_LE(b.elapsed_us, 5000u); +} + +TEST(I2cBusClear, ClockRateStaysAtOrBelow100kHz) { + FakeBus b; + b.slave_sda_clocks_needed = 9; + i2cBusClear(b); + // 9 clocks, two half periods each, plus settle and STOP: well under 1 ms beyond the settle time + EXPECT_GE(b.elapsed_us, 9u * 2 * I2C_BUS_CLEAR_HALF_PERIOD_US); + EXPECT_LT(b.elapsed_us, I2C_BUS_CLEAR_SETTLE_US + 1000u); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_identity_store/test_identity_store.cpp b/test/test_identity_store/test_identity_store.cpp new file mode 100644 index 00000000000..9e49caa0834 --- /dev/null +++ b/test/test_identity_store/test_identity_store.cpp @@ -0,0 +1,210 @@ +#include + +#include + +// IdentityStore is the only place that persists a node's keypair. Every role +// generates a new identity when the load at boot comes up empty and then saves +// it, so a save that reports success without having written anything costs the +// node its identity at the next boot. +// +// The mock filesystem reproduces both ways a medium refuses data: a write that +// comes up short, which is what littlefs does on nRF52 and STM32, and a write +// that reports success while only the close hands the bytes over, which is what +// the buffered ESP32 file does. + +namespace { + +const size_t KEYPAIR_BYTES = PUB_KEY_SIZE + PRV_KEY_SIZE; // 96 +const size_t DISPLAY_NAME_BYTES = 32; + +mesh::LocalIdentity makeIdentity() { + mesh::LocalIdentity id; + for (size_t i = 0; i < PUB_KEY_SIZE; i++) id.pub_key[i] = (uint8_t) (0xA0 + i); + for (size_t i = 0; i < PRV_KEY_SIZE; i++) id.prv_key[i] = (uint8_t) (0x10 + i); + return id; +} + +class IdentityStoreSave : public ::testing::Test { +protected: + Adafruit_LittleFS fs; + mesh::LocalIdentity id = makeIdentity(); +}; + +TEST_F(IdentityStoreSave, ReportsSuccessWhenTheKeypairFits) { + fs.write_budget = KEYPAIR_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id)); + ASSERT_NE(fs.contents("/_main.id"), nullptr); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES); +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenTheFileCannotBeOpened) { + fs.fail_open = true; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); +} + +// The defect: the write fails, the identity is not on the medium, and the +// caller is told the save worked. +TEST_F(IdentityStoreSave, ReportsFailureWhenNoByteCanBeWritten) { + fs.write_budget = 0; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_TRUE(fs.contents("/_main.id")->empty()); +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenThePrivateKeyIsCutOff) { + fs.write_budget = PUB_KEY_SIZE + 1; // public key fits, private key does not + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_EQ(fs.contents("/_main.id")->size(), (size_t) PUB_KEY_SIZE + 1); +} + +TEST_F(IdentityStoreSave, RoundTripsAnIdentityThroughTheFilesystem) { + IdentityStore store(fs, ""); + ASSERT_TRUE(store.save("_main", id)); + + mesh::LocalIdentity loaded; + ASSERT_TRUE(store.load("_main", loaded)); + EXPECT_EQ(memcmp(loaded.pub_key, id.pub_key, PUB_KEY_SIZE), 0); + EXPECT_EQ(memcmp(loaded.prv_key, id.prv_key, PRV_KEY_SIZE), 0); +} + +class IdentityStoreSaveWithName : public IdentityStoreSave { }; + +TEST_F(IdentityStoreSaveWithName, ReportsSuccessWhenKeypairAndNameFit) { + fs.write_budget = KEYPAIR_BYTES + DISPLAY_NAME_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id, "room")); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES + DISPLAY_NAME_BYTES); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheKeypairIsCutOff) { + fs.write_budget = 0; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); +} + +// The display name is written after the keypair, so a medium that fills up in +// between leaves a file that loads as an identity but carries no name. +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenOnlyTheNameIsCutOff) { + fs.write_budget = KEYPAIR_BYTES; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheFileCannotBeOpened) { + fs.fail_open = true; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); +} + +// The second failure mode, and the one that matters on ESP32: every write +// reports success because the bytes are still in a buffer, and the medium only +// sees them when the file is closed. File::close() is void, so nothing in the +// write path can report this. Only reading the file back afterwards can. + +TEST_F(IdentityStoreSave, ReportsFailureWhenTheCommitLosesEverything) { + fs.commit_budget = 0; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_TRUE(fs.contents("/_main.id")->empty()); +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenTheCommitKeepsOnlyThePublicKey) { + fs.commit_budget = PUB_KEY_SIZE; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_EQ(fs.contents("/_main.id")->size(), (size_t) PUB_KEY_SIZE); +} + +// A worn cell that reads back a different byte keeps the file length intact, so +// only comparing the content catches it. This index is inside the private key, +// where a comparison of the public key alone would look away. +TEST_F(IdentityStoreSave, ReportsFailureWhenAByteOfThePrivateKeyComesBackWrong) { + fs.corrupt_on_commit = PUB_KEY_SIZE + 20; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES); // the length is right +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenAByteOfThePublicKeyComesBackWrong) { + fs.corrupt_on_commit = 3; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); +} + +TEST_F(IdentityStoreSave, ReportsSuccessWhenTheCommitKeepsEverything) { + fs.commit_budget = KEYPAIR_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id)); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheCommitLosesTheName) { + fs.commit_budget = KEYPAIR_BYTES; // the keypair survives, the name does not + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenAByteOfTheNameComesBackWrong) { + fs.corrupt_on_commit = KEYPAIR_BYTES + 2; // inside "room" + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES + DISPLAY_NAME_BYTES); +} + +TEST_F(IdentityStoreSaveWithName, ReportsSuccessWhenTheCommitKeepsEverything) { + fs.commit_budget = KEYPAIR_BYTES + DISPLAY_NAME_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id, "room")); +} + +// The three cases above pick single indices. These two nail down that every +// byte of the comparison counts, which is what keeps a shortened comparison +// from passing unnoticed. +TEST_F(IdentityStoreSave, ReportsFailureForEveryByteOfTheKeypairComingBackWrong) { + for (size_t i = 0; i < KEYPAIR_BYTES; i++) { + Adafruit_LittleFS one_fs; + one_fs.corrupt_on_commit = i; + IdentityStore store(one_fs, ""); + + EXPECT_FALSE(store.save("_main", id)) << "byte " << i << " went unnoticed"; + } +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureForEveryByteOfTheNameComingBackWrong) { + // Byte 31 of the name field is padding that load() forces to zero, so a + // change there cannot be seen. It also does not matter: the name itself is + // shorter and stays intact. + for (size_t i = 0; i + 1 < DISPLAY_NAME_BYTES; i++) { + Adafruit_LittleFS one_fs; + one_fs.corrupt_on_commit = KEYPAIR_BYTES + i; + IdentityStore store(one_fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")) << "name byte " << i << " went unnoticed"; + } +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_millis_deadline/test_millis_deadline.cpp b/test/test_millis_deadline/test_millis_deadline.cpp new file mode 100644 index 00000000000..fd1259d0dd1 --- /dev/null +++ b/test/test_millis_deadline/test_millis_deadline.cpp @@ -0,0 +1,121 @@ +#include + +#include + +// The firmware schedules periodic work by storing 'millis() + interval' and asking +// once per loop pass whether that deadline has passed. These tests drive exactly that +// loop, including over the two points where a 32-bit millisecond counter is awkward: +// where it passes 2^31 and where it wraps back to zero. +// +// Every value here is uint32_t on purpose. The firmware targets have a 32-bit long, +// the test host a 64-bit one, so a test written with long would measure the host +// rather than the target. + +namespace { + +const uint32_t SIGN_FLIP = 0x80000000u; // 24.8 days +const uint32_t INTERVAL = 1000; +const uint32_t SPAN = 3000000; // 3000 s of loop time, 1 ms per pass + +// Reference form of the same question, as Dispatcher::millisHasNowPassed spells it. +bool signedDifferenceIdiom(uint32_t now, uint32_t deadline) { + return (int32_t)(now - deadline) > 0; +} + +// One periodic block, driven pass by pass. Records what a caller actually cares about: +// how often it ran, the longest wait between two runs, and the longest run of +// consecutive passes that all fired (which must stay 1 for a periodic block). +struct PeriodicBlock { + uint32_t deadline; + uint32_t fires = 0; + uint32_t longest_gap = 0; + uint32_t longest_burst = 0; + uint32_t gap = 0; + uint32_t burst = 0; + + explicit PeriodicBlock(uint32_t start) : deadline(start) {} + + void pass(uint32_t now) { + gap++; + if (mesh::millisHasPassed(now, deadline)) { + if (gap > longest_gap) longest_gap = gap; + gap = 0; + fires++; + burst++; + if (burst > longest_burst) longest_burst = burst; + deadline = now + INTERVAL; + } else { + burst = 0; + } + } +}; + +PeriodicBlock runFrom(uint32_t start) { + PeriodicBlock block(start); + for (uint32_t i = 0; i < SPAN; i++) block.pass(start + i); + return block; +} + +} // namespace + +TEST(MillisDeadline, FiresOnlyStrictlyAfterTheDeadline) { + EXPECT_FALSE(mesh::millisHasPassed(999, 1000)); + EXPECT_FALSE(mesh::millisHasPassed(1000, 1000)); // strict, as the shipped loops were + EXPECT_TRUE(mesh::millisHasPassed(1001, 1000)); +} + +TEST(MillisDeadline, MatchesTheSignedDifferenceIdiom) { + const uint32_t deadlines[] = {0, 1, 1000, SIGN_FLIP - 1, SIGN_FLIP, SIGN_FLIP + 1, 0xFFFFFFFFu}; + const uint32_t offsets[] = {0, 1, 2, 1000, 0x7FFFFFFEu, 0x7FFFFFFFu, SIGN_FLIP, SIGN_FLIP + 1, + 0xFFFFFFFEu, 0xFFFFFFFFu}; + for (uint32_t deadline : deadlines) { + for (uint32_t offset : offsets) { + const uint32_t now = deadline + offset; + EXPECT_EQ(signedDifferenceIdiom(now, deadline), mesh::millisHasPassed(now, deadline)) + << "now=" << now << " deadline=" << deadline; + } + } +} + +TEST(MillisDeadline, TreatsADeadlineThatWrappedAsStillAhead) { + // The last scheduling before the counter wraps: millis() is 256 short of 2^32 and + // the stored deadline has already wrapped to 500, so it lies 756 ms in the future. + const uint32_t now = 0xFFFFFF00u; + const uint32_t wrapped_deadline = 500; + + EXPECT_FALSE(mesh::millisHasPassed(now, wrapped_deadline)); + EXPECT_EQ(-756, (int32_t)(now - wrapped_deadline)); // 756 ms still to wait + EXPECT_TRUE(mesh::millisHasPassed(now + 757, wrapped_deadline)); +} + +TEST(MillisDeadline, PeriodIsUnchangedByTheSignFlipAndTheWrap) { + const PeriodicBlock quiet = runFrom(0x10000000u); // nowhere near a boundary + const PeriodicBlock sign = runFrom(SIGN_FLIP - SPAN / 2); // across 2^31 + const PeriodicBlock wrap = runFrom(0u - SPAN / 2); // across the counter wrap + + EXPECT_EQ(INTERVAL + 1, quiet.longest_gap); // strict compare, so one pass later + EXPECT_EQ(1u, quiet.longest_burst); // a periodic block never fires twice in a row + + EXPECT_EQ(quiet.fires, sign.fires); + EXPECT_EQ(quiet.longest_gap, sign.longest_gap); + EXPECT_EQ(quiet.longest_burst, sign.longest_burst); + + EXPECT_EQ(quiet.fires, wrap.fires); + EXPECT_EQ(quiet.longest_gap, wrap.longest_gap); + EXPECT_EQ(quiet.longest_burst, wrap.longest_burst); +} + +TEST(MillisDeadline, LooksAheadHalfTheCounterAtMost) { + // Documented horizon: a deadline up to 2^31 - 1 ahead reads as future, beyond that + // the difference can no longer tell future from past. Every interval the firmware + // schedules is far inside that horizon. + const uint32_t now = 0; + EXPECT_TRUE(mesh::millisHasPassed(now, now - 0x7FFFFFFFu)); // 2^31 - 1 in the past + EXPECT_FALSE(mesh::millisHasPassed(now, now - SIGN_FLIP)); // exactly 2^31, reads as future + EXPECT_FALSE(mesh::millisHasPassed(now, now + 1)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_node_prefs/test_node_prefs.cpp b/test/test_node_prefs/test_node_prefs.cpp new file mode 100644 index 00000000000..3478eb83d6d --- /dev/null +++ b/test/test_node_prefs/test_node_prefs.cpp @@ -0,0 +1,141 @@ +#include + +#include + +// extra_sf and discovery_mod_timestamp live in NodePrefs but were missing from +// its serialisation, so a server role lost both on every reboot. The binary +// loader still read discovery_mod_timestamp (CommonCLI.cpp, offset 162), which +// is why the loss only shows on nodes that boot from /prefs.json, meaning every +// node since 1.17. + +namespace { + +class StringInput : public Stream { + const char* _text; + size_t _pos, _len; +public: + explicit StringInput(const char* text) : _text(text), _pos(0), _len(strlen(text)) { } + int available() override { return (int) (_len - _pos); } + int read() override { return _pos < _len ? _text[_pos++] : -1; } + int peek() override { return _pos < _len ? _text[_pos] : -1; } +}; + +class StringOutput : public Stream { + std::string _text; +public: + using Print::write; + size_t write(uint8_t b) override { _text.push_back((char) b); return 1; } + size_t print(double v, int places = 2) override { + char buf[32]; + snprintf(buf, sizeof(buf), "%.*f", places, v); + return Print::print(buf); + } + size_t print(unsigned int v, int base = 10) override { return printNumber((unsigned long long) v); } + size_t print(unsigned long v, int base = 10) override { return printNumber((unsigned long long) v); } + size_t print(int v, int base = 10) override { return printSigned(v); } + size_t print(long v, int base = 10) override { return printSigned(v); } + const std::string& text() const { return _text; } +private: + size_t printNumber(unsigned long long v) { + char buf[24]; + snprintf(buf, sizeof(buf), "%llu", v); + return Print::print(buf); + } + size_t printSigned(long long v) { + char buf[24]; + snprintf(buf, sizeof(buf), "%lld", v); + return Print::print(buf); + } +}; + +std::string save(NodePrefs& p) { + StringOutput out; + EXPECT_TRUE(p.saveSerial(out)); + return out.text(); +} + +bool contains(const std::string& haystack, const char* needle) { + return haystack.find(needle) != std::string::npos; +} + +class NodePrefsSerialisation : public ::testing::Test { +protected: + NodePrefs p; +}; + +// A fresh NodePrefs has to start from a defined extra_sf, because it is now +// written out and "get extra.sf" reads it. +TEST_F(NodePrefsSerialisation, ExtraSfStartsZeroed) { + for (size_t i = 0; i < sizeof(p.extra_sf); i++) { + EXPECT_EQ(0, (int) p.extra_sf[i]) << "index " << i; + } +} + +TEST_F(NodePrefsSerialisation, SaveWritesTheDiscoveryTimestamp) { + p.discovery_mod_timestamp = 1760000000; + EXPECT_TRUE(contains(save(p), "disc_mod:1760000000")); +} + +TEST_F(NodePrefsSerialisation, SaveWritesTheExtraSpreadFactors) { + p.extra_sf[0] = 9; p.extra_sf[1] = 10; p.extra_sf[2] = 11; p.extra_sf[3] = 0; + EXPECT_TRUE(contains(save(p), "extra_sf:\"090A0B00\"")); +} + +TEST_F(NodePrefsSerialisation, LoadRestoresTheDiscoveryTimestamp) { + StringInput in("{disc_mod:1760000000}"); + EXPECT_TRUE(p.loadSerial(in)); + EXPECT_EQ(1760000000u, p.discovery_mod_timestamp); +} + +TEST_F(NodePrefsSerialisation, LoadRestoresTheExtraSpreadFactors) { + StringInput in("{extra_sf:\"090A0B00\"}"); + EXPECT_TRUE(p.loadSerial(in)); + EXPECT_EQ(9, (int) p.extra_sf[0]); + EXPECT_EQ(10, (int) p.extra_sf[1]); + EXPECT_EQ(11, (int) p.extra_sf[2]); + EXPECT_EQ(0, (int) p.extra_sf[3]); +} + +TEST_F(NodePrefsSerialisation, RoundTripKeepsBothFields) { + p.discovery_mod_timestamp = 1234567890; + p.extra_sf[0] = 12; p.extra_sf[1] = 0; + std::string text = save(p); + + NodePrefs loaded; + StringInput in(text.c_str()); + EXPECT_TRUE(loaded.loadSerial(in)); + EXPECT_EQ(1234567890u, loaded.discovery_mod_timestamp); + EXPECT_EQ(12, (int) loaded.extra_sf[0]); + EXPECT_EQ(0, (int) loaded.extra_sf[1]); +} + +// Upgrade: a file written by an older firmware has neither key. Loading it must +// leave both fields alone instead of zeroing them. +TEST_F(NodePrefsSerialisation, LoadWithoutTheNewKeysLeavesThemAlone) { + p.discovery_mod_timestamp = 42; + p.extra_sf[0] = 7; + + StringInput in("{name:\"node\",adv_int:1,radio:{sf:11,cr:5}}"); + EXPECT_TRUE(p.loadSerial(in)); + + EXPECT_EQ(42u, p.discovery_mod_timestamp); + EXPECT_EQ(7, (int) p.extra_sf[0]); + EXPECT_EQ(11, (int) p.sf); // the keys that are there still arrive +} + +// Downgrade: a firmware that does not know the keys has to skip them, which is +// what the serializer does with any unknown key. +TEST_F(NodePrefsSerialisation, UnknownKeysAreSkipped) { + StringInput in("{disc_mod:5,whats_this:9,extra_sf:\"0A000000\",radio:{nope:1,sf:10}}"); + EXPECT_TRUE(p.loadSerial(in)); + EXPECT_EQ(5u, p.discovery_mod_timestamp); + EXPECT_EQ(10, (int) p.extra_sf[0]); + EXPECT_EQ(10, (int) p.sf); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_radio_chips/stubs/RadioLib.h b/test/test_radio_chips/stubs/RadioLib.h new file mode 100644 index 00000000000..7cea666da9a --- /dev/null +++ b/test/test_radio_chips/stubs/RadioLib.h @@ -0,0 +1,189 @@ +#pragma once + +// Host stand-in for the RadioLib chip classes that src/helpers/radiolib/Custom*.h +// derive from, so the real chip classes can be compiled and their receive +// detection exercised on the host. It is listed before test/mocks for this suite +// only, and replaces the PhysicalLayer-only mock there. +// +// The IRQ bit values are copied from RadioLib 7.7.1, the version the firmware +// builds against, so a test that names a bit means the bit the chip reports. +// Every call the chip classes make that the tests do not care about is accepted +// by a catch-all template and does nothing. + +#include "Arduino.h" + +#include +#include +#include +#include +#include +#include + +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_SPI_CMD_INVALID (-706) +#define RADIOLIB_ERR_SPI_CMD_FAILED (-707) +#define RADIOLIB_NC (0xFFFFFFFF) +#define RADIOLIB_PREAMBLE_DETECTED (-14) +#define RADIOLIB_ASSERT(STATEVAR) { if((STATEVAR) != RADIOLIB_ERR_NONE) { return(STATEVAR); } } + +// RadioLib's generic IRQ bit numbers (TypeDef.h, enum RadioLibIrqType_t). +enum { + RADIOLIB_IRQ_TX_DONE = 0x00, + RADIOLIB_IRQ_RX_DONE, + RADIOLIB_IRQ_PREAMBLE_DETECTED, + RADIOLIB_IRQ_SYNC_WORD_VALID, + RADIOLIB_IRQ_HEADER_VALID, + RADIOLIB_IRQ_HEADER_ERR, + RADIOLIB_IRQ_CRC_ERR, + RADIOLIB_IRQ_CAD_DONE, + RADIOLIB_IRQ_CAD_DETECTED, + RADIOLIB_IRQ_TIMEOUT, +}; +#define RADIOLIB_IRQ_RX_DEFAULT_FLAGS ((1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT) | (1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_VALID) | (1UL << RADIOLIB_IRQ_HEADER_ERR)) +#define RADIOLIB_IRQ_RX_DEFAULT_MASK ((1UL << RADIOLIB_IRQ_RX_DONE)) + +// SX126x family (SX126x_commands.h) +#define RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED 0b0000000000000100 +#define RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID 0b0000000000001000 +#define RADIOLIB_SX126X_IRQ_HEADER_VALID 0b0000000000010000 +#define RADIOLIB_SX126X_IRQ_HEADER_ERR 0b0000000000100000 +#define RADIOLIB_SX126X_RX_TIMEOUT_INF 0xFFFFFF +#define RADIOLIB_SX126X_SYNC_WORD_PRIVATE 0x12 +#define RADIOLIB_SX126X_REG_RX_GAIN 0x08AC +#define RADIOLIB_SX126X_RX_GAIN_BOOSTED 0x96 + +// LR11x0 family (LR11x0_commands.h) +#define RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED (0x01UL << 4) +#define RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID (0x01UL << 5) +#define RADIOLIB_LR11X0_IRQ_HEADER_ERR (0x01UL << 6) +#define RADIOLIB_LR11X0_RX_TIMEOUT_INF (0xFFFFFFUL) +#define RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE (0x12) + +// LR2021 (LR2021_commands.h) +#define RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED (0x01UL << 5) +#define RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID (0x01UL << 6) +#define RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR (0x01UL << 9) +#define RADIOLIB_LR2021_RX_TIMEOUT_INF (0xFFFFFFUL) +#define RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE (0x12) + +// Formats a call and its arguments for the call logs below, so a test can pin +// which calls a chip class makes, with which arguments, in which order. +inline std::string stubArg(double v) { char b[32]; snprintf(b, sizeof(b), "%g", v); return b; } +inline std::string stubArg(float v) { return stubArg((double)v); } +template std::string stubArg(T v) { return std::to_string(v); } +template std::string stubCall(const char* name, A... a) { + std::string s = name; + s += "("; + const char* sep = ""; + ((s += sep, s += stubArg(a), sep = ","), ...); + return s + ")"; +} + +class SPIClass { +public: + std::vector calls; + template void begin(A... a) { calls.push_back(stubCall("begin", a...)); } + template void setPins(A... a) { calls.push_back(stubCall("setPins", a...)); } +}; + +class Module { +public: + Module() { } +}; +using STM32WLx_Module = Module; +using SX1262_Module = Module; + +// One base for every chip: the IRQ register is a plain field a test sets, and +// every clear the chip class asks for is recorded and applied to it. +class StubChip { +public: + explicit StubChip(Module*) { } + virtual ~StubChip() = default; + + uint32_t irq = 0; + std::vector clears; + + struct RxCall { uint32_t timeout, flags, mask; size_t len; }; + std::vector rx_calls; + + float freqMHz = 869.525f; + uint8_t spreadingFactor = 9; + + // SX126x and LR2021 names + uint32_t getIrqFlags() { return irq; } + int16_t clearIrqFlags(uint32_t mask) { clears.push_back(mask); irq &= ~mask; return RADIOLIB_ERR_NONE; } + // LR11x0 names + uint32_t getIrqStatus() { return irq; } + int16_t clearIrqState(uint32_t mask) { clears.push_back(mask); irq &= ~mask; return RADIOLIB_ERR_NONE; } + + virtual int16_t startReceive() { return RADIOLIB_ERR_NONE; } + int16_t startReceive(uint32_t timeout, uint32_t flags, uint32_t mask, size_t len) { + rx_calls.push_back({timeout, flags, mask, len}); + return RADIOLIB_ERR_NONE; + } + + virtual size_t getPacketLength(bool) { return 0; } + virtual int16_t setOutputPower(int8_t) { return RADIOLIB_ERR_NONE; } + int16_t setOutputPower(int8_t, bool) { return RADIOLIB_ERR_NONE; } + virtual int16_t setFrequency(float) { return RADIOLIB_ERR_NONE; } + + template int16_t begin(A...) { return RADIOLIB_ERR_NONE; } + template int16_t standby(A...) { return RADIOLIB_ERR_NONE; } + // The configuration calls std_init() makes, in order, with their arguments. + std::vector calls; + // Register contents readRegister() returns and writeRegister() changes. + std::map regs; + + template int16_t setCRC(A... a) { calls.push_back(stubCall("setCRC", a...)); return RADIOLIB_ERR_NONE; } + template int16_t setCurrentLimit(A... a) { calls.push_back(stubCall("setCurrentLimit", a...)); return RADIOLIB_ERR_NONE; } + template int16_t setDio2AsRfSwitch(A... a) { calls.push_back(stubCall("setDio2AsRfSwitch", a...)); return RADIOLIB_ERR_NONE; } + template int16_t setRxBoostedGainMode(A... a) { calls.push_back(stubCall("setRxBoostedGainMode", a...)); return RADIOLIB_ERR_NONE; } + template void setRfSwitchPins(A... a) { calls.push_back(stubCall("setRfSwitchPins", a...)); } + int16_t readRegister(uint16_t addr, uint8_t* data, size_t len) { + calls.push_back(stubCall("readRegister", addr, len)); + for (size_t i = 0; i < len; i++) data[i] = regs[addr + i]; + return RADIOLIB_ERR_NONE; + } + int16_t writeRegister(uint16_t addr, const uint8_t* data, size_t len) { + std::string c = stubCall("writeRegister", addr, len); + for (size_t i = 0; i < len; i++) { regs[addr + i] = data[i]; c += ":" + std::to_string(data[i]); } + calls.push_back(c); + return RADIOLIB_ERR_NONE; + } + template int16_t setRegulatorDCDC(A...) { return RADIOLIB_ERR_NONE; } + template int16_t checkOutputPower(A...) { return RADIOLIB_ERR_NONE; } + template int16_t explicitHeader(A...) { return RADIOLIB_ERR_NONE; } + // Distinct values, so a debug line that prints them is pinned with them. + uint8_t getStatus() { return 0xA5; } + uint16_t getDeviceErrors() { return 0x0120; } +}; + +// The SX126x begin() with RadioLib's parameters and defaults (SX1262.h, SX1268.h +// and LLCC68.h at the pinned commit all default useRegulatorLDO to false). Each +// call is logged; the results a test queues are returned in order, then +// RADIOLIB_ERR_NONE. +class SX126x : public StubChip { +public: + using StubChip::StubChip; + std::vector begin_results; + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, uint8_t syncWord = RADIOLIB_SX126X_SYNC_WORD_PRIVATE, + int8_t power = 10, uint16_t preambleLength = 8, float tcxoVoltage = 1.6, bool useRegulatorLDO = false) { + calls.push_back(stubCall("begin", freq, bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage, useRegulatorLDO)); + if (begin_results.empty()) return RADIOLIB_ERR_NONE; + int16_t r = begin_results.front(); + begin_results.erase(begin_results.begin()); + return r; + } +}; +class SX1262 : public SX126x { public: using SX126x::SX126x; }; +class SX1268 : public SX126x { public: using SX126x::SX126x; }; +class LLCC68 : public SX1262 { public: using SX1262::SX1262; }; +class STM32WLx : public SX1262 { public: using SX1262::SX1262; }; +class LR11x0 : public StubChip { public: using StubChip::StubChip; }; +class LR1110 : public LR11x0 { public: using LR11x0::LR11x0; }; +class LR1121 : public LR11x0 { public: using LR11x0::LR11x0; }; +class LR2021 : public StubChip { +public: + using StubChip::StubChip; + uint8_t irqDioNum = 0; +}; diff --git a/test/test_radio_chips/test_radio_chips.cpp b/test/test_radio_chips/test_radio_chips.cpp new file mode 100644 index 00000000000..2eb366db37a --- /dev/null +++ b/test/test_radio_chips/test_radio_chips.cpp @@ -0,0 +1,336 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +// Characterization of how the chip classes tell the wrapper that a packet is on +// the air (isReceiving(), which RadioLibWrapper::isReceivingPacket() forwards to). +// The dispatcher holds a transmission back while this reports true, so the +// timing below decides when a node talks over a packet it is hearing. +// +// The same expectations run against every chip class. Each chip names its IRQ +// bits differently and clears a slightly different set, which the traits below +// spell out; everything else is meant to be the same state machine. + +namespace { + +struct SX126xBits { + static constexpr uint32_t preamble = RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; + static constexpr uint32_t header = RADIOLIB_SX126X_IRQ_HEADER_VALID; + static constexpr uint32_t hdrErr = RADIOLIB_SX126X_IRQ_HEADER_ERR; + // the SX126x copies also clear the sync word bit + static constexpr uint32_t clearAll = preamble | header | hdrErr | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID; + static constexpr uint32_t rxTimeout = RADIOLIB_SX126X_RX_TIMEOUT_INF; +}; +struct LR11x0Bits { + static constexpr uint32_t preamble = RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; + static constexpr uint32_t header = RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; + static constexpr uint32_t hdrErr = RADIOLIB_LR11X0_IRQ_HEADER_ERR; + static constexpr uint32_t clearAll = preamble | header | hdrErr; + static constexpr uint32_t rxTimeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF; +}; +struct LR2021Bits { + static constexpr uint32_t preamble = RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED; + static constexpr uint32_t header = RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID; + static constexpr uint32_t hdrErr = RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR; + static constexpr uint32_t clearAll = preamble | header | hdrErr; + static constexpr uint32_t rxTimeout = RADIOLIB_LR2021_RX_TIMEOUT_INF; +}; + +template struct Chip { using Radio = C; using Bits = B; }; + +using Chips = ::testing::Types< + Chip, + Chip, + Chip, + Chip, + Chip, + Chip, + Chip>; + +// The defaults the chip classes start with, before setParams() replaces them. +constexpr uint32_t DEFAULT_PREAMBLE_MS = 66; +constexpr uint32_t DEFAULT_PAYLOAD_MS = 3934; + +// Start the clock away from zero: a timer value of 0 means "not started" in the +// state machine, which a separate test pins. +constexpr uint32_t T0 = 1000; + +template +struct RadioChipTest : public ::testing::Test { + using Radio = typename T::Radio; + using Bits = typename T::Bits; + Module mod; + Radio radio{&mod}; + + void SetUp() override { g_mock_millis = T0; } + + bool poll(uint32_t irq) { radio.irq = irq; return radio.isReceiving(); } + void at(uint32_t ms) { g_mock_millis = T0 + ms; } +}; +TYPED_TEST_SUITE(RadioChipTest, Chips); + +TYPED_TEST(RadioChipTest, QuietChannelIsNotReceiving) { + EXPECT_FALSE(this->poll(0)); + EXPECT_TRUE(this->radio.clears.empty()); +} + +TYPED_TEST(RadioChipTest, OtherIrqBitsAreIgnored) { + using B = typename TestFixture::Bits; + uint32_t other = ~(B::preamble | B::header | B::hdrErr); + EXPECT_FALSE(this->poll(other)); + EXPECT_TRUE(this->radio.clears.empty()); +} + +TYPED_TEST(RadioChipTest, PreambleCountsUntilItsDeadlineThenIsCleared) { + using B = typename TestFixture::Bits; + EXPECT_TRUE(this->poll(B::preamble)); + this->at(DEFAULT_PREAMBLE_MS); // deadline itself still counts + EXPECT_TRUE(this->poll(B::preamble)); + EXPECT_TRUE(this->radio.clears.empty()); + this->at(DEFAULT_PREAMBLE_MS + 1); // one past: give up on it + EXPECT_FALSE(this->poll(B::preamble)); + ASSERT_EQ(this->radio.clears.size(), 1u); + EXPECT_EQ(this->radio.clears[0], B::preamble); // only the preamble bit +} + +TYPED_TEST(RadioChipTest, PreambleTimerRestartsAfterAClear) { + using B = typename TestFixture::Bits; + this->poll(B::preamble); + this->at(DEFAULT_PREAMBLE_MS + 1); + EXPECT_FALSE(this->poll(B::preamble)); + // the chip reports a new preamble: a fresh deadline from now + this->at(DEFAULT_PREAMBLE_MS + 2); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(2 * DEFAULT_PREAMBLE_MS + 2); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(2 * DEFAULT_PREAMBLE_MS + 3); + EXPECT_FALSE(this->poll(B::preamble)); + EXPECT_EQ(this->radio.clears.size(), 2u); +} + +TYPED_TEST(RadioChipTest, QuietPollResetsThePreambleTimer) { + using B = typename TestFixture::Bits; + this->poll(B::preamble); + this->at(50); + EXPECT_FALSE(this->poll(0)); + this->at(100); // 100 ms after the first preamble + EXPECT_TRUE(this->poll(B::preamble)); // but a new timer started at 100 + this->at(100 + DEFAULT_PREAMBLE_MS); + EXPECT_TRUE(this->poll(B::preamble)); + EXPECT_TRUE(this->radio.clears.empty()); +} + +TYPED_TEST(RadioChipTest, HeaderCountsUntilThePayloadDeadlineThenAllIsCleared) { + using B = typename TestFixture::Bits; + EXPECT_TRUE(this->poll(B::preamble | B::header)); + this->at(DEFAULT_PAYLOAD_MS); + EXPECT_TRUE(this->poll(B::preamble | B::header)); + EXPECT_TRUE(this->radio.clears.empty()); + this->at(DEFAULT_PAYLOAD_MS + 1); + EXPECT_FALSE(this->poll(B::preamble | B::header)); + ASSERT_EQ(this->radio.clears.size(), 1u); + EXPECT_EQ(this->radio.clears[0], B::clearAll); +} + +TYPED_TEST(RadioChipTest, PayloadDeadlineStartsAtTheHeaderNotThePreamble) { + using B = typename TestFixture::Bits; + this->poll(B::preamble); + this->at(50); + EXPECT_TRUE(this->poll(B::preamble | B::header)); + this->at(50 + DEFAULT_PAYLOAD_MS); + EXPECT_TRUE(this->poll(B::header)); + this->at(51 + DEFAULT_PAYLOAD_MS); + EXPECT_FALSE(this->poll(B::header)); +} + +TYPED_TEST(RadioChipTest, HeaderAloneOutlivesThePreambleDeadline) { + using B = typename TestFixture::Bits; + EXPECT_TRUE(this->poll(B::header)); + this->at(DEFAULT_PREAMBLE_MS + 500); + EXPECT_TRUE(this->poll(B::header | B::preamble)); + EXPECT_TRUE(this->radio.clears.empty()); +} + +TYPED_TEST(RadioChipTest, HeaderErrorWinsAndClearsEverything) { + using B = typename TestFixture::Bits; + this->poll(B::preamble | B::header); + this->at(10); + EXPECT_FALSE(this->poll(B::preamble | B::header | B::hdrErr)); + ASSERT_EQ(this->radio.clears.size(), 1u); + EXPECT_EQ(this->radio.clears[0], B::clearAll); +} + +TYPED_TEST(RadioChipTest, HeaderErrorWithoutHeaderAlsoClears) { + using B = typename TestFixture::Bits; + EXPECT_FALSE(this->poll(B::hdrErr)); + ASSERT_EQ(this->radio.clears.size(), 1u); + EXPECT_EQ(this->radio.clears[0], B::clearAll); +} + +TYPED_TEST(RadioChipTest, HeaderErrorResetsTheTimers) { + using B = typename TestFixture::Bits; + this->poll(B::preamble); + this->at(60); + EXPECT_FALSE(this->poll(B::hdrErr)); + this->at(61); // a new preamble right after + EXPECT_TRUE(this->poll(B::preamble)); + this->at(61 + DEFAULT_PREAMBLE_MS); + EXPECT_TRUE(this->poll(B::preamble)); +} + +TYPED_TEST(RadioChipTest, HeaderFlagClearedElsewhereEndsTheReception) { + using B = typename TestFixture::Bits; + this->poll(B::header); + this->at(10); + // the header bit is gone (the packet was read), a preamble bit is still up + EXPECT_FALSE(this->poll(B::preamble)); + EXPECT_TRUE(this->radio.clears.empty()); + // that poll reset the state, so the next one starts a preamble timer + this->at(20); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(20 + DEFAULT_PREAMBLE_MS); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(21 + DEFAULT_PREAMBLE_MS); + EXPECT_FALSE(this->poll(B::preamble)); +} + +TYPED_TEST(RadioChipTest, DeadlinesFollowTheSetters) { + using B = typename TestFixture::Bits; + this->radio.setPreambleMillis(20); + this->radio.setMaxPayloadMillis(300); + this->poll(B::preamble); + this->at(20); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(21); + EXPECT_FALSE(this->poll(B::preamble)); + + this->at(100); + this->poll(B::header); + this->at(400); + EXPECT_TRUE(this->poll(B::header)); + this->at(401); + EXPECT_FALSE(this->poll(B::header)); +} + +// A timer value of 0 doubles as "not started". A preamble first seen at +// millis() == 0 therefore starts its timer on the next poll instead. Pinned +// because a rewrite with a separate flag would change it. +TYPED_TEST(RadioChipTest, PreambleSeenAtMillisZeroStartsOnTheNextPoll) { + using B = typename TestFixture::Bits; + g_mock_millis = 0; + EXPECT_TRUE(this->poll(B::preamble)); + g_mock_millis = 50; + EXPECT_TRUE(this->poll(B::preamble)); + g_mock_millis = 50 + DEFAULT_PREAMBLE_MS; // 116 ms after the first sighting + EXPECT_TRUE(this->poll(B::preamble)); + g_mock_millis = 51 + DEFAULT_PREAMBLE_MS; + EXPECT_FALSE(this->poll(B::preamble)); +} + +TYPED_TEST(RadioChipTest, DeadlinesSurviveTheMillisWrap) { + using B = typename TestFixture::Bits; + g_mock_millis = 0xFFFFFFF0u; + this->poll(B::header); + g_mock_millis = 0xFFFFFFF0u + DEFAULT_PAYLOAD_MS; // wraps past zero + EXPECT_TRUE(this->poll(B::header)); + g_mock_millis = 0xFFFFFFF0u + DEFAULT_PAYLOAD_MS + 1; + EXPECT_FALSE(this->poll(B::header)); +} + +TYPED_TEST(RadioChipTest, StartReceiveAsksForThePreambleIrq) { + using B = typename TestFixture::Bits; + this->radio.startReceive(); + ASSERT_EQ(this->radio.rx_calls.size(), 1u); + const auto& c = this->radio.rx_calls[0]; + EXPECT_EQ(c.timeout, B::rxTimeout); + EXPECT_EQ(c.flags, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED)); + EXPECT_EQ(c.mask, RADIOLIB_IRQ_RX_DEFAULT_MASK); + EXPECT_EQ(c.len, 0u); +} + +// std_init() and getRxBoostedGainMode() of the three SX126x classes that have a +// std_init(), built here without any of the board option macros +// (SX126X_USE_REGULATOR_LDO, SX126X_REGISTER_PATCH, SX126X_CURRENT_LIMIT, ...), +// without P_LORA_SCLK and without MESH_DEBUG. test_radio_chips_init builds the +// same classes with all of them set. + +template +struct SX126xInitTest : public ::testing::Test { + Module mod; + T radio{&mod}; + void SetUp() override { Serial.reset(); } +}; +using SX126xChips = ::testing::Types; +TYPED_TEST_SUITE(SX126xInitTest, SX126xChips); + +// LORA_FREQ, LORA_BW, LORA_SF and LORA_TX_POWER come from the environment's +// build flags; coding rate 5, the private sync word (0x12 = 18), preamble 16, +// TCXO 1.6 V and the DC-DC regulator are the defaults std_init() supplies. +const std::string BEGIN_DEFAULT = "begin(869.525,250,11,5,18,22,16,1.6,0)"; +const std::string BEGIN_TCXO_OFF = "begin(869.525,250,11,5,18,22,16,0,0)"; + +TYPED_TEST(SX126xInitTest, StdInitWithoutOptions) { + EXPECT_TRUE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT, "setCRC(1)"}; + EXPECT_EQ(this->radio.calls, want); + EXPECT_EQ(Serial.out, ""); +} + +TYPED_TEST(SX126xInitTest, StdInitWithSpiButNoPinsLeavesSpiAlone) { + SPIClass spi; + EXPECT_TRUE(this->radio.std_init(&spi)); + EXPECT_TRUE(spi.calls.empty()); +} + +TYPED_TEST(SX126xInitTest, SpiCmdFailedRetriesWithTcxoOff) { + this->radio.begin_results = {RADIOLIB_ERR_SPI_CMD_FAILED}; + EXPECT_TRUE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT, BEGIN_TCXO_OFF, "setCRC(1)"}; + EXPECT_EQ(this->radio.calls, want); + EXPECT_EQ(Serial.out, ""); +} + +TYPED_TEST(SX126xInitTest, SpiCmdInvalidRetriesWithTcxoOff) { + this->radio.begin_results = {RADIOLIB_ERR_SPI_CMD_INVALID}; + EXPECT_TRUE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT, BEGIN_TCXO_OFF, "setCRC(1)"}; + EXPECT_EQ(this->radio.calls, want); +} + +TYPED_TEST(SX126xInitTest, FailedRetryReportsTheSecondError) { + this->radio.begin_results = {RADIOLIB_ERR_SPI_CMD_FAILED, RADIOLIB_ERR_SPI_CMD_INVALID}; + EXPECT_FALSE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT, BEGIN_TCXO_OFF}; + EXPECT_EQ(this->radio.calls, want); + EXPECT_EQ(Serial.out, "ERROR: radio init failed: -706\n"); +} + +TYPED_TEST(SX126xInitTest, OtherErrorIsNotRetried) { + this->radio.begin_results = {-2}; + EXPECT_FALSE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT}; + EXPECT_EQ(this->radio.calls, want); + EXPECT_EQ(Serial.out, "ERROR: radio init failed: -2\n"); +} + +TYPED_TEST(SX126xInitTest, RxBoostedGainModeReadsTheGainRegister) { + this->radio.regs[RADIOLIB_SX126X_REG_RX_GAIN] = RADIOLIB_SX126X_RX_GAIN_BOOSTED; + EXPECT_TRUE(this->radio.getRxBoostedGainMode()); + this->radio.regs[RADIOLIB_SX126X_REG_RX_GAIN] = 0x94; // power saving gain + EXPECT_FALSE(this->radio.getRxBoostedGainMode()); + std::vector want = {"readRegister(2220,1)", "readRegister(2220,1)"}; + EXPECT_EQ(this->radio.calls, want); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_radio_chips_init/test_radio_chips_init.cpp b/test/test_radio_chips_init/test_radio_chips_init.cpp new file mode 100644 index 00000000000..1002a6536d7 --- /dev/null +++ b/test/test_radio_chips_init/test_radio_chips_init.cpp @@ -0,0 +1,122 @@ +// The board option macros std_init() of the SX126x classes reads, all set, and +// MESH_DEBUG on. This is its own test program (not part of test_radio_chips), +// because the chip classes are header only: the two builds must not be linked +// into one binary with different macro values. +#define ARDUINO 1 +#define MESH_DEBUG 1 +#define SX126X_USE_REGULATOR_LDO true +#define SX126X_REGISTER_PATCH 1 +#define SX126X_DIO3_TCXO_VOLTAGE 1.8f +#define LORA_CR 8 +#define SX126X_CURRENT_LIMIT 140 +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_RX_BOOSTED_GAIN 1 +#define SX126X_RXEN 7 +#define SX126X_TXEN 8 +#define P_LORA_SCLK 5 +#define P_LORA_MISO 3 +#define P_LORA_MOSI 6 + +#include + +#include +#include +#include + +// What std_init() does with every option set. All three chips share the +// configuration path; SX1262 alone passes the LDO option to begin(), applies the +// register patch for 0x8B5 and prints its three debug lines. SX1268 and LLCC68 +// ignore those two macros and print nothing. These differences are pinned per +// chip, so a refactor that merges the classes has to keep them. + +namespace { + +template +struct InitTest : public ::testing::Test { + Module mod; + T radio{&mod}; + SPIClass spi; + void SetUp() override { + Serial.reset(); + radio.regs[0x8B5] = 0x40; + } +}; + +class SX1262Init : public InitTest { }; +class SX1268Init : public InitTest { }; +class LLCC68Init : public InitTest { }; + +// The calls between begin() and the register patch, the same for all three. +const std::vector CONFIG = { + "setCRC(1)", "setCurrentLimit(140)", "setDio2AsRfSwitch(1)", + "setRxBoostedGainMode(1)", "setRfSwitchPins(7,8)", +}; +const std::vector SPI_BEGIN = {"begin(5,3,6)"}; + +std::vector join(std::vector a, const std::vector& b) { + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +TEST_F(SX1262Init, AllOptions) { + EXPECT_TRUE(radio.std_init(&spi)); + EXPECT_EQ(spi.calls, SPI_BEGIN); + auto want = join({"begin(869.525,250,11,8,18,22,16,1.8,1)"}, CONFIG); + want = join(want, {"readRegister(2229,1)", "writeRegister(2229,1):65"}); + EXPECT_EQ(radio.calls, want); + EXPECT_EQ(radio.regs[0x8B5], 0x41); + EXPECT_EQ(Serial.out, + "DEBUG: SX1262 regulator requested: LDO\n" + "DEBUG: SX1262 status=0xA5 device_errors=0x0120\n"); +} + +TEST_F(SX1262Init, RetryKeepsLdoAndPrintsTheError) { + radio.begin_results = {RADIOLIB_ERR_SPI_CMD_FAILED}; + EXPECT_TRUE(radio.std_init(&spi)); + auto want = join({"begin(869.525,250,11,8,18,22,16,1.8,1)", "begin(869.525,250,11,8,18,22,16,0,1)"}, CONFIG); + want = join(want, {"readRegister(2229,1)", "writeRegister(2229,1):65"}); + EXPECT_EQ(radio.calls, want); + EXPECT_EQ(Serial.out, + "DEBUG: SX1262 regulator requested: LDO\n" + "DEBUG: SX1262 init failed with error -707, retrying with TCXO at 0.0V\n" + "DEBUG: SX1262 status=0xA5 device_errors=0x0120\n"); +} + +TEST_F(SX1262Init, FailureSkipsPatchAndStatusLine) { + radio.begin_results = {-2}; + EXPECT_FALSE(radio.std_init(&spi)); + std::vector want = {"begin(869.525,250,11,8,18,22,16,1.8,1)"}; + EXPECT_EQ(radio.calls, want); + EXPECT_EQ(Serial.out, + "DEBUG: SX1262 regulator requested: LDO\n" + "ERROR: radio init failed: -2\n"); +} + +// SX1268 and LLCC68: begin() without the LDO argument (RadioLib's default false), +// no register patch, no debug lines. +template void expectWithoutSX1262Extras(F& t) { + EXPECT_TRUE(t.radio.std_init(&t.spi)); + EXPECT_EQ(t.spi.calls, SPI_BEGIN); + EXPECT_EQ(t.radio.calls, join({"begin(869.525,250,11,8,18,22,16,1.8,0)"}, CONFIG)); + EXPECT_EQ(t.radio.regs[0x8B5], 0x40); + EXPECT_EQ(Serial.out, ""); +} + +template void expectRetryWithoutSX1262Extras(F& t) { + t.radio.begin_results = {RADIOLIB_ERR_SPI_CMD_FAILED}; + EXPECT_TRUE(t.radio.std_init(&t.spi)); + EXPECT_EQ(t.radio.calls, join({"begin(869.525,250,11,8,18,22,16,1.8,0)", "begin(869.525,250,11,8,18,22,16,0,0)"}, CONFIG)); + EXPECT_EQ(Serial.out, ""); +} + +TEST_F(SX1268Init, AllOptions) { expectWithoutSX1262Extras(*this); } +TEST_F(SX1268Init, RetryIsSilent) { expectRetryWithoutSX1262Extras(*this); } +TEST_F(LLCC68Init, AllOptions) { expectWithoutSX1262Extras(*this); } +TEST_F(LLCC68Init, RetryIsSilent) { expectRetryWithoutSX1262Extras(*this); } + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_radio_wrappers/test_radio_wrappers.cpp b/test/test_radio_wrappers/test_radio_wrappers.cpp new file mode 100644 index 00000000000..062a7db19cc --- /dev/null +++ b/test/test_radio_wrappers/test_radio_wrappers.cpp @@ -0,0 +1,148 @@ +#include + +#include + +// The packet score drives the flood rebroadcast delay (Dispatcher::calcRxDelay). +// It depends on the spreading factor, because the SNR a packet needs to be +// decodable rises as the spreading factor falls. These tests pin that the score +// a wrapper reports is the score for the spreading factor the radio reports, +// whether or not the wrapper overrides packetScore itself. + +namespace { + +class StubBoard : public mesh::MainBoard { +public: + uint16_t getBattMilliVolts() override { return 4200; } + const char* getManufacturerName() const override { return "stub"; } + void reboot() override { } + uint8_t getStartupReason() const override { return 0; } +}; + +// Stands in for the wrappers that report the spreading factor but leave +// packetScore to the base class (LR1110, LR1121, LR2021 do exactly this). +class ReportingWrapper : public RadioLibWrapper { +public: + uint8_t sf = 10; + + ReportingWrapper(PhysicalLayer& r, mesh::MainBoard& b) : RadioLibWrapper(r, b) { } + + void setParams(float, float, uint8_t, uint8_t) override { } + bool isReceivingPacket() override { return false; } + float getCurrentRSSI() override { return -100.0f; } + + uint8_t getSpreadingFactor() const override { return sf; } + + // packetScoreInt is protected; expose it so a test can state the expected + // value in terms of the formula under test rather than a copy of it. + float scoreAt(float snr, int sf_, int len) { return packetScoreInt(snr, sf_, len); } +}; + +// Stands in for a wrapper that reports nothing, so the base class falls back +// to the compile-time LORA_SF. +class SilentWrapper : public RadioLibWrapper { +public: + SilentWrapper(PhysicalLayer& r, mesh::MainBoard& b) : RadioLibWrapper(r, b) { } + void setParams(float, float, uint8_t, uint8_t) override { } + bool isReceivingPacket() override { return false; } + float getCurrentRSSI() override { return -100.0f; } +}; + +struct RadioWrapperFixture : public ::testing::Test { + PhysicalLayer radio; + StubBoard board; +}; + +TEST_F(RadioWrapperFixture, ScoreFollowsTheReportedSpreadingFactor) { + ReportingWrapper w(radio, board); + for (uint8_t sf = 7; sf <= 12; sf++) { + w.sf = sf; + for (float snr : {0.0f, -5.0f, -10.0f, -15.0f, -18.0f}) { + EXPECT_FLOAT_EQ(w.packetScore(snr, 64), w.scoreAt(snr, sf, 64)) + << "sf=" << (int)sf << " snr=" << snr; + } + } +} + +// The old base class assumed sf=10 for every radio that did not override +// packetScore. These are the cases where that assumption is wrong: at sf=7 it +// scored too high (delay too short), at sf=12 too low (delay too long). +TEST_F(RadioWrapperFixture, DoesNotAssumeSpreadingFactorTen) { + ReportingWrapper w(radio, board); + + w.sf = 7; + EXPECT_NE(w.packetScore(-5.0f, 64), w.scoreAt(-5.0f, 10, 64)); + EXPECT_LT(w.packetScore(-5.0f, 64), w.scoreAt(-5.0f, 10, 64)); + + w.sf = 12; + EXPECT_NE(w.packetScore(-10.0f, 64), w.scoreAt(-10.0f, 10, 64)); + EXPECT_GT(w.packetScore(-10.0f, 64), w.scoreAt(-10.0f, 10, 64)); +} + +// At sf=10 old and new agree, which is the probe on the arithmetic above. +TEST_F(RadioWrapperFixture, AgreesWithTheOldAssumptionAtSpreadingFactorTen) { + ReportingWrapper w(radio, board); + w.sf = 10; + for (float snr : {0.0f, -5.0f, -10.0f, -15.0f, -18.0f}) { + EXPECT_FLOAT_EQ(w.packetScore(snr, 64), w.scoreAt(snr, 10, 64)) << "snr=" << snr; + } +} + +// A wrapper that reports no spreading factor gets the build's LORA_SF, not a +// hardcoded 10. The env builds with LORA_SF=9 so the two differ. +TEST_F(RadioWrapperFixture, FallsBackToTheBuildsSpreadingFactor) { + SilentWrapper silent(radio, board); + ReportingWrapper probe(radio, board); + EXPECT_EQ(silent.getSpreadingFactor(), LORA_SF); + EXPECT_FLOAT_EQ(silent.packetScore(-5.0f, 64), probe.scoreAt(-5.0f, LORA_SF, 64)); +} + +// The score also carries the length penalty, so a longer packet scores lower. +TEST_F(RadioWrapperFixture, LongerPacketsScoreLower) { + ReportingWrapper w(radio, board); + w.sf = 8; + EXPECT_GT(w.packetScore(0.0f, 16), w.packetScore(0.0f, 240)); +} + +// Below the SNR threshold for the reported spreading factor there is no chance +// of success, so the score is zero. +TEST_F(RadioWrapperFixture, BelowThresholdScoresZero) { + ReportingWrapper w(radio, board); + w.sf = 7; + EXPECT_FLOAT_EQ(w.packetScore(-8.0f, 64), 0.0f); + w.sf = 12; + EXPECT_FLOAT_EQ(w.packetScore(-21.0f, 64), 0.0f); +} + +// Absolute values, worked out by hand from the formula, so a shifted SNR +// threshold, a dropped length penalty or a dropped clamp cannot hide behind a +// comparison of the score against itself. +// score = clamp((snr - threshold[sf]) / 10 * (1 - len / 256), 0, 1) +TEST_F(RadioWrapperFixture, ScoreMatchesTheFormulaByHand) { + ReportingWrapper w(radio, board); + + // sf=8 threshold -10 dB: (-5 + 10) / 10 * (1 - 64/256) = 0.5 * 0.75 + w.sf = 8; + EXPECT_FLOAT_EQ(w.packetScore(-5.0f, 64), 0.375f); + + // sf=7 threshold -7.5 dB: (0 + 7.5) / 10 * (1 - 128/256) = 0.75 * 0.5 + w.sf = 7; + EXPECT_FLOAT_EQ(w.packetScore(0.0f, 128), 0.375f); + + // sf=12 threshold -20 dB: (0 + 20) / 10 * 0.75 = 1.5, clamped to 1.0 + w.sf = 12; + EXPECT_FLOAT_EQ(w.packetScore(0.0f, 64), 1.0f); + + // exactly at the threshold the score is zero, and half a dB above it the + // formula already applies: (-14.5 + 15) / 10 * 0.75. Pinning a value this + // close to the threshold is what keeps the threshold itself under test. + w.sf = 10; + EXPECT_FLOAT_EQ(w.packetScore(-15.0f, 64), 0.0f); + EXPECT_FLOAT_EQ(w.packetScore(-14.5f, 64), 0.0375f); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_region_map/stubs/Arduino.h b/test/test_region_map/stubs/Arduino.h new file mode 100644 index 00000000000..147b4b8e301 --- /dev/null +++ b/test/test_region_map/stubs/Arduino.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "Stream.h" + +// TxtDataHelpers.cpp is built for StrHelper::strncpy() and also holds code that +// calls Arduino's ltoa(), which is not in the C library. Decimal is all it needs here. +inline char* ltoa(long value, char* buf, int) { + snprintf(buf, 21, "%ld", value); + return buf; +} diff --git a/test/test_region_map/stubs/FS.h b/test/test_region_map/stubs/FS.h new file mode 100644 index 00000000000..705f65915b2 --- /dev/null +++ b/test/test_region_map/stubs/FS.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +// RegionMap::load() and save() name the file system types, so they have to exist for +// the file to compile. The test never loads or saves, hence a file system with +// nothing in it. +namespace fs { + +class File { +public: + explicit operator bool() const { return false; } + int read(uint8_t*, size_t) { return 0; } + size_t write(const uint8_t*, size_t) { return 0; } + void close() {} +}; + +class FS { +public: + bool exists(const char*) { return false; } + File open(const char*, const char* = "r", bool = false) { return File(); } + bool remove(const char*) { return false; } + bool mkdir(const char*) { return false; } +}; + +} // namespace fs + +using fs::File; diff --git a/test/test_region_map/stubs/SHA256.h b/test/test_region_map/stubs/SHA256.h new file mode 100644 index 00000000000..c43de75f63f --- /dev/null +++ b/test/test_region_map/stubs/SHA256.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +// A deterministic stand-in for the SHA256 class RegionMap and TransportKeyStore use. +// It is not cryptographic. It only has to give a different key per region name and a +// different transport code per key, so a test can tell one region from another. The +// shared mock in test/mocks leaves finalizeHMAC() empty, which would hand +// calcTransportCode() an uninitialised code. +class SHA256 { + uint8_t _state[32]; + size_t _len; + + void mix(const uint8_t* bytes, size_t len) { + for (size_t i = 0; i < len; i++) { + uint8_t b = bytes[i]; + _state[_len % 32] ^= b; + _state[(_len + 1) % 32] += (uint8_t)((b >> 1) | (b << 7)); + _len++; + } + } + +public: + SHA256() : _len(0) { memset(_state, 0, sizeof(_state)); } + + void update(const void* data, size_t len) { mix((const uint8_t*)data, len); } + + void finalize(void* hash, size_t hashLen) { + for (size_t i = 0; i < hashLen; i++) ((uint8_t*)hash)[i] = _state[i % 32]; + } + + void resetHMAC(const void* key, size_t keyLen) { + memset(_state, 0, sizeof(_state)); + _len = 0; + mix((const uint8_t*)key, keyLen); + } + + void finalizeHMAC(const void* key, size_t keyLen, void* hash, size_t hashLen) { + mix((const uint8_t*)key, keyLen); + finalize(hash, hashLen); + } +}; diff --git a/test/test_region_map/stubs/Stream.h b/test/test_region_map/stubs/Stream.h new file mode 100644 index 00000000000..5908787e10d --- /dev/null +++ b/test/test_region_map/stubs/Stream.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Just enough of Arduino's Print and Stream for RegionMap's exporters to compile. +// The test never exports, so nothing here keeps what is written. +class Print { +public: + virtual ~Print() = default; + virtual size_t write(uint8_t) { return 1; } + virtual size_t write(const uint8_t* buffer, size_t size) { + size_t n = 0; + for (size_t i = 0; i < size; i++) n += write(buffer[i]); + return n; + } + size_t print(char c) { return write((uint8_t)c); } + size_t print(const char* s) { return write((const uint8_t*)s, strlen(s)); } + size_t println(const char* s) { return print(s) + print('\n'); } + size_t printf(const char* fmt, ...) { + char buf[96]; + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + return n > 0 ? write((const uint8_t*)buf, (size_t)n) : 0; + } + virtual void flush() {} +}; + +class Stream : public Print { +public: + virtual int available() { return 0; } + virtual int read() { return -1; } + virtual int peek() { return -1; } +}; diff --git a/test/test_region_map/test_region_map.cpp b/test/test_region_map/test_region_map.cpp new file mode 100644 index 00000000000..f97746417b8 --- /dev/null +++ b/test/test_region_map/test_region_map.cpp @@ -0,0 +1,123 @@ +#include +#include + +// RegionMap::findMatch() decides which Region a scoped flood arrives on. The servers +// call it with REGION_DENY_FLOOD and forward the packet only when it returns a Region. +// An un-scoped flood is filtered by the roles themselves, on the wildcard's own flag. +// A scoped flood that carries transport code 0 has no scope at all and is meant to be +// handled like an un-scoped one, so the wildcard's flag has to apply to it as well. + +namespace { + +mesh::Packet scopedFlood(uint16_t code) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = ROUTE_TYPE_TRANSPORT_FLOOD; + pkt.transport_codes[0] = code; + return pkt; +} + +class FindMatchTest : public ::testing::Test { +protected: + TransportKeyStore store; + RegionMap regions{store}; + + RegionEntry& wildcard() { return regions.getWildcard(); } + + // A Region that allows flood, together with the code a sender scoping to it uses. + RegionEntry* allowedRegion(const char* name, mesh::Packet& pkt) { + RegionEntry* region = regions.putRegion(name, 0); + region->flags &= ~REGION_DENY_FLOOD; // putRegion() denies flood by default + + TransportKey keys[4]; + EXPECT_GT(regions.getTransportKeysFor(*region, keys, 4), 0); + pkt.transport_codes[0] = keys[0].calcTransportCode(&pkt); + return region; + } +}; + +} // namespace + +TEST_F(FindMatchTest, CodeZeroArrivesOnTheWildcardWhenItAllowsFlood) { + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), &wildcard()); +} + +TEST_F(FindMatchTest, CodeZeroHasNoRegionWhenTheWildcardDeniesFlood) { + wildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), nullptr); +} + +TEST_F(FindMatchTest, CodeZeroIsOnlyHeldBackByTheFlagTheMaskNames) { + // The wildcard denies flood, but the caller asks about a different flag, and + // asking about none at all matches everything. So the shortcut has to apply the + // mask it is given, not a fixed flag. + wildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_DIRECT), &wildcard()); + EXPECT_EQ(regions.findMatch(&pkt, 0), &wildcard()); + + wildcard().flags = REGION_DENY_DIRECT; + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_DIRECT), nullptr); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), &wildcard()); +} + +TEST_F(FindMatchTest, AnUnknownCodeHasNoRegionEvenWhenTheWildcardAllowsFlood) { + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0x1234); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), nullptr); +} + +TEST_F(FindMatchTest, ACodeThatBelongsToNoRegionHasNoRegionWhileRegionsExist) { + // With a Region in the table the lookup really compares codes, so a code that is + // not that Region's has to come back empty. + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + allowedRegion("#probe", pkt); + pkt.transport_codes[0]++; // the Region's code is never 0xFFFF, so this stays non-zero + + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), nullptr); +} + +TEST_F(FindMatchTest, TheCodeOfAnAllowedRegionFindsThatRegion) { + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + RegionEntry* region = allowedRegion("#probe", pkt); + ASSERT_NE(pkt.transport_codes[0], 0); + + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), region); +} + +TEST_F(FindMatchTest, ARegionThatDeniesFloodIsSkippedForItsOwnCode) { + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + RegionEntry* region = allowedRegion("#probe", pkt); + region->flags |= REGION_DENY_FLOOD; + + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), nullptr); +} + +TEST_F(FindMatchTest, ADeniedWildcardDoesNotHoldBackAScopeThatIsAllowed) { + // Only a packet with no scope falls back on the wildcard. A packet scoped to a + // Region that allows flood is that Region's business, whatever the wildcard says. + wildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + RegionEntry* region = allowedRegion("#probe", pkt); + + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), region); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_repeater_role/identity_stubs.cpp b/test/test_repeater_role/identity_stubs.cpp new file mode 100644 index 00000000000..83a2af17e4a --- /dev/null +++ b/test/test_repeater_role/identity_stubs.cpp @@ -0,0 +1,98 @@ +// Link-time stand-ins for the parts of mesh::Identity that the core and the server +// helpers reference. The real implementations need the board crypto libraries, +// which do not exist on the host. +// +// These are deterministic, NOT cryptographic. The roles sign every advertisement +// they schedule, so a stub that refused to run would turn ordinary operation into a +// test failure; instead each operation derives its output from its inputs, so a +// round trip is self consistent and a test stays repeatable. No test in this suite +// asserts a cryptographic property, and none may: a passing signature here says +// nothing about Ed25519. + +#include +#include + +#include + +namespace { + +// A stable byte mix, so output depends on every input byte. +void derive(uint8_t* dest, size_t dest_len, const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + uint8_t acc = 0x5A; + for (size_t i = 0; i < a_len; i++) acc = (uint8_t)(acc * 31 + a[i]); + for (size_t i = 0; i < b_len; i++) acc = (uint8_t)(acc * 31 + b[i]); + for (size_t i = 0; i < dest_len; i++) { + acc = (uint8_t)(acc * 31 + (uint8_t)i); + dest[i] = acc; + } +} + +} // namespace + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + derive(sig, SIGNATURE_SIZE, prv_key, PRV_KEY_SIZE, message, (size_t)msg_len); +} + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + // Cannot be checked without the private key, so accept anything. A test that + // needs to tell a good signature from a bad one must not use this stub. + return true; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + derive(secret, PUB_KEY_SIZE, prv_key, PRV_KEY_SIZE, other_pub_key, PUB_KEY_SIZE); +} + +// The identity file and the identity CLI commands are not exercised here, so these +// only have to satisfy the linker, and any call is a test failure. + +bool Identity::readFrom(Stream& s) { + ADD_FAILURE() << "Identity::readFrom must not be called by the repeater role tests"; + return false; +} + +bool Identity::writeTo(Stream& s) const { + ADD_FAILURE() << "Identity::writeTo must not be called by the repeater role tests"; + return false; +} + +void Identity::printTo(Stream& s) const { + ADD_FAILURE() << "Identity::printTo must not be called by the repeater role tests"; +} + +bool LocalIdentity::readFrom(Stream& s) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the repeater role tests"; + return false; +} + +bool LocalIdentity::writeTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the repeater role tests"; + return false; +} + +void LocalIdentity::printTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::printTo must not be called by the repeater role tests"; +} + +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the repeater role tests"; + return 0; +} + +void LocalIdentity::readFrom(const uint8_t* src, size_t len) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the repeater role tests"; +} + +bool LocalIdentity::validatePrivateKey(const uint8_t prv[64]) { + ADD_FAILURE() << "LocalIdentity::validatePrivateKey must not be called by the repeater role tests"; + return false; +} + +} // namespace mesh diff --git a/test/test_repeater_role/test_boot_sequence.cpp b/test/test_repeater_role/test_boot_sequence.cpp new file mode 100644 index 00000000000..5c6fd18d839 --- /dev/null +++ b/test/test_repeater_role/test_boot_sequence.cpp @@ -0,0 +1,19 @@ +// The boot sequence of the repeater, the role whose begin() differs from the other +// two: it starts the bridge when one is built in, and logs the RX gain it applied. +// The expectations live in test/mocks/BootSequenceChecks.h, shared with the room +// server and the sensor; main() is in test_repeater_role.cpp. + +#include "MyMesh.h" + +using BootRole = MyMesh; + +// Of the three roles only the repeater logs the gain mode it applied (debug builds). +#define BOOT_ROLE_LOGS_RX_GAIN + +// The repeater forwards out of the box and checks the Region and loops on top of the +// two checks all roles share. +#define FORWARD_ROLE_FORWARDS_BY_DEFAULT +#define FORWARD_ROLE_CHECKS_REGION_AND_LOOPS + +#include +#include diff --git a/test/test_repeater_role/test_region_forwarding.cpp b/test/test_repeater_role/test_region_forwarding.cpp new file mode 100644 index 00000000000..38b88f11866 --- /dev/null +++ b/test/test_repeater_role/test_region_forwarding.cpp @@ -0,0 +1,100 @@ +// Whether the repeater forwards a flood depends on the Region the packet resolves to: +// allowPacketForward() drops a flood that arrived on no Region. These tests let the +// real resolution in onRecvPacket() run and then ask the real gate, so they cover the +// repeater's end of ledger issue 10 (a scoped flood with transport code 0 and the +// wildcard's flood denial), which the room server suite in test_server_base cannot, +// because only the repeater turns a missing Region into a dropped packet. + +#include + +#include +#include + +#include +#include +#include +#include + +#include "MyMesh.h" + +namespace { + +constexpr uint32_t START_MILLIS = 1000; + +class ForwardProbe : public MyMesh { +public: + using MyMesh::MyMesh; + + RegionMap& regions() { return region_map; } + RegionEntry* seenRegion() const { return recv_pkt_region; } + mesh::DispatcherAction recv(mesh::Packet* pkt) { return onRecvPacket(pkt); } + bool forwards(const mesh::Packet* pkt) { return allowPacketForward(pkt); } +}; + +class RepeaterRegionForwardingTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr node; + + void SetUp() override { + g_mock_millis = START_MILLIS; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + node.reset(new ForwardProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + node->begin(&fs); + } + + // A flood with an empty path and transport code 0: nothing but the route type for + // the Region lookup to read, and nothing for the hop limits or the loop detection + // to object to. + static mesh::Packet floodOfRouteType(uint8_t route_type) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = route_type; + return pkt; + } + + // What the repeater does with the packet: resolve its Region, then decide. + bool forwardsAfterReceiving(mesh::Packet& pkt) { + node->recv(&pkt); + return node->forwards(&pkt); + } +}; + +TEST_F(RepeaterRegionForwardingTest, ForwardsAScopedFloodWithCodeZeroWhileTheWildcardAllowsFlood) { + // The control case: with flood allowed on the wildcard nothing else in the gate + // stops this packet, so the two cases below fail for the Region alone. + node->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = floodOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + EXPECT_TRUE(forwardsAfterReceiving(pkt)); + EXPECT_NE(node->seenRegion(), nullptr); +} + +TEST_F(RepeaterRegionForwardingTest, DropsAnUnscopedFloodWhenTheWildcardDeniesFlood) { + node->regions().getWildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = floodOfRouteType(ROUTE_TYPE_FLOOD); + EXPECT_FALSE(forwardsAfterReceiving(pkt)); + EXPECT_EQ(node->seenRegion(), nullptr); +} + +TEST_F(RepeaterRegionForwardingTest, DropsAScopedFloodWithCodeZeroWhenTheWildcardDeniesFlood) { + // `region denyf *` is documented to drop packets without a Region transport code. + // A scoped flood whose code is 0 carries none, so it must be dropped like the + // un-scoped flood above. + node->regions().getWildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = floodOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + EXPECT_FALSE(forwardsAfterReceiving(pkt)); + EXPECT_EQ(node->seenRegion(), nullptr); +} + +} // namespace diff --git a/test/test_repeater_role/test_repeater_role.cpp b/test/test_repeater_role/test_repeater_role.cpp new file mode 100644 index 00000000000..f2de1b47e7b --- /dev/null +++ b/test/test_repeater_role/test_repeater_role.cpp @@ -0,0 +1,12 @@ +// The repeater on the host. The repeater and the room server both define `MyMesh`, +// so each needs a binary of its own, and this is the repeater's. It exists so the +// boot sequence of all three server roles can be pinned (see +// test/mocks/BootSequenceChecks.h and docs/refactoring/0007-boot-sequence.md); the +// repeater's other behaviour is not characterized here. + +#include + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_sensor_role/identity_stubs.cpp b/test/test_sensor_role/identity_stubs.cpp new file mode 100644 index 00000000000..8190ab4e9a6 --- /dev/null +++ b/test/test_sensor_role/identity_stubs.cpp @@ -0,0 +1,98 @@ +// Link-time stand-ins for the parts of mesh::Identity that the core and the server +// helpers reference. The real implementations need the board crypto libraries, +// which do not exist on the host. +// +// These are deterministic, NOT cryptographic. The roles sign every advertisement +// they schedule, so a stub that refused to run would turn ordinary operation into a +// test failure; instead each operation derives its output from its inputs, so a +// round trip is self consistent and a test stays repeatable. No test in this suite +// asserts a cryptographic property, and none may: a passing signature here says +// nothing about Ed25519. + +#include +#include + +#include + +namespace { + +// A stable byte mix, so output depends on every input byte. +void derive(uint8_t* dest, size_t dest_len, const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + uint8_t acc = 0x5A; + for (size_t i = 0; i < a_len; i++) acc = (uint8_t)(acc * 31 + a[i]); + for (size_t i = 0; i < b_len; i++) acc = (uint8_t)(acc * 31 + b[i]); + for (size_t i = 0; i < dest_len; i++) { + acc = (uint8_t)(acc * 31 + (uint8_t)i); + dest[i] = acc; + } +} + +} // namespace + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + derive(sig, SIGNATURE_SIZE, prv_key, PRV_KEY_SIZE, message, (size_t)msg_len); +} + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + // Cannot be checked without the private key, so accept anything. A test that + // needs to tell a good signature from a bad one must not use this stub. + return true; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + derive(secret, PUB_KEY_SIZE, prv_key, PRV_KEY_SIZE, other_pub_key, PUB_KEY_SIZE); +} + +// The identity file and the identity CLI commands are not exercised here, so these +// only have to satisfy the linker, and any call is a test failure. + +bool Identity::readFrom(Stream& s) { + ADD_FAILURE() << "Identity::readFrom must not be called by the sensor role tests"; + return false; +} + +bool Identity::writeTo(Stream& s) const { + ADD_FAILURE() << "Identity::writeTo must not be called by the sensor role tests"; + return false; +} + +void Identity::printTo(Stream& s) const { + ADD_FAILURE() << "Identity::printTo must not be called by the sensor role tests"; +} + +bool LocalIdentity::readFrom(Stream& s) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the sensor role tests"; + return false; +} + +bool LocalIdentity::writeTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the sensor role tests"; + return false; +} + +void LocalIdentity::printTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::printTo must not be called by the sensor role tests"; +} + +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the sensor role tests"; + return 0; +} + +void LocalIdentity::readFrom(const uint8_t* src, size_t len) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the sensor role tests"; +} + +bool LocalIdentity::validatePrivateKey(const uint8_t prv[64]) { + ADD_FAILURE() << "LocalIdentity::validatePrivateKey must not be called by the sensor role tests"; + return false; +} + +} // namespace mesh diff --git a/test/test_sensor_role/test_boot_sequence.cpp b/test/test_sensor_role/test_boot_sequence.cpp new file mode 100644 index 00000000000..9c69fab33b4 --- /dev/null +++ b/test/test_sensor_role/test_boot_sequence.cpp @@ -0,0 +1,29 @@ +// The boot sequence of the sensor, which step 4f moves into the shared base. The +// expectations live in test/mocks/BootSequenceChecks.h, shared with the repeater and +// the room server; main() is in test_sensor_role.cpp. + +#include "SensorMesh.h" + +// SensorMesh is abstract on purpose: the application supplies what to read and how +// to answer a series query. This is the smallest concrete role, and it adds nothing +// to the boot sequence under test. +class BootSensor : public SensorMesh { +public: + using SensorMesh::SensorMesh; + +protected: + void onSensorDataRead() override { } + int querySeriesData(uint32_t start_secs_ago, uint32_t end_secs_ago, + MinMaxAvg dest[], int max_num) override { + return 0; + } +}; + +using BootRole = BootSensor; + +// Before prefs version 1 the sensor ignored the two hop limits and stored a zero +// for both, so a zero in an unversioned file was never chosen. +#define BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS + +#include +#include diff --git a/test/test_sensor_role/test_sensor_role.cpp b/test/test_sensor_role/test_sensor_role.cpp new file mode 100644 index 00000000000..e3277412c13 --- /dev/null +++ b/test/test_sensor_role/test_sensor_role.cpp @@ -0,0 +1,271 @@ +// Characterization tests for the sensor role, which until now had no host +// environment of its own: the repeater and the room server both define MyMesh, so +// each role needs a separate binary. +// +// The sensor is the role that drifted away from the other two (see the ledger, +// finding F2). These tests record what it does today, before the alignment changes +// any of it, so the change can be shown to land exactly where it was meant to. + +#include + +#include +#include + +#include +#include +#include +#include + +#include "SensorMesh.h" + +namespace { + +constexpr uint32_t T0 = 1000; + +// SensorMesh is abstract on purpose: the application supplies what to read and how +// to answer a series query. This is the smallest concrete role that satisfies that, +// mirroring the shape of examples/simple_sensor/main.cpp without its battery logic, +// so the tests exercise the role and not an application's choices. +class TestSensor : public SensorMesh { +public: + TestSensor(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, + mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables) + : SensorMesh(board, radio, ms, rng, rtc, tables) { } + + int reads = 0; + + // The role's own decisions, opened up so a test can read them directly. Nothing + // else in the firmware reports any of these. + uint8_t extraAcks() const { return getExtraAckTransmitCount(); } + bool forwards(const mesh::Packet* pkt) { return allowPacketForward(pkt); } + uint32_t floodRetransmitDelay(const mesh::Packet* pkt) { return getRetransmitDelay(pkt); } + uint32_t airtimeFor(int len) { return _radio->getEstAirtimeFor(len); } + NodePrefs* prefs() { return getNodePrefs(); } + bool upgradeFrom(uint8_t version) { return upgradePrefs(version); } + +protected: + void onSensorDataRead() override { reads++; } + + int querySeriesData(uint32_t start_secs_ago, uint32_t end_secs_ago, + MinMaxAvg dest[], int max_num) override { + return 0; + } +}; + +class SensorRoleTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + LittleFS.reset(); + rng.begin(1); + + mesh.reset(new TestSensor(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + } +}; + +TEST_F(SensorRoleTest, TheSensorStartsUp) { + // The harness itself, so a failure here is the environment and not the behaviour. + EXPECT_NE(mesh->getNodePrefs(), nullptr); +} + + +// A flood packet carrying a chosen number of path hashes, which is what the hop +// limits are tested against. +static mesh::Packet floodPacketWithHops(uint8_t route_type, uint8_t hops) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = route_type; + pkt.path_len = hops; // one byte per hash at the default path hash size + return pkt; +} + +TEST_F(SensorRoleTest, MultiAcksTakesEffect) { + // Was: the role reported no count whatever the setting said, so the branch in + // SensorMesh::sendAckTo() was dead and 'set multi.acks on' did nothing. The + // override is gone, so the core reports the stored setting as it does for the + // other two roles. + mesh->prefs()->multi_acks = 1; + EXPECT_EQ(mesh->extraAcks(), 1); + + mesh->prefs()->multi_acks = 0; + EXPECT_EQ(mesh->extraAcks(), 0) << "and off still means off"; +} + +TEST_F(SensorRoleTest, TheUnscopedHopLimitIsApplied) { + // Was: the sensor looked at flood_max only, so it forwarded this while the other + // two roles stopped it. + mesh->prefs()->disable_fwd = 0; + mesh->prefs()->flood_max = 5; + mesh->prefs()->flood_max_unscoped = 1; + mesh->prefs()->flood_max_advert = 8; + + mesh::Packet over = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + EXPECT_FALSE(mesh->forwards(&over)) << "two hops is past the un-scoped limit of one"; + + mesh::Packet under = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + EXPECT_TRUE(mesh->forwards(&under)) << "and a fresh one still goes on"; +} + +TEST_F(SensorRoleTest, TheAdvertHopLimitIsApplied) { + mesh->prefs()->disable_fwd = 0; + mesh->prefs()->flood_max = 64; + mesh->prefs()->flood_max_unscoped = 64; + mesh->prefs()->flood_max_advert = 2; + + mesh::Packet advert = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + advert.header |= (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + EXPECT_FALSE(mesh->forwards(&advert)) << "adverts have their own, lower ceiling"; + + mesh::Packet plain = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + EXPECT_TRUE(mesh->forwards(&plain)) << "a non advert at the same hop count goes on"; +} + +TEST_F(SensorRoleTest, AZeroHopLimitIsWrittenOutAsAZero) { + // The first half of the upgrade hazard: if a zero were written as an empty value, + // the loader would leave the new default alone and there would be no hazard. + mesh->prefs()->flood_max_unscoped = 0; + mesh->prefs()->flood_max_advert = 0; + mesh->savePrefs(); + + ASSERT_TRUE(fs.exists("/prefs.json")); + const auto& bytes = fs.files["/prefs.json"]; + const std::string json(bytes.begin(), bytes.end()); + + EXPECT_NE(json.find("f_max_uns:0,"), std::string::npos) + << "a zero is written out as a zero, not left empty: " << json; +} + +TEST_F(SensorRoleTest, ASensorUpgradedOntoZeroHopLimitsKeepsForwarding) { + // The hazard this change had to answer. A sensor built before it set no default for + // the two limits, so it stored zero for both, and with forwarding switched on by an + // operator that is the state an updated node boots into. Zero means "never + // forward", because the limits are compared with >=. The file carries no prefs + // version, so the first boot lifts both zeros to the role defaults (see + // BootSequenceChecks.h for the upgrade itself). + const char* older = "{repeat:{disable:0,f_max:64,f_max_uns:0,f_max_adv:0}}"; + const std::string text(older); + fs.files["/prefs.json"] = std::vector(text.begin(), text.end()); + + TestSensor updated(board, radio_driver, ms, rng, rtc_clock, tables); + updated.begin(&fs); + + EXPECT_EQ(updated.prefs()->disable_fwd, 0) << "the operator had forwarding on"; + + mesh::Packet fresh_flood = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + EXPECT_TRUE(updated.forwards(&fresh_flood)) + << "an upgraded node goes on forwarding un-scoped floods"; + + mesh::Packet advert = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + advert.header |= (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + EXPECT_TRUE(updated.forwards(&advert)) << "and adverts"; +} + +TEST_F(SensorRoleTest, TheHopLimitStepIsOnlyForFilesBeforeVersionOne) { + // begin() only asks for an upgrade from an older version, but the step must hold + // on its own: from version 1 on, a zero was chosen on a firmware that applies it. + mesh->prefs()->flood_max_unscoped = 0; + mesh->prefs()->flood_max_advert = 0; + + EXPECT_FALSE(mesh->upgradeFrom(1)); + EXPECT_EQ(mesh->prefs()->flood_max_unscoped, 0); + EXPECT_EQ(mesh->prefs()->flood_max_advert, 0); + + EXPECT_TRUE(mesh->upgradeFrom(0)) << "and from version 0 it does lift them"; + EXPECT_EQ(mesh->prefs()->flood_max_unscoped, 64); +} + +TEST_F(SensorRoleTest, AMissingKeyLeavesTheNewDefaultStanding) { + // The other half of the upgrade picture: a key absent from the file overwrites + // nothing, so a node whose file predates the key gets the new default. + const char* without = "{repeat:{disable:0,f_max:64}}"; + const std::string text(without); + fs.files["/prefs.json"] = std::vector(text.begin(), text.end()); + + TestSensor updated(board, radio_driver, ms, rng, rtc_clock, tables); + updated.begin(&fs); + + EXPECT_EQ(updated.prefs()->flood_max_unscoped, 64); + EXPECT_EQ(updated.prefs()->flood_max_advert, 8); +} + +TEST_F(SensorRoleTest, FloodMaxItselfIsApplied) { + mesh->prefs()->disable_fwd = 0; + mesh->prefs()->flood_max = 2; + + mesh::Packet under = floodPacketWithHops(ROUTE_TYPE_FLOOD, 1); + EXPECT_TRUE(mesh->forwards(&under)); + + mesh::Packet at_limit = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + EXPECT_FALSE(mesh->forwards(&at_limit)) << "the limit is reached, not exceeded"; +} + +TEST_F(SensorRoleTest, ForwardingCanBeTurnedOffEntirely) { + mesh->prefs()->disable_fwd = 1; + mesh->prefs()->flood_max = 5; + + mesh::Packet pkt = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + EXPECT_FALSE(mesh->forwards(&pkt)); +} + +TEST_F(SensorRoleTest, TheBoostedGainPrefIsAppliedAtBoot) { + // Was: the sensor pushed nothing to the radio in begin(), so a value set over the + // CLI applied at once and was gone after a reboot. begin() has already run in + // SetUp(), so the call must have happened by now. + EXPECT_EQ(radio_driver.rx_boost_calls, 1); + + // The pref itself is only defaulted on SX1262 and SX1268 builds, which this host + // environment is not, so it stays at zero here and the applied value is zero. + EXPECT_EQ(mesh->prefs()->rx_boosted_gain, 0); + EXPECT_FALSE(radio_driver.rx_boosted); +} + +TEST_F(SensorRoleTest, AStoredBoostedGainSurvivesAReboot) { + // The point of applying it in begin(): the value has to reach the radio again + // after a restart, not only when the command is given. + const char* stored = "{radio:{rxgain:1}}"; + const std::string text(stored); + fs.files["/prefs.json"] = std::vector(text.begin(), text.end()); + + radio_driver.reset(); + TestSensor rebooted(board, radio_driver, ms, rng, rtc_clock, tables); + rebooted.begin(&fs); + + EXPECT_EQ(rebooted.prefs()->rx_boosted_gain, 1); + EXPECT_EQ(radio_driver.rx_boost_calls, 1); + EXPECT_TRUE(radio_driver.rx_boosted) << "the stored setting reached the radio"; +} + +TEST_F(SensorRoleTest, TheRetransmitDelayDrawsFromSixDiscreteSlots) { + // This one is deliberately kept, not aligned: the other roles draw uniformly from + // a continuous range, and upstream still calls that change experimental. Pinned so + // the role parameter cannot be lost by accident. + mesh->prefs()->tx_delay_factor = 1.0f; + + mesh::Packet pkt = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + const uint32_t t = mesh->airtimeFor(pkt.getPathByteLen() + pkt.payload_len + 2); + ASSERT_GT(t, 0u); + + for (int i = 0; i < 200; i++) { + const uint32_t d = mesh->floodRetransmitDelay(&pkt); + ASSERT_EQ(d % t, 0u) << "every delay is a whole multiple of the airtime"; + ASSERT_LE(d / t, 5u) << "and never more than five of them"; + } +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_server_base/identity_stubs.cpp b/test/test_server_base/identity_stubs.cpp new file mode 100644 index 00000000000..f839f177219 --- /dev/null +++ b/test/test_server_base/identity_stubs.cpp @@ -0,0 +1,98 @@ +// Link-time stand-ins for the parts of mesh::Identity that the core and the server +// helpers reference. The real implementations need the board crypto libraries, +// which do not exist on the host. +// +// These are deterministic, NOT cryptographic. The roles sign every advertisement +// they schedule, so a stub that refused to run would turn ordinary operation into a +// test failure; instead each operation derives its output from its inputs, so a +// round trip is self consistent and a test stays repeatable. No test in this suite +// asserts a cryptographic property, and none may: a passing signature here says +// nothing about Ed25519. + +#include +#include + +#include + +namespace { + +// A stable byte mix, so output depends on every input byte. +void derive(uint8_t* dest, size_t dest_len, const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + uint8_t acc = 0x5A; + for (size_t i = 0; i < a_len; i++) acc = (uint8_t)(acc * 31 + a[i]); + for (size_t i = 0; i < b_len; i++) acc = (uint8_t)(acc * 31 + b[i]); + for (size_t i = 0; i < dest_len; i++) { + acc = (uint8_t)(acc * 31 + (uint8_t)i); + dest[i] = acc; + } +} + +} // namespace + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + derive(sig, SIGNATURE_SIZE, prv_key, PRV_KEY_SIZE, message, (size_t)msg_len); +} + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + // Cannot be checked without the private key, so accept anything. A test that + // needs to tell a good signature from a bad one must not use this stub. + return true; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + derive(secret, PUB_KEY_SIZE, prv_key, PRV_KEY_SIZE, other_pub_key, PUB_KEY_SIZE); +} + +// The identity file and the identity CLI commands are not exercised here, so these +// only have to satisfy the linker, and any call is a test failure. + +bool Identity::readFrom(Stream& s) { + ADD_FAILURE() << "Identity::readFrom must not be called by the server base tests"; + return false; +} + +bool Identity::writeTo(Stream& s) const { + ADD_FAILURE() << "Identity::writeTo must not be called by the server base tests"; + return false; +} + +void Identity::printTo(Stream& s) const { + ADD_FAILURE() << "Identity::printTo must not be called by the server base tests"; +} + +bool LocalIdentity::readFrom(Stream& s) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the server base tests"; + return false; +} + +bool LocalIdentity::writeTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the server base tests"; + return false; +} + +void LocalIdentity::printTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::printTo must not be called by the server base tests"; +} + +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the server base tests"; + return 0; +} + +void LocalIdentity::readFrom(const uint8_t* src, size_t len) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the server base tests"; +} + +bool LocalIdentity::validatePrivateKey(const uint8_t prv[64]) { + ADD_FAILURE() << "LocalIdentity::validatePrivateKey must not be called by the server base tests"; + return false; +} + +} // namespace mesh diff --git a/test/test_server_base/test_boot_sequence.cpp b/test/test_server_base/test_boot_sequence.cpp new file mode 100644 index 00000000000..7b1110d809c --- /dev/null +++ b/test/test_server_base/test_boot_sequence.cpp @@ -0,0 +1,10 @@ +// The boot sequence of the room server, which step 4f moves into the shared base. +// The expectations live in test/mocks/BootSequenceChecks.h, shared with the repeater +// and the sensor; main() is in test_server_base.cpp. + +#include "MyMesh.h" + +using BootRole = MyMesh; + +#include +#include diff --git a/test/test_server_base/test_server_base.cpp b/test/test_server_base/test_server_base.cpp new file mode 100644 index 00000000000..53b53b0e50a --- /dev/null +++ b/test/test_server_base/test_server_base.cpp @@ -0,0 +1,640 @@ +// Characterization tests for the server underpinnings the three roles carry in +// triplicate (see docs/refactoring/0004-shared-server-base.md). They run the real +// role implementation on the host against the mocks in test/mocks, so the recorded +// behaviour is the firmware's, not a copy of its formulas. +// +// This file pins the temporary radio parameters, which step 4c moves into the +// shared base: what `set radio ...` schedules, when the pending values reach the +// radio, that they reach it once, and when the node reverts to its own settings. +// It also pins the Region a packet is taken to have arrived on, which step 4a +// already moved into ServerMesh::onRecvPacket(). +// The room server stands in for the three roles here; the repeater and the sensor +// need an environment of their own, because both of them also define `MyMesh`. + +#include + +#include +#include +#include +#include + +#include "MyMesh.h" + +namespace { + +// The delay the role gives the CLI to answer before the pending values are applied. +constexpr uint32_t APPLY_DELAY_MS = 2000; + +// The clock the role starts from in these tests. +constexpr uint32_t T0 = 1000; + +// Dispatcher::millisHasNowPassed() is `(long)(now - timestamp) > 0`, so a timer set +// for T fires at T + 1, not at T. That one millisecond is pinned deliberately: it is +// the observable edge of every scheduled action in the roles, and the signed +// difference is what makes the comparison survive the millis() rollover. + +class ServerBaseTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + + // Built the way the role's own main.cpp builds it. + std::unique_ptr mesh; + + float orig_freq = 0, orig_bw = 0; + uint8_t orig_sf = 0, orig_cr = 0; + + void SetUp() override { + g_mock_millis = T0; // not 0, so a scheduled time is distinguishable from "off" + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + mesh.reset(new MyMesh(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + + // begin() pushes the node's own settings to the radio; remember them as the + // values a revert has to restore, without naming the defaults here. + orig_freq = radio_driver.freq; + orig_bw = radio_driver.bw; + orig_sf = radio_driver.sf; + orig_cr = radio_driver.cr; + radio_driver.set_params_calls = 0; + } +}; + +TEST_F(ServerBaseTest, BeginPushesTheNodesOwnSettingsToTheRadio) { + EXPECT_GT(orig_freq, 0.0f); + EXPECT_GT(orig_bw, 0.0f); + EXPECT_GT(orig_sf, 0); +} + +TEST_F(ServerBaseTest, TempParamsAreNotAppliedBeforeTheReplyDelay) { + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, 3); + + mesh->loop(); + EXPECT_EQ(radio_driver.set_params_calls, 0); // still inside the delay + EXPECT_FLOAT_EQ(radio_driver.freq, orig_freq); + + // The scheduled moment itself is still too early. + g_mock_millis = T0 + APPLY_DELAY_MS; + mesh->loop(); + EXPECT_EQ(radio_driver.set_params_calls, 0); + EXPECT_FLOAT_EQ(radio_driver.freq, orig_freq); +} + +TEST_F(ServerBaseTest, TempParamsReachTheRadioOneMillisecondAfterTheDelay) { + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, 3); + + g_mock_millis = T0 + APPLY_DELAY_MS + 1; + mesh->loop(); + + EXPECT_EQ(radio_driver.set_params_calls, 1); + EXPECT_FLOAT_EQ(radio_driver.freq, 867.5f); + EXPECT_FLOAT_EQ(radio_driver.bw, 125.0f); + EXPECT_EQ(radio_driver.sf, 11); + EXPECT_EQ(radio_driver.cr, 6); +} + +TEST_F(ServerBaseTest, TempParamsAreAppliedOnlyOnce) { + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, 3); + + g_mock_millis = T0 + APPLY_DELAY_MS + 1; + mesh->loop(); + ASSERT_EQ(radio_driver.set_params_calls, 1); + + mesh->loop(); + mesh->loop(); + EXPECT_EQ(radio_driver.set_params_calls, 1); // the timer is cleared when it fires +} + +TEST_F(ServerBaseTest, TheNodeRevertsToItsOwnSettingsAfterTheTimeout) { + const uint32_t timeout_mins = 3; + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, (int)timeout_mins); + + g_mock_millis = T0 + APPLY_DELAY_MS + 1; + mesh->loop(); + ASSERT_FLOAT_EQ(radio_driver.freq, 867.5f); + + // The revert is scheduled from the time of the command, not from the apply, and + // it is the same strictly-greater comparison. + const uint32_t revert_at = T0 + APPLY_DELAY_MS + timeout_mins * 60 * 1000; + g_mock_millis = revert_at; + mesh->loop(); + EXPECT_FLOAT_EQ(radio_driver.freq, 867.5f); // the scheduled moment is still early + + g_mock_millis = revert_at + 1; + mesh->loop(); + EXPECT_FLOAT_EQ(radio_driver.freq, orig_freq); + EXPECT_FLOAT_EQ(radio_driver.bw, orig_bw); + EXPECT_EQ(radio_driver.sf, orig_sf); + EXPECT_EQ(radio_driver.cr, orig_cr); +} + +TEST_F(ServerBaseTest, RevertHappensOnlyOnce) { + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, 1); + + g_mock_millis = T0 + APPLY_DELAY_MS + 60 * 1000 + 1; + mesh->loop(); + ASSERT_FLOAT_EQ(radio_driver.freq, orig_freq); // applied and reverted in one loop + const int after_revert = radio_driver.set_params_calls; + + mesh->loop(); + mesh->loop(); + EXPECT_EQ(radio_driver.set_params_calls, after_revert); +} + + +// A role with the shared base's state opened up, so a test can see which Region a +// packet was taken to have arrived on. That field drives the scope of every flooded +// reply, and nothing else in the firmware reports it. +class RegionProbe : public MyMesh { +public: + using MyMesh::MyMesh; + + RegionEntry* seenRegion() const { return recv_pkt_region; } + RegionMap& regions() { return region_map; } + mesh::DispatcherAction recv(mesh::Packet* pkt) { return onRecvPacket(pkt); } +}; + +class RegionResolutionTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + mesh.reset(new RegionProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + } + + // A packet carrying nothing but a route type, which is all the resolution reads. + static mesh::Packet packetOfRouteType(uint8_t route_type) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = route_type; + return pkt; + } +}; + +TEST_F(RegionResolutionTest, ADirectPacketHasNoRegion) { + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_DIRECT); + mesh->recv(&pkt); + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + +TEST_F(RegionResolutionTest, AnUnscopedFloodPacketArrivesOnTheWildcardRegion) { + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_FLOOD); + mesh->recv(&pkt); + + ASSERT_NE(mesh->seenRegion(), nullptr); + EXPECT_TRUE(mesh->seenRegion()->isWildcard()); + EXPECT_EQ(mesh->seenRegion(), &mesh->regions().getWildcard()); +} + +TEST_F(RegionResolutionTest, AnUnscopedFloodPacketHasNoRegionWhenTheWildcardDeniesFlood) { + mesh->regions().getWildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_FLOOD); + mesh->recv(&pkt); + + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + +TEST_F(RegionResolutionTest, AScopedFloodPacketWithTransportCodeZeroArrivesOnTheWildcard) { + // RegionMap::findMatch() returns the wildcard for transport code 0 before it looks + // at any Region, so a scoped flood with no code is handled like an un-scoped one. + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + mesh->recv(&pkt); + + ASSERT_NE(mesh->seenRegion(), nullptr); + EXPECT_TRUE(mesh->seenRegion()->isWildcard()); +} + +TEST_F(RegionResolutionTest, TransportCodeZeroRespectsTheWildcardsFloodDenial) { + // Both flood route types agree here. The un-scoped branch in onRecvPacket() checks + // REGION_DENY_FLOOD itself, and findMatch() now applies the wildcard's flags to a + // scoped flood carrying code 0 as well (ledger issue 10), so denying flood on the + // wildcard stops both. Before that fix this test pinned the disagreement. + mesh->regions().getWildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet unscoped = packetOfRouteType(ROUTE_TYPE_FLOOD); + mesh->recv(&unscoped); + EXPECT_EQ(mesh->seenRegion(), nullptr) << "un-scoped flood respects the denial"; + + mesh::Packet scoped = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + mesh->recv(&scoped); + EXPECT_EQ(mesh->seenRegion(), nullptr) << "scoped flood with code 0 respects the denial too"; +} + +TEST_F(RegionResolutionTest, AScopedFloodPacketWithAnUnknownCodeHasNoRegion) { + // A non-zero code goes through the Region lookup, and nothing but the wildcard is + // loaded, so no Region claims it. This is the case that tells the lookup apart + // from simply handing back the wildcard. + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + pkt.transport_codes[0] = 0x1234; + mesh->recv(&pkt); + + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + +TEST_F(RegionResolutionTest, ANewRegionDeniesFloodUntilItIsAllowed) { + // RegionMap::putRegion() sets REGION_DENY_FLOOD on a new Region ("DENY by + // default"), so a freshly added Region is invisible to the flood lookup until the + // flag is cleared. Pinned because the tests below depend on it, and because the + // default itself is what the lookup's mask acts on. + RegionEntry* region = mesh->regions().putRegion("#probe", 0); + ASSERT_NE(region, nullptr); + EXPECT_TRUE(region->flags & REGION_DENY_FLOOD); +} + +TEST_F(RegionResolutionTest, AScopedFloodPacketArrivesOnTheRegionWhoseCodeItCarries) { + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + RegionEntry* region = mesh->regions().putRegion("#probe", 0); + ASSERT_NE(region, nullptr); + region->flags &= ~REGION_DENY_FLOOD; // allow flood, so the lookup considers it + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + TransportKey keys[4]; + ASSERT_GT(mesh->regions().getTransportKeysFor(*region, keys, 4), 0); + pkt.transport_codes[0] = keys[0].calcTransportCode(&pkt); + + mesh->recv(&pkt); + + ASSERT_NE(mesh->seenRegion(), nullptr); + EXPECT_EQ(mesh->seenRegion()->id, region->id); +} + +TEST_F(RegionResolutionTest, ARegionThatDeniesFloodIsSkippedEvenForItsOwnCode) { + // The lookup is passed REGION_DENY_FLOOD as its mask, so a Region carrying that + // flag is passed over and the packet ends up with no Region at all. This is the + // case that tells the mask apart from passing 0. + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + RegionEntry* region = mesh->regions().putRegion("#probe", 0); + ASSERT_NE(region, nullptr); + region->flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + TransportKey keys[4]; + ASSERT_GT(mesh->regions().getTransportKeysFor(*region, keys, 4), 0); + pkt.transport_codes[0] = keys[0].calcTransportCode(&pkt); + + // Same packet, same code, only the Region's flag differs. + region->flags |= REGION_DENY_FLOOD; + mesh->recv(&pkt); + + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + +TEST_F(RegionResolutionTest, TheRegionIsClearedBetweenPackets) { + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet flood = packetOfRouteType(ROUTE_TYPE_FLOOD); + mesh->recv(&flood); + ASSERT_NE(mesh->seenRegion(), nullptr); + + // A direct packet must not leave the previous packet's Region standing, or a + // reply would be scoped by whatever arrived before it. + mesh::Packet direct = packetOfRouteType(ROUTE_TYPE_DIRECT); + mesh->recv(&direct); + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + + +// The advertisement schedule, which step 4b moves into the shared base. Driven +// through the role's own public surface, so these tests read the same before and +// after the move. They pin the interval arithmetic and which send path each kind of +// advertisement takes, not the millisecond the transmitter happens to pick: the +// duty cycle budget in Dispatcher decides that, and it is not what moved. +class AdvertScheduleTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + mesh.reset(new MyMesh(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + + // Start from "no advertisements scheduled", whatever begin() left behind. + mesh->getNodePrefs()->advert_interval = 0; + mesh->getNodePrefs()->flood_advert_interval = 0; + mesh->updateAdvertTimer(); + mesh->updateFloodAdvertTimer(); + radio_driver.send_calls = 0; + } + + // Run the node forward, and report whether it transmitted anything. + bool runFor(uint32_t millis_span, uint32_t step = 250) { + const int before = radio_driver.send_calls; + for (uint32_t t = 0; t < millis_span; t += step) { + g_mock_millis += step; + mesh->loop(); + } + return radio_driver.send_calls > before; + } +}; + +TEST_F(AdvertScheduleTest, NothingIsAdvertisedWhenBothIntervalsAreZero) { + EXPECT_FALSE(runFor(3 * 60 * 60 * 1000)); // three hours of quiet +} + +TEST_F(AdvertScheduleTest, TheLocalIntervalCountsTwoMinutesPerUnit) { + mesh->getNodePrefs()->advert_interval = 1; // the field is minutes / 2 + mesh->updateAdvertTimer(); + + EXPECT_FALSE(runFor(2 * 60 * 1000 - 1000)) << "nothing before the two minutes"; + EXPECT_TRUE(runFor(2000)) << "advertised once the two minutes passed"; +} + +TEST_F(AdvertScheduleTest, AZeroLocalIntervalStopsAScheduledAdvert) { + mesh->getNodePrefs()->advert_interval = 1; + mesh->updateAdvertTimer(); + + // Turning it off has to clear the pending timer, not just stop rescheduling. + mesh->getNodePrefs()->advert_interval = 0; + mesh->updateAdvertTimer(); + + EXPECT_FALSE(runFor(10 * 60 * 1000)); +} + +TEST_F(AdvertScheduleTest, TheFloodIntervalCountsOneHourPerUnit) { + mesh->getNodePrefs()->flood_advert_interval = 1; // the field is hours + mesh->updateFloodAdvertTimer(); + + EXPECT_FALSE(runFor(60 * 60 * 1000 - 60 * 1000)) << "nothing before the hour"; + EXPECT_TRUE(runFor(2 * 60 * 1000)) << "advertised once the hour passed"; +} + +TEST_F(AdvertScheduleTest, AZeroFloodIntervalStopsAScheduledAdvert) { + mesh->getNodePrefs()->flood_advert_interval = 1; + mesh->updateFloodAdvertTimer(); + + mesh->getNodePrefs()->flood_advert_interval = 0; + mesh->updateFloodAdvertTimer(); + + EXPECT_FALSE(runFor(2 * 60 * 60 * 1000)); +} + +TEST_F(AdvertScheduleTest, AFloodAdvertReschedulesTheLocalOneToo) { + // The role schedules both when a flood advert fires, so the two do not overlap. + // With the local interval left at zero only the flood advert can fire, and it has + // to keep firing on its own schedule afterwards. + mesh->getNodePrefs()->flood_advert_interval = 1; + mesh->updateFloodAdvertTimer(); + + ASSERT_TRUE(runFor(60 * 60 * 1000 + 60 * 1000)) << "first flood advert"; + EXPECT_FALSE(runFor(30 * 60 * 1000)) << "not again within the next half hour"; + EXPECT_TRUE(runFor(35 * 60 * 1000)) << "again after the next hour"; +} + +TEST_F(AdvertScheduleTest, AnAdvertisementGoesOutOnTheRouteItWasAskedFor) { + // The route type is in the first byte of the frame, so the two send paths are + // told apart by what actually left the radio, not just by something leaving it. + mesh->sendSelfAdvertisement(0, false); + ASSERT_TRUE(runFor(10 * 1000)) << "zero hop advertisement"; + EXPECT_EQ(radio_driver.last_send[0] & PH_ROUTE_MASK, ROUTE_TYPE_DIRECT); + + radio_driver.send_calls = 0; + mesh->sendSelfAdvertisement(0, true); + ASSERT_TRUE(runFor(10 * 1000)) << "flooded advertisement"; + // No default scope is configured here, so the scoped send falls back to a plain + // flood rather than a transport flood. + EXPECT_EQ(radio_driver.last_send[0] & PH_ROUTE_MASK, ROUTE_TYPE_FLOOD); +} + + +// Identity, file system and statistics, which step 4d moves into the shared base. +// Driven through the role's own public surface, so these read the same before and +// after the move. +class HousekeepingTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + LittleFS.reset(); + rng.begin(1); + + mesh.reset(new MyMesh(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + } +}; + +TEST_F(HousekeepingTest, SettingTheTxPowerReachesTheRadio) { + radio_driver.tx_power_calls = 0; + + mesh->setTxPower(-7); + + EXPECT_EQ(radio_driver.tx_power_calls, 1); + EXPECT_EQ(radio_driver.tx_power, -7); +} + +TEST_F(HousekeepingTest, ClearingTheStatsResetsTheRadioCounters) { + radio_driver.packets_recv = 11; + radio_driver.packets_sent = 7; + radio_driver.packets_recv_errors = 3; + + mesh->clearStats(); + + EXPECT_EQ(radio_driver.packets_recv, 0u); + EXPECT_EQ(radio_driver.packets_sent, 0u); + EXPECT_EQ(radio_driver.packets_recv_errors, 0u); +} + +TEST_F(HousekeepingTest, FormattingTheFileSystemErasesIt) { + // On this platform the role erases through the LittleFS global, which is the same + // in-memory instance the test can look into. + LittleFS.files["/leftover"] = { 1, 2, 3 }; + ASSERT_TRUE(LittleFS.exists("/leftover")); + + EXPECT_TRUE(mesh->formatFileSystem()); + + EXPECT_FALSE(LittleFS.exists("/leftover")); +} + +TEST_F(HousekeepingTest, TheStatsRepliesAreFilledIn) { + char reply[256]; + + memset(reply, 0, sizeof(reply)); + mesh->formatStatsReply(reply); + EXPECT_GT(strlen(reply), 0u) << "core stats"; + + memset(reply, 0, sizeof(reply)); + mesh->formatRadioStatsReply(reply); + EXPECT_GT(strlen(reply), 0u) << "radio stats"; + + memset(reply, 0, sizeof(reply)); + mesh->formatPacketStatsReply(reply); + EXPECT_GT(strlen(reply), 0u) << "packet stats"; +} + +TEST_F(HousekeepingTest, ClearingTheStatsResetsTheCoreCountersToo) { + // Make the core count a transmission of its own, so the reset has something to + // clear beyond the radio's counters. + mesh->sendSelfAdvertisement(0, true); + for (int i = 0; i < 40; i++) { g_mock_millis += 250; mesh->loop(); } + ASSERT_GT(radio_driver.send_calls, 0) << "something has to have been sent"; + + char busy[256] = {0}; + mesh->formatPacketStatsReply(busy); + + mesh->clearStats(); + + char cleared[256] = {0}; + mesh->formatPacketStatsReply(cleared); + EXPECT_STRNE(busy, cleared) << "the core's own counters have to be reset as well"; +} + +TEST_F(HousekeepingTest, ThePacketStatsFollowTheRadioCounters) { + char before[256] = {0}; + mesh->formatPacketStatsReply(before); + + radio_driver.packets_recv_errors = 42; + char after[256] = {0}; + mesh->formatPacketStatsReply(after); + + EXPECT_STRNE(before, after) << "the reply has to reflect the counters it reads"; +} + + +// The ACL peer lookup, which step 4e moved into the shared base. The ACL is +// protected on the base, so a subclass can populate it; before the move it was +// private in each role and could not be reached from a test at all. +class PeerLookupProbe : public MyMesh { +public: + using MyMesh::MyMesh; + + ClientACL& clients() { return acl; } + int search(const uint8_t* hash) { return searchPeersByHash(hash); } + void secretFor(uint8_t* dest, int peer_idx) { getPeerSharedSecret(dest, peer_idx); } +}; + +class PeerLookupTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + mesh.reset(new PeerLookupProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + } + + // The hash is the first byte of the public key (PATH_HASH_SIZE is 1), so the + // first byte decides who matches and the rest only keeps the keys distinct. + mesh::Identity identityStarting(uint8_t first, uint8_t tag) { + uint8_t pub[PUB_KEY_SIZE]; + memset(pub, tag, sizeof(pub)); + pub[0] = first; + return mesh::Identity(pub); + } + + ClientInfo* addClient(uint8_t first, uint8_t tag) { + ClientInfo* c = mesh->clients().putClient(identityStarting(first, tag), 0); + if (c) memset(c->shared_secret, tag, PUB_KEY_SIZE); + return c; + } +}; + +TEST_F(PeerLookupTest, AnEmptyAclMatchesNobody) { + const uint8_t hash[1] = { 0xAA }; + EXPECT_EQ(mesh->search(hash), 0); +} + +TEST_F(PeerLookupTest, OnlyTheClientsWhoseHashMatchesAreReturned) { + addClient(0xAA, 1); + addClient(0xBB, 2); + addClient(0xAA, 3); + + const uint8_t hash[1] = { 0xAA }; + EXPECT_EQ(mesh->search(hash), 2); + + const uint8_t other[1] = { 0xBB }; + EXPECT_EQ(mesh->search(other), 1); + + const uint8_t missing[1] = { 0xCC }; + EXPECT_EQ(mesh->search(missing), 0); +} + +TEST_F(PeerLookupTest, TheSharedSecretBelongsToTheMatchedClient) { + addClient(0xBB, 2); // not a match, so the index must not be mistaken for it + addClient(0xAA, 7); + + const uint8_t hash[1] = { 0xAA }; + ASSERT_EQ(mesh->search(hash), 1); + + uint8_t secret[PUB_KEY_SIZE]; + memset(secret, 0, sizeof(secret)); + mesh->secretFor(secret, 0); + + uint8_t expected[PUB_KEY_SIZE]; + memset(expected, 7, sizeof(expected)); + EXPECT_EQ(memcmp(secret, expected, PUB_KEY_SIZE), 0); +} + +TEST_F(PeerLookupTest, MoreThanTwentyFourMatchesAreAllReported) { + // The sensor used to stop at 24, because its index array was sized that way + // while the other two roles sized theirs for the whole ACL. One array in the + // base ends the truncation, which is a deliberate change of behaviour for the + // sensor and is recorded in docs/refactoring/0004-shared-server-base.md. + const int wanted = 30; // more than 24, still inside MAX_CLIENTS + for (int i = 0; i < wanted; i++) { + ASSERT_NE(addClient(0xAA, (uint8_t)(i + 1)), nullptr) << "client " << i; + } + + const uint8_t hash[1] = { 0xAA }; + EXPECT_EQ(mesh->search(hash), wanted); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_variant_target/test_variant_target.cpp b/test/test_variant_target/test_variant_target.cpp new file mode 100644 index 00000000000..801b25ba09a --- /dev/null +++ b/test/test_variant_target/test_variant_target.cpp @@ -0,0 +1,168 @@ +#include + +#include + +#include +#include "target.h" + +// What a board variant's target.cpp provides to the roles, pinned on the host. +// 87 of the 89 variants create a new identity with the same two lines, and the +// identity is the node's address in the mesh, so the entropy it is made from +// matters: it has to come from the LoRa radio's noise, mixed with Arduino's +// random(), exactly as RadioNoiseListener does it. + +// Stand-in for src/Identity.cpp, which needs the Ed25519 library. The real +// constructor draws SEED_SIZE bytes from the RNG and derives both keys from +// them. Keeping the seed itself as the public key lets a test see exactly what +// was drawn, which is the only input the real key derivation has. +mesh::Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } +mesh::LocalIdentity::LocalIdentity(mesh::RNG* rng) { rng->random(pub_key, SEED_SIZE); } + +// The globals a variant's target.cpp defines. The shared definitions must +// reach the radio through radio_driver, so a second radio that nothing wraps +// shows whether they pick the right one. +TestRadio radio; +TestRadio other_radio; +TestBoard board; +WRAPPER_CLASS radio_driver(radio, board); +TestRTCClock rtc_clock; + +namespace { + +// The body of radio_new_identity() as 87 variants' target.cpp carry it, verbatim +// apart from the parameter: there `radio` is the variant's global radio. +mesh::LocalIdentity variant_copy(PhysicalLayer& radio) { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + +// The seed RadioNoiseListener draws: each byte is the radio's random byte XOR +// the low byte of Arduino's random(0, 256), in order. +void expected_seed(uint8_t* seed, uint8_t radio_byte, long random_seed) { + randomSeed(random_seed); + for (int i = 0; i < SEED_SIZE; i++) { + seed[i] = radio_byte ^ (random(0, 256) & 0xFF); + } +} + +TEST(VariantTarget, VariantCopySeedsFromTheRadioAndArduinoRandom) { + PhysicalLayer radio; + radio.random_byte = 0xC3; + randomSeed(7); + mesh::LocalIdentity id = variant_copy(radio); + + uint8_t seed[SEED_SIZE]; + expected_seed(seed, 0xC3, 7); + EXPECT_EQ(0, memcmp(seed, id.pub_key, SEED_SIZE)); +} + +TEST(VariantTarget, VariantCopyDependsOnTheRadio) { + // Same random() sequence, two radios: the identities must differ, so the + // radio's entropy really goes into the seed. + PhysicalLayer a, b; + a.random_byte = 0x11; + b.random_byte = 0x22; + randomSeed(3); + mesh::LocalIdentity from_a = variant_copy(a); + randomSeed(3); + mesh::LocalIdentity from_b = variant_copy(b); + EXPECT_NE(0, memcmp(from_a.pub_key, from_b.pub_key, SEED_SIZE)); +} + +TEST(VariantTarget, SharedDefaultMatchesTheVariantCopy) { + // The definition in src/helpers/radiolib/RadioLibTarget.cpp, which the + // variants no longer repeat, draws the same seed from the same radio. + radio.random_byte = 0xA7; + randomSeed(11); + mesh::LocalIdentity shared = radio_new_identity(); + randomSeed(11); + mesh::LocalIdentity copy = variant_copy(radio); + EXPECT_EQ(0, memcmp(copy.pub_key, shared.pub_key, SEED_SIZE)); +} + +TEST(VariantTarget, SharedDefaultListensOnTheWrappedRadio) { + radio.random_byte = 0x01; + other_radio.random_byte = 0xFE; + randomSeed(5); + mesh::LocalIdentity shared = radio_new_identity(); + + uint8_t seed[SEED_SIZE]; + expected_seed(seed, 0x01, 5); + EXPECT_EQ(0, memcmp(seed, shared.pub_key, SEED_SIZE)); + expected_seed(seed, 0xFE, 5); + EXPECT_NE(0, memcmp(seed, shared.pub_key, SEED_SIZE)); +} + +TEST(VariantTarget, ListenerOnTheDriverMatchesListenerOnTheRadio) { + radio.random_byte = 0x3C; + uint8_t via_radio[16], via_driver[16]; + randomSeed(9); + RadioNoiseListener(radio).random(via_radio, sizeof(via_radio)); + randomSeed(9); + RadioNoiseListener(radio_driver).random(via_driver, sizeof(via_driver)); + EXPECT_EQ(0, memcmp(via_radio, via_driver, sizeof(via_radio))); +} + +// The body of radio_init() as 28 nRF52 variants' target.cpp carry it, verbatim. +// The 3 others in cut C (heltec_t096, heltec_t1, heltec_t114) pass nothing +// instead of &SPI when P_LORA_SCLK is not defined, which std_init() ignores +// then; see the note, "Cut C". +bool variant_copy_radio_init() { + rtc_clock.begin(Wire); + return radio.std_init(&SPI); +} + +// The cases below run against each radio_init() in the list: the variants' +// copy, and from the code commit of cut C on, the shared default as well. +class RadioInit : public ::testing::TestWithParam { +protected: + void SetUp() override { + target_calls.clear(); + radio.std_init_result = true; + radio.std_init_calls = 0; + other_radio.std_init_calls = 0; + } +}; + +TEST_P(RadioInit, StartsTheRtcOnWireThenTheRadioOnSpi) { + GetParam()(); + std::vector expected = {"rtc_clock.begin(Wire)", "std_init(&SPI)"}; + EXPECT_EQ(expected, target_calls); +} + +TEST_P(RadioInit, StartsTheRadioOnce) { + GetParam()(); + EXPECT_EQ(1, radio.std_init_calls); +} + +TEST_P(RadioInit, ReturnsTrueWhenTheRadioStarts) { + radio.std_init_result = true; + EXPECT_TRUE(GetParam()()); +} + +TEST_P(RadioInit, ReturnsFalseWhenTheRadioFails) { + // The roles stop on false ("Radio init failed"), so a failure must not be + // turned into success. + radio.std_init_result = false; + EXPECT_FALSE(GetParam()()); + std::vector expected = {"rtc_clock.begin(Wire)", "std_init(&SPI)"}; + EXPECT_EQ(expected, target_calls); +} + +TEST_P(RadioInit, LeavesOtherRadiosAlone) { + GetParam()(); + EXPECT_EQ(0, other_radio.std_init_calls); +} + +INSTANTIATE_TEST_SUITE_P(VariantCopy, RadioInit, ::testing::Values(&variant_copy_radio_init)); + +// The default in src/helpers/radiolib/RadioLibTarget.cpp, which the 31 variants +// no longer repeat. +INSTANTIATE_TEST_SUITE_P(SharedDefault, RadioInit, ::testing::Values(&radio_init)); + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_variant_target_override/test_variant_target_override.cpp b/test/test_variant_target_override/test_variant_target_override.cpp new file mode 100644 index 00000000000..2c8fe0d9ff0 --- /dev/null +++ b/test/test_variant_target_override/test_variant_target_override.cpp @@ -0,0 +1,64 @@ +#include + +#include + +#include +#include "target.h" + +// A variant that keeps its own radio_new_identity() and radio_init(), as the +// ESP-NOW variants and the variants outside cut C do and as every variant in a +// downstream fork still does. Its definitions must take precedence over the +// shared ones in src/helpers/radiolib/RadioLibTarget.cpp, which are built into +// this binary as well; if a shared one were not weak, this suite would not link. + +mesh::Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } +mesh::LocalIdentity::LocalIdentity(mesh::RNG* rng) { rng->random(pub_key, SEED_SIZE); } + +TestRadio radio; +TestBoard board; +WRAPPER_CLASS radio_driver(radio, board); +TestRTCClock rtc_clock; + +namespace { + +// Fills the seed with one marker byte, which no radio noise produces here. +class MarkerRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { memset(dest, 0xEE, sz); } +}; + +} // namespace + +mesh::LocalIdentity radio_new_identity() { + MarkerRNG rng; + return mesh::LocalIdentity(&rng); +} + +bool radio_init() { + target_calls.push_back("variant radio_init()"); + return false; +} + +namespace { + +TEST(VariantTargetOverride, VariantRadioInitTakesPrecedence) { + target_calls.clear(); + EXPECT_FALSE(radio_init()); + std::vector expected = {"variant radio_init()"}; + EXPECT_EQ(expected, target_calls); + EXPECT_EQ(0, radio.std_init_calls); +} + +TEST(VariantTargetOverride, VariantDefinitionTakesPrecedence) { + mesh::LocalIdentity id = radio_new_identity(); + uint8_t marker[SEED_SIZE]; + memset(marker, 0xEE, sizeof(marker)); + EXPECT_EQ(0, memcmp(marker, id.pub_key, SEED_SIZE)); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tools/refactoring/README.md b/tools/refactoring/README.md new file mode 100644 index 00000000000..ac720fd1bf6 --- /dev/null +++ b/tools/refactoring/README.md @@ -0,0 +1,133 @@ +# Hardware checks for the refactoring series + +`nodetool.py` drives the upgrade/downgrade check described in each step's design +note (`docs/refactoring/`): flash a firmware, change settings through the CLI, +reboot, read them back, flash the other firmware, compare. Boards are addressed by +their USB serial number, so the check cannot hit the wrong board when several are +attached. macOS only (it finds ports through `ioreg`); Python 3 standard library +only. + +```sh +python3 nodetool.py port # serial port of the board +python3 nodetool.py cli @scripts/server_gets.txt # repeater / room server / sensor console +python3 nodetool.py comp @scripts/comp_gets.txt # companion: CMD_RUN_CLI_COMMAND per line +python3 nodetool.py compinfo # companion: self info and tuning parameters +python3 nodetool.py dfu firmware.zip # 1200 baud touch, then adafruit-nrfutil serial DFU +``` + +`@file` stands for the lines of that file. `reboot` as a command reboots the board +(companion: `CMD_REBOOT`). + +## Telling the images apart + +All builds of one release report the same version and build date, so `ver` cannot +show which image is running. Build the images for the check with a marker: + +```sh +PLATFORMIO_BUILD_FLAGS='-D FIRMWARE_BUILD_DATE=\"step1-\"' pio run -e LilyGo_T-Echo_room_server +``` + +`ver` then answers `v1.18.0 (Build: step1-)`. Setting +`PLATFORMIO_BUILD_FLAGS` makes PlatformIO clean the whole build folder of the +project, so build the marked images in a separate worktree. + +## Companion frame sweep (step 10) + +`companion_sweep.py` sends the frames of the host frame sweep +(`test/test_companion_frames/frame_sweep.golden`, the all zero pass) to a USB +companion and checks that the board answers as the host harness does: every +frame the host refuses, every length of the commands that only report, and four +lengths of every code that is not a command. Commands that would store, transmit, +restart or format are never sent at an accepted length; the docstring lists them. +About 6,800 frames, 12 minutes on a T-Echo. + +```sh +python3 companion_sweep.py +``` + +`companion_session.py` complements it with 39 frames the commands accept: every +setter writes back the value the board already holds, so the state is unchanged +afterwards. Run it once per image and diff the two outputs; only the build marker +may differ. + +```sh +python3 companion_session.py > a.txt # first image +python3 companion_session.py > b.txt # second image +diff a.txt b.txt +``` + +## Frame reads past the end (step 10c) + +`cmd_frame_offsets.py` lists every access to `cmd_frame` in the handlers of the +companion command table, with the row's minimum, and marks constant offsets that +are read without a length test of their own. + +`companion_asan_probe.patch` measures the same at run time: it builds the +`native_companion` harness with AddressSanitizer, poisons the bytes of `cmd_frame` +above each frame and sends every code 0 to 69 at every length with ten fills. Apply +it, run, summarise, revert: + +```sh +git apply tools/refactoring/companion_asan_probe.patch +ASAN_OPTIONS=halt_on_error=0:detect_leaks=0:detect_container_overflow=0:suppress_equal_pcs=0:malloc_context_size=0 \ + PROBE_SKIP=1 pio test -e native_companion -v -a '--gtest_filter=AsanProbe.*' > probe.log 2>&1 +python3 tools/refactoring/companion_asan_probe_summary.py probe.log +git apply -R tools/refactoring/companion_asan_probe.patch +``` + +`PROBE_SKIP=1` poisons from `len + 1` (the terminator some handlers write at `len` +is allowed); `PROBE_SKIP=0` from `len`, which also lists those terminators. About 30 +seconds with `PROBE_SKIP=1`, 15 minutes with `0` (every terminator is a report). + +## Diagnostic build for step 2 + +`diag-tuning.patch` adds a `diag tuning` command to the room server and the +companion that prints what the running core uses for its tuning hooks (duty cycle +budget, RX delay at score 0.3 and 1000 ms airtime, interference threshold, CAD, +AGC reset interval, extra ACKs). Apply the same patch to `dev` and to the step, +build both with a marker, and compare the output for the same settings: + +```sh +git apply tools/refactoring/diag-tuning.patch +``` + +The patch is for testing only and is not part of any step. + +## Diagnostic build for the sensor alignment + +`diag-sensor-rxgain-acks.patch` adds `diag sensor` to the sensor role. It prints the +stored RX boosted gain, the gain the radio really runs with (read back from the +SX126x register) and the extra ACK count the core uses, because `get radio.rxgain` +only shows the stored value. It also makes two boot changes that only serve the +check: `setup()` waits up to 15 s for the host to open the serial port, so the boot +output is not lost, and it frees an I2C bus that a soft reset left with SDA held low +(see ledger issue 14), since the board otherwise hangs in the RTC probe. The T-Echo +has no sensor environment in the tree. The check used one like this, appended to +`variants/lilygo_techo/platformio.ini`; the board's own build also sets its admin +password and `DEFAULT_FLOOD_SCOPE_NAME`, which only take effect on a board without +stored prefs: + +```ini +[env:LilyGo_T-Echo_sensor] +extends = LilyGo_T-Echo +build_src_filter = ${LilyGo_T-Echo.build_src_filter} + +<../examples/simple_sensor> +build_flags = + ${LilyGo_T-Echo.build_flags} + -D ADVERT_NAME='"T-Echo Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' +``` + +The patch is for testing only and is not part of any step. + +## Restoring a board + +Since v1.17 every role reads `/prefs.json`. Flashing the old firmware back keeps +the values set during the check, so set them back to the original values first +(read them before the check with the `get` scripts). + +If `~/.platformio/penv` points at a Python that no longer exists, set +`NRFUTIL_PYTHON` to another interpreter, for example the one PlatformIO itself +runs with. diff --git a/tools/refactoring/asan_link.py b/tools/refactoring/asan_link.py new file mode 100644 index 00000000000..6f6a3bcd751 --- /dev/null +++ b/tools/refactoring/asan_link.py @@ -0,0 +1,4 @@ +# Links the native_companion test program against the AddressSanitizer runtime +# (companion_asan_probe.patch); build_flags only reach the compiler. +Import("env") +env.Append(LINKFLAGS=["-fsanitize=address"]) diff --git a/tools/refactoring/cmd_frame_offsets.py b/tools/refactoring/cmd_frame_offsets.py new file mode 100644 index 00000000000..2dc638a0bb3 --- /dev/null +++ b/tools/refactoring/cmd_frame_offsets.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Lists every access to cmd_frame in the companion command handlers. + +For each row of MyMesh::CMD_TABLE (examples/companion_radio/MyMesh.cpp) it prints +the row's minimum, then every line of the handler that touches cmd_frame, sorted +into four kinds: + + fixed cmd_frame[N] or &cmd_frame[N] with a constant N (the hand counted offsets) + cursor cmd_frame[i] / &cmd_frame[i] with a running index + write an assignment into cmd_frame (the in place string terminators) + len a length derived from len that is handed on with a pointer into the frame + +and, for the fixed offsets, the highest byte the handler reads without a further +length test, against the row's minimum. A constant offset at or above the minimum +is only safe behind a len test, which the script marks as "guarded" when the line +or an enclosing if names len. + +Usage: tools/refactoring/cmd_frame_offsets.py [path/to/MyMesh.cpp] +""" + +import re +import sys + +PUB_KEY_SIZE = 32 +MAX_PATH_SIZE = 64 +CONSTS = {"PUB_KEY_SIZE": PUB_KEY_SIZE, "MAX_PATH_SIZE": MAX_PATH_SIZE} + +# widths of the reads a line does at a fixed offset, by the call around it +WIDTH_HINTS = [ + (re.compile(r"memcpy\(\s*&\w+\s*,\s*&cmd_frame\[([^\]]+)\]\s*,\s*(\d+)\s*\)"), None), + (re.compile(r"memcmp\(\s*&cmd_frame\[([^\]]+)\]\s*,\s*\"[^\"]*\"\s*,\s*(\d+)\s*\)"), None), +] + + +def ev(expr): + e = expr + for k, v in CONSTS.items(): + e = e.replace(k, str(v)) + if not re.fullmatch(r"[\d\s+\-*()]+", e): + return None + return eval(e) # digits and + - * only + + +def handlers(src): + table = re.search(r"const MyMesh::CmdHandler MyMesh::CMD_TABLE\[\] = \{(.*?)\n\};", src, re.S).group(1) + rows = re.findall(r"\{\s*(CMD_\w+),\s*([^,]+?),\s*&MyMesh::(\w+)\s*\}", table) + for code, minimum, fn in rows: + m = re.search(r"\nbool MyMesh::" + fn + r"\(size_t[^)]*\) \{\n(.*?)\n\}\n", src, re.S) + yield code, ev(minimum.strip()), fn, m.group(1).split("\n") + + +def classify(line): + kinds = [] + if re.search(r"cmd_frame\[[^\]]+\]\s*=[^=]", line) or re.search(r"\*np\+\+\s*=", line) or re.search(r"\btext\[tlen\]\s*=", line): + kinds.append("write") + for m in re.finditer(r"(&?)cmd_frame\[([^\]]+)\]", line): + off = ev(m.group(2)) + kinds.append(("fixed", off, m.group(1) == "&") if off is not None else ("cursor", m.group(2))) + if re.search(r"len\s*-\s*[\w(]", line) and "cmd_frame" in line or re.search(r"\blen - \(", line): + kinds.append("len") + return kinds + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "examples/companion_radio/MyMesh.cpp" + src = open(path).read() + total = {"fixed": 0, "cursor": 0, "write": 0, "len": 0} + nhandlers = 0 + for code, minimum, fn, body in handlers(src): + nhandlers += 1 + print(f"{code} (min {minimum}) {fn}") + guards = [] + top = 0 + for line in body: + s = line.strip() + if re.match(r"if \(.*\blen\b", s) or re.match(r"\} else if \(.*\blen\b", s): + guards.append(line) + kinds = classify(line) + if not kinds and "cmd_frame" not in line and not re.search(r"\blen\b", line): + continue + guarded = bool(re.search(r"\blen\b", line)) or any(len(g) - len(g.lstrip()) < len(line) - len(line.lstrip()) for g in guards) + tags = [] + for k in kinds: + if isinstance(k, tuple) and k[0] == "fixed": + total["fixed"] += 1 + width = 1 + mm = re.search(r"&cmd_frame\[[^\]]+\][^,]*,\s*(\d+)\)", line) + if k[2] and mm: + width = int(mm.group(1)) + elif k[2]: + width = 0 # a pointer handed on; its extent is the callee's + hi = k[1] + width + if not guarded and width: + top = max(top, hi) + tags.append(f"fixed[{k[1]}..{hi})" + (" guarded" if guarded else "")) + elif isinstance(k, tuple): + total["cursor"] += 1 + tags.append(f"cursor[{k[1]}]") + else: + total[k] += 1 + tags.append(k) + if tags or re.search(r"\blen\b", line): + print(f" {', '.join(tags) or 'len test':40s} {s}") + if top: + mark = "ok" if minimum is not None and top <= minimum else "ABOVE MIN" + print(f" -> unguarded fixed reads end at {top}, minimum {minimum}: {mark}") + print() + print(f"{nhandlers} handlers; fixed offsets {total['fixed']}, cursor {total['cursor']}, " + f"writes {total['write']}, len-derived lengths {total['len']}") + + +if __name__ == "__main__": + main() diff --git a/tools/refactoring/companion_asan_probe.patch b/tools/refactoring/companion_asan_probe.patch new file mode 100644 index 00000000000..44a5a80226d --- /dev/null +++ b/tools/refactoring/companion_asan_probe.patch @@ -0,0 +1,88 @@ +diff --git a/platformio.ini b/platformio.ini +index 5a57d9fa..d6c0867d 100644 +--- a/platformio.ini ++++ b/platformio.ini +@@ -340,6 +340,7 @@ lib_deps = + google/googletest @ 1.17.0 + + [env:native_companion] ++extra_scripts = post:tools/refactoring/asan_link.py + ; The companion on the host: the real MyMesh.cpp and DataStore.cpp against the mocks in + ; test/mocks, so the frame protocol (handleCmdFrame) can be characterized before it + ; is restructured (docs/refactoring/0010-companion-command-table.md). RP2040_PLATFORM +@@ -348,7 +349,7 @@ lib_deps = + ; sides of those two branches are reachable. + platform = native + test_framework = googletest +-build_flags = -std=c++17 ++build_flags = -std=c++17 -g -fsanitize=address -fsanitize-recover=address -fno-omit-frame-pointer + -D RP2040_PLATFORM + -D ENABLE_PRIVATE_KEY_EXPORT=1 + -D ENABLE_PRIVATE_KEY_IMPORT=1 +diff --git a/test/test_companion_frames/CompanionHarness.h b/test/test_companion_frames/CompanionHarness.h +index 07bb4fa7..4508500c 100644 +--- a/test/test_companion_frames/CompanionHarness.h ++++ b/test/test_companion_frames/CompanionHarness.h +@@ -1,4 +1,7 @@ + #pragma once ++#include ++#include ++inline size_t g_poison_skip = 1; + + // The companion on the host, driven through its frame protocol. A test hands a + // command frame to a fake serial interface, runs one MyMesh::loop() (which is where +@@ -45,6 +48,7 @@ public: + std::vector written; + bool enabled = false; + int disable_calls = 0; ++ uint8_t* asan_buf = nullptr; + + void enable() override { enabled = true; } + void disable() override { enabled = false; disable_calls++; } +@@ -62,6 +66,8 @@ public: + Bytes f = inbound.front(); + inbound.pop_front(); + memcpy(dest, f.data(), f.size()); ++ asan_buf = dest; ++ if (g_poison_skip <= MAX_FRAME_SIZE + 1 - f.size()) __asan_poison_memory_region(dest + f.size() + g_poison_skip, MAX_FRAME_SIZE + 1 - f.size() - g_poison_skip); + return f.size(); + } + }; +@@ -148,6 +154,7 @@ public: + serial.written.clear(); + serial.inbound.push_back(frame); + node->loop(); ++ if (serial.asan_buf) __asan_unpoison_memory_region(serial.asan_buf, MAX_FRAME_SIZE + 1); + return serial.written; + } + +diff --git a/test/test_companion_frames/test_asan_probe.cpp b/test/test_companion_frames/test_asan_probe.cpp +new file mode 100644 +index 00000000..d315f549 +--- /dev/null ++++ b/test/test_companion_frames/test_asan_probe.cpp +@@ -0,0 +1,24 @@ ++// ASan probe for reads past len: every code, every length, ten fills. Bytes above ++// the frame are poisoned from len + PROBE_SKIP (0: the byte at len too). ++#include "CompanionHarness.h" ++#include ++using namespace companion; ++TEST(AsanProbe, AllCodesAllLengths) { ++ const char* e = getenv("PROBE_SKIP"); ++ if (!e) GTEST_SKIP(); ++ g_poison_skip = atoi(e); ++ const uint8_t fills[] = {0x00, 0x01, 0x02, 0x3F, 0x40, 0x7F, 0x81, 0xBF, 0xFF, 0x5A}; ++ Harness h; ++ for (int code = 0; code < 70; code++) { ++ for (uint8_t fill : fills) { ++ for (size_t len = 1; len <= MAX_FRAME_SIZE; len++) { ++ h.reset(); ++ h.boot(); ++ Bytes f; putU8(f, (uint8_t)code); putFill(f, len - 1, fill); ++ fprintf(stderr, "PROBE code=%d fill=%02x len=%zu\n", code, fill, len); ++ h.send(f); ++ } ++ } ++ } ++ g_poison_skip = 1; ++} diff --git a/tools/refactoring/companion_asan_probe_summary.py b/tools/refactoring/companion_asan_probe_summary.py new file mode 100644 index 00000000000..50788e479a4 --- /dev/null +++ b/tools/refactoring/companion_asan_probe_summary.py @@ -0,0 +1,24 @@ +# Reads the log of the ASan probe (companion_asan_probe.patch) and prints one line per +# handler, first non-libc frame and access kind, with the codes, fills and lengths that +# hit it. +import sys,re,collections +c=collections.defaultdict(lambda:[0,set(),set(),set()]) +cur=None +lines=open(sys.argv[1],errors='replace').read().split('\n') +i=0 +while i + +companion_sweep.py only sends frames that a command refuses or that only report. +This sends frames the commands accept, so that two firmware images can be compared +on the same board: run it once per image and diff the output. Every setter writes +back the value the board already holds (read first through the protocol), so the +board's state is the same afterwards. Nothing is sent on air, nothing restarts. + +What a reply carries that legitimately differs between two runs (the clock, the +uptime, the battery voltage) is masked in the output. +""" +import os +import struct +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import nodetool # noqa: E402 +from companion_sweep import read_frame # noqa: E402 + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return 2 + fd = nodetool.comp_open(sys.argv[1]) + + def tx(payload, note, mask=None, want_push=False): + nodetool.read_for(fd, 0.02) + os.write(fd, b"<" + struct.pack(" {shown}", flush=True) + return r + + # read the current values the setters will write back + info = tx(bytes([1, 3, 0, 0, 0, 0, 0, 0]) + b"session", "APP_START") + dev = tx(bytes([22, 3]), "DEVICE_QUERY") + tun = tx(bytes([43]), "GET_TUNING_PARAMS") + auto = tx(bytes([59]), "GET_AUTOADD_CONFIG") + scope = tx(bytes([64]), "GET_DEFAULT_FLOOD_SCOPE") + if not (info and dev and tun and auto and scope): + print("could not read the current values, stopping") + return 1 + + tx_power, lat, lon = info[2], info[36:40], info[40:44] + multi_acks, loc_policy, telemetry, manual_add = info[44], info[45], info[46], info[47] + freq, bw, sf, cr, name = info[48:52], info[52:56], info[56], info[57], info[58:] + pin, repeat, hash_mode = dev[4:8], dev[80], dev[81] + + tx(bytes([12, tx_power]), "SET_RADIO_TX_POWER (same)") + tx(bytes([11]) + freq + bw + bytes([sf, cr, repeat]), "SET_RADIO_PARAMS (same)") + tx(bytes([21]) + tun[1:9], "SET_TUNING_PARAMS (same)") + tx(bytes([38, manual_add, telemetry, loc_policy, multi_acks]), "SET_OTHER_PARAMS (same)") + tx(bytes([58]) + auto[1:3], "SET_AUTOADD_CONFIG (same)") + tx(bytes([61, 0, hash_mode]), "SET_PATH_HASH_MODE (same)") + tx(bytes([8]) + name, "SET_ADVERT_NAME (same)") + tx(bytes([14]) + lat + lon, "SET_ADVERT_LATLON (same)") + tx(bytes([37]) + pin, "SET_DEVICE_PIN (same)") + if len(scope) == 48: + tx(bytes([63]) + scope[1:48], "SET_DEFAULT_FLOOD_SCOPE (same)") + tx(bytes([54, 0]), "SET_FLOOD_SCOPE_KEY clear") + tx(bytes([54, 2]), "SET_FLOOD_SCOPE_KEY sub 2") + tx(bytes([61, 1, hash_mode]), "SET_PATH_HASH_MODE byte 1 not 0") + tx(bytes([61, 0, 3]), "SET_PATH_HASH_MODE 3") + tx(bytes([12, 0x7F]), "SET_RADIO_TX_POWER 127") + tx(bytes([6]) + struct.pack(" [golden] + +The host suite test/test_companion_frames records, for every command code and +every frame length, what the companion answers to a frame of zeros +(test/test_companion_frames/frame_sweep.golden, the lines of the "00" pass). This +tool sends the same frames to a real board over the USB companion interface and +checks that the board answers the same way, so the host harness is shown to model +the firmware, and a later restructuring can be checked on the board with the same +reference. + +Only frames that cannot change the board are sent: + +- every frame the golden file says is refused (an error reply and no side effect), + which covers every length below every command's minimum; +- every length of the commands in READ_ONLY, which only report; +- lengths 1, 2, 50 and 176 of every code that is not a command. + +Commands that store, send on air, restart, format or keep session state are never +sent at a length the host accepts. Replies are compared byte for byte where they +are node independent (errors and OK); for the report commands only the reply code +is compared, since their content is the board's own (its key, its clock, its +contacts). Private key export is left out on purpose. +""" +import os +import re +import struct +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import nodetool # noqa: E402 + +GOLDEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "../../test/test_companion_frames/frame_sweep.golden") + +# Commands that report and change nothing, at any length, with zeros as arguments. +READ_ONLY = { + 1, # APP_START + 5, # GET_DEVICE_TIME + 6, # SET_DEVICE_TIME: zero is in the past, so it is refused + 11, # SET_RADIO_PARAMS: zeros are out of range, refused + 16, # SHARE_CONTACT: the zero key finds a reserved slot, which has no advert + 17, # EXPORT_CONTACT: self advert, returned and not sent + 18, # IMPORT_CONTACT: zeros are no advert, refused + 20, # GET_BATT_AND_STORAGE + 22, # DEVICE_QUERY + 28, # HAS_CONNECTION + 30, # GET_CONTACT_BY_KEY + 31, # GET_CHANNEL + 35, # SIGN_FINISH: nothing started, refused + 40, # GET_CUSTOM_VARS + 41, # SET_CUSTOM_VAR: no separator, refused + 42, # GET_ADVERT_PATH + 43, # GET_TUNING_PARAMS + 56, # GET_STATS + 59, # GET_AUTOADD_CONFIG + 60, # GET_ALLOWED_REPEAT_FREQ + 62, # SEND_CHANNEL_DATA: data type 0 is reserved, refused + 64, # GET_DEFAULT_FLOOD_SCOPE + 66, # RUN_CLI_COMMAND: empty command, "Unknown command" +} + +# Never sent above their minimum, whatever the golden file says: they store, send, +# restart, format, hold session state, or (23) hand out the private key. 13 and 15 +# are here because a key of zeros is not "no contact": the eight reserved slots at +# the start of the contact table are all zeros, lookupContactByPubKey() finds them, +# and REMOVE_CONTACT then removes one (RESET_PATH changes one). +NEVER = {2, 3, 4, 7, 8, 9, 10, 12, 13, 14, 15, 19, 21, 23, 24, 25, 26, 27, 29, 32, 33, + 34, 36, 37, 38, 39, 50, 51, 52, 54, 55, 57, 58, 61, 63, 65} + +COMMAND_CODES = set(range(1, 44)) | set(range(50, 53)) | set(range(54, 67)) + + +def load_golden(path): + """{(code, length): reply hex of the first frame, or None for no reply}, pass "00".""" + out = {} + for line in open(path): + m = re.match(r"([0-9a-f]{2}) 00 (\d+)(?:-(\d+))?: (.*)$", line.strip()) + if not m: + continue + code, lo = int(m.group(1), 16), int(m.group(2)) + hi = int(m.group(3) or lo) + rest = m.group(4) + flags = re.search(r"\[(\w+)\]$", rest) + replies = re.sub(r"\s*\[\w+\]$", "", rest).split() + first = None if replies == ["(none)"] else replies[0] + for n in range(lo, hi + 1): + out[(code, n)] = (first, flags.group(1) if flags else "") + return out + + +def selected(golden): + for (code, n), (first, flags) in sorted(golden.items()): + refused = first is not None and first.startswith("01") and not flags + if code not in COMMAND_CODES: + if n in (1, 2, 50, 176): + yield code, n, first, True + elif refused: + yield code, n, first, True + elif code in READ_ONLY and code not in NEVER and not flags: + exact = first == "00" + yield code, n, first, exact + + +def read_frame(fd, timeout=2.0, want_push=False): + """The next radio->app frame ('>' len16 payload); pushes (0x80 and up) are skipped + unless one is expected.""" + buf, end = b"", time.time() + timeout + while time.time() < end: + buf += nodetool.read_for(fd, 0.02) + while True: + i = buf.find(b">") + if i < 0 or len(buf) < i + 3: + break + n = struct.unpack(" 2 else GOLDEN) + frames = list(selected(golden)) + fd = nodetool.comp_open(sys.argv[1]) + stats = {"sent": 0, "same": 0, "differ": 0, "no reply": 0} + per_kind = {"exact": 0, "code": 0} + t0 = time.time() + for code, n, first, exact in frames: + expect = bytes.fromhex(first) + nodetool.read_for(fd, 0.005) + payload = bytes([code]) + bytes(n - 1) + os.write(fd, b"<" + struct.pack("= 0x80) + stats["sent"] += 1 + if got is None: + stats["no reply"] += 1 + print(f"code {code:3d} len {n:3d}: no reply, expected {first}", flush=True) + continue + same = (got == expect) if exact else (got[:1] == expect[:1]) + per_kind["exact" if exact else "code"] += 1 + if same: + stats["same"] += 1 + else: + stats["differ"] += 1 + print(f"code {code:3d} len {n:3d}: got {got[:24].hex()} expected {first[:48]}", flush=True) + os.close(fd) + print(f"{stats['sent']} frames in {time.time() - t0:.0f} s: {stats['same']} as on the host, " + f"{stats['differ']} different, {stats['no reply']} without reply " + f"(compared byte for byte: {per_kind['exact']}, by reply code: {per_kind['code']})") + codes = sorted({c for c, _, _, _ in frames}) + print(f"codes covered: {len(codes)}") + return 0 if stats["differ"] == 0 and stats["no reply"] == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/refactoring/diag-espnow-rxgain.patch b/tools/refactoring/diag-espnow-rxgain.patch new file mode 100644 index 00000000000..c495d12b31d --- /dev/null +++ b/tools/refactoring/diag-espnow-rxgain.patch @@ -0,0 +1,20 @@ +diff --git a/variants/generic_espnow/platformio.ini b/variants/generic_espnow/platformio.ini +index 928d5315..c6fb9839 100644 +--- a/variants/generic_espnow/platformio.ini ++++ b/variants/generic_espnow/platformio.ini +@@ -13,6 +13,15 @@ build_flags = + -D PIN_BOARD_SCL=-1 + ; -D P_LORA_TX_LED=8 + -D PIN_USER_BTN=0 ++; DIAGNOSTIC ONLY: ESP32Board references the LoRa pins unconditionally ++; (ESP32Board.h:73, ESP32Board.cpp:62), so an ESP-NOW-only variant ++; cannot compile without them. Dummy values, compile check only. ++ -D P_LORA_DIO_1=0 ++ -D P_LORA_NSS=0 ++; DIAGNOSTIC ONLY: the framework compiles project sources with -w, which ++; beats any later -Werror=. Drop -w, then promote this one check. ++ -Werror=return-type ++build_unflags = -w + ; -D ARDUINO_USB_MODE=1 + ; -D ARDUINO_USB_CDC_ON_BOOT=1 + ; -D ESPNOW_DEBUG_LOGGING=1 diff --git a/tools/refactoring/diag-llcc68-wrapper.patch b/tools/refactoring/diag-llcc68-wrapper.patch new file mode 100644 index 00000000000..e8e536b8008 --- /dev/null +++ b/tools/refactoring/diag-llcc68-wrapper.patch @@ -0,0 +1,15 @@ +diff --git a/variants/heltec_v3/target.cpp b/variants/heltec_v3/target.cpp +index e6af466a..5c87138f 100644 +--- a/variants/heltec_v3/target.cpp ++++ b/variants/heltec_v3/target.cpp +@@ -1,6 +1,10 @@ + #include + #include "target.h" + ++// DIAGNOSTIC ONLY: pull the otherwise unused LLCC68 wrapper into a ++// translation unit so the compiler checks it. Not part of any fix. ++#include ++ + HeltecV3Board board; + + #if defined(P_LORA_SCLK) diff --git a/tools/refactoring/diag-packet-score.patch b/tools/refactoring/diag-packet-score.patch new file mode 100644 index 00000000000..dad017fc009 --- /dev/null +++ b/tools/refactoring/diag-packet-score.patch @@ -0,0 +1,25 @@ +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 99a9aa30..eeec4e39 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -2077,6 +2077,20 @@ void MyMesh::enterCLIRescue() { + } + + bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { ++ if (strcmp(command, "diag score") == 0) { // diagnostic build only, not part of any commit ++ // cfg = spreading factor the prefs ask for, rep = what the radio reports. ++ // sNNN = packetScore at that SNR for a 64 byte packet, scaled by 1000. ++ // rx5 = flood rebroadcast delay at SNR -5 dB and 500 ms airtime. ++ sprintf(reply, "> cfg=%d rep=%d s0=%d s5=%d s10=%d s15=%d rx5=%d", ++ (int)_prefs.sf, ++ (int)radio_driver.getSpreadingFactor(), ++ (int)(radio_driver.packetScore(0.0f, 64) * 1000), ++ (int)(radio_driver.packetScore(-5.0f, 64) * 1000), ++ (int)(radio_driver.packetScore(-10.0f, 64) * 1000), ++ (int)(radio_driver.packetScore(-15.0f, 64) * 1000), ++ calcRxDelay(radio_driver.packetScore(-5.0f, 64), 500)); ++ return true; ++ } + while (*command == ' ') command++; // skip leading spaces + + if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) diff --git a/tools/refactoring/diag-sensor-rxgain-acks.patch b/tools/refactoring/diag-sensor-rxgain-acks.patch new file mode 100644 index 00000000000..473557b7c39 --- /dev/null +++ b/tools/refactoring/diag-sensor-rxgain-acks.patch @@ -0,0 +1,47 @@ +diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp +index 75fa8b74..62444ed8 100644 +--- a/examples/simple_sensor/SensorMesh.cpp ++++ b/examples/simple_sensor/SensorMesh.cpp +@@ -376,6 +376,11 @@ uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* + } + + void SensorMesh::handleCommand(ClientInfo* from, uint32_t sender_timestamp, char* command, char* reply) { ++ if (strcmp(command, "diag sensor") == 0) { // diagnostic build only, not part of any commit ++ sprintf(reply, "> rxgain_pref=%d rxgain_chip=%d acks=%d", (int)_prefs.rx_boosted_gain, ++ (int)radio_driver.getRxBoostedGainMode(), (int)getExtraAckTransmitCount()); ++ return; ++ } + if (region_load_active) { + if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation + region_map = temp_map; // copy over the temp instance as new current map +diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp +index 66991daa..06184d3d 100644 +--- a/examples/simple_sensor/main.cpp ++++ b/examples/simple_sensor/main.cpp +@@ -55,7 +55,25 @@ static char command[160]; + void setup() { + Serial.begin(115200); + delay(1000); +- ++ while (!Serial && millis() < 15000) delay(10); // diagnostic build only: boot output needs the host ++ ++ { // diagnostic build only: free an I2C bus a soft reset left with SDA held low ++ pinMode(PIN_WIRE_SDA, INPUT_PULLUP); pinMode(PIN_WIRE_SCL, INPUT_PULLUP); delay(2); ++ int sda = digitalRead(PIN_WIRE_SDA), pulses = 0; ++ if (sda == LOW) { ++ pinMode(PIN_WIRE_SCL, OUTPUT); ++ while (pulses < 9 && digitalRead(PIN_WIRE_SDA) == LOW) { ++ digitalWrite(PIN_WIRE_SCL, LOW); delayMicroseconds(10); ++ digitalWrite(PIN_WIRE_SCL, HIGH); delayMicroseconds(10); ++ pulses++; ++ } ++ pinMode(PIN_WIRE_SDA, OUTPUT); digitalWrite(PIN_WIRE_SDA, LOW); delayMicroseconds(10); ++ digitalWrite(PIN_WIRE_SCL, HIGH); delayMicroseconds(10); ++ digitalWrite(PIN_WIRE_SDA, HIGH); delayMicroseconds(10); ++ pinMode(PIN_WIRE_SDA, INPUT_PULLUP); pinMode(PIN_WIRE_SCL, INPUT_PULLUP); delay(2); ++ } ++ Serial.printf("DIAG i2c sda_at_boot=%d pulses=%d sda_after=%d\n", sda, pulses, digitalRead(PIN_WIRE_SDA)); ++ } + board.begin(); + + #ifdef HAS_EXTERNAL_WATCHDOG diff --git a/tools/refactoring/diag-tuning.patch b/tools/refactoring/diag-tuning.patch new file mode 100644 index 00000000000..e5b933d7a55 --- /dev/null +++ b/tools/refactoring/diag-tuning.patch @@ -0,0 +1,34 @@ +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 5b14e7d0..56befd36 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -2050,6 +2050,12 @@ void MyMesh::enterCLIRescue() { + } + + bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { ++ if (strcmp(command, "diag tuning") == 0) { // diagnostic build only, not part of any commit ++ sprintf(reply, "> af100=%d rx=%d thr=%d cad=%d agc=%d acks=%d", (int)(getAirtimeBudgetFactor() * 100), ++ calcRxDelay(0.3f, 1000), getInterferenceThreshold(), (int)getCADEnabled(), getAGCResetInterval(), ++ (int)getExtraAckTransmitCount()); ++ return true; ++ } + while (*command == ' ') command++; // skip leading spaces + + if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) +diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp +index 10cca41c..1efd7eae 100644 +--- a/examples/simple_room_server/MyMesh.cpp ++++ b/examples/simple_room_server/MyMesh.cpp +@@ -885,6 +885,12 @@ void MyMesh::formatPacketStatsReply(char *reply) { + } + + void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { ++ if (strcmp(command, "diag tuning") == 0) { // diagnostic build only, not part of any commit ++ sprintf(reply, "> af100=%d rx=%d thr=%d cad=%d agc=%d acks=%d", (int)(getAirtimeBudgetFactor() * 100), ++ calcRxDelay(0.3f, 1000), getInterferenceThreshold(), (int)getCADEnabled(), getAGCResetInterval(), ++ (int)getExtraAckTransmitCount()); ++ return; ++ } + if (region_load_active) { + if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation + region_map = temp_map; // copy over the temp instance as new current map diff --git a/tools/refactoring/nodetool.py b/tools/refactoring/nodetool.py new file mode 100644 index 00000000000..e7dc9709bdd --- /dev/null +++ b/tools/refactoring/nodetool.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Talks to MeshCore nRF52 boards over USB, addressed by USB serial number (macOS, uses ioreg). + +Used for the hardware upgrade/downgrade checks of the refactoring series, see README.md. + + nodetool.py port serial port of that board + nodetool.py cli [...] text console (repeater, room server, sensor) + nodetool.py comp [...] companion: CMD_RUN_CLI_COMMAND (66) per command + nodetool.py compinfo companion: APP_START self info + tuning params + nodetool.py dfu 1200-baud touch, then adafruit-nrfutil serial DFU +""" +import os +import plistlib +import select +import struct +import subprocess +import sys +import termios +import time +import tty + +# adafruit-nrfutil as shipped with PlatformIO. NRFUTIL_PYTHON selects the interpreter +# (default: PlatformIO's own virtualenv); the tool's bundled packages are put on PYTHONPATH. +NRFUTIL_DIR = os.path.expanduser("~/.platformio/packages/tool-adafruit-nrfutil") +NRFUTIL = [os.environ.get("NRFUTIL_PYTHON", os.path.expanduser("~/.platformio/penv/bin/python")), + NRFUTIL_DIR + "/adafruit-nrfutil.py"] +NRFUTIL_ENV = dict(os.environ, PYTHONPATH=NRFUTIL_DIR + "/site-packages") + + +def find_port(usb_serial, timeout=20.0): + """Returns the first callout device below the USB device with this serial number.""" + end = time.time() + timeout + while time.time() < end: + r = subprocess.run(["ioreg", "-a", "-r", "-c", "IOUSBHostDevice", "-l"], capture_output=True) + if r.returncode == 0: + def walk(node, inside=False): + inside = inside or node.get("USB Serial Number") == usb_serial + if inside and "IOCalloutDevice" in node: + yield node["IOCalloutDevice"] + for c in node.get("IORegistryEntryChildren", []): + yield from walk(c, inside) + ports = sorted(p for root in plistlib.loads(r.stdout) for p in walk(root)) + if ports: + return ports[0] + time.sleep(0.5) + return None + + +def open_raw(port, baud=termios.B115200): + fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + tty.setraw(fd) + attrs = termios.tcgetattr(fd) + attrs[2] |= termios.CLOCAL | termios.CREAD + attrs[4] = attrs[5] = baud + termios.tcsetattr(fd, termios.TCSANOW, attrs) + return fd + + +def read_for(fd, seconds): + data, end = b"", time.time() + seconds + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.05) + if r: + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + continue + if not chunk: + break + data += chunk + return data + + +def text_cli(usb_serial, commands, timeout=6.0): + """Server roles echo the command, then print the reply as a line ' -> ...'. + Input is drained before each command and only a reply line that follows the + echo of this command counts, so a late reply can never be taken for the next one. + A command without reply is sent once more (every get/set used here is idempotent).""" + fd = open_raw(find_port(usb_serial)) + read_for(fd, 0.5) + out = [] + for cmd in commands: + if cmd == "reboot": + os.write(fd, b"reboot\r") + out.append((cmd, "(sent)")) + break + reply = None + for attempt in range(2): + read_for(fd, 0.3) + os.write(fd, (cmd + "\r").encode()) + buf, end = "", time.time() + timeout + while time.time() < end and reply is None: + buf += read_for(fd, 0.1).decode(errors="replace") + echo = buf.find(cmd) + if echo < 0: + continue + for line in buf[echo + len(cmd):].splitlines(): + if line.startswith(" -> "): + reply = line[5:] + break + if reply is not None: + break + out.append((cmd, reply)) + try: + os.close(fd) + except OSError: + pass + return out + + +def comp_frame(fd, payload, want, timeout=4.0): + """Sends one app->radio frame ('<' len16 payload) and waits for a reply frame of code `want`.""" + os.write(fd, b"<" + struct.pack("") + if i < 0 or len(buf) < i + 3: + break + n = struct.unpack(" {r}") + elif cmd == "comp": + for c, r in comp_cli(serial, sys.argv[3:]): + print(f"{c} => {r}") + elif cmd == "compinfo": + for k, v in comp_info(serial).items(): + print(f"{k} = {v}") + elif cmd == "dfu": + sys.exit(dfu(serial, sys.argv[3])) + + +if __name__ == "__main__": + main() diff --git a/tools/refactoring/scripts/comp_gets.txt b/tools/refactoring/scripts/comp_gets.txt new file mode 100644 index 00000000000..94b10913809 --- /dev/null +++ b/tools/refactoring/scripts/comp_gets.txt @@ -0,0 +1,16 @@ +ver +get radio +get freq +get tx +get af +get dutycycle +get rxdelay +get txdelay +get direct.txdelay +get int.thresh +get agc.reset.interval +get multi.acks +get path.hash.mode +get cad +get radio.rxgain +get tz.offset diff --git a/tools/refactoring/scripts/comp_sets_1.txt b/tools/refactoring/scripts/comp_sets_1.txt new file mode 100644 index 00000000000..ab4101f6ddb --- /dev/null +++ b/tools/refactoring/scripts/comp_sets_1.txt @@ -0,0 +1,12 @@ +set radio 869.618,62.5,8,8 +set tx 20 +set af 2 +set rxdelay 3.5 +set txdelay 0.75 +set direct.txdelay 0.35 +set int.thresh 12 +set agc.reset.interval 40 +set multi.acks 1 +set path.hash.mode 1 +set cad on +set radio.rxgain off diff --git a/tools/refactoring/scripts/comp_sets_2.txt b/tools/refactoring/scripts/comp_sets_2.txt new file mode 100644 index 00000000000..4d6884346e8 --- /dev/null +++ b/tools/refactoring/scripts/comp_sets_2.txt @@ -0,0 +1,11 @@ +set tx 18 +set af 1.5 +set rxdelay 2.25 +set txdelay 0.6 +set direct.txdelay 0.15 +set int.thresh 7 +set agc.reset.interval 24 +set multi.acks 0 +set path.hash.mode 2 +set cad off +set radio.rxgain on diff --git a/tools/refactoring/scripts/server_gets.txt b/tools/refactoring/scripts/server_gets.txt new file mode 100644 index 00000000000..49f651106df --- /dev/null +++ b/tools/refactoring/scripts/server_gets.txt @@ -0,0 +1,31 @@ +ver +get role +get name +get radio +get freq +get tx +get af +get dutycycle +get rxdelay +get txdelay +get direct.txdelay +get int.thresh +get agc.reset.interval +get multi.acks +get path.hash.mode +get cad +get radio.rxgain +get lat +get lon +get advert.interval +get flood.advert.interval +get flood.max +get flood.max.unscoped +get flood.max.advert +get loop.detect +get allow.read.only +get owner.info +get adc.multiplier +get repeat +gps +gps advert diff --git a/tools/refactoring/scripts/server_sets_1.txt b/tools/refactoring/scripts/server_sets_1.txt new file mode 100644 index 00000000000..ae6d8824eee --- /dev/null +++ b/tools/refactoring/scripts/server_sets_1.txt @@ -0,0 +1,15 @@ +set radio 869.618,62.5,8,8 +set tx 20 +set af 2 +set rxdelay 3.5 +set txdelay 0.75 +set direct.txdelay 0.35 +set int.thresh 12 +set agc.reset.interval 40 +set multi.acks 1 +set path.hash.mode 1 +set cad on +set radio.rxgain off +gps on +set flood.max 32 +set owner.info set-by-base diff --git a/tools/refactoring/scripts/server_sets_2.txt b/tools/refactoring/scripts/server_sets_2.txt new file mode 100644 index 00000000000..2b607604036 --- /dev/null +++ b/tools/refactoring/scripts/server_sets_2.txt @@ -0,0 +1,13 @@ +set tx 18 +set af 1.5 +set rxdelay 2.25 +set txdelay 0.6 +set direct.txdelay 0.15 +set int.thresh 7 +set agc.reset.interval 24 +set multi.acks 0 +set path.hash.mode 0 +set cad off +set radio.rxgain on +set flood.max 48 +set owner.info set-by-B diff --git a/tools/refactoring/scripts/sx126x_inventory.py b/tools/refactoring/scripts/sx126x_inventory.py new file mode 100644 index 00000000000..6f85955f86a --- /dev/null +++ b/tools/refactoring/scripts/sx126x_inventory.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Measure how far the SX126x chip classes in src/helpers/radiolib differ. + +Usage: python3 tools/refactoring/scripts/sx126x_inventory.py [repo root] + +Cuts every member out of CustomSX1262.h, CustomSX1268.h and CustomLLCC68.h, +removes comments and white space and replaces the chip name with a placeholder, +then prints per member which chips carry an identical copy. For members that +differ, it prints a unified diff of the normalized lines against SX1262. +Lines outside the class body (includes) are compared the same way. +""" +import difflib +import hashlib +import re +import sys +from pathlib import Path + +CHIPS = ["SX1262", "SX1268", "LLCC68"] + + +def normalize(text, chip): + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + text = re.sub(r"//[^\n]*", "", text) + text = text.replace("Custom" + chip, "Custom@CHIP@").replace(chip, "@CHIP@") + lines = [re.sub(r"\s+", " ", l).strip() for l in text.splitlines()] + return [l for l in lines if l] + + +def members(lines): + """Split the normalized class body into members by brace depth.""" + out, head, cur, depth, in_class = {}, [], [], 0, False + for l in lines: + if not in_class: + if l.startswith("class "): + in_class = True + depth = l.count("{") - l.count("}") + else: + head.append(l) + continue + if depth == 1 and not cur and l in ("public:", "};"): + continue + cur.append(l) + depth += l.count("{") - l.count("}") + if depth == 1 and (l.endswith("}") or l.endswith(";")): + sig = next((c for c in cur if not c.startswith("#")), cur[0]) + name = re.search(r"([~\w]+)\s*\(", sig) + key = name.group(1) if name else sig + while key in out: + key += "'" + out[key] = cur + cur = [] + return head, out + + +def main(): + root = Path(sys.argv[1] if len(sys.argv) > 1 else ".") + parsed = {} + for chip in CHIPS: + src = (root / "src/helpers/radiolib" / f"Custom{chip}.h").read_text() + parsed[chip] = members(normalize(src, chip)) + + names = [] + for chip in CHIPS: + for n in ["(includes)"] + list(parsed[chip][1]): + if n not in names: + names.append(n) + + def body(chip, n): + head, ms = parsed[chip] + return head if n == "(includes)" else ms.get(n) + + print(f"{'member':<22} {'lines':>5} groups of identical copies") + diffs = [] + for n in names: + groups = {} + for chip in CHIPS: + b = body(chip, n) + h = hashlib.sha1("\n".join(b).encode()).hexdigest()[:8] if b is not None else "absent" + groups.setdefault(h, []).append(chip) + ln = len(body("SX1262", n) or []) + desc = " / ".join(", ".join(v) + ("" if k != "absent" else " (absent)") for k, v in groups.items()) + print(f"{n:<22} {ln:>5} {desc}") + if len(groups) > 1: + diffs.append(n) + + for n in diffs: + for chip in CHIPS[1:]: + a, b = body("SX1262", n) or [], body(chip, n) or [] + if a == b: + continue + print(f"\n--- {n}: SX1262 vs {chip}") + for l in difflib.unified_diff(a, b, "SX1262", chip, n=0, lineterm=""): + if not l.startswith(("---", "+++")): + print(l) + + +if __name__ == "__main__": + main() diff --git a/tools/refactoring/variant_duplication.py b/tools/refactoring/variant_duplication.py new file mode 100644 index 00000000000..d40a612247c --- /dev/null +++ b/tools/refactoring/variant_duplication.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Measure how much of the board variants' code is copied. + +Used for step 11 of the refactoring series (docs/refactoring/0011-variant-target.md) +to show the duplication before each cut and what is left after it. Python 3 +standard library only; run from the repository root: + + python3 tools/refactoring/variant_duplication.py + +For every function that a variant's target.cpp defines, it reports in how many +files the function exists and how many different bodies there are, once with +only runs of spaces collapsed (strict) and once with comments and all white space +removed (normalized). The FEM gain handlers of the board classes are compared +with the board class name replaced, since that name is the only thing they are +expected to differ in. +""" + +import collections +import glob +import re + +FUNC_HEAD = re.compile(r"^(?:[\w:<>*&]+\s+)+([\w:]+)\s*\([^;{)]*\)\s*(?:const\s*)?\{", re.M) + +# Board classes whose FEM gain handlers are expected to be copies of each other, +# plus the other boards that have handlers of the same name, for comparison. +FEM_BOARDS = [ + ("variants/heltec_v4/HeltecV4Board.cpp", "HeltecV4Board"), + ("variants/heltec_v4_r8/HeltecV4R8Board.cpp", "HeltecV4R8Board"), + ("variants/heltec_t096/T096Board.cpp", "T096Board"), + ("variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp", "HeltecTrackerV2Board"), + ("variants/lilygo_tbeam_1w/TBeam1WBoard.cpp", "TBeam1WBoard"), + ("variants/station_g3_esp32/StationG3Board.cpp", "StationG3Board"), + ("variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp", "WioTrackerL1Board"), +] + + +def body(src, start): + """The text of the function whose head ends at `start` (just after its `{`).""" + depth, i = 1, start + while depth: + depth += (src[i] == "{") - (src[i] == "}") + i += 1 + return src[:i] + + +def function(src, name): + m = re.search(r"^[^\n]*\b" + re.escape(name) + r"\s*\([^)]*\)\s*\{", src, re.M) + return None if m is None else body(src[m.start():], m.end() - m.start()) + + +def strict(text): + return re.sub(r" +", " ", text) + + +def normalized(text): + text = re.sub(r"//[^\n]*", "", text) + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"\s+", " ", text).strip() + + +def variant_functions(): + files = sorted(glob.glob("variants/*/target.cpp")) + seen = collections.defaultdict(list) # name -> [(variant, body)] + for path in files: + src = open(path).read() + for m in FUNC_HEAD.finditer(src): + seen[m.group(1)].append((path.split("/")[1], body(src[m.start():], m.end() - m.start()))) + return files, seen + + +def main(): + variant_dirs = sorted(glob.glob("variants/*/")) + files, seen = variant_functions() + print(f"variant directories: {len(variant_dirs)}, with target.cpp: {len(files)}") + lines = sum(len(open(f).read().splitlines()) for f in files) + print(f"lines in all target.cpp: {lines}") + print() + print(f"{'function':32s} {'files':>5s} {'strict':>6s} {'normal':>6s} largest normalized group") + for name, defs in sorted(seen.items(), key=lambda kv: -len(kv[1])): + if len(defs) < 2: + continue + strict_groups = collections.Counter(strict(b) for _, b in defs) + norm_groups = collections.Counter(normalized(b) for _, b in defs) + print(f"{name:32s} {len(defs):5d} {len(strict_groups):6d} {len(norm_groups):6d} " + f"{norm_groups.most_common(1)[0][1]}") + + print() + print("radio_new_identity() bodies:") + groups = collections.defaultdict(list) + for variant, b in seen.get("radio_new_identity", []): + groups[normalized(b)].append(variant) + for text, variants in sorted(groups.items(), key=lambda kv: -len(kv[1])): + listed = "" if len(variants) > 5 else " (" + ", ".join(variants) + ")" + print(f" {len(variants):3d} x {text}{listed}") + + print() + print("FEM gain handlers, board class name replaced by BOARD:") + for fn in ("attachDynamicPrefs", "handleCommand"): + groups = collections.defaultdict(list) + for path, cls in FEM_BOARDS: + b = function(open(path).read(), fn) + groups["" if b is None else normalized(b.replace(cls, "BOARD"))].append(cls) + print(f" {fn}: {len(groups)} distinct bodies") + for text, classes in groups.items(): + print(f" {len(text):5d} chars: {', '.join(classes)}") + + +if __name__ == "__main__": + main() diff --git a/variants/ebyte_eora_s3/target.cpp b/variants/ebyte_eora_s3/target.cpp index 91d851ff3e2..ee965c2dc68 100644 --- a/variants/ebyte_eora_s3/target.cpp +++ b/variants/ebyte_eora_s3/target.cpp @@ -63,8 +63,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/gat562_30s_mesh_kit/target.cpp b/variants/gat562_30s_mesh_kit/target.cpp index 8f84a7a19ef..11ee09f07aa 100644 --- a/variants/gat562_30s_mesh_kit/target.cpp +++ b/variants/gat562_30s_mesh_kit/target.cpp @@ -32,13 +32,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/gat562_mesh_evb_pro/target.cpp b/variants/gat562_mesh_evb_pro/target.cpp index 8dc203de979..d1b021c8cf4 100644 --- a/variants/gat562_mesh_evb_pro/target.cpp +++ b/variants/gat562_mesh_evb_pro/target.cpp @@ -31,13 +31,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/gat562_mesh_tracker_pro/target.cpp b/variants/gat562_mesh_tracker_pro/target.cpp index d16b453810b..ad85c842f2d 100644 --- a/variants/gat562_mesh_tracker_pro/target.cpp +++ b/variants/gat562_mesh_tracker_pro/target.cpp @@ -32,13 +32,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/gat562_mesh_watch13/target.cpp b/variants/gat562_mesh_watch13/target.cpp index 95bcf6dc280..dddd24485ab 100644 --- a/variants/gat562_mesh_watch13/target.cpp +++ b/variants/gat562_mesh_watch13/target.cpp @@ -23,13 +23,3 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); EnvironmentSensorManager sensors; - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/generic-e22/target.cpp b/variants/generic-e22/target.cpp index 26bba1c0754..0e7e69d0a3e 100644 --- a/variants/generic-e22/target.cpp +++ b/variants/generic-e22/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/heltec_ct62/target.cpp b/variants/heltec_ct62/target.cpp index 522c3199225..bfd651d772f 100644 --- a/variants/heltec_ct62/target.cpp +++ b/variants/heltec_ct62/target.cpp @@ -15,8 +15,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_e213/target.cpp b/variants/heltec_e213/target.cpp index 6914769d9c8..12e22b30a8c 100644 --- a/variants/heltec_e213/target.cpp +++ b/variants/heltec_e213/target.cpp @@ -32,8 +32,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_e290/target.cpp b/variants/heltec_e290/target.cpp index f23279548f9..bcfdbcdba26 100644 --- a/variants/heltec_e290/target.cpp +++ b/variants/heltec_e290/target.cpp @@ -32,8 +32,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_mesh_solar/MeshSolarBoard.h b/variants/heltec_mesh_solar/MeshSolarBoard.h index 81633625597..6fa2742d2cc 100644 --- a/variants/heltec_mesh_solar/MeshSolarBoard.h +++ b/variants/heltec_mesh_solar/MeshSolarBoard.h @@ -6,6 +6,8 @@ #ifdef HELTEC_MESH_SOLAR #include "meshSolarApp.h" +// meshsolar's logger.h defines NONE as a macro, which clashes with InterfaceType::NONE +#undef NONE #endif // LoRa radio module pins for Heltec T114 diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index 94e2684c12a..c89eea6b800 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -1,6 +1,7 @@ #include #include "target.h" #include +#include #include MeshSolarBoard board; @@ -19,16 +20,6 @@ SolarExternalWatchdog external_watchdog; DISPLAY_CLASS display; #endif -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void SolarSensorManager::start_gps() { if (!gps_active) { gps_active = true; @@ -66,11 +57,11 @@ bool SolarSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& } void SolarSensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _location->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (_location->isValid()) { node_lat = ((double)_location->getLatitude())/1000000.; node_lon = ((double)_location->getLongitude())/1000000.; diff --git a/variants/heltec_rc32/target.cpp b/variants/heltec_rc32/target.cpp index cc7e4deb740..be6d73b87bc 100644 --- a/variants/heltec_rc32/target.cpp +++ b/variants/heltec_rc32/target.cpp @@ -45,8 +45,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/heltec_t096/T096Board.cpp b/variants/heltec_t096/T096Board.cpp index ea26d2cc15a..860f79d23e1 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -136,45 +136,9 @@ bool T096Board::isLoRaFemLnaEnabled() const { } void T096Board::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; - - char radio_fem_rxgain[8] = { 0 }; - _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values - - setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + fem_rxgain.attachPrefs(*this, prefs); } bool T096Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { - if (strcmp(command, "get radio.fem.rxgain") == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); - } - return true; - } - if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else if (memcmp(&command[21], "on", 2) == 0) { - if (setLoRaFemLnaEnabled(true)) { - _prefs->setByKey("fem_rxgain", "1"); - strcpy(reply, "OK - LoRa FEM RX gain on"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else if (memcmp(&command[21], "off", 3) == 0) { - if (setLoRaFemLnaEnabled(false)) { - _prefs->setByKey("fem_rxgain", "0"); - strcpy(reply, "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } - - return false; // not handled + return fem_rxgain.handleCommand(*this, command, reply); } diff --git a/variants/heltec_t096/T096Board.h b/variants/heltec_t096/T096Board.h index e926c8a6a31..b9715ae18c0 100644 --- a/variants/heltec_t096/T096Board.h +++ b/variants/heltec_t096/T096Board.h @@ -5,10 +5,12 @@ #include #include #include +#include #include "LoRaFEMControl.h" class T096Board : public NRF52BoardDCDC { - KeyValueStore* _prefs = NULL; + friend class FemRxGain; + FemRxGain fem_rxgain; protected: #ifdef NRF52_POWER_MANAGEMENT diff --git a/variants/heltec_t096/target.cpp b/variants/heltec_t096/target.cpp index 30509977eee..34452dfbf3f 100644 --- a/variants/heltec_t096/target.cpp +++ b/variants/heltec_t096/target.cpp @@ -32,18 +32,3 @@ EnvironmentSensorManager sensors; DISPLAY_CLASS display(&board.periph_power); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - -#if defined(P_LORA_SCLK) - return radio.std_init(&SPI); -#else - return radio.std_init(); -#endif -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_t1/target.cpp b/variants/heltec_t1/target.cpp index af771582dab..cc372637d90 100644 --- a/variants/heltec_t1/target.cpp +++ b/variants/heltec_t1/target.cpp @@ -31,18 +31,3 @@ EnvironmentSensorManager sensors; DISPLAY_CLASS display(&board.periph_power); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - -#if defined(P_LORA_SCLK) - return radio.std_init(&SPI); -#else - return radio.std_init(); -#endif -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/heltec_t114/target.cpp b/variants/heltec_t114/target.cpp index 90a902f804a..625812d2d56 100644 --- a/variants/heltec_t114/target.cpp +++ b/variants/heltec_t114/target.cpp @@ -32,18 +32,3 @@ EnvironmentSensorManager sensors; DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - -#if defined(P_LORA_SCLK) - return radio.std_init(&SPI); -#else - return radio.std_init(); -#endif -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_t190/target.cpp b/variants/heltec_t190/target.cpp index 6a2eab7d847..3c32f047eb4 100644 --- a/variants/heltec_t190/target.cpp +++ b/variants/heltec_t190/target.cpp @@ -32,8 +32,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_tower_v2/target.cpp b/variants/heltec_tower_v2/target.cpp index ec8e7a17dac..54c2621e80b 100644 --- a/variants/heltec_tower_v2/target.cpp +++ b/variants/heltec_tower_v2/target.cpp @@ -21,16 +21,6 @@ DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} - bool TowerV2ExternalWatchdog::begin() { last_feed_watchdog = 0; pinMode(EXTERNAL_WATCHDOG_WAKE_PIN, INPUT); diff --git a/variants/heltec_tracker/target.cpp b/variants/heltec_tracker/target.cpp index f32c41ff477..7d88a33210e 100644 --- a/variants/heltec_tracker/target.cpp +++ b/variants/heltec_tracker/target.cpp @@ -1,6 +1,7 @@ #include #include "target.h" +#include #include HeltecV3Board board; @@ -37,11 +38,6 @@ bool radio_init() { } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void HWTSensorManager::start_gps() { if (!gps_active) { _location->begin(); // Claims periph_power via RefCountedDigitalPin @@ -71,11 +67,11 @@ bool HWTSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& t } void HWTSensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _location->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (gps_active && _location->isValid()) { node_lat = ((double)_location->getLatitude())/1000000.; node_lon = ((double)_location->getLongitude())/1000000.; diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 753824a05ae..a420ca63bc1 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -77,45 +77,9 @@ void HeltecTrackerV2Board::begin() { } void HeltecTrackerV2Board::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; - - char radio_fem_rxgain[8] = { 0 }; - _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values - - setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + fem_rxgain.attachPrefs(*this, prefs); } bool HeltecTrackerV2Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { - if (strcmp(command, "get radio.fem.rxgain") == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); - } - return true; - } - if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else if (memcmp(&command[21], "on", 2) == 0) { - if (setLoRaFemLnaEnabled(true)) { - _prefs->setByKey("fem_rxgain", "1"); - strcpy(reply, "OK - LoRa FEM RX gain on"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else if (memcmp(&command[21], "off", 3) == 0) { - if (setLoRaFemLnaEnabled(false)) { - _prefs->setByKey("fem_rxgain", "0"); - strcpy(reply, "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } - - return false; // not handled + return fem_rxgain.handleCommand(*this, command, reply); } diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h index 5fed2b65559..098fec4c860 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h @@ -3,10 +3,12 @@ #include #include #include +#include #include "LoRaFEMControl.h" class HeltecTrackerV2Board : public ESP32Board { - KeyValueStore* _prefs = NULL; + friend class FemRxGain; + FemRxGain fem_rxgain; bool setLoRaFemLnaEnabled(bool enable); bool isLoRaFemLnaEnabled() const; diff --git a/variants/heltec_tracker_v2/target.cpp b/variants/heltec_tracker_v2/target.cpp index c48a29f6a05..6d042913666 100644 --- a/variants/heltec_tracker_v2/target.cpp +++ b/variants/heltec_tracker_v2/target.cpp @@ -38,9 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/heltec_v2/target.cpp b/variants/heltec_v2/target.cpp index 585b0a37749..5698f4095cc 100644 --- a/variants/heltec_v2/target.cpp +++ b/variants/heltec_v2/target.cpp @@ -31,8 +31,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/heltec_v3/target.cpp b/variants/heltec_v3/target.cpp index e6af466a84d..b467bd98c5a 100644 --- a/variants/heltec_v3/target.cpp +++ b/variants/heltec_v3/target.cpp @@ -38,9 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 794257d76ad..f5c1937032f 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -78,45 +78,9 @@ void HeltecV4Board::begin() { } void HeltecV4Board::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; - - char radio_fem_rxgain[8] = { 0 }; - _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values - - setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + fem_rxgain.attachPrefs(*this, prefs); } bool HeltecV4Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { - if (strcmp(command, "get radio.fem.rxgain") == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); - } - return true; - } - if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else if (memcmp(&command[21], "on", 2) == 0) { - if (setLoRaFemLnaEnabled(true)) { - _prefs->setByKey("fem_rxgain", "1"); - strcpy(reply, "OK - LoRa FEM RX gain on"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else if (memcmp(&command[21], "off", 3) == 0) { - if (setLoRaFemLnaEnabled(false)) { - _prefs->setByKey("fem_rxgain", "0"); - strcpy(reply, "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } - - return false; // not handled + return fem_rxgain.handleCommand(*this, command, reply); } diff --git a/variants/heltec_v4/HeltecV4Board.h b/variants/heltec_v4/HeltecV4Board.h index 79a690e8407..89c6c4cff91 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "LoRaFEMControl.h" #ifndef ADC_MULTIPLIER @@ -10,7 +11,8 @@ #endif class HeltecV4Board : public ESP32Board { - KeyValueStore* _prefs = NULL; + friend class FemRxGain; + FemRxGain fem_rxgain; bool setLoRaFemLnaEnabled(bool enable); bool isLoRaFemLnaEnabled() const; diff --git a/variants/heltec_v4/target.cpp b/variants/heltec_v4/target.cpp index 8483f276891..9f163e1a8db 100644 --- a/variants/heltec_v4/target.cpp +++ b/variants/heltec_v4/target.cpp @@ -38,9 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index ea2cfbedb4a..d8f7585ab9d 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -100,45 +100,9 @@ bool HeltecV4R8Board::isLoRaFemLnaEnabled() const { } void HeltecV4R8Board::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; - - char radio_fem_rxgain[8] = { 0 }; - _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values - - setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + fem_rxgain.attachPrefs(*this, prefs); } bool HeltecV4R8Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { - if (strcmp(command, "get radio.fem.rxgain") == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); - } - return true; - } - if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else if (memcmp(&command[21], "on", 2) == 0) { - if (setLoRaFemLnaEnabled(true)) { - _prefs->setByKey("fem_rxgain", "1"); - strcpy(reply, "OK - LoRa FEM RX gain on"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else if (memcmp(&command[21], "off", 3) == 0) { - if (setLoRaFemLnaEnabled(false)) { - _prefs->setByKey("fem_rxgain", "0"); - strcpy(reply, "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } - - return false; // not handled + return fem_rxgain.handleCommand(*this, command, reply); } diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.h b/variants/heltec_v4_r8/HeltecV4R8Board.h index 7ab29f96200..7f9956317ea 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.h +++ b/variants/heltec_v4_r8/HeltecV4R8Board.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "LoRaFEMControl.h" #ifndef ADC_MULTIPLIER @@ -11,7 +12,8 @@ #endif class HeltecV4R8Board : public ESP32Board { - KeyValueStore* _prefs = NULL; + friend class FemRxGain; + FemRxGain fem_rxgain; bool setLoRaFemLnaEnabled(bool enable); bool isLoRaFemLnaEnabled() const; diff --git a/variants/heltec_v4_r8/target.cpp b/variants/heltec_v4_r8/target.cpp index 0b38531e3c0..2f5390c5961 100644 --- a/variants/heltec_v4_r8/target.cpp +++ b/variants/heltec_v4_r8/target.cpp @@ -38,8 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/heltec_wireless_paper/target.cpp b/variants/heltec_wireless_paper/target.cpp index be21695ec4e..91ee526f119 100644 --- a/variants/heltec_wireless_paper/target.cpp +++ b/variants/heltec_wireless_paper/target.cpp @@ -31,9 +31,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/ikoka_handheld_nrf/target.cpp b/variants/ikoka_handheld_nrf/target.cpp index 6c53c5e324a..6f658f83195 100644 --- a/variants/ikoka_handheld_nrf/target.cpp +++ b/variants/ikoka_handheld_nrf/target.cpp @@ -18,15 +18,3 @@ EnvironmentSensorManager sensors; MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/ikoka_nano_nrf/target.cpp b/variants/ikoka_nano_nrf/target.cpp index baf02c3d122..85177c24a05 100644 --- a/variants/ikoka_nano_nrf/target.cpp +++ b/variants/ikoka_nano_nrf/target.cpp @@ -16,15 +16,3 @@ WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); EnvironmentSensorManager sensors; - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/ikoka_stick_nrf/target.cpp b/variants/ikoka_stick_nrf/target.cpp index bb140505a86..c48f677de60 100644 --- a/variants/ikoka_stick_nrf/target.cpp +++ b/variants/ikoka_stick_nrf/target.cpp @@ -16,15 +16,3 @@ WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); EnvironmentSensorManager sensors; - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/keepteen_lt1/target.cpp b/variants/keepteen_lt1/target.cpp index f1879ac569c..396c2fa711e 100644 --- a/variants/keepteen_lt1/target.cpp +++ b/variants/keepteen_lt1/target.cpp @@ -22,16 +22,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/lilygo_t3s3/target.cpp b/variants/lilygo_t3s3/target.cpp index 34df31c3b17..2679f6f914e 100644 --- a/variants/lilygo_t3s3/target.cpp +++ b/variants/lilygo_t3s3/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_t3s3_sx1276/target.cpp b/variants/lilygo_t3s3_sx1276/target.cpp index 770db4a7746..5e1c352d822 100644 --- a/variants/lilygo_t3s3_sx1276/target.cpp +++ b/variants/lilygo_t3s3_sx1276/target.cpp @@ -32,8 +32,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/lilygo_t_impulse_plus/target.cpp b/variants/lilygo_t_impulse_plus/target.cpp index 87b3dbfed7e..cb34ae6e83f 100644 --- a/variants/lilygo_t_impulse_plus/target.cpp +++ b/variants/lilygo_t_impulse_plus/target.cpp @@ -23,15 +23,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_tbeam_1w/target.cpp b/variants/lilygo_tbeam_1w/target.cpp index 6755df09f59..b98d8924c37 100644 --- a/variants/lilygo_tbeam_1w/target.cpp +++ b/variants/lilygo_tbeam_1w/target.cpp @@ -42,9 +42,3 @@ bool radio_init() { } return success; } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} - diff --git a/variants/lilygo_tbeam_SX1262/target.cpp b/variants/lilygo_tbeam_SX1262/target.cpp index 6e5292a77ec..d5ea9ad1f8c 100644 --- a/variants/lilygo_tbeam_SX1262/target.cpp +++ b/variants/lilygo_tbeam_SX1262/target.cpp @@ -33,9 +33,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_tbeam_SX1276/target.cpp b/variants/lilygo_tbeam_SX1276/target.cpp index 30f23dab78f..35b2c0af3fc 100644 --- a/variants/lilygo_tbeam_SX1276/target.cpp +++ b/variants/lilygo_tbeam_SX1276/target.cpp @@ -38,8 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/lilygo_tbeam_supreme_SX1262/target.cpp b/variants/lilygo_tbeam_supreme_SX1262/target.cpp index a0b765bf6d1..a0e11204235 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/target.cpp +++ b/variants/lilygo_tbeam_supreme_SX1262/target.cpp @@ -30,9 +30,3 @@ bool radio_init() { rtc_clock.begin(Wire1); return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_tdeck/target.cpp b/variants/lilygo_tdeck/target.cpp index d20c56a3e9d..f63c06f6099 100644 --- a/variants/lilygo_tdeck/target.cpp +++ b/variants/lilygo_tdeck/target.cpp @@ -33,8 +33,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/lilygo_techo/TechoBoard.cpp b/variants/lilygo_techo/TechoBoard.cpp index 81d3d0c9af9..f57c2119d41 100644 --- a/variants/lilygo_techo/TechoBoard.cpp +++ b/variants/lilygo_techo/TechoBoard.cpp @@ -2,12 +2,19 @@ #include #include "TechoBoard.h" +#include #ifdef LILYGO_TECHO void TechoBoard::begin() { NRF52Board::begin(); + // A soft reset can leave the RTC or sensor holding SDA low; without this the + // first Wire transfer (RTC autodiscovery) never returns. + I2cBusClearResult i2c = i2cBusClearNrf52(PIN_WIRE_SDA, PIN_WIRE_SCL); + if (i2c.sda_low_at_start) { + MESH_DEBUG_PRINTLN("I2C bus clear: %d clocks, %s", i2c.pulses, i2c.ok ? "released" : "still stuck"); + } Wire.begin(); pinMode(SX126X_POWER_EN, OUTPUT); diff --git a/variants/lilygo_techo/target.cpp b/variants/lilygo_techo/target.cpp index 278fc76dad5..e3f797e5c94 100644 --- a/variants/lilygo_techo/target.cpp +++ b/variants/lilygo_techo/target.cpp @@ -24,16 +24,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/lilygo_techo_card/target.cpp b/variants/lilygo_techo_card/target.cpp index 8bbe036338a..15059da30f7 100644 --- a/variants/lilygo_techo_card/target.cpp +++ b/variants/lilygo_techo_card/target.cpp @@ -25,14 +25,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); MomentaryButton back_btn(PIN_BUTTON2, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/lilygo_techo_lite/target.cpp b/variants/lilygo_techo_lite/target.cpp index 95a62fcd250..6bff6ad95dd 100644 --- a/variants/lilygo_techo_lite/target.cpp +++ b/variants/lilygo_techo_lite/target.cpp @@ -23,16 +23,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/lilygo_teth_elite/target.cpp b/variants/lilygo_teth_elite/target.cpp index 4dc377d6202..7afbace768e 100644 --- a/variants/lilygo_teth_elite/target.cpp +++ b/variants/lilygo_teth_elite/target.cpp @@ -36,8 +36,3 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { void radio_set_tx_power(int8_t dbm) { radio.setOutputPower(dbm); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/lilygo_tlora_c6/target.cpp b/variants/lilygo_tlora_c6/target.cpp index d70e9b79177..02dfcd52a35 100644 --- a/variants/lilygo_tlora_c6/target.cpp +++ b/variants/lilygo_tlora_c6/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_tlora_v2_1/target.cpp b/variants/lilygo_tlora_v2_1/target.cpp index 8c120161505..00ae597b84c 100644 --- a/variants/lilygo_tlora_v2_1/target.cpp +++ b/variants/lilygo_tlora_v2_1/target.cpp @@ -27,8 +27,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/mesh_pocket/target.cpp b/variants/mesh_pocket/target.cpp index ee8f944de41..79af757a1cb 100644 --- a/variants/mesh_pocket/target.cpp +++ b/variants/mesh_pocket/target.cpp @@ -22,9 +22,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/meshadventurer/target.cpp b/variants/meshadventurer/target.cpp index 8795a4cbe73..8241cac144d 100644 --- a/variants/meshadventurer/target.cpp +++ b/variants/meshadventurer/target.cpp @@ -1,6 +1,7 @@ #include #include "target.h" +#include #include MeshadventurerBoard board; @@ -30,11 +31,6 @@ bool radio_init() { #endif } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void MASensorManager::start_gps() { if(!gps_active) { @@ -65,9 +61,9 @@ bool MASensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& te } void MASensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _location->loop(); - if(millis() > next_gps_update && gps_active) { + if(mesh::millisHasPassed(millis(), next_gps_update) && gps_active) { if(_location->isValid()) { node_lat = ((double)_location->getLatitude()) / 1000000.; node_lon = ((double)_location->getLongitude()) / 1000000.; diff --git a/variants/meshnology_w12/target.cpp b/variants/meshnology_w12/target.cpp index bf04ba36470..aa2e0e13060 100644 --- a/variants/meshnology_w12/target.cpp +++ b/variants/meshnology_w12/target.cpp @@ -65,9 +65,3 @@ bool radio_init() { return true; } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/meshtiny/target.cpp b/variants/meshtiny/target.cpp index 50d678266e8..179ed75d31a 100644 --- a/variants/meshtiny/target.cpp +++ b/variants/meshtiny/target.cpp @@ -20,13 +20,3 @@ DISPLAY_CLASS display; MomentaryButton joystick_right(ENCODER_RIGHT, 1000, true, true); MomentaryButton back_btn(PIN_SIDE_BUTTON, 1000, true, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/meshtracker_x1/platformio.ini b/variants/meshtracker_x1/platformio.ini index 2194d48d986..80286460bb6 100644 --- a/variants/meshtracker_x1/platformio.ini +++ b/variants/meshtracker_x1/platformio.ini @@ -34,6 +34,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/meshtracker_x1> lib_deps = ${nrf52_base.lib_deps} https://github.com/adafruit/Adafruit_SPA06_003/archive/refs/tags/1.0.2.zip + stevemarple/MicroNMEA @ ^2.0.6 debug_tool = jlink upload_protocol = nrfutil @@ -51,7 +52,6 @@ build_flags = ${MeshTracker_X1.build_flags} build_src_filter = ${MeshTracker_X1.build_src_filter} +<../examples/simple_repeater> lib_deps = ${MeshTracker_X1.lib_deps} - stevemarple/MicroNMEA @ ^2.0.6 [env:MeshTracker_X1_room_server] extends = MeshTracker_X1 @@ -67,7 +67,6 @@ build_flags = ${MeshTracker_X1.build_flags} build_src_filter = ${MeshTracker_X1.build_src_filter} +<../examples/simple_room_server> lib_deps = ${MeshTracker_X1.lib_deps} - stevemarple/MicroNMEA @ ^2.0.6 [env:MeshTracker_X1_companion_radio_usb] extends = MeshTracker_X1 @@ -94,7 +93,6 @@ build_src_filter = ${MeshTracker_X1.build_src_filter} +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${MeshTracker_X1.lib_deps} densaugeo/base64 @ ~1.4.0 - stevemarple/MicroNMEA @ ^2.0.6 end2endzone/NonBlockingRTTTL@^1.3.0 adafruit/Adafruit DRV2605 Library @ ^1.2.4 @@ -127,7 +125,6 @@ build_src_filter = ${MeshTracker_X1.build_src_filter} +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${MeshTracker_X1.lib_deps} densaugeo/base64 @ ~1.4.0 - stevemarple/MicroNMEA @ ^2.0.6 end2endzone/NonBlockingRTTTL@^1.3.0 adafruit/Adafruit DRV2605 Library @ ^1.2.4 diff --git a/variants/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp index 5b90d660934..a04df3f01cf 100644 --- a/variants/meshtracker_x1/target.cpp +++ b/variants/meshtracker_x1/target.cpp @@ -1,5 +1,6 @@ #include #include "target.h" +#include #include #include @@ -21,11 +22,6 @@ bool radio_init() { return radio.std_init(&SPI); } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void MeshTrackerX1SensorManager::start_gps() { gps_active = true; digitalWrite(GPS_EN, HIGH); @@ -75,12 +71,12 @@ bool MeshTrackerX1SensorManager::querySensors(uint8_t requester_permissions, Cay } void MeshTrackerX1SensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _nmea->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (gps_active && _nmea->isValid()) { node_lat = ((double)_nmea->getLatitude())/1000000.; node_lon = ((double)_nmea->getLongitude())/1000000.; diff --git a/variants/minewsemi_me25ls01/target.cpp b/variants/minewsemi_me25ls01/target.cpp index 41fc71a7d9d..ade9e7666ab 100644 --- a/variants/minewsemi_me25ls01/target.cpp +++ b/variants/minewsemi_me25ls01/target.cpp @@ -76,8 +76,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/muzi_base/target.cpp b/variants/muzi_base/target.cpp index 5bd846c9861..5a54999c37a 100644 --- a/variants/muzi_base/target.cpp +++ b/variants/muzi_base/target.cpp @@ -155,8 +155,3 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { void radio_set_tx_power(int8_t dbm) { radio.setOutputPower(dbm); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/muziworks_r1_neo/target.cpp b/variants/muziworks_r1_neo/target.cpp index f667a2827e1..401eab17b0e 100644 --- a/variants/muziworks_r1_neo/target.cpp +++ b/variants/muziworks_r1_neo/target.cpp @@ -25,13 +25,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/nano_g2_ultra/target.cpp b/variants/nano_g2_ultra/target.cpp index 69a2772ccb1..2e3c9c5622c 100644 --- a/variants/nano_g2_ultra/target.cpp +++ b/variants/nano_g2_ultra/target.cpp @@ -2,6 +2,7 @@ #include #include +#include #include NanoG2Ultra board; @@ -20,11 +21,6 @@ DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - void NanoG2UltraSensorManager::start_gps() { MESH_DEBUG_PRINTLN("Starting GPS"); if (!gps_active) { @@ -73,7 +69,7 @@ bool NanoG2UltraSensorManager::querySensors(uint8_t requester_permissions, Cayen } void NanoG2UltraSensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; if (!gps_active) { return; // GPS is not active, skip further processing @@ -81,7 +77,7 @@ void NanoG2UltraSensorManager::loop() { _location->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (_location->isValid()) { node_lat = ((double)_location->getLatitude()) / 1000000.; node_lon = ((double)_location->getLongitude()) / 1000000.; @@ -121,8 +117,3 @@ bool NanoG2UltraSensorManager::setSettingValue(const char *name, const char *val } return false; // not supported } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/nibble_screen_connect/target.cpp b/variants/nibble_screen_connect/target.cpp index ceb9fe49cf0..6609338e757 100644 --- a/variants/nibble_screen_connect/target.cpp +++ b/variants/nibble_screen_connect/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} - diff --git a/variants/nibble_zero_connect/target.cpp b/variants/nibble_zero_connect/target.cpp index d53b6a1c3d8..2679f6f914e 100644 --- a/variants/nibble_zero_connect/target.cpp +++ b/variants/nibble_zero_connect/target.cpp @@ -26,10 +26,3 @@ bool radio_init() { return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} - - diff --git a/variants/promicro/target.cpp b/variants/promicro/target.cpp index b5a74c90dce..a476bf7e487 100644 --- a/variants/promicro/target.cpp +++ b/variants/promicro/target.cpp @@ -22,16 +22,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/rak11310/target.cpp b/variants/rak11310/target.cpp index 4c288975097..9727a2908ab 100644 --- a/variants/rak11310/target.cpp +++ b/variants/rak11310/target.cpp @@ -21,8 +21,3 @@ bool radio_init() { void radio_set_tx_power(int8_t dbm) { radio.setOutputPower(dbm); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/rak3112/target.cpp b/variants/rak3112/target.cpp index 9df1bf8f8ca..214426d474c 100644 --- a/variants/rak3112/target.cpp +++ b/variants/rak3112/target.cpp @@ -38,9 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/rak3401/target.cpp b/variants/rak3401/target.cpp index 5309e6b22b9..a1a73588577 100644 --- a/variants/rak3401/target.cpp +++ b/variants/rak3401/target.cpp @@ -31,14 +31,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/rak3x72/target.cpp b/variants/rak3x72/target.cpp index e55543a324f..1e94b62c6ef 100644 --- a/variants/rak3x72/target.cpp +++ b/variants/rak3x72/target.cpp @@ -54,8 +54,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/rak4631/target.cpp b/variants/rak4631/target.cpp index a41ba720751..b7691dd26c3 100644 --- a/variants/rak4631/target.cpp +++ b/variants/rak4631/target.cpp @@ -31,13 +31,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/rak_wismesh_tag/target.cpp b/variants/rak_wismesh_tag/target.cpp index cdf81f4f5b9..95f8961eb2e 100644 --- a/variants/rak_wismesh_tag/target.cpp +++ b/variants/rak_wismesh_tag/target.cpp @@ -27,14 +27,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/rpi_picow/target.cpp b/variants/rpi_picow/target.cpp index 9318b7f0f76..f586405b821 100644 --- a/variants/rpi_picow/target.cpp +++ b/variants/rpi_picow/target.cpp @@ -17,9 +17,3 @@ bool radio_init() { return radio.std_init(&SPI1); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/sensecap_solar/target.cpp b/variants/sensecap_solar/target.cpp index 466415176f8..01574e9f796 100644 --- a/variants/sensecap_solar/target.cpp +++ b/variants/sensecap_solar/target.cpp @@ -18,15 +18,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors = EnvironmentSensorManager(); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/station_g2/target.cpp b/variants/station_g2/target.cpp index 8018c20eb88..743fb988da4 100644 --- a/variants/station_g2/target.cpp +++ b/variants/station_g2/target.cpp @@ -39,9 +39,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/station_g3_esp32/target.cpp b/variants/station_g3_esp32/target.cpp index d3de074ba81..2cd01990e1f 100644 --- a/variants/station_g3_esp32/target.cpp +++ b/variants/station_g3_esp32/target.cpp @@ -39,8 +39,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp index 70a64359fd1..22a5b87c0fe 100644 --- a/variants/t1000-e/target.cpp +++ b/variants/t1000-e/target.cpp @@ -1,6 +1,7 @@ #include #include "t1000e_sensors.h" #include "target.h" +#include #include #include @@ -75,11 +76,6 @@ bool radio_init() { return true; // success } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void T1000SensorManager::start_gps() { gps_active = true; digitalWrite(GPS_EN, HIGH); @@ -120,11 +116,11 @@ bool T1000SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& } void T1000SensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _nmea->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (gps_active && _nmea->isValid()) { node_lat = ((double)_nmea->getLatitude())/1000000.; node_lon = ((double)_nmea->getLongitude())/1000000.; diff --git a/variants/tenstar_c3/target.cpp b/variants/tenstar_c3/target.cpp index 909625edfc8..b800e2df6c4 100644 --- a/variants/tenstar_c3/target.cpp +++ b/variants/tenstar_c3/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/thinknode_m1/target.cpp b/variants/thinknode_m1/target.cpp index 69306fc0e1e..3a0a7f04f98 100644 --- a/variants/thinknode_m1/target.cpp +++ b/variants/thinknode_m1/target.cpp @@ -1,6 +1,7 @@ #include #include "target.h" #include +#include #include ThinkNodeM1Board board; @@ -19,16 +20,6 @@ ThinkNodeM1SensorManager sensors = ThinkNodeM1SensorManager(nmea); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void ThinkNodeM1SensorManager::start_gps() { if (!gps_active) { gps_active = true; @@ -69,8 +60,8 @@ bool ThinkNodeM1SensorManager::querySensors(uint8_t requester_permissions, Cayen } void ThinkNodeM1SensorManager::loop() { - static long next_gps_update = 0; - static long last_switch_check = 0; + static uint32_t next_gps_update = 0; + static uint32_t last_switch_check = 0; // Check GPS switch state every second if (millis() - last_switch_check > 1000) { @@ -98,7 +89,7 @@ void ThinkNodeM1SensorManager::loop() { _location->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (_location->isValid()) { node_lat = ((double)_location->getLatitude())/1000000.; node_lon = ((double)_location->getLongitude())/1000000.; diff --git a/variants/thinknode_m2/target.cpp b/variants/thinknode_m2/target.cpp index ff0dd6cf4db..a75604d4879 100644 --- a/variants/thinknode_m2/target.cpp +++ b/variants/thinknode_m2/target.cpp @@ -33,10 +33,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/thinknode_m3/target.cpp b/variants/thinknode_m3/target.cpp index 1e6c2ad13cb..ea710f74fac 100644 --- a/variants/thinknode_m3/target.cpp +++ b/variants/thinknode_m3/target.cpp @@ -77,8 +77,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/thinknode_m4/target.cpp b/variants/thinknode_m4/target.cpp index 7da3f400d48..c10fda51a56 100644 --- a/variants/thinknode_m4/target.cpp +++ b/variants/thinknode_m4/target.cpp @@ -77,8 +77,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/thinknode_m5/target.cpp b/variants/thinknode_m5/target.cpp index be534f67d75..6fea04e2b41 100644 --- a/variants/thinknode_m5/target.cpp +++ b/variants/thinknode_m5/target.cpp @@ -40,10 +40,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/thinknode_m6/target.cpp b/variants/thinknode_m6/target.cpp index de167194e40..f8258af1d02 100644 --- a/variants/thinknode_m6/target.cpp +++ b/variants/thinknode_m6/target.cpp @@ -22,14 +22,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/thinknode_m7/target.cpp b/variants/thinknode_m7/target.cpp index f4b898d8e93..9d164855fa5 100644 --- a/variants/thinknode_m7/target.cpp +++ b/variants/thinknode_m7/target.cpp @@ -75,10 +75,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/thinknode_m8/target.cpp b/variants/thinknode_m8/target.cpp index ac4ac100edc..5bf588139a1 100644 --- a/variants/thinknode_m8/target.cpp +++ b/variants/thinknode_m8/target.cpp @@ -24,13 +24,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); RotaryInputGPIO rotary_input(PIN_BUTTON1_A, PIN_BUTTON1_B); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/thinknode_m9/target.cpp b/variants/thinknode_m9/target.cpp index 3cbd7d758a8..8d8ccc86d85 100644 --- a/variants/thinknode_m9/target.cpp +++ b/variants/thinknode_m9/target.cpp @@ -71,8 +71,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/tiny_relay/target.cpp b/variants/tiny_relay/target.cpp index aed8a67c642..c96c04338d1 100644 --- a/variants/tiny_relay/target.cpp +++ b/variants/tiny_relay/target.cpp @@ -56,9 +56,3 @@ bool radio_init() return true; // success } - -mesh::LocalIdentity radio_new_identity() -{ - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/waveshare_esp32s3_lr1121/target.cpp b/variants/waveshare_esp32s3_lr1121/target.cpp index 56775c4cef7..a9c9a00e5a3 100644 --- a/variants/waveshare_esp32s3_lr1121/target.cpp +++ b/variants/waveshare_esp32s3_lr1121/target.cpp @@ -56,8 +56,3 @@ bool radio_init() { return true; } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/waveshare_rp2040_lora/target.cpp b/variants/waveshare_rp2040_lora/target.cpp index ef47708fb34..7478189391d 100644 --- a/variants/waveshare_rp2040_lora/target.cpp +++ b/variants/waveshare_rp2040_lora/target.cpp @@ -27,9 +27,3 @@ bool radio_init() { //passing NULL skips init of SPI return radio.std_init(NULL); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/wio-e5-dev/target.cpp b/variants/wio-e5-dev/target.cpp index 680da631a40..3e37d585de8 100644 --- a/variants/wio-e5-dev/target.cpp +++ b/variants/wio-e5-dev/target.cpp @@ -51,8 +51,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/wio-e5-mini/target.cpp b/variants/wio-e5-mini/target.cpp index 92775d5511f..c4b3e178029 100644 --- a/variants/wio-e5-mini/target.cpp +++ b/variants/wio-e5-mini/target.cpp @@ -50,11 +50,6 @@ bool radio_init() { return true; // success } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - bool WIOE5SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { if (!has_bme) return false; diff --git a/variants/wio-tracker-l1-1w/target.cpp b/variants/wio-tracker-l1-1w/target.cpp index 7a573258e6d..1170282a50f 100644 --- a/variants/wio-tracker-l1-1w/target.cpp +++ b/variants/wio-tracker-l1-1w/target.cpp @@ -26,15 +26,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, false, false); MomentaryButton back_btn(PIN_BACK_BTN, 1000, true, false, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/wio-tracker-l1/target.cpp b/variants/wio-tracker-l1/target.cpp index 7a573258e6d..1170282a50f 100644 --- a/variants/wio-tracker-l1/target.cpp +++ b/variants/wio-tracker-l1/target.cpp @@ -26,15 +26,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, false, false); MomentaryButton back_btn(PIN_BACK_BTN, 1000, true, false, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/wio_wm1110/target.cpp b/variants/wio_wm1110/target.cpp index 1bb7cec8cb0..883fd2e5eab 100644 --- a/variants/wio_wm1110/target.cpp +++ b/variants/wio_wm1110/target.cpp @@ -69,9 +69,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/xiao_c3/target.cpp b/variants/xiao_c3/target.cpp index 7f30dc8b60c..20bb8c3338e 100644 --- a/variants/xiao_c3/target.cpp +++ b/variants/xiao_c3/target.cpp @@ -34,9 +34,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/xiao_nrf52/target.cpp b/variants/xiao_nrf52/target.cpp index def1dbbeaaa..ecdcbdb6e9c 100644 --- a/variants/xiao_nrf52/target.cpp +++ b/variants/xiao_nrf52/target.cpp @@ -17,14 +17,3 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); EnvironmentSensorManager sensors; - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/xiao_rp2040/target.cpp b/variants/xiao_rp2040/target.cpp index fe2c62c3dc8..c378b03df8e 100644 --- a/variants/xiao_rp2040/target.cpp +++ b/variants/xiao_rp2040/target.cpp @@ -27,9 +27,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/xiao_s3/target.cpp b/variants/xiao_s3/target.cpp index a7ae7dd8e00..9c093681c16 100644 --- a/variants/xiao_s3/target.cpp +++ b/variants/xiao_s3/target.cpp @@ -34,8 +34,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/xiao_s3_wio/target.cpp b/variants/xiao_s3_wio/target.cpp index 8a975959fa4..5358262e3a9 100644 --- a/variants/xiao_s3_wio/target.cpp +++ b/variants/xiao_s3_wio/target.cpp @@ -34,9 +34,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} -