From eecd1a9f4c9a572d14676ba554fc5ed978146533 Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 3 Jun 2026 09:00:28 -0700 Subject: [PATCH 1/2] awk parsers for logs --- .../scripts/handy_log_parsers.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 exercises/204_established_identities/scripts/handy_log_parsers.txt diff --git a/exercises/204_established_identities/scripts/handy_log_parsers.txt b/exercises/204_established_identities/scripts/handy_log_parsers.txt new file mode 100644 index 0000000..7924472 --- /dev/null +++ b/exercises/204_established_identities/scripts/handy_log_parsers.txt @@ -0,0 +1,16 @@ +Using awk: + +awk -v start="20260603_080000" -v end="20260603_083000" ' + $1 >= start && $1 < end { print } +' BOB_raw_20260602_191631.log | grep Hi + + +awk -v start="20260603_080000" -v end="20260603_083000" ' + $1 >= start && $1 < end { print } +' BOB_raw_20260602_191631.log | grep 'TX LINK:' + + +awk -v start="20260603_080000" -v end="20260603_083000" ' + $1 >= start && $1 < end { print } +' BOB_raw_20260602_191631.log | grep 'RX LINK:' + From 2a6fef41c79565248555d59cc21f83d9aa058101 Mon Sep 17 00:00:00 2001 From: John Poole Date: Wed, 3 Jun 2026 09:19:33 -0700 Subject: [PATCH 2/2] What changed from 204: Kept all units in transport mode with reticulum.transport_enabled(true). Removed physical blocking: SIM_PHY_BLOCK_BOB_CY=0, no per-unit block. Removed the extra deep transmission/debug flags from platformio.ini. Kept the 204 announcement schedule/protocol. Removed intentional Link teardown after message cycles. Every unit now attempts an outbound Link to every peer that announces. Added retry health behavior: 3 Link attempts within 3 minutes, then wait for a fresh announce before trying that peer again. Preserved substantive parseable logs: TX ANNOUNCE, RX ANNOUNCE, TX LINKREQUEST, LINK ACTIVE, TX LINK, RX LINK, retry/failure/reset events. Updated copied helper scripts to point at 205_sustained_link. Updated README for the new exercise behavior. --- exercises/205_sustained_link/README.md | 69 + .../identities/AMY.identity | 1 + .../identities/AMY.identity_info.txt | 3 + .../identities/BOB.identity | 2 + .../identities/BOB.identity_info.txt | 3 + .../205_sustained_link/identities/CY.identity | 2 + .../identities/CY.identity_info.txt | 3 + .../identities/DAN.identity | 1 + .../identities/DAN.identity_info.txt | 3 + .../205_sustained_link/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 + exercises/205_sustained_link/platformio.ini | 141 ++ .../scripts/handy_log_parsers.txt | 16 + .../205_sustained_link/scripts/load_only.sh | 184 +++ .../scripts/pull_build_from_ryzdesk.sh | 25 + .../scripts/pull_only_from_ryzdesk.sh | 26 + .../scripts/set_build_identity.py | 23 + .../src/TBeamSupremeLoRaInterface.cpp | 245 ++++ .../src/TBeamSupremeLoRaInterface.h | 63 + exercises/205_sustained_link/src/main.cpp | 1253 +++++++++++++++++ 24 files changed, 2076 insertions(+) create mode 100644 exercises/205_sustained_link/README.md create mode 100644 exercises/205_sustained_link/identities/AMY.identity create mode 100644 exercises/205_sustained_link/identities/AMY.identity_info.txt create mode 100644 exercises/205_sustained_link/identities/BOB.identity create mode 100644 exercises/205_sustained_link/identities/BOB.identity_info.txt create mode 100644 exercises/205_sustained_link/identities/CY.identity create mode 100644 exercises/205_sustained_link/identities/CY.identity_info.txt create mode 100644 exercises/205_sustained_link/identities/DAN.identity create mode 100644 exercises/205_sustained_link/identities/DAN.identity_info.txt create mode 100644 exercises/205_sustained_link/identities/ED.identity create mode 100644 exercises/205_sustained_link/identities/ED.identity_info.txt create mode 100644 exercises/205_sustained_link/identities/FLO.identity create mode 100644 exercises/205_sustained_link/identities/FLO.identity_info.txt create mode 100644 exercises/205_sustained_link/identities/GUY.identity create mode 100644 exercises/205_sustained_link/identities/GUY.identity_info.txt create mode 100644 exercises/205_sustained_link/platformio.ini create mode 100644 exercises/205_sustained_link/scripts/handy_log_parsers.txt create mode 100755 exercises/205_sustained_link/scripts/load_only.sh create mode 100755 exercises/205_sustained_link/scripts/pull_build_from_ryzdesk.sh create mode 100755 exercises/205_sustained_link/scripts/pull_only_from_ryzdesk.sh create mode 100644 exercises/205_sustained_link/scripts/set_build_identity.py create mode 100644 exercises/205_sustained_link/src/TBeamSupremeLoRaInterface.cpp create mode 100644 exercises/205_sustained_link/src/TBeamSupremeLoRaInterface.h create mode 100644 exercises/205_sustained_link/src/main.cpp diff --git a/exercises/205_sustained_link/README.md b/exercises/205_sustained_link/README.md new file mode 100644 index 0000000..973d6c5 --- /dev/null +++ b/exercises/205_sustained_link/README.md @@ -0,0 +1,69 @@ +# Introduction +Exercise 205 builds on Exercise 204 established identities and LoRa Link traffic. It keeps the same identity, announce, RTC/GPS, OLED, and machine-parseable TX/RX event style, but changes the link policy for longer multi-unit field runs. + +All seven units, `AMY` through `GUY`, are supported. Every unit runs in transport mode and every unit attempts to open a Link to any other unit that announces. + +# Behavior + +The announce protocol is intentionally the same as the last Exercise 204 protocol: + +```text +startup announce: immediate +second announce: ANNOUNCEMENT_2 seconds after startup, default 300 +repeat announce: ANNOUNCEMENT_REPEAT seconds after that, default 3600 +``` + +There is no simulated BOB/CY physical block in this exercise. `SIM_PHY_ENVELOPE` remains enabled so log records can report the physical sender slot, but `SIM_PHY_BLOCK_BOB_CY=0` for every environment. + +Exercise 205 does not intentionally tear down Links after a message count. A Link is reused while it remains active. If a Link becomes stale or closes, the unit clears local state and attempts to recreate the outbound Link. + +The outbound Link retry budget is: + +```text +retry interval: 60 seconds +failure window: 3 minutes +max attempts: 3 +``` + +After three failed attempts within the window, the peer is marked failed and no more Link requests are sent to that peer until another announce for that peer is received. A fresh announce resets the retry budget and starts over. + +# Clock Gate + +The unit checks the RTC and `/ex205/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. + +# Log Events + +Substantive events retain the Exercise 204 style so multi-unit log parsing can correlate TX and RX: + +```text +TX ANNOUNCE: Bob +RX ANNOUNCE: label=Cy hash= phy=Cy(2) +TX LINKREQUEST: opening link to Cy slot=19 attempt=1/3 +LINK ACTIVE: initiator link established to Cy hash= +RX LINK: inbound link established hash= phy=Bob(1) +TX LINK: Hi from Bob iter=0 to=Cy via=outbound hash= status=2 +RX LINK: Hi from Cy iter=0 to=Bob | phy=Cy(2) RSSI=... SNR=... +LINK RETRY: no establishment after 60000 ms; retrying Cy attempts=1/3 +LINK FAILED: peer=Cy attempts=3 window_ms=... waiting_for_announce=1 +LINK RETRY RESET: fresh announce from Cy +``` + +# Build, Upload, And Monitor + +```bash +source /home/jlpoole/rnsenv/bin/activate +cd /usr/local/src/microreticulum/microReticulumTbeam + +pio run -d exercises/205_sustained_link -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/205_sustained_link -e "${env}" -t upload +done +``` + +Monitor one unit: + +```bash +pio device monitor -d exercises/205_sustained_link -e bob +``` diff --git a/exercises/205_sustained_link/identities/AMY.identity b/exercises/205_sustained_link/identities/AMY.identity new file mode 100644 index 0000000..a13546a --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/identities/AMY.identity_info.txt b/exercises/205_sustained_link/identities/AMY.identity_info.txt new file mode 100644 index 0000000..decfea7 --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/identities/BOB.identity b/exercises/205_sustained_link/identities/BOB.identity new file mode 100644 index 0000000..565253f --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/identities/BOB.identity_info.txt b/exercises/205_sustained_link/identities/BOB.identity_info.txt new file mode 100644 index 0000000..3cb6bda --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/identities/CY.identity b/exercises/205_sustained_link/identities/CY.identity new file mode 100644 index 0000000..73a6a68 --- /dev/null +++ b/exercises/205_sustained_link/identities/CY.identity @@ -0,0 +1,2 @@ +7EbI$vp1->嫵Gs.ѥ|ݞ;Y+D; +BPjH`: \ No newline at end of file diff --git a/exercises/205_sustained_link/identities/CY.identity_info.txt b/exercises/205_sustained_link/identities/CY.identity_info.txt new file mode 100644 index 0000000..b66e212 --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/identities/DAN.identity b/exercises/205_sustained_link/identities/DAN.identity new file mode 100644 index 0000000..091fe60 --- /dev/null +++ b/exercises/205_sustained_link/identities/DAN.identity @@ -0,0 +1 @@ +w'lNӴ-ɥ2vu|,vqGD1Xϱyꧫ܅.LA \ No newline at end of file diff --git a/exercises/205_sustained_link/identities/DAN.identity_info.txt b/exercises/205_sustained_link/identities/DAN.identity_info.txt new file mode 100644 index 0000000..1689740 --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/identities/ED.identity b/exercises/205_sustained_link/identities/ED.identity new file mode 100644 index 0000000..7fbee55 --- /dev/null +++ b/exercises/205_sustained_link/identities/ED.identity @@ -0,0 +1 @@ +L&(_IriTSOKDlק ˖xv-h@ \ No newline at end of file diff --git a/exercises/205_sustained_link/identities/ED.identity_info.txt b/exercises/205_sustained_link/identities/ED.identity_info.txt new file mode 100644 index 0000000..3962a6b --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/identities/FLO.identity b/exercises/205_sustained_link/identities/FLO.identity new file mode 100644 index 0000000..b1b8612 --- /dev/null +++ b/exercises/205_sustained_link/identities/FLO.identity @@ -0,0 +1,2 @@ + +057X!##CM t{%+X6T!ͪE MP6[\^Z& \ No newline at end of file diff --git a/exercises/205_sustained_link/identities/FLO.identity_info.txt b/exercises/205_sustained_link/identities/FLO.identity_info.txt new file mode 100644 index 0000000..a2d1630 --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/identities/GUY.identity b/exercises/205_sustained_link/identities/GUY.identity new file mode 100644 index 0000000..2986e72 --- /dev/null +++ b/exercises/205_sustained_link/identities/GUY.identity @@ -0,0 +1 @@ +vp{BQ5 ؍\oe+o2<٭Jy'L2 \ No newline at end of file diff --git a/exercises/205_sustained_link/identities/GUY.identity_info.txt b/exercises/205_sustained_link/identities/GUY.identity_info.txt new file mode 100644 index 0000000..24340b4 --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/platformio.ini b/exercises/205_sustained_link/platformio.ini new file mode 100644 index 0000000..cd1a30e --- /dev/null +++ b/exercises/205_sustained_link/platformio.ini @@ -0,0 +1,141 @@ +; Exercise 205: sustained microReticulum Links over established identities + +[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 + -D SIM_PHY_ENVELOPE=1 + -D SIM_PHY_BLOCK_BOB_CY=0 + -D ANNOUNCEMENT_2=300 + -D ANNOUNCEMENT_REPEAT=3600 +; 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/205_sustained_link/scripts/handy_log_parsers.txt b/exercises/205_sustained_link/scripts/handy_log_parsers.txt new file mode 100644 index 0000000..7924472 --- /dev/null +++ b/exercises/205_sustained_link/scripts/handy_log_parsers.txt @@ -0,0 +1,16 @@ +Using awk: + +awk -v start="20260603_080000" -v end="20260603_083000" ' + $1 >= start && $1 < end { print } +' BOB_raw_20260602_191631.log | grep Hi + + +awk -v start="20260603_080000" -v end="20260603_083000" ' + $1 >= start && $1 < end { print } +' BOB_raw_20260602_191631.log | grep 'TX LINK:' + + +awk -v start="20260603_080000" -v end="20260603_083000" ' + $1 >= start && $1 < end { print } +' BOB_raw_20260602_191631.log | grep 'RX LINK:' + diff --git a/exercises/205_sustained_link/scripts/load_only.sh b/exercises/205_sustained_link/scripts/load_only.sh new file mode 100755 index 0000000..6ffa96e --- /dev/null +++ b/exercises/205_sustained_link/scripts/load_only.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# +# 20260602 ChatGPT +# $Id$ +# $HeadURL$ +# +# Example command lines: +# +# ./load_only.sh --tbeams "CY" +# ./load_only.sh --tbeams "DAN" +# ./load_only.sh --tbeams "BOB" +# ./load_only.sh --tbeams "BOB CY DAN" +# ./load_only.sh --tbeams "bob,cy,dan" +# +# If pio/platformio is not in PATH: +# +# ./load_only.sh --pio-bin "$HOME/pioenv/bin/platformio" --tbeams "DAN" +# + +set -euo pipefail + +EXERCISE="/usr/local/src/microreticulum/microReticulumTbeam/exercises/205_sustained_link" +REMOTE_HOST="ryzdesk" +TBEAMS_RAW="" +PIO_BIN="" + +usage() { + cat <<'EOF' +Usage: + ./load_only.sh --tbeams "CY" + ./load_only.sh --tbeams "BOB CY DAN" + ./load_only.sh --tbeams "bob,cy,dan" + +Required: + --tbeams One or more of: AMY BOB CY DAN ED FLO GUY + +Optional: + --pio-bin Full path to pio/platformio executable + --exercise PlatformIO project directory + --remote Remote build host, default: ryzdesk +EOF +} + +while [ "$#" -gt 0 ] +do + case "$1" in + --tbeams) + shift + [ "$#" -gt 0 ] || { echo "ERROR: --tbeams requires a value" >&2; exit 1; } + TBEAMS_RAW="$1" + ;; + --pio-bin) + shift + [ "$#" -gt 0 ] || { echo "ERROR: --pio-bin requires a value" >&2; exit 1; } + PIO_BIN="$1" + ;; + --exercise) + shift + [ "$#" -gt 0 ] || { echo "ERROR: --exercise requires a value" >&2; exit 1; } + EXERCISE="$1" + ;; + --remote) + shift + [ "$#" -gt 0 ] || { echo "ERROR: --remote requires a value" >&2; exit 1; } + REMOTE_HOST="$1" + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "ERROR: unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +if [ -z "$TBEAMS_RAW" ]; then + echo "ERROR: missing required option --tbeams" >&2 + usage >&2 + exit 1 +fi + +if [ ! -d "$EXERCISE" ]; then + echo "ERROR: EXERCISE directory not found: $EXERCISE" >&2 + exit 1 +fi + +if [ ! -f "$EXERCISE/platformio.ini" ]; then + echo "ERROR: platformio.ini not found under: $EXERCISE" >&2 + exit 1 +fi + +if [ -n "$PIO_BIN" ]; then + if [ ! -x "$PIO_BIN" ]; then + echo "ERROR: --pio-bin is not executable: $PIO_BIN" >&2 + exit 1 + fi +else + if command -v pio >/dev/null 2>&1; then + PIO_BIN="$(command -v pio)" + elif command -v platformio >/dev/null 2>&1; then + PIO_BIN="$(command -v platformio)" + else + echo "ERROR: cannot find pio or platformio in PATH" >&2 + echo " Try: --pio-bin \"\$HOME/pioenv/bin/platformio\"" >&2 + exit 1 + fi +fi + +echo "EXERCISE: $EXERCISE" +echo "REMOTE_HOST: $REMOTE_HOST" +echo "PIO_BIN: $PIO_BIN" +echo + +# +# Accept either: +# "BOB CY DAN" +# "bob,cy,dan" +# +TBEAMS_NORMALIZED="$(echo "$TBEAMS_RAW" | tr ',' ' ')" + +for env_raw in $TBEAMS_NORMALIZED +do + env="$(echo "$env_raw" | tr '[:upper:]' '[:lower:]')" + ENV="$(echo "$env" | tr '[:lower:]' '[:upper:]')" + + case "$env" in + amy|bob|cy|dan|ed|flo|guy) + ;; + *) + echo "ERROR: invalid T-Beam name: $env_raw" >&2 + echo " Allowed names: AMY BOB CY DAN ED FLO GUY" >&2 + exit 1 + ;; + esac + + dev="/dev/ttyt${ENV}" + remote_build_dir="${EXERCISE}/.pio/build/${env}" + local_build_dir="${EXERCISE}/.pio/build/${env}" + + if [ ! -e "$dev" ]; then + echo "ERROR: expected device not found for $ENV: $dev" >&2 + exit 1 + fi + + if ! ssh "$REMOTE_HOST" "test -d '$remote_build_dir'"; then + echo "ERROR: remote build directory missing on ${REMOTE_HOST}: $remote_build_dir" >&2 + exit 1 + fi + + if [ ! -d "$local_build_dir" ]; then + echo "Creating missing local build directory: $local_build_dir" + mkdir -p "$local_build_dir" || { + echo "ERROR: failed to create $local_build_dir" >&2 + exit 1 + } + fi + + echo "===== copy built artifact tree for $env from $REMOTE_HOST =====" + rsync -a \ + "${REMOTE_HOST}:${remote_build_dir}/" \ + "${local_build_dir}/" + + if [ ! -f "${local_build_dir}/firmware.bin" ] && + [ ! -f "${local_build_dir}/firmware.elf" ]; then + echo "ERROR: copied build tree does not appear to contain firmware output:" >&2 + echo " $local_build_dir" >&2 + exit 1 + fi + + echo "===== loader-only upload $ENV on $dev =====" + "$PIO_BIN" run \ + -d "$EXERCISE" \ + -e "$env" \ + -t nobuild \ + -t upload \ + --upload-port "$dev" + + echo "===== finished loader-only upload for $ENV =====" + echo +done diff --git a/exercises/205_sustained_link/scripts/pull_build_from_ryzdesk.sh b/exercises/205_sustained_link/scripts/pull_build_from_ryzdesk.sh new file mode 100755 index 0000000..17d08a6 --- /dev/null +++ b/exercises/205_sustained_link/scripts/pull_build_from_ryzdesk.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# $Header$ +# $HeadURL$ + +set -e + +EXERCISE="/usr/local/src/microreticulum/microReticulumTbeam/exercises/205_sustained_link" + +for env in amy bob cy dan ed flo guy +do + ENV="$(echo "$env" | tr '[:lower:]' '[:upper:]')" + + echo "===== copy artifact $env from ryzdesk =====" + rsync -a \ + "ryzdesk:${EXERCISE}/.pio/build/${env}/" \ + "${EXERCISE}/.pio/build/${env}/" + + echo "===== upload $env / $ENV =====" + pio run \ + -d "$EXERCISE" \ + -e "$env" \ + -t nobuild \ + -t upload \ + --upload-port "/dev/ttyt${ENV}" +done diff --git a/exercises/205_sustained_link/scripts/pull_only_from_ryzdesk.sh b/exercises/205_sustained_link/scripts/pull_only_from_ryzdesk.sh new file mode 100755 index 0000000..9cbe0aa --- /dev/null +++ b/exercises/205_sustained_link/scripts/pull_only_from_ryzdesk.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# $Header$ +# $HeadURL$ + +set -e + +EXERCISE="/usr/local/src/microreticulum/microReticulumTbeam/exercises/205_sustained_link" + +for env in amy bob cy dan ed flo guy +do + ENV="$(echo "$env" | tr '[:lower:]' '[:upper:]')" + local_build_dir="${EXERCISE}/.pio/build/${env}" + + if [ ! -d "$local_build_dir" ]; then + echo "Creating missing local build directory: $local_build_dir" + mkdir -p "$local_build_dir" || { + echo "ERROR: failed to create $local_build_dir" >&2 + exit 1 + } + fi + echo "===== copy artifact $env from ryzdesk =====" + rsync -a \ + "ryzdesk:${EXERCISE}/.pio/build/${env}/" \ + "${EXERCISE}/.pio/build/${env}/" + +done diff --git a/exercises/205_sustained_link/scripts/set_build_identity.py b/exercises/205_sustained_link/scripts/set_build_identity.py new file mode 100644 index 0000000..e877182 --- /dev/null +++ b/exercises/205_sustained_link/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/205_sustained_link/src/TBeamSupremeLoRaInterface.cpp b/exercises/205_sustained_link/src/TBeamSupremeLoRaInterface.cpp new file mode 100644 index 0000000..8735459 --- /dev/null +++ b/exercises/205_sustained_link/src/TBeamSupremeLoRaInterface.cpp @@ -0,0 +1,245 @@ +#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 +#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; + +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 = (uint16_t)(LORA_MAX_PAYLOAD * 2); +} + +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[RADIO_MAX_PAYLOAD]; + int state = _radio->readData(rx_buf, len); + + 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); + + 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(); +} + +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) { + ++_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 +} + +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 rand_nibble = (uint8_t)(Cryptography::randomnum(256)) & 0xF0; + + if ((int)data.size() <= LORA_MAX_PAYLOAD) { + int state = transmit_frame(rand_nibble, data.data(), 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; + + 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; + 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); + } + } + + _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/205_sustained_link/src/TBeamSupremeLoRaInterface.h b/exercises/205_sustained_link/src/TBeamSupremeLoRaInterface.h new file mode 100644 index 0000000..eb87b5c --- /dev/null +++ b/exercises/205_sustained_link/src/TBeamSupremeLoRaInterface.h @@ -0,0 +1,63 @@ +#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; } + 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; + 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 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; } + + 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; + + Module* _module = nullptr; + SX1262* _radio = nullptr; +}; diff --git a/exercises/205_sustained_link/src/main.cpp b/exercises/205_sustained_link/src/main.cpp new file mode 100644 index 0000000..2a038cf --- /dev/null +++ b/exercises/205_sustained_link/src/main.cpp @@ -0,0 +1,1253 @@ +#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 + +#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 EX205_RNS_TRACE +#define EX205_RNS_TRACE 0 +#endif +#ifndef MR_LINKFWD_DELAY_MS +#define MR_LINKFWD_DELAY_MS 0 +#endif +#ifndef ANNOUNCEMENT_2 +#define ANNOUNCEMENT_2 300 +#endif +#ifndef ANNOUNCEMENT_REPEAT +#define ANNOUNCEMENT_REPEAT 3600 +#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 = "/ex205/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 = 60000; +static constexpr uint32_t LINK_RETRY_WINDOW_MS = 180000; +static constexpr uint32_t LINK_RX_STALE_MS = 75000; +static constexpr uint32_t LINK_REOPEN_DELAY_MS = 5000; +static constexpr uint8_t LINK_MAX_ATTEMPTS_PER_WINDOW = 3; +static constexpr uint32_t ANNOUNCEMENT_2_MS = (uint32_t)ANNOUNCEMENT_2 * 1000UL; +static constexpr uint32_t ANNOUNCEMENT_REPEAT_MS = (uint32_t)ANNOUNCEMENT_REPEAT * 1000UL; + +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; + bool outbound_failed = false; + uint8_t outbound_attempts = 0; + uint32_t outbound_attempt_window_ms = 0; + 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 last_tx_second = 255; + uint8_t last_skip_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 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(); +} + +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; + } + 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(); + return marker_epoch; +} + +static bool write_clock_marker(int64_t epoch) { + if (!sd_ready) { + return false; + } + SD.mkdir("/ex205"); + if (SD.exists(CLOCK_MARKER_PATH)) { + SD.remove(CLOCK_MARKER_PATH); + } + 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 205 " BOARD_ID, left, right, footer); +} + +static void show_splash() { + oled_display.showLines("Exercise 205", "Build", FW_BUILD_UTC, BOARD_ID, NODE_LABEL, "sustained"); +} + +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; + } + 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"); + 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; + } + 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; +} + +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 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 directed_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; + } + 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; + } + 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 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].outbound_failed = false; + peers[index].outbound_attempts = 0; + peers[index].outbound_attempt_window_ms = 0; + 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].last_tx_second = 255; + peers[index].last_skip_second = 255; +} + +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 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) { + 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); +} + +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); + 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; + } + if (peer_index >= 0 && peers[peer_index].label.length() == 0 && + sender.length() > 0 && sender != NODE_LABEL) { + 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) { + 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; + 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; + 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()); +} + +static void on_link_closed(RNS::Link& link) { + 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) { + 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].next_link_open_ms = millis() + LINK_REOPEN_DELAY_MS; + } + 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; + } + } + Serial.printf("LINK CLOSED: %s hash=%s\r\n", label, link_hash_hex.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()); + 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); + peers[peer_index].outbound_active = true; + peers[peer_index].outbound_attempted = true; + peers[peer_index].outbound_failed = false; + peers[peer_index].outbound_attempts = 0; + peers[peer_index].outbound_attempt_window_ms = 0; + peers[peer_index].last_link_attempt_ms = 0; + peers[peer_index].last_link_active_ms = millis(); + 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(); + } + 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); + peers[peer_index].inbound_active = true; + peers[peer_index].last_link_active_ms = millis(); + } + 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()); +} + +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; + if (peers[peer_index].outbound_failed) { + peers[peer_index].outbound_failed = false; + peers[peer_index].outbound_attempts = 0; + peers[peer_index].outbound_attempt_window_ms = 0; + peers[peer_index].outbound_attempted = false; + peers[peer_index].last_link_attempt_ms = 0; + peers[peer_index].next_link_open_ms = millis() + LINK_REOPEN_DELAY_MS; + Serial.printf("LINK RETRY RESET: fresh announce from %s\r\n", peers[peer_index].label.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()); + } +}; + +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); + Serial.printf("Sim: phy_envelope=%d phy_block_bob_cy=%d node_slot=%d rns_trace=%d linkfwd_delay_ms=%u transport=1\r\n", + (int)SIM_PHY_ENVELOPE, + (int)SIM_PHY_BLOCK_BOB_CY, + (int)NODE_SLOT_INDEX, + (int)EX205_RNS_TRACE, + (unsigned)MR_LINKFWD_DELAY_MS); + Serial.printf("Announce: startup=1 second=%lu repeat=%lu seconds\r\n", + (unsigned long)ANNOUNCEMENT_2, + (unsigned long)ANNOUNCEMENT_REPEAT); +} + +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_send_scheduled_announce() { + if (!clock_ready || !inbound_destination) { + return; + } + + static bool startup_announce_sent = false; + static uint8_t announce_count = 0; + static uint32_t next_announce_ms = 0; + + const uint32_t now = millis(); + if (!startup_announce_sent) { + startup_announce_sent = true; + announce_count = 1; + next_announce_ms = now + ANNOUNCEMENT_2_MS; + send_announce(); + return; + } + + if ((int32_t)(now - next_announce_ms) < 0) { + return; + } + + send_announce(); + if (announce_count < 2) { + announce_count = 2; + } + next_announce_ms = now + ANNOUNCEMENT_REPEAT_MS; +} + +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) { + return; + } + for (uint8_t i = 0; i < MAX_PEERS; ++i) { + PeerState& peer = peers[i]; + if (!peer.announced || !peer.destination) { + continue; + } + if (peer.outbound_failed) { + 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.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)) { + 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; + } + 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 attempts=%u/%u\r\n", + (unsigned long)LINK_RETRY_MS, + peer.label.c_str(), + (unsigned)peer.outbound_attempts, + (unsigned)LINK_MAX_ATTEMPTS_PER_WINDOW); + peer.outbound_link = {RNS::Type::NONE}; + peer.outbound_attempted = false; + peer.last_link_attempt_ms = 0; + if (peer.outbound_attempts >= LINK_MAX_ATTEMPTS_PER_WINDOW && + peer.outbound_attempt_window_ms != 0 && + (uint32_t)(now - peer.outbound_attempt_window_ms) <= LINK_RETRY_WINDOW_MS) { + peer.outbound_failed = true; + Serial.printf("LINK FAILED: peer=%s attempts=%u window_ms=%lu waiting_for_announce=1\r\n", + peer.label.c_str(), + (unsigned)peer.outbound_attempts, + (unsigned long)(now - peer.outbound_attempt_window_ms)); + show_status("LINK FAILED", peer.label.c_str(), "wait announce"); + continue; + } + } + if (peer.outbound_attempted || peer.outbound_link) { + continue; + } + const uint8_t open_second = directed_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; + } + if (peer.outbound_attempt_window_ms == 0 || + (uint32_t)(now - peer.outbound_attempt_window_ms) >= LINK_RETRY_WINDOW_MS) { + peer.outbound_attempt_window_ms = now; + peer.outbound_attempts = 0; + } + if (peer.outbound_attempts >= LINK_MAX_ATTEMPTS_PER_WINDOW) { + peer.outbound_failed = true; + Serial.printf("LINK FAILED: peer=%s attempts=%u window_ms=%lu waiting_for_announce=1\r\n", + peer.label.c_str(), + (unsigned)peer.outbound_attempts, + (unsigned long)(now - peer.outbound_attempt_window_ms)); + show_status("LINK FAILED", peer.label.c_str(), "wait announce"); + continue; + } + + ++peer.outbound_attempts; + Serial.printf("TX LINKREQUEST: opening link to %s slot=%u attempt=%u/%u\r\n", + peer.label.c_str(), + (unsigned)open_second, + (unsigned)peer.outbound_attempts, + (unsigned)LINK_MAX_ATTEMPTS_PER_WINDOW); + 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 205: sustained transport Links"); +#if EX205_RNS_TRACE + RNS::loglevel(RNS::LOG_TRACE); +#else + RNS::loglevel(RNS::LOG_NOTICE); +#endif + + (void)tbeam_supreme::initPmuForPeripherals(pmu, &Serial); + tbeam::DisplayConfig display_config; + display_config.powerSave = false; + oled_display.begin(display_config); + show_splash(); + delay(10000); + + 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 last_rtc_poll_ms = 0; + static DateTime rtc_now{}; + static bool have_rtc_now = false; + uint32_t now = millis(); + + 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); + + maybe_send_scheduled_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)) { + 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) { + 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; + } + + 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 via=%s hash=%s status=%u\r\n", + message.c_str(), + link == &peer.outbound_link ? "outbound" : "inbound", + link->hash().toHex().c_str(), + (unsigned)link->status()); + 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; +}