diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e1f7443b9..8fb846fc7 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -971,7 +971,14 @@ jobs: python3 -B test/test_ui_startup_windows.py -v node test/test_notification_builder.js python3 -B test/test_notification_settings.py + python3 -B test/test_companion_alert_control.py -v + python3 -B test/test_companion_compact_led_control.py -v + python3 -B test/test_companion_alert_drivers.py -v python3 -B test/test_notification_led_ownership.py -v + python3 -B test/test_led_settings.py -v + python3 -B test/test_board_led_policy.py -v + python3 -B test/test_status_led_policy.py -v + python3 -B test/test_nrf52_led_policy.py -v python3 -B test/test_mono_led_rollover.py -v python3 -B test/test_backlight_poll_rollover.py -v python3 -B test/test_rgb_led_rollover.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 472f0d5b7..d38a72f7d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1958,6 +1958,25 @@ the verified pin assignments, startup fixes, and saved amplifier settings. --- +#### Turn Companion sound and vibration on or off + +`get buzzer` and `set buzzer on|off` control the Companion's buzzer. Devices +with a vibration motor also support `get vibration` and `set vibration on|off`. +Changes take effect immediately, are saved across reboots, and produce no +confirmation tone. Muting an output stops an alert already playing. + +Fresh settings default to **sound off** and **vibration on**. Existing saved +choices are retained. The same preferences are used by the device buttons and +UI. Unsupported hardware reports an error. + +These are the device mute controls. Programmable `notify.sound` and +`notify.vibration` rules also respect them; an enabled rule cannot bypass a +muted output. The notification output masks still select which alert outputs +a rule may use. Explicit button mute/unmute confirmations retain their existing +behavior. + +Adapted from [upstream PR #3603](https://github.com/meshcore-dev/MeshCore/pull/3603). + #### Turn indicator LEDs on or off ```text diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index fd7f0a48b..f514d32f2 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -68,6 +68,7 @@ public: virtual void notificationVibration(bool on) { (void)on; } virtual void notificationMelody(const char* text) { (void)text; } virtual void notificationScreen(int8_t mode) { (void)mode; } + virtual void syncAlertPreferences() {} void setShutdownGuard(UIShutdownGuard* guard) { _shutdownGuard = guard; } void setHasConnection(bool connected) { _connected = connected; } bool hasConnection() const { return _interfaceManager->isConnected(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 38f4f6695..d2162028a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -294,7 +294,7 @@ static bool save_filter(const ContactInfo& c); #define DEFAULT_AUTOADD_CONFIG 0 #endif #ifndef DEFAULT_BUZZER_QUIET -#define DEFAULT_BUZZER_QUIET 0 +#define DEFAULT_BUZZER_QUIET 1 #endif #ifndef DEFAULT_CAD_ENABLED // Preserve the tuned Companion behavior that preceded the runtime setting. @@ -2833,6 +2833,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.manual_add_contacts = DEFAULT_MANUAL_ADD_CONTACTS; _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.buzzer_quiet = DEFAULT_BUZZER_QUIET ? 1 : 0; + _prefs.vibe_quiet = 0; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // Use the default 1-second fix-processing interval _prefs.gps_sync_interval_hours = 0; // Retain the board's default time-sync cadence @@ -3320,6 +3321,13 @@ void MyMesh::begin(bool has_display, bool radio_available, void MyMesh::configureRadioFromPrefs() { board.attachDynamicPrefs(_prefs.getCustom()); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + // The reserved preference byte keeps the existing image and filesystem size. + _prefs.leds_quiet = _prefs.leds_quiet == 1 ? 1 : 0; + mesh::leds::setEnabled(!_prefs.leds_quiet); + board.applyLedPolicy(); + mesh::leds::applyStatusPolicy(); +#endif if (!_radio_available) { saved_radio_apply_pending = false; @@ -10315,17 +10323,17 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, terminalOutput().print( "Commands:\r\n" " stats-core / stats-radio / stats-radio-diag / stats-packets\r\n" - " get prv.key (when private key export is enabled)\r\n" + " get prv.key (if export enabled)\r\n" #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) " password (shared setup WiFi, WebConfig and WiFi OTA)\r\n" " get password (management password status)\r\n" #else - " get password (this role has no admin password)\r\n" + " get password (no admin password)\r\n" #endif - " erase (erase stored settings and identity)\r\n" + " erase (settings and identity)\r\n" " board\r\n" - " version\r\n"); - terminalOutput().print(" get storage.layout\r\n"); + " version\r\n" + " get storage.layout\r\n"); #if MESH_ENABLE_TX_BEACONS terminalOutput().print(" get beacon [slot] / set beacon ...\r\n" " delete beacon / set beacon.interval <12|24|36|48> (default 24h)\r\n"); @@ -10363,58 +10371,52 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, terminalOutput().print(" get eth / set eth on|off (physical RAK13800; default off, this boot)\r\n" " get sdcard [free] / ota cache [on|off] (RAK15002; mutually exclusive with Ethernet)\r\n"); #endif - terminalOutput().print(" get pwrmgt.bootreason\r\n"); + terminalOutput().print(" get pwrmgt.bootreason\r\n" #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS - terminalOutput().print(" memory\r\n"); + " memory\r\n" +#endif + " get/set display.rotation <0|90|180|270>\r\n" +#ifdef PIN_BUZZER + " get/set buzzer on|off\r\n" +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + " get/set vibration on|off\r\n" #endif - terminalOutput().print(" get display.rotation\r\n"); #if COMPANION_FEATURE_NOTIFICATIONS - terminalOutput().print(" get notify / get notify.rules / get notify.gpio.pins\r\n"); - terminalOutput().print(" set notify.{sound|vibration|led|screen|gpio} on|off\r\n"); - terminalOutput().print(" set notify. \r\n"); - terminalOutput().print(" set notify.remote on|off (!notify, max 15s, no GPIO)\r\n"); - terminalOutput().print(" notify.test / notify.stop / notify.delete \r\n"); + " get notify / get notify.rules / get notify.gpio.pins\r\n" + " set notify.{sound|vibration|led|screen|gpio} on|off\r\n" + " set notify. \r\n" + " set notify.remote on|off (!notify, max 15s, no GPIO)\r\n" + " notify.test / notify.stop / notify.delete \r\n" #endif - terminalOutput().print( - " get display.inbox\r\n" - " set display.inbox \r\n" - " set display.rotation <0|90|180|270>\r\n" - " get display.touch\r\n" - " set display.touch (this boot only)\r\n" - " get public.key\r\n"); - terminalOutput().print( + " get/set display.inbox \r\n" + " get/set display.touch (this boot)\r\n" + " get public.key\r\n" " set {name|lat|lon|freq|tx|af} {value}\r\n" " get wifi|espnow|2.4ghz\r\n" " set wifi|espnow on|off [force] (force only with off)\r\n" " set 2.4ghz on [all|force]|off [force] (this boot)\r\n" - " get bluetooth.name\r\n" - " set bluetooth.name \r\n"); + " get/set bluetooth.name \r\n" #if defined(BLE_PIN_CODE) - terminalOutput().print(" get bluetooth (alias: get ble)\r\n"); - terminalOutput().print(" set bluetooth [force] (saved; force only with off)\r\n"); - terminalOutput().print(" get bluetooth.mac\r\n"); - terminalOutput().print( - " set bluetooth.mac \r\n"); - terminalOutput().print(" get bluetooth.stealth\r\n"); - terminalOutput().print(" set bluetooth.stealth on|off\r\n"); + " get bluetooth (alias: get ble)\r\n" + " set bluetooth [force] (saved; force only with off)\r\n" + " get bluetooth.mac\r\n" + " set bluetooth.mac \r\n" + " get/set bluetooth.stealth on|off\r\n" #endif #if MESH_ENABLE_LOST_REPLY - terminalOutput().print(" get lost.reply\r\n" " set lost.reply \r\n"); + " get/set lost.reply \r\n" #endif - terminalOutput().print(" set pin <0-999999>\r\n" " powersaving [on|off]\r\n"); + " set pin <0-999999>\r\n" " powersaving [on|off]\r\n" #if MESH_USB_LOGGING_AVAILABLE - terminalOutput().print( - " get usb.logging\r\n" - " set usb.logging [reboot]\r\n" - " get usb.debug\r\n" - " set usb.debug \r\n" + " get/set usb.logging [reboot]\r\n" + " get/set usb.debug \r\n" #if MESH_USB_CONSOLE_COOPERATIVE - " get usb.watchdog\r\n" " get usb.watchdog.last\r\n" - " set usb.watchdog (saved; default auto)\r\n" + " get/set usb.watchdog (saved; default auto)\r\n" +#endif #endif ); -#endif #if defined(ESP32) && defined(WIFI_SSID) #if defined(COMPANION_RADIO_FULL) terminalOutput().print(" start ota [ap] (WiFi firmware update on port 8080)\r\n"); @@ -10447,23 +10449,15 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, terminalOutput().print(" get espnow.channel\r\n"); terminalOutput().print(" set espnow.channel <1-13>\r\n"); #endif - // Keep identical help text in bounded chunks. Fewer virtual Print calls - // leave room for USB controls on small-flash boards without cutting CLI. + // Concise help and grouped Print calls preserve small-board flash space. terminalOutput().print( - " get radio.rxps\r\n" " get radio.rxps.config\r\n" - " set radio.rxps \r\n" - " get radio.cad\r\n" - " set radio.cad \r\n" - " set radio.cad timings \r\n"); - terminalOutput().print( - " get radio.rxgain\r\n" - " set radio.rxgain \r\n" - " get radio.fem.rxgain\r\n" - " set radio.fem.rxgain \r\n" - " get radio.fem.txgain\r\n" - " set radio.fem.txgain \r\n"); - terminalOutput().print( + " get/set radio.rxps \r\n" + " get/set radio.cad \r\n" + " set radio.cad timings \r\n" + " get/set radio.rxgain \r\n" + " get/set radio.fem.rxgain \r\n" + " get/set radio.fem.txgain \r\n" " get/set flood.retry.count <0-15>\r\n" " get/set flood.retry.path <0-63|off>\r\n" " get/set flood.retry.group.path <0-63|off>\r\n" @@ -10475,13 +10469,13 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, terminalOutput().print( " list [n]\r\n" " show [adverts|channels|emergency] [on|off]\r\n" - " to [recipient name or prefix]\r\n" - " path [direct|clear|hops separated by spaces or commas]\r\n" + " to [name|prefix]\r\n" + " path [direct|clear|hop,...]\r\n" " send \r\n" " login \r\n" " cmd \r\n"); terminalOutput().print( - " trace [recipient name or prefix]\r\n" + " trace [name|prefix]\r\n" " trace path <1|2|4> \r\n" " advert\r\n" " reset path\r\n" @@ -10508,7 +10502,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (_terminal_mode) { terminalOutput().print(" +++MESHCORE-TERM-STOP\r\n"); } else { - terminalOutput().print(" disconnect (closes the network terminal)\r\n"); + terminalOutput().print(" disconnect (network terminal)\r\n"); } } else if (handleCommand(command, 0, local_reply)) { // Fill the same safe shared-command surface for getters (`get name`, @@ -10726,7 +10720,14 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } if (mesh::power::handleRegulatorCommand(command, reply, reply_capacity)) return true; +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + if (handleCompactLedCommand(command, reply, reply_capacity)) return true; +#else if (mesh::leds::handleCommand(command, reply, reply_capacity)) return true; +#endif +#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(HAS_DRV2605) + if (handleAlertCommand(command, reply, reply_capacity)) return true; +#endif #if COMPANION_FEATURE_READER if (sender_timestamp == 0 && mesh::morseTimingActive(_radio) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b62b33d5d..0530d85f9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -295,6 +295,10 @@ public: void setNotificationOutputMute(mesh::notify::Output, bool) {} #endif bool handleNotificationCommand(const char* command, char* reply, size_t size); + bool handleAlertCommand(const char* command, char* reply, size_t size); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + bool handleCompactLedCommand(const char* command, char* reply, size_t size); +#endif #if defined(STM32_PLATFORM) // Keep this large dispatcher separate from contact streaming on flash- // constrained STM32 targets; merging their branches increases code size. diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index d25581a1f..a913d6550 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -49,8 +49,12 @@ public: float rx_delay_base = 0; uint32_t ble_pin = 0; uint8_t advert_loc_policy = 0; - uint8_t buzzer_quiet = 0; + uint8_t buzzer_quiet = 1; uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled) +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + // Uses existing alignment space; stored in the reserved wire byte at 79. + uint8_t leds_quiet = 0; +#endif uint32_t gps_interval = 0; // seconds; 0 uses the 1-second default uint8_t autoadd_config = 0; // bitmask for auto-add contacts config uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode diff --git a/examples/companion_radio/Notifications.cpp b/examples/companion_radio/Notifications.cpp index cadd2353a..9dc50ce98 100644 --- a/examples/companion_radio/Notifications.cpp +++ b/examples/companion_radio/Notifications.cpp @@ -2,6 +2,88 @@ #include "target.h" #include "NotificationSettingsFile.h" +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) +bool MyMesh::handleCompactLedCommand(const char* command, char* reply, size_t size) { + if (!command || !reply || !size) return false; + if (strcmp(command, "get leds") == 0) { + snprintf(reply, size, "> %s", mesh::leds::enabled() ? "on" : "off"); + return true; + } + if (strncmp(command, "set leds", 8) != 0 + || (command[8] && command[8] != ' ' && command[8] != '\t')) return false; + const char* value = command + 8; + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, size, "Error: usage set leds on|off"); + return true; + } + const bool on = strcmp(value, "on") == 0; + const uint8_t previous = _prefs.leds_quiet; + _prefs.leds_quiet = !on; + if (_prefs.leds_quiet != previous) { + if (!savePrefs()) { + _prefs.leds_quiet = previous; + snprintf(reply, size, "Error: LED setting save unconfirmed; reboot may differ"); + return true; + } + } + mesh::leds::setEnabled(on); + board.applyLedPolicy(); + mesh::leds::applyStatusPolicy(); + snprintf(reply, size, "OK"); + return true; +} +#endif + +bool MyMesh::handleAlertCommand(const char* command, char* reply, size_t size) { + if (!command || !reply || !size) return false; + const bool get = strncmp(command, "get ", 4) == 0; + if (!get && strncmp(command, "set ", 4) != 0) return false; + const char* name = command + 4; + uint8_t* quiet = nullptr; + size_t length = 0; + bool supported = false; + if (strncmp(name, "buzzer", 6) == 0) { + length = 6; + quiet = &_prefs.buzzer_quiet; +#ifdef PIN_BUZZER + supported = true; +#endif + } else if (strncmp(name, "vibration", 9) == 0) { + length = 9; + quiet = &_prefs.vibe_quiet; +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + supported = true; +#endif + } else return false; + const char* value = name + length; + if (*value && *value != ' ' && *value != '\t') return false; + if (!supported) { + snprintf(reply, size, "Error: %.*s unavailable on this build", int(length), name); + return true; + } + if (get) { + if (*value) snprintf(reply, size, "Error: usage get %.*s", int(length), name); + else snprintf(reply, size, "> %s", *quiet ? "off" : "on"); + return true; + } + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, size, "Error: usage set %.*s on|off", int(length), name); + return true; + } + const uint8_t previous = *quiet; + *quiet = strcmp(value, "off") == 0; + if (*quiet != previous && !savePrefs()) { + *quiet = previous; + snprintf(reply, size, "Error: failed to save %.*s setting", int(length), name); + return true; + } + if (_ui) _ui->syncAlertPreferences(); + snprintf(reply, size, "OK"); + return true; +} + #if COMPANION_FEATURE_NOTIFICATIONS // The UI's STATUS_LED_RGB define is local to its own translation unit. uint8_t MyMesh::capabilities() const { diff --git a/examples/companion_radio/PrefsStorageLayout.h b/examples/companion_radio/PrefsStorageLayout.h index e63c4bd04..16f0da974 100644 --- a/examples/companion_radio/PrefsStorageLayout.h +++ b/examples/companion_radio/PrefsStorageLayout.h @@ -67,7 +67,11 @@ static constexpr PrefField PREF_FIELDS[] = { PREF_FIELD(advert_loc_policy, 1), PREF_FIELD(multi_acks, 1), PREF_FIELD(path_hash_mode, 1), +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + PREF_FIELD(leds_quiet, 1), +#else prefSpecial(), +#endif PREF_FIELD(ble_pin, 4), PREF_FIELD(buzzer_quiet, 1), PREF_FIELD(gps_enabled, 1), diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 5372afbe5..67ed9f8c6 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3635,7 +3635,9 @@ void setup() { if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot) || store.isVolatilePrimaryFS()) regulator_prefs_fs = nullptr; #endif +#if !defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) mesh::leds::beginSettings(board, regulator_prefs_fs); +#endif if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 314739de8..9df30285c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1965,6 +1965,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode #if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.begin(); + vibration.quiet(_node_prefs->vibe_quiet); #endif ui_started_at = millis(); @@ -2051,7 +2052,7 @@ if (_notification_outputs & mesh::notify::Sound) switch(t){ } #endif -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) // Trigger vibration for all UI events except none if ((_notification_outputs & mesh::notify::Vibration) && t != UIEventType::none) { vibration.trigger(); @@ -2276,7 +2277,20 @@ bool UITask::isButtonGesturePending() const { #endif } +void UITask::syncAlertPreferences() { + if (!_node_prefs) return; +#ifdef PIN_BUZZER + if (buzzer.isQuiet() != bool(_node_prefs->buzzer_quiet)) + buzzer.quiet(_node_prefs->buzzer_quiet); +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + if (vibration.isQuiet() != bool(_node_prefs->vibe_quiet)) + vibration.quiet(_node_prefs->vibe_quiet); +#endif +} + void UITask::loop() { + syncAlertPreferences(); // Observe startup expiry even while display/Morse/input defers rendering. if (!_startup_complete && static_cast(millis() - ui_started_at) >= 8000u) { _startup_complete = true; @@ -2730,7 +2744,7 @@ uint8_t UITask::notificationCapabilities() const { } void UITask::notificationMelody(const char* text) { #ifdef PIN_BUZZER - if (text) buzzer.playNotification(text); else buzzer.stop(); + if (text) buzzer.playNotification(text); else buzzer.stopNotification(); #endif } void UITask::notificationScreen(int8_t mode) { @@ -2739,10 +2753,7 @@ void UITask::notificationScreen(int8_t mode) { _next_refresh = millis(); } void UITask::notificationVibration(bool on) { -#ifdef HAS_DRV2605 +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.pulse(on); -#elif defined(PIN_VIBRATION) - vibration.stop(); - digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); #endif } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 340f85d4e..d5dbb82bc 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -43,7 +43,7 @@ class UITask : public AbstractUITask { unsigned long _pairing_screen_until; bool _deferred_msg_preview; bool _startup_complete = false; - CompanionNodePrefs* _node_prefs; + CompanionNodePrefs* _node_prefs = nullptr; char _alert[80]; uint32_t _alert_expiry; int _msgcount; @@ -200,6 +200,7 @@ public: void notificationVibration(bool on) override; void notificationMelody(const char* text) override; void notificationScreen(int8_t mode) override; + void syncAlertPreferences() override; void loop() override; void shutdown(bool restart = false); diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 75acf7f12..656a59884 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -124,11 +124,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode buzzer.startup(); #endif -#ifdef HAS_DRV2605 +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.begin(); vibration.quiet(_node_prefs->vibe_quiet); -#elif defined(PIN_VIBRATION) - vibration.begin(); #endif // Initialize digital button if available @@ -719,13 +717,26 @@ void UITask::servicePairingState() { } } +void UITask::syncAlertPreferences() { + if (!_node_prefs) return; +#ifdef PIN_BUZZER + if (buzzer.isQuiet() != bool(_node_prefs->buzzer_quiet)) + buzzer.quiet(_node_prefs->buzzer_quiet); +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + if (vibration.isQuiet() != bool(_node_prefs->vibe_quiet)) + vibration.quiet(_node_prefs->vibe_quiet); +#endif +} + void UITask::loop() { + syncAlertPreferences(); // Observe startup expiry before button callbacks and optional rendering. if (!_startup_complete && static_cast(millis() - ui_started_at) >= 8000u) { _startup_complete = true; } const bool morse_timing = mesh::morseTimingActive(&radio_driver); -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.loop(); #endif servicePairingState(); @@ -1132,18 +1143,14 @@ void UITask::notificationScreen(int8_t mode) { _need_refresh = true; } void UITask::notificationVibration(bool on) { -#ifdef HAS_DRV2605 +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) #if defined(PIN_BUZZER) && MESH_GPS_VOICE - // The saved physical alert mode stays authoritative even if the separate - // notification-mask write fails. Off callbacks must still stop the driver. + // Keep the committed physical mode authoritative if a mask save failed. if (on && _node_prefs && _node_prefs->vibe_quiet) { vibration.stop(); return; } #endif vibration.pulse(on); -#elif defined(PIN_VIBRATION) - vibration.stop(); - digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); #endif } diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index a4ee6f28d..4896e32a2 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -32,7 +32,7 @@ class UITask : public AbstractUITask { GenericVibration vibration; #endif uint32_t _next_refresh; - CompanionNodePrefs* _node_prefs; + CompanionNodePrefs* _node_prefs = nullptr; char _version_info[32]; char _origin[62]; #if UI_SMALL_MESSAGE_FONT == 1 @@ -125,6 +125,7 @@ public: void notificationVibration(bool on) override; void notificationMelody(const char* text) override; void notificationScreen(int8_t mode) override; + void syncAlertPreferences() override; void loop() override; #ifdef MESH_BUTTON_AUDIO_HIL bool handleButtonAudioTest(const char* command, char* reply, size_t size) override; diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index e7fa3012c..cfbb60444 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -666,6 +666,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode #if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.begin(); + vibration.quiet(_node_prefs->vibe_quiet); #endif ui_started_at = millis(); @@ -708,7 +709,7 @@ if (_notification_outputs & mesh::notify::Sound) switch(t){ } #endif -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) // Trigger vibration for all UI events except none if ((_notification_outputs & mesh::notify::Vibration) && t != UIEventType::none) { vibration.trigger(); @@ -867,7 +868,20 @@ bool UITask::isButtonPressed() const { #endif } +void UITask::syncAlertPreferences() { + if (!_node_prefs) return; +#ifdef PIN_BUZZER + if (buzzer.isQuiet() != bool(_node_prefs->buzzer_quiet)) + buzzer.quiet(_node_prefs->buzzer_quiet); +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + if (vibration.isQuiet() != bool(_node_prefs->vibe_quiet)) + vibration.quiet(_node_prefs->vibe_quiet); +#endif +} + void UITask::loop() { + syncAlertPreferences(); // Observe startup expiry even while display/Morse/input defers rendering. if (!_startup_complete && static_cast(millis() - ui_started_at) >= 8000u) { _startup_complete = true; @@ -1167,7 +1181,7 @@ uint8_t UITask::notificationCapabilities() const { } void UITask::notificationMelody(const char* text) { #ifdef PIN_BUZZER - if (text) buzzer.playNotification(text); else buzzer.stop(); + if (text) buzzer.playNotification(text); else buzzer.stopNotification(); #endif } void UITask::notificationScreen(int8_t mode) { @@ -1176,10 +1190,7 @@ void UITask::notificationScreen(int8_t mode) { _next_refresh = millis(); } void UITask::notificationVibration(bool on) { -#ifdef HAS_DRV2605 +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.pulse(on); -#elif defined(PIN_VIBRATION) - vibration.stop(); - digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); #endif } diff --git a/examples/companion_radio/ui-tiny/UITask.h b/examples/companion_radio/ui-tiny/UITask.h index 717519ff2..30a964efe 100644 --- a/examples/companion_radio/ui-tiny/UITask.h +++ b/examples/companion_radio/ui-tiny/UITask.h @@ -40,7 +40,7 @@ class UITask : public AbstractUITask { GenericVibration vibration; #endif uint32_t _next_refresh; - CompanionNodePrefs* _node_prefs; + CompanionNodePrefs* _node_prefs = nullptr; char _alert[80]; uint32_t _alert_expiry; unsigned long _pairing_screen_until; @@ -137,6 +137,7 @@ public: void notificationVibration(bool on) override; void notificationMelody(const char* text) override; void notificationScreen(int8_t mode) override; + void syncAlertPreferences() override; void loop() override; void shutdown(bool restart = false); diff --git a/src/helpers/ui/DRV2605Vibration.cpp b/src/helpers/ui/DRV2605Vibration.cpp index d00e2c055..1293976a3 100644 --- a/src/helpers/ui/DRV2605Vibration.cpp +++ b/src/helpers/ui/DRV2605Vibration.cpp @@ -77,7 +77,7 @@ void DRV2605Vibration::stop() { void DRV2605Vibration::pulse(bool on) { if (!_ready) return; - if (!on) { + if (!on || _quiet) { stop(); return; } diff --git a/src/helpers/ui/DRV2605Vibration.h b/src/helpers/ui/DRV2605Vibration.h index f6b5ceecd..50ad29ca7 100644 --- a/src/helpers/ui/DRV2605Vibration.h +++ b/src/helpers/ui/DRV2605Vibration.h @@ -27,7 +27,7 @@ public: bool isVibrating(); void stop(); // stop vibration immediately void pulse(bool on); // continuous RTP amplitude for scheduled ms pulses - void quiet(bool q) { _quiet = q; } + void quiet(bool q) { if (q) stop(); _quiet = q; } bool isQuiet() const { return _quiet; } private: diff --git a/src/helpers/ui/GenericVibration.cpp b/src/helpers/ui/GenericVibration.cpp index 9226b812a..fb466ca39 100644 --- a/src/helpers/ui/GenericVibration.cpp +++ b/src/helpers/ui/GenericVibration.cpp @@ -8,6 +8,7 @@ void GenericVibration::begin() { } void GenericVibration::trigger() { + if (_quiet) return; duration = millis(); digitalWrite(PIN_VIBRATION, HIGH); } @@ -35,4 +36,9 @@ void GenericVibration::stop() { digitalWrite(PIN_VIBRATION, LOW); } +void GenericVibration::pulse(bool on) { + stop(); + if (on && !_quiet) digitalWrite(PIN_VIBRATION, HIGH); +} + #endif // ifdef PIN_VIBRATION diff --git a/src/helpers/ui/GenericVibration.h b/src/helpers/ui/GenericVibration.h index 38755bd8d..5de6076f9 100644 --- a/src/helpers/ui/GenericVibration.h +++ b/src/helpers/ui/GenericVibration.h @@ -25,9 +25,13 @@ public: void loop(); // non-blocking timer handling bool isVibrating(); // returns true if currently vibrating void stop(); // stop vibration immediately + void pulse(bool on); + void quiet(bool q) { if (q) stop(); _quiet = q; } + bool isQuiet() const { return _quiet; } private: - unsigned long duration; + unsigned long duration = 0; + bool _quiet = false; }; #endif // ifdef PIN_VIBRATION diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index 5d02b979c..e5aad92ce 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -48,16 +48,14 @@ static mesh::audio::VoiceClip buttonVoiceClip(ButtonVoicePrompt prompt) { #endif void genericBuzzer::begin() { -// Serial.print("DBG: Setting up buzzer on pin "); -// Serial.println(PIN_BUZZER); - #ifdef PIN_BUZZER_EN - pinMode(PIN_BUZZER_EN, OUTPUT); - digitalWrite(PIN_BUZZER_EN, HIGH); - #endif - - quiet(false); + digitalWrite(PIN_BUZZER, LOW); pinMode(PIN_BUZZER, OUTPUT); - digitalWrite(PIN_BUZZER, LOW); // need to pull low by default to avoid extreme power draw +#ifdef PIN_BUZZER_EN + digitalWrite(PIN_BUZZER_EN, LOW); + pinMode(PIN_BUZZER_EN, OUTPUT); +#endif + // Start muted until the UI applies the saved preference. + quiet(_is_quiet); } void genericBuzzer::play(const char *melody) { @@ -79,6 +77,8 @@ void genericBuzzer::play(const char *melody) { } void genericBuzzer::playNotification(const char* melody) { + // Muted background alerts must not interrupt an explicit button prompt. + if (_is_quiet) return; stop(); #ifdef PIN_BUZZER_EN digitalWrite(PIN_BUZZER_EN, HIGH); diff --git a/test/fixtures/companion_ui_deadlines.cpp b/test/fixtures/companion_ui_deadlines.cpp index 74738666a..0d4ee2049 100644 --- a/test/fixtures/companion_ui_deadlines.cpp +++ b/test/fixtures/companion_ui_deadlines.cpp @@ -71,6 +71,7 @@ struct UITask { unsigned battery_reads = 0, shutdowns = 0, profile_services = 0; char _alert[80] = {}, _origin[80] = {}, _msg[80] = {}; void servicePairingState() {} + void syncAlertPreferences() {} void serviceWiFiToggleButton() {} bool isPairingScreenActive() const { return false; } bool isButtonGesturePending() const { return gesture_pending; } diff --git a/test/test_button_voice_actions.py b/test/test_button_voice_actions.py index e64b55157..e13374314 100644 --- a/test/test_button_voice_actions.py +++ b/test/test_button_voice_actions.py @@ -40,7 +40,7 @@ int main(){ {ButtonVoicePrompt::AlertsSilent,alertsSilentClip,nullptr}, #endif }; - genericBuzzer buzzer;buzzer.begin(); + genericBuzzer buzzer;buzzer.begin();buzzer.quiet(false); auto finish=[&]{ assert(!registers.ENABLE); rtttl::finish();buzzer.loop(); @@ -548,7 +548,7 @@ NOTIFICATION_STOPS = r''' #include uint32_t now_ms=0; int main(){ - genericBuzzer buzzer;buzzer.begin(); + genericBuzzer buzzer;buzzer.begin();buzzer.quiet(false); buzzer.playNotification("ordinary:d=4,o=5,b=120:c"); assert(buzzer.isPlaying()&&rtttl::isPlaying()); buzzer.stopNotification();assert(!buzzer.isPlaying()&&!rtttl::isPlaying()); @@ -590,11 +590,13 @@ int main(){ assert(!buzzer.isPlaying()&&!rtttl::isPlaying()); buzzer.quiet(true); #endif + buzzer.playNotification("ordinary:d=4,o=5,b=120:c"); + assert(!buzzer.isPlaying()&&buzzer.isQuiet()&&pin_states[PIN_BUZZER_EN]==LOW); + buzzer.quiet(false); buzzer.playNotification("ordinary:d=4,o=5,b=120:c"); assert(buzzer.isPlaying()&&pin_states[PIN_BUZZER_EN]==HIGH); buzzer.stopNotification(); - assert(!buzzer.isPlaying()&&!rtttl::isPlaying()&&buzzer.isQuiet()); - assert(pin_states[PIN_BUZZER_EN]==LOW); + assert(!buzzer.isPlaying()&&!rtttl::isPlaying()&&!buzzer.isQuiet()); } ''' diff --git a/test/test_companion_alert_control.py b/test/test_companion_alert_control.py new file mode 100644 index 000000000..23da55bd9 --- /dev/null +++ b/test/test_companion_alert_control.py @@ -0,0 +1,217 @@ +"""Run the Companion alert CLI against storage and live-output fixtures.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def alert_handler(): + source = (ROOT / "examples/companion_radio/Notifications.cpp").read_text() + match = re.search(r"\bbool\s+MyMesh::handleAlertCommand\([^)]*\)\s*\{", source) + if not match: + raise AssertionError("production Companion alert handler is missing") + end, depth = match.end(), 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[match.start():end] + + +STUBS = r""" +#include +#include +#include +#include +#include +#include +#include +struct Prefs { + uint8_t buzzer_quiet = 1, vibe_quiet = 0; + uint32_t unrelated = 0x12345678; +}; +struct UI { + Prefs* prefs; + Prefs* persisted; + unsigned syncs = 0; + bool sound_quiet = true, vibration_quiet = false; + UI(Prefs* p, Prefs* saved) : prefs(p), persisted(saved) {} + void syncAlertPreferences() { + assert(prefs->buzzer_quiet == persisted->buzzer_quiet); + assert(prefs->vibe_quiet == persisted->vibe_quiet); + ++syncs; + sound_quiet = prefs->buzzer_quiet; + vibration_quiet = prefs->vibe_quiet; + } +}; +class MyMesh { +public: + Prefs _prefs, saved; + UI ui{&_prefs, &saved}; + UI* _ui = &ui; + unsigned saves = 0; + bool save_ok = true; + bool savePrefs() { + ++saves; + if (!save_ok) return false; + saved = _prefs; + return true; + } + bool handleAlertCommand(const char*, char*, size_t); +}; +""" + + +SCENARIOS = r""" +std::string run(MyMesh& mesh, const char* command) { + char reply[160] = {}; + assert(mesh.handleAlertCommand(command, reply, sizeof(reply))); + return reply; +} +void error(MyMesh& mesh, const char* command) { + const auto before = mesh._prefs; + const unsigned saves = mesh.saves, syncs = mesh.ui.syncs; + assert(run(mesh, command).find("Error") == 0); + assert(mesh._prefs.buzzer_quiet == before.buzzer_quiet); + assert(mesh._prefs.vibe_quiet == before.vibe_quiet); + assert(mesh.saves == saves && mesh.ui.syncs == syncs); +} +void supported(MyMesh& mesh, const char* name, bool initial_on) { + const std::string get = std::string("get ") + name; + const std::string set = std::string("set ") + name; + const bool sound = std::string(name) == "buzzer"; + const uint8_t other = sound ? mesh._prefs.vibe_quiet : mesh._prefs.buzzer_quiet; + assert(run(mesh, get.c_str()) == (initial_on ? "> on" : "> off")); + assert(mesh.saves == 0 && mesh.ui.syncs == 0); + for (const char* value : {"", " ", "on extra", "off extra", "1", "0", "ON", "OFF", + "true", "false", "onward", "officer"}) { + error(mesh, (set + " " + value).c_str()); + } + error(mesh, set.c_str()); + error(mesh, (get + " on").c_str()); + + // An unchanged value acknowledges the command without rewriting flash. + mesh.ui.sound_quiet = false; + mesh.ui.vibration_quiet = true; + assert(run(mesh, (set + (initial_on ? " on" : " off")).c_str()) == "OK"); + assert(mesh.saves == 0 && mesh.ui.syncs == 1); + assert(mesh.ui.sound_quiet == bool(mesh._prefs.buzzer_quiet)); + assert(mesh.ui.vibration_quiet == bool(mesh._prefs.vibe_quiet)); + + // Persist first, then apply to the running UI. Changing one output must not + // overwrite the independent preference or unrelated node settings. + assert(run(mesh, (set + (initial_on ? " off" : " on")).c_str()) == "OK"); + assert(mesh.saves == 1 && mesh.ui.syncs == 2); + assert(run(mesh, get.c_str()) == (initial_on ? "> off" : "> on")); + assert((sound ? mesh._prefs.buzzer_quiet : mesh._prefs.vibe_quiet) == initial_on); + assert((sound ? mesh._prefs.vibe_quiet : mesh._prefs.buzzer_quiet) == other); + assert(mesh.saved.buzzer_quiet == mesh._prefs.buzzer_quiet); + assert(mesh.saved.vibe_quiet == mesh._prefs.vibe_quiet); + assert(mesh.saved.unrelated == 0x12345678); + assert(mesh.ui.sound_quiet == bool(mesh._prefs.buzzer_quiet)); + assert(mesh.ui.vibration_quiet == bool(mesh._prefs.vibe_quiet)); + + const Prefs previous = mesh._prefs, persisted = mesh.saved; + mesh.save_ok = false; + assert(run(mesh, (set + (initial_on ? " on" : " off")).c_str()).find("Error") == 0); + assert(mesh.saves == 2 && mesh.ui.syncs == 2); + assert(mesh._prefs.buzzer_quiet == previous.buzzer_quiet); + assert(mesh._prefs.vibe_quiet == previous.vibe_quiet); + assert(mesh.saved.buzzer_quiet == persisted.buzzer_quiet); + assert(mesh.saved.vibe_quiet == persisted.vibe_quiet); + assert(run(mesh, get.c_str()) == (initial_on ? "> off" : "> on")); + + // A build without an attached UI can still persist the board preference. + mesh._ui = nullptr; + mesh.save_ok = true; + assert(run(mesh, (set + (initial_on ? " on" : " off")).c_str()) == "OK"); + assert(mesh.saves == 3 && mesh.ui.syncs == 2); + assert(run(mesh, get.c_str()) == (initial_on ? "> on" : "> off")); +} +int main() { + MyMesh buzzer, vibration; +#if EXPECT_BUZZER + supported(buzzer, "buzzer", false); +#else + error(buzzer, "get buzzer"); + error(buzzer, "set buzzer on"); + error(buzzer, "set buzzer off"); +#endif +#if EXPECT_VIBRATION + supported(vibration, "vibration", true); +#else + error(vibration, "get vibration"); + error(vibration, "set vibration on"); + error(vibration, "set vibration off"); +#endif + MyMesh mesh; + for (const char* command : {"get buzzerx", "get buzzer.extra", "get vibrationx", + "set buzzerx on", "set vibrationx off", "get leds", "get radio", "get notify", + "get sound", "set vibe on", "buzzer", "vibration", "get", "", "set"}) { + char reply[16] = "unchanged"; + assert(!mesh.handleAlertCommand(command, reply, sizeof(reply))); + assert(strcmp(reply, "unchanged") == 0); + assert(mesh.saves == 0 && mesh.ui.syncs == 0); + } + for (const char* command : {"get buzzer", "get vibration", "set buzzer bad", + "set vibration bad", "set buzzer on", "set vibration off"}) { + for (size_t capacity = 1; capacity <= 160; ++capacity) { + char bounded[164]; memset(bounded, '#', sizeof(bounded)); + memcpy(bounded, "ab|", 3); + assert(mesh.handleAlertCommand(command, bounded + 3, capacity)); + assert(memcmp(bounded, "ab|", 3) == 0); + assert(bounded[3 + capacity] == '#'); + assert(memchr(bounded + 3, 0, capacity)); + } + } + char reply[16]; + assert(!mesh.handleAlertCommand(nullptr, reply, sizeof(reply))); + assert(!mesh.handleAlertCommand("get buzzer", nullptr, sizeof(reply))); + assert(!mesh.handleAlertCommand("get buzzer", reply, 0)); +} +""" + + +class CompanionAlertControlTests(unittest.TestCase): + def test_cli_storage_and_hardware_matrix(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("C++ compiler unavailable") + cases = ( + ("none", (), False, False), + ("buzzer", ("PIN_BUZZER=1",), True, False), + ("gpio_vibration", ("PIN_VIBRATION=2",), False, True), + ("drv2605", ("HAS_DRV2605=1",), False, True), + ("buzzer_gpio", ("PIN_BUZZER=1", "PIN_VIBRATION=2"), True, True), + ("buzzer_drv2605", ("PIN_BUZZER=1", "HAS_DRV2605=1"), True, True), + ) + source = STUBS + alert_handler() + SCENARIOS + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + cpp = directory / "alert_cli.cpp" + cpp.write_text(source, encoding="ascii") + for name, macros, buzzer, vibration in cases: + with self.subTest(hardware=name): + exe = directory / (name + (".exe" if os.name == "nt" else "")) + flags = [f"-D{macro}" for macro in macros] + if os.name != "nt": + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + result = subprocess.run( + [compiler, "-std=c++11", "-Wall", "-Wextra", *flags, + f"-DEXPECT_BUZZER={int(buzzer)}", + f"-DEXPECT_VIBRATION={int(vibration)}", + str(cpp), "-o", str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_alert_drivers.py b/test/test_companion_alert_drivers.py new file mode 100644 index 000000000..81ab58a44 --- /dev/null +++ b/test/test_companion_alert_drivers.py @@ -0,0 +1,248 @@ +"""Run Companion master mute and live UI synchronization with inert drivers.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +import test_gps_voice_buttons as voice +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] +ARDUINO = voice.ARDUINO.replace( + "inline unsigned pin_writes[64]={};", + "inline unsigned pin_writes[64]={}, high_writes[64]={};").replace( + "pin_states[pin]=value;++pin_writes[pin];", + "pin_states[pin]=value;++pin_writes[pin];if(value==HIGH)++high_writes[pin];") +ARDUINO += "\ninline void delay(unsigned) {}\n" +WIRE = "#pragma once\nstruct TwoWire {};\ninline TwoWire Wire;\n" +ADAFRUIT = r''' +#pragma once +#include +#include +#define DRV2605_MODE_INTTRIG 0 +#define DRV2605_MODE_REALTIME 5 +#define DRV2605_REG_FEEDBACK 0x1a +#define DRV2605_REG_GO 0x0c +struct MockDriver { + bool found=true; + uint8_t registers[32]={}; + unsigned writes=0; +}; +inline MockDriver hardware; +class Adafruit_DRV2605 { +public: + bool begin(TwoWire*) { return hardware.found; } + void writeRegister8(uint8_t reg,uint8_t value) { + hardware.registers[reg]=value;++hardware.writes; + } + uint8_t readRegister8(uint8_t reg) { return hardware.registers[reg]; } + void selectLibrary(uint8_t value) { writeRegister8(3,value); } + void setMode(uint8_t value) { writeRegister8(1,value); } + void setWaveform(uint8_t slot,uint8_t value) { writeRegister8(4+slot,value); } + void setRealtimeValue(uint8_t value) { writeRegister8(2,value); } + void go() { writeRegister8(DRV2605_REG_GO,1); } + void stop() { writeRegister8(DRV2605_REG_GO,0); } +}; +''' + +BUZZER = r''' +#include +#define PIN_BUZZER 25 +#define PIN_BUZZER_EN 37 +#define MESH_GPS_VOICE @VOICE@ +#include +uint32_t now_ms=100; +int main() { + genericBuzzer buzzer;buzzer.begin(); + assert(buzzer.isQuiet()&&pin_states[PIN_BUZZER]==LOW&&pin_states[PIN_BUZZER_EN]==LOW); + assert(high_writes[PIN_BUZZER]==0&&high_writes[PIN_BUZZER_EN]==0); + buzzer.quiet(false); + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + assert(buzzer.isPlaying()); + buzzer.quiet(true); + assert(!buzzer.isPlaying()&&pin_states[PIN_BUZZER_EN]==LOW); + auto starts=rtttl::begins; + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + assert(!buzzer.isPlaying()&&rtttl::begins==starts); + assert(pin_states[PIN_BUZZER_EN]==LOW); +#if MESH_GPS_VOICE + // A rejected background melody cannot cancel explicit courtesy speech. + assert(buzzer.speakButton(ButtonVoicePrompt::SoundOff,true)); + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + buzzer.stopNotification(); + assert(buzzer.isQuiet()&&buzzer.isPlaying()); + auto cancelled=gps_voice.cancelled(); + buzzer.loop();now_ms+=100;buzzer.loop(); + assert(registers.ENABLE); + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + assert(registers.ENABLE&&gps_voice.cancelled()==cancelled); + buzzer.stop(); +#endif + buzzer.quiet(false); + buzzer.playNotification("alert:d=4,o=5,b=120:c"); + assert(buzzer.isPlaying()&&rtttl::begins==starts+1); + buzzer.stopNotification();assert(!buzzer.isPlaying()); +} +''' + +VIBRATION = r''' +#include +@FLAGS@ +#include +#include +uint32_t now_ms=100; +#ifdef HAS_DRV2605 +using Vibration = DRV2605Vibration; +bool active() { return hardware.registers[1]!=0x40; } +#else +using Vibration = GenericVibration; +bool active() { return pin_states[PIN_VIBRATION]==HIGH; } +#endif +int main() { + Vibration motor;motor.begin(); + motor.trigger();assert(active()); + motor.quiet(true);assert(!active()&&!motor.isVibrating()); + motor.trigger();motor.pulse(true);motor.loop(); + assert(motor.isQuiet()&&!active()&&!motor.isVibrating()); + motor.pulse(false);assert(!active()); + motor.quiet(false); + motor.pulse(true);assert(active()); + motor.quiet(true);assert(!active()); + motor.quiet(false); + now_ms+=6000;motor.trigger();assert(active()); + motor.stop();assert(!active()); +#ifdef HAS_DRV2605 + hardware.found=false;motor.begin(); + auto writes=hardware.writes; + motor.quiet(true);motor.trigger(true);motor.pulse(true);motor.pulse(false); + assert(hardware.writes==writes&&!motor.isVibrating()); +#endif +} +''' + +UI = r''' +#include +#define MESH_GPS_VOICE 0 +@FLAGS@ +#include +#include +#include +uint32_t now_ms=100; +struct CompanionNodePrefs { uint8_t buzzer_quiet=0,vibe_quiet=0; }; +struct UITask { + CompanionNodePrefs* _node_prefs=nullptr; +#ifdef PIN_BUZZER + genericBuzzer buzzer; +#endif +#ifdef HAS_DRV2605 + DRV2605Vibration vibration; +#elif defined(PIN_VIBRATION) + GenericVibration vibration; +#endif + void syncAlertPreferences(); + void notificationMelody(const char*); + void notificationVibration(bool); +}; +@METHODS@ +int main() { + UITask ui;ui.syncAlertPreferences(); + CompanionNodePrefs prefs;ui._node_prefs=&prefs; +#ifdef PIN_BUZZER + ui.buzzer.begin();ui.buzzer.quiet(false);ui.notificationMelody("alert:d=4,o=5,b=120:c"); + assert(ui.buzzer.isPlaying()); +#endif +#if defined(HAS_DRV2605) || defined(PIN_VIBRATION) + ui.vibration.begin();ui.notificationVibration(true); +#endif + prefs.buzzer_quiet=prefs.vibe_quiet=1; + ui.syncAlertPreferences(); +#ifdef PIN_BUZZER + assert(ui.buzzer.isQuiet()&&!ui.buzzer.isPlaying()); + ui.notificationMelody("alert:d=4,o=5,b=120:c"); + assert(!ui.buzzer.isPlaying()); +#endif +#if defined(HAS_DRV2605) || defined(PIN_VIBRATION) + assert(ui.vibration.isQuiet()&&!ui.vibration.isVibrating()); + ui.notificationVibration(true); +#ifdef HAS_DRV2605 + assert(hardware.registers[1]==0x40); + auto writes=hardware.writes; + ui.syncAlertPreferences();ui.syncAlertPreferences(); + assert(hardware.writes==writes); // unchanged prefs must not add I2C traffic +#else + assert(pin_states[PIN_VIBRATION]==LOW); +#endif +#endif + prefs.buzzer_quiet=prefs.vibe_quiet=0; + ui.syncAlertPreferences(); +#ifdef PIN_BUZZER + ui.notificationMelody("alert:d=4,o=5,b=120:c"); + assert(ui.buzzer.isPlaying()); + ui.notificationMelody(nullptr);assert(!ui.buzzer.isPlaying()); +#endif +#if defined(HAS_DRV2605) || defined(PIN_VIBRATION) + ui.notificationVibration(true); +#ifdef HAS_DRV2605 + assert(hardware.registers[1]==DRV2605_MODE_REALTIME); +#else + assert(pin_states[PIN_VIBRATION]==HIGH); +#endif + ui.notificationVibration(false); +#endif +} +''' + + +class CompanionAlertDriversTests(unittest.TestCase): + def build(self, source): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++ compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-alerts-") as directory: + directory = Path(directory) + for name, content in (("Arduino.h", ARDUINO), ("Wire.h", WIRE), + ("Adafruit_DRV2605.h", ADAFRUIT), + ("HardwarePWM.h", voice.PWM), + ("NonBlockingRtttl.h", voice.RTTTL)): + (directory / name).write_text(content) + cpp = directory / "test.cpp" + exe = directory / ("test.exe" if os.name == "nt" else "test") + cpp.write_text(source) + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-I", str(directory), + "-I", str(ROOT / "src"), str(cpp), "-o", str(exe)], + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_programmable_sound_obeys_mute_and_preserves_courtesy_speech(self): + for speech in (0, 1): + with self.subTest(speech=speech): + self.build(BUZZER.replace("@VOICE@", str(speech))) + + def test_gpio_and_drv2605_mute_stops_active_effects_and_blocks_every_entry(self): + for flags in ("#define PIN_VIBRATION 12", "#define HAS_DRV2605 1"): + with self.subTest(flags=flags): + self.build(VIBRATION.replace("@FLAGS@", flags)) + + def test_each_ui_applies_saved_changes_without_reboot_or_repeated_driver_writes(self): + for ui in ("ui-orig", "ui-new", "ui-tiny"): + source = (ROOT / "examples/companion_radio" / ui / "UITask.cpp").read_text() + methods = "\n".join(method(source, signature) for signature in ( + "void UITask::syncAlertPreferences()", + "void UITask::notificationMelody(const char* text)", + "void UITask::notificationVibration(bool on)", + )) + for flags in ("#define PIN_BUZZER 25\n#define PIN_VIBRATION 12", + "#define PIN_BUZZER 25", "#define HAS_DRV2605 1", ""): + with self.subTest(ui=ui, flags=flags): + self.build(UI.replace("@FLAGS@", flags).replace("@METHODS@", methods)) + loop = method(source, "void UITask::loop()") + self.assertRegex(loop, r"\{\s*syncAlertPreferences\(\);") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_compact_led_control.py b/test/test_companion_compact_led_control.py new file mode 100644 index 000000000..5a77a7512 --- /dev/null +++ b/test/test_companion_compact_led_control.py @@ -0,0 +1,227 @@ +"""Exercise the compact Companion LED CLI and its existing preference store.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def method(source, name): + match = re.search(rf"\bbool\s+MyMesh::{name}\([^)]*\)\s*\{{", source) + if not match: + raise AssertionError(f"production {name} is missing") + end, depth = match.end(), 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[match.start():end] + + +def startup_loader(): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + setup = source.split("void MyMesh::configureRadioFromPrefs() {", 1)[1] + match = re.search( + r"#if defined\(WIO_E5_MINI_HEADLESS_COMPANION_UI\)\n(.*?)#endif", setup, re.S) + if not match: + raise AssertionError("compact LED startup restore is missing") + return "void MyMesh::loadCompactLedSetting() {\n" + match.group(1) + "}\n" + + +STUBS = r""" +#include +#include +#include +#include +#include +#include +#include +#include +std::vector events; +struct Prefs { + uint8_t leds_quiet = 0; + uint32_t unrelated = 0x12345678; +}; +struct Board { + unsigned applies = 0; + bool level = true; + void applyLedPolicy() { + events.push_back("board"); + ++applies; + level = mesh::leds::enabled(); + } +} board; +unsigned status_applies = 0; +bool status_level = true; +namespace mesh { namespace leds { +void applyStatusPolicy() { + events.push_back("status"); + ++status_applies; + status_level = enabled(); +} +}} +class MyMesh { +public: + Prefs _prefs; + Prefs saved; + bool save_ok = true; + unsigned saves = 0; + bool savePrefs() { + events.push_back("save"); + ++saves; + if (!save_ok) return false; + saved = _prefs; + return true; + } + bool handleCompactLedCommand(const char*, char*, size_t); + void loadCompactLedSetting(); +}; +void reset() { + mesh::leds::setEnabled(true); + board.applies = status_applies = 0; + board.level = status_level = true; + events.clear(); +} +""" + + +SCENARIOS = r""" +std::string run(MyMesh& mesh, const char* command) { + char reply[160] = {}; + assert(mesh.handleCompactLedCommand(command, reply, sizeof(reply))); + return reply; +} +int main() { + reset(); + MyMesh mesh; + assert(run(mesh, "get leds") == "> on"); + assert(mesh.saves == 0 && board.applies == 0 && status_applies == 0); + for (const char* command : {"set leds", "set leds ", + "set leds 1", "set leds 0", "set leds ON", "set leds OFF", "set leds true", + "set leds off extra", "set leds on extra"}) { + assert(run(mesh, command).find("Error") == 0); + assert(mesh.saves == 0 && board.applies == 0 && status_applies == 0); + assert(mesh::leds::enabled()); + } + for (const char* command : {"get leds on", "get leds ", "get led", "get leds.extra", "get ledsx", "set ledsx on", + "set led on", "get buzzer", "get radio", "get", "set", ""}) { + char reply[16] = "unchanged"; + assert(!mesh.handleCompactLedCommand(command, reply, sizeof(reply))); + assert(strcmp(reply, "unchanged") == 0); + } + assert(run(mesh, "set leds on") == "OK"); + assert(mesh.saves == 0); + assert(mesh._prefs.leds_quiet == 0); + + // An unchanged stored preference can repair stale live policy without flash. + mesh::leds::setEnabled(false); + assert(run(mesh, "set leds on") == "OK"); + assert(mesh.saves == 0 && mesh::leds::enabled() && board.level && status_level); + events.clear(); + const unsigned applied = board.applies, status = status_applies; + + // A failed preference transaction restores the byte and leaves live output. + mesh.save_ok = false; + assert(run(mesh, "set leds off").find("Error") == 0); + assert(mesh.saves == 1 && mesh._prefs.leds_quiet == 0); + assert(mesh::leds::enabled() && mesh.saved.leds_quiet == 0); + assert(board.applies == applied && status_applies == status); + assert(events == std::vector{"save"}); + + mesh.save_ok = true; + events.clear(); + assert(run(mesh, "set leds off") == "OK"); + assert(mesh.saves == 2 && !mesh::leds::enabled()); + assert(mesh._prefs.leds_quiet == 1 && mesh.saved.leds_quiet == 1); + assert(mesh._prefs.unrelated == 0x12345678 && mesh.saved.unrelated == 0x12345678); + assert(board.applies == applied + 1 && !board.level); + assert(status_applies == status + 1 && !status_level); + assert((events == std::vector{"save", "board", "status"})); + assert(run(mesh, "get leds") == "> off"); + assert(run(mesh, "set leds off") == "OK" && mesh.saves == 2); + + // Reload durable preference bytes through the actual compact startup block. + MyMesh reboot; + reboot._prefs = mesh.saved; + reset(); + reboot.loadCompactLedSetting(); + assert(!mesh::leds::enabled()); + assert(board.applies == 1 && !board.level && status_applies == 1 && !status_level); + assert(run(reboot, "get leds") == "> off"); + assert(run(reboot, "set leds off") == "OK" && reboot.saves == 0); + reboot.save_ok = false; + const unsigned before_failed_apply = board.applies, before_failed_status = status_applies; + assert(run(reboot, "set leds on").find("Error") == 0 && reboot.saves == 1); + assert(reboot._prefs.leds_quiet == 1 && !mesh::leds::enabled()); + assert(board.applies == before_failed_apply && status_applies == before_failed_status); + reboot.save_ok = true; + assert(run(reboot, "set leds on") == "OK" && reboot.saves == 2); + assert(reboot.saved.leds_quiet == 0 && reboot.saved.unrelated == 0x12345678); + assert(mesh::leds::enabled() && board.level && status_level); + MyMesh restored_on; + restored_on._prefs = reboot.saved; + mesh::leds::setEnabled(false); + restored_on.loadCompactLedSetting(); + assert(mesh::leds::enabled() && board.level && status_level); + + // Historic reserved zero means on; only exactly one requests quiet LEDs. + for (unsigned value = 0; value < 256; ++value) { + MyMesh fresh; + fresh._prefs.leds_quiet = value; + reset(); + mesh::leds::setEnabled(false); + fresh.loadCompactLedSetting(); + const bool on = value != 1; + assert(mesh::leds::enabled() == on && fresh._prefs.leds_quiet == !on); + assert(board.applies == 1 && board.level == on); + assert(status_applies == 1 && status_level == on); + assert(fresh.saves == 0); + } + for (const char* command : {"get leds", "set leds invalid", "set leds off", "set leds on"}) { + for (size_t capacity = 1; capacity <= 160; ++capacity) { + char reply[164]; memset(reply, '#', sizeof(reply)); + memcpy(reply, "ab|", 3); + assert(mesh.handleCompactLedCommand(command, reply + 3, capacity)); + assert(memcmp(reply, "ab|", 3) == 0 && reply[3 + capacity] == '#'); + assert(memchr(reply + 3, 0, capacity)); + } + } + char reply[16]; + assert(!mesh.handleCompactLedCommand(nullptr, reply, sizeof(reply))); + assert(!mesh.handleCompactLedCommand("get leds", nullptr, sizeof(reply))); + assert(!mesh.handleCompactLedCommand("get leds", reply, 0)); +} +""" + + +class CompanionCompactLedControlTests(unittest.TestCase): + def test_preference_byte_storage_and_policy(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("C++ compiler unavailable") + source = (ROOT / "examples/companion_radio/Notifications.cpp").read_text() + source = STUBS + method(source, "handleCompactLedCommand") + startup_loader() + SCENARIOS + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + cpp = directory / "compact_led_cli.cpp" + exe = directory / ("compact_led_cli.exe" if os.name == "nt" else "compact_led_cli") + cpp.write_text(source, encoding="ascii") + flags = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + result = subprocess.run( + [compiler, "-std=c++11", "-Wall", "-Wextra", *flags, + "-DWIO_E5_MINI_HEADLESS_COMPANION_UI=1", "-I", str(ROOT / "src"), + str(cpp), "-o", str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index 7e0a7bf3c..40fb6f233 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -51,6 +51,8 @@ bool contactPathPresence(FILESYSTEM* fs,const char* path,bool& present){ int main(int argc,char** argv){ const int scenario=argc>1?atoi(argv[1]):0; DataStore store;CompanionNodePrefs original; + assert(original.buzzer_quiet==1&&original.vibe_quiet==0); + original.buzzer_quiet=0;original.vibe_quiet=1; strcpy(original.node_name,"durable node");original.freq=910.525f; original.ble_pin=876543;original.bluetooth_stealth_mode=2; original.gps_interval=123;original.sf=7;original.cr=7;original.bw=62.5f; @@ -103,6 +105,7 @@ int main(int argc,char** argv){ CompanionNodePrefs loaded;double lat=0,lon=0; assert(store.loadPrefs(loaded,lat,lon)); assert(loaded.freq==changed.freq&&loaded.ble_pin==original.ble_pin); + assert(loaded.buzzer_quiet==0&&loaded.vibe_quiet==1); assert(loaded.tx_delay_factor==1.25f&&loaded.direct_tx_delay_factor==0.75f); assert(loaded.interference_threshold==8&&loaded.agc_reset_interval==15&&loaded.tz_offset==-7); assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3); @@ -153,6 +156,7 @@ int main(int argc,char** argv){ } DataStore fresh;CompanionNodePrefs defaults;double lat=0,lon=0; assert(fresh.loadPrefs(defaults,lat,lon)); + assert(defaults.buzzer_quiet==1&&defaults.vibe_quiet==0); assert(defaults.wifi_enabled==0); // Fresh installs never enable WiFi implicitly. assert(fresh.savePrefs(defaults,lat,lon)); } else if(scenario==2){ diff --git a/test/test_companion_prefs_image.py b/test/test_companion_prefs_image.py index e180e4e29..08a862296 100644 --- a/test/test_companion_prefs_image.py +++ b/test/test_companion_prefs_image.py @@ -11,7 +11,8 @@ from test_radio_receive_contract import method ROOT = Path(__file__).resolve().parents[1] # Published field order and sizes, independent of the implementation's emitter -# and the class's in-memory member order or padding. None represents zero pad. +# and the class's in-memory member order or padding. None represents reserved +# padding; compact Wio Companion claims the single byte at wire offset 79. WIRE_FIELDS = ( ('airtime_factor', 4), ('node_name', 32), (None, 4), ('@lat', 8), ('@lon', 8), ('freq', 4), ('sf', 1), ('cr', 1), @@ -147,17 +148,32 @@ int main() { #endif DataStore reference; assert(reference.savePrefsLegacy(prefs, 47.125, -122.25)); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + // The compact target claims exactly the former reserved byte. Its frozen + // predecessor still emits zero there; every other byte stays identical. + auto historical = store.fs.files.at("/new_prefs"); + assert(historical[79] == prefs.leds_quiet); + historical[79] = 0; + assert(reference.fs.files.at("/new_prefs") == historical); +#else assert(reference.fs.files.at("/new_prefs") == store.fs.files.at("/new_prefs")); +#endif ++comparisons; } CompanionNodePrefs original; + assert(original.buzzer_quiet == 1 && original.vibe_quiet == 0); assert(original.lost_reply == 0); assert(original.tx_media == mesh::RADIO_TX_AUTO && original.tx_ssh == mesh::RADIO_TX_AUTO); strcpy(original.node_name, "durable image"); original.freq = 915.25f; original.ble_pin = 123456; original.bluetooth_enabled = 1; original.usb_debug_enabled = 1; original.gps_sync_interval_hours = 336; + original.buzzer_quiet = 0; original.vibe_quiet = 1; +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + assert(original.leds_quiet == 0); + original.leds_quiet = 1; +#endif original.lost_reply = 2; original.tx_media = mesh::RADIO_TX_SECONDARY; original.tx_ssh = mesh::RADIO_TX_OFF; @@ -199,6 +215,11 @@ int main() { double lat = 1, lon = 2; assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon)); assert(live.lost_reply == 0 && live.ble_pin == original.ble_pin); + assert(live.buzzer_quiet == (length >= 85 ? 0 : 1)); + assert(live.vibe_quiet == (length >= 143 ? 1 : 0)); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + assert(live.leds_quiet == 1); +#endif assert(live.tx_media==mesh::RADIO_TX_AUTO&&live.tx_ssh==mesh::RADIO_TX_AUTO); assert(live.freq == original.freq && !strcmp(live.node_name, original.node_name)); assert(lat == 47.125 && lon == -122.25); @@ -232,6 +253,8 @@ int main() { assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon) == expected); if (expected) { assert(live.freq == original.freq); + assert(live.buzzer_quiet == (length >= 85 ? 0 : 1)); + assert(live.vibe_quiet == (length >= 143 ? 1 : 0)); assert(live.lost_reply == (length >= historical_size ? 2 : 0)); assert(live.tx_media == (length == before.size() ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_AUTO)); assert(live.tx_ssh == (length == before.size() ? mesh::RADIO_TX_OFF : mesh::RADIO_TX_AUTO)); @@ -291,8 +314,75 @@ int main() { changed.freq = 910.5f; changed.usb_debug_enabled = 0; changed.gps_sync_interval_hours = 24; changed.lost_reply = 1; +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + changed.leds_quiet = 0; +#endif const auto after = golden(changed, 42.25, -121.125); + // Saved choices remain independent of the new fresh-install defaults. + for (unsigned sound_quiet = 0; sound_quiet < 2; ++sound_quiet) { + for (unsigned vibration_quiet = 0; vibration_quiet < 2; ++vibration_quiet) { + CompanionNodePrefs saved; + assert(saved.copyPersistedValuesFrom(original)); + saved.buzzer_quiet = sound_quiet; + saved.vibe_quiet = vibration_quiet; + DataStore writer; + assert(writer.savePrefs(saved, 47.125, -122.25)); + const auto disk = writer.fs.files.at("/new_prefs"); + assert(disk[84] == sound_quiet && disk[142] == vibration_quiet); + CompanionNodePrefs reboot; + reboot.buzzer_quiet = !sound_quiet; + reboot.vibe_quiet = !vibration_quiet; + double lat = 0, lon = 0; + assert(writer.loadPrefsInt("/new_prefs", reboot, lat, lon)); + assert(reboot.buzzer_quiet == sound_quiet); + assert(reboot.vibe_quiet == vibration_quiet); +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + assert(disk[79] == 1 && reboot.leds_quiet == 1); +#endif + assert(writer.fs.files.at("/new_prefs") == disk); + ++comparisons; + } + } +#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) + // The reserved byte is part of the atomic image, at its original offset. + // Old firmware emitted zero; raw invalid encodings are clamped by startup. + for (unsigned quiet : {0u, 1u, 2u, 255u}) { + CompanionNodePrefs saved; + assert(saved.copyPersistedValuesFrom(original)); + saved.leds_quiet = quiet; + DataStore writer; + assert(writer.savePrefs(saved, 47.125, -122.25)); + const auto disk = writer.fs.files.at("/new_prefs"); + assert(disk.size() == before.size() && disk[79] == quiet); + auto expected = before; expected[79] = quiet; + assert(disk == expected); + CompanionNodePrefs reboot; + reboot.leds_quiet = !quiet; + double lat = 0, lon = 0; + assert(writer.loadPrefsInt("/new_prefs", reboot, lat, lon)); + assert(reboot.leds_quiet == quiet); + assert(reboot.buzzer_quiet == 0 && reboot.vibe_quiet == 1); + assert(writer.fs.files.at("/new_prefs") == disk); + ++comparisons; + } + for (size_t length : {size_t(84), historical_size}) { + auto historical = before; + historical[79] = 0; + historical.resize(length); + DataStore old; + old.fs.files["/new_prefs"] = historical; + CompanionNodePrefs live; + live.leds_quiet = 1; + double lat = 0, lon = 0; + assert(old.loadPrefsInt("/new_prefs", live, lat, lon)); + assert(live.leds_quiet == 0); // Old reserved zero restores default-on LEDs. + assert(live.ble_pin == original.ble_pin && live.freq == original.freq); + assert(old.fs.files.at("/new_prefs") == historical); + ++comparisons; + } +#endif + // Every possible short write and readback, including every tail byte, // must preserve the old durable image. STM32 issues only one image write; // the other supported platforms preserve their scalar-write path. @@ -424,7 +514,14 @@ int main() { def field_code(rows, seed=False): result = [] for name, size in rows: - if name == 'tz_offset': + if name is None and size == 1: + result.append('#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)') + result.append('fill(&prefs.leds_quiet, sizeof(prefs.leds_quiet));' if seed + else 'append(&prefs.leds_quiet, 1);') + if not seed: + result.append('#else\nbytes.push_back(0);') + result.append('#endif') + elif name == 'tz_offset': if seed: result.append('prefs.tz_offset = float(int(random % 27) - 12);') else: @@ -530,7 +627,8 @@ inline char* utoa(unsigned int value,char* output,int base){ (work / 'helpers/IdentityStore.h').write_text(fs) cpp = work / 'test.cpp' cpp.write_text(code + tests) - for platform in ('STM32_PLATFORM', 'NRF52_PLATFORM', 'NRF52_PLATFORM,RAK_4631', 'ESP32_PLATFORM', + for platform in ('STM32_PLATFORM', 'STM32_PLATFORM,WIO_E5_MINI_HEADLESS_COMPANION_UI', + 'NRF52_PLATFORM', 'NRF52_PLATFORM,RAK_4631', 'ESP32_PLATFORM', 'RP2040_PLATFORM', 'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'): with self.subTest(platform=platform): diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 458d48786..4d8ae35d1 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -278,6 +278,7 @@ namespace leds { bool handleCommand(const char*,char*,size_t) { return false; } struct MyMesh { Radio radio; Radio* _radio = &radio; + bool handleAlertCommand(const char*, char*, size_t) { return false; } bool handleCommand(const char*, uint32_t, char*); }; @HANDLER@ diff --git a/test/test_drv2605_power.py b/test/test_drv2605_power.py index d6203d95c..bffffde3f 100644 --- a/test/test_drv2605_power.py +++ b/test/test_drv2605_power.py @@ -152,6 +152,8 @@ int main() { auto count=events.size(); vibration.trigger();assert(events.size()==count); // cooldown suppresses wake vibration.quiet(true); + assert(standby() && !vibration.isVibrating()); + count=events.size(); vibration.trigger(true);assert(events.size()==count); vibration.stop();assert(standby() && !vibration.isVibrating()); assert(hardware.registers[DRV2605_REG_GO]==0 && hardware.registers[2]==0); @@ -216,7 +218,7 @@ class Drv2605PowerTests(unittest.TestCase): source = source.replace("(drv.readRegister8(DRV2605_REG_GO) & 1) == 0", "(drv.readRegister8(DRV2605_REG_GO) & 1) != 0") elif mutation == "no-pulse-off": - source = source.replace("if (!on) {", "if (false && !on) {") + source = source.replace("if (!on || _quiet) {", "if (false && !on) {") elif mutation == "poll-idle": source = source.replace("if (!_ready || !_effect_active) return;", "if (!_ready) return;") diff --git a/test/test_gps_voice_buttons.py b/test/test_gps_voice_buttons.py index e15f1a8f3..27c68fec6 100644 --- a/test/test_gps_voice_buttons.py +++ b/test/test_gps_voice_buttons.py @@ -313,7 +313,7 @@ BUZZER_SEQUENCE = r''' #include uint32_t now_ms=0; int main(){ - genericBuzzer buzzer;buzzer.begin(); + genericBuzzer buzzer;buzzer.begin();buzzer.quiet(false); char status[180]; auto check=[&](const char* expected){buzzer.voiceStatus(status,sizeof(status));assert(strstr(status,expected));}; auto finish_tone=[&]{ @@ -363,7 +363,7 @@ BUZZER_TONES = r''' #include uint32_t now_ms=0; int main(){ - genericBuzzer buzzer;buzzer.begin(); + genericBuzzer buzzer;buzzer.begin();buzzer.quiet(false); assert(buzzer.speakGps(true));assert(buzzer.isPlaying()); assert(rtttl::melody=="Startup:d=4,o=5,b=160:16c6,16e6,8g6"); rtttl::finish();buzzer.loop();assert(!buzzer.isPlaying());