mirror of
https://github.com/markqvist/RNode_Firmware
synced 2026-08-12 17:39:55 -04:00
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:
parent
180207aa2e
commit
033ddd6757
25 changed files with 3251 additions and 308 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -7,3 +7,6 @@ Release/*.zip
|
|||
Release/*.json
|
||||
Console/build
|
||||
build/*
|
||||
*.svd
|
||||
debug.cfg
|
||||
debug_custom.json
|
||||
|
|
|
|||
209
Beacon.h
Normal file
209
Beacon.h
Normal 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
159
BeaconCrypto.h
Normal 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
|
||||
15
Bluetooth.h
15
Bluetooth.h
|
|
@ -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() {
|
||||
|
|
|
|||
34
Boards.h
34
Boards.h
|
|
@ -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
|
||||
|
|
|
|||
5
Config.h
5
Config.h
|
|
@ -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
151
Display.h
|
|
@ -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
337
Documentation/BEACON.md
Normal 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 |
|
||||
39
Framing.h
39
Framing.h
|
|
@ -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
161
GPS.h
Normal 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
186
IfacAuth.h
Normal 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
805
LxmfBeacon.h
Normal 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
|
||||
38
Makefile
38
Makefile
|
|
@ -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
|
||||
|
|
|
|||
1
Power.h
1
Power.h
|
|
@ -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(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
8
ROM.h
|
|
@ -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
131
RTC.h
Normal 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
|
||||
130
Utilities.h
130
Utilities.h
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
144
flash_parts.sh
Executable 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
217
sx126x.cpp
217
sx126x.cpp
|
|
@ -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, ®_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); }
|
||||
|
|
|
|||
11
sx126x.h
11
sx126x.h
|
|
@ -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
136
test_kiss.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue