Add GPS beacon and LXMF telemetry for T-Beam Supreme and Heltec V4

Standalone GPS beacon mode: when no KISS host is connected for 15s,
the RNode transmits position and battery telemetry over LoRa.

Two beacon paths:
- LXMF (recommended): encrypted per-packet messages with announces,
  compatible with Sideband and any LXMF application. Supports IFAC
  network authentication.
- Legacy JSON: plaintext or encrypted raw packets for simple collectors.

Key changes:
- GPS support for T-Beam Supreme S3 (L76K) and Heltec V4 (external)
- SX1262 radio fixes: IQ polarity, DCD preamble lockup, RX reliability
- LXMF identity management with NVS-backed Ed25519/X25519 keys
- IFAC authentication (CMD_IFAC_KEY 0x89) for private networks
- Per-channel serial isolation (USB, BLE, WiFi)
- GPS status page in OLED display rotation
- Provisioning via rnlog: provision-lxmf, provision-ifac
- Documentation in Documentation/BEACON.md
This commit is contained in:
GlassOnTin 2026-03-12 17:01:29 +00:00
parent 180207aa2e
commit 033ddd6757
25 changed files with 3251 additions and 308 deletions

3
.gitignore vendored
View file

@ -7,3 +7,6 @@ Release/*.zip
Release/*.json
Console/build
build/*
*.svd
debug.cfg
debug_custom.json

209
Beacon.h Normal file
View file

@ -0,0 +1,209 @@
// Copyright (C) 2026, GPS beacon support contributed by GlassOnTin
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
#ifndef BEACON_H
#define BEACON_H
#if HAS_GPS == true
// Beacon interval and timing
#define BEACON_INTERVAL_MS 30000 // 30 seconds between beacons
#define BEACON_STARTUP_DELAY_MS 10000 // Wait 10s after boot before first beacon
// BEACON_NO_HOST_TIMEOUT_MS and last_host_activity defined in GPS.h
// Beacon radio parameters — must match the router's LoRa interface
#define BEACON_FREQ 868000000
#define BEACON_BW 125000
#define BEACON_SF 7
#define BEACON_CR 5
#define BEACON_TXP 17
// Pre-computed RNS destination hash for PLAIN destination "rnlog.beacon"
// Computed as: SHA256(SHA256("rnlog.beacon")[:10])[:16]
const uint8_t RNS_DEST_HASH[16] = {
0x18, 0xbc, 0xd8, 0xa3, 0xde, 0xa1, 0x6e, 0xf6,
0x76, 0x5c, 0x6b, 0x27, 0xd0, 0x08, 0xd2, 0x20
};
// RNS packet header constants (PLAIN destination, HEADER_1, DATA)
// FLAGS: header_type=0 (HEADER_1), propagation=0 (BROADCAST),
// destination=0 (PLAIN), packet_type=2 (DATA), transport=0
#define RNS_FLAGS 0x08
#define RNS_CONTEXT 0x00
// Beacon state
bool beacon_mode_active = false;
uint32_t last_beacon_tx = 0;
// Forward declarations from main firmware
void lora_receive();
bool startRadio();
void setTXPower();
void setBandwidth();
void setSpreadingFactor();
void setCodingRate();
void beacon_transmit(uint16_t size);
// Diagnostic: track which gate blocks beacon_update()
// 0=not called, 1=host active, 2=startup delay, 3=radio offline,
// 4=no gps fix, 5=interval wait, 6=beacon sent
uint8_t beacon_gate = 0;
void beacon_check_host_activity() {
last_host_activity = millis();
if (beacon_mode_active) {
beacon_mode_active = false;
}
}
void beacon_update() {
// Don't beacon if host has been active recently
if (last_host_activity > 0 &&
(millis() - last_host_activity < BEACON_NO_HOST_TIMEOUT_MS)) {
beacon_gate = 1;
return;
}
// Wait for startup delay after boot
if (millis() < BEACON_STARTUP_DELAY_MS) {
beacon_gate = 2;
return;
}
// No point beaconing without a GPS fix — check BEFORE touching
// radio params to avoid putting the radio into standby needlessly
if (!gps_has_fix) {
beacon_gate = 4;
return;
}
// Radio must be online — restart if needed
if (!radio_online) {
lora_freq = (uint32_t)868000000;
lora_bw = (uint32_t)125000;
lora_sf = 7;
lora_cr = 5;
lora_txp = 17;
if (!startRadio()) {
beacon_gate = 3;
return;
}
} else {
// Radio is online but may have host/EEPROM params — force beacon settings
lora_freq = (uint32_t)868000000;
lora_bw = (uint32_t)125000;
lora_sf = 7;
lora_cr = 5;
lora_txp = 17;
setTXPower();
setBandwidth();
setSpreadingFactor();
setCodingRate();
}
// Respect beacon interval
if (last_beacon_tx > 0 &&
(millis() - last_beacon_tx < BEACON_INTERVAL_MS)) {
beacon_gate = 5;
return;
}
beacon_mode_active = true;
beacon_gate = 6;
// LXMF path: send proper LXMF message with FIELD_TELEMETRY directly to Sideband
if (lxmf_identity_configured && beacon_crypto_configured) {
// Periodic LXMF announce (every 10 minutes)
lxmf_announce_if_needed("RNode GPS Tracker");
// Get Unix timestamp from GPS directly
uint32_t timestamp = (uint32_t)(millis() / 1000);
#if HAS_GPS == true
{
extern TinyGPSPlus gps_parser;
if (gps_parser.date.isValid() && gps_parser.time.isValid() && gps_parser.date.year() >= 2024) {
uint32_t days = 0;
uint16_t yr = gps_parser.date.year();
uint8_t mo = gps_parser.date.month();
for (uint16_t y = 1970; y < yr; y++)
days += (y % 4 == 0) ? 366 : 365;
static const uint16_t mdays[] = {0,31,59,90,120,151,181,212,243,273,304,334};
if (mo >= 1 && mo <= 12) {
days += mdays[mo - 1];
if (mo > 2 && (yr % 4 == 0)) days++;
}
days += gps_parser.date.day() - 1;
timestamp = days * 86400UL + gps_parser.time.hour() * 3600UL
+ gps_parser.time.minute() * 60UL + gps_parser.time.second();
}
}
#endif
lxmf_beacon_send(gps_lat, gps_lon, gps_alt,
gps_speed, gps_hdop,
timestamp, (int)battery_percent);
last_beacon_tx = millis();
return;
}
// Legacy path: JSON payload for rnlog collector (no LXMF identity)
char json_buf[256];
int json_len = snprintf(json_buf, sizeof(json_buf),
"{\"lat\":%.6f,\"lon\":%.6f,\"alt\":%.1f,"
"\"sat\":%d,\"spd\":%.1f,\"hdop\":%.1f,"
"\"bat\":%d,\"fix\":%s}",
gps_lat, gps_lon, gps_alt,
gps_sats, gps_speed, gps_hdop,
(int)battery_percent,
gps_has_fix ? "true" : "false");
if (json_len <= 0 || json_len >= (int)sizeof(json_buf)) return;
if (beacon_crypto_configured) {
// Encrypted SINGLE packet (legacy JSON to rnlog.collector)
tbuf[0] = 0x00; // FLAGS: HEADER_1, BROADCAST, SINGLE, DATA
tbuf[1] = 0x00; // HOPS
memcpy(&tbuf[2], collector_dest_hash, 16);
tbuf[18] = 0x00; // CONTEXT_NONE
int crypto_len = beacon_crypto_encrypt(
(uint8_t*)json_buf, json_len,
collector_pub_key, collector_identity_hash,
&tbuf[19]
);
if (crypto_len > 0 && (19 + crypto_len) <= (int)MTU) {
beacon_transmit(19 + crypto_len);
lora_receive();
last_beacon_tx = millis();
}
} else {
// Fallback: PLAIN beacon (unencrypted, original behavior)
tbuf[0] = RNS_FLAGS; // 0x08 = PLAIN DATA
tbuf[1] = 0x00;
memcpy(&tbuf[2], RNS_DEST_HASH, 16);
tbuf[18] = RNS_CONTEXT;
memcpy(&tbuf[19], json_buf, json_len);
if (19 + json_len <= (int)MTU) {
beacon_transmit(19 + json_len);
lora_receive();
last_beacon_tx = millis();
}
}
}
#endif
#endif

159
BeaconCrypto.h Normal file
View file

@ -0,0 +1,159 @@
// Copyright (C) 2026, GPS beacon encryption contributed by GlassOnTin
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
#ifndef BEACON_CRYPTO_H
#define BEACON_CRYPTO_H
#if HAS_GPS == true
#include "sodium/crypto_scalarmult_curve25519.h"
#include "mbedtls/aes.h"
#include "mbedtls/md.h"
#include "esp_random.h"
// State loaded from EEPROM on boot
bool beacon_crypto_configured = false;
uint8_t collector_pub_key[32];
uint8_t collector_identity_hash[16];
uint8_t collector_dest_hash[16];
// HMAC-SHA256 (single-shot)
static int hmac_sha256(const uint8_t *key, size_t key_len,
const uint8_t *data, size_t data_len,
uint8_t *output) {
const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
return mbedtls_md_hmac(md_info, key, key_len, data, data_len, output);
}
// RFC 5869 HKDF-SHA256 with info=b"", output 64 bytes
//
// Extract: PRK = HMAC-SHA256(key=salt, data=ikm)
// Expand: T1 = HMAC-SHA256(PRK, 0x01) [1 byte input]
// T2 = HMAC-SHA256(PRK, T1 || 0x02) [33 bytes input]
// output = T1 || T2
static int rns_hkdf(const uint8_t *ikm, size_t ikm_len,
const uint8_t *salt, size_t salt_len,
uint8_t *output_64) {
uint8_t prk[32];
int ret = hmac_sha256(salt, salt_len, ikm, ikm_len, prk);
if (ret != 0) return ret;
// T1 = HMAC-SHA256(PRK, 0x01)
uint8_t expand_buf[33];
expand_buf[0] = 0x01;
ret = hmac_sha256(prk, 32, expand_buf, 1, output_64);
if (ret != 0) return ret;
// T2 = HMAC-SHA256(PRK, T1 || 0x02)
memcpy(expand_buf, output_64, 32);
expand_buf[32] = 0x02;
ret = hmac_sha256(prk, 32, expand_buf, 33, output_64 + 32);
return ret;
}
// PKCS7 pad to 16-byte blocks. Returns padded length, or 0 on error.
static size_t pkcs7_pad(const uint8_t *input, size_t input_len,
uint8_t *output, size_t output_size) {
uint8_t pad_val = 16 - (input_len % 16);
size_t padded_len = input_len + pad_val;
if (padded_len > output_size) return 0;
memcpy(output, input, input_len);
memset(output + input_len, pad_val, pad_val);
return padded_len;
}
// Encrypt beacon payload for RNS SINGLE destination.
//
// Output layout: [ephemeral_pub:32][IV:16][ciphertext:var][HMAC:32]
// Returns total output length, or -1 on error.
//
// Crypto pipeline:
// 1. Generate ephemeral X25519 keypair (libsodium)
// 2. ECDH shared secret with collector's public key
// 3. HKDF-SHA256 → signing_key(32) + encryption_key(32)
// 4. AES-256-CBC encrypt PKCS7-padded plaintext
// 5. HMAC-SHA256(signing_key, IV || ciphertext)
static int beacon_crypto_encrypt(const uint8_t *plaintext, size_t pt_len,
const uint8_t *peer_pub,
const uint8_t *identity_hash,
uint8_t *output) {
// 1. Generate ephemeral X25519 keypair
uint8_t eph_priv[32];
esp_fill_random(eph_priv, 32);
// Clamp private key per RFC 7748
eph_priv[0] &= 248;
eph_priv[31] &= 127;
eph_priv[31] |= 64;
// Compute ephemeral public key and write to output
if (crypto_scalarmult_curve25519_base(output, eph_priv) != 0) return -1;
// 2. ECDH shared secret
uint8_t ss_bytes[32];
if (crypto_scalarmult_curve25519(ss_bytes, eph_priv, peer_pub) != 0) return -1;
// 3. HKDF-SHA256: derive signing_key(32) + encryption_key(32)
uint8_t derived[64];
int ret = rns_hkdf(ss_bytes, 32, identity_hash, 16, derived);
if (ret != 0) return -1;
uint8_t *signing_key = derived; // bytes 0-31
uint8_t *encryption_key = derived + 32; // bytes 32-63
// 4. Random IV
uint8_t *iv_pos = output + 32; // after ephemeral pubkey
esp_fill_random(iv_pos, 16);
uint8_t iv_copy[16];
memcpy(iv_copy, iv_pos, 16); // AES-CBC modifies IV in-place
// 5. PKCS7 pad
uint8_t padded[512];
size_t padded_len = pkcs7_pad(plaintext, pt_len, padded, sizeof(padded));
if (padded_len == 0) return -1;
// 6. AES-256-CBC encrypt
uint8_t *ct_pos = output + 32 + 16; // after ephemeral pubkey + IV
mbedtls_aes_context aes;
mbedtls_aes_init(&aes);
ret = mbedtls_aes_setkey_enc(&aes, encryption_key, 256);
if (ret != 0) { mbedtls_aes_free(&aes); return -1; }
ret = mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_ENCRYPT, padded_len,
iv_copy, padded, ct_pos);
mbedtls_aes_free(&aes);
if (ret != 0) return -1;
// 7. HMAC-SHA256(signing_key, IV || ciphertext)
uint8_t *hmac_pos = ct_pos + padded_len;
const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_context_t md_ctx;
mbedtls_md_init(&md_ctx);
ret = mbedtls_md_setup(&md_ctx, md_info, 1); // 1 = use HMAC
if (ret != 0) { mbedtls_md_free(&md_ctx); return -1; }
ret = mbedtls_md_hmac_starts(&md_ctx, signing_key, 32);
if (ret != 0) { mbedtls_md_free(&md_ctx); return -1; }
ret = mbedtls_md_hmac_update(&md_ctx, iv_pos, 16);
if (ret != 0) { mbedtls_md_free(&md_ctx); return -1; }
ret = mbedtls_md_hmac_update(&md_ctx, ct_pos, padded_len);
if (ret != 0) { mbedtls_md_free(&md_ctx); return -1; }
ret = mbedtls_md_hmac_finish(&md_ctx, hmac_pos);
mbedtls_md_free(&md_ctx);
if (ret != 0) return -1;
// Total: ephemeral_pub(32) + IV(16) + ciphertext(padded_len) + HMAC(32)
return 32 + 16 + (int)padded_len + 32;
}
#endif
#endif

View file

@ -110,11 +110,12 @@ char bt_devname[11];
display_unblank();
if(event == ESP_SPP_SRV_OPEN_EVT) {
bt_state = BT_STATE_CONNECTED;
cable_state = CABLE_STATE_DISCONNECTED;
cable_state = CABLE_STATE_CONNECTED;
}
if(event == ESP_SPP_CLOSE_EVT ){
bt_state = BT_STATE_ON;
if (data_channel == CHANNEL_BT) data_channel = CHANNEL_USB;
}
}
@ -306,7 +307,7 @@ char bt_devname[11];
display_unblank();
ble_authenticated = false;
if (bt_state != BT_STATE_PAIRING) { bt_state = BT_STATE_CONNECTED; }
cable_state = CABLE_STATE_DISCONNECTED;
cable_state = CABLE_STATE_CONNECTED;
}
void bt_disconnect_callback(BLEServer *server) {
@ -315,6 +316,7 @@ char bt_devname[11];
display_unblank();
ble_authenticated = false;
bt_state = BT_STATE_ON;
if (data_channel == CHANNEL_BT) data_channel = CHANNEL_USB;
}
bool bt_setup_hw() {
@ -361,7 +363,7 @@ char bt_devname[11];
uint8_t rsp_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK;
esp_ble_auth_req_t auth_req = ESP_LE_AUTH_REQ_SC_MITM_BOND;
uint8_t auth_option = ESP_BLE_ONLY_ACCEPT_SPECIFIED_AUTH_ENABLE;
uint8_t auth_option = ESP_BLE_ONLY_ACCEPT_SPECIFIED_AUTH_DISABLE;
uint8_t oob_support = ESP_BLE_OOB_DISABLE;
esp_ble_io_cap_t iocap = ESP_IO_CAP_OUT;
@ -435,7 +437,7 @@ char bt_devname[11];
if (security.sm == 1 && security.lv >= 3) {
// Serial.println("Auth level success");
bt_state = BT_STATE_CONNECTED;
cable_state = CABLE_STATE_DISCONNECTED;
cable_state = CABLE_STATE_CONNECTED;
connection->disconnect();
bt_disable_pairing();
} else {
@ -462,7 +464,7 @@ char bt_devname[11];
void bt_connect_callback(uint16_t conn_handle) {
// Serial.println("Connect callback");
bt_state = BT_STATE_CONNECTED;
cable_state = CABLE_STATE_DISCONNECTED;
cable_state = CABLE_STATE_CONNECTED;
BLEConnection* conn = Bluefruit.Connection(conn_handle);
conn->requestPHY(BLE_GAP_PHY_2MBPS);
@ -475,6 +477,7 @@ char bt_devname[11];
if (reason != BLE_GAP_SEC_STATUS_SUCCESS) {
bt_state = BT_STATE_ON;
}
if (data_channel == CHANNEL_BT) data_channel = CHANNEL_USB;
}
void bt_update_passkey() {

View file

@ -214,9 +214,15 @@
#define EEPROM_OFFSET EEPROM_SIZE-EEPROM_RESERVED
#define CONFIG_OFFSET 0
#define GPS_BAUD_RATE 9600
#define PIN_GPS_TX 12
#define PIN_GPS_RX 34
#ifndef GPS_BAUD_RATE
#define GPS_BAUD_RATE 9600
#endif
#ifndef PIN_GPS_TX
#define PIN_GPS_TX 12
#endif
#ifndef PIN_GPS_RX
#define PIN_GPS_RX 34
#endif
#if BOARD_MODEL == BOARD_GENERIC_ESP32
#define HAS_BLUETOOTH true
@ -397,6 +403,15 @@
#define HAS_SLEEP true
#define HAS_LORA_PA true
#define HAS_LORA_LNA true
#define HAS_GPS true
#define PIN_GPS_TX 38
#define PIN_GPS_RX 39
#define PIN_GPS_EN 34
#define PIN_GPS_RST 42
#define PIN_GPS_PPS 41
#define PIN_GPS_STANDBY 40
#define GPS_EN_ACTIVE LOW
#define GPS_BAUD_RATE 9600
#define PIN_WAKEUP GPIO_NUM_0
#define WAKEUP_LEVEL 0
#define OCP_TUNED 0x18
@ -632,6 +647,15 @@
#define HAS_INPUT true
#define HAS_SLEEP false
#define HAS_GPS true
#define PIN_GPS_TX 8
#define PIN_GPS_RX 9
#define PIN_GPS_PPS 6
#define PIN_GPS_STANDBY 7
#define GPS_BAUD_RATE 9600
#define HAS_RTC true
#define PMU_IRQ 40
#define I2C_SCL 41
@ -928,4 +952,8 @@
#define NP_M 0.15
#endif
#ifndef HAS_GPS
#define HAS_GPS false
#endif
#endif

View file

@ -164,8 +164,9 @@
// Incoming packet buffer
uint8_t pbuf[MTU];
// KISS command buffer
uint8_t cmdbuf[CMD_L];
// Response routing globals (channel 0 = USB)
volatile uint8_t response_channel = 0;
uint8_t data_channel = 0;
// LoRa transmit buffer
uint8_t tbuf[MTU];

151
Display.h
View file

@ -783,6 +783,23 @@ void draw_stat_area() {
if (radio_online) {
draw_waterfall(27, 4);
}
#if HAS_GPS == true
stat_area.setFont(SMALL_FONT);
stat_area.setTextSize(1);
stat_area.setTextWrap(false);
if (gps_has_fix) {
stat_area.setTextColor(SSD1306_WHITE);
stat_area.fillRect(22, 48, 20, 7, SSD1306_BLACK);
stat_area.setCursor(22, 54);
stat_area.printf("%ds", gps_sats);
} else if (gps_ready) {
stat_area.setTextColor(SSD1306_WHITE);
stat_area.fillRect(22, 48, 20, 7, SSD1306_BLACK);
stat_area.setCursor(22, 54);
stat_area.print("gps");
}
#endif
}
}
@ -810,7 +827,11 @@ void update_stat_area() {
}
#define START_PAGE 0
const uint8_t pages = 3;
#if HAS_GPS == true
const uint8_t pages = 6; // 2 diagnostics + 3 GPS + 1 LXMF status
#else
const uint8_t pages = 3;
#endif
uint8_t disp_page = START_PAGE;
extern char bt_devname[11];
extern char bt_dh[16];
@ -829,6 +850,127 @@ void draw_disp_area() {
} else {
if (!disp_ext_fb or bt_ssp_pin != 0) {
if (radio_online && display_diagnostics) {
if (millis()-last_page_flip >= page_interval) {
disp_page = (++disp_page%pages);
last_page_flip = millis();
}
#if HAS_GPS == true
if (disp_page >= 2 && disp_page <= 4) {
// GPS page (pages 2-4, diagnostics on pages 0-1)
disp_area.fillRect(0,8,disp_area.width(),56, SSD1306_BLACK);
disp_area.setFont(SMALL_FONT); disp_area.setTextWrap(false); disp_area.setTextColor(SSD1306_WHITE); disp_area.setTextSize(1);
#if HAS_RTC == true
if (rtc_ready && rtc_synced) {
disp_area.setCursor(2, 13);
disp_area.printf("%02d:%02d:%02d", rtc_hour, rtc_minute, rtc_second);
disp_area.setCursor(56, 13);
disp_area.printf("%02d/%02d/%02d", rtc_day, rtc_month, rtc_year % 100);
} else {
#endif
disp_area.setCursor(2, 13);
disp_area.print("On");
disp_area.setCursor(14, 13);
disp_area.print("@");
disp_area.setCursor(21, 13);
disp_area.printf("%.1fKbps", (float)lora_bitrate/1000.0);
#if HAS_RTC == true
}
#endif
// Provisioning flash overlay (3 seconds)
if (lxmf_provisioned_at > 0 && (millis() - lxmf_provisioned_at < 3000)) {
disp_area.setCursor(2, 27);
disp_area.print("PROVISIONED");
disp_area.setCursor(2, 39);
disp_area.print("");
disp_area.setCursor(2, 51);
disp_area.print("");
} else {
if (lxmf_provisioned_at > 0 && (millis() - lxmf_provisioned_at >= 3000)) {
lxmf_provisioned_at = 0; // Clear after display
}
if (gps_has_fix) {
disp_area.setCursor(2, 27);
disp_area.printf("%.5f", gps_lat);
disp_area.setCursor(2, 39);
disp_area.printf("%.5f", gps_lon);
disp_area.setCursor(2, 51);
if (beacon_mode_active) {
if (lxmf_identity_configured && beacon_crypto_configured) {
disp_area.printf("%dsat %.0fm LX", gps_sats, gps_alt);
} else {
disp_area.printf("%dsat %.0fm BCN", gps_sats, gps_alt);
}
} else {
disp_area.printf("%dsat %.0fm", gps_sats, gps_alt);
}
} else if (gps_ready) {
disp_area.setCursor(2, 27);
disp_area.print("GPS searching");
disp_area.setCursor(2, 39);
disp_area.printf("%d sats", gps_sats);
#if HAS_RTC == true
if (rtc_ready && rtc_synced) {
disp_area.setCursor(2, 51);
disp_area.printf("%02d:%02d:%02d UTC", rtc_hour, rtc_minute, rtc_second);
}
#endif
} else {
disp_area.setCursor(2, 27);
disp_area.print("GPS starting");
}
}
} else if (disp_page == 5) {
// LXMF status page
disp_area.fillRect(0,8,disp_area.width(),56, SSD1306_BLACK);
disp_area.setFont(SMALL_FONT); disp_area.setTextWrap(false); disp_area.setTextColor(SSD1306_WHITE); disp_area.setTextSize(1);
disp_area.setCursor(2, 13);
if (lxmf_identity_configured) {
disp_area.print("LXMF Identity");
} else {
disp_area.print("LXMF: no identity");
}
disp_area.setCursor(2, 27);
if (lxmf_identity_configured) {
char hash_str[17];
for (int i = 0; i < 8; i++) {
sprintf(&hash_str[i*2], "%02x", lxmf_source_hash[i]);
}
hash_str[16] = '\0';
disp_area.print(hash_str);
}
disp_area.setCursor(2, 39);
if (beacon_crypto_configured) {
char tgt_str[24];
snprintf(tgt_str, sizeof(tgt_str), "Tgt: %02x%02x%02x%02x",
collector_dest_hash[0], collector_dest_hash[1],
collector_dest_hash[2], collector_dest_hash[3]);
disp_area.print(tgt_str);
} else {
disp_area.print("Tgt: none");
}
disp_area.setCursor(2, 51);
if (lxmf_identity_configured) {
uint32_t ann_ago = (lxmf_last_announce > 0) ? (millis() - lxmf_last_announce) / 60000 : 0;
uint32_t bcn_ago = (last_beacon_tx > 0) ? (millis() - last_beacon_tx) / 1000 : 0;
char timing_str[24];
if (lxmf_last_announce > 0) {
snprintf(timing_str, sizeof(timing_str), "Ann:%lum Bcn:%lus", (unsigned long)ann_ago, (unsigned long)bcn_ago);
} else {
snprintf(timing_str, sizeof(timing_str), "Ann:-- Bcn:--");
}
disp_area.print(timing_str);
}
} else {
#endif
// Diagnostics page (original)
disp_area.fillRect(0,8,disp_area.width(),37, SSD1306_BLACK); disp_area.fillRect(0,37,disp_area.width(),27, SSD1306_WHITE);
disp_area.setFont(SMALL_FONT); disp_area.setTextWrap(false); disp_area.setTextColor(SSD1306_WHITE); disp_area.setTextSize(1);
@ -842,7 +984,7 @@ void draw_disp_area() {
//disp_area.setCursor(31, 23-1);
disp_area.setCursor(2, 23-1);
disp_area.print("Airtime:");
disp_area.setCursor(11, 33-1);
if (total_channel_util < 0.099) {
//disp_area.printf("%.1f%%", total_channel_util*100.0);
@ -869,7 +1011,7 @@ void draw_disp_area() {
disp_area.print("Channel");
disp_area.setCursor(38, 46);
disp_area.print("Load:");
disp_area.setCursor(11, 57);
if (total_channel_util < 0.099) {
//disp_area.printf("%.1f%%", airtime*100.0);
@ -889,6 +1031,9 @@ void draw_disp_area() {
disp_area.printf("%.0f%%", longterm_channel_util*100.0);
}
disp_area.drawBitmap(32+2, 50, bm_hg_high, 5, 9, SSD1306_BLACK, SSD1306_WHITE);
#if HAS_GPS == true
}
#endif
} else {
if (device_signatures_ok()) { disp_area.drawBitmap(0, 0, bm_def_lc, disp_area.width(), 23, SSD1306_WHITE, SSD1306_BLACK); }

337
Documentation/BEACON.md Normal file
View file

@ -0,0 +1,337 @@
# GPS Beacon Mode
RNode devices with GPS hardware (e.g. T-Beam Supreme, Heltec V4) can autonomously transmit GPS position beacons over LoRa when no host computer is connected. This is useful for vehicle tracking, field asset monitoring, or any scenario where the RNode operates standalone on battery power.
## Quick start
Minimum steps to get a beacon transmitting and a receiver collecting data:
```
1. Build & flash firmware make firmware-tbeam_supreme && make upload-tbeam_supreme
2. Install rnlog cd rns-collector && pip install .
3. Provision collector key rnlog provision-lxmf --dest <SIDEBAND_HASH> --port /dev/ttyACM0
4. Provision IFAC key rnlog provision-ifac --name helv4net --passphrase 'R3ticulum-priv8-m3sh' --port /dev/ttyACM0
5. Start receiver rnlog serve
6. Disconnect USB & wait RNode enters beacon mode after 15s of no host activity
```
The rest of this document explains each step, the beacon modes, and how to verify things are working.
## How it works
When no KISS host activity is detected for 15 seconds, the RNode enters beacon mode and transmits a GPS position every 30 seconds. When a host reconnects (e.g. laptop running `rnsd`), beaconing stops automatically and normal RNode operation resumes.
## Beacon modes
There are two beacon paths. The firmware selects automatically based on what has been provisioned.
### LXMF beacon (recommended)
When an LXMF identity and collector key are provisioned, the RNode:
1. Sends RNS **announce** packets (so the receiver learns the device's identity)
2. Sends **LXMF messages** with `FIELD_TELEMETRY` containing lat, lon, alt, speed, battery
3. Messages are encrypted per-packet with X25519 ECDH + AES-256-CBC + HMAC
These are proper Reticulum announces and LXMF messages — the receiver can be `rnsd`, Sideband, or any LXMF-aware application.
If IFAC is also provisioned, all packets are tagged with an authentication code before transmission. Receivers with matching `network_name`/`passphrase` accept the packets; others silently drop them.
### Legacy JSON beacon
Without LXMF provisioning, the RNode falls back to sending raw JSON payloads:
```json
{"lat":51.507400,"lon":-0.127800,"alt":15.0,"sat":8,"spd":0.5,"hdop":1.2,"bat":87,"fix":true}
```
- **Plaintext**: Sent to well-known PLAIN destination `rnlog.beacon`. Zero configuration needed, but anyone in range can read it.
- **Encrypted**: When provisioned with `rnlog provision` key, sent as SINGLE packets. Only the matching collector can decrypt.
## Hardware
### Tested boards
| Board | GPS | LoRa | Notes |
|-------|-----|------|-------|
| LilyGO T-Beam Supreme S3 | L76K (UART) | SX1262 | Best option. RTC, large battery connector |
| Heltec LoRa32 V4 | External (UART) | SX1262 | Needs external GPS module |
### Wiring (T-Beam Supreme)
No external wiring needed — GPS and LoRa are integrated. Connect via USB-C for provisioning and flashing.
## Building firmware
### Prerequisites
- Arduino CLI (installed by `make prep-esp32`)
- Python 3 with `pyserial`
- USB cable to the device
### Build and flash
```sh
cd RNode_Firmware
# First time: install Arduino cores and libraries
make prep-esp32
# Build
make firmware-tbeam_supreme
# Flash (device must be connected via USB)
make upload-tbeam_supreme PORT=/dev/ttyACM0
```
Other board targets: `firmware-tbeam`, `firmware-heltec32_v4`, `firmware-lora32_v21`, etc. Run `make` with no arguments to see available targets.
## Provisioning
Provisioning configures the RNode with cryptographic keys over USB. Keys are stored in NVS (ESP32) or EEPROM and persist across power cycles. You only need to do this once per device.
There are three independent provisioning steps. Each is optional but recommended:
### 1. LXMF identity + collector key
This gives the RNode an LXMF identity (Ed25519 keypair) and tells it where to send telemetry.
```sh
# Install rnlog if not already done
cd rns-collector && pip install .
# Provision (requires rnsd running with a path to the Sideband destination)
rnlog provision-lxmf --dest <SIDEBAND_DEST_HASH> --port /dev/ttyACM0
```
What this does:
- Resolves the Sideband destination hash via Reticulum to get its public key
- Sends the key material to the RNode via KISS `CMD_BCN_KEY` (0x86)
- The RNode generates an Ed25519 identity (stored in NVS) and prints its source hash
- Reads back the RNode's LXMF source hash via `CMD_LXMF_HASH` (0x87)
After provisioning, the OLED display shows an "LXMF Identity" page (page 5) with the source hash and target info.
### 2. IFAC authentication
If your receiver's RNodeInterface uses `network_name` and `passphrase` (IFAC), the beacon must send matching authentication tags or packets will be silently dropped.
```sh
rnlog provision-ifac \
--name helv4net \
--passphrase 'R3ticulum-priv8-m3sh' \
--port /dev/ttyACM0
```
The `--name` and `--passphrase` must match the receiver's Reticulum config:
```ini
# Receiver's ~/.reticulum/config
[[LoRa 868 MHz]]
type = RNodeInterface
network_name = helv4net
passphrase = R3ticulum-priv8-m3sh
...
```
What this does:
- Derives a 64-byte IFAC key: `HKDF-SHA256(SHA256(network_name) + SHA256(passphrase), IFAC_SALT, 64)`
- Sends to RNode via KISS `CMD_IFAC_KEY` (0x89)
- The RNode derives an Ed25519 signing keypair from the key and applies IFAC tags to all transmitted packets
### 3. Legacy collector key (alternative to LXMF)
If you don't need LXMF and just want encrypted JSON beacons:
```sh
rnlog provision
# Copy the 64-byte hex output, then send via CMD_BCN_KEY — see the Python
# snippet in the rns-collector README.
```
## Receiver setup
The receiver is any machine running `rnsd` with an RNode interface on the same LoRa parameters.
### Reticulum config
```ini
# ~/.reticulum/config
[reticulum]
enable_transport = yes
[interfaces]
[[LoRa 868 MHz]]
type = RNodeInterface
interface_enabled = True
port = /dev/ttyACM0
frequency = 868000000
bandwidth = 125000
spreadingfactor = 7
txpower = 14
# Optional but recommended — must match beacon provisioning:
network_name = helv4net
passphrase = R3ticulum-priv8-m3sh
```
### Start the receiver
```sh
# Start rnsd (if not already running as a service)
rnsd
# In another terminal, start the telemetry collector
rnlog serve
```
Or to also relay GPS data to a Sideband app:
```sh
rnlog serve --sideband-dest <SIDEBAND_DEST_HASH>
```
### Query collected data
```sh
# Recent readings
rnlog query -n 10
# Last hour, as JSON
rnlog -j query -s 1h
# Database summary
rnlog summary
# Export to CSV
rnlog export -f csv > telemetry.csv
```
## Testing and verification
### USB test (device connected)
Trigger a test beacon over USB without waiting for the 30s interval:
```sh
rnlog test-lxmf --port /dev/ttyACM0
```
This sends `CMD_LXMF_TEST` (0x88) to the RNode, which immediately transmits an announce + LXMF beacon and emits the pre-encryption plaintext as `CMD_DIAG` frames back over USB. The test tool validates:
- Announce packet structure and Ed25519 signature
- LXMF message structure and Fernet decryption
- FIELD_TELEMETRY parsing (lat, lon, alt, speed, battery)
### Over-the-air verification
To verify the receiver is accepting IFAC-authenticated packets:
```python
import RNS
reticulum = RNS.Reticulum()
target = bytes.fromhex("YOUR_RNODE_DEST_HASH") # from provision-lxmf output
# Check if the receiver has seen the announce
identity = RNS.Identity.recall(target)
if identity:
print(f"Identity known: {identity}")
else:
print("Not yet received — trigger a test beacon or wait for next announce")
```
### Checking the OLED display
The T-Beam Supreme has 6 display pages (cycle with the button):
| Page | Content |
|------|---------|
| 0-1 | Radio diagnostics (standard RNode) |
| 2-4 | GPS (coordinates, satellites, altitude) |
| 5 | LXMF status (identity hash, target, announce/beacon timing) |
When LXMF is active, page 2 shows `LX` instead of `BCN` next to the satellite count.
## Radio parameters
Beacons use fixed LoRa parameters that must match the receiver's RNode interface:
| Parameter | Value |
|------------------|---------|
| Frequency | 868 MHz |
| Bandwidth | 125 kHz |
| Spreading Factor | 7 |
| Coding Rate | 4/5 |
| TX Power | 17 dBm |
These are set in `Beacon.h`. If your receiver uses different radio parameters, update the `BEACON_*` defines and rebuild.
## Timing
| Parameter | Default | Define |
|--------------------------|---------|-----------------------------|
| Beacon interval | 30s | `BEACON_INTERVAL_MS` |
| Announce interval | 10min | `LXMF_ANNOUNCE_INTERVAL_MS` |
| Startup delay | 10s | `BEACON_STARTUP_DELAY_MS` |
| Host inactivity timeout | 15s | `BEACON_NO_HOST_TIMEOUT_MS` |
## Packet sizes
| Mode | Header | Payload | Total |
|---------------------|--------|----------------------|--------|
| Plaintext JSON | 19B | ~93B JSON | ~112B |
| Encrypted JSON | 19B | 32+16+96+32 = 176B | ~195B |
| LXMF announce | 2B | 183B (+ 8B IFAC tag) | ~193B |
| LXMF beacon | 2B | ~250B encrypted | ~260B |
All fit within the RNS MTU of 508 bytes.
## Troubleshooting
### Receiver doesn't see any packets
1. **Radio mismatch**: Verify frequency, bandwidth, and spreading factor match between beacon and receiver
2. **IFAC mismatch**: If the receiver has `network_name`/`passphrase` set, the beacon must be provisioned with matching IFAC key. Packets without valid IFAC are silently dropped — no error is logged
3. **Range**: LoRa SF7 at 868 MHz has limited range in urban environments. Try line-of-sight first
### `rnlog provision-lxmf` fails to resolve destination
The Sideband destination must be reachable via Reticulum. Ensure `rnsd` is running and has a path to the destination (either direct or via transport nodes).
### `has_path()` returns False but identity is recalled
This is expected. `has_path()` checks the RNS path table (populated by Transport routing), while `Identity.recall()` checks `known_destinations` (populated when any announce is validated). For local interfaces, the path table entry may not be created, but the identity is still usable.
### Device shows "GPS searching" indefinitely
The T-Beam Supreme GPS needs clear sky view for first fix. Cold start takes 30-60 seconds outdoors, longer indoors. The RTC retains time across reboots once synced, speeding subsequent fixes.
## Architecture
```
┌─────────────────────────┐ LoRa 868 MHz ┌────────────────────────┐
│ T-Beam Supreme │ ──────────────────────► │ Receiver │
│ │ IFAC-tagged packets │ (rnsd + RNode) │
│ GPS ──► Beacon.h │ │ │
│ ├─ announce │ Announce (185B + 8B IFAC) │ rnsd validates IFAC │
│ └─ LXMF msg │ LXMF (250B + 8B IFAC) │ ├─ known_destinations │
│ │ │ └─ LXMF delivery │
│ Provisioned via USB: │ │ │
│ - Collector key (64B) │ │ rnlog serve │
│ - IFAC key (64B) │ │ ├─ SQLite database │
│ - LXMF identity (NVS) │ │ └─ Sideband relay │
└─────────────────────────┘ └────────────────────────┘
```
## Files
| File | Purpose |
|------------------|-------------------------------------------------------|
| `Beacon.h` | Beacon state machine, LXMF/JSON path selection |
| `BeaconCrypto.h` | X25519 ECDH (libsodium), HKDF, AES-256-CBC, HMAC |
| `LxmfBeacon.h` | LXMF identity, announce construction, beacon messages |
| `IfacAuth.h` | IFAC key storage (NVS), Ed25519 signing, tag masking |
| `GPS.h` | GPS parsing (TinyGPS++) |
| `ROM.h` | EEPROM addresses for beacon key storage |
| `Framing.h` | KISS command definitions (CMD_BCN_KEY, CMD_IFAC_KEY, etc.) |
| `Display.h` | OLED display pages including LXMF status |

View file

@ -47,6 +47,9 @@
#define CMD_STAT_BAT 0x27
#define CMD_STAT_CSMA 0x28
#define CMD_STAT_TEMP 0x29
#define CMD_STAT_GPS 0x2A
#define CMD_GPS_NMEA 0x2B
#define CMD_DIAG 0x2C
#define CMD_BLINK 0x30
#define CMD_RANDOM 0x40
@ -71,6 +74,11 @@
#define CMD_WIFI_CHN 0x6E
#define CMD_WIFI_IP 0x84
#define CMD_WIFI_NM 0x85
#define CMD_BCN_KEY 0x86
#define CMD_LXMF_HASH 0x87
#define CMD_LXMF_TEST 0x88
#define CMD_IFAC_KEY 0x89
#define CMD_TRANSPORT_ID 0x8A
#define CMD_BOARD 0x47
#define CMD_PLATFORM 0x48
@ -115,10 +123,31 @@
#define ERROR_MEMORY_LOW 0x05
#define ERROR_MODEM_TIMEOUT 0x06
// Serial framing variables
size_t frame_len;
bool IN_FRAME = false;
bool ESCAPE = false;
uint8_t command = CMD_UNKNOWN;
// Channel constants
#define CHANNEL_USB 0
#define CHANNEL_BT 1
#define CHANNEL_WIFI 2
// Compile-time channel count per platform
#if HAS_WIFI == true
#define NUM_CHANNELS 3
#elif HAS_BLUETOOTH == true || HAS_BLE == true
#define NUM_CHANNELS 2
#else
#define NUM_CHANNELS 1
#endif
// Per-channel parser state
typedef struct {
bool in_frame;
bool escape;
uint8_t command;
size_t frame_len;
uint8_t cmdbuf[CMD_L];
#if NUM_CHANNELS > 1
uint8_t pktbuf[MTU];
uint16_t pkt_len;
#endif
} ChannelState;
#endif

161
GPS.h Normal file
View file

@ -0,0 +1,161 @@
// Copyright (C) 2024, Mark Qvist
// Copyright (C) 2026, GPS support contributed by GlassOnTin
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
#ifndef GPS_H
#define GPS_H
#if HAS_GPS == true
#include <TinyGPSPlus.h>
#include <HardwareSerial.h>
TinyGPSPlus gps_parser;
HardwareSerial gps_serial(1);
bool gps_ready = false;
bool gps_has_fix = false;
uint8_t gps_sats = 0;
double gps_lat = 0.0;
double gps_lon = 0.0;
double gps_alt = 0.0;
double gps_speed = 0.0;
double gps_hdop = 0.0;
uint32_t gps_last_update = 0;
uint32_t gps_last_report = 0;
#define GPS_REPORT_INTERVAL_MS 30000 // Report GPS stats to host every 30s
void kiss_indicate_stat_gps();
// Host activity tracking — shared with Beacon.h
// GPS telemetry and beacon mode both suppress when host is active.
#define BEACON_NO_HOST_TIMEOUT_MS 15000
uint32_t last_host_activity = 0;
void gps_power_on() {
#if defined(PIN_GPS_EN)
pinMode(PIN_GPS_EN, OUTPUT);
#if GPS_EN_ACTIVE == LOW
digitalWrite(PIN_GPS_EN, LOW);
#else
digitalWrite(PIN_GPS_EN, HIGH);
#endif
#endif
#if defined(PIN_GPS_RST)
// Keep reset HIGH (inactive) to preserve backup RAM (ephemeris/almanac).
// This allows warm/hot starts with much faster time-to-fix.
pinMode(PIN_GPS_RST, OUTPUT);
digitalWrite(PIN_GPS_RST, HIGH);
#endif
#if defined(PIN_GPS_STANDBY)
pinMode(PIN_GPS_STANDBY, OUTPUT);
digitalWrite(PIN_GPS_STANDBY, HIGH);
#endif
}
void gps_power_off() {
#if defined(PIN_GPS_EN)
#if GPS_EN_ACTIVE == LOW
digitalWrite(PIN_GPS_EN, HIGH);
#else
digitalWrite(PIN_GPS_EN, LOW);
#endif
#endif
}
void gps_setup() {
gps_power_on();
delay(1000); // Allow L76K time to boot after reset
// PIN_GPS_TX/RX named from ESP32 perspective:
// PIN_GPS_RX = ESP32 receives FROM GPS module
// PIN_GPS_TX = ESP32 transmits TO GPS module
gps_serial.begin(GPS_BAUD_RATE, SERIAL_8N1, PIN_GPS_RX, PIN_GPS_TX);
delay(250);
// L76K init: force internal antenna (ceramic patch)
gps_serial.print("$PCAS15,0*19\r\n");
delay(250);
// Hot start — use cached ephemeris/almanac if available in L76K backup RAM
gps_serial.print("$PCAS10,0*1C\r\n");
delay(500);
// Enable GPS+GLONASS+BeiDou
gps_serial.print("$PCAS04,7*1E\r\n");
delay(250);
// Output GGA, GSA, GSV, and RMC
gps_serial.print("$PCAS03,1,0,1,1,1,0,0,0,0,0,,,0,0*02\r\n");
delay(250);
// Set navigation mode to Portable (general purpose, works stationary and moving)
gps_serial.print("$PCAS11,0*1D\r\n");
delay(250);
gps_ready = true;
}
void gps_update() {
if (!gps_ready) return;
while (gps_serial.available() > 0) {
gps_parser.encode(gps_serial.read());
}
if (gps_parser.location.isUpdated()) {
gps_has_fix = gps_parser.location.isValid();
if (gps_has_fix) {
gps_lat = gps_parser.location.lat();
gps_lon = gps_parser.location.lng();
gps_last_update = millis();
}
}
if (gps_parser.altitude.isUpdated() && gps_parser.altitude.isValid()) {
gps_alt = gps_parser.altitude.meters();
}
if (gps_parser.speed.isUpdated() && gps_parser.speed.isValid()) {
gps_speed = gps_parser.speed.kmph();
}
if (gps_parser.satellites.isUpdated()) {
gps_sats = gps_parser.satellites.value();
}
if (gps_parser.hdop.isUpdated()) {
gps_hdop = gps_parser.hdop.hdop();
}
// Mark fix as stale after 10 seconds without update
if (gps_has_fix && (millis() - gps_last_update > 10000)) {
gps_has_fix = false;
}
// Periodically report GPS stats to host (like battery/temp in Power.h).
// rnsd parses CMD_STAT_GPS frames and exposes them in interface stats.
if (millis() - gps_last_report >= GPS_REPORT_INTERVAL_MS) {
response_channel = data_channel;
kiss_indicate_stat_gps();
gps_last_report = millis();
}
}
void gps_teardown() {
gps_serial.end();
gps_power_off();
gps_ready = false;
}
#endif
#endif

186
IfacAuth.h Normal file
View file

@ -0,0 +1,186 @@
// Copyright (C) 2026, IFAC authentication contributed by GlassOnTin
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
#ifndef IFAC_AUTH_H
#define IFAC_AUTH_H
#if HAS_GPS == true
#include "sodium/crypto_sign_ed25519.h"
#include "nvs_flash.h"
#include "nvs.h"
// NVS namespace for IFAC key storage
#define IFAC_NVS_NAMESPACE "ifac"
#define IFAC_NVS_KEY "ifac_key"
#define IFAC_SIZE 8
// IFAC state
bool ifac_configured = false;
uint8_t ifac_key[64];
uint8_t ifac_ed25519_pk[32];
uint8_t ifac_ed25519_sk[64]; // libsodium format: seed(32) + pk(32)
// ---- NVS Storage ----
static bool ifac_nvs_load() {
nvs_handle_t handle;
if (nvs_open(IFAC_NVS_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) return false;
size_t key_len = 64;
bool ok = (nvs_get_blob(handle, IFAC_NVS_KEY, ifac_key, &key_len) == ESP_OK)
&& (key_len == 64);
nvs_close(handle);
return ok;
}
static bool ifac_nvs_save() {
nvs_handle_t handle;
if (nvs_open(IFAC_NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) return false;
bool ok = (nvs_set_blob(handle, IFAC_NVS_KEY, ifac_key, 64) == ESP_OK)
&& (nvs_commit(handle) == ESP_OK);
nvs_close(handle);
return ok;
}
// ---- Ed25519 Keypair Derivation ----
// The signing seed is ifac_key[32:64] (last 32 bytes).
static void ifac_derive_keypair() {
crypto_sign_ed25519_seed_keypair(ifac_ed25519_pk, ifac_ed25519_sk,
ifac_key + 32);
}
// ---- Variable-Length HKDF-SHA256 ----
// Matches RNS Cryptography.hkdf() with context=b"".
// Uses hmac_sha256() from BeaconCrypto.h (must be included first).
//
// Extract: PRK = HMAC-SHA256(salt, ikm)
// Expand: T(i) = HMAC-SHA256(PRK, T(i-1) || counter_byte)
// counter_byte = (i+1) % 256, starting from i=0
static int rns_hkdf_var(const uint8_t *ikm, size_t ikm_len,
const uint8_t *salt, size_t salt_len,
uint8_t *output, size_t output_len) {
uint8_t prk[32];
int ret = hmac_sha256(salt, salt_len, ikm, ikm_len, prk);
if (ret != 0) return ret;
uint8_t prev_block[32];
size_t prev_len = 0;
size_t written = 0;
uint8_t expand_buf[32 + 1]; // max: prev_block(32) + counter(1)
int block_idx = 0;
while (written < output_len) {
// Build input: T(i-1) || counter
if (prev_len > 0) {
memcpy(expand_buf, prev_block, prev_len);
}
expand_buf[prev_len] = (uint8_t)((block_idx + 1) % 256);
uint8_t block[32];
ret = hmac_sha256(prk, 32, expand_buf, prev_len + 1, block);
if (ret != 0) return ret;
size_t to_copy = output_len - written;
if (to_copy > 32) to_copy = 32;
memcpy(output + written, block, to_copy);
written += to_copy;
memcpy(prev_block, block, 32);
prev_len = 32;
block_idx++;
}
return 0;
}
// ---- IFAC Initialization ----
// Call from setup() after lxmf_init_identity().
static void ifac_init() {
if (ifac_nvs_load()) {
ifac_derive_keypair();
ifac_configured = true;
}
}
// ---- Apply IFAC to Outgoing Packet ----
// Modifies pkt in-place. Returns new size (size + IFAC_SIZE) on success,
// or original size if IFAC is not configured.
//
// Algorithm (from RNS Transport.transmit):
// 1. sig = Ed25519Sign(pkt, sk) → 64 bytes
// 2. ifac = sig[56:64] → last 8 bytes
// 3. mask = HKDF(ikm=ifac, salt=ifac_key, len=size+8)
// 4. Assemble: new_header(2) + ifac(8) + payload(size-2)
// 5. Set IFAC flag: byte[0] |= 0x80
// 6. XOR mask:
// - byte 0: masked, but preserve 0x80 flag
// - byte 1: masked
// - bytes 2..9: NOT masked (IFAC itself)
// - bytes 10+: masked
static uint16_t ifac_apply(uint8_t *pkt, uint16_t size) {
if (!ifac_configured || size < 2) return size;
uint16_t new_size = size + IFAC_SIZE;
// 1. Sign the original packet
uint8_t signature[64];
unsigned long long sig_len_unused;
crypto_sign_ed25519_detached(signature, &sig_len_unused,
pkt, size, ifac_ed25519_sk);
// 2. Extract IFAC: last 8 bytes of signature
uint8_t ifac[IFAC_SIZE];
memcpy(ifac, signature + 64 - IFAC_SIZE, IFAC_SIZE);
// 3. Generate mask
uint8_t mask[MTU + IFAC_SIZE];
rns_hkdf_var(ifac, IFAC_SIZE, ifac_key, 64, mask, new_size);
// 4. Shift payload to make room for IFAC after header
// pkt layout before: [hdr0][hdr1][payload...]
// pkt layout after: [hdr0|0x80][hdr1][ifac:8][payload...]
memmove(pkt + 2 + IFAC_SIZE, pkt + 2, size - 2);
// 5. Insert IFAC
memcpy(pkt + 2, ifac, IFAC_SIZE);
// 6. Set IFAC flag
pkt[0] |= 0x80;
// 7. Apply mask
// byte 0: XOR then force 0x80
pkt[0] = (pkt[0] ^ mask[0]) | 0x80;
// byte 1: XOR
pkt[1] ^= mask[1];
// bytes 2..9 (IFAC): NOT masked
// bytes 10+: XOR
for (uint16_t i = IFAC_SIZE + 2; i < new_size; i++) {
pkt[i] ^= mask[i];
}
return new_size;
}
#endif // HAS_GPS
#endif // IFAC_AUTH_H

805
LxmfBeacon.h Normal file
View file

@ -0,0 +1,805 @@
// Copyright (C) 2026, LXMF beacon support contributed by GlassOnTin
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
#ifndef LXMF_BEACON_H
#define LXMF_BEACON_H
#if HAS_GPS == true
#include "sodium/crypto_sign_ed25519.h"
#include "sodium/crypto_scalarmult_curve25519.h"
#include "mbedtls/sha256.h"
#include "esp_random.h"
// NVS namespace for LXMF identity storage
#define LXMF_NVS_NAMESPACE "lxmf"
#define LXMF_NVS_KEY_SEED "ed_seed"
#define LXMF_NVS_KEY_EDPUB "ed_pub"
#define LXMF_NVS_KEY_TXID "transport_id"
// LXMF identity state
bool lxmf_identity_configured = false;
uint8_t lxmf_ed25519_seed[32]; // Ed25519 private seed
uint8_t lxmf_ed25519_pk[32]; // Ed25519 public key
uint8_t lxmf_ed25519_sk[64]; // Ed25519 expanded secret key (seed+pk)
uint8_t lxmf_x25519_pk[32]; // X25519 public key (derived from Ed25519)
uint8_t lxmf_x25519_sk[32]; // X25519 private key (derived from Ed25519)
uint8_t lxmf_identity_hash[16]; // SHA256(x25519_pk + ed25519_pk)[:16]
uint8_t lxmf_source_hash[16]; // SHA256(name_hash("lxmf","delivery") + identity_hash)[:16]
// Transport node identity for HEADER_2 routing
bool transport_configured = false;
uint8_t transport_id[16]; // Transport node's identity hash (16B)
// Announce timing
#define LXMF_ANNOUNCE_INTERVAL_MS 600000 // 10 minutes
uint32_t lxmf_last_announce = 0;
// Provisioning display feedback
uint32_t lxmf_provisioned_at = 0; // millis() when last CMD_BCN_KEY received
// Forward declarations
void beacon_transmit(uint16_t size);
void lora_receive();
// ---- SHA-256 helpers ----
static void sha256_once(const uint8_t *data, size_t len, uint8_t *out32) {
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0);
mbedtls_sha256_update(&ctx, data, len);
mbedtls_sha256_finish(&ctx, out32);
mbedtls_sha256_free(&ctx);
}
static void sha256_two(const uint8_t *a, size_t a_len,
const uint8_t *b, size_t b_len,
uint8_t *out32) {
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0);
mbedtls_sha256_update(&ctx, a, a_len);
mbedtls_sha256_update(&ctx, b, b_len);
mbedtls_sha256_finish(&ctx, out32);
mbedtls_sha256_free(&ctx);
}
// ---- RNS Identity Hash Computation ----
// identity_hash = SHA256(x25519_pub(32) + ed25519_pub(32))[:16]
static void compute_identity_hash(const uint8_t *x25519_pub, const uint8_t *ed25519_pub,
uint8_t *out16) {
uint8_t full[32];
sha256_two(x25519_pub, 32, ed25519_pub, 32, full);
memcpy(out16, full, 16);
}
// ---- RNS Destination Hash Computation ----
// dest_hash = SHA256(name_hash + identity_hash)[:16]
// where name_hash = SHA256(SHA256("lxmf") + SHA256("delivery"))[:10]
static void compute_name_hash(const char *app, const char *aspect, uint8_t *out10) {
// RNS: name_hash = SHA256("app.aspect")[:10]
char full_name[64];
snprintf(full_name, sizeof(full_name), "%s.%s", app, aspect);
uint8_t hash[32];
sha256_once((const uint8_t*)full_name, strlen(full_name), hash);
memcpy(out10, hash, 10);
}
static void compute_dest_hash(const uint8_t *name_hash10, const uint8_t *identity_hash16,
uint8_t *out16) {
uint8_t full[32];
sha256_two(name_hash10, 10, identity_hash16, 16, full);
memcpy(out16, full, 16);
}
// ---- NVS Identity Storage ----
#include "nvs_flash.h"
#include "nvs.h"
static bool lxmf_nvs_load_identity() {
nvs_handle_t handle;
if (nvs_open(LXMF_NVS_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) return false;
size_t seed_len = 32, pub_len = 32;
bool ok = (nvs_get_blob(handle, LXMF_NVS_KEY_SEED, lxmf_ed25519_seed, &seed_len) == ESP_OK)
&& (nvs_get_blob(handle, LXMF_NVS_KEY_EDPUB, lxmf_ed25519_pk, &pub_len) == ESP_OK)
&& (seed_len == 32) && (pub_len == 32);
nvs_close(handle);
return ok;
}
static bool lxmf_nvs_save_identity() {
nvs_handle_t handle;
if (nvs_open(LXMF_NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) return false;
bool ok = (nvs_set_blob(handle, LXMF_NVS_KEY_SEED, lxmf_ed25519_seed, 32) == ESP_OK)
&& (nvs_set_blob(handle, LXMF_NVS_KEY_EDPUB, lxmf_ed25519_pk, 32) == ESP_OK)
&& (nvs_commit(handle) == ESP_OK);
nvs_close(handle);
return ok;
}
static bool lxmf_nvs_load_transport_id() {
nvs_handle_t handle;
if (nvs_open(LXMF_NVS_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) return false;
size_t len = 16;
bool ok = (nvs_get_blob(handle, LXMF_NVS_KEY_TXID, transport_id, &len) == ESP_OK) && (len == 16);
nvs_close(handle);
return ok;
}
static bool lxmf_nvs_save_transport_id() {
nvs_handle_t handle;
if (nvs_open(LXMF_NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) return false;
bool ok = (nvs_set_blob(handle, LXMF_NVS_KEY_TXID, transport_id, 16) == ESP_OK)
&& (nvs_commit(handle) == ESP_OK);
nvs_close(handle);
return ok;
}
// ---- Identity Initialization ----
// Call from setup(). Loads or generates Ed25519 keypair, derives X25519 keys,
// computes identity_hash and source_hash (LXMF delivery destination).
static void lxmf_init_identity() {
bool loaded = lxmf_nvs_load_identity();
if (!loaded) {
// Generate new Ed25519 keypair
crypto_sign_ed25519_keypair(lxmf_ed25519_pk, lxmf_ed25519_sk);
// Extract seed from sk (first 32 bytes of libsodium's 64-byte sk)
memcpy(lxmf_ed25519_seed, lxmf_ed25519_sk, 32);
lxmf_nvs_save_identity();
} else {
// Validate NVS: regenerate pk from seed and compare
uint8_t verify_pk[32], verify_sk[64];
crypto_sign_ed25519_seed_keypair(verify_pk, verify_sk, lxmf_ed25519_seed);
if (memcmp(verify_pk, lxmf_ed25519_pk, 32) != 0) {
// NVS corruption — regenerate and save
memcpy(lxmf_ed25519_pk, verify_pk, 32);
memcpy(lxmf_ed25519_sk, verify_sk, 64);
lxmf_nvs_save_identity();
} else {
// Reconstruct expanded secret key from seed
// libsodium ed25519 sk = seed(32) + pk(32)
memcpy(lxmf_ed25519_sk, lxmf_ed25519_seed, 32);
memcpy(lxmf_ed25519_sk + 32, lxmf_ed25519_pk, 32);
}
}
// Derive X25519 keys from Ed25519
crypto_sign_ed25519_pk_to_curve25519(lxmf_x25519_pk, lxmf_ed25519_pk);
crypto_sign_ed25519_sk_to_curve25519(lxmf_x25519_sk, lxmf_ed25519_sk);
// Compute identity hash: SHA256(x25519_pub + ed25519_pub)[:16]
compute_identity_hash(lxmf_x25519_pk, lxmf_ed25519_pk, lxmf_identity_hash);
// Compute source hash (LXMF delivery destination hash)
uint8_t name_hash[10];
compute_name_hash("lxmf", "delivery", name_hash);
compute_dest_hash(name_hash, lxmf_identity_hash, lxmf_source_hash);
lxmf_identity_configured = true;
// Load transport node identity (for HEADER_2 routing)
transport_configured = lxmf_nvs_load_transport_id();
}
// ---- Minimal Msgpack Encoder ----
// Fixed-schema packer: no dynamic allocation, writes directly to output buffer.
struct MsgpackWriter {
uint8_t *buf;
size_t pos;
size_t cap;
bool ok() const { return pos <= cap; }
void write_byte(uint8_t b) {
if (pos < cap) buf[pos] = b;
pos++;
}
void write_bytes(const uint8_t *data, size_t len) {
for (size_t i = 0; i < len; i++) write_byte(data[i]);
}
// msgpack fixint (0-127)
void pack_uint7(uint8_t v) { write_byte(v & 0x7f); }
// msgpack uint8
void pack_uint8(uint8_t v) { write_byte(0xcc); write_byte(v); }
// msgpack uint16
void pack_uint16(uint16_t v) {
write_byte(0xcd);
write_byte((v >> 8) & 0xff);
write_byte(v & 0xff);
}
// msgpack uint32
void pack_uint32(uint32_t v) {
write_byte(0xce);
write_byte((v >> 24) & 0xff);
write_byte((v >> 16) & 0xff);
write_byte((v >> 8) & 0xff);
write_byte(v & 0xff);
}
// msgpack float64
void pack_float64(double v) {
write_byte(0xcb);
union { double d; uint8_t b[8]; } u;
u.d = v;
// IEEE 754 big-endian
for (int i = 7; i >= 0; i--) write_byte(u.b[i]);
}
// msgpack bin8 (up to 255 bytes)
void pack_bin8(const uint8_t *data, uint8_t len) {
write_byte(0xc4);
write_byte(len);
if (len > 0 && data != NULL) write_bytes(data, len);
}
// msgpack fixstr (up to 31 bytes)
void pack_fixstr(const char *s, uint8_t len) {
write_byte(0xa0 | (len & 0x1f));
write_bytes((const uint8_t*)s, len);
}
// msgpack empty string
void pack_empty_str() { write_byte(0xa0); }
// msgpack fixarray header (up to 15 elements)
void pack_fixarray(uint8_t n) { write_byte(0x90 | (n & 0x0f)); }
// msgpack fixmap header (up to 15 entries)
void pack_fixmap(uint8_t n) { write_byte(0x80 | (n & 0x0f)); }
// msgpack false
void pack_false() { write_byte(0xc2); }
// msgpack nil
void pack_nil() { write_byte(0xc0); }
};
// ---- Sideband Telemetry Packing ----
// Produces Sideband-compatible FIELD_TELEMETRY bytes (Telemeter.packed() format).
// Format: msgpack map { SID_TIME(0x01): unix_ts, SID_LOCATION(0x02): [...], SID_BATTERY(0x04): [...] }
//
// Size budget: LoRa max payload = 255 bytes.
// With HEADER_2(35) + IFAC(8) + crypto overhead(80) + PKCS7 padding,
// LXMF plaintext must be ≤ 127 bytes (pads to 128).
// Fixed: source_hash(16) + signature(64) + payload wrapper(~14) = 94.
// Max telemetry = 127 - 94 = 33 bytes.
//
// Sideband Location.unpack() builds a dict from ALL 7 array elements at once.
// Short arrays cause IndexError → unpack returns None → no location displayed.
// We send only 3 elements (lat, lon, alt) to fit the budget, and patch
// Sideband's Location.unpack() on the phone to handle short arrays.
//
// Actual: SID_TIME(6) + SID_LOCATION(22) + SID_BATTERY(5) + map hdr(1) = 34 max.
// Without battery: 1 + 6 + 22 = 29 bytes.
static size_t lxmf_pack_telemetry(uint8_t *out, size_t out_cap,
double lat, double lon, double alt,
double speed, double hdop,
uint32_t timestamp, int bat_percent) {
MsgpackWriter w = { out, 0, out_cap };
bool has_bat = (bat_percent > 0);
w.pack_fixmap(has_bat ? 3 : 2); // SID_TIME + SID_LOCATION [+ SID_BATTERY]
// SID_TIME = 0x01 → uint32 Unix timestamp (REQUIRED by Sideband)
w.pack_uint7(0x01);
w.pack_uint32(timestamp);
// SID_LOCATION = 0x02 → fixarray[3] [lat, lon, alt]
// (Sideband patched to handle short arrays; see patch_sideband_location.py)
w.pack_uint7(0x02);
w.pack_fixarray(3);
// [0] lat: bin4 (struct.pack("!i", lat*1e6))
int32_t lat_i = (int32_t)round(lat * 1e6);
uint8_t lat_b[4] = {
(uint8_t)((lat_i >> 24) & 0xff), (uint8_t)((lat_i >> 16) & 0xff),
(uint8_t)((lat_i >> 8) & 0xff), (uint8_t)(lat_i & 0xff)
};
w.pack_bin8(lat_b, 4);
// [1] lon: bin4 (struct.pack("!i", lon*1e6))
int32_t lon_i = (int32_t)round(lon * 1e6);
uint8_t lon_b[4] = {
(uint8_t)((lon_i >> 24) & 0xff), (uint8_t)((lon_i >> 16) & 0xff),
(uint8_t)((lon_i >> 8) & 0xff), (uint8_t)(lon_i & 0xff)
};
w.pack_bin8(lon_b, 4);
// [2] alt: bin4 (struct.pack("!i", alt*1e2))
int32_t alt_i = (int32_t)round(alt * 1e2);
uint8_t alt_b[4] = {
(uint8_t)((alt_i >> 24) & 0xff), (uint8_t)((alt_i >> 16) & 0xff),
(uint8_t)((alt_i >> 8) & 0xff), (uint8_t)(alt_i & 0xff)
};
w.pack_bin8(alt_b, 4);
// SID_BATTERY = 0x04 → fixarray[3] [pct, charging, temperature]
if (has_bat) {
w.pack_uint7(0x04);
w.pack_fixarray(3);
w.pack_uint7((uint8_t)(bat_percent > 100 ? 100 : bat_percent));
w.pack_false(); // not charging
w.pack_nil(); // temperature unknown
}
if (!w.ok()) return 0;
return w.pos;
}
// ---- LXMF Message Construction ----
// Builds the LXMF plaintext (before RNS encryption).
//
// LXMF packed format for OPPORTUNISTIC delivery:
// source_hash(16) + signature(64) + msgpack_payload
// (dest_hash is omitted; RNS header carries it)
//
// msgpack_payload = [timestamp_f64, title_str, content_str, fields_map]
// fields_map = { 0x02: telemetry_bytes } (FIELD_TELEMETRY = 0x02 in LXMF)
//
// Signing (from LXMF LXMessage.pack()):
// hashed_part = dest_hash + source_hash + msgpack_payload
// message_hash = SHA256(hashed_part)
// signed_part = hashed_part + message_hash
// signature = Ed25519Sign(signed_part, ed25519_sk)
static int lxmf_build_message(uint8_t *out, size_t out_cap,
const uint8_t *dest_hash16,
double lat, double lon, double alt,
double speed, double hdop,
uint32_t timestamp, int bat_percent) {
// 1. Pack telemetry bytes
uint8_t telemetry[128];
size_t telem_len = lxmf_pack_telemetry(telemetry, sizeof(telemetry),
lat, lon, alt, speed, hdop,
timestamp, bat_percent);
if (telem_len == 0) return -1;
// 2. Build msgpack payload: [timestamp, nil, nil, {0x02: telemetry}]
uint8_t payload[256];
MsgpackWriter pw = { payload, 0, sizeof(payload) };
pw.pack_fixarray(4);
// timestamp as uint32 (saves 4 bytes vs float64)
pw.pack_uint32(timestamp);
// empty title and content as bin8(0) — must not be nil,
// Sideband calls len(content) which fails on None
pw.pack_bin8(NULL, 0);
pw.pack_bin8(NULL, 0);
// fields: {FIELD_TELEMETRY(0x02): telemetry_bytes}
pw.pack_fixmap(1);
pw.pack_uint7(0x02);
pw.pack_bin8(telemetry, (uint8_t)telem_len);
if (!pw.ok()) return -1;
size_t payload_len = pw.pos;
// 3. Compute signature
// hashed_part = dest_hash(16) + source_hash(16) + payload
uint8_t hashed_part[256 + 32];
size_t hp_len = 16 + 16 + payload_len;
if (hp_len > sizeof(hashed_part)) return -1;
memcpy(hashed_part, dest_hash16, 16);
memcpy(hashed_part + 16, lxmf_source_hash, 16);
memcpy(hashed_part + 32, payload, payload_len);
// message_hash = SHA256(hashed_part) (RNS.Identity.full_hash is single SHA256)
uint8_t message_hash[32];
sha256_once(hashed_part, hp_len, message_hash);
// signed_part = hashed_part + message_hash
uint8_t signed_part[256 + 32 + 32];
size_t sp_len = hp_len + 32;
if (sp_len > sizeof(signed_part)) return -1;
memcpy(signed_part, hashed_part, hp_len);
memcpy(signed_part + hp_len, message_hash, 32);
// signature = Ed25519Sign(signed_part)
uint8_t signature[64];
unsigned long long sig_len_unused;
crypto_sign_ed25519_detached(signature, &sig_len_unused,
signed_part, sp_len, lxmf_ed25519_sk);
// 4. Assemble LXMF wire format for OPPORTUNISTIC:
// source_hash(16) + signature(64) + payload
size_t total = 16 + 64 + payload_len;
if (total > out_cap) return -1;
memcpy(out, lxmf_source_hash, 16);
memcpy(out + 16, signature, 64);
memcpy(out + 80, payload, payload_len);
return (int)total;
}
// ---- RNS Announce Packet Construction ----
// Builds a complete RNS announce packet in tbuf for transmission.
//
// RNS header: flags(1) + hops(1) + dest_hash(16) + context(1) = 19 bytes
// Announce payload:
// public_key(64): x25519_pub + ed25519_pub
// name_hash(10): computed for "lxmf.delivery"
// random_hash(10): random bytes
// signature(64): Ed25519Sign(dest_hash + public_key + name_hash + random_hash + app_data)
// app_data: msgpack string with display name
static int lxmf_build_announce(uint8_t *out, size_t out_cap, const char *display_name) {
// Announce FLAGS: header_type=0 (HEADER_1), propagation=0 (BROADCAST),
// destination=0 (SINGLE), packet_type=1 (ANNOUNCE), transport=0
// Bits: [header_type:2][propagation_type:2][destination_type:2][packet_type:2]
// ANNOUNCE packet_type = 1 → 0x01 in low 2 bits
// But RNS packs: ifac_flag(1) | header_type(1) | propagation_type(2) | destination_type(1) | packet_type(1) | transport_type(1) | context_flag(1)
// Wait, let me use the correct bit layout from RNS:
// header byte = (ifac_flag << 7) | (header_type << 6) | (propagation << 4) | (destination << 2) | (packet_type) | transport bit
// For announce: ifac=0, header_type=0(HEADER_1), propagation=0(BROADCAST),
// destination=0(SINGLE), packet_type=1(ANNOUNCE), transport=0
// = 0b00000010 = 0x02
// But context_flag is separate: context byte for announce = 0x00 (CONTEXT_NONE)
// Actually the RNS header is:
// byte 0: [ifac_flag:1][header_type:1][propagation_type:2][destination_type:2][packet_type:2]
// For HEADER_1 + BROADCAST + SINGLE + ANNOUNCE:
// = 0b00_00_00_01 = 0x01
out[0] = 0x01; // FLAGS: HEADER_1, BROADCAST, SINGLE, ANNOUNCE
out[1] = 0x00; // HOPS
// dest_hash for our LXMF delivery destination
memcpy(&out[2], lxmf_source_hash, 16);
out[18] = 0x00; // CONTEXT_NONE
size_t pos = 19;
// public_key: x25519_pub(32) + ed25519_pub(32)
memcpy(&out[pos], lxmf_x25519_pk, 32); pos += 32;
memcpy(&out[pos], lxmf_ed25519_pk, 32); pos += 32;
// name_hash: SHA256("lxmf.delivery")[:10]
uint8_t name_hash[10];
compute_name_hash("lxmf", "delivery", name_hash);
memcpy(&out[pos], name_hash, 10); pos += 10;
// random_hash: 5 random bytes + 5-byte big-endian Unix timestamp
// RNS uses random_hash[5:10] as an "announce emitted" timebase
// for ordering announces in the transport node's path table.
uint8_t random_hash[10];
esp_fill_random(random_hash, 5);
uint32_t now_sec = 0;
#if HAS_GPS == true
// Use GPS time directly (always available when beaconing since gps_has_fix is checked)
extern TinyGPSPlus gps_parser;
if (gps_parser.date.isValid() && gps_parser.time.isValid() && gps_parser.date.year() >= 2024) {
uint32_t days = 0;
uint16_t yr = gps_parser.date.year();
uint8_t mo = gps_parser.date.month();
uint8_t dy = gps_parser.date.day();
for (uint16_t y = 1970; y < yr; y++)
days += (y % 4 == 0) ? 366 : 365;
static const uint16_t mdays[] = {0,31,59,90,120,151,181,212,243,273,304,334};
if (mo >= 1 && mo <= 12) {
days += mdays[mo - 1];
if (mo > 2 && (yr % 4 == 0)) days++;
}
days += dy - 1;
now_sec = days * 86400UL + gps_parser.time.hour() * 3600UL
+ gps_parser.time.minute() * 60UL + gps_parser.time.second();
} else {
now_sec = (uint32_t)(millis() / 1000);
}
#else
now_sec = (uint32_t)(millis() / 1000);
#endif
// 5-byte big-endian timestamp (40-bit, fits Unix time for centuries)
random_hash[5] = 0; // high byte always 0 for current era
random_hash[6] = (uint8_t)(now_sec >> 24);
random_hash[7] = (uint8_t)(now_sec >> 16);
random_hash[8] = (uint8_t)(now_sec >> 8);
random_hash[9] = (uint8_t)(now_sec);
memcpy(&out[pos], random_hash, 10); pos += 10;
// app_data: raw UTF-8 display name (no msgpack wrapping —
// Sideband's display_name_from_app_data() decodes as plain UTF-8)
size_t name_len = strlen(display_name);
uint8_t app_data[48];
size_t app_data_len = name_len;
memcpy(app_data, display_name, name_len);
// signature: Ed25519Sign(dest_hash + public_key + name_hash + random_hash + app_data)
// signed_data = dest_hash(16) + public_key(64) + name_hash(10) + random_hash(10) + app_data
uint8_t signed_data[256];
size_t sd_len = 0;
memcpy(&signed_data[sd_len], lxmf_source_hash, 16); sd_len += 16;
memcpy(&signed_data[sd_len], lxmf_x25519_pk, 32); sd_len += 32;
memcpy(&signed_data[sd_len], lxmf_ed25519_pk, 32); sd_len += 32;
memcpy(&signed_data[sd_len], name_hash, 10); sd_len += 10;
memcpy(&signed_data[sd_len], random_hash, 10); sd_len += 10;
memcpy(&signed_data[sd_len], app_data, app_data_len); sd_len += app_data_len;
uint8_t signature[64];
unsigned long long sig_len_unused;
crypto_sign_ed25519_detached(signature, &sig_len_unused,
signed_data, sd_len, lxmf_ed25519_sk);
memcpy(&out[pos], signature, 64); pos += 64;
// app_data
memcpy(&out[pos], app_data, app_data_len); pos += app_data_len;
if (pos > out_cap) return -1;
return (int)pos;
}
// ---- Public API ----
// Build and transmit an LXMF telemetry beacon.
// The LXMF message is encrypted as an RNS SINGLE packet to collector_dest_hash
// using the same ECDH encryption pipeline from BeaconCrypto.h.
static void lxmf_beacon_send(double lat, double lon, double alt,
double speed, double hdop,
uint32_t timestamp, int bat_percent) {
if (!lxmf_identity_configured || !beacon_crypto_configured) return;
// Build LXMF message plaintext
uint8_t lxmf_msg[300];
int msg_len = lxmf_build_message(lxmf_msg, sizeof(lxmf_msg),
collector_dest_hash,
lat, lon, alt, speed, hdop,
timestamp, bat_percent);
if (msg_len <= 0) return;
// RNS packet: HEADER_2 is required — transport nodes only forward
// packets with explicit transport_id. HEADER_1 packets are silently
// dropped for transport purposes.
int hdr_len;
if (transport_configured) {
// HEADER_2: flags + hops + transport_id(16) + dest_hash(16) + context
tbuf[0] = 0x50; // HEADER_2(1<<6) | TRANSPORT(1<<4) | SINGLE(0) | DATA(0)
tbuf[1] = 0x00; // HOPS
memcpy(&tbuf[2], transport_id, 16);
memcpy(&tbuf[18], collector_dest_hash, 16);
tbuf[34] = 0x00; // CONTEXT_NONE
hdr_len = 35;
} else {
// HEADER_1: flags + hops + dest_hash(16) + context
tbuf[0] = 0x00; // HEADER_1, BROADCAST, SINGLE, DATA
tbuf[1] = 0x00; // HOPS
memcpy(&tbuf[2], collector_dest_hash, 16);
tbuf[18] = 0x00; // CONTEXT_NONE
hdr_len = 19;
}
int crypto_len = beacon_crypto_encrypt(
lxmf_msg, msg_len,
collector_pub_key, collector_identity_hash,
&tbuf[hdr_len]
);
// LoRa FIFO limit is 255 bytes. After IFAC (+8 bytes), the packet
// must still fit. Check against 247 (255 - IFAC_SIZE) to prevent
// silent truncation that corrupts the IFAC signature.
int pre_ifac_size = hdr_len + crypto_len;
int max_lora = 255 - 8; // IFAC adds 8 bytes
if (crypto_len > 0 && pre_ifac_size <= max_lora && pre_ifac_size <= (int)MTU) {
beacon_transmit(pre_ifac_size);
lora_receive();
}
}
// Build and transmit an LXMF announce packet.
static void lxmf_announce_send(const char *display_name) {
if (!lxmf_identity_configured) return;
int pkt_len = lxmf_build_announce(tbuf, MTU, display_name);
if (pkt_len > 0) {
beacon_transmit(pkt_len);
lora_receive();
}
}
// Check if announce is due and send it.
static void lxmf_announce_if_needed(const char *display_name) {
if (!lxmf_identity_configured) return;
if (lxmf_last_announce == 0 ||
(millis() - lxmf_last_announce >= LXMF_ANNOUNCE_INTERVAL_MS)) {
lxmf_announce_send(display_name);
lxmf_last_announce = millis();
}
}
// ---- CMD_LXMF_TEST: Force-trigger announce + beacon for USB testing ----
// Emits pre-encryption plaintext as CMD_DIAG KISS frames over serial,
// then transmits encrypted packets over LoRa.
// KISS framing constants (from Framing.h, repeated here to avoid include-order issues)
#ifndef LXMF_KISS_FEND
#define LXMF_KISS_FEND 0xC0
#define LXMF_KISS_FESC 0xDB
#define LXMF_KISS_TFEND 0xDC
#define LXMF_KISS_TFESC 0xDD
#define LXMF_CMD_DIAG 0x2C
#endif
// Forward declarations from main firmware
void serial_write(uint8_t byte);
bool startRadio();
void setTXPower();
void setBandwidth();
void setSpreadingFactor();
void setCodingRate();
static void kiss_emit_diag(const uint8_t *data, size_t len) {
serial_write(LXMF_KISS_FEND);
serial_write(LXMF_CMD_DIAG);
for (size_t i = 0; i < len; i++) {
uint8_t b = data[i];
if (b == LXMF_KISS_FEND) { serial_write(LXMF_KISS_FESC); serial_write(LXMF_KISS_TFEND); }
else if (b == LXMF_KISS_FESC) { serial_write(LXMF_KISS_FESC); serial_write(LXMF_KISS_TFESC); }
else serial_write(b);
}
serial_write(LXMF_KISS_FEND);
}
// Beacon radio parameters (from Beacon.h, repeated to avoid include-order dependency)
#ifndef LXMF_BEACON_RADIO_FREQ
#define LXMF_BEACON_RADIO_FREQ 868000000
#define LXMF_BEACON_RADIO_BW 125000
#define LXMF_BEACON_RADIO_SF 7
#define LXMF_BEACON_RADIO_CR 5
#define LXMF_BEACON_RADIO_TXP 17
#endif
static void lxmf_test_send() {
if (!lxmf_identity_configured) return;
// Save current LoRa params
uint32_t save_freq = lora_freq;
uint32_t save_bw = lora_bw;
int save_sf = lora_sf;
int save_cr = lora_cr;
int save_txp = lora_txp;
// Set beacon LoRa params
lora_freq = (uint32_t)LXMF_BEACON_RADIO_FREQ;
lora_bw = (uint32_t)LXMF_BEACON_RADIO_BW;
lora_sf = LXMF_BEACON_RADIO_SF;
lora_cr = LXMF_BEACON_RADIO_CR;
lora_txp = LXMF_BEACON_RADIO_TXP;
if (!radio_online) {
startRadio();
}
if (radio_online) {
setTXPower();
setBandwidth();
setSpreadingFactor();
setCodingRate();
}
// 1. Build and emit announce
int ann_len = lxmf_build_announce(tbuf, MTU, "RNode GPS Tracker");
if (ann_len > 0) {
// Emit announce plaintext as CMD_DIAG (pre-IFAC)
kiss_emit_diag(tbuf, ann_len);
// Transmit over LoRa (beacon_transmit applies IFAC)
if (radio_online) {
beacon_transmit(ann_len);
}
}
// 2. Build and emit LXMF beacon
if (beacon_crypto_configured) {
// Get Unix timestamp from GPS (always available when beaconing)
uint32_t timestamp = (uint32_t)(millis() / 1000);
#if HAS_GPS == true
{
extern TinyGPSPlus gps_parser;
if (gps_parser.date.isValid() && gps_parser.time.isValid() && gps_parser.date.year() >= 2024) {
uint32_t days = 0;
uint16_t yr = gps_parser.date.year();
uint8_t mo = gps_parser.date.month();
for (uint16_t y = 1970; y < yr; y++)
days += (y % 4 == 0) ? 366 : 365;
static const uint16_t mdays[] = {0,31,59,90,120,151,181,212,243,273,304,334};
if (mo >= 1 && mo <= 12) {
days += mdays[mo - 1];
if (mo > 2 && (yr % 4 == 0)) days++;
}
days += gps_parser.date.day() - 1;
timestamp = days * 86400UL + gps_parser.time.hour() * 3600UL
+ gps_parser.time.minute() * 60UL + gps_parser.time.second();
}
}
#endif
// Build LXMF message plaintext
uint8_t lxmf_msg[300];
int msg_len = lxmf_build_message(lxmf_msg, sizeof(lxmf_msg),
collector_dest_hash,
gps_lat, gps_lon, gps_alt,
gps_speed, gps_hdop,
timestamp, (int)battery_percent);
if (msg_len > 0) {
// Emit LXMF plaintext as CMD_DIAG (pre-encryption)
kiss_emit_diag(lxmf_msg, msg_len);
// Build encrypted RNS packet and transmit
int hdr_len;
if (transport_configured) {
tbuf[0] = 0x50; // HEADER_2 | TRANSPORT | SINGLE | DATA
tbuf[1] = 0x00;
memcpy(&tbuf[2], transport_id, 16);
memcpy(&tbuf[18], collector_dest_hash, 16);
tbuf[34] = 0x00;
hdr_len = 35;
} else {
tbuf[0] = 0x00; // HEADER_1 | BROADCAST | SINGLE | DATA
tbuf[1] = 0x00;
memcpy(&tbuf[2], collector_dest_hash, 16);
tbuf[18] = 0x00;
hdr_len = 19;
}
int crypto_len = beacon_crypto_encrypt(
lxmf_msg, msg_len,
collector_pub_key, collector_identity_hash,
&tbuf[hdr_len]
);
if (crypto_len > 0 && (hdr_len + crypto_len) <= (int)MTU && radio_online) {
beacon_transmit(hdr_len + crypto_len);
}
}
}
// Restore LoRa params
lora_freq = save_freq;
lora_bw = save_bw;
lora_sf = save_sf;
lora_cr = save_cr;
lora_txp = save_txp;
if (radio_online) {
setTXPower();
setBandwidth();
setSpreadingFactor();
setCodingRate();
}
lora_receive();
}
#endif // HAS_GPS
#endif // LXMF_BEACON_H

View file

@ -19,12 +19,25 @@ ARDUINO_ESP_CORE_VER = 2.0.17
# Version 3.2.0 of the Arduino ESP core is based on ESP-IDF v5.4.1
# ARDUINO_ESP_CORE_VER = 3.2.0
PORT ?= /dev/ttyACM4
all: release
clean:
-rm -r ./build
-rm ./Release/rnode_firmware*
# Stop any process holding the serial port (e.g. rnsd)
free-port:
@if fuser $(PORT) >/dev/null 2>&1; then \
echo "Stopping process on $(PORT)..."; \
kill $$(fuser $(PORT) 2>/dev/null | tr -d ' :') 2>/dev/null; \
sleep 2; \
fi
test-kiss: free-port
@python3 test_kiss.py $(PORT)
prep: prep-avr prep-esp32 prep-samd
prep-avr:
@ -41,6 +54,7 @@ prep-esp32:
arduino-cli lib install "Adafruit NeoPixel"
arduino-cli lib install "XPowersLib"
arduino-cli lib install "Crypto"
arduino-cli lib install "TinyGPSPlus"
prep-samd:
arduino-cli core update-index --config-file arduino-cli.yaml
@ -98,6 +112,11 @@ firmware-tdeck:
firmware-tbeam_supreme:
arduino-cli compile --log --fqbn "esp32:esp32:esp32s3:CDCOnBoot=cdc" -e --build-property "build.partitions=no_ota" --build-property "upload.maximum_size=2097152" --build-property "compiler.cpp.extra_flags=-DBOARD_MODEL=0x3D"
deploy-tbeam_supreme: firmware-tbeam_supreme free-port
./flash_parts.sh $(PORT)
@sleep 3
rnodeconf $(PORT) --firmware-hash $$(./partition_hashes ./build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin)
firmware-lora32_v10: check_bt_buffers
arduino-cli compile --log --fqbn esp32:esp32:ttgo-lora32 -e --build-property "build.partitions=no_ota" --build-property "upload.maximum_size=2097152" --build-property "compiler.cpp.extra_flags=\"-DBOARD_MODEL=0x39\""
@ -214,6 +233,15 @@ upload-heltec32_v4:
@sleep 3
python ./Release/esptool/esptool.py --chip esp32-s3 --port /dev/ttyACM0 --baud 921600 --before default_reset --after hard_reset write_flash -z --flash_mode dio --flash_freq 80m --flash_size 4MB 0x210000 ./Release/console_image.bin
deploy-heltec32_v4: firmware-heltec32_v4
python3 $(HOME)/.arduino15/packages/esp32/tools/esptool_py/4.5.1/esptool.py \
--chip esp32s3 --port /dev/ttyACM0 --baud 115200 --no-stub \
--before default_reset --after hard_reset write_flash -z \
--flash_mode dio --flash_freq 80m --flash_size 8MB \
0x10000 build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin
@sleep 3
rnodeconf /dev/ttyACM0 --firmware-hash $$(./partition_hashes ./build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin)
upload-tdeck:
arduino-cli upload -p /dev/ttyACM0 --fqbn esp32:esp32:esp32s3
@sleep 1
@ -221,12 +249,12 @@ upload-tdeck:
@sleep 3
python ./Release/esptool/esptool.py --chip esp32-s3 --port /dev/ttyACM0 --baud 921600 --before default_reset --after hard_reset write_flash -z --flash_mode dio --flash_freq 80m --flash_size 4MB 0x210000 ./Release/console_image.bin
upload-tbeam_supreme:
arduino-cli upload -p /dev/ttyACM0 --fqbn esp32:esp32:esp32s3
@sleep 1
rnodeconf /dev/ttyACM0 --firmware-hash $$(./partition_hashes ./build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin)
upload-tbeam_supreme: free-port
arduino-cli upload -p $(PORT) --fqbn esp32:esp32:esp32s3
@sleep 3
python ./Release/esptool/esptool.py --chip esp32-s3 --port /dev/ttyACM0 --baud 921600 --before default_reset --after hard_reset write_flash -z --flash_mode dio --flash_freq 80m --flash_size 4MB 0x210000 ./Release/console_image.bin
rnodeconf $(PORT) --firmware-hash $$(./partition_hashes ./build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin)
@sleep 3
python ./Release/esptool/esptool.py --chip esp32-s3 --port $(PORT) --baud 921600 --before default_reset --after hard_reset write_flash -z --flash_mode dio --flash_freq 80m --flash_size 4MB 0x210000 ./Release/console_image.bin
upload-rnode_ng_20:
arduino-cli upload -p /dev/ttyUSB0 --fqbn esp32:esp32:ttgo-lora32

View file

@ -392,6 +392,7 @@ void measure_battery() {
if (battery_ready) {
pmu_rc++;
if (pmu_rc%PMU_R_INTERVAL == 0) {
response_channel = data_channel;
kiss_indicate_battery();
if (pmu_temp_sensor_ready) { kiss_indicate_temperature(); }
}

View file

@ -86,6 +86,14 @@ The RNode Firmware supports the following boards:
- Homebrew RNodes based on Adafruit Feather ESP32 boards
- Homebrew RNodes based on generic ESP32 boards
## Tested Devices (GPS + Beacon branch)
The following devices have been tested with GPS and beacon support on the `gps-beacon` branch:
| Device | Band | GPS | LoRa | Beacon | Notes |
|--------|------|-----|------|--------|-------|
| Heltec LoRa32 v4 | 868 MHz | L76K | SX1262 | Yes | Reference device for GPS+beacon implementation |
| LilyGo T-Beam Supreme S3 | 868 MHz | L76K | SX1262 | Yes | Requires SPI.begin fix in sx126x.cpp; upload via USB CDC (ttyACM), not CH340 (ttyUSB) |
## Supported Transceiver Modules
The RNode Firmware supports all transceiver modules based on Semtech **SX1276**, **SX1278**, **SX1262**, **SX1268** and **SX1280** chips, that have an **SPI interface** and expose the relevant **DIO** interrupt pins from the chip.

File diff suppressed because it is too large Load diff

8
ROM.h
View file

@ -58,6 +58,14 @@
#define ADDR_CONF_PSK 0x21
#define ADDR_CONF_IP 0x42
#define ADDR_CONF_NM 0x46
// Beacon encryption config — stored in config region via config_addr()
// Primary region (eeprom_addr) is full past 0xBB; these use config space
// after WiFi config (SSID/PSK/IP/NM end at 0x49)
#define ADDR_BCN_OK 0x50 // Config valid flag (0x73 = valid) — 1 byte
#define ADDR_BCN_KEY 0x51 // Collector X25519 public key — 32 bytes (0x51-0x70)
#define ADDR_BCN_IHASH 0x71 // Collector identity hash — 16 bytes (0x71-0x80)
#define ADDR_BCN_DHASH 0x81 // Collector dest hash — 16 bytes (0x81-0x90)
//////////////////////////////////
#endif

131
RTC.h Normal file
View file

@ -0,0 +1,131 @@
// Copyright (C) 2026, GPS/RTC support contributed by GlassOnTin
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
#ifndef RTC_H
#define RTC_H
#if HAS_RTC == true
#include <Wire.h>
// PCF8563 I2C address and registers
#define PCF8563_ADDR 0x51
#define PCF8563_REG_SEC 0x02
#define PCF8563_REG_MIN 0x03
#define PCF8563_REG_HOUR 0x04
#define PCF8563_REG_DAY 0x05
#define PCF8563_REG_WDAY 0x06
#define PCF8563_REG_MON 0x07
#define PCF8563_REG_YEAR 0x08
bool rtc_ready = false;
bool rtc_synced = false; // true once GPS time has been written to RTC
uint32_t rtc_last_sync = 0;
#define RTC_SYNC_INTERVAL 3600000 // re-sync from GPS every hour
// Cached time from last RTC read
uint8_t rtc_hour = 0;
uint8_t rtc_minute = 0;
uint8_t rtc_second = 0;
uint8_t rtc_day = 0;
uint8_t rtc_month = 0;
uint16_t rtc_year = 0;
static uint8_t bcd_to_dec(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); }
static uint8_t dec_to_bcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }
void rtc_setup() {
// The sensor I2C bus (Wire) is already initialised by Display.h
// on pins SDA_OLED/SCL_OLED (17/18 for T-Beam Supreme).
// Just probe for the PCF8563.
Wire.beginTransmission(PCF8563_ADDR);
if (Wire.endTransmission() == 0) {
rtc_ready = true;
// Clear control registers (normal mode, no alarms)
Wire.beginTransmission(PCF8563_ADDR);
Wire.write(0x00); // control/status 1
Wire.write(0x00); // normal mode
Wire.write(0x00); // control/status 2: no alarms/timer
Wire.endTransmission();
}
}
bool rtc_read_time() {
if (!rtc_ready) return false;
Wire.beginTransmission(PCF8563_ADDR);
Wire.write(PCF8563_REG_SEC);
if (Wire.endTransmission() != 0) return false;
Wire.requestFrom((uint8_t)PCF8563_ADDR, (uint8_t)7);
if (Wire.available() < 7) return false;
uint8_t sec = Wire.read();
uint8_t min = Wire.read();
uint8_t hour = Wire.read();
uint8_t day = Wire.read();
Wire.read(); // weekday — skip
uint8_t mon = Wire.read();
uint8_t year = Wire.read();
// Check clock integrity bit (sec register bit 7)
if (sec & 0x80) return false; // clock integrity not guaranteed
rtc_second = bcd_to_dec(sec & 0x7F);
rtc_minute = bcd_to_dec(min & 0x7F);
rtc_hour = bcd_to_dec(hour & 0x3F);
rtc_day = bcd_to_dec(day & 0x3F);
rtc_month = bcd_to_dec(mon & 0x1F);
rtc_year = 2000 + bcd_to_dec(year);
return true;
}
bool rtc_write_time(uint16_t year, uint8_t month, uint8_t day,
uint8_t hour, uint8_t minute, uint8_t second) {
if (!rtc_ready) return false;
Wire.beginTransmission(PCF8563_ADDR);
Wire.write(PCF8563_REG_SEC);
Wire.write(dec_to_bcd(second));
Wire.write(dec_to_bcd(minute));
Wire.write(dec_to_bcd(hour));
Wire.write(dec_to_bcd(day));
Wire.write(0x00); // weekday (not used)
Wire.write(dec_to_bcd(month));
Wire.write(dec_to_bcd(year - 2000));
return Wire.endTransmission() == 0;
}
// Called from gps_update() when GPS has a valid time fix.
// Syncs RTC from GPS time at most once per RTC_SYNC_INTERVAL.
void rtc_sync_from_gps(TinyGPSPlus &gps) {
if (!rtc_ready) return;
if (!gps.date.isValid() || !gps.time.isValid()) return;
if (gps.date.year() < 2024) return; // sanity check
uint32_t now = millis();
if (rtc_synced && (now - rtc_last_sync < RTC_SYNC_INTERVAL)) return;
if (rtc_write_time(gps.date.year(), gps.date.month(), gps.date.day(),
gps.time.hour(), gps.time.minute(), gps.time.second())) {
rtc_synced = true;
rtc_last_sync = now;
}
}
#endif
#endif

View file

@ -15,6 +15,18 @@
#include "Config.h"
#if HAS_GPS == true
#include "GPS.h"
#include "BeaconCrypto.h"
#include "IfacAuth.h"
#include "LxmfBeacon.h"
#include "Beacon.h"
#endif
#if HAS_RTC == true
#include "RTC.h"
#endif
#if HAS_EEPROM
#include <EEPROM.h>
#elif PLATFORM == PLATFORM_NRF52
@ -799,26 +811,28 @@ int8_t led_standby_direction = 0;
#endif
void serial_write(uint8_t byte) {
#if HAS_BLUETOOTH || HAS_BLE == true
if (bt_state != BT_STATE_CONNECTED) {
#if HAS_WIFI
if (wifi_host_is_connected()) { wifi_remote_write(byte); }
else { Serial.write(byte); }
#else
Serial.write(byte);
#endif
} else {
switch (response_channel) {
#if HAS_BLUETOOTH || HAS_BLE == true
case CHANNEL_BT:
SerialBT.write(byte);
#if MCU_VARIANT == MCU_NRF52 && HAS_BLE
// This ensures that the TX buffer is flushed after a frame is queued in serial.
// serial_in_frame is used to ensure that the flush only happens at the end of the frame
if (serial_in_frame && byte == FEND) { SerialBT.flushTXD(); serial_in_frame = false; }
else if (!serial_in_frame && byte == FEND) { serial_in_frame = true; }
#endif
}
#else
Serial.write(byte);
#endif
#if MCU_VARIANT == MCU_NRF52 && HAS_BLE
// This ensures that the TX buffer is flushed after a frame is queued in serial.
// serial_in_frame is used to ensure that the flush only happens at the end of the frame
if (serial_in_frame && byte == FEND) { SerialBT.flushTXD(); serial_in_frame = false; }
else if (!serial_in_frame && byte == FEND) { serial_in_frame = true; }
#endif
break;
#endif
#if HAS_WIFI == true
case CHANNEL_WIFI:
wifi_remote_write(byte);
break;
#endif
case CHANNEL_USB:
default:
Serial.write(byte);
break;
}
}
void escaped_serial_write(uint8_t byte) {
@ -883,6 +897,65 @@ void kiss_indicate_stat_snr() {
serial_write(FEND);
}
#if HAS_GPS == true
void kiss_indicate_stat_gps() {
// Report GPS data as a KISS frame:
// [fix(1)] [sats(1)] [lat(4)] [lon(4)] [alt(4)] [speed(4)] [hdop(4)]
// All floats are IEEE 754 single-precision, big-endian
serial_write(FEND);
serial_write(CMD_STAT_GPS);
escaped_serial_write(gps_has_fix ? 0x01 : 0x00);
escaped_serial_write(gps_sats);
union { float f; uint8_t b[4]; } u;
u.f = (float)gps_lat;
for (int i = 3; i >= 0; i--) escaped_serial_write(u.b[i]);
u.f = (float)gps_lon;
for (int i = 3; i >= 0; i--) escaped_serial_write(u.b[i]);
u.f = (float)gps_alt;
for (int i = 3; i >= 0; i--) escaped_serial_write(u.b[i]);
u.f = (float)gps_speed;
for (int i = 3; i >= 0; i--) escaped_serial_write(u.b[i]);
u.f = (float)gps_hdop;
for (int i = 3; i >= 0; i--) escaped_serial_write(u.b[i]);
// Diagnostic counters from TinyGPS++ (uint32 big-endian each)
uint32_t val;
val = gps_parser.charsProcessed();
escaped_serial_write((val >> 24) & 0xFF);
escaped_serial_write((val >> 16) & 0xFF);
escaped_serial_write((val >> 8) & 0xFF);
escaped_serial_write(val & 0xFF);
val = gps_parser.passedChecksum();
escaped_serial_write((val >> 24) & 0xFF);
escaped_serial_write((val >> 16) & 0xFF);
escaped_serial_write((val >> 8) & 0xFF);
escaped_serial_write(val & 0xFF);
val = gps_parser.failedChecksum();
escaped_serial_write((val >> 24) & 0xFF);
escaped_serial_write((val >> 16) & 0xFF);
escaped_serial_write((val >> 8) & 0xFF);
escaped_serial_write(val & 0xFF);
val = gps_parser.sentencesWithFix();
escaped_serial_write((val >> 24) & 0xFF);
escaped_serial_write((val >> 16) & 0xFF);
escaped_serial_write((val >> 8) & 0xFF);
escaped_serial_write(val & 0xFF);
serial_write(FEND);
}
#endif
void kiss_indicate_radio_lock() {
serial_write(FEND);
serial_write(CMD_RADIO_LOCK);
@ -1263,12 +1336,20 @@ void updateBitrate() {
}
void setSpreadingFactor() {
if (radio_online) LoRa->setSpreadingFactor(lora_sf);
if (radio_online) {
LoRa->standby();
LoRa->setSpreadingFactor(lora_sf);
lora_receive();
}
updateBitrate();
}
void setCodingRate() {
if (radio_online) LoRa->setCodingRate4(lora_cr);
if (radio_online) {
LoRa->standby();
LoRa->setCodingRate4(lora_cr);
lora_receive();
}
updateBitrate();
}
@ -1396,8 +1477,10 @@ void getBandwidth() {
void setBandwidth() {
if (radio_online) {
LoRa->standby();
LoRa->setSignalBandwidth(lora_bw);
getBandwidth();
lora_receive();
}
}
@ -1409,8 +1492,10 @@ void getFrequency() {
void setFrequency() {
if (radio_online) {
LoRa->standby();
LoRa->setFrequency(lora_freq);
getFrequency();
lora_receive();
}
}
@ -2019,4 +2104,7 @@ void host_disconnected() {
last_rssi = -292;
last_rssi_raw = 0x00;
last_snr_raw = 0x80;
#if HAS_WIFI == true
if (data_channel == CHANNEL_WIFI) data_channel = CHANNEL_USB;
#endif
}

View file

@ -4,4 +4,4 @@ board_manager:
- https://raw.githubusercontent.com/RAKwireless/RAKwireless-Arduino-BSP-Index/main/package_rakwireless_index.json
- https://github.com/HelTecAutomation/Heltec_nRF52/releases/download/1.7.0/package_heltec_nrf_index.json
- https://adafruit.github.io/arduino-board-index/package_adafruit_index.json
- http://unsigned.io/arduino/package_unsignedio_UnsignedBoards_index.json
# - http://unsigned.io/arduino/package_unsignedio_UnsignedBoards_index.json # 404

144
flash_parts.sh Executable file
View file

@ -0,0 +1,144 @@
#!/bin/bash
#
# Workaround for ESP32-S3 rev v0.2 USB-Serial/JTAG controller bug:
# the USB controller drops after ~80KB of sustained compressed writes.
#
# This script splits the firmware into small page-aligned chunks and
# flashes each with a full device reset between them, keeping each
# transfer well under the ~80KB compressed limit.
# Only pauses on failure — successful writes proceed immediately.
#
# Usage:
# ./flash_parts.sh [port] [firmware_bin]
#
# Defaults:
# port = /dev/ttyACM4
# firmware_bin = build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin
set -uo pipefail
PORT="${1:-/dev/ttyACM4}"
FIRMWARE="${2:-build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin}"
BOOTLOADER="build/esp32.esp32.esp32s3/RNode_Firmware.ino.bootloader.bin"
PARTITIONS="build/esp32.esp32.esp32s3/RNode_Firmware.ino.partitions.bin"
BOOT_APP0="${HOME}/.arduino15/packages/esp32/hardware/esp32/2.0.17/tools/partitions/boot_app0.bin"
BAUD=460800
DELAY=1 # seconds between successful writes (USB recovery)
RETRY_DELAY=8 # seconds to wait before retry on failure
N_PARTS=16
FW_BASE=0x10000 # firmware flash offset
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# --- Validate inputs ---
for f in "$FIRMWARE" "$BOOTLOADER" "$PARTITIONS" "$BOOT_APP0"; do
if [ ! -f "$f" ]; then
echo "ERROR: Missing file: $f"
echo "Run 'make firmware-tbeam_supreme' first."
exit 1
fi
done
if [ ! -e "$PORT" ]; then
echo "ERROR: Serial port $PORT not found"
exit 1
fi
FW_SIZE=$(stat -c%s "$FIRMWARE")
echo "Firmware: $FIRMWARE ($FW_SIZE bytes)"
echo "Port: $PORT"
echo ""
# --- Split firmware into page-aligned parts ---
CHUNK_RAW=$(( (FW_SIZE / N_PARTS / 4096) * 4096 ))
python3 -c "
import sys
with open('$FIRMWARE', 'rb') as f:
data = f.read()
size = len(data)
chunk = $CHUNK_RAW
n = $N_PARTS
for i in range(n):
start = i * chunk
end = size if i == n - 1 else start + chunk
part = data[start:end]
pad = ((len(part) + 4095) // 4096) * 4096
part = part.ljust(pad, b'\xff')
path = '$TMPDIR/part_{}.bin'.format(i)
with open(path, 'wb') as pf:
pf.write(part)
addr = $FW_BASE + start
print('Part {:2d}: 0x{:06x} {:6d} bytes (padded to {})'.format(i, addr, end - start, pad))
"
echo ""
# --- Flash bootloader + partition table + boot_app0 ---
echo "=== Flashing bootloader, partitions, boot_app0 ==="
OUTPUT=$(esptool --chip esp32s3 --port "$PORT" --baud "$BAUD" \
--before default-reset --after hard-reset \
write-flash -z --flash-mode dio --flash-freq 80m --flash-size 8MB \
0x0 "$BOOTLOADER" \
0x8000 "$PARTITIONS" \
0xe000 "$BOOT_APP0" 2>&1)
if ! echo "$OUTPUT" | grep -q "Hash of data verified"; then
echo "Bootloader: FAILED — retrying after ${RETRY_DELAY}s..."
sleep "$RETRY_DELAY"
OUTPUT=$(esptool --chip esp32s3 --port "$PORT" --baud "$BAUD" \
--before default-reset --after hard-reset \
write-flash -z --flash-mode dio --flash-freq 80m --flash-size 8MB \
0x0 "$BOOTLOADER" \
0x8000 "$PARTITIONS" \
0xe000 "$BOOT_APP0" 2>&1)
if ! echo "$OUTPUT" | grep -q "Hash of data verified"; then
echo "Bootloader: FAILED after retry"
echo "$OUTPUT" | tail -n 5
exit 1
fi
fi
echo "Bootloader: OK"
sleep "$DELAY"
# --- Flash each firmware part ---
FAILED=0
for i in $(seq 0 $((N_PARTS - 1))); do
ADDR=$(printf "0x%06x" $((FW_BASE + i * CHUNK_RAW)))
echo "=== Part $i/$((N_PARTS - 1)): $ADDR ==="
OUTPUT=$(esptool --chip esp32s3 --port "$PORT" --baud "$BAUD" \
--before default-reset --after hard-reset \
write-flash -z --flash-mode dio --flash-freq 80m --flash-size 8MB \
"$ADDR" "$TMPDIR/part_${i}.bin" 2>&1)
if echo "$OUTPUT" | grep -q "Hash of data verified"; then
echo "Part $i: VERIFIED"
sleep "$DELAY"
else
echo "Part $i: FAILED — retrying after ${RETRY_DELAY}s..."
sleep "$RETRY_DELAY"
OUTPUT=$(esptool --chip esp32s3 --port "$PORT" --baud "$BAUD" \
--before default-reset --after hard-reset \
write-flash -z --flash-mode dio --flash-freq 80m --flash-size 8MB \
"$ADDR" "$TMPDIR/part_${i}.bin" 2>&1)
if echo "$OUTPUT" | grep -q "Hash of data verified"; then
echo "Part $i: VERIFIED (retry)"
else
echo "Part $i: FAILED after retry"
echo "$OUTPUT" | tail -n 5
FAILED=1
break
fi
fi
done
echo ""
if [ "$FAILED" -eq 0 ]; then
echo "=== ALL $N_PARTS PARTS FLASHED AND VERIFIED ==="
else
echo "=== FLASH INCOMPLETE — see errors above ==="
exit 1
fi

View file

@ -17,15 +17,9 @@
import os
import sys
import RNS
import json
import hashlib
import subprocess
major_version = None
minor_version = None
target_version = None
target_file = os.path.join(sys.argv[1])
if sys.argv[1] == "from_device":
@ -39,7 +33,9 @@ if not from_device:
part_hash = firmware_data[-32:]
if calc_hash == part_hash:
print(RNS.hexrep(part_hash, delimit=False))
print(part_hash.hex())
else:
sys.exit("ERROR: Embedded hash does not match calculated hash")
else:
try:

View file

@ -45,6 +45,8 @@
#define OP_RX_TX_FALLBACK_MODE_6X 0x93
#define OP_REGULATOR_MODE_6X 0x96
#define OP_CALIBRATE_IMAGE_6X 0x98
#define OP_GET_DEVICE_ERRORS_6X 0x17
#define OP_CLR_DEVICE_ERRORS_6X 0x07
#define MASK_CALIBRATE_ALL 0x7f
@ -118,14 +120,15 @@ sx126x::sx126x() :
_fifo_rx_addr_ptr(0),
_packet({0}),
_preinit_done(false),
_onReceive(NULL)
_onReceive(NULL),
_rx_pending(false)
{ setTimeout(0); }
bool sx126x::preInit() {
pinMode(_ss, OUTPUT);
digitalWrite(_ss, HIGH);
#if BOARD_MODEL == BOARD_T3S3 || BOARD_MODEL == BOARD_HELTEC32_V3 || BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_TDECK || BOARD_MODEL == BOARD_XIAO_S3
#if BOARD_MODEL == BOARD_T3S3 || BOARD_MODEL == BOARD_HELTEC32_V3 || BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_TDECK || BOARD_MODEL == BOARD_XIAO_S3 || BOARD_MODEL == BOARD_TBEAM_S_V1
SPI.begin(pin_sclk, pin_miso, pin_mosi, pin_cs);
#elif BOARD_MODEL == BOARD_TECHO
SPI.setPins(pin_miso, pin_sclk, pin_mosi);
@ -270,9 +273,23 @@ void sx126x::setPacketParams(long preamble_symbols, uint8_t headermode, uint8_t
buf[4] = crc;
buf[5] = 0x00; // standard IQ setting (no inversion)
buf[6] = 0x00; // unused params
buf[7] = 0x00;
buf[8] = 0x00;
buf[7] = 0x00;
buf[8] = 0x00;
executeOpcode(OP_PACKET_PARAMS_6X, buf, 9);
// SX1262 errata section 15.4: IQ polarity is inverted compared to
// SX1276. Register 0x0736 must be set to correct the polarity.
// Standard IQ: set bit 2 (register value 0x0D → keep other bits).
// Inverted IQ: clear bit 2 (register value 0x09).
// Without this fix, LoRa RX demodulation fails silently.
uint8_t iqreg = readRegister(0x0736);
if (buf[5] == 0x00) {
// Standard IQ: set bit 2
writeRegister(0x0736, iqreg | 0x04);
} else {
// Inverted IQ: clear bit 2
writeRegister(0x0736, iqreg & ~0x04);
}
}
void sx126x::reset(void) {
@ -316,9 +333,25 @@ int sx126x::begin(long frequency) {
if (!_preinit_done) { if (!preInit()) { return false; } }
if (_rxen != -1) { pinMode(_rxen, OUTPUT); }
// Enable DC-DC regulator if the board has the required inductor.
// Default after reset is LDO-only. Heltec V4 (and most SX1262
// boards) have the DC-DC inductor on VREGSW and need this set
// before TCXO/calibration per datasheet Section 13.1.
#if BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC32_V3 || BOARD_MODEL == BOARD_RAK4631 || BOARD_MODEL == BOARD_T3S3 || BOARD_MODEL == BOARD_TBEAM || BOARD_MODEL == BOARD_TBEAM_S_V1 || BOARD_MODEL == BOARD_TDECK
uint8_t reg_mode = 0x01; // DC-DC + LDO
executeOpcode(OP_REGULATOR_MODE_6X, &reg_mode, 1);
#endif
// SX1262 datasheet requires TCXO to be enabled BEFORE calibration.
// With TCXO off, calibrate() uses the inaccurate RC oscillator as
// reference, resulting in bad PLL/image calibration that can kill
// RX sensitivity while TX still works (enough power margin).
enableTCXO();
// Clear any latched errors from power-on before calibration
uint8_t clr_err[2] = {0x00, 0x00};
executeOpcode(OP_CLR_DEVICE_ERRORS_6X, clr_err, 2);
calibrate();
calibrate_image(frequency);
enableTCXO();
loraMode();
standby();
@ -329,6 +362,13 @@ int sx126x::begin(long frequency) {
// enable dio2 rf switch
uint8_t byte = 0x01;
executeOpcode(OP_DIO2_RF_CTRL_6X, &byte, 1);
// After TX the SX1262 falls back to STANDBY_RC by default.
// Set fallback to STDBY_XOSC (0x40) so the oscillator stays
// running and DIO2 toggles the GC1109 PA/LNA RF switch back
// to RX promptly when receive() is called after a transmit.
uint8_t fb = 0x40; // STDBY_XOSC
executeOpcode(OP_RX_TX_FALLBACK_MODE_6X, &fb, 1);
#endif
rxAntEnable();
@ -336,11 +376,20 @@ int sx126x::begin(long frequency) {
setTxPower(2);
enableCrc();
writeRegister(REG_LNA_6X, 0x96); // Set LNA boost
// Undocumented register 0x8B5: setting bit 0 improves RX sensitivity
// on boards with GC1109 PA/LNA (Heltec V4). Patch recommended by
// Heltec engineer, confirmed by MeshCore community testing.
#if HAS_LORA_PA && LORA_PA_GC1109
uint8_t reg8b5 = readRegister(0x08B5);
writeRegister(0x08B5, reg8b5 | 0x01);
#endif
uint8_t basebuf[2] = {0}; // Set base addresses
executeOpcode(OP_BUFFER_BASE_ADDR_6X, basebuf, 2);
setModulationParams(_sf, _bw, _cr, _ldro);
setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode);
optimizeModemSensitivity();
#if HAS_LORA_PA
#if LORA_PA_GC1109
@ -349,25 +398,18 @@ int sx126x::begin(long frequency) {
pinMode(LORA_PA_PWR_EN, OUTPUT);
digitalWrite(LORA_PA_PWR_EN, HIGH);
// Enable PA LNA and TX standby
// CSD: Chip enable - must be HIGH for GC1109 to work
pinMode(LORA_PA_CSD, OUTPUT);
digitalWrite(LORA_PA_CSD, HIGH);
// Keep PA CPS low until actual
// transmit. Does it save power?
// Who knows? Will have to measure.
// Note from the future: Nope.
// Power consumption is the same,
// and turning it on and off is
// not something that it likes.
// Keeping it high for now.
delay(1); // Allow GC1109 FEM time to power up
// CPS held HIGH permanently for now. Per GC1109 datasheet CPS is
// "don't care" in RX (LNA mode: CSD=1, CTX=0, CPS=X), and HIGH
// in TX (PA mode: CSD=1, CTX=1, CPS=1). Keeping it HIGH covers
// both modes. CTX is driven by SX1262 DIO2 automatically.
pinMode(LORA_PA_CPS, OUTPUT);
digitalWrite(LORA_PA_CPS, HIGH);
// On Heltec V4, the PA CTX pin
// is driven by the SX1262 DIO2
// pin directly, so we do not
// need to manually raise this.
#endif
#endif
@ -377,16 +419,6 @@ int sx126x::begin(long frequency) {
void sx126x::end() { sleep(); SPI.end(); _preinit_done = false; }
int sx126x::beginPacket(int implicitHeader) {
#if HAS_LORA_PA
#if LORA_PA_GC1109
// Enable PA CPS for transmit
// digitalWrite(LORA_PA_CPS, HIGH);
// Disabled since we're keeping it
// on permanently as long as the
// radio is powered up.
#endif
#endif
standby();
if (implicitHeader) { implicitHeaderMode(); }
else { explicitHeaderMode(); }
@ -443,21 +475,29 @@ bool sx126x::dcd() {
if ((buf[1] & IRQ_HEADER_DET_MASK_6X) != 0) { header_detected = true; carrier_detected = true; }
else { header_detected = false; }
if ((buf[1] & IRQ_PREAMBLE_DET_MASK_6X) != 0) {
// After a false preamble timeout, ignore new preamble detections
// briefly to prevent noise-driven preamble→timeout→preamble cycles
// that keep carrier_detected true and block CSMA transmissions.
static uint32_t preamble_cooldown_until = 0;
if ((buf[1] & IRQ_PREAMBLE_DET_MASK_6X) != 0 && now >= preamble_cooldown_until) {
carrier_detected = true;
if (preamble_detected_at == 0) { preamble_detected_at = now; }
if (now - preamble_detected_at > lora_preamble_time_ms + lora_header_time_ms) {
preamble_detected_at = 0;
if (!header_detected) { false_preamble_detected = true; }
uint8_t clearbuf[2] = {0};
clearbuf[1] = IRQ_PREAMBLE_DET_MASK_6X;
// Clear all IRQ flags except RX_DONE (bit 1) and CRC_ERR (bit 6)
// to prevent sticky flags (HeaderErr, HeaderValid, Preamble) from
// keeping carrier_detected true and blocking CSMA transmissions.
uint8_t clearbuf[2] = {0x03, 0xBD}; // bits 8-9 + 0-7 except 1,6
executeOpcode(OP_CLEAR_IRQ_STATUS_6X, clearbuf, 2);
// Cooldown: ignore preamble detections for 2x the preamble+header
// time to prevent noise-driven re-detection cycles.
preamble_cooldown_until = now + (lora_preamble_time_ms + lora_header_time_ms) * 2;
}
}
// TODO: Maybe there's a way of unlatching the RSSI
// status without re-activating receive mode?
if (false_preamble_detected) { sx126x_modem.receive(); false_preamble_detected = false; }
if (false_preamble_detected) { false_preamble_detected = false; }
return carrier_detected;
}
@ -594,17 +634,19 @@ void sx126x::onReceive(void(*callback)(int)){
}
void sx126x::receive(int size) {
#if HAS_LORA_PA
#if LORA_PA_GC1109
// Disable PA CPS for receive
// digitalWrite(LORA_PA_CPS, LOW);
// That turned out to be a bad idea.
// The LNA goes wonky if it's toggled
// on and off too quickly. We'll keep
// it on permanently, as long as the
// radio is powered up.
#endif
#endif
// CPS stays HIGH permanently (set in begin())
// Ensure LoRa packet type is set before entering RX.
// On some SX1262 chips, the packet type reverts to GFSK (0x00)
// after calibration despite being set in begin(). This is a
// no-op if already in LoRa mode since SetPacketType only resets
// params when the type actually changes.
if (getPacketType() != MODE_LONG_RANGE_MODE_6X) {
loraMode();
// Re-apply modulation/packet params since SetPacketType
// resets all params to defaults when type changes.
setModulationParams(_sf, _bw, _cr, _ldro);
}
if (size > 0) {
implicitHeaderMode();
@ -641,9 +683,11 @@ void sx126x::enableTCXO() {
#elif BOARD_MODEL == BOARD_TECHO
uint8_t buf[4] = {MODE_TCXO_1_8V_6X, 0x00, 0x00, 0xFF};
#elif BOARD_MODEL == BOARD_HELTEC32_V4
uint8_t buf[4] = {MODE_TCXO_1_8V_6X, 0x00, 0x00, 0xFF};
uint8_t buf[4] = {MODE_TCXO_1_8V_6X, 0x00, 0xC8, 0x00}; // 800ms timeout
#endif
executeOpcode(OP_DIO3_TCXO_CTRL_6X, buf, 4);
delay(10); // Allow TCXO to stabilize
waitOnBusy();
#endif
}
@ -728,8 +772,16 @@ void sx126x::handleLowDataRate() {
else { _ldro = 0x00; lora_low_datarate = false; }
}
// TODO: Check if there's anything the sx1262 can do here
void sx126x::optimizeModemSensitivity(){ }
// SX1262 errata section 15.1: Modulation quality with 500 kHz LoRa BW.
// Register 0x0889 bit 2 must be cleared for 500 kHz, set for all others.
void sx126x::optimizeModemSensitivity(){
uint8_t reg = readRegister(0x0889);
if (getSignalBandwidth() == 500E3) {
writeRegister(0x0889, reg & 0xFB); // clear bit 2
} else {
writeRegister(0x0889, reg | 0x04); // set bit 2
}
}
void sx126x::setSignalBandwidth(long sbw) {
if (sbw <= 7.8E3) { _bw = 0x00; }
@ -787,6 +839,16 @@ void sx126x::dumpRegisters(Stream& out) {
}
void ISR_VECT sx126x::handleDio0Rise() {
// Deferred ISR: no SPI from interrupt context.
// SPI operations in ISR can corrupt the bus on ESP32
// when the main loop is also accessing SPI.
_rx_pending = true;
}
void sx126x::processRxInterrupt() {
if (!_rx_pending) return;
_rx_pending = false;
uint8_t buf[2];
buf[0] = 0x00;
buf[1] = 0x00;
@ -794,14 +856,61 @@ void ISR_VECT sx126x::handleDio0Rise() {
executeOpcode(OP_CLEAR_IRQ_STATUS_6X, buf, 2);
if ((buf[1] & IRQ_PAYLOAD_CRC_ERROR_MASK_6X) == 0) {
_packetIndex = 0;
uint8_t rxbuf[2] = {0}; // Read packet length
executeOpcodeRead(OP_RX_BUFFER_STATUS_6X, rxbuf, 2);
int packetLength = rxbuf[0];
if (_onReceive) { _onReceive(packetLength); }
if (buf[1] & IRQ_RX_DONE_MASK_6X) {
_packetIndex = 0;
uint8_t rxbuf[2] = {0};
executeOpcodeRead(OP_RX_BUFFER_STATUS_6X, rxbuf, 2);
int packetLength = rxbuf[0];
if (_onReceive) { _onReceive(packetLength); }
}
}
}
uint8_t sx126x::getModemStatus() {
// GetStatus (0xC0): status byte is returned during the NOP
// byte after the opcode, not in the data buffer.
waitOnBusy();
uint8_t status;
digitalWrite(_ss, LOW);
SPI.beginTransaction(_spiSettings);
SPI.transfer(OP_STATUS_6X);
status = SPI.transfer(0x00);
SPI.endTransaction();
digitalWrite(_ss, HIGH);
return status;
}
void sx126x::getIrqStatus(uint8_t *buf) {
buf[0] = 0x00;
buf[1] = 0x00;
executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2);
}
void sx126x::getDeviceErrors(uint8_t *buf) {
buf[0] = 0x00;
buf[1] = 0x00;
executeOpcodeRead(OP_GET_DEVICE_ERRORS_6X, buf, 2);
}
uint32_t sx126x::getActualFrequency() {
// Read the 4-byte frequency register at 0x088B-0x088E
uint32_t freq_reg = ((uint32_t)readRegister(0x088B) << 24) |
((uint32_t)readRegister(0x088C) << 16) |
((uint32_t)readRegister(0x088D) << 8) |
readRegister(0x088E);
// Convert: freq_Hz = freq_reg * F_XTAL / 2^25 = freq_reg * 32000000 / 33554432
// Simplify: freq_Hz = freq_reg * 0.95367431640625
return (uint32_t)((uint64_t)freq_reg * 32000000ULL / 33554432ULL);
}
uint8_t sx126x::readPublicRegister(uint16_t address) { return readRegister(address); }
uint8_t sx126x::getPacketType() {
uint8_t buf = 0;
executeOpcodeRead(0x11, &buf, 1); // GetPacketType
return buf;
}
void ISR_VECT sx126x::onDio0Rise() { sx126x_modem.handleDio0Rise(); }
void sx126x::setSPIFrequency(uint32_t frequency) { _spiSettings = SPISettings(frequency, MSBFIRST, SPI_MODE0); }
void sx126x::enableCrc() { _crcMode = 1; setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); }

View file

@ -84,6 +84,16 @@ public:
void readBuffer(uint8_t* buffer, size_t size);
void setPacketParams(long preamble_symbols, uint8_t headermode, uint8_t payload_length, uint8_t crc);
// Deferred ISR processing: call from main loop to handle RX
bool rxPending() { return _rx_pending; }
void processRxInterrupt();
uint8_t getModemStatus();
void getIrqStatus(uint8_t *buf);
void getDeviceErrors(uint8_t *buf);
uint32_t getActualFrequency();
uint8_t readPublicRegister(uint16_t address);
uint8_t getPacketType();
void setModulationParams(uint8_t sf, uint8_t bw, uint8_t cr, int ldro);
// deprecated
@ -138,6 +148,7 @@ private:
uint8_t _packet[255];
bool _preinit_done;
void (*_onReceive)(int);
volatile bool _rx_pending;
};
extern sx126x sx126x_modem;

136
test_kiss.py Normal file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
# Quick KISS protocol smoke test for RNode devices.
# Sends standard KISS commands over USB serial and verifies responses.
#
# Usage:
# python3 test_kiss.py [port]
# make test-kiss PORT=/dev/ttyACM4
import serial
import struct
import sys
import time
FEND = 0xC0
def parse_kiss_frames(data):
frames = []
i = 0
while i < len(data):
if data[i] == 0xC0:
while i < len(data) and data[i] == 0xC0:
i += 1
frame = bytearray()
while i < len(data) and data[i] != 0xC0:
frame.append(data[i])
i += 1
if len(frame) > 0:
frames.append(frame)
else:
i += 1
return frames
def wait_for_cmd(ser, target_cmd, timeout=2.0):
start = time.time()
buf = bytearray()
while time.time() - start < timeout:
chunk = ser.read(256)
if chunk:
buf.extend(chunk)
for f in parse_kiss_frames(buf):
if f[0] == target_cmd:
return f
else:
time.sleep(0.05)
return None
def main():
port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyACM4"
ser = serial.Serial(port, 115200, timeout=0.1)
ser.dtr = False
time.sleep(0.1)
ser.dtr = True
time.sleep(4)
ser.reset_input_buffer()
time.sleep(1)
ser.read(8192)
tests = []
# Batch detect (same sequence rnodeconf uses)
detect_seq = bytes([
FEND, 0x08, 0x73, # CMD_DETECT, DETECT_REQ
FEND, 0x50, 0x00, # CMD_FW_VERSION
FEND, 0x48, 0x00, # CMD_PLATFORM
FEND, 0x49, 0x00, # CMD_MCU
FEND, 0x47, 0x00, # CMD_BOARD
FEND,
])
ser.write(detect_seq)
time.sleep(1)
resp = ser.read(8192)
received = {}
if resp:
for f in parse_kiss_frames(resp):
received[f[0]] = f
checks = [
("DETECT", 0x08, lambda f: f"0x{f[1]:02X}" if len(f) > 1 else "?"),
("FW_VERSION", 0x50, lambda f: f"v{f[1]}.{f[2]:02X}" if len(f) >= 3 else "?"),
("PLATFORM", 0x48, lambda f: f"0x{f[1]:02X}" if len(f) > 1 else "?"),
("MCU", 0x49, lambda f: f"0x{f[1]:02X}" if len(f) > 1 else "?"),
("BOARD", 0x47, lambda f: f"0x{f[1]:02X}" if len(f) > 1 else "?"),
]
for name, cmd, fmt in checks:
f = received.get(cmd)
ok = f is not None
detail = fmt(f) if ok else "no response"
tests.append((name, ok, detail))
# Individual queries
queries = [
("STAT_RX", 0x21),
("STAT_TX", 0x22),
("STAT_GPS", 0x2A),
]
for name, cmd in queries:
ser.write(bytes([FEND, cmd, 0x00, FEND]))
f = wait_for_cmd(ser, cmd, timeout=2.0)
if f:
if cmd == 0x2A and len(f) >= 3:
detail = f"fix={f[1]}, sats={f[2]}"
elif cmd in (0x21, 0x22) and len(f) >= 5:
detail = f"count={int.from_bytes(f[1:5], 'big')}"
else:
detail = f"len={len(f)}"
tests.append((name, True, detail))
else:
tests.append((name, False, "no response"))
# Periodic stats check
ser.reset_input_buffer()
time.sleep(4)
resp = ser.read(8192)
stats = {}
if resp:
for f in parse_kiss_frames(resp):
stats[f[0]] = stats.get(f[0], 0) + 1
periodic_ok = len(stats) > 0
detail = ", ".join(f"0x{k:02X}({v})" for k, v in sorted(stats.items())) if stats else "none"
tests.append(("PERIODIC_STATS", periodic_ok, detail))
ser.close()
# Results
passed = sum(1 for _, ok, _ in tests if ok)
total = len(tests)
print(f"KISS smoke test on {port}: {passed}/{total} passed")
for name, ok, detail in tests:
print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
sys.exit(0 if passed == total else 1)
if __name__ == "__main__":
main()