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 66b605a49d
commit 7657840d38
9 files changed
+796 -46

No files matched your search

+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();