From e555986b87124d30781b5f5151342f8d75d3cca1 Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 27 May 2026 11:16:23 -0700 Subject: [PATCH 01/14] Improved workflow for migrating logs into database --- .../scripts/import_exercise_26_ble_log.pl | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl index 67cab96..40d3760 100755 --- a/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl +++ b/exercises/26_Bluetooth_discover/scripts/import_exercise_26_ble_log.pl @@ -23,6 +23,31 @@ --db ${DB_BLE} \ --log "${BOB_LOG}" +Improved steps: + + export DB_BLE=ble_fieldtest_20260527_1651.sqlite + sqlite3 ${DB_BLE} < create_exercise_26_ble_schema.sql + export LOG_DIR=/home/jlpoole/work/tbeam/logs/20260527_1651 + export BOB_LOG=${LOG_DIR}/20260527_165156_BOB_ble_search.log + export CY_LOG=${LOG_DIR}/20260527_165201_CY_ble_search.log + export ED_LOG=${LOG_DIR}/20260527_165206_ED_ble_search.log + export FLO_LOG=${LOG_DIR}/20260527_165213_FLO_ble_search.log + # smoke test + date; awk -f exercise_26_smoke_test.awk ${BOB_LOG} + date; awk -f exercise_26_smoke_test.awk ${CY_LOG} + date; awk -f exercise_26_smoke_test.awk ${ED_LOG} + date; awk -f exercise_26_smoke_test.awk ${FLO_LOG} + + for NODE in BOB CY ED FLO + do + LOG_VAR="${NODE}_LOG" + + ./import_exercise_26_ble_log.pl \ + --db "${DB_BLE}" \ + --log "${!LOG_VAR}" + done + + =cut # Manifest: # From 58c62ed07d42555d8312eb4231fb6510f81e9e6e Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 27 May 2026 11:47:21 -0700 Subject: [PATCH 02/14] Chat GPT revision, small, after 11:00 a.m. today --- .../scripts/create_exercise_26_ble_schema.sql | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql index 7dfa192..112b240 100644 --- a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql +++ b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql @@ -6,13 +6,13 @@ -- -- SQLite schema for Exercise 26 BLE Discovery field-test logs. -- --- This schema targets the current 18-column Exercise 26 log format only. +-- This schema targets the current 19-column Exercise 26 log format only. -- -- Current Exercise 26 log format: -- -- human_time,rx_epoch_ms,receiver,rx_lat,rx_lon,gps_fix_age_ms, -- clock_valid,gps_valid,discipline_age_ms,last_discipline_epoch_ms, --- heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload +-- heard,rssi,avg_rssi,age_s,count,seq,tx_payload_epoch_ms,payload,vbat_mv -- -- Current payload format: -- @@ -144,7 +144,7 @@ CREATE TABLE IF NOT EXISTS log_manifest_kv ( -- --------------------------------------------------------------------------- -- ble_observation_raw -- --- Faithful row-level import from the current 18-column CSV log. +-- Faithful row-level import from the current 19-column CSV log. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS ble_observation_raw ( @@ -175,6 +175,7 @@ CREATE TABLE IF NOT EXISTS ble_observation_raw ( seq INTEGER, tx_payload_epoch_ms INTEGER, payload TEXT, + vbat_mv INTEGER, UNIQUE(log_file_id, source_line_no) ); @@ -230,6 +231,8 @@ CREATE TABLE IF NOT EXISTS ble_observation ( discipline_age_ms INTEGER, last_discipline_epoch_ms INTEGER, + vbat_mv INTEGER, + rssi INTEGER, avg_rssi INTEGER, age_s INTEGER, @@ -517,6 +520,7 @@ SELECT gps_valid, discipline_age_ms, last_discipline_epoch_ms, + vbat_mv, rssi, avg_rssi, payload_seq, From ff7c6fa0adf162a1181d2fc11d8f971e43ca85ca Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 27 May 2026 11:51:30 -0700 Subject: [PATCH 03/14] Untested revision of schema as of 11:50 AM --- .../scripts/create_exercise_26_ble_schema.sql | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql index 112b240..839a3bb 100644 --- a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql +++ b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql @@ -5,6 +5,7 @@ -- sqlite3 ble_fieldtest_YYYYMMDD_HHMM.sqlite < create_exercise_26_ble_schema.sql -- -- SQLite schema for Exercise 26 BLE Discovery field-test logs. +-- 20260527_1156: aligned derived delta column name with importer: rx_tx_delta_ms. -- -- This schema targets the current 19-column Exercise 26 log format only. -- @@ -27,7 +28,7 @@ -- advertising payload was generated/configured. It is not a per-RF-packet -- transmit timestamp. -- --- rx_tx_payload_delta_ms is diagnostic only. It is useful for clock, +-- rx_tx_delta_ms is diagnostic only. It is useful for clock, -- scheduling, payload-age, and repeated-observation analysis. It is not -- BLE propagation delay. -- @@ -197,7 +198,7 @@ ON ble_observation_raw(clock_valid, gps_valid, gps_fix_age_ms); -- -- Parsed and normalized BLE observation table. -- --- rx_tx_payload_delta_ms is the receiver row timestamp minus the sender +-- rx_tx_delta_ms is the receiver row timestamp minus the sender -- payload-generation timestamp. It is diagnostic only. -- --------------------------------------------------------------------------- @@ -217,7 +218,7 @@ CREATE TABLE IF NOT EXISTS ble_observation ( tx_payload_epoch_ms INTEGER, tx_payload_epoch_s REAL, - rx_tx_payload_delta_ms INTEGER, + rx_tx_delta_ms INTEGER, receiver TEXT NOT NULL REFERENCES unit(unit_name), heard TEXT NOT NULL REFERENCES unit(unit_name), @@ -269,7 +270,7 @@ CREATE INDEX IF NOT EXISTS idx_obs_validity ON ble_observation(clock_valid, gps_valid, gps_fix_age_ms); CREATE INDEX IF NOT EXISTS idx_obs_payload_time -ON ble_observation(tx_payload_epoch_ms, rx_tx_payload_delta_ms); +ON ble_observation(tx_payload_epoch_ms, rx_tx_delta_ms); -- --------------------------------------------------------------------------- -- v_log_file_summary @@ -378,7 +379,7 @@ GROUP BY trial_id, receiver, heard; -- -- Per receiver/heard timing-delta summary. -- --- rx_tx_payload_delta_ms is useful for detecting clock disagreement, +-- rx_tx_delta_ms is useful for detecting clock disagreement, -- scheduling, repeated payload observations, and payload-age behavior. -- It is not RF propagation delay. -- --------------------------------------------------------------------------- @@ -391,9 +392,9 @@ SELECT COUNT(*) AS observation_count, - MIN(rx_tx_payload_delta_ms) AS min_rx_tx_payload_delta_ms, - AVG(rx_tx_payload_delta_ms) AS avg_rx_tx_payload_delta_ms, - MAX(rx_tx_payload_delta_ms) AS max_rx_tx_payload_delta_ms + MIN(rx_tx_delta_ms) AS min_rx_tx_delta_ms, + AVG(rx_tx_delta_ms) AS avg_rx_tx_delta_ms, + MAX(rx_tx_delta_ms) AS max_rx_tx_delta_ms FROM ble_observation WHERE tx_payload_epoch_ms IS NOT NULL AND tx_payload_epoch_ms > 0 @@ -410,9 +411,9 @@ SELECT heard, payload_seq, COUNT(*) AS observations, - MIN(rx_tx_payload_delta_ms) AS min_delta_ms, - AVG(rx_tx_payload_delta_ms) AS avg_delta_ms, - MAX(rx_tx_payload_delta_ms) AS max_delta_ms, + MIN(rx_tx_delta_ms) AS min_delta_ms, + AVG(rx_tx_delta_ms) AS avg_delta_ms, + MAX(rx_tx_delta_ms) AS max_delta_ms, MIN(rx_epoch_ms) AS first_rx_epoch_ms, MAX(rx_epoch_ms) AS last_rx_epoch_ms FROM ble_observation @@ -454,9 +455,9 @@ SELECT MIN(rx_epoch_ms) AS first_rx_epoch_ms, MAX(rx_epoch_ms) AS last_rx_epoch_ms, MAX(rx_epoch_ms) - MIN(rx_epoch_ms) AS rx_spread_ms, - MIN(rx_tx_payload_delta_ms) AS min_delta_ms, - AVG(rx_tx_payload_delta_ms) AS avg_delta_ms, - MAX(rx_tx_payload_delta_ms) AS max_delta_ms + MIN(rx_tx_delta_ms) AS min_delta_ms, + AVG(rx_tx_delta_ms) AS avg_delta_ms, + MAX(rx_tx_delta_ms) AS max_delta_ms FROM ble_observation WHERE tx_payload_epoch_ms IS NOT NULL AND tx_payload_epoch_ms > 0 @@ -525,7 +526,7 @@ SELECT avg_rssi, payload_seq, tx_payload_epoch_ms, - rx_tx_payload_delta_ms + rx_tx_delta_ms FROM ble_observation WHERE rx_lat IS NOT NULL AND rx_lon IS NOT NULL; From 10498b57e15cbafc6c1d4467a010768c8fe3435d Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 27 May 2026 11:55:49 -0700 Subject: [PATCH 04/14] Additional schema change to reconcile with Perl importer --- .../scripts/create_exercise_26_ble_schema.sql | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql index 839a3bb..e1dca3a 100644 --- a/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql +++ b/exercises/26_Bluetooth_discover/scripts/create_exercise_26_ble_schema.sql @@ -245,7 +245,7 @@ CREATE TABLE IF NOT EXISTS ble_observation ( payload_kind TEXT, payload_node TEXT, payload_seq INTEGER, - payload_tx_payload_epoch_ms INTEGER, + payload_tx_epoch_ms INTEGER, payload_legacy_uptime INTEGER, parse_warning TEXT @@ -477,26 +477,26 @@ WITH x AS ( receiver, heard, payload_seq, - MIN(payload_tx_payload_epoch_ms) AS payload_tx_payload_epoch_ms + MIN(payload_tx_epoch_ms) AS payload_tx_epoch_ms FROM ble_observation - WHERE payload_tx_payload_epoch_ms IS NOT NULL - AND payload_tx_payload_epoch_ms > 0 + WHERE payload_tx_epoch_ms IS NOT NULL + AND payload_tx_epoch_ms > 0 GROUP BY trial_id, receiver, heard, payload_seq ), y AS ( SELECT x.*, - LAG(payload_tx_payload_epoch_ms) OVER ( + LAG(payload_tx_epoch_ms) OVER ( PARTITION BY trial_id, receiver, heard ORDER BY payload_seq - ) AS prev_payload_tx_payload_epoch_ms + ) AS prev_payload_tx_epoch_ms FROM x ) SELECT *, - payload_tx_payload_epoch_ms - prev_payload_tx_payload_epoch_ms AS tx_payload_epoch_step_ms + payload_tx_epoch_ms - prev_payload_tx_epoch_ms AS tx_payload_epoch_step_ms FROM y -WHERE prev_payload_tx_payload_epoch_ms IS NOT NULL; +WHERE prev_payload_tx_epoch_ms IS NOT NULL; -- --------------------------------------------------------------------------- -- v_map_observation_points From f1670a61239c8298b16a3affc96a79005e9351a9 Mon Sep 17 00:00:00 2001 From: John Poole Date: Thu, 28 May 2026 16:44:41 -0700 Subject: [PATCH 05/14] 4 Units are talking, but LINK rarely works. Committing now for safety. --- .../204_established_identities/Codex_1.md | 18 + .../204_established_identities/README.md | 106 ++ .../identities/AMY.identity | 1 + .../identities/AMY.identity_info.txt | 3 + .../identities/BOB.identity | 2 + .../identities/BOB.identity_info.txt | 3 + .../identities/CY.identity | 2 + .../identities/CY.identity_info.txt | 3 + .../identities/DAN.identity | 1 + .../identities/DAN.identity_info.txt | 3 + .../identities/ED.identity | 1 + .../identities/ED.identity_info.txt | 3 + .../identities/FLO.identity | 2 + .../identities/FLO.identity_info.txt | 3 + .../identities/GUY.identity | 1 + .../identities/GUY.identity_info.txt | 3 + .../204_established_identities/platformio.ini | 137 +++ .../scripts/set_build_identity.py | 23 + .../src/TBeamSupremeLoRaInterface.cpp | 166 ++++ .../src/TBeamSupremeLoRaInterface.h | 42 + .../204_established_identities/src/main.cpp | 917 ++++++++++++++++++ 21 files changed, 1440 insertions(+) create mode 100644 exercises/204_established_identities/Codex_1.md create mode 100644 exercises/204_established_identities/README.md create mode 100644 exercises/204_established_identities/identities/AMY.identity create mode 100644 exercises/204_established_identities/identities/AMY.identity_info.txt create mode 100644 exercises/204_established_identities/identities/BOB.identity create mode 100644 exercises/204_established_identities/identities/BOB.identity_info.txt create mode 100644 exercises/204_established_identities/identities/CY.identity create mode 100644 exercises/204_established_identities/identities/CY.identity_info.txt create mode 100644 exercises/204_established_identities/identities/DAN.identity create mode 100644 exercises/204_established_identities/identities/DAN.identity_info.txt create mode 100644 exercises/204_established_identities/identities/ED.identity create mode 100644 exercises/204_established_identities/identities/ED.identity_info.txt create mode 100644 exercises/204_established_identities/identities/FLO.identity create mode 100644 exercises/204_established_identities/identities/FLO.identity_info.txt create mode 100644 exercises/204_established_identities/identities/GUY.identity create mode 100644 exercises/204_established_identities/identities/GUY.identity_info.txt create mode 100644 exercises/204_established_identities/platformio.ini create mode 100644 exercises/204_established_identities/scripts/set_build_identity.py create mode 100644 exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp create mode 100644 exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h create mode 100644 exercises/204_established_identities/src/main.cpp diff --git a/exercises/204_established_identities/Codex_1.md b/exercises/204_established_identities/Codex_1.md new file mode 100644 index 0000000..660b0f8 --- /dev/null +++ b/exercises/204_established_identities/Codex_1.md @@ -0,0 +1,18 @@ +Prompt: +Referencing /usr/local/src/microreticulum/microReticulumTbeam/exercises/203_microreticulum_link_ping_pong, you are to create a new 204_established_identities (I already created the directory) building upon Exercise 203. You probably should copy Exercise 203's files, except the README.md since I have already started a draft of Exercise 204's README.md. You can, of course, copy portions from the README.md in Exercise 203 into the 204 as appropriate. + +Please review Exercise's 204 README.md -- it is a preliminary draft that contains information about identities created for the 7 units. Exercise 204 is to utilize these already created identities. Do not worry about secrecy for the identities, they were simply created for exclusive use in this exercise. Please modify the platformio.ini (copied from Exercise 203 as) so that each build has its correlated identity information. + +You need only compile two unit's build, BOB and CY and load same and monitor via a serial interface. I'll compile the other 5 and then load the binaries as needed -- this is to save time and reduce the thought time your have to utilize and which is allocated to me in 5 hour chunks. + +Please use the OLED facility. At startup there should be a splash screen withe "Ex 204 v. XX" -- you will use the Python script for versioning of this execerise so at start-up, I can verify which version is loaded. + +Please use the disciplined clock approach which means if the RTC has not been disciplined within the last 24 hours, then the unit needs to obtain time from satellites and therefore needs to be taken outside. If a unit has been disciplined in the last 24 hours (using the saved clock file appoach we have used in other exercises), then the unit does not need to be exposed to satellites and my proceed transmitting and receiving messages. + +We'll eventually have event written to a log file, but that is a nice-to-have feature we'll implement once we've proven that messages are being transported by intermediate units. + +Please configure all units as "transport". + +The first iteration of this exercise is to succeed in having BOB and CY exchange messages over LoRa. Once that works, then for a second iteration, what we'll do is prevent BOB's LoRa from being received by CY so that BOB's message must come through another unit in 2 or more hops. We will use hard-coded blocks to simulate the out-of-transmission-range state between BOB and CY for Lora thereby forcing intermediate units, e.g. DAN, ED, FLO or GUY to transport the packets. And then for the utlimate version of this exercise, we'll cause blockages among the units such that for BOB to commmunicate with CY, their packets will have to be transported through several intermediate units. + +Messages sent and received should have iteration numbers so we can detect dropped packets. As each unit learns of other units, then each unit should send a "Hi from [unit name]" and the 7 units \ No newline at end of file diff --git a/exercises/204_established_identities/README.md b/exercises/204_established_identities/README.md new file mode 100644 index 0000000..47058bf --- /dev/null +++ b/exercises/204_established_identities/README.md @@ -0,0 +1,106 @@ +# Introduction +Exercise 204 builds upon Exercise 203 where two units generate ids, announce, and then establish a LINK and exchange messages. In Exercise 204, we add: + OLED display + use of established identities + configuration of units as transports + Allow for multiple units to be communicating + +# Identities +We have 7 units: AMY...GUY. The following command creates identities for use in this exercise. + + # Activate the Python environment that has Reticulum & pio installed + source ~/rnsenv/bin/activate + cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/204_established_identities + mkdir identities + cd identities + for n in AMY BOB CY DAN ED FLO GUY + do + rnid -g "./${n}.identity" + rnid -i "./${n}.identity" -p > "./${n}.identity_info.txt" + done + +## Example run +```bash +(rnsenv) jlpoole@jp /usr/local/src/microreticulum/microReticulumTbeam/exercises/204_established_identities/identities $ for n in AMY BOB CY DAN ED FLO GUY + do + rnid -g "./${n}.identity" + rnid -i "./${n}.identity" -p > "./${n}.identity_info.txt" + done +[2026-05-28 11:26:29] New identity written to ./AMY.identity +[2026-05-28 11:26:33] New identity <5769e13e1214e62b96e43c17bd47085e> written to ./BOB.identity +[2026-05-28 11:26:38] New identity <92ce7c2eb62820c2e4476308350cc69d> written to ./CY.identity +[2026-05-28 11:26:42] New identity written to ./DAN.identity +[2026-05-28 11:26:46] New identity <4ad998c481ff6c71d1acb8cbaf111e1f> written to ./ED.identity +[2026-05-28 11:26:51] New identity <5671752180661e6af4bfe49e962f23dd> written to ./FLO.identity +[2026-05-28 11:26:55] New identity <051cfb95faa527b68368d24efb40f689> written to ./GUY.identity +(rnsenv) jlpoole@jp /usr/local/src/microreticulum/microReticulumTbeam/exercises/204_established_identities/identities $ +``` + +# Implementation notes + +`platformio.ini` defines environments for `amy`, `bob`, `cy`, `dan`, `ed`, `flo`, and `guy`. The pre-build script `scripts/set_build_identity.py` embeds the matching `identities/.identity` file into the firmware and also stamps `FW_BUILD_UTC` for the OLED splash screen. + +At boot the OLED shows: + +```text +Ex 204 v. + + +``` + +The unit then checks the RTC and `/ex204/clock.txt` on the SD card. If the saved discipline marker is less than 24 hours old according to the RTC, LoRa/microReticulum starts immediately. If not, the OLED shows `Take outside` and serial prints `gps_gate ...` until GPS UTC/fix is available. A successful GPS discipline writes a fresh marker and then starts LoRa. + +All units run with `reticulum.transport_enabled(true)`. Path learning remains enabled, but `RNS_PERSIST_PATHS` is intentionally not defined for this exercise. Live announces are sufficient for the single-hop field test, and disabling persistent paths avoids ESP32 LittleFS `/path_store_*.dat` compaction errors during dense multi-unit announce traffic. + +Each unit tracks up to six peers, so a full seven-unit field run can form one Link per ordered pair. The lexicographically lower node initiates each pair to avoid both ends opening duplicate Links at the same time; for example `Bob` initiates to `Cy`, `Dan`, `Ed`, `Flo`, and `Guy`, while `Amy` initiates to everyone. If an outbound link request is sent but no link becomes active, the initiator retries after 30 seconds. This matters in field use because the first request can be missed while units are being moved, reset, or connected to monitors. + +Application traffic is scheduled from the RTC so all nodes do not transmit at once: + +```text +Amy: 04,34 Bob: 08,38 Cy: 12,42 Dan: 16,46 +Ed: 20,50 Flo: 24,54 Guy: 28,58 +``` + +Follow-up note: DAN/ED field testing showed the software version/build stamp was not visible on the OLED even though `FW_BUILD_UTC` is compiled into the splash text. Recheck the OLED startup/status path on the next firmware modification; do not rebuild solely for this note. + +# Build, upload, and monitor + +```bash +source /home/jlpoole/rnsenv/bin/activate +cd /usr/local/src/microreticulum/microReticulumTbeam + +pio run -d exercises/204_established_identities -e amy -e bob -e cy -e dan -e ed -e flo -e guy + +for env in amy bob cy dan ed flo guy +do + pio run -d exercises/204_established_identities -e "${env}" -t upload +done +``` + +After both units have a fresh clock marker, monitor them: + +```bash +pio device monitor -d exercises/204_established_identities -e bob +``` + +Expected first-run clock gate: + +```text +SD ready +No saved clock marker; GPS discipline required +Waiting for GPS UTC before LoRa startup +gps_gate time=0 fix=0 sats=-1 pps_ms=0 +``` + +Expected LoRa/link traffic after the clock gate: + +```text +Local Identity: +Local SINGLE destination: +TX ANNOUNCE: Bob +RX ANNOUNCE: label=Cy hash= +TX LINKREQUEST: opening link to Cy +LINK ACTIVE: initiator link established +TX LINK: Hi from Bob iter=0 to=Cy +RX LINK: Hi from Cy iter=0 to=Bob | RSSI=... SNR=... +``` diff --git a/exercises/204_established_identities/identities/AMY.identity b/exercises/204_established_identities/identities/AMY.identity new file mode 100644 index 0000000..a13546a --- /dev/null +++ b/exercises/204_established_identities/identities/AMY.identity @@ -0,0 +1 @@ + fTd렐ʉB Sl>w6`d>Z6%Q W gy \ No newline at end of file diff --git a/exercises/204_established_identities/identities/AMY.identity_info.txt b/exercises/204_established_identities/identities/AMY.identity_info.txt new file mode 100644 index 0000000..decfea7 --- /dev/null +++ b/exercises/204_established_identities/identities/AMY.identity_info.txt @@ -0,0 +1,3 @@ +[2026-05-28 11:26:31] Loaded Identity from ./AMY.identity +[2026-05-28 11:26:31] Public Key : f25adccd75eefaf9fafe5a4b22f0a16c43bf0094810c5f9279eb30ad3fd97312ba71cdd7940bb139c15949d433f8ffb57d75441d1b84a1d091b234420d22a608 +[2026-05-28 11:26:31] Private Key : Hidden diff --git a/exercises/204_established_identities/identities/BOB.identity b/exercises/204_established_identities/identities/BOB.identity new file mode 100644 index 0000000..565253f --- /dev/null +++ b/exercises/204_established_identities/identities/BOB.identity @@ -0,0 +1,2 @@ +ǽt$Uw)mi$yz3ӧQwKK'g +bYD6ƾ&!2 \ No newline at end of file diff --git a/exercises/204_established_identities/identities/BOB.identity_info.txt b/exercises/204_established_identities/identities/BOB.identity_info.txt new file mode 100644 index 0000000..3cb6bda --- /dev/null +++ b/exercises/204_established_identities/identities/BOB.identity_info.txt @@ -0,0 +1,3 @@ +[2026-05-28 11:26:35] Loaded Identity <5769e13e1214e62b96e43c17bd47085e> from ./BOB.identity +[2026-05-28 11:26:35] Public Key : 4705bf1ab8a17cc3bb07d1fa51d4fe59dc92e4e93fd9d55124c808079d33744e894241f52583b3abc7bcaf48a5fad31554c2dee142dbbc3c4d4c5e3855d94814 +[2026-05-28 11:26:35] Private Key : Hidden diff --git a/exercises/204_established_identities/identities/CY.identity b/exercises/204_established_identities/identities/CY.identity new file mode 100644 index 0000000..73a6a68 --- /dev/null +++ b/exercises/204_established_identities/identities/CY.identity @@ -0,0 +1,2 @@ +7EbI$vp1->嫵Gs.ѥ|ݞ;Y+D; +BPjH`: \ No newline at end of file diff --git a/exercises/204_established_identities/identities/CY.identity_info.txt b/exercises/204_established_identities/identities/CY.identity_info.txt new file mode 100644 index 0000000..b66e212 --- /dev/null +++ b/exercises/204_established_identities/identities/CY.identity_info.txt @@ -0,0 +1,3 @@ +[2026-05-28 11:26:40] Loaded Identity <92ce7c2eb62820c2e4476308350cc69d> from ./CY.identity +[2026-05-28 11:26:40] Public Key : 22dc1ae58534f27562ee37edc7b072eb53502847d7aa718bd84e4baf3064867ab5945f1fc842213b10d7faeebc35d8a71903eb3cee30c112d21e39575c44131a +[2026-05-28 11:26:40] Private Key : Hidden diff --git a/exercises/204_established_identities/identities/DAN.identity b/exercises/204_established_identities/identities/DAN.identity new file mode 100644 index 0000000..091fe60 --- /dev/null +++ b/exercises/204_established_identities/identities/DAN.identity @@ -0,0 +1 @@ +w'lNӴ-ɥ2vu|,vqGD1Xϱyꧫ܅.LA \ No newline at end of file diff --git a/exercises/204_established_identities/identities/DAN.identity_info.txt b/exercises/204_established_identities/identities/DAN.identity_info.txt new file mode 100644 index 0000000..1689740 --- /dev/null +++ b/exercises/204_established_identities/identities/DAN.identity_info.txt @@ -0,0 +1,3 @@ +[2026-05-28 11:26:44] Loaded Identity from ./DAN.identity +[2026-05-28 11:26:44] Public Key : 14483f044c5ea19c12a2c89ba539ca1ee2cea613bde7eb8d5d700058351d1067b5415b26b0ae8667c2cac4d4cc932f24b48ca727f3c5e42c614750d05e475d80 +[2026-05-28 11:26:44] Private Key : Hidden diff --git a/exercises/204_established_identities/identities/ED.identity b/exercises/204_established_identities/identities/ED.identity new file mode 100644 index 0000000..7fbee55 --- /dev/null +++ b/exercises/204_established_identities/identities/ED.identity @@ -0,0 +1 @@ +L&(_IriTSOKDlק ˖xv-h@ \ No newline at end of file diff --git a/exercises/204_established_identities/identities/ED.identity_info.txt b/exercises/204_established_identities/identities/ED.identity_info.txt new file mode 100644 index 0000000..3962a6b --- /dev/null +++ b/exercises/204_established_identities/identities/ED.identity_info.txt @@ -0,0 +1,3 @@ +[2026-05-28 11:26:48] Loaded Identity <4ad998c481ff6c71d1acb8cbaf111e1f> from ./ED.identity +[2026-05-28 11:26:48] Public Key : c90deacc4f0ccfd552eb12205b92c31d10485ea71778bfb494a1fa6af7217c39b0a5fc1b770486cc2d0a8cb29e52ab2d44c2907e8bce6524397eabe507ff1384 +[2026-05-28 11:26:48] Private Key : Hidden diff --git a/exercises/204_established_identities/identities/FLO.identity b/exercises/204_established_identities/identities/FLO.identity new file mode 100644 index 0000000..b1b8612 --- /dev/null +++ b/exercises/204_established_identities/identities/FLO.identity @@ -0,0 +1,2 @@ + +057X!##CM t{%+X6T!ͪE MP6[\^Z& \ No newline at end of file diff --git a/exercises/204_established_identities/identities/FLO.identity_info.txt b/exercises/204_established_identities/identities/FLO.identity_info.txt new file mode 100644 index 0000000..a2d1630 --- /dev/null +++ b/exercises/204_established_identities/identities/FLO.identity_info.txt @@ -0,0 +1,3 @@ +[2026-05-28 11:26:53] Loaded Identity <5671752180661e6af4bfe49e962f23dd> from ./FLO.identity +[2026-05-28 11:26:53] Public Key : 3ea3edb5198020bf0f2f87219ed2d5ea2cff6eb16116702992f14eb2a43fcd69530d1e8a8b0db60065bdbbf75332c559498cc42f171f283189a62b54f14bf28f +[2026-05-28 11:26:53] Private Key : Hidden diff --git a/exercises/204_established_identities/identities/GUY.identity b/exercises/204_established_identities/identities/GUY.identity new file mode 100644 index 0000000..2986e72 --- /dev/null +++ b/exercises/204_established_identities/identities/GUY.identity @@ -0,0 +1 @@ +vp{BQ5 ؍\oe+o2<٭Jy'L2 \ No newline at end of file diff --git a/exercises/204_established_identities/identities/GUY.identity_info.txt b/exercises/204_established_identities/identities/GUY.identity_info.txt new file mode 100644 index 0000000..24340b4 --- /dev/null +++ b/exercises/204_established_identities/identities/GUY.identity_info.txt @@ -0,0 +1,3 @@ +[2026-05-28 11:26:57] Loaded Identity <051cfb95faa527b68368d24efb40f689> from ./GUY.identity +[2026-05-28 11:26:57] Public Key : cfba3c49750b6ddd3fe7a5083af447b927ffca57e2459bc16bc731aff574cd67abd4cc6198e93721c1f0bb143ddce574a629d8ecf9aa5fea530821cd1fef60f2 +[2026-05-28 11:26:57] Private Key : Hidden diff --git a/exercises/204_established_identities/platformio.ini b/exercises/204_established_identities/platformio.ini new file mode 100644 index 0000000..1d5e9d4 --- /dev/null +++ b/exercises/204_established_identities/platformio.ini @@ -0,0 +1,137 @@ +; Exercise 204: established identities over microReticulum Link transport + +[platformio] +default_envs = bob + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +monitor_speed = 115200 +upload_speed = 460800 +board_build.partitions = huge_app.csv +extra_scripts = pre:scripts/set_build_identity.py +lib_extra_dirs = + ../../lib + +build_flags = + -Wall + -Wno-missing-field-initializers + -Wno-format + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -I ../../lib/tbeam_display/src + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D RNS_USE_FS + -D USTORE_USE_UNIVERSALFS + -D MSGPACK_USE_BOOST=OFF + -D MCU_ESP32 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D RTC_I2C_ADDR=0x51 + -D GPS_RX_PIN=9 + -D GPS_TX_PIN=8 + -D GPS_1PPS_PIN=6 + -D LORA_CS=10 + -D LORA_MOSI=11 + -D LORA_SCK=12 + -D LORA_MISO=13 + -D LORA_RESET=5 + -D LORA_DIO1=1 + -D LORA_BUSY=4 + -D LORA_TCXO_VOLTAGE=1.8 + -D LORA_FREQ_MHZ=915.0 + -D LORA_BW_KHZ=125.0 + -D LORA_SF=7 + -D LORA_CR=5 + -D LORA_SYNC_WORD=0x12 + -D LORA_TX_POWER_DBM=14 + -D USTORE_MAX_VALUE_LEN=1200 +; Live announces are enough for this single-hop field exercise. Do not define +; RNS_PERSIST_PATHS here: the LittleFS-backed path_store compactor can leave an +; active segment FD open while unlinking /path_store_*.dat on ESP32. + +lib_deps = + Wire + SD + olikraus/U8g2@^2.36.4 + lewisxhe/XPowersLib@0.3.3 + ArduinoJson@^7.4.2 + MsgPack@^0.4.2 + jgromes/RadioLib@^7.0.0 + https://github.com/attermann/Crypto.git + https://github.com/attermann/microStore.git + microReticulum=symlink:///usr/local/src/microreticulum/microReticulum + +[env:amy] +extends = env +upload_port = /dev/ttytAMY +monitor_port = /dev/ttytAMY +build_flags = + ${env.build_flags} + -D BOARD_ID=\"AMY\" + -D NODE_LABEL=\"Amy\" + -D NODE_SLOT_INDEX=0 + +[env:bob] +extends = env +upload_port = /dev/ttytBOB +monitor_port = /dev/ttytBOB +build_flags = + ${env.build_flags} + -D BOARD_ID=\"BOB\" + -D NODE_LABEL=\"Bob\" + -D NODE_SLOT_INDEX=1 + +[env:cy] +extends = env +upload_port = /dev/ttytCY +monitor_port = /dev/ttytCY +build_flags = + ${env.build_flags} + -D BOARD_ID=\"CY\" + -D NODE_LABEL=\"Cy\" + -D NODE_SLOT_INDEX=2 + +[env:dan] +extends = env +upload_port = /dev/ttytDAN +monitor_port = /dev/ttytDAN +build_flags = + ${env.build_flags} + -D BOARD_ID=\"DAN\" + -D NODE_LABEL=\"Dan\" + -D NODE_SLOT_INDEX=3 + +[env:ed] +extends = env +upload_port = /dev/ttytED +monitor_port = /dev/ttytED +build_flags = + ${env.build_flags} + -D BOARD_ID=\"ED\" + -D NODE_LABEL=\"Ed\" + -D NODE_SLOT_INDEX=4 + +[env:flo] +extends = env +upload_port = /dev/ttytFLO +monitor_port = /dev/ttytFLO +build_flags = + ${env.build_flags} + -D BOARD_ID=\"FLO\" + -D NODE_LABEL=\"Flo\" + -D NODE_SLOT_INDEX=5 + +[env:guy] +extends = env +upload_port = /dev/ttytGUY +monitor_port = /dev/ttytGUY +build_flags = + ${env.build_flags} + -D BOARD_ID=\"GUY\" + -D NODE_LABEL=\"Guy\" + -D NODE_SLOT_INDEX=6 diff --git a/exercises/204_established_identities/scripts/set_build_identity.py b/exercises/204_established_identities/scripts/set_build_identity.py new file mode 100644 index 0000000..e877182 --- /dev/null +++ b/exercises/204_established_identities/scripts/set_build_identity.py @@ -0,0 +1,23 @@ +import binascii +import time +from pathlib import Path + +Import("env") + +pioenv = env.subst("$PIOENV").upper() +identity_path = Path(env.subst("$PROJECT_DIR")) / "identities" / f"{pioenv}.identity" + +if not identity_path.exists(): + raise RuntimeError(f"Missing identity file for {pioenv}: {identity_path}") + +identity_hex = binascii.hexlify(identity_path.read_bytes()).decode("ascii") +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("LOCAL_IDENTITY_HEX", f'\\"{identity_hex}\\"'), + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", f'\\"{utc_tag}\\"'), + ] +) diff --git a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp new file mode 100644 index 0000000..80359a2 --- /dev/null +++ b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp @@ -0,0 +1,166 @@ +#include "TBeamSupremeLoRaInterface.h" + +#include +#include + +#include + +#ifndef LORA_CS +#error "LORA_CS not defined" +#endif +#ifndef LORA_DIO1 +#error "LORA_DIO1 not defined" +#endif +#ifndef LORA_RESET +#error "LORA_RESET not defined" +#endif +#ifndef LORA_BUSY +#error "LORA_BUSY not defined" +#endif + +using namespace RNS; + +TBeamSupremeLoRaInterface::TBeamSupremeLoRaInterface(const char* name) : InterfaceImpl(name) { + _IN = true; + _OUT = true; + _bitrate = (double)LORA_SF * ((4.0 / LORA_CR) / (pow(2, LORA_SF) / LORA_BW_KHZ)) * 1000.0; + _HW_MTU = 508; +} + +TBeamSupremeLoRaInterface::~TBeamSupremeLoRaInterface() { + stop(); + delete _radio; + delete _module; +} + +bool TBeamSupremeLoRaInterface::start() { + _online = false; + INFO("LoRa initializing for T-Beam Supreme..."); + + SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); + _module = new Module(LORA_CS, LORA_DIO1, LORA_RESET, LORA_BUSY, SPI); + _radio = new SX1262(_module); + + int state = _radio->begin( + LORA_FREQ_MHZ, + LORA_BW_KHZ, + LORA_SF, + LORA_CR, + LORA_SYNC_WORD, + LORA_TX_POWER_DBM); + + if (state != RADIOLIB_ERR_NONE) { + ERRORF("LoRa init failed, code %d", state); + return false; + } + + state = _radio->startReceive(); + if (state != RADIOLIB_ERR_NONE) { + ERRORF("LoRa startReceive failed, code %d", state); + return false; + } + + _online = true; + INFO("LoRa init succeeded."); + return true; +} + +void TBeamSupremeLoRaInterface::stop() { + if (_radio) { + _radio->standby(); + } + _online = false; +} + +void TBeamSupremeLoRaInterface::loop() { + if (!_online || !_radio) { + return; + } + + if (!_radio->checkIrq(RADIOLIB_IRQ_RX_DONE)) { + return; + } + + int len = _radio->getPacketLength(); + uint8_t rx_buf[255]; + int state = _radio->readData(rx_buf, len); + + if (state == RADIOLIB_ERR_NONE && len > 1) { + _last_rssi = _radio->getRSSI(); + _last_snr = _radio->getSNR(); + + uint8_t header = rx_buf[0]; + uint8_t seq = packet_sequence(header); + + if (is_split_packet(header)) { + if (_rx_seq == SEQ_UNSET || _rx_seq != seq) { + _rx_seq = seq; + _rx_buffer.clear(); + _rx_buffer.append(rx_buf + 1, len - 1); + } else { + _rx_buffer.append(rx_buf + 1, len - 1); + _rx_seq = SEQ_UNSET; + on_incoming(_rx_buffer); + } + } else { + if (_rx_seq != SEQ_UNSET) { + _rx_buffer.clear(); + _rx_seq = SEQ_UNSET; + } + _rx_buffer.clear(); + _rx_buffer.append(rx_buf + 1, len - 1); + on_incoming(_rx_buffer); + } + } else if (state != RADIOLIB_ERR_NONE) { + DEBUGF("LoRa readData failed, code %d", state); + } + + _radio->startReceive(); +} + +void TBeamSupremeLoRaInterface::send_outgoing(const Bytes& data) { + if (!_online || !_radio) { + return; + } + + try { + uint8_t tx_buf[255]; + uint8_t rand_nibble = (uint8_t)(Cryptography::randomnum(256)) & 0xF0; + + if ((int)data.size() <= LORA_MAX_PAYLOAD) { + tx_buf[0] = rand_nibble; + memcpy(tx_buf + 1, data.data(), data.size()); + int state = _radio->transmit(tx_buf, 1 + data.size()); + if (state != RADIOLIB_ERR_NONE) { + ERRORF("LoRa transmit failed, code %d", state); + } + } else { + uint8_t seq = (_tx_seq_ctr++) & HEADER_SEQ_MASK; + uint8_t split_header = rand_nibble | HEADER_SPLIT | seq; + + tx_buf[0] = split_header; + memcpy(tx_buf + 1, data.data(), LORA_MAX_PAYLOAD); + int state = _radio->transmit(tx_buf, 1 + LORA_MAX_PAYLOAD); + if (state != RADIOLIB_ERR_NONE) { + ERRORF("LoRa transmit part 1 failed, code %d", state); + } + + size_t remainder = data.size() - LORA_MAX_PAYLOAD; + tx_buf[0] = split_header; + memcpy(tx_buf + 1, data.data() + LORA_MAX_PAYLOAD, remainder); + state = _radio->transmit(tx_buf, 1 + remainder); + if (state != RADIOLIB_ERR_NONE) { + ERRORF("LoRa transmit part 2 failed, code %d", state); + } + } + + _radio->startReceive(); + InterfaceImpl::handle_outgoing(data); + } catch (const std::exception& e) { + ERRORF("LoRa transmit exception: %s", e.what()); + } +} + +void TBeamSupremeLoRaInterface::on_incoming(const Bytes& data) { + InterfaceImpl::handle_incoming(data); +} diff --git a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h new file mode 100644 index 0000000..701de8a --- /dev/null +++ b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include +#include +#include + +class TBeamSupremeLoRaInterface : public RNS::InterfaceImpl { +public: + explicit TBeamSupremeLoRaInterface(const char* name = "TBeamSupremeLoRa"); + ~TBeamSupremeLoRaInterface() override; + + bool start() override; + void stop() override; + void loop() override; + + float last_rssi() const { return _last_rssi; } + float last_snr() const { return _last_snr; } + +private: + void send_outgoing(const RNS::Bytes& data) override; + void on_incoming(const RNS::Bytes& data); + + static constexpr uint8_t HEADER_SPLIT = 0x08; + static constexpr uint8_t HEADER_SEQ_MASK = 0x07; + static constexpr uint8_t SEQ_UNSET = 0xFF; + static constexpr int LORA_MAX_PAYLOAD = 254; + + static bool is_split_packet(uint8_t header) { return (header & HEADER_SPLIT) != 0; } + static uint8_t packet_sequence(uint8_t header) { return header & HEADER_SEQ_MASK; } + + RNS::Bytes _rx_buffer; + uint8_t _rx_seq = SEQ_UNSET; + uint8_t _tx_seq_ctr = 0; + float _last_rssi = 0.0f; + float _last_snr = 0.0f; + + Module* _module = nullptr; + SX1262* _radio = nullptr; +}; diff --git a/exercises/204_established_identities/src/main.cpp b/exercises/204_established_identities/src/main.cpp new file mode 100644 index 0000000..0ad102c --- /dev/null +++ b/exercises/204_established_identities/src/main.cpp @@ -0,0 +1,917 @@ +#include "TBeamSupremeLoRaInterface.h" +#include "TBeamDisplay.h" +#include "tbeam_supreme_adapter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef NODE_LABEL +#define NODE_LABEL "?" +#endif + +#ifndef BOARD_ID +#define BOARD_ID "UNKNOWN" +#endif + +#ifndef LOCAL_IDENTITY_HEX +#error "LOCAL_IDENTITY_HEX must be supplied by scripts/set_build_identity.py" +#endif + +#ifndef FW_BUILD_UTC +#define FW_BUILD_UTC "unknown" +#endif + +static constexpr const char* APP_NAME = "microreticulum"; +static constexpr const char* APP_ASPECT = "linkping"; +static constexpr const char* ANNOUNCE_FILTER = "microreticulum.linkping"; +static constexpr const char* CLOCK_MARKER_PATH = "/ex204/clock.txt"; +static constexpr uint32_t DISCIPLINE_HOLDOVER_SECONDS = 24UL * 60UL * 60UL; +static constexpr uint32_t GPS_STATUS_PERIOD_MS = 2000; +static constexpr uint32_t PPS_WAIT_MS = 1500; +//static constexpr uint32_t LINK_RETRY_MS = 30000; +static constexpr uint32_t LINK_RETRY_MS = 90000; + +struct DateTime { + uint16_t year = 0; + uint8_t month = 0; + uint8_t day = 0; + uint8_t hour = 0; + uint8_t minute = 0; + uint8_t second = 0; +}; + +struct GpsState { + bool valid_time = false; + bool valid_fix = false; + int sats_used = -1; + DateTime utc; + uint32_t last_time_ms = 0; +}; + +static RNS::Reticulum reticulum({RNS::Type::NONE}); +static RNS::Interface lora_interface({RNS::Type::NONE}); +static RNS::Identity local_identity({RNS::Type::NONE}); +static RNS::Identity transport_identity({RNS::Type::NONE}); +static RNS::Destination inbound_destination({RNS::Type::NONE}); +static bool clock_ready = false; +static bool sd_ready = false; +static TBeamSupremeLoRaInterface* lora_impl = nullptr; +static XPowersLibInterface* pmu = nullptr; +static tbeam::TBeamDisplay oled_display; +static SPIClass sd_spi(FSPI); +static HardwareSerial gps_serial(1); +static GpsState gps; +static char gps_line[128]; +static size_t gps_line_len = 0; +static volatile uint32_t last_pps_ms = 0; + +static constexpr uint8_t MAX_PEERS = 6; + +struct PeerState { + String label; + RNS::Bytes destination_hash; + RNS::Destination destination = {RNS::Type::NONE}; + RNS::Link outbound_link = {RNS::Type::NONE}; + RNS::Link inbound_link = {RNS::Type::NONE}; + bool announced = false; + bool outbound_attempted = false; + bool outbound_active = false; + bool inbound_active = false; + uint32_t last_link_attempt_ms = 0; + uint32_t tx_iter = 0; + uint8_t last_tx_second = 255; +}; + +static PeerState peers[MAX_PEERS]; + +static void on_link_packet(const RNS::Bytes& data, const RNS::Packet& packet); +static void on_link_closed(RNS::Link& link); + +static void IRAM_ATTR on_pps_edge() { + last_pps_ms = millis(); +} + +static uint8_t to_bcd(uint8_t value) { + return (uint8_t)(((value / 10U) << 4U) | (value % 10U)); +} + +static uint8_t from_bcd(uint8_t value) { + return (uint8_t)(((value >> 4U) * 10U) + (value & 0x0FU)); +} + +static bool is_leap_year(uint16_t year) { + return ((year % 4U) == 0U && (year % 100U) != 0U) || ((year % 400U) == 0U); +} + +static uint8_t days_in_month(uint16_t year, uint8_t month) { + static const uint8_t days[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month == 2U) { + return is_leap_year(year) ? 29U : 28U; + } + if (month >= 1U && month <= 12U) { + return days[month - 1U]; + } + return 0; +} + +static bool valid_datetime(const DateTime& dt) { + return dt.year >= 2024U && dt.year <= 2099U && + dt.month >= 1U && dt.month <= 12U && + dt.day >= 1U && dt.day <= days_in_month(dt.year, dt.month) && + dt.hour <= 23U && dt.minute <= 59U && dt.second <= 59U; +} + +static int64_t days_from_civil(int year, unsigned month, unsigned day) { + year -= (month <= 2U); + const int era = (year >= 0 ? year : year - 399) / 400; + const unsigned yoe = (unsigned)(year - era * 400); + const unsigned doy = (153U * (month + (month > 2U ? (unsigned)-3 : 9U)) + 2U) / 5U + day - 1U; + const unsigned doe = yoe * 365U + yoe / 4U - yoe / 100U + doy; + return era * 146097 + (int)doe - 719468; +} + +static int64_t to_epoch_seconds(const DateTime& dt) { + const int64_t days = days_from_civil((int)dt.year, dt.month, dt.day); + return days * 86400LL + (int64_t)dt.hour * 3600LL + (int64_t)dt.minute * 60LL + dt.second; +} + +static bool from_epoch_seconds(int64_t seconds, DateTime& out) { + if (seconds < 0) { + return false; + } + + int64_t days = seconds / 86400LL; + int64_t rem = seconds % 86400LL; + out.hour = (uint8_t)(rem / 3600LL); + rem %= 3600LL; + out.minute = (uint8_t)(rem / 60LL); + out.second = (uint8_t)(rem % 60LL); + + days += 719468; + const int era = (days >= 0 ? days : days - 146096) / 146097; + const unsigned doe = (unsigned)(days - era * 146097); + const unsigned yoe = (doe - doe / 1460U + doe / 36524U - doe / 146096U) / 365U; + int year = (int)yoe + era * 400; + const unsigned doy = doe - (365U * yoe + yoe / 4U - yoe / 100U); + const unsigned mp = (5U * doy + 2U) / 153U; + const unsigned day = doy - (153U * mp + 2U) / 5U + 1U; + const unsigned month = mp + (mp < 10U ? 3U : (unsigned)-9); + year += (month <= 2U); + + out.year = (uint16_t)year; + out.month = (uint8_t)month; + out.day = (uint8_t)day; + return valid_datetime(out); +} + +static void format_iso(const DateTime& dt, char* out, size_t out_size) { + snprintf(out, out_size, "%04u-%02u-%02uT%02u:%02u:%02uZ", + (unsigned)dt.year, (unsigned)dt.month, (unsigned)dt.day, + (unsigned)dt.hour, (unsigned)dt.minute, (unsigned)dt.second); +} + +static bool read_rtc(DateTime& out, int64_t* epoch_out = nullptr) { + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + if (Wire1.endTransmission(false) != 0) { + return false; + } + + if (Wire1.requestFrom((int)RTC_I2C_ADDR, 7) != 7) { + return false; + } + + const uint8_t sec = Wire1.read(); + const uint8_t min = Wire1.read(); + const uint8_t hour = Wire1.read(); + const uint8_t day = Wire1.read(); + (void)Wire1.read(); + const uint8_t month = Wire1.read(); + const uint8_t year = Wire1.read(); + + if (sec & 0x80U) { + return false; + } + out.second = from_bcd(sec & 0x7FU); + out.minute = from_bcd(min & 0x7FU); + out.hour = from_bcd(hour & 0x3FU); + out.day = from_bcd(day & 0x3FU); + out.month = from_bcd(month & 0x1FU); + out.year = (uint16_t)((month & 0x80U) ? 1900U : 2000U) + from_bcd(year); + if (!valid_datetime(out)) { + return false; + } + if (epoch_out) { + *epoch_out = to_epoch_seconds(out); + } + return true; +} + +static bool write_rtc(const DateTime& dt) { + if (!valid_datetime(dt)) { + return false; + } + Wire1.beginTransmission(RTC_I2C_ADDR); + Wire1.write(0x02); + Wire1.write(to_bcd(dt.second)); + Wire1.write(to_bcd(dt.minute)); + Wire1.write(to_bcd(dt.hour)); + Wire1.write(to_bcd(dt.day)); + Wire1.write(0x00); + Wire1.write(to_bcd(dt.month)); + Wire1.write(to_bcd((uint8_t)(dt.year % 100U))); + return Wire1.endTransmission() == 0; +} + +static bool mount_sd() { + pinMode(tbeam_supreme::sdCs(), OUTPUT); + digitalWrite(tbeam_supreme::sdCs(), HIGH); + pinMode(tbeam_supreme::imuCs(), OUTPUT); + digitalWrite(tbeam_supreme::imuCs(), HIGH); + sd_spi.begin(tbeam_supreme::sdSck(), tbeam_supreme::sdMiso(), tbeam_supreme::sdMosi(), tbeam_supreme::sdCs()); + sd_ready = SD.begin(tbeam_supreme::sdCs(), sd_spi, 4000000); + Serial.printf("SD %s\r\n", sd_ready ? "ready" : "not mounted"); + return sd_ready; +} + +static int64_t read_clock_marker() { + if (!sd_ready || !SD.exists(CLOCK_MARKER_PATH)) { + return 0; + } + File f = SD.open(CLOCK_MARKER_PATH, FILE_READ); + if (!f) { + return 0; + } + String line = f.readStringUntil('\n'); + f.close(); + line.trim(); + return (int64_t)strtoll(line.c_str(), nullptr, 10); +} + +static bool write_clock_marker(int64_t epoch) { + if (!sd_ready) { + return false; + } + SD.mkdir("/ex204"); + File f = SD.open(CLOCK_MARKER_PATH, FILE_WRITE); + if (!f) { + return false; + } + f.printf("%lld\n", (long long)epoch); + f.close(); + return true; +} + +static void show_status(const char* left, const char* right = nullptr, const char* footer = nullptr) { + oled_display.showStatus("Ex 204 " BOARD_ID, left, right, footer); +} + +static void show_splash() { + oled_display.showLines("Ex 204 v. " FW_BUILD_UTC, BOARD_ID, NODE_LABEL, "established ids", "transport"); +} + +static bool hex_nibble(char c, uint8_t& out) { + if (c >= '0' && c <= '9') { + out = (uint8_t)(c - '0'); + return true; + } + if (c >= 'a' && c <= 'f') { + out = (uint8_t)(10 + c - 'a'); + return true; + } + if (c >= 'A' && c <= 'F') { + out = (uint8_t)(10 + c - 'A'); + return true; + } + return false; +} + +static bool identity_bytes_from_hex(RNS::Bytes& out) { + const char* hex = LOCAL_IDENTITY_HEX; + const size_t len = strlen(hex); + if (len == 0 || (len % 2U) != 0U) { + return false; + } + uint8_t buffer[96]; + const size_t byte_count = len / 2U; + if (byte_count > sizeof(buffer)) { + return false; + } + for (size_t i = 0; i < byte_count; ++i) { + uint8_t hi = 0; + uint8_t lo = 0; + if (!hex_nibble(hex[i * 2U], hi) || !hex_nibble(hex[i * 2U + 1U], lo)) { + return false; + } + buffer[i] = (uint8_t)((hi << 4U) | lo); + } + out = RNS::Bytes(buffer, byte_count); + return true; +} + +static bool parse_utc_time(const char* value, DateTime& out) { + if (!value || strlen(value) < 6) { + return false; + } + out.hour = (uint8_t)((value[0] - '0') * 10 + (value[1] - '0')); + out.minute = (uint8_t)((value[2] - '0') * 10 + (value[3] - '0')); + out.second = (uint8_t)((value[4] - '0') * 10 + (value[5] - '0')); + return out.hour <= 23U && out.minute <= 59U && out.second <= 59U; +} + +static bool parse_utc_date(const char* value, DateTime& out) { + if (!value || strlen(value) < 6) { + return false; + } + out.day = (uint8_t)((value[0] - '0') * 10 + (value[1] - '0')); + out.month = (uint8_t)((value[2] - '0') * 10 + (value[3] - '0')); + const uint8_t yy = (uint8_t)((value[4] - '0') * 10 + (value[5] - '0')); + out.year = (uint16_t)(2000U + yy); + return true; +} + +static void parse_gga(char* line) { + char* fields[16] = {}; + size_t count = 0; + char* save = nullptr; + for (char* tok = strtok_r(line, ",", &save); tok && count < 16; tok = strtok_r(nullptr, ",", &save)) { + fields[count++] = tok; + } + if (count > 7 && fields[7] && fields[7][0]) { + gps.sats_used = atoi(fields[7]); + } +} + +static void parse_rmc(char* line) { + char* fields[16] = {}; + size_t count = 0; + char* save = nullptr; + for (char* tok = strtok_r(line, ",", &save); tok && count < 16; tok = strtok_r(nullptr, ",", &save)) { + fields[count++] = tok; + } + if (count <= 9) { + return; + } + DateTime dt = gps.utc; + const bool has_time = parse_utc_time(fields[1], dt); + const bool active = fields[2] && fields[2][0] == 'A'; + const bool has_date = parse_utc_date(fields[9], dt); + if (has_time && has_date && valid_datetime(dt)) { + gps.utc = dt; + gps.valid_time = true; + gps.valid_fix = active; + gps.last_time_ms = millis(); + } +} + +static void process_nmea(char* line) { + if (strncmp(line, "$GPRMC", 6) == 0 || strncmp(line, "$GNRMC", 6) == 0) { + parse_rmc(line); + } else if (strncmp(line, "$GPGGA", 6) == 0 || strncmp(line, "$GNGGA", 6) == 0) { + parse_gga(line); + } +} + +static void poll_gps() { + while (gps_serial.available() > 0) { + const char c = (char)gps_serial.read(); + if (c == '\r') { + continue; + } + if (c == '\n') { + if (gps_line_len > 0) { + gps_line[gps_line_len] = '\0'; + process_nmea(gps_line); + gps_line_len = 0; + } + } else if (gps_line_len + 1U < sizeof(gps_line)) { + gps_line[gps_line_len++] = c; + } else { + gps_line_len = 0; + } + } +} + +static bool wait_for_pps(uint32_t timeout_ms) { + const uint32_t start = millis(); + const uint32_t prior = last_pps_ms; + while ((uint32_t)(millis() - start) < timeout_ms) { + if (last_pps_ms != prior) { + return true; + } + poll_gps(); + delay(5); + } + return false; +} + +static bool saved_clock_is_fresh() { + DateTime rtc_now{}; + int64_t rtc_epoch = 0; + if (!read_rtc(rtc_now, &rtc_epoch)) { + Serial.println("RTC invalid; GPS discipline required"); + return false; + } + const int64_t marker_epoch = read_clock_marker(); + if (marker_epoch <= 0) { + Serial.println("No saved clock marker; GPS discipline required"); + return false; + } + const int64_t age = rtc_epoch - marker_epoch; + if (age < 0 || age > (int64_t)DISCIPLINE_HOLDOVER_SECONDS) { + Serial.printf("Saved clock stale: age_s=%lld\r\n", (long long)age); + return false; + } + char iso[32]; + format_iso(rtc_now, iso, sizeof(iso)); + Serial.printf("Clock holdover accepted: rtc=%s age_s=%lld\r\n", iso, (long long)age); + show_status("Clock OK", "holdover", iso); + return true; +} + +static void discipline_clock_from_gps() { + Serial.println("Waiting for GPS UTC before LoRa startup"); + gps_serial.begin(9600, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); +#ifdef GPS_1PPS_PIN + pinMode(GPS_1PPS_PIN, INPUT); + attachInterrupt(digitalPinToInterrupt(GPS_1PPS_PIN), on_pps_edge, RISING); +#endif + + uint32_t last_status = 0; + while (true) { + poll_gps(); + const uint32_t now = millis(); + if ((uint32_t)(now - last_status) >= GPS_STATUS_PERIOD_MS) { + last_status = now; + char sats[16]; + snprintf(sats, sizeof(sats), "sats=%d", gps.sats_used); + show_status("Take outside", gps.valid_time ? "GPS time" : "No GPS", sats); + Serial.printf("gps_gate time=%u fix=%u sats=%d pps_ms=%lu\r\n", + gps.valid_time ? 1U : 0U, + gps.valid_fix ? 1U : 0U, + gps.sats_used, + (unsigned long)last_pps_ms); + } + + if (gps.valid_time && gps.valid_fix && (uint32_t)(now - gps.last_time_ms) < 5000U) { + DateTime disciplined = gps.utc; + bool used_pps = wait_for_pps(PPS_WAIT_MS); + if (used_pps) { + int64_t snapped = to_epoch_seconds(gps.utc) + 1LL; + (void)from_epoch_seconds(snapped, disciplined); + } + if (write_rtc(disciplined)) { + const int64_t epoch = to_epoch_seconds(disciplined); + (void)write_clock_marker(epoch); + char iso[32]; + format_iso(disciplined, iso, sizeof(iso)); + Serial.printf("Clock disciplined from GPS: utc=%s pps=%u marker_written=%u\r\n", + iso, + used_pps ? 1U : 0U, + sd_ready ? 1U : 0U); + show_status("Clock set", used_pps ? "GPS+PPS" : "GPS", iso); + delay(1000); + return; + } + Serial.println("RTC write failed; retrying GPS discipline"); + } + delay(10); + } +} + +static bool should_initiate_link_to(const String& label) { + return strcmp(NODE_LABEL, label.c_str()) < 0; +} + +static uint8_t node_send_slot_second() { + if (strcmp(NODE_LABEL, "Amy") == 0) { + return 4; + } + if (strcmp(NODE_LABEL, "Bob") == 0) { + return 8; + } + if (strcmp(NODE_LABEL, "Cy") == 0) { + return 12; + } + if (strcmp(NODE_LABEL, "Dan") == 0) { + return 16; + } + if (strcmp(NODE_LABEL, "Ed") == 0) { + return 20; + } + if (strcmp(NODE_LABEL, "Flo") == 0) { + return 24; + } + if (strcmp(NODE_LABEL, "Guy") == 0) { + return 28; + } + return (uint8_t)(4U + ((uint8_t)NODE_SLOT_INDEX * 4U)); +} + +static int find_peer_by_label(const String& label) { + for (uint8_t i = 0; i < MAX_PEERS; ++i) { + if (peers[i].label == label) { + return i; + } + } + return -1; +} + +static int find_peer_by_link_hash(const RNS::Bytes& link_hash) { + for (uint8_t i = 0; i < MAX_PEERS; ++i) { + if (peers[i].outbound_link && peers[i].outbound_link.hash() == link_hash) { + return i; + } + if (peers[i].inbound_link && peers[i].inbound_link.hash() == link_hash) { + return i; + } + } + return -1; +} + +static int first_free_peer_slot() { + for (uint8_t i = 0; i < MAX_PEERS; ++i) { + if (peers[i].label.length() == 0 && !peers[i].outbound_link && !peers[i].inbound_link) { + return i; + } + } + return -1; +} + +static int ensure_peer_for_label(const String& label) { + int index = find_peer_by_label(label); + if (index >= 0) { + return index; + } + index = first_free_peer_slot(); + if (index >= 0) { + peers[index].label = label; + } + return index; +} + +static String parse_sender_label(const String& text) { + const int from_pos = text.indexOf("from "); + if (from_pos < 0) { + return ""; + } + const int start = from_pos + 5; + int end = text.indexOf(' ', start); + if (end < 0) { + end = text.length(); + } + return text.substring(start, end); +} + +static void attach_link_callbacks(RNS::Link& link) { + link.set_packet_callback(on_link_packet); + link.set_link_closed_callback(on_link_closed); +} + +static void on_link_packet(const RNS::Bytes& data, const RNS::Packet& packet) { + String peer = "(unknown)"; + int peer_index = -1; + if (packet.link()) { + peer_index = find_peer_by_link_hash(packet.link().hash()); + } + String text = data.toString().c_str(); + String sender = parse_sender_label(text); + if (peer_index >= 0 && peers[peer_index].label.length() == 0 && + sender.length() > 0 && sender != NODE_LABEL) { + peers[peer_index].label = sender; + } + if (peer_index < 0) { + if (sender.length() > 0 && sender != NODE_LABEL) { + peer_index = ensure_peer_for_label(sender); + if (peer_index >= 0 && packet.link()) { + peers[peer_index].inbound_link = packet.link(); + peers[peer_index].inbound_active = true; + attach_link_callbacks(peers[peer_index].inbound_link); + } + } + } + if (peer_index >= 0) { + peer = peers[peer_index].label; + } + float rssi = lora_impl ? lora_impl->last_rssi() : 0.0f; + float snr = lora_impl ? lora_impl->last_snr() : 0.0f; + Serial.printf("RX LINK: %s | RSSI=%.1f SNR=%.1f\r\n", + text.c_str(), rssi, snr); + show_status("RX LINK", peer.c_str(), text.c_str()); +} + +static void on_link_closed(RNS::Link& link) { + int peer_index = find_peer_by_link_hash(link.hash()); + const char* label = "(unknown)"; + if (peer_index >= 0) { + label = peers[peer_index].label.c_str(); + if (peers[peer_index].outbound_link && peers[peer_index].outbound_link.hash() == link.hash()) { + peers[peer_index].outbound_link = {RNS::Type::NONE}; + peers[peer_index].outbound_active = false; + peers[peer_index].outbound_attempted = false; + peers[peer_index].last_link_attempt_ms = 0; + } + if (peers[peer_index].inbound_link && peers[peer_index].inbound_link.hash() == link.hash()) { + peers[peer_index].inbound_link = {RNS::Type::NONE}; + peers[peer_index].inbound_active = false; + } + } + Serial.printf("LINK CLOSED: %s hash=%s\r\n", label, link.hash().toHex().c_str()); + show_status("LINK CLOSED", label); +} + +static void on_outbound_link_established(RNS::Link& link) { + int peer_index = find_peer_by_link_hash(link.hash()); + if (peer_index >= 0) { + peers[peer_index].outbound_link = link; + attach_link_callbacks(peers[peer_index].outbound_link); + peers[peer_index].outbound_active = true; + peers[peer_index].outbound_attempted = true; + peers[peer_index].last_link_attempt_ms = 0; + Serial.printf("LINK ACTIVE: initiator link established to %s hash=%s\r\n", + peers[peer_index].label.c_str(), link.hash().toHex().c_str()); + show_status("LINK ACTIVE", peers[peer_index].label.c_str(), "initiator"); + } else { + Serial.printf("LINK ACTIVE: initiator link established hash=%s peer=unknown\r\n", + link.hash().toHex().c_str()); + } +} + +static void on_inbound_link_established(RNS::Link& link) { + int peer_index = find_peer_by_link_hash(link.hash()); + if (peer_index < 0) { + peer_index = first_free_peer_slot(); + } + if (peer_index >= 0) { + peers[peer_index].inbound_link = link; + attach_link_callbacks(peers[peer_index].inbound_link); + peers[peer_index].inbound_active = true; + } + Serial.printf("RX LINK: inbound link established hash=%s\r\n", link.hash().toHex().c_str()); + show_status("LINK ACTIVE", "inbound", link.hash().toHex().c_str()); +} + +class LinkAnnounceHandler : public RNS::AnnounceHandler { + public: + LinkAnnounceHandler() : RNS::AnnounceHandler(ANNOUNCE_FILTER) {} + + void received_announce(const RNS::Bytes& destination_hash, + const RNS::Identity& announced_identity, + const RNS::Bytes& app_data) override { + String label = app_data ? String(app_data.toString().c_str()) : String("(no label)"); + if (label == NODE_LABEL) { + return; + } + if (!announced_identity) { + Serial.printf("RX ANNOUNCE ignored: missing identity for hash=%s\r\n", + destination_hash.toHex().c_str()); + return; + } + + int peer_index = ensure_peer_for_label(label); + if (peer_index < 0) { + Serial.printf("RX ANNOUNCE ignored: peer table full label=%s hash=%s\r\n", + label.c_str(), destination_hash.toHex().c_str()); + return; + } + + peers[peer_index].destination_hash = destination_hash; + peers[peer_index].destination = RNS::Destination(announced_identity, + RNS::Type::Destination::OUT, + RNS::Type::Destination::SINGLE, + destination_hash); + peers[peer_index].announced = true; + + Serial.printf("RX ANNOUNCE: label=%s hash=%s\r\n", + peers[peer_index].label.c_str(), peers[peer_index].destination_hash.toHex().c_str()); + show_status("RX ANNOUNCE", peers[peer_index].label.c_str(), peers[peer_index].destination_hash.toHex().c_str()); + } +}; + +static RNS::HAnnounceHandler announce_handler(new LinkAnnounceHandler()); + +static void print_config() { + Serial.printf("Node=%s Board=%s Build=%s\r\n", NODE_LABEL, BOARD_ID, FW_BUILD_UTC); + Serial.printf("Pins: CS=%d DIO1=%d RST=%d BUSY=%d SCK=%d MISO=%d MOSI=%d\r\n", + (int)LORA_CS, (int)LORA_DIO1, (int)LORA_RESET, (int)LORA_BUSY, + (int)LORA_SCK, (int)LORA_MISO, (int)LORA_MOSI); + Serial.printf("LoRa: freq=%.1f BW=%.1f SF=%d CR=%d sync=0x%02x txp=%d\r\n", + (double)LORA_FREQ_MHZ, (double)LORA_BW_KHZ, (int)LORA_SF, + (int)LORA_CR, (int)LORA_SYNC_WORD, (int)LORA_TX_POWER_DBM); +} + +static void send_announce() { + if (!inbound_destination || !clock_ready) { + return; + } + Serial.printf("TX ANNOUNCE: %s\r\n", NODE_LABEL); + show_status("TX ANNOUNCE", NODE_LABEL); + inbound_destination.announce(RNS::bytesFromString(NODE_LABEL)); +} + +static void maybe_open_link() { + const uint32_t now = millis(); + static uint32_t last_open_ms = 0; + if (!clock_ready) { + return; + } + for (uint8_t i = 0; i < MAX_PEERS; ++i) { + PeerState& peer = peers[i]; + if (!peer.announced || !peer.destination || !should_initiate_link_to(peer.label)) { + continue; + } + if (peer.outbound_active || (peer.outbound_link && peer.outbound_link.status() == RNS::Type::Link::ACTIVE)) { + peer.outbound_active = true; + continue; + } + if (peer.outbound_attempted && peer.last_link_attempt_ms != 0 && + (uint32_t)(now - peer.last_link_attempt_ms) >= LINK_RETRY_MS) { + Serial.printf("LINK RETRY: no establishment after %lu ms; retrying %s\r\n", + (unsigned long)LINK_RETRY_MS, + peer.label.c_str()); + peer.outbound_link = {RNS::Type::NONE}; + peer.outbound_attempted = false; + peer.last_link_attempt_ms = 0; + } + if (peer.outbound_attempted || peer.outbound_link) { + continue; + } + if (last_open_ms != 0 && (uint32_t)(now - last_open_ms) < 1500U) { + return; + } + + Serial.printf("TX LINKREQUEST: opening link to %s\r\n", peer.label.c_str()); + show_status("TX LINKREQ", peer.label.c_str()); + peer.outbound_link = RNS::Link(peer.destination); + peer.outbound_link.set_packet_callback(on_link_packet); + peer.outbound_link.set_link_established_callback(on_outbound_link_established); + peer.outbound_link.set_link_closed_callback(on_link_closed); + peer.outbound_attempted = true; + peer.last_link_attempt_ms = now; + last_open_ms = now; + return; + } +} + +static void setup_reticulum() { + microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem()}; + filesystem.init(); + RNS::Utilities::OS::register_filesystem(filesystem); + + lora_impl = new TBeamSupremeLoRaInterface(); + lora_interface = lora_impl; + lora_interface.mode(RNS::Type::Interface::MODE_GATEWAY); + RNS::Transport::register_interface(lora_interface); + lora_interface.start(); + + reticulum = RNS::Reticulum(); + reticulum.transport_enabled(true); + reticulum.probe_destination_enabled(false); + + RNS::Bytes private_key; + if (!identity_bytes_from_hex(private_key)) { + Serial.println("FATAL: identity hex decode failed"); + show_status("IDENTITY FAIL", BOARD_ID); + while (true) { + delay(1000); + } + } + local_identity = RNS::Identity(false); + if (!local_identity.load_private_key(private_key)) { + Serial.println("FATAL: identity load failed"); + show_status("IDENTITY FAIL", BOARD_ID); + while (true) { + delay(1000); + } + } + transport_identity = RNS::Identity(false); + transport_identity.load_private_key(private_key); + RNS::Transport::identity(transport_identity); + + reticulum.start(); + + inbound_destination = RNS::Destination(local_identity, + RNS::Type::Destination::IN, + RNS::Type::Destination::SINGLE, + APP_NAME, + APP_ASPECT); + inbound_destination.set_link_established_callback(on_inbound_link_established); + inbound_destination.set_proof_strategy(RNS::Type::Destination::PROVE_NONE); + + RNS::Transport::register_announce_handler(announce_handler); + + Serial.printf("Local Identity: %s\r\n", local_identity.hash().toHex().c_str()); + Serial.printf("Local SINGLE destination: %s\r\n", + inbound_destination.hash().toHex().c_str()); +} + +void setup() { + Serial.begin(115200); + while (!Serial && millis() < 5000) { + delay(100); + } + delay(250); + + Serial.println(); + Serial.println("Exercise 204: established identities transport Link ping-pong"); + RNS::loglevel(RNS::LOG_NOTICE); + + (void)tbeam_supreme::initPmuForPeripherals(pmu, &Serial); + tbeam::DisplayConfig display_config; + display_config.powerSave = false; + oled_display.begin(display_config); + show_splash(); + delay(1200); + + print_config(); + mount_sd(); + + if (saved_clock_is_fresh()) { + clock_ready = true; + } else { + discipline_clock_from_gps(); + clock_ready = true; + } + + show_status("Starting LoRa", NODE_LABEL); + setup_reticulum(); + show_status("microR ready", NODE_LABEL, local_identity.hash().toHex().c_str()); + Serial.println("microReticulum ready"); +} + +void loop() { + reticulum.loop(); + + static uint32_t next_announce_ms = 0; + static uint32_t last_rtc_poll_ms = 0; + static DateTime rtc_now{}; + static bool have_rtc_now = false; + uint32_t now = millis(); + + if (next_announce_ms == 0) { + next_announce_ms = now + 700U + ((uint32_t)NODE_SLOT_INDEX * 350U); + } + if ((int32_t)(now - next_announce_ms) >= 0) { + next_announce_ms = now + 15000; + send_announce(); + } + + maybe_open_link(); + + if ((uint32_t)(now - last_rtc_poll_ms) >= 200U) { + last_rtc_poll_ms = now; + have_rtc_now = read_rtc(rtc_now); + } + const uint8_t send_slot = node_send_slot_second(); + const uint8_t second_slot = (uint8_t)((send_slot + 30U) % 60U); + if (have_rtc_now && (rtc_now.second == send_slot || rtc_now.second == second_slot)) { + for (uint8_t i = 0; i < MAX_PEERS; ++i) { + PeerState& peer = peers[i]; + if (peer.label.length() == 0 || peer.last_tx_second == rtc_now.second) { + continue; + } + + RNS::Link* link = nullptr; + if (peer.outbound_link && peer.outbound_link.status() == RNS::Type::Link::ACTIVE) { + peer.outbound_active = true; + link = &peer.outbound_link; + } else if (peer.inbound_link && peer.inbound_link.status() == RNS::Type::Link::ACTIVE) { + peer.inbound_active = true; + link = &peer.inbound_link; + } + if (!link) { + continue; + } + + peer.last_tx_second = rtc_now.second; + String message = String("Hi from ") + NODE_LABEL + " iter=" + String(peer.tx_iter++) + " to=" + peer.label; + Serial.printf("TX LINK: %s\r\n", message.c_str()); + show_status("TX LINK", peer.label.c_str(), message.c_str()); + RNS::Packet(*link, RNS::bytesFromString(message.c_str())).send(); + delay(120); + } + } + + delay(5); +} + +int _write(int file, char* ptr, int len) { + (void)file; + int wrote = Serial.write(ptr, len); + Serial.flush(); + return wrote; +} From d8e5cc0a8ddf0f3a892c62b9fd22bff3b1f6fe9b Mon Sep 17 00:00:00 2001 From: John Poole Date: Thu, 28 May 2026 17:25:31 -0700 Subject: [PATCH 06/14] Safety --- .../notes/20250628_1724.txt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 exercises/204_established_identities/notes/20250628_1724.txt diff --git a/exercises/204_established_identities/notes/20250628_1724.txt b/exercises/204_established_identities/notes/20250628_1724.txt new file mode 100644 index 0000000..5c701e9 --- /dev/null +++ b/exercises/204_established_identities/notes/20250628_1724.txt @@ -0,0 +1,24 @@ + BOB is having problem with using the clock sentinel. Every time BOB starts anew, I have to take it to a window to get satellite time. I tried erasing the clock.txt file and the directory, and a new directory and file were created, but it does not rely upon the clock.txt and forces a satellite time reset. I tried clicking RESET on BOB, and it needs satellites. RESET on the three others does not, they resume without needing satellites. + + Also, the splash screen with version needs: + 1) the version needes to be on a 2nd line, it goes off to the right of the screen, so i cannot determine its full version. + 2) the splash screen needs to hold for 10 seconds, it flashes by too quickly, so please introduce a sleep. + + ED never seems to link. + + I changed in main.cpp: + + //static constexpr uint32_t LINK_RETRY_MS = 30000; + static constexpr uint32_t LINK_RETRY_MS = 90000; + +because the LINKS were timing out. Still, with 90 seconds, they time out. + +Is the continued ANNOUNCEMENT something that is interfering with the longevity of LINKS? It seems that if a LINK is established, then both units on the LINK would just continue sending back and forth; that is not happening, the LINK seems to be torn down. + +I have a 3 minute set of logs with the lastest code at: ~/logs/20260528_1707. Of the four 180 second logs, only one entry of: + + jlpoole@jp ~/logs/20260528_1707 $ grep -i 'RX LINK' * + CY_raw_20260528_170755.log:RX LINK: inbound link established hash=3255588dd68ff2bc6ab95b8712120343 + jlpoole@jp ~/logs/20260528_1707 $ + +Are we hitting some bugs in microreticulum? \ No newline at end of file From fd96e56410865cb9daeff7fa1a5f47188bca91b6 Mon Sep 17 00:00:00 2001 From: John Poole Date: Thu, 28 May 2026 20:50:16 -0700 Subject: [PATCH 07/14] All 4 units, BOB, CY, DAN & ED, are LINKing with the other three. Success. --- .../204_established_identities/README.md | 12 +- .../notes/20250628_1724.txt | 8 +- .../204_established_identities/src/main.cpp | 237 ++++++++++++++++-- 3 files changed, 227 insertions(+), 30 deletions(-) diff --git a/exercises/204_established_identities/README.md b/exercises/204_established_identities/README.md index 47058bf..9391b42 100644 --- a/exercises/204_established_identities/README.md +++ b/exercises/204_established_identities/README.md @@ -43,18 +43,22 @@ We have 7 units: AMY...GUY. The following command creates identities for use in At boot the OLED shows: ```text -Ex 204 v. +Exercise 204 +Build + ``` -The unit then checks the RTC and `/ex204/clock.txt` on the SD card. If the saved discipline marker is less than 24 hours old according to the RTC, LoRa/microReticulum starts immediately. If not, the OLED shows `Take outside` and serial prints `gps_gate ...` until GPS UTC/fix is available. A successful GPS discipline writes a fresh marker and then starts LoRa. +The splash is held for 10 seconds so the full build stamp can be read. + +The unit then checks the RTC and `/ex204/clock.txt` on the SD card. If the saved discipline marker is less than 24 hours old according to the RTC, LoRa/microReticulum starts immediately. If not, the OLED shows `Take outside` and serial prints `gps_gate ...` until GPS UTC/fix is available. A successful GPS discipline writes a fresh marker and then starts LoRa. Marker writes replace the old file, and marker reads use the last valid line so stale appended clock data cannot keep forcing GPS discipline. All units run with `reticulum.transport_enabled(true)`. Path learning remains enabled, but `RNS_PERSIST_PATHS` is intentionally not defined for this exercise. Live announces are sufficient for the single-hop field test, and disabling persistent paths avoids ESP32 LittleFS `/path_store_*.dat` compaction errors during dense multi-unit announce traffic. -Each unit tracks up to six peers, so a full seven-unit field run can form one Link per ordered pair. The lexicographically lower node initiates each pair to avoid both ends opening duplicate Links at the same time; for example `Bob` initiates to `Cy`, `Dan`, `Ed`, `Flo`, and `Guy`, while `Amy` initiates to everyone. If an outbound link request is sent but no link becomes active, the initiator retries after 30 seconds. This matters in field use because the first request can be missed while units are being moved, reset, or connected to monitors. +Each unit tracks up to six peers, so a full seven-unit field run can form one Link per ordered pair. The lexicographically lower node initiates each pair to avoid both ends opening duplicate Links at the same time; for example `Bob` initiates to `Cy`, `Dan`, `Ed`, `Flo`, and `Guy`, while `Amy` initiates to everyone. If an outbound link request is sent but no link becomes active, the initiator retries after 90 seconds. This matters in field use because the first request can be missed while units are being moved, reset, or connected to monitors. -Application traffic is scheduled from the RTC so all nodes do not transmit at once: +Announces and application traffic are scheduled from the RTC so all nodes do not transmit at once. Announces run once per minute at `02,06,10,14,18,22,26` for `Amy..Guy`; Link payloads run twice per minute: ```text Amy: 04,34 Bob: 08,38 Cy: 12,42 Dan: 16,46 diff --git a/exercises/204_established_identities/notes/20250628_1724.txt b/exercises/204_established_identities/notes/20250628_1724.txt index 5c701e9..e4e4e15 100644 --- a/exercises/204_established_identities/notes/20250628_1724.txt +++ b/exercises/204_established_identities/notes/20250628_1724.txt @@ -21,4 +21,10 @@ I have a 3 minute set of logs with the lastest code at: ~/logs/20260528_1707. O CY_raw_20260528_170755.log:RX LINK: inbound link established hash=3255588dd68ff2bc6ab95b8712120343 jlpoole@jp ~/logs/20260528_1707 $ -Are we hitting some bugs in microreticulum? \ No newline at end of file +Are we hitting some bugs in microreticulum? + +When you are ready to compile, please compile only 1 unit, if that compiles, then so will all the rest. Notify me and I can run a bash script that will compile the others -- it takes about 4 minutes to complete. + +Please leave all uploading of newly compiled firmware to me. + +I am trying to maximize your time within my 5 Hour allowance and which recently has been consumed at a rapid rate and I think having you monitor units and wait for compiles and upload may be eating into the budget. I've developed a logging system that isolates logs from a run into its own directory, e.g. ~/logs/20260528_1707, so we can use that to efficiently evaluate what is going on. \ No newline at end of file diff --git a/exercises/204_established_identities/src/main.cpp b/exercises/204_established_identities/src/main.cpp index 0ad102c..bebbf96 100644 --- a/exercises/204_established_identities/src/main.cpp +++ b/exercises/204_established_identities/src/main.cpp @@ -44,6 +44,9 @@ static constexpr uint32_t GPS_STATUS_PERIOD_MS = 2000; static constexpr uint32_t PPS_WAIT_MS = 1500; //static constexpr uint32_t LINK_RETRY_MS = 30000; static constexpr uint32_t LINK_RETRY_MS = 90000; +static constexpr uint32_t LINK_RX_STALE_MS = 75000; +static constexpr uint32_t LINK_REOPEN_DELAY_MS = 5000; +static constexpr uint8_t LINK_MESSAGES_PER_CYCLE = 5; struct DateTime { uint16_t year = 0; @@ -92,7 +95,12 @@ struct PeerState { bool outbound_active = false; bool inbound_active = false; uint32_t last_link_attempt_ms = 0; + uint32_t last_link_active_ms = 0; + uint32_t next_link_open_ms = 0; + uint32_t last_rx_ms = 0; + uint32_t last_tx_ms = 0; uint32_t tx_iter = 0; + uint8_t tx_since_link_open = 0; uint8_t last_tx_second = 255; }; @@ -256,10 +264,17 @@ static int64_t read_clock_marker() { if (!f) { return 0; } - String line = f.readStringUntil('\n'); + int64_t marker_epoch = 0; + while (f.available()) { + String line = f.readStringUntil('\n'); + line.trim(); + const int64_t value = (int64_t)strtoll(line.c_str(), nullptr, 10); + if (value > 0) { + marker_epoch = value; + } + } f.close(); - line.trim(); - return (int64_t)strtoll(line.c_str(), nullptr, 10); + return marker_epoch; } static bool write_clock_marker(int64_t epoch) { @@ -267,6 +282,9 @@ static bool write_clock_marker(int64_t epoch) { return false; } SD.mkdir("/ex204"); + if (SD.exists(CLOCK_MARKER_PATH)) { + SD.remove(CLOCK_MARKER_PATH); + } File f = SD.open(CLOCK_MARKER_PATH, FILE_WRITE); if (!f) { return false; @@ -281,7 +299,7 @@ static void show_status(const char* left, const char* right = nullptr, const cha } static void show_splash() { - oled_display.showLines("Ex 204 v. " FW_BUILD_UTC, BOARD_ID, NODE_LABEL, "established ids", "transport"); + oled_display.showLines("Exercise 204", "Build", FW_BUILD_UTC, BOARD_ID, NODE_LABEL, "transport"); } static bool hex_nibble(char c, uint8_t& out) { @@ -426,6 +444,13 @@ static bool saved_clock_is_fresh() { Serial.println("RTC invalid; GPS discipline required"); return false; } + char iso[32]; + format_iso(rtc_now, iso, sizeof(iso)); + if (!sd_ready) { + Serial.printf("Clock holdover accepted without SD marker: rtc=%s reason=sd_unavailable\r\n", iso); + show_status("Clock OK", "RTC only", iso); + return true; + } const int64_t marker_epoch = read_clock_marker(); if (marker_epoch <= 0) { Serial.println("No saved clock marker; GPS discipline required"); @@ -436,9 +461,8 @@ static bool saved_clock_is_fresh() { Serial.printf("Saved clock stale: age_s=%lld\r\n", (long long)age); return false; } - char iso[32]; - format_iso(rtc_now, iso, sizeof(iso)); Serial.printf("Clock holdover accepted: rtc=%s age_s=%lld\r\n", iso, (long long)age); + (void)write_clock_marker(rtc_epoch); show_status("Clock OK", "holdover", iso); return true; } @@ -497,6 +521,45 @@ static bool should_initiate_link_to(const String& label) { return strcmp(NODE_LABEL, label.c_str()) < 0; } +static int8_t node_index_for_label(const String& label) { + if (label == "Amy") { + return 0; + } + if (label == "Bob") { + return 1; + } + if (label == "Cy") { + return 2; + } + if (label == "Dan") { + return 3; + } + if (label == "Ed") { + return 4; + } + if (label == "Flo") { + return 5; + } + if (label == "Guy") { + return 6; + } + return -1; +} + +static uint8_t pair_link_open_second(const String& label) { + int8_t a = node_index_for_label(String(NODE_LABEL)); + int8_t b = node_index_for_label(label); + if (a < 0 || b < 0) { + return 1; + } + if (a > b) { + const int8_t tmp = a; + a = b; + b = tmp; + } + return (uint8_t)(1U + (uint8_t)(((uint8_t)a * 7U + (uint8_t)b) % 29U) * 2U); +} + static uint8_t node_send_slot_second() { if (strcmp(NODE_LABEL, "Amy") == 0) { return 4; @@ -522,6 +585,10 @@ static uint8_t node_send_slot_second() { return (uint8_t)(4U + ((uint8_t)NODE_SLOT_INDEX * 4U)); } +static uint8_t node_announce_slot_second() { + return (uint8_t)(2U + ((uint8_t)NODE_SLOT_INDEX * 4U)); +} + static int find_peer_by_label(const String& label) { for (uint8_t i = 0; i < MAX_PEERS; ++i) { if (peers[i].label == label) { @@ -552,6 +619,26 @@ static int first_free_peer_slot() { return -1; } +static void clear_peer_slot(uint8_t index) { + peers[index].label = ""; + peers[index].destination_hash.clear(); + peers[index].destination = {RNS::Type::NONE}; + peers[index].outbound_link = {RNS::Type::NONE}; + peers[index].inbound_link = {RNS::Type::NONE}; + peers[index].announced = false; + peers[index].outbound_attempted = false; + peers[index].outbound_active = false; + peers[index].inbound_active = false; + peers[index].last_link_attempt_ms = 0; + peers[index].last_link_active_ms = 0; + peers[index].next_link_open_ms = 0; + peers[index].last_rx_ms = 0; + peers[index].last_tx_ms = 0; + peers[index].tx_iter = 0; + peers[index].tx_since_link_open = 0; + peers[index].last_tx_second = 255; +} + static int ensure_peer_for_label(const String& label) { int index = find_peer_by_label(label); if (index >= 0) { @@ -577,6 +664,19 @@ static String parse_sender_label(const String& text) { return text.substring(start, end); } +static String parse_recipient_label(const String& text) { + const int to_pos = text.indexOf("to="); + if (to_pos < 0) { + return ""; + } + const int start = to_pos + 3; + int end = text.indexOf(' ', start); + if (end < 0) { + end = text.length(); + } + return text.substring(start, end); +} + static void attach_link_callbacks(RNS::Link& link) { link.set_packet_callback(on_link_packet); link.set_link_closed_callback(on_link_closed); @@ -590,9 +690,25 @@ static void on_link_packet(const RNS::Bytes& data, const RNS::Packet& packet) { } String text = data.toString().c_str(); String sender = parse_sender_label(text); + String recipient = parse_recipient_label(text); + if (sender == NODE_LABEL || (recipient.length() > 0 && recipient != NODE_LABEL)) { + Serial.printf("RX LINK ignored: self_or_wrong_recipient text=%s\r\n", text.c_str()); + return; + } if (peer_index >= 0 && peers[peer_index].label.length() == 0 && sender.length() > 0 && sender != NODE_LABEL) { - peers[peer_index].label = sender; + const int existing_index = find_peer_by_label(sender); + if (existing_index >= 0 && existing_index != peer_index) { + if (packet.link()) { + peers[existing_index].inbound_link = packet.link(); + peers[existing_index].inbound_active = true; + attach_link_callbacks(peers[existing_index].inbound_link); + } + clear_peer_slot((uint8_t)peer_index); + peer_index = existing_index; + } else { + peers[peer_index].label = sender; + } } if (peer_index < 0) { if (sender.length() > 0 && sender != NODE_LABEL) { @@ -606,6 +722,7 @@ static void on_link_packet(const RNS::Bytes& data, const RNS::Packet& packet) { } if (peer_index >= 0) { peer = peers[peer_index].label; + peers[peer_index].last_rx_ms = millis(); } float rssi = lora_impl ? lora_impl->last_rssi() : 0.0f; float snr = lora_impl ? lora_impl->last_snr() : 0.0f; @@ -615,22 +732,34 @@ static void on_link_packet(const RNS::Bytes& data, const RNS::Packet& packet) { } static void on_link_closed(RNS::Link& link) { - int peer_index = find_peer_by_link_hash(link.hash()); + if (!link) { + Serial.println("LINK CLOSED: null link callback"); + show_status("LINK CLOSED", "null"); + return; + } + + const RNS::Bytes link_hash = link.hash(); + const String link_hash_hex = link_hash.toHex().c_str(); + int peer_index = find_peer_by_link_hash(link_hash); const char* label = "(unknown)"; if (peer_index >= 0) { label = peers[peer_index].label.c_str(); - if (peers[peer_index].outbound_link && peers[peer_index].outbound_link.hash() == link.hash()) { + if (peers[peer_index].outbound_link && peers[peer_index].outbound_link.hash() == link_hash) { peers[peer_index].outbound_link = {RNS::Type::NONE}; peers[peer_index].outbound_active = false; peers[peer_index].outbound_attempted = false; peers[peer_index].last_link_attempt_ms = 0; + peers[peer_index].last_link_active_ms = 0; + peers[peer_index].tx_since_link_open = 0; } - if (peers[peer_index].inbound_link && peers[peer_index].inbound_link.hash() == link.hash()) { + if (peers[peer_index].inbound_link && peers[peer_index].inbound_link.hash() == link_hash) { peers[peer_index].inbound_link = {RNS::Type::NONE}; peers[peer_index].inbound_active = false; + peers[peer_index].last_link_active_ms = 0; + peers[peer_index].tx_since_link_open = 0; } } - Serial.printf("LINK CLOSED: %s hash=%s\r\n", label, link.hash().toHex().c_str()); + Serial.printf("LINK CLOSED: %s hash=%s\r\n", label, link_hash_hex.c_str()); show_status("LINK CLOSED", label); } @@ -642,6 +771,8 @@ static void on_outbound_link_established(RNS::Link& link) { peers[peer_index].outbound_active = true; peers[peer_index].outbound_attempted = true; peers[peer_index].last_link_attempt_ms = 0; + peers[peer_index].last_link_active_ms = millis(); + peers[peer_index].tx_since_link_open = 0; Serial.printf("LINK ACTIVE: initiator link established to %s hash=%s\r\n", peers[peer_index].label.c_str(), link.hash().toHex().c_str()); show_status("LINK ACTIVE", peers[peer_index].label.c_str(), "initiator"); @@ -660,6 +791,8 @@ static void on_inbound_link_established(RNS::Link& link) { peers[peer_index].inbound_link = link; attach_link_callbacks(peers[peer_index].inbound_link); peers[peer_index].inbound_active = true; + peers[peer_index].last_link_active_ms = millis(); + peers[peer_index].tx_since_link_open = 0; } Serial.printf("RX LINK: inbound link established hash=%s\r\n", link.hash().toHex().c_str()); show_status("LINK ACTIVE", "inbound", link.hash().toHex().c_str()); @@ -723,7 +856,7 @@ static void send_announce() { inbound_destination.announce(RNS::bytesFromString(NODE_LABEL)); } -static void maybe_open_link() { +static void maybe_open_link(const DateTime& rtc_now, bool have_rtc_now) { const uint32_t now = millis(); static uint32_t last_open_ms = 0; if (!clock_ready) { @@ -734,6 +867,37 @@ static void maybe_open_link() { if (!peer.announced || !peer.destination || !should_initiate_link_to(peer.label)) { continue; } + uint32_t stale_reference_ms = peer.last_rx_ms; + if (stale_reference_ms == 0) { + stale_reference_ms = peer.last_tx_ms; + } + if (stale_reference_ms == 0) { + stale_reference_ms = peer.last_link_active_ms; + } + if (stale_reference_ms == 0) { + stale_reference_ms = peer.last_link_attempt_ms; + } + if ((peer.outbound_link || peer.inbound_link) && stale_reference_ms != 0 && + (uint32_t)(now - stale_reference_ms) >= LINK_RX_STALE_MS) { + Serial.printf("LINK STALE: no RX from %s after %lu ms ref=%lu; clearing link state\r\n", + peer.label.c_str(), + (unsigned long)LINK_RX_STALE_MS, + (unsigned long)stale_reference_ms); + peer.outbound_link = {RNS::Type::NONE}; + peer.inbound_link = {RNS::Type::NONE}; + peer.outbound_active = false; + peer.inbound_active = false; + peer.outbound_attempted = false; + peer.last_link_attempt_ms = 0; + peer.last_link_active_ms = 0; + peer.last_rx_ms = 0; + peer.last_tx_ms = 0; + peer.tx_since_link_open = 0; + peer.next_link_open_ms = now + LINK_REOPEN_DELAY_MS; + } + if (peer.next_link_open_ms != 0 && (int32_t)(now - peer.next_link_open_ms) < 0) { + continue; + } if (peer.outbound_active || (peer.outbound_link && peer.outbound_link.status() == RNS::Type::Link::ACTIVE)) { peer.outbound_active = true; continue; @@ -750,11 +914,17 @@ static void maybe_open_link() { if (peer.outbound_attempted || peer.outbound_link) { continue; } + const uint8_t open_second = pair_link_open_second(peer.label); + if (!have_rtc_now || rtc_now.second != open_second) { + continue; + } if (last_open_ms != 0 && (uint32_t)(now - last_open_ms) < 1500U) { return; } - Serial.printf("TX LINKREQUEST: opening link to %s\r\n", peer.label.c_str()); + Serial.printf("TX LINKREQUEST: opening link to %s slot=%u\r\n", + peer.label.c_str(), + (unsigned)open_second); show_status("TX LINKREQ", peer.label.c_str()); peer.outbound_link = RNS::Link(peer.destination); peer.outbound_link.set_packet_callback(on_link_packet); @@ -835,7 +1005,7 @@ void setup() { display_config.powerSave = false; oled_display.begin(display_config); show_splash(); - delay(1200); + delay(10000); print_config(); mount_sd(); @@ -856,26 +1026,23 @@ void setup() { void loop() { reticulum.loop(); - static uint32_t next_announce_ms = 0; static uint32_t last_rtc_poll_ms = 0; static DateTime rtc_now{}; static bool have_rtc_now = false; + static uint8_t last_announce_minute = 255; uint32_t now = millis(); - if (next_announce_ms == 0) { - next_announce_ms = now + 700U + ((uint32_t)NODE_SLOT_INDEX * 350U); - } - if ((int32_t)(now - next_announce_ms) >= 0) { - next_announce_ms = now + 15000; - send_announce(); - } - - maybe_open_link(); - if ((uint32_t)(now - last_rtc_poll_ms) >= 200U) { last_rtc_poll_ms = now; have_rtc_now = read_rtc(rtc_now); } + maybe_open_link(rtc_now, have_rtc_now); + + if (have_rtc_now && rtc_now.second == node_announce_slot_second() && + last_announce_minute != rtc_now.minute) { + last_announce_minute = rtc_now.minute; + send_announce(); + } const uint8_t send_slot = node_send_slot_second(); const uint8_t second_slot = (uint8_t)((send_slot + 30U) % 60U); if (have_rtc_now && (rtc_now.second == send_slot || rtc_now.second == second_slot)) { @@ -898,10 +1065,30 @@ void loop() { } peer.last_tx_second = rtc_now.second; + peer.last_tx_ms = now; String message = String("Hi from ") + NODE_LABEL + " iter=" + String(peer.tx_iter++) + " to=" + peer.label; Serial.printf("TX LINK: %s\r\n", message.c_str()); show_status("TX LINK", peer.label.c_str(), message.c_str()); RNS::Packet(*link, RNS::bytesFromString(message.c_str())).send(); + ++peer.tx_since_link_open; + if (peer.tx_since_link_open >= LINK_MESSAGES_PER_CYCLE) { + Serial.printf("LINK CYCLE: sent %u messages to %s; tearing down for reopen\r\n", + (unsigned)peer.tx_since_link_open, + peer.label.c_str()); + show_status("LINK CYCLE", peer.label.c_str(), "teardown"); + link->teardown(); + peer.outbound_link = {RNS::Type::NONE}; + peer.inbound_link = {RNS::Type::NONE}; + peer.outbound_active = false; + peer.inbound_active = false; + peer.outbound_attempted = false; + peer.last_link_attempt_ms = 0; + peer.last_link_active_ms = 0; + peer.last_rx_ms = 0; + peer.last_tx_ms = 0; + peer.tx_since_link_open = 0; + peer.next_link_open_ms = now + LINK_REOPEN_DELAY_MS; + } delay(120); } } From 7f78dfb70e1be8bdc92e9da9f78fc4810d1c2bc8 Mon Sep 17 00:00:00 2001 From: John Poole Date: Thu, 28 May 2026 21:23:10 -0700 Subject: [PATCH 08/14] Change frame to have prefix showing origin. Tested and all units received from the other tree. I should have committed then, but forgot. Here, the blocking has been activated, but not tested. --- .../204_established_identities/platformio.ini | 2 + .../src/TBeamSupremeLoRaInterface.cpp | 93 ++++++++++++++++--- .../src/TBeamSupremeLoRaInterface.h | 15 ++- 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/exercises/204_established_identities/platformio.ini b/exercises/204_established_identities/platformio.ini index 1d5e9d4..4fd19f3 100644 --- a/exercises/204_established_identities/platformio.ini +++ b/exercises/204_established_identities/platformio.ini @@ -50,6 +50,8 @@ build_flags = -D LORA_SYNC_WORD=0x12 -D LORA_TX_POWER_DBM=14 -D USTORE_MAX_VALUE_LEN=1200 + -D SIM_PHY_ENVELOPE=1 + -D SIM_PHY_BLOCK_BOB_CY=1 ; Live announces are enough for this single-hop field exercise. Do not define ; RNS_PERSIST_PATHS here: the LittleFS-backed path_store compactor can leave an ; active segment FD open while unlinking /path_store_*.dat on ESP32. diff --git a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp index 80359a2..1f47075 100644 --- a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp +++ b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp @@ -17,6 +17,15 @@ #ifndef LORA_BUSY #error "LORA_BUSY not defined" #endif +#ifndef NODE_SLOT_INDEX +#define NODE_SLOT_INDEX 255 +#endif +#ifndef SIM_PHY_ENVELOPE +#define SIM_PHY_ENVELOPE 0 +#endif +#ifndef SIM_PHY_BLOCK_BOB_CY +#define SIM_PHY_BLOCK_BOB_CY 0 +#endif using namespace RNS; @@ -24,7 +33,7 @@ TBeamSupremeLoRaInterface::TBeamSupremeLoRaInterface(const char* name) : Interfa _IN = true; _OUT = true; _bitrate = (double)LORA_SF * ((4.0 / LORA_CR) / (pow(2, LORA_SF) / LORA_BW_KHZ)) * 1000.0; - _HW_MTU = 508; + _HW_MTU = (uint16_t)(LORA_MAX_PAYLOAD * 2); } TBeamSupremeLoRaInterface::~TBeamSupremeLoRaInterface() { @@ -82,13 +91,23 @@ void TBeamSupremeLoRaInterface::loop() { } int len = _radio->getPacketLength(); - uint8_t rx_buf[255]; + uint8_t rx_buf[RADIO_MAX_PAYLOAD]; int state = _radio->readData(rx_buf, len); - if (state == RADIOLIB_ERR_NONE && len > 1) { + if (state == RADIOLIB_ERR_NONE) { _last_rssi = _radio->getRSSI(); _last_snr = _radio->getSNR(); + uint8_t physical_tx = 255; + if (!unpack_frame(rx_buf, len, physical_tx)) { + _radio->startReceive(); + return; + } + if (len <= 1) { + _radio->startReceive(); + return; + } + uint8_t header = rx_buf[0]; uint8_t seq = packet_sequence(header); @@ -118,19 +137,71 @@ void TBeamSupremeLoRaInterface::loop() { _radio->startReceive(); } +int TBeamSupremeLoRaInterface::transmit_frame(uint8_t header, const uint8_t* payload, size_t payload_len) { + uint8_t tx_buf[RADIO_MAX_PAYLOAD]; +#if SIM_PHY_ENVELOPE + tx_buf[0] = PHY_MAGIC_0; + tx_buf[1] = PHY_MAGIC_1; + tx_buf[2] = PHY_VERSION; + tx_buf[3] = (uint8_t)NODE_SLOT_INDEX; + tx_buf[PHY_ENVELOPE_LEN] = header; + memcpy(tx_buf + PHY_ENVELOPE_LEN + 1, payload, payload_len); + return _radio->transmit(tx_buf, PHY_ENVELOPE_LEN + 1 + payload_len); +#else + tx_buf[0] = header; + memcpy(tx_buf + 1, payload, payload_len); + return _radio->transmit(tx_buf, 1 + payload_len); +#endif +} + +bool TBeamSupremeLoRaInterface::unpack_frame(uint8_t* frame, int& len, uint8_t& physical_tx) { +#if SIM_PHY_ENVELOPE + if (len < PHY_ENVELOPE_LEN + 1) { + DEBUGF("SIM PHY malformed: short frame len=%d", len); + return false; + } + if (frame[0] != PHY_MAGIC_0 || frame[1] != PHY_MAGIC_1 || frame[2] != PHY_VERSION) { + DEBUGF("SIM PHY malformed: bad envelope len=%d", len); + return false; + } + physical_tx = frame[3]; + if (should_drop_physical_tx(physical_tx)) { + DEBUGF("SIM PHY DROP: rx=%u tx=%u len=%d", + (unsigned)NODE_SLOT_INDEX, + (unsigned)physical_tx, + len); + return false; + } + len -= PHY_ENVELOPE_LEN; + memmove(frame, frame + PHY_ENVELOPE_LEN, len); + return true; +#else + (void)frame; + physical_tx = 255; + return true; +#endif +} + +bool TBeamSupremeLoRaInterface::should_drop_physical_tx(uint8_t physical_tx) { +#if SIM_PHY_BLOCK_BOB_CY + const uint8_t local = (uint8_t)NODE_SLOT_INDEX; + return (local == 1U && physical_tx == 2U) || (local == 2U && physical_tx == 1U); +#else + (void)physical_tx; + return false; +#endif +} + void TBeamSupremeLoRaInterface::send_outgoing(const Bytes& data) { if (!_online || !_radio) { return; } try { - uint8_t tx_buf[255]; uint8_t rand_nibble = (uint8_t)(Cryptography::randomnum(256)) & 0xF0; if ((int)data.size() <= LORA_MAX_PAYLOAD) { - tx_buf[0] = rand_nibble; - memcpy(tx_buf + 1, data.data(), data.size()); - int state = _radio->transmit(tx_buf, 1 + data.size()); + int state = transmit_frame(rand_nibble, data.data(), data.size()); if (state != RADIOLIB_ERR_NONE) { ERRORF("LoRa transmit failed, code %d", state); } @@ -138,17 +209,13 @@ void TBeamSupremeLoRaInterface::send_outgoing(const Bytes& data) { uint8_t seq = (_tx_seq_ctr++) & HEADER_SEQ_MASK; uint8_t split_header = rand_nibble | HEADER_SPLIT | seq; - tx_buf[0] = split_header; - memcpy(tx_buf + 1, data.data(), LORA_MAX_PAYLOAD); - int state = _radio->transmit(tx_buf, 1 + LORA_MAX_PAYLOAD); + int state = transmit_frame(split_header, data.data(), LORA_MAX_PAYLOAD); if (state != RADIOLIB_ERR_NONE) { ERRORF("LoRa transmit part 1 failed, code %d", state); } size_t remainder = data.size() - LORA_MAX_PAYLOAD; - tx_buf[0] = split_header; - memcpy(tx_buf + 1, data.data() + LORA_MAX_PAYLOAD, remainder); - state = _radio->transmit(tx_buf, 1 + remainder); + state = transmit_frame(split_header, data.data() + LORA_MAX_PAYLOAD, remainder); if (state != RADIOLIB_ERR_NONE) { ERRORF("LoRa transmit part 2 failed, code %d", state); } diff --git a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h index 701de8a..9b751d6 100644 --- a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h +++ b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h @@ -22,11 +22,24 @@ public: private: void send_outgoing(const RNS::Bytes& data) override; void on_incoming(const RNS::Bytes& data); + int transmit_frame(uint8_t header, const uint8_t* payload, size_t payload_len); + bool unpack_frame(uint8_t* frame, int& len, uint8_t& physical_tx); + static bool should_drop_physical_tx(uint8_t physical_tx); static constexpr uint8_t HEADER_SPLIT = 0x08; static constexpr uint8_t HEADER_SEQ_MASK = 0x07; static constexpr uint8_t SEQ_UNSET = 0xFF; - static constexpr int LORA_MAX_PAYLOAD = 254; + static constexpr int RADIO_MAX_PAYLOAD = 255; +#if defined(SIM_PHY_ENVELOPE) && SIM_PHY_ENVELOPE + static constexpr uint8_t PHY_MAGIC_0 = 0xC2; + static constexpr uint8_t PHY_MAGIC_1 = 0x04; + static constexpr uint8_t PHY_VERSION = 0x01; + static constexpr int PHY_ENVELOPE_LEN = 4; + static constexpr int LORA_MAX_PAYLOAD = RADIO_MAX_PAYLOAD - PHY_ENVELOPE_LEN - 1; +#else + static constexpr int PHY_ENVELOPE_LEN = 0; + static constexpr int LORA_MAX_PAYLOAD = RADIO_MAX_PAYLOAD - 1; +#endif static bool is_split_packet(uint8_t header) { return (header & HEADER_SPLIT) != 0; } static uint8_t packet_sequence(uint8_t header) { return header & HEADER_SEQ_MASK; } From 197b46b4bba2b49f38cddd51b25a7f264b3f348b Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 29 May 2026 10:58:14 -0700 Subject: [PATCH 09/14] Exercise is reduced to BOB, CY & DAN. LINK is still broken, but writing history_of_a_LINK.md for a forensic analysis and referencing this version --- .../history_of_a_LINK.md | 235 ++++++++++++++++++ .../204_established_identities/platformio.ini | 3 + .../src/TBeamSupremeLoRaInterface.cpp | 12 + .../src/TBeamSupremeLoRaInterface.h | 8 + .../204_established_identities/src/main.cpp | 57 ++++- 5 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 exercises/204_established_identities/history_of_a_LINK.md diff --git a/exercises/204_established_identities/history_of_a_LINK.md b/exercises/204_established_identities/history_of_a_LINK.md new file mode 100644 index 0000000..58dfe17 --- /dev/null +++ b/exercises/204_established_identities/history_of_a_LINK.md @@ -0,0 +1,235 @@ +# History of a BOB to CY Link + +Source logs: `/home/jlpoole/logs/20260528_2319` + +This run used BOB, DAN, and CY only. ED was offline. The simulated physical +block between BOB and CY was enabled, so direct BOB<->CY LoRa frames were +dropped below microReticulum in `TBeamSupremeLoRaInterface`. Packets physically +transmitted by DAN were still accepted by both BOB and CY. + +## Key Evidence With Exact Log Lines + +These are the most pertinent lines in `file:line:entry` form. + +```text +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:85:20260528_232309.309 TX ANNOUNCE: Bob +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:88:20260528_232316.679 01-02:10:30.334 [NOT] MR TRANSPORT PATH: dest=f04ce61418eecae211de3f78d0e652e6 hops=2 via=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:89:20260528_232316.682 RX ANNOUNCE: label=Cy hash=f04ce61418eecae211de3f78d0e652e6 phy=Dan(3) + +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:97:20260528_232322.256 TX LINKREQUEST: opening link to Cy slot=19 +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:98:20260528_232322.386 01-02:10:36.041 [NOT] MR TRANSPORT OUT: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 hops=2 next=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] + +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1680:20260528_232322.594 23:23:59.788 [NOT] MR TRANSPORT FWD: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 remaining=1 next=f04ce61418eecae211de3f78d0e652e6 iface=Interface[TBeamSupremeLoRa] +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1681:20260528_232322.595 23:23:59.789 [---] Transport::inbound: Packet is next-hop LINKREQUEST + +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1314:20260528_232322.767 18:52:08.577 [---] Transport::inbound: packet: ht=0 tt=0 dt=0 pt=2 hp=1 ti= dh=f04ce61418eecae211de3f78d0e652e6 ph=387de6033cddc91ac6c583721fa79eb84b58bfc84dc4b8f35fb1d98ef0f3bffc +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1320:20260528_232322.773 18:52:08.583 [---] Transport::inbound: Packet is LINKREQUEST +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1322:20260528_232322.774 18:52:08.584 [---] Transport::inbound: Found local destination for LINKREQUEST +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1323:20260528_232322.775 18:52:08.585 [---] ***** Accepting link request +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1335:20260528_232322.846 18:52:08.656 [DBG] Link 387de6033cddc91ac6c583721fa79eb8 requesting proof +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1347:20260528_232322.910 18:52:08.720 [NOT] MR TRANSPORT OUT: type=PROOF dest=387de6033cddc91ac6c583721fa79eb8 path=unknown + +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:103:20260528_232323.747 LINK ACTIVE: initiator link established to Cy hash=387de6033cddc91ac6c583721fa79eb8 + +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:110:20260528_232411.271 TX LINK: Hi from Bob iter=5 to=Cy +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:112:20260528_232411.306 01-02:11:24.961 [NOT] MR TRANSPORT OUT: type=DATA dest=387de6033cddc91ac6c583721fa79eb8 path=unknown + +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1885:20260528_232411.501 23:24:48.694 [---] Transport::inbound: packet: ht=0 tt=0 dt=3 pt=0 hp=0 ti= dh=387de6033cddc91ac6c583721fa79eb8 ph=838aaf52dc8f5a71ca7d878154504fce9471a5e40cad556f55739287c01720f2 +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1893:20260528_232411.507 23:24:48.700 [---] Transport::inbound: Link inbound/outbound interfaes are same, transporting on same interface +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1894:20260528_232411.509 23:24:48.701 [NOT] MR TRANSPORT LINKFWD: dest=387de6033cddc91ac6c583721fa79eb8 hops=1 link_remaining=1 link_hops=1 iface=Interface[TBeamSupremeLoRa] + +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:113:20260528_232411.702 RNSDEC ms=3378664 board=library role=unknown event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 token_crc32=808C9304 sign_key_crc32=928C394D enc_key_crc32=D6DE582A link_obj={Link:387de6033cddc91ac6c583721fa79eb8} +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:114:20260528_232411.704 RX LINK ignored: self_or_wrong_recipient text=Hi from Bob iter=5 to=Cy + +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1534:20260528_232411.704 18:52:57.513 [---] Transport::inbound: packet: ht=0 tt=0 dt=3 pt=0 hp=1 ti= dh=387de6033cddc91ac6c583721fa79eb8 ph=838aaf52dc8f5a71ca7d878154504fce9471a5e40cad556f55739287c01720f2 +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1541:20260528_232411.711 18:52:57.519 [---] Transport::inbound: Packet is DATA +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1542:20260528_232411.712 18:52:57.520 [---] Transport::inbound: Packet is DATA for a LINK +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1543:20260528_232411.712 18:52:57.520 [---] Transport::inbound: Packet is DATA for an active LINK +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1545:20260528_232411.714 RNSDEC ms=1197054 board=library role=unknown event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 token_crc32=808C9304 sign_key_crc32=928C394D enc_key_crc32=D6DE582A link_obj={Link:387de6033cddc91ac6c583721fa79eb8} +``` + +## Identity Legend + +The relevant destination and transport identities in this run were: + +```text +BOB destination hash: e431430abeca68dca8411f50ca9864b0 +CY destination hash: f04ce61418eecae211de3f78d0e652e6 +DAN destination hash: d7217d5b6372e94aa78df9a43110723d +DAN transport ID: d0524d8f1d98fc39f13772655640ea30 + +BOB->CY Link ID: 387de6033cddc91ac6c583721fa79eb8 +``` + +The application policy is that BOB is the initiator for this BOB/CY pair. The +logs do not show the identity comparison itself, but they do show BOB initiating +the LinkRequest and CY accepting the inbound request. + +## 1. Announcements Establish the Path + +BOB announces directly. DAN receives the BOB announce and records a direct +one-hop path to BOB: + +```text +DAN line 1404: packet destination=e431430abeca68dca8411f50ca9864b0 hops=0 +DAN line 1414: Valid announce for e431430abeca68dca8411f50ca9864b0 1 hops away +DAN line 1425: MR TRANSPORT PATH: dest=e431430abeca68dca8411f50ca9864b0 hops=1 via=e431430abeca68dca8411f50ca9864b0 iface=Interface[TBeamSupremeLoRa] +DAN line 1436: RX ANNOUNCE: label=Bob hash=e431430abeca68dca8411f50ca9864b0 phy=Bob(1) +``` + +DAN rebroadcasts BOB's announce with DAN as the transport ID: + +```text +DAN line 1444: Rebroadcasting announce for e431430abeca68dca8411f50ca9864b0 with hop count 1 +DAN line 1447: Packet::pack: transport id: d0524d8f1d98fc39f13772655640ea30 +DAN line 1448: Packet::pack: destination hash: e431430abeca68dca8411f50ca9864b0 +``` + +CY receives BOB's announce through DAN and records a two-hop path to BOB via +DAN: + +```text +CY line 1497: packet: ... hp=1 ti=d0524d8f1d98fc39f13772655640ea30 dh=e431430abeca68dca8411f50ca9864b0 +CY line 1507: Valid announce for e431430abeca68dca8411f50ca9864b0 2 hops away, received via d0524d8f1d98fc39f13772655640ea30 +CY line 1517: Destination e431430abeca68dca8411f50ca9864b0 is now 2 hops away via d0524d8f1d98fc39f13772655640ea30 +CY line 1518: MR TRANSPORT PATH: dest=e431430abeca68dca8411f50ca9864b0 hops=2 via=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] +CY line 1529: RX ANNOUNCE: label=Bob hash=e431430abeca68dca8411f50ca9864b0 phy=Dan(3) +``` + +The reverse direction is also known. BOB repeatedly receives CY announces via +DAN and records a two-hop path to CY: + +```text +BOB line 88: MR TRANSPORT PATH: dest=f04ce61418eecae211de3f78d0e652e6 hops=2 via=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] +BOB line 89: RX ANNOUNCE: label=Cy hash=f04ce61418eecae211de3f78d0e652e6 phy=Dan(3) +``` + +At this point, BOB knows that CY is reachable through DAN. + +## 2. BOB Initiates the LinkRequest + +BOB decides to open a Link to CY. Since BOB is the initiator for this pair, BOB +creates a LinkRequest targeting CY's destination hash. Because CY is known as a +two-hop destination via DAN, BOB sends the LinkRequest to DAN as the next hop: + +```text +BOB line 97: TX LINKREQUEST: opening link to Cy slot=19 +BOB line 98: MR TRANSPORT OUT: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 hops=2 next=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] +``` + +DAN receives that transported LinkRequest. DAN recognizes that it is the +designated next hop and forwards the request toward CY: + +```text +DAN line 1680: MR TRANSPORT FWD: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 remaining=1 next=f04ce61418eecae211de3f78d0e652e6 iface=Interface[TBeamSupremeLoRa] +DAN line 1681: Transport::inbound: Packet is next-hop LINKREQUEST +``` + +CY also sees one direct physical BOB frame and drops it at the simulated PHY +layer. This is expected and is not a microReticulum decision: + +```text +CY line 1310: SIM PHY DROP: rx=2 tx=1 len=107 blocked=113 +``` + +## 3. CY Accepts the LinkRequest + +CY receives the forwarded LinkRequest from DAN. It is no longer a direct BOB +physical frame; it is physically transmitted by DAN, so it passes the simulated +PHY block and reaches microReticulum: + +```text +CY line 1314: packet: ht=0 tt=0 dt=0 pt=2 hp=1 ... dh=f04ce61418eecae211de3f78d0e652e6 ph=387de6033cddc91ac6c583721fa79eb8... +CY line 1315: destination=f04ce61418eecae211de3f78d0e652e6 hops=1 +CY line 1320: Transport::inbound: Packet is LINKREQUEST +CY line 1322: Transport::inbound: Found local destination for LINKREQUEST +CY line 1323: ***** Accepting link request +CY line 1327: Validating link request 387de6033cddc91ac6c583721fa79eb8 +CY line 1335: Link 387de6033cddc91ac6c583721fa79eb8 requesting proof +``` + +CY sends a proof for the LinkRequest: + +```text +CY line 1342: Packet::pack: destination link id: 387de6033cddc91ac6c583721fa79eb8 +CY line 1345: Transport::outbound: destination=387de6033cddc91ac6c583721fa79eb8 hops=0 +CY line 1347: MR TRANSPORT OUT: type=PROOF dest=387de6033cddc91ac6c583721fa79eb8 path=unknown +CY line 1353: Incoming link request {Link:387de6033cddc91ac6c583721fa79eb8} accepted +``` + +BOB receives enough of the proof/handshake to mark the Link active: + +```text +BOB line 100: RNSDEC ... event=encrypt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=64 ... +BOB line 101: RNSDEC ... event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=64 ... +BOB line 102: MR TRANSPORT OUT: type=DATA dest=387de6033cddc91ac6c583721fa79eb8 path=unknown +BOB line 103: LINK ACTIVE: initiator link established to Cy hash=387de6033cddc91ac6c583721fa79eb8 +``` + +This is the key success point: with ED removed, BOB successfully establishes a +Link to CY through DAN. + +## 4. First Data Packet After Link Establishment + +After the Link is active, BOB sends an application payload over the Link: + +```text +BOB line 110: TX LINK: Hi from Bob iter=5 to=Cy +BOB line 111: RNSDEC ... event=encrypt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 ... +BOB line 112: MR TRANSPORT OUT: type=DATA dest=387de6033cddc91ac6c583721fa79eb8 path=unknown +``` + +DAN receives the Link data and identifies it as a packet that should be link +transported. The inbound and outbound interfaces are the same LoRa interface, so +DAN forwards the packet back out on the same interface: + +```text +DAN line 1885: packet: ht=0 tt=0 dt=3 pt=0 hp=0 ... dh=387de6033cddc91ac6c583721fa79eb8 +DAN line 1886: destination=387de6033cddc91ac6c583721fa79eb8 hops=0 +DAN line 1894: MR TRANSPORT LINKFWD: dest=387de6033cddc91ac6c583721fa79eb8 hops=1 link_remaining=1 link_hops=1 iface=Interface[TBeamSupremeLoRa] +``` + +## 5. Failure Point + +BOB hears the forwarded Link packet on the shared LoRa channel. Since the +plaintext says it is from Bob and to Cy, BOB's application rejects it as +self/wrong-recipient: + +```text +BOB line 113: RNSDEC ... event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 ... +BOB line 114: RX LINK ignored: self_or_wrong_recipient text=Hi from Bob iter=5 to=Cy +``` + +CY also receives a packet for the same Link ID around the same time: + +```text +CY line 1532: Transport::inbound: received 99 bytes +CY line 1534: packet: ht=0 tt=0 dt=3 pt=0 hp=1 ... dh=387de6033cddc91ac6c583721fa79eb8 +CY line 1535: destination=387de6033cddc91ac6c583721fa79eb8 hops=1 +CY line 1545: RNSDEC ... event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 ... +``` + +But CY does not log the expected application-level receive: + +```text +expected but absent: +RX LINK: Hi from Bob iter=5 to=Cy +``` + +The failure is therefore not in initial path discovery and not in LinkRequest +delivery. The BOB->DAN->CY Link establishment succeeds. The observed failure is +in post-establishment Link data handling after DAN forwards the Link data on the +same broadcast LoRa interface. BOB hears the forwarded packet and ignores it as +self/wrong-recipient; CY attempts to process the packet but does not emit the +expected `RX LINK` application payload line. + +## Working Interpretation + +1. Announcement transport works: BOB and CY learn each other through DAN. +2. LinkRequest transport works in the single-intermediary case: DAN forwards + BOB's LinkRequest to CY. +3. Link establishment works: BOB marks the BOB->CY Link active. +4. Link data transport partially works: DAN recognizes and forwards Link data. +5. The failure point is after Link data forwarding on the same LoRa interface. + The forwarded packet is heard by BOB and apparently reaches CY, but CY does + not produce the expected decrypted application payload log. diff --git a/exercises/204_established_identities/platformio.ini b/exercises/204_established_identities/platformio.ini index 4fd19f3..598d9e9 100644 --- a/exercises/204_established_identities/platformio.ini +++ b/exercises/204_established_identities/platformio.ini @@ -52,6 +52,7 @@ build_flags = -D USTORE_MAX_VALUE_LEN=1200 -D SIM_PHY_ENVELOPE=1 -D SIM_PHY_BLOCK_BOB_CY=1 + -D MR_TRANSPORT_PROBE=1 ; Live announces are enough for this single-hop field exercise. Do not define ; RNS_PERSIST_PATHS here: the LittleFS-backed path_store compactor can leave an ; active segment FD open while unlinking /path_store_*.dat on ESP32. @@ -94,6 +95,7 @@ upload_port = /dev/ttytCY monitor_port = /dev/ttytCY build_flags = ${env.build_flags} + -D EX204_RNS_TRACE=1 -D BOARD_ID=\"CY\" -D NODE_LABEL=\"Cy\" -D NODE_SLOT_INDEX=2 @@ -104,6 +106,7 @@ upload_port = /dev/ttytDAN monitor_port = /dev/ttytDAN build_flags = ${env.build_flags} + -D EX204_RNS_TRACE=1 -D BOARD_ID=\"DAN\" -D NODE_LABEL=\"Dan\" -D NODE_SLOT_INDEX=3 diff --git a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp index 1f47075..8735459 100644 --- a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp +++ b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp @@ -157,27 +157,39 @@ int TBeamSupremeLoRaInterface::transmit_frame(uint8_t header, const uint8_t* pay bool TBeamSupremeLoRaInterface::unpack_frame(uint8_t* frame, int& len, uint8_t& physical_tx) { #if SIM_PHY_ENVELOPE if (len < PHY_ENVELOPE_LEN + 1) { + ++_phy_bad_frames; DEBUGF("SIM PHY malformed: short frame len=%d", len); return false; } if (frame[0] != PHY_MAGIC_0 || frame[1] != PHY_MAGIC_1 || frame[2] != PHY_VERSION) { + ++_phy_bad_frames; DEBUGF("SIM PHY malformed: bad envelope len=%d", len); return false; } physical_tx = frame[3]; if (should_drop_physical_tx(physical_tx)) { + ++_phy_blocked_frames; + Serial.printf("SIM PHY DROP: rx=%u tx=%u len=%d blocked=%lu\r\n", + (unsigned)NODE_SLOT_INDEX, + (unsigned)physical_tx, + len, + (unsigned long)_phy_blocked_frames); DEBUGF("SIM PHY DROP: rx=%u tx=%u len=%d", (unsigned)NODE_SLOT_INDEX, (unsigned)physical_tx, len); return false; } + _last_physical_tx = physical_tx; + ++_phy_rx_frames; len -= PHY_ENVELOPE_LEN; memmove(frame, frame + PHY_ENVELOPE_LEN, len); return true; #else (void)frame; physical_tx = 255; + _last_physical_tx = physical_tx; + ++_phy_rx_frames; return true; #endif } diff --git a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h index 9b751d6..eb87b5c 100644 --- a/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h +++ b/exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h @@ -18,6 +18,10 @@ public: float last_rssi() const { return _last_rssi; } float last_snr() const { return _last_snr; } + uint8_t last_physical_tx() const { return _last_physical_tx; } + uint32_t phy_rx_frames() const { return _phy_rx_frames; } + uint32_t phy_blocked_frames() const { return _phy_blocked_frames; } + uint32_t phy_bad_frames() const { return _phy_bad_frames; } private: void send_outgoing(const RNS::Bytes& data) override; @@ -47,6 +51,10 @@ private: RNS::Bytes _rx_buffer; uint8_t _rx_seq = SEQ_UNSET; uint8_t _tx_seq_ctr = 0; + uint8_t _last_physical_tx = 255; + uint32_t _phy_rx_frames = 0; + uint32_t _phy_blocked_frames = 0; + uint32_t _phy_bad_frames = 0; float _last_rssi = 0.0f; float _last_snr = 0.0f; diff --git a/exercises/204_established_identities/src/main.cpp b/exercises/204_established_identities/src/main.cpp index bebbf96..4ca6ec1 100644 --- a/exercises/204_established_identities/src/main.cpp +++ b/exercises/204_established_identities/src/main.cpp @@ -35,6 +35,18 @@ #define FW_BUILD_UTC "unknown" #endif +#ifndef SIM_PHY_ENVELOPE +#define SIM_PHY_ENVELOPE 0 +#endif + +#ifndef SIM_PHY_BLOCK_BOB_CY +#define SIM_PHY_BLOCK_BOB_CY 0 +#endif + +#ifndef EX204_RNS_TRACE +#define EX204_RNS_TRACE 0 +#endif + static constexpr const char* APP_NAME = "microreticulum"; static constexpr const char* APP_ASPECT = "linkping"; static constexpr const char* ANNOUNCE_FILTER = "microreticulum.linkping"; @@ -109,6 +121,19 @@ static PeerState peers[MAX_PEERS]; static void on_link_packet(const RNS::Bytes& data, const RNS::Packet& packet); static void on_link_closed(RNS::Link& link); +static const char* node_label_for_slot(uint8_t slot) { + switch (slot) { + case 0: return "Amy"; + case 1: return "Bob"; + case 2: return "Cy"; + case 3: return "Dan"; + case 4: return "Ed"; + case 5: return "Fay"; + case 6: return "Guy"; + default: return "unknown"; + } +} + static void IRAM_ATTR on_pps_edge() { last_pps_ms = millis(); } @@ -726,8 +751,13 @@ static void on_link_packet(const RNS::Bytes& data, const RNS::Packet& packet) { } float rssi = lora_impl ? lora_impl->last_rssi() : 0.0f; float snr = lora_impl ? lora_impl->last_snr() : 0.0f; - Serial.printf("RX LINK: %s | RSSI=%.1f SNR=%.1f\r\n", - text.c_str(), rssi, snr); + uint8_t physical_tx = lora_impl ? lora_impl->last_physical_tx() : 255; + Serial.printf("RX LINK: %s | phy=%s(%u) RSSI=%.1f SNR=%.1f\r\n", + text.c_str(), + node_label_for_slot(physical_tx), + (unsigned)physical_tx, + rssi, + snr); show_status("RX LINK", peer.c_str(), text.c_str()); } @@ -794,7 +824,11 @@ static void on_inbound_link_established(RNS::Link& link) { peers[peer_index].last_link_active_ms = millis(); peers[peer_index].tx_since_link_open = 0; } - Serial.printf("RX LINK: inbound link established hash=%s\r\n", link.hash().toHex().c_str()); + uint8_t physical_tx = lora_impl ? lora_impl->last_physical_tx() : 255; + Serial.printf("RX LINK: inbound link established hash=%s phy=%s(%u)\r\n", + link.hash().toHex().c_str(), + node_label_for_slot(physical_tx), + (unsigned)physical_tx); show_status("LINK ACTIVE", "inbound", link.hash().toHex().c_str()); } @@ -829,8 +863,12 @@ class LinkAnnounceHandler : public RNS::AnnounceHandler { destination_hash); peers[peer_index].announced = true; - Serial.printf("RX ANNOUNCE: label=%s hash=%s\r\n", - peers[peer_index].label.c_str(), peers[peer_index].destination_hash.toHex().c_str()); + uint8_t physical_tx = lora_impl ? lora_impl->last_physical_tx() : 255; + Serial.printf("RX ANNOUNCE: label=%s hash=%s phy=%s(%u)\r\n", + peers[peer_index].label.c_str(), + peers[peer_index].destination_hash.toHex().c_str(), + node_label_for_slot(physical_tx), + (unsigned)physical_tx); show_status("RX ANNOUNCE", peers[peer_index].label.c_str(), peers[peer_index].destination_hash.toHex().c_str()); } }; @@ -845,6 +883,11 @@ static void print_config() { Serial.printf("LoRa: freq=%.1f BW=%.1f SF=%d CR=%d sync=0x%02x txp=%d\r\n", (double)LORA_FREQ_MHZ, (double)LORA_BW_KHZ, (int)LORA_SF, (int)LORA_CR, (int)LORA_SYNC_WORD, (int)LORA_TX_POWER_DBM); + Serial.printf("Sim: phy_envelope=%d phy_block_bob_cy=%d node_slot=%d rns_trace=%d\r\n", + (int)SIM_PHY_ENVELOPE, + (int)SIM_PHY_BLOCK_BOB_CY, + (int)NODE_SLOT_INDEX, + (int)EX204_RNS_TRACE); } static void send_announce() { @@ -998,7 +1041,11 @@ void setup() { Serial.println(); Serial.println("Exercise 204: established identities transport Link ping-pong"); +#if EX204_RNS_TRACE + RNS::loglevel(RNS::LOG_TRACE); +#else RNS::loglevel(RNS::LOG_NOTICE); +#endif (void)tbeam_supreme::initPmuForPeripherals(pmu, &Serial); tbeam::DisplayConfig display_config; From 294a17660d208207ea8735053efd084d8c4afa84 Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 29 May 2026 12:30:04 -0700 Subject: [PATCH 10/14] John draft, I asked Codex to review and it started changing, I intervened and asked to keep Codex review separate, I think it sucessfuly backed out its edits. Committing for posterity, there is a style difference between me and Codex. --- .../history_of_a_LINK.md | 553 ++++++++++++++++-- 1 file changed, 505 insertions(+), 48 deletions(-) diff --git a/exercises/204_established_identities/history_of_a_LINK.md b/exercises/204_established_identities/history_of_a_LINK.md index 58dfe17..bcb17c5 100644 --- a/exercises/204_established_identities/history_of_a_LINK.md +++ b/exercises/204_established_identities/history_of_a_LINK.md @@ -1,4 +1,18 @@ # History of a BOB to CY Link +This is a forensic analysis to help understand and fix the failure of a LINK between two isolated units forced to use an intermediary for transport. This arrangement is the heart of a "mesh" network. The units are LilyGO T-Beam Supremes named: BOB, CY, DAN & ED. Additional units of AMY, FLO & GUY may be involved in later testing. + +## Background & Tools +The version of this exercise, 204_established_identities, is: +```bash + commit 197b46b4bba2b49f38cddd51b25a7f264b3f348b (HEAD -> feature/fieldtest-beacon-sd-provision) + Author: John Poole + Date: Fri May 29 10:58:14 2026 -0700 +``` +`platformio.ini` was modified so that DAN & CY had additional debugging activated in microReticulum's Transport.cpp through the use of the build directive of `-D EX204_RNS_TRACE=1`. + +The log captures were performed using 4 serial console connections (ED was defunct) using an bash script, `monitor_tbeam`, that adds colors using the Perl script: `color_rx_tx.pl`. + +Finally all the logs were moved into their own run directory with the Perl script: `clump_tbeam_logs.pl`. Source logs: `/home/jlpoole/logs/20260528_2319` @@ -7,46 +21,9 @@ block between BOB and CY was enabled, so direct BOB<->CY LoRa frames were dropped below microReticulum in `TBeamSupremeLoRaInterface`. Packets physically transmitted by DAN were still accepted by both BOB and CY. -## Key Evidence With Exact Log Lines +### Time +`main.cpp` disciplines the time among the T-Beams, up to 24 hour drift is allowed. The workstation, jp, that the logging and serial port monitor occurred is disciplined to a local stratum 1 time server. Time stamps from the bash logging script exhibited some delay. -These are the most pertinent lines in `file:line:entry` form. - -```text -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:85:20260528_232309.309 TX ANNOUNCE: Bob -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:88:20260528_232316.679 01-02:10:30.334 [NOT] MR TRANSPORT PATH: dest=f04ce61418eecae211de3f78d0e652e6 hops=2 via=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:89:20260528_232316.682 RX ANNOUNCE: label=Cy hash=f04ce61418eecae211de3f78d0e652e6 phy=Dan(3) - -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:97:20260528_232322.256 TX LINKREQUEST: opening link to Cy slot=19 -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:98:20260528_232322.386 01-02:10:36.041 [NOT] MR TRANSPORT OUT: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 hops=2 next=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] - -/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1680:20260528_232322.594 23:23:59.788 [NOT] MR TRANSPORT FWD: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 remaining=1 next=f04ce61418eecae211de3f78d0e652e6 iface=Interface[TBeamSupremeLoRa] -/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1681:20260528_232322.595 23:23:59.789 [---] Transport::inbound: Packet is next-hop LINKREQUEST - -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1314:20260528_232322.767 18:52:08.577 [---] Transport::inbound: packet: ht=0 tt=0 dt=0 pt=2 hp=1 ti= dh=f04ce61418eecae211de3f78d0e652e6 ph=387de6033cddc91ac6c583721fa79eb84b58bfc84dc4b8f35fb1d98ef0f3bffc -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1320:20260528_232322.773 18:52:08.583 [---] Transport::inbound: Packet is LINKREQUEST -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1322:20260528_232322.774 18:52:08.584 [---] Transport::inbound: Found local destination for LINKREQUEST -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1323:20260528_232322.775 18:52:08.585 [---] ***** Accepting link request -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1335:20260528_232322.846 18:52:08.656 [DBG] Link 387de6033cddc91ac6c583721fa79eb8 requesting proof -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1347:20260528_232322.910 18:52:08.720 [NOT] MR TRANSPORT OUT: type=PROOF dest=387de6033cddc91ac6c583721fa79eb8 path=unknown - -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:103:20260528_232323.747 LINK ACTIVE: initiator link established to Cy hash=387de6033cddc91ac6c583721fa79eb8 - -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:110:20260528_232411.271 TX LINK: Hi from Bob iter=5 to=Cy -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:112:20260528_232411.306 01-02:11:24.961 [NOT] MR TRANSPORT OUT: type=DATA dest=387de6033cddc91ac6c583721fa79eb8 path=unknown - -/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1885:20260528_232411.501 23:24:48.694 [---] Transport::inbound: packet: ht=0 tt=0 dt=3 pt=0 hp=0 ti= dh=387de6033cddc91ac6c583721fa79eb8 ph=838aaf52dc8f5a71ca7d878154504fce9471a5e40cad556f55739287c01720f2 -/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1893:20260528_232411.507 23:24:48.700 [---] Transport::inbound: Link inbound/outbound interfaes are same, transporting on same interface -/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1894:20260528_232411.509 23:24:48.701 [NOT] MR TRANSPORT LINKFWD: dest=387de6033cddc91ac6c583721fa79eb8 hops=1 link_remaining=1 link_hops=1 iface=Interface[TBeamSupremeLoRa] - -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:113:20260528_232411.702 RNSDEC ms=3378664 board=library role=unknown event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 token_crc32=808C9304 sign_key_crc32=928C394D enc_key_crc32=D6DE582A link_obj={Link:387de6033cddc91ac6c583721fa79eb8} -/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:114:20260528_232411.704 RX LINK ignored: self_or_wrong_recipient text=Hi from Bob iter=5 to=Cy - -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1534:20260528_232411.704 18:52:57.513 [---] Transport::inbound: packet: ht=0 tt=0 dt=3 pt=0 hp=1 ti= dh=387de6033cddc91ac6c583721fa79eb8 ph=838aaf52dc8f5a71ca7d878154504fce9471a5e40cad556f55739287c01720f2 -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1541:20260528_232411.711 18:52:57.519 [---] Transport::inbound: Packet is DATA -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1542:20260528_232411.712 18:52:57.520 [---] Transport::inbound: Packet is DATA for a LINK -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1543:20260528_232411.712 18:52:57.520 [---] Transport::inbound: Packet is DATA for an active LINK -/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1545:20260528_232411.714 RNSDEC ms=1197054 board=library role=unknown event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 token_crc32=808C9304 sign_key_crc32=928C394D enc_key_crc32=D6DE582A link_obj={Link:387de6033cddc91ac6c583721fa79eb8} -``` ## Identity Legend @@ -65,8 +42,128 @@ The application policy is that BOB is the initiator for this BOB/CY pair. The logs do not show the identity comparison itself, but they do show BOB initiating the LinkRequest and CY accepting the inbound request. -## 1. Announcements Establish the Path +## Software isolation of BOB and CY +To simulate BOB and CY being out of radio range, we resorted to blocking packets at each unit. Here is a Codex explanation of the blocking mechanism designed to be outside of the Reticulum code. +Blocking Implementation + +Here is Codex's explanation of our blockage: + +We are simulating BOB/CY RF separation below microReticulum, inside the T-Beam LoRa interface adapter. microReticulum packets are not inspected, parsed, or altered for the block decision. + +Each transmitted LoRa frame gets a small 4-byte simulation envelope prepended by TBeamSupremeLoRaInterface: + + +[ SIM PHY ENVELOPE ][ local LoRa fragment header ][ raw microReticulum packet bytes ... ] + +SIM PHY ENVELOPE: + byte 0: 0xC2 magic + byte 1: 0x04 exercise marker + byte 2: 0x01 envelope version + byte 3: NODE_SLOT physical transmitter ID + +NODE_SLOT: + 1 = BOB + 2 = CY + 3 = DAN + 4 = ED + +On receive, the LoRa adapter strips that 4-byte envelope before passing bytes to microReticulum. So microReticulum receives the same byte stream it would have received without this simulation wrapper. + +The block rule is only: +if local node is BOB and physical transmitter is CY: drop before microReticulum +if local node is CY and physical transmitter is BOB: drop before microReticulum +otherwise: strip envelope and deliver to microReticulum + +Example: direct BOB -> CY is blocked before microReticulum sees it: + +LoRa air frame: + c2 04 01 01 | H | + ^^ + physical_tx = 1 = BOB + +CY receives: + local = 2 = CY + physical_tx = 1 = BOB + decision = DROP + +Result: + +InterfaceImpl::handle_incoming() is not called. +microReticulum never sees this packet. + +Example: BOB traffic transported/rebroadcast by DAN is allowed: + +LoRa air frame: + + c2 04 01 03 | H | + ^^ + physical_tx = 3 = DAN + +CY receives: + + local = 2 = CY + physical_tx = 3 = DAN + decision = ACCEPT + +Adapter strips envelope: + H | + +Then calls: + InterfaceImpl::handle_incoming() + +So the important point is: the BOB/CY block is a physical-link simulation in TBeamSupremeLoRaInterface, not a microReticulum-layer rule. A packet physically emitted by DAN or ED is allowed through even if the Reticulum payload originated from BOB or CY. + + +# Analysis +## 1. Announcements Establish the Path +### ANNOUNCEMENT +An ANNOUNCEMENT is a packet that contains a DESTINATION, aka "DESTINATION HASH", and PUBLIC KEY, and possibly other information. See https://reticulum.network/manual/understanding.html#public-key-announcements + +#### DESTINATION +Reticulum does away with the idea of addresses and ports known from IP, TCP and UDP. Instead Reticulum uses the singular concept of destinations. Destinations are represented as a 16 byte hash. The inbound_destination is computed at main.cpp (line 1020): +```cpp +inbound_destination = RNS::Destination(local_identity, + RNS::Type::Destination::IN, + RNS::Type::Destination::SINGLE, + APP_NAME, + APP_ASPECT); +``` +and APP_NAME and APP_ASPECT are arbitrary strings defined at: +```cpp +static constexpr const char* APP_NAME = "microreticulum"; +static constexpr const char* APP_ASPECT = "linkping"; +``` +So BOB's destination hash is derived from: + +```bash +Bob's fixed identity ++ app name: microreticulum ++ aspect: linkping +``` +microReticulum expands that into a destination name and hashes it. The relevant library code is: +```cpp +Bytes addr_hash_material = name_hash(app_name, aspects); +if (identity) { + addr_hash_material << identity.hash(); +} +return Identity::truncated_hash(addr_hash_material); +``` +For purposes of this analysis, BOB's destination hash is: e431430abeca68dca8411f50ca9864b0 + +#### PUUBLIC KEY +PUBLIC KEY was generated from the Reticulum utility `rnid`. BOB's identity consists of: + +```bash +(rnsenv) jlpoole@jp /usr/local/src/microreticulum/microReticulumTbeam/exercises/204_established_identities/identities $ cat BOB.identity_info.txt +[2026-05-28 11:26:35] Loaded Identity <5769e13e1214e62b96e43c17bd47085e> from ./BOB.identity +[2026-05-28 11:26:35] Public Key : 4705bf1ab8a17cc3bb07d1fa51d4fe59dc92e4e93fd9d55124c808079d33744e894241f52583b3abc7bcaf48a5fad31554c2dee142dbbc3c4d4c5e3855d94814 +[2026-05-28 11:26:35] Private Key : Hidden +(rnsenv) jlpoole@jp /usr/local/src/microreticulum/microReticulumTbeam/exercises/204_established_identities/identities $ +``` +Therein is BOB's Public KEY: 4705...4814. + +#### BOB's ANNOUNCEMENT BOB announces directly. DAN receives the BOB announce and records a direct one-hop path to BOB: @@ -77,7 +174,7 @@ DAN line 1425: MR TRANSPORT PATH: dest=e431430abeca68dca8411f50ca9864b0 hops=1 v DAN line 1436: RX ANNOUNCE: label=Bob hash=e431430abeca68dca8411f50ca9864b0 phy=Bob(1) ``` -DAN rebroadcasts BOB's announce with DAN as the transport ID: +DAN rebroadcasts BOB's announce with DAN as the transport ID (d052...a30): ```text DAN line 1444: Rebroadcasting announce for e431430abeca68dca8411f50ca9864b0 with hop count 1 @@ -95,16 +192,15 @@ CY line 1517: Destination e431430abeca68dca8411f50ca9864b0 is now 2 hops away vi CY line 1518: MR TRANSPORT PATH: dest=e431430abeca68dca8411f50ca9864b0 hops=2 via=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] CY line 1529: RX ANNOUNCE: label=Bob hash=e431430abeca68dca8411f50ca9864b0 phy=Dan(3) ``` +Line 1529 above confirms that CY received that rebroadcast from DAN and CY now knows Bob is reachable via DAN. -The reverse direction is also known. BOB repeatedly receives CY announces via -DAN and records a two-hop path to CY: +Likewise, for BOB, at line 89 below, the reverse direction is also known. BOB repeatedly receives CY announces via DAN and records a two-hop path to CY: ```text BOB line 88: MR TRANSPORT PATH: dest=f04ce61418eecae211de3f78d0e652e6 hops=2 via=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] BOB line 89: RX ANNOUNCE: label=Cy hash=f04ce61418eecae211de3f78d0e652e6 phy=Dan(3) ``` - -At this point, BOB knows that CY is reachable through DAN. +At this point, BOB & CY have each other's destinations and know that the other is reachable through DAN. The rule employed in this exercise is that the "lower" identity is the unit that initiates a LINK. (This would not be a real life scenario, but this rule is adopted to keep transmissions over the LoRa to a minimum and to easily progress with linking.) In this exercise, each unit is aware of the other's id, sort of an address book, so BOB can determine it is its responsibility to initiate a LINK with CY. ## 2. BOB Initiates the LinkRequest @@ -116,6 +212,7 @@ two-hop destination via DAN, BOB sends the LinkRequest to DAN as the next hop: BOB line 97: TX LINKREQUEST: opening link to Cy slot=19 BOB line 98: MR TRANSPORT OUT: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 hops=2 next=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] ``` +In line 98 above, "dest" stands for destination, e.g. "f04c...e6", and utilizes the value from the "hash" value in BOB's log, line 89 above. DAN receives that transported LinkRequest. DAN recognizes that it is the designated next hop and forwards the request toward CY: @@ -125,7 +222,7 @@ DAN line 1680: MR TRANSPORT FWD: type=LINKREQUEST dest=f04ce61418eecae211de3f78d DAN line 1681: Transport::inbound: Packet is next-hop LINKREQUEST ``` -CY also sees one direct physical BOB frame and drops it at the simulated PHY +Be aware that CY also sees one direct physical BOB frame and drops it at the simulated PHY layer. This is expected and is not a microReticulum decision: ```text @@ -166,8 +263,7 @@ BOB line 102: MR TRANSPORT OUT: type=DATA dest=387de6033cddc91ac6c583721fa79eb8 BOB line 103: LINK ACTIVE: initiator link established to Cy hash=387de6033cddc91ac6c583721fa79eb8 ``` -This is the key success point: with ED removed, BOB successfully establishes a -Link to CY through DAN. +BOB successfully establishes a Link to CY through DAN. (Previously in the 4 unit test, this event never occurred. This is the key success point: with ED removed.) ## 4. First Data Packet After Link Establishment @@ -233,3 +329,364 @@ expected `RX LINK` application payload line. 5. The failure point is after Link data forwarding on the same LoRa interface. The forwarded packet is heard by BOB and apparently reaches CY, but CY does not produce the expected decrypted application payload log. + +# Appendix +## Key Evidence With Exact Log Lines + +These are the most pertinent lines in `file:line:entry` form. + +```text +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:85:20260528_232309.309 TX ANNOUNCE: Bob +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:88:20260528_232316.679 01-02:10:30.334 [NOT] MR TRANSPORT PATH: dest=f04ce61418eecae211de3f78d0e652e6 hops=2 via=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:89:20260528_232316.682 RX ANNOUNCE: label=Cy hash=f04ce61418eecae211de3f78d0e652e6 phy=Dan(3) + +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:97:20260528_232322.256 TX LINKREQUEST: opening link to Cy slot=19 +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:98:20260528_232322.386 01-02:10:36.041 [NOT] MR TRANSPORT OUT: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 hops=2 next=d0524d8f1d98fc39f13772655640ea30 iface=Interface[TBeamSupremeLoRa] + +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1680:20260528_232322.594 23:23:59.788 [NOT] MR TRANSPORT FWD: type=LINKREQUEST dest=f04ce61418eecae211de3f78d0e652e6 remaining=1 next=f04ce61418eecae211de3f78d0e652e6 iface=Interface[TBeamSupremeLoRa] +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1681:20260528_232322.595 23:23:59.789 [---] Transport::inbound: Packet is next-hop LINKREQUEST + +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1314:20260528_232322.767 18:52:08.577 [---] Transport::inbound: packet: ht=0 tt=0 dt=0 pt=2 hp=1 ti= dh=f04ce61418eecae211de3f78d0e652e6 ph=387de6033cddc91ac6c583721fa79eb84b58bfc84dc4b8f35fb1d98ef0f3bffc +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1320:20260528_232322.773 18:52:08.583 [---] Transport::inbound: Packet is LINKREQUEST +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1322:20260528_232322.774 18:52:08.584 [---] Transport::inbound: Found local destination for LINKREQUEST +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1323:20260528_232322.775 18:52:08.585 [---] ***** Accepting link request +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1335:20260528_232322.846 18:52:08.656 [DBG] Link 387de6033cddc91ac6c583721fa79eb8 requesting proof +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1347:20260528_232322.910 18:52:08.720 [NOT] MR TRANSPORT OUT: type=PROOF dest=387de6033cddc91ac6c583721fa79eb8 path=unknown + +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:103:20260528_232323.747 LINK ACTIVE: initiator link established to Cy hash=387de6033cddc91ac6c583721fa79eb8 + +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:110:20260528_232411.271 TX LINK: Hi from Bob iter=5 to=Cy +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:112:20260528_232411.306 01-02:11:24.961 [NOT] MR TRANSPORT OUT: type=DATA dest=387de6033cddc91ac6c583721fa79eb8 path=unknown + +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1885:20260528_232411.501 23:24:48.694 [---] Transport::inbound: packet: ht=0 tt=0 dt=3 pt=0 hp=0 ti= dh=387de6033cddc91ac6c583721fa79eb8 ph=838aaf52dc8f5a71ca7d878154504fce9471a5e40cad556f55739287c01720f2 +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1893:20260528_232411.507 23:24:48.700 [---] Transport::inbound: Link inbound/outbound interfaes are same, transporting on same interface +/home/jlpoole/logs/20260528_2319/DAN_raw_20260528_231958.log:1894:20260528_232411.509 23:24:48.701 [NOT] MR TRANSPORT LINKFWD: dest=387de6033cddc91ac6c583721fa79eb8 hops=1 link_remaining=1 link_hops=1 iface=Interface[TBeamSupremeLoRa] + +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:113:20260528_232411.702 RNSDEC ms=3378664 board=library role=unknown event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 token_crc32=808C9304 sign_key_crc32=928C394D enc_key_crc32=D6DE582A link_obj={Link:387de6033cddc91ac6c583721fa79eb8} +/home/jlpoole/logs/20260528_2319/BOB_raw_20260528_231955.log:114:20260528_232411.704 RX LINK ignored: self_or_wrong_recipient text=Hi from Bob iter=5 to=Cy + +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1534:20260528_232411.704 18:52:57.513 [---] Transport::inbound: packet: ht=0 tt=0 dt=3 pt=0 hp=1 ti= dh=387de6033cddc91ac6c583721fa79eb8 ph=838aaf52dc8f5a71ca7d878154504fce9471a5e40cad556f55739287c01720f2 +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1541:20260528_232411.711 18:52:57.519 [---] Transport::inbound: Packet is DATA +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1542:20260528_232411.712 18:52:57.520 [---] Transport::inbound: Packet is DATA for a LINK +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1543:20260528_232411.712 18:52:57.520 [---] Transport::inbound: Packet is DATA for an active LINK +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1545:20260528_232411.714 RNSDEC ms=1197054 board=library role=unknown event=attempt link_id=387de6033cddc91ac6c583721fa79eb8 token_len=80 token_crc32=808C9304 sign_key_crc32=928C394D enc_key_crc32=D6DE582A link_obj={Link:387de6033cddc91ac6c583721fa79eb8} +``` +## bash script monitor_tbeam +```bash +(rnsenv) jlpoole@jp ~ $ cat /usr/bin/monitor_tbeam +#!/bin/bash +# 20260528 ChatGPT +# $Header: https://ares/svn/workstation/trunk/scripts/monitor_tbeam.sh 46 2026-05-29 05:04:50Z jlpoole $ +# $HeadURL: https://ares/svn/workstation/trunk/scripts/monitor_tbeam.sh $ +# +# Example: +# monitor_tbeam ED +# +# Install: +# sudo ln -s /home/jlpoole/workstation/scripts/monitor_tbeam.sh /usr/bin/monitor_tbeam +# sudo chmod +x /home/jlpoole/workstation/scripts/monitor_tbeam.sh +# + +export TBEAM="$1" + +if [ -z "${TBEAM}" ]; then + echo "Usage: monitor_tbeam ED" + echo "Example: monitor_tbeam GUY" + exit 1 +fi + +source ~/rnsenv/bin/activate + +mkdir -p ~/logs + +LOGFILE=~/logs/${TBEAM}_raw_$(date +%Y%m%d_%H%M%S).log + +pio device monitor -b 115200 -p "/dev/ttyt${TBEAM}" 2>&1 \ + | perl -MTime::HiRes=time -MPOSIX=strftime -ne ' + BEGIN { + $| = 1; + } + + s/\r?\n$//; + + my $t = time(); + my $s = int($t); + my $ms = int(($t - $s) * 1000); + + my $stamp = strftime("%Y%m%d_%H%M%S", localtime($s)); + + printf "%s.%03d %s\n", $stamp, $ms, $_; + ' \ + | tee "$LOGFILE" \ + | /usr/bin/color_rx_tx.pl +(rnsenv) jlpoole@jp ~ $ + +``` +## Perl script color_rx_tx.pl +```perl +jlpoole@jp ~/workstation/perl $ cat color_rx_tx.pl +#!/usr/bin/env perl +# 20260528 ChatGPT +# $Header: https://ares/svn/workstation/trunk/perl/color_rx_tx.pl 44 2026-05-28 23:36:24Z jlpoole $ +# $HeadURL: https://ares/svn/workstation/trunk/perl/color_rx_tx.pl $ +# +# sudo ln -s /home/jlpoole/workstation/perl/color_rx_tx.pl /usr/bin/color_rx_tx.pl +# +# + +use strict; +use warnings; + +$| = 1; + +my $BLUE = "\e[96m"; # light blue / cyan +my $GREEN = "\e[92m"; # light green +my $RESET = "\e[0m"; + +while (my $line = ) { + $line =~ s/\r?\n$//; + + if ($line =~ /\bTX\b/) { + print $BLUE, $line, $RESET, "\n"; + } + elsif ($line =~ /\bRX\b/) { + print $GREEN, $line, $RESET, "\n"; + } + else { + print $line, "\n"; + } +} +jlpoole@jp ~/workstation/perl $ + +``` +## Perl script clump_tbeam_logs.pl +```perl +jlpoole@jp ~/workstation/perl $ cat clump_tbeam_logs.pl +#!/usr/bin/env perl +# +# Example: +# chmod 755 clump_tbeam_logs.pl +# ./clump_tbeam_logs.pl +# ./clump_tbeam_logs.pl --dry-run +# ./clump_tbeam_logs.pl --dir ~/logs --window 60 --min-files 4 +# +# 20260528 ChatGPT +# $Header: https://ares/svn/workstation/trunk/perl/clump_tbeam_logs.pl 44 2026-05-28 23:36:24Z jlpoole $ +# $HeadURL: https://ares/svn/workstation/trunk/perl/clump_tbeam_logs.pl $ +# +=pod + +chmod +x clump_tbeam_logs.pl +sudo ln -s /home/jlpoole/workstation/perl/clump_tbeam_logs.pl /usr/bin/clump_tbeam_logs.pl + +=cut + +use strict; +use warnings; + +use Getopt::Long qw(GetOptions); +use File::Basename qw(basename); +use File::Path qw(make_path); +use File::Spec; +use Time::Piece; +use List::Util qw(max); + +my $log_dir = "$ENV{HOME}/logs"; +my $window = 60; # seconds +my $min_files = 2; # use 4 if you want to require four consoles +my $dry_run = 0; +my $help = 0; + +GetOptions( + 'dir=s' => \$log_dir, + 'window=i' => \$window, + 'min-files=i' => \$min_files, + 'dry-run' => \$dry_run, + 'help' => \$help, +) or die "Bad option. Try --help\n"; + +if ($help) { + print <<"HELP"; +Usage: + ./clump_tbeam_logs.pl [options] + +Options: + --dir DIR Log directory. Default: ~/logs + --window SECONDS Timestamp grouping window. Default: 60 + --min-files N Minimum matching files required. Default: 2 + --dry-run Show what would happen, but do not move files. + --help Show this help. + +Example: + ./clump_tbeam_logs.pl --dir ~/logs --window 60 --min-files 4 +HELP + exit 0; +} + +die "Directory not found: $log_dir\n" unless -d $log_dir; + +my @units = qw(AMY BOB CY DAN ED FLO GUY); +my $unit_re = join '|', @units; + +print "\nContents of $log_dir:\n"; +print "-" x 72, "\n"; + +my @all_paths = glob(File::Spec->catfile($log_dir, '*')); + +my @listed = sort { + (stat($b))[9] <=> (stat($a))[9] +} @all_paths; + +for my $path (@listed) { + my @st = stat($path); + next unless @st; + + my $mtime = localtime($st[9])->strftime('%Y-%m-%d %H:%M:%S'); + my $size = $st[7]; + my $name = basename($path); + + printf "%s %10d %s\n", $mtime, $size, $name; +} + +print "-" x 72, "\n\n"; + +my @candidates; + +for my $path (@all_paths) { + next unless -f $path; + + my $base = basename($path); + + next unless $base =~ /(?:^|[_\-.])($unit_re)(?:[_\-.]|$)/i; + my $unit = uc($1); + + next unless $base =~ /(\d{8}_\d{6})/; + my $stamp = $1; + + my $tp; + eval { + $tp = Time::Piece->strptime($stamp, '%Y%m%d_%H%M%S'); + }; + if ($@) { + warn "Skipping file with bad timestamp: $base\n"; + next; + } + + my $minute_stamp = substr($stamp, 0, 13); # YYYYMMDD_HHMM + + push @candidates, { + path => $path, + base => $base, + unit => $unit, + stamp => $stamp, + minute_stamp => $minute_stamp, + epoch => $tp->epoch, + }; +} + +die "No matching T-Beam log files found in $log_dir\n" + unless @candidates; + +@candidates = sort { + $b->{epoch} <=> $a->{epoch} +} @candidates; + +my $seed = $candidates[0]; + +my @window_matches = grep { + abs($_->{epoch} - $seed->{epoch}) <= $window +} @candidates; + +# Keep only the newest file per unit inside the time window. +my %best_for_unit; + +for my $item (@window_matches) { + my $unit = $item->{unit}; + + if (!exists $best_for_unit{$unit} + || $item->{epoch} > $best_for_unit{$unit}->{epoch}) { + $best_for_unit{$unit} = $item; + } +} + +my @selected = sort { + $a->{epoch} <=> $b->{epoch} +} values %best_for_unit; + +if (@selected < $min_files) { + print "Newest matching file:\n"; + printf " %s %s %s\n", $seed->{unit}, $seed->{stamp}, $seed->{base}; + + print "\nFiles found within +/- $window seconds:\n"; + for my $item (@selected) { + printf " %-3s %s %s\n", + $item->{unit}, $item->{stamp}, $item->{base}; + } + + die "\nOnly found " . scalar(@selected) . + " matching file(s), but --min-files is $min_files. No files moved.\n"; +} + +my %minute_count; +for my $item (@selected) { + $minute_count{ $item->{minute_stamp} }++; +} + +my $max_count = max(values %minute_count); + +my @mode_minutes = sort grep { + $minute_count{$_} == $max_count +} keys %minute_count; + +# Tie-breaker: use the newest file's minute if it is one of the modes; +# otherwise use the latest mode minute. +my $dir_stamp = $seed->{minute_stamp}; + +if (!grep { $_ eq $dir_stamp } @mode_minutes) { + $dir_stamp = $mode_minutes[-1]; +} + +my $target_dir = File::Spec->catdir($log_dir, $dir_stamp); + +print "Seed file:\n"; +printf " %-3s %s %s\n", $seed->{unit}, $seed->{stamp}, $seed->{base}; + +print "\nSelected files within +/- $window seconds:\n"; +for my $item (@selected) { + printf " %-3s %s %s\n", + $item->{unit}, $item->{stamp}, $item->{base}; +} + +print "\nMost common minute stamp: $dir_stamp\n"; +print "Target directory: $target_dir\n\n"; + +if ($dry_run) { + print "DRY RUN: no files will be moved.\n\n"; +} else { + if (!-d $target_dir) { + print "Creating directory: $target_dir\n"; + make_path($target_dir) or die "Unable to create $target_dir: $!\n"; + } +} + +for my $item (@selected) { + my $src = $item->{path}; + my $dst = File::Spec->catfile($target_dir, $item->{base}); + + if (-e $dst) { + die "Refusing to overwrite existing file: $dst\n"; + } + + if ($dry_run) { + print "Would move: $src\n"; + print " to: $dst\n"; + } else { + print "Moving: $item->{base}\n"; + rename($src, $dst) or die "rename failed for $src -> $dst: $!\n"; + } +} + +print "\nDone.\n"; +jlpoole@jp ~/workstation/perl $ + +``` From 4e1d9a75db98d87fe20adc74d04a5fa5b363b0df Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 29 May 2026 12:30:45 -0700 Subject: [PATCH 11/14] For posterity --- .../history_of_a_LINK_codex_review.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 exercises/204_established_identities/history_of_a_LINK_codex_review.md diff --git a/exercises/204_established_identities/history_of_a_LINK_codex_review.md b/exercises/204_established_identities/history_of_a_LINK_codex_review.md new file mode 100644 index 0000000..b1318b1 --- /dev/null +++ b/exercises/204_established_identities/history_of_a_LINK_codex_review.md @@ -0,0 +1,101 @@ +# Review Notes for `history_of_a_LINK.md` + +These notes are intentionally separate from the draft. + +## Strong Points + +The document now gives a useful foundation for a new reader. The added background explains the run setup, the three-node test condition, the purpose of the simulated BOB/CY isolation, and the difference between direct physical LoRa reception and transported microReticulum packets. + +The analysis also correctly frames the main result from run `20260528_2319`: announcement transport works, the BOB-to-CY LinkRequest reaches CY through DAN, CY accepts the LinkRequest, and BOB marks the Link active. The remaining failure is later, after DAN forwards Link data on the shared LoRa interface. + +## Citation Improvements + +For an appellate-brief style record, the main body should not rely on shorthand citations such as: + +```text +CY line 1529: RX ANNOUNCE: label=Bob hash=e431430abeca68dca8411f50ca9864b0 phy=Dan(3) +``` + +Prefer the full citation format already used in the appendix: + +```text +/home/jlpoole/logs/20260528_2319/CY_raw_20260528_231957.log:1529:RX ANNOUNCE: label=Bob hash=e431430abeca68dca8411f50ca9864b0 phy=Dan(3) +``` + +That makes each quoted line independently traceable without requiring the reader to cross-reference the appendix. + +For quoted source code, use the same idea: + +```cpp +// exercises/204_established_identities/src/main.cpp:1020-1024 +inbound_destination = RNS::Destination(local_identity, + RNS::Type::Destination::IN, + RNS::Type::Destination::SINGLE, + APP_NAME, + APP_ASPECT); +``` + +Useful source citations for the current document: + +```text +exercises/204_established_identities/src/main.cpp:50-51 +exercises/204_established_identities/src/main.cpp:1020-1024 +exercises/204_established_identities/src/TBeamSupremeLoRaInterface.h:37-42 +exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp:142-149 +exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp:157-187 +exercises/204_established_identities/src/TBeamSupremeLoRaInterface.cpp:197-200 +/usr/local/src/microreticulum/microReticulum/src/Destination.cpp:115-126 +``` + +## Terminology Notes + +`phy=Dan(3)` means CY received that LoRa frame from physical transmitter DAN, slot 3. It does not mean CY sent a confirmation to DAN or BOB. In the cited BOB announcement example, it means CY learned BOB's destination from a frame physically transmitted by DAN. + +`destination hash` should be introduced as the stable Reticulum destination identifier for this application endpoint. In this exercise, BOB's destination hash is derived from BOB's fixed identity plus the exercise's app name and aspect strings. + +The `addr_hash_material << identity.hash();` line appends the identity hash bytes to the destination-name hash material before truncating the final hash. + +## Suggested Wording Fixes + +`PUUBLIC KEY` should be `PUBLIC KEY`. + +`using an bash script` should be `using a bash script`. + +`up to 24 hour drift is allowed` would read more clearly as `up to 24 hours of holdover is allowed`. + +`ED was defunct` may read as hardware failure. If ED was intentionally offline for the test, say `ED was offline for this run`. + +`This would not be a real life scenario` can be softened to `This is a test policy, not an inherent Reticulum rule`. + +## One Claim to Qualify + +The draft says the lower identity initiates the Link. The logs prove the result, namely BOB initiates and CY accepts. Unless the document cites the application code that performs the ordering comparison, phrase this as an exercise policy observed in the logs rather than as a fact proved by the quoted log lines alone. + +Suggested wording: + +```text +The exercise policy is that one side of a known peer pair initiates the Link, +avoiding simultaneous LinkRequests and reducing LoRa airtime. The logs do not +show the peer-ordering comparison itself; they show the resulting behavior: +BOB initiates, and CY accepts. +``` + +## Blocking Section + +The blocking explanation is good and worth keeping. To make it more rigorous, cite the source lines that show: + +```text +1. The envelope constants: TBeamSupremeLoRaInterface.h:37-42 +2. The transmit-side envelope prepend: TBeamSupremeLoRaInterface.cpp:142-149 +3. The receive-side envelope strip: TBeamSupremeLoRaInterface.cpp:157-187 +4. The BOB/CY physical drop rule: TBeamSupremeLoRaInterface.cpp:197-200 +``` + +The strongest single sentence in that section is: + +```text +The BOB/CY block is a physical-link simulation in TBeamSupremeLoRaInterface, +not a microReticulum-layer rule. +``` + +That distinction should remain prominent. From a52617862f3f8812703703928f5c0e382f0e5c35 Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 29 May 2026 17:24:33 -0700 Subject: [PATCH 12/14] Before further debugging of LINK bug --- .../204_established_identities/src/main.cpp | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/exercises/204_established_identities/src/main.cpp b/exercises/204_established_identities/src/main.cpp index 4ca6ec1..dab683d 100644 --- a/exercises/204_established_identities/src/main.cpp +++ b/exercises/204_established_identities/src/main.cpp @@ -703,6 +703,14 @@ static String parse_recipient_label(const String& text) { } static void attach_link_callbacks(RNS::Link& link) { + if (link) { + Serial.printf("APP LINK CALLBACKS: attach hash=%s status=%u initiator=%u\r\n", + link.hash().toHex().c_str(), + (unsigned)link.status(), + link.initiator() ? 1U : 0U); + } else { + Serial.println("APP LINK CALLBACKS: attach skipped null link"); + } link.set_packet_callback(on_link_packet); link.set_link_closed_callback(on_link_closed); } @@ -716,6 +724,14 @@ static void on_link_packet(const RNS::Bytes& data, const RNS::Packet& packet) { String text = data.toString().c_str(); String sender = parse_sender_label(text); String recipient = parse_recipient_label(text); + Serial.printf("APP RX LINK ENTER: link=%s status=%u initiator=%u peer_index=%d sender=%s recipient=%s text=%s\r\n", + packet.link() ? packet.link().hash().toHex().c_str() : "(none)", + packet.link() ? (unsigned)packet.link().status() : 255U, + packet.link() && packet.link().initiator() ? 1U : 0U, + peer_index, + sender.c_str(), + recipient.c_str(), + text.c_str()); if (sender == NODE_LABEL || (recipient.length() > 0 && recipient != NODE_LABEL)) { Serial.printf("RX LINK ignored: self_or_wrong_recipient text=%s\r\n", text.c_str()); return; @@ -795,6 +811,11 @@ static void on_link_closed(RNS::Link& link) { static void on_outbound_link_established(RNS::Link& link) { int peer_index = find_peer_by_link_hash(link.hash()); + Serial.printf("APP LINK ESTABLISHED CB: direction=outbound hash=%s status=%u initiator=%u peer_index=%d\r\n", + link.hash().toHex().c_str(), + (unsigned)link.status(), + link.initiator() ? 1U : 0U, + peer_index); if (peer_index >= 0) { peers[peer_index].outbound_link = link; attach_link_callbacks(peers[peer_index].outbound_link); @@ -817,6 +838,11 @@ static void on_inbound_link_established(RNS::Link& link) { if (peer_index < 0) { peer_index = first_free_peer_slot(); } + Serial.printf("APP LINK ESTABLISHED CB: direction=inbound hash=%s status=%u initiator=%u peer_index=%d\r\n", + link.hash().toHex().c_str(), + (unsigned)link.status(), + link.initiator() ? 1U : 0U, + peer_index); if (peer_index >= 0) { peers[peer_index].inbound_link = link; attach_link_callbacks(peers[peer_index].inbound_link); @@ -942,6 +968,12 @@ static void maybe_open_link(const DateTime& rtc_now, bool have_rtc_now) { continue; } if (peer.outbound_active || (peer.outbound_link && peer.outbound_link.status() == RNS::Type::Link::ACTIVE)) { + if (!peer.outbound_active) { + Serial.printf("APP LINK STATE: outbound already active peer=%s hash=%s status=%u\r\n", + peer.label.c_str(), + peer.outbound_link.hash().toHex().c_str(), + (unsigned)peer.outbound_link.status()); + } peer.outbound_active = true; continue; } @@ -1108,13 +1140,27 @@ void loop() { link = &peer.inbound_link; } if (!link) { + Serial.printf("TX LINK SKIP: peer=%s outbound=%u outbound_status=%u inbound=%u inbound_status=%u attempted=%u active_out=%u active_in=%u\r\n", + peer.label.c_str(), + peer.outbound_link ? 1U : 0U, + peer.outbound_link ? (unsigned)peer.outbound_link.status() : 255U, + peer.inbound_link ? 1U : 0U, + peer.inbound_link ? (unsigned)peer.inbound_link.status() : 255U, + peer.outbound_attempted ? 1U : 0U, + peer.outbound_active ? 1U : 0U, + peer.inbound_active ? 1U : 0U); continue; } peer.last_tx_second = rtc_now.second; peer.last_tx_ms = now; String message = String("Hi from ") + NODE_LABEL + " iter=" + String(peer.tx_iter++) + " to=" + peer.label; - Serial.printf("TX LINK: %s\r\n", message.c_str()); + Serial.printf("TX LINK: %s via=%s hash=%s status=%u since_open=%u\r\n", + message.c_str(), + link == &peer.outbound_link ? "outbound" : "inbound", + link->hash().toHex().c_str(), + (unsigned)link->status(), + (unsigned)peer.tx_since_link_open); show_status("TX LINK", peer.label.c_str(), message.c_str()); RNS::Packet(*link, RNS::bytesFromString(message.c_str())).send(); ++peer.tx_since_link_open; From ce56e876bd1d703e5168433a969dfbe8bb17e334 Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 29 May 2026 17:46:56 -0700 Subject: [PATCH 13/14] Note tested, added 750ms delay to overcome possible race condition --- .../204_established_identities/platformio.ini | 1 + .../204_established_identities/src/main.cpp | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/exercises/204_established_identities/platformio.ini b/exercises/204_established_identities/platformio.ini index 598d9e9..f4936d0 100644 --- a/exercises/204_established_identities/platformio.ini +++ b/exercises/204_established_identities/platformio.ini @@ -53,6 +53,7 @@ build_flags = -D SIM_PHY_ENVELOPE=1 -D SIM_PHY_BLOCK_BOB_CY=1 -D MR_TRANSPORT_PROBE=1 + -D MR_LINKFWD_DELAY_MS=750 ; Live announces are enough for this single-hop field exercise. Do not define ; RNS_PERSIST_PATHS here: the LittleFS-backed path_store compactor can leave an ; active segment FD open while unlinking /path_store_*.dat on ESP32. diff --git a/exercises/204_established_identities/src/main.cpp b/exercises/204_established_identities/src/main.cpp index dab683d..c6c4de1 100644 --- a/exercises/204_established_identities/src/main.cpp +++ b/exercises/204_established_identities/src/main.cpp @@ -46,6 +46,9 @@ #ifndef EX204_RNS_TRACE #define EX204_RNS_TRACE 0 #endif +#ifndef MR_LINKFWD_DELAY_MS +#define MR_LINKFWD_DELAY_MS 0 +#endif static constexpr const char* APP_NAME = "microreticulum"; static constexpr const char* APP_ASPECT = "linkping"; @@ -114,6 +117,7 @@ struct PeerState { uint32_t tx_iter = 0; uint8_t tx_since_link_open = 0; uint8_t last_tx_second = 255; + uint8_t last_skip_second = 255; }; static PeerState peers[MAX_PEERS]; @@ -662,6 +666,7 @@ static void clear_peer_slot(uint8_t index) { peers[index].tx_iter = 0; peers[index].tx_since_link_open = 0; peers[index].last_tx_second = 255; + peers[index].last_skip_second = 255; } static int ensure_peer_for_label(const String& label) { @@ -909,11 +914,12 @@ static void print_config() { Serial.printf("LoRa: freq=%.1f BW=%.1f SF=%d CR=%d sync=0x%02x txp=%d\r\n", (double)LORA_FREQ_MHZ, (double)LORA_BW_KHZ, (int)LORA_SF, (int)LORA_CR, (int)LORA_SYNC_WORD, (int)LORA_TX_POWER_DBM); - Serial.printf("Sim: phy_envelope=%d phy_block_bob_cy=%d node_slot=%d rns_trace=%d\r\n", + Serial.printf("Sim: phy_envelope=%d phy_block_bob_cy=%d node_slot=%d rns_trace=%d linkfwd_delay_ms=%u\r\n", (int)SIM_PHY_ENVELOPE, (int)SIM_PHY_BLOCK_BOB_CY, (int)NODE_SLOT_INDEX, - (int)EX204_RNS_TRACE); + (int)EX204_RNS_TRACE, + (unsigned)MR_LINKFWD_DELAY_MS); } static void send_announce() { @@ -1140,15 +1146,18 @@ void loop() { link = &peer.inbound_link; } if (!link) { - Serial.printf("TX LINK SKIP: peer=%s outbound=%u outbound_status=%u inbound=%u inbound_status=%u attempted=%u active_out=%u active_in=%u\r\n", - peer.label.c_str(), - peer.outbound_link ? 1U : 0U, - peer.outbound_link ? (unsigned)peer.outbound_link.status() : 255U, - peer.inbound_link ? 1U : 0U, - peer.inbound_link ? (unsigned)peer.inbound_link.status() : 255U, - peer.outbound_attempted ? 1U : 0U, - peer.outbound_active ? 1U : 0U, - peer.inbound_active ? 1U : 0U); + if (peer.last_skip_second != rtc_now.second) { + peer.last_skip_second = rtc_now.second; + Serial.printf("TX LINK SKIP: peer=%s outbound=%u outbound_status=%u inbound=%u inbound_status=%u attempted=%u active_out=%u active_in=%u\r\n", + peer.label.c_str(), + peer.outbound_link ? 1U : 0U, + peer.outbound_link ? (unsigned)peer.outbound_link.status() : 255U, + peer.inbound_link ? 1U : 0U, + peer.inbound_link ? (unsigned)peer.inbound_link.status() : 255U, + peer.outbound_attempted ? 1U : 0U, + peer.outbound_active ? 1U : 0U, + peer.inbound_active ? 1U : 0U); + } continue; } From dc8e6163af9e4b14b2399c9d01f8c5a6f093f26a Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 29 May 2026 18:04:17 -0700 Subject: [PATCH 14/14] Another delay injection --- exercises/204_established_identities/platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/exercises/204_established_identities/platformio.ini b/exercises/204_established_identities/platformio.ini index f4936d0..de07b41 100644 --- a/exercises/204_established_identities/platformio.ini +++ b/exercises/204_established_identities/platformio.ini @@ -54,6 +54,7 @@ build_flags = -D SIM_PHY_BLOCK_BOB_CY=1 -D MR_TRANSPORT_PROBE=1 -D MR_LINKFWD_DELAY_MS=750 + -D MR_LRPROOF_DELAY_MS=750 ; Live announces are enough for this single-hop field exercise. Do not define ; RNS_PERSIST_PATHS here: the LittleFS-backed path_store compactor can leave an ; active segment FD open while unlinking /path_store_*.dat on ESP32.