2026-06-03 09:19:33 -07:00
|
|
|
#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
|
|
|
|
|
|
|
|
|
|
#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 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;
|
2026-06-03 17:52:00 -07:00
|
|
|
static constexpr uint32_t LINK_RX_STALE_MS = 12UL * 60UL * 60UL * 1000UL;
|
2026-06-03 09:19:33 -07:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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";
|
2026-06-03 10:00:57 -07:00
|
|
|
case 5: return "Flo";
|
2026-06-03 09:19:33 -07:00
|
|
|
case 6: return "Guy";
|
|
|
|
|
default: return "unknown";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 10:00:57 -07:00
|
|
|
static const char* board_id_for_label(const String& label) {
|
|
|
|
|
if (label == "Amy") {
|
|
|
|
|
return "AMY";
|
|
|
|
|
}
|
|
|
|
|
if (label == "Bob") {
|
|
|
|
|
return "BOB";
|
|
|
|
|
}
|
|
|
|
|
if (label == "Cy") {
|
|
|
|
|
return "CY";
|
|
|
|
|
}
|
|
|
|
|
if (label == "Dan") {
|
|
|
|
|
return "DAN";
|
|
|
|
|
}
|
|
|
|
|
if (label == "Ed") {
|
|
|
|
|
return "ED";
|
|
|
|
|
}
|
|
|
|
|
if (label == "Flo") {
|
|
|
|
|
return "FLO";
|
|
|
|
|
}
|
|
|
|
|
if (label == "Guy") {
|
|
|
|
|
return "GUY";
|
|
|
|
|
}
|
|
|
|
|
return label.c_str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static bool is_local_name(const String& value) {
|
|
|
|
|
return value == NODE_LABEL || value == BOARD_ID;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static String peer_label_from_name(const String& value) {
|
|
|
|
|
if (value == "AMY") {
|
|
|
|
|
return "Amy";
|
|
|
|
|
}
|
|
|
|
|
if (value == "BOB") {
|
|
|
|
|
return "Bob";
|
|
|
|
|
}
|
|
|
|
|
if (value == "CY") {
|
|
|
|
|
return "Cy";
|
|
|
|
|
}
|
|
|
|
|
if (value == "DAN") {
|
|
|
|
|
return "Dan";
|
|
|
|
|
}
|
|
|
|
|
if (value == "ED") {
|
|
|
|
|
return "Ed";
|
|
|
|
|
}
|
|
|
|
|
if (value == "FLO") {
|
|
|
|
|
return "Flo";
|
|
|
|
|
}
|
|
|
|
|
if (value == "GUY") {
|
|
|
|
|
return "Guy";
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 09:19:33 -07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-06-03 10:00:57 -07:00
|
|
|
const int says_pos = text.indexOf(" says ");
|
|
|
|
|
if (says_pos > 0) {
|
|
|
|
|
return text.substring(0, says_pos);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 09:19:33 -07:00
|
|
|
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) {
|
2026-06-03 10:00:57 -07:00
|
|
|
const int says_hi_to_pos = text.indexOf(" says Hi to ");
|
|
|
|
|
if (says_hi_to_pos >= 0) {
|
|
|
|
|
const int start = says_hi_to_pos + 12;
|
|
|
|
|
int end = text.indexOf(' ', start);
|
|
|
|
|
if (end < 0) {
|
|
|
|
|
end = text.length();
|
|
|
|
|
}
|
|
|
|
|
return text.substring(start, end);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 09:19:33 -07:00
|
|
|
const int to_pos = text.indexOf("to=");
|
|
|
|
|
if (to_pos < 0) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
const int start = to_pos + 3;
|
|
|
|
|
int end = text.indexOf(' ', start);
|
|
|
|
|
if (end < 0) {
|
|
|
|
|
end = text.length();
|
|
|
|
|
}
|
|
|
|
|
return text.substring(start, end);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void attach_link_callbacks(RNS::Link& link) {
|
|
|
|
|
link.set_packet_callback(on_link_packet);
|
|
|
|
|
link.set_link_closed_callback(on_link_closed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-03 10:00:57 -07:00
|
|
|
const String sender_label = peer_label_from_name(sender);
|
|
|
|
|
if (is_local_name(sender) || (recipient.length() > 0 && !is_local_name(recipient))) {
|
2026-06-03 09:19:33 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (peer_index >= 0 && peers[peer_index].label.length() == 0 &&
|
2026-06-03 10:00:57 -07:00
|
|
|
sender.length() > 0 && !is_local_name(sender)) {
|
|
|
|
|
const int existing_index = find_peer_by_label(sender_label);
|
2026-06-03 09:19:33 -07:00
|
|
|
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 {
|
2026-06-03 10:00:57 -07:00
|
|
|
peers[peer_index].label = sender_label;
|
2026-06-03 09:19:33 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (peer_index < 0) {
|
2026-06-03 10:00:57 -07:00
|
|
|
if (sender.length() > 0 && !is_local_name(sender)) {
|
|
|
|
|
peer_index = ensure_peer_for_label(sender_label);
|
2026-06-03 09:19:33 -07:00
|
|
|
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());
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
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);
|
2026-06-03 10:00:57 -07:00
|
|
|
Serial.printf("Sim: phy_envelope=%d phy_block_bob_cy=%d node_slot=%d rns_log=warning linkfwd_delay_ms=%u transport=1\r\n",
|
2026-06-03 09:19:33 -07:00
|
|
|
(int)SIM_PHY_ENVELOPE,
|
|
|
|
|
(int)SIM_PHY_BLOCK_BOB_CY,
|
|
|
|
|
(int)NODE_SLOT_INDEX,
|
|
|
|
|
(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)) {
|
|
|
|
|
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");
|
2026-06-03 10:00:57 -07:00
|
|
|
RNS::loglevel(RNS::LOG_WARNING);
|
2026-06-03 09:19:33 -07:00
|
|
|
|
|
|
|
|
(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();
|
2026-06-03 10:00:57 -07:00
|
|
|
if (have_rtc_now && rtc_now.second == send_slot) {
|
2026-06-03 09:19:33 -07:00
|
|
|
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;
|
|
|
|
|
peer.last_tx_ms = now;
|
2026-06-03 10:00:57 -07:00
|
|
|
const char* recipient = board_id_for_label(peer.label);
|
|
|
|
|
String message = String(BOARD_ID) + " says Hi to " + recipient + " iter=" + String(peer.tx_iter++);
|
2026-06-03 09:19:33 -07:00
|
|
|
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;
|
|
|
|
|
}
|