feat(kiss): add dual-profile radio2 support for OpenHop

This commit is contained in:
mikecarper committed 2026-09-20 07:59:43 -07:00
1 parent 9844982b00
commit 01ad5ca8a8
9 files changed
+796 -46

No files matched your search

+77 -6
View File
@@ -2,6 +2,10 @@
Standard KISS TNC firmware for MeshCore LoRa radios. Compatible with any KISS client (Direwolf, APRSdroid, YAAC, etc.) for sending and receiving raw packets. MeshCore-specific extensions (cryptography, radio configuration, telemetry) are available through the standard SetHardware (0x06) command.
KISS protocol version 2 adds an opt-in logical port 1 for MeshCore's second
time-shared LoRa profile. It is still one physical transceiver: the firmware
fast-switches between `radio` and `radio2`; it does not create a second radio.
## Serial Configuration
115200 baud, 8N1, no flow control.
@@ -30,7 +34,7 @@ The type byte is split into two nibbles:
| Bits | Field | Description |
|------|---------|-------------------------------------|
| 7-4 | Port | Port number (0 for single-port TNC) |
| 7-4 | Port | `0` primary `radio`; `1` secondary `radio2` for Data only in protocol v2; controls stay on port 0 |
| 3-0 | Command | Command number |
Maximum unescaped frame size: 512 bytes.
@@ -41,7 +45,7 @@ Maximum unescaped frame size: 512 bytes.
| Command | Value | Data | Description |
|-------------|--------|--------------------|-------------------------------------------------------------|
| Data | `0x00` | Raw packet | Queue packet for transmission (one pending at a time) |
| Data | `0x00` | Raw packet | Queue on primary `radio`; use type `0x10` to queue on `radio2` in v2 (one pending packet total) |
| TXDELAY | `0x01` | Delay (1 byte) | Transmitter keyup delay in 10ms units (default: 50 = 500ms) |
| Persistence | `0x02` | P (1 byte) | CSMA persistence parameter 0-255 (default: 63) |
| SlotTime | `0x03` | Interval (1 byte) | CSMA slot interval in 10ms units (default: 10 = 100ms) |
@@ -54,12 +58,35 @@ Maximum unescaped frame size: 512 bytes.
| Type | Value | Data | Description |
|------|--------|------------|----------------------------|
| Data | `0x00` | Raw packet | Received packet from radio |
| Data | `0x00` | Raw packet | Received packet from primary `radio` |
| Data | `0x10` | Raw packet | Received packet from secondary `radio2` (v2) |
Data frames carry raw packet data only, with no metadata prepended. The Data command payload is limited to 255 bytes to match the MeshCore maximum transmission unit (MAX_TRANS_UNIT); frames larger than 255 bytes are silently dropped. The KISS specification recommends at least 1024 bytes for general-purpose TNCs; this modem is intended for MeshCore packets only, whose protocol MTU is 255 bytes.
Data frames carry raw packet data only, with no metadata prepended. The Data command payload is limited to 255 bytes to match the MeshCore maximum transmission unit (MAX_TRANS_UNIT); frames larger than 255 bytes are silently dropped. The KISS specification recommends at least 1024 bytes for general-purpose TNCs; this modem is intended for MeshCore packets only, whose protocol MTU is 255 bytes. A v2 port-1 Data packet reaches RF only while `radio2` is configured `rxtx`; with `off` or `rx`, the modem returns `TxDone(0)` and never falls back to primary.
Only one packet may be pending for radio transmission at a time. If the host sends a second Data frame before the first has completed, the modem responds with Error (0xF1) and TxBusy (0x07).
### Fast dual-profile receive switching
After OpenHop (or another KISS client) configures `radio2` or `tempradio2`,
the board's `radio_driver.loop()` performs the RF hops locally. KISS does not
send a command for every hop: that would be slower than the serial link alone.
The shared scanner uses a **600 µs nominal retune allowance** per hop, with
the profile timing/preamble calculation accounting for it. It is a measured
target rather than a hard maximum; packet handling and a slow board loop can
extend an individual hop. The KISS parser services at most 32 incoming bytes
per outer loop so a busy OpenHop serial peer cannot starve the scanner.
Thus “around 0.6 ms” refers to the on-board RF setting transition, not a full
host-to-board request/response round trip. The returned Data port (`0x00` or
`0x10`) identifies which profile actually received each packet.
`SetRadio2` and `SetTempRadio2` acknowledge that their tuple was accepted and
scheduled; they do not acknowledge that a receiver has already entered that
tuple. The next local scanner service performs the retune. KISS clients should
configure the profiles once and let the board perform automatic time-sharing;
a 115200-baud serial round trip cannot be a 0.6 ms receive-hop control plane.
Clients that need temporary-lease state can poll `GetTempRadio2`.
### Host Output Backpressure
Outbound frames are encoded into a 2-slot queue and flushed when serial output space is available; `loop()` never blocks on writes. Radio TX state advances independently of host read speed. TxDone is retained until it can be queued. If the outbound queue is full, the modem responds with Error (0xF1) and TxBusy (0x07). Hosts should read serial promptly to avoid delayed responses.
@@ -118,6 +145,10 @@ MeshCore-specific functionality uses the standard KISS SetHardware command. The
| Reboot | `0x18` | - |
| SetSignalReport | `0x19` | Enable (1): 0x00=disable, nonzero=enable |
| GetSignalReport | `0x1A` | - |
| SetRadio2 | `0x1B` | Radio2 parameters (13; see below) |
| GetRadio2 | `0x1C` | - |
| SetTempRadio2 | `0x1D` | Radio2 parameters + duration (15) |
| GetTempRadio2 | `0x1E` | - |
### Response Sub-commands (TNC to Host)
@@ -147,6 +178,8 @@ Response codes use the high-bit convention: `response = command | 0x80`. Generic
| DeviceName | `0x96` | Name (variable, UTF-8) |
| Pong | `0x97` | - |
| SignalReport | `0x9A` | Status (1): 0x00=disabled, 0x01=enabled |
| Radio2 | `0x9C` | Saved radio2 parameters (13) |
| TempRadio2 | `0x9E` | Active temporary radio2 + remaining minutes (15) |
| OK | `0xF0` | - |
| Error | `0xF1` | Error code (1) |
| TxDone | `0xF8` | Result (1): 0x00=failed, 0x01=success |
@@ -170,7 +203,7 @@ The TNC sends these SetHardware frames without a preceding request:
**TxDone (0xF8)**: Sent after radio transmission completes. Contains a single byte: 0x01 for success, 0x00 for failure. Delivery to the host may be delayed under serial backpressure but is not dropped.
**RxMeta (0xF9)**: Sent after each standard data frame (type 0x00) with SNR (1 byte, signed, value x4) and RSSI (1 byte, signed, dBm). Queued with the data frame; omitted if the data frame cannot be queued. Enabled by default; toggle with SetSignalReport. Standard KISS clients ignore this frame.
**RxMeta (0xF9)**: Sent after each Data frame (type 0x00 or v2 type 0x10) with SNR (1 byte, signed, value x4) and RSSI (1 byte, signed, dBm). It remains exactly two bytes; the Data port identifies the receiving profile. Queued with the data frame; omitted if the data frame cannot be queued. Enabled by default; toggle with SetSignalReport. Standard KISS clients ignore this frame.
## Data Formats
@@ -185,6 +218,43 @@ All values little-endian.
| SF | 1 byte | Spreading factor (5-12) |
| CR | 1 byte | Coding rate (5-8) |
### Secondary Radio Parameters (SetRadio2 / Radio2 response)
`SetRadio2` is the binary KISS equivalent of `radio2`. It changes only RAM
state for this KISS session; it does not write the Mesh CLI profile file.
All values are little-endian. The mode values are `0=off`, `1=rx`, and
`2=rxtx`. An `off` tuple may contain zero radio parameters, which are ignored.
| Field | Size | Description |
|-----------|---------|-------------------------------------|
| Frequency | 4 bytes | Hz |
| Bandwidth | 4 bytes | Hz |
| SF | 1 byte | Spreading factor (5-12) |
| CR | 1 byte | Coding rate (5-8) |
| Mode | 1 byte | `off`, `rx`, or `rxtx` as above |
| Preamble | 2 bytes | Symbols; `0` chooses automatic |
`GetRadio2` returns this exact 13-byte tuple. The modem preserves the exact
frequency and bandwidth values sent by the host even though its radio driver
uses floating-point MHz/kHz internally.
### Temporary Secondary Radio (SetTempRadio2 / TempRadio2 response)
`SetTempRadio2` is the binary KISS equivalent of `tempradio2`: the 13-byte
secondary tuple followed by `duration_minutes:u16`, from 1 to 10080. It is
RAM-only and automatically restores the saved `radio2` tuple on expiry or
reboot. A mode `off` with duration zero cancels the temporary lease and
immediately restores saved `radio2`; all other `off`/zero-duration combinations
are invalid. `GetTempRadio2` returns the active 13-byte tuple plus remaining
minutes, rounded up. It returns a zero/off tuple with zero duration when no
temporary lease is active.
Changing saved `radio2` during an active temporary lease does not interrupt
the active tuple; the replacement becomes active when the lease ends. Pending
KISS Data is bound to a profile generation at queue time. If that profile is
replaced before RF transmission begins, the modem sends normal `TxDone(0)` and
never falls back to the other profile.
### Version (Version response)
| Field | Size | Description |
@@ -285,5 +355,6 @@ Data returned in CayenneLPP format. See [CayenneLPP documentation](https://docs.
- All multi-byte values are little-endian unless stated otherwise
- SNR values in RxMeta are multiplied by 4 for 0.25 dB precision
- TxDone is sent as a SetHardware event after each transmission
- Standard KISS clients receive only type 0x00 data frames and can safely ignore all SetHardware (0x06) frames
- Legacy KISS clients use only type 0x00 data and remain compatible. Version-2-aware clients can use type 0x10 to send on, or identify reception from, `radio2`.
- Standard KISS clients can safely ignore all SetHardware (0x06) frames
- See [packet_format.md](./packet_format.md) for packet format
+10 -3
View File
@@ -11,8 +11,10 @@ profile makes the other unavailable until that packet finishes.
The second-profile commands are shared by repeater, Companion, room server,
sensor and standalone terminal-chat firmware with a LoRa radio. ESP-NOW-only
devices reject LoRa profile commands. KISS is a raw host-controlled modem and
does not expose these Mesh CLI commands or Mesh retry queues.
devices reject LoRa profile commands. KISS is a raw host-controlled modem: it
exposes equivalent RAM-only binary `SetRadio2`/`SetTempRadio2` commands and
logical KISS Data ports, documented in [the KISS modem protocol](kiss_modem_protocol.md).
It does not use the Mesh CLI's persistent profile file or Mesh retry queues.
## Quick setup
@@ -474,7 +476,12 @@ tested redundant standby/IRQ/buffer/packet setup and SPI transfer changes.
Its fastest candidate measured 0.535 ms mean on XIAO but 8.203 ms on the
Indicator, whose radio GPIOs go through an I2C expander. The validated production
implementation now opts XIAO S3 WIO and Indicator LoRa into buffered 8 MHz SPI
and a guarded fast-RX state machine. It skips redundant standby, IRQ mapping,
and a guarded fast-RX state machine. The `heltec_v4_kiss_modem` target uses the
same guarded path only for KISS dual-profile scanning; its normal V4 roles keep
their established HAL/timing policy. That host-powered KISS target uses the
existing V4 160 MHz role override, but still needs an exact-image hardware
timing check before its nominal 600 µs target is claimed. It skips redundant
standby, IRQ mapping,
buffer-base writes and modem queries only during an owned continuous RX-to-RX
retune. Preamble/packet parameters (including the IQ workaround), stale-IRQ
clearing, RX commands and BUSY waits remain. TX, CAD, sleep, reset, RXPS and
+321 -31
View File
@@ -1,6 +1,39 @@
#include "KissModem.h"
#include <CayenneLPP.h>
namespace {
uint16_t readU16LE(const uint8_t* data) {
return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}
uint32_t readU32LE(const uint8_t* data) {
return (uint32_t)data[0] | ((uint32_t)data[1] << 8)
| ((uint32_t)data[2] << 16) | ((uint32_t)data[3] << 24);
}
void writeU16LE(uint8_t* data, uint16_t value) {
data[0] = (uint8_t)value;
data[1] = (uint8_t)(value >> 8);
}
void writeU32LE(uint8_t* data, uint32_t value) {
data[0] = (uint8_t)value;
data[1] = (uint8_t)(value >> 8);
data[2] = (uint8_t)(value >> 16);
data[3] = (uint8_t)(value >> 24);
}
uint32_t frequencyToHz(float mhz) {
return mhz > 0 ? (uint32_t)((double)mhz * 1000000.0 + 0.5) : 0;
}
uint32_t bandwidthToHz(float khz) {
return khz > 0 ? (uint32_t)((double)khz * 1000.0 + 0.5) : 0;
}
} // namespace
KissModem::KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& rng,
mesh::Radio& radio, mesh::MainBoard& board, SensorManager& sensors)
: _serial(serial), _identity(identity), _rng(rng), _radio(radio), _board(board), _sensors(sensors) {
@@ -9,6 +42,8 @@ KissModem::KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& r
_rx_active = false;
_has_pending_tx = false;
_pending_tx_len = 0;
_pending_tx_profile = 0;
_pending_tx_profile_generation = 0;
_txdelay = KISS_DEFAULT_TXDELAY;
_persistence = KISS_DEFAULT_PERSISTENCE;
_slottime = KISS_DEFAULT_SLOTTIME;
@@ -21,6 +56,10 @@ KissModem::KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& r
_getCurrentRssiCallback = nullptr;
_getStatsCallback = nullptr;
_config = {0, 0, 0, 0, 0};
_saved_radio2 = {};
_temporary_radio2 = {};
_temporary_radio2_active = false;
_temporary_radio2_end_ms = 0;
_signal_report_enabled = true;
resetOutputQueue();
}
@@ -30,7 +69,11 @@ void KissModem::begin() {
_rx_escaped = false;
_rx_active = false;
_has_pending_tx = false;
_pending_tx_profile = 0;
_pending_tx_profile_generation = 0;
_tx_state = TX_IDLE;
syncPrimaryConfigFromRadio();
syncSavedRadio2FromRadio();
resetOutputQueue();
}
@@ -200,10 +243,13 @@ void KissModem::writeHardwareError(uint8_t error_code) {
}
void KissModem::loop() {
expireTemporaryRadio2();
tryFlushFrames();
while (_serial.available()) {
uint8_t serviced_bytes = 0;
while (serviced_bytes < KISS_RX_SERVICE_BYTE_BUDGET && _serial.available()) {
uint8_t b = _serial.read();
++serviced_bytes;
if (b == KISS_FEND) {
if (_rx_active && _rx_len > 0) {
@@ -254,22 +300,36 @@ void KissModem::processFrame() {
uint8_t port = (type_byte >> 4) & 0x0F;
uint8_t cmd = type_byte & 0x0F;
if (port != 0) return;
const uint8_t* data = &_rx_buf[1];
uint16_t data_len = _rx_len - 1;
switch (cmd) {
case KISS_CMD_DATA:
if (data_len > 0 && data_len <= KISS_MAX_PACKET_SIZE && !_has_pending_tx) {
memcpy(_pending_tx, data, data_len);
_pending_tx_len = data_len;
_has_pending_tx = true;
} else if (_has_pending_tx) {
writeHardwareError(HW_ERR_TX_BUSY);
}
break;
// KISS v2 exposes the two time-shared profiles as logical data ports. All
// configuration/control commands remain on port 0, so legacy KISS clients
// keep their exact command framing.
if (cmd == KISS_CMD_DATA) {
if (port > 1) return;
if (data_len == 0 || data_len > KISS_MAX_PACKET_SIZE) return;
if (_has_pending_tx) {
writeHardwareError(HW_ERR_TX_BUSY);
return;
}
const mesh::RadioProfiles* profiles = _radio.profiles();
// Accept a legal logical port even if its current profile cannot TX. The
// state machine emits the normal TxDone(0) verdict below, which lets KISS
// clients resolve a Data request without guessing whether F1 belonged to
// DATA or to an unrelated concurrent SetHardware command.
memcpy(_pending_tx, data, data_len);
_pending_tx_len = data_len;
_pending_tx_profile = port;
_pending_tx_profile_generation = profiles ? profiles->generation[port] : 0;
_has_pending_tx = true;
return;
}
if (port != 0) return;
switch (cmd) {
case KISS_CMD_TXDELAY:
if (data_len >= 1) _txdelay = data[0];
break;
@@ -381,12 +441,52 @@ void KissModem::handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint
case HW_CMD_GET_SIGNAL_REPORT:
handleGetSignalReport();
break;
case HW_CMD_SET_RADIO2:
handleSetRadio2(data, len);
break;
case HW_CMD_GET_RADIO2:
handleGetRadio2();
break;
case HW_CMD_SET_TEMPRADIO2:
handleSetTempRadio2(data, len);
break;
case HW_CMD_GET_TEMPRADIO2:
handleGetTempRadio2();
break;
default:
writeHardwareError(HW_ERR_UNKNOWN_CMD);
break;
}
}
void KissModem::expireTemporaryRadio2() {
if (!_temporary_radio2_active
|| (int32_t)(millis() - _temporary_radio2_end_ms) < 0) return;
mesh::RadioProfiles* profiles = _radio.profiles();
if (profiles) profiles->setSecondary(_saved_radio2.profile, false);
_temporary_radio2_active = false;
_temporary_radio2_end_ms = 0;
_temporary_radio2 = {};
}
bool KissModem::pendingTxProfileUnchanged() const {
const mesh::RadioProfiles* profiles = _radio.profiles();
if (!profiles) return _pending_tx_profile == 0;
return _pending_tx_profile <= 1
&& profiles->generation[_pending_tx_profile] == _pending_tx_profile_generation
&& profiles->canTransmit(_pending_tx_profile);
}
mesh::RadioParamApplyResult KissModem::preparePendingTransmitProfile() {
if (!pendingTxProfileUnchanged()) return mesh::RadioParamApplyResult::FAILED;
return _radio.prepareTransmitProfile(_pending_tx_profile);
}
uint32_t KissModem::pendingTxAirtime(uint16_t len) {
return _radio.getProfileAirtime(_pending_tx_profile, len);
}
void KissModem::processTx() {
switch (_tx_state) {
case TX_IDLE:
@@ -402,6 +502,13 @@ void KissModem::processTx() {
break;
case TX_WAIT_CLEAR:
{
const auto result = preparePendingTransmitProfile();
if (result == mesh::RadioParamApplyResult::BUSY) break;
if (result != mesh::RadioParamApplyResult::APPLIED) {
setTxDonePending(0x00);
break;
}
if (!_radio.isReceiving()) {
uint8_t rand_val;
_rng.random(&rand_val, 1);
@@ -412,10 +519,11 @@ void KissModem::processTx() {
_tx_timer = millis();
_tx_state = TX_SLOT_WAIT;
}
} else if (millis() - _tx_timer >= _radio.getEstAirtimeFor(KISS_MAX_PACKET_SIZE) * KISS_TX_TIMEOUT_FACTOR) {
} else if (millis() - _tx_timer >= pendingTxAirtime(KISS_MAX_PACKET_SIZE) * KISS_TX_TIMEOUT_FACTOR) {
_tx_timer = millis();
_tx_state = TX_DELAY;
}
}
break;
case TX_SLOT_WAIT:
@@ -427,6 +535,20 @@ void KissModem::processTx() {
case TX_DELAY:
if (millis() - _tx_timer >= (uint32_t)_txdelay * 10) {
// The scanner runs after this modem loop. Retune at the last safe
// instant so TXDELAY cannot make a queued port-1 packet leak out on
// the primary profile.
const auto result = preparePendingTransmitProfile();
if (result == mesh::RadioParamApplyResult::BUSY) break;
if (result != mesh::RadioParamApplyResult::APPLIED) {
setTxDonePending(0x00);
break;
}
if (!_fullduplex && _radio.isReceiving()) {
_tx_timer = millis();
_tx_state = TX_WAIT_CLEAR;
break;
}
if (_radio.startSendRaw(_pending_tx, _pending_tx_len)) {
_tx_timer = millis();
_tx_state = TX_SENDING;
@@ -440,7 +562,7 @@ void KissModem::processTx() {
if (_radio.isSendComplete()) {
_radio.onSendFinished();
setTxDonePending(0x01);
} else if (millis() - _tx_timer >= _radio.getEstAirtimeFor(_pending_tx_len) * KISS_TX_TIMEOUT_FACTOR) {
} else if (millis() - _tx_timer >= pendingTxAirtime(_pending_tx_len) * KISS_TX_TIMEOUT_FACTOR) {
_radio.onSendFinished();
setTxDonePending(0x00);
}
@@ -455,8 +577,10 @@ void KissModem::processTx() {
}
}
void KissModem::onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len) {
if (queueFrame(KISS_CMD_DATA, packet, len) && _signal_report_enabled) {
void KissModem::onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len, uint8_t profile) {
if (profile > 1) return;
const uint8_t data_type = (uint8_t)((profile << 4) | KISS_CMD_DATA);
if (queueFrame(data_type, packet, len) && _signal_report_enabled) {
uint8_t meta[2] = { (uint8_t)snr, (uint8_t)rssi };
writeHardwareFrame(HW_RESP_RX_META, meta, 2);
}
@@ -571,22 +695,97 @@ void KissModem::handleHash(const uint8_t* data, uint16_t len) {
writeHardwareFrame(HW_RESP(HW_CMD_HASH), hash, 32);
}
bool KissModem::decodeRadio2Config(const uint8_t* data, Radio2Config& config) const {
config = {};
config.freq_hz = readU32LE(data);
config.bw_hz = readU32LE(data + 4);
config.profile.params.freq = (float)((double)config.freq_hz / 1000000.0);
config.profile.params.bw = (float)((double)config.bw_hz / 1000.0);
config.profile.params.sf = data[8];
config.profile.params.cr = data[9];
const uint8_t mode = data[10];
if (mode > (uint8_t)mesh::RadioProfileMode::RxTx) return false;
config.profile.mode = (mesh::RadioProfileMode)mode;
config.profile.params.preamble = readU16LE(data + 11);
return true;
}
void KissModem::encodeRadio2Config(const Radio2Config& config, uint8_t* data) const {
writeU32LE(data, config.freq_hz);
writeU32LE(data + 4, config.bw_hz);
data[8] = config.profile.params.sf;
data[9] = config.profile.params.cr;
data[10] = (uint8_t)config.profile.mode;
writeU16LE(data + 11, config.profile.params.preamble);
}
bool KissModem::validateRadio2Config(const Radio2Config& config) const {
const mesh::RadioProfiles* profiles = _radio.profiles();
if (!profiles) return false;
if ((uint8_t)config.profile.mode > (uint8_t)mesh::RadioProfileMode::RxTx) return false;
if (config.profile.mode == mesh::RadioProfileMode::Off) return true;
if (!_radio.validateProfile(config.profile.params)) return false;
mesh::RadioProfiles preview = *profiles;
preview.secondary = config.profile;
return preview.automaticPreambleFits();
}
void KissModem::syncPrimaryConfigFromRadio() {
const mesh::RadioProfiles* profiles = _radio.profiles();
if (!profiles) return;
_config.freq_hz = frequencyToHz(profiles->primary.freq);
_config.bw_hz = bandwidthToHz(profiles->primary.bw);
_config.sf = profiles->primary.sf;
_config.cr = profiles->primary.cr;
}
void KissModem::syncSavedRadio2FromRadio() {
const mesh::RadioProfiles* profiles = _radio.profiles();
if (!profiles) return;
_saved_radio2.profile = profiles->secondary;
_saved_radio2.freq_hz = frequencyToHz(profiles->secondary.params.freq);
_saved_radio2.bw_hz = bandwidthToHz(profiles->secondary.params.bw);
}
void KissModem::handleSetRadio(const uint8_t* data, uint16_t len) {
if (len < 10) {
if (len < KISS_RADIO_PARAMS_SIZE) {
writeHardwareError(HW_ERR_INVALID_LENGTH);
return;
}
if (!_setRadioCallback) {
writeHardwareError(HW_ERR_NO_CALLBACK);
return;
RadioConfig requested = _config;
requested.freq_hz = readU32LE(data);
requested.bw_hz = readU32LE(data + 4);
requested.sf = data[8];
requested.cr = data[9];
mesh::RadioProfiles* profiles = _radio.profiles();
if (profiles) {
mesh::RadioProfileParams params = profiles->primary;
params.freq = (float)((double)requested.freq_hz / 1000000.0);
params.bw = (float)((double)requested.bw_hz / 1000.0);
params.sf = requested.sf;
params.cr = requested.cr;
const auto result = _radio.trySetPrimaryParams(params, false);
if (result == mesh::RadioParamApplyResult::BUSY) {
writeHardwareError(HW_ERR_TX_BUSY);
return;
}
if (result != mesh::RadioParamApplyResult::APPLIED) {
writeHardwareError(HW_ERR_INVALID_PARAM);
return;
}
} else {
if (!_setRadioCallback) {
writeHardwareError(HW_ERR_NO_CALLBACK);
return;
}
_setRadioCallback((float)((double)requested.freq_hz / 1000000.0),
(float)((double)requested.bw_hz / 1000.0),
requested.sf, requested.cr);
}
memcpy(&_config.freq_hz, data, 4);
memcpy(&_config.bw_hz, data + 4, 4);
_config.sf = data[8];
_config.cr = data[9];
_setRadioCallback(_config.freq_hz / 1000000.0f, _config.bw_hz / 1000.0f, _config.sf, _config.cr);
_config = requested;
writeHardwareFrame(HW_RESP_OK, nullptr, 0);
}
@@ -606,12 +805,103 @@ void KissModem::handleSetTxPower(const uint8_t* data, uint16_t len) {
}
void KissModem::handleGetRadio() {
uint8_t buf[10];
memcpy(buf, &_config.freq_hz, 4);
memcpy(buf + 4, &_config.bw_hz, 4);
syncPrimaryConfigFromRadio();
uint8_t buf[KISS_RADIO_PARAMS_SIZE];
writeU32LE(buf, _config.freq_hz);
writeU32LE(buf + 4, _config.bw_hz);
buf[8] = _config.sf;
buf[9] = _config.cr;
writeHardwareFrame(HW_RESP(HW_CMD_GET_RADIO), buf, 10);
writeHardwareFrame(HW_RESP(HW_CMD_GET_RADIO), buf, sizeof(buf));
}
void KissModem::handleSetRadio2(const uint8_t* data, uint16_t len) {
if (len != KISS_RADIO2_PARAMS_SIZE) {
writeHardwareError(HW_ERR_INVALID_LENGTH);
return;
}
mesh::RadioProfiles* profiles = _radio.profiles();
if (!profiles) {
writeHardwareError(HW_ERR_NO_CALLBACK);
return;
}
Radio2Config config;
if (!decodeRadio2Config(data, config) || !validateRadio2Config(config)) {
writeHardwareError(HW_ERR_INVALID_PARAM);
return;
}
// Like the CLI, changing the saved secondary profile must not interrupt an
// active temporary lease. It becomes active when that lease ends.
_saved_radio2 = config;
if (!_temporary_radio2_active) profiles->setSecondary(_saved_radio2.profile, false);
writeHardwareFrame(HW_RESP_OK, nullptr, 0);
}
void KissModem::handleGetRadio2() {
if (!_radio.profiles()) {
writeHardwareError(HW_ERR_NO_CALLBACK);
return;
}
uint8_t buf[KISS_RADIO2_PARAMS_SIZE];
encodeRadio2Config(_saved_radio2, buf);
writeHardwareFrame(HW_RESP(HW_CMD_GET_RADIO2), buf, sizeof(buf));
}
void KissModem::handleSetTempRadio2(const uint8_t* data, uint16_t len) {
if (len != KISS_TEMPRADIO2_PARAMS_SIZE) {
writeHardwareError(HW_ERR_INVALID_LENGTH);
return;
}
mesh::RadioProfiles* profiles = _radio.profiles();
if (!profiles) {
writeHardwareError(HW_ERR_NO_CALLBACK);
return;
}
Radio2Config config;
const uint16_t minutes = readU16LE(data + KISS_RADIO2_PARAMS_SIZE);
if (!decodeRadio2Config(data, config)) {
writeHardwareError(HW_ERR_INVALID_PARAM);
return;
}
if (config.profile.mode == mesh::RadioProfileMode::Off && minutes == 0) {
_temporary_radio2 = {};
_temporary_radio2_active = false;
_temporary_radio2_end_ms = 0;
profiles->setSecondary(_saved_radio2.profile, false);
writeHardwareFrame(HW_RESP_OK, nullptr, 0);
return;
}
if (config.profile.mode == mesh::RadioProfileMode::Off || minutes == 0
|| minutes > KISS_MAX_TEMPRADIO2_MINUTES || !validateRadio2Config(config)) {
writeHardwareError(HW_ERR_INVALID_PARAM);
return;
}
_temporary_radio2 = config;
_temporary_radio2_active = true;
_temporary_radio2_end_ms = millis() + (uint32_t)minutes * 60000UL;
profiles->setSecondary(_temporary_radio2.profile, true);
writeHardwareFrame(HW_RESP_OK, nullptr, 0);
}
void KissModem::handleGetTempRadio2() {
if (!_radio.profiles()) {
writeHardwareError(HW_ERR_NO_CALLBACK);
return;
}
uint8_t buf[KISS_TEMPRADIO2_PARAMS_SIZE] = {};
if (_temporary_radio2_active) {
encodeRadio2Config(_temporary_radio2, buf);
const uint32_t now = millis();
const uint32_t remaining_ms = (int32_t)(_temporary_radio2_end_ms - now) > 0
? _temporary_radio2_end_ms - now : 0;
const uint32_t remaining_minutes = (remaining_ms + 59999UL) / 60000UL;
writeU16LE(buf + KISS_RADIO2_PARAMS_SIZE, (uint16_t)remaining_minutes);
}
writeHardwareFrame(HW_RESP(HW_CMD_GET_TEMPRADIO2), buf, sizeof(buf));
}
void KissModem::handleGetTxPower() {
+50 -2
View File
@@ -21,6 +21,9 @@
#define KISS_MAX_ENCODED_FRAME_SIZE (KISS_FRAME_BOUNDARY_BYTES + KISS_MAX_ESCAPED_PAYLOAD_SIZE)
#define KISS_TX_FRAME_QUEUE_DEPTH 2
#define KISS_HW_MAX_PAYLOAD_SIZE (KISS_MAX_FRAME_SIZE + KISS_HW_SUBCMD_BYTES)
// Bound host parsing work so a busy USB/UART peer cannot starve the on-board
// dual-profile scanner. A partial KISS frame is retained across calls.
#define KISS_RX_SERVICE_BYTE_BUDGET 32
#define KISS_CMD_DATA 0x00
#define KISS_CMD_TXDELAY 0x01
@@ -62,6 +65,10 @@
#define HW_CMD_REBOOT 0x18
#define HW_CMD_SET_SIGNAL_REPORT 0x19
#define HW_CMD_GET_SIGNAL_REPORT 0x1A
#define HW_CMD_SET_RADIO2 0x1B
#define HW_CMD_GET_RADIO2 0x1C
#define HW_CMD_SET_TEMPRADIO2 0x1D
#define HW_CMD_GET_TEMPRADIO2 0x1E
/* Response code = command code | 0x80. Generic / unsolicited use 0xF0+. */
#define HW_RESP(cmd) ((cmd) | 0x80)
@@ -82,7 +89,13 @@
#define HW_ERR_ENCRYPT_FAILED 0x06
#define HW_ERR_TX_BUSY 0x07
#define KISS_FIRMWARE_VERSION 1
#define KISS_RADIO_PARAMS_SIZE 10
#define KISS_RADIO2_PARAMS_SIZE 13
#define KISS_TEMPRADIO2_PARAMS_SIZE 15
#define KISS_MAX_TEMPRADIO2_MINUTES 10080
/* Version 2 adds the logical secondary-radio KISS port and radio2 commands. */
#define KISS_FIRMWARE_VERSION 2
typedef void (*SetRadioCallback)(float freq, float bw, uint8_t sf, uint8_t cr);
typedef void (*SetTxPowerCallback)(uint8_t power);
@@ -97,6 +110,14 @@ struct RadioConfig {
uint8_t tx_power;
};
// Keep the exact host wire tuple beside the float profile used by the radio.
// A float cannot preserve every whole-Hz value in the KISS representation.
struct Radio2Config {
mesh::RadioProfileConfig profile;
uint32_t freq_hz = 0;
uint32_t bw_hz = 0;
};
enum TxState {
TX_IDLE,
TX_WAIT_CLEAR,
@@ -122,6 +143,8 @@ class KissModem {
uint8_t _pending_tx[KISS_MAX_PACKET_SIZE];
uint16_t _pending_tx_len;
bool _has_pending_tx;
uint8_t _pending_tx_profile;
uint32_t _pending_tx_profile_generation;
uint8_t _txdelay;
uint8_t _persistence;
@@ -138,6 +161,12 @@ class KissModem {
GetStatsCallback _getStatsCallback;
RadioConfig _config;
// KISS settings are host-session state only. They intentionally do not use
// the CLI's persistent radio-profile storage.
Radio2Config _saved_radio2;
Radio2Config _temporary_radio2;
bool _temporary_radio2_active;
uint32_t _temporary_radio2_end_ms;
bool _signal_report_enabled;
uint8_t _tx_frame_buf[KISS_TX_FRAME_QUEUE_DEPTH][KISS_MAX_ENCODED_FRAME_SIZE];
uint16_t _tx_frame_len[KISS_TX_FRAME_QUEUE_DEPTH];
@@ -165,6 +194,15 @@ class KissModem {
void processFrame();
void handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint16_t len);
void processTx();
void expireTemporaryRadio2();
bool pendingTxProfileUnchanged() const;
mesh::RadioParamApplyResult preparePendingTransmitProfile();
uint32_t pendingTxAirtime(uint16_t len);
bool decodeRadio2Config(const uint8_t* data, Radio2Config& config) const;
void encodeRadio2Config(const Radio2Config& config, uint8_t* data) const;
bool validateRadio2Config(const Radio2Config& config) const;
void syncPrimaryConfigFromRadio();
void syncSavedRadio2FromRadio();
void handleGetIdentity();
void handleGetRandom(const uint8_t* data, uint16_t len);
@@ -177,6 +215,10 @@ class KissModem {
void handleSetRadio(const uint8_t* data, uint16_t len);
void handleSetTxPower(const uint8_t* data, uint16_t len);
void handleGetRadio();
void handleSetRadio2(const uint8_t* data, uint16_t len);
void handleGetRadio2();
void handleSetTempRadio2(const uint8_t* data, uint16_t len);
void handleGetTempRadio2();
void handleGetTxPower();
void handleGetVersion();
void handleGetCurrentRssi();
@@ -205,7 +247,13 @@ public:
void setGetCurrentRssiCallback(GetCurrentRssiCallback cb) { _getCurrentRssiCallback = cb; }
void setGetStatsCallback(GetStatsCallback cb) { _getStatsCallback = cb; }
void onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len);
/**
* profile is the physical profile selected when recvRaw completed. It maps
* directly to the KISS port nibble (0=radio, 1=radio2) without touching the
* raw MeshCore packet or the fixed RxMeta payload.
*/
void onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len,
uint8_t profile = 0);
bool isTxBusy() const { return _tx_state != TX_IDLE; }
/** True only when radio is actually transmitting; use to skip recvRaw in main loop. */
bool isActuallyTransmitting() const { return _tx_state == TX_SENDING; }
+5 -1
View File
@@ -173,9 +173,13 @@ void loop() {
uint8_t rx_buf[256];
int rx_len = radio_driver.recvRaw(rx_buf, sizeof(rx_buf));
if (rx_len > 0) {
// Snapshot before the receive-complete hook or fast profile scanner can
// retune the one physical radio. KISS port 0/1 reports this origin.
const uint8_t profile = radio_driver.receiveProfile();
int8_t snr = (int8_t)(radio_driver.getLastSNR() * 4);
int8_t rssi = (int8_t)radio_driver.getLastRSSI();
modem->onPacketReceived(snr, rssi, rx_buf, rx_len);
modem->onPacketReceived(snr, rssi, rx_buf, rx_len, profile);
radio_driver.onReceiveProcessed();
}
}
+25
View File
@@ -1,14 +1,39 @@
#pragma once
#include <cstdint>
#include <RadioProfiles.h>
namespace mesh {
enum class RadioParamApplyResult : uint8_t {
APPLIED,
BUSY,
FAILED
};
class Radio {
public:
virtual ~Radio() = default;
virtual RadioProfiles* profiles() { return nullptr; }
virtual const RadioProfiles* profiles() const { return nullptr; }
virtual bool validateProfile(const RadioProfileParams&) const { return false; }
virtual uint8_t receiveProfile() const { return 0; }
virtual uint32_t receiveProfileGeneration() const {
const RadioProfiles* p = profiles();
return p ? p->generation[receiveProfile()] : 0;
}
virtual RadioParamApplyResult prepareTransmitProfile(uint8_t profile) {
return profile == 0 ? RadioParamApplyResult::APPLIED : RadioParamApplyResult::FAILED;
}
virtual RadioParamApplyResult trySetPrimaryParams(const RadioProfileParams&,
bool, const uint32_t* = nullptr) {
return RadioParamApplyResult::FAILED;
}
virtual bool isReceiving() { return false; }
virtual uint32_t getEstAirtimeFor(uint16_t) { return 10; }
virtual uint32_t getProfileAirtime(uint8_t, int len, uint8_t = 0) {
return getEstAirtimeFor((uint16_t)len);
}
virtual bool startSendRaw(const uint8_t*, uint16_t) { return true; }
virtual bool isSendComplete() { return true; }
virtual void onSendFinished() {}
+288 -2
View File
@@ -43,11 +43,21 @@ public:
return _writes.size();
}
size_t rxAvailable() const {
std::lock_guard<std::mutex> lock(_mutex);
return _rx.size();
}
std::vector<uint8_t> writesSnapshot() const {
std::lock_guard<std::mutex> lock(_mutex);
return _writes;
}
void clearWrites() {
std::lock_guard<std::mutex> lock(_mutex);
_writes.clear();
}
int availableForWrite() override {
std::lock_guard<std::mutex> lock(_mutex);
return _block_writes ? 0 : TEST_TX_AVAILABLE_BYTES;
@@ -111,10 +121,46 @@ public:
class FakeRadio : public mesh::Radio {
public:
FakeRadio() {
_profiles.primary.freq = 909.5f;
_profiles.primary.bw = 62.5f;
_profiles.primary.sf = 7;
_profiles.primary.cr = 5;
}
mesh::RadioProfiles* profiles() override { return &_profiles; }
const mesh::RadioProfiles* profiles() const override { return &_profiles; }
bool validateProfile(const mesh::RadioProfileParams& params) const override {
return mesh::RadioProfiles::valid(params);
}
uint8_t receiveProfile() const override { return _active_profile; }
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override {
_prepared_profiles.push_back(profile);
if (_prepare_result != mesh::RadioParamApplyResult::APPLIED) return _prepare_result;
if (!_profiles.canTransmit(profile)) return mesh::RadioParamApplyResult::FAILED;
_active_profile = profile;
return mesh::RadioParamApplyResult::APPLIED;
}
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams& params,
bool temporary, const uint32_t* = nullptr) override {
mesh::RadioProfiles preview = _profiles;
preview.primary = params;
if (!validateProfile(params) || !preview.automaticPreambleFits()) {
return mesh::RadioParamApplyResult::FAILED;
}
_profiles.setPrimary(params, temporary);
_active_profile = 0;
return mesh::RadioParamApplyResult::APPLIED;
}
bool isReceiving() override { return false; }
uint32_t getEstAirtimeFor(uint16_t) override { return 10; }
uint32_t getProfileAirtime(uint8_t profile, int, uint8_t = 0) override {
_airtime_profiles.push_back(profile);
return 10;
}
bool startSendRaw(const uint8_t*, uint16_t) override {
_start_send_count++;
_sent_profiles.push_back(_active_profile);
return _start_send_result;
}
bool isSendComplete() override { return _send_complete; }
@@ -123,14 +169,28 @@ public:
void setStartSendResult(bool result) { _start_send_result = result; }
void setSendComplete(bool complete) { _send_complete = complete; }
void setPrepareResult(mesh::RadioParamApplyResult result) { _prepare_result = result; }
void setReceiveProfile(uint8_t profile) { _active_profile = profile; }
int startSendCount() const { return _start_send_count; }
int sendFinishedCount() const { return _send_finished_count; }
uint8_t lastSentProfile() const { return _sent_profiles.empty() ? 0xff : _sent_profiles.back(); }
int prepareCount() const { return (int)_prepared_profiles.size(); }
uint8_t lastPreparedProfile() const {
return _prepared_profiles.empty() ? 0xff : _prepared_profiles.back();
}
mesh::RadioProfiles& profileConfig() { return _profiles; }
private:
mesh::RadioProfiles _profiles;
uint8_t _active_profile = 0;
bool _start_send_result = true;
bool _send_complete = true;
int _start_send_count = 0;
int _send_finished_count = 0;
mesh::RadioParamApplyResult _prepare_result = mesh::RadioParamApplyResult::APPLIED;
std::vector<uint8_t> _prepared_profiles;
std::vector<uint8_t> _airtime_profiles;
std::vector<uint8_t> _sent_profiles;
};
class FakeBoard : public mesh::MainBoard {
@@ -161,13 +221,42 @@ protected:
modem.begin();
}
static std::vector<uint8_t> dataFrame(const std::vector<uint8_t>& packet) {
std::vector<uint8_t> frame = {KISS_FEND, KISS_CMD_DATA};
void SetUp() override {
resetArduinoMock();
}
static std::vector<uint8_t> dataFrame(const std::vector<uint8_t>& packet, uint8_t profile = 0) {
std::vector<uint8_t> frame = {KISS_FEND, (uint8_t)(profile << 4)};
frame.insert(frame.end(), packet.begin(), packet.end());
frame.push_back(KISS_FEND);
return frame;
}
static std::vector<uint8_t> hardwareFrame(uint8_t command, const std::vector<uint8_t>& payload = {}) {
std::vector<uint8_t> frame = {KISS_FEND, KISS_CMD_SETHARDWARE, command};
frame.insert(frame.end(), payload.begin(), payload.end());
frame.push_back(KISS_FEND);
return frame;
}
static std::vector<uint8_t> radio2Payload(uint32_t freq_hz, uint32_t bw_hz,
uint8_t sf, uint8_t cr, uint8_t mode, uint16_t preamble) {
return {
(uint8_t)freq_hz, (uint8_t)(freq_hz >> 8), (uint8_t)(freq_hz >> 16), (uint8_t)(freq_hz >> 24),
(uint8_t)bw_hz, (uint8_t)(bw_hz >> 8), (uint8_t)(bw_hz >> 16), (uint8_t)(bw_hz >> 24),
sf, cr, mode, (uint8_t)preamble, (uint8_t)(preamble >> 8)};
}
static void appendU16(std::vector<uint8_t>& bytes, uint16_t value) {
bytes.push_back((uint8_t)value);
bytes.push_back((uint8_t)(value >> 8));
}
void sendHardware(uint8_t command, const std::vector<uint8_t>& payload = {}) {
serial.pushRx(hardwareFrame(command, payload));
modem.loop();
}
void advanceToTxSending() {
modem.loop();
modem.loop();
@@ -288,6 +377,203 @@ TEST_F(KissModemFixture, MaxPacketWorstCaseEscapingFitsQueuedFrame) {
EXPECT_EQ(serial.writesSnapshot(), expected);
}
TEST_F(KissModemFixture, LegacyPrimaryRadioSetAndGetStayTenBytes) {
const std::vector<uint8_t> primary = {
0x60, 0xDE, 0x35, 0x36, // 909500000 Hz
0x24, 0xF4, 0x00, 0x00, // 62500 Hz
7, 5};
sendHardware(HW_CMD_SET_RADIO, primary);
EXPECT_EQ(serial.writesSnapshot(), (std::vector<uint8_t>{
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_OK, KISS_FEND}));
serial.clearWrites();
sendHardware(HW_CMD_GET_RADIO);
const std::vector<uint8_t> expected = {
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_GET_RADIO),
primary[0], primary[1], primary[2], primary[3], primary[4], primary[5], primary[6], primary[7],
primary[8], primary[9], KISS_FEND};
EXPECT_EQ(serial.writesSnapshot(), expected);
}
TEST_F(KissModemFixture, Radio2WireRoundTripAndValidation) {
const std::vector<uint8_t> radio2 = radio2Payload(910500000, 62500, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 0);
sendHardware(HW_CMD_SET_RADIO2, radio2);
EXPECT_EQ(serial.writesSnapshot(), (std::vector<uint8_t>{
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_OK, KISS_FEND}));
EXPECT_EQ(radio.profileConfig().secondary.mode, mesh::RadioProfileMode::RxTx);
EXPECT_FLOAT_EQ(radio.profileConfig().secondary.params.freq, 910.5f);
serial.clearWrites();
sendHardware(HW_CMD_GET_RADIO2);
std::vector<uint8_t> expected = {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_GET_RADIO2)};
expected.insert(expected.end(), radio2.begin(), radio2.end());
expected.push_back(KISS_FEND);
EXPECT_EQ(serial.writesSnapshot(), expected);
serial.clearWrites();
std::vector<uint8_t> short_payload(radio2.begin(), radio2.end() - 1);
sendHardware(HW_CMD_SET_RADIO2, short_payload);
EXPECT_EQ(serial.writesSnapshot(), (std::vector<uint8_t>{
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_ERROR, HW_ERR_INVALID_LENGTH, KISS_FEND}));
EXPECT_FLOAT_EQ(radio.profileConfig().secondary.params.freq, 910.5f);
serial.clearWrites();
std::vector<uint8_t> invalid_mode = radio2;
invalid_mode[10] = 3;
sendHardware(HW_CMD_SET_RADIO2, invalid_mode);
EXPECT_EQ(serial.writesSnapshot(), (std::vector<uint8_t>{
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_ERROR, HW_ERR_INVALID_PARAM, KISS_FEND}));
EXPECT_EQ(radio.profileConfig().secondary.mode, mesh::RadioProfileMode::RxTx);
}
TEST_F(KissModemFixture, TempRadio2RestoresSavedProfileAndLeavesSavedUpdatesDeferred) {
const std::vector<uint8_t> saved = radio2Payload(910500000, 62500, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 0);
sendHardware(HW_CMD_SET_RADIO2, saved);
serial.clearWrites();
std::vector<uint8_t> temporary = radio2Payload(868000000, 125000, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 32);
appendU16(temporary, 1);
sendHardware(HW_CMD_SET_TEMPRADIO2, temporary);
EXPECT_EQ(serial.writesSnapshot(), (std::vector<uint8_t>{
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_OK, KISS_FEND}));
EXPECT_TRUE(radio.profileConfig().secondary_temporary);
EXPECT_FLOAT_EQ(radio.profileConfig().secondary.params.freq, 868.0f);
serial.clearWrites();
const std::vector<uint8_t> replacement_saved = radio2Payload(433920000, 125000, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 0);
sendHardware(HW_CMD_SET_RADIO2, replacement_saved);
EXPECT_FALSE(radio.profileConfig().secondary.params.freq == 433.92f)
<< "saved radio2 must not interrupt the active temporary lease";
EXPECT_FLOAT_EQ(radio.profileConfig().secondary.params.freq, 868.0f);
serial.clearWrites();
sendHardware(HW_CMD_GET_RADIO2);
std::vector<uint8_t> saved_expected = {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_GET_RADIO2)};
saved_expected.insert(saved_expected.end(), replacement_saved.begin(), replacement_saved.end());
saved_expected.push_back(KISS_FEND);
EXPECT_EQ(serial.writesSnapshot(), saved_expected);
delay(60000);
modem.loop();
EXPECT_FALSE(radio.profileConfig().secondary_temporary);
EXPECT_FLOAT_EQ(radio.profileConfig().secondary.params.freq, 433.92f);
serial.clearWrites();
sendHardware(HW_CMD_GET_TEMPRADIO2);
EXPECT_EQ(serial.writesSnapshot(), (std::vector<uint8_t>{
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_GET_TEMPRADIO2),
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, KISS_FEND}));
}
TEST_F(KissModemFixture, SecondaryReceiveUsesPortOneWithoutChangingPacketOrRxMeta) {
const std::vector<uint8_t> radio2 = radio2Payload(910500000, 62500, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 0);
sendHardware(HW_CMD_SET_RADIO2, radio2);
serial.clearWrites();
static constexpr uint8_t packet[] = {0x01, 0x02, 0x03};
serial.setBlockWrites(true);
modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, packet, sizeof(packet), 1);
serial.setBlockWrites(false);
modem.loop();
modem.loop();
const std::vector<uint8_t> expected = {
KISS_FEND, 0x10, packet[0], packet[1], packet[2], KISS_FEND,
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND};
EXPECT_EQ(serial.writesSnapshot(), expected);
}
TEST_F(KissModemFixture, SecondaryTransmitUsesPortOneAndRejectsReceiveOnlyProfile) {
const std::vector<uint8_t> rxtx = radio2Payload(910500000, 62500, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 0);
sendHardware(HW_CMD_SET_RADIO2, rxtx);
serial.clearWrites();
serial.pushRx(dataFrame({0x42}, 1));
advanceToTxSending();
EXPECT_EQ(radio.startSendCount(), 1);
EXPECT_EQ(radio.lastSentProfile(), 1);
EXPECT_EQ(radio.lastPreparedProfile(), 1);
modem.loop();
modem.loop();
serial.clearWrites();
const std::vector<uint8_t> receive_only = radio2Payload(910500000, 62500, 7, 5,
(uint8_t)mesh::RadioProfileMode::Rx, 0);
sendHardware(HW_CMD_SET_RADIO2, receive_only);
serial.clearWrites();
serial.pushRx(dataFrame({0x43}, 1));
modem.loop();
modem.loop();
modem.loop();
EXPECT_EQ(radio.startSendCount(), 1);
EXPECT_EQ(serial.writesSnapshot(), (std::vector<uint8_t>{
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_TX_DONE, 0x00, KISS_FEND}));
}
TEST_F(KissModemFixture, PendingSecondaryTransmitFailsWhenItsProfileChanges) {
const std::vector<uint8_t> radio2 = radio2Payload(910500000, 62500, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 0);
sendHardware(HW_CMD_SET_RADIO2, radio2);
serial.clearWrites();
serial.pushRx(dataFrame({0x42}, 1));
modem.loop(); // Accept data and bind its profile generation.
EXPECT_EQ(radio.startSendCount(), 0);
std::vector<uint8_t> temporary = radio2Payload(868000000, 125000, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 32);
appendU16(temporary, 1);
sendHardware(HW_CMD_SET_TEMPRADIO2, temporary);
EXPECT_EQ(radio.startSendCount(), 0);
serial.clearWrites();
modem.loop();
EXPECT_EQ(radio.startSendCount(), 0);
EXPECT_EQ(serial.writesSnapshot(), (std::vector<uint8_t>{
KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_TX_DONE, 0x00, KISS_FEND}));
}
TEST_F(KissModemFixture, BusyProfilePreparationDefersSecondaryTransmit) {
const std::vector<uint8_t> radio2 = radio2Payload(910500000, 62500, 7, 5,
(uint8_t)mesh::RadioProfileMode::RxTx, 0);
sendHardware(HW_CMD_SET_RADIO2, radio2);
serial.clearWrites();
radio.setPrepareResult(mesh::RadioParamApplyResult::BUSY);
serial.pushRx(dataFrame({0x42}, 1));
modem.loop();
modem.loop();
delay(KISS_DEFAULT_TXDELAY * 10);
modem.loop();
EXPECT_EQ(radio.startSendCount(), 0);
radio.setPrepareResult(mesh::RadioParamApplyResult::APPLIED);
modem.loop();
delay(KISS_DEFAULT_TXDELAY * 10);
modem.loop();
EXPECT_EQ(radio.startSendCount(), 1);
EXPECT_EQ(radio.lastSentProfile(), 1);
}
TEST_F(KissModemFixture, BusyHostInputCannotStarveTheProfileScannerLoop) {
std::vector<uint8_t> host_bytes(KISS_RX_SERVICE_BYTE_BUDGET * 2, 0x01);
serial.pushRx(host_bytes);
modem.loop();
// A full KISS frame may span calls; bounded parsing leaves the outer sketch
// loop free to run radio_driver.loop() and make the next ~0.6 ms profile hop.
EXPECT_EQ(serial.rxAvailable(), KISS_RX_SERVICE_BYTE_BUDGET);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
+8
View File
@@ -847,6 +847,14 @@ build_unflags =
build_flags =
${Heltec_lora32_v4.build_flags}
-DARDUINO_USB_MODE=1
; KISS radio2 scanning stays on the board. This narrowly opts this target
; into the guarded SX1262 RX-to-RX fast path and buffered 8 MHz SPI. The
; host-powered modem also uses the established V4 160 MHz role override;
; normal V4 images retain their lower-power 80 MHz policy.
-D MC_SX1262_FAST_PROFILE_SWITCH=1
-D MC_SX1262_BUFFERED_RADIO_HAL=1
-D MC_SX1262_SPI_HZ=8000000
-D ESP32_CPU_FREQ=160
build_src_filter = ${Heltec_lora32_v4.build_src_filter}
+<../examples/kiss_modem/>
+12 -1
View File
@@ -1,11 +1,22 @@
#include <Arduino.h>
#include "target.h"
// Normal V4 images retain the established RadioLib Arduino HAL. The KISS
// modem can opt into buffered SPI for its on-board dual-profile RX scanner.
#if defined(MC_SX1262_BUFFERED_RADIO_HAL) && MC_SX1262_BUFFERED_RADIO_HAL
#include <helpers/radiolib/ESP32BufferedRadioHal.h>
#endif
HeltecV4Board board;
#if defined(P_LORA_SCLK)
static SPIClass spi;
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
#if defined(MC_SX1262_BUFFERED_RADIO_HAL) && MC_SX1262_BUFFERED_RADIO_HAL
static ESP32BufferedRadioHal radio_hal(spi);
RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
#else
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
#endif
#else
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
#endif