diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a69fe8ac8..a0f6309ab 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -972,7 +972,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 @@ -990,6 +997,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 cbec4abc8..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 @@ -1958,6 +1971,52 @@ 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 +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/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 4c8f4df2e..d2162028a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -293,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. @@ -2832,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 @@ -3319,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; @@ -10314,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"); @@ -10362,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"); @@ -10446,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" @@ -10474,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" @@ -10507,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`, @@ -10725,6 +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 184221fce..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 { @@ -20,6 +102,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/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 cd8a4650b..67ed9f8c6 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 @@ -3633,6 +3634,9 @@ void setup() { #if defined(NRF52_PLATFORM) 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 ed1c71517..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(); @@ -2105,6 +2106,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(); @@ -2275,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; @@ -2729,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) { @@ -2738,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 fd66182bc..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 @@ -451,6 +449,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 +469,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 }; @@ -717,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(); @@ -1130,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 489c2652b..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(); @@ -751,6 +752,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(); @@ -866,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; @@ -1166,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) { @@ -1175,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/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/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/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/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/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/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_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 7c8b076ac..4d8ae35d1 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -272,11 +272,13 @@ 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 { 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()); 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_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/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/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 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)