Merge upstream dev fixes

Keep our radio transactions, notification defaults, display settings and pinned RadioLib release. Integrate contact and sensor safety fixes, saturated statistics and compatible board/display updates.
This commit is contained in:
mikecarper committed 2026-10-30 17:43:40 -07:00
commit 49d70fe0c6
32 files changed
+675 -103

No files matched your search

+3
View File
@@ -399,6 +399,9 @@ jobs:
- name: Run Unit Tests
run: pio test -e native -e native_kiss_modem -e native_ota_channel -vv
- name: Verify sensor reply bounds and duplicate counter saturation
run: python3 -B test/test_upstream_cli_bounds.py -v
- name: Verify real GPS NMEA parser and provider with address and undefined sanitizers
run: pio test -e native_gps -vv
+62
View File
@@ -0,0 +1,62 @@
# Companion CLI
Companions accept local CLI commands through the Companion protocol. Remote
commands require the configured admin permission. Available commands depend on
the board and firmware profile; use `help` for the commands in your build.
## Common commands
| Purpose | Commands |
| --- | --- |
| Firmware version | `ver` |
| Node name | `get name`, `set name <name>` |
| Radio profile | `get radio`, `set radio <MHz>,<kHz>,<SF>,<CR>` |
| Frequency | `get freq`, `set freq <MHz>` |
| Transmit power | `get tx`, `set tx <dBm>` |
| Radio apply status | `get radio.apply` |
| Timezone, hours | `get tz.offset`, `set tz.offset <hours>` |
| Airtime limit | `get dutycycle`, `set dutycycle <percent>` |
| Receive gain | `get radio.rxgain`, `set radio.rxgain <on/off>` |
| Receive power saving | `get radio.rxps`, `get radio.rxps.config` |
| FEM receive gain | `get radio.fem.rxgain`, `set radio.fem.rxgain <on/off>` |
| FEM transmit gain | `get radio.fem.txgain`, `set radio.fem.txgain <on/off>` |
| BLE PIN | `set pin <0-999999>` |
| Restart | `reboot` |
| Power off, where supported | `poweroff` or `shutdown` |
Supported frequencies, bandwidths and power limits depend on the radio and
board. RF changes may wait for active traffic; `get radio.apply` reports the
apply state. Transmit power is the radio-chip setting, which can differ from
antenna output on amplified boards.
See [CLI commands](cli_commands.md) for setting ranges and access restrictions,
and [radio profiles](radio_profiles.md) for radio2, temporary profiles and
schedules.
## Wi-Fi
Wi-Fi builds expose `get wifi.status`, `get wifi.ip`, and
`get/set wifi.enabled`. Configure credentials with `set wifi.ssid <SSID>` and
`set wifi.pwd <password>`. Credential access follows the normal local/admin
restrictions. Reboot after saving Wi-Fi settings to apply them.
## Display and notifications
This fork uses the saved `display.*` controls described in
[display controls](cli_commands.md#set-display-modes-and-timeouts):
```text
get display.mode
get display.timeout
get display.usb.mode
get display.usb.timeout
get display.inbox
```
Timeouts default to 900 seconds on e-paper and 15 seconds on other screens.
Battery and USB settings are independent. Display settings live in a separate
checked preferences file and preserve the existing node-preferences layout.
The upstream `ui.*` settings are not aliases in this fork.
For sound, vibration and custom notification rules, see
[notifications](notifications.md).
+12 -16
View File
@@ -617,11 +617,7 @@ class HomeScreen : public UIScreen {
sensors_scroll_offset = 0;
return;
}
#if AUTO_OFF_MILLIS > 0
const uint32_t refresh_interval = 5000;
#else
const uint32_t refresh_interval = 60000;
#endif
const uint32_t refresh_interval = display.isEink() ? 60000 : 5000;
// Empty data requests an initial/forced sample. Elapsed time also handles
// returning to this page after more than half a millis() cycle.
if (sensors_lpp.getSize() == 0
@@ -2293,11 +2289,7 @@ public:
#endif
renderChannelFilter(display);
#if AUTO_OFF_MILLIS==0 // probably e-ink
return UI_BUTTON_READER_HINT == 1 ? 3000 : 10000;
#else
return 1000; // next render after 1000 ms
#endif
return display.isEink() ? (UI_BUTTON_READER_HINT == 1 ? 3000 : 10000) : 1000;
}
bool handleInput(char c) override {
@@ -2735,9 +2727,13 @@ void UITask::shutdown(bool restart){
if (restart) {
_board->reboot();
} else {
display.forceFullRefresh();
display.clear();
display.endFrame();
if (_display != NULL && _display->isEink()) {
if (_display->isOn()) _display->turnOff();
} else {
display.forceFullRefresh();
display.clear();
display.endFrame();
}
// Power off board including radio, display, GPS and components
_board->powerOff();
}
@@ -2875,11 +2871,11 @@ void UITask::loop() {
#endif
#if defined(UI_HAS_ROTARY_INPUT)
RotaryInputEvent rotaryEv = rotary_input.poll();
if (c == 0 && _display != NULL && _display->isOn()) {
if (c == 0 && _display != NULL) {
if (rotaryEv == RotaryInputEvent::Next) {
c = KEY_NEXT;
c = checkDisplayOn(KEY_NEXT);
} else if (rotaryEv == RotaryInputEvent::Prev) {
c = KEY_PREV;
c = checkDisplayOn(KEY_PREV);
}
}
#endif
@@ -761,6 +761,7 @@ void UITask::loop() {
userLedHandler();
#ifdef PIN_BUZZER
if (buzzer.isQuiet() != (bool)_node_prefs->buzzer_quiet) buzzer.quiet(_node_prefs->buzzer_quiet);
if (buzzer.isPlaying()) buzzer.loop();
#endif
#if defined(PIN_BUZZER) && MESH_GPS_VOICE
@@ -998,6 +998,7 @@ void UITask::loop() {
userLedHandler();
#ifdef PIN_BUZZER
if (buzzer.isQuiet() != (bool)_node_prefs->buzzer_quiet) buzzer.quiet(_node_prefs->buzzer_quiet);
if (buzzer.isPlaying()) buzzer.loop();
#endif
+2 -2
View File
@@ -846,8 +846,8 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t
stats.n_recv_direct = getNumRecvDirect();
stats.err_events = _err_flags;
stats.last_snr = (int16_t)(radio_driver.getLastSNR() * 4);
stats.n_direct_dups = ((SimpleMeshTables *)getTables())->getNumDirectDups();
stats.n_flood_dups = ((SimpleMeshTables *)getTables())->getNumFloodDups();
stats.n_direct_dups = std::min<uint32_t>(((SimpleMeshTables *)getTables())->getNumDirectDups(), 0xFFFF);
stats.n_flood_dups = std::min<uint32_t>(((SimpleMeshTables *)getTables())->getNumFloodDups(), 0xFFFF);
stats.total_rx_air_time_secs = getReceiveAirTime() / 1000;
stats.n_recv_errors = radio_driver.getPacketsRecvErrors();
memcpy(&reply_data[4], &stats, sizeof(stats));
+2 -2
View File
@@ -1383,8 +1383,8 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t
stats.n_recv_direct = getNumRecvDirect();
stats.err_events = _err_flags;
stats.last_snr = (int16_t)(radio_driver.getLastSNR() * 4);
stats.n_direct_dups = ((SimpleMeshTables *)getTables())->getNumDirectDups();
stats.n_flood_dups = ((SimpleMeshTables *)getTables())->getNumFloodDups();
stats.n_direct_dups = std::min<uint32_t>(((SimpleMeshTables *)getTables())->getNumDirectDups(), 0xFFFF);
stats.n_flood_dups = std::min<uint32_t>(((SimpleMeshTables *)getTables())->getNumFloodDups(), 0xFFFF);
stats.n_posted = _num_posted;
stats.n_post_push = _num_post_pushes;
+13
View File
@@ -244,6 +244,7 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
ContactInfo* from = NULL;
for (int i = 0; i < num_contacts; i++) {
if (contacts[i].isEmptySlot()) continue;
if (id.matches(contacts[i].id)) { // is from one of our contacts
from = &contacts[i];
if (timestamp <= from->last_advert_timestamp) { // check for replay attacks!!
@@ -327,11 +328,14 @@ int BaseChatMesh::searchPeersByHash(const uint8_t* hash) {
// A matching transient slot may have the same identity as an accepted contact.
// Try persistent contacts first so the transient slot cannot swallow their DMs.
for (int i = MAX_ANON_CONTACTS; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) {
if (contacts[i].isEmptySlot()) continue;
if (contacts[i].id.isHashMatch(hash)) {
matching_peer_indexes[n++] = i;
}
}
for (int i = 0; i < MAX_ANON_CONTACTS && n < MAX_SEARCH_RESULTS; i++) {
// Empty anonymous slots must not consume matches or derive a null key.
if (contacts[i].isEmptySlot()) continue;
if (contacts[i].id.isHashMatch(hash)) {
matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods)
}
@@ -748,6 +752,11 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient,
}
bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len) {
if (text_len < 0) {
MESH_DEBUG_PRINTLN("sendGroupMessage: invalid negative text_len=%d", text_len);
return false;
}
uint8_t temp[5+MAX_TEXT_LEN+32];
memcpy(temp, &timestamp, 4); // mostly an extra blob to help make packet_hash unique
temp[4] = 0; // TXT_TYPE_PLAIN
@@ -1086,6 +1095,7 @@ ContactInfo* BaseChatMesh::searchContactsByPrefix(const char* name_prefix) {
int len = strlen(name_prefix);
for (int i = 0; i < num_contacts; i++) {
auto c = &contacts[i];
if (c->isEmptySlot() || c->name[0] == 0) continue;
if (memcmp(c->name, name_prefix, len) == 0) return c;
}
return NULL; // not found
@@ -1094,6 +1104,7 @@ ContactInfo* BaseChatMesh::searchContactsByPrefix(const char* name_prefix) {
ContactInfo* BaseChatMesh::lookupContactByPubKey(const uint8_t* pub_key, int prefix_len) {
for (int i = 0; i < num_contacts; i++) {
auto c = &contacts[i];
if (c->isEmptySlot()) continue;
if (memcmp(c->id.pub_key, pub_key, prefix_len) == 0) return c;
}
return NULL; // not found
@@ -1102,6 +1113,7 @@ ContactInfo* BaseChatMesh::lookupContactByPubKey(const uint8_t* pub_key, int pre
ContactInfo* BaseChatMesh::lookupTransientContactByPubKey(const uint8_t* pub_key, int prefix_len) {
for (int i = 0; i < MAX_ANON_CONTACTS; i++) {
auto c = &contacts[i];
if (c->isEmptySlot()) continue;
if (memcmp(c->id.pub_key, pub_key, prefix_len) == 0) return c;
}
return NULL; // not found
@@ -1110,6 +1122,7 @@ ContactInfo* BaseChatMesh::lookupTransientContactByPubKey(const uint8_t* pub_key
ContactInfo* BaseChatMesh::lookupPersistentContactByPubKey(const uint8_t* pub_key, int prefix_len) {
for (int i = MAX_ANON_CONTACTS; i < num_contacts; i++) {
auto c = &contacts[i];
if (c->isEmptySlot()) continue;
if (memcmp(c->id.pub_key, pub_key, prefix_len) == 0) return c;
}
return NULL; // not found
+14 -6
View File
@@ -4166,18 +4166,26 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
if (start >= end) {
strcpy(reply, "no custom var");
} else {
const int reply_max = 160; // callers pass a 160-byte reply buffer
const int next_reserve = 16; // room for "... next:NNN"
sprintf(dp, "%d vars\n", end);
dp = strchr(dp, 0);
int i;
for (i = start; i < end && (dp-reply < 134); i++) {
sprintf(dp, "%s=%s\n",
_sensors->getSettingName(i),
_sensors->getSettingValue(i));
for (i = start; i < end; i++) {
const char* name = _sensors->getSettingName(i);
const char* value = _sensors->getSettingValue(i);
if (name == NULL) name = "?";
if (value == NULL) value = "";
int room = reply_max - next_reserve - (int)(dp - reply);
int need = snprintf(NULL, 0, "%s=%s\n", name, value);
if (need >= room && i > start) break; // continue on the next page
snprintf(dp, room, "%s=%s\n", name, value); // a single oversized entry is truncated
dp = strchr(dp, 0);
if (need >= room) { i++; break; }
}
if (i < end) {
sprintf(dp, "... next:%d", i);
} else {
snprintf(dp, reply_max - (int)(dp - reply), "... next:%d", i);
} else if (dp > reply && *(dp-1) == '\n') {
*(dp-1) = 0; // remove last CR
}
}
+6
View File
@@ -95,6 +95,12 @@ struct ContactInfo {
bool isTelemLocAllowed() const { return flags & 0x04; }
bool isTelemEnvAllowed() const { return flags & 0x08; }
bool isRemoteCLIAllowed() const { return flags & 0x10; }
bool isEmptySlot() const {
for (int i = 0; i < PUB_KEY_SIZE; i++) {
if (id.pub_key[i] != 0) return false;
}
return true;
}
private:
#if !MESH_CONTACT_CACHE
+2 -2
View File
@@ -1406,8 +1406,8 @@
}
],
"noncompact_tokens_sha256": {
"src/helpers/CommonCLI.cpp": "fd13053f606a8d008c10a690cbc7729c2701f44f84e433bb3cd2c776b9e61b0c",
"examples/simple_repeater/MyMesh.cpp": "e92d418e64bd3e2ddff19d196aafac130cef02dd6463a687735dfe11cd273725",
"src/helpers/CommonCLI.cpp": "ba486e64f3c948c44f08a6cb2ea4ef82bf1f3844fe0287e94f653654d3675dea",
"examples/simple_repeater/MyMesh.cpp": "9e688372f2b35a2c0f20f0eae00255877200ac9d84c9e5a30870296fbb928ca8",
"src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624",
"src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73",
"src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000",
+7 -1
View File
@@ -81,6 +81,10 @@ struct Task {
void toggleGPS() { ++toggles; }
};
struct Display {
bool isEink() const { return TEST_EINK; }
} display;
enum class HomePage { OTHER, SENSORS };
struct Home {
@@ -97,7 +101,9 @@ struct Home {
}
};
static constexpr uint32_t interval_ms = AUTO_OFF_MILLIS > 0 ? 5000u : 60000u;
static constexpr uint32_t interval_ms = TEST_NEW_UI
? (TEST_EINK ? 60000u : 5000u)
: (AUTO_OFF_MILLIS > 0 ? 5000u : 60000u);
static void begin_case(const char* name, uint32_t initial_ms) {
active_case = name;
@@ -0,0 +1,234 @@
#include <gtest/gtest.h>
#include <cstring>
#include <ed_25519.h>
#include "helpers/AdvertDataHelpers.cpp"
#include "helpers/BaseChatMesh.cpp"
namespace {
class DummyRadio : public mesh::Radio {
public:
int recvRaw(uint8_t*, int) override { return 0; }
uint32_t getEstAirtimeFor(int) override { return 10; }
float packetScore(float, int) override { return 1.0f; }
bool startSendRaw(const uint8_t*, int) override { return true; }
bool isSendComplete() override { return true; }
void onSendFinished() override {}
bool isInRecvMode() const override { return true; }
};
class DummyMillisecondClock : public mesh::MillisecondClock {
public:
unsigned long getMillis() override { return 1000; }
};
class DummyRNG : public mesh::RNG {
public:
void random(uint8_t* dest, size_t len) override { memset(dest, 0x42, len); }
};
class DummyRTCClock : public mesh::RTCClock {
uint32_t _now = 1700000000;
public:
uint32_t getCurrentTime() override { return _now; }
void setCurrentTime(uint32_t time) override { _now = time; }
};
class DummyPacketManager : public mesh::PacketManager {
public:
unsigned allocations = 0;
mesh::Packet* allocNew() override { ++allocations; return nullptr; }
void free(mesh::Packet*) override {}
bool queueOutbound(mesh::Packet*, uint8_t, uint32_t) override { return false; }
mesh::Packet* getNextOutbound(uint32_t) override { return nullptr; }
int getOutboundCount(uint32_t) const override { return 0; }
int getOutboundTotal() const override { return 0; }
int getFreeCount() const override { return 16; }
mesh::Packet* getOutboundByIdx(int) override { return nullptr; }
mesh::Packet* removeOutboundByIdx(int) override { return nullptr; }
void queueInbound(mesh::Packet*, uint32_t) override {}
mesh::Packet* getNextInbound(uint32_t) override { return nullptr; }
};
class DummyMeshTables : public mesh::MeshTables {
public:
bool wasSeen(const mesh::Packet*) override { return false; }
void markSeen(const mesh::Packet*) override {}
void markSent(const mesh::Packet*) override {}
void clear(const mesh::Packet*) override {}
};
class TestChatMesh : public BaseChatMesh {
public:
int messages_received = 0;
TestChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng,
mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables)
: BaseChatMesh(radio, ms, rng, rtc, mgr, tables) {}
void onDiscoveredContact(ContactInfo&, bool, uint8_t, const uint8_t*) override {}
bool processAck(const uint8_t*, ContactInfo*& peer) override {
peer = nullptr;
return false;
}
void onContactPathUpdated(const ContactInfo&) override {}
void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {
messages_received++;
}
void onCommandDataRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {}
void onCLICommandRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*, char*) override {}
bool onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const uint8_t*, const char*) override { return true; }
void onChannelMessageRecv(const mesh::GroupChannel&, mesh::Packet*, uint32_t, const char*) override {}
uint8_t onContactRequest(const ContactInfo&, uint32_t, const uint8_t*, uint8_t, uint8_t*) override { return 0; }
void onContactResponse(const ContactInfo&, const uint8_t*, uint8_t) override {}
uint32_t calcFloodTimeoutMillisFor(uint32_t) const override { return 1000; }
uint32_t calcDirectTimeoutMillisFor(uint32_t, uint8_t) const override { return 1000; }
void onSendTimeout() override {}
using BaseChatMesh::resetContacts;
using BaseChatMesh::searchPeersByHash;
using BaseChatMesh::getPeerSharedSecret;
using BaseChatMesh::onPeerDataRecv;
using mesh::Mesh::onRecvPacket;
};
struct MeshFixture {
DummyRadio radio;
DummyMillisecondClock ms;
DummyRNG rng;
DummyRTCClock rtc;
DummyPacketManager mgr;
DummyMeshTables tables;
TestChatMesh chat{radio, ms, rng, rtc, mgr, tables};
};
} // namespace
TEST(EmptyContactSlots, ZeroPubKeyProducesAllZeroSharedSecretForAnyPrivateKey) {
ContactInfo empty_slot;
EXPECT_TRUE(empty_slot.isEmptySlot());
uint8_t raw_prv[PRV_KEY_SIZE];
for (int i = 0; i < PRV_KEY_SIZE; i++) {
raw_prv[i] = static_cast<uint8_t>(0xA5 ^ (i * 13 + 7));
}
uint8_t secret[PUB_KEY_SIZE];
ed25519_key_exchange(secret, empty_slot.id.pub_key, raw_prv);
uint8_t expected_zeroes[PUB_KEY_SIZE] = { 0 };
EXPECT_EQ(0, memcmp(secret, expected_zeroes, PUB_KEY_SIZE));
}
TEST(EmptyContactSlots, NullKeyPeerPacketIsRejectedWhenAnonSlotsAreEmpty) {
MeshFixture fx;
fx.chat.self_id.pub_key[0] = 0x42;
// Craft a PAYLOAD_TYPE_TXT_MSG addressed to self_id (dest_hash=0x42) with src_hash=0x00,
// encrypted and MAC'd under the all-zero 32-byte key that an empty ContactInfo slot derives.
uint8_t zero_secret[PUB_KEY_SIZE] = { 0 };
uint8_t plaintext[16] = { 0 };
uint32_t ts = 1700000001;
memcpy(plaintext, &ts, 4);
plaintext[4] = (TXT_TYPE_PLAIN << 2);
strcpy(reinterpret_cast<char*>(&plaintext[5]), "forged");
mesh::Packet pkt;
pkt.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT);
pkt.path_len = 0;
pkt.payload[0] = 0x42; // dest_hash (matches fx.chat.self_id)
pkt.payload[1] = 0x00; // src_hash (matches all-zero pub_key if empty slots are not skipped)
int enc_len = mesh::Utils::encryptThenMAC(zero_secret, &pkt.payload[2], plaintext, 12);
pkt.payload_len = static_cast<uint8_t>(2 + enc_len);
fx.chat.onRecvPacket(&pkt);
EXPECT_EQ(0, fx.chat.messages_received);
}
TEST(EmptyContactSlots, SearchPeersByHashSkipsEmptyAnonSlotsAndFindsRealZeroPrefixPeer) {
MeshFixture fx;
const uint8_t zero_hash = 0x00;
// Freshly reset contacts has MAX_ANON_CONTACTS (8) zeroed transient slots.
EXPECT_EQ(0, fx.chat.searchPeersByHash(&zero_hash));
// Add a legitimate permanent contact whose public key starts with 0x00.
ContactInfo peer;
peer.type = ADV_TYPE_CHAT;
peer.lastmod = 1700000100;
strcpy(peer.name, "Alice");
peer.id.pub_key[0] = 0x00;
for (int i = 1; i < PUB_KEY_SIZE; i++) {
peer.id.pub_key[i] = static_cast<uint8_t>(i);
}
ASSERT_TRUE(fx.chat.addContact(peer));
// Only the populated contact at index MAX_ANON_CONTACTS should match.
EXPECT_EQ(1, fx.chat.searchPeersByHash(&zero_hash));
// Lookup by pubkey and name prefix must also skip the empty transient slots.
uint8_t zero_prefix[6] = { 0 };
EXPECT_EQ(nullptr, fx.chat.lookupContactByPubKey(zero_prefix, sizeof(zero_prefix)));
EXPECT_EQ(nullptr, fx.chat.lookupTransientContactByPubKey(zero_prefix, sizeof(zero_prefix)));
EXPECT_EQ(nullptr, fx.chat.lookupPersistentContactByPubKey(zero_prefix, sizeof(zero_prefix)));
ContactInfo* found = fx.chat.lookupContactByPubKey(peer.id.pub_key, 6);
ASSERT_NE(nullptr, found);
EXPECT_STREQ("Alice", found->name);
ContactInfo* by_name = fx.chat.searchContactsByPrefix("");
ASSERT_NE(nullptr, by_name);
EXPECT_STREQ("Alice", by_name->name);
}
TEST(EmptyContactSlots, PopulatedTransientAnonSlotStillMatchesEvenWhenLastmodIsZero) {
MeshFixture fx;
const uint8_t zero_hash = 0x00;
ContactInfo anon;
anon.type = ADV_TYPE_NONE;
anon.lastmod = 0; // e.g. RTC not yet initialised on nRF52
anon.id.pub_key[0] = 0x00;
anon.id.pub_key[1] = 0x7F;
ASSERT_TRUE(fx.chat.addContact(anon));
EXPECT_FALSE(anon.isEmptySlot());
EXPECT_EQ(1, fx.chat.searchPeersByHash(&zero_hash));
EXPECT_NE(nullptr, fx.chat.lookupContactByPubKey(anon.id.pub_key, PUB_KEY_SIZE));
}
TEST(EmptyContactSlots, AcceptedContactKeepsPriorityOverMatchingTransientPeer) {
MeshFixture fx;
ContactInfo anon;
anon.id.pub_key[1] = 0x7F;
ASSERT_TRUE(fx.chat.addContact(anon));
ContactInfo accepted = anon;
accepted.type = ADV_TYPE_CHAT;
accepted.out_path_len = OUT_PATH_UNKNOWN;
strcpy(accepted.name, "Alice");
ASSERT_TRUE(fx.chat.addContact(accepted));
const uint8_t zero_hash = 0;
ASSERT_EQ(2, fx.chat.searchPeersByHash(&zero_hash));
mesh::Packet packet;
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT);
packet.path_len = 0;
uint8_t secret[PUB_KEY_SIZE] = {};
uint8_t text[16] = {};
text[4] = TXT_TYPE_PLAIN << 2;
strcpy(reinterpret_cast<char*>(text + 5), "hello");
fx.chat.onPeerDataRecv(&packet, PAYLOAD_TYPE_TXT_MSG, 0, secret, text, 11);
EXPECT_EQ(1, fx.chat.messages_received);
}
TEST(EmptyContactSlots, NegativeGroupTextLengthFailsBeforeReadingInputOrAllocating) {
MeshFixture fx;
mesh::GroupChannel channel;
EXPECT_FALSE(fx.chat.sendGroupMessage(1, channel, nullptr, nullptr, -1));
EXPECT_EQ(0u, fx.mgr.allocations);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+4 -3
View File
@@ -67,13 +67,14 @@ class HomeSensorRefreshTest(unittest.TestCase):
for ui in ('ui-new', 'ui-tiny'):
source = work / (ui + '.cpp')
source.write_text(harness(ui, mutation), encoding='ascii')
for auto_off in (15000, 0):
with self.subTest(ui=ui, auto_off=auto_off):
binary = work / (ui + '-' + str(auto_off) +
for auto_off, eink in ((15000, False), (15000, True), (0, False), (0, True)):
with self.subTest(ui=ui, auto_off=auto_off, eink=eink):
binary = work / (ui + '-' + str(auto_off) + '-' + str(int(eink)) +
('.exe' if os.name == 'nt' else ''))
compiled = subprocess.run([
compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror',
*sanitize, '-DAUTO_OFF_MILLIS=' + str(auto_off),
'-DTEST_EINK=' + str(int(eink)),
'-DTEST_NEW_UI=' + str(int(ui == 'ui-new')),
str(source), '-o', str(binary),
], text=True, capture_output=True, timeout=60)
@@ -12,6 +12,9 @@ static Packet makeFlood(uint8_t route_type, uint8_t payload_type, uint8_t hops)
p.transport_codes[1] = 0;
p.setPathHashSizeAndCount(1, hops);
p.payload_len = 1;
if (route_type == ROUTE_TYPE_TRANSPORT_FLOOD) {
p.transport_codes[0] = 1; // has to be non-zero
}
return p;
}
+285
View File
@@ -0,0 +1,285 @@
"""Exercise upstream sensor-list bounds and duplicate-stat wire saturation."""
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
SENSOR_HARNESS = r'''
#include <array>
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
@ATOI@
struct Setting { const char* name; const char* value; };
struct Sensors {
std::vector<Setting> settings;
int reads = 0;
int getNumSettings() const { return static_cast<int>(settings.size()); }
const char* getSettingName(int i) {
assert(i >= 0 && i < getNumSettings());
++reads;
return settings[i].name;
}
const char* getSettingValue(int i) {
assert(i >= 0 && i < getNumSettings());
++reads;
return settings[i].value;
}
};
struct Fixture {
Sensors* _sensors;
void handle(char* command, char* reply) {
@SENSOR_LIST@
else { assert(false && "fixture accepts only sensor list"); }
}
};
static std::string page(Fixture& fixture, int start = -1) {
// A long trailing guard also makes the old unbounded write fail our own
// assertion, without depending on a particular platform's stack layout.
std::array<unsigned char, 32 + 160 + 8192> guarded;
guarded.fill(0xA5);
char* reply = reinterpret_cast<char*>(guarded.data() + 32);
std::memset(reply, 0, 160);
std::string text = "sensor list";
if (start >= 0) text += " " + std::to_string(start);
std::vector<char> command(text.begin(), text.end());
command.push_back(0);
fixture.handle(command.data(), reply);
for (size_t i = 0; i < 32; ++i) assert(guarded[i] == 0xA5);
for (size_t i = 32 + 160; i < guarded.size(); ++i) assert(guarded[i] == 0xA5);
assert(std::memchr(reply, 0, 160) != nullptr);
return reply;
}
static int nextPage(const std::string& text) {
const size_t marker = text.find("... next:");
return marker == std::string::npos ? -1 : std::atoi(text.c_str() + marker + 9);
}
int main(int argc, char** argv) {
assert(argc == 2);
const std::string scenario = argv[1];
if (scenario == "nulls") {
Sensors sensors{{{nullptr, nullptr}, {"enabled", nullptr}, {nullptr, "ok"}}};
Fixture fixture{&sensors};
assert(page(fixture) == "3 vars\n?=\nenabled=\n?=ok");
sensors.settings = {{"", ""}, {"name", "1"}};
assert(page(fixture) == "2 vars\n=\nname=1");
sensors.settings.clear(); sensors.reads = 0;
assert(page(fixture) == "no custom var" && sensors.reads == 0);
sensors.settings = {{"one", "1"}};
assert(page(fixture, 1) == "no custom var");
assert(page(fixture, 999) == "no custom var" && sensors.reads == 0);
} else if (scenario == "oversized") {
const std::string name(1024, 'n'), value(3072, 'v');
Sensors sensors{{{name.c_str(), value.c_str()}}};
Fixture fixture{&sensors};
const std::string output = page(fixture);
assert(output.size() < 160 && output.find("1 vars\n") == 0);
assert(nextPage(output) == -1);
} else if (scenario == "pagination") {
std::vector<std::string> names, values;
names.reserve(30); values.reserve(30);
Sensors sensors;
for (int i = 0; i < 30; ++i) {
names.push_back("sensor" + std::to_string(i));
values.push_back(std::string(20, 'a' + i % 26));
sensors.settings.push_back({names.back().c_str(), values.back().c_str()});
}
Fixture fixture{&sensors};
int start = 0, pages = 0;
std::string all;
do {
const std::string output = page(fixture, start);
assert(output.find("30 vars\n") == 0);
all += output + "\n";
const int next = nextPage(output);
if (next < 0) break;
assert(next > start && next < 30 && ++pages < 30);
start = next;
} while (true);
assert(pages > 1);
for (size_t i = 0; i < names.size(); ++i) {
const std::string row = names[i] + "=" + values[i] + "\n";
const size_t found = all.find(row);
assert(found != std::string::npos);
assert(all.find(row, found + row.size()) == std::string::npos);
}
// An oversized entry encountered after a fitting entry is deferred, then
// consumed once on the next page, so it cannot trap the paging cursor.
const std::string huge(2048, 'z');
sensors.settings = {{"first", "ok"}, {"large", huge.c_str()}, {"last", "ok"}};
const std::string first = page(fixture, 0);
assert(first.find("first=ok\n") != std::string::npos && nextPage(first) == 1);
assert(nextPage(page(fixture, 1)) == 2);
assert(page(fixture, 2) == "3 vars\nlast=ok");
} else if (scenario == "boundaries") {
// Move each entry across every remaining-space and truncation boundary.
for (size_t length = 0; length <= 260; ++length) {
const std::string value(length, 'x');
Sensors sensors{{{"edge", value.c_str()}, {"tail", "done"}}};
Fixture fixture{&sensors};
const std::string first = page(fixture);
const int next = nextPage(first);
if (next >= 0) {
assert(next == 1);
assert(page(fixture, next) == "2 vars\ntail=done");
} else {
assert(first.find("tail=done") != std::string::npos);
}
}
} else { assert(false && "unknown scenario"); }
return 0;
}
'''
STATS_HARNESS = r'''
#include <algorithm>
#include <cassert>
#include <cstdint>
#include <cstring>
@STRUCT@;
using WireStats = @STATS_TYPE@;
static_assert(sizeof(WireStats::n_direct_dups) == 2, "direct duplicate wire width");
static_assert(sizeof(WireStats::n_flood_dups) == 2, "flood duplicate wire width");
struct SimpleMeshTables {
uint32_t direct, flood;
uint32_t getNumDirectDups() const { return direct; }
uint32_t getNumFloodDups() const { return flood; }
};
struct Fixture {
SimpleMeshTables tables;
void* getTables() { return &tables; }
WireStats read() {
WireStats stats{};
@ASSIGNMENTS@
return stats;
}
};
int main() {
const uint32_t values[] = {0, 1, 65530, 65534, 65535, 65536, 65537, 131065, UINT32_MAX};
for (const uint32_t direct : values) for (const uint32_t flood : values) {
Fixture fixture{{direct, flood}};
const WireStats stats = fixture.read();
assert(stats.n_direct_dups == (direct > 65535 ? 65535 : direct));
assert(stats.n_flood_dups == (flood > 65535 ? 65535 : flood));
assert(fixture.tables.direct == direct && fixture.tables.flood == flood);
unsigned char wire[sizeof(stats)];
std::memcpy(wire, &stats, sizeof(stats));
WireStats decoded{};
std::memcpy(&decoded, wire, sizeof(decoded));
assert(decoded.n_direct_dups == stats.n_direct_dups);
assert(decoded.n_flood_dups == stats.n_flood_dups);
}
return 0;
}
'''
class UpstreamCliBoundsTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = shutil.which("g++") or shutil.which("clang++")
if not cls.compiler:
raise unittest.SkipTest("a host C++17 compiler is required")
cls.temporary = tempfile.TemporaryDirectory(prefix="meshcore-upstream-bounds-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.work = Path(cls.temporary.name)
source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8")
cls.sensor_code = SENSOR_HARNESS.replace(
"@ATOI@", extract_braced(source, "static uint32_t _atoi("))
cls.sensor_code = cls.sensor_code.replace(
"@SENSOR_LIST@", extract_braced(source, 'if (memcmp(command, "sensor list", 11) == 0)'))
cls.sensor = cls.compile(cls.sensor_code, "sensors")
@classmethod
def compile(cls, code, name):
source = cls.work / (name + ".cpp")
binary = cls.work / (name + (".exe" if sys.platform == "win32" else ""))
source.write_text(code, encoding="utf-8")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-no-pie"]
if sys.platform.startswith("linux") else [])
built = subprocess.run([cls.compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra",
"-Werror", *sanitizers, str(source), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
return binary
def run_binary(self, binary, *arguments, expect_failure=False):
result = subprocess.run([str(binary), *arguments], capture_output=True, text=True, timeout=30)
if expect_failure:
self.assertNotEqual(result.returncode, 0, "unsafe negative control unexpectedly passed")
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_sensor_list_handles_null_empty_and_exhausted_entries(self):
self.run_binary(self.sensor, "nulls")
def test_sensor_list_truncates_one_oversized_entry_without_overflow(self):
self.run_binary(self.sensor, "oversized")
def test_sensor_list_paginates_without_losing_entries_or_stalling(self):
self.run_binary(self.sensor, "pagination")
def test_sensor_list_remaining_space_boundaries(self):
self.run_binary(self.sensor, "boundaries")
def test_previous_unbounded_sensor_write_is_detected(self):
unsafe = self.sensor_code.replace('snprintf(dp, room, "%s=%s\\n", name, value);',
'sprintf(dp, "%s=%s\\n", name, value);')
self.assertNotEqual(unsafe, self.sensor_code)
self.run_binary(self.compile(unsafe, "unsafe-sensors"), "oversized", expect_failure=True)
def stats_code(self, role, unsafe=False):
source = (ROOT / "examples" / role / "MyMesh.cpp").read_text(encoding="utf-8")
if role == "simple_repeater":
struct_source = (ROOT / "examples" / role / "MyMesh.h").read_text(encoding="utf-8")
stats_type = "RepeaterStats"
else:
struct_source = source
stats_type = "ServerStats"
request = extract_braced(source, "int MyMesh::handleRequest(")
status = extract_braced(request, "if (payload[0] == REQ_TYPE_GET_STATUS)")
assignments = []
for field in ("direct", "flood"):
match = re.search(r"stats\.n_" + field + r"_dups\s*=[^;]+;", status)
self.assertIsNotNone(match, role + " " + field)
text = match.group()
if unsafe:
text = re.sub(r"std::min<uint32_t>\((.*), 0xFFFF\)", r"\1", text)
self.assertNotEqual(text, match.group())
assignments.append(text)
return (STATS_HARNESS.replace("@STRUCT@", extract_braced(struct_source, "struct " + stats_type))
.replace("@STATS_TYPE@", stats_type).replace("@ASSIGNMENTS@", "\n".join(assignments)))
def test_duplicate_stats_saturate_both_roles_at_the_wire_boundary(self):
for role in ("simple_repeater", "simple_room_server"):
with self.subTest(role=role):
self.run_binary(self.compile(self.stats_code(role), role + "-stats"))
def test_previous_wrapping_duplicate_stats_are_detected(self):
for role in ("simple_repeater", "simple_room_server"):
with self.subTest(role=role):
self.run_binary(self.compile(self.stats_code(role, unsafe=True), role + "-unsafe-stats"),
expect_failure=True)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -32,6 +32,9 @@ build_flags = ${rak4631_hw.build_flags}
-D PIN_GPS_RESET_ACTIVE=LOW
-D TELEM_BME280_ADDRESS=0x77
-D DISPLAY_CLASS=GxEPDDisplay
-D EINK_DISPLAY_MODEL=GxEPD2_154_D67
-D UI_RECENT_LIST_SIZE=9
-D UI_MSG_PREVIEW_SIZE=165
-D BACKLIGHT_BTN=PIN_BUTTON2
-D AUTO_OFF_MILLIS=0
build_src_filter = ${rak4631_hw.build_src_filter}
+6
View File
@@ -131,6 +131,12 @@ extern const int SCK;
#define DISP_BUSY (3)
#define DISP_BACKLIGHT (43)
// used by GxEPDDisplay when EINK_DISPLAY_MODEL is set
#define PIN_DISPLAY_CS DISP_CS
#define PIN_DISPLAY_DC DISP_DC
#define PIN_DISPLAY_RST DISP_RST
#define PIN_DISPLAY_BUSY DISP_BUSY
////////////////////////////////////////////////////////////////////////////////
// GPS
@@ -5,7 +5,6 @@
void MeshTrackerX1Board::begin() {
NRF52BoardDCDC::begin();
btn_prev_state = LOW; // button is active HIGH
#ifdef BUTTON_PIN
pinMode(BATTERY_PIN, INPUT);
@@ -5,9 +5,6 @@
#include <helpers/NRF52Board.h>
class MeshTrackerX1Board : public NRF52BoardDCDC {
protected:
uint8_t btn_prev_state;
public:
MeshTrackerX1Board() : NRF52Board("X1_OTA") {}
void begin();
@@ -50,17 +47,6 @@ public:
return "Seeed SenseCAP MeshTracker X1";
}
int buttonStateChanged() {
#ifdef BUTTON_PIN
uint8_t v = digitalRead(BUTTON_PIN);
if (v != btn_prev_state) {
btn_prev_state = v;
return (v == USER_BTN_PRESSED) ? 1 : -1;
}
#endif
return 0;
}
void shutdownPeripherals() override {
NRF52Board::shutdownPeripherals();
-1
View File
@@ -5,7 +5,6 @@
void PromicroBoard::begin() {
NRF52Board::begin();
btn_prev_state = HIGH;
pinMode(PIN_VBAT_READ, INPUT);
-12
View File
@@ -9,7 +9,6 @@
class PromicroBoard : public NRF52BoardDCDC {
protected:
uint8_t btn_prev_state;
float adc_mult = ADC_MULTIPLIER;
public:
@@ -51,15 +50,4 @@ public:
const char* getManufacturerName() const override {
return "ProMicro DIY";
}
int buttonStateChanged() {
#ifdef BUTTON_PIN
uint8_t v = digitalRead(BUTTON_PIN);
if (v != btn_prev_state) {
btn_prev_state = v;
return (v == LOW) ? 1 : -1;
}
#endif
return 0;
}
};
-1
View File
@@ -5,7 +5,6 @@
void T1000eBoard::begin() {
NRF52BoardDCDC::begin();
btn_prev_state = HIGH;
#ifdef BUTTON_PIN
pinMode(BATTERY_PIN, INPUT);
-14
View File
@@ -5,9 +5,6 @@
#include <helpers/NRF52Board.h>
class T1000eBoard : public NRF52BoardDCDC {
protected:
uint8_t btn_prev_state;
public:
T1000eBoard() : NRF52Board("T1000E_OTA") {}
void begin();
@@ -46,17 +43,6 @@ public:
return "Seeed Tracker T1000-E";
}
int buttonStateChanged() {
#ifdef BUTTON_PIN
uint8_t v = digitalRead(BUTTON_PIN);
if (v != btn_prev_state) {
btn_prev_state = v;
return (v == USER_BTN_PRESSED) ? 1 : -1;
}
#endif
return 0;
}
void shutdownPeripherals() override {
NRF52Board::shutdownPeripherals();
+5
View File
@@ -25,6 +25,11 @@ build_flags = ${rak4631_hw.build_flags}
-D SX126X_RX_BOOSTED_GAIN=1
-D LORA_TX_POWER=22
-D P_LORA_TX_LED=13
-D UI_RECENT_LIST_SIZE=9
-D UI_MSG_PREVIEW_SIZE=165
-D EINK_DISPLAY_MODEL=GxEPD2_154_D67
-D BACKLIGHT_BTN=PIN_BUTTON2
-D DISABLE_DIAGNOSTIC_OUTPUT
build_src_filter = ${rak4631_hw.build_src_filter}
+<helpers/*.cpp>
+<ThinkNodeM1Board.cpp>
+6 -1
View File
@@ -81,7 +81,7 @@
#define BUTTON_PIN PIN_BUTTON1
#define PIN_USER_BTN BUTTON_PIN
#define PIN_BUTTON2 (11)
#define PIN_BUTTON2 (39)
#define BUTTON_PIN2 PIN_BUTTON2
#define EXTERNAL_FLASH_DEVICES MX25R1635F
@@ -131,6 +131,11 @@ extern const int SCK;
#define DISP_BUSY (3)
#define DISP_BACKLIGHT (43)
#define PIN_DISPLAY_CS DISP_CS
#define PIN_DISPLAY_DC DISP_DC
#define PIN_DISPLAY_RST DISP_RST
#define PIN_DISPLAY_BUSY DISP_BUSY
////////////////////////////////////////////////////////////////////////////////
// GPS
@@ -6,7 +6,6 @@
void ThinkNodeM3Board::begin() {
NRF52Board::begin();
btn_prev_state = HIGH;
Wire.begin();
-12
View File
@@ -11,7 +11,6 @@ protected:
#if NRF52_POWER_MANAGEMENT
void initiateShutdown(uint8_t reason) override;
#endif
uint8_t btn_prev_state;
public:
ThinkNodeM3Board() : NRF52Board("THINKNODE_M3_OTA") {}
@@ -38,17 +37,6 @@ public:
return "Elecrow ThinkNode M3";
}
int buttonStateChanged() {
#ifdef BUTTON_PIN
uint8_t v = digitalRead(BUTTON_PIN);
if (v != btn_prev_state) {
btn_prev_state = v;
return (v == LOW) ? 1 : -1;
}
#endif
return 0;
}
void shutdownPeripherals() override {
NRF52Board::shutdownPeripherals();
@@ -6,7 +6,6 @@
void ThinkNodeM4Board::begin() {
NRF52Board::begin();
btn_prev_state = HIGH;
Wire.begin();
battery_serial->begin(4800);
-12
View File
@@ -9,7 +9,6 @@ protected:
#if NRF52_POWER_MANAGEMENT
void initiateShutdown(uint8_t reason) override;
#endif
uint8_t btn_prev_state;
HardwareSerial * battery_serial;
uint16_t bat_level_mv = 0;
uint8_t bat_level_percent =0;
@@ -37,17 +36,6 @@ public:
return "Elecrow ThinkNode M4";
}
int buttonStateChanged() {
#ifdef BUTTON_PIN
uint8_t v = digitalRead(BUTTON_PIN);
if (v != btn_prev_state) {
btn_prev_state = v;
return (v == USER_BTN_PRESSED) ? 1 : -1;
}
#endif
return 0;
}
void shutdownPeripherals() override {
// shutdown common peripherals
NRF52Board::shutdownPeripherals();
+3
View File
@@ -26,6 +26,7 @@ build_flags = ${esp32_base.build_flags}
-D BACKLIGHT_BTN=PIN_BUTTON2
-D AUTO_OFF_MILLIS=0
-D UI_RECENT_LIST_SIZE=9
-D UI_MSG_PREVIEW_SIZE=165
-D DISABLE_DIAGNOSTIC_OUTPUT
-D SX126X_DIO2_AS_RF_SWITCH=true
-D SX126X_DIO3_TCXO_VOLTAGE=3.3
@@ -174,12 +175,14 @@ build_flags =
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=256
-D ENABLE_USB_INTERFACE
-D ENABLE_WIFI_INTERFACE
build_src_filter = ${ThinkNode_M5.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/buzzer.cpp>
+<helpers/wifi/*.cpp>
lib_deps =
${ThinkNode_M5.lib_deps}
densaugeo/base64 @ ~1.4.0
+1 -1
View File
@@ -36,7 +36,7 @@ build_flags = ${nrf52_base.build_flags}
-D LORA_TX_POWER=22
build_src_filter = ${nrf52_base.build_src_filter}
+<helpers/*.cpp>
+<helpers/sensors/EnvironmentSensorManager.cpp>
+<helpers/sensors>
+<ThinkNodeM8Board.cpp>
+<../variants/thinknode_m8>
lib_deps =