From 2936598e63dc58ed5dd70a357ef1bf433dc74153 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 31 Oct 2026 13:29:39 -0700 Subject: [PATCH] Consolidate Cardputer, Wio L1 and headless KISS builds --- .github/workflows/run-unit-tests.yml | 23 ++ build_legacy.sh | 16 + docs/_javascript/firmware_picker.js | 35 +- docs/full_companion_features.md | 21 +- docs/kiss_modem_protocol.md | 6 + examples/companion_radio/MyMesh.cpp | 3 + examples/companion_radio/main.cpp | 9 +- examples/companion_radio/ui-new/UITask.cpp | 34 +- scripts/check_firmware_ram.py | 8 + scripts/consolidated_profiles.py | 51 ++- scripts/generate_picker_controls.py | 5 + scripts/optional_display_profiles.py | 3 + src/helpers/CompanionRtcQueue.h | 7 +- src/helpers/ui/DisplayPowerSettings.cpp | 5 +- src/helpers/ui/DisplayPowerSettings.h | 3 +- src/helpers/ui/WioL1Display.cpp | 68 ++++ src/helpers/ui/WioL1Display.h | 52 +++ src/helpers/ui/WioL1DisplayLayout.h | 12 + src/helpers/ui/WioL1DisplayProbe.h | 155 ++++++++ test/fixtures/cli_reply_text.json | 2 +- test/test_build_profiles.sh | 54 +-- test/test_cardputer_board_policy.py | 82 ++++ test/test_cardputer_runtime_memory.py | 21 + test/test_combined_board_picker.js | 59 +++ test/test_combined_board_profiles.py | 153 ++++++++ test/test_companion_rtc_queue.py | 6 +- test/test_companion_shared_ota_capacity.py | 41 +- test/test_display_power.py | 18 + test/test_firmware_ram.py | 14 + test/test_headless_kiss_builds.py | 145 +++++++ test/test_indicator_exclusive_transport.py | 6 +- test/test_picker_controls_generator.py | 7 + test/test_shared_mota_queue.py | 30 +- test/test_wio_l1_display_detection.py | 361 ++++++++++++++++++ test/test_wio_tracker_identity.py | 9 +- tools/mota/test_mota.py | 1 + variants/heltec_v4_r8/HeadlessKissPins.h | 17 + variants/heltec_v4_r8/HeltecV4R8Board.cpp | 16 +- variants/heltec_v4_r8/platformio.ini | 4 + .../ui-cardputer/CardputerUITask.cpp | 14 + .../ui-cardputer/CardputerUITask.h | 3 + .../ui-cardputer/screen/SettingsScreen.cpp | 13 + .../ui-cardputer/screen/SettingsScreen.h | 3 + .../hw/CardputerLayout.cpp | 14 +- .../hw/CardputerLayout.h | 2 +- variants/m5stack_cardputer_adv/platformio.ini | 18 + variants/wio-tracker-l1-eink/platformio.ini | 2 + variants/wio-tracker-l1/HeadlessKissPins.h | 12 + variants/wio-tracker-l1/WioTrackerL1Board.cpp | 11 + variants/wio-tracker-l1/WioTrackerL1Board.h | 4 +- variants/wio-tracker-l1/platformio.ini | 19 +- variants/wio-tracker-l1/target.cpp | 5 + variants/wio-tracker-l1/target.h | 4 +- 53 files changed, 1598 insertions(+), 88 deletions(-) create mode 100644 src/helpers/ui/WioL1Display.cpp create mode 100644 src/helpers/ui/WioL1Display.h create mode 100644 src/helpers/ui/WioL1DisplayLayout.h create mode 100644 src/helpers/ui/WioL1DisplayProbe.h create mode 100644 test/test_combined_board_picker.js create mode 100644 test/test_combined_board_profiles.py create mode 100644 test/test_headless_kiss_builds.py create mode 100644 test/test_wio_l1_display_detection.py create mode 100644 variants/heltec_v4_r8/HeadlessKissPins.h create mode 100644 variants/wio-tracker-l1/HeadlessKissPins.h diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 675a67be2..93f4bfeb9 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -221,6 +221,8 @@ jobs: -e M5Stack_Cardputer_Adv_companion_radio_usb -e M5Stack_Cardputer_Adv_repeater -e M5Stack_Cardputer_Adv_room_server -e LilyGo_TDeck_companion_radio_ble + - name: Qualify combined Cardputer-Adv Full Companion + run: bash build.sh build-firmware M5Stack_Cardputer_Adv_companion_radio_full --firmware-version v1.18.0.2-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade universal-lora-smoke: runs-on: ubuntu-latest @@ -262,6 +264,27 @@ jobs: - name: Qualify Muzi Full Companion run: bash build.sh build-firmware muzi_base_companion_radio_full --firmware-version v1.18.0.2-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade + headless-kiss-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + - name: Verify optional-display KISS wiring and consolidation evidence + run: | + python3 -B test/test_headless_kiss_builds.py -v + python3 -B test/test_wio_l1_display_detection.py -v + python3 -B test/test_display_power.py -v + python3 -B test/test_combined_board_profiles.py -v + node test/test_combined_board_picker.js + - name: Compile shared and historical KISS recipes + # One process: PlatformIO shares the checkout's build tree. + run: >- + pio run -e heltec_v4_r8_kiss_modem -e heltec_v4_r8_tft_kiss_modem + -e WioTrackerL1_kiss_modem -e WioTrackerL1Eink_kiss_modem + - name: Qualify Wio L1 Full Companion with runtime OLED, e-paper or headless selection + run: bash build.sh build-firmware WioTrackerL1_companion_radio_full --firmware-version v1.18.0.2-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade + management-reports: runs-on: ubuntu-latest steps: diff --git a/build_legacy.sh b/build_legacy.sh index 60ba1c647..77190d963 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -482,6 +482,11 @@ for section, options in data: PIO_ENV_FULL_BUILD_BY_NAME["$full_env"]=0 PIO_ENV_FULL_WIFI_OTA_BY_NAME["$full_env"]=0 PIO_ENV_BUILD_BASE_BY_NAME["$full_env"]="$env_name" + if [ "$full_env" = WioTrackerL1_companion_radio_full ]; then + # The BLE recipe owns runtime OLED/e-paper selection. Keep the old + # USB-only OLED recipe exact for explicit compatibility builds. + PIO_ENV_BUILD_BASE_BY_NAME["$full_env"]="$ble_env" + fi done # Some qualified boards historically published only BLE, or BLE plus USB, @@ -492,6 +497,7 @@ for section, options in data: # remain explicit-build aliases and canonical releases use the Full target. local -a qualified_esp32_full_companion_bases=( M5Stack_Unit_C6L_companion_radio_ble + M5Stack_Cardputer_Adv_companion_radio_ble Heltec_Wireless_Tracker_companion_radio_ble LilyGo_T3S3_sx1276_companion_radio_ble LilyGo_Tlora_C6_companion_radio_ble_ @@ -4593,6 +4599,15 @@ apply_companion_radio_full_profile() { # measured-safe tables for FULL OTA without changing ordinary USB/BLE/WiFi # companion builds. case "${env_name,,}" in + m5stack_cardputer_adv_companion_radio_full) + # Keep the complete keyboard UI and 350/40/256 capacities. USB is + # independent; the saved WiFi/BLE selection applies on the next boot. + # Only CPU-owned retained frames use RTC; the borrowed OTA context + # stays in ordinary DRAM and keeps 128 receive frames while active. + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCOMPANION_EXCLUSIVE_WIFI_BLE=1 -DOTA_SHARED_COMPANION_QUEUE=1 -DCOMPANION_RTC_FAST_OFFLINE_FRAMES=32 -DCOMPANION_RTC_SLOW_OFFLINE_FRAMES=32" + record_build_reduction \ + "Cardputer Full: USB plus selected WiFi or Bluetooth; 256 offline frames normally, 128 during mOTA" + ;; heltec_wireless_paper_companion_radio_full) # The cold mOTA workspace fits in half the offline queue. Keep the # board's 350 contacts and all simultaneous Full transports. @@ -6159,6 +6174,7 @@ get_consolidated_hardware_replacement() { picow_companion_radio_usb|picow_companion_radio_ble|picow_companion_radio_wifi|\ heltec_v4_tft_*|heltec_v4_3_tft_*|heltec_v4_expansionkit_*|heltec_v4_3_expansionkit_*|\ heltec_v4_r8_tft_portrait_repeater_observer_mqtt|heltec_v4_r8_tft_portrait_room_server_observer_mqtt|\ + heltec_v4_r8_tft_kiss_modem|wiotrackerl1eink_kiss_modem|wiotrackerl1eink_companion_radio_*|\ muzi_base_duo_superio_companion_radio_*|muzi_base_uno_superio_companion_radio_*|lilygo_t-echo-lite_non_shell_companion_radio_*|\ heltec_rc32_without_display_*) ;; *) return 1 ;; diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 272b516a8..87e8fbc79 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -828,6 +828,12 @@ profile.controls.display === true && releaseBoundControl(profile, "unifiedV4Display")); } + function unifiedWioL1DisplayProfile(profile) { + return Boolean(profile && profile.hardware === "WioTrackerL1" && isFullCompanion(profile) && + profile.controls && profile.controls.platform === "NRF52_PLATFORM" && + optionalAutoDisplayProfile(profile) && releaseBoundControl(profile, "unifiedWioL1Display")); + } + const RADIO_DETECT_FAMILIES = [ ["Tbeam", ["Tbeam_SX1262", "Tbeam_SX1276"]], ["LilyGo_T3S3", ["LilyGo_T3S3_sx1262", "LilyGo_T3S3_sx1276"]], @@ -847,6 +853,17 @@ function consolidationSuccessor(profile, candidate) { if (!profile || !candidate || profile.role !== candidate.role) return false; + if (/^WioTrackerL1Eink_companion_radio_(?:ble|full)$/.test(profile.target) && profile.role === "companion") { + return unifiedWioL1DisplayProfile(candidate); + } + if (profile.target === "heltec_v4_r8_tft_kiss_modem") { + return candidate.target === "heltec_v4_r8_kiss_modem" && candidate.controls && + candidate.controls.platform === "ESP32_PLATFORM" && releaseBoundControl(candidate, "headlessR8Kiss"); + } + if (profile.target === "WioTrackerL1Eink_kiss_modem") { + return candidate.target === "WioTrackerL1_kiss_modem" && candidate.controls && + candidate.controls.platform === "NRF52_PLATFORM" && releaseBoundControl(candidate, "headlessWioL1Kiss"); + } const radioFamily = RADIO_DETECT_FAMILIES.find(function (family) { return family[1].includes(profile.hardware); }); @@ -908,8 +925,9 @@ : /^heltec_(?:wireless_tracker|tracker_v1_1)_/.test(target) ? "st7735" : /^heltec_v4_r8_tft_/.test(target) ? "st7789" : /^muzi_base_(?:(?:uno|duo)_(?:superio_)?)?companion_radio_/.test(target) ? "sh1107" - : /^lilygo_t-echo-lite_(?:non_shell_)?(?:companion_radio_|repeater|room_server)/.test(target) ? "ssd1681" : ""; - const platform = controller === "sh1107" || controller === "ssd1681" ? "NRF52_PLATFORM" : "ESP32_PLATFORM"; + : /^lilygo_t-echo-lite_(?:non_shell_)?(?:companion_radio_|repeater|room_server)/.test(target) ? "ssd1681" + : /^wiotrackerl1_companion_radio_(?:ble|full)$/.test(target) ? "sh1106_ssd1680" : ""; + const platform = ["sh1107", "ssd1681", "sh1106_ssd1680"].includes(controller) ? "NRF52_PLATFORM" : "ESP32_PLATFORM"; return Boolean(controller && controls && controls.platform === platform && controls.display === true && controls.displayAuto === true && controls.displayController === controller && /^[0-9a-f]{40}$/.test(controls.displaySource || "") && @@ -1783,6 +1801,10 @@ ["muzi_base_uno_superIO", "muzi_base_uno", function (profile) { return isFullCompanion(profile) && optionalAutoDisplayProfile(profile); }], ["muzi_base_duo_superIO", "muzi_base_duo", function (profile) { return isFullCompanion(profile) && optionalAutoDisplayProfile(profile); }], ["LilyGo_T-Echo-Lite_non_shell", "LilyGo_T-Echo-Lite", function (profile) { return isFullCompanion(profile) && optionalAutoDisplayProfile(profile); }], + ["WioTrackerL1Eink", "WioTrackerL1", function (profile) { return unifiedWioL1DisplayProfile(profile) || + profile.target === "WioTrackerL1_kiss_modem" && releaseBoundControl(profile, "headlessWioL1Kiss"); }], + ["heltec_v4_r8_tft", "heltec_v4_r8", function (profile) { return profile.target === "heltec_v4_r8_kiss_modem" && + releaseBoundControl(profile, "headlessR8Kiss"); }], ].forEach(function (alias) { if (!profiles.some(function (profile) { return profile.hardware === alias[0] && (!requested.role || profile.role === requested.role); }) && profiles.some(function (profile) { return profile.hardware === alias[1] && alias[2](profile) && (!requested.role || profile.role === requested.role); })) { @@ -2072,6 +2094,13 @@ notes.push("One ordinary Heltec V4 image supports V4.2/V4.3 with OLED, the stock touch TFT kit, or no screen. The saved display.type setting selects auto, oled, tft or off; board reports the detected revision and active display. R8 uses its separate hardware image."); notes.push("When changing from a legacy TFT or expansion-kit target, use a local USB installation or a supported WiFi application update. Their LoRa OTA target IDs remain distinct; never force a different target's LoRa OTA package."); } + if (unifiedWioL1DisplayProfile(profile)) { + notes.push("One Wio L1 Full Companion supports the OLED, e-paper and headless models, including Lite and Pro. The screen is detected at startup; the 1W model uses separate firmware."); + notes.push("E-paper keeps its screen visible for 15 minutes by default. Saved display timeout settings take precedence. Older e-paper LoRa OTA identities remain distinct; use USB to change to this combined target."); + } + if (releaseBoundControl(profile, "headlessR8Kiss") || releaseBoundControl(profile, "headlessWioL1Kiss")) { + notes.push("This KISS modem supports either display version of this board and leaves the display off. Historical display-specific OTA identities remain separate."); + } if (releaseBoundControl(profile, "oledExpansionCompatible")) { notes.push("The same OLED firmware supports the base Heltec V4 and its OLED expansion kit. Existing expansion-kit OTA identities still require their exact historical update or a local USB migration."); } @@ -2422,7 +2451,7 @@ ? "This Full image shares one 2.4 GHz radio between WiFi/MQTT and ESP-NOW. When both are enabled, ESP-NOW begins after WiFi connects and only when bridge.channel matches that access point's fixed channel. ESP-NOW-only mode does not need WiFi credentials. Primary ESP-NOW mesh uses set espnow.channel instead." : "Set bridge.channel to the bridge channel. Primary ESP-NOW mesh uses set espnow.channel instead; see the board guide before changing channels."); } - if (full && /sensecapindicator/i.test(profile.target)) section("Indicator wireless transport", [ + if (full && (/sensecapindicator/i.test(profile.target) || profile.target === "M5Stack_Cardputer_Adv_companion_radio_full")) section("Wireless transport", [ { label: "WiFi", commands: ["set companion.transport wifi", "reboot"] }, { label: "Bluetooth", commands: ["set companion.transport ble", "reboot"] }, { label: "Check", commands: ["get companion.transport"] }, diff --git a/docs/full_companion_features.md b/docs/full_companion_features.md index c60a0bc93..da098ba33 100644 --- a/docs/full_companion_features.md +++ b/docs/full_companion_features.md @@ -18,6 +18,24 @@ New **Heltec T114** Full images automatically detect the optional screen and also run without it. Choose **Heltec T114** in the firmware picker for either board configuration; see the [T114 update guide](heltec_t114.md). +**Wio Tracker L1** Full uses one image for the OLED, E-Ink, Lite and Pro +models. It detects the screen at startup and also runs headless. The E-Ink +idle timeout defaults to 15 minutes; saved display settings take precedence. +The 1W model keeps its separate image. Use USB to move from an older +`WioTrackerL1Eink` image to `WioTrackerL1_companion_radio_full`; historical +LoRa OTA target identities remain separate. + +**Cardputer-Adv** Full combines USB and Bluetooth with the keyboard UI in +`M5Stack_Cardputer_Adv_companion_radio_full`. USB remains available when +Bluetooth is disabled. WiFi and Bluetooth run one at a time; select the +wireless transport and reboot as shown below. Full retains 350 contacts, +40 channels and 256 queued messages. During an mOTA session, 128 queue +slots remain available while the other half holds its workspace. Sync +pending messages before starting if more than 128 are queued. The older +USB and BLE build names remain available for explicit compatibility builds. +When moving from the older BLE image, forget the previous phone pairing and +pair again if necessary; Full uses NimBLE and keeps the saved PIN. + The Wireless Paper Full memory-sharing update keeps 350 contacts and 256 queued messages normally. During an mOTA session, 128 message slots are available while the other half holds its workspace. If more than 128 unread @@ -118,7 +136,8 @@ Reboot does not undo a saved-off preference. See [Bluetooth controls](cli_commands.md#turn-bluetooth-on-or-off-companion) for reply draining, connection checks, and unavailable boot modes. -SenseCAP Indicator selects one secondary wireless transport per boot: +SenseCAP Indicator and Cardputer-Adv Full select one secondary wireless +transport per boot: ```text set companion.transport wifi diff --git a/docs/kiss_modem_protocol.md b/docs/kiss_modem_protocol.md index b91b619f9..79d658eb7 100644 --- a/docs/kiss_modem_protocol.md +++ b/docs/kiss_modem_protocol.md @@ -6,6 +6,12 @@ Standard KISS TNC firmware for MeshCore LoRa radios. Compatible with any KISS client (Direwolf, APRSdroid, YAAC, etc.) for sending and receiving raw packets. MeshCore-specific extensions (cryptography, radio configuration, telemetry) are available through the standard SetHardware (0x06) command. +Use `heltec_v4_r8_kiss_modem` for either the plain or TFT Heltec V4 R8, +and `WioTrackerL1_kiss_modem` for either OLED or E-Ink Wio L1. These modem +images leave the displays off. The historical TFT/E-Ink build names and +their OTA target identities remain available for explicit updates; they +are omitted from ordinary build lists when the shared recipe is present. + KISS protocol version 2 adds an opt-in logical port 1 for MeshCore's second time-shared LoRa profile. It is still one physical transceiver: the firmware fast-switches between `radio` and `radio2`; it does not create a second radio. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6ff461e7a..0e541d975 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3185,6 +3185,9 @@ void MyMesh::begin(bool has_display, bool radio_available, false, #endif true // All Companion transports show startup unless the saved mode forbids it. +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + , display.defaultTimeoutSeconds() +#endif ); #ifdef MESHCORE_HAS_REAL_DISPLAY // A saved identity makes later boots ordinary even if display preferences diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 01c6545b7..9c6d1efe1 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3823,6 +3823,9 @@ void setup() { #endif #if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT mesh::ui::loadOLEDControllerSettings(regulator_prefs_fs); +#endif +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + display.detect(); #endif mesh::ui::loadDisplayPowerSettings(display_prefs_fs, #ifdef BLE_PIN_CODE @@ -3830,7 +3833,11 @@ void setup() { #else false, #endif - true); + true +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + , display.defaultTimeoutSeconds() +#endif + ); mesh::ui::StartupScreen startup_screen; #endif diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index f941025df..d8ff27f35 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -122,6 +122,9 @@ static mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus( #ifndef UI_RECENT_LIST_SIZE #define UI_RECENT_LIST_SIZE 4 #endif +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED +#include +#endif #ifdef HAS_TOUCH #define PRESS_LABEL "tap center" @@ -644,6 +647,9 @@ class HomeScreen : public UIScreen { } bool isPageVisible(uint8_t page) const { +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED && !defined(UI_NO_HIBERNATE) + if (page == HomePage::SHUTDOWN && !display.isEink()) return false; +#endif return the_mesh.isDualRadioActive() || (page != HomePage::RADIO2 && page != HomePage::RADIO_STATUS); } @@ -1529,7 +1535,12 @@ public: r.skipData(type); } - for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) { + int sensor_rows = sensors_scroll ? UI_RECENT_LIST_SIZE : sensors_nb; +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + sensor_rows = mesh::ui::wio_l1::visibleRows(display.height(), y, normal_line, + row_height > 12 ? row_height : 12, sensor_rows); +#endif + for (int i = 0; i < sensor_rows; i++) { uint8_t channel, type; if (!r.readHeader(channel, type)) { // reached end, reset r.reset(); @@ -1586,7 +1597,7 @@ public: display.print(buf); y += row_height > 12 ? row_height : 12; } - if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; + if (sensors_nb > sensor_rows) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; else sensors_scroll_offset = 0; #endif #if UI_WIFI_SETUP_HOME_PAGE == 1 @@ -1598,7 +1609,14 @@ public: int count = the_mesh.getDiscoveredNodes(discovered, UI_RECENT_LIST_SIZE); display.setColor(UIColor::primary_txt); int y = 20; - for (int i = 0; i < count; i++, y += 11) { + int discovery_spacing = 11; +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + y = body_top; + discovery_spacing = row_height; + count = mesh::ui::wio_l1::visibleRows(display.height(), y, normal_line, + discovery_spacing, count); +#endif + for (int i = 0; i < count; i++, y += discovery_spacing) { char name[32]; auto a = &discovered[i]; if ((a->name[0] == 0) || !discovery_disp_names) { @@ -1624,6 +1642,10 @@ public: return 1000; // more frequent updates just after req } else if (count < UI_RECENT_LIST_SIZE -1) { // show only 5 sec after last disc y = 10 + 11 * UI_RECENT_LIST_SIZE; +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + y = body_top + count * discovery_spacing; + if (y + normal_line > display.height()) return 5000; +#endif display.drawTextCentered(display.width() / 2, y, "discover: " PRESS_LABEL); } #endif @@ -1977,6 +1999,9 @@ class MsgPreviewScreen : public UIScreen { void renderChannelFilter(DisplayDriver& display) const { #if UI_MESSAGE_CHANNEL_FOOTER == 1 +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + if (!display.isEink()) return; +#endif display.setTextSize(1); const mesh::ui::CompanionMessageChromeLayout layout = mesh::ui::makeCompanionMessageChromeLayout( @@ -2148,6 +2173,9 @@ public: native_large ? display.textLineHeight() : 0); int body_bottom = UI_MESSAGE_CHANNEL_FOOTER == 1 ? display.height() - layout.filter_height : display.height(); +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + if (!display.isEink()) body_bottom = display.height(); +#endif display.setCompactText(layout.compact_text); int header_text_y = 0; #if UI_BUTTON_READER_HINT == 1 diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index d0eebde16..580a5c0e1 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -176,6 +176,14 @@ def requirements(platform, defines, target): depth = integer(defines, "UI_BUFFER_COLOR_DEPTH", 8) pixels = 480 * 480 if "INDICATOR_TRANSPORT_RENDER_PROFILE" in defines else 320 * 320 display_heap = (pixels * depth + 7) // 8 + 1024 + elif display == "WioL1Display": + if platform != "NRF52_PLATFORM" or not integer(defines, "WIO_L1_DISPLAY_UNIFIED", 0): + raise ValueError("unified Wio L1 display budget requires nRF52 and its unified recipe") + # The two drivers share one static placement buffer, counted in the + # linked ELF. Only the selected OLED allocates a framebuffer/adapter; + # e-paper pixel storage lives inside that same static placement buffer. + # Retain the existing nRF52 OLED allowance, even on headless boots. + display_heap = DISPLAY_HEAP["SH1106Display"] elif display == "WioTrackerL2Display": if platform != "ESP32_PLATFORM": raise ValueError("Wio Tracker L2 display budget requires ESP32") diff --git a/scripts/consolidated_profiles.py b/scripts/consolidated_profiles.py index 13596ce59..1c71e8661 100644 --- a/scripts/consolidated_profiles.py +++ b/scripts/consolidated_profiles.py @@ -6,6 +6,23 @@ import sys from optional_display_profiles import resolved_defines, spec_for_defines SPECS = ( + dict(key="unifiedWioL1Display", capability="display.wio_l1.unified", + marker="meshcore.capability.display.wio_l1_unified.v1", + platform="NRF52_PLATFORM", board="seeed-wio-tracker-l1", + target=r"WioTrackerL1_companion_radio_(?:ble|full)", + defines={"WIO_TRACKER_L1": "1", "WIO_L1_DISPLAY_UNIFIED": "1", + "DISPLAY_CLASS": "WioL1Display", "MAX_CONTACTS": "350", + "MAX_GROUP_CHANNELS": "40", "OFFLINE_QUEUE_SIZE": "256"}), + *[dict(key=key, capability="modem.headless." + family, + marker="meshcore.capability.modem.headless." + family + ".v1", + platform=platform, board=board, target=target, + defines={guard: "1", "MESHCORE_HEADLESS_KISS_COMPAT": "1"}) + for key, family, platform, board, target, guard in ( + ("headlessR8Kiss", "heltec_r8", "ESP32_PLATFORM", "heltec_v4_r8", + r"heltec_v4_r8_kiss_modem", "HELTEC_V4_R8"), + ("headlessWioL1Kiss", "wio_l1", "NRF52_PLATFORM", "seeed-wio-tracker-l1", + r"WioTrackerL1_kiss_modem", "WIO_TRACKER_L1"), + )], dict(key="autoLoRaRadio", capability="radio.autodetect.sx126x_sx127x", marker="meshcore.capability.radio.autodetect.sx126x_sx127x.v1", platform="ESP32_PLATFORM", board="ttgo-t-beam", @@ -115,7 +132,9 @@ def specs_for_config(target, env): return [spec for spec in SPECS if env.get("board") == spec["board"] and re.fullmatch(spec["target"], target, re.I) and all(defines.get(key) == value for key, value in spec["defines"].items()) - and (spec["key"] != "autoLoRaRadio" or radio_capacity_matches(target, defines))] + and (spec["key"] != "autoLoRaRadio" or radio_capacity_matches(target, defines)) + and (spec["key"] not in ("headlessR8Kiss", "headlessWioL1Kiss") + or "DISPLAY_CLASS" not in defines)] def proven(manifest, spec): @@ -196,6 +215,12 @@ def radio_replacements(target): def replacements(target): """Known functionality successors, never aliases for deployed update IDs.""" + if re.fullmatch(r"WioTrackerL1Eink_companion_radio_(?:ble|full)", target, re.I): + return [("WioTrackerL1_companion_radio_full", "unifiedWioL1Display")] + if target.lower() == "heltec_v4_r8_tft_kiss_modem": + return [("heltec_v4_r8_kiss_modem", "headlessR8Kiss")] + if target.lower() == "wiotrackerl1eink_kiss_modem": + return [("WioTrackerL1_kiss_modem", "headlessWioL1Kiss")] radio = radio_replacements(target) if radio: return radio @@ -232,7 +257,7 @@ def configuration_replacement(target, envs): return None for candidate, key in replacements(target): radio = key in ("autoLoRaRadio", "muziRadioAccessories") - env = environment(candidate, radio) + env = environment(candidate, radio or key == "unifiedWioL1Display") source_env = environment(target, radio) if not env or not source_env or env.get("board") != source_env.get("board"): continue @@ -268,10 +293,13 @@ def configuration_replacement(target, envs): def omit_redundant_records(records): """Remove resumed alternatives only with an actual qualified successor.""" + def same_sensors(left, right): + return (sorted(x for x in left.get("capabilities", []) if x.startswith("sensor.profile.")) + == sorted(x for x in right.get("capabilities", []) if x.startswith("sensor.profile."))) + def same_policy(left, right): return (left.get("build_profile") == right.get("build_profile") - and sorted(x for x in left.get("capabilities", []) if x.startswith("sensor.profile.")) - == sorted(x for x in right.get("capabilities", []) if x.startswith("sensor.profile."))) + and same_sensors(left, right)) def qualified(manifest, key): if key == "muziRadioAccessories": @@ -296,10 +324,14 @@ def omit_redundant_records(records): for row in manifest.get("verification", []))) return any(s["key"] == key and proven(manifest, s) for s in SPECS) - def radio_successor(left, right): + def radio_successor(left, right, key): source = left.get("source_commit") + wio_full = (key == "unifiedWioL1Display" and right.get("build_profile") == "full" + and left.get("target", "").lower().endswith(("_usb", "_ble")) + and same_sensors(left, right)) if (not isinstance(source, str) or not re.fullmatch(r"[0-9a-f]{40}", source) - or right.get("source_commit") != source or not same_policy(left, right)): + or right.get("source_commit") != source + or not (same_policy(left, right) or wio_full)): return False # Full already requires its full capacity recipe before advertising # this packaged marker. An old transport/terminal image may retire @@ -318,10 +350,11 @@ def omit_redundant_records(records): manifest = record["manifest"] alternatives = replacements(manifest["target"]) if not any(candidate["manifest"]["target"].lower() == target.lower() - and (key not in ("autoLoRaRadio", "muziRadioAccessories") - or radio_successor(manifest, candidate["manifest"])) + and (key not in ("autoLoRaRadio", "muziRadioAccessories", "unifiedWioL1Display", + "headlessR8Kiss", "headlessWioL1Kiss") + or radio_successor(manifest, candidate["manifest"], key)) and (same_policy(manifest, candidate["manifest"]) - or key in ("muziAccessories", "techoLiteDisplay") + or key in ("muziAccessories", "techoLiteDisplay", "unifiedWioL1Display") and candidate["manifest"].get("build_profile") == "full" and manifest["target"].lower().endswith(("_usb", "_ble"))) and qualified(candidate["manifest"], key) diff --git a/scripts/generate_picker_controls.py b/scripts/generate_picker_controls.py index e8c961ce4..417c8efca 100644 --- a/scripts/generate_picker_controls.py +++ b/scripts/generate_picker_controls.py @@ -40,6 +40,11 @@ def partition_migrations(): def capacity_note(reductions, defines): notes = [] for reduction in reductions: + if reduction == 'Cardputer Full: USB plus selected WiFi or Bluetooth; 256 offline frames normally, 128 during mOTA': + notes.append('USB remains available. Select WiFi or Bluetooth with set companion.transport wifi|ble, then reboot. ' + 'Full retains 350 contacts, 40 channels and 256 queued messages; 128 queue slots remain while mOTA borrows storage. ' + 'Sync unread messages before starting mOTA if more than 128 are pending.') + continue if reduction == 'display.canvas limited to 320x320 internal pixels, scaled to the 480x480 panel; 350 contacts, 512 queued frames and all Full transports retained': notes.append('Display renders at 320x320 and scales to the 480x480 panel to preserve internal RAM. All Full transports, 350 contacts, 512 queued messages and full-length message previews are retained.') continue diff --git a/scripts/optional_display_profiles.py b/scripts/optional_display_profiles.py index 254f0f77d..c6d1e9966 100644 --- a/scripts/optional_display_profiles.py +++ b/scripts/optional_display_profiles.py @@ -38,6 +38,9 @@ SPECS = ( driver="GxEPDDisplay", controller="ssd1681", marker="techo_lite", platform="NRF52_PLATFORM", defines={"EINK_DISPLAY_MODEL": "GxEPD2_122_T61"}, targets=r"lilygo_t-echo-lite_(?:non_shell_)?(?:companion_radio_(?:usb|ble|full)|(?:repeater|room_server)(?:_lora_ota(?:_no_external_sensors)?)?)"), + dict(flag="WIO_L1_DISPLAY_UNIFIED", board="seeed-wio-tracker-l1", guard="WIO_TRACKER_L1", + driver="WioL1Display", controller="sh1106_ssd1680", marker="wio_l1", platform="NRF52_PLATFORM", + targets=r"wiotrackerl1_companion_radio_(?:ble|full)"), ) diff --git a/src/helpers/CompanionRtcQueue.h b/src/helpers/CompanionRtcQueue.h index 692fe75ce..2cb44030a 100644 --- a/src/helpers/CompanionRtcQueue.h +++ b/src/helpers/CompanionRtcQueue.h @@ -35,10 +35,9 @@ #if COMPANION_RTC_SLOW_OFFLINE_FRAMES && !defined(CONFIG_IDF_TARGET_ESP32S3) #error "The separate RTC SLOW offline-frame bank requires ESP32-S3" #endif -#if defined(OTA_SHARED_COMPANION_QUEUE) \ - && (!defined(CONFIG_IDF_TARGET_ESP32C3) || COMPANION_RTC_SLOW_OFFLINE_FRAMES) -#error "RTC retained shared-OTA frames are qualified only on ESP32-C3 FAST memory" -#endif +// Shared OTA retains only CPU-owned message frames in these same banks. +// BorrowableFrameBuffer keeps its context/workspace in ordinary DRAM on +// both qualified chips; it never hands an RTC address to an SDK/DMA owner. namespace mesh { namespace companion_rtc { diff --git a/src/helpers/ui/DisplayPowerSettings.cpp b/src/helpers/ui/DisplayPowerSettings.cpp index bbdac4a4f..a1240e899 100644 --- a/src/helpers/ui/DisplayPowerSettings.cpp +++ b/src/helpers/ui/DisplayPowerSettings.cpp @@ -95,7 +95,8 @@ bool savePrefs(const DisplayPowerPrefs& prefs) { #endif // MESHCORE_HAS_REAL_DISPLAY } -bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_on_boot) { +bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_on_boot, + uint16_t default_timeout) { loadDisplayNightSettings(fs); #ifdef MESHCORE_HAS_REAL_DISPLAY settings_fs = fs; @@ -104,6 +105,8 @@ bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_ #endif pairing_available = pairing_supported; DisplayPowerPrefs prefs; + if (default_timeout >= 1 && default_timeout <= 3600) + prefs.battery.seconds = prefs.usb.seconds = default_timeout; prefs.wake_on_boot = wake_on_boot; if (pairing_supported) prefs.battery.mode = prefs.usb.mode = DisplayMode::ButtonPairing; #if CARDPUTER_ADV_UI diff --git a/src/helpers/ui/DisplayPowerSettings.h b/src/helpers/ui/DisplayPowerSettings.h index c87198853..8020b8396 100644 --- a/src/helpers/ui/DisplayPowerSettings.h +++ b/src/helpers/ui/DisplayPowerSettings.h @@ -12,8 +12,9 @@ namespace mesh { namespace ui { // Roles arm first_install_boot after a missing identity is saved successfully; // the initial setup window is runtime-only and is never written to this file. // Headless builds initialize runtime defaults and never access this file. +// A detected display can override the fresh-install timeout; saved profiles win. bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, - bool wake_on_boot = false); + bool wake_on_boot = false, uint16_t default_timeout = 0); bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size, bool inbox_supported = false); bool displayPairingSupported(); diff --git a/src/helpers/ui/WioL1Display.cpp b/src/helpers/ui/WioL1Display.cpp new file mode 100644 index 000000000..7ac00584b --- /dev/null +++ b/src/helpers/ui/WioL1Display.cpp @@ -0,0 +1,68 @@ +#include "WioL1Display.h" +#include "WioL1DisplayProbe.h" +#include + +extern const char meshcore_capability_display_wio_l1_unified[]; +const char meshcore_capability_display_wio_l1_unified[] = + "meshcore.capability.display.wio_l1_unified.v1"; +extern const char meshcore_capability_display_autodetect_wio_l1[]; +const char meshcore_capability_display_autodetect_wio_l1[] = + "meshcore.capability.display.autodetect.wio_l1.v1"; + +WioL1Display::~WioL1Display() { + if (_constructed == Type::Oled) _storage.oled.~SH1106Display(); + else if (_constructed == Type::Eink) _storage.eink.~GxEPDDisplay(); +} + +WioL1Display::Type WioL1Display::detect() { + if (_detected) return _type; + _detected = true; + const volatile char* capability = meshcore_capability_display_autodetect_wio_l1; + (void)*capability; + const uint8_t addresses[] = {DISPLAY_ADDRESS, DISPLAY_ADDRESS ^ 1}; + Wire.end(); + for (uint8_t address : addresses) { + if (mesh::ui::wio_l1::probeOled(address)) { + _type = Type::Oled; + Wire.begin(); + return _type; + } + } + Wire.begin(); + if (mesh::ui::wio_l1::probeEink()) _type = Type::Eink; + return _type; +} + +bool WioL1Display::begin() { + if (_started) return _active != nullptr; + _started = true; + const volatile char* capability = meshcore_capability_display_wio_l1_unified; + (void)*capability; + detect(); + bool ready = false; + if (_type == Type::Oled) { + _constructed = Type::Oled; + auto* oled = new (&_storage.oled) SH1106Display(); + ready = oled->begin(); + if (ready) _active = oled; + } else if (_type == Type::Eink) { + _constructed = Type::Eink; + auto* eink = new (&_storage.eink) GxEPDDisplay(); + ready = eink->begin(); + if (ready) { + _active = eink; + eink->forceFullRefresh(); + } + } + if (!ready) { + _type = Type::None; + return false; + } + const ColorVal background = isEink() ? 0xFFFF : 0; + const ColorVal foreground = isEink() ? 0 : 1; + UIColor::window_bkg = UIColor::title_bkg = UIColor::popup_bkg = background; + UIColor::title_txt = UIColor::primary_txt = UIColor::secondary_txt = + UIColor::warning_txt = UIColor::popup_txt = UIColor::corp_blue = foreground; + setDimensions(_active->width(), _active->height()); + return true; +} diff --git a/src/helpers/ui/WioL1Display.h b/src/helpers/ui/WioL1Display.h new file mode 100644 index 000000000..b2d9dbb36 --- /dev/null +++ b/src/helpers/ui/WioL1Display.h @@ -0,0 +1,52 @@ +#pragma once + +#include "SH1106Display.h" +#include "GxEPDDisplay.h" + +// L1/Lite/Pro and L1 E-Ink share the same MCU, storage and radio wiring. +// Construct only the detected backend, sharing the larger static allocation. +// No OLED framebuffer is allocated on e-paper or headless hardware. +class WioL1Display : public DisplayDriver { +public: + enum class Type : uint8_t { None, Oled, Eink }; + +private: + union Storage { + SH1106Display oled; + GxEPDDisplay eink; + Storage() {} + ~Storage() {} + } _storage; + DisplayDriver* _active = nullptr; + Type _type = Type::None; + Type _constructed = Type::None; + bool _detected = false; + bool _started = false; + +public: + WioL1Display() : DisplayDriver(128, 64) {} + ~WioL1Display(); + Type detect(); + Type selectedType() const { return _type; } + uint16_t defaultTimeoutSeconds() const { return _type == Type::Eink ? 900 : 15; } + bool begin(); + bool isOn() override { return _active && _active->isOn(); } + bool isEink() override { return _type == Type::Eink; } + void forceFullRefresh() override { if (_active) _active->forceFullRefresh(); } + void turnOn() override { if (_active) _active->turnOn(); } + void turnOff() override { if (isOn()) _active->turnOff(); } + int textLineHeight() override { return _active ? _active->textLineHeight() : 10; } + void clear() override { if (isOn()) _active->clear(); } + void startFrame(ColorVal bkg = UIColor::window_bkg) override { if (isOn()) _active->startFrame(bkg); } + void setTextSize(int size) override { if (_active) _active->setTextSize(size); } + void setColor(ColorVal color) override { if (_active) _active->setColor(color); } + void setCursor(int x, int y) override { if (isOn()) _active->setCursor(x, y); } + void print(const char* text) override { if (isOn()) _active->print(text); } + void fillRect(int x, int y, int w, int h) override { if (isOn()) _active->fillRect(x, y, w, h); } + void drawRect(int x, int y, int w, int h) override { if (isOn()) _active->drawRect(x, y, w, h); } + void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { + if (isOn()) _active->drawXbm(x, y, bits, w, h); + } + uint16_t getTextWidth(const char* text) override { return _active ? _active->getTextWidth(text) : 0; } + void endFrame() override { if (isOn()) _active->endFrame(); } +}; diff --git a/src/helpers/ui/WioL1DisplayLayout.h b/src/helpers/ui/WioL1DisplayLayout.h new file mode 100644 index 000000000..ff36dcb00 --- /dev/null +++ b/src/helpers/ui/WioL1DisplayLayout.h @@ -0,0 +1,12 @@ +#pragma once + +namespace mesh { namespace ui { namespace wio_l1 { + +inline int visibleRows(int height, int top, int glyph_height, int spacing, int limit) { + if (spacing <= 0 || glyph_height <= 0 || height - top < glyph_height || limit <= 0) + return 0; + const int rows = 1 + (height - top - glyph_height) / spacing; + return rows < limit ? rows : limit; +} + +}}} diff --git a/src/helpers/ui/WioL1DisplayProbe.h b/src/helpers/ui/WioL1DisplayProbe.h new file mode 100644 index 000000000..7dc2ca746 --- /dev/null +++ b/src/helpers/ui/WioL1DisplayProbe.h @@ -0,0 +1,155 @@ +#pragma once + +#include + +namespace mesh { namespace ui { namespace wio_l1 { + +inline void i2cLow(uint8_t pin) { + digitalWrite(pin, LOW); + pinMode(pin, OUTPUT); +} + +inline bool i2cClockHigh() { + pinMode(PIN_WIRE_SCL, INPUT_PULLUP); + for (uint8_t retry = 0; retry < 50; ++retry) { + if (digitalRead(PIN_WIRE_SCL) == HIGH) return true; + delayMicroseconds(10); + } + return false; +} + +// Called while Wire is disabled, before any radio/RTC background work starts. +// GPIO only drives LOW; INPUT_PULLUP releases each open-drain line. A stuck +// SCL/SDA fails in bounded time, unlike the nRF52 Wire driver's event waits. +inline bool probeOled(uint8_t address) { + pinMode(PIN_WIRE_SDA, INPUT_PULLUP); + bool ready = i2cClockHigh() && digitalRead(PIN_WIRE_SDA) == HIGH; + bool ack = false; + if (ready) { + i2cLow(PIN_WIRE_SDA); + delayMicroseconds(5); + i2cLow(PIN_WIRE_SCL); + for (uint8_t mask = 0x80; mask && ready; mask >>= 1) { + if ((address << 1) & mask) pinMode(PIN_WIRE_SDA, INPUT_PULLUP); + else i2cLow(PIN_WIRE_SDA); + delayMicroseconds(5); + ready = i2cClockHigh(); + delayMicroseconds(5); + i2cLow(PIN_WIRE_SCL); + } + if (ready) { + pinMode(PIN_WIRE_SDA, INPUT_PULLUP); + delayMicroseconds(5); + ready = i2cClockHigh(); + ack = ready && digitalRead(PIN_WIRE_SDA) == LOW; + i2cLow(PIN_WIRE_SCL); + } + i2cLow(PIN_WIRE_SDA); + ready = i2cClockHigh() && ready; + delayMicroseconds(5); + } + pinMode(PIN_WIRE_SDA, INPUT_PULLUP); // STOP, or release a failed bus. + pinMode(PIN_WIRE_SCL, INPUT_PULLUP); + delayMicroseconds(5); + return ready && ack && digitalRead(PIN_WIRE_SDA) == HIGH; +} + +// The L1's SSD1680 uses bidirectional SDA on the display MOSI pin. Read +// status 0x2F before starting SPI1; BUSY alone cannot identify a fitted panel. +// SSD1680 Rev 0.14 pp. 10-11,29: bits 1:0 are 01; bits 5:4 are undefined +// after reset. Opposite input pulls reject an absent or disconnected display. +inline void writeCommand(uint8_t command) { + digitalWrite(PIN_DISPLAY_CS, LOW); + digitalWrite(PIN_DISPLAY_DC, LOW); + pinMode(PIN_SPI1_MOSI, OUTPUT); + for (uint8_t mask = 0x80; mask; mask >>= 1) { + digitalWrite(PIN_SPI1_MOSI, command & mask ? HIGH : LOW); + delayMicroseconds(5); + digitalWrite(PIN_SPI1_SCK, HIGH); + delayMicroseconds(5); + digitalWrite(PIN_SPI1_SCK, LOW); + } + digitalWrite(PIN_DISPLAY_CS, HIGH); +} + +inline uint8_t readStatus(uint8_t bias) { + digitalWrite(PIN_DISPLAY_CS, LOW); + digitalWrite(PIN_DISPLAY_DC, LOW); + pinMode(PIN_SPI1_MOSI, OUTPUT); + for (uint8_t mask = 0x80; mask; mask >>= 1) { + digitalWrite(PIN_SPI1_MOSI, 0x2F & mask ? HIGH : LOW); + delayMicroseconds(5); + digitalWrite(PIN_SPI1_SCK, HIGH); + delayMicroseconds(5); + if (mask == 1) { + // Release SDA before the controller drives the final falling edge. + pinMode(PIN_SPI1_MOSI, bias); + digitalWrite(PIN_DISPLAY_DC, HIGH); + } + digitalWrite(PIN_SPI1_SCK, LOW); + } + uint8_t result = 0; + for (uint8_t bit = 0; bit < 8; ++bit) { + delayMicroseconds(5); + result = uint8_t((result << 1) | (digitalRead(PIN_SPI1_MOSI) ? 1u : 0u)); + digitalWrite(PIN_SPI1_SCK, HIGH); + delayMicroseconds(5); + digitalWrite(PIN_SPI1_SCK, LOW); + } + digitalWrite(PIN_DISPLAY_CS, HIGH); + return result; +} + +inline bool waitReady() { + pinMode(PIN_DISPLAY_BUSY, INPUT_PULLUP); + for (uint16_t elapsed = 0; elapsed < 200; ++elapsed) { + if (digitalRead(PIN_DISPLAY_BUSY) == LOW) return true; + delay(1); + } + return false; +} + +inline void parkAbsent() { + digitalWrite(PIN_DISPLAY_RST, LOW); + pinMode(PIN_DISPLAY_CS, INPUT); + pinMode(PIN_DISPLAY_DC, INPUT); + pinMode(PIN_SPI1_MOSI, INPUT); + pinMode(PIN_SPI1_SCK, INPUT); + pinMode(PIN_DISPLAY_BUSY, INPUT); +} + +inline bool probeEink() { + digitalWrite(PIN_DISPLAY_CS, HIGH); + pinMode(PIN_DISPLAY_CS, OUTPUT); + digitalWrite(PIN_SPI1_SCK, LOW); + pinMode(PIN_SPI1_SCK, OUTPUT); + pinMode(PIN_DISPLAY_DC, OUTPUT); + digitalWrite(PIN_DISPLAY_RST, LOW); + pinMode(PIN_DISPLAY_RST, OUTPUT); + for (uint8_t attempt = 0; attempt < 2; ++attempt) { + digitalWrite(PIN_DISPLAY_RST, LOW); + delay(2); + digitalWrite(PIN_DISPLAY_RST, HIGH); + delay(10); + if (!waitReady()) continue; + writeCommand(0x12); + if (!waitReady()) continue; + uint8_t status[2]; + bool stable = true; + for (uint8_t bias = 0; bias < 2; ++bias) { + const uint8_t mode = bias == 0 ? INPUT_PULLUP : INPUT_PULLDOWN; + (void)readStatus(mode); + status[bias] = readStatus(mode) & 0xCFu; + stable = ((readStatus(mode) & 0xCFu) == status[bias]) && stable; + } + if (stable && status[0] == 0x01u && status[1] == status[0]) { + pinMode(PIN_SPI1_MOSI, INPUT); + pinMode(PIN_DISPLAY_BUSY, INPUT); + return true; + } + } + parkAbsent(); + return false; +} + +}}} // namespace mesh::ui::wio_l1 diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 882e30796..7d53d4a8f 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1414,7 +1414,7 @@ "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", - "examples/companion_radio/MyMesh.cpp": "b1a97d4f2975c0a8d53f3bbaade327e6edc09af6c4476cf34f27a096e65647bb", + "examples/companion_radio/MyMesh.cpp": "562448d691a8b1b39a4f656c4acdde966d0ce21ebb63cfef4640d1b3ac1db694", "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873" diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 139326745..fa8d11755 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -9,16 +9,6 @@ fail() { exit 1 } -# These are the only current ESP32 Companion recipes without a qualified Full -# replacement. Keep inventory assertions consistent without exempting boards. -is_existing_cardputer_companion_recipe() { - case "$1" in - M5Stack_Cardputer_Adv_companion_radio_usb|M5Stack_Cardputer_Adv_companion_radio_ble) - return 0 ;; - esac - return 1 -} - # Optional debug settings must remain usable by strict shell callers before # any PlatformIO flags have been initialized. ( @@ -241,6 +231,17 @@ require("Heltec_E290_companion_usb_ble", "build_flags", "ENABLE_USB_INTERFACE") require("Heltec_E290_companion_usb_ble", "build_flags", "BLE_PIN_CODE=123456") require("Heltec_T190_companion_radio_usb_ble_", "build_flags", "ENABLE_USB_INTERFACE") require("Heltec_T190_companion_radio_usb_ble_", "build_flags", "BLE_PIN_CODE=123456") +cardputer = "M5Stack_Cardputer_Adv_companion_radio_ble" +require(cardputer, "board", "m5stack-stamps3") +require(cardputer, "board_build.partitions", "default_8MB.csv") +for flag in ("CARDPUTER_ADV_UI=1", "MESH_CONTACT_CACHE=1", "MAX_CONTACTS=350", + "MAX_GROUP_CHANNELS=40", "OFFLINE_QUEUE_SIZE=256", + "ENABLE_USB_INTERFACE", "BLE_PIN_CODE=123456", + "RECOVERABLE_EXTERNAL_RADIO", "ENV_INCLUDE_GPS=1"): + require(cardputer, "build_flags", flag) +for source in ("examples/companion_radio", "variants/m5stack_cardputer_adv/companion_radio", + "variants/m5stack_cardputer_adv/hw"): + require(cardputer, "build_src_filter", source) waveshare_full = "Waveshare_S3_LR1121_companion_radio_full" require(waveshare_full, "board", "esp32-s3-zero") require(waveshare_full, "board_build.flash_mode", "dio") @@ -509,11 +510,10 @@ for env_name in ( ): require(env_name, "build_flags", "BLE_TX_POWER=0") -for env_name in ( - "WioTrackerL1_companion_radio_usb", - "WioTrackerL1_companion_radio_ble", -): - require(env_name, "build_flags", "UI_NO_HIBERNATE") +require("WioTrackerL1_companion_radio_usb", "build_flags", "UI_NO_HIBERNATE") +require("WioTrackerL1_companion_radio_ble", "build_flags", "WIO_L1_DISPLAY_UNIFIED=1") +require("WioTrackerL1_companion_radio_ble", "build_flags", "DISPLAY_CLASS=WioL1Display") +reject("WioTrackerL1_companion_radio_ble", "build_flags", "UI_NO_HIBERNATE") rak_usb = "RAK_3401_companion_radio_usb" require(rak_usb, "build_flags", "RAK_BOARD") @@ -532,6 +532,11 @@ for env_name in ("RAK_4631_companion_radio_usb", "RAK_4631_repeater"): # replace each with that exact board's Full target. init_project_context >/dev/null +# The generated L1 Full target must use the recipe with both display drivers, +# rather than the earlier USB-only OLED recipe with the same physical board. +[ "${PIO_ENV_BUILD_BASE_BY_NAME[WioTrackerL1_companion_radio_full]:-}" = WioTrackerL1_companion_radio_ble ] \ + || fail "Wio L1 Full did not select its runtime-display BLE base" + # The interactive board menu must offer the same ordinary Full choices as the # release, while exact compatibility/development recipes stay directly usable. # Test real board inheritance: dropping aliases by suffix alone would also @@ -580,15 +585,18 @@ init_project_context >/dev/null || fail "nRF52 menu depends on alias input order" ) -# Cardputer's existing UI recipes have not qualified a combined Full profile. -# Keep both exact transport recipes available in its ordinary release lists. +# Cardputer keeps its historical recipes for explicit recovery builds while +# ordinary releases publish one USB/BLE/WiFi Full image with the keyboard UI. for cardputer_env in M5Stack_Cardputer_Adv_companion_radio_usb \ M5Stack_Cardputer_Adv_companion_radio_ble; do [ "$(get_pio_build_env "$cardputer_env")" = "$cardputer_env" ] \ || fail "$cardputer_env lost its exact board recipe" for resolver in resolve_all_firmwares resolve_companion_firmwares; do - grep -Fxq "$cardputer_env" <<<"$("$resolver")" \ - || fail "$resolver omitted supported Cardputer recipe $cardputer_env" + if grep -Fxq "$cardputer_env" <<<"$("$resolver")"; then + fail "$resolver republishes Cardputer transport alias $cardputer_env" + fi + grep -Fxq M5Stack_Cardputer_Adv_companion_radio_full <<<"$("$resolver")" \ + || fail "$resolver omitted combined Cardputer Full" done done @@ -604,9 +612,6 @@ for resolver in resolve_all_firmwares resolve_repeater_firmwares \ fi is_redundant_bulk_build_target "$release_env" \ && fail "$resolver republishes utility or legacy alias $release_env" - if is_existing_cardputer_companion_recipe "$release_env"; then - continue - fi if is_kiss_modem_target "$release_env"; then if is_esp32_full_release_build_target "$release_env"; then fail "$release_env host modem was promoted into the node Full profile" @@ -1115,6 +1120,8 @@ while IFS='|' read -r source_env full_env build_base; do is_redundant_bulk_build_target "$source_env" \ || fail "$source_env remained beside its Full Companion" done <<'FULL_COMPANION_SPECS' +M5Stack_Cardputer_Adv_companion_radio_usb|M5Stack_Cardputer_Adv_companion_radio_full|M5Stack_Cardputer_Adv_companion_radio_full +M5Stack_Cardputer_Adv_companion_radio_ble|M5Stack_Cardputer_Adv_companion_radio_full|M5Stack_Cardputer_Adv_companion_radio_full LilyGo_Tlora_C6_companion_radio_ble_|LilyGo_Tlora_C6_companion_radio_full_|LilyGo_Tlora_C6_companion_radio_ble_ Meshimi_companion_radio_ble_|Meshimi_companion_radio_full_|Meshimi_companion_radio_ble_ WHY2025_badge_companion_radio_ble_|WHY2025_badge_companion_radio_full_|WHY2025_badge_companion_radio_ble_ @@ -1362,8 +1369,7 @@ MQTT_BRIDGE_OVERRIDE="" while IFS= read -r env_name; do if [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = ESP32_PLATFORM ] \ - && ! is_companion_radio_full_target "$env_name" \ - && ! is_existing_cardputer_companion_recipe "$env_name"; then + && ! is_companion_radio_full_target "$env_name"; then fail "canonical ESP32 Companion inventory still contains $env_name" fi done < <(resolve_companion_firmwares) diff --git a/test/test_cardputer_board_policy.py b/test/test_cardputer_board_policy.py index 9e2d50f9f..9a26fda03 100644 --- a/test/test_cardputer_board_policy.py +++ b/test/test_cardputer_board_policy.py @@ -18,6 +18,88 @@ VARIANT = ROOT / "variants/m5stack_cardputer_adv" class CardputerBoardPolicyTests(unittest.TestCase): + def test_full_transport_selection_preserves_live_bluetooth_controls(self): + source = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text() + toggle = extract_braced(source, "void CardputerUITask::toggleBluetooth()") + transport = extract_braced(source, "void CardputerUITask::toggleTransport()") + code = r''' +#include +#include +#include +#include +#include "CompanionBluetooth.h" +#include "CompanionWiFi.h" +static std::string command; +static bool fail = false; +bool handleCompanionBluetoothCommand(const char* text, char* reply, size_t size, + CompanionBluetoothCommandSource) { + command = text; + snprintf(reply, size, "%s", fail ? "Error: save failed" : "OK - saved"); + return true; +} +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) +static CompanionTransportMode selected = CompanionTransportMode::Bluetooth; +CompanionTransportMode getCompanionTransportMode() { return selected; } +bool selectCompanionTransportMode(CompanionTransportMode next) { + if (fail) return false; + selected = next; + return true; +} +#endif +struct CardputerUITask { + bool enabled = true; + std::string alert; + bool isBluetoothEnabled() const { return enabled; } + void showAlert(const char* text, int) { alert = text; } + void toggleBluetooth(); +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + void toggleTransport(); +#endif +}; +@METHOD@ +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) +@TRANSPORT@ +#endif +int main() { + CardputerUITask task; + task.toggleBluetooth(); + assert(command == "set bluetooth off" && task.alert == "saved"); + task.enabled = false; task.toggleBluetooth(); + assert(command == "set bluetooth on" && task.alert == "saved"); + fail = true; task.toggleBluetooth(); + assert(task.alert == "Error: save failed"); + fail = false; command.clear(); +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + task.toggleTransport(); + assert(selected == CompanionTransportMode::WiFi && command.empty()); + assert(task.alert == "WiFi selected; reboot required"); + fail = true; task.toggleTransport(); + assert(selected == CompanionTransportMode::WiFi && task.alert == "Transport save failed"); + fail = false; task.toggleTransport(); + assert(selected == CompanionTransportMode::Bluetooth && command.empty()); + assert(task.alert == "Bluetooth selected; reboot required"); +#endif +} +'''.replace("@METHOD@", toggle).replace("@TRANSPORT@", transport) + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a native C++ compiler is required") + with tempfile.TemporaryDirectory(prefix="cardputer-transport-") as directory: + source = Path(directory) / "transport.cpp" + binary = Path(directory) / ("transport.exe" if os.name == "nt" else "transport") + source.write_text(code, encoding="ascii") + for full in (False, True): + with self.subTest(full=full): + flags = (["-DESP32=1", "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1", + '-DWIFI_SSID="test"', "-DBLE_PIN_CODE=123456"] if full else []) + sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *sanitizer, *flags, "-I", str(ROOT / "examples/companion_radio"), + str(source), "-o", str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + def check_gpio(self, remove_board_guard=False): method = extract_braced((VARIANT / "CardputerAdvBoard.h").read_text(), "bool isUserGpioAvailable(uint8_t pin) const override") diff --git a/test/test_cardputer_runtime_memory.py b/test/test_cardputer_runtime_memory.py index eec59ae8e..ee890d8af 100644 --- a/test/test_cardputer_runtime_memory.py +++ b/test/test_cardputer_runtime_memory.py @@ -13,6 +13,27 @@ from test_replay_reset_integration import extract_braced class CardputerRuntimeMemoryTests(unittest.TestCase): + def test_full_retains_custom_ui_allowances_and_selected_wireless_stack(self): + full = {"ESP32_PLATFORM": "1", "BLE_PIN_CODE": "123456", + "WIFI_SSID": '"WIFI_SSID"', "WIFI_OTA_SEEDER": "1", + "ENABLE_OTA": "1", "OTA_SEEDER_ONLY": "1", + "COMPANION_RADIO_FULL": "1", "CARDPUTER_ADV_UI": "1", + "COMPANION_EXCLUSIVE_WIFI_BLE": "1", + "MESH_CONTACT_CACHE": "1", "MAX_CONTACTS": "350", + "MAX_GROUP_CHANNELS": "40", "OFFLINE_QUEUE_SIZE": "256"} + measured = requirements("ESP32_PLATFORM", full, + "M5Stack_Cardputer_Adv_companion_radio_full") + legacy = requirements("ESP32_PLATFORM", {"ESP32_PLATFORM": "1", + "BLE_PIN_CODE": "123456", "CARDPUTER_ADV_UI": "1"}, + "M5Stack_Cardputer_Adv_companion_radio_ble") + for key, value in legacy["components"].items(): + if key.startswith("cardputer_"): + self.assertEqual(measured["components"][key], value) + self.assertEqual(measured["components"]["wireless_stacks"], max(49152, 32768)) + self.assertIn("ota_source_scratch", measured["components"]) + self.assertIn("ota_generic_source_buffers", measured["components"]) + self.assertGreater(measured["required_heap_bytes"], legacy["required_heap_bytes"]) + def test_custom_ui_owners_are_additive_and_fonts_fit_their_allowance(self): base = {"ESP32_PLATFORM": "1", "BLE_PIN_CODE": "123456"} target = "M5Stack_Cardputer_Adv_companion_radio_ble" diff --git a/test/test_combined_board_picker.js b/test/test_combined_board_picker.js new file mode 100644 index 000000000..db6e29376 --- /dev/null +++ b/test/test_combined_board_picker.js @@ -0,0 +1,59 @@ +"use strict"; +const assert = require("assert"); +const picker = require("../docs/_javascript/firmware_picker.js"); +const source = "a".repeat(40), tag = "v1.18.0.2-dev-aaaaaaaa"; +const cases = [ + ["heltec_v4_r8_tft_kiss_modem", "heltec_v4_r8_kiss_modem", "ESP32_PLATFORM", "headlessR8Kiss"], + ["WioTrackerL1Eink_kiss_modem", "WioTrackerL1_kiss_modem", "NRF52_PLATFORM", "headlessWioL1Kiss"], + ["WioTrackerL1Eink_companion_radio_ble", "WioTrackerL1_companion_radio_full", "NRF52_PLATFORM", "unifiedWioL1Display"], +]; + +function catalog(legacy, target, platform, key, changes = {}, names = [legacy, target]) { + const profiles = {[target]: {platform, [key]: true, [key + "Source"]: source, + ...(key === "unifiedWioL1Display" ? {display: true, displayAuto: true, + displayController: "sh1106_ssd1680", displaySource: source} : {}), ...changes}}; + const release = {tag_name: tag, name: tag, draft: false, published_at: "2026-10-31T00:00:00Z", + html_url: "https://github.com/mikecarper/MeshCore/releases/tag/" + tag, + assets: names.map(name => { + const file = name + "-" + tag + (platform === "NRF52_PLATFORM" ? ".uf2" : ".bin"); + return {name: file, size: 500000, + browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/" + tag + "/" + file}; + })}; + const before = JSON.stringify(release); + const result = picker.buildCatalog([release], {familyTag: tag, source, profiles}); + assert.strictEqual(JSON.stringify(release), before, "historical filenames and downloads stay exact"); + return result; +} + +for (const args of cases) { + const [legacy, target, platform, key] = args; + const result = catalog(...args); + assert.deepStrictEqual(result.profiles.map(row => row.target), [target]); + assert.strictEqual(result.rows.length, 2, "the exact historical download remains available"); + const old = picker.parseTargetProfile(legacy), next = picker.parseTargetProfile(target); + const selected = picker.selectionFromUrl("https://example.org/?hardware=" + old.hardware + "&role=" + old.role, + result.profiles); + assert.strictEqual(selected.filters.hardware, next.hardware); + assert.deepStrictEqual(selected.unavailable, []); + for (const fault of [{[key]: false}, {[key]: 1}, {[key + "Source"]: "b".repeat(40)}, + {[key + "Source"]: "aaaaaaaa"}, {platform: platform === "ESP32_PLATFORM" ? "NRF52_PLATFORM" : "ESP32_PLATFORM"}]) { + assert.strictEqual(catalog(...args, fault).profiles.length, 2, "unqualified replacements keep old choices"); + } + assert.strictEqual(catalog(...args, {}, [legacy]).profiles.length, 1, "missing successor keeps legacy"); + if (key === "unifiedWioL1Display") { + for (const fault of [{display: false}, {displayAuto: false}, {displaySource: "b".repeat(40)}, + {displayController: "ssd1681"}]) { + assert.strictEqual(catalog(...args, fault).profiles.length, 2, "Wio needs its exact optional display proof"); + } + assert(picker.boardNotes(result.profiles[0]).some(note => note.includes("1W"))); + } else { + assert(picker.boardNotes(result.profiles[0]).some(note => note.includes("display"))); + } +} +const wio = cases[2]; +for (const other of ["WioTrackerL1_1W_companion_radio_ble", "WioTrackerL1Eink_repeater", + "WioTrackerL1Eink_companion_radio_ble_lora_ota"]) { + assert.strictEqual(catalog(other, wio[1], wio[2], wio[3]).profiles.length, 2, + other + ": different physical hardware, roles and deployed OTA identities stay separate"); +} +console.log("Combined Wio L1 / headless KISS choices require exact release proof and preserve historical downloads"); diff --git a/test/test_combined_board_profiles.py b/test/test_combined_board_profiles.py new file mode 100644 index 000000000..2f8145df9 --- /dev/null +++ b/test/test_combined_board_profiles.py @@ -0,0 +1,153 @@ +"""Evidence-bound Wio L1 Companion and headless KISS consolidation.""" +from copy import deepcopy +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "scripts"), str(ROOT / "tools/mota")] +import consolidated_profiles as profiles +import motalib + +SOURCE = "a" * 40 +CASES = ( + dict(target="heltec_v4_r8_kiss_modem", legacy="heltec_v4_r8_tft_kiss_modem", + key="headlessR8Kiss", capability="modem.headless.heltec_r8", + marker="meshcore.capability.modem.headless.heltec_r8.v1", + platform="ESP32_PLATFORM", board="heltec_v4_r8", + flags=["-DHELTEC_V4_R8=1", "-DMESHCORE_HEADLESS_KISS_COMPAT=1"]), + dict(target="WioTrackerL1_kiss_modem", legacy="WioTrackerL1Eink_kiss_modem", + key="headlessWioL1Kiss", capability="modem.headless.wio_l1", + marker="meshcore.capability.modem.headless.wio_l1.v1", + platform="NRF52_PLATFORM", board="seeed-wio-tracker-l1", + flags=["-DWIO_TRACKER_L1=1", "-DMESHCORE_HEADLESS_KISS_COMPAT=1"]), + dict(target="WioTrackerL1_companion_radio_full", legacy="WioTrackerL1Eink_companion_radio_ble", + key="unifiedWioL1Display", capability="display.wio_l1.unified", + marker="meshcore.capability.display.wio_l1_unified.v1", + platform="NRF52_PLATFORM", board="seeed-wio-tracker-l1", + flags=["-DWIO_TRACKER_L1=1", "-DWIO_L1_DISPLAY_UNIFIED=1", + "-DDISPLAY_CLASS=WioL1Display", "-DMAX_CONTACTS=350", + "-DMAX_GROUP_CHANNELS=40", "-DOFFLINE_QUEUE_SIZE=256"]), +) + + +def environment(case): + return dict(board=case["board"], build_flags=case["flags"][:]) + + +def record(case, *, legacy=False): + target = case["legacy"] if legacy else case["target"] + manifest = dict(target=target, source_commit=SOURCE, verified=True, + build_profile="full", platform=case["platform"], + capabilities=["sensor.profile.full"], verification=[]) + if not legacy: + manifest["capabilities"].append(case["capability"]) + manifest["verification"].append(dict(capability=case["capability"], present=True, + source="packaged application", evidence=case["marker"])) + if "_companion_radio_" in target: + for key in ("companion.usb", "companion.bluetooth"): + manifest["capabilities"].append(key) + manifest["verification"].append(dict(capability=key, present=True, + source="linked image", evidence="linked symbol")) + return dict(manifest=manifest, files=[target + ".bin"]) + + +class CombinedBoardProfileTests(unittest.TestCase): + def test_each_successor_requires_exact_config_and_source_bound_packaged_evidence(self): + for case in CASES: + base = record(case)["manifest"] + with self.subTest(target=case["target"]): + self.assertEqual(profiles.runtime_metadata(base, environment(case), SOURCE), + {case["key"]: True, case["key"] + "Source": SOURCE}) + for changes in (dict(verified=False), dict(platform="OTHER"), dict(verification=[]), + dict(source_commit="b" * 40), dict(source_commit="a" * 8)): + with self.subTest(target=case["target"], changes=changes), self.assertRaises(ValueError): + profiles.runtime_metadata({**base, **changes}, environment(case), SOURCE) + for changes in (dict(present=False), dict(present=1), dict(source="linked image"), + dict(evidence="driver linked")): + bad = deepcopy(base) + bad["verification"][0].update(changes) + with self.subTest(target=case["target"], changes=changes), self.assertRaises(ValueError): + profiles.runtime_metadata(bad, environment(case), SOURCE) + + def test_omission_needs_same_source_build_policy_and_real_packaged_marker(self): + for case in CASES: + old, new = record(case, legacy=True), record(case) + self.assertEqual(profiles.omit_redundant_records([old, new]), [new]) + self.assertEqual(profiles.omit_redundant_records([old]), [old]) + faults = (dict(verified=False), dict(platform="OTHER"), dict(verification=[]), + dict(source_commit="b" * 40), dict(source_commit="a" * 8), + dict(build_profile="reduced"), dict(capabilities=["sensor.profile.full"]), + dict(capabilities=[case["capability"]]), + dict(capabilities=[case["capability"], "sensor.profile.reduced"])) + for changes in faults: + candidate = deepcopy(new) + candidate["manifest"].update(changes) + with self.subTest(target=case["target"], changes=changes): + self.assertEqual(profiles.omit_redundant_records([old, candidate]), [old, candidate]) + + def test_companion_replacement_keeps_both_actual_transports(self): + case = CASES[2] + old, new = record(case, legacy=True), record(case) + for capability in ("companion.usb", "companion.bluetooth"): + for changes in (dict(present=False), dict(source="packaged application"), dict(evidence="")): + bad = deepcopy(new) + for row in bad["manifest"]["verification"]: + if row["capability"] == capability: + row.update(changes) + with self.subTest(capability=capability, changes=changes): + self.assertEqual(profiles.omit_redundant_records([old, bad]), [old, bad]) + + def test_resumed_wio_ble_profile_retires_only_with_matching_sensor_policy(self): + old, new = record(CASES[2], legacy=True), record(CASES[2]) + old["manifest"]["build_profile"] = "standard" + self.assertEqual(profiles.omit_redundant_records([old, new]), [new]) + old["manifest"]["capabilities"] = ["sensor.profile.reduced"] + self.assertEqual(profiles.omit_redundant_records([old, new]), [old, new]) + + def test_configuration_cannot_fold_a_different_board_overridden_define_or_visible_kiss_display(self): + for case in CASES: + canonical, legacy = "env:" + case["target"], "env:" + case["legacy"] + envs = {canonical: environment(case), legacy: environment(case)} + self.assertEqual(profiles.configuration_replacement(case["legacy"], envs), case["target"]) + for flag in case["flags"]: + key = flag[2:].split("=", 1)[0] + for override in ("-U" + key, "-D" + key + "=0"): + bad = deepcopy(envs) + bad[canonical]["build_flags"].append(override) + with self.subTest(target=case["target"], override=override): + self.assertIsNone(profiles.configuration_replacement(case["legacy"], bad)) + for name in (canonical, legacy): + bad = deepcopy(envs) + bad[name]["board"] = "different-board" + self.assertIsNone(profiles.configuration_replacement(case["legacy"], bad)) + if case["key"].startswith("headless"): + bad = deepcopy(envs) + bad[canonical]["build_flags"].append("-DDISPLAY_CLASS=SomeDisplay") + self.assertIsNone(profiles.configuration_replacement(case["legacy"], bad)) + + def test_only_exact_old_targets_fold_and_update_ids_are_never_aliased(self): + for case in CASES: + self.assertEqual(profiles.replacements(case["legacy"]), [(case["target"], case["key"])]) + self.assertEqual(profiles.replacements(case["legacy"] + "_custom"), []) + self.assertNotEqual(motalib.target_id_for_env(case["target"]), + motalib.target_id_for_env(case["legacy"])) + for target in ("WioTrackerL1_1W_companion_radio_ble", "WioTrackerL1Eink_repeater", + "WioTrackerL1Eink_companion_radio_ble_lora_ota", + "heltec_v4_r8_tft_repeater", "heltec_v4_r8_tft_sensor"): + self.assertEqual(profiles.replacements(target), [], target) + + def test_wio_generated_full_resolves_from_ble_and_retains_capacity_guards(self): + case = CASES[2] + envs = {"env:" + case["legacy"]: environment(case), + "env:WioTrackerL1_companion_radio_ble": environment(case)} + self.assertEqual(profiles.configuration_replacement(case["legacy"], envs), case["target"]) + for key, value in (("MAX_CONTACTS", "349"), ("MAX_GROUP_CHANNELS", "39"), + ("OFFLINE_QUEUE_SIZE", "255")): + bad = deepcopy(envs) + bad["env:WioTrackerL1_companion_radio_ble"]["build_flags"].append("-D" + key + "=" + value) + self.assertIsNone(profiles.configuration_replacement(case["legacy"], bad)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_rtc_queue.py b/test/test_companion_rtc_queue.py index bec0754cf..7c1eb6ac8 100644 --- a/test/test_companion_rtc_queue.py +++ b/test/test_companion_rtc_queue.py @@ -291,7 +291,7 @@ class CompanionRtcQueueTests(unittest.TestCase): 'CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP=1', 'COMPANION_RTC_FAST_OFFLINE_FRAMES=32'] cases = ([value for value in base if not value.startswith('CONFIG_IDF_TARGET_')], base + ['BOARD_HAS_PSRAM=1'], [value for value in base if not value.startswith('CONFIG_IDF_TARGET_')] - + ['OTA_SHARED_COMPANION_QUEUE=1', 'CONFIG_IDF_TARGET_ESP32S3=1'], + + ['OTA_SHARED_COMPANION_QUEUE=1', 'CONFIG_IDF_TARGET_ESP32=1'], base + ['ONE_KEY_DM_SHARED_OFFLINE_QUEUE=1'], base + ['COMPANION_RTC_SLOW_OFFLINE_FRAMES=32'], [value for value in base if not value.startswith('COMPANION_RADIO_FULL')], @@ -320,6 +320,7 @@ class CompanionRtcQueueTests(unittest.TestCase): ('MKE_s3_companion_radio_full', 32, 32, 350, 256), ('ThinkNode_M2_companion_radio_full', 32, 32, 350, 256), ('ThinkNode_M5_companion_radio_full', 32, 32, 350, 256), + ('M5Stack_Cardputer_Adv_companion_radio_full', 32, 32, 350, 256), ('SenseCapIndicator-ESPNow_companion_radio_full', 0, 0, 350, 256), ('RAK_4631_companion_radio_full', 0, 0, 350, 256)): with self.subTest(target=target): @@ -337,6 +338,9 @@ class CompanionRtcQueueTests(unittest.TestCase): if target in ('Xiao_C3_companion_radio_full', 'Heltec_CT62_companion_radio_full'): self.assertEqual(flags.get('OTA_SHARED_COMPANION_QUEUE'), '1') self.assertEqual(flags.get('ONE_KEY_DM_COMPACT_HELD'), '1') + elif target == 'M5Stack_Cardputer_Adv_companion_radio_full': + self.assertEqual(flags.get('OTA_SHARED_COMPANION_QUEUE'), '1') + self.assertEqual(flags.get('COMPANION_EXCLUSIVE_WIFI_BLE'), '1') else: self.assertNotIn('OTA_SHARED_COMPANION_QUEUE', flags) diff --git a/test/test_companion_shared_ota_capacity.py b/test/test_companion_shared_ota_capacity.py index 9d247ed65..825a53077 100644 --- a/test/test_companion_shared_ota_capacity.py +++ b/test/test_companion_shared_ota_capacity.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Exercise C3's full queue while its explicit OTA work borrows cold storage.""" +"""Exercise split C3/S3 queues while explicit OTA work borrows cold storage.""" from pathlib import Path import re @@ -30,15 +30,19 @@ class SharedCompanionOtaCapacityTest(unittest.TestCase): binary = Path(temp) / "queue" (Path(temp) / "sdkconfig.h").write_text("#pragma once\n") (Path(temp) / "esp_attr.h").write_text( - '#pragma once\n#define RTC_FAST_ATTR __attribute__((section(".rtc.force_fast.test")))\n') + '#pragma once\n#define RTC_FAST_ATTR __attribute__((section(".rtc.force_fast.test")))\n' + '#define RTC_SLOW_ATTR __attribute__((section(".rtc.force_slow.test")))\n') command = ["c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "src"), "-I", temp, "-x", "c++", "-", "-o", str(binary)] if rtc: + s3 = rtc == "ESP32S3" command[1:1] = ["-DNDEBUG", "-DESP32_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1", - "-DOTA_SHARED_COMPANION_QUEUE=1", "-DOFFLINE_QUEUE_SIZE=224", "-DCONFIG_IDF_TARGET_ESP32C3=1", + "-DOTA_SHARED_COMPANION_QUEUE=1", f"-DOFFLINE_QUEUE_SIZE={256 if s3 else 224}", + f"-DCONFIG_IDF_TARGET_{'ESP32S3' if s3 else 'ESP32C3'}=1", "-DCONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP=1", - "-DCOMPANION_RTC_FAST_OFFLINE_FRAMES=32"] + "-DCOMPANION_RTC_FAST_OFFLINE_FRAMES=32", + f"-DCOMPANION_RTC_SLOW_OFFLINE_FRAMES={32 if s3 else 0}"] if run: command[1:1] = ["-fsanitize=address,undefined", "-g"] result = subprocess.run(command, input=text, text=True, @@ -52,6 +56,10 @@ class SharedCompanionOtaCapacityTest(unittest.TestCase): match = re.search(r'\.rtc\.force_fast\.test\s+([0-9a-fA-F]+)', sections) self.assertIsNotNone(match, sections) self.assertEqual(int(match[1], 16), 32 * 177, sections) + if s3: + match = re.search(r'\.rtc\.force_slow\.test\s+([0-9a-fA-F]+)', sections) + self.assertIsNotNone(match, sections) + self.assertEqual(int(match[1], 16), 32 * 177, sections) def no_core_dump(): resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) result = subprocess.run([str(binary), *args], text=True, @@ -59,7 +67,7 @@ class SharedCompanionOtaCapacityTest(unittest.TestCase): self.assertEqual(result.returncode, expected_returncode, result.stderr) return result - def queue_fixture(self, rtc=False): + def queue_fixture(self, rtc=False, capacity=224): source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() declaration = (ROOT / "examples/companion_radio/MyMesh.h").read_text() retained = re.search( @@ -140,20 +148,20 @@ struct MyMesh { int main() { using mesh::ota::OtaContext; MyMesh mesh; - for (unsigned head = 0; head < 224; ++head) { - while (mesh.offline_queue_len < 224) mesh.add(); - // Visit every wrapped head with a full 224-frame normal queue. + for (unsigned head = 0; head < @CAPACITY@; ++head) { + while (mesh.offline_queue_len < @CAPACITY@) mesh.add(); + // Visit every wrapped head with a full normal queue. for (unsigned i = 0; i < head; ++i) { mesh.pop(); mesh.add(); } int original_head = mesh.offline_queue_head; unsigned constructed = OtaContext::constructions; assert(!MyMesh::acquireOfflineQueueForOta(&mesh)); - assert(mesh.offline_queue_len == 224); + assert(mesh.offline_queue_len == @CAPACITY@); assert(mesh.offline_queue_head == original_head); assert(OtaContext::constructions == constructed && OtaContext::live == 0); // Only the explicit drain consumes frames, preserving the newest 128. unsigned notified = mesh.notifications; - assert(MyMesh::drainOfflineQueueForOta(&mesh) == 96); - assert(mesh.notifications == notified + 96 && mesh.offline_queue_len == 128); + assert(MyMesh::drainOfflineQueueForOta(&mesh) == @DRAINED@); + assert(mesh.notifications == notified + @DRAINED@ && mesh.offline_queue_len == 128); assert(MyMesh::drainOfflineQueueForOta(&mesh) == 0); OtaContext* owner = MyMesh::acquireOfflineQueueForOta(&mesh); assert(owner && OtaContext::live == 1 && mesh.offline_queue.capacity() == 128); @@ -163,15 +171,16 @@ int main() { memset(owner->bytes, 0xCC, sizeof owner->bytes); for (unsigned i = 0; i < head % 128; ++i) { mesh.pop(); mesh.add(); } MyMesh::releaseOfflineQueueFromOta(&mesh); - assert(OtaContext::live == 0 && mesh.offline_queue.capacity() == 224); + assert(OtaContext::live == 0 && mesh.offline_queue.capacity() == @CAPACITY@); assert(OtaContext::constructions == OtaContext::destructions); MyMesh::releaseOfflineQueueFromOta(&mesh); // idle release cannot destroy twice - while (mesh.offline_queue_len < 224) mesh.add(); + while (mesh.offline_queue_len < @CAPACITY@) mesh.add(); while (mesh.offline_queue_len) mesh.pop(); assert(mesh.expected.empty()); } } -'''.replace("@RETAINED@", retained).replace("@METHODS@", methods) +'''.replace("@RETAINED@", retained).replace("@METHODS@", methods).replace( + "@CAPACITY@", str(capacity)).replace("@DRAINED@", str(capacity - 128)) if rtc: start = declaration.index(' // Concrete definitions in MyMesh.cpp') end = declaration.index('\n#endif\n#if defined(OTA_SHARED_COMPANION_QUEUE)', start) @@ -195,6 +204,10 @@ int main() { def test_rtc_backed_224_and_128_fifo_all_heads_use_actual_mesh_storage_and_callbacks(self): self.compile(self.queue_fixture(rtc=True), run=True, rtc=True) + def test_s3_256_and_128_fifo_all_heads_use_both_banks_and_actual_mesh_callbacks(self): + self.compile(self.queue_fixture(rtc=True, capacity=256), + run=True, rtc="ESP32S3") + def test_rtc_owner_reset_geometry_and_release_build_safeguards(self): harness = self.queue_fixture(rtc=True).split('int main() {', 1)[0] + r''' using Frame = MyMesh::Frame; diff --git a/test/test_display_power.py b/test/test_display_power.py index ebc49fccc..0ab3d97d4 100644 --- a/test/test_display_power.py +++ b/test/test_display_power.py @@ -363,6 +363,24 @@ void firstInstallSettingsTests() { } } void companionSettingsTests() { + for (uint16_t seconds : {uint16_t(15), uint16_t(900)}) { + FakeFS fs; + assert(!loadDisplayPowerSettings(&fs, true, true, seconds)); + assert(displayPowerPrefs().battery.seconds == seconds); + assert(displayPowerPrefs().usb.seconds == seconds); + DisplayPowerPolicy policy; + policy.update(displayPowerPrefs(), false, false, false, 100); + policy.update(displayPowerPrefs(), false, false, false, 100 + seconds * 1000U - 1); + assert(policy.on()); + policy.update(displayPowerPrefs(), false, false, false, 100 + seconds * 1000U); + assert(!policy.on()); + char reply[160]; + assert(handleDisplayPowerCommand("set display.timeout 123", reply, sizeof(reply))); + assert(std::string(reply) == "OK"); + assert(loadDisplayPowerSettings(&fs, true, true, 900)); + assert(displayPowerPrefs().battery.seconds == 123); + assert(displayPowerPrefs().usb.seconds == seconds); + } for (bool ble : {false, true}) { FakeFS fs; assert(!loadDisplayPowerSettings(&fs, ble, true)); diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 10c8e49ca..714939435 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -285,6 +285,20 @@ int main() { elf32(path, {"__HeapBase": (0x2003F800-capacity,0), "__HeapLimit": (0x2003F800,0)}) self.assertEqual(ram.check_firmware(path,"NRF52_PLATFORM","nrf52840",flags,target)==0, accepted) + def test_wio_l1_unified_display_keeps_oled_heap_allowance_on_every_boot(self): + flags = {"DISPLAY_CLASS": "WioL1Display", "WIO_L1_DISPLAY_UNIFIED": 1, + "UI_SMALL_MESSAGE_FONT": 1, "COMPANION_RADIO_FULL": 1, "BLE_PIN_CODE": 123456} + target = "WioTrackerL1_companion_radio_full" + policy = ram.requirements("NRF52_PLATFORM", flags, target) + oled = ram.requirements("NRF52_PLATFORM", {**flags, "DISPLAY_CLASS": "SH1106Display"}, target) + self.assertEqual(policy["components"], oled["components"]) + self.assertEqual(policy["components"]["display_pixels_and_driver"], 4096) + self.assertEqual(policy["components"]["allocation_and_transient_margin"], 4096) + for platform, changed in (("ESP32_PLATFORM", flags), + ("NRF52_PLATFORM", {**flags, "WIO_L1_DISPLAY_UNIFIED": 0})): + with self.subTest(platform=platform, flags=changed), self.assertRaises(ValueError): + ram.requirements(platform, changed, target) + def test_device_encoder_budget_matches_storage_and_role_gates(self): matrix = [ ("ESP32_PLATFORM", {"ENABLE_OTA": 1, "MESHCORE_OTA_DEVICE_DEFLATE": 1}, "v4_repeater", True), diff --git a/test/test_headless_kiss_builds.py b/test/test_headless_kiss_builds.py new file mode 100644 index 000000000..c9ba893d6 --- /dev/null +++ b/test/test_headless_kiss_builds.py @@ -0,0 +1,145 @@ +"""Headless KISS compatibility across optional R8 and Wio L1 displays.""" +import configparser +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PAIRS = ( + ("heltec_v4_r8_kiss_modem", "heltec_v4_r8_tft_kiss_modem"), + ("WioTrackerL1_kiss_modem", "WioTrackerL1Eink_kiss_modem"), +) + + +class HeadlessKissBuildTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.config = configparser.ConfigParser(interpolation=None, strict=False, + inline_comment_prefixes=(";",)) + cls.config.read([ROOT / "platformio.ini", + ROOT / "variants/heltec_v4_r8/platformio.ini", + ROOT / "variants/wio-tracker-l1/platformio.ini", + ROOT / "variants/wio-tracker-l1-eink/platformio.ini"]) + + def option(self, section, key): + options = self.config[section] + if key not in options: + for parent in options.get("extends", "").split(","): + if parent.strip(): + value = self.option(parent.strip(), key) + if value is not None: + return value + return None + return re.sub(r"\$\{([^}.]+)\.([^}]+)\}", + lambda m: self.option(m[1], m[2]) or "", options[key]) + + def flags(self, name): + return dict((m[1], m[2] or "1") for m in re.finditer( + r"-D\s*([A-Za-z_][A-Za-z0-9_]*)(?:=([^\s]+))?", + self.option("env:" + name, "build_flags"))) + + def test_both_historical_names_remain_buildable_without_a_display(self): + for pair in PAIRS: + for name in pair: + with self.subTest(name=name): + flags = self.flags(name) + self.assertEqual(flags["MESHCORE_HEADLESS_KISS_COMPAT"], "1") + self.assertEqual(flags["RADIO_CLASS"], "CustomSX1262") + self.assertNotIn("DISPLAY_CLASS", flags) + sources = self.option("env:" + name, "build_src_filter") + self.assertIn("+<../examples/kiss_modem/>", sources) + self.assertNotIn("../examples/companion_radio", sources) + + def test_pairs_keep_identical_radio_settings_and_boot_layouts(self): + for shared, legacy in PAIRS: + with self.subTest(shared=shared): + shared_flags, legacy_flags = self.flags(shared), self.flags(legacy) + for key in ("USE_SX1262", "RADIO_CLASS", "WRAPPER_CLASS", "LORA_TX_POWER", + "P_LORA_NSS", "P_LORA_RESET", "P_LORA_BUSY", "P_LORA_DIO_1", + "P_LORA_SCLK", "P_LORA_MOSI", "P_LORA_MISO", + "SX126X_DIO3_TCXO_VOLTAGE", "SX126X_CURRENT_LIMIT", + "ENV_INCLUDE_GPS", "ENV_PIN_SDA", "ENV_PIN_SCL"): + self.assertEqual(shared_flags.get(key), legacy_flags.get(key), key) + for key in ("board", "board_build.ldscript", "board_upload.maximum_size"): + self.assertEqual(self.option("env:" + shared, key), + self.option("env:" + legacy, key), key) + + def test_capability_evidence_is_retained_from_the_board_startup(self): + for variant, board, kind, call in ( + ("heltec_v4_r8", "HeltecV4R8Board", "heltec_r8", "parkHeltecR8KissDisplayPins"), + ("wio-tracker-l1", "WioTrackerL1Board", "wio_l1", "parkWioL1KissDisplayPins"), + ): + with self.subTest(variant=variant): + source = (ROOT / "variants" / variant / (board + ".cpp")).read_text() + self.assertIn("meshcore.capability.modem.headless." + kind + ".v1", source) + begin = source.split("void " + board + "::begin()", 1)[1].split("\n}", 1)[0] + self.assertIn("const volatile char* capability", begin) + self.assertIn("(void)*capability", begin) + self.assertIn(call + "();", begin) + + def test_real_pin_parking_deselects_displays_without_touching_radio(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "host C++ compiler required") + with tempfile.TemporaryDirectory(prefix="headless-kiss-pins-") as directory: + work = Path(directory) + (work / "WVariant.h").write_text("#pragma once\n", encoding="ascii") + (work / "Arduino.h").write_text(r''' +#pragma once +#include +#include +#include +constexpr int LOW = 0, HIGH = 1, OUTPUT = 2; +struct Event { int pin, level; bool output; }; +inline std::vector events; +inline std::map levels; +inline void digitalWrite(int pin, int level) { + events.push_back({pin, level, false}); levels[pin] = level; +} +inline void pinMode(int pin, int mode) { + assert(mode == OUTPUT); + assert(levels.count(pin)); // The intended level must be loaded first. + events.push_back({pin, levels.at(pin), true}); +} +''', encoding="ascii") + source = work / "pins.cpp" + source.write_text(r''' +#include +#include "variants/wio-tracker-l1/variant.h" +#include "variants/heltec_v4_r8/HeadlessKissPins.h" +#include "variants/wio-tracker-l1/HeadlessKissPins.h" +void check(const std::map& expected) { + assert(levels == expected); + assert(events.size() == expected.size() * 2); + for (std::size_t i = 0; i < events.size(); i += 2) { + assert(!events[i].output && events[i+1].output); + assert(events[i].pin == events[i+1].pin); + assert(events[i].level == events[i+1].level); + } + events.clear(); levels.clear(); +} +int main() { + for (int pass = 0; pass < 2; ++pass) { + parkHeltecR8KissDisplayPins(); + check({{44, LOW}, {47, HIGH}, {3, HIGH}, {21, LOW}}); + parkWioL1KissDisplayPins(); + check({{36, HIGH}, {32, LOW}}); + } +} +''', encoding="ascii") + executable = work / ("pins.exe" if os.name == "nt" else "pins") + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I" + str(work), "-I" + str(ROOT), + str(source), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_indicator_exclusive_transport.py b/test/test_indicator_exclusive_transport.py index 952bf20a3..83178f9af 100644 --- a/test/test_indicator_exclusive_transport.py +++ b/test/test_indicator_exclusive_transport.py @@ -175,7 +175,11 @@ profile_start = build.index("\napply_companion_radio_full_profile() {") profile_end = build.index("\napply_radio_overrides()", profile_start) profile = build[profile_start:profile_end] flag = "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1" -assert profile.count(flag) == 1 +assert profile.count(flag) == 2 +cardputer_start = profile.index(" m5stack_cardputer_adv_companion_radio_full)") +cardputer_case = profile[cardputer_start:profile.index(";;", cardputer_start)] +assert flag in cardputer_case +assert "-DOTA_SHARED_COMPANION_QUEUE=1" in cardputer_case exclusive_policy = profile.index( "# Both Indicator Full layouts select exactly one secondary" ) diff --git a/test/test_picker_controls_generator.py b/test/test_picker_controls_generator.py index 12170c667..7101172c4 100644 --- a/test/test_picker_controls_generator.py +++ b/test/test_picker_controls_generator.py @@ -13,6 +13,13 @@ SPEC.loader.exec_module(GENERATOR) class PickerControlsTests(unittest.TestCase): + def test_cardputer_full_explains_wireless_selection_and_shared_queue(self): + note = GENERATOR.capacity_note([ + 'Cardputer Full: USB plus selected WiFi or Bluetooth; 256 offline frames normally, 128 during mOTA'], {}) + for text in ('USB remains available', 'set companion.transport wifi|ble', 'reboot', + '350 contacts', '40 channels', '256 queued messages', '128 queue slots'): + self.assertIn(text, note) + def test_shared_radio_keeps_supported_runtime_rxps_control(self): shared = dict(RADIO_CLASS='UniversalLoRaRadio', MESH_RADIO_AUTO_FAMILY='1', USE_SX1262='1') self.assertTrue(GENERATOR.has_rxps_control(shared)) diff --git a/test/test_shared_mota_queue.py b/test/test_shared_mota_queue.py index 3f8a0188b..5cfc204aa 100644 --- a/test/test_shared_mota_queue.py +++ b/test/test_shared_mota_queue.py @@ -18,9 +18,12 @@ class SharedMotaQueueTest(unittest.TestCase): self.run_transfer_test("ESP32_PLATFORM") def test_c3_rtc_retained_queue_preserves_real_ota_and_wifi_transfers(self): - self.run_transfer_test("ESP32_PLATFORM", rtc=True) + self.run_transfer_test("ESP32_PLATFORM", rtc="ESP32C3") - def run_transfer_test(self, platform, rtc=False): + def test_s3_both_rtc_banks_preserve_real_ota_and_wifi_transfers(self): + self.run_transfer_test("ESP32_PLATFORM", rtc="ESP32S3") + + def run_transfer_test(self, platform, rtc=None): with tempfile.TemporaryDirectory(prefix="meshcore-mota-queue-") as temp: binary = Path(temp) / "transfer" tinf = Path(temp) / "tinf.o" @@ -48,27 +51,34 @@ class SharedMotaQueueTest(unittest.TestCase): "src/helpers/ota/MotaSourceSerial.cpp", "src/helpers/ota/FolderMotaStore.cpp"] if rtc: + capacity = 224 if rtc == "ESP32C3" else 256 + slow = 0 if rtc == "ESP32C3" else 32 fixture = ROOT / "test/fixtures/shared_mota_queue" source = (fixture / "test_shared_mota_queue.cpp").read_text() source = source[:source.index(" Queue full;")] + "}\n" - source = source.replace("256", "224") + source = source.replace("256", str(capacity)) source = source.replace("struct Queue {", ( "#include \n" - "alignas(4) RTC_FAST_ATTR Frame rtc_frames[32];\nstruct Queue {")) + "alignas(4) RTC_FAST_ATTR Frame rtc_frames[32];\n" + + ("alignas(4) RTC_SLOW_ATTR Frame rtc_slow_frames[32];\n" if slow else "") + + "struct Queue {")) source = source.replace( - "mesh::BorrowableFrameBuffer buffer;", - "mesh::BorrowableFrameBuffer buffer;", + f"mesh::BorrowableFrameBuffer> buffer;\n" - " Queue() : buffer(rtc_frames, nullptr) {}") + + " Queue() : buffer(rtc_frames, " + + ("rtc_slow_frames" if slow else "nullptr") + ") {}") (Path(temp) / "test_shared_rtc.cpp").write_text(source) (Path(temp) / "test_wifi_shared_queue.h").write_text( - (fixture / "test_wifi_shared_queue.h").read_text().replace("256", "224")) + (fixture / "test_wifi_shared_queue.h").read_text().replace("256", str(capacity))) (Path(temp) / "sdkconfig.h").write_text("#pragma once\n") (Path(temp) / "esp_attr.h").write_text( - '#pragma once\n#define RTC_FAST_ATTR __attribute__((section(".rtc.force_fast.test")))\n') + '#pragma once\n#define RTC_FAST_ATTR __attribute__((section(".rtc.force_fast.test")))\n' + '#define RTC_SLOW_ATTR __attribute__((section(".rtc.force_slow.test")))\n') sources[0] = str(Path(temp) / "test_shared_rtc.cpp") flags += ["-DCOMPANION_RTC_FAST_OFFLINE_FRAMES=32", - "-DCONFIG_IDF_TARGET_ESP32C3=1", + f"-DCOMPANION_RTC_SLOW_OFFLINE_FRAMES={slow}", + f"-DCONFIG_IDF_TARGET_{rtc}=1", "-DCONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP=1", "-I", str(fixture)] built = subprocess.run([ diff --git a/test/test_wio_l1_display_detection.py b/test/test_wio_l1_display_detection.py new file mode 100644 index 000000000..ffd404895 --- /dev/null +++ b/test/test_wio_l1_display_detection.py @@ -0,0 +1,361 @@ +"""Exercise the real L1 display selector and SSD1680 readback on the host.""" +from pathlib import Path +import configparser +import os +import subprocess +import tempfile +import unittest + +from test_techo_lite_display_detection import ARDUINO as GPIO, PANEL, SPI, SAN +import test_companion_inbox as inbox_fixture +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +UI = ROOT / "src/helpers/ui" + +ARDUINO = GPIO.replace( + '''#if defined(LILYGO_TECHO_LITE) && defined(TECHO_LITE_DISPLAY_AUTODETECT) && TECHO_LITE_DISPLAY_AUTODETECT == 1 + return true; +#else + return false; +#endif''', 'return true;').replace("inline void pinMode(", "inline void epdPinMode(").replace( + "inline void digitalWrite(", "inline void epdDigitalWrite(").replace( + "inline int digitalRead(", "inline int epdDigitalRead(") + r''' +#define PIN_SPI1_MOSI DISP_MOSI +#define PIN_SPI1_SCK DISP_SCLK +#define DISPLAY_ADDRESS 0x3D +#define PIN_WIRE_SDA 14 +#define PIN_WIRE_SCL 15 +inline unsigned oled_constructs=0, oled_inits=0, oled_allocs=0, oled_frees=0, oled_draws=0; +inline unsigned eink_constructs=0; +inline bool wire_running=true, i2c_transaction=false; +inline unsigned wire_ends=0, wire_begins=0, i2c_clocks=0, i2c_command=0; +inline bool i2cPin(int pin) { return pin==PIN_WIRE_SDA || pin==PIN_WIRE_SCL; } +inline bool oledAt(unsigned address) { + return (address==0x3d && (scenario=="oled" || scenario=="oled-fail" || scenario=="both")) + || (address==0x3c && scenario=="oled-alt"); +} +inline void pinMode(int pin,int mode) { + if(!i2cPin(pin)) { epdPinMode(pin,mode); return; } + assert(!wire_running && "release Wire before GPIO probe"); ++events; + assert(mode==INPUT_PULLUP || (mode==OUTPUT && levels[pin]==LOW)); + const int previous=modes[pin]; modes[pin]=mode; + if(pin==PIN_WIRE_SDA && mode==OUTPUT && modes[PIN_WIRE_SCL]==INPUT_PULLUP) { + i2c_transaction=true; i2c_clocks=i2c_command=0; + } + if(pin==PIN_WIRE_SCL && mode==INPUT_PULLUP && previous==OUTPUT && i2c_transaction) { + ++i2c_clocks; + if(i2c_clocks<=8) i2c_command=(i2c_command<<1)|(modes[PIN_WIRE_SDA]==INPUT_PULLUP ? 1u : 0u); + } + if(pin==PIN_WIRE_SDA && mode==INPUT_PULLUP && modes[PIN_WIRE_SCL]==INPUT_PULLUP && i2c_clocks>=10) + i2c_transaction=false; +} +inline void digitalWrite(int pin,int value) { + if(!i2cPin(pin)) { epdDigitalWrite(pin,value); return; } + assert(!wire_running && value==LOW && "I2C GPIO must never drive HIGH"); + levels[pin]=value; ++events; +} +inline int digitalRead(int pin) { + if(!i2cPin(pin)) return epdDigitalRead(pin); + assert(!wire_running && modes[pin]==INPUT_PULLUP); + if(pin==PIN_WIRE_SCL) return scenario=="scl-stuck" ? LOW : HIGH; + if(scenario=="sda-stuck") return LOW; + if(i2c_transaction && i2c_clocks==9) { + assert((i2c_command&1)==0 && "OLED probe uses a write address"); + return oledAt(i2c_command>>1) ? LOW : HIGH; + } + return HIGH; +} +''' + +WIRE = r''' +#pragma once +#include +struct TwoWire { + unsigned probes=0; uint8_t address=0; + void end() { assert(wire_running); wire_running=false; ++wire_ends; } + void begin() { assert(!wire_running); wire_running=true; ++wire_begins; } + void beginTransmission(uint8_t value) { address=value; ++probes; } + uint8_t endTransmission() { + assert(wire_running); return oledAt(address) ? 0 : 2; + } +}; +inline TwoWire Wire; +''' + +OLED = r''' +#pragma once +#include +#include +#define SH110X_BLACK 0 +#define SH110X_WHITE 1 +#define SH110X_DISPLAYON 0xAF +#define SH110X_DISPLAYOFF 0xAE +struct Adafruit_I2CDevice {}; +struct Adafruit_SH1106G { + Adafruit_I2CDevice* i2c_dev=nullptr; + uint8_t* buffer=nullptr; + Adafruit_SH1106G(int,int,TwoWire*,int) { ++oled_constructs; } + ~Adafruit_SH1106G() { if(buffer) { delete[] buffer; ++oled_frees; } delete i2c_dev; } + bool begin(uint8_t address,bool) { + ++oled_inits; + assert((scenario=="oled-alt" ? address==0x3c : address==0x3d)); + if(scenario=="oled-fail") return false; + assert(!buffer); buffer=new uint8_t[1024]; ++oled_allocs; + i2c_dev=new Adafruit_I2CDevice; + return true; + } + void oled_command(int) { assert(buffer); } + void clearDisplay() { assert(buffer); ++oled_draws; } + void display() { assert(buffer); ++oled_draws; } + void setTextColor(int color) { assert(buffer && (color==0 || color==1)); } + void setTextSize(int) { assert(buffer); } + void cp437(bool) { assert(buffer); } + void setCursor(int,int) { assert(buffer); } + void print(const char*) { assert(buffer); ++oled_draws; } + void fillRect(int,int,int,int,int) { assert(buffer); ++oled_draws; } + void drawRect(int,int,int,int,int) { assert(buffer); ++oled_draws; } + void drawBitmap(int,int,const uint8_t*,int,int,int) { assert(buffer); ++oled_draws; } + void getTextBounds(const char* str,int,int,int16_t* x,int16_t* y,uint16_t* w,uint16_t* h) { + assert(buffer); *x=*y=0; *w=strlen(str)*6; *h=8; + } +}; +''' + +MAIN = r''' +#include "WioL1Display.h" +#include "WioL1DisplayLayout.h" +void frame(WioL1Display& display) { + const uint8_t bitmap[]={0x80}; + display.clear(); display.startFrame(); display.setTextSize(1); + display.setColor(UIColor::primary_txt); display.setCursor(1,2); display.print("test"); + display.fillRect(1,2,3,4); display.drawRect(1,2,3,4); + display.drawXbm(1,2,bitmap,1,1); (void)display.getTextWidth("test"); display.endFrame(); +} +int main(int argc,char** argv) { + assert(argc==2); scenario=argv[1]; + assert(mesh::ui::wio_l1::visibleRows(64,18,10,12,6)==4); + assert(mesh::ui::wio_l1::visibleRows(122,29,22,22,6)==4); + assert(mesh::ui::wio_l1::visibleRows(64,60,10,12,6)==0); + for(int height:{40,64,122,250}) for(int top:{0,18,29,60}) + for(int line:{10,12,22}) for(int limit:{0,1,4,6}) { + const int rows=mesh::ui::wio_l1::visibleRows(height,top,line,line,limit); + assert(rows>=0 && rows<=limit); + for(int row=0;rowheight); + } + const bool oled=scenario=="oled" || scenario=="oled-alt" || scenario=="both"; + const bool invalid=scenario=="floating" || scenario=="high" || scenario=="low" + || scenario=="wrong-id" || scenario=="reserved" || scenario=="biased" + || scenario=="noise" || scenario=="busy-high" || scenario=="busy-low" + || scenario=="busy-floating" || scenario=="oled-fail"; + { + WioL1Display display; + frame(display); display.turnOff(); display.forceFullRefresh(); + assert(events==0 && Wire.probes==0 && oled_constructs==0 && eink_constructs==0); + auto selected=display.detect(); auto events_after_probe=events, probes=Wire.probes; + assert(wire_running && wire_ends==1 && wire_begins==1); + assert(display.detect()==selected && events==events_after_probe && Wire.probes==probes); + assert(oled_constructs==0 && eink_constructs==0 && "probe must not allocate backend storage"); + if(oled) { + assert(selected==WioL1Display::Type::Oled && !display.isEink()); + assert(display.defaultTimeoutSeconds()==15 && commands==0 && resets==0); + } else if(!invalid) { + assert(selected==WioL1Display::Type::Eink && display.isEink()); + assert(display.defaultTimeoutSeconds()==900); + } + bool ready=display.begin(); + if(invalid) { + assert(!ready && !display.isOn() && display.selectedType()==WioL1Display::Type::None); + assert(inits==0 && spi_begins==0 && draws==0 && oled_allocs==0); + if(scenario!="oled-fail") { + assert(oled_constructs==0 && eink_constructs==0 && levels[DISP_RST]==LOW); + for(int pin:{DISP_CS,DISP_DC,DISP_MOSI,DISP_SCLK,DISP_BUSY}) assert(modes[pin]==INPUT); + } + auto count=events; display.turnOn(); display.turnOff(); frame(display); + assert(!display.begin() && events==count && draws==0 && oled_draws==0 && now_us<500000); + } else { + assert(ready); + display.turnOn(); assert(display.isOn()); + assert(display.width()==(oled ? 128 : 250) && display.height()==(oled ? 64 : 122)); + assert(UIColor::window_bkg==(oled ? 0 : 0xffff)); + assert(UIColor::primary_txt==(oled ? 1 : 0)); + assert(oled_constructs==(oled?1u:0u) && eink_constructs==(oled?0u:1u)); + assert(oled_allocs==(oled?1u:0u)); + auto commands_before=commands, resets_before=resets; + for(int i=0;i<3;++i) { frame(display); display.turnOff(); } + auto painted=draws+oled_draws; frame(display); assert(draws+oled_draws==painted); + display.turnOn(); frame(display); assert(display.isOn() && draws+oled_draws>painted); + assert(display.begin() && commands==commands_before && resets==resets_before); + assert(oled_inits==(oled?1u:0u) && inits==(oled?0u:1u)); + } + } + assert(oled_frees==oled_allocs); +} +''' + + +class WioL1DisplayDetectionTest(unittest.TestCase): + def build(self, work, mutation=None): + work.mkdir() + (work / "Fonts").mkdir() + for name in ("WioL1Display.h", "WioL1Display.cpp", "WioL1DisplayProbe.h", + "SH1106Display.h", "SH1106Display.cpp", "GxEPDDisplay.h", "GxEPDDisplay.cpp"): + source = (UI / name).read_text() + if mutation and name == "WioL1DisplayProbe.h": + old, new = mutation + self.assertIn(old, source) + source = source.replace(old, new) + (work / name).write_text(source) + panel = PANEL.replace("WIDTH=176,HEIGHT=192", "WIDTH=122,HEIGHT=250") + panel = panel.replace("GxEPD2_122_T61(T...) {}", "GxEPD2_122_T61(T...) { ++eink_constructs; }") + panel += "\nusing GxEPD2_213_B74=GxEPD2_122_T61;\n" + files = { + "Arduino.h": ARDUINO, "Wire.h": WIRE, "SPI.h": SPI, + "GxEPD2_BW.h": panel, "Adafruit_SH110X.h": OLED, + "Adafruit_GrayOLED.h": "#pragma once\n", + "Adafruit_GFX.h": "#pragma once\n", "main.cpp": MAIN, + "CRC32.h": "#pragma once\n#include \n#include \nstruct CRC32 { template void update(T) {} template void update(const T*,size_t) {} void reset() {} uint32_t finalize() { return 1; } };\n", + } + for name in ("GxEPD2_3C.h", "GxEPD2_4C.h", "GxEPD2_7C.h"): + files[name] = "#pragma once\n" + for font in ("FreeSans9pt7b", "FreeSansBold12pt7b", "FreeSans18pt7b"): + files[f"Fonts/{font}.h"] = f"#pragma once\ninline const int {font}=8;\n" + for name, source in files.items(): + (work / name).write_text(source) + binary = work / "native" + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-sign-compare", *SAN, + "-DDISPLAY_CLASS=WioL1Display", "-DWIO_L1_DISPLAY_UNIFIED=1", + "-DEINK_DISPLAY_MODEL=GxEPD2_213_B74", "-DDISPLAY_ROTATION=1", + "-DEINK_MAX_PARTIAL_REFRESH=10", "-I" + str(work), "-I" + str(UI), + "-I" + str(ROOT / "src"), str(work / "main.cpp"), + *[str(work / name) for name in ("WioL1Display.cpp", "GxEPDDisplay.cpp", "SH1106Display.cpp")], + "-o", str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return binary + + def run_case(self, binary, scenario, negative=None): + result = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10) + if negative: + self.assertNotEqual(result.returncode, 0) + self.assertIn(negative, result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_actual_selector_gpio_readback_and_headless_paths(self): + with tempfile.TemporaryDirectory(prefix="wio-l1-display-") as temp: + binary = self.build(Path(temp) / "native") + for marker in ("wio_l1_unified", "autodetect.wio_l1"): + self.assertIn(("meshcore.capability.display." + marker + ".v1").encode(), binary.read_bytes()) + for scenario in ("oled", "oled-alt", "oled-fail", "both", "valid", "flags", "prime", + "recover", "floating", "high", "low", "wrong-id", "reserved", + "biased", "noise", "busy-high", "busy-low", "busy-floating", + "scl-stuck", "sda-stuck"): + with self.subTest(scenario=scenario): + self.run_case(binary, scenario) + + def test_negative_controls_reject_wrong_identity_and_turnaround(self): + with tempfile.TemporaryDirectory(prefix="wio-l1-display-") as temp: + for name, mutation, scenario, failure in ( + ("id", ("status[0] == 0x01u", "status[0] == 0x02u"), "wrong-id", "!ready"), + ("settle", ("delay(10);", "delay(0);"), "valid", "reset settle before commands"), + ("turnaround", ("pinMode(PIN_SPI1_MOSI, bias);", "pinMode(PIN_SPI1_MOSI, OUTPUT);"), + "valid", "release SDA before controller read falling edge"), + ("i2c-drive", ("digitalWrite(pin, LOW);", "digitalWrite(pin, HIGH);"), + "oled", "I2C GPIO must never drive HIGH"), + ("i2c-address", ("(address << 1) & mask", "address & mask"), + "oled", "OLED probe uses a write address")): + binary = self.build(Path(temp) / name, mutation) + self.run_case(binary, scenario, negative=failure) + + def test_recipe_keeps_capacities_and_legacy_display_specific_targets(self): + config = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=(";",)) + config.read(ROOT / "variants/wio-tracker-l1/platformio.ini") + combined = config["env:WioTrackerL1_companion_radio_ble"] + for flag in ("DISPLAY_CLASS=WioL1Display", "WIO_L1_DISPLAY_UNIFIED=1", + "MAX_CONTACTS=350", "MAX_GROUP_CHANNELS=40", "OFFLINE_QUEUE_SIZE=256"): + self.assertIn(flag, combined["build_flags"]) + for source in ("WioL1Display.cpp", "GxEPDDisplay.cpp", "SerialBLEInterface.cpp"): + self.assertIn(source, combined["build_src_filter"]) + self.assertIn("DISPLAY_CLASS=SH1106Display", config["env:WioTrackerL1_companion_radio_usb"]["build_flags"]) + config.read(ROOT / "variants/wio-tracker-l1-eink/platformio.ini") + self.assertIn("DISPLAY_CLASS=GxEPDDisplay", config["env:WioTrackerL1Eink_companion_radio_ble"]["build_flags"]) + + def test_real_message_footer_and_hibernate_page_follow_selected_panel(self): + ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text() + preamble = ("#define WIO_L1_DISPLAY_UNIFIED 1\n#define UI_MESSAGE_CHANNEL_FOOTER 1\n" + "#define UI_BUTTON_READER_HINT 0\n" + inbox_fixture.PREAMBLE) + preamble = preamble.replace("bool on=true, compact=false;", r''' + bool on=true, compact=false, eink=false; + bool isEink() override { return eink; } + void selectEink(bool selected) { eink=selected; setDimensions(selected?250:128,selected?122:64); } +''') + implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n" + implementation += "Display display;\nstruct HomeScreen { enum HomePage { RADIO2,RADIO_STATUS,SHUTDOWN,FIRST };\n" + implementation += extract_braced(ui, "bool isPageVisible(uint8_t page) const {") + "\n};\n" + scenario = r''' +int main() { + UITask task(display); MsgPreviewScreen messages(&task); HomeScreen home; + for(bool eink:{false,true}) { + display.selectEink(eink); display.clear(); messages.render(display); + assert(display.contains("All channels")==eink); + assert(home.isPageVisible(HomeScreen::SHUTDOWN)==eink); + assert(home.isPageVisible(HomeScreen::FIRST)); + assert(!home.isPageVisible(HomeScreen::RADIO2)); + the_mesh.dual_radio_active=true; + assert(home.isPageVisible(HomeScreen::RADIO2)); + the_mesh.dual_radio_active=false; + } +} +''' + inbox_fixture.CompanionInboxTest.compile_and_run(self, preamble + implementation + scenario) + + def test_actual_radio_start_skips_stuck_optional_rtc_bus_only_when_unified(self): + target = (ROOT / "variants/wio-tracker-l1/target.cpp").read_text() + implementation = extract_braced(target, "bool radio_init()") + source = r''' +#include +#define PIN_WIRE_SDA 14 +#define PIN_WIRE_SCL 15 +#define HIGH 1 +int sda=1,scl=1,reads=0,Wire=0,SPI=0; +int digitalRead(int pin) { ++reads; return pin==PIN_WIRE_SDA ? sda : scl; } +struct Clock { int probes=0; void begin(int&) { ++probes; } } rtc_clock; +struct Radio { int starts=0; bool result=true; bool std_init(int*) { ++starts; return result; } } radio; +''' + implementation + r''' +int main() { + for(int data:{0,1}) for(int clock:{0,1}) for(bool success:{false,true}) { + sda=data; scl=clock; radio.result=success; + const int before=rtc_clock.probes, starts=radio.starts; + assert(radio_init()==success && radio.starts==starts+1); +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + assert(rtc_clock.probes==before+(data && clock)); +#else + assert(rtc_clock.probes==before+1); +#endif + } +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + assert(reads>0); +#else + assert(reads==0); +#endif +} +''' + # The local initializer lists belong to this standalone harness only. + source = "#include \n" + source + with tempfile.TemporaryDirectory(prefix="wio-l1-rtc-") as temp: + for label, flags in (("unified", ["-DWIO_L1_DISPLAY_UNIFIED=1"]), + ("legacy", []), ("disabled", ["-DWIO_L1_DISPLAY_UNIFIED=0"])): + binary = Path(temp) / label + result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", + "-Wall", "-Wextra", "-Werror", *SAN, *flags, "-x", "c++", "-", + "-o", str(binary)], input=source, capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_wio_tracker_identity.py b/test/test_wio_tracker_identity.py index a02a4d23e..ad7b16274 100644 --- a/test/test_wio_tracker_identity.py +++ b/test/test_wio_tracker_identity.py @@ -41,7 +41,7 @@ class NRF52BoardDCDC : virtual public NRF52Board {}; class WioTrackerIdentityTests(unittest.TestCase): - def check_header(self, variant, expected, *, eink=False): + def check_header(self, variant, expected, *, eink=False, unified=False): compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") if not compiler: self.skipTest("C++ compiler unavailable") @@ -65,7 +65,7 @@ void WioTrackerL1Board::begin() {{}} int main() {{ const WioTrackerL1Board board; assert(std::strcmp(board.getManufacturerName(), "{expected}") == 0); - assert(std::strcmp(board.getManufacturerName(), "Seeed Wio Tracker L1") != 0); + assert((std::strcmp(board.getManufacturerName(), "Seeed Wio Tracker L1") == 0) == {str(unified).lower()}); }} ''' cpp.write_text(source, encoding="ascii") @@ -73,6 +73,8 @@ int main() {{ "-I", str(folder), "-I", str(ROOT)] if eink: command.append("-DWIO_TRACKER_L1_EINK") + if unified: + command.append("-DWIO_L1_DISPLAY_UNIFIED=1") result = subprocess.run(command + [str(cpp), "-o", str(executable)], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) @@ -91,6 +93,9 @@ int main() {{ def test_one_watt_reports_the_distinct_fem_power_profile(self): self.check_header("wio-tracker-l1-1w", "Seeed Wio Tracker L1 1W") + def test_unified_companion_reports_the_shared_physical_family(self): + self.check_header("wio-tracker-l1", "Seeed Wio Tracker L1", unified=True) + if __name__ == "__main__": unittest.main() diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index 7595d40b2..4b402e450 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -521,6 +521,7 @@ def test_measured_full_companion_promotions_are_exact_and_bounded(): expected = [ "M5Stack_Unit_C6L_companion_radio_ble", + "M5Stack_Cardputer_Adv_companion_radio_ble", "Heltec_Wireless_Tracker_companion_radio_ble", "LilyGo_T3S3_sx1276_companion_radio_ble", "Heltec_ct62_companion_radio_ble", diff --git a/variants/heltec_v4_r8/HeadlessKissPins.h b/variants/heltec_v4_r8/HeadlessKissPins.h new file mode 100644 index 000000000..6837f33d3 --- /dev/null +++ b/variants/heltec_v4_r8/HeadlessKissPins.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +inline void parkHeltecR8KissDisplayPins() { + // These optional Expansion Kit pins are unused by the headless modem. + // Preload the output levels before enabling them to avoid a backlight pulse + // or selecting the LCD / microSD during startup. + digitalWrite(44, LOW); // LCD backlight, active high + pinMode(44, OUTPUT); + digitalWrite(47, HIGH); // LCD chip select + pinMode(47, OUTPUT); + digitalWrite(3, HIGH); // Expansion Kit V2 microSD chip select + pinMode(3, OUTPUT); + digitalWrite(21, LOW); // Shared LCD/touch or OLED reset + pinMode(21, OUTPUT); +} diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index 9197c3f43..3c9189f88 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -1,6 +1,12 @@ #include "HeltecV4R8Board.h" #include +#if MESHCORE_HEADLESS_KISS_COMPAT + #include "HeadlessKissPins.h" + extern const char meshcore_capability_modem_headless_heltec_r8[] = + "meshcore.capability.modem.headless.heltec_r8.v1"; +#endif + // Expansion Kit V2 control-pin notes (kept here beside the shared-reset // sequencing): display/touch I2C is SDA 17 / SCL 18; GPIO 21 is the shared // LCD_RESET / TP_RESET line; GPIO 44 drives the active-high backlight. The @@ -10,6 +16,12 @@ void HeltecV4R8Board::begin() { ESP32Board::begin(); +#if MESHCORE_HEADLESS_KISS_COMPAT + const volatile char* capability = meshcore_capability_modem_headless_heltec_r8; + (void)*capability; + parkHeltecR8KissDisplayPins(); +#endif + periph_power.begin(); periph_power.claim(); // R8 VEXT also feeds the LoRa antenna boost rail. @@ -88,7 +100,9 @@ bool HeltecV4R8Board::isExternalPowered() { } const char* HeltecV4R8Board::getManufacturerName() const { -#ifdef HELTEC_V4_R8_TFT +#if MESHCORE_HEADLESS_KISS_COMPAT + return "Heltec V4 R8"; +#elif defined(HELTEC_V4_R8_TFT) return "Heltec V4 R8 TFT"; #else return "Heltec V4 R8 OLED"; diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index 9e06180a6..b89bf3271 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -560,10 +560,14 @@ lib_deps = [env:heltec_v4_r8_kiss_modem] extends = Heltec_v4_r8 +build_flags = ${Heltec_v4_r8.build_flags} + -D MESHCORE_HEADLESS_KISS_COMPAT=1 build_src_filter = ${Heltec_v4_r8.build_src_filter} +<../examples/kiss_modem/> [env:heltec_v4_r8_tft_kiss_modem] extends = heltec_v4_r8_tft +build_flags = ${heltec_v4_r8_tft.build_flags} + -D MESHCORE_HEADLESS_KISS_COMPAT=1 build_src_filter = ${heltec_v4_r8_tft.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp index f01a75b81..6306113ab 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp @@ -8,6 +8,7 @@ #include "target.h" #include "helpers.h" #include "CompanionBluetooth.h" +#include "CompanionWiFi.h" #include #include @@ -432,6 +433,19 @@ int CardputerUITask::drawModifierBadges(CardputerDisplay &lcd, int x, int y) { return x; } +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) +void CardputerUITask::toggleTransport() { + const CompanionTransportMode next = getCompanionTransportMode() == CompanionTransportMode::WiFi + ? CompanionTransportMode::Bluetooth : CompanionTransportMode::WiFi; + if (!selectCompanionTransportMode(next)) { + showAlert("Transport save failed", 2500); + return; + } + showAlert(next == CompanionTransportMode::WiFi + ? "WiFi selected; reboot required" : "Bluetooth selected; reboot required", 2500); +} +#endif + void CardputerUITask::toggleBluetooth() { // Same checks and saved preference as the CLI's "set bluetooth on|off" char reply[96]; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h index b7bf4f2d7..1d0cbe244 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h @@ -84,6 +84,9 @@ public: inline uint16_t getBattMilliVoltsCorrected() { return (float)getBattMilliVolts() * _custom_prefs->battery_correction; } bool toggleBuzzer(); void toggleBluetooth(); +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + void toggleTransport(); +#endif void cycleTextSize(); bool togglePowerSave(); bool getGPSState(); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp index a689c830b..634431b23 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp @@ -1,6 +1,7 @@ #include "SettingsScreen.h" #include "helpers.h" +#include "CompanionWiFi.h" #include @@ -68,6 +69,12 @@ void SettingsScreen::renderItem(CardputerDisplay &lcd, SettingsItem item, int x, case SettingsItem::DeviceBluetooth: snprintf(tmp, sizeof(tmp), "Bluetooth: %s", _task->isBluetoothEnabled() ? "ON" : "OFF"); break; +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + case SettingsItem::DeviceTransport: + snprintf(tmp, sizeof(tmp), "Transport: %s", getCompanionTransportMode() == CompanionTransportMode::WiFi + ? "WiFi" : "Bluetooth"); + break; +#endif case SettingsItem::DevicePowersave: snprintf(tmp, sizeof(tmp), "Power save: %s", _task->powerSaveEnabled() ? "ON" : "OFF"); @@ -119,6 +126,12 @@ bool SettingsScreen::enterItemEdit(SettingsItem item) { _task->toggleBluetooth(); return true; +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + case SettingsItem::DeviceTransport: + _task->toggleTransport(); + return true; +#endif + case SettingsItem::DevicePowersave: _task->togglePowerSave(); return true; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h index f81ce828c..925e9a163 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h @@ -16,6 +16,9 @@ class SettingsScreen : public CardputerScreen { DeviceTextSize, DeviceBeep, DeviceBluetooth, +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + DeviceTransport, +#endif DeviceGps, DevicePowersave, DeviceBatteryCorrection, diff --git a/variants/m5stack_cardputer_adv/hw/CardputerLayout.cpp b/variants/m5stack_cardputer_adv/hw/CardputerLayout.cpp index dccb85be4..bd1f53aee 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerLayout.cpp +++ b/variants/m5stack_cardputer_adv/hw/CardputerLayout.cpp @@ -4,7 +4,8 @@ using namespace Keyboard::Layout; -static LayoutLayer default_layout_layers[2] = { +// The built-in map is immutable; keep its 450 bytes in flash instead of DRAM. +static const LayoutLayer default_layout_layers[2] = { { (Keyboard::Layout::LAYER_MASK_BASE), { "`", "", "", "", "1", "q", "", "", "2", "w", "a", "", "3", "e", "s", "z", "4", "r", "d", "x", "5", "t", "f", "c", "6", "y", "g", "v", "7", "u", "h", "b", "8", "i", "j", "n", "9", "o", @@ -170,8 +171,9 @@ void CardputerLayout::parseLayout(FS &fs, LayoutType layoutType) { LayoutData* layout = new (std::nothrow) LayoutData(); if (!layout) { layout_file.close(); return; } - layout->layers = new (std::nothrow) LayoutLayer[layer_count](); - if (!layout->layers) { delete layout; layout_file.close(); return; } + LayoutLayer* layers = new (std::nothrow) LayoutLayer[layer_count](); + if (!layers) { delete layout; layout_file.close(); return; } + layout->layers = layers; layout->layers_count = 0; int current_layer_index = -1; remaining = file_bytes; @@ -186,12 +188,12 @@ void CardputerLayout::parseLayout(FS &fs, LayoutType layoutType) { } else if (readLayerMask(line, mask)) { current_layer_index = 0; while (current_layer_index < layout->layers_count - && layout->layers[current_layer_index].modifiers_mask != mask) ++current_layer_index; + && layers[current_layer_index].modifiers_mask != mask) ++current_layer_index; if (current_layer_index == layout->layers_count) { // The file may change or fail between passes. Never grow past the // counted allocation or publish a partial replacement. if (layout->layers_count == layer_count) { valid = false; break; } - layout->layers[current_layer_index].modifiers_mask = mask; + layers[current_layer_index].modifiers_mask = mask; ++layout->layers_count; } } else if (line[0] == 'K' && length >= 5 && line[4] == '=' @@ -200,7 +202,7 @@ void CardputerLayout::parseLayout(FS &fs, LayoutType layoutType) { && line[3] >= '0' && line[3] <= '9') ? (line[2] - '0') * 10 + line[3] - '0' : 0; if (key_id >= 1 && key_id <= TOTAL_KEYS) { - char* dest = layout->layers[current_layer_index].entries[key_id - 1].data; + char* dest = layers[current_layer_index].entries[key_id - 1].data; memset(dest, 0, KB_LAYOUT_CHAR_MAX + 1); memcpy(dest, line + 5, length - 5 < KB_LAYOUT_CHAR_MAX ? length - 5 : KB_LAYOUT_CHAR_MAX); } diff --git a/variants/m5stack_cardputer_adv/hw/CardputerLayout.h b/variants/m5stack_cardputer_adv/hw/CardputerLayout.h index c12ba9bd6..d8c653fda 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerLayout.h +++ b/variants/m5stack_cardputer_adv/hw/CardputerLayout.h @@ -34,7 +34,7 @@ struct LayoutLayer { struct LayoutData { char language_code[3]; uint8_t layers_count; - LayoutLayer *layers; + const LayoutLayer *layers; }; class CardputerLayout { diff --git a/variants/m5stack_cardputer_adv/platformio.ini b/variants/m5stack_cardputer_adv/platformio.ini index bfef60ef8..9d5063dbd 100644 --- a/variants/m5stack_cardputer_adv/platformio.ini +++ b/variants/m5stack_cardputer_adv/platformio.ini @@ -123,3 +123,21 @@ extends = M5Stack_Cardputer_Adv_ui build_flags = ${M5Stack_Cardputer_Adv_ui.build_flags} -D BLE_PIN_CODE=123456 + +; Canonical USB/BLE/WiFi image. Keep the older recipes for recovery builds. +; Use the existing authenticated NimBLE adapter with the same saved-phone limit. +[env:M5Stack_Cardputer_Adv_companion_radio_full] +extends = env:M5Stack_Cardputer_Adv_companion_radio_ble +build_flags = + ${env:M5Stack_Cardputer_Adv_companion_radio_ble.build_flags} + -D MESHCORE_REQUIRES_COMPANION_RADIO_FULL=1 + -D MESH_USE_NIMBLE_ARDUINO=1 + -D CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 + -D CONFIG_BT_NIMBLE_MAX_BONDS=15 + -D CONFIG_BT_NIMBLE_MAX_CCCDS=30 + -D CONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED=1 + -D CONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED=1 +lib_deps = + ${env:M5Stack_Cardputer_Adv_companion_radio_ble.lib_deps} + h2zero/NimBLE-Arduino @ 2.5.1 +lib_ignore = ESP32 BLE Arduino diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 6d403941f..f16e0c26a 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -67,5 +67,7 @@ debug_tool=stlink [env:WioTrackerL1Eink_kiss_modem] extends = WioTrackerL1Eink +build_flags = ${WioTrackerL1Eink.build_flags} + -D MESHCORE_HEADLESS_KISS_COMPAT=1 build_src_filter = ${WioTrackerL1Eink.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/wio-tracker-l1/HeadlessKissPins.h b/variants/wio-tracker-l1/HeadlessKissPins.h new file mode 100644 index 000000000..de352868f --- /dev/null +++ b/variants/wio-tracker-l1/HeadlessKissPins.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +inline void parkWioL1KissDisplayPins() { + // OLED, e-paper and Lite share this board. The modem uses neither display; + // keep optional e-paper reset asserted and its separate SPI bus deselected. + digitalWrite(PIN_DISPLAY_CS, HIGH); + pinMode(PIN_DISPLAY_CS, OUTPUT); + digitalWrite(PIN_DISPLAY_RST, LOW); + pinMode(PIN_DISPLAY_RST, OUTPUT); +} diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.cpp b/variants/wio-tracker-l1/WioTrackerL1Board.cpp index 4153714e9..e6cc5b986 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.cpp +++ b/variants/wio-tracker-l1/WioTrackerL1Board.cpp @@ -3,8 +3,19 @@ #include "WioTrackerL1Board.h" +#if MESHCORE_HEADLESS_KISS_COMPAT + #include "HeadlessKissPins.h" + extern const char meshcore_capability_modem_headless_wio_l1[] = + "meshcore.capability.modem.headless.wio_l1.v1"; +#endif + void WioTrackerL1Board::begin() { NRF52BoardDCDC::begin(); +#if MESHCORE_HEADLESS_KISS_COMPAT + const volatile char* capability = meshcore_capability_modem_headless_wio_l1; + (void)*capability; + parkWioL1KissDisplayPins(); +#endif btn_prev_state = HIGH; pinMode(PIN_VBAT_READ, INPUT); // VBAT ADC input diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 569e7b64a..011fcd1fc 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -40,7 +40,9 @@ public: const char* getManufacturerName() const override { // Keep the physical display/power variants distinguishable to read-only // USB clients. Older releases used the same name for Pro, E-Ink and 1W. -#if defined(WIO_TRACKER_L1_EINK) +#if MESHCORE_HEADLESS_KISS_COMPAT || WIO_L1_DISPLAY_UNIFIED + return "Seeed Wio Tracker L1"; +#elif defined(WIO_TRACKER_L1_EINK) return "Seeed Wio Tracker L1 E-Ink"; #else return "Seeed Wio Tracker L1 Pro"; diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index f67b22834..d122f0a7a 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -137,9 +137,16 @@ build_flags = ${WioTrackerL1.build_flags} -D BLE_PIN_CODE=123456 -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 - -D DISPLAY_CLASS=SH1106Display + -D DISPLAY_CLASS=WioL1Display + -D WIO_L1_DISPLAY_UNIFIED=1 + -D EINK_DISPLAY_MODEL=GxEPD2_213_B74 + -D DISPLAY_ROTATION=1 + -D DISABLE_DIAGNOSTIC_OUTPUT + -D EINK_MAX_PARTIAL_REFRESH=10 + -D UI_SMALL_MESSAGE_FONT=1 + -D UI_RECENT_LIST_SIZE=6 + -D UI_SENSORS_PAGE=1 -D UI_HAS_JOYSTICK=1 - -D UI_NO_HIBERNATE -D PIN_BUZZER=12 -D QSPIFLASH=1 -D ADVERT_NAME='"@@MAC"' @@ -149,16 +156,24 @@ build_flags = ${WioTrackerL1.build_flags} ; -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = ${WioTrackerL1.lib_deps} + zinggjm/GxEPD2 @ 1.6.2 + bakercp/CRC32 @ ^2.0.0 adafruit/RTClib @ ^2.1.3 densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 [env:WioTrackerL1_kiss_modem] extends = WioTrackerL1 +build_flags = ${WioTrackerL1.build_flags} + -D MESHCORE_HEADLESS_KISS_COMPAT=1 + -D ENV_PIN_SDA=PIN_WIRE1_SDA + -D ENV_PIN_SCL=PIN_WIRE1_SCL build_src_filter = ${WioTrackerL1.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/wio-tracker-l1/target.cpp b/variants/wio-tracker-l1/target.cpp index 5bfc5693c..edb79c552 100644 --- a/variants/wio-tracker-l1/target.cpp +++ b/variants/wio-tracker-l1/target.cpp @@ -33,6 +33,11 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); #endif bool radio_init() { +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + // The pinned nRF52 Wire driver has no event timeout. A failed OLED bus + // must not stall radio startup during the optional RTC address probes. + if (digitalRead(PIN_WIRE_SDA) == HIGH && digitalRead(PIN_WIRE_SCL) == HIGH) +#endif rtc_clock.begin(Wire); return radio.std_init(&SPI); diff --git a/variants/wio-tracker-l1/target.h b/variants/wio-tracker-l1/target.h index 05bc7ff1c..f8ef05050 100644 --- a/variants/wio-tracker-l1/target.h +++ b/variants/wio-tracker-l1/target.h @@ -9,7 +9,9 @@ #include #include #ifdef DISPLAY_CLASS - #if defined(WIO_TRACKER_L1_EINK) + #if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + #include + #elif defined(WIO_TRACKER_L1_EINK) #include #else #include