From f99ea79b362b8791ccd9ae3a57c14d8b40168a1c Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Sat, 26 Sep 2026 20:39:23 +0200 Subject: [PATCH 01/39] Fix ThinkNode M8 repeater/room server build: compile sensor helpers for all envs --- variants/thinknode_m8/platformio.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/variants/thinknode_m8/platformio.ini b/variants/thinknode_m8/platformio.ini index db19e446644..57f2a938cbc 100644 --- a/variants/thinknode_m8/platformio.ini +++ b/variants/thinknode_m8/platformio.ini @@ -40,6 +40,7 @@ build_flags = ${nrf52_base.build_flags} -D LORA_TX_POWER=22 build_src_filter = ${nrf52_base.build_src_filter} + + + + +<../variants/thinknode_m8> lib_deps = @@ -110,7 +111,6 @@ build_src_filter = ${ThinkNode_M8.build_src_filter} + + + - + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = @@ -144,7 +144,6 @@ build_src_filter = ${ThinkNode_M8.build_src_filter} + + + - + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = From f215e6db266d6f1b485e550a0b8c6b38ed9c8e4b Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 19:33:35 +0100 Subject: [PATCH 02/39] Add T1000-E custom CLI command to toggle the buzzer --- variants/t1000-e/T1000eBoard.cpp | 70 +++++++++++++++++++++++++++++++- variants/t1000-e/T1000eBoard.h | 8 ++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/variants/t1000-e/T1000eBoard.cpp b/variants/t1000-e/T1000eBoard.cpp index 0d390f798e1..a47130173f8 100644 --- a/variants/t1000-e/T1000eBoard.cpp +++ b/variants/t1000-e/T1000eBoard.cpp @@ -3,6 +3,10 @@ #include "T1000eBoard.h" +#ifdef PIN_BUZZER_EN + #include +#endif + void T1000eBoard::begin() { NRF52BoardDCDC::begin(); btn_prev_state = HIGH; @@ -20,4 +24,68 @@ void T1000eBoard::begin() { Wire.begin(); delay(10); // give sx1262 some time to power up -} \ No newline at end of file +} + +void T1000eBoard::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; +#ifdef PIN_BUZZER_EN + char val[8]; + val[0] = 0; + _prefs->getByKey("buzzer", val, sizeof(val)); + _buzzer_muted = strcmp(val, "0") == 0; +#endif +} + +#ifdef PIN_BUZZER_EN +static void playTune(const char* tune) { + if (rtttl::isPlaying()) rtttl::stop(); + rtttl::begin(PIN_BUZZER, tune); +} +#endif + +void T1000eBoard::setBuzzerEnabled(bool enable) { +#ifdef PIN_BUZZER_EN + _buzzer_muted = !enable; + digitalWrite(PIN_BUZZER_EN, enable ? HIGH : LOW); +#endif +} + +void T1000eBoard::loop() { + NRF52BoardDCDC::loop(); +#ifdef PIN_BUZZER_EN + if (!rtttl::done()) rtttl::play(); + if (_buzzer_mute_pending && rtttl::done()) { // mute once the confirmation beep has finished + _buzzer_mute_pending = false; + setBuzzerEnabled(false); + } + if (_buzzer_muted) digitalWrite(PIN_BUZZER_EN, LOW); // keep it off even if the UI re-enables it +#endif +} + +bool T1000eBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { +#ifdef PIN_BUZZER_EN + if (strcmp(command, "get buzzer") == 0) { + sprintf(reply, "> %s", (_buzzer_muted || _buzzer_mute_pending) ? "off" : "on"); + return true; + } + if (memcmp(command, "set buzzer ", 11) == 0) { + if (strcmp(&command[11], "on") == 0) { + _buzzer_mute_pending = false; + setBuzzerEnabled(true); + if (_prefs) _prefs->setByKey("buzzer", "1"); + playTune("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); // same tune as a new message + strcpy(reply, "OK - buzzer on"); + } else if (strcmp(&command[11], "off") == 0) { + setBuzzerEnabled(true); + _buzzer_mute_pending = true; + if (_prefs) _prefs->setByKey("buzzer", "0"); + playTune("ack:d=32,o=8,b=120:c"); // same beep as an ack, before going quiet + strcpy(reply, "OK - buzzer off"); + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } +#endif + return false; // not handled +} diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index ba8bf0c1d9d..846c7df2ef8 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -7,10 +7,18 @@ class T1000eBoard : public NRF52BoardDCDC { protected: uint8_t btn_prev_state; + KeyValueStore* _prefs = NULL; + bool _buzzer_muted = false; + bool _buzzer_mute_pending = false; + + void setBuzzerEnabled(bool enable); public: T1000eBoard() : NRF52Board("T1000E_OTA") {} void begin(); + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; + void loop() override; uint16_t getBattMilliVolts() override { #ifdef BATTERY_PIN From b21cc3e36a843d4efad2d818bd6465700dbf0a22 Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 20:12:13 +0100 Subject: [PATCH 03/39] Fix off-by-one when loading the T1000-E buzzer setting --- variants/t1000-e/T1000eBoard.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/t1000-e/T1000eBoard.cpp b/variants/t1000-e/T1000eBoard.cpp index a47130173f8..ff42f5e4282 100644 --- a/variants/t1000-e/T1000eBoard.cpp +++ b/variants/t1000-e/T1000eBoard.cpp @@ -31,7 +31,7 @@ void T1000eBoard::attachDynamicPrefs(KeyValueStore* prefs) { #ifdef PIN_BUZZER_EN char val[8]; val[0] = 0; - _prefs->getByKey("buzzer", val, sizeof(val)); + _prefs->getByKey("buzzer", val, sizeof(val) - 1); _buzzer_muted = strcmp(val, "0") == 0; #endif } From d7de7f63c2bad39275690ce7398ec8d3dff8957e Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 20:12:23 +0100 Subject: [PATCH 04/39] Report an error when the buzzer setting fails to save --- variants/t1000-e/T1000eBoard.cpp | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/variants/t1000-e/T1000eBoard.cpp b/variants/t1000-e/T1000eBoard.cpp index ff42f5e4282..9bdeff89c67 100644 --- a/variants/t1000-e/T1000eBoard.cpp +++ b/variants/t1000-e/T1000eBoard.cpp @@ -70,17 +70,23 @@ bool T1000eBoard::handleCommand(const char* command, uint32_t sender_timestamp, } if (memcmp(command, "set buzzer ", 11) == 0) { if (strcmp(&command[11], "on") == 0) { - _buzzer_mute_pending = false; - setBuzzerEnabled(true); - if (_prefs) _prefs->setByKey("buzzer", "1"); - playTune("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); // same tune as a new message - strcpy(reply, "OK - buzzer on"); + if (_prefs && !_prefs->setByKey("buzzer", "1")) { + strcpy(reply, "Error: failed to save buzzer setting"); + } else { + _buzzer_mute_pending = false; + setBuzzerEnabled(true); + playTune("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); // same tune as a new message + strcpy(reply, "OK - buzzer on"); + } } else if (strcmp(&command[11], "off") == 0) { - setBuzzerEnabled(true); - _buzzer_mute_pending = true; - if (_prefs) _prefs->setByKey("buzzer", "0"); - playTune("ack:d=32,o=8,b=120:c"); // same beep as an ack, before going quiet - strcpy(reply, "OK - buzzer off"); + if (_prefs && !_prefs->setByKey("buzzer", "0")) { + strcpy(reply, "Error: failed to save buzzer setting"); + } else { + setBuzzerEnabled(true); + _buzzer_mute_pending = true; + playTune("ack:d=32,o=8,b=120:c"); // same beep as an ack, before going quiet + strcpy(reply, "OK - buzzer off"); + } } else { strcpy(reply, "Error: state must be on or off"); } From b9c328a88202ad29af547f68368b8d73caa8d9a0 Mon Sep 17 00:00:00 2001 From: amdoolittle Date: Fri, 16 Oct 2026 10:04:44 -0700 Subject: [PATCH 05/39] Remove BLE screen when BLE not available --- examples/companion_radio/ui-new/UITask.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc25de79c17..60106d1c9de 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -91,7 +91,9 @@ class HomeScreen : public UIScreen { FIRST, RECENT, RADIO, +#ifdef BLE_PIN_CODE BLUETOOTH, +# endif ADVERT, #if ENV_INCLUDE_GPS == 1 GPS, @@ -372,6 +374,7 @@ class HomeScreen : public UIScreen { display.setCursor(0, 53); sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); display.print(tmp); +#ifdef BLE_PIN_CODE } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, @@ -380,6 +383,7 @@ class HomeScreen : public UIScreen { display.setColor(UIColor::secondary_txt); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); +#endif } else if (_page == HomePage::ADVERT) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); @@ -575,6 +579,7 @@ class HomeScreen : public UIScreen { #endif return true; } +#ifdef BLE_PIN_CODE if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off _task->disableBluetooth(); @@ -583,6 +588,7 @@ class HomeScreen : public UIScreen { } return true; } +#endif if (c == KEY_ENTER && _page == HomePage::ADVERT) { _task->notify(UIEventType::ack); if (the_mesh.advert()) { From 6e1f474f7dedc144cab2a094cdf4798bab5c371e Mon Sep 17 00:00:00 2001 From: amdoolittle Date: Fri, 16 Oct 2026 17:30:19 -0700 Subject: [PATCH 06/39] Update UITask.cpp Housekeeping: removed unnecessary space --- examples/companion_radio/ui-new/UITask.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 60106d1c9de..6e38c1fbe31 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -93,7 +93,7 @@ class HomeScreen : public UIScreen { RADIO, #ifdef BLE_PIN_CODE BLUETOOTH, -# endif +#endif ADVERT, #if ENV_INCLUDE_GPS == 1 GPS, From 7a0fde2912ce5517d62ca4f4b7ae670be3db81b3 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sat, 17 Oct 2026 15:16:38 +1300 Subject: [PATCH 07/39] timezone offset is now stored as float to allow half hour offsets --- examples/companion_radio/MyMesh.cpp | 4 ++-- examples/companion_radio/NodePrefs.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f32ea67aecf..99a9aa30d6e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2192,11 +2192,11 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } if (strcmp(command, "get tz.offset") == 0) { - sprintf(reply, "> %d", _prefs.tz_offset); + sprintf(reply, "> %.1f", _prefs.tz_offset); return true; } if (memcmp(command, "set tz.offset ", 14) == 0) { - int8_t tz = atof(&command[14]); + float tz = atof(&command[14]); if (tz < -12 || tz > 14) { strcpy(reply, "Error, must be from -12 to +14"); } else { diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 46a3dbb7c98..3cceed56668 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -47,7 +47,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file uint8_t agc_reset_interval = 0; // secs / 4 char default_scope_name[31]; uint8_t default_scope_key[16]; - int8_t tz_offset = 0; + float tz_offset = 0; #ifdef ENABLE_WIFI_INTERFACE #ifndef WIFI_SSID #define WIFI_SSID "" From 0e63c183a87c43df31d24f9c98c6d79b6c8ef2d1 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sat, 17 Oct 2026 15:22:46 +1300 Subject: [PATCH 08/39] tz_offset should be treated as float when down casting calculation result to int32 --- examples/companion_radio/ui-new/UITask.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc25de79c17..e3488853125 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -295,7 +295,7 @@ class HomeScreen : public UIScreen { #ifdef UI_SHOW_CLOCK display.setTextSize(3); uint32_t now = _rtc->getCurrentTime(); - now += (int32_t)_node_prefs->tz_offset * 3600; + now += (int32_t)(_node_prefs->tz_offset * 3600); DateTime dt (now); sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute()); display.drawTextCentered(display.width() / 2, 60, tmp); From 9d93a7269322a046dc5ace6998bdfdda2d470a13 Mon Sep 17 00:00:00 2001 From: amdoolittle Date: Fri, 16 Oct 2026 22:39:26 -0700 Subject: [PATCH 09/39] Added WiFi to TFT BLE version of Heltec V4 --- variants/heltec_v4/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 76e8e714e68..c91e31a80fb 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -380,12 +380,14 @@ build_flags = -D AUTO_SHUTDOWN_MILLIVOLTS=3400 -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_WIFI_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${heltec_v4_tft.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = From 85ad886589e29ec1dfb3f41ab00f97f3adc6d730 Mon Sep 17 00:00:00 2001 From: rado <6479266+rbrus@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:36:33 +0200 Subject: [PATCH 10/39] Fix unsigned conversion of negative TX power in CommonRadioPrefs --- examples/companion_radio/NodePrefs.h | 4 +- src/helpers/CommonCLI.h | 4 +- src/helpers/CommonRadioPrefs.h | 4 +- .../test_companion_node_prefs.cpp | 57 +++++++++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 3cceed56668..1cc5b501826 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -102,8 +102,8 @@ class NodePrefs : public ConfigSerializer { // persisted to file void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } - uint8_t getTxPower() const override { return _parent->tx_power_dbm; } - void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } + int8_t getTxPower() const override { return _parent->tx_power_dbm; } + void setTxPower(int8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } float getRxDelay() const override { return _parent->rx_delay_base; } void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } uint16_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f0e..675df7a4be7 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -115,8 +115,8 @@ class NodePrefs : public ConfigSerializer { void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } - uint8_t getTxPower() const override { return _parent->tx_power_dbm; } - void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } + int8_t getTxPower() const override { return _parent->tx_power_dbm; } + void setTxPower(int8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } float getRxDelay() const override { return _parent->rx_delay_base; } void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } uint16_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 0e83ca9dfdb..d0083b8c73b 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -35,8 +35,8 @@ class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { virtual uint8_t getRxGain() const = 0; virtual void setRxGain(uint8_t g) = 0; - virtual uint8_t getTxPower() const = 0; - virtual void setTxPower(uint8_t dbm) = 0; + virtual int8_t getTxPower() const = 0; + virtual void setTxPower(int8_t dbm) = 0; virtual float getRxDelay() const = 0; virtual void setRxDelay(float d) = 0; diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 433c9710601..ab5e0f90846 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -6,6 +6,32 @@ #include "../../examples/companion_radio/NodePrefs.h" +bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { + if (strcmp(key, "fem_rxgain") == 0) { + snprintf(value, max_len, "%d", (uint32_t)getFEMRxGain()); + return true; + } + if (strcmp(key, "fem_txgain") == 0) { + snprintf(value, max_len, "%d", (uint32_t)getFEMTxGain()); + return true; + } + return false; +} + +bool CommonRadioPrefs::setByKey(const char* key, const char* value) { + if (strcmp(key, "fem_rxgain") == 0) { + setFEMRxGain(atoi(value)); + markDirty(); + return true; + } + if (strcmp(key, "fem_txgain") == 0) { + setFEMTxGain(atoi(value)); + markDirty(); + return true; + } + return false; +} + class ReplayStream : public Stream { const char* _text; int _pos = 0; @@ -77,6 +103,37 @@ TEST(CompanionNodePrefs, RxGainSettingsRoundTripIndependently) { } #endif +TEST(CompanionNodePrefs, NegativeTxPowerPreservedAsSigned) { + NodePrefs prefs; + CommonRadioPrefs* radio = prefs.getRadioPrefs(); + + radio->setTxPower(-9); + EXPECT_EQ(-9, prefs.tx_power_dbm); + EXPECT_EQ(-9, radio->getTxPower()); + EXPECT_EQ(-9, static_cast(radio->getTxPower())); + + char reply[32]; + snprintf(reply, sizeof(reply), "> %d", static_cast(radio->getTxPower())); + EXPECT_STREQ("> -9", reply); +} + +TEST(CompanionNodePrefs, NegativeTxPowerRoundTripsSerial) { + NodePrefs saved; + saved.getRadioPrefs()->setTxPower(-9); + + CaptureStream output; + ASSERT_TRUE(saved.saveSerial(output)); + EXPECT_NE(std::string::npos, output.text().find("tx:-9")); + + ReplayStream input("{radio:{tx:-9}}"); + NodePrefs loaded; + loaded.tx_power_dbm = 22; + + ASSERT_TRUE(loaded.loadSerial(input)); + EXPECT_EQ(-9, loaded.tx_power_dbm); + EXPECT_EQ(-9, loaded.getRadioPrefs()->getTxPower()); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From b7389353e2a1c8c89e38d6a6285cdb9e67908a3b Mon Sep 17 00:00:00 2001 From: Confituurke <9595028+Confituurke@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:03:06 +0200 Subject: [PATCH 11/39] Show quarter-hour timezone offsets with two decimals 'get tz.offset' printed one decimal, so a UTC+5:45 offset (5.75) read back as 5.8. Print two decimals when the offset is on a quarter hour (x.25 / x.75); everything else keeps one decimal. --- examples/companion_radio/MyMesh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 99a9aa30d6e..2ea539dbc9c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2192,7 +2192,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } if (strcmp(command, "get tz.offset") == 0) { - sprintf(reply, "> %.1f", _prefs.tz_offset); + sprintf(reply, ((int)(_prefs.tz_offset * 4) % 2) ? "> %.2f" : "> %.1f", _prefs.tz_offset); return true; } if (memcmp(command, "set tz.offset ", 14) == 0) { From e9c5a558724721cfdfa1120d28389dfcd7064092 Mon Sep 17 00:00:00 2001 From: R0ck Date: Sat, 17 Oct 2026 23:27:43 +0100 Subject: [PATCH 12/39] Remove the acknowledgement tones --- variants/t1000-e/T1000eBoard.cpp | 24 ++---------------------- variants/t1000-e/T1000eBoard.h | 1 - 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/variants/t1000-e/T1000eBoard.cpp b/variants/t1000-e/T1000eBoard.cpp index 9bdeff89c67..649ae9d6482 100644 --- a/variants/t1000-e/T1000eBoard.cpp +++ b/variants/t1000-e/T1000eBoard.cpp @@ -3,10 +3,6 @@ #include "T1000eBoard.h" -#ifdef PIN_BUZZER_EN - #include -#endif - void T1000eBoard::begin() { NRF52BoardDCDC::begin(); btn_prev_state = HIGH; @@ -36,13 +32,6 @@ void T1000eBoard::attachDynamicPrefs(KeyValueStore* prefs) { #endif } -#ifdef PIN_BUZZER_EN -static void playTune(const char* tune) { - if (rtttl::isPlaying()) rtttl::stop(); - rtttl::begin(PIN_BUZZER, tune); -} -#endif - void T1000eBoard::setBuzzerEnabled(bool enable) { #ifdef PIN_BUZZER_EN _buzzer_muted = !enable; @@ -53,11 +42,6 @@ void T1000eBoard::setBuzzerEnabled(bool enable) { void T1000eBoard::loop() { NRF52BoardDCDC::loop(); #ifdef PIN_BUZZER_EN - if (!rtttl::done()) rtttl::play(); - if (_buzzer_mute_pending && rtttl::done()) { // mute once the confirmation beep has finished - _buzzer_mute_pending = false; - setBuzzerEnabled(false); - } if (_buzzer_muted) digitalWrite(PIN_BUZZER_EN, LOW); // keep it off even if the UI re-enables it #endif } @@ -65,7 +49,7 @@ void T1000eBoard::loop() { bool T1000eBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { #ifdef PIN_BUZZER_EN if (strcmp(command, "get buzzer") == 0) { - sprintf(reply, "> %s", (_buzzer_muted || _buzzer_mute_pending) ? "off" : "on"); + sprintf(reply, "> %s", _buzzer_muted ? "off" : "on"); return true; } if (memcmp(command, "set buzzer ", 11) == 0) { @@ -73,18 +57,14 @@ bool T1000eBoard::handleCommand(const char* command, uint32_t sender_timestamp, if (_prefs && !_prefs->setByKey("buzzer", "1")) { strcpy(reply, "Error: failed to save buzzer setting"); } else { - _buzzer_mute_pending = false; setBuzzerEnabled(true); - playTune("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); // same tune as a new message strcpy(reply, "OK - buzzer on"); } } else if (strcmp(&command[11], "off") == 0) { if (_prefs && !_prefs->setByKey("buzzer", "0")) { strcpy(reply, "Error: failed to save buzzer setting"); } else { - setBuzzerEnabled(true); - _buzzer_mute_pending = true; - playTune("ack:d=32,o=8,b=120:c"); // same beep as an ack, before going quiet + setBuzzerEnabled(false); strcpy(reply, "OK - buzzer off"); } } else { diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index 846c7df2ef8..e6ceb4fecf5 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -9,7 +9,6 @@ class T1000eBoard : public NRF52BoardDCDC { uint8_t btn_prev_state; KeyValueStore* _prefs = NULL; bool _buzzer_muted = false; - bool _buzzer_mute_pending = false; void setBuzzerEnabled(bool enable); From 2ed45f08e77645ed934eb1e4096567b3eb2184a9 Mon Sep 17 00:00:00 2001 From: R0ck Date: Sat, 17 Oct 2026 23:37:14 +0100 Subject: [PATCH 13/39] Move to companion firmware, remove from varient --- examples/companion_radio/MyMesh.cpp | 17 +++++++++ variants/t1000-e/T1000eBoard.cpp | 56 +---------------------------- variants/t1000-e/T1000eBoard.h | 7 ---- 3 files changed, 18 insertions(+), 62 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f32ea67aecf..ebe0636a4f6 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2122,6 +2122,23 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } +#ifdef PIN_BUZZER + if (strcmp(command, "get buzzer") == 0) { + sprintf(reply, "> %s", _prefs.buzzer_quiet ? "off" : "on"); + return true; + } + if (memcmp(command, "set buzzer ", 11) == 0) { + if (strcmp(&command[11], "on") == 0 || strcmp(&command[11], "off") == 0) { + _prefs.buzzer_quiet = strcmp(&command[11], "off") == 0; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } +#endif + if (memcmp(command, "set pin ", 8) == 0) { _prefs.ble_pin = atoi(&command[8]); savePrefs(); diff --git a/variants/t1000-e/T1000eBoard.cpp b/variants/t1000-e/T1000eBoard.cpp index 649ae9d6482..0d390f798e1 100644 --- a/variants/t1000-e/T1000eBoard.cpp +++ b/variants/t1000-e/T1000eBoard.cpp @@ -20,58 +20,4 @@ void T1000eBoard::begin() { Wire.begin(); delay(10); // give sx1262 some time to power up -} - -void T1000eBoard::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; -#ifdef PIN_BUZZER_EN - char val[8]; - val[0] = 0; - _prefs->getByKey("buzzer", val, sizeof(val) - 1); - _buzzer_muted = strcmp(val, "0") == 0; -#endif -} - -void T1000eBoard::setBuzzerEnabled(bool enable) { -#ifdef PIN_BUZZER_EN - _buzzer_muted = !enable; - digitalWrite(PIN_BUZZER_EN, enable ? HIGH : LOW); -#endif -} - -void T1000eBoard::loop() { - NRF52BoardDCDC::loop(); -#ifdef PIN_BUZZER_EN - if (_buzzer_muted) digitalWrite(PIN_BUZZER_EN, LOW); // keep it off even if the UI re-enables it -#endif -} - -bool T1000eBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { -#ifdef PIN_BUZZER_EN - if (strcmp(command, "get buzzer") == 0) { - sprintf(reply, "> %s", _buzzer_muted ? "off" : "on"); - return true; - } - if (memcmp(command, "set buzzer ", 11) == 0) { - if (strcmp(&command[11], "on") == 0) { - if (_prefs && !_prefs->setByKey("buzzer", "1")) { - strcpy(reply, "Error: failed to save buzzer setting"); - } else { - setBuzzerEnabled(true); - strcpy(reply, "OK - buzzer on"); - } - } else if (strcmp(&command[11], "off") == 0) { - if (_prefs && !_prefs->setByKey("buzzer", "0")) { - strcpy(reply, "Error: failed to save buzzer setting"); - } else { - setBuzzerEnabled(false); - strcpy(reply, "OK - buzzer off"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } -#endif - return false; // not handled -} +} \ No newline at end of file diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index e6ceb4fecf5..ba8bf0c1d9d 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -7,17 +7,10 @@ class T1000eBoard : public NRF52BoardDCDC { protected: uint8_t btn_prev_state; - KeyValueStore* _prefs = NULL; - bool _buzzer_muted = false; - - void setBuzzerEnabled(bool enable); public: T1000eBoard() : NRF52Board("T1000E_OTA") {} void begin(); - void attachDynamicPrefs(KeyValueStore* prefs); - bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; - void loop() override; uint16_t getBattMilliVolts() override { #ifdef BATTERY_PIN From 332f298544841886dea07a8b286ad5466aa550b7 Mon Sep 17 00:00:00 2001 From: R0ck Date: Sat, 17 Oct 2026 23:53:27 +0100 Subject: [PATCH 14/39] Apply buzzer setting changes in the UI so no reboot needed. --- examples/companion_radio/ui-new/UITask.cpp | 1 + examples/companion_radio/ui-orig/UITask.cpp | 1 + examples/companion_radio/ui-tiny/UITask.cpp | 1 + 3 files changed, 3 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc25de79c17..e43fc5ea46e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1015,6 +1015,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 diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index db8772f46e6..8ae57c7a99d 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -485,6 +485,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 diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 7b70f83686c..6bf33849767 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -691,6 +691,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 From 933c0f6a983a7cef8af95b9ab8c323ed2f1cdc45 Mon Sep 17 00:00:00 2001 From: Radek <“6479266+rbrus@users.noreply.github.com”> Date: Sun, 18 Oct 2026 09:21:53 +0200 Subject: [PATCH 15/39] added dynamic wifi to: lilygo t-beam supreme sx1262 ble companion --- variants/lilygo_tbeam_supreme_SX1262/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index af4a8eafa50..18a82802563 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -131,11 +131,13 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_WIFI_INTERFACE ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=8 ; -D MESH_DEBUG=1 build_src_filter = ${T_Beam_S3_Supreme_SX1262.build_src_filter} + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> From 060fd2193d21b2b36e0eeb9460fe75e68c5fd623 Mon Sep 17 00:00:00 2001 From: rado <6479266+rbrus@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:56:02 +0200 Subject: [PATCH 16/39] Skip empty anon contact slots in peer/contact lookups (prevent null-key ECDH sink) --- src/helpers/BaseChatMesh.cpp | 10 +- src/helpers/ContactInfo.h | 6 + .../test_empty_contact_slots.cpp | 230 ++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 test/test_empty_contact_slots/test_empty_contact_slots.cpp diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 28592415ca9..2c9ad7453f6 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -128,6 +128,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!! @@ -203,6 +204,11 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, int BaseChatMesh::searchPeersByHash(const uint8_t* hash) { int n = 0; for (int i = 0; i < num_contacts && n < MAX_SEARCH_RESULTS; i++) { + // Skip empty/unconfigured anon slots. An empty slot has an all-zero pub_key + // (an order-4 Edwards25519 point whose clamped X25519 key exchange yields an + // all-zero shared secret for any private key) and would otherwise match + // src_hash == 0x00 as a null-key peer sink while consuming search slots. + 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) } @@ -866,6 +872,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 @@ -874,6 +881,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 @@ -891,7 +899,7 @@ bool BaseChatMesh::addContact(const ContactInfo& contact) { bool BaseChatMesh::removeContact(ContactInfo& contact) { int idx = 0; - while (idx < num_contacts && !contacts[idx].id.matches(contact.id)) { + while (idx < num_contacts && (contacts[idx].isEmptySlot() || !contacts[idx].id.matches(contact.id))) { idx++; } if (idx >= num_contacts) return false; // not found diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 5a156c82929..a401117e5a4 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -31,6 +31,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: mutable uint8_t shared_secret[PUB_KEY_SIZE]; diff --git a/test/test_empty_contact_slots/test_empty_contact_slots.cpp b/test/test_empty_contact_slots/test_empty_contact_slots.cpp new file mode 100644 index 00000000000..cb4d5912fbb --- /dev/null +++ b/test/test_empty_contact_slots/test_empty_contact_slots.cpp @@ -0,0 +1,230 @@ +#include +#include +#include + +#include "Dispatcher.cpp" +#include "Mesh.cpp" +#include "helpers/AdvertDataHelpers.cpp" +#include "helpers/BaseChatMesh.cpp" + +void StrHelper::strncpy(char* dest, const char* src, size_t buf_sz) { + if (buf_sz == 0) return; + std::strncpy(dest, src, buf_sz - 1); + dest[buf_sz - 1] = 0; +} + +namespace mesh { + +Identity::Identity() { + memset(pub_key, 0, sizeof(pub_key)); +} + +bool Identity::verify(const uint8_t*, const uint8_t*, int) const { + return true; +} + +LocalIdentity::LocalIdentity() : Identity() { + memset(prv_key, 0, sizeof(prv_key)); +} + +void LocalIdentity::sign(uint8_t* sig, const uint8_t*, int) const { + memset(sig, 0, SIGNATURE_SIZE); +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + ed25519_key_exchange(secret, other_pub_key, prv_key); +} + +} // namespace mesh + +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: + mesh::Packet* allocNew() override { return nullptr; } + void free(mesh::Packet*) override {} + void queueOutbound(mesh::Packet*, uint8_t, uint32_t) override {} + 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 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 {} + ContactInfo* processAck(const uint8_t*) override { return nullptr; } + 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 {} + void onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const uint8_t*, const char*) override {} + 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 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; + memset(&empty_slot, 0, sizeof(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(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(&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(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; + memset(&peer, 0, sizeof(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(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))); + + 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; + memset(&anon, 0, sizeof(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)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 87c76a8b06a9113fe16a40ca9961bf9aaf99da9d Mon Sep 17 00:00:00 2001 From: liamcottle Date: Wed, 21 Oct 2026 15:41:11 +1300 Subject: [PATCH 17/39] implement new auto detect display for sh1106 and ssd1306 --- src/helpers/ui/AutoDetectDisplay.cpp | 45 ++++++++++++++++++++++++++++ src/helpers/ui/AutoDetectDisplay.h | 32 ++++++++++++++++++++ src/helpers/ui/SH1106Display.cpp | 18 +++++------ variants/rak4631/platformio.ini | 23 +++++++------- variants/rak4631/target.h | 2 +- 5 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 src/helpers/ui/AutoDetectDisplay.cpp create mode 100644 src/helpers/ui/AutoDetectDisplay.h diff --git a/src/helpers/ui/AutoDetectDisplay.cpp b/src/helpers/ui/AutoDetectDisplay.cpp new file mode 100644 index 00000000000..e3537ab2582 --- /dev/null +++ b/src/helpers/ui/AutoDetectDisplay.cpp @@ -0,0 +1,45 @@ +#include "AutoDetectDisplay.h" +#include + +int AutoDetectDisplay::getDisplayStatus(TwoWire& wire, uint8_t addr) { + int status = -1; + for(int i = 0; i < 4; i++){ + + // request display status + wire.beginTransmission(addr); + wire.write((uint8_t)0x00); + + // ensure response received + if (wire.endTransmission() != 0) return -1; // expect success + if (wire.requestFrom((int)addr, 1) == 0) return -1; // expect at least 1 byte + + // ensure two consecutive reads are the same + int previous = status; + status = wire.read() & 0x0F; + if(status == previous){ + break; + } + + } + return status; +} + +bool AutoDetectDisplay::begin() { + + // request status from display address + int status = getDisplayStatus(Wire, DISPLAY_ADDRESS); + MESH_DEBUG_PRINTLN("AutoDetectDisplay: status=0x%02X", status); + + // detect SH1106 + if(status == 0x00 || status == 0x08){ + MESH_DEBUG_PRINTLN("AutoDetectDisplay: SH1106 detected"); + _impl = &_sh1106; + return _sh1106.begin(); + } + + // fallback to SSD1306 + MESH_DEBUG_PRINTLN("AutoDetectDisplay: falling back to SSD1306"); + _impl = &_ssd1306; + return _ssd1306.begin(); + +} diff --git a/src/helpers/ui/AutoDetectDisplay.h b/src/helpers/ui/AutoDetectDisplay.h new file mode 100644 index 00000000000..7208d4f59df --- /dev/null +++ b/src/helpers/ui/AutoDetectDisplay.h @@ -0,0 +1,32 @@ +#pragma once + +#include "DisplayDriver.h" +#include "SSD1306Display.h" +#include "SH1106Display.h" + +// Auto detects SH1106 display at runtime, otherwise falls back to using SSD1306 +class AutoDetectDisplay : public DisplayDriver { + DisplayDriver* _impl; + SSD1306Display _ssd1306; + SH1106Display _sh1106; + + int getDisplayStatus(TwoWire& wire, uint8_t addr); +public: + AutoDetectDisplay() : DisplayDriver(128, 64), _impl(&_ssd1306) { } + bool begin(); + + bool isOn() override { return _impl->isOn(); } + void turnOn() override { _impl->turnOn(); } + void turnOff() override { _impl->turnOff(); } + void clear() override { _impl->clear(); } + void startFrame(ColorVal bkg = UIColor::window_bkg) override { _impl->startFrame(bkg); } + void setTextSize(int sz) override { _impl->setTextSize(sz); } + void setColor(ColorVal c) override { _impl->setColor(c); } + void setCursor(int x, int y) override { _impl->setCursor(x, y); } + void print(const char* str) override { _impl->print(str); } + void fillRect(int x, int y, int w, int h) override { _impl->fillRect(x, y, w, h); } + void drawRect(int x, int y, int w, int h) override { _impl->drawRect(x, y, w, h); } + void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { _impl->drawXbm(x, y, bits, w, h); } + uint16_t getTextWidth(const char* str) override { return _impl->getTextWidth(str); } + void endFrame() override { _impl->endFrame(); } +}; diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index 8b91d857c05..e290b35567b 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -10,15 +10,15 @@ bool SH1106Display::i2c_probe(TwoWire &wire, uint8_t addr) } // Color scheme -ColorVal UIColor::window_bkg = SH110X_BLACK; -ColorVal UIColor::title_bkg = SH110X_BLACK; -ColorVal UIColor::title_txt = SH110X_WHITE; -ColorVal UIColor::primary_txt = SH110X_WHITE; -ColorVal UIColor::secondary_txt = SH110X_WHITE; -ColorVal UIColor::warning_txt = SH110X_WHITE; -ColorVal UIColor::popup_bkg = SH110X_BLACK; -ColorVal UIColor::popup_txt = SH110X_WHITE; -ColorVal UIColor::corp_blue = SH110X_WHITE; +__attribute__((weak)) ColorVal UIColor::window_bkg = SH110X_BLACK; +__attribute__((weak)) ColorVal UIColor::title_bkg = SH110X_BLACK; +__attribute__((weak)) ColorVal UIColor::title_txt = SH110X_WHITE; +__attribute__((weak)) ColorVal UIColor::primary_txt = SH110X_WHITE; +__attribute__((weak)) ColorVal UIColor::secondary_txt = SH110X_WHITE; +__attribute__((weak)) ColorVal UIColor::warning_txt = SH110X_WHITE; +__attribute__((weak)) ColorVal UIColor::popup_bkg = SH110X_BLACK; +__attribute__((weak)) ColorVal UIColor::popup_txt = SH110X_WHITE; +__attribute__((weak)) ColorVal UIColor::corp_blue = SH110X_WHITE; bool SH1106Display::begin() { diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index d2781d9fa2b..4c13b0d74b9 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -28,11 +28,14 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/rak4631> + + + + + + + lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit SSD1306 @ ^2.5.13 + adafruit/Adafruit SH110X @ ^2.1.13 sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 boschsensortec/BSEC Software Library @ ^1.8.1492 @@ -40,7 +43,7 @@ lib_deps = extends = rak4631 build_flags = ${rak4631.build_flags} - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D ADVERT_NAME='"RAK4631 Repeater"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -56,7 +59,7 @@ build_src_filter = ${rak4631.build_src_filter} extends = rak4631 build_flags = ${rak4631.build_flags} - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D ADVERT_NAME='"RAK4631 Repeater"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -75,7 +78,7 @@ lib_deps = extends = rak4631 build_flags = ${rak4631.build_flags} - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D ADVERT_NAME='"RS232 Bridge"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -98,7 +101,7 @@ build_src_filter = ${rak4631.build_src_filter} extends = rak4631 build_flags = ${rak4631.build_flags} - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D ADVERT_NAME='"RS232 Bridge"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -121,7 +124,7 @@ build_src_filter = ${rak4631.build_src_filter} extends = rak4631 build_flags = ${rak4631.build_flags} - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D ADVERT_NAME='"Test Room"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -137,7 +140,7 @@ build_src_filter = ${rak4631.build_src_filter} extends = rak4631 build_flags = ${rak4631.build_flags} - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D ADVERT_NAME='"RAK4631 Room Server"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -161,7 +164,7 @@ build_flags = -I examples/companion_radio/ui-new -D PIN_USER_BTN=9 -D PIN_USER_BTN_ANA=31 - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D ENABLE_USB_INTERFACE @@ -186,7 +189,7 @@ build_flags = -I examples/companion_radio/ui-new -D PIN_USER_BTN=9 -D PIN_USER_BTN_ANA=31 - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D ETHERNET_ENABLED=1 @@ -215,7 +218,7 @@ build_flags = -I examples/companion_radio/ui-new -D PIN_USER_BTN=9 -D PIN_USER_BTN_ANA=31 - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 @@ -251,7 +254,7 @@ lib_deps = extends = rak4631 build_flags = ${rak4631.build_flags} - -D DISPLAY_CLASS=SSD1306Display + -D DISPLAY_CLASS=AutoDetectDisplay -D ADVERT_NAME='"RAK4631 Sensor"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 diff --git a/variants/rak4631/target.h b/variants/rak4631/target.h index 1529f904246..c04cd4e4822 100644 --- a/variants/rak4631/target.h +++ b/variants/rak4631/target.h @@ -9,7 +9,7 @@ #include #ifdef DISPLAY_CLASS - #include + #include extern DISPLAY_CLASS display; #include extern MomentaryButton user_btn; From a8e8d01a3c8dd951c85f4c891550c9173a3a342f Mon Sep 17 00:00:00 2001 From: Confituurke <9595028+Confituurke@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:24:34 +0200 Subject: [PATCH 18/39] Use two decimals only for exact quarter-hour offsets --- examples/companion_radio/MyMesh.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2ea539dbc9c..cc6340bb1bb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2192,7 +2192,8 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } if (strcmp(command, "get tz.offset") == 0) { - sprintf(reply, ((int)(_prefs.tz_offset * 4) % 2) ? "> %.2f" : "> %.1f", _prefs.tz_offset); + float q = _prefs.tz_offset * 4; + sprintf(reply, (q == (int)q && ((int)q & 1)) ? "> %.2f" : "> %.1f", _prefs.tz_offset); return true; } if (memcmp(command, "set tz.offset ", 14) == 0) { From a31e2c92df6c9e870ce3b0bbb5d3defa37a64490 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 21 Oct 2026 14:08:56 -0400 Subject: [PATCH 19/39] refactoring eink devices behaviour --- examples/companion_radio/ui-new/UITask.cpp | 44 ++++++++++++++++------ examples/companion_radio/ui-new/UITask.h | 3 +- src/helpers/ui/DisplayDriver.h | 1 + src/helpers/ui/GxEPDDisplay.cpp | 6 ++- src/helpers/ui/GxEPDDisplay.h | 1 + variants/lilygo_techo/platformio.ini | 8 ++-- variants/lilygo_techo/target.cpp | 1 + variants/lilygo_techo/target.h | 1 + variants/thinknode_m1/platformio.ini | 7 ++++ variants/thinknode_m1/target.cpp | 1 + variants/thinknode_m1/target.h | 1 + variants/thinknode_m5/platformio.ini | 7 +++- variants/thinknode_m5/target.cpp | 1 + variants/thinknode_m5/target.h | 1 + variants/thinknode_m8/platformio.ini | 11 +++--- variants/thinknode_m8/target.cpp | 1 + variants/thinknode_m8/target.h | 1 + 17 files changed, 71 insertions(+), 25 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index d48647b3dd3..de9e8f58a7a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -197,11 +197,10 @@ class HomeScreen : public UIScreen { sensors_nb ++; } sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE; -#if AUTO_OFF_MILLIS > 0 - next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec -#else - next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min -#endif + if (display.isEink()) // preserve eink displays + next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min + else + next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec } } @@ -703,11 +702,7 @@ class MsgPreviewScreen : public UIScreen { display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); display.printWordWrap(filtered_msg, display.width()); -#if AUTO_OFF_MILLIS==0 // probably e-ink - return 10000; // 10 s -#else - return 1000; // next render after 1000 ms -#endif + return display.isEink() ? 10000 : 1000; // 10 s for eink } bool handleInput(char c) override { @@ -739,6 +734,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #if defined(PIN_USER_BTN_ANA) analog_btn.begin(); #endif +#if defined(BACKLIGHT_BTN) + backlight_btn.begin(); +#endif _node_prefs = node_prefs; @@ -1002,13 +1000,30 @@ void UITask::loop() { #endif #if defined(BACKLIGHT_BTN) if (millis() > next_backlight_btn_check) { - bool touch_state = digitalRead(PIN_BUTTON2); + if (backlight_btn.isPressed()) { + if (!_display->isOn()) { + _display->turnOn(); + _next_refresh = 0; + } + _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + backlight_btn_pressed_at = millis(); // still pressed + } + bool touch_state = (millis() - backlight_btn_pressed_at > 5000); + // Backlight button events + int bev = backlight_btn.check(); + if (bev == BUTTON_EVENT_DOUBLE_CLICK) { + _display->forceFullRefresh(); + } else if (bev == BUTTON_EVENT_TRIPLE_CLICK) { + _display->blankScreen(); + _display->turnOff(); + backlight_btn_pressed_at = 0; + } #if defined(DISP_BACKLIGHT) digitalWrite(DISP_BACKLIGHT, !touch_state); #elif defined(EXP_PIN_BACKLIGHT) expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state); #endif - next_backlight_btn_check = millis() + 300; + next_backlight_btn_check = millis() + 100; } #endif @@ -1056,6 +1071,11 @@ void UITask::loop() { } #endif if (millis() > _auto_off) { + #ifdef EINK_SCREENOFF_BLANK + if (curr != msg_preview) { // don't blank if messages are waiting + display.blankScreen(); + } + #endif _display->turnOff(); } #endif diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 361b04d0125..4d44bfe1a6e 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -61,7 +61,8 @@ class UITask : public AbstractUITask { unsigned long _alert_expiry; int _msgcount; unsigned long ui_started_at, next_batt_chck; - int next_backlight_btn_check = 0; + unsigned long next_backlight_btn_check = 0; + unsigned long backlight_btn_pressed_at = 0; #ifdef PIN_STATUS_LED int led_state = 0; int next_led_change = 0; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 3e9e2dde86f..cbef3f98fd0 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -27,6 +27,7 @@ class DisplayDriver { virtual void turnOn() = 0; virtual void turnOff() = 0; virtual void clear() = 0; + virtual void blankScreen() {} // to cleanly erase screen before turning off virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0; virtual void setTextSize(int sz) = 0; virtual void setColor(ColorVal c) = 0; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index 332157eb8d1..21b00afc53a 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -82,13 +82,17 @@ void GxEPDDisplay::turnOff() { expander.digitalWrite(EXP_PIN_BACKLIGHT, LOW); #endif _isOn = false; + last_display_crc_value=0; + display.hibernate(); +} + +void GxEPDDisplay::blankScreen() { // do full refresh before powering off to clear screen // no full refresh needed at wakeup display.clearScreen(0xFF); // Clears microcontroller side RAM display.writeScreenBuffer(0xFF); // Forces 0xFF (White) into the display controller's history registers resetPartialRefreshCounter(); last_display_crc_value=0; - display.hibernate(); } void GxEPDDisplay::clear() { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 8a37b72cc69..99b25d150fb 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -61,6 +61,7 @@ class GxEPDDisplay : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; + void blankScreen() override; void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; void setColor(ColorVal c) override; diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index 81da2e48c3f..1404062235f 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -32,8 +32,12 @@ build_flags = ${nrf52_base.build_flags} -D PIN_GPS_RESET_ACTIVE=LOW -D TELEM_BME280_ADDRESS=0x77 -D DISPLAY_CLASS=GxEPDDisplay + -D EINK_SCREENOFF_BLANK + -D UI_RECENT_LIST_SIZE=9 + -D UI_MSG_PREVIEW_SIZE=165 + -D EINK_MAX_PARTIAL_REFRESH=60 + -D AUTO_OFF_MILLIS=1800000 -D BACKLIGHT_BTN=PIN_BUTTON2 - -D AUTO_OFF_MILLIS=0 build_src_filter = ${nrf52_base.build_src_filter} + + @@ -91,7 +95,6 @@ build_flags = -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 - -D UI_RECENT_LIST_SIZE=9 -D UI_SENSORS_PAGE=1 -D UI_GPS_PAGE=1 ; -D MESH_PACKET_LOGGING=1 @@ -116,7 +119,6 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 - -D UI_RECENT_LIST_SIZE=9 -D UI_SENSORS_PAGE=1 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 -D QSPIFLASH=1 diff --git a/variants/lilygo_techo/target.cpp b/variants/lilygo_techo/target.cpp index 278fc76dad5..12b40d7ee94 100644 --- a/variants/lilygo_techo/target.cpp +++ b/variants/lilygo_techo/target.cpp @@ -23,6 +23,7 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); #ifdef DISPLAY_CLASS DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); + MomentaryButton backlight_btn(BACKLIGHT_BTN, 1000, true); #endif bool radio_init() { diff --git a/variants/lilygo_techo/target.h b/variants/lilygo_techo/target.h index 2b8a1783f06..a1668f03187 100644 --- a/variants/lilygo_techo/target.h +++ b/variants/lilygo_techo/target.h @@ -22,6 +22,7 @@ extern EnvironmentSensorManager sensors; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display; extern MomentaryButton user_btn; + extern MomentaryButton backlight_btn; #endif bool radio_init(); diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini index 89c48403bf5..2e1aa48f45a 100644 --- a/variants/thinknode_m1/platformio.ini +++ b/variants/thinknode_m1/platformio.ini @@ -24,6 +24,13 @@ build_flags = ${nrf52_base.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_MAX_PARTIAL_REFRESH=60 + -D BACKLIGHT_BTN=PIN_BUTTON2 + -D AUTO_OFF_MILLIS=1800000 + -D UI_NO_HIBERNATE + -D DISABLE_DIAGNOSTIC_OUTPUT build_src_filter = ${nrf52_base.build_src_filter} + + diff --git a/variants/thinknode_m1/target.cpp b/variants/thinknode_m1/target.cpp index 69306fc0e1e..c430cee00df 100644 --- a/variants/thinknode_m1/target.cpp +++ b/variants/thinknode_m1/target.cpp @@ -17,6 +17,7 @@ ThinkNodeM1SensorManager sensors = ThinkNodeM1SensorManager(nmea); #ifdef DISPLAY_CLASS DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); + MomentaryButton backlight_btn(BACKLIGHT_BTN, 1000, true); #endif bool radio_init() { diff --git a/variants/thinknode_m1/target.h b/variants/thinknode_m1/target.h index ec1297beffc..d2d6aed1d50 100644 --- a/variants/thinknode_m1/target.h +++ b/variants/thinknode_m1/target.h @@ -40,6 +40,7 @@ extern ThinkNodeM1SensorManager sensors; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display; extern MomentaryButton user_btn; + extern MomentaryButton backlight_btn; #endif bool radio_init(); diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini index a3f348b3374..ed187dd25a2 100644 --- a/variants/thinknode_m5/platformio.ini +++ b/variants/thinknode_m5/platformio.ini @@ -26,8 +26,12 @@ build_flags = ${esp32_base.build_flags} -D EINK_SCALE_Y=1.5625f -D EINK_X_OFFSET=0 -D EINK_Y_OFFSET=10 + -D UI_RECENT_LIST_SIZE=9 + -D UI_MSG_PREVIEW_SIZE=165 + -D EINK_MAX_PARTIAL_REFRESH=60 -D BACKLIGHT_BTN=PIN_BUTTON2 - -D AUTO_OFF_MILLIS=0 + -D AUTO_OFF_MILLIS=1800000 + -D UI_NO_HIBERNATE -D DISABLE_DIAGNOSTIC_OUTPUT -D SX126X_DIO2_AS_RF_SWITCH=true -D SX126X_DIO3_TCXO_VOLTAGE=3.3 @@ -150,7 +154,6 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 -D OFFLINE_QUEUE_SIZE=256 - -D UI_RECENT_LIST_SIZE=9 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D GPS_NMEA_DEBUG diff --git a/variants/thinknode_m5/target.cpp b/variants/thinknode_m5/target.cpp index be534f67d75..ec524df749f 100644 --- a/variants/thinknode_m5/target.cpp +++ b/variants/thinknode_m5/target.cpp @@ -26,6 +26,7 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); #ifdef DISPLAY_CLASS DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); + MomentaryButton backlight_btn(BACKLIGHT_BTN, 1000, true); #endif bool radio_init() { diff --git a/variants/thinknode_m5/target.h b/variants/thinknode_m5/target.h index a613e390901..7cce1edbf88 100644 --- a/variants/thinknode_m5/target.h +++ b/variants/thinknode_m5/target.h @@ -24,6 +24,7 @@ extern PCA9557 expander; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display; extern MomentaryButton user_btn; + extern MomentaryButton backlight_btn; #endif bool radio_init(); diff --git a/variants/thinknode_m8/platformio.ini b/variants/thinknode_m8/platformio.ini index db19e446644..2c07038f2d8 100644 --- a/variants/thinknode_m8/platformio.ini +++ b/variants/thinknode_m8/platformio.ini @@ -18,12 +18,16 @@ build_flags = ${nrf52_base.build_flags} -D P_LORA_SCLK=19 -D P_LORA_MISO=22 -D P_LORA_MOSI=20 - -D UI_SHOW_CLOCK=1 + -D DISPLAY_ROTATION=4 -D UI_HAS_ROTARY_INPUT=1 -D UI_HAS_NAV_INPUT=1 -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 -D EINK_MAX_PARTIAL_REFRESH=60 + -D AUTO_OFF_MILLIS=1800000 + -D BACKLIGHT_BTN=PIN_BUTTON2 + -D UI_SHOW_CLOCK=1 + -D EINK_SCREENOFF_BLANK -D EINK_DISPLAY_MODEL=GxEPD2_154_D67 ; -D EINK_DISPLAY_MODEL=GxEPD2_154_GDEY0154D67 ; -D EINK_DISPLAY_MODEL=GxEPD2_150_BN @@ -92,10 +96,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 # -D BLE_DEBUG_LOGGING=1 - -D DISPLAY_ROTATION=4 -D DISPLAY_CLASS=GxEPDDisplay - -D BACKLIGHT_BTN=PIN_BUTTON2 - -D AUTO_OFF_MILLIS=0 -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=33 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 @@ -133,8 +134,6 @@ build_flags = -D DISPLAY_ROTATION=4 -D QSPIFLASH=1 -D DISPLAY_CLASS=GxEPDDisplay - -D BACKLIGHT_BTN=PIN_BUTTON2 - -D AUTO_OFF_MILLIS=0 -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=6 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 diff --git a/variants/thinknode_m8/target.cpp b/variants/thinknode_m8/target.cpp index ac4ac100edc..69f3cc52604 100644 --- a/variants/thinknode_m8/target.cpp +++ b/variants/thinknode_m8/target.cpp @@ -23,6 +23,7 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); RotaryInputGPIO rotary_input(PIN_BUTTON1_A, PIN_BUTTON1_B); + MomentaryButton backlight_btn(BACKLIGHT_BTN, 1000, true); #endif bool radio_init() { diff --git a/variants/thinknode_m8/target.h b/variants/thinknode_m8/target.h index c3402a01a7e..39928506585 100644 --- a/variants/thinknode_m8/target.h +++ b/variants/thinknode_m8/target.h @@ -23,6 +23,7 @@ extern EnvironmentSensorManager sensors; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display; extern MomentaryButton user_btn; + extern MomentaryButton backlight_btn; extern RotaryInputGPIO rotary_input; #endif From 408d674ace41688bd53a3d1888cd397adaacba9c Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 21 Oct 2026 17:13:27 -0400 Subject: [PATCH 20/39] DisplayDriver: fixing alignment issue in vtable for nrf52 and epd display --- examples/companion_radio/ui-new/UITask.cpp | 6 +++++- src/helpers/ui/DisplayDriver.h | 3 ++- src/helpers/ui/GxEPDDisplay.cpp | 7 +++++-- src/helpers/ui/GxEPDDisplay.h | 3 ++- variants/lilygo_techo/platformio.ini | 2 +- variants/thinknode_m1/platformio.ini | 2 +- variants/thinknode_m5/platformio.ini | 2 +- variants/thinknode_m8/platformio.ini | 2 +- 8 files changed, 18 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index de9e8f58a7a..6e987f87568 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -955,7 +955,9 @@ void UITask::loop() { if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_ENTER); } else if (ev == BUTTON_EVENT_LONG_PRESS) { + display.blankScreen(); display.turnOff(); + display.powerOff(); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { c = handleDoubleClick(KEY_SELECT); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { @@ -1016,6 +1018,7 @@ void UITask::loop() { } else if (bev == BUTTON_EVENT_TRIPLE_CLICK) { _display->blankScreen(); _display->turnOff(); + _display->powerOff(); backlight_btn_pressed_at = 0; } #if defined(DISP_BACKLIGHT) @@ -1073,7 +1076,8 @@ void UITask::loop() { if (millis() > _auto_off) { #ifdef EINK_SCREENOFF_BLANK if (curr != msg_preview) { // don't blank if messages are waiting - display.blankScreen(); + _display->blankScreen(); + _display->powerOff(); } #endif _display->turnOff(); diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index cbef3f98fd0..58147aa0e75 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -26,8 +26,9 @@ class DisplayDriver { virtual void forceFullRefresh() {} // next refresh will be full for eink virtual void turnOn() = 0; virtual void turnOff() = 0; - virtual void clear() = 0; virtual void blankScreen() {} // to cleanly erase screen before turning off + virtual void powerOff() {} + virtual void clear() = 0; virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0; virtual void setTextSize(int sz) = 0; virtual void setColor(ColorVal c) = 0; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index 21b00afc53a..ce09423e11a 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -75,7 +75,11 @@ void GxEPDDisplay::turnOn() { } } -void GxEPDDisplay::turnOff() { +void GxEPDDisplay::powerOff() { + display.hibernate(); +} + +void GxEPDDisplay::turnOff() { // Only stops refresh #if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) digitalWrite(DISP_BACKLIGHT, LOW); #elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN) @@ -83,7 +87,6 @@ void GxEPDDisplay::turnOff() { #endif _isOn = false; last_display_crc_value=0; - display.hibernate(); } void GxEPDDisplay::blankScreen() { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 99b25d150fb..462744026bd 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -60,8 +60,9 @@ class GxEPDDisplay : public DisplayDriver { void resetPartialRefreshCounter() { _cycles_before_full_refresh = max_partial_refresh; }; void turnOn() override; void turnOff() override; - void clear() override; void blankScreen() override; + void powerOff() override; + void clear() override; void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; void setColor(ColorVal c) override; diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index 1404062235f..eb4a69e284e 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -36,7 +36,7 @@ build_flags = ${nrf52_base.build_flags} -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 -D EINK_MAX_PARTIAL_REFRESH=60 - -D AUTO_OFF_MILLIS=1800000 + -D AUTO_OFF_MILLIS=900000 -D BACKLIGHT_BTN=PIN_BUTTON2 build_src_filter = ${nrf52_base.build_src_filter} + diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini index 2e1aa48f45a..c775ae3fabb 100644 --- a/variants/thinknode_m1/platformio.ini +++ b/variants/thinknode_m1/platformio.ini @@ -28,7 +28,7 @@ build_flags = ${nrf52_base.build_flags} -D UI_MSG_PREVIEW_SIZE=165 -D EINK_MAX_PARTIAL_REFRESH=60 -D BACKLIGHT_BTN=PIN_BUTTON2 - -D AUTO_OFF_MILLIS=1800000 + -D AUTO_OFF_MILLIS=900000 -D UI_NO_HIBERNATE -D DISABLE_DIAGNOSTIC_OUTPUT build_src_filter = ${nrf52_base.build_src_filter} diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini index ed187dd25a2..1980575ec66 100644 --- a/variants/thinknode_m5/platformio.ini +++ b/variants/thinknode_m5/platformio.ini @@ -30,7 +30,7 @@ build_flags = ${esp32_base.build_flags} -D UI_MSG_PREVIEW_SIZE=165 -D EINK_MAX_PARTIAL_REFRESH=60 -D BACKLIGHT_BTN=PIN_BUTTON2 - -D AUTO_OFF_MILLIS=1800000 + -D AUTO_OFF_MILLIS=900000 -D UI_NO_HIBERNATE -D DISABLE_DIAGNOSTIC_OUTPUT -D SX126X_DIO2_AS_RF_SWITCH=true diff --git a/variants/thinknode_m8/platformio.ini b/variants/thinknode_m8/platformio.ini index 2c07038f2d8..47d3a3fcd78 100644 --- a/variants/thinknode_m8/platformio.ini +++ b/variants/thinknode_m8/platformio.ini @@ -24,7 +24,7 @@ build_flags = ${nrf52_base.build_flags} -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 -D EINK_MAX_PARTIAL_REFRESH=60 - -D AUTO_OFF_MILLIS=1800000 + -D AUTO_OFF_MILLIS=900000 -D BACKLIGHT_BTN=PIN_BUTTON2 -D UI_SHOW_CLOCK=1 -D EINK_SCREENOFF_BLANK From 56b5f4e9c68ae668662f93044b0c2a62d2ccf10b Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Thu, 22 Oct 2026 09:33:07 +0000 Subject: [PATCH 21/39] fix(chat): refuse a negative text length for a group message CMD_SEND_CHANNEL_TXT_MSG reads txt_type, channel_idx and a 4 byte timestamp, so the frame needs 7 bytes before the text starts, and the branch had no length guard. For a shorter frame "len - i" is computed in size_t, wraps, and reaches sendGroupMessage() as a negative int. The clamp there tests "text_len + prefix_len > MAX_TEXT_LEN", which a negative value passes, and the following memcpy() converts the length back to a size_t: a 3 byte frame asks for 4294967292 bytes into a 197 byte stack buffer. Two guards, because there are two mistakes. The branch now states its minimum like most other branches of handleCmdFrame do, so a frame below 7 bytes is answered through the existing path instead of being parsed. And sendGroupMessage() refuses a negative length the way sendGroupData() in the same file already does, which protects every other caller. A frame of exactly 7 bytes keeps working and sends an empty message, as before. Evidence. On a T-Echo running dev 428a95c6, built with FIRMWARE_BUILD_DATE "f1-dev" and confirmed by "ver => v1.18.0 (Build: f1-dev)": CH_TXT len=7 -> RESP_CODE_OK GET_STATS core -> uptime=78s CH_TXT len=3 -> NO REPLY DEVICE_QUERY -> NO REPLY (three times over 8 s, node never came back) --- examples/companion_radio/MyMesh.cpp | 4 +++- src/helpers/BaseChatMesh.cpp | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 99a9aa30d6e..d74050be7e0 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1175,7 +1175,9 @@ void MyMesh::handleCmdFrame(size_t len) { ? ERR_CODE_NOT_FOUND : ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_* } - } else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG) { // send GroupChannel text msg + } else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG && len >= 7) { // send GroupChannel text msg + // 7 = code, txt_type, channel_idx and the 4 byte timestamp; the text may be empty. + // Without this the text length below is computed as len - 7 and turns negative. int i = 1; uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN uint8_t channel_idx = cmd_frame[i++]; diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 28592415ca9..2637bc327ab 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -516,6 +516,11 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest } bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len) { + if (text_len < 0) { // memcpy() below would read this as a size_t, so clamping cannot catch it + MESH_DEBUG_PRINTLN("sendGroupMessage: invalid negative text_len=%d", text_len); + return false; + } + uint8_t temp[5+MAX_TEXT_LEN+32]; memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = 0; // TXT_TYPE_PLAIN From 83bd824f6c9b1b8e36f802a341d5b2201b647f7b Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Thu, 22 Oct 2026 09:42:58 +0000 Subject: [PATCH 22/39] fix(radio): refuse "nan" for the airtime factor "set af nan" is accepted and stores a NaN in airtime_factor, the duty cycle budget the roles hand to their airtime accounting. strtof() consumes the token, so the parse guard does not fire, and no ordered comparison is true for a NaN, so "af < 0 || af > 9" cannot reject it. "set dutycycle" writes the same field and has the same hole. Both commands now check the text before it is parsed: after optional white space and sign, a number has to start with a digit or a '.'. That refuses "nan", "inf" and their variants, and keeps every spelling that was accepted before (signs, leading white space, ".5"). The ranges and both reply strings are unchanged. The check is on the text rather than on the parsed float on purpose. nRF52 builds use -Ofast, and under -ffast-math the compiler may assume a float is never NaN or infinite. A test on the value, even one that reads its bits, can then be folded away: clang does exactly that with a bit test at -Ofast, while arm-none-eabi-gcc keeps it. A character test does not depend on that. --- src/helpers/CommonRadioPrefs.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932a5..44e0eba3161 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -2,6 +2,22 @@ #include "TxtDataHelpers.h" #include "Utils.h" #include "target.h" +#include + +namespace { + +// strtof() and atof() also read "nan" and "inf". A NaN cannot be refused by a +// range check: no ordered comparison is true for it, and under -ffast-math, +// which nRF52 builds use, the compiler may assume it never occurs and fold any +// test on the parsed value away. So the text is checked before it is parsed: +// after optional white space and sign, a number starts with a digit or a '.'. +bool looksLikeANumber(const char* s) { + while (isspace((unsigned char)*s)) s++; + if (*s == '+' || *s == '-') s++; + return isdigit((unsigned char)*s) || *s == '.'; +} + +} // namespace bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { if (strcmp(key, "fem_rxgain") == 0) { @@ -69,7 +85,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest if (memcmp(command, "set af ", 7) == 0) { char* end; float af = strtof(&command[7], &end); - if (end == &command[7] || af < 0 || af > 9) { + if (end == &command[7] || !looksLikeANumber(&command[7]) || af < 0 || af > 9) { strcpy(reply, "ERROR: af must be 0-9"); } else { setAirtimeFactor(af); @@ -87,7 +103,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest } if (memcmp(command, "set dutycycle ", 14) == 0) { float dc = atof(&command[14]); - if (dc < 1 || dc > 100) { + if (!looksLikeANumber(&command[14]) || dc < 1 || dc > 100) { strcpy(reply, "ERROR: dutycycle must be 1-100"); } else { setAirtimeFactor((100.0f / dc) - 1.0f); From 47259633f2e1d419377b248c88ce8dd2e740fa63 Mon Sep 17 00:00:00 2001 From: Blake Latchford Date: Thu, 22 Oct 2026 17:57:33 -0400 Subject: [PATCH 23/39] Fix broken KISS modem build on 6 ESP32-S3 platforms Broken out from PR #3496 which has been open for a month. --- examples/kiss_modem/main.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/kiss_modem/main.cpp b/examples/kiss_modem/main.cpp index 44b2a3df0cf..bb77c23d7bf 100644 --- a/examples/kiss_modem/main.cpp +++ b/examples/kiss_modem/main.cpp @@ -115,7 +115,10 @@ void setup() { delay(100); #if defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT Serial.setTxTimeoutMs(USB_TX_TIMEOUT_MS); +#if defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE + // HWCDC only; TinyUSB's USBCDC TX buffer is fixed at build time Serial.setTxBufferSize(USB_TX_BUFFER_SIZE); +#endif #endif modem = new KissModem(Serial, identity, rng, radio_driver, board, sensors); #endif From da98f5318da86699b0711914a9abe27880cec805 Mon Sep 17 00:00:00 2001 From: Florent Date: Sat, 24 Oct 2026 10:16:56 -0400 Subject: [PATCH 24/39] UiTask: better cleaning of eink screens when hibernating --- examples/companion_radio/ui-new/UITask.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6e987f87568..aced739dc45 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -908,9 +908,11 @@ void UITask::shutdown(bool restart){ if (restart) { _board->reboot(); } else { - display.forceFullRefresh(); - display.clear(); - display.endFrame(); + if (_display->isEink()) { + _display->blankScreen(); + _display->turnOff(); + _display->powerOff(); + } // Power off board including radio, display, GPS and components _board->powerOff(); } From d7c0eadbe25c336a0b2d3d80249dfe07521ad6d5 Mon Sep 17 00:00:00 2001 From: Florent Date: Sat, 24 Oct 2026 11:15:28 -0400 Subject: [PATCH 25/39] thinknode_m1: fix button2 definition --- variants/thinknode_m1/variant.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/thinknode_m1/variant.h b/variants/thinknode_m1/variant.h index f7f52a6a592..e096f3caae6 100644 --- a/variants/thinknode_m1/variant.h +++ b/variants/thinknode_m1/variant.h @@ -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 From b2106ea7ee3a569158369ad772318569504d543c Mon Sep 17 00:00:00 2001 From: Florent Date: Sat, 24 Oct 2026 13:26:06 -0400 Subject: [PATCH 26/39] ThinknodeM1: update eink driver --- variants/thinknode_m1/platformio.ini | 5 +++++ variants/thinknode_m1/variant.h | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini index c775ae3fabb..7c620e2ed6d 100644 --- a/variants/thinknode_m1/platformio.ini +++ b/variants/thinknode_m1/platformio.ini @@ -27,6 +27,11 @@ build_flags = ${nrf52_base.build_flags} -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 -D EINK_MAX_PARTIAL_REFRESH=60 + -D EINK_DISPLAY_MODEL=GxEPD2_154_D67 + -D EINK_SCALE_X=1.5625f + -D EINK_SCALE_Y=1.5625f + -D EINK_X_OFFSET=0 + -D EINK_Y_OFFSET=10 -D BACKLIGHT_BTN=PIN_BUTTON2 -D AUTO_OFF_MILLIS=900000 -D UI_NO_HIBERNATE diff --git a/variants/thinknode_m1/variant.h b/variants/thinknode_m1/variant.h index e096f3caae6..aecf197e62d 100644 --- a/variants/thinknode_m1/variant.h +++ b/variants/thinknode_m1/variant.h @@ -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 From 2cad4eecb4d48ebe4e663bf26a92c8265993a18e Mon Sep 17 00:00:00 2001 From: Florent Date: Sun, 25 Oct 2026 17:49:16 -0400 Subject: [PATCH 27/39] ThinknodeM5: enable wifi in usb companion --- variants/thinknode_m5/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini index 1980575ec66..a9761597d8e 100644 --- a/variants/thinknode_m5/platformio.ini +++ b/variants/thinknode_m5/platformio.ini @@ -177,12 +177,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} + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> + + + lib_deps = ${ThinkNode_M5.lib_deps} densaugeo/base64 @ ~1.4.0 From 26e438904e19e374c798ce2dfd4b453e0f40741c Mon Sep 17 00:00:00 2001 From: Blake Latchford Date: Sun, 25 Oct 2026 21:45:10 -0400 Subject: [PATCH 28/39] Saturate duplicate packet stats instead of wrapping Tested on a Heltec V4 repeater with the counter seeded at 65530. Fixes #3255 Co-Authored-By: Claude Opus 5.5 --- examples/simple_repeater/MyMesh.cpp | 4 ++-- examples/simple_room_server/MyMesh.cpp | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 61b6c149e98..b50d19d013d 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -229,8 +229,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(((SimpleMeshTables *)getTables())->getNumDirectDups(), 0xFFFF); + stats.n_flood_dups = std::min(((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)); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index da4c776c0ea..935fd7f92fe 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include #define REPLY_DELAY_MILLIS 1500 #define PUSH_NOTIFY_DELAY_MILLIS 2000 @@ -170,8 +171,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(((SimpleMeshTables *)getTables())->getNumDirectDups(), 0xFFFF); + stats.n_flood_dups = std::min(((SimpleMeshTables *)getTables())->getNumFloodDups(), 0xFFFF); stats.n_posted = _num_posted; stats.n_post_push = _num_post_pushes; From bd882a2aa207e997e125c90afec16c437da399c1 Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Mon, 26 Oct 2026 20:29:39 +1100 Subject: [PATCH 29/39] * scope 0 rule fixes --- src/helpers/RegionMap.cpp | 2 +- src/helpers/RoutingPolicy.h | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index d8d773803b1..16a8e1ff90f 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -188,7 +188,7 @@ int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[], } RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) { - if (packet->transport_codes[0] == 0) return &wildcard; + if (packet->transport_codes[0] == 0) return (wildcard.flags & mask) == 0 ? &wildcard : NULL; for (int i = 0; i < num_regions; i++) { auto region = ®ions[i]; if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param) diff --git a/src/helpers/RoutingPolicy.h b/src/helpers/RoutingPolicy.h index 5251a590a42..225e5cd4896 100644 --- a/src/helpers/RoutingPolicy.h +++ b/src/helpers/RoutingPolicy.h @@ -17,6 +17,7 @@ inline bool isFloodHopLimitExceeded(const Packet* packet, uint8_t flood_max, uint8_t hops = packet->getPathHashCount(); if (hops >= flood_max) return true; if (packet->getRouteType() == ROUTE_TYPE_FLOOD && hops >= flood_max_unscoped) return true; + if (packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD && packet->transport_codes[0] == 0 && hops >= flood_max_unscoped) return true; // scope 0, treat as unscoped if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && hops >= flood_max_advert) return true; return false; } From 55b961e36f8e020a08f0f5f8b65ad37485be070b Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Mon, 26 Oct 2026 20:39:46 +1100 Subject: [PATCH 30/39] * unit test fix --- test/test_routing_policy/test_routing_policy.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test_routing_policy/test_routing_policy.cpp b/test/test_routing_policy/test_routing_policy.cpp index bba9ec79013..3f7faf3054b 100644 --- a/test/test_routing_policy/test_routing_policy.cpp +++ b/test/test_routing_policy/test_routing_policy.cpp @@ -8,6 +8,9 @@ static Packet makeFlood(uint8_t route_type, uint8_t payload_type, uint8_t hops) p.header = route_type | (payload_type << PH_TYPE_SHIFT); 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; } From e197e557c32166cbfdbf1d142452d0c8048d3f37 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Fri, 23 Oct 2026 01:30:57 +0200 Subject: [PATCH 31/39] fix(esp32): define the RTC backup of ESP32RTCClock once ESP32Board.h defined _rtc_backup_time and _rtc_backup_magic as static in the header, so every translation unit that includes it has its own copy, and the inline members of ESP32RTCClock read and write the copy of the unit they are compiled in. Today one unit per image uses them; a second one could restore a copy that setCurrentTime() never wrote. The header now declares them, and ESP32Board.cpp defines them once. --- src/helpers/ESP32Board.cpp | 4 ++++ src/helpers/ESP32Board.h | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index a55abb264e9..d190fca1906 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -3,6 +3,10 @@ #include "ESP32Board.h" #include +// The RTC backup of ESP32RTCClock, declared in ESP32Board.h. +RTC_NOINIT_ATTR uint32_t _rtc_backup_time; +RTC_NOINIT_ATTR uint32_t _rtc_backup_magic; + #if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only #include #include diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 3f95e6923f4..cd274cd931c 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -200,8 +200,11 @@ class ESP32Board : public mesh::MainBoard { } }; -static RTC_NOINIT_ATTR uint32_t _rtc_backup_time; -static RTC_NOINIT_ATTR uint32_t _rtc_backup_magic; +// The time ESP32RTCClock restores after a reset, in RTC memory that a reset does +// not clear. Defined once, in ESP32Board.cpp: the inline members below run in +// every translation unit that uses them and must all see the same backup. +extern uint32_t _rtc_backup_time; +extern uint32_t _rtc_backup_magic; #define RTC_BACKUP_MAGIC 0xAA55CC33 #define RTC_TIME_MIN 1772323200 // 1 Mar 2026 From b0bacf8a0451b1b5e5b32015dbf2f204fab173ca Mon Sep 17 00:00:00 2001 From: WayenWeng Date: Tue, 27 Oct 2026 10:44:36 +0800 Subject: [PATCH 32/39] fix(radio): put LR2021 in standby before re-config in setParams --- src/helpers/radiolib/CustomLR2021Wrapper.h | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 8299e2bf55b..c60bfda3dd1 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -16,15 +16,23 @@ class CustomLR2021Wrapper : public RadioLibWrapper { CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomLR2021 *)_radio)->setFrequency(freq); - ((CustomLR2021 *)_radio)->setSpreadingFactor(sf); - ((CustomLR2021 *)_radio)->setBandwidth(bw); - ((CustomLR2021 *)_radio)->setCodingRate(cr); + auto r = (CustomLR2021 *)_radio; + // LR2021 only accepts RF/modulation config commands while in STANDBY mode. + // Re-configuring while in continuous RX gets silently rejected (-706), + // which broke hot frequency changes over BLE. + int16_t status = r->standby(); + if (status != RADIOLIB_ERR_NONE) { MESH_DEBUG_PRINTLN("CustomLR2021Wrapper: standby() -> %d", status); } + if ((status = r->setFrequency(freq)) != RADIOLIB_ERR_NONE) { MESH_DEBUG_PRINTLN("CustomLR2021Wrapper: setFrequency() -> %d", status); } + if ((status = r->setSpreadingFactor(sf)) != RADIOLIB_ERR_NONE) { MESH_DEBUG_PRINTLN("CustomLR2021Wrapper: setSpreadingFactor() -> %d", status); } + if ((status = r->setBandwidth(bw)) != RADIOLIB_ERR_NONE) { MESH_DEBUG_PRINTLN("CustomLR2021Wrapper: setBandwidth() -> %d", status); } + if ((status = r->setCodingRate(cr)) != RADIOLIB_ERR_NONE) { MESH_DEBUG_PRINTLN("CustomLR2021Wrapper: setCodingRate() -> %d", status); } updatePreamble(sf); - applySideDetectorConfig(); + // internal idle() sets STATE_IDLE, which triggers a startReceive() on the next loop + status = applySideDetectorConfig(); + if (status != RADIOLIB_ERR_NONE) { MESH_DEBUG_PRINTLN("CustomLR2021Wrapper: applySideDetectorConfig() -> %d", status); } PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); - ((CustomLR2021 *)_radio)->setPreambleMillis(pm.preambleMillis); - ((CustomLR2021 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + r->setPreambleMillis(pm.preambleMillis); // pure in-memory setters, no standby needed + r->setMaxPayloadMillis(pm.payloadMillis); } From 3e4f71d9a22ce990128f1dc49139adccdc746de2 Mon Sep 17 00:00:00 2001 From: rado <6479266+rbrus@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:21:24 +0100 Subject: [PATCH 33/39] Drop unit tests per review feedback --- .../test_companion_node_prefs.cpp | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index ab5e0f90846..433c9710601 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -6,32 +6,6 @@ #include "../../examples/companion_radio/NodePrefs.h" -bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { - if (strcmp(key, "fem_rxgain") == 0) { - snprintf(value, max_len, "%d", (uint32_t)getFEMRxGain()); - return true; - } - if (strcmp(key, "fem_txgain") == 0) { - snprintf(value, max_len, "%d", (uint32_t)getFEMTxGain()); - return true; - } - return false; -} - -bool CommonRadioPrefs::setByKey(const char* key, const char* value) { - if (strcmp(key, "fem_rxgain") == 0) { - setFEMRxGain(atoi(value)); - markDirty(); - return true; - } - if (strcmp(key, "fem_txgain") == 0) { - setFEMTxGain(atoi(value)); - markDirty(); - return true; - } - return false; -} - class ReplayStream : public Stream { const char* _text; int _pos = 0; @@ -103,37 +77,6 @@ TEST(CompanionNodePrefs, RxGainSettingsRoundTripIndependently) { } #endif -TEST(CompanionNodePrefs, NegativeTxPowerPreservedAsSigned) { - NodePrefs prefs; - CommonRadioPrefs* radio = prefs.getRadioPrefs(); - - radio->setTxPower(-9); - EXPECT_EQ(-9, prefs.tx_power_dbm); - EXPECT_EQ(-9, radio->getTxPower()); - EXPECT_EQ(-9, static_cast(radio->getTxPower())); - - char reply[32]; - snprintf(reply, sizeof(reply), "> %d", static_cast(radio->getTxPower())); - EXPECT_STREQ("> -9", reply); -} - -TEST(CompanionNodePrefs, NegativeTxPowerRoundTripsSerial) { - NodePrefs saved; - saved.getRadioPrefs()->setTxPower(-9); - - CaptureStream output; - ASSERT_TRUE(saved.saveSerial(output)); - EXPECT_NE(std::string::npos, output.text().find("tx:-9")); - - ReplayStream input("{radio:{tx:-9}}"); - NodePrefs loaded; - loaded.tx_power_dbm = 22; - - ASSERT_TRUE(loaded.loadSerial(input)); - EXPECT_EQ(-9, loaded.tx_power_dbm); - EXPECT_EQ(-9, loaded.getRadioPrefs()->getTxPower()); -} - int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From 63bc8ed2bfba30dcb23d5e65f3d3f1766d9fabd1 Mon Sep 17 00:00:00 2001 From: Rastislav Vysoky Date: Tue, 27 Oct 2026 07:42:32 +0100 Subject: [PATCH 34/39] bump RadioLib to 7.8.1 --- platformio.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index f296f838213..92c94204ccc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -19,8 +19,7 @@ monitor_speed = 115200 lib_deps = SPI Wire - ;jgromes/RadioLib @ ^7.7.1 - https://github.com/jgromes/RadioLib.git#6d8934836678d8894e3d556550475b37dce3e2b6 + jgromes/RadioLib @ 7.8.1 rweather/Crypto @ ^0.4.0 adafruit/RTClib @ ^2.1.3 melopero/Melopero RV3028 @ ^1.1.0 From 492814bbef5cb0918c3fa6c51b49b0c847692542 Mon Sep 17 00:00:00 2001 From: akalinin-softvelum <142931037+akalinin-softvelum@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:20:36 +0000 Subject: [PATCH 35/39] T-Echo: use GxEPD2_154_D67 e-ink driver T-Echo used the fallback GxEPD2_150_BN; on my unit every refresh hit the 10 s busy timeout. Switch to GxEPD2_154_D67 like Meshtastic. --- variants/lilygo_techo/platformio.ini | 5 +++++ variants/lilygo_techo/variant.h | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index eb4a69e284e..ba39549e1f0 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -32,6 +32,11 @@ build_flags = ${nrf52_base.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 EINK_SCALE_X=1.5625f + -D EINK_SCALE_Y=1.5625f + -D EINK_X_OFFSET=0 + -D EINK_Y_OFFSET=10 -D EINK_SCREENOFF_BLANK -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 diff --git a/variants/lilygo_techo/variant.h b/variants/lilygo_techo/variant.h index 1de62d91aa8..d11aac58c3a 100644 --- a/variants/lilygo_techo/variant.h +++ b/variants/lilygo_techo/variant.h @@ -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 From cedb8050a507549a079744f88a215bfd62d20268 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 27 Oct 2026 15:41:36 -0400 Subject: [PATCH 36/39] Remove unused buttonStateChanged from some variants --- variants/meshtracker_x1/MeshTrackerX1Board.cpp | 1 - variants/meshtracker_x1/MeshTrackerX1Board.h | 14 -------------- variants/promicro/PromicroBoard.cpp | 1 - variants/promicro/PromicroBoard.h | 12 ------------ variants/t1000-e/T1000eBoard.cpp | 1 - variants/t1000-e/T1000eBoard.h | 14 -------------- variants/thinknode_m3/ThinkNodeM3Board.cpp | 1 - variants/thinknode_m3/ThinkNodeM3Board.h | 12 ------------ variants/thinknode_m4/ThinkNodeM4Board.cpp | 1 - variants/thinknode_m4/ThinkNodeM4Board.h | 12 ------------ 10 files changed, 69 deletions(-) diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.cpp b/variants/meshtracker_x1/MeshTrackerX1Board.cpp index 56e537a23e6..94c20a63c36 100644 --- a/variants/meshtracker_x1/MeshTrackerX1Board.cpp +++ b/variants/meshtracker_x1/MeshTrackerX1Board.cpp @@ -5,7 +5,6 @@ void MeshTrackerX1Board::begin() { NRF52BoardDCDC::begin(); - btn_prev_state = LOW; // button is active HIGH #ifdef BUTTON_PIN pinMode(BATTERY_PIN, INPUT); diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.h b/variants/meshtracker_x1/MeshTrackerX1Board.h index 51f022e0b85..d332244f528 100644 --- a/variants/meshtracker_x1/MeshTrackerX1Board.h +++ b/variants/meshtracker_x1/MeshTrackerX1Board.h @@ -5,9 +5,6 @@ #include class MeshTrackerX1Board : public NRF52BoardDCDC { -protected: - uint8_t btn_prev_state; - public: MeshTrackerX1Board() : NRF52Board("X1_OTA") {} void begin(); @@ -35,17 +32,6 @@ class MeshTrackerX1Board : public NRF52BoardDCDC { 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 powerOff() override { #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); diff --git a/variants/promicro/PromicroBoard.cpp b/variants/promicro/PromicroBoard.cpp index 7011521b8f2..aba808786a5 100644 --- a/variants/promicro/PromicroBoard.cpp +++ b/variants/promicro/PromicroBoard.cpp @@ -5,7 +5,6 @@ void PromicroBoard::begin() { NRF52Board::begin(); - btn_prev_state = HIGH; pinMode(PIN_VBAT_READ, INPUT); diff --git a/variants/promicro/PromicroBoard.h b/variants/promicro/PromicroBoard.h index ee16c9654b6..605b0365218 100644 --- a/variants/promicro/PromicroBoard.h +++ b/variants/promicro/PromicroBoard.h @@ -9,7 +9,6 @@ class PromicroBoard : public NRF52BoardDCDC { protected: - uint8_t btn_prev_state; float adc_mult = ADC_MULTIPLIER; public: @@ -48,15 +47,4 @@ class PromicroBoard : public NRF52BoardDCDC { 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; - } }; diff --git a/variants/t1000-e/T1000eBoard.cpp b/variants/t1000-e/T1000eBoard.cpp index 0d390f798e1..1d3ce423c22 100644 --- a/variants/t1000-e/T1000eBoard.cpp +++ b/variants/t1000-e/T1000eBoard.cpp @@ -5,7 +5,6 @@ void T1000eBoard::begin() { NRF52BoardDCDC::begin(); - btn_prev_state = HIGH; #ifdef BUTTON_PIN pinMode(BATTERY_PIN, INPUT); diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index ba8bf0c1d9d..653bea617a7 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -5,9 +5,6 @@ #include class T1000eBoard : public NRF52BoardDCDC { -protected: - uint8_t btn_prev_state; - public: T1000eBoard() : NRF52Board("T1000E_OTA") {} void begin(); @@ -38,17 +35,6 @@ class T1000eBoard : public NRF52BoardDCDC { 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 powerOff() override { #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); diff --git a/variants/thinknode_m3/ThinkNodeM3Board.cpp b/variants/thinknode_m3/ThinkNodeM3Board.cpp index ac513ade5b8..518d15bda8a 100644 --- a/variants/thinknode_m3/ThinkNodeM3Board.cpp +++ b/variants/thinknode_m3/ThinkNodeM3Board.cpp @@ -6,7 +6,6 @@ void ThinkNodeM3Board::begin() { NRF52Board::begin(); - btn_prev_state = HIGH; Wire.begin(); diff --git a/variants/thinknode_m3/ThinkNodeM3Board.h b/variants/thinknode_m3/ThinkNodeM3Board.h index 9e8f49894d2..bbd69d2643a 100644 --- a/variants/thinknode_m3/ThinkNodeM3Board.h +++ b/variants/thinknode_m3/ThinkNodeM3Board.h @@ -11,7 +11,6 @@ class ThinkNodeM3Board : public NRF52BoardDCDC { #if NRF52_POWER_MANAGEMENT void initiateShutdown(uint8_t reason) override; #endif - uint8_t btn_prev_state; public: ThinkNodeM3Board() : NRF52Board("THINKNODE_M3_OTA") {} @@ -31,17 +30,6 @@ class ThinkNodeM3Board : public NRF52BoardDCDC { 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 powerOff() override { // turn off all leds, sd_power_system_off will not do this for us digitalWrite(PIN_LED_BLUE, !LED_STATE_ON); diff --git a/variants/thinknode_m4/ThinkNodeM4Board.cpp b/variants/thinknode_m4/ThinkNodeM4Board.cpp index 4a144a3a460..281983ae310 100644 --- a/variants/thinknode_m4/ThinkNodeM4Board.cpp +++ b/variants/thinknode_m4/ThinkNodeM4Board.cpp @@ -6,7 +6,6 @@ void ThinkNodeM4Board::begin() { NRF52Board::begin(); - btn_prev_state = HIGH; Wire.begin(); battery_serial->begin(4800); diff --git a/variants/thinknode_m4/ThinkNodeM4Board.h b/variants/thinknode_m4/ThinkNodeM4Board.h index c2cae54b616..63de1ff2733 100644 --- a/variants/thinknode_m4/ThinkNodeM4Board.h +++ b/variants/thinknode_m4/ThinkNodeM4Board.h @@ -9,7 +9,6 @@ class ThinkNodeM4Board : public NRF52BoardDCDC { #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; @@ -32,17 +31,6 @@ class ThinkNodeM4Board : public NRF52BoardDCDC { 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(); From 5d7100ae7d09784106f791e9f8bd748b5f473363 Mon Sep 17 00:00:00 2001 From: Florent Date: Mon, 26 Oct 2026 15:04:08 -0400 Subject: [PATCH 37/39] ui: add some parameters to the cli (mostly regarding eink behaviour) --- docs/companion_cli.md | 472 +++++++++++++++++++++ examples/companion_radio/MyMesh.cpp | 102 +++++ examples/companion_radio/NodePrefs.h | 55 +++ examples/companion_radio/ui-new/UITask.cpp | 173 ++++---- examples/companion_radio/ui-new/UITask.h | 2 +- src/helpers/ui/DisplayDriver.h | 4 +- src/helpers/ui/GxEPDDisplay.cpp | 71 ++-- src/helpers/ui/GxEPDDisplay.h | 23 +- variants/lilygo_techo/platformio.ini | 3 +- variants/thinknode_m1/platformio.ini | 3 +- variants/thinknode_m5/platformio.ini | 3 +- variants/thinknode_m8/platformio.ini | 4 +- 12 files changed, 783 insertions(+), 132 deletions(-) create mode 100644 docs/companion_cli.md diff --git a/docs/companion_cli.md b/docs/companion_cli.md new file mode 100644 index 00000000000..31a055847b1 --- /dev/null +++ b/docs/companion_cli.md @@ -0,0 +1,472 @@ +# Companion CLI + +Since version 1.18 meshcore companion radio firmware has a CLI interface, which can be accessed locally (through companion radio protocol) or remotely via the network. + +## Navigation +- [Operational](#operational) +- [Configuration](#configuration) + - [System](#system) + - [Radio](#radio) + - [BLE](#ble) + - [Wifi](#wifi) + - [UI](#ui) + +## Operational + +### Reboot the node +**Usage:** +- `reboot` + +**Note:** No reply is sent. + +--- + +### Power-off the node +**Usage:** +- `poweroff`, or +- `shutdown` + +**Note:** No reply is sent. + +--- + +## Configuration + +### System + +#### View or change this node's name +**Usage:** +- `get name` +- `set name ` + +**Parameters:** +- `name`: Node name + +**Set by build flag:** `ADVERT_NAME` + +**Default:** Varies by board + +**Note:** Advertised names can use up to 23 bytes when location is included and 31 bytes otherwise. Emoji and Unicode characters may take more than one byte. Names that exceed the available advert space are truncated at a valid UTF-8 code point boundary. + +--- + +#### View this node's firmware version +**Usage:** `ver` + +--- + +#### Configure this node's timezone +**Usage:** +- `set tz.offset ` +- `get tz.offset` + +**Parameters:** +- `offset`: float value of the offset in hours + +--- + +### Radio + +Radio parameters are common to all roles and handled by `CommonRadioPrefs`. + +#### View or change this node's radio parameters +**Usage:** +- `get radio` +- `set radio ,,,` + +**Parameters:** +- `freq`: Frequency in MHz +- `bw`: Bandwidth in kHz +- `sf`: Spreading factor (5-12) +- `cr`: Coding rate (5-8) + +**Set by build flag:** `LORA_FREQ`, `LORA_BW`, `LORA_SF`, `LORA_CR` + +**Default:** `869.618,62.5,8,5` + +**Note:** Requires reboot to apply + +--- + +#### View or change this node's transmit power +**Usage:** +- `get tx` +- `set tx ` + +**Parameters:** +- `dbm`: Power level in dBm (1-22) + +**Set by build flag:** `LORA_TX_POWER` + +**Default:** Varies by board + +**Notes:** This setting only controls the power level of the LoRa chip. Some nodes have an additional power amplifier stage which increases the total output. Refer to the node's manual for the correct setting to use. **Setting a value too high may violate the laws in your country.** + +--- + +#### View or change this node's frequency +**Usage:** +- `get freq` +- `set freq ` + +**Parameters:** +- `frequency`: Frequency in MHz + +**Default:** `869.618` + +**Note:** Requires reboot to apply +**Serial Only:** `set freq ` + +--- + +#### View or change this node's rx boosted gain mode (SX12xx and LR1110) +**Usage:** +- `get radio.rxgain` +- `set radio.rxgain ` + +**Parameters:** + - `state`: `on`|`off` + +**Default:** `on` + +--- + +#### View or change the LoRa FEM receive-path gain state on supported boards +**Usage:** +- `get radio.fem.rxgain` +- `set radio.fem.rxgain ` + +**Parameters:** +- `state`: `on`|`off` + +**Notes:** +- This controls the external LoRa FEM receive-path LNA where the board supports it. +- This is separate from `radio.rxgain`, which controls the radio chip receive gain mode. + +--- + +#### View or change the LoRa FEM transmit-path gain state on supported boards +**Usage:** +- `get radio.fem.txgain` +- `set radio.fem.txgain ` + +**Parameters:** +- `state`: `on`|`off` + +**Notes:** +- This controls a software-selectable external LoRa FEM transmit gain where the board supports it. +- On Station G3, remove the PA PL1 jumper to allow software control. `on` selects PA PL1 high/short and `off` selects PA PL1 low/open. The PA PL2 hardware jumper determines whether this switches between power levels 1/3 or 2/4. +- Select an operating level and SX1262 transmit power that comply with local RF limits and the Station G3 power-supply requirements. +- The setting is saved immediately, but on Station G3 the level is applied to the hardware at the start of the next transmit, so that the PA supply rail is never re-targeted while the PA is being driven. `get` reports the configured state, which may lead the hardware until the node next transmits. + +--- + +#### View or change the duty cycle limit +**Usage:** +- `get dutycycle` +- `set dutycycle ` + +**Parameters:** +- `value`: Duty cycle percentage (1-100) + +**Default:** `50%` (equivalent to airtime factor 1.0) + +**Examples:** +- `set dutycycle 100` — no duty cycle limit +- `set dutycycle 50` — 50% duty cycle (default) +- `set dutycycle 10` — 10% duty cycle +- `set dutycycle 1` — 1% duty cycle (strictest EU requirement) + +> **Note:** Added in firmware v1.15.0 + +--- + +#### View or change the airtime factor (duty cycle limit) +> **Deprecated** as of firmware v1.15.0. Use [`get/set dutycycle`](#view-or-change-the-duty-cycle-limit) instead. + +**Usage:** +- `get af` +- `set af ` + +**Parameters:** +- `value`: Airtime factor (0-9). After each transmission, the repeater enforces a silent period of approximately the on-air transmission time multiplied by the value. This results in a long-term duty cycle of roughly 1 divided by (1 plus the value). For example: + - `af = 1` → ~50% duty + - `af = 2` → ~33% duty + - `af = 3` → ~25% duty + - `af = 9` → ~10% duty + You are responsible for choosing a value that is appropriate for your jurisdiction and channel plan (for example EU 868 Mhz 10% duty cycle regulation). + +**Default:** `1.0` + +--- + +#### View or change the local interference threshold +**Usage:** +- `get int.thresh` +- `set int.thresh ` + +**Parameters:** +- `value`: Interference threshold value + +**Default:** `0.0` + +--- + +#### Enable or disable hardware Channel Activity Detection (CAD) +**Usage:** +- `get cad` +- `set cad ` + +**Description:** When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of `int.thresh` — either, both, or none may be active. + +**Parameters:** +- `on|off`: Enable or disable hardware CAD + +**Default:** `off` + +--- + +#### View or change the AGC Reset Interval +**Usage:** +- `get agc.reset.interval` +- `set agc.reset.interval ` + +**Parameters:** +- `value`: Interval in seconds, 0-1020, rounded down to a multiple of 4 (17 becomes 16). 0 to disable. + +**Default:** `0.0` + +--- + +#### Enable or disable Multi-Acks support +**Usage:** +- `get multi.acks` +- `set multi.acks ` + +**Parameters:** +- `state`: `0` (disable) or `1` (enable) + +**Default:** `0` + +--- + +#### View or change this node's advert path hash size +**Usage:** +- `get path.hash.mode` +- `set path.hash.mode ` + +**Parameters:** +- `value`: Path hash size (0-2) + - `0`: 1 Byte hash size (256 unique ids)[64 max flood] + - `1`: 2 Byte hash size (65,536 unique ids)[32 max flood] + - `2`: 3 Byte hash size (16,777,216 unique ids)[21 max flood] + - `3`: DO NOT USE (Reserved) + +**Default:** `0` + +**Note:** the 'path.hash.mode' sets the low-level ID/hash encoding size used when the repeater adverts. This setting has no impact on what packet ID/hash size this repeater forwards, all sizes should be forwarded on firmware >= 1.14. This feature was added in firmware 1.14 +#### View or change the retransmit delay factor for flood traffic +**Usage:** +- `get txdelay` +- `set txdelay ` + +**Parameters:** +- `value`: Transmit delay factor (0-2) + +**Default:** `0.5` + +**Note:** When multiple nearby repeaters all hear the same flood packet, each waits a random amount of time before retransmitting to avoid simultaneous collisions. This factor scales the size of that random window. Higher values reduce collision risk at the cost of added latency. `0` disables the window entirely. + +--- + +#### View or change the retransmit delay factor for direct traffic +**Usage:** +- `get direct.txdelay` +- `set direct.txdelay ` + +**Parameters:** +- `value`: Direct transmit delay factor (0-2) + +**Default:** `0.3` (Repeater) - `0.2` (Room Server, Sensor) + +**Note:** Same collision-avoidance random window as `txdelay`, but applied to direct (non-flood, routed) traffic. The default is lower because direct packets are addressed to a specific next hop, so far fewer nodes compete to retransmit them. + +--- + +#### [Experimental] View or change the processing delay for received traffic +**Usage:** +- `get rxdelay` +- `set rxdelay ` + +**Parameters:** +- `value`: Receive delay base (0-20) + +**Default:** `0.0` + +**Note:** When enabled, repeaters that received a flood packet with a weak signal are held in a delay queue before processing, while those that received it with a strong signal process it immediately. This gives strong-signal paths forwarding priority. By the time weak-signal nodes process their copy, the packet may have already propagated and will be suppressed as a duplicate, reducing redundant retransmissions. + +--- + +### BLE + +#### Change BLE PIN +**Usage:** +- `set pin ` + +**Parameters:** +- `pin`: New BLE PIN + +**Set by build flag:** `BLE_PIN_CODE` + +--- + +### Wifi + +#### Get status +**Usage:** +- `get wifi.status` +- `get wifi.ip` + +--- + +#### Toggle +**Usage:** +- `set wifi.enabled ` +- `get wifi.enabled` + +**Parameters:** +- `value`: `0` (disable) or `1` (enable) + +--- + +#### Set SSID +**Usage:** +- `set wifi.ssid ` +- `get wifi.ssid` + +**Parameters:** +- `value`: ssid as a string + +--- + +#### Set password +**Usage:** +- `set wifi.pwd ` +- `get wifi.pwd` + +**Parameters:** +- `value`: password string + +--- + +### UI + +#### Show clock +**Usage:** +- `set ui.show_clock ` +- `get ui.show_clock` + +**Parameters:** +- `state`: `0` (disable) or `1` (enable) + +**Default:** `0` (or `UI_SHOW_CLOCK` build flag) + +--- + +#### Node discovery screen shows nodes names +**Usage:** +- `set ui.nd_name ` +- `get ui.nd_name` + +**Parameters:** +- `state`: `1` (show name) or `0` (show pubkey_prefix) + +**Default:** `1` + +**Note:** When showing name, there is not always enough space to draw both incoming and outgoing snr values. + +--- + +#### Auto-off screen +**Usage:** +- `set ui.auto_off_delay ` +- `get ui.auto_off_delay` + +**Parameters:** +- `delay`: in seconds (`0` means always on) + +**Default:** `15` (or set by `AUTO_OFF_MILLIS` build flag (in ms)) + +**Note:** +On e-ink displays, when turned off, the screen is not necessarily blanked, see `ui.auto_off_blank` and `ui.backlight_delay`. + +When screen is not blanked, it is not refreshed any more. To let the user aware of that, a sleep icon is shown. + +--- + +#### Display backlight timeout +**Usage:** +- `set ui.backlight_delay ` +- `get ui.backlight_delay` + +**Parameters:** +- `delay`: in seconds + - `0` means no timeout + - `-2` means always off + - `-1` means always on + +**Note:** +When set to `0`, backlight can only be triggered using backlight btn. + +--- + +#### When to turn backlight on +**Usage:** +- `set ui.backlight_on_keys ` +- `get ui.backlight_on_keys` +- `set ui.backlight_on_msgs ` +- `get ui.backlight_on_msgs` + +**Parameters:** +- `state`: `0` (disable) or `1` (enable) + +**Default:** +- `ui.backlight_on_msgs = 0` +- `ui.backlight_on_keys = 0` if there is a backlight button on the board, else `1` + +**Note:** +By default backlight is triggered by backlight button if present (as long as the button is pressed backlight is on, and is turned off after a delay). +These toggles permits to toggle backlight when keys are pressed or when a message arrives (it acts as a notification signal). +If `backlight_delay` is set to `0` then this setting has no effect, backlight won't be turned on. + +--- + +#### Control if eink screen should be blanked +**Usage:** +- `set ui.auto_off_blank ` +- `get ui.auto_off_blank` + +**Parameters:** +- `state`: `0` (disable) or `1` (enable) + +**Default:** `0` (or set by `UI_SCREENOFF_BLANK` build flag) + +**Note:** +If disabled, when screen is shut the displays stays on screen and is not updated (an sleep icons covers battery to show the screen is sleeping). + +When screen is blanked, the controller is also put into hibernation, which should preserve the screen a little and some battery ... + +--- + +#### Maximum number of partial refreshes +**Usage:** +- `set ui.max_partial ` +- `get ui.max_partial` + +**Parmeters:** +- `value`: max partial refreshes before doing a full refresh + +**Default:** 0 (no partial refresh or set by `EINK_MAX_PARTIAL_REFRESH` build flag) + +**Note:** +Forcing full refresh helps revives the screen when some tearing appears (under certain environmental conditions). If you see some tearing appearing, you can force full refresh on your screen manually by double clicking the backlight button. If tearing reappears often, use this setting to force full refresh every `value` partial refreshes. \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6112b2dbb17..8a8f7f1d3eb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2209,6 +2209,108 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } return true; } +#ifdef DISPLAY_CLASS + if (memcmp(command, "set ui.show_clock", 17) == 0) { + _prefs.ui_show_clock = atoi(&command[17]) ? 1 : 0; + savePrefs(); + sprintf(reply, "> now %d", _prefs.ui_show_clock); + return true; + } + if (memcmp(command, "set ui.auto_off_delay", 21) == 0) { + _prefs.ui_auto_off_delay = atoi(&command[21]); + savePrefs(); + sprintf(reply, "> now %d seconds", _prefs.ui_auto_off_delay); + return true; + } + if (memcmp(command, "set ui.backlight_delay", 22) == 0) { + _prefs.ui_backlight_delay = atoi(&command[22]); + savePrefs(); + if (_prefs.ui_backlight_delay >= 0) + sprintf(reply, "> now %d seconds", _prefs.ui_backlight_delay); + else if (_prefs.ui_backlight_delay == -1) + sprintf(reply, "> now always on (-1)"); + else + sprintf(reply, "> now disabled (<-1)"); + return true; + } + if (memcmp(command, "set ui.backlight_on_keys", 24) == 0) { + _prefs.ui_backlight_on_keys = atoi(&command[24]); + savePrefs(); + sprintf(reply, "> now %d", _prefs.ui_backlight_on_keys); + return true; + } + if (memcmp(command, "set ui.backlight_on_msgs", 24) == 0) { + _prefs.ui_backlight_on_msgs = atoi(&command[24]); + savePrefs(); + sprintf(reply, "> now %d", _prefs.ui_backlight_on_msgs); + return true; + } + if (memcmp(command, "set ui.auto_off_blank", 21) == 0) { + _prefs.ui_auto_off_blank = atoi(&command[21]); + savePrefs(); + sprintf(reply, "> now %d", _prefs.ui_auto_off_blank); + return true; + } + if (memcmp(command, "set ui.auto_off_keep_msgs", 25) == 0) { + _prefs.ui_auto_off_keep_msgs = atoi(&command[25]); + savePrefs(); + sprintf(reply, "> now %d", _prefs.ui_auto_off_keep_msgs); + return true; + } + if (memcmp(command, "set ui.max_partial", 18) == 0) { + _prefs.ui_max_partial = atoi(&command[18]); + savePrefs(); + sprintf(reply, "> now %d (applied at next screen wakeup)", _prefs.ui_max_partial); + return true; + } + if (memcmp(command, "set ui.nd_name", 14) == 0) { + _prefs.ui_nd_name = atoi(&command[14]); + savePrefs(); + sprintf(reply, "> ui.nd_name is now %d", _prefs.ui_nd_name); + return true; + } + if (memcmp(command, "get ui.show_clock", 17) == 0) { + sprintf(reply, "> %d", _prefs.ui_show_clock); + return true; + } + if (memcmp(command, "get ui.auto_off_delay", 21) == 0) { + sprintf(reply, "> %d seconds", _prefs.ui_auto_off_delay); + return true; + } + if (memcmp(command, "get ui.backlight_delay", 22) == 0) { + if (_prefs.ui_backlight_delay >= 0) + sprintf(reply, "> %d seconds", _prefs.ui_backlight_delay); + else if (_prefs.ui_backlight_delay == -1) + sprintf(reply, "> always on (-1)"); + else + sprintf(reply, "> disabled (<-1)"); + return true; + } + if (memcmp(command, "get ui.backlight_on_keys", 24) == 0) { + sprintf(reply, "> %d", _prefs.ui_backlight_on_keys); + return true; + } + if (memcmp(command, "get ui.backlight_on_msgs", 24) == 0) { + sprintf(reply, "> %d", _prefs.ui_backlight_on_msgs); + return true; + } + if (memcmp(command, "get ui.auto_off_blank", 21) == 0) { + sprintf(reply, "> %d", _prefs.ui_auto_off_blank); + return true; + } + if (memcmp(command, "get ui.auto_off_keep_msgs", 25) == 0) { + sprintf(reply, "> %d", _prefs.ui_auto_off_keep_msgs); + return true; + } + if (memcmp(command, "get ui.max_partial", 18) == 0) { + sprintf(reply, "> %d", _prefs.ui_max_partial); + return true; + } + if (memcmp(command, "get ui.nd_name", 14) == 0) { + sprintf(reply, "> %d", _prefs.ui_nd_name); + return true; + } +#endif return false; // not handled } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 1cc5b501826..c5d1df04a42 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -58,6 +58,34 @@ class NodePrefs : public ConfigSerializer { // persisted to file // use ssid from prefs, or fallback to ssid from build flags const char* getWifiSSID() const { return wifi_ssid[0] ? wifi_ssid : WIFI_SSID; } #endif +#ifdef DISPLAY_CLASS + #ifndef UI_SHOW_CLOCK + #define UI_SHOW_CLOCK 0 + #endif + #ifndef AUTO_OFF_MILLIS + #define AUTO_OFF_MILLIS 15000 + #endif + #ifndef EINK_MAX_PARTIAL_REFRESH + #define EINK_MAX_PARTIAL_REFRESH 0 + #endif + uint8_t ui_show_clock = UI_SHOW_CLOCK; + uint32_t ui_auto_off_delay = AUTO_OFF_MILLIS / 1000; + int32_t ui_backlight_delay = 5; + #if defined(BACKLIGHT_BTN) + uint8_t ui_backlight_on_keys = 0; + #else + uint8_t ui_backlight_on_keys = 1; + #endif + uint8_t ui_backlight_on_msgs = 0; +#ifdef UI_SCREENOFF_BLANK + uint8_t ui_auto_off_blank = 1; +#else + uint8_t ui_auto_off_blank = 0; +#endif + uint8_t ui_auto_off_keep_msgs = 1; + uint8_t ui_max_partial = EINK_MAX_PARTIAL_REFRESH; + uint8_t ui_nd_name = 1; +#endif private: class RadioPrefs : public CommonRadioPrefs { @@ -189,6 +217,27 @@ class NodePrefs : public ConfigSerializer { // persisted to file WiFiPrefs wifi; #endif +#ifdef DISPLAY_CLASS + class UI_Prefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("show_clock", _parent->ui_show_clock); + def("auto_off", _parent->ui_auto_off_delay); + def("bl_dly", _parent->ui_backlight_delay); + def("bl_keys", _parent->ui_backlight_on_keys); + def("bl_msgs", _parent->ui_backlight_on_msgs); + def("max_partial", _parent->ui_max_partial); + def("off_blank", _parent->ui_auto_off_blank); + def("off_keep_msgs", _parent->ui_auto_off_keep_msgs); + def("nd_name", _parent->ui_nd_name); + } + public: + UI_Prefs(NodePrefs* parent) : _parent(parent) {} + }; + UI_Prefs ui; +#endif + protected: void structure() override { def("name", node_name, sizeof(node_name)); @@ -203,12 +252,18 @@ class NodePrefs : public ConfigSerializer { // persisted to file def("custom", custom); #ifdef ENABLE_WIFI_INTERFACE def("wifi", wifi); +#endif +#ifdef DISPLAY_CLASS + def("ui", ui); #endif } public: NodePrefs() : radio(this), gps(this), companion(this), custom(&radio) #ifdef ENABLE_WIFI_INTERFACE , wifi(this) +#endif +#ifdef DISPLAY_CLASS + , ui(this) #endif { node_name[0] = 0; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index aced739dc45..89008d7a4fd 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -122,7 +122,6 @@ class HomeScreen : public UIScreen { uint32_t disc_node_req_tag = 0; uint32_t disc_nodes_count = 0; uint32_t discovery_req_time = 0; - bool discovery_disp_names = true; // by default desplay names if available (removes SNR_O) #endif void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { @@ -293,17 +292,17 @@ class HomeScreen : public UIScreen { sprintf(tmp, "MSG: %d", _task->getMsgCount()); display.drawTextCentered(display.width() / 2, 22, tmp); #endif - #ifdef UI_SHOW_CLOCK - display.setTextSize(3); - uint32_t now = _rtc->getCurrentTime(); - now += (int32_t)(_node_prefs->tz_offset * 3600); - DateTime dt (now); - sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute()); - display.drawTextCentered(display.width() / 2, 60, tmp); - display.setTextSize(1); - sprintf(tmp, "%02d/%02d/%d", dt.day(), dt.month(), dt.year()); - display.drawTextCentered(display.width() / 2, 80, tmp); - #endif + if (_node_prefs->ui_show_clock) { + display.setTextSize(3); + uint32_t now = _rtc->getCurrentTime(); + now += (int32_t)(_node_prefs->tz_offset * 3600); + DateTime dt (now); + sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute()); + display.drawTextCentered(display.width() / 2, 60, tmp); + display.setTextSize(1); + sprintf(tmp, "%02d/%02d/%d", dt.day(), dt.month(), dt.year()); + display.drawTextCentered(display.width() / 2, 80, tmp); + } #ifdef ENABLE_WIFI_INTERFACE IPAddress ip = WiFi.localIP(); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); @@ -313,21 +312,20 @@ class HomeScreen : public UIScreen { if (_task->hasConnection()) { display.setColor(UIColor::warning_txt); display.setTextSize(1); - #ifdef UI_SHOW_CLOCK - display.drawTextCentered(display.width() / 2, 110, "< Connected >"); - #else - display.drawTextCentered(display.width() / 2, 43, "< Connected >"); - #endif + if (_node_prefs->ui_show_clock) + display.drawTextCentered(display.width() / 2, 110, "< Connected >"); + else + display.drawTextCentered(display.width() / 2, 43, "< Connected >"); } else if (the_mesh.getBLEPin() != 0) { // BT pin display.setColor(UIColor::warning_txt); sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); - #ifdef UI_SHOW_CLOCK - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 110, tmp); - #else - display.setTextSize(2); - display.drawTextCentered(display.width() / 2, 43, tmp); - #endif + if (_node_prefs->ui_show_clock) { + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 110, tmp); + } else { + display.setTextSize(2); + display.drawTextCentered(display.width() / 2, 43, tmp); + } } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); @@ -518,7 +516,7 @@ class HomeScreen : public UIScreen { for (int i = 0; i < count; i++, y += 11) { char name[32]; auto a = &discovered[i]; - if ((a->name[0] == 0) || !discovery_disp_names) { + if ((a->name[0] == 0) || !_node_prefs->ui_nd_name) { mesh::Utils::toHex(name, a->pubkey_prefix, 4); } else { strncpy(name, a->name, 32); @@ -618,7 +616,7 @@ class HomeScreen : public UIScreen { return true; } if (c == KEY_SELECT && _page == HomePage::DISCOVERY) { - discovery_disp_names = !discovery_disp_names; + _node_prefs->ui_nd_name = !_node_prefs->ui_nd_name; return true; } #endif @@ -726,7 +724,6 @@ class MsgPreviewScreen : public UIScreen { void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; - _auto_off = millis() + AUTO_OFF_MILLIS; #if defined(PIN_USER_BTN) user_btn.begin(); @@ -739,9 +736,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #endif _node_prefs = node_prefs; + _auto_off = millis() + _node_prefs->ui_auto_off_delay * 1000; + _backlight_off = millis() + _node_prefs->ui_backlight_delay * 1000; if (_display != NULL) { - _display->turnOn(); + _display->turnOn(_node_prefs->ui_max_partial); } #ifdef PIN_BUZZER @@ -807,11 +806,14 @@ void UITask::onMessageRecv(mesh::Packet *pkt, const ContactInfo &from, uint8_t t if (_display != NULL) { if (!_display->isOn() && !hasConnection()) { - _display->turnOn(); + _display->turnOn(_node_prefs->ui_max_partial); } if (_display->isOn()) { - _auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer - _next_refresh = 100; // trigger refresh + _auto_off = millis() + _node_prefs->ui_auto_off_delay * 1000; // extend the auto-off timer + _next_refresh = 100; // trigger refresh + if (_node_prefs->ui_backlight_on_msgs) { + _backlight_off = millis() + _node_prefs->ui_backlight_delay * 1000; + } } } @@ -827,10 +829,10 @@ void UITask::onChannelMessageRecv(mesh::Packet *pkt, ChannelDetails& channel_det if (_display != NULL) { if (!_display->isOn() && !hasConnection()) { - _display->turnOn(); + _display->turnOn(_node_prefs->ui_max_partial); } if (_display->isOn()) { - _auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer + _auto_off = millis() + _node_prefs->ui_auto_off_delay * 1000; // extend the auto-off timer _next_refresh = 100; // trigger refresh } } @@ -909,9 +911,7 @@ void UITask::shutdown(bool restart){ _board->reboot(); } else { if (_display->isEink()) { - _display->blankScreen(); - _display->turnOff(); - _display->powerOff(); + _display->turnOff(true); } // Power off board including radio, display, GPS and components _board->powerOff(); @@ -957,9 +957,7 @@ void UITask::loop() { if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_ENTER); } else if (ev == BUTTON_EVENT_LONG_PRESS) { - display.blankScreen(); - display.turnOff(); - display.powerOff(); + display.turnOff(true); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { c = handleDoubleClick(KEY_SELECT); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { @@ -979,11 +977,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 @@ -1002,39 +1000,52 @@ void UITask::loop() { _analogue_pin_read_millis = millis(); } #endif +#if defined(DISP_BACKLIGHT) || defined(EXP_PIN_BACKLIGHT) #if defined(BACKLIGHT_BTN) if (millis() > next_backlight_btn_check) { if (backlight_btn.isPressed()) { if (!_display->isOn()) { - _display->turnOn(); + _display->turnOn(_node_prefs->ui_max_partial); _next_refresh = 0; } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer - backlight_btn_pressed_at = millis(); // still pressed - } - bool touch_state = (millis() - backlight_btn_pressed_at > 5000); - // Backlight button events - int bev = backlight_btn.check(); - if (bev == BUTTON_EVENT_DOUBLE_CLICK) { - _display->forceFullRefresh(); - } else if (bev == BUTTON_EVENT_TRIPLE_CLICK) { - _display->blankScreen(); - _display->turnOff(); - _display->powerOff(); - backlight_btn_pressed_at = 0; + _auto_off = millis() + _node_prefs->ui_auto_off_delay * 1000; // extend auto-off timer + _backlight_off = millis() + _node_prefs->ui_backlight_delay * 1000; // extend auto_off for backlight } + next_backlight_btn_check = millis() + 100; + } + int bev = backlight_btn.check(); + if (bev == BUTTON_EVENT_DOUBLE_CLICK) { + _display->forceFullRefresh(); + } else if (bev == BUTTON_EVENT_TRIPLE_CLICK) { + _display->turnOff(true); + _backlight_off = 0; + } +#endif + + bool backlight_state; + if (_node_prefs->ui_backlight_delay == -1) { + backlight_state = true; + } else if (_node_prefs->ui_backlight_delay < -1) { + backlight_state = false; + } else if (_node_prefs->ui_backlight_delay == 0) { + backlight_state = false; + #ifdef BACKLIGHT_BTN + backlight_state = backlight_btn.isPressed(); + #endif + } else { + backlight_state = (millis() < _backlight_off); + } + #if defined(DISP_BACKLIGHT) - digitalWrite(DISP_BACKLIGHT, !touch_state); + digitalWrite(DISP_BACKLIGHT, backlight_state); #elif defined(EXP_PIN_BACKLIGHT) - expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state); + expander.digitalWrite(EXP_PIN_BACKLIGHT, backlight_state); #endif - next_backlight_btn_check = millis() + 100; - } #endif if (c != 0 && curr) { curr->handleInput(c); - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _auto_off = millis() + _node_prefs->ui_auto_off_delay * 1000; // extend auto-off timer _next_refresh = 100; // trigger refresh } @@ -1065,26 +1076,27 @@ void UITask::loop() { } _display->endFrame(); } -#if AUTO_OFF_MILLIS > 0 + if (_node_prefs->ui_auto_off_delay > 0) { #ifdef KEEP_DISPLAY_ON_USB - // Opt-in: refresh the auto-off deadline while externally powered, so the - // timer counts from the moment external power is removed. Off by default - // because OLED panels burn in quickly; only enable for LCD targets or - // where the display is replaceable. - if (board.isExternalPowered()) { - _auto_off = millis() + AUTO_OFF_MILLIS; - } + // Opt-in: refresh the auto-off deadline while externally powered, so the + // timer counts from the moment external power is removed. Off by default + // because OLED panels burn in quickly; only enable for LCD targets or + // where the display is replaceable. + if (board.isExternalPowered()) { + _auto_off = millis() + _node_prefs->ui_auto_off_delay * 1000; + } #endif - if (millis() > _auto_off) { - #ifdef EINK_SCREENOFF_BLANK - if (curr != msg_preview) { // don't blank if messages are waiting - _display->blankScreen(); - _display->powerOff(); + if (millis() > _auto_off) { + if (_node_prefs->ui_auto_off_blank && + !(_node_prefs->ui_auto_off_keep_msgs + && curr == msg_preview)) { + // don't blank if messages are waiting + _display->turnOff(true); + } else { + _display->turnOff(); } - #endif - _display->turnOff(); + } } -#endif } #ifdef PIN_VIBRATION @@ -1116,10 +1128,13 @@ void UITask::loop() { char UITask::checkDisplayOn(char c) { if (_display != NULL) { if (!_display->isOn()) { - _display->turnOn(); // turn display on and consume event + _display->turnOn(_node_prefs->ui_max_partial); // turn display on and consume event c = 0; } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _auto_off = millis() + _node_prefs->ui_auto_off_delay * 1000; // extend auto-off timer + if (_node_prefs->ui_backlight_on_keys) { + _backlight_off = millis() + _node_prefs->ui_backlight_delay * 1000; + } _next_refresh = 0; // trigger refresh } return c; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 4d44bfe1a6e..618314885d4 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -62,7 +62,7 @@ class UITask : public AbstractUITask { int _msgcount; unsigned long ui_started_at, next_batt_chck; unsigned long next_backlight_btn_check = 0; - unsigned long backlight_btn_pressed_at = 0; + unsigned long _backlight_off = 0; #ifdef PIN_STATUS_LED int led_state = 0; int next_led_change = 0; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 58147aa0e75..0e41dab5e4d 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -25,9 +25,9 @@ class DisplayDriver { virtual bool isEink() { return false; } // default to non-eink, override in eink drivers virtual void forceFullRefresh() {} // next refresh will be full for eink virtual void turnOn() = 0; + virtual void turnOn(int max_partial) { turnOn(); } virtual void turnOff() = 0; - virtual void blankScreen() {} // to cleanly erase screen before turning off - virtual void powerOff() {} + virtual void turnOff(bool blank) { turnOff(); } virtual void clear() = 0; virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0; virtual void setTextSize(int sz) = 0; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index ce09423e11a..d7f9562bb3a 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -1,11 +1,6 @@ #include "GxEPDDisplay.h" -#ifdef EXP_PIN_BACKLIGHT - #include - extern PCA9557 expander; -#endif - #ifndef DISPLAY_ROTATION #define DISPLAY_ROTATION 3 #endif @@ -52,7 +47,7 @@ bool GxEPDDisplay::begin() { } display.setPartialWindow(0, 0, display.width(), display.height()); - resetPartialRefreshCounter(); + _partial_refresh_count = 0; #if DISP_BACKLIGHT digitalWrite(DISP_BACKLIGHT, LOW); @@ -65,26 +60,43 @@ bool GxEPDDisplay::begin() { void GxEPDDisplay::turnOn() { if (!_init) begin(); -#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) - digitalWrite(DISP_BACKLIGHT, HIGH); -#elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN) - expander.digitalWrite(EXP_PIN_BACKLIGHT, HIGH); -#endif if (!_isOn) { _isOn = true; } } -void GxEPDDisplay::powerOff() { - display.hibernate(); +void GxEPDDisplay::turnOn(int max_partial) { + setMaxPartialRefresh(max_partial); + turnOn(); } -void GxEPDDisplay::turnOff() { // Only stops refresh -#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) - digitalWrite(DISP_BACKLIGHT, LOW); -#elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN) - expander.digitalWrite(EXP_PIN_BACKLIGHT, LOW); -#endif +// 24x24 sleep icon, MSB-first (matches GxEPDDisplay::drawXbm) +static const uint8_t sleep_icon_24x24[] = { + 0x00, 0x00, 0xff, 0x00, 0x00, 0xff, 0x00, 0xe0, 0x0e, 0x03, 0xc0, 0x18, + 0x0f, 0x80, 0x70, 0x1f, 0x80, 0xff, 0x3f, 0x80, 0xff, 0x3f, 0x00, 0x00, + 0x7f, 0x3f, 0x00, 0x7f, 0x3f, 0x00, 0xff, 0x0e, 0x00, 0xff, 0x1c, 0x7c, + 0xff, 0xbf, 0x7c, 0xff, 0xbf, 0x38, 0xff, 0xc0, 0x7c, 0xff, 0xe0, 0x7c, + 0xff, 0xf0, 0x00, 0x7f, 0xfc, 0x00, 0x7f, 0xff, 0xf8, 0x3f, 0xff, 0xf0, + 0x1f, 0xff, 0xf0, 0x0f, 0xff, 0xe0, 0x07, 0xff, 0x80, 0x01, 0xff, 0x00 +}; + +void GxEPDDisplay::drawSleepIcon() { + const int W = 24, H = 24; + int x = (int)(display.width() / scale_x) - W; // top-right, logical coordinates + int y = 0; + + display.fillRect((x - 5) * scale_x - 1, y * scale_y, (W+5) * scale_x + 2, (H-2) * scale_y + 1, GxEPD_WHITE); + _curr_color = GxEPD_BLACK; + drawXbm(x, y, sleep_icon_24x24, W, H); + display.display(true); +} + +void GxEPDDisplay::turnOff(bool blank) { + if (blank) { + blankScreen(); + } else { + drawSleepIcon(); + } _isOn = false; last_display_crc_value=0; } @@ -94,8 +106,10 @@ void GxEPDDisplay::blankScreen() { // no full refresh needed at wakeup display.clearScreen(0xFF); // Clears microcontroller side RAM display.writeScreenBuffer(0xFF); // Forces 0xFF (White) into the display controller's history registers - resetPartialRefreshCounter(); - last_display_crc_value=0; + _partial_refresh_count = 0; + last_display_crc_value = 0; + // we can hibernate as screen has been cleaned + display.hibernate(); } void GxEPDDisplay::clear() { @@ -108,15 +122,18 @@ void GxEPDDisplay::startFrame(ColorVal bkg) { display.fillScreen(bkg); display.setTextColor(_curr_color = UIColor::primary_txt); display_crc.reset(); - if (_cycles_before_full_refresh != 0) { - display.setPartialWindow(0, 0, display.width(), display.height()); - } else { + if (_force_full_refresh + || (_max_partial_refresh > 0 + && _partial_refresh_count >= _max_partial_refresh)) { // forces a full wipe of the screen ... display.clearScreen(0xFF); // Clears microcontroller side RAM display.writeScreenBuffer(0xFF); // Forces 0xFF (White) into the display controller's history registers // we'll need a partial refresh after that (whatever crc value is) last_display_crc_value = 0; - resetPartialRefreshCounter(); + _partial_refresh_count = 0; + _force_full_refresh = false; + } else { + display.setPartialWindow(0, 0, display.width(), display.height()); } } @@ -223,9 +240,7 @@ void GxEPDDisplay::endFrame() { uint32_t crc = display_crc.finalize(); if (crc != last_display_crc_value) { display.display(true); - if (_cycles_before_full_refresh > 0) { - _cycles_before_full_refresh--; - } + _partial_refresh_count++; } last_display_crc_value = crc; } diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 462744026bd..9d91844303a 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -16,12 +16,6 @@ #include "DisplayDriver.h" -#ifndef EINK_MAX_PARTIAL_REFRESH - // 60 to prevent ghosting when refreshing every minute ... - // set to -1 to disable the counter - #define EINK_MAX_PARTIAL_REFRESH -1 -#endif - class GxEPDDisplay : public DisplayDriver { #if defined(EINK_DISPLAY_MODEL) @@ -42,8 +36,9 @@ class GxEPDDisplay : public DisplayDriver { uint16_t _curr_color; CRC32 display_crc; int last_display_crc_value = 0; - int _cycles_before_full_refresh; - int max_partial_refresh = EINK_MAX_PARTIAL_REFRESH; // so this can be changed by an option after + bool _force_full_refresh = false; + int _partial_refresh_count = 0; + int _max_partial_refresh = 0; public: #if defined(EINK_DISPLAY_MODEL) @@ -56,12 +51,13 @@ class GxEPDDisplay : public DisplayDriver { bool isOn() override { return _isOn; } bool isEink() override { return true; } - void forceFullRefresh() override { _cycles_before_full_refresh = 0; }; - void resetPartialRefreshCounter() { _cycles_before_full_refresh = max_partial_refresh; }; + void forceFullRefresh() override { _force_full_refresh = true; } + void turnOn(int max_partial) override; void turnOn() override; - void turnOff() override; - void blankScreen() override; - void powerOff() override; + void turnOff(bool blank) override; + void turnOff() override { turnOff(false); } + virtual void blankScreen(); + virtual void setMaxPartialRefresh(int count) {_max_partial_refresh = count; } void clear() override; void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; @@ -71,6 +67,7 @@ class GxEPDDisplay : public DisplayDriver { void fillRect(int x, int y, int w, int h) override; void drawRect(int x, int y, int w, int h) override; void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; + void drawSleepIcon(); uint16_t getTextWidth(const char* str) override; void endFrame() override; }; diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index ba39549e1f0..896e42a656f 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -40,8 +40,7 @@ build_flags = ${nrf52_base.build_flags} -D EINK_SCREENOFF_BLANK -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 - -D EINK_MAX_PARTIAL_REFRESH=60 - -D AUTO_OFF_MILLIS=900000 + -D AUTO_OFF_MILLIS=300000 -D BACKLIGHT_BTN=PIN_BUTTON2 build_src_filter = ${nrf52_base.build_src_filter} + diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini index 7c620e2ed6d..03b0eb4efff 100644 --- a/variants/thinknode_m1/platformio.ini +++ b/variants/thinknode_m1/platformio.ini @@ -26,14 +26,13 @@ build_flags = ${nrf52_base.build_flags} -D P_LORA_TX_LED=13 -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 - -D EINK_MAX_PARTIAL_REFRESH=60 -D EINK_DISPLAY_MODEL=GxEPD2_154_D67 -D EINK_SCALE_X=1.5625f -D EINK_SCALE_Y=1.5625f -D EINK_X_OFFSET=0 -D EINK_Y_OFFSET=10 -D BACKLIGHT_BTN=PIN_BUTTON2 - -D AUTO_OFF_MILLIS=900000 + -D AUTO_OFF_MILLIS=300000 ; 5 minutes -D UI_NO_HIBERNATE -D DISABLE_DIAGNOSTIC_OUTPUT build_src_filter = ${nrf52_base.build_src_filter} diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini index a9761597d8e..d2b791a327e 100644 --- a/variants/thinknode_m5/platformio.ini +++ b/variants/thinknode_m5/platformio.ini @@ -28,9 +28,8 @@ build_flags = ${esp32_base.build_flags} -D EINK_Y_OFFSET=10 -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 - -D EINK_MAX_PARTIAL_REFRESH=60 -D BACKLIGHT_BTN=PIN_BUTTON2 - -D AUTO_OFF_MILLIS=900000 + -D AUTO_OFF_MILLIS=300000 -D UI_NO_HIBERNATE -D DISABLE_DIAGNOSTIC_OUTPUT -D SX126X_DIO2_AS_RF_SWITCH=true diff --git a/variants/thinknode_m8/platformio.ini b/variants/thinknode_m8/platformio.ini index 5282f280f6a..fdbaea1f587 100644 --- a/variants/thinknode_m8/platformio.ini +++ b/variants/thinknode_m8/platformio.ini @@ -23,11 +23,9 @@ build_flags = ${nrf52_base.build_flags} -D UI_HAS_NAV_INPUT=1 -D UI_RECENT_LIST_SIZE=9 -D UI_MSG_PREVIEW_SIZE=165 - -D EINK_MAX_PARTIAL_REFRESH=60 - -D AUTO_OFF_MILLIS=900000 + -D AUTO_OFF_MILLIS=300000 -D BACKLIGHT_BTN=PIN_BUTTON2 -D UI_SHOW_CLOCK=1 - -D EINK_SCREENOFF_BLANK -D EINK_DISPLAY_MODEL=GxEPD2_154_D67 ; -D EINK_DISPLAY_MODEL=GxEPD2_154_GDEY0154D67 ; -D EINK_DISPLAY_MODEL=GxEPD2_150_BN From 017db61cae10ddfacf12eacdc2e2e3b3c533adbc Mon Sep 17 00:00:00 2001 From: Olexandr Tyshchenko Date: Thu, 29 Oct 2026 23:49:49 +0200 Subject: [PATCH 38/39] Fix possible reply buffer overflow in 'sensor list' 'sensor list' appended 'name=value' lines into the 160-byte CLI reply with unbounded sprintf, only checking the position before each write (< 134). A single long value (or several medium ones) could write past the end of the buffer. Write each entry with snprintf against the remaining space, keep room for the '... next:N' paging hint, and move entries that do not fit to the next page. Also guard against NULL names/values from a sensor manager. --- src/helpers/CommonCLI.cpp | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 04199e24d1d..5d7501d9985 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -326,18 +326,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 } } From 92e5194d1bb8c13223601d9d0bf023136ebfd192 Mon Sep 17 00:00:00 2001 From: Olexandr Tyshchenko Date: Sun, 1 Nov 2026 17:02:22 +0200 Subject: [PATCH 39/39] Dispatcher: skip the AGC reset while packets are being received A reset takes the radio off the air. As in Meshtastic (develop), skip the periodic reset if a packet was decoded within the last reset interval or a packet is queued for transmit; the gain is not stuck then. Still reset at least once per AGC_FORCED_RESET_MILLIS (default 24 h). Only matters when agc.reset.interval > 0. --- src/Dispatcher.cpp | 19 ++++++++++++++++++- src/Dispatcher.h | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c0610b7f8a7..10b3e975e23 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -15,6 +15,9 @@ namespace mesh { #ifndef NOISE_FLOOR_CALIB_INTERVAL #define NOISE_FLOOR_CALIB_INTERVAL 2000 // 2 seconds #endif +#ifndef AGC_FORCED_RESET_MILLIS + #define AGC_FORCED_RESET_MILLIS (24UL*60*60*1000) // reset AGC at least once a day, even on a busy channel +#endif void Dispatcher::begin() { n_sent_flood = n_sent_direct = 0; @@ -131,7 +134,20 @@ void Dispatcher::loop() { } if (getAGCResetInterval() > 0 && millisHasNowPassed(next_agc_reset_time)) { - _radio->resetAGC(); + // a reset takes the radio off the air, so (as in Meshtastic) skip it while packets are still + // being decoded: the gain is not stuck then. Still reset at least once per AGC_FORCED_RESET_MILLIS. + unsigned long now = _ms->getMillis(); + bool hearing = last_rx_good_time != 0 && now - last_rx_good_time < (unsigned long) getAGCResetInterval(); + bool forced = last_agc_reset_time == 0 || now - last_agc_reset_time >= AGC_FORCED_RESET_MILLIS; + if (_mgr->getOutboundTotal() > 0) { + MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): AGC reset skipped, tx queued", getLogDateTime()); + } else if (hearing && !forced) { + MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): AGC reset skipped, last rx %lu ms ago", getLogDateTime(), now - last_rx_good_time); + } else { + MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): AGC reset", getLogDateTime()); + _radio->resetAGC(); + last_agc_reset_time = now ? now : 1; + } next_agc_reset_time = futureMillis(getAGCResetInterval()); } @@ -236,6 +252,7 @@ void Dispatcher::checkRecv() { } #endif logRx(pkt, pkt->getRawLength(), score); // hook for custom logging + last_rx_good_time = _ms->getMillis(); if (pkt->isRouteFlood()) { n_recv_flood++; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec2..646ce9672b9 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -122,6 +122,7 @@ class Dispatcher { unsigned long cad_busy_start; unsigned long radio_nonrx_start; unsigned long next_floor_calib_time, next_agc_reset_time; + unsigned long last_rx_good_time, last_agc_reset_time; bool prev_isrecv_mode; uint32_t n_sent_flood, n_sent_direct; uint32_t n_recv_flood, n_recv_direct; @@ -146,6 +147,7 @@ class Dispatcher { next_tx_time = ms.getMillis(); cad_busy_start = 0; next_floor_calib_time = next_agc_reset_time = 0; + last_rx_good_time = last_agc_reset_time = 0; _err_flags = 0; radio_nonrx_start = 0; prev_isrecv_mode = true;