4 Units are talking, but LINK rarely works. Committing now for safety.

This commit is contained in:
John Poole 2026-05-28 16:44:41 -07:00
commit f1670a6123
21 changed files with 1440 additions and 0 deletions

View file

@ -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

View file

@ -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 <c95d06fb622a80b4d80389fc7fe55d16> 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 <d0524d8f1d98fc39f13772655640ea30> 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/<BOARD>.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. <build utc>
<BOARD>
<node label>
```
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: <identity hash>
Local SINGLE destination: <destination hash>
TX ANNOUNCE: Bob
RX ANNOUNCE: label=Cy hash=<destination 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=...
```

View file

@ -0,0 +1 @@
 <EFBFBD>Ο Υf°TΐdΒΦλ <CEBB>ΡΚ‰B οSl>ϊw»6ΧΗ`d³Ζ¥>Z®υΚ%Μο <07>¦W±‰ ±gy―®λ…

View file

@ -0,0 +1,3 @@
[2026-05-28 11:26:31] Loaded Identity <c95d06fb622a80b4d80389fc7fe55d16> from ./AMY.identity
[2026-05-28 11:26:31] Public Key : f25adccd75eefaf9fafe5a4b22f0a16c43bf0094810c5f9279eb30ad3fd97312ba71cdd7940bb139c15949d433f8ffb57d75441d1b84a1d091b234420d22a608
[2026-05-28 11:26:31] Private Key : Hidden

View file

@ -0,0 +1,2 @@
àǽt½Æ$Uw™)mi$yz÷3¶Ó§ªQðwK„ïKÉ'Æù<C386>ôgÌù
bY¶<EFBFBD>D6íÆ¾¹ˆ&ÕÜ!Òüó2ð

View file

@ -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

View file

@ -0,0 +1,2 @@
øß7EøbI$‡vÛp1ƒ->Ö嫵²¥GësÁ„.Ñ¥|üÝž;Y+­­³¢;¬ôò
¼BãPjàH`º:

View file

@ -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

View file

@ -0,0 +1 @@
˜á¸w'Úl æNñÓ´Ñ-µÉ¥¼2¥<0E>Évñu¶|,vq£GêDâ1øXϱÒyê§«ëˆÜ….<2E>LA

View file

@ -0,0 +1,3 @@
[2026-05-28 11:26:44] Loaded Identity <d0524d8f1d98fc39f13772655640ea30> from ./DAN.identity
[2026-05-28 11:26:44] Public Key : 14483f044c5ea19c12a2c89ba539ca1ee2cea613bde7eb8d5d700058351d1067b5415b26b0ae8667c2cac4d4cc932f24b48ca727f3c5e42c614750d05e475d80
[2026-05-28 11:26:44] Private Key : Hidden

View file

@ -0,0 +1 @@
àßðš¨L”&޵œ£(_ÁßIär¾¤ÙßÂÐiT°S±OëKØDlàÿ×§ÇÍ àËx¯vœ-å•hþ@¹

View file

@ -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

View file

@ -0,0 +1,2 @@
<EFBFBD>
0<03><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>5<EFBFBD><35><EFBFBD>7X<37>!##<23><>C<EFBFBD>M<EFBFBD> t{%+X<>6T!<21>ͪE <0C>M<EFBFBD>P6<50>[\^<5E>Z<EFBFBD><5A>&<26>

View file

@ -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

View file

@ -0,0 +1 @@
Àv<EFBFBD>p˜³{BQ5<51>ðî ¼°Ø<C2B0>Ýñ\ûƉoe´+o„à¯2ž<Ù­J®y•ñ'­¥äL¦2ð¤

View file

@ -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

View file

@ -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

View file

@ -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}\\"'),
]
)

View file

@ -0,0 +1,166 @@
#include "TBeamSupremeLoRaInterface.h"
#include <Cryptography/Random.h>
#include <Log.h>
#include <string.h>
#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);
}

View file

@ -0,0 +1,42 @@
#pragma once
#include <Bytes.h>
#include <Interface.h>
#include <Arduino.h>
#include <RadioLib.h>
#include <SPI.h>
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;
};

View file

@ -0,0 +1,917 @@
#include "TBeamSupremeLoRaInterface.h"
#include "TBeamDisplay.h"
#include "tbeam_supreme_adapter.h"
#include <Arduino.h>
#include <Destination.h>
#include <Identity.h>
#include <Link.h>
#include <Log.h>
#include <Packet.h>
#include <Reticulum.h>
#include <Transport.h>
#include <Type.h>
#include <Utilities/OS.h>
#include <SD.h>
#include <SPI.h>
#include <Wire.h>
#include <XPowersLib.h>
#include <microStore/Adapters/UniversalFileSystem.h>
#include <microStore/FileSystem.h>
#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;
}