diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 8772b929fe..d3759f079b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1117,7 +1117,7 @@ region save --- -#### View or change the channel used for bridging (ESPNow only) +#### View or change the channel used for bridging (wireless bridges only) **Usage:** - `get bridge.channel` - `set bridge.channel ` @@ -1127,13 +1127,13 @@ region save --- -#### Set the ESP-Now secret +#### Set the wireless bridge secret **Usage:** - `get bridge.secret` - `set bridge.secret ` **Parameters:** -- `secret`: ESP-NOW bridge secret, up to 15 characters +- `secret`: bridge secret, up to 15 characters **Default:** Varies by board diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7d0179f3ab..3f8f0ff0be 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -198,6 +198,8 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender reply_data[8] |= 0x01; // is bridge, type UART #elif WITH_ESPNOW_BRIDGE reply_data[8] |= 0x03; // is bridge, type ESP-NOW +#elif WITH_NRF_BRIDGE + reply_data[8] |= 0x05; // is bridge, type nRF 2.4GHz #endif if (_prefs.disable_fwd) { // is this repeater currently disabled reply_data[8] |= 0x80; // is disabled @@ -872,6 +874,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #if defined(WITH_ESPNOW_BRIDGE) , bridge(&_prefs, _mgr, &rtc) #endif +#if defined(WITH_NRF_BRIDGE) + , bridge(&_prefs, _mgr, &rtc) +#endif { last_millis = 0; uptime_millis = 0; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 04bd4fb928..c0bb33a4e7 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -24,6 +24,11 @@ #define WITH_BRIDGE #endif +#ifdef WITH_NRF_BRIDGE +#include "helpers/bridges/NRFRadioBridge.h" +#define WITH_BRIDGE +#endif + #include #include #include @@ -118,6 +123,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { RS232Bridge bridge; #elif defined(WITH_ESPNOW_BRIDGE) ESPNowBridge bridge; +#elif defined(WITH_NRF_BRIDGE) + NRFRadioBridge bridge; #endif void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); diff --git a/platformio.ini b/platformio.ini index e78124a40b..18dbb9bea6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -51,6 +51,7 @@ build_src_filter = + + + + + + ; ----------------- ESP32 --------------------- @@ -170,6 +171,7 @@ build_src_filter = +<../src/Utils.cpp> +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/bridges/NRFRadioChannel.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b318bb58e8..b39582aa30 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -207,6 +207,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, "ERR: clock cannot go backwards"); } } else if (memcmp(command, "start ota", 9) == 0) { + // OTA needs the radio the bridge is holding: the SoftDevice claims the nRF + // RADIO, and on ESP32 the update wants the Wi-Fi interface ESP-NOW is on. + _callbacks->setBridgeState(false); if (!_board->startOTAUpdate(_prefs->node_name, reply)) { strcpy(reply, "Error"); } @@ -746,7 +749,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD); } #endif -#ifdef WITH_ESPNOW_BRIDGE +#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_NRF_BRIDGE) } else if (memcmp(config, "bridge.channel ", 15) == 0) { int ch = atoi(&config[15]); if (ch > 0 && ch < 15) { @@ -910,6 +913,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep "rs232" #elif WITH_ESPNOW_BRIDGE "espnow" +#elif WITH_NRF_BRIDGE + "nrf" #else "none" #endif @@ -926,7 +931,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (memcmp(config, "bridge.baud", 11) == 0) { sprintf(reply, "> %d", (uint32_t)_prefs->bridge_baud); #endif -#ifdef WITH_ESPNOW_BRIDGE +#if defined(WITH_ESPNOW_BRIDGE) || defined(WITH_NRF_BRIDGE) } else if (memcmp(config, "bridge.channel", 14) == 0) { sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel); } else if (memcmp(config, "bridge.secret", 13) == 0) { diff --git a/src/helpers/bridges/NRFRadioBridge.cpp b/src/helpers/bridges/NRFRadioBridge.cpp new file mode 100644 index 0000000000..2db3156ad1 --- /dev/null +++ b/src/helpers/bridges/NRFRadioBridge.cpp @@ -0,0 +1,362 @@ +#include "NRFRadioBridge.h" + +#ifdef WITH_NRF_BRIDGE + +#include +#include +#include +#include + +// Static member to handle the interrupt +NRFRadioBridge *NRFRadioBridge::_instance = nullptr; + +// Bounded, so a peripheral that never reports ready cannot hang the node. Both +// events are specified in microseconds, so these are generous. +static const uint32_t HFCLK_START_SPINS = 100000; +static const uint32_t RADIO_DISABLE_SPINS = 100000; + +// The RADIO vector. Only the SoftDevice would otherwise claim it, and begin() +// refuses to run while the SoftDevice is enabled. +extern "C" void RADIO_IRQHandler(void) { + NRFRadioBridge::radio_isr(); +} + +void NRFRadioBridge::radio_isr() { + if (_instance) { + _instance->onRadioEvent(); + } +} + +NRFRadioBridge::NRFRadioBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) + : BridgeBase(prefs, mgr, rtc), _radio_state(RADIO_OFF), _rx_head(0), _rx_tail(0) { + _instance = this; +} + +bool NRFRadioBridge::startHighFreqClock() { + const uint32_t running = (CLOCK_HFCLKSTAT_STATE_Running << CLOCK_HFCLKSTAT_STATE_Pos) | + (CLOCK_HFCLKSTAT_SRC_Xtal << CLOCK_HFCLKSTAT_SRC_Pos); + const uint32_t mask = CLOCK_HFCLKSTAT_STATE_Msk | CLOCK_HFCLKSTAT_SRC_Msk; + + // Re-triggering the task with the crystal already running produces no + // HFCLKSTARTED event, so the wait below would spin out for nothing. That is the + // path `set bridge.channel` takes when it restarts the bridge. + if ((NRF_CLOCK->HFCLKSTAT & mask) == running) return true; + + NRF_CLOCK->EVENTS_HFCLKSTARTED = 0; + NRF_CLOCK->TASKS_HFCLKSTART = 1; + for (uint32_t i = 0; i < HFCLK_START_SPINS && NRF_CLOCK->EVENTS_HFCLKSTARTED == 0; i++) { + __NOP(); + } + + return (NRF_CLOCK->HFCLKSTAT & mask) == running; +} + +void NRFRadioBridge::configureRadio(uint8_t frequency) { + NRF_RADIO->MODE = RADIO_MODE_MODE_Nrf_1Mbit << RADIO_MODE_MODE_Pos; + NRF_RADIO->FREQUENCY = frequency; + +#if defined(RADIO_TXPOWER_TXPOWER_Pos8dBm) + NRF_RADIO->TXPOWER = RADIO_TXPOWER_TXPOWER_Pos8dBm << RADIO_TXPOWER_TXPOWER_Pos; +#else + NRF_RADIO->TXPOWER = RADIO_TXPOWER_TXPOWER_Pos4dBm << RADIO_TXPOWER_TXPOWER_Pos; +#endif + + // 8-bit LENGTH field, no S0/S1: the frame is a bare length byte plus payload + NRF_RADIO->PCNF0 = (8 << RADIO_PCNF0_LFLEN_Pos) | (0 << RADIO_PCNF0_S0LEN_Pos) | + (0 << RADIO_PCNF0_S1LEN_Pos); + + // 5-byte address (1 prefix + 4 base), dynamic length, no whitening + NRF_RADIO->PCNF1 = (MAX_NRF_PACKET_SIZE << RADIO_PCNF1_MAXLEN_Pos) | + (0 << RADIO_PCNF1_STATLEN_Pos) | (4 << RADIO_PCNF1_BALEN_Pos) | + (RADIO_PCNF1_ENDIAN_Big << RADIO_PCNF1_ENDIAN_Pos) | + (RADIO_PCNF1_WHITEEN_Disabled << RADIO_PCNF1_WHITEEN_Pos); + + // One shared address on logical address 0: transmit on it, listen for it + NRF_RADIO->BASE0 = ADDRESS_BASE; + NRF_RADIO->PREFIX0 = ADDRESS_PREFIX; + NRF_RADIO->TXADDRESS = 0; + NRF_RADIO->RXADDRESSES = 1; + + // CRC-16 over the payload, so a corrupted frame never reaches the bridge + NRF_RADIO->CRCCNF = (RADIO_CRCCNF_LEN_Two << RADIO_CRCCNF_LEN_Pos) | + (RADIO_CRCCNF_SKIPADDR_Skip << RADIO_CRCCNF_SKIPADDR_Pos); + NRF_RADIO->CRCPOLY = 0x11021; + NRF_RADIO->CRCINIT = 0xFFFF; + + // Ramp up and start without CPU involvement, and disable once the frame ends, + // so DISABLED is the single event the interrupt has to handle. + NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_DISABLE_Msk; + + NRF_RADIO->INTENCLR = 0xFFFFFFFF; + NRF_RADIO->INTENSET = RADIO_INTENSET_DISABLED_Msk; +} + +void NRFRadioBridge::armReceive() { + NRF_RADIO->PACKETPTR = (uint32_t)_rx_raw; + NRF_RADIO->EVENTS_END = 0; + NRF_RADIO->EVENTS_DISABLED = 0; + _radio_state = RADIO_RX; + NRF_RADIO->TASKS_RXEN = 1; +} + +void NRFRadioBridge::disableRadio() { + if (NRF_RADIO->STATE == RADIO_STATE_STATE_Disabled) { + return; // already stopped; TASKS_DISABLE would produce no event to wait for + } + + NRF_RADIO->EVENTS_DISABLED = 0; + NRF_RADIO->TASKS_DISABLE = 1; + + for (uint32_t i = 0; i < RADIO_DISABLE_SPINS && NRF_RADIO->EVENTS_DISABLED == 0; i++) { + __NOP(); + } + + NRF_RADIO->EVENTS_DISABLED = 0; + _radio_state = RADIO_OFF; +} + +void NRFRadioBridge::onRadioEvent() { + if (NRF_RADIO->EVENTS_DISABLED == 0) return; + NRF_RADIO->EVENTS_DISABLED = 0; + + if (_radio_state == RADIO_RX && NRF_RADIO->CRCSTATUS == 1) { + const uint8_t len = _rx_raw[0]; + const uint8_t next = (uint8_t)((_rx_head + 1) % RX_QUEUE_DEPTH); + + // Copy the frame out rather than parsing it here: the packet pool and the + // seen-packet table are not safe to touch from an interrupt. A full queue + // drops the frame, which the radio would have done anyway. + if (len > 0 && next != _rx_tail) { + memcpy(_rx_queue[_rx_head], &_rx_raw[1], len); + _rx_queue_len[_rx_head] = len; + _rx_head = next; + } + } + + // Back to listening, unless the bridge is being torn down: re-arming then + // would leave a receiver running past end(), filling a queue nothing drains. + if (_initialized) { + armReceive(); + } +} + +void NRFRadioBridge::begin() { + BRIDGE_DEBUG_PRINTLN("Initializing...\n"); + + // One RADIO, and the SoftDevice owns it whenever BLE is up + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + if (sd_enabled) { + BRIDGE_DEBUG_PRINTLN("Cannot start while the SoftDevice owns the radio\n"); + return; + } + + // Refused rather than corrected, so a pref predating bridge_channel behaves as + // it does on ESP-NOW, where esp_wifi_set_channel() rejects the same value. + const int frequency = NRFRadioChannel::frequencyFor(_prefs->bridge_channel); + if (frequency < 0) { + BRIDGE_DEBUG_PRINTLN("Error setting radio channel to %d\n", _prefs->bridge_channel); + return; + } + + if (!startHighFreqClock()) { + BRIDGE_DEBUG_PRINTLN("HF clock did not start\n"); + return; + } + + configureRadio((uint8_t)frequency); + + NVIC_ClearPendingIRQ(RADIO_IRQn); + // Priority 2 stays available to the application if the SoftDevice is ever up + NVIC_SetPriority(RADIO_IRQn, 2); + NVIC_EnableIRQ(RADIO_IRQn); + + _initialized = true; + + armReceive(); +} + +void NRFRadioBridge::end() { + BRIDGE_DEBUG_PRINTLN("Stopping...\n"); + + _initialized = false; + + // Silence the interrupt before tearing down, so nothing can push into the queue + NVIC_DisableIRQ(RADIO_IRQn); + __DSB(); + __ISB(); + NRF_RADIO->INTENCLR = 0xFFFFFFFF; + + disableRadio(); + NRF_RADIO->SHORTS = 0; + NVIC_ClearPendingIRQ(RADIO_IRQn); + + // The crystal is left running on purpose. TASKS_HFCLKSTOP is not reference + // counted and USB requests the same crystal (TinyUSB's hfclk_enable), so + // stopping it would cut off the console of whoever just disabled the bridge. + + _rx_head = _rx_tail = 0; +} + +void NRFRadioBridge::loop() { + if (!_initialized) return; + + // USB releases the same crystal on suspend without telling anyone. One + // register read once it is running. + startHighFreqClock(); + + for (uint8_t i = 0; i < RX_DRAIN_PER_LOOP && _rx_tail != _rx_head; i++) { + handleFrame(_rx_queue[_rx_tail], _rx_queue_len[_rx_tail]); + _rx_tail = (uint8_t)((_rx_tail + 1) % RX_QUEUE_DEPTH); + } +} + +void NRFRadioBridge::xorCrypt(uint8_t *data, size_t len) { + size_t keyLen = strlen(_prefs->bridge_secret); + if (keyLen == 0) return; + for (size_t i = 0; i < len; i++) { + data[i] ^= _prefs->bridge_secret[i % keyLen]; + } +} + +void NRFRadioBridge::handleFrame(const uint8_t *frame, size_t len) { + // Ignore frames that are too small to contain header + checksum + if (len < (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE)) { + BRIDGE_DEBUG_PRINTLN("RX packet too small, len=%d\n", (int)len); + return; + } + + // Check packet header magic + uint16_t received_magic = (frame[0] << 8) | frame[1]; + if (received_magic != BRIDGE_PACKET_MAGIC) { + BRIDGE_DEBUG_PRINTLN("RX invalid magic 0x%04X\n", received_magic); + return; + } + + // Make a copy we can decrypt + uint8_t decrypted[MAX_NRF_PACKET_SIZE]; + const size_t encryptedDataLen = len - BRIDGE_MAGIC_SIZE; + memcpy(decrypted, frame + BRIDGE_MAGIC_SIZE, encryptedDataLen); + + // Try to decrypt (checksum + payload) + xorCrypt(decrypted, encryptedDataLen); + + // Validate checksum + uint16_t received_checksum = (decrypted[0] << 8) | decrypted[1]; + const size_t payloadLen = encryptedDataLen - BRIDGE_CHECKSUM_SIZE; + + if (!validateChecksum(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen, received_checksum)) { + // Failed to decrypt - likely from a different network + BRIDGE_DEBUG_PRINTLN("RX checksum mismatch, rcv=0x%04X\n", received_checksum); + return; + } + + BRIDGE_DEBUG_PRINTLN("RX, payload_len=%d\n", (int)payloadLen); + + // Create mesh packet + mesh::Packet *pkt = _mgr->allocNew(); + if (!pkt) return; + + if (pkt->readFrom(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen)) { + onPacketReceived(pkt); + } else { + _mgr->free(pkt); + } +} + +bool NRFRadioBridge::sendFrame(const uint8_t *frame, size_t len) { + if (len == 0 || len > MAX_NRF_PACKET_SIZE) return false; + + // The interrupt drives the same state machine, so keep it out while the radio + // turns around. It runs above thread mode, so once these barriers retire it + // cannot be mid-execution: it either finished already or can no longer start. + NVIC_DisableIRQ(RADIO_IRQn); + __DSB(); + __ISB(); + + if (_radio_state == RADIO_TX) { + NVIC_EnableIRQ(RADIO_IRQn); + return false; // already transmitting + } + + // Stop listening. A frame arriving now is lost, which is inherent to a + // half-duplex radio and no different to a collision on air. + disableRadio(); + + // Clear the pending bit left by the disable above BEFORE arming, never after: a + // short frame completes in tens of microseconds, so a clear after TASKS_TXEN can + // drop its completion, and nothing re-latches the NVIC bit. + NVIC_ClearPendingIRQ(RADIO_IRQn); + + _tx_raw[0] = (uint8_t)len; + memcpy(&_tx_raw[1], frame, len); + + NRF_RADIO->PACKETPTR = (uint32_t)_tx_raw; + NRF_RADIO->EVENTS_END = 0; + NRF_RADIO->EVENTS_DISABLED = 0; + _radio_state = RADIO_TX; + + NVIC_EnableIRQ(RADIO_IRQn); + NRF_RADIO->TASKS_TXEN = 1; + return true; +} + +void NRFRadioBridge::sendPacket(mesh::Packet *packet) { + // Guard against uninitialized state + if (_initialized == false) { + return; + } + + // First validate the packet pointer + if (!packet) { + BRIDGE_DEBUG_PRINTLN("TX invalid packet pointer\n"); + return; + } + + if (!_seen_packets.wasSeen(packet)) { + _seen_packets.markSeen(packet); + // Create a temporary buffer just for size calculation and reuse for actual writing + uint8_t sizingBuffer[MAX_PAYLOAD_SIZE]; + uint16_t meshPacketLen = packet->writeTo(sizingBuffer); + + // Check if packet fits within our maximum payload size + if (meshPacketLen > MAX_PAYLOAD_SIZE) { + BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", meshPacketLen, + MAX_PAYLOAD_SIZE); + return; + } + + uint8_t buffer[MAX_NRF_PACKET_SIZE]; + + // Write magic header (2 bytes) + buffer[0] = (BRIDGE_PACKET_MAGIC >> 8) & 0xFF; + buffer[1] = BRIDGE_PACKET_MAGIC & 0xFF; + + // Write packet payload starting after magic header and checksum + const size_t packetOffset = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE; + memcpy(buffer + packetOffset, sizingBuffer, meshPacketLen); + + // Calculate and add checksum (only of the payload) + uint16_t checksum = fletcher16(buffer + packetOffset, meshPacketLen); + buffer[2] = (checksum >> 8) & 0xFF; // High byte + buffer[3] = checksum & 0xFF; // Low byte + + // Encrypt payload and checksum (not including magic header) + xorCrypt(buffer + BRIDGE_MAGIC_SIZE, meshPacketLen + BRIDGE_CHECKSUM_SIZE); + + // Total packet size: magic header + checksum + payload + const size_t totalPacketSize = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE + meshPacketLen; + + if (sendFrame(buffer, totalPacketSize)) { + BRIDGE_DEBUG_PRINTLN("TX, len=%d\n", meshPacketLen); + } else { + BRIDGE_DEBUG_PRINTLN("TX FAILED!\n"); + } + } +} + +void NRFRadioBridge::onPacketReceived(mesh::Packet *packet) { + handleReceivedPacket(packet); +} + +#endif diff --git a/src/helpers/bridges/NRFRadioBridge.h b/src/helpers/bridges/NRFRadioBridge.h new file mode 100644 index 0000000000..4de926fac9 --- /dev/null +++ b/src/helpers/bridges/NRFRadioBridge.h @@ -0,0 +1,221 @@ +#pragma once + +#include "MeshCore.h" +#include "helpers/bridges/BridgeBase.h" +#include "helpers/bridges/NRFRadioChannel.h" + +#ifdef WITH_NRF_BRIDGE + +extern "C" void RADIO_IRQHandler(void); + +/** + * @brief Bridge implementation using the nRF52 2.4GHz proprietary radio + * + * Carries mesh packets over Nordic's 1 Mbit proprietary mode, doing what + * ESPNowBridge does on ESP32: one fixed address that every bridge shares, so a + * transmission reaches all of them, with no pairing or acknowledgement. + * + * The two radios are not compatible on air. An nRF bridge only pairs with + * another nRF bridge. + * + * Features: + * - Broadcast-based communication (all bridges receive all frames) + * - Network isolation using XOR encryption with shared secret + * - Duplicate packet detection using SimpleMeshTables tracking + * - Maximum frame size of 255 bytes (RADIO LENGTH field is 8 bits) + * + * Packet Structure (identical to ESPNowBridge's): + * [2 bytes] Magic Header - Used to identify bridge frames + * [2 bytes] Fletcher-16 checksum of the payload, encrypted + * [251 bytes max] Encrypted payload containing the mesh packet + * + * RADIO checks a 16-bit CRC in hardware, so a corrupted frame never reaches the + * bridge. The Fletcher-16 is left doing one job: rejecting frames from a network + * using a different secret. + * + * Interrupt context: + * RADIO reports frames in an interrupt rather than on a driver task. The packet + * pool, inbound queue and seen-packet table have no locking, so the handler + * touches none of them: it copies the frame into _rx_queue, and loop() turns + * those into mesh packets on the main task. This is the one structural + * difference from ESPNowBridge, which can do that work in its callback. + * + * Configuration: + * - Define WITH_NRF_BRIDGE to enable this bridge + * - Set _prefs->bridge_secret to the network encryption key + * - Set _prefs->bridge_channel; the Wi-Fi 1-14 numbering is shared with the + * ESP-NOW bridge and maps onto the same frequencies (see NRFRadioChannel) + * + * SoftDevice: + * The nRF52 has one RADIO and the SoftDevice owns it whenever BLE is enabled, so + * this bridge refuses to start while the SoftDevice is up and must be stopped + * before anything enables it (see NRF52Board::startOTAUpdate). That is why it + * ships on repeater and room server builds only, matching ESP-NOW. + */ +class NRFRadioBridge : public BridgeBase { + // The RADIO vector is a free function, so it needs reaching in here to dispatch + friend void ::RADIO_IRQHandler(void); + +private: + static NRFRadioBridge *_instance; + + /** RADIO interrupt handler, dispatched to the instance. */ + static void radio_isr(); + + /** + * RADIO frame structure: + * - Preamble and address: sent by the hardware, not counted here + * - LENGTH byte: 8 bits, so a frame carries up to 255 bytes + * - CRC: 2 bytes appended and checked by the hardware + * + * Our Bridge Packet Structure (must fit in the RADIO payload): + * - Magic header: 2 bytes + * - Checksum: 2 bytes + * - Available payload: 251 bytes + */ + static const size_t MAX_NRF_PACKET_SIZE = 255; + + /** Largest mesh packet blob that still fits a RADIO frame once framed. */ + static const size_t MAX_PAYLOAD_SIZE = + MAX_NRF_PACKET_SIZE - (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE); + + /** + * On-air address, shared by every bridge regardless of secret. + * + * Fixed rather than derived from bridge_secret, so a node holding the wrong + * secret still hears its neighbours and drops their frames on the checksum, + * the way ESP-NOW does. Filtering in hardware would leave a bad secret looking + * identical to a dead radio. + */ + static const uint32_t ADDRESS_BASE = 0xC03E5BE1; + static const uint8_t ADDRESS_PREFIX = 0xC0; + + /** Frames the interrupt can buffer before loop() gets to them. */ + static const uint8_t RX_QUEUE_DEPTH = 4; + + /** Frames turned into mesh packets per loop() call. */ + static const uint8_t RX_DRAIN_PER_LOOP = 4; + + /** What the radio is doing. Written by the interrupt and by begin()/end(). */ + enum RadioState : uint8_t { + RADIO_OFF, ///< peripheral disabled, bridge stopped + RADIO_RX, ///< listening, or about to be re-armed + RADIO_TX, ///< transmitting a frame + }; + + volatile RadioState _radio_state; + + /** + * RADIO reads and writes the frame through PACKETPTR, LENGTH byte first. One + * buffer per direction, so transmitting cannot clobber a frame arriving. + */ + uint8_t _rx_raw[1 + MAX_NRF_PACKET_SIZE]; + uint8_t _tx_raw[1 + MAX_NRF_PACKET_SIZE]; + + /** + * Frames waiting to become mesh packets. Single producer (the interrupt, + * advancing _rx_head) and single consumer (loop(), advancing _rx_tail), so + * neither needs to lock the other out. + */ + uint8_t _rx_queue[RX_QUEUE_DEPTH][MAX_NRF_PACKET_SIZE]; + uint8_t _rx_queue_len[RX_QUEUE_DEPTH]; + volatile uint8_t _rx_head; + volatile uint8_t _rx_tail; + + /** + * Performs XOR encryption/decryption of data + * Used to isolate different mesh networks + * + * Uses _prefs->bridge_secret as the key in a simple XOR operation. + * The same operation is used for both encryption and decryption. + * While not cryptographically secure, it provides basic network isolation. + * + * @param data Pointer to data to encrypt/decrypt + * @param len Length of data in bytes + */ + void xorCrypt(uint8_t *data, size_t len); + + /** + * @brief Makes sure the radio has the external crystal, starting it if not. + * + * @return true if HFCLK is running from the crystal + */ + static bool startHighFreqClock(); + + /** + * @brief Applies the RADIO configuration. Called once by begin(). + * + * @param frequency MHz above 2400, as returned by NRFRadioChannel + */ + void configureRadio(uint8_t frequency); + + /** Points RADIO at the receive buffer and starts listening. */ + void armReceive(); + + /** Brings RADIO to a stop, waiting for it to report DISABLED. */ + void disableRadio(); + + /** Handles one RADIO interrupt. */ + void onRadioEvent(); + + /** Turns one queued frame into a mesh packet. */ + void handleFrame(const uint8_t *frame, size_t len); + + /** Hands a framed blob to the radio. @return false if it could not be sent. */ + bool sendFrame(const uint8_t *frame, size_t len); + +public: + /** + * Constructs an NRFRadioBridge instance + * + * @param prefs Node preferences for configuration settings + * @param mgr PacketManager for allocating and queuing packets + * @param rtc RTCClock for timestamping debug messages + */ + NRFRadioBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc); + + /** + * Initializes the nRF proprietary radio bridge + * + * - Refuses to start if the SoftDevice owns the radio + * - Refuses to start on a channel outside 1-14, as ESP-NOW does + * - Configures RADIO for 1 Mbit Nordic mode on the configured channel + * - Enables the RADIO interrupt and starts listening + */ + void begin() override; + + /** + * Stops the nRF proprietary radio bridge + * + * - Disables the RADIO interrupt + * - Brings the peripheral to a stop, releasing it for BLE + * - Drops anything still buffered + */ + void end() override; + + /** + * Main loop handler + * + * Turns frames buffered by the interrupt into mesh packets. Must be called + * regularly; nothing else moves received packets through the bridge. + */ + void loop() override; + + /** + * Called when a packet is received over the air + * Queues the packet for mesh processing if not seen before + * + * @param packet The received mesh packet + */ + void onPacketReceived(mesh::Packet *packet) override; + + /** + * Called when a packet needs to be transmitted + * Encrypts and broadcasts the packet if not seen before + * + * @param packet The mesh packet to transmit + */ + void sendPacket(mesh::Packet *packet) override; +}; + +#endif diff --git a/src/helpers/bridges/NRFRadioChannel.cpp b/src/helpers/bridges/NRFRadioChannel.cpp new file mode 100644 index 0000000000..1151b5d8e8 --- /dev/null +++ b/src/helpers/bridges/NRFRadioChannel.cpp @@ -0,0 +1,11 @@ +#include "NRFRadioChannel.h" + +int NRFRadioChannel::frequencyFor(uint8_t wifi_channel) { + if (wifi_channel < 1 || wifi_channel > 14) return -1; + + // Channel 14 sits 12 MHz above 13 rather than the usual 5, so it is spelled out + if (wifi_channel == 14) return 2484 - 2400; + + // Channels 1-13 are 2412 MHz + 5 MHz per channel + return 2407 + 5 * wifi_channel - 2400; +} diff --git a/src/helpers/bridges/NRFRadioChannel.h b/src/helpers/bridges/NRFRadioChannel.h new file mode 100644 index 0000000000..178c1285ab --- /dev/null +++ b/src/helpers/bridges/NRFRadioChannel.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +/** + * @brief Maps the `bridge.channel` pref onto an nRF RADIO frequency. + * + * Kept free of Arduino/nRF dependencies so it can be unit tested natively. + * + * The pref is shared with the ESP-NOW bridge and keeps its Wi-Fi 1-14 numbering, + * so `set bridge.channel 6` means 2437 MHz on either platform. That leaves the + * CLI validation, the persisted layout and the docs untouched. + */ +class NRFRadioChannel { +public: + /** + * @brief Wi-Fi channel number to RADIO FREQUENCY register value. + * + * A channel outside 1-14 is refused rather than corrected, so that the bridge + * declines to start on it exactly as the ESP-NOW bridge does when + * esp_wifi_set_channel() rejects the same value. The case that matters is 0, + * which is what a pref saved before bridge_channel existed reads back as. + * + * @param wifi_channel 1-14 + * @return MHz above 2400, within RADIO's 0-100 range, or -1 if out of range + */ + static int frequencyFor(uint8_t wifi_channel); +}; diff --git a/test/test_bridge/test_nrf_radio_channel.cpp b/test/test_bridge/test_nrf_radio_channel.cpp new file mode 100644 index 0000000000..ef3c399fa6 --- /dev/null +++ b/test/test_bridge/test_nrf_radio_channel.cpp @@ -0,0 +1,59 @@ +#include + +#include "helpers/bridges/NRFRadioChannel.h" + +// The bridge reuses the ESP-NOW `bridge.channel` pref unchanged, so a channel +// number has to mean the same frequency on both platforms: the Wi-Fi channel's +// centre frequency, expressed as the RADIO FREQUENCY register wants it (MHz +// above 2400). + +TEST(NRFRadioChannel, MapsChannelOneTo2412MHz) { + EXPECT_EQ(NRFRadioChannel::frequencyFor(1), 12); +} + +TEST(NRFRadioChannel, MapsChannelSixTo2437MHz) { + EXPECT_EQ(NRFRadioChannel::frequencyFor(6), 37); +} + +TEST(NRFRadioChannel, MapsChannelThirteenTo2472MHz) { + EXPECT_EQ(NRFRadioChannel::frequencyFor(13), 72); +} + +TEST(NRFRadioChannel, MapsChannelFourteenTo2484MHz) { + // Channel 14 breaks the 5 MHz spacing, exactly as it does in 802.11 + EXPECT_EQ(NRFRadioChannel::frequencyFor(14), 84); +} + +TEST(NRFRadioChannel, RejectsAnUnsetChannel) { + // A pref saved before bridge_channel existed reads back as 0. ESP-NOW hands that + // to esp_wifi_set_channel(), which rejects it, and the bridge does not start. + EXPECT_LT(NRFRadioChannel::frequencyFor(0), 0); +} + +TEST(NRFRadioChannel, RejectsAChannelAboveFourteen) { + EXPECT_LT(NRFRadioChannel::frequencyFor(15), 0); + EXPECT_LT(NRFRadioChannel::frequencyFor(255), 0); +} + +TEST(NRFRadioChannel, KeepsEveryValidChannelInsideTheRadioFrequencyRange) { + // RADIO's FREQUENCY register covers 2400-2500 MHz, i.e. 0-100 + for (uint8_t ch = 1; ch <= 14; ch++) { + const int freq = NRFRadioChannel::frequencyFor(ch); + EXPECT_GE(freq, 0) << "channel " << (int)ch; + EXPECT_LE(freq, 100) << "channel " << (int)ch; + } +} + +TEST(NRFRadioChannel, GivesEveryChannelItsOwnFrequency) { + for (uint8_t a = 1; a <= 14; a++) { + for (uint8_t b = (uint8_t)(a + 1); b <= 14; b++) { + EXPECT_NE(NRFRadioChannel::frequencyFor(a), NRFRadioChannel::frequencyFor(b)) + << "channels " << (int)a << " and " << (int)b; + } + } +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 0dfbf79775..42bd76d825 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -271,3 +271,23 @@ build_src_filter = ${rak4631.build_src_filter} +<../examples/kiss_modem/> lib_deps = ${rak4631.lib_deps} + +[env:RAK_4631_repeater_bridge_nrf] +extends = rak4631 +build_flags = + ${rak4631.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"nRF Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_NRF_BRIDGE=1 + -UENV_INCLUDE_GPS +; -D BRIDGE_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak4631.build_src_filter} + + + + + +<../examples/simple_repeater>