From 9d52b1fd68d8cb8e1e72bfc253cd80d37d6a6066 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 20 Oct 2026 08:59:39 -0700 Subject: [PATCH 1/3] Add saved LED controls across supported boards Add get leds and set leds on|off for Companion, repeater, room server and sensor builds. Preserve the setting across reboots and cover GPIO, RGB, Bluetooth, notification and expander indicators without changing radio amplifier controls or screen backlights. RAK Companions blink while advertising and keep the Bluetooth LED off once connected, including late timer callbacks. Adapted from meshcore-dev/MeshCore#3605 and #2694. Validated with host regressions and firmware builds for SenseCAP Solar, RAK4631, T096, Heltec V4, T-Beam, Xiao S3, Pico W and Wio E5 Mini. Co-authored-by: R0ck Co-authored-by: Nick Dunklee --- docs/cli_commands.md | 27 ++ examples/companion_radio/MyMesh.cpp | 2 + examples/companion_radio/Notifications.cpp | 1 + examples/companion_radio/main.cpp | 2 + examples/companion_radio/ui-new/UITask.cpp | 1 + examples/companion_radio/ui-orig/UITask.cpp | 2 + examples/companion_radio/ui-tiny/UITask.cpp | 1 + examples/simple_repeater/UITask.cpp | 2 +- examples/simple_repeater/main.cpp | 2 + examples/simple_room_server/main.cpp | 2 + examples/simple_sensor/main.cpp | 2 + scripts/nrf52_ble_cccd_fix.py | 45 +++ src/MeshCore.h | 3 + src/helpers/CommonCLI.cpp | 2 + src/helpers/ESP32Board.h | 15 +- src/helpers/LedPolicy.cpp | 24 ++ src/helpers/LedPolicy.h | 16 + src/helpers/LedSettings.cpp | 107 +++++++ src/helpers/LedSettings.h | 14 + src/helpers/NRF52Board.cpp | 7 + src/helpers/NRF52Board.h | 1 + src/helpers/esp32/TBeamBoard.h | 16 +- src/helpers/nrf52/BluetoothLed.h | 50 +++ src/helpers/nrf52/SerialBLEInterface.cpp | 8 +- src/helpers/stm32/STM32Board.h | 6 +- test/fixtures/notification_led_ownership.cpp | 19 ++ test/fixtures/nrf52_shutdown_leds.cpp | 1 + test/test_board_led_policy.py | 188 +++++++++++ test/test_companion_reader.py | 1 + test/test_heltec_r8_external_power.py | 2 + test/test_led_settings.py | 229 +++++++++++++ test/test_notification_led_ownership.py | 3 +- test/test_nrf52_ble_startup.py | 14 + test/test_nrf52_led_policy.py | 301 ++++++++++++++++++ test/test_status_led_policy.py | 134 ++++++++ test/test_tbeam_charge_target.py | 3 + test/test_wio_tracker_l2_gps_power.py | 2 + .../GAT56230SMeshKitBoard.h | 7 +- .../gat562_mesh_evb_pro/GAT562EVBProBoard.h | 7 +- .../GAT562MeshTrackerProBoard.h | 7 +- variants/heltec_rc32/HeltecRC32Board.cpp | 2 +- variants/heltec_t096/T096Board.cpp | 7 +- variants/heltec_t096/T096Board.h | 1 + variants/heltec_t1/T1Board.cpp | 7 +- variants/heltec_t1/T1Board.h | 1 + variants/heltec_t114/T114Board.h | 7 +- .../heltec_tower_v2/HeltecTowerV2Board.cpp | 7 +- variants/heltec_tower_v2/HeltecTowerV2Board.h | 1 + .../HeltecTrackerV2Board.cpp | 2 +- variants/heltec_v4/HeltecV4Board.cpp | 2 +- variants/heltec_v4_r8/HeltecV4R8Board.cpp | 2 +- variants/ikoka_handheld_nrf/IkokaNrf52Board.h | 7 +- variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h | 7 +- variants/ikoka_stick_nrf/IkokaStickNRFBoard.h | 7 +- variants/keepteen_lt1/KeepteenLT1Board.h | 7 +- .../lilygo_t_impulse_plus/TImpulsePlusBoard.h | 7 +- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 6 +- variants/lilygo_tbeam_1w/TBeam1WBoard.h | 1 + variants/lilygo_tdeck/TDeckBoard.h | 18 +- variants/lilygo_techo_card/TechoCardBoard.cpp | 9 + variants/lilygo_techo_card/TechoCardBoard.h | 1 + .../meshnology_w12/MeshnologyW12Board.cpp | 6 +- variants/meshnology_w12/MeshnologyW12Board.h | 1 + variants/meshtiny/MeshtinyBoard.h | 7 +- variants/meshtracker_x1/MeshTrackerX1Board.h | 2 +- .../MinewsemiME25LS01Board.h | 7 +- variants/muziworks_r1_neo/R1NeoBoard.h | 7 +- variants/rak11310/RAK11310Board.h | 6 +- variants/rak_wismesh_tag/RAKWismeshTagBoard.h | 9 +- variants/rpi_picow/PicoWBoard.h | 6 +- variants/sensecap_solar/SenseCapSolarBoard.h | 7 +- variants/t1000-e/T1000eBoard.h | 2 +- variants/thinknode_m1/ThinkNodeM1Board.h | 7 +- variants/thinknode_m3/ThinkNodeM3Board.h | 7 +- variants/thinknode_m4/ThinkNodeM4Board.h | 9 +- variants/thinknode_m5/ThinknodeM5Board.h | 6 +- variants/thinknode_m6/ThinkNodeM6Board.h | 7 +- variants/thinknode_m7/ThinkNodeM7Board.h | 6 +- variants/thinknode_m8/ThinkNodeM8Board.h | 7 +- .../waveshare_rp2040_lora/WaveshareBoard.h | 6 +- .../wio-tracker-l1-1w/WioTrackerL1Board.h | 7 +- variants/wio-tracker-l1/WioTrackerL1Board.h | 7 +- variants/wio-tracker-l2/WioTrackerL2Board.cpp | 2 +- variants/wio-tracker-l2/WioTrackerL2Board.h | 3 + variants/wio_wm1110/WioWM1110Board.cpp | 2 +- variants/wio_wm1110/WioWM1110Board.h | 8 +- variants/xiao_c3/XiaoC3Board.h | 2 +- variants/xiao_nrf52/XiaoNrf52Board.h | 9 +- variants/xiao_rp2040/XiaoRP2040Board.h | 6 +- variants/xiao_s3/platformio.ini | 1 + variants/xiao_s3/target.cpp | 3 +- 91 files changed, 1485 insertions(+), 65 deletions(-) create mode 100644 src/helpers/LedPolicy.cpp create mode 100644 src/helpers/LedPolicy.h create mode 100644 src/helpers/LedSettings.cpp create mode 100644 src/helpers/LedSettings.h create mode 100644 src/helpers/nrf52/BluetoothLed.h create mode 100644 test/test_board_led_policy.py create mode 100644 test/test_led_settings.py create mode 100644 test/test_nrf52_led_policy.py create mode 100644 test/test_status_led_policy.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index cbec4abc8..472f0d5b7 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1958,6 +1958,33 @@ the verified pin assignments, startup fixes, and saved amplifier settings. --- +#### Turn indicator LEDs on or off + +```text +get leds +set leds off +set leds on +``` + +Available in Companion, repeater, room-server, and sensor firmware. The setting +is saved across reboots and defaults to `on`. `off` suppresses software-controlled +transmit, Bluetooth, status, and notification LEDs. `on` restores their normal +behavior; it does not leave every LED lit. A build with `DISABLE_BLE_LED` keeps +the Bluetooth indicator disabled. + +Supported outputs include ordinary and inverted GPIO LEDs, addressable RGB LEDs, +and board-controlled expander LEDs on nRF52, ESP32, RP2040, and STM32 boards. +Screen backlights and manually controlled torches keep their own controls. +Power and charging LEDs wired directly to a charger cannot be switched off by +firmware. Bootloader and early startup indicators may still appear before the +saved setting is loaded. + +This extends the SenseCAP Solar LED switch from +[upstream PR #3605](https://github.com/meshcore-dev/MeshCore/pull/3605). +With LEDs enabled, RAK-family Companions blink their Bluetooth LED while +advertising and leave it off once connected, following +[upstream PR #2694](https://github.com/meshcore-dev/MeshCore/pull/2694). + #### Turn WiFi, ESP-NOW, or all 2.4 GHz services on or off ```text diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4c8f4df2e..38f4f6695 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -10725,6 +10726,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } if (mesh::power::handleRegulatorCommand(command, reply, reply_capacity)) return true; + if (mesh::leds::handleCommand(command, reply, reply_capacity)) return true; #if COMPANION_FEATURE_READER if (sender_timestamp == 0 && mesh::morseTimingActive(_radio) diff --git a/examples/companion_radio/Notifications.cpp b/examples/companion_radio/Notifications.cpp index 184221fce..cadd2353a 100644 --- a/examples/companion_radio/Notifications.cpp +++ b/examples/companion_radio/Notifications.cpp @@ -20,6 +20,7 @@ void MyMesh::pulse(mesh::notify::Output output,bool on,int8_t pin) { } else if(output==mesh::notify::Vibration) { if(_ui)_ui->notificationVibration(on); } else if(output==mesh::notify::Led) { + on = on && mesh::leds::enabled(); #if defined(PIN_STATUS_LED_R) && defined(PIN_STATUS_LED_G) && defined(PIN_STATUS_LED_B) analogWrite(PIN_STATUS_LED_R,on?255:0); analogWrite(PIN_STATUS_LED_G,on?90:0); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index cd8a4650b..5372afbe5 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1,4 +1,5 @@ #include +#include #include // needed for PlatformIO #include #include @@ -3634,6 +3635,7 @@ void setup() { if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot) || store.isVolatilePrimaryFS()) regulator_prefs_fs = nullptr; #endif + mesh::leds::beginSettings(board, regulator_prefs_fs); if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ed1c71517..314739de8 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2105,6 +2105,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, } void UITask::userLedHandler() { + if (!mesh::leds::enabled()) return; if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return; #ifdef PIN_STATUS_LED const uint32_t cur_time = millis(); diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index fd66182bc..75acf7f12 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -451,6 +451,7 @@ void UITask::renderCurrScreen() { #ifdef STATUS_LED_RGB static void statusLedWrite(uint8_t r, uint8_t g, uint8_t b) { + if (!mesh::leds::enabled()) r = g = b = 0; analogWrite(PIN_STATUS_LED_R, r); analogWrite(PIN_STATUS_LED_G, g); analogWrite(PIN_STATUS_LED_B, b); @@ -470,6 +471,7 @@ static void statusLedWheel(uint8_t pos) { // smooth hue sweep, 0..255 #endif void UITask::userLedHandler() { + if (!mesh::leds::enabled()) return; if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return; #ifdef STATUS_LED_RGB enum class FlourishState : uint8_t { Unseen, Boot, Message, Idle }; diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 489c2652b..e7fa3012c 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -751,6 +751,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, } void UITask::userLedHandler() { + if (!mesh::leds::enabled()) return; if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return; #ifdef PIN_STATUS_LED const uint32_t cur_time = millis(); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index bd92ee567..c1e7a0e6b 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -981,7 +981,7 @@ void UITask::loop() { if (_powering_off_at > 0) { // power off timer armed #if defined(LED_PIN) && defined(LED_STATE_ON) - digitalWrite(LED_PIN, LED_STATE_ON); // switch on the led until poweroff + if (mesh::leds::enabled()) digitalWrite(LED_PIN, LED_STATE_ON); delay(1000); digitalWrite(LED_PIN, !LED_STATE_ON); // off LED #endif diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 207a775a9..ab965be14 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -1,4 +1,5 @@ #include +#include #include // needed for PlatformIO #include #include @@ -208,6 +209,7 @@ void setup() { #if defined(NRF52_PLATFORM) if (volatile_primary_fs) regulator_prefs_fs = nullptr; #endif + mesh::leds::beginSettings(board, regulator_prefs_fs); if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index f837dca26..a60beda64 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -1,4 +1,5 @@ #include +#include #include // needed for PlatformIO #include #include @@ -142,6 +143,7 @@ void setup() { #if defined(NRF52_PLATFORM) if (volatile_primary_fs) regulator_prefs_fs = nullptr; #endif + mesh::leds::beginSettings(board, regulator_prefs_fs); if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 56ea058b6..5d5e9c6f6 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -1,4 +1,5 @@ #include +#include #include "SensorMesh.h" #include #include @@ -167,6 +168,7 @@ void setup() { #if defined(NRF52_PLATFORM) if (volatile_primary_fs) regulator_prefs_fs = nullptr; #endif + mesh::leds::beginSettings(board, regulator_prefs_fs); if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } diff --git a/scripts/nrf52_ble_cccd_fix.py b/scripts/nrf52_ble_cccd_fix.py index f3c219c12..2ba0ab140 100644 --- a/scripts/nrf52_ble_cccd_fix.py +++ b/scripts/nrf52_ble_cccd_fix.py @@ -155,6 +155,49 @@ def patched_trace_source(source): ''') +def patched_led_source(source): + """A queued advertising blink must not relight a disabled indicator.""" + source = source.replace("\r\n", "\n") + old = """ (void) xTimer; + digitalToggle(LED_BLUE); +""" + new = """ (void) xTimer; + // MeshCore saved LED policy also covers already queued timer callbacks. + if (LED_BLUE < 0 || (uint32_t)LED_BLUE >= PINS_COUNT) return; + if (digitalPinToPinName(LED_BLUE) == 0xff) return; +#ifdef DISABLE_BLE_LED + const bool enabled = false; +#elif defined(RAK_BOARD) && defined(BLE_PIN_CODE) + const bool enabled = mesh::leds::enabled() && !Bluefruit.connected(); +#else + const bool enabled = mesh::leds::enabled(); +#endif + if (enabled) digitalToggle(LED_BLUE); + else digitalWrite(LED_BLUE, !LED_STATE_ON); +""" + include = '#include "bluefruit.h"\n' + policy_include = '#include \n' + old_connected = " digitalWrite(LED_BLUE, on_off ? LED_STATE_ON : (1-LED_STATE_ON) );" + new_connected = """ // RAK Companions blink while advertising, then go dark when connected. +#if defined(DISABLE_BLE_LED) || (defined(RAK_BOARD) && defined(BLE_PIN_CODE)) + on_off = false; +#else + on_off = on_off && mesh::leds::enabled(); +#endif + if (LED_BLUE >= 0 && (uint32_t)LED_BLUE < PINS_COUNT + && digitalPinToPinName(LED_BLUE) != 0xff) + digitalWrite(LED_BLUE, on_off ? LED_STATE_ON : (1-LED_STATE_ON));""" + if (new in source and new_connected in source + and policy_include in source and old not in source + and old_connected not in source): + return source + if (source.count(old) != 1 or source.count(include) != 1 + or source.count(old_connected) != 1): + raise RuntimeError("nRF52 BLE LED policy: unrecognized timer callback") + return source.replace(old, new).replace(old_connected, new_connected).replace( + include, include + policy_include) + + def replace_framework_source(build_env, node): source = Path(node.srcnode().get_abspath()) if source.name not in ("BLEGatt.cpp", "BLEConnection.cpp", "bluefruit.cpp") or source.parent.name != "src": @@ -166,6 +209,8 @@ def replace_framework_source(build_env, node): patched = patched_connection_source(original) else: patched = patched_bluefruit_source(original) + patched = patched_led_source(patched) + build_env.AppendUnique(CPPPATH=[build_env.subst("$PROJECT_DIR/src")]) if "MESH_NRF52_BLE_TRACE" in str(build_env.get("CPPDEFINES", [])): patched = patched_trace_source(patched) build_env.AppendUnique(CPPPATH=[build_env.subst("$PROJECT_DIR/src")]) diff --git a/src/MeshCore.h b/src/MeshCore.h index 9b6daf6ff..ebccbed08 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -7,6 +7,7 @@ #include "helpers/BatteryChargeSupport.h" #include "helpers/RegulatorMode.h" #include "helpers/PowerSavingDiagnostics.h" +#include "helpers/LedPolicy.h" #if defined(ARDUINO) && (defined(RP2040_PLATFORM) || defined(STM32_PLATFORM)) #include #endif @@ -145,6 +146,8 @@ public: virtual const char* getManufacturerName() const = 0; virtual void onBeforeTransmit() { } virtual void onAfterTransmit() { } + // Update only indicator outputs; transmit hooks may also control RF hardware. + virtual void applyLedPolicy() { } virtual void reboot() = 0; MESH_REGULATOR_VIRTUAL uint8_t mcuRegulatorModes() const { return 0; } MESH_REGULATOR_VIRTUAL power::RegulatorMode defaultMcuRegulatorMode() const { diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 7abe0efa0..28b662f21 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,4 +1,5 @@ #include +#include #include #include "CarrierTools.h" #include "PacketFileLoggingConfig.h" @@ -2980,6 +2981,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (mesh::power::handleBatteryChargeCommand(*_board, command, reply, 160)) return; #endif if (mesh::power::handleRegulatorCommand(command, reply, 157)) return; + if (mesh::leds::handleCommand(command, reply, 157)) return; if (mesh::handleUsbLoggingWatchdogCommand(command, reply, 160)) return; if (mesh::powerdiag::handleCommand(command, reply, 157, millis())) return; if (handleManagementCommand(command, reply)) return; diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 1a2e3690e..8d1bc90b6 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -271,9 +271,18 @@ public: uint8_t getStartupReason() const override { return startup_reason; } bool isUserGpioAvailable(uint8_t pin) const override; + void applyLedPolicy() override { + if (mesh::leds::enabled()) return; +#if defined(P_LORA_TX_LED) + digitalWrite(P_LORA_TX_LED, LOW); +#elif defined(P_LORA_TX_NEOPIXEL_LED) + neopixelWrite(P_LORA_TX_NEOPIXEL_LED, 0, 0, 0); +#endif + } + #if defined(P_LORA_TX_LED) void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off @@ -282,7 +291,9 @@ public: #define NEOPIXEL_BRIGHTNESS 64 // white brightness (max 255) void onBeforeTransmit() override { - neopixelWrite(P_LORA_TX_NEOPIXEL_LED, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS); // turn TX neopixel on (White) + if (mesh::leds::enabled()) { + neopixelWrite(P_LORA_TX_NEOPIXEL_LED, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS); // turn TX neopixel on (White) + } } void onAfterTransmit() override { neopixelWrite(P_LORA_TX_NEOPIXEL_LED, 0, 0, 0); // turn TX neopixel off diff --git a/src/helpers/LedPolicy.cpp b/src/helpers/LedPolicy.cpp new file mode 100644 index 000000000..c7156f243 --- /dev/null +++ b/src/helpers/LedPolicy.cpp @@ -0,0 +1,24 @@ +#include "LedPolicy.h" +#include +#include + +namespace mesh { namespace leds { + +void applyStatusPolicy() { + if (enabled()) return; +#if defined(PIN_STATUS_LED_R) && defined(PIN_STATUS_LED_G) && defined(PIN_STATUS_LED_B) + analogWrite(PIN_STATUS_LED_R, 0); + analogWrite(PIN_STATUS_LED_G, 0); + analogWrite(PIN_STATUS_LED_B, 0); +#elif defined(PIN_STATUS_LED) + if (PIN_STATUS_LED < 0) return; +#ifdef LED_STATE_ON + digitalWrite(PIN_STATUS_LED, !LED_STATE_ON); +#else + digitalWrite(PIN_STATUS_LED, LOW); +#endif + pinMode(PIN_STATUS_LED, OUTPUT); +#endif +} + +}} // namespace mesh::leds diff --git a/src/helpers/LedPolicy.h b/src/helpers/LedPolicy.h new file mode 100644 index 000000000..e5951e338 --- /dev/null +++ b/src/helpers/LedPolicy.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace mesh { namespace leds { + +inline std::atomic& policy() { + static std::atomic value{true}; + return value; +} + +inline bool enabled() { return policy().load(std::memory_order_relaxed); } +inline void setEnabled(bool value) { policy().store(value, std::memory_order_relaxed); } +void applyStatusPolicy(); + +}} // namespace mesh::leds diff --git a/src/helpers/LedSettings.cpp b/src/helpers/LedSettings.cpp new file mode 100644 index 000000000..f55b5919a --- /dev/null +++ b/src/helpers/LedSettings.cpp @@ -0,0 +1,107 @@ +#include "LedSettings.h" +#include +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include +#else +#include +#endif +#include +#include + +namespace mesh { namespace leds { +namespace { +MainBoard* main_board = nullptr; +FILESYSTEM* settings_fs = nullptr; +const char* const path = "/leds_v1"; +const char* const backup = "/leds_v1.bak"; + +void encode(bool on, uint8_t (&data)[8]) { + const uint8_t value = on ? 1 : 0; + const uint8_t image[] = {'L', 'E', 'D', 1, value, + static_cast(value ^ 0xff), 0, 0}; + memcpy(data, image, sizeof(data)); +} + +bool readSetting(const char* name, bool& on) { + if (!settings_fs) return false; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file(*settings_fs); + if (!file.open(name, FILE_O_READ)) return false; +#else + File file = settings_fs->open(name, "r"); + if (!file) return false; +#endif + uint8_t data[8], expected[8]; + const bool complete = file.size() == sizeof(data) + && file.read(data, sizeof(data)) == sizeof(data); + file.close(); + if (!complete || data[4] > 1) return false; + encode(data[4] != 0, expected); + if (memcmp(data, expected, sizeof(data)) != 0) return false; + on = data[4] != 0; + return true; +} + +bool saveSetting(bool on) { + if (!settings_fs) return false; + uint8_t data[8]; + encode(on, data); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter file(settings_fs, path); +#else + ContactFileTransaction file(settings_fs, path); +#endif + if (!file || file.write(data, sizeof(data)) != sizeof(data) || !file.commit()) + return false; + bool checked = !on; + return readSetting(path, checked) && checked == on; +} +} // namespace + +void beginSettings(MainBoard& board, FILESYSTEM* fs) { + main_board = &board; + settings_fs = fs; + bool on = true; + if (!readSetting(path, on) && readSetting(backup, on)) { + // Recover the rename gap without discarding the last complete preference. + if ((!fs->exists(path) || fs->remove(path)) && fs->rename(backup, path)) { + bool checked = !on; + if (!readSetting(path, checked) || checked != on) settings_fs = nullptr; + } else { + settings_fs = nullptr; + } + } + setEnabled(on); + board.applyLedPolicy(); + applyStatusPolicy(); +} + +bool handleCommand(const char* command, char* reply, size_t capacity) { + if (!command || !reply || capacity == 0) return false; + const bool get = strncmp(command, "get ", 4) == 0; + const bool set = strncmp(command, "set ", 4) == 0; + if ((!get && !set) || strncmp(command + 4, "leds", 4) != 0) return false; + const char* value = command + 8; + if (*value && *value != ' ' && *value != '\t') return false; + if (get) { + if (*value) snprintf(reply, capacity, "Error: usage get leds"); + else snprintf(reply, capacity, "> %s", enabled() ? "on" : "off"); + return true; + } + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, capacity, "Error: usage set leds on|off"); + return true; + } + const bool on = strcmp(value, "on") == 0; + if (!main_board || !saveSetting(on)) { + snprintf(reply, capacity, "Error: LED setting save unconfirmed; reboot may differ"); + return true; + } + setEnabled(on); + main_board->applyLedPolicy(); + applyStatusPolicy(); + snprintf(reply, capacity, "OK"); + return true; +} +} } // namespace mesh::leds diff --git a/src/helpers/LedSettings.h b/src/helpers/LedSettings.h new file mode 100644 index 000000000..9665d638a --- /dev/null +++ b/src/helpers/LedSettings.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +namespace mesh { +class MainBoard; +namespace leds { + +void beginSettings(MainBoard& board, FILESYSTEM* fs); +bool handleCommand(const char* command, char* reply, size_t capacity); + +} // namespace leds +} // namespace mesh diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 170c52447..aadf055c9 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -6,6 +6,7 @@ #include "nrf52/SoftDeviceState.h" #include "nrf52/SleepCycleState.h" #include "nrf52/ShutdownLeds.h" +#include "nrf52/BluetoothLed.h" #include #include "UserGpioPinPolicy.h" #include "PowerSavingDiagnostics.h" @@ -263,6 +264,10 @@ bool NRF52Board::applyMcuRegulator(mesh::power::RegulatorMode mode) { return true; } +void NRF52Board::applyLedPolicy() { + mesh_nrf52::applyBluetoothLedPolicy(); +} + void NRF52Board::loop() { #ifdef NRF52_POWER_MANAGEMENT if (!mesh::power::voltagePolicySupported()) return; @@ -959,10 +964,12 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[], bool force_ap) { // Even a failed begin can leave SoftDevice enabled partway through SDK // startup. Require a fresh authoritative query before the next sleep. mesh_nrf52::sleepCycleState().invalidate(); + mesh_nrf52::applyBluetoothLedPolicy(); if (!Bluefruit.begin(1, 0)) { return false; } ota_ble_started = true; + mesh_nrf52::bluetoothLedStarted(); // To be consistent OTA DFU should be added first if it exists bledfu.begin(); diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 9972b2b99..41ae8562e 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -61,6 +61,7 @@ public: {} virtual void begin(); void loop() override; + void applyLedPolicy() override; // Feed the hardware main-loop watchdog. The first call starts it, so normal // setup (including filesystem mount and radio initialization) is not timed. // A loop blocked forever in SoftDevice-backed flash I/O will stop feeding it diff --git a/src/helpers/esp32/TBeamBoard.h b/src/helpers/esp32/TBeamBoard.h index 234be4657..b78a867b0 100644 --- a/src/helpers/esp32/TBeamBoard.h +++ b/src/helpers/esp32/TBeamBoard.h @@ -139,9 +139,23 @@ public: return charge_target_restore_failed; } + void applyLedPolicy() override { +#ifndef TBEAM_SUPREME_SX1262 + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); +#else + ESP32Board::applyLedPolicy(); +#endif +#if !defined(PORTABLE_MQTT_OBSERVER) || defined(TBEAM_SUPREME_SX1262) + if (charge_pmu_ready && PMU) { + PMU->setChargingLedMode(mesh::leds::enabled() + ? XPOWERS_CHG_LED_CTRL_CHG : XPOWERS_CHG_LED_OFF); + } +#endif + } + #ifndef TBEAM_SUPREME_SX1262 void onBeforeTransmit() override{ - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276 + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276 } void onAfterTransmit() override{ digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276 diff --git a/src/helpers/nrf52/BluetoothLed.h b/src/helpers/nrf52/BluetoothLed.h new file mode 100644 index 000000000..6d4e4b13c --- /dev/null +++ b/src/helpers/nrf52/BluetoothLed.h @@ -0,0 +1,50 @@ +#pragma once + +#include "../LedPolicy.h" +#include "ShutdownLeds.h" +#include + +namespace mesh_nrf52 { + +inline bool& bluetoothLedReady() { + static bool ready = false; + return ready; +} + +inline void applyBluetoothLedPolicy() { +#ifdef LED_BLUE + if (LED_BLUE < 0 || static_cast(LED_BLUE) >= PINS_COUNT + || digitalPinToPinName(LED_BLUE) == 0xff) { + Bluefruit.autoConnLed(false); + return; + } +#else + Bluefruit.autoConnLed(false); + return; +#endif +#ifdef DISABLE_BLE_LED + const bool enable = false; +#else + const bool enable = mesh::leds::enabled(); +#endif + Bluefruit.autoConnLed(enable); + if (!enable) { +#ifdef LED_BLUE + turnOffShutdownLed(LED_BLUE); +#endif + } else if (bluetoothLedReady()) { +#if defined(RAK_BOARD) && defined(BLE_PIN_CODE) + Bluefruit._setConnLed(false); +#else + Bluefruit._setConnLed(Bluefruit.connected() != 0); +#endif + if (Bluefruit.Advertising.isRunning()) Bluefruit._startConnLed(); + } +} + +inline void bluetoothLedStarted() { + bluetoothLedReady() = true; + applyBluetoothLedPolicy(); +} + +} // namespace mesh_nrf52 diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index b9d20c303..da6d95f0d 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -4,6 +4,7 @@ #include "SoftDeviceState.h" #include "SleepCycleState.h" #include "BleDebugTrace.h" +#include "BluetoothLed.h" #include #include #include "ble_gap.h" @@ -836,11 +837,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, char charpin[20]; snprintf(charpin, sizeof(charpin), "%lu", (unsigned long)pin_code); -#ifdef DISABLE_BLE_LED - // Opt out of Bluefruit's automatic LED control without changing the normal - // board/library default or the application's own LED handling. - Bluefruit.autoConnLed(false); -#endif + mesh_nrf52::applyBluetoothLedPolicy(); // Configure individual connection values rather than the all-or-nothing // Bluefruit preset. Defaults are identical to BANDWIDTH_MAX; constrained // board profiles can lower event/buffer reservations without shrinking the @@ -875,6 +872,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, return false; } + mesh_nrf52::bluetoothLedStarted(); if (clear_bonds) Bluefruit.Periph.clearBonds(); if (custom_address != nullptr) { diff --git a/src/helpers/stm32/STM32Board.h b/src/helpers/stm32/STM32Board.h index f6dfe2e80..01cad1d13 100644 --- a/src/helpers/stm32/STM32Board.h +++ b/src/helpers/stm32/STM32Board.h @@ -36,8 +36,12 @@ public: } #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off diff --git a/test/fixtures/notification_led_ownership.cpp b/test/fixtures/notification_led_ownership.cpp index defa3fb76..2fba358f4 100644 --- a/test/fixtures/notification_led_ownership.cpp +++ b/test/fixtures/notification_led_ownership.cpp @@ -208,6 +208,25 @@ int main() { checkGuard(node, false); }); + run("board-led-policy-overrides-alerts-and-heartbeat", [] { + MyMesh node; + mesh::leds::setEnabled(false); + command(node, "set notify.led all 50,50"); + command(node, "notify.test all", "OK - alert test started"); + expectLed(false); + checkGuard(node, false); + seedUiRgb(); + expectLed(false); + node._notifications.loop(1050, false); + node._notifications.loop(1100, false); + mesh::leds::setEnabled(true); + node.syncNotifications(); + checkGuard(node, true); + node.pulse(mesh::notify::Led, true, -1); + expectLed(true); + node.pulse(mesh::notify::Led, false, -1); + }); + run("disabled-feature-restores-ui-policy", [] { MyMesh node; startAlert(node); diff --git a/test/fixtures/nrf52_shutdown_leds.cpp b/test/fixtures/nrf52_shutdown_leds.cpp index 80efe350d..de867cbdf 100644 --- a/test/fixtures/nrf52_shutdown_leds.cpp +++ b/test/fixtures/nrf52_shutdown_leds.cpp @@ -31,6 +31,7 @@ #define pdMS_TO_TICKS(ms) (ms) #include +#include std::vector events; int levels[PINS_COUNT]; diff --git a/test/test_board_led_policy.py b/test/test_board_led_policy.py new file mode 100644 index 000000000..3218418bd --- /dev/null +++ b/test/test_board_led_policy.py @@ -0,0 +1,188 @@ +"""Exercise production board LED callbacks without touching hardware.""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def body(path, name, index=0): + source = (ROOT / path).read_text() + matches = list(re.finditer( + rf"\bvoid\s+(?:\w+::)?{name}\([^)]*\)(?:\s+override)?\s*\{{", source)) + start = matches[index].end() + end, depth = start, 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end - 1] + + +STUBS = r""" +#include +#include +#define HIGH 1 +#define LOW 0 +#define P_LORA_TX_LED 1 +#define LED_PIN 1 +#define LED_BUILTIN 1 +#define LED_RED 1 +#define EXP_PIN_LED 1 +#define EXP_PIN_USER_LED 1 +#define LED_STATE_ON LOW +int levels[8], tx, rx, fan_calls, fan_state; +void digitalWrite(int pin, int value) { levels[pin] = value; } +struct Expander { void digitalWrite(int p, int v) { ::digitalWrite(p, v); } } expander; +void expWritePin(int p, int v) { digitalWrite(p, v); } +struct NRF52Board { static void applyLedPolicy() {} }; +struct Fem { + void setTxModeEnable() { ++tx; } + void setRxModeEnable() { ++rx; } +} loRaFEMControl; +void reset() { + for (auto &v : levels) v = -1; + tx = rx = fan_calls = fan_state = 0; + mesh::leds::setEnabled(false); +} +""" + + +class BoardLedPolicyTests(unittest.TestCase): + def run_cpp(self, source): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("C++ compiler unavailable") + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + cpp = directory / "led_policy.cpp" + exe = directory / ("led_policy.exe" if os.name == "nt" else "led_policy") + cpp.write_text(source) + result = subprocess.run( + [compiler, "-std=c++17", "-I", str(ROOT / "src"), str(cpp), "-o", str(exe)], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_gpio_polarity_and_fem_callbacks(self): + # Exercise active-low, active-high, LED_STATE_ON and expander boards. + cases = [ + ("variants/lilygo_tdeck/TDeckBoard.h", 0, False, None), + ("variants/thinknode_m7/ThinkNodeM7Board.h", 0, False, None), + ("variants/thinknode_m5/ThinknodeM5Board.h", 1, False, None), + ("variants/xiao_nrf52/XiaoNrf52Board.h", 0, False, None), + ("variants/rak11310/RAK11310Board.h", 1, False, None), + ("variants/xiao_rp2040/XiaoRP2040Board.h", 1, False, None), + ("variants/rpi_picow/PicoWBoard.h", 1, False, None), + ("variants/sensecap_solar/SenseCapSolarBoard.h", 1, False, None), + ("variants/heltec_t096/T096Board.cpp", 1, True, None), + ("variants/heltec_tower_v2/HeltecTowerV2Board.cpp", 0, True, None), + ("variants/heltec_v4/HeltecV4Board.cpp", 1, True, "src/helpers/ESP32Board.h"), + ("variants/heltec_v4_r8/HeltecV4R8Board.cpp", 1, True, "src/helpers/ESP32Board.h"), + ("src/helpers/stm32/STM32Board.h", 0, False, None), + ] + source = STUBS + for index, (path, on, fem, policy_path) in enumerate(cases): + source += f""" +namespace test{index} {{ +void before() {{ {body(path, "onBeforeTransmit")} }} +void after() {{ {body(path, "onAfterTransmit")} }} +void apply() {{ {body(policy_path or path, "applyLedPolicy")} }} +void run() {{ + reset(); + levels[1] = {on}; + apply(); + assert(levels[1] == {1 - on}); + assert(tx == 0 && rx == 0); + before(); + assert(levels[1] == {1 - on}); + assert(tx == {int(fem)} && rx == 0); + after(); + assert(levels[1] == {1 - on}); + assert(rx == {int(fem)}); + mesh::leds::setEnabled(true); + before(); + assert(levels[1] == {on}); + after(); + assert(levels[1] == {1 - on}); +}} +}} +""" + source += "int main() {" + "".join(f"test{i}::run();" for i in range(len(cases))) + "}" + self.run_cpp(source) + + def test_wio_expander_preserves_gnss_wake_level(self): + path = "variants/wio-tracker-l2/WioTrackerL2Board.h" + self.run_cpp(STUBS + f""" +bool expander_ok = true; +void setLed(bool on) {{ {body("variants/wio-tracker-l2/WioTrackerL2Board.cpp", "setLed")} }} +void before() {{ {body(path, "onBeforeTransmit")} }} +void after() {{ {body(path, "onAfterTransmit")} }} +void apply() {{ {body(path, "applyLedPolicy")} }} +int main() {{ + reset(); + apply(); assert(levels[1] == HIGH); + before(); assert(levels[1] == HIGH); + after(); assert(levels[1] == HIGH); + mesh::leds::setEnabled(true); + before(); assert(levels[1] == LOW); + after(); assert(levels[1] == HIGH); + expander_ok = false; levels[1] = -1; + apply(); before(); after(); assert(levels[1] == -1); +}} +""") + + def test_tbeam_fan_runs_with_leds_disabled(self): + path = "variants/lilygo_tbeam_1w/TBeam1WBoard.cpp" + self.run_cpp(STUBS + r""" +constexpr int FAN_AUTO = 2, FAN_TX_COOLDOWN_MS = 1000; +int _fan_mux = 0, _mode = FAN_AUTO; +bool _tx_active = false, _stopped = false, _tx_cooldown_active = false; +unsigned _tx_until_ms = 0; +void portENTER_CRITICAL(int*) {} +void portEXIT_CRITICAL(int*) {} +unsigned millis() { return 23; } +void setFanOutputLocked(bool on) { ++fan_calls; fan_state = on; } +""" + f""" +void before() {{ {body(path, "onBeforeTransmit")} }} +void after() {{ {body(path, "onAfterTransmit")} }} +void apply() {{ {body(path, "applyLedPolicy")} }} +int main() {{ + reset(); apply(); + before(); + assert(levels[1] == LOW && _tx_active && fan_calls == 1 && fan_state); + apply(); assert(_tx_active && fan_calls == 1 && !_tx_cooldown_active); + after(); + assert(!_tx_active && _tx_cooldown_active && _tx_until_ms == 1023); +}} +""") + + def test_rgb_tx_does_not_change_torch(self): + path = "variants/lilygo_techo_card/TechoCardBoard.cpp" + self.run_cpp(STUBS + r""" +struct Pixel { + int value = 0, shows = 0; + void setPixelColor(int, int r, int g, int b) { value = r + g + b; } + void show() { ++shows; } +} Led_A, Led_C; +""" + f""" +void before() {{ {body(path, "onBeforeTransmit")} }} +void after() {{ {body(path, "onAfterTransmit")} }} +void apply() {{ {body(path, "applyLedPolicy")} }} +int main() {{ + reset(); Led_A.value = 60; Led_C.value = 765; + apply(); before(); + assert(Led_A.value == 0 && Led_C.value == 765 && Led_C.shows == 0); + mesh::leds::setEnabled(true); before(); assert(Led_A.value == 60); + after(); assert(Led_A.value == 0 && Led_C.value == 765); +}} +""") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 7c8b076ac..458d48786 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -272,6 +272,7 @@ struct Radio { bool active = false; }; namespace mesh { bool morseTimingActive(const Radio* radio) { return radio && radio->active; } namespace power { bool handleRegulatorCommand(const char*,char*,size_t) { return false; } } +namespace leds { bool handleCommand(const char*,char*,size_t) { return false; } } } @PREDICATE@ struct MyMesh { diff --git a/test/test_heltec_r8_external_power.py b/test/test_heltec_r8_external_power.py index 640c965dd..c130083fc 100644 --- a/test/test_heltec_r8_external_power.py +++ b/test/test_heltec_r8_external_power.py @@ -30,12 +30,14 @@ inline uint32_t analogReadMilliVolts(int) { return 830; } BASE_BOARD = r''' #pragma once #include +#include class ESP32Board { protected: uint8_t startup_reason = 0; public: void begin() {} virtual ~ESP32Board() = default; + virtual void applyLedPolicy() {} virtual void onBeforeTransmit() {} virtual void onAfterTransmit() {} virtual void powerOff() {} diff --git a/test/test_led_settings.py b/test/test_led_settings.py new file mode 100644 index 000000000..5c754fedd --- /dev/null +++ b/test/test_led_settings.py @@ -0,0 +1,229 @@ +"""Exercise persisted LED commands on every filesystem backend.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_regulator_settings import FILESYSTEM +from test_serial_wifi_sessions import SANITIZER_FLAGS + +ROOT = Path(__file__).resolve().parents[1] + +BOARD = r''' +#pragma once +#include +namespace mesh { +namespace leds { +inline bool& state() { static bool value = true; return value; } +inline bool enabled() { return state(); } +inline void setEnabled(bool on) { state() = on; } +inline void applyStatusPolicy() {} +} +struct MainBoard { + unsigned applies = 0; + bool last_applied = true; + void applyLedPolicy() { ++applies; last_applied = leds::enabled(); } +}; +} +''' + +HARNESS = r''' +#include +#include +#include +#include +#include +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#undef ATOMIC_FILE_WRITER_IMPLEMENTATION +#endif +#include + +using namespace mesh::leds; +static const char* const target = "/leds_v1"; +static const char* const rollback = "/leds_v1.bak"; + +std::string command(const char* text) { + char reply[160] = {}; + assert(handleCommand(text, reply, sizeof(reply))); + return reply; +} +void defaults() { + mesh::MainBoard board; + MockFS fs; + setEnabled(false); + beginSettings(board, &fs); + assert(enabled() && board.last_applied && board.applies == 1); + assert(command("get leds") == "> on"); + assert(fs.writes == 0); + assert(command("set leds off") == "OK"); + assert(!enabled() && !board.last_applied && board.applies == 2); + assert(command("get leds") == "> off"); + assert(fs.files[target].size() == 8); + assert(!fs.exists("/leds_v1.tmp") && !fs.exists(rollback)); + setEnabled(true); + beginSettings(board, &fs); + assert(!enabled() && !board.last_applied); + assert(command("set leds on") == "OK"); + beginSettings(board, &fs); + assert(enabled() && board.last_applied); + beginSettings(board, nullptr); + const unsigned applies = board.applies; + assert(command("set leds off").find("save unconfirmed") != std::string::npos); + assert(enabled() && board.applies == applies); +} +void parsing() { + mesh::MainBoard board; + MockFS fs; + beginSettings(board, &fs); + const char* unrelated[] = {"", "g", "get", "get ", "get led", "get ledsX", + "set ledsX off", "set led off", "get\tleds"}; + for (const char* text : unrelated) { + char reply[80] = "untouched"; + assert(!handleCommand(text, reply, sizeof(reply))); + assert(strcmp(reply, "untouched") == 0); + } + const char* invalid[] = {"set leds", "set leds ", "set leds 0", + "set leds off extra", "set leds on ", "set leds ON", "get leds off", "get leds "}; + for (const char* text : invalid) + assert(command(text).find("Error: usage") == 0); + assert(fs.writes == 0 && board.applies == 1); + assert(command("set leds\t off") == "OK" && !enabled()); + char bounded[3] = {'x', 'y', 'z'}; + assert(handleCommand("get leds", bounded, 1)); + assert(bounded[0] == 0 && bounded[1] == 'y' && bounded[2] == 'z'); + assert(!handleCommand(nullptr, bounded, sizeof(bounded))); + assert(!handleCommand("get leds", nullptr, sizeof(bounded))); + assert(!handleCommand("get leds", bounded, 0)); +} +void failures() { + for (unsigned failure = 0; failure < 5; ++failure) { + mesh::MainBoard board; + MockFS fs; + beginSettings(board, &fs); + assert(command("set leds on") == "OK"); + const auto original = fs.files[target]; + const unsigned applies = board.applies; + if (failure == 0) fs.fail_open = true; + if (failure == 1) fs.fail_write = true; + if (failure == 2) fs.corrupt_write = true; + if (failure == 3) fs.fail_rename_from = "/leds_v1.tmp"; + if (failure == 4) fs.fail_read_path = target; + assert(command("set leds off").find("save unconfirmed") != std::string::npos); + assert(enabled() && board.applies == applies); + if (failure != 4) assert(fs.files[target] == original); + fs.fail_open = fs.fail_write = fs.corrupt_write = false; + fs.fail_rename_from.clear(); + fs.fail_read_path.clear(); + beginSettings(board, &fs); + assert(enabled() == (failure != 4)); + } +} +void recovery() { + mesh::MainBoard board; + MockFS fs; + beginSettings(board, &fs); + assert(command("set leds off") == "OK"); + const auto saved = fs.files[target]; + fs.files[rollback] = saved; + fs.files.erase(target); + fs.files["/leds_v1.tmp"] = {'x'}; + beginSettings(board, &fs); + assert(!enabled() && fs.files[target] == saved && !fs.exists(rollback)); + assert(command("set leds on") == "OK"); + assert(!fs.exists("/leds_v1.tmp")); + fs.files[rollback] = saved; + fs.files[target] = {'x'}; + beginSettings(board, &fs); + assert(!enabled() && fs.files[target] == saved); + fs.files[rollback] = saved; + fs.files.erase(target); + fs.fail_rename_from = rollback; + beginSettings(board, &fs); + assert(!enabled() && fs.files[rollback] == saved); + const unsigned applies = board.applies; + assert(command("set leds on").find("save unconfirmed") != std::string::npos); + assert(!enabled() && board.applies == applies && fs.files[rollback] == saved); +} +void corrupt() { + mesh::MainBoard board; + MockFS fs; + beginSettings(board, &fs); + assert(command("set leds off") == "OK"); + const auto saved = fs.files[target]; + for (unsigned i = 0; i < saved.size(); ++i) { + fs.files[target] = saved; + fs.files[target][i] ^= 2; + beginSettings(board, &fs); + assert(enabled()); + } + for (unsigned length = 0; length < saved.size(); ++length) { + fs.files[target] = {saved.begin(), saved.begin() + length}; + beginSettings(board, &fs); + assert(enabled()); + } + fs.files[target] = saved; + fs.files[target].push_back(0); + beginSettings(board, &fs); + assert(enabled()); +} +int main() { + defaults(); + parsing(); + failures(); + recovery(); + corrupt(); +} +''' + + +class LedSettingsTests(unittest.TestCase): + def test_commands_persistence_failures_and_recovery_across_platforms(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("native C++ compiler unavailable") + with tempfile.TemporaryDirectory(prefix="meshcore-led-settings-") as directory: + folder = Path(directory) + helpers = folder / "helpers" + helpers.mkdir() + filesystem = FILESYSTEM.replace( + " void flush() {}", " void flush() {}\n bool setBufferSize(size_t) { return true; }" + ).replace( + "#if !defined(NRF52_PLATFORM)", + "#if !defined(NRF52_PLATFORM) && !defined(STM32_PLATFORM)", + ) + (helpers / "IdentityStore.h").write_text(filesystem) + (helpers / "ContactFileTransaction.h").write_text( + (ROOT / "src/helpers/ContactFileTransaction.h").read_text() + ) + (folder / "MeshCore.h").write_text(BOARD) + source = folder / "test.cpp" + source.write_text(HARNESS) + for platform, flags in { + "esp32": ["ESP32=1", "ESP32_PLATFORM=1"], + "nrf52": ["NRF52_PLATFORM=1"], + "stm32": ["STM32_PLATFORM=1"], + "rp2040": ["RP2040_PLATFORM=1"], + }.items(): + with self.subTest(platform=platform): + binary = folder / platform + built = subprocess.run( + [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *SANITIZER_FLAGS, *["-D" + flag for flag in flags], + "-I", str(folder), "-I", str(ROOT / "src"), + "-I", str(ROOT / "src/helpers"), + str(source), "-o", str(binary)], + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run( + [str(binary)], capture_output=True, text=True, timeout=20 + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_notification_led_ownership.py b/test/test_notification_led_ownership.py index 9952c0484..f6595fcad 100644 --- a/test/test_notification_led_ownership.py +++ b/test/test_notification_led_ownership.py @@ -24,6 +24,7 @@ RGB = "defined(PIN_STATUS_LED_R) && defined(PIN_STATUS_LED_G) && defined(PIN_STA HARNESS = r''' #pragma once #include +#include #include #include inline unsigned levels[64] = {}, analog_writes[64] = {}, digital_writes[64] = {}, mode_writes[64] = {}; @@ -133,7 +134,7 @@ class NotificationLedOwnershipTest(unittest.TestCase): checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) print(f"\n[{name}]\n" + checked.stdout + checked.stderr, flush=True) self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) - expected_scenarios = 10 if expected_rgb or expected_mono else 4 + expected_scenarios = 11 if expected_rgb or expected_mono else 4 self.assertEqual(checked.stdout.count("PASS:"), expected_scenarios, checked.stdout) self.assertRegex(checked.stdout, rf"{expected_scenarios} scenarios, [0-9]+ checks, 0 failures") diff --git a/test/test_nrf52_ble_startup.py b/test/test_nrf52_ble_startup.py index 5c5875f9d..51f17bc4e 100644 --- a/test/test_nrf52_ble_startup.py +++ b/test/test_nrf52_ble_startup.py @@ -13,6 +13,13 @@ ROOT = Path(__file__).resolve().parents[1] RTOS = r''' #pragma once #include +#define PINS_COUNT 64 +#define LED_STATE_ON 1 +#define LED_BLUE 12 +#define OUTPUT 1 +inline int digitalPinToPinName(int pin) { return pin; } +inline void digitalWrite(int, int) {} +inline void pinMode(int, int) {} using BaseType_t = int; using UBaseType_t = unsigned; using TaskHandle_t = void*; @@ -89,6 +96,7 @@ extern "C" BaseType_t __real_xTaskCreate(TaskFunction_t, const char* name, return pdPASS; } struct Settings { + bool isRunning() const { return false; } template void clearBonds(T...) {} template void setMITM(T...) {} template void setPIN(T...) {} @@ -132,8 +140,13 @@ struct BluefruitStub { } void setTxPower(int) {} void setName(const char*) {} + void autoConnLed(bool) {} + void _setConnLed(bool) {} + void _startConnLed() {} + unsigned connected() const { return 0; } template void setEventCallback(T) {} } Bluefruit; +#include struct Service { int id; int begin() { return fail_service == id ? 1 : ERROR_NONE; } @@ -251,6 +264,7 @@ class Nrf52BleStartupTest(unittest.TestCase): with tempfile.TemporaryDirectory(prefix="meshcore-ble-start-") as temp: temp = Path(temp) (temp / "Arduino.h").write_text(RTOS) + (temp / "bluefruit.h").write_text("#pragma once\n") for name in ("FreeRTOS.h", "task.h"): (temp / name).write_text('#include "Arduino.h"\n') cpp = temp / "startup.cpp" diff --git a/test/test_nrf52_led_policy.py b/test/test_nrf52_led_policy.py new file mode 100644 index 000000000..f9dcd360b --- /dev/null +++ b/test/test_nrf52_led_policy.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Run the nRF52 LED policy and patched Bluefruit timer callback on the host.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = spec_from_file_location("nrf52_led_patch", ROOT / "scripts/nrf52_ble_cccd_fix.py") +PATCH = module_from_spec(SPEC) +SPEC.loader.exec_module(PATCH) + +ARDUINO = r''' +#pragma once +#include +#include +#define PINS_COUNT 64 +#define OUTPUT 1 +extern int led_level, writes; +inline int digitalPinToPinName(int pin) { return pin == 62 ? 0xff : pin; } +inline bool validPin(int pin) { + return pin >= 0 && pin < PINS_COUNT && digitalPinToPinName(pin) != 0xff; +} +inline void digitalWrite(int pin, int value) { + assert(validPin(pin)); + led_level = value; + ++writes; +} +inline void digitalToggle(int pin) { digitalWrite(pin, !led_level); } +inline void pinMode(int pin, int) { assert(validPin(pin)); } +''' + +BLUEFRUIT = r''' +#pragma once +#include +using TimerHandle_t = void*; +struct Advertiser { + bool running = false; + bool isRunning() const { return running; } +}; +struct AdafruitBluefruit { + bool ready = false, automatic = true, linked = false; + bool _led_conn = true; + int timer_starts = 0, timer_stops = 0, connection_queries = 0; + Advertiser Advertising; + void autoConnLed(bool enabled) { + automatic = _led_conn = enabled; + if (!enabled && ready) ++timer_stops; + } + unsigned connected() { + assert(ready); + ++connection_queries; + return linked; + } + void _startConnLed() { + assert(ready); + if (automatic) ++timer_starts; + } +#ifdef EXTERNAL_LED_METHOD + void _setConnLed(bool on); +#else + void _setConnLed(bool on) { + assert(ready); +#ifdef LED_BLUE + if (automatic) digitalWrite(LED_BLUE, on ? LED_STATE_ON : !LED_STATE_ON); +#else + (void)on; +#endif + } +#endif +}; +using MockBluefruit = AdafruitBluefruit; +extern MockBluefruit Bluefruit; +''' + +# Exact callback from the pinned Adafruit nRF52 Bluefruit52Lib/src/bluefruit.cpp. +# The build middleware patches this body in its private framework copy. +CALLBACK = r'''#include "bluefruit.h" +static void bluefruit_blinky_cb( TimerHandle_t xTimer ) +{ + (void) xTimer; + digitalToggle(LED_BLUE); +} + +void AdafruitBluefruit::_setConnLed (bool on_off) +{ + if (_led_conn) + { + digitalWrite(LED_BLUE, on_off ? LED_STATE_ON : (1-LED_STATE_ON) ); + } +} +''' + +HARNESS = r''' +#include +MockBluefruit Bluefruit; +int led_level = LED_STATE_ON, writes = 0; +int main() { +#if defined(DISABLE_BLE_LED) || !defined(LED_BLUE) + const bool supported = false; +#else + const bool supported = validPin(LED_BLUE); +#endif + // With the default enabled preference, the helper may configure the owner + // before begin(), but must not query a connection or start a missing timer. + mesh_nrf52::applyBluetoothLedPolicy(); + assert(Bluefruit.automatic == supported); + assert(Bluefruit.timer_starts == 0 && Bluefruit.timer_stops == 0); + assert(Bluefruit.connection_queries == 0); + + // Saved preferences are applied before begin() has created an LED timer. + mesh::leds::setEnabled(false); + mesh_nrf52::applyBluetoothLedPolicy(); + assert(!Bluefruit.automatic); + assert(Bluefruit.timer_starts == 0 && Bluefruit.timer_stops == 0); + assert(Bluefruit.connection_queries == 0); +#if defined(LED_BLUE) + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); +#endif + + // A later stack start must preserve the off preference. + Bluefruit.ready = true; + Bluefruit.Advertising.running = true; + mesh_nrf52::bluetoothLedStarted(); + assert(!Bluefruit.automatic && Bluefruit.timer_starts == 0); + + // Enabling again restores the current state without restarting Bluetooth. + mesh::leds::setEnabled(true); + mesh_nrf52::applyBluetoothLedPolicy(); + assert(Bluefruit.automatic == supported); + if (supported) { + assert(Bluefruit.timer_starts == 1); + assert(led_level == !LED_STATE_ON); + } else { + assert(Bluefruit.timer_starts == 0); + } + + Bluefruit.Advertising.running = false; + Bluefruit.linked = true; + mesh_nrf52::applyBluetoothLedPolicy(); +#if defined(RAK_BOARD) && defined(BLE_PIN_CODE) + if (supported) assert(led_level == !LED_STATE_ON); +#else + if (supported) assert(led_level == LED_STATE_ON); +#endif + mesh::leds::setEnabled(false); + mesh_nrf52::applyBluetoothLedPolicy(); + assert(!Bluefruit.automatic); +#ifdef LED_BLUE + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); + else assert(writes == 0); +#else + assert(writes == 0); +#endif +} +''' + +TIMER_HARNESS = r''' +MockBluefruit Bluefruit; +int led_level = LED_STATE_ON, writes = 0; +int main() { + Bluefruit.ready = true; + // A previously queued callback can still arrive after autoConnLed(false). + mesh::leds::setEnabled(false); + for (int i = 0; i < 4; ++i) { + bluefruit_blinky_cb(nullptr); + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); + } + Bluefruit._setConnLed(true); + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); + mesh::leds::setEnabled(true); + bluefruit_blinky_cb(nullptr); +#ifdef DISABLE_BLE_LED + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); +#else + if (validPin(LED_BLUE)) assert(led_level == LED_STATE_ON); +#endif + // PR #2694: advertising still blinks on a RAK Companion, but connecting + // leaves the LED dark. Other boards retain the steady connected indicator. + Bluefruit.linked = true; + Bluefruit._setConnLed(true); +#if defined(DISABLE_BLE_LED) || (defined(RAK_BOARD) && defined(BLE_PIN_CODE)) + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); +#else + if (validPin(LED_BLUE)) assert(led_level == LED_STATE_ON); +#endif +#if defined(RAK_BOARD) && defined(BLE_PIN_CODE) + // Stopping the advertising timer is asynchronous. Any callback already + // queued when the connection completes must leave the RAK indicator dark. + for (int i = 0; i < 4; ++i) { + bluefruit_blinky_cb(nullptr); + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); + } +#endif + Bluefruit.linked = false; + Bluefruit._setConnLed(false); + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); +#if defined(RAK_BOARD) && defined(BLE_PIN_CODE) + // A later disconnection lets the existing advertising timer blink again. + bluefruit_blinky_cb(nullptr); +#ifdef DISABLE_BLE_LED + if (validPin(LED_BLUE)) assert(led_level == !LED_STATE_ON); +#else + if (validPin(LED_BLUE)) assert(led_level == LED_STATE_ON); +#endif +#endif + if (!validPin(LED_BLUE)) assert(writes == 0); +} +''' + + +def compile_run(case, source, defines, expect_success=True): + if "AdafruitBluefruit::_setConnLed" in source: + defines = [*defines, "EXTERNAL_LED_METHOD"] + compiler = shutil.which("c++") or shutil.which("g++") + case.assertIsNotNone(compiler, "run this host suite where c++ is available") + with tempfile.TemporaryDirectory(prefix="mesh-nrf52-led-policy-") as folder: + folder = Path(folder) + (folder / "Arduino.h").write_text(ARDUINO) + (folder / "bluefruit.h").write_text(BLUEFRUIT) + (folder / "case.cpp").write_text(source) + binary = folder / ("case.exe" if os.name == "nt" else "case") + built = subprocess.run([ + compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-I", str(folder), "-I", str(ROOT / "src"), + *["-D" + value for value in defines], str(folder / "case.cpp"), + "-o", str(binary)], text=True, capture_output=True, timeout=30) + case.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10) + if expect_success: + case.assertEqual(result.returncode, 0, result.stdout + result.stderr) + else: + case.assertNotEqual(result.returncode, 0, "negative control did not fail") + + +class Nrf52LedPolicyTests(unittest.TestCase): + def test_policy_before_start_and_during_advertising_and_connection(self): + for polarity in (0, 1): + for disabled in (False, True): + defines = ["LED_BLUE=12", f"LED_STATE_ON={polarity}"] + if disabled: + defines.append("DISABLE_BLE_LED") + with self.subTest(polarity=polarity, disabled=disabled): + compile_run(self, HARNESS, defines) + + def test_absent_and_unmapped_pins_never_enable_bluefruit_led(self): + for pin in (None, "-1", "255", "PINS_COUNT", "62"): + defines = ["LED_STATE_ON=0"] + ([] if pin is None else ["LED_BLUE=" + pin]) + with self.subTest(pin=pin): + compile_run(self, HARNESS, defines) + + def test_rak_companion_policy_leaves_connection_indicator_off(self): + # https://github.com/meshcore-dev/MeshCore/pull/2694 + for flags in (["RAK_BOARD"], ["BLE_PIN_CODE=123456"], + ["RAK_BOARD", "BLE_PIN_CODE=123456"]): + with self.subTest(flags=flags): + compile_run(self, HARNESS, ["LED_BLUE=12", "LED_STATE_ON=0", *flags]) + + def test_pending_callback_respects_saved_policy_and_compile_time_disable(self): + source = PATCH.patched_led_source(CALLBACK) + TIMER_HARNESS + for polarity in (0, 1): + for disabled in (False, True): + defines = ["LED_BLUE=12", f"LED_STATE_ON={polarity}"] + if disabled: + defines.append("DISABLE_BLE_LED") + with self.subTest(polarity=polarity, disabled=disabled): + compile_run(self, source, defines) + + def test_pending_callback_ignores_invalid_pins(self): + source = PATCH.patched_led_source(CALLBACK) + TIMER_HARNESS + for pin in ("-1", "255", "PINS_COUNT", "62"): + with self.subTest(pin=pin): + compile_run(self, source, ["LED_BLUE=" + pin, "LED_STATE_ON=0"]) + + def test_rak_companion_blinks_while_advertising_then_goes_dark(self): + source = PATCH.patched_led_source(CALLBACK) + TIMER_HARNESS + for flags in (["RAK_BOARD"], ["BLE_PIN_CODE=123456"], + ["RAK_BOARD", "BLE_PIN_CODE=123456"], + ["RAK_BOARD", "BLE_PIN_CODE=123456", "DISABLE_BLE_LED"]): + for polarity in (0, 1): + with self.subTest(flags=flags, polarity=polarity): + compile_run(self, source, + ["LED_BLUE=12", f"LED_STATE_ON={polarity}", *flags]) + + def test_unpatched_callback_negative_control(self): + compile_run(self, "#include \n" + CALLBACK + TIMER_HARNESS, + ["LED_BLUE=12", "LED_STATE_ON=0"], expect_success=False) + + def test_patch_is_idempotent_and_rejects_unrecognized_callback(self): + patched = PATCH.patched_led_source(CALLBACK) + self.assertEqual(PATCH.patched_led_source(patched), patched) + with self.assertRaises(RuntimeError): + PATCH.patched_led_source(CALLBACK.replace("digitalToggle(LED_BLUE);", "changed();")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_status_led_policy.py b/test/test_status_led_policy.py new file mode 100644 index 000000000..0d939f3c6 --- /dev/null +++ b/test/test_status_led_policy.py @@ -0,0 +1,134 @@ +"""Run the common LED policy and Xiao S3 LED initialization with fake GPIO.""" + +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +ARDUINO = r''' +#pragma once +#include +#include +#define LOW 0 +#define HIGH 1 +#define INPUT 0 +#define OUTPUT 1 +struct Event { int kind, pin, value; }; +extern std::vector events; +inline void digitalWrite(int pin, int value) { events.push_back({0, pin, value}); } +inline void pinMode(int pin, int mode) { events.push_back({1, pin, mode}); } +inline void analogWrite(int pin, int value) { events.push_back({2, pin, value}); } +''' + +HARNESS = r''' +#include +#include +#include +std::vector events; +int main() { + // Enabling permits the existing heartbeat owner to run; it does not light + // an idle LED or change its output mode as a side effect of the command. + assert(mesh::leds::enabled()); + mesh::leds::applyStatusPolicy(); + assert(events.empty()); + mesh::leds::setEnabled(false); + mesh::leds::applyStatusPolicy(); + assert(!mesh::leds::enabled()); + assert(events.size() == EXPECT_EVENTS); +#if EXPECT_EVENTS == 2 + assert(events[0].kind == 0 && events[0].pin == PIN_STATUS_LED); + assert(events[0].value == EXPECT_OFF); + assert(events[1].kind == 1 && events[1].pin == PIN_STATUS_LED); + assert(events[1].value == OUTPUT); // Set the off latch before enabling output. +#elif EXPECT_EVENTS == 3 + const int pins[] = {PIN_STATUS_LED_R, PIN_STATUS_LED_G, PIN_STATUS_LED_B}; + for (unsigned i = 0; i < 3; ++i) + assert(events[i].kind == 2 && events[i].pin == pins[i] && events[i].value == 0); +#endif + events.clear(); + mesh::leds::setEnabled(true); + mesh::leds::applyStatusPolicy(); + assert(mesh::leds::enabled() && events.empty()); +} +''' + + +def compile_run(case, source, defines): + compiler = shutil.which("c++") or shutil.which("g++") + case.assertIsNotNone(compiler, "run this host suite where c++ is available") + with tempfile.TemporaryDirectory(prefix="mesh-status-led-") as folder: + folder = Path(folder) + (folder / "Arduino.h").write_text(ARDUINO) + (folder / "target.h").write_text("#pragma once\n" + "\n".join( + "#define " + define.replace("=", " ", 1) for define in defines)) + (folder / "case.cpp").write_text(source) + binary = folder / "case" + built = subprocess.run([ + compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-I", str(folder), "-I", str(ROOT / "src"), + str(ROOT / "src/helpers/LedPolicy.cpp"), str(folder / "case.cpp"), + "-o", str(binary)], text=True, capture_output=True, timeout=30) + case.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10) + case.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + +class StatusLedPolicyTests(unittest.TestCase): + def test_off_is_immediate_and_on_leaves_heartbeat_in_control(self): + cases = ( + ("active-high", ["PIN_STATUS_LED=12", "LED_STATE_ON=HIGH", + "EXPECT_OFF=LOW", "EXPECT_EVENTS=2"]), + ("active-low", ["PIN_STATUS_LED=12", "LED_STATE_ON=LOW", + "EXPECT_OFF=HIGH", "EXPECT_EVENTS=2"]), + ("default-polarity", ["PIN_STATUS_LED=12", "EXPECT_OFF=LOW", + "EXPECT_EVENTS=2"]), + ("absent", ["EXPECT_EVENTS=0"]), + ("negative-pin", ["PIN_STATUS_LED=-1", "EXPECT_EVENTS=0"]), + ("rgb", ["PIN_STATUS_LED_R=3", "PIN_STATUS_LED_G=24", + "PIN_STATUS_LED_B=28", "PIN_STATUS_LED=24", "EXPECT_EVENTS=3"]), + ) + for name, defines in cases: + with self.subTest(board=name): + compile_run(self, HARNESS, defines) + + def test_xiao_s3_uses_active_low_output_during_radio_init(self): + ini = (ROOT / "variants/xiao_s3/platformio.ini").read_text() + names = ("PIN_STATUS_LED", "LED_STATE_ON", "P_LORA_SCLK", + "P_LORA_MISO", "P_LORA_MOSI") + definitions = {name: re.findall(r"-D\s+" + name + r"=([^\s;]+)", ini) + for name in names} + self.assertEqual(definitions["PIN_STATUS_LED"], ["21"]) + self.assertEqual(definitions["LED_STATE_ON"], ["LOW"]) + for name in names: + self.assertEqual(len(definitions[name]), 1, name) + method = extract_braced((ROOT / "variants/xiao_s3/target.cpp").read_text(), + "bool radio_init()") + source = r''' +#include +#include +std::vector events; +struct Clock { template void begin(T...) {} } fallback_clock, rtc_clock; +struct Spi { void begin(int, int, int) {} } spi; +struct Radio { bool std_init(Spi*) { return true; } } radio; +int Wire = 0; +@METHOD@ +int main() { + assert(radio_init()); + assert(events.size() == 3); + assert(events[0].kind == 0 && events[0].pin == 21 && events[0].value == HIGH); + assert(events[1].kind == 1 && events[1].pin == 21 && events[1].value == OUTPUT); + assert(events[2].kind == 1 && events[2].pin == 48 && events[2].value == OUTPUT); +} +''' + compile_run(self, source.replace("@METHOD@", method), + [name + "=" + definitions[name][0] for name in names]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_tbeam_charge_target.py b/test/test_tbeam_charge_target.py index 73d5d8140..bc261efaa 100644 --- a/test/test_tbeam_charge_target.py +++ b/test/test_tbeam_charge_target.py @@ -231,6 +231,7 @@ MOCK_SDK = r''' #pragma once #include #include +#include #include #include #include @@ -291,6 +292,7 @@ public: int startup_reason = 0; virtual ~ESP32Board() {} void begin() {} + virtual void applyLedPolicy() {} virtual void onBeforeTransmit() {} virtual void onAfterTransmit() {} virtual bool getBatteryChargeTarget(uint16_t&) { return false; } @@ -305,6 +307,7 @@ constexpr int XPOWERS_DCDC2 = 0, XPOWERS_DCDC3 = 0, XPOWERS_DCDC4 = 0; constexpr int XPOWERS_DCDC5 = 0, XPOWERS_ALDO1 = 0, XPOWERS_ALDO2 = 0; constexpr int XPOWERS_ALDO3 = 0, XPOWERS_ALDO4 = 0, XPOWERS_BLDO1 = 0; constexpr int XPOWERS_BLDO2 = 0, XPOWERS_VBACKUP = 0, XPOWERS_CHG_LED_CTRL_CHG = 0; +constexpr int XPOWERS_CHG_LED_OFF = 1; constexpr int XPOWERS_DLDO1 = 0, XPOWERS_DLDO2 = 0; constexpr int XPOWERS_AXP2101_DCDC4_VOL2_MAX = 0, XPOWERS_AXP192_ALL_IRQ = 0; constexpr int XPOWERS_AXP2101_ALL_IRQ = 0, XPOWERS_AXP192_CHG_CUR_450MA = 0; diff --git a/test/test_wio_tracker_l2_gps_power.py b/test/test_wio_tracker_l2_gps_power.py index b9018c295..e47e943b8 100644 --- a/test/test_wio_tracker_l2_gps_power.py +++ b/test/test_wio_tracker_l2_gps_power.py @@ -114,9 +114,11 @@ inline TwoWire Wire; ESP_BOARD = r''' #pragma once #include +#include class ESP32Board { public: virtual void begin() {} + virtual void applyLedPolicy() {} virtual uint16_t getBattMilliVolts() { return 0; } virtual const char* getManufacturerName() const { return "host"; } virtual void onBeforeTransmit() {} diff --git a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h index b18ea8afa..eba6a7176 100644 --- a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h +++ b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h @@ -34,8 +34,13 @@ public: } #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { diff --git a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h index 0d2b9f472..bf64b19d8 100644 --- a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h +++ b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h @@ -34,8 +34,13 @@ public: } #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { diff --git a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h index e694b15f4..33f51043b 100644 --- a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h +++ b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h @@ -34,8 +34,13 @@ public: } #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { diff --git a/variants/heltec_rc32/HeltecRC32Board.cpp b/variants/heltec_rc32/HeltecRC32Board.cpp index 1f3048b51..5fe3036cb 100644 --- a/variants/heltec_rc32/HeltecRC32Board.cpp +++ b/variants/heltec_rc32/HeltecRC32Board.cpp @@ -56,7 +56,7 @@ void HeltecRC32Board::powerOff() { } void HeltecRC32Board::onBeforeTransmit() { - digitalWrite(P_LORA_TX_LED, HIGH); + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); } void HeltecRC32Board::onAfterTransmit() { diff --git a/variants/heltec_t096/T096Board.cpp b/variants/heltec_t096/T096Board.cpp index 2d87d11a9..eaa91d5b1 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -56,8 +56,13 @@ void T096Board::begin() { delay(1); } +void T096Board::applyLedPolicy() { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); +} + void T096Board::onBeforeTransmit() { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on loRaFEMControl.setTxModeEnable(); } diff --git a/variants/heltec_t096/T096Board.h b/variants/heltec_t096/T096Board.h index 6980e527c..1dd40fafd 100644 --- a/variants/heltec_t096/T096Board.h +++ b/variants/heltec_t096/T096Board.h @@ -25,6 +25,7 @@ public: void attachDynamicPrefs(KeyValueStore* prefs); bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; + void applyLedPolicy() override; void onBeforeTransmit(void) override; void onAfterTransmit(void) override; uint16_t getBattMilliVolts() override; diff --git a/variants/heltec_t1/T1Board.cpp b/variants/heltec_t1/T1Board.cpp index 8804d579b..51d103c6e 100644 --- a/variants/heltec_t1/T1Board.cpp +++ b/variants/heltec_t1/T1Board.cpp @@ -58,8 +58,13 @@ void T1Board::begin() { delay(1); } +void T1Board::applyLedPolicy() { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); +} + void T1Board::onBeforeTransmit() { - digitalWrite(P_LORA_TX_LED, LED_STATE_ON); + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LED_STATE_ON); } void T1Board::onAfterTransmit() { diff --git a/variants/heltec_t1/T1Board.h b/variants/heltec_t1/T1Board.h index 3e9afd1b9..812bc7914 100644 --- a/variants/heltec_t1/T1Board.h +++ b/variants/heltec_t1/T1Board.h @@ -18,6 +18,7 @@ public: T1Board() : periph_power(PIN_TFT_VDD_CTL, PIN_TFT_VDD_CTL_ACTIVE), NRF52Board("T1_OTA") {} void begin(); + void applyLedPolicy() override; void onBeforeTransmit() override; void onAfterTransmit() override; uint16_t getBattMilliVolts() override; diff --git a/variants/heltec_t114/T114Board.h b/variants/heltec_t114/T114Board.h index e3ea410e0..e398066c3 100644 --- a/variants/heltec_t114/T114Board.h +++ b/variants/heltec_t114/T114Board.h @@ -20,8 +20,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp index 801ba95bd..6e19d2a9d 100644 --- a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp @@ -60,8 +60,13 @@ void HeltecTowerV2Board::begin() { loRaFEMControl.init(); } +void HeltecTowerV2Board::applyLedPolicy() { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); +} + void HeltecTowerV2Board::onBeforeTransmit() { - digitalWrite(P_LORA_TX_LED, LED_STATE_ON); + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LED_STATE_ON); loRaFEMControl.setTxModeEnable(); } diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.h b/variants/heltec_tower_v2/HeltecTowerV2Board.h index d6cc85ef7..375f1a485 100644 --- a/variants/heltec_tower_v2/HeltecTowerV2Board.h +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.h @@ -16,6 +16,7 @@ public: HeltecTowerV2Board() : NRF52Board("TOWER_V2_OTA") {} void begin(); + void applyLedPolicy() override; void onBeforeTransmit() override; void onAfterTransmit() override; uint16_t getBattMilliVolts() override; diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 51a037ea6..c7f0672fc 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -27,7 +27,7 @@ void HeltecTrackerV2Board::begin() { } void HeltecTrackerV2Board::onBeforeTransmit(void) { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on loRaFEMControl.setTxModeEnable(); } diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 24483c5e4..b6984e606 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -24,7 +24,7 @@ void HeltecV4Board::begin() { } void HeltecV4Board::onBeforeTransmit(void) { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on loRaFEMControl.setTxModeEnable(); } diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index f1ce286c4..37f1a18e9 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -37,7 +37,7 @@ void HeltecV4R8Board::begin() { } void HeltecV4R8Board::onBeforeTransmit(void) { - digitalWrite(P_LORA_TX_LED, HIGH); + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); loRaFEMControl.setTxModeEnable(); } diff --git a/variants/ikoka_handheld_nrf/IkokaNrf52Board.h b/variants/ikoka_handheld_nrf/IkokaNrf52Board.h index 954b34785..53e27b0d9 100644 --- a/variants/ikoka_handheld_nrf/IkokaNrf52Board.h +++ b/variants/ikoka_handheld_nrf/IkokaNrf52Board.h @@ -12,8 +12,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off diff --git a/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h b/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h index 7d1b6ba00..92bf74627 100644 --- a/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h +++ b/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h @@ -12,8 +12,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on #if defined(LED_BLUE) // turn off that annoying blue LED before transmitting digitalWrite(LED_BLUE, HIGH); diff --git a/variants/ikoka_stick_nrf/IkokaStickNRFBoard.h b/variants/ikoka_stick_nrf/IkokaStickNRFBoard.h index 6ab046c77..b664861bb 100644 --- a/variants/ikoka_stick_nrf/IkokaStickNRFBoard.h +++ b/variants/ikoka_stick_nrf/IkokaStickNRFBoard.h @@ -12,8 +12,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on #if defined(LED_BLUE) // turn off that annoying blue LED before transmitting digitalWrite(LED_BLUE, HIGH); diff --git a/variants/keepteen_lt1/KeepteenLT1Board.h b/variants/keepteen_lt1/KeepteenLT1Board.h index 25d4d8676..d32dfcd23 100644 --- a/variants/keepteen_lt1/KeepteenLT1Board.h +++ b/variants/keepteen_lt1/KeepteenLT1Board.h @@ -30,8 +30,13 @@ public: } #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h b/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h index f7ef536ba..136601d06 100644 --- a/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h +++ b/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h @@ -13,8 +13,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index 4efa9f4a2..86a6573f0 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -84,8 +84,12 @@ void TBeam1WBoard::powerCycleRadio() { power_off_level, digitalRead(SX126X_POWER_EN)); } +void TBeam1WBoard::applyLedPolicy() { + if (!mesh::leds::enabled()) digitalWrite(LED_PIN, LOW); +} + void TBeam1WBoard::onBeforeTransmit() { - digitalWrite(LED_PIN, HIGH); // TX LED on + if (mesh::leds::enabled()) digitalWrite(LED_PIN, HIGH); // TX LED on portENTER_CRITICAL(&_fan_mux); _tx_active = true; if (_mode == FAN_AUTO && !_stopped) { diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index 9bd1574fb..9c8db6346 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -71,6 +71,7 @@ public: void begin(); void attachDynamicPrefs(KeyValueStore* prefs); bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; + void applyLedPolicy() override; void onBeforeTransmit() override; void onAfterTransmit() override; uint16_t getBattMilliVolts() override; diff --git a/variants/lilygo_tdeck/TDeckBoard.h b/variants/lilygo_tdeck/TDeckBoard.h index e2844360d..48e9dfd03 100644 --- a/variants/lilygo_tdeck/TDeckBoard.h +++ b/variants/lilygo_tdeck/TDeckBoard.h @@ -13,13 +13,17 @@ public: void begin(); #ifdef P_LORA_TX_LED - void onBeforeTransmit() override{ - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276 - } + void applyLedPolicy() override { + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); + } - void onAfterTransmit() override{ - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276 - } + void onBeforeTransmit() override{ + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276 + } + + void onAfterTransmit() override{ + digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276 + } #endif uint16_t getBattMilliVolts() { @@ -41,4 +45,4 @@ public: const char* getManufacturerName() const{ return "LilyGo T-Deck"; } -}; \ No newline at end of file +}; diff --git a/variants/lilygo_techo_card/TechoCardBoard.cpp b/variants/lilygo_techo_card/TechoCardBoard.cpp index 8a3a54b16..86dbf53db 100644 --- a/variants/lilygo_techo_card/TechoCardBoard.cpp +++ b/variants/lilygo_techo_card/TechoCardBoard.cpp @@ -57,7 +57,16 @@ uint16_t TechoCardBoard::getBattMilliVolts() { return (uint16_t)((float)adcvalue * REAL_VBAT_MV_PER_LSB); } +void TechoCardBoard::applyLedPolicy() { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) { + Led_A.setPixelColor(0, 0, 0, 0); + Led_A.show(); + } +} + void TechoCardBoard::onBeforeTransmit() { + if (!mesh::leds::enabled()) return; Led_A.setPixelColor(0, 20, 20, 20); // turn TX LED on Led_A.show(); } diff --git a/variants/lilygo_techo_card/TechoCardBoard.h b/variants/lilygo_techo_card/TechoCardBoard.h index a2ee3ea1e..9fd6935df 100644 --- a/variants/lilygo_techo_card/TechoCardBoard.h +++ b/variants/lilygo_techo_card/TechoCardBoard.h @@ -20,6 +20,7 @@ public: TechoCardBoard() : NRF52Board("TECHO_OTA") {} void begin(); uint16_t getBattMilliVolts() override; + void applyLedPolicy() override; void onBeforeTransmit(void) override; void onAfterTransmit(void) override; diff --git a/variants/meshnology_w12/MeshnologyW12Board.cpp b/variants/meshnology_w12/MeshnologyW12Board.cpp index e40912524..d877d2ed1 100644 --- a/variants/meshnology_w12/MeshnologyW12Board.cpp +++ b/variants/meshnology_w12/MeshnologyW12Board.cpp @@ -29,8 +29,12 @@ void MeshnologyW12Board::begin() { } } + void MeshnologyW12Board::applyLedPolicy() { + if (!mesh::leds::enabled()) neopixelWrite(NEOPIXEL_LED, 0, 0, 0); + } + void MeshnologyW12Board::onBeforeTransmit(void) { - neopixelWrite(NEOPIXEL_LED, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS); // turn TX neopixel on (White) + if (mesh::leds::enabled()) neopixelWrite(NEOPIXEL_LED, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS); // turn TX neopixel on (White) } void MeshnologyW12Board::onAfterTransmit(void) { diff --git a/variants/meshnology_w12/MeshnologyW12Board.h b/variants/meshnology_w12/MeshnologyW12Board.h index bf87ca11b..f08acc1e3 100644 --- a/variants/meshnology_w12/MeshnologyW12Board.h +++ b/variants/meshnology_w12/MeshnologyW12Board.h @@ -22,6 +22,7 @@ public: MeshnologyW12Board() : periph_power(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE) { } void begin(); + void applyLedPolicy() override; void onBeforeTransmit(void) override; void onAfterTransmit(void) override; void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); diff --git a/variants/meshtiny/MeshtinyBoard.h b/variants/meshtiny/MeshtinyBoard.h index 2d3e5de96..e5841e8b5 100644 --- a/variants/meshtiny/MeshtinyBoard.h +++ b/variants/meshtiny/MeshtinyBoard.h @@ -13,8 +13,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.h b/variants/meshtracker_x1/MeshTrackerX1Board.h index 02df7b683..99ae1a6be 100644 --- a/variants/meshtracker_x1/MeshTrackerX1Board.h +++ b/variants/meshtracker_x1/MeshTrackerX1Board.h @@ -78,7 +78,7 @@ public: // set led on and wait for button release before poweroff #ifdef LED_PIN - digitalWrite(LED_PIN, HIGH); + if (mesh::leds::enabled()) digitalWrite(LED_PIN, HIGH); #endif #ifdef BUTTON_PIN while(digitalRead(BUTTON_PIN)); diff --git a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h index e4bbc7d06..5ce5cad20 100644 --- a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h +++ b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h @@ -70,8 +70,13 @@ public: } #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH);// turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH);// turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/muziworks_r1_neo/R1NeoBoard.h b/variants/muziworks_r1_neo/R1NeoBoard.h index ecd75b717..2e21aa2cb 100644 --- a/variants/muziworks_r1_neo/R1NeoBoard.h +++ b/variants/muziworks_r1_neo/R1NeoBoard.h @@ -26,8 +26,13 @@ public: #endif #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on #if defined(LED_BLUE) // turn off that annoying blue LED before transmitting digitalWrite(LED_BLUE, LOW); diff --git a/variants/rak11310/RAK11310Board.h b/variants/rak11310/RAK11310Board.h index 6bef47b14..f58d424d4 100644 --- a/variants/rak11310/RAK11310Board.h +++ b/variants/rak11310/RAK11310Board.h @@ -24,7 +24,11 @@ public: void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op #ifdef P_LORA_TX_LED - void onBeforeTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); } + void applyLedPolicy() override { + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + + void onBeforeTransmit() override { if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); } #endif diff --git a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h index 9368d17e8..97aa0261f 100644 --- a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h +++ b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h @@ -14,8 +14,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) && defined(LED_STATE_ON) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); // turn TX LED off @@ -53,7 +58,7 @@ public: // set led on and wait for button release before poweroff #ifdef LED_PIN - digitalWrite(LED_PIN, HIGH); + if (mesh::leds::enabled()) digitalWrite(LED_PIN, HIGH); #endif #ifdef BUTTON_PIN // wismesh tag uses LOW to indicate button is pressed, wait until it goes HIGH to indicate it was released diff --git a/variants/rpi_picow/PicoWBoard.h b/variants/rpi_picow/PicoWBoard.h index d0037558e..72d8d834a 100644 --- a/variants/rpi_picow/PicoWBoard.h +++ b/variants/rpi_picow/PicoWBoard.h @@ -19,8 +19,12 @@ public: void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op + void applyLedPolicy() override { + if (!mesh::leds::enabled()) digitalWrite(LED_BUILTIN, LOW); + } + void onBeforeTransmit() override { - digitalWrite(LED_BUILTIN, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(LED_BUILTIN, HIGH); // turn TX LED on } void onAfterTransmit() override { diff --git a/variants/sensecap_solar/SenseCapSolarBoard.h b/variants/sensecap_solar/SenseCapSolarBoard.h index 0fc2c5e5c..b2342b963 100644 --- a/variants/sensecap_solar/SenseCapSolarBoard.h +++ b/variants/sensecap_solar/SenseCapSolarBoard.h @@ -15,8 +15,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index a08fc4e4f..f0b9dd7dd 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -73,7 +73,7 @@ public: #endif // set led on and wait for button release before poweroff #ifdef LED_PIN - digitalWrite(LED_PIN, HIGH); + if (mesh::leds::enabled()) digitalWrite(LED_PIN, HIGH); #endif #ifdef BUTTON_PIN while(digitalRead(BUTTON_PIN)); diff --git a/variants/thinknode_m1/ThinkNodeM1Board.h b/variants/thinknode_m1/ThinkNodeM1Board.h index e5af3690d..f3eb8ed66 100644 --- a/variants/thinknode_m1/ThinkNodeM1Board.h +++ b/variants/thinknode_m1/ThinkNodeM1Board.h @@ -20,8 +20,13 @@ public: uint16_t getBattMilliVolts() override; #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/thinknode_m3/ThinkNodeM3Board.h b/variants/thinknode_m3/ThinkNodeM3Board.h index 496ee2e0e..2584fd4cb 100644 --- a/variants/thinknode_m3/ThinkNodeM3Board.h +++ b/variants/thinknode_m3/ThinkNodeM3Board.h @@ -19,8 +19,13 @@ public: uint16_t getBattMilliVolts() override; #ifdef P_LORA_TX_LED + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); // turn TX LED off diff --git a/variants/thinknode_m4/ThinkNodeM4Board.h b/variants/thinknode_m4/ThinkNodeM4Board.h index c2cae54b6..72a9d97b4 100644 --- a/variants/thinknode_m4/ThinkNodeM4Board.h +++ b/variants/thinknode_m4/ThinkNodeM4Board.h @@ -20,8 +20,13 @@ public: uint16_t getBattMilliVolts() override; #ifdef P_LORA_TX_LED + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); // turn TX LED off @@ -48,7 +53,7 @@ public: NRF52Board::shutdownPeripherals(); #ifdef LED_STATUS - digitalWrite(LED_STATUS, HIGH); + if (mesh::leds::enabled()) digitalWrite(LED_STATUS, HIGH); #endif #ifdef BUTTON_PIN while(!digitalRead(BUTTON_PIN)); diff --git a/variants/thinknode_m5/ThinknodeM5Board.h b/variants/thinknode_m5/ThinknodeM5Board.h index 57ff0d00e..eb2ea3394 100644 --- a/variants/thinknode_m5/ThinknodeM5Board.h +++ b/variants/thinknode_m5/ThinknodeM5Board.h @@ -15,8 +15,12 @@ public: uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; + void applyLedPolicy() override { + if (!mesh::leds::enabled()) expander.digitalWrite(EXP_PIN_LED, LOW); + } + void onBeforeTransmit() override { - expander.digitalWrite(EXP_PIN_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) expander.digitalWrite(EXP_PIN_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { expander.digitalWrite(EXP_PIN_LED, LOW); // turn TX LED off diff --git a/variants/thinknode_m6/ThinkNodeM6Board.h b/variants/thinknode_m6/ThinkNodeM6Board.h index 3203ab718..6dde0f13c 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -24,8 +24,13 @@ public: uint16_t getBattMilliVolts() override; #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/thinknode_m7/ThinkNodeM7Board.h b/variants/thinknode_m7/ThinkNodeM7Board.h index 7e705bde7..f80d79c6e 100644 --- a/variants/thinknode_m7/ThinkNodeM7Board.h +++ b/variants/thinknode_m7/ThinkNodeM7Board.h @@ -12,8 +12,12 @@ class ThinkNodeM7Board : public ESP32Board { public: void begin(); const char* getManufacturerName() const override; + void applyLedPolicy() override { + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); diff --git a/variants/thinknode_m8/ThinkNodeM8Board.h b/variants/thinknode_m8/ThinkNodeM8Board.h index 73aeb1f5d..7f38ba53a 100644 --- a/variants/thinknode_m8/ThinkNodeM8Board.h +++ b/variants/thinknode_m8/ThinkNodeM8Board.h @@ -20,8 +20,13 @@ public: uint16_t getBattMilliVolts() override; #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/waveshare_rp2040_lora/WaveshareBoard.h b/variants/waveshare_rp2040_lora/WaveshareBoard.h index ecf3bdd23..b1bdca56b 100644 --- a/variants/waveshare_rp2040_lora/WaveshareBoard.h +++ b/variants/waveshare_rp2040_lora/WaveshareBoard.h @@ -36,7 +36,11 @@ public: void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op #ifdef P_LORA_TX_LED - void onBeforeTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); } + void applyLedPolicy() override { + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + + void onBeforeTransmit() override { if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); } #endif diff --git a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h index d8a17829c..bb1e314e7 100644 --- a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h @@ -19,8 +19,13 @@ public: bool canControlLoRaFemPaGain() const override { return true; } #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 9dbe85642..efa232fa7 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -13,8 +13,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.cpp b/variants/wio-tracker-l2/WioTrackerL2Board.cpp index c68a5e1de..f521d3150 100644 --- a/variants/wio-tracker-l2/WioTrackerL2Board.cpp +++ b/variants/wio-tracker-l2/WioTrackerL2Board.cpp @@ -146,7 +146,7 @@ void WioTrackerL2Board::setLed(bool on) { // P10 doubles as GNSS wakeup and idles HIGH; blink = brief LOW pulses so // the GPS never sees a sustained low level if (expander_ok) { - expWritePin(EXP_PIN_USER_LED, !on); + expWritePin(EXP_PIN_USER_LED, !(on && mesh::leds::enabled())); } } diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.h b/variants/wio-tracker-l2/WioTrackerL2Board.h index c5644e2f2..3a7f8ecbe 100644 --- a/variants/wio-tracker-l2/WioTrackerL2Board.h +++ b/variants/wio-tracker-l2/WioTrackerL2Board.h @@ -49,6 +49,9 @@ public: void onBeforeTransmit() override { setLed(true); } void onAfterTransmit() override { setLed(false); } + void applyLedPolicy() override { + if (!mesh::leds::enabled()) setLed(false); + } // Mesh/user LED lives on the IO expander, not a GPIO void setLed(bool on); diff --git a/variants/wio_wm1110/WioWM1110Board.cpp b/variants/wio_wm1110/WioWM1110Board.cpp index 2825e5543..40e374174 100644 --- a/variants/wio_wm1110/WioWM1110Board.cpp +++ b/variants/wio_wm1110/WioWM1110Board.cpp @@ -13,7 +13,7 @@ void WioWM1110Board::begin() { pinMode(LED_RED, OUTPUT); pinMode(SENSOR_POWER_PIN, OUTPUT); - digitalWrite(LED_GREEN, HIGH); + digitalWrite(LED_GREEN, mesh::leds::enabled() ? HIGH : LOW); digitalWrite(LED_RED, LOW); digitalWrite(SENSOR_POWER_PIN, LOW); diff --git a/variants/wio_wm1110/WioWM1110Board.h b/variants/wio_wm1110/WioWM1110Board.h index 26f95c868..310b72255 100644 --- a/variants/wio_wm1110/WioWM1110Board.h +++ b/variants/wio_wm1110/WioWM1110Board.h @@ -17,8 +17,14 @@ public: void begin(); #if defined(LED_GREEN) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + digitalWrite(LED_GREEN, mesh::leds::enabled() ? HIGH : LOW); + if (!mesh::leds::enabled()) digitalWrite(LED_RED, LOW); + } + void onBeforeTransmit() override { - digitalWrite(LED_RED, HIGH); + if (mesh::leds::enabled()) digitalWrite(LED_RED, HIGH); } void onAfterTransmit() override { digitalWrite(LED_RED, LOW); diff --git a/variants/xiao_c3/XiaoC3Board.h b/variants/xiao_c3/XiaoC3Board.h index 61d2b1d01..67826716a 100644 --- a/variants/xiao_c3/XiaoC3Board.h +++ b/variants/xiao_c3/XiaoC3Board.h @@ -42,7 +42,7 @@ public: #if defined(LORA_TX_BOOST_PIN) || defined(P_LORA_TX_LED) void onBeforeTransmit() override { #if defined(P_LORA_TX_LED) - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on #endif #if defined(LORA_TX_BOOST_PIN) digitalWrite(LORA_TX_BOOST_PIN, LOW); diff --git a/variants/xiao_nrf52/XiaoNrf52Board.h b/variants/xiao_nrf52/XiaoNrf52Board.h index 0c97a1392..d4892fcbf 100644 --- a/variants/xiao_nrf52/XiaoNrf52Board.h +++ b/variants/xiao_nrf52/XiaoNrf52Board.h @@ -21,8 +21,13 @@ public: void begin(); #if defined(P_LORA_TX_LED) + void applyLedPolicy() override { + NRF52Board::applyLedPolicy(); + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); + } + void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on + if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off @@ -43,7 +48,7 @@ public: NRF52Board::shutdownPeripherals(); // set led on and wait for button release before poweroff - digitalWrite(PIN_LED, LOW); + if (mesh::leds::enabled()) digitalWrite(PIN_LED, LOW); #ifdef PIN_USER_BTN while(digitalRead(PIN_USER_BTN) == USER_BTN_PRESSED); #endif diff --git a/variants/xiao_rp2040/XiaoRP2040Board.h b/variants/xiao_rp2040/XiaoRP2040Board.h index bcc2d5cb6..3e4afd5e1 100644 --- a/variants/xiao_rp2040/XiaoRP2040Board.h +++ b/variants/xiao_rp2040/XiaoRP2040Board.h @@ -34,7 +34,11 @@ public: void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op #ifdef P_LORA_TX_LED - void onBeforeTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); } + void applyLedPolicy() override { + if (!mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, LOW); + } + + void onBeforeTransmit() override { if (mesh::leds::enabled()) digitalWrite(P_LORA_TX_LED, HIGH); } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); } #endif diff --git a/variants/xiao_s3/platformio.ini b/variants/xiao_s3/platformio.ini index 0dc9ba618..3910f4ce4 100644 --- a/variants/xiao_s3/platformio.ini +++ b/variants/xiao_s3/platformio.ini @@ -18,6 +18,7 @@ build_flags = ${esp32_base.build_flags} -D P_LORA_MOSI=9 ; D10 -D PIN_USER_BTN=-1 ; NC -D PIN_STATUS_LED=21 ; Orange user led, LOW=On + -D LED_STATE_ON=LOW -D PIN_BOARD_SDA=D6 ; D6=43 -D PIN_BOARD_SCL=D7 ; D7=44 -D SX126X_RXEN=6 ; D5 diff --git a/variants/xiao_s3/target.cpp b/variants/xiao_s3/target.cpp index a7ae7dd8e..1b4e11a24 100644 --- a/variants/xiao_s3/target.cpp +++ b/variants/xiao_s3/target.cpp @@ -24,7 +24,8 @@ EnvironmentSensorManager sensors; bool radio_init() { fallback_clock.begin(); rtc_clock.begin(Wire); - pinMode(21, INPUT); + digitalWrite(PIN_STATUS_LED, !LED_STATE_ON); + pinMode(PIN_STATUS_LED, OUTPUT); pinMode(48, OUTPUT); #if defined(P_LORA_SCLK) From f57dffa2bed5db51978d55d9f8255a25a3c25620 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 20 Oct 2026 09:33:43 -0700 Subject: [PATCH 2/3] Add saved Companion sound and vibration controls Default fresh settings to sound off and vibration on, and keep existing saved choices. Apply changes immediately across all three UIs and make custom notification patterns respect the same mute controls. Keep the Wio Mini Companion within its existing flash boundary by storing its LED switch in a reserved preference byte and shortening CLI help without removing commands. Add coverage for live output changes, persistence, and storage failures. Adapted from https://github.com/meshcore-dev/MeshCore/pull/3603. Co-authored-by: R0ck --- .github/workflows/run-unit-tests.yml | 7 + docs/cli_commands.md | 19 ++ examples/companion_radio/AbstractUITask.h | 1 + examples/companion_radio/MyMesh.cpp | 121 ++++----- examples/companion_radio/MyMesh.h | 4 + examples/companion_radio/NodePrefs.h | 6 +- examples/companion_radio/Notifications.cpp | 82 ++++++ examples/companion_radio/PrefsStorageLayout.h | 4 + examples/companion_radio/main.cpp | 2 + examples/companion_radio/ui-new/UITask.cpp | 23 +- examples/companion_radio/ui-new/UITask.h | 3 +- examples/companion_radio/ui-orig/UITask.cpp | 27 +- examples/companion_radio/ui-orig/UITask.h | 3 +- examples/companion_radio/ui-tiny/UITask.cpp | 23 +- examples/companion_radio/ui-tiny/UITask.h | 3 +- src/helpers/ui/DRV2605Vibration.cpp | 2 +- src/helpers/ui/DRV2605Vibration.h | 2 +- src/helpers/ui/GenericVibration.cpp | 6 + src/helpers/ui/GenericVibration.h | 6 +- src/helpers/ui/buzzer.cpp | 18 +- test/fixtures/companion_ui_deadlines.cpp | 1 + test/test_button_voice_actions.py | 10 +- test/test_companion_alert_control.py | 217 +++++++++++++++ test/test_companion_alert_drivers.py | 248 ++++++++++++++++++ test/test_companion_compact_led_control.py | 227 ++++++++++++++++ .../test_companion_preferences_transaction.py | 4 + test/test_companion_prefs_image.py | 104 +++++++- test/test_companion_reader.py | 1 + test/test_drv2605_power.py | 4 +- test/test_gps_voice_buttons.py | 4 +- 30 files changed, 1074 insertions(+), 108 deletions(-) create mode 100644 test/test_companion_alert_control.py create mode 100644 test/test_companion_alert_drivers.py create mode 100644 test/test_companion_compact_led_control.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e1f7443b9..8fb846fc7 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -971,7 +971,14 @@ jobs: python3 -B test/test_ui_startup_windows.py -v node test/test_notification_builder.js python3 -B test/test_notification_settings.py + python3 -B test/test_companion_alert_control.py -v + python3 -B test/test_companion_compact_led_control.py -v + python3 -B test/test_companion_alert_drivers.py -v python3 -B test/test_notification_led_ownership.py -v + python3 -B test/test_led_settings.py -v + python3 -B test/test_board_led_policy.py -v + python3 -B test/test_status_led_policy.py -v + python3 -B test/test_nrf52_led_policy.py -v python3 -B test/test_mono_led_rollover.py -v python3 -B test/test_backlight_poll_rollover.py -v python3 -B test/test_rgb_led_rollover.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 472f0d5b7..d38a72f7d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1958,6 +1958,25 @@ the verified pin assignments, startup fixes, and saved amplifier settings. --- +#### Turn Companion sound and vibration on or off + +`get buzzer` and `set buzzer on|off` control the Companion's buzzer. Devices +with a vibration motor also support `get vibration` and `set vibration on|off`. +Changes take effect immediately, are saved across reboots, and produce no +confirmation tone. Muting an output stops an alert already playing. + +Fresh settings default to **sound off** and **vibration on**. Existing saved +choices are retained. The same preferences are used by the device buttons and +UI. Unsupported hardware reports an error. + +These are the device mute controls. Programmable `notify.sound` and +`notify.vibration` rules also respect them; an enabled rule cannot bypass a +muted output. The notification output masks still select which alert outputs +a rule may use. Explicit button mute/unmute confirmations retain their existing +behavior. + +Adapted from [upstream PR #3603](https://github.com/meshcore-dev/MeshCore/pull/3603). + #### Turn indicator LEDs on or off ```text diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index fd7f0a48b..f514d32f2 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -68,6 +68,7 @@ public: virtual void notificationVibration(bool on) { (void)on; } virtual void notificationMelody(const char* text) { (void)text; } virtual void notificationScreen(int8_t mode) { (void)mode; } + virtual void syncAlertPreferences() {} void setShutdownGuard(UIShutdownGuard* guard) { _shutdownGuard = guard; } void setHasConnection(bool connected) { _connected = connected; } bool hasConnection() const { return _interfaceManager->isConnected(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 38f4f6695..d2162028a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -294,7 +294,7 @@ static bool save_filter(const ContactInfo& c); #define DEFAULT_AUTOADD_CONFIG 0 #endif #ifndef DEFAULT_BUZZER_QUIET -#define DEFAULT_BUZZER_QUIET 0 +#define DEFAULT_BUZZER_QUIET 1 #endif #ifndef DEFAULT_CAD_ENABLED // Preserve the tuned Companion behavior that preceded the runtime setting. @@ -2833,6 +2833,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.manual_add_contacts = DEFAULT_MANUAL_ADD_CONTACTS; _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.buzzer_quiet = DEFAULT_BUZZER_QUIET ? 1 : 0; + _prefs.vibe_quiet = 0; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // Use the default 1-second fix-processing interval _prefs.gps_sync_interval_hours = 0; // Retain the board's default time-sync cadence @@ -3320,6 +3321,13 @@ void MyMesh::begin(bool has_display, bool radio_available, void MyMesh::configureRadioFromPrefs() { board.attachDynamicPrefs(_prefs.getCustom()); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + // The reserved preference byte keeps the existing image and filesystem size. + _prefs.leds_quiet = _prefs.leds_quiet == 1 ? 1 : 0; + mesh::leds::setEnabled(!_prefs.leds_quiet); + board.applyLedPolicy(); + mesh::leds::applyStatusPolicy(); +#endif if (!_radio_available) { saved_radio_apply_pending = false; @@ -10315,17 +10323,17 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, terminalOutput().print( "Commands:\r\n" " stats-core / stats-radio / stats-radio-diag / stats-packets\r\n" - " get prv.key (when private key export is enabled)\r\n" + " get prv.key (if export enabled)\r\n" #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) " password (shared setup WiFi, WebConfig and WiFi OTA)\r\n" " get password (management password status)\r\n" #else - " get password (this role has no admin password)\r\n" + " get password (no admin password)\r\n" #endif - " erase (erase stored settings and identity)\r\n" + " erase (settings and identity)\r\n" " board\r\n" - " version\r\n"); - terminalOutput().print(" get storage.layout\r\n"); + " version\r\n" + " get storage.layout\r\n"); #if MESH_ENABLE_TX_BEACONS terminalOutput().print(" get beacon [slot] / set beacon ...\r\n" " delete beacon / set beacon.interval <12|24|36|48> (default 24h)\r\n"); @@ -10363,58 +10371,52 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, terminalOutput().print(" get eth / set eth on|off (physical RAK13800; default off, this boot)\r\n" " get sdcard [free] / ota cache [on|off] (RAK15002; mutually exclusive with Ethernet)\r\n"); #endif - terminalOutput().print(" get pwrmgt.bootreason\r\n"); + terminalOutput().print(" get pwrmgt.bootreason\r\n" #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS - terminalOutput().print(" memory\r\n"); + " memory\r\n" +#endif + " get/set display.rotation <0|90|180|270>\r\n" +#ifdef PIN_BUZZER + " get/set buzzer on|off\r\n" +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + " get/set vibration on|off\r\n" #endif - terminalOutput().print(" get display.rotation\r\n"); #if COMPANION_FEATURE_NOTIFICATIONS - terminalOutput().print(" get notify / get notify.rules / get notify.gpio.pins\r\n"); - terminalOutput().print(" set notify.{sound|vibration|led|screen|gpio} on|off\r\n"); - terminalOutput().print(" set notify. \r\n"); - terminalOutput().print(" set notify.remote on|off (!notify, max 15s, no GPIO)\r\n"); - terminalOutput().print(" notify.test / notify.stop / notify.delete \r\n"); + " get notify / get notify.rules / get notify.gpio.pins\r\n" + " set notify.{sound|vibration|led|screen|gpio} on|off\r\n" + " set notify. \r\n" + " set notify.remote on|off (!notify, max 15s, no GPIO)\r\n" + " notify.test / notify.stop / notify.delete \r\n" #endif - terminalOutput().print( - " get display.inbox\r\n" - " set display.inbox \r\n" - " set display.rotation <0|90|180|270>\r\n" - " get display.touch\r\n" - " set display.touch (this boot only)\r\n" - " get public.key\r\n"); - terminalOutput().print( + " get/set display.inbox \r\n" + " get/set display.touch (this boot)\r\n" + " get public.key\r\n" " set {name|lat|lon|freq|tx|af} {value}\r\n" " get wifi|espnow|2.4ghz\r\n" " set wifi|espnow on|off [force] (force only with off)\r\n" " set 2.4ghz on [all|force]|off [force] (this boot)\r\n" - " get bluetooth.name\r\n" - " set bluetooth.name \r\n"); + " get/set bluetooth.name \r\n" #if defined(BLE_PIN_CODE) - terminalOutput().print(" get bluetooth (alias: get ble)\r\n"); - terminalOutput().print(" set bluetooth [force] (saved; force only with off)\r\n"); - terminalOutput().print(" get bluetooth.mac\r\n"); - terminalOutput().print( - " set bluetooth.mac \r\n"); - terminalOutput().print(" get bluetooth.stealth\r\n"); - terminalOutput().print(" set bluetooth.stealth on|off\r\n"); + " get bluetooth (alias: get ble)\r\n" + " set bluetooth [force] (saved; force only with off)\r\n" + " get bluetooth.mac\r\n" + " set bluetooth.mac \r\n" + " get/set bluetooth.stealth on|off\r\n" #endif #if MESH_ENABLE_LOST_REPLY - terminalOutput().print(" get lost.reply\r\n" " set lost.reply \r\n"); + " get/set lost.reply \r\n" #endif - terminalOutput().print(" set pin <0-999999>\r\n" " powersaving [on|off]\r\n"); + " set pin <0-999999>\r\n" " powersaving [on|off]\r\n" #if MESH_USB_LOGGING_AVAILABLE - terminalOutput().print( - " get usb.logging\r\n" - " set usb.logging [reboot]\r\n" - " get usb.debug\r\n" - " set usb.debug \r\n" + " get/set usb.logging [reboot]\r\n" + " get/set usb.debug \r\n" #if MESH_USB_CONSOLE_COOPERATIVE - " get usb.watchdog\r\n" " get usb.watchdog.last\r\n" - " set usb.watchdog (saved; default auto)\r\n" + " get/set usb.watchdog (saved; default auto)\r\n" +#endif #endif ); -#endif #if defined(ESP32) && defined(WIFI_SSID) #if defined(COMPANION_RADIO_FULL) terminalOutput().print(" start ota [ap] (WiFi firmware update on port 8080)\r\n"); @@ -10447,23 +10449,15 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, terminalOutput().print(" get espnow.channel\r\n"); terminalOutput().print(" set espnow.channel <1-13>\r\n"); #endif - // Keep identical help text in bounded chunks. Fewer virtual Print calls - // leave room for USB controls on small-flash boards without cutting CLI. + // Concise help and grouped Print calls preserve small-board flash space. terminalOutput().print( - " get radio.rxps\r\n" " get radio.rxps.config\r\n" - " set radio.rxps \r\n" - " get radio.cad\r\n" - " set radio.cad \r\n" - " set radio.cad timings \r\n"); - terminalOutput().print( - " get radio.rxgain\r\n" - " set radio.rxgain \r\n" - " get radio.fem.rxgain\r\n" - " set radio.fem.rxgain \r\n" - " get radio.fem.txgain\r\n" - " set radio.fem.txgain \r\n"); - terminalOutput().print( + " get/set radio.rxps \r\n" + " get/set radio.cad \r\n" + " set radio.cad timings \r\n" + " get/set radio.rxgain \r\n" + " get/set radio.fem.rxgain \r\n" + " get/set radio.fem.txgain \r\n" " get/set flood.retry.count <0-15>\r\n" " get/set flood.retry.path <0-63|off>\r\n" " get/set flood.retry.group.path <0-63|off>\r\n" @@ -10475,13 +10469,13 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, terminalOutput().print( " list [n]\r\n" " show [adverts|channels|emergency] [on|off]\r\n" - " to [recipient name or prefix]\r\n" - " path [direct|clear|hops separated by spaces or commas]\r\n" + " to [name|prefix]\r\n" + " path [direct|clear|hop,...]\r\n" " send \r\n" " login \r\n" " cmd \r\n"); terminalOutput().print( - " trace [recipient name or prefix]\r\n" + " trace [name|prefix]\r\n" " trace path <1|2|4> \r\n" " advert\r\n" " reset path\r\n" @@ -10508,7 +10502,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (_terminal_mode) { terminalOutput().print(" +++MESHCORE-TERM-STOP\r\n"); } else { - terminalOutput().print(" disconnect (closes the network terminal)\r\n"); + terminalOutput().print(" disconnect (network terminal)\r\n"); } } else if (handleCommand(command, 0, local_reply)) { // Fill the same safe shared-command surface for getters (`get name`, @@ -10726,7 +10720,14 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } if (mesh::power::handleRegulatorCommand(command, reply, reply_capacity)) return true; +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + if (handleCompactLedCommand(command, reply, reply_capacity)) return true; +#else if (mesh::leds::handleCommand(command, reply, reply_capacity)) return true; +#endif +#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(HAS_DRV2605) + if (handleAlertCommand(command, reply, reply_capacity)) return true; +#endif #if COMPANION_FEATURE_READER if (sender_timestamp == 0 && mesh::morseTimingActive(_radio) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b62b33d5d..0530d85f9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -295,6 +295,10 @@ public: void setNotificationOutputMute(mesh::notify::Output, bool) {} #endif bool handleNotificationCommand(const char* command, char* reply, size_t size); + bool handleAlertCommand(const char* command, char* reply, size_t size); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + bool handleCompactLedCommand(const char* command, char* reply, size_t size); +#endif #if defined(STM32_PLATFORM) // Keep this large dispatcher separate from contact streaming on flash- // constrained STM32 targets; merging their branches increases code size. diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index d25581a1f..a913d6550 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -49,8 +49,12 @@ public: float rx_delay_base = 0; uint32_t ble_pin = 0; uint8_t advert_loc_policy = 0; - uint8_t buzzer_quiet = 0; + uint8_t buzzer_quiet = 1; uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled) +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + // Uses existing alignment space; stored in the reserved wire byte at 79. + uint8_t leds_quiet = 0; +#endif uint32_t gps_interval = 0; // seconds; 0 uses the 1-second default uint8_t autoadd_config = 0; // bitmask for auto-add contacts config uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode diff --git a/examples/companion_radio/Notifications.cpp b/examples/companion_radio/Notifications.cpp index cadd2353a..9dc50ce98 100644 --- a/examples/companion_radio/Notifications.cpp +++ b/examples/companion_radio/Notifications.cpp @@ -2,6 +2,88 @@ #include "target.h" #include "NotificationSettingsFile.h" +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) +bool MyMesh::handleCompactLedCommand(const char* command, char* reply, size_t size) { + if (!command || !reply || !size) return false; + if (strcmp(command, "get leds") == 0) { + snprintf(reply, size, "> %s", mesh::leds::enabled() ? "on" : "off"); + return true; + } + if (strncmp(command, "set leds", 8) != 0 + || (command[8] && command[8] != ' ' && command[8] != '\t')) return false; + const char* value = command + 8; + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, size, "Error: usage set leds on|off"); + return true; + } + const bool on = strcmp(value, "on") == 0; + const uint8_t previous = _prefs.leds_quiet; + _prefs.leds_quiet = !on; + if (_prefs.leds_quiet != previous) { + if (!savePrefs()) { + _prefs.leds_quiet = previous; + snprintf(reply, size, "Error: LED setting save unconfirmed; reboot may differ"); + return true; + } + } + mesh::leds::setEnabled(on); + board.applyLedPolicy(); + mesh::leds::applyStatusPolicy(); + snprintf(reply, size, "OK"); + return true; +} +#endif + +bool MyMesh::handleAlertCommand(const char* command, char* reply, size_t size) { + if (!command || !reply || !size) return false; + const bool get = strncmp(command, "get ", 4) == 0; + if (!get && strncmp(command, "set ", 4) != 0) return false; + const char* name = command + 4; + uint8_t* quiet = nullptr; + size_t length = 0; + bool supported = false; + if (strncmp(name, "buzzer", 6) == 0) { + length = 6; + quiet = &_prefs.buzzer_quiet; +#ifdef PIN_BUZZER + supported = true; +#endif + } else if (strncmp(name, "vibration", 9) == 0) { + length = 9; + quiet = &_prefs.vibe_quiet; +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + supported = true; +#endif + } else return false; + const char* value = name + length; + if (*value && *value != ' ' && *value != '\t') return false; + if (!supported) { + snprintf(reply, size, "Error: %.*s unavailable on this build", int(length), name); + return true; + } + if (get) { + if (*value) snprintf(reply, size, "Error: usage get %.*s", int(length), name); + else snprintf(reply, size, "> %s", *quiet ? "off" : "on"); + return true; + } + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, size, "Error: usage set %.*s on|off", int(length), name); + return true; + } + const uint8_t previous = *quiet; + *quiet = strcmp(value, "off") == 0; + if (*quiet != previous && !savePrefs()) { + *quiet = previous; + snprintf(reply, size, "Error: failed to save %.*s setting", int(length), name); + return true; + } + if (_ui) _ui->syncAlertPreferences(); + snprintf(reply, size, "OK"); + return true; +} + #if COMPANION_FEATURE_NOTIFICATIONS // The UI's STATUS_LED_RGB define is local to its own translation unit. uint8_t MyMesh::capabilities() const { diff --git a/examples/companion_radio/PrefsStorageLayout.h b/examples/companion_radio/PrefsStorageLayout.h index e63c4bd04..16f0da974 100644 --- a/examples/companion_radio/PrefsStorageLayout.h +++ b/examples/companion_radio/PrefsStorageLayout.h @@ -67,7 +67,11 @@ static constexpr PrefField PREF_FIELDS[] = { PREF_FIELD(advert_loc_policy, 1), PREF_FIELD(multi_acks, 1), PREF_FIELD(path_hash_mode, 1), +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + PREF_FIELD(leds_quiet, 1), +#else prefSpecial(), +#endif PREF_FIELD(ble_pin, 4), PREF_FIELD(buzzer_quiet, 1), PREF_FIELD(gps_enabled, 1), diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 5372afbe5..67ed9f8c6 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3635,7 +3635,9 @@ void setup() { if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot) || store.isVolatilePrimaryFS()) regulator_prefs_fs = nullptr; #endif +#if !defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) mesh::leds::beginSettings(board, regulator_prefs_fs); +#endif if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 314739de8..9df30285c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1965,6 +1965,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode #if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.begin(); + vibration.quiet(_node_prefs->vibe_quiet); #endif ui_started_at = millis(); @@ -2051,7 +2052,7 @@ if (_notification_outputs & mesh::notify::Sound) switch(t){ } #endif -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) // Trigger vibration for all UI events except none if ((_notification_outputs & mesh::notify::Vibration) && t != UIEventType::none) { vibration.trigger(); @@ -2276,7 +2277,20 @@ bool UITask::isButtonGesturePending() const { #endif } +void UITask::syncAlertPreferences() { + if (!_node_prefs) return; +#ifdef PIN_BUZZER + if (buzzer.isQuiet() != bool(_node_prefs->buzzer_quiet)) + buzzer.quiet(_node_prefs->buzzer_quiet); +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + if (vibration.isQuiet() != bool(_node_prefs->vibe_quiet)) + vibration.quiet(_node_prefs->vibe_quiet); +#endif +} + void UITask::loop() { + syncAlertPreferences(); // Observe startup expiry even while display/Morse/input defers rendering. if (!_startup_complete && static_cast(millis() - ui_started_at) >= 8000u) { _startup_complete = true; @@ -2730,7 +2744,7 @@ uint8_t UITask::notificationCapabilities() const { } void UITask::notificationMelody(const char* text) { #ifdef PIN_BUZZER - if (text) buzzer.playNotification(text); else buzzer.stop(); + if (text) buzzer.playNotification(text); else buzzer.stopNotification(); #endif } void UITask::notificationScreen(int8_t mode) { @@ -2739,10 +2753,7 @@ void UITask::notificationScreen(int8_t mode) { _next_refresh = millis(); } void UITask::notificationVibration(bool on) { -#ifdef HAS_DRV2605 +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.pulse(on); -#elif defined(PIN_VIBRATION) - vibration.stop(); - digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); #endif } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 340f85d4e..d5dbb82bc 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -43,7 +43,7 @@ class UITask : public AbstractUITask { unsigned long _pairing_screen_until; bool _deferred_msg_preview; bool _startup_complete = false; - CompanionNodePrefs* _node_prefs; + CompanionNodePrefs* _node_prefs = nullptr; char _alert[80]; uint32_t _alert_expiry; int _msgcount; @@ -200,6 +200,7 @@ public: void notificationVibration(bool on) override; void notificationMelody(const char* text) override; void notificationScreen(int8_t mode) override; + void syncAlertPreferences() override; void loop() override; void shutdown(bool restart = false); diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 75acf7f12..656a59884 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -124,11 +124,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode buzzer.startup(); #endif -#ifdef HAS_DRV2605 +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.begin(); vibration.quiet(_node_prefs->vibe_quiet); -#elif defined(PIN_VIBRATION) - vibration.begin(); #endif // Initialize digital button if available @@ -719,13 +717,26 @@ void UITask::servicePairingState() { } } +void UITask::syncAlertPreferences() { + if (!_node_prefs) return; +#ifdef PIN_BUZZER + if (buzzer.isQuiet() != bool(_node_prefs->buzzer_quiet)) + buzzer.quiet(_node_prefs->buzzer_quiet); +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + if (vibration.isQuiet() != bool(_node_prefs->vibe_quiet)) + vibration.quiet(_node_prefs->vibe_quiet); +#endif +} + void UITask::loop() { + syncAlertPreferences(); // Observe startup expiry before button callbacks and optional rendering. if (!_startup_complete && static_cast(millis() - ui_started_at) >= 8000u) { _startup_complete = true; } const bool morse_timing = mesh::morseTimingActive(&radio_driver); -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.loop(); #endif servicePairingState(); @@ -1132,18 +1143,14 @@ void UITask::notificationScreen(int8_t mode) { _need_refresh = true; } void UITask::notificationVibration(bool on) { -#ifdef HAS_DRV2605 +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) #if defined(PIN_BUZZER) && MESH_GPS_VOICE - // The saved physical alert mode stays authoritative even if the separate - // notification-mask write fails. Off callbacks must still stop the driver. + // Keep the committed physical mode authoritative if a mask save failed. if (on && _node_prefs && _node_prefs->vibe_quiet) { vibration.stop(); return; } #endif vibration.pulse(on); -#elif defined(PIN_VIBRATION) - vibration.stop(); - digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); #endif } diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index a4ee6f28d..4896e32a2 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -32,7 +32,7 @@ class UITask : public AbstractUITask { GenericVibration vibration; #endif uint32_t _next_refresh; - CompanionNodePrefs* _node_prefs; + CompanionNodePrefs* _node_prefs = nullptr; char _version_info[32]; char _origin[62]; #if UI_SMALL_MESSAGE_FONT == 1 @@ -125,6 +125,7 @@ public: void notificationVibration(bool on) override; void notificationMelody(const char* text) override; void notificationScreen(int8_t mode) override; + void syncAlertPreferences() override; void loop() override; #ifdef MESH_BUTTON_AUDIO_HIL bool handleButtonAudioTest(const char* command, char* reply, size_t size) override; diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index e7fa3012c..cfbb60444 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -666,6 +666,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode #if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.begin(); + vibration.quiet(_node_prefs->vibe_quiet); #endif ui_started_at = millis(); @@ -708,7 +709,7 @@ if (_notification_outputs & mesh::notify::Sound) switch(t){ } #endif -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) // Trigger vibration for all UI events except none if ((_notification_outputs & mesh::notify::Vibration) && t != UIEventType::none) { vibration.trigger(); @@ -867,7 +868,20 @@ bool UITask::isButtonPressed() const { #endif } +void UITask::syncAlertPreferences() { + if (!_node_prefs) return; +#ifdef PIN_BUZZER + if (buzzer.isQuiet() != bool(_node_prefs->buzzer_quiet)) + buzzer.quiet(_node_prefs->buzzer_quiet); +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + if (vibration.isQuiet() != bool(_node_prefs->vibe_quiet)) + vibration.quiet(_node_prefs->vibe_quiet); +#endif +} + void UITask::loop() { + syncAlertPreferences(); // Observe startup expiry even while display/Morse/input defers rendering. if (!_startup_complete && static_cast(millis() - ui_started_at) >= 8000u) { _startup_complete = true; @@ -1167,7 +1181,7 @@ uint8_t UITask::notificationCapabilities() const { } void UITask::notificationMelody(const char* text) { #ifdef PIN_BUZZER - if (text) buzzer.playNotification(text); else buzzer.stop(); + if (text) buzzer.playNotification(text); else buzzer.stopNotification(); #endif } void UITask::notificationScreen(int8_t mode) { @@ -1176,10 +1190,7 @@ void UITask::notificationScreen(int8_t mode) { _next_refresh = millis(); } void UITask::notificationVibration(bool on) { -#ifdef HAS_DRV2605 +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.pulse(on); -#elif defined(PIN_VIBRATION) - vibration.stop(); - digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); #endif } diff --git a/examples/companion_radio/ui-tiny/UITask.h b/examples/companion_radio/ui-tiny/UITask.h index 717519ff2..30a964efe 100644 --- a/examples/companion_radio/ui-tiny/UITask.h +++ b/examples/companion_radio/ui-tiny/UITask.h @@ -40,7 +40,7 @@ class UITask : public AbstractUITask { GenericVibration vibration; #endif uint32_t _next_refresh; - CompanionNodePrefs* _node_prefs; + CompanionNodePrefs* _node_prefs = nullptr; char _alert[80]; uint32_t _alert_expiry; unsigned long _pairing_screen_until; @@ -137,6 +137,7 @@ public: void notificationVibration(bool on) override; void notificationMelody(const char* text) override; void notificationScreen(int8_t mode) override; + void syncAlertPreferences() override; void loop() override; void shutdown(bool restart = false); diff --git a/src/helpers/ui/DRV2605Vibration.cpp b/src/helpers/ui/DRV2605Vibration.cpp index d00e2c055..1293976a3 100644 --- a/src/helpers/ui/DRV2605Vibration.cpp +++ b/src/helpers/ui/DRV2605Vibration.cpp @@ -77,7 +77,7 @@ void DRV2605Vibration::stop() { void DRV2605Vibration::pulse(bool on) { if (!_ready) return; - if (!on) { + if (!on || _quiet) { stop(); return; } diff --git a/src/helpers/ui/DRV2605Vibration.h b/src/helpers/ui/DRV2605Vibration.h index f6b5ceecd..50ad29ca7 100644 --- a/src/helpers/ui/DRV2605Vibration.h +++ b/src/helpers/ui/DRV2605Vibration.h @@ -27,7 +27,7 @@ public: bool isVibrating(); void stop(); // stop vibration immediately void pulse(bool on); // continuous RTP amplitude for scheduled ms pulses - void quiet(bool q) { _quiet = q; } + void quiet(bool q) { if (q) stop(); _quiet = q; } bool isQuiet() const { return _quiet; } private: diff --git a/src/helpers/ui/GenericVibration.cpp b/src/helpers/ui/GenericVibration.cpp index 9226b812a..fb466ca39 100644 --- a/src/helpers/ui/GenericVibration.cpp +++ b/src/helpers/ui/GenericVibration.cpp @@ -8,6 +8,7 @@ void GenericVibration::begin() { } void GenericVibration::trigger() { + if (_quiet) return; duration = millis(); digitalWrite(PIN_VIBRATION, HIGH); } @@ -35,4 +36,9 @@ void GenericVibration::stop() { digitalWrite(PIN_VIBRATION, LOW); } +void GenericVibration::pulse(bool on) { + stop(); + if (on && !_quiet) digitalWrite(PIN_VIBRATION, HIGH); +} + #endif // ifdef PIN_VIBRATION diff --git a/src/helpers/ui/GenericVibration.h b/src/helpers/ui/GenericVibration.h index 38755bd8d..5de6076f9 100644 --- a/src/helpers/ui/GenericVibration.h +++ b/src/helpers/ui/GenericVibration.h @@ -25,9 +25,13 @@ public: void loop(); // non-blocking timer handling bool isVibrating(); // returns true if currently vibrating void stop(); // stop vibration immediately + void pulse(bool on); + void quiet(bool q) { if (q) stop(); _quiet = q; } + bool isQuiet() const { return _quiet; } private: - unsigned long duration; + unsigned long duration = 0; + bool _quiet = false; }; #endif // ifdef PIN_VIBRATION diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index 5d02b979c..e5aad92ce 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -48,16 +48,14 @@ static mesh::audio::VoiceClip buttonVoiceClip(ButtonVoicePrompt prompt) { #endif void genericBuzzer::begin() { -// Serial.print("DBG: Setting up buzzer on pin "); -// Serial.println(PIN_BUZZER); - #ifdef PIN_BUZZER_EN - pinMode(PIN_BUZZER_EN, OUTPUT); - digitalWrite(PIN_BUZZER_EN, HIGH); - #endif - - quiet(false); + digitalWrite(PIN_BUZZER, LOW); pinMode(PIN_BUZZER, OUTPUT); - digitalWrite(PIN_BUZZER, LOW); // need to pull low by default to avoid extreme power draw +#ifdef PIN_BUZZER_EN + digitalWrite(PIN_BUZZER_EN, LOW); + pinMode(PIN_BUZZER_EN, OUTPUT); +#endif + // Start muted until the UI applies the saved preference. + quiet(_is_quiet); } void genericBuzzer::play(const char *melody) { @@ -79,6 +77,8 @@ void genericBuzzer::play(const char *melody) { } void genericBuzzer::playNotification(const char* melody) { + // Muted background alerts must not interrupt an explicit button prompt. + if (_is_quiet) return; stop(); #ifdef PIN_BUZZER_EN digitalWrite(PIN_BUZZER_EN, HIGH); diff --git a/test/fixtures/companion_ui_deadlines.cpp b/test/fixtures/companion_ui_deadlines.cpp index 74738666a..0d4ee2049 100644 --- a/test/fixtures/companion_ui_deadlines.cpp +++ b/test/fixtures/companion_ui_deadlines.cpp @@ -71,6 +71,7 @@ struct UITask { unsigned battery_reads = 0, shutdowns = 0, profile_services = 0; char _alert[80] = {}, _origin[80] = {}, _msg[80] = {}; void servicePairingState() {} + void syncAlertPreferences() {} void serviceWiFiToggleButton() {} bool isPairingScreenActive() const { return false; } bool isButtonGesturePending() const { return gesture_pending; } diff --git a/test/test_button_voice_actions.py b/test/test_button_voice_actions.py index e64b55157..e13374314 100644 --- a/test/test_button_voice_actions.py +++ b/test/test_button_voice_actions.py @@ -40,7 +40,7 @@ int main(){ {ButtonVoicePrompt::AlertsSilent,alertsSilentClip,nullptr}, #endif }; - genericBuzzer buzzer;buzzer.begin(); + genericBuzzer buzzer;buzzer.begin();buzzer.quiet(false); auto finish=[&]{ assert(!registers.ENABLE); rtttl::finish();buzzer.loop(); @@ -548,7 +548,7 @@ NOTIFICATION_STOPS = r''' #include uint32_t now_ms=0; int main(){ - genericBuzzer buzzer;buzzer.begin(); + genericBuzzer buzzer;buzzer.begin();buzzer.quiet(false); buzzer.playNotification("ordinary:d=4,o=5,b=120:c"); assert(buzzer.isPlaying()&&rtttl::isPlaying()); buzzer.stopNotification();assert(!buzzer.isPlaying()&&!rtttl::isPlaying()); @@ -590,11 +590,13 @@ int main(){ assert(!buzzer.isPlaying()&&!rtttl::isPlaying()); buzzer.quiet(true); #endif + buzzer.playNotification("ordinary:d=4,o=5,b=120:c"); + assert(!buzzer.isPlaying()&&buzzer.isQuiet()&&pin_states[PIN_BUZZER_EN]==LOW); + buzzer.quiet(false); buzzer.playNotification("ordinary:d=4,o=5,b=120:c"); assert(buzzer.isPlaying()&&pin_states[PIN_BUZZER_EN]==HIGH); buzzer.stopNotification(); - assert(!buzzer.isPlaying()&&!rtttl::isPlaying()&&buzzer.isQuiet()); - assert(pin_states[PIN_BUZZER_EN]==LOW); + assert(!buzzer.isPlaying()&&!rtttl::isPlaying()&&!buzzer.isQuiet()); } ''' diff --git a/test/test_companion_alert_control.py b/test/test_companion_alert_control.py new file mode 100644 index 000000000..23da55bd9 --- /dev/null +++ b/test/test_companion_alert_control.py @@ -0,0 +1,217 @@ +"""Run the Companion alert CLI against storage and live-output fixtures.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def alert_handler(): + source = (ROOT / "examples/companion_radio/Notifications.cpp").read_text() + match = re.search(r"\bbool\s+MyMesh::handleAlertCommand\([^)]*\)\s*\{", source) + if not match: + raise AssertionError("production Companion alert handler is missing") + end, depth = match.end(), 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[match.start():end] + + +STUBS = r""" +#include +#include +#include +#include +#include +#include +#include +struct Prefs { + uint8_t buzzer_quiet = 1, vibe_quiet = 0; + uint32_t unrelated = 0x12345678; +}; +struct UI { + Prefs* prefs; + Prefs* persisted; + unsigned syncs = 0; + bool sound_quiet = true, vibration_quiet = false; + UI(Prefs* p, Prefs* saved) : prefs(p), persisted(saved) {} + void syncAlertPreferences() { + assert(prefs->buzzer_quiet == persisted->buzzer_quiet); + assert(prefs->vibe_quiet == persisted->vibe_quiet); + ++syncs; + sound_quiet = prefs->buzzer_quiet; + vibration_quiet = prefs->vibe_quiet; + } +}; +class MyMesh { +public: + Prefs _prefs, saved; + UI ui{&_prefs, &saved}; + UI* _ui = &ui; + unsigned saves = 0; + bool save_ok = true; + bool savePrefs() { + ++saves; + if (!save_ok) return false; + saved = _prefs; + return true; + } + bool handleAlertCommand(const char*, char*, size_t); +}; +""" + + +SCENARIOS = r""" +std::string run(MyMesh& mesh, const char* command) { + char reply[160] = {}; + assert(mesh.handleAlertCommand(command, reply, sizeof(reply))); + return reply; +} +void error(MyMesh& mesh, const char* command) { + const auto before = mesh._prefs; + const unsigned saves = mesh.saves, syncs = mesh.ui.syncs; + assert(run(mesh, command).find("Error") == 0); + assert(mesh._prefs.buzzer_quiet == before.buzzer_quiet); + assert(mesh._prefs.vibe_quiet == before.vibe_quiet); + assert(mesh.saves == saves && mesh.ui.syncs == syncs); +} +void supported(MyMesh& mesh, const char* name, bool initial_on) { + const std::string get = std::string("get ") + name; + const std::string set = std::string("set ") + name; + const bool sound = std::string(name) == "buzzer"; + const uint8_t other = sound ? mesh._prefs.vibe_quiet : mesh._prefs.buzzer_quiet; + assert(run(mesh, get.c_str()) == (initial_on ? "> on" : "> off")); + assert(mesh.saves == 0 && mesh.ui.syncs == 0); + for (const char* value : {"", " ", "on extra", "off extra", "1", "0", "ON", "OFF", + "true", "false", "onward", "officer"}) { + error(mesh, (set + " " + value).c_str()); + } + error(mesh, set.c_str()); + error(mesh, (get + " on").c_str()); + + // An unchanged value acknowledges the command without rewriting flash. + mesh.ui.sound_quiet = false; + mesh.ui.vibration_quiet = true; + assert(run(mesh, (set + (initial_on ? " on" : " off")).c_str()) == "OK"); + assert(mesh.saves == 0 && mesh.ui.syncs == 1); + assert(mesh.ui.sound_quiet == bool(mesh._prefs.buzzer_quiet)); + assert(mesh.ui.vibration_quiet == bool(mesh._prefs.vibe_quiet)); + + // Persist first, then apply to the running UI. Changing one output must not + // overwrite the independent preference or unrelated node settings. + assert(run(mesh, (set + (initial_on ? " off" : " on")).c_str()) == "OK"); + assert(mesh.saves == 1 && mesh.ui.syncs == 2); + assert(run(mesh, get.c_str()) == (initial_on ? "> off" : "> on")); + assert((sound ? mesh._prefs.buzzer_quiet : mesh._prefs.vibe_quiet) == initial_on); + assert((sound ? mesh._prefs.vibe_quiet : mesh._prefs.buzzer_quiet) == other); + assert(mesh.saved.buzzer_quiet == mesh._prefs.buzzer_quiet); + assert(mesh.saved.vibe_quiet == mesh._prefs.vibe_quiet); + assert(mesh.saved.unrelated == 0x12345678); + assert(mesh.ui.sound_quiet == bool(mesh._prefs.buzzer_quiet)); + assert(mesh.ui.vibration_quiet == bool(mesh._prefs.vibe_quiet)); + + const Prefs previous = mesh._prefs, persisted = mesh.saved; + mesh.save_ok = false; + assert(run(mesh, (set + (initial_on ? " on" : " off")).c_str()).find("Error") == 0); + assert(mesh.saves == 2 && mesh.ui.syncs == 2); + assert(mesh._prefs.buzzer_quiet == previous.buzzer_quiet); + assert(mesh._prefs.vibe_quiet == previous.vibe_quiet); + assert(mesh.saved.buzzer_quiet == persisted.buzzer_quiet); + assert(mesh.saved.vibe_quiet == persisted.vibe_quiet); + assert(run(mesh, get.c_str()) == (initial_on ? "> off" : "> on")); + + // A build without an attached UI can still persist the board preference. + mesh._ui = nullptr; + mesh.save_ok = true; + assert(run(mesh, (set + (initial_on ? " on" : " off")).c_str()) == "OK"); + assert(mesh.saves == 3 && mesh.ui.syncs == 2); + assert(run(mesh, get.c_str()) == (initial_on ? "> on" : "> off")); +} +int main() { + MyMesh buzzer, vibration; +#if EXPECT_BUZZER + supported(buzzer, "buzzer", false); +#else + error(buzzer, "get buzzer"); + error(buzzer, "set buzzer on"); + error(buzzer, "set buzzer off"); +#endif +#if EXPECT_VIBRATION + supported(vibration, "vibration", true); +#else + error(vibration, "get vibration"); + error(vibration, "set vibration on"); + error(vibration, "set vibration off"); +#endif + MyMesh mesh; + for (const char* command : {"get buzzerx", "get buzzer.extra", "get vibrationx", + "set buzzerx on", "set vibrationx off", "get leds", "get radio", "get notify", + "get sound", "set vibe on", "buzzer", "vibration", "get", "", "set"}) { + char reply[16] = "unchanged"; + assert(!mesh.handleAlertCommand(command, reply, sizeof(reply))); + assert(strcmp(reply, "unchanged") == 0); + assert(mesh.saves == 0 && mesh.ui.syncs == 0); + } + for (const char* command : {"get buzzer", "get vibration", "set buzzer bad", + "set vibration bad", "set buzzer on", "set vibration off"}) { + for (size_t capacity = 1; capacity <= 160; ++capacity) { + char bounded[164]; memset(bounded, '#', sizeof(bounded)); + memcpy(bounded, "ab|", 3); + assert(mesh.handleAlertCommand(command, bounded + 3, capacity)); + assert(memcmp(bounded, "ab|", 3) == 0); + assert(bounded[3 + capacity] == '#'); + assert(memchr(bounded + 3, 0, capacity)); + } + } + char reply[16]; + assert(!mesh.handleAlertCommand(nullptr, reply, sizeof(reply))); + assert(!mesh.handleAlertCommand("get buzzer", nullptr, sizeof(reply))); + assert(!mesh.handleAlertCommand("get buzzer", reply, 0)); +} +""" + + +class CompanionAlertControlTests(unittest.TestCase): + def test_cli_storage_and_hardware_matrix(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("C++ compiler unavailable") + cases = ( + ("none", (), False, False), + ("buzzer", ("PIN_BUZZER=1",), True, False), + ("gpio_vibration", ("PIN_VIBRATION=2",), False, True), + ("drv2605", ("HAS_DRV2605=1",), False, True), + ("buzzer_gpio", ("PIN_BUZZER=1", "PIN_VIBRATION=2"), True, True), + ("buzzer_drv2605", ("PIN_BUZZER=1", "HAS_DRV2605=1"), True, True), + ) + source = STUBS + alert_handler() + SCENARIOS + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + cpp = directory / "alert_cli.cpp" + cpp.write_text(source, encoding="ascii") + for name, macros, buzzer, vibration in cases: + with self.subTest(hardware=name): + exe = directory / (name + (".exe" if os.name == "nt" else "")) + flags = [f"-D{macro}" for macro in macros] + if os.name != "nt": + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + result = subprocess.run( + [compiler, "-std=c++11", "-Wall", "-Wextra", *flags, + f"-DEXPECT_BUZZER={int(buzzer)}", + f"-DEXPECT_VIBRATION={int(vibration)}", + str(cpp), "-o", str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_alert_drivers.py b/test/test_companion_alert_drivers.py new file mode 100644 index 000000000..81ab58a44 --- /dev/null +++ b/test/test_companion_alert_drivers.py @@ -0,0 +1,248 @@ +"""Run Companion master mute and live UI synchronization with inert drivers.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +import test_gps_voice_buttons as voice +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] +ARDUINO = voice.ARDUINO.replace( + "inline unsigned pin_writes[64]={};", + "inline unsigned pin_writes[64]={}, high_writes[64]={};").replace( + "pin_states[pin]=value;++pin_writes[pin];", + "pin_states[pin]=value;++pin_writes[pin];if(value==HIGH)++high_writes[pin];") +ARDUINO += "\ninline void delay(unsigned) {}\n" +WIRE = "#pragma once\nstruct TwoWire {};\ninline TwoWire Wire;\n" +ADAFRUIT = r''' +#pragma once +#include +#include +#define DRV2605_MODE_INTTRIG 0 +#define DRV2605_MODE_REALTIME 5 +#define DRV2605_REG_FEEDBACK 0x1a +#define DRV2605_REG_GO 0x0c +struct MockDriver { + bool found=true; + uint8_t registers[32]={}; + unsigned writes=0; +}; +inline MockDriver hardware; +class Adafruit_DRV2605 { +public: + bool begin(TwoWire*) { return hardware.found; } + void writeRegister8(uint8_t reg,uint8_t value) { + hardware.registers[reg]=value;++hardware.writes; + } + uint8_t readRegister8(uint8_t reg) { return hardware.registers[reg]; } + void selectLibrary(uint8_t value) { writeRegister8(3,value); } + void setMode(uint8_t value) { writeRegister8(1,value); } + void setWaveform(uint8_t slot,uint8_t value) { writeRegister8(4+slot,value); } + void setRealtimeValue(uint8_t value) { writeRegister8(2,value); } + void go() { writeRegister8(DRV2605_REG_GO,1); } + void stop() { writeRegister8(DRV2605_REG_GO,0); } +}; +''' + +BUZZER = r''' +#include +#define PIN_BUZZER 25 +#define PIN_BUZZER_EN 37 +#define MESH_GPS_VOICE @VOICE@ +#include +uint32_t now_ms=100; +int main() { + genericBuzzer buzzer;buzzer.begin(); + assert(buzzer.isQuiet()&&pin_states[PIN_BUZZER]==LOW&&pin_states[PIN_BUZZER_EN]==LOW); + assert(high_writes[PIN_BUZZER]==0&&high_writes[PIN_BUZZER_EN]==0); + buzzer.quiet(false); + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + assert(buzzer.isPlaying()); + buzzer.quiet(true); + assert(!buzzer.isPlaying()&&pin_states[PIN_BUZZER_EN]==LOW); + auto starts=rtttl::begins; + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + assert(!buzzer.isPlaying()&&rtttl::begins==starts); + assert(pin_states[PIN_BUZZER_EN]==LOW); +#if MESH_GPS_VOICE + // A rejected background melody cannot cancel explicit courtesy speech. + assert(buzzer.speakButton(ButtonVoicePrompt::SoundOff,true)); + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + buzzer.stopNotification(); + assert(buzzer.isQuiet()&&buzzer.isPlaying()); + auto cancelled=gps_voice.cancelled(); + buzzer.loop();now_ms+=100;buzzer.loop(); + assert(registers.ENABLE); + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + assert(registers.ENABLE&&gps_voice.cancelled()==cancelled); + buzzer.stop(); +#endif + buzzer.quiet(false); + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + assert(buzzer.isPlaying()&&rtttl::begins==starts+1); + buzzer.stopNotification();assert(!buzzer.isPlaying()); +} +''' + +VIBRATION = r''' +#include +@FLAGS@ +#include +#include +uint32_t now_ms=100; +#ifdef HAS_DRV2605 +using Vibration = DRV2605Vibration; +bool active() { return hardware.registers[1]!=0x40; } +#else +using Vibration = GenericVibration; +bool active() { return pin_states[PIN_VIBRATION]==HIGH; } +#endif +int main() { + Vibration motor;motor.begin(); + motor.trigger();assert(active()); + motor.quiet(true);assert(!active()&&!motor.isVibrating()); + motor.trigger();motor.pulse(true);motor.loop(); + assert(motor.isQuiet()&&!active()&&!motor.isVibrating()); + motor.pulse(false);assert(!active()); + motor.quiet(false); + motor.pulse(true);assert(active()); + motor.quiet(true);assert(!active()); + motor.quiet(false); + now_ms+=6000;motor.trigger();assert(active()); + motor.stop();assert(!active()); +#ifdef HAS_DRV2605 + hardware.found=false;motor.begin(); + auto writes=hardware.writes; + motor.quiet(true);motor.trigger(true);motor.pulse(true);motor.pulse(false); + assert(hardware.writes==writes&&!motor.isVibrating()); +#endif +} +''' + +UI = r''' +#include +#define MESH_GPS_VOICE 0 +@FLAGS@ +#include +#include +#include +uint32_t now_ms=100; +struct CompanionNodePrefs { uint8_t buzzer_quiet=0,vibe_quiet=0; }; +struct UITask { + CompanionNodePrefs* _node_prefs=nullptr; +#ifdef PIN_BUZZER + genericBuzzer buzzer; +#endif +#ifdef HAS_DRV2605 + DRV2605Vibration vibration; +#elif defined(PIN_VIBRATION) + GenericVibration vibration; +#endif + void syncAlertPreferences(); + void notificationMelody(const char*); + void notificationVibration(bool); +}; +@METHODS@ +int main() { + UITask ui;ui.syncAlertPreferences(); + CompanionNodePrefs prefs;ui._node_prefs=&prefs; +#ifdef PIN_BUZZER + ui.buzzer.begin();ui.buzzer.quiet(false);ui.notificationMelody("alert:d=4,o=5,b=120:c"); + assert(ui.buzzer.isPlaying()); +#endif +#if defined(HAS_DRV2605) || defined(PIN_VIBRATION) + ui.vibration.begin();ui.notificationVibration(true); +#endif + prefs.buzzer_quiet=prefs.vibe_quiet=1; + ui.syncAlertPreferences(); +#ifdef PIN_BUZZER + assert(ui.buzzer.isQuiet()&&!ui.buzzer.isPlaying()); + ui.notificationMelody("alert:d=4,o=5,b=120:c"); + assert(!ui.buzzer.isPlaying()); +#endif +#if defined(HAS_DRV2605) || defined(PIN_VIBRATION) + assert(ui.vibration.isQuiet()&&!ui.vibration.isVibrating()); + ui.notificationVibration(true); +#ifdef HAS_DRV2605 + assert(hardware.registers[1]==0x40); + auto writes=hardware.writes; + ui.syncAlertPreferences();ui.syncAlertPreferences(); + assert(hardware.writes==writes); // unchanged prefs must not add I2C traffic +#else + assert(pin_states[PIN_VIBRATION]==LOW); +#endif +#endif + prefs.buzzer_quiet=prefs.vibe_quiet=0; + ui.syncAlertPreferences(); +#ifdef PIN_BUZZER + ui.notificationMelody("alert:d=4,o=5,b=120:c"); + assert(ui.buzzer.isPlaying()); + ui.notificationMelody(nullptr);assert(!ui.buzzer.isPlaying()); +#endif +#if defined(HAS_DRV2605) || defined(PIN_VIBRATION) + ui.notificationVibration(true); +#ifdef HAS_DRV2605 + assert(hardware.registers[1]==DRV2605_MODE_REALTIME); +#else + assert(pin_states[PIN_VIBRATION]==HIGH); +#endif + ui.notificationVibration(false); +#endif +} +''' + + +class CompanionAlertDriversTests(unittest.TestCase): + def build(self, source): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-alerts-") as directory: + directory = Path(directory) + for name, content in (("Arduino.h", ARDUINO), ("Wire.h", WIRE), + ("Adafruit_DRV2605.h", ADAFRUIT), + ("HardwarePWM.h", voice.PWM), + ("NonBlockingRtttl.h", voice.RTTTL)): + (directory / name).write_text(content) + cpp = directory / "test.cpp" + exe = directory / ("test.exe" if os.name == "nt" else "test") + cpp.write_text(source) + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-I", str(directory), + "-I", str(ROOT / "src"), str(cpp), "-o", str(exe)], + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_programmable_sound_obeys_mute_and_preserves_courtesy_speech(self): + for speech in (0, 1): + with self.subTest(speech=speech): + self.build(BUZZER.replace("@VOICE@", str(speech))) + + def test_gpio_and_drv2605_mute_stops_active_effects_and_blocks_every_entry(self): + for flags in ("#define PIN_VIBRATION 12", "#define HAS_DRV2605 1"): + with self.subTest(flags=flags): + self.build(VIBRATION.replace("@FLAGS@", flags)) + + def test_each_ui_applies_saved_changes_without_reboot_or_repeated_driver_writes(self): + for ui in ("ui-orig", "ui-new", "ui-tiny"): + source = (ROOT / "examples/companion_radio" / ui / "UITask.cpp").read_text() + methods = "\n".join(method(source, signature) for signature in ( + "void UITask::syncAlertPreferences()", + "void UITask::notificationMelody(const char* text)", + "void UITask::notificationVibration(bool on)", + )) + for flags in ("#define PIN_BUZZER 25\n#define PIN_VIBRATION 12", + "#define PIN_BUZZER 25", "#define HAS_DRV2605 1", ""): + with self.subTest(ui=ui, flags=flags): + self.build(UI.replace("@FLAGS@", flags).replace("@METHODS@", methods)) + loop = method(source, "void UITask::loop()") + self.assertRegex(loop, r"\{\s*syncAlertPreferences\(\);") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_compact_led_control.py b/test/test_companion_compact_led_control.py new file mode 100644 index 000000000..5a77a7512 --- /dev/null +++ b/test/test_companion_compact_led_control.py @@ -0,0 +1,227 @@ +"""Exercise the compact Companion LED CLI and its existing preference store.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def method(source, name): + match = re.search(rf"\bbool\s+MyMesh::{name}\([^)]*\)\s*\{{", source) + if not match: + raise AssertionError(f"production {name} is missing") + end, depth = match.end(), 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[match.start():end] + + +def startup_loader(): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + setup = source.split("void MyMesh::configureRadioFromPrefs() {", 1)[1] + match = re.search( + r"#if defined\(WIO_E5_MINI_HEADLESS_COMPANION_UI\)\n(.*?)#endif", setup, re.S) + if not match: + raise AssertionError("compact LED startup restore is missing") + return "void MyMesh::loadCompactLedSetting() {\n" + match.group(1) + "}\n" + + +STUBS = r""" +#include +#include +#include +#include +#include +#include +#include +#include +std::vector events; +struct Prefs { + uint8_t leds_quiet = 0; + uint32_t unrelated = 0x12345678; +}; +struct Board { + unsigned applies = 0; + bool level = true; + void applyLedPolicy() { + events.push_back("board"); + ++applies; + level = mesh::leds::enabled(); + } +} board; +unsigned status_applies = 0; +bool status_level = true; +namespace mesh { namespace leds { +void applyStatusPolicy() { + events.push_back("status"); + ++status_applies; + status_level = enabled(); +} +}} +class MyMesh { +public: + Prefs _prefs; + Prefs saved; + bool save_ok = true; + unsigned saves = 0; + bool savePrefs() { + events.push_back("save"); + ++saves; + if (!save_ok) return false; + saved = _prefs; + return true; + } + bool handleCompactLedCommand(const char*, char*, size_t); + void loadCompactLedSetting(); +}; +void reset() { + mesh::leds::setEnabled(true); + board.applies = status_applies = 0; + board.level = status_level = true; + events.clear(); +} +""" + + +SCENARIOS = r""" +std::string run(MyMesh& mesh, const char* command) { + char reply[160] = {}; + assert(mesh.handleCompactLedCommand(command, reply, sizeof(reply))); + return reply; +} +int main() { + reset(); + MyMesh mesh; + assert(run(mesh, "get leds") == "> on"); + assert(mesh.saves == 0 && board.applies == 0 && status_applies == 0); + for (const char* command : {"set leds", "set leds ", + "set leds 1", "set leds 0", "set leds ON", "set leds OFF", "set leds true", + "set leds off extra", "set leds on extra"}) { + assert(run(mesh, command).find("Error") == 0); + assert(mesh.saves == 0 && board.applies == 0 && status_applies == 0); + assert(mesh::leds::enabled()); + } + for (const char* command : {"get leds on", "get leds ", "get led", "get leds.extra", "get ledsx", "set ledsx on", + "set led on", "get buzzer", "get radio", "get", "set", ""}) { + char reply[16] = "unchanged"; + assert(!mesh.handleCompactLedCommand(command, reply, sizeof(reply))); + assert(strcmp(reply, "unchanged") == 0); + } + assert(run(mesh, "set leds on") == "OK"); + assert(mesh.saves == 0); + assert(mesh._prefs.leds_quiet == 0); + + // An unchanged stored preference can repair stale live policy without flash. + mesh::leds::setEnabled(false); + assert(run(mesh, "set leds on") == "OK"); + assert(mesh.saves == 0 && mesh::leds::enabled() && board.level && status_level); + events.clear(); + const unsigned applied = board.applies, status = status_applies; + + // A failed preference transaction restores the byte and leaves live output. + mesh.save_ok = false; + assert(run(mesh, "set leds off").find("Error") == 0); + assert(mesh.saves == 1 && mesh._prefs.leds_quiet == 0); + assert(mesh::leds::enabled() && mesh.saved.leds_quiet == 0); + assert(board.applies == applied && status_applies == status); + assert(events == std::vector{"save"}); + + mesh.save_ok = true; + events.clear(); + assert(run(mesh, "set leds off") == "OK"); + assert(mesh.saves == 2 && !mesh::leds::enabled()); + assert(mesh._prefs.leds_quiet == 1 && mesh.saved.leds_quiet == 1); + assert(mesh._prefs.unrelated == 0x12345678 && mesh.saved.unrelated == 0x12345678); + assert(board.applies == applied + 1 && !board.level); + assert(status_applies == status + 1 && !status_level); + assert((events == std::vector{"save", "board", "status"})); + assert(run(mesh, "get leds") == "> off"); + assert(run(mesh, "set leds off") == "OK" && mesh.saves == 2); + + // Reload durable preference bytes through the actual compact startup block. + MyMesh reboot; + reboot._prefs = mesh.saved; + reset(); + reboot.loadCompactLedSetting(); + assert(!mesh::leds::enabled()); + assert(board.applies == 1 && !board.level && status_applies == 1 && !status_level); + assert(run(reboot, "get leds") == "> off"); + assert(run(reboot, "set leds off") == "OK" && reboot.saves == 0); + reboot.save_ok = false; + const unsigned before_failed_apply = board.applies, before_failed_status = status_applies; + assert(run(reboot, "set leds on").find("Error") == 0 && reboot.saves == 1); + assert(reboot._prefs.leds_quiet == 1 && !mesh::leds::enabled()); + assert(board.applies == before_failed_apply && status_applies == before_failed_status); + reboot.save_ok = true; + assert(run(reboot, "set leds on") == "OK" && reboot.saves == 2); + assert(reboot.saved.leds_quiet == 0 && reboot.saved.unrelated == 0x12345678); + assert(mesh::leds::enabled() && board.level && status_level); + MyMesh restored_on; + restored_on._prefs = reboot.saved; + mesh::leds::setEnabled(false); + restored_on.loadCompactLedSetting(); + assert(mesh::leds::enabled() && board.level && status_level); + + // Historic reserved zero means on; only exactly one requests quiet LEDs. + for (unsigned value = 0; value < 256; ++value) { + MyMesh fresh; + fresh._prefs.leds_quiet = value; + reset(); + mesh::leds::setEnabled(false); + fresh.loadCompactLedSetting(); + const bool on = value != 1; + assert(mesh::leds::enabled() == on && fresh._prefs.leds_quiet == !on); + assert(board.applies == 1 && board.level == on); + assert(status_applies == 1 && status_level == on); + assert(fresh.saves == 0); + } + for (const char* command : {"get leds", "set leds invalid", "set leds off", "set leds on"}) { + for (size_t capacity = 1; capacity <= 160; ++capacity) { + char reply[164]; memset(reply, '#', sizeof(reply)); + memcpy(reply, "ab|", 3); + assert(mesh.handleCompactLedCommand(command, reply + 3, capacity)); + assert(memcmp(reply, "ab|", 3) == 0 && reply[3 + capacity] == '#'); + assert(memchr(reply + 3, 0, capacity)); + } + } + char reply[16]; + assert(!mesh.handleCompactLedCommand(nullptr, reply, sizeof(reply))); + assert(!mesh.handleCompactLedCommand("get leds", nullptr, sizeof(reply))); + assert(!mesh.handleCompactLedCommand("get leds", reply, 0)); +} +""" + + +class CompanionCompactLedControlTests(unittest.TestCase): + def test_preference_byte_storage_and_policy(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("C++ compiler unavailable") + source = (ROOT / "examples/companion_radio/Notifications.cpp").read_text() + source = STUBS + method(source, "handleCompactLedCommand") + startup_loader() + SCENARIOS + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + cpp = directory / "compact_led_cli.cpp" + exe = directory / ("compact_led_cli.exe" if os.name == "nt" else "compact_led_cli") + cpp.write_text(source, encoding="ascii") + flags = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + result = subprocess.run( + [compiler, "-std=c++11", "-Wall", "-Wextra", *flags, + "-DWIO_E5_MINI_HEADLESS_COMPANION_UI=1", "-I", str(ROOT / "src"), + str(cpp), "-o", str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index 7e0a7bf3c..40fb6f233 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -51,6 +51,8 @@ bool contactPathPresence(FILESYSTEM* fs,const char* path,bool& present){ int main(int argc,char** argv){ const int scenario=argc>1?atoi(argv[1]):0; DataStore store;CompanionNodePrefs original; + assert(original.buzzer_quiet==1&&original.vibe_quiet==0); + original.buzzer_quiet=0;original.vibe_quiet=1; strcpy(original.node_name,"durable node");original.freq=910.525f; original.ble_pin=876543;original.bluetooth_stealth_mode=2; original.gps_interval=123;original.sf=7;original.cr=7;original.bw=62.5f; @@ -103,6 +105,7 @@ int main(int argc,char** argv){ CompanionNodePrefs loaded;double lat=0,lon=0; assert(store.loadPrefs(loaded,lat,lon)); assert(loaded.freq==changed.freq&&loaded.ble_pin==original.ble_pin); + assert(loaded.buzzer_quiet==0&&loaded.vibe_quiet==1); assert(loaded.tx_delay_factor==1.25f&&loaded.direct_tx_delay_factor==0.75f); assert(loaded.interference_threshold==8&&loaded.agc_reset_interval==15&&loaded.tz_offset==-7); assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3); @@ -153,6 +156,7 @@ int main(int argc,char** argv){ } DataStore fresh;CompanionNodePrefs defaults;double lat=0,lon=0; assert(fresh.loadPrefs(defaults,lat,lon)); + assert(defaults.buzzer_quiet==1&&defaults.vibe_quiet==0); assert(defaults.wifi_enabled==0); // Fresh installs never enable WiFi implicitly. assert(fresh.savePrefs(defaults,lat,lon)); } else if(scenario==2){ diff --git a/test/test_companion_prefs_image.py b/test/test_companion_prefs_image.py index e180e4e29..08a862296 100644 --- a/test/test_companion_prefs_image.py +++ b/test/test_companion_prefs_image.py @@ -11,7 +11,8 @@ from test_radio_receive_contract import method ROOT = Path(__file__).resolve().parents[1] # Published field order and sizes, independent of the implementation's emitter -# and the class's in-memory member order or padding. None represents zero pad. +# and the class's in-memory member order or padding. None represents reserved +# padding; compact Wio Companion claims the single byte at wire offset 79. WIRE_FIELDS = ( ('airtime_factor', 4), ('node_name', 32), (None, 4), ('@lat', 8), ('@lon', 8), ('freq', 4), ('sf', 1), ('cr', 1), @@ -147,17 +148,32 @@ int main() { #endif DataStore reference; assert(reference.savePrefsLegacy(prefs, 47.125, -122.25)); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + // The compact target claims exactly the former reserved byte. Its frozen + // predecessor still emits zero there; every other byte stays identical. + auto historical = store.fs.files.at("/new_prefs"); + assert(historical[79] == prefs.leds_quiet); + historical[79] = 0; + assert(reference.fs.files.at("/new_prefs") == historical); +#else assert(reference.fs.files.at("/new_prefs") == store.fs.files.at("/new_prefs")); +#endif ++comparisons; } CompanionNodePrefs original; + assert(original.buzzer_quiet == 1 && original.vibe_quiet == 0); assert(original.lost_reply == 0); assert(original.tx_media == mesh::RADIO_TX_AUTO && original.tx_ssh == mesh::RADIO_TX_AUTO); strcpy(original.node_name, "durable image"); original.freq = 915.25f; original.ble_pin = 123456; original.bluetooth_enabled = 1; original.usb_debug_enabled = 1; original.gps_sync_interval_hours = 336; + original.buzzer_quiet = 0; original.vibe_quiet = 1; +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + assert(original.leds_quiet == 0); + original.leds_quiet = 1; +#endif original.lost_reply = 2; original.tx_media = mesh::RADIO_TX_SECONDARY; original.tx_ssh = mesh::RADIO_TX_OFF; @@ -199,6 +215,11 @@ int main() { double lat = 1, lon = 2; assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon)); assert(live.lost_reply == 0 && live.ble_pin == original.ble_pin); + assert(live.buzzer_quiet == (length >= 85 ? 0 : 1)); + assert(live.vibe_quiet == (length >= 143 ? 1 : 0)); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + assert(live.leds_quiet == 1); +#endif assert(live.tx_media==mesh::RADIO_TX_AUTO&&live.tx_ssh==mesh::RADIO_TX_AUTO); assert(live.freq == original.freq && !strcmp(live.node_name, original.node_name)); assert(lat == 47.125 && lon == -122.25); @@ -232,6 +253,8 @@ int main() { assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon) == expected); if (expected) { assert(live.freq == original.freq); + assert(live.buzzer_quiet == (length >= 85 ? 0 : 1)); + assert(live.vibe_quiet == (length >= 143 ? 1 : 0)); assert(live.lost_reply == (length >= historical_size ? 2 : 0)); assert(live.tx_media == (length == before.size() ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_AUTO)); assert(live.tx_ssh == (length == before.size() ? mesh::RADIO_TX_OFF : mesh::RADIO_TX_AUTO)); @@ -291,8 +314,75 @@ int main() { changed.freq = 910.5f; changed.usb_debug_enabled = 0; changed.gps_sync_interval_hours = 24; changed.lost_reply = 1; +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + changed.leds_quiet = 0; +#endif const auto after = golden(changed, 42.25, -121.125); + // Saved choices remain independent of the new fresh-install defaults. + for (unsigned sound_quiet = 0; sound_quiet < 2; ++sound_quiet) { + for (unsigned vibration_quiet = 0; vibration_quiet < 2; ++vibration_quiet) { + CompanionNodePrefs saved; + assert(saved.copyPersistedValuesFrom(original)); + saved.buzzer_quiet = sound_quiet; + saved.vibe_quiet = vibration_quiet; + DataStore writer; + assert(writer.savePrefs(saved, 47.125, -122.25)); + const auto disk = writer.fs.files.at("/new_prefs"); + assert(disk[84] == sound_quiet && disk[142] == vibration_quiet); + CompanionNodePrefs reboot; + reboot.buzzer_quiet = !sound_quiet; + reboot.vibe_quiet = !vibration_quiet; + double lat = 0, lon = 0; + assert(writer.loadPrefsInt("/new_prefs", reboot, lat, lon)); + assert(reboot.buzzer_quiet == sound_quiet); + assert(reboot.vibe_quiet == vibration_quiet); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + assert(disk[79] == 1 && reboot.leds_quiet == 1); +#endif + assert(writer.fs.files.at("/new_prefs") == disk); + ++comparisons; + } + } +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + // The reserved byte is part of the atomic image, at its original offset. + // Old firmware emitted zero; raw invalid encodings are clamped by startup. + for (unsigned quiet : {0u, 1u, 2u, 255u}) { + CompanionNodePrefs saved; + assert(saved.copyPersistedValuesFrom(original)); + saved.leds_quiet = quiet; + DataStore writer; + assert(writer.savePrefs(saved, 47.125, -122.25)); + const auto disk = writer.fs.files.at("/new_prefs"); + assert(disk.size() == before.size() && disk[79] == quiet); + auto expected = before; expected[79] = quiet; + assert(disk == expected); + CompanionNodePrefs reboot; + reboot.leds_quiet = !quiet; + double lat = 0, lon = 0; + assert(writer.loadPrefsInt("/new_prefs", reboot, lat, lon)); + assert(reboot.leds_quiet == quiet); + assert(reboot.buzzer_quiet == 0 && reboot.vibe_quiet == 1); + assert(writer.fs.files.at("/new_prefs") == disk); + ++comparisons; + } + for (size_t length : {size_t(84), historical_size}) { + auto historical = before; + historical[79] = 0; + historical.resize(length); + DataStore old; + old.fs.files["/new_prefs"] = historical; + CompanionNodePrefs live; + live.leds_quiet = 1; + double lat = 0, lon = 0; + assert(old.loadPrefsInt("/new_prefs", live, lat, lon)); + assert(live.leds_quiet == 0); // Old reserved zero restores default-on LEDs. + assert(live.ble_pin == original.ble_pin && live.freq == original.freq); + assert(old.fs.files.at("/new_prefs") == historical); + ++comparisons; + } +#endif + // Every possible short write and readback, including every tail byte, // must preserve the old durable image. STM32 issues only one image write; // the other supported platforms preserve their scalar-write path. @@ -424,7 +514,14 @@ int main() { def field_code(rows, seed=False): result = [] for name, size in rows: - if name == 'tz_offset': + if name is None and size == 1: + result.append('#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)') + result.append('fill(&prefs.leds_quiet, sizeof(prefs.leds_quiet));' if seed + else 'append(&prefs.leds_quiet, 1);') + if not seed: + result.append('#else\nbytes.push_back(0);') + result.append('#endif') + elif name == 'tz_offset': if seed: result.append('prefs.tz_offset = float(int(random % 27) - 12);') else: @@ -530,7 +627,8 @@ inline char* utoa(unsigned int value,char* output,int base){ (work / 'helpers/IdentityStore.h').write_text(fs) cpp = work / 'test.cpp' cpp.write_text(code + tests) - for platform in ('STM32_PLATFORM', 'NRF52_PLATFORM', 'NRF52_PLATFORM,RAK_4631', 'ESP32_PLATFORM', + for platform in ('STM32_PLATFORM', 'STM32_PLATFORM,WIO_E5_MINI_HEADLESS_COMPANION_UI', + 'NRF52_PLATFORM', 'NRF52_PLATFORM,RAK_4631', 'ESP32_PLATFORM', 'RP2040_PLATFORM', 'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'): with self.subTest(platform=platform): diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 458d48786..4d8ae35d1 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -278,6 +278,7 @@ namespace leds { bool handleCommand(const char*,char*,size_t) { return false; } struct MyMesh { Radio radio; Radio* _radio = &radio; + bool handleAlertCommand(const char*, char*, size_t) { return false; } bool handleCommand(const char*, uint32_t, char*); }; @HANDLER@ diff --git a/test/test_drv2605_power.py b/test/test_drv2605_power.py index d6203d95c..bffffde3f 100644 --- a/test/test_drv2605_power.py +++ b/test/test_drv2605_power.py @@ -152,6 +152,8 @@ int main() { auto count=events.size(); vibration.trigger();assert(events.size()==count); // cooldown suppresses wake vibration.quiet(true); + assert(standby() && !vibration.isVibrating()); + count=events.size(); vibration.trigger(true);assert(events.size()==count); vibration.stop();assert(standby() && !vibration.isVibrating()); assert(hardware.registers[DRV2605_REG_GO]==0 && hardware.registers[2]==0); @@ -216,7 +218,7 @@ class Drv2605PowerTests(unittest.TestCase): source = source.replace("(drv.readRegister8(DRV2605_REG_GO) & 1) == 0", "(drv.readRegister8(DRV2605_REG_GO) & 1) != 0") elif mutation == "no-pulse-off": - source = source.replace("if (!on) {", "if (false && !on) {") + source = source.replace("if (!on || _quiet) {", "if (false && !on) {") elif mutation == "poll-idle": source = source.replace("if (!_ready || !_effect_active) return;", "if (!_ready) return;") diff --git a/test/test_gps_voice_buttons.py b/test/test_gps_voice_buttons.py index e15f1a8f3..27c68fec6 100644 --- a/test/test_gps_voice_buttons.py +++ b/test/test_gps_voice_buttons.py @@ -313,7 +313,7 @@ BUZZER_SEQUENCE = r''' #include uint32_t now_ms=0; int main(){ - genericBuzzer buzzer;buzzer.begin(); + genericBuzzer buzzer;buzzer.begin();buzzer.quiet(false); char status[180]; auto check=[&](const char* expected){buzzer.voiceStatus(status,sizeof(status));assert(strstr(status,expected));}; auto finish_tone=[&]{ @@ -363,7 +363,7 @@ BUZZER_TONES = r''' #include uint32_t now_ms=0; int main(){ - genericBuzzer buzzer;buzzer.begin(); + genericBuzzer buzzer;buzzer.begin();buzzer.quiet(false); assert(buzzer.speakGps(true));assert(buzzer.isPlaying()); assert(rtttl::melody=="Startup:d=4,o=5,b=160:16c6,16e6,8g6"); rtttl::finish();buzzer.loop();assert(!buzzer.isPlaying()); From d5a6df6283cb3979b946805f28cdbc00fc93877b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 20 Oct 2026 09:43:25 -0700 Subject: [PATCH 3/3] Detect SSD1306 and SH1106 screens on RAK boards Use repeated status reads at screen initialization and preserve SSD1306 for ambiguous responses. Share the existing framebuffer, drawing, rotation and power handling; use SH1106 page transfers when detected. Enable this for RAK4631 and RAK3401, including WisMesh Pocket v2/v3. Add controller overrides, protocol tests and CI coverage. Addresses meshcore-dev/MeshCore#3296. --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_commands.md | 13 + src/helpers/ui/AutoOLEDBackend.h | 102 +++++ src/helpers/ui/OLEDDisplayProbe.h | 33 ++ src/helpers/ui/SSD1306Display.h | 7 + test/test_oled_display_probe.py | 571 +++++++++++++++++++++++++++ variants/rak3401/platformio.ini | 1 + variants/rak4631/platformio.ini | 1 + 8 files changed, 729 insertions(+) create mode 100644 src/helpers/ui/AutoOLEDBackend.h create mode 100644 src/helpers/ui/OLEDDisplayProbe.h create mode 100644 test/test_oled_display_probe.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 8fb846fc7..ce0f76d59 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -996,6 +996,7 @@ jobs: python3 -B test/test_legacy_ui_version_bounds.py -v python3 -B test/test_infrastructure_ui_version.py -v python3 -B test/test_ssd1306_power.py -v + python3 -B test/test_oled_display_probe.py -v python3 -B test/test_companion_inbox.py python3 -B test/test_heltec_r8_external_power.py python3 -B test/test_bluetooth_mac_contract.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index d38a72f7d..e4b55a5fc 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1059,6 +1059,19 @@ set display.rotation 270 The values are clockwise degrees. `0` clears the override and restores the board's compiled default. Unsupported display drivers return an error. +RAK4631 and RAK3401 images detect SSD1306 or SH1106 OLED controllers when +initializing the screen. This lets the same RAK4631 image serve WisMesh Pocket +v2 and v3. Both retain rotation support and use one 1 KiB framebuffer. + +Detection checks the configured `DISPLAY_ADDRESS` (default `0x3C`). Only +repeated SH1106 status readings select that controller; ambiguous responses +keep SSD1306, preserving compatibility with older RAK1921 panels. For panels +that cannot identify themselves, build with `OLED_CONTROLLER_OVERRIDE=1` +(SSD1306) or `=2` (SH1106); `=0` restores automatic detection. + +See [the Pocket v3 issue](https://github.com/meshcore-dev/MeshCore/issues/3296) +and [the RAK1921 detection caveat](https://github.com/meshtastic/firmware/pull/11265). + --- ## Show touchscreen touch areas diff --git a/src/helpers/ui/AutoOLEDBackend.h b/src/helpers/ui/AutoOLEDBackend.h new file mode 100644 index 000000000..cff55bd20 --- /dev/null +++ b/src/helpers/ui/AutoOLEDBackend.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include "OLEDDisplayProbe.h" + +// Keep Adafruit's drawing, rotation and single 1 KiB framebuffer. SH1106 uses +// page writes into 132-column RAM, with the visible 128 columns starting at 2. +class AutoOLEDBackend : public Adafruit_SSD1306 { + mesh::ui::OLEDController _controller = mesh::ui::OLEDController::Absent; + bool _ready = false; + + bool writeCommands(const uint8_t* commands, uint8_t count) { + wire->beginTransmission(i2caddr); + const bool control = wire->write(uint8_t(0x00)) == 1; + const bool payload = wire->write(commands, count) == count; + return wire->endTransmission() == 0 && control && payload; + } + +public: + using Adafruit_SSD1306::Adafruit_SSD1306; + + bool begin(uint8_t vcc, uint8_t address, bool reset, bool periphBegin) { + _ready = false; + i2caddr = address; + if (periphBegin) wire->begin(); + if (reset && rstPin >= 0) { + pinMode(rstPin, OUTPUT); + digitalWrite(rstPin, HIGH); + delay(1); + digitalWrite(rstPin, LOW); + delay(10); + digitalWrite(rstPin, HIGH); + } + +#if defined(OLED_CONTROLLER_OVERRIDE) && OLED_CONTROLLER_OVERRIDE != 0 + static_assert(OLED_CONTROLLER_OVERRIDE == 1 || OLED_CONTROLLER_OVERRIDE == 2, + "OLED_CONTROLLER_OVERRIDE must be 0 (auto), 1 (SSD1306) or 2 (SH1106)"); + wire->beginTransmission(address); + _controller = wire->endTransmission() != 0 ? mesh::ui::OLEDController::Absent + : OLED_CONTROLLER_OVERRIDE == 2 ? mesh::ui::OLEDController::SH1106 + : mesh::ui::OLEDController::SSD1306; +#else + _controller = mesh::ui::detectOLEDController(*wire, address); +#endif + if (_controller == mesh::ui::OLEDController::Absent) return false; + if (_controller == mesh::ui::OLEDController::SSD1306) { + _ready = Adafruit_SSD1306::begin(vcc, address, false, false); + return _ready; + } + + if (!buffer && !(buffer = static_cast(malloc(WIDTH * ((HEIGHT + 7) / 8))))) + return false; + vccstate = vcc; + clearDisplay(); + // SH1106G initialization, matching Adafruit_SH1106G::begin(). + static const uint8_t init[] = { + 0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, + 0xAD, 0x8B, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xFF, + 0xD9, 0x1F, 0xDB, 0x40, 0x33, 0xA6, 0x20, 0x10, 0xA4 + }; + wire->setClock(wireClk); + const bool initialized = writeCommands(init, sizeof(init)); + wire->setClock(restoreClk); + if (!initialized) return false; + delay(100); + const uint8_t on = 0xAF; + wire->setClock(wireClk); + _ready = writeCommands(&on, 1); + wire->setClock(restoreClk); + return _ready; + } + + mesh::ui::OLEDController controller() const { return _controller; } + + void ssd1306_command(uint8_t command) { + if (_ready) Adafruit_SSD1306::ssd1306_command(command); + } + + void display() { + if (!_ready) return; + if (_controller == mesh::ui::OLEDController::SSD1306) { + Adafruit_SSD1306::display(); + return; + } + wire->setClock(wireClk); + bool ok = true; + for (uint8_t page = 0; ok && page < (HEIGHT + 7) / 8; ++page) { + const uint8_t commands[] = {uint8_t(0xB0 | page), 0x02, 0x10}; + ok = writeCommands(commands, sizeof(commands)); + for (uint16_t column = 0; ok && column < WIDTH;) { + const uint8_t count = WIDTH - column > 31 ? 31 : WIDTH - column; + wire->beginTransmission(i2caddr); + const bool control = wire->write(uint8_t(0x40)) == 1; + const bool payload = wire->write(buffer + page * WIDTH + column, count) == count; + ok = wire->endTransmission() == 0 && control && payload; + column += count; + } + } + wire->setClock(restoreClk); + } +}; diff --git a/src/helpers/ui/OLEDDisplayProbe.h b/src/helpers/ui/OLEDDisplayProbe.h new file mode 100644 index 000000000..fc46ea840 --- /dev/null +++ b/src/helpers/ui/OLEDDisplayProbe.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace mesh { namespace ui { + +enum class OLEDController : uint8_t { Absent, SSD1306, SH1106 }; + +// Status-byte detection is used by ss_oled and Meshtastic. Only 0x08 is +// evidence of SH1106: some RAK1921 SSD1306 panels return the ambiguous 0x00. +// See https://github.com/meshtastic/firmware/pull/11265. +template +OLEDController detectOLEDController(WireBus& wire, uint8_t address) { + wire.beginTransmission(address); + if (wire.endTransmission() != 0) return OLEDController::Absent; + + int previous = -1; + for (unsigned attempt = 0; attempt < 4; ++attempt) { + wire.beginTransmission(address); + const bool written = wire.write(uint8_t(0x00)) == 1; + if (wire.endTransmission() != 0 || !written) break; + if (wire.requestFrom(address, uint8_t(1)) != 1 || wire.available() != 1) break; + const int status = wire.read(); + if (status < 0) break; + const int nibble = status & 0x0f; + if (nibble == previous) + return nibble == 0x08 ? OLEDController::SH1106 : OLEDController::SSD1306; + previous = nibble; + } + return OLEDController::SSD1306; +} + +}} // namespace mesh::ui diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index 0c387b9b5..5df001706 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -6,6 +6,9 @@ #define SSD1306_NO_SPLASH #include #include +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT +#include "AutoOLEDBackend.h" +#endif #ifndef PIN_OLED_RESET #define PIN_OLED_RESET 21 // Reset pin # (or -1 if sharing Arduino reset pin) @@ -21,7 +24,11 @@ public: static constexpr int16_t PANEL_HEIGHT = 64; static constexpr size_t FRAMEBUFFER_BYTES = PANEL_WIDTH * ((PANEL_HEIGHT + 7) / 8); private: +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT + AutoOLEDBackend display; +#else Adafruit_SSD1306 display; +#endif bool _isOn; bool _panel_ready = false; bool _flipped = false; diff --git a/test/test_oled_display_probe.py b/test/test_oled_display_probe.py new file mode 100644 index 000000000..0bcfaa320 --- /dev/null +++ b/test/test_oled_display_probe.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +"""Run the real SSD1306/SH1106 status probe against scripted I2C replies.""" + +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include + +struct Sample { + uint8_t status; + uint8_t count = 1; + bool available = true; + int read = -2; // -2 supplies the status byte, -1 simulates a failed read. +}; + +struct ScriptedWire { + uint8_t expected_address = 0x3C; + uint8_t probe_error = 0; + std::vector samples; + std::vector status_errors; + std::vector> writes; + unsigned requests = 0, reads = 0, ends = 0, write_calls = 0; + unsigned failed_write = std::numeric_limits::max(); + Sample current{}; + + void beginTransmission(uint8_t address) { + assert(address == expected_address); + writes.push_back({}); + } + size_t write(uint8_t byte) { + assert(!writes.empty()); + writes.back().push_back(byte); + return write_calls++ == failed_write ? 0 : 1; + } + uint8_t endTransmission(bool = true) { + const unsigned transaction = ends++; + if (transaction == 0) return probe_error; + const unsigned sample = transaction - 1; + return sample < status_errors.size() ? status_errors[sample] : 0; + } + uint8_t requestFrom(uint8_t address, uint8_t count) { + assert(address == expected_address); + assert(count == 1); + assert(requests < 4); + current = requests < samples.size() ? samples[requests] : Sample{0}; + ++requests; + return current.count; + } + int available() { return current.available ? current.count : 0; } + int read() { + ++reads; + return current.read == -2 ? current.status : current.read; + } + + void checkBus() const { + assert(ends <= 5); + assert(requests <= 4); + assert(reads <= requests); + assert(!writes.empty()); + assert(writes.front().empty()); // Address probe does not alter the panel. + for (size_t i = 1; i < writes.size(); ++i) { + assert(writes[i] == std::vector{0x00}); + } + } +}; + +using mesh::ui::OLEDController; + +static OLEDController detect(ScriptedWire& wire) { + const auto controller = mesh::ui::detectOLEDController(wire, wire.expected_address); + wire.checkBus(); + return controller; +} + +static ScriptedWire status(std::initializer_list values) { + ScriptedWire wire; + for (uint8_t value : values) wire.samples.push_back({value}); + return wire; +} + +int main() { + for (uint8_t address : {0x3C, 0x3D}) { + for (uint8_t error : {1, 2, 3, 4, 5}) { + ScriptedWire wire; + wire.expected_address = address; + wire.probe_error = error; + assert(detect(wire) == OLEDController::Absent); + assert(wire.requests == 0 && wire.reads == 0 && wire.ends == 1); + } + auto wire = status({0x08, 0x08}); + wire.expected_address = address; + assert(detect(wire) == OLEDController::SH1106); + assert(wire.requests == 2); + } + + // Only the status low nibble identifies the controller. Busy bit 0x80 + // alone is also seen on SSD1306 hardware and must not select SH1106. + for (unsigned value = 0; value <= 255; ++value) { + auto wire = status({uint8_t(value), uint8_t(value), + uint8_t(value), uint8_t(value)}); + const auto expected = (value & 0x0F) == 0x08 + ? OLEDController::SH1106 : OLEDController::SSD1306; + assert(detect(wire) == expected); + assert(wire.requests == 2); + } + for (auto values : { + std::initializer_list{8, 0, 8, 0}, + {0, 8, 0, 8}, {8, 0x80, 8, 0x80}, {0xFF, 8, 0xFF, 8}}) { + auto wire = status(values); + assert(detect(wire) == OLEDController::SSD1306); + assert(wire.requests == 4); + } + for (auto values : { + std::initializer_list{0, 8, 0x88}, + {0, 1, 8, 8}, {8, 0, 8, 8}}) { + auto wire = status(values); + assert(detect(wire) == OLEDController::SH1106); + assert(wire.requests >= 3 && wire.requests <= 4); + } + // Two ordinary stable statuses identify SSD1306 without wasting all four + // reads, and must not be overturned by later unrelated reply bytes. + auto stable_ssd = status({0x00, 0x80, 8, 8}); + assert(detect(stable_ssd) == OLEDController::SSD1306); + assert(stable_ssd.requests == 2); + + // Incomplete and failed samples may never complete a matching pair. + for (unsigned failure = 0; failure < 4; ++failure) { + auto wire = status({8, 8, 0, 0}); + if (failure == 0) wire.samples[1].count = 0; + if (failure == 1) wire.samples[1].available = false; + if (failure == 2) wire.samples[1].read = -1; + if (failure == 3) wire.samples[1].count = 2; + assert(detect(wire) == OLEDController::SSD1306); + assert(wire.requests == 2); + } + for (unsigned failed_sample = 0; failed_sample < 4; ++failed_sample) { + auto wire = status({0, 1, 2, 3}); + wire.status_errors.resize(4, 0); + wire.status_errors[failed_sample] = 2; + assert(detect(wire) == OLEDController::SSD1306); + assert(wire.requests == failed_sample); + assert(wire.ends == failed_sample + 2); + } + for (unsigned failed_sample = 0; failed_sample < 4; ++failed_sample) { + auto wire = status({0, 1, 2, 3}); + wire.failed_write = failed_sample; + assert(detect(wire) == OLEDController::SSD1306); + assert(wire.requests == failed_sample); + } + + // No detection state carries between boots, addresses, or missing panels. + auto sh = status({8, 8}); + auto ssd = status({0x80, 0x80, 0x80, 0x80}); + ScriptedWire missing; + missing.probe_error = 2; + assert(detect(sh) == OLEDController::SH1106); + assert(detect(ssd) == OLEDController::SSD1306); + assert(detect(missing) == OLEDController::Absent); + std::puts("PASS: OLED controller status detection, errors and sample bounds"); +} +''' + +ARDUINO = r''' +#pragma once +#include +#include +#include +#define OUTPUT 1 +#define HIGH 1 +#define LOW 0 +inline std::vector> pin_modes, pin_levels; +inline std::vector delays; +inline void pinMode(int pin, int mode) { pin_modes.push_back({pin, mode}); } +inline void digitalWrite(int pin, int level) { pin_levels.push_back({pin, level}); } +inline void delay(unsigned ms) { delays.push_back(ms); } +''' + +WIRE = r''' +#pragma once +#include +#include +#include +#include +#include +#include +struct TwoWire { + struct Transaction { uint8_t address; std::vector data; }; + std::vector transactions; + std::vector clocks; + std::map present{{0x3C, true}, {0x3D, false}}; + std::map> status{{0x3C, {8, 8, 8, 8}}}; + std::map requests; + unsigned begun = 0, byte_writes = 0, block_writes = 0; + unsigned failed_tx = std::numeric_limits::max(); + unsigned failed_byte = std::numeric_limits::max(); + unsigned failed_block = std::numeric_limits::max(); + int current_read = 0; + void begin() { ++begun; } + void setClock(uint32_t value) { clocks.push_back(value); } + void beginTransmission(uint8_t address) { transactions.push_back({address, {}}); } + size_t write(uint8_t byte) { + assert(!transactions.empty()); + transactions.back().data.push_back(byte); + return byte_writes++ == failed_byte ? 0 : 1; + } + size_t write(const uint8_t* bytes, size_t count) { + assert(!transactions.empty()); + transactions.back().data.insert(transactions.back().data.end(), bytes, bytes + count); + assert(transactions.back().data.size() <= 32); + return block_writes++ == failed_block ? count - 1 : count; + } + uint8_t endTransmission(bool = true) { + const auto& tx = transactions.back(); + return !present[tx.address] || transactions.size() - 1 == failed_tx ? 2 : 0; + } + uint8_t requestFrom(uint8_t address, uint8_t count) { + assert(count == 1); + const unsigned index = requests[address]++; + current_read = index < status[address].size() ? status[address][index] : 0; + return present[address] ? 1 : 0; + } + int available() { return 1; } + int read() { return current_read; } +}; +inline TwoWire Wire; +''' + +VENDOR = r''' +#pragma once +#include +#include +#include +#include +#define SSD1306_SWITCHCAPVCC 2 +void* test_malloc(size_t count); +void test_free(void* pointer); +class Adafruit_SSD1306 { +protected: + const uint16_t WIDTH, HEIGHT; + TwoWire* wire; + uint8_t* buffer = nullptr; + int8_t rstPin, i2caddr = 0, vccstate = 0; + uint32_t wireClk, restoreClk; +public: + unsigned base_begin_calls = 0, base_display_calls = 0; + bool fail_begin = false, last_reset = true, last_periph_begin = true; + Adafruit_SSD1306(uint8_t width, uint8_t height, TwoWire* bus = &Wire, + int8_t reset = -1, uint32_t during = 400000, uint32_t after = 100000) + : WIDTH(width), HEIGHT(height), wire(bus), rstPin(reset), + wireClk(during), restoreClk(after) {} + ~Adafruit_SSD1306() { test_free(buffer); } + bool begin(uint8_t vcc, uint8_t address, bool reset, bool periph_begin) { + ++base_begin_calls; + last_reset = reset; + last_periph_begin = periph_begin; + if (fail_begin) return false; + if (!buffer) buffer = static_cast(test_malloc(WIDTH * ((HEIGHT + 7) / 8))); + if (!buffer) return false; + i2caddr = address; + vccstate = vcc; + clearDisplay(); + return true; + } + void clearDisplay() { std::memset(buffer, 0, WIDTH * ((HEIGHT + 7) / 8)); } + uint8_t* getBuffer() { return buffer; } + void display() { ++base_display_calls; } + void ssd1306_command(uint8_t command) { + wire->beginTransmission(uint8_t(i2caddr)); + wire->write(uint8_t(0)); + wire->write(command); + wire->endTransmission(); + } +}; +''' + +BACKEND_HARNESS = r''' +#include +#include +#include +#include +#include +#include +static unsigned active_allocations = 0, total_allocations = 0; +static bool allocation_fails = false; +void* test_malloc(size_t count) { + assert(count == 1024); + if (allocation_fails) return nullptr; + void* result = std::malloc(count); + assert(result); + ++active_allocations; + ++total_allocations; + return result; +} +void test_free(void* pointer) { + if (!pointer) return; + assert(active_allocations == 1); + --active_allocations; + std::free(pointer); +} +#define malloc test_malloc +#include +#undef malloc +using mesh::ui::OLEDController; + +static void resetPins() { pin_modes.clear(); pin_levels.clear(); delays.clear(); } +static unsigned totalRequests(const TwoWire& wire) { + unsigned count = 0; + for (const auto& entry : wire.requests) count += entry.second; + return count; +} +[[maybe_unused]] static void beginSH(AutoOLEDBackend& display, TwoWire& wire) { + assert(display.begin(SSD1306_SWITCHCAPVCC, 0x3C, true, false)); + assert(display.controller() == OLEDController::SH1106); + assert(display.base_begin_calls == 0); + assert(display.getBuffer() != nullptr); + assert(active_allocations == 1); + assert(wire.begun == 0); + assert(wire.clocks.back() == 100000); +} +[[maybe_unused]] static void verifyFrame(AutoOLEDBackend& display, TwoWire& wire) { + uint8_t* buffer = display.getBuffer(); + for (unsigned i = 0; i < 1024; ++i) buffer[i] = uint8_t((i * 37 + i / 128) & 255); + const size_t first = wire.transactions.size(); + display.display(); + assert(display.base_display_calls == 0); + size_t index = first; + for (unsigned page = 0; page < 8; ++page) { + const auto& command = wire.transactions.at(index++); + assert(command.address == 0x3C); + assert(command.data == (std::vector{0x00, uint8_t(0xB0 | page), 0x02, 0x10})); + unsigned column = 0; + while (column < 128) { + const auto& data = wire.transactions.at(index++); + assert(data.address == 0x3C); + const unsigned count = std::min(31u, 128u - column); + assert(data.data.size() == count + 1); + assert(data.data[0] == 0x40); + for (unsigned i = 0; i < count; ++i) + assert(data.data[i + 1] == buffer[page * 128 + column + i]); + column += count; + } + } + assert(index == wire.transactions.size()); + assert(wire.clocks.back() == 100000); +} + +int main() { + resetPins(); +#if defined(OLED_CONTROLLER_OVERRIDE) && OLED_CONTROLLER_OVERRIDE == 1 + { + TwoWire wire; + AutoOLEDBackend display(128, 64, &wire, -1); + assert(display.begin(2, 0x3C, true, true)); + assert(display.controller() == OLEDController::SSD1306); + assert(display.base_begin_calls == 1); + assert(!display.last_reset && !display.last_periph_begin); + assert(wire.begun == 1 && totalRequests(wire) == 0); + display.display(); + assert(display.base_display_calls == 1); + } +#else + { + TwoWire wire; +#if defined(OLED_CONTROLLER_OVERRIDE) && OLED_CONTROLLER_OVERRIDE == 2 + wire.status[0x3C] = {0, 0}; +#endif + AutoOLEDBackend display(128, 64, &wire, 9); + beginSH(display, wire); + assert(pin_modes == (std::vector>{{9, OUTPUT}})); + assert(pin_levels == (std::vector>{{9, HIGH}, {9, LOW}, {9, HIGH}})); + assert(delays == (std::vector{1, 10, 100})); + assert(totalRequests(wire) == +#if defined(OLED_CONTROLLER_OVERRIDE) && OLED_CONTROLLER_OVERRIDE == 2 + 0 +#else + 2 +#endif + ); + const std::vector init = { + 0x00, 0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, + 0xAD, 0x8B, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xFF, + 0xD9, 0x1F, 0xDB, 0x40, 0x33, 0xA6, 0x20, 0x10, 0xA4 + }; + assert(wire.transactions[wire.transactions.size() - 2].data == init); + assert(wire.transactions.back().data == (std::vector{0, 0xAF})); + verifyFrame(display, wire); + const auto* original = display.getBuffer(); + const auto allocations = total_allocations; + resetPins(); + assert(display.begin(2, 0x3C, false, false)); + assert(display.getBuffer() == original && total_allocations == allocations); + assert(pin_modes.empty() && pin_levels.empty()); + verifyFrame(display, wire); + } + assert(active_allocations == 0); + + // Initialization and frame errors stop further writes, restore the bus + // clock, and leave a bounded framebuffer available for a later retry. + for (unsigned failure = 0; failure < 8; ++failure) { + TwoWire wire; + AutoOLEDBackend display(128, 64, &wire, -1); + const unsigned init = +#if defined(OLED_CONTROLLER_OVERRIDE) && OLED_CONTROLLER_OVERRIDE == 2 + 1; +#else + 3; +#endif + if (failure < 2) wire.failed_tx = init + failure; + if (failure == 2) wire.failed_byte = init - 1; + if (failure == 3) wire.failed_block = 0; + if (failure < 4) { + assert(!display.begin(2, 0x3C, false, false)); + assert(wire.clocks.back() == 100000); + const auto transfers = wire.transactions.size(); + display.display(); + display.ssd1306_command(0xAF); + assert(wire.transactions.size() == transfers); + } else { + beginSH(display, wire); + const auto transfers = wire.transactions.size(); + if (failure == 4) wire.failed_tx = transfers; + if (failure == 5) wire.failed_block = wire.block_writes + 1; + if (failure == 6) wire.failed_tx = transfers + 1; + if (failure == 7) wire.failed_byte = wire.byte_writes + 1; + display.display(); + assert(wire.transactions.size() == transfers + (failure == 4 ? 1 : 2)); + assert(wire.clocks.back() == 100000); + } + wire.failed_tx = wire.failed_byte = wire.failed_block = ~0u; + wire.requests.clear(); + const auto* original = display.getBuffer(); + const auto allocations = total_allocations; + assert(display.begin(2, 0x3C, false, false)); + assert(display.getBuffer() == original && total_allocations == allocations); + verifyFrame(display, wire); + } + assert(active_allocations == 0); + { + TwoWire wire; + AutoOLEDBackend display(128, 64, &wire, -1); + allocation_fails = true; + assert(!display.begin(2, 0x3C, false, false)); + assert(display.getBuffer() == nullptr && active_allocations == 0); + const auto transfers = wire.transactions.size(); + display.display(); + display.ssd1306_command(0xAF); + assert(wire.transactions.size() == transfers); + allocation_fails = false; + wire.requests.clear(); + beginSH(display, wire); + } +#endif + assert(active_allocations == 0); + +#if !defined(OLED_CONTROLLER_OVERRIDE) || OLED_CONTROLLER_OVERRIDE == 0 + { + TwoWire wire; + wire.status[0x3C] = {0, 0}; + AutoOLEDBackend display(128, 64, &wire, -1); + assert(display.begin(2, 0x3C, false, false)); + const auto* original = display.getBuffer(); + const auto allocations = total_allocations; + wire = TwoWire{}; + assert(display.begin(2, 0x3C, false, false)); + assert(display.controller() == OLEDController::SH1106); + assert(display.getBuffer() == original && total_allocations == allocations); + verifyFrame(display, wire); + wire = TwoWire{}; + wire.present[0x3C] = false; + assert(!display.begin(2, 0x3C, false, false)); + assert(display.controller() == OLEDController::Absent); + const auto transfers = wire.transactions.size(); + display.display(); + assert(wire.transactions.size() == transfers); + wire = TwoWire{}; + wire.status[0x3C] = {0x80, 0x80}; + assert(display.begin(2, 0x3C, false, false)); + assert(display.controller() == OLEDController::SSD1306); + assert(display.getBuffer() == original && total_allocations == allocations); + display.display(); + assert(display.base_display_calls == 1 && display.base_begin_calls == 2); + } + assert(active_allocations == 0); + for (bool fail : {false, true}) { + TwoWire wire; + wire.status[0x3C] = {0x00, 0x80}; + AutoOLEDBackend display(128, 64, &wire, -1); + display.fail_begin = fail; + assert(display.begin(2, 0x3C, true, true) == !fail); + assert(display.controller() == OLEDController::SSD1306); + assert(display.base_begin_calls == 1); + assert(!display.last_reset && !display.last_periph_begin); + assert(wire.begun == 1); + display.display(); + assert(display.base_display_calls == unsigned(!fail)); + } +#endif + { + TwoWire wire; + wire.present[0x3C] = false; + AutoOLEDBackend display(128, 64, &wire, -1); + display.ssd1306_command(0xAE); + assert(wire.transactions.empty()); + assert(!display.begin(2, 0x3C, true, false)); + assert(display.controller() == OLEDController::Absent); + assert(display.getBuffer() == nullptr); + assert(totalRequests(wire) == 0); + const auto transfers = wire.transactions.size(); + display.display(); + display.ssd1306_command(0xAE); + assert(wire.transactions.size() == transfers); + } + assert(active_allocations == 0); + std::puts("PASS: OLED backend selection, single buffer, page writes and retry"); +} +''' + + +class OledDisplayProbeTests(unittest.TestCase): + def compile_run(self, source_text, definitions=(), mocks=None): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-oled-probe-") as directory: + work = Path(directory) + source = work / "probe.cpp" + source.write_text(source_text, encoding="ascii") + for name, contents in (mocks or {}).items(): + (work / name).write_text(contents, encoding="ascii") + binary = work / "probe" + sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + built = subprocess.run([ + compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", + *sanitizer, *definitions, "-I", str(work), + "-I", str(ROOT / "src"), str(source), "-o", str(binary), + ], text=True, capture_output=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], text=True, capture_output=True, timeout=15) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + return checked.stdout + + def test_real_detector_selects_only_repeated_sh1106_status(self): + self.assertIn("PASS: OLED controller status detection", self.compile_run(HARNESS)) + + def test_real_backend_transfers_and_failure_recovery(self): + mocks = {"Arduino.h": ARDUINO, "Wire.h": WIRE, "Adafruit_SSD1306.h": VENDOR} + for definitions in ((), ("-DOLED_CONTROLLER_OVERRIDE=1",), + ("-DOLED_CONTROLLER_OVERRIDE=2",)): + with self.subTest(definitions=definitions): + output = self.compile_run(BACKEND_HARNESS, definitions, mocks) + self.assertIn("PASS: OLED backend selection", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index 5c2f868db..ccb776f2b 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -16,6 +16,7 @@ build_flags = ${nrf52_base.build_flags} ; UICR and reset once before the external SX1262 can be reached. -D CONFIG_NFCT_PINS_AS_GPIOS -D NRF52_POWER_MANAGEMENT + -D OLED_AUTO_DETECT=1 ; shared WisBlock SSD1306 / SH1106 screen support -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 08932eb15..ffc2c54eb 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -19,6 +19,7 @@ build_flags = ${nrf52_base.build_flags} ; The boot power policy automatically protects Ethernet/PoE and external NOR. -D PIN_GPS_EN=-1 -D PIN_OLED_RESET=-1 + -D OLED_AUTO_DETECT=1 ; SSD1306 / SH1106, including WisMesh Pocket v2 and v3 -D USE_SX1262 -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper