feat: add Bluetooth-controlled LoRa OTA

Add the framed BLE mOTA transport and companion control path, complete the full-profile build and runtime fixes, and document and test the OTA workflow.
This commit is contained in:
mikecarper committed 2026-08-27 01:22:18 -07:00
1 parent 91870e4bb0
commit e5fd77e53f
79 files changed
+3178 -304

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace ota {
// MeshCore BLE mOTA seeder service. The device notifies one framed seeder
// request on REQUEST; the paired host writes the framed response to RESPONSE.
// Both characteristics require an encrypted MITM-authenticated BLE link.
static constexpr char BLE_MOTA_SERVICE_UUID[] =
"14518fc2-7e7a-4d84-8cae-6664b0234cf2";
static constexpr char BLE_MOTA_REQUEST_UUID[] =
"2bfaa1ee-7030-459a-b65a-e7cfd5b09735";
static constexpr char BLE_MOTA_RESPONSE_UUID[] =
"acf38a51-dd58-4dce-917f-0b1135e41b1a";
static constexpr uint16_t BLE_MOTA_REQUEST_MAX = 11;
static constexpr uint16_t BLE_MOTA_RESPONSE_MAX = 197;
} // namespace ota
} // namespace mesh
+163
View File
@@ -0,0 +1,163 @@
#pragma once
#include <Arduino.h>
#include <atomic>
#include <stddef.h>
#include <stdint.h>
namespace mesh {
namespace ota {
// A single-producer/single-consumer Stream adapter for the nRF52 BLE mOTA
// GATT service. The BLE callback appends host response fragments while the
// mesh loop synchronously consumes them through SerialMotaSource. Device
// requests travel in the other direction through the bounded send callback.
//
// One seeder response is at most 197 bytes. Keeping 256 bytes here holds a
// complete response while still failing closed on duplicate or injected data.
class BleMotaStream : public Stream {
public:
static constexpr uint16_t RX_CAPACITY = 256;
using SendCallback = size_t (*)(void* context, const uint8_t* data,
size_t length);
BleMotaStream() = default;
void setSender(SendCallback callback, void* context) {
_send_context = context;
_send = callback;
}
void setActive(bool active) {
_active.store(false, std::memory_order_release);
if (active) {
clear();
} else {
invalidateWriters();
}
_overflowed.store(false, std::memory_order_release);
_active.store(active, std::memory_order_release);
}
bool isActive() const {
return _active.load(std::memory_order_acquire);
}
bool overflowed() const {
return _overflowed.load(std::memory_order_acquire);
}
// Called only by the BLE response-characteristic callback. A fragment is
// accepted atomically or rejected in full; a partial frame is never queued.
bool pushRx(const uint8_t* data, size_t length) {
if (!isActive() || data == nullptr || length == 0
|| length >= RX_CAPACITY) {
return false;
}
const uint32_t head_state = _head_state.load(std::memory_order_acquire);
const uint16_t head = static_cast<uint16_t>(head_state);
const uint16_t tail = _tail.load(std::memory_order_acquire);
const uint16_t used = head >= tail ? head - tail
: RX_CAPACITY - (tail - head);
const uint16_t free_bytes = RX_CAPACITY - used - 1;
if (length > free_bytes) {
_overflowed.store(true, std::memory_order_release);
return false;
}
uint16_t cursor = head;
for (size_t i = 0; i < length; ++i) {
_rx[cursor] = data[i];
cursor = static_cast<uint16_t>((cursor + 1) % RX_CAPACITY);
}
const uint32_t next_state = (head_state & 0xFFFF0000UL) | cursor;
uint32_t expected = head_state;
// setActive() changes the generation, and a new session also resets the
// head. The CAS ensures a callback which overlapped either transition
// cannot publish stale bytes into the next source session, even when both
// sessions happened to use the same ring index.
return isActive()
&& _head_state.compare_exchange_strong(
expected, next_state, std::memory_order_release,
std::memory_order_relaxed);
}
int available() override {
if (!isActive()) return 0;
const uint16_t head = static_cast<uint16_t>(
_head_state.load(std::memory_order_acquire));
const uint16_t tail = _tail.load(std::memory_order_relaxed);
return head >= tail ? head - tail : RX_CAPACITY - (tail - head);
}
int read() override {
if (!isActive()) return -1;
const uint16_t tail = _tail.load(std::memory_order_relaxed);
const uint16_t head = static_cast<uint16_t>(
_head_state.load(std::memory_order_acquire));
if (tail == head) return -1;
const uint8_t value = _rx[tail];
_tail.store(static_cast<uint16_t>((tail + 1) % RX_CAPACITY),
std::memory_order_release);
return value;
}
int peek() override {
if (!isActive()) return -1;
const uint16_t tail = _tail.load(std::memory_order_relaxed);
const uint16_t head = static_cast<uint16_t>(
_head_state.load(std::memory_order_acquire));
return tail == head ? -1 : _rx[tail];
}
void flush() override {}
size_t write(uint8_t value) override {
return write(&value, 1);
}
size_t write(const uint8_t* data, size_t length) override {
if (!isActive() || data == nullptr || length == 0 || _send == nullptr) {
return 0;
}
return _send(_send_context, data, length);
}
using Print::write;
private:
static uint16_t nextGeneration(uint32_t state) {
return static_cast<uint16_t>(state >> 16) + 1;
}
void invalidateWriters() {
const uint32_t previous = _head_state.load(std::memory_order_relaxed);
const uint32_t next =
(static_cast<uint32_t>(nextGeneration(previous)) << 16)
| static_cast<uint16_t>(previous);
_head_state.store(next, std::memory_order_release);
}
void clear() {
const uint32_t previous = _head_state.load(std::memory_order_relaxed);
_head_state.store(
static_cast<uint32_t>(nextGeneration(previous)) << 16,
std::memory_order_release);
_tail.store(0, std::memory_order_release);
}
uint8_t _rx[RX_CAPACITY] = {};
// High 16 bits are a source-session generation; low 16 bits are the ring
// head. Packing both into one lock-free nRF52840 atomic prevents an ABA
// commit across clear()/restart.
std::atomic<uint32_t> _head_state{0};
std::atomic<uint16_t> _tail{0};
std::atomic<bool> _active{false};
std::atomic<bool> _overflowed{false};
SendCallback _send = nullptr;
void* _send_context = nullptr;
};
} // namespace ota
} // namespace mesh
+93
View File
@@ -588,5 +588,98 @@ inline bool parseIntegerStrict(const char* text, int32_t& result) {
return true;
}
inline bool splitCommaFieldsStrict(const char* text, char* storage,
size_t storage_size,
const char** fields,
size_t field_count) {
if (text == nullptr || storage == nullptr || storage_size == 0
|| fields == nullptr || field_count == 0) {
return false;
}
const size_t length = strlen(text);
if (length == 0 || length >= storage_size) return false;
memcpy(storage, text, length + 1);
char* cursor = storage;
for (size_t i = 0; i < field_count; ++i) {
fields[i] = cursor;
char* separator = strchr(cursor, ',');
if (i + 1 == field_count) {
if (separator != nullptr) return false;
} else {
if (separator == nullptr) return false;
*separator = 0;
cursor = separator + 1;
}
}
return true;
}
// Parse the radio tuple syntax without libc's optional float-scanf support.
// In particular, newlib-nano nRF52 builds commonly compile `%f` but return no
// conversions unless the much larger _scanf_float implementation is linked.
inline bool parseRadioTupleStrict(const char* text, float& frequency,
float& bandwidth, uint8_t& spreading_factor,
uint8_t& coding_rate) {
char storage[96] = {0};
const char* fields[4] = {nullptr};
if (!splitCommaFieldsStrict(text, storage, sizeof(storage), fields, 4)) {
return false;
}
float parsed_frequency = 0.0f;
float parsed_bandwidth = 0.0f;
int32_t parsed_sf = 0;
int32_t parsed_cr = 0;
if (!parseDecimalStrict(fields[0], parsed_frequency)
|| !parseDecimalStrict(fields[1], parsed_bandwidth)
|| !parseIntegerStrict(fields[2], parsed_sf)
|| !parseIntegerStrict(fields[3], parsed_cr)
|| parsed_sf < 0 || parsed_sf > 255
|| parsed_cr < 0 || parsed_cr > 255) {
return false;
}
frequency = parsed_frequency;
bandwidth = parsed_bandwidth;
spreading_factor = static_cast<uint8_t>(parsed_sf);
coding_rate = static_cast<uint8_t>(parsed_cr);
return true;
}
inline bool parseTemporaryRadioTupleStrict(
const char* text, float& frequency, float& bandwidth,
uint8_t& spreading_factor, uint8_t& coding_rate,
uint32_t& timeout_minutes) {
char storage[96] = {0};
const char* fields[5] = {nullptr};
if (!splitCommaFieldsStrict(text, storage, sizeof(storage), fields, 5)) {
return false;
}
float parsed_frequency = 0.0f;
float parsed_bandwidth = 0.0f;
int32_t parsed_sf = 0;
int32_t parsed_cr = 0;
int32_t parsed_timeout = 0;
if (!parseDecimalStrict(fields[0], parsed_frequency)
|| !parseDecimalStrict(fields[1], parsed_bandwidth)
|| !parseIntegerStrict(fields[2], parsed_sf)
|| !parseIntegerStrict(fields[3], parsed_cr)
|| !parseIntegerStrict(fields[4], parsed_timeout)
|| parsed_sf < 0 || parsed_sf > 255
|| parsed_cr < 0 || parsed_cr > 255
|| parsed_timeout < 0) {
return false;
}
frequency = parsed_frequency;
bandwidth = parsed_bandwidth;
spreading_factor = static_cast<uint8_t>(parsed_sf);
coding_rate = static_cast<uint8_t>(parsed_cr);
timeout_minutes = static_cast<uint32_t>(parsed_timeout);
return true;
}
} // namespace cli
} // namespace mesh
+96 -6
View File
@@ -768,6 +768,11 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
_prefs->system_watchdog_enabled = 1;
memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf));
_prefs->usb_logging_enabled = 1;
#ifdef WITH_RS232_BRIDGE
_prefs->bridge_uart = WITH_RS232_BRIDGE_UART;
#else
_prefs->bridge_uart = 0;
#endif
#ifdef WITH_MQTT_BRIDGE
bool node_prefs_needs_migration = false;
@@ -898,6 +903,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
File file = fs->open(filename);
#endif
if (file) {
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
bool has_runtime_bridge_uart = false;
#endif
// Every supported layout contains the fixed 290-byte common core. Reject
// a truncated in-place write before it can leave strings unterminated or
// feed partial radio values into startup. loadPrefs() rewrites the image.
@@ -1257,6 +1265,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->usb_logging_enabled)) {
file.read((uint8_t *)&_prefs->usb_logging_enabled,
sizeof(_prefs->usb_logging_enabled));
if (file.available() >= (int)sizeof(_prefs->bridge_uart)) {
file.read((uint8_t *)&_prefs->bridge_uart,
sizeof(_prefs->bridge_uart));
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
has_runtime_bridge_uart = true;
#endif
}
}
}
} else if (file.available() > 0) {
@@ -1314,11 +1329,32 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->loop_detect = constrain(_prefs->loop_detect, 0, 3); // LOOP_DETECT_OFF..LOOP_DETECT_STRICT
// sanitise bad bridge pref values
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
if (!has_runtime_bridge_uart) {
// Pre-merge normal repeaters persisted bridge_enabled=1 even though no
// bridge was compiled. Fail safe on the first merged boot instead of
// unexpectedly claiming a UART; the user can explicitly enable it.
_prefs->bridge_enabled = 0;
_com_prefs_needs_upgrade = true;
}
#endif
_prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1);
_prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000);
_prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1);
_prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD);
_prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14);
#ifdef WITH_RS232_BRIDGE
if (_prefs->bridge_uart != WITH_RS232_BRIDGE_UART
#ifdef WITH_RS232_BRIDGE_ALT
&& _prefs->bridge_uart != WITH_RS232_BRIDGE_ALT_UART
#endif
) {
_prefs->bridge_uart = WITH_RS232_BRIDGE_UART;
_com_prefs_needs_upgrade = true;
}
#else
_prefs->bridge_uart = 0;
#endif
_prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1);
_prefs->reboot_interval = constrain(_prefs->reboot_interval, 0, 255);
@@ -1538,6 +1574,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) {
WRITE_COMMON_PREFS(&prefs->extra_sf); // 856
WRITE_COMMON_PREFS(&prefs->radio_fem_txgain); // 860
WRITE_COMMON_PREFS(&prefs->usb_logging_enabled); // 861
WRITE_COMMON_PREFS(&prefs->bridge_uart); // 862
#undef WRITE_COMMON_PREFS_BYTES
#undef WRITE_COMMON_PREFS
@@ -1690,7 +1727,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
file.write((uint8_t *)_prefs->extra_sf, sizeof(_prefs->extra_sf)); // 856
file.write((uint8_t *)&_prefs->radio_fem_txgain, sizeof(_prefs->radio_fem_txgain)); // 860
file.write((uint8_t *)&_prefs->usb_logging_enabled, sizeof(_prefs->usb_logging_enabled)); // 861
// next: 862
file.write((uint8_t *)&_prefs->bridge_uart, sizeof(_prefs->bridge_uart)); // 862
// next: 863
#if defined(NRF52_PLATFORM)
if (!file.commit()) {
@@ -2538,7 +2576,15 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "gps_interval must be 0..86400 seconds");
} else if (!valid_gps_toggle) {
strcpy(reply, "gps must be 0 or 1");
} else if (_sensors->setSettingValue(key, value)) {
}
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
else if (is_gps_toggle && strcmp(value, "1") == 0
&& _prefs->bridge_enabled
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
strcpy(reply, "turn the RS232 bridge off or select another UART first");
}
#endif
else if (_sensors->setSettingValue(key, value)) {
if (is_gps_interval) {
_prefs->gps_interval = gps_interval;
savePrefs();
@@ -2579,6 +2625,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
handleRegionCmd(command, reply);
#if ENV_INCLUDE_GPS == 1
} else if (memcmp(command, "gps on", 6) == 0) {
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
if (_prefs->bridge_enabled
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
strcpy(reply, "turn the RS232 bridge off or select another UART first");
} else
#endif
if (_sensors->setSettingValue("gps", "1")) {
_prefs->gps_enabled = 1;
savePrefs();
@@ -4019,10 +4071,19 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
}
#ifdef WITH_BRIDGE
} else if (memcmp(config, "bridge.enabled ", 15) == 0) {
_prefs->bridge_enabled = memcmp(&config[15], "on", 2) == 0;
_callbacks->setBridgeState(_prefs->bridge_enabled);
savePrefs();
strcpy(reply, "OK");
const bool enable = memcmp(&config[15], "on", 2) == 0;
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
if (enable && _prefs->gps_enabled
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
strcpy(reply, "Error: turn GPS off or select another UART first");
} else
#endif
{
_prefs->bridge_enabled = enable;
_callbacks->setBridgeState(_prefs->bridge_enabled);
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "bridge.delay ", 13) == 0) {
int delay = _atoi(&config[13]);
if (delay >= 0 && delay <= 10000) {
@@ -4059,6 +4120,33 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
} else {
sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD);
}
} else if (memcmp(config, "bridge.uart ", 12) == 0) {
const int uart = atoi(&config[12]);
const bool supported = uart == WITH_RS232_BRIDGE_UART
#ifdef WITH_RS232_BRIDGE_ALT
|| uart == WITH_RS232_BRIDGE_ALT_UART
#endif
;
if (!supported) {
#ifdef WITH_RS232_BRIDGE_ALT
sprintf(reply, "Error: UART must be %d or %d",
WITH_RS232_BRIDGE_UART, WITH_RS232_BRIDGE_ALT_UART);
#else
sprintf(reply, "Error: UART is fixed at %d", WITH_RS232_BRIDGE_UART);
#endif
}
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
else if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART
&& _prefs->gps_enabled && _prefs->bridge_enabled) {
strcpy(reply, "Error: turn GPS or the bridge off first");
}
#endif
else {
_prefs->bridge_uart = (uint8_t)uart;
_callbacks->restartBridge();
savePrefs();
strcpy(reply, "OK");
}
#endif
#ifdef WITH_ESPNOW_BRIDGE
} else if (memcmp(config, "bridge.channel ", 15) == 0) {
@@ -4453,6 +4541,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
#ifdef WITH_RS232_BRIDGE
} else if (memcmp(config, "bridge.baud", 11) == 0) {
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_baud);
} else if (memcmp(config, "bridge.uart", 11) == 0) {
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_uart);
#endif
#ifdef WITH_ESPNOW_BRIDGE
} else if (memcmp(config, "bridge.channel", 14) == 0) {
+10 -2
View File
@@ -25,6 +25,10 @@
#define WITH_BRIDGE
#endif
#if defined(WITH_RS232_BRIDGE) && !defined(WITH_RS232_BRIDGE_UART)
#define WITH_RS232_BRIDGE_UART 1
#endif
#define ADVERT_LOC_NONE 0
#define ADVERT_LOC_SHARE 1
#define ADVERT_LOC_PREFS 2
@@ -169,9 +173,12 @@ public:
uint8_t extra_sf[4] = {};
uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain; persisted at /com_prefs offset 860
// Runtime USB packet output gate. Appended at /com_prefs offset 861 so
// older images remain readable and logging builds keep their historical
// enabled-at-first-boot behavior.
// older images remain readable and merged standard builds keep the former
// logging artifact's enabled-at-first-boot behavior.
uint8_t usb_logging_enabled = 1;
// Runtime UART choice for merged RS-232 repeater artifacts. Appended at
// /com_prefs offset 862; single-UART builds keep their compiled port here.
uint8_t bridge_uart = 0;
uint8_t retry_preset = 0;
uint8_t direct_retry_attempts = 0;
uint16_t direct_retry_base_ms = 0;
@@ -267,6 +274,7 @@ private:
def("delay", _parent->bridge_delay);
def("src", _parent->bridge_pkt_src);
def("baud", _parent->bridge_baud);
def("uart", _parent->bridge_uart);
def("ch", _parent->bridge_channel);
def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret));
def("usb_log", _parent->usb_logging_enabled);
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
namespace mesh {
namespace companion {
static constexpr uint8_t CMD_EXEC_LOCAL_OTA_CONTROL = 0x4A;
static constexpr uint8_t CMD_BLE_MOTA_SOURCE = 0x4B;
enum class MotaSourceAction : uint8_t {
Status = 0,
Start = 1,
Stop = 2,
};
static constexpr uint8_t MOTA_SOURCE_FLAG_CHANNEL_READY = 0x01;
static constexpr uint8_t MOTA_SOURCE_FLAG_ATTACHED = 0x02;
static constexpr uint8_t MOTA_SOURCE_FLAG_ANOTHER_LINK_ACTIVE = 0x04;
struct MotaSourceStatus {
bool channel_ready = false;
bool attached = false;
bool another_link_active = false;
uint16_t offered = 0;
uint16_t advertised = 0;
uint32_t packets_sent = 0;
};
class MotaSourceControl {
public:
virtual ~MotaSourceControl() = default;
virtual bool start(char* reply, size_t reply_size) = 0;
virtual bool stop(char* reply, size_t reply_size) = 0;
virtual MotaSourceStatus status() const = 0;
};
// Bluetooth may expose only the local commands needed to coordinate a LoRa
// mOTA session. The command arrives as one length-delimited Companion frame,
// so reject embedded NUL/control bytes and USB folder ownership commands.
inline bool isBleOtaControlCommandAllowed(const uint8_t* command,
size_t length) {
if (command == nullptr || length == 0 || length > 174) return false;
for (size_t i = 0; i < length; ++i) {
if (command[i] < 0x20 || command[i] > 0x7E) return false;
switch (command[i]) {
case ';':
case '&':
case '|':
case '`':
case '$':
case '\\':
case '\'':
case '"':
return false;
default:
break;
}
}
const auto exact = [command, length](const char* text, size_t text_length) {
if (length != text_length) return false;
for (size_t i = 0; i < length; ++i) {
if (command[i] != static_cast<uint8_t>(text[i])) return false;
}
return true;
};
const auto token = [command, length](const char* text, size_t text_length) {
if (length < text_length) return false;
for (size_t i = 0; i < text_length; ++i) {
if (command[i] != static_cast<uint8_t>(text[i])) return false;
}
return length == text_length || command[text_length] == ' ';
};
if (exact("normalradio", 11)) return true;
// Bare `tempradio` is a read-only status query. Keep the parameterized
// form strict, but let a BLE controller prove that cleanup completed.
if (exact("tempradio", 9)) return true;
if (token("tempradio", 9)) return length > 10;
if (!token("ota", 3)) return false;
if (token("ota folder", 10)) return false;
return true;
}
} // namespace companion
} // namespace mesh
+2 -2
View File
@@ -34,8 +34,8 @@
namespace mesh {
#if defined(COMPANION_RADIO_FULL)
// Full Companion owns its primary USB stream for framed traffic until saved
#if defined(ENABLE_USB_INTERFACE)
// A USB Companion owns its primary stream for framed traffic until saved
// preferences are loaded. Starting disabled prevents early boot diagnostics
// from corrupting that stream before single-TTY builds can enter terminal mode.
static std::atomic<bool> usb_logging_enabled{false};
+5 -5
View File
@@ -1,8 +1,8 @@
#pragma once
// Logging artifacts compile at least one of these two diagnostics. Keep the
// command surface out of ordinary images while providing one runtime gate for
// every diagnostic category that writes to the USB Serial stream.
// Canonical USB-capable images compile at least one of these two diagnostics.
// One runtime gate covers every diagnostic category that writes to the USB
// Serial stream, so a separate logging firmware is unnecessary.
#if defined(ARDUINO) && \
((defined(MESH_DEBUG) && MESH_DEBUG) || \
(defined(MESH_PACKET_LOGGING) && MESH_PACKET_LOGGING))
@@ -16,8 +16,8 @@
namespace mesh {
// Ordinary logging images start enabled. Every Full Companion starts with
// logging disabled and restores its saved choice after preferences load.
// Ordinary merged images start enabled. USB/Full Companion starts disabled to
// protect framed traffic and restores its saved choice after preferences load.
bool isUsbLoggingEnabled();
void setUsbLoggingEnabled(bool enabled);
+6
View File
@@ -251,6 +251,12 @@ inline bool isFirmwareReserved(uint8_t pin) {
#ifdef WITH_RS232_BRIDGE_TX
WITH_RS232_BRIDGE_TX,
#endif
#ifdef WITH_RS232_BRIDGE_ALT_RX
WITH_RS232_BRIDGE_ALT_RX,
#endif
#ifdef WITH_RS232_BRIDGE_ALT_TX
WITH_RS232_BRIDGE_ALT_TX,
#endif
#ifdef PIN_SERIAL_RX
PIN_SERIAL_RX,
#endif
+11 -13
View File
@@ -5,30 +5,28 @@
#ifdef WITH_RS232_BRIDGE
RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, mesh::PacketManager *mgr, mesh::RTCClock *rtc)
: BridgeBase(prefs, mgr, rtc), _serial(&serial) {}
RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin,
int16_t tx_pin, mesh::PacketManager *mgr,
mesh::RTCClock *rtc)
: BridgeBase(prefs, mgr, rtc), _serial(&serial), _rx_pin(rx_pin),
_tx_pin(tx_pin) {}
void RS232Bridge::begin() {
BRIDGE_DEBUG_PRINTLN("Initializing at %d baud...\n", _prefs->bridge_baud);
#if !defined(WITH_RS232_BRIDGE_RX) || !defined(WITH_RS232_BRIDGE_TX)
#error "WITH_RS232_BRIDGE_RX and WITH_RS232_BRIDGE_TX must be defined"
#endif
#if defined(ESP32)
((HardwareSerial *)_serial)->setPins(WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX);
((HardwareSerial *)_serial)->setPins(_rx_pin, _tx_pin);
#elif defined(NRF52_PLATFORM)
// Tested with RAK_4631 and T114
// The Adafruit Uart object may already be active on its variant defaults.
// Stop it before changing pins or the EasyDMA instance can retain the old
// pin selection and silently receive nothing.
mesh::bridge::prepareNrfUart(*((Uart *)_serial),
WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX);
mesh::bridge::prepareNrfUart(*((Uart *)_serial), _rx_pin, _tx_pin);
#elif defined(RP2040_PLATFORM)
((SerialUART *)_serial)->setRX(WITH_RS232_BRIDGE_RX);
((SerialUART *)_serial)->setTX(WITH_RS232_BRIDGE_TX);
((SerialUART *)_serial)->setRX(_rx_pin);
((SerialUART *)_serial)->setTX(_tx_pin);
#elif defined(STM32_PLATFORM)
((HardwareSerial *)_serial)->setRx(WITH_RS232_BRIDGE_RX);
((HardwareSerial *)_serial)->setTx(WITH_RS232_BRIDGE_TX);
((HardwareSerial *)_serial)->setRx(_rx_pin);
((HardwareSerial *)_serial)->setTx(_tx_pin);
#else
#error RS232Bridge was not tested on the current platform
#endif
+6 -5
View File
@@ -18,7 +18,7 @@
* - Fletcher-16 checksum for data integrity verification
* - Magic header for packet synchronization and frame alignment
* - Duplicate packet detection using SimpleMeshTables tracking
* - Configurable RX/TX pins via build defines
* - Configurable UART and RX/TX pins selected by the owning runtime
* - Fixed baud rate at 115200 for consistent timing
*
* Packet Structure:
@@ -34,8 +34,7 @@
*
* Configuration:
* - Define WITH_RS232_BRIDGE to enable this bridge
* - Define WITH_RS232_BRIDGE_RX with the RX pin number
* - Define WITH_RS232_BRIDGE_TX with the TX pin number
* - The owning role supplies the selected UART and pins to the constructor
*
* Platform Support:
* Different platforms require different pin configuration methods:
@@ -54,12 +53,12 @@ public:
* @param mgr PacketManager for allocating and queuing packets
* @param rtc RTCClock for timestamping debug messages
*/
RS232Bridge(NodePrefs *prefs, Stream &serial, mesh::PacketManager *mgr, mesh::RTCClock *rtc);
RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin, int16_t tx_pin,
mesh::PacketManager *mgr, mesh::RTCClock *rtc);
/**
* Initializes the RS232 bridge
*
* - Validates that RX/TX pins are defined
* - Configures UART pins based on target platform
* - Sets baud rate to 115200 for consistent communication
* - Platform-specific pin configuration methods are used
@@ -137,6 +136,8 @@ private:
/** Hardware serial port interface */
Stream *_serial;
int16_t _rx_pin;
int16_t _tx_pin;
/** Buffer for building received packets */
uint8_t _rx_buffer[MAX_SERIAL_PACKET_SIZE];
+7
View File
@@ -67,9 +67,16 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pi
BLESecurity sec;
#if defined(CONFIG_NIMBLE_ENABLED)
sec.setPassKey(true, pin_code);
// A passkey alone does not provide MITM protection when the controller's
// default capability is NoInputNoOutput: the peers can silently fall back
// to Just Works and create an unauthenticated bond. The Companion displays
// (or otherwise publishes) this PIN for entry on the phone/host, so declare
// the peripheral as DisplayOnly and force the passkey-entry association.
sec.setCapability(ESP_IO_CAP_OUT);
sec.setAuthenticationMode(true, true, true);
#else
sec.setStaticPIN(pin_code);
sec.setCapability(ESP_IO_CAP_OUT);
sec.setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND);
#endif
+84
View File
@@ -40,6 +40,9 @@ void SerialBLEInterface::onConnect(uint16_t connection_handle) {
instance->_isDeviceConnected = false;
instance->_security_timer.start(millis());
instance->clearBuffers();
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
instance->setMotaStreamActive(false);
#endif
}
}
@@ -52,10 +55,46 @@ void SerialBLEInterface::onDisconnect(uint16_t connection_handle, uint8_t reason
instance->_isDeviceConnected = false;
instance->_security_timer.cancel();
instance->clearBuffers();
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
instance->setMotaStreamActive(false);
#endif
}
}
}
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
void SerialBLEInterface::onMotaResponse(
uint16_t conn_handle, BLECharacteristic* characteristic,
uint8_t* data, uint16_t length) {
if (!instance || characteristic != &instance->_mota_response
|| instance->_conn_handle != conn_handle || !instance->isConnected()
|| !instance->_mota_stream.isActive()) {
return;
}
if (length == 0 || length > mesh::ota::BLE_MOTA_RESPONSE_MAX
|| !instance->_mota_stream.pushRx(data, length)) {
// A response that cannot fit intact would make the byte stream ambiguous.
// Disable the link so the current transaction times out and the main loop
// detaches it instead of consuming a partial or injected frame.
instance->setMotaStreamActive(false);
}
}
size_t SerialBLEInterface::sendMotaRequest(void* context,
const uint8_t* data,
size_t length) {
SerialBLEInterface* self = static_cast<SerialBLEInterface*>(context);
if (!self || !data || length == 0
|| length > mesh::ota::BLE_MOTA_REQUEST_MAX
|| !self->isMotaChannelReady() || !self->_mota_stream.isActive()) {
return 0;
}
return self->_mota_request.notify(self->_conn_handle, data, length)
? length : 0;
}
#endif
void SerialBLEInterface::onSecured(uint16_t connection_handle) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: onSecured handle=0x%04X", connection_handle);
if (instance) {
@@ -272,6 +311,38 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pi
bleuart.begin();
bleuart.setRxCallback(onBleUartRX);
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
_mota_stream.setSender(sendMotaRequest, this);
_mota_stream.setActive(false);
_mota_service.setPermission(SECMODE_ENC_WITH_MITM,
SECMODE_ENC_WITH_MITM);
if (_mota_service.begin() != ERROR_NONE) {
BLE_DEBUG_PRINTLN("Bluetooth mOTA service begin failed");
return false;
}
_mota_request.setProperties(CHR_PROPS_NOTIFY);
_mota_request.setPermission(SECMODE_ENC_WITH_MITM,
SECMODE_NO_ACCESS);
_mota_request.setMaxLen(mesh::ota::BLE_MOTA_REQUEST_MAX);
_mota_request.setUserDescriptor("mOTA device request");
if (_mota_request.begin() != ERROR_NONE) {
BLE_DEBUG_PRINTLN("Bluetooth mOTA request characteristic begin failed");
return false;
}
_mota_response.setProperties(CHR_PROPS_WRITE);
_mota_response.setPermission(SECMODE_NO_ACCESS,
SECMODE_ENC_WITH_MITM);
_mota_response.setMaxLen(mesh::ota::BLE_MOTA_RESPONSE_MAX);
_mota_response.setUserDescriptor("mOTA host response");
_mota_response.setWriteCallback(onMotaResponse);
if (_mota_response.begin() != ERROR_NONE) {
BLE_DEBUG_PRINTLN("Bluetooth mOTA response characteristic begin failed");
return false;
}
#endif
// Register DFU on the main BLE stack so paired clients can discover it
@@ -390,6 +461,9 @@ void SerialBLEInterface::recoverStalledTx(const char* cause) {
_last_retry_attempt = 0;
_tx_stall_watchdog.reset();
bleuart.flush();
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
setMotaStreamActive(false);
#endif
_tx_disconnect_recovery.begin();
serviceTxRecovery((uint32_t)millis());
}
@@ -442,6 +516,9 @@ void SerialBLEInterface::disable() {
disconnect();
_security_timer.cancel();
_last_health_check = 0;
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
setMotaStreamActive(false);
#endif
}
size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
@@ -610,6 +687,13 @@ bool SerialBLEInterface::isConnected() const {
Bluefruit.connected() > 0;
}
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
bool SerialBLEInterface::isMotaChannelReady() {
return isConnected() && _conn_handle != BLE_CONN_HANDLE_INVALID
&& _mota_request.notifyEnabled(_conn_handle);
}
#endif
bool SerialBLEInterface::isReadBusy() const {
return (recv_queue_len > 0);
}
+25
View File
@@ -6,6 +6,10 @@
#include "SecuritySessionTimer.h"
#include <bluefruit.h>
#include <atomic>
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
#include "../BleMotaProtocol.h"
#include "../BleMotaStream.h"
#endif
#ifndef BLE_TX_POWER
#define BLE_TX_POWER 4
@@ -14,6 +18,14 @@
class SerialBLEInterface : public BaseSerialInterface {
BLEDfu bledfu;
BLEUart bleuart;
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
BLEService _mota_service = BLEService(mesh::ota::BLE_MOTA_SERVICE_UUID);
BLECharacteristic _mota_request =
BLECharacteristic(mesh::ota::BLE_MOTA_REQUEST_UUID);
BLECharacteristic _mota_response =
BLECharacteristic(mesh::ota::BLE_MOTA_RESPONSE_UUID);
mesh::ota::BleMotaStream _mota_stream;
#endif
bool _isEnabled;
bool _isDeviceConnected;
uint16_t _conn_handle;
@@ -56,6 +68,13 @@ class SerialBLEInterface : public BaseSerialInterface {
static void onPairingComplete(uint16_t connection_handle, uint8_t auth_status);
static void onBLEEvent(ble_evt_t* evt);
static void onBleUartRX(uint16_t conn_handle);
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
static void onMotaResponse(uint16_t conn_handle,
BLECharacteristic* characteristic,
uint8_t* data, uint16_t length);
static size_t sendMotaRequest(void* context, const uint8_t* data,
size_t length);
#endif
public:
SerialBLEInterface() {
@@ -91,6 +110,12 @@ public:
}
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
Stream& motaStream() { return _mota_stream; }
bool isMotaChannelReady();
bool isMotaStreamActive() const { return _mota_stream.isActive(); }
void setMotaStreamActive(bool active) { _mota_stream.setActive(active); }
#endif
};
#if BLE_DEBUG_LOGGING && ARDUINO
+2 -1
View File
@@ -11,6 +11,7 @@ bool FolderMotaStore::readByteT(uint8_t& b) const {
while ((millis() - t0) < _to) {
int c = _io.read();
if (c >= 0) { b = (uint8_t)c; return true; }
delay(1); // let BLE/WiFi callbacks deliver the response
}
return false;
}
@@ -43,7 +44,7 @@ bool FolderMotaStore::txn(uint8_t op, const uint8_t* args, uint16_t arglen,
uint32_t t0 = millis(); bool got = false; uint8_t prev = 0;
while ((millis() - t0) < _to) { // scan for response magic 'm' 's' (tolerate noise)
int c = _io.read();
if (c < 0) continue;
if (c < 0) { delay(1); continue; }
if (prev == MOTA_SEEDER_RSP_MAGIC0 && (uint8_t)c == MOTA_SEEDER_RSP_MAGIC1) { got = true; break; }
prev = (uint8_t)c;
}
+2 -1
View File
@@ -11,6 +11,7 @@ bool SerialMotaSource::readByteT(uint8_t& b) {
while ((millis() - t0) < _to) {
int c = _io.read();
if (c >= 0) { b = (uint8_t)c; return true; }
delay(1); // let BLE/WiFi callbacks deliver the response
}
return false;
}
@@ -43,7 +44,7 @@ bool SerialMotaSource::txn(uint8_t op, const uint8_t* args, uint8_t arglen,
uint8_t prev = 0;
while ((millis() - t0) < _to) {
int c = _io.read();
if (c < 0) continue;
if (c < 0) { delay(1); continue; }
if (prev == MOTA_SEEDER_RSP_MAGIC0 && (uint8_t)c == MOTA_SEEDER_RSP_MAGIC1) { got = true; break; }
prev = (uint8_t)c;
}
+10 -1
View File
@@ -321,8 +321,17 @@ struct OtaContext {
FOLDER_LINK_NONE = 0,
FOLDER_LINK_SERIAL,
FOLDER_LINK_TCP,
FOLDER_LINK_BLE,
};
FolderLink folderLink() const { return _folder_link; }
static const char* folderLinkName(FolderLink link) {
switch (link) {
case FOLDER_LINK_SERIAL: return "serial";
case FOLDER_LINK_TCP: return "tcp";
case FOLDER_LINK_BLE: return "ble";
default: return "none";
}
}
bool folderSourceStats(uint16_t& offered, uint16_t& advertised) const {
return manager.sourceStats(_folder_source, offered, advertised);
}
@@ -337,7 +346,7 @@ struct OtaContext {
}
if (folder_active && _folder_link != link) {
snprintf(msg, cap, "ERR folder already attached via %s",
_folder_link == FOLDER_LINK_TCP ? "tcp" : "serial");
folderLinkName(_folder_link));
return false;
}
if (folder_active && _folder_source == source) {
+5 -1
View File
@@ -447,6 +447,7 @@ public:
FetchError fetchError() const { return _fetch_error; }
uint32_t blocksHave() const { return _have; }
uint32_t blocksTotal() const { return _fbc; }
uint32_t packetsSent() const { return _packets_sent; }
uint8_t fetchPipelineWidth() const { return _pipeline_width; }
static constexpr uint8_t fetchPipelineCapacity() { return OTA_FETCH_PIPELINE; }
uint32_t fetchRetryTimeoutMs() const;
@@ -482,7 +483,9 @@ private:
bool expandCatalog();
bool emit(const uint8_t* b, uint16_t n, bool flood) {
return _send && n && _send(_ctx, b, n, flood);
const bool sent = _send && n && _send(_ctx, b, n, flood);
if (sent) _packets_sent++;
return sent;
}
void handleAdv(const uint8_t* m, uint16_t n); // beacon -> sources table (+ query if interested)
void handleQuery(const uint8_t* m, uint16_t n); // serve: reply OTA_HAVE catalog
@@ -655,6 +658,7 @@ private:
uint32_t _radio_packet_airtime_ms = 0; // measured for MAX_TRANS_UNIT at active SF/BW
uint16_t _tx_spacing_permille = 2000; // 1/duty-cycle; default airtime factor 1 => 50% TX
uint8_t _observed_path_transmissions = 0; // source + relays; 0 falls back to configured max_hops+1
uint32_t _packets_sent = 0; // OTA packets accepted by the radio adapter (wrap-safe)
// multi-fragment manifest reassembly (a signed v2 manifest exceeds one packet)
uint8_t _mf_buf[OTA_MF_MAXFRAG * OTA_MF_FRAG]; // sized to the fragment cap so no valid manifest is silently dropped
uint16_t _mf_retries = 0; // GET_MANIFEST retries while WANT_MANIFEST (give up after a cap)
+6
View File
@@ -15,6 +15,7 @@ class DisplayDriver {
int _w, _h;
protected:
DisplayDriver(int w, int h) { _w = w; _h = h; }
void setDimensions(int w, int h) { _w = w; _h = h; }
static size_t trimLastUTF8Codepoint(char* str, size_t length) {
if (length == 0) return 0;
@@ -30,6 +31,11 @@ public:
int height() const { return _h; }
virtual bool isOn() = 0;
virtual bool supportsRotation() const { return false; }
virtual bool setRotationDegrees(uint16_t degrees) {
(void)degrees;
return false;
}
virtual bool isEink() { return false; } // default to non-eink, override in eink drivers
virtual void forceFullRefresh() {} // next refresh will be full for eink
virtual void turnOn() = 0;
+28 -4
View File
@@ -22,10 +22,34 @@ bool SSD1306Display::begin() {
if (_peripher_power) _peripher_power->claim();
_isOn = true;
}
#ifdef DISPLAY_ROTATION
display.setRotation(DISPLAY_ROTATION);
#endif
return display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS, true, false) && i2c_probe(Wire, DISPLAY_ADDRESS);
const bool started = display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS,
true, false)
&& i2c_probe(Wire, DISPLAY_ADDRESS);
if (started) setRotationDegrees(0);
return started;
}
bool SSD1306Display::setRotationDegrees(uint16_t degrees) {
uint8_t rotation;
switch (degrees) {
case 0:
#ifdef DISPLAY_ROTATION
rotation = DISPLAY_ROTATION & 3;
#else
rotation = 0;
#endif
break;
case 90: rotation = 1; break;
case 180: rotation = 2; break;
case 270: rotation = 3; break;
default: return false;
}
display.setRotation(rotation);
setDimensions(display.width(), display.height());
display.clearDisplay();
display.display();
return true;
}
void SSD1306Display::turnOn() {
+2
View File
@@ -32,6 +32,8 @@ public:
bool begin();
bool isOn() override { return _isOn; }
bool supportsRotation() const override { return true; }
bool setRotationDegrees(uint16_t degrees) override;
void turnOn() override;
void turnOff() override;
void clear() override;