From 7791f3f8c9e5422da32089b920c9b1985045fa44 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 31 Aug 2026 15:11:11 -0700 Subject: [PATCH] Improve Indicator transport touch UI --- examples/companion_radio/ui-new/UITask.cpp | 50 +++++++++++----- src/helpers/ui/CompanionHomeLayout.h | 25 +++++++- .../ui/CompanionTransportSelectorLayout.h | 8 +-- src/helpers/ui/TouchInput.h | 10 +++- test/README.md | 2 +- test/test_companion_transport_selector.py | 59 +++++++++++++++---- .../test_display_driver.cpp | 30 ++++++++++ test/test_indicator_display_profile.py | 29 ++++++++- test/test_touch_input/test_touch_input.cpp | 26 ++++++++ variants/sensecap_indicator-espnow/README.md | 11 ++++ 10 files changed, 212 insertions(+), 38 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 237eb918..968df68f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -86,16 +86,17 @@ static void drawCompanionTransportChoice(DisplayDriver& display, } const bool large_transport_text = display.height() >= 96; - display.setTextSize(large_transport_text ? 2 : 1); + display.setTextSize(large_transport_text ? 3 : 1); const bool show_status_label = height >= 44; const int label_y = show_status_label - ? y + (large_transport_text ? 9 : 12) + ? y + (large_transport_text ? 17 : 12) : y + (height - 8) / 2; display.drawTextCentered(x + width / 2, label_y, label); if (show_status_label && (active || selected)) { + display.setTextSize(large_transport_text ? 2 : 1); display.drawTextCentered( x + width / 2, - y + height - (large_transport_text ? 23 : 17), + y + height - (large_transport_text ? 25 : 17), active ? "ACTIVE" : (large_transport_text ? "NEXT" : "NEXT BOOT")); } @@ -375,13 +376,27 @@ public: if (_page == HomePage::FIRST) { display.setColor(UIColor::primary_txt); +#ifdef UI_DEDICATED_PAIRING_BLOCK + const bool expanded_home = + mesh::ui::usesExpandedCompanionHomeTypography( + display.width(), display.height(), + display.renderWidth(), display.renderHeight()); + sprintf(tmp, expanded_home ? "INBOX %d" : "INBOX: %d", + _task->getPreviewCount()); + display.setTextSize(expanded_home ? 3 : 2); + if (expanded_home && display.getTextWidth(tmp) > display.width() - 4) { + display.setTextSize(2); + } +#else display.setTextSize(2); sprintf(tmp, "INBOX: %d", _task->getPreviewCount()); +#endif display.drawTextCentered(display.width() / 2, 22, tmp); #ifdef UI_DEDICATED_PAIRING_BLOCK const mesh::ui::CompanionHomeLayout layout = mesh::ui::makeLargeCompanionHomeLayout(display.width(), - display.height()); + display.height(), + expanded_home); // These are distinct repaint regions. In particular, removing or // replacing a pairing PIN cannot leave old glyphs behind, and long @@ -390,10 +405,11 @@ public: mesh::ui::clearDisplayRegion(display, layout.info); mesh::ui::clearDisplayRegion(display, layout.pairing); - display.setTextSize(1); + display.setTextSize(expanded_home ? 2 : 1); display.setColor(UIColor::secondary_txt); mesh::ui::drawTextCenteredEllipsized( - display, layout.info, layout.instruction_y, "tap center: inbox"); + display, layout.info, layout.instruction_y, + expanded_home ? "tap inbox" : "tap center: inbox"); #ifdef WIFI_SSID if (isCompanionWiFiEnabled()) { @@ -403,6 +419,10 @@ public: } else { strcpy(tmp, "WiFi: OFF"); } + display.setTextSize(expanded_home ? 2 : 1); + if (display.getTextWidth(tmp) > layout.info.width) { + display.setTextSize(1); + } mesh::ui::drawTextCenteredEllipsized( display, layout.info, layout.network_y, tmp); #endif @@ -415,11 +435,11 @@ public: int pairing_value_size = 2; char pairing_pin[16]; if (bluetooth_connected) { - pairing_label = "BLUETOOTH"; + pairing_label = expanded_home ? "BLE" : "BLUETOOTH"; pairing_value = "CONNECTED"; } else if (mesh::ui::shouldDisplayBluetoothPairingPin( bluetooth_enabled, bluetooth_connected, bluetooth_pin)) { - pairing_label = "BLUETOOTH PIN"; + pairing_label = expanded_home ? "BLE PIN" : "BLUETOOTH PIN"; snprintf(pairing_pin, sizeof(pairing_pin), "%06u", (unsigned int)bluetooth_pin); pairing_value = pairing_pin; @@ -430,7 +450,7 @@ public: display.setColor(UIColor::title_bkg); mesh::ui::clearDisplayRegion(display, layout.pairing); display.setColor(UIColor::title_txt); - display.setTextSize(1); + display.setTextSize(expanded_home ? 2 : 1); mesh::ui::drawTextCenteredEllipsized( display, layout.pairing, layout.pairing_label_y, pairing_label); display.setTextSize(pairing_value_size); @@ -591,10 +611,10 @@ public: mesh::ui::makeCompanionTransportSelectorLayout( display.width(), display.height()); - // Size 2 is already a substantial increase. Keep this page independent - // of the additional native-480 text boost so ACTIVE still fits inside - // either half-screen choice; the compact status strip above was drawn - // before this point and is unaffected. + // Keep this page independent of the additional native-480 text boost: + // the size-3 choice names and size-2 status already consume nearly the + // full half-screen width, and should remain equally readable in the + // memory-saving 320 profile. display.setCompactText(true); if (layout.show_title) { display.setColor(UIColor::primary_txt); @@ -605,14 +625,14 @@ public: drawCompanionTransportChoice( display, layout.wifi.x, layout.wifi.y, layout.wifi.width, layout.wifi.height, - "Wi-Fi", wifi_active, wifi_selected); + "WiFi", wifi_active, wifi_selected); drawCompanionTransportChoice( display, layout.bluetooth.x, layout.bluetooth.y, layout.bluetooth.width, layout.bluetooth.height, "BLE", !wifi_active, !wifi_selected); display.setColor(UIColor::secondary_txt); - display.setTextSize(display.height() >= 96 ? 2 : 1); + display.setTextSize(1); #ifdef HAS_TOUCH display.drawTextCentered( display.width() / 2, layout.prompt_y, "tap a box"); diff --git a/src/helpers/ui/CompanionHomeLayout.h b/src/helpers/ui/CompanionHomeLayout.h index d8b0e823..9a2e4e57 100644 --- a/src/helpers/ui/CompanionHomeLayout.h +++ b/src/helpers/ui/CompanionHomeLayout.h @@ -30,6 +30,17 @@ struct CompactCompanionPairingLayout { int pairing_value_y; }; +// Full-size Indicator builds keep the established 160x160 logical coordinate +// system even when they obtain a native 480x480 render buffer. Expose that +// distinction to the home page so it can spend the extra pixel density on +// larger, deliberately reflowed text without changing the 320 BLE profile or +// unrelated displays that happen to have a tall viewport. +inline bool usesExpandedCompanionHomeTypography( + int width, int height, int render_width, int render_height) { + return width == 160 && height == 160 + && render_width == 480 && render_height == 480; +} + // The common Companion OLED/TFT viewport is 128x64. Its normal instruction // and network rows occupy the same lower area needed by the Bluetooth status, // so an active PIN/connection replaces those rows with this compact block. @@ -56,7 +67,19 @@ inline CompactCompanionPairingLayout makeCompactCompanionPairingLayout( // fifths of the display height. The Indicator renders a 160x160 logical UI, // for which this produces a 48-pixel-tall block centered at y=128. inline CompanionHomeLayout makeLargeCompanionHomeLayout(int width, - int height) { + int height, + bool expanded = false) { + if (expanded && width == 160 && height == 160) { + return { + {2, 51, 156, 46}, + {8, 100, 144, 58}, + 52, + 76, + 102, + 126, + }; + } + // Ordinary one-line status needs almost the full logical width when the // Indicator's recovered mono font is active. The PIN block gets the larger // inset so it remains visibly distinct from the page background. diff --git a/src/helpers/ui/CompanionTransportSelectorLayout.h b/src/helpers/ui/CompanionTransportSelectorLayout.h index 23d4fa0f..57e8f5ed 100644 --- a/src/helpers/ui/CompanionTransportSelectorLayout.h +++ b/src/helpers/ui/CompanionTransportSelectorLayout.h @@ -23,10 +23,10 @@ inline CompanionTransportSelectorLayout makeCompanionTransportSelectorLayout( const bool tall_display = height >= 96; const int margin = 2; const int gap = 4; - const int box_y = tall_display ? 40 : 20; - const int prompt_y = height - (tall_display ? 22 : 11); - int box_height = prompt_y - box_y - 8; - if (box_height > 72) box_height = 72; + const int box_y = tall_display ? 36 : 20; + const int prompt_y = height - (tall_display ? 20 : 11); + int box_height = prompt_y - box_y - (tall_display ? 6 : 8); + if (box_height > 98) box_height = 98; if (box_height < 20) box_height = 20; const int box_width = (width - margin * 2 - gap) / 2; const int bluetooth_x = margin + box_width + gap; diff --git a/src/helpers/ui/TouchInput.h b/src/helpers/ui/TouchInput.h index ff3bca6a..6d24136f 100644 --- a/src/helpers/ui/TouchInput.h +++ b/src/helpers/ui/TouchInput.h @@ -89,9 +89,13 @@ public: _active = false; _release_samples = 0; - // A contact seen only once has no measurable direction. Ignoring it is - // safer than treating the endpoint of a fast swipe as an opposite tap. - if (_touch_samples < 2 || width <= 0 || height <= 0) { + // A contact seen only once has no measurable direction. Keep ignoring it + // on ordinary pages so the endpoint of a fast swipe cannot become an + // opposite tap. An explicit split selector is different: its bounded + // boxes are unambiguous stationary targets, and rejecting a quick contact + // makes a normal tap disappear when it lands within one polling interval. + if (_touch_samples == 0 || width <= 0 || height <= 0 + || (_touch_samples < 2 && split_selector == nullptr)) { return TouchAction::None; } diff --git a/test/README.md b/test/README.md index 5b66fad4..9bd6dd0d 100644 --- a/test/README.md +++ b/test/README.md @@ -53,7 +53,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation | | `test_mqtt_prefs_json_import` | `src/helpers/MQTTPrefsJsonImport.h`, `src/helpers/MQTTPrefsSerializer.h` | one-time observer `/mqtt.json` v1 import grammar, strict schema/repair rules, binary-first precedence, future/artifact preservation, display-tail encoding, and atomic commit failure routing | | `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | portrait logical-to-physical mapping, span coverage, fitted-width conversion, text scaling, and visible-frame signatures | -| `test_display_driver` | `src/helpers/ui/DisplayDriver.h`, `DisplayTextLayout.h`, `CompanionHomeLayout.h` | UTF-8-safe ellipsis/wrapping; maximum SSID and long setup-address bounds; Indicator home info/pairing separation and 4/5-height PIN placement; compact 128x64 pairing replacement; stale-region clearing | +| `test_display_driver` | `src/helpers/ui/DisplayDriver.h`, `DisplayTextLayout.h`, `CompanionHomeLayout.h` | UTF-8-safe ellipsis/wrapping; maximum SSID and long setup-address bounds; Indicator 320/native-480 home info/pairing separation and enlarged-text placement; compact 128x64 pairing replacement; stale-region clearing | | `test_indicator_font_stage_v2_protocol` | `src/helpers/IndicatorFontStageV2Protocol.h` | fail-closed STAGEV2 negotiation and legacy fallback; exact cumulative ACK parsing; 512-byte block boundaries, short final blocks, and real font-asset transfer geometry | | `test_radio_activity_window` | `src/helpers/RadioActivityWindow.h` | rolling minute buckets, derived rates, expiry, warm-up, silence, saturation, and `millis()` rollover | | `test_observer_dashboard` | `src/helpers/ui/ObserverDashboard.h` | landscape and portrait dashboard layout bounds, compact formatting, graph scaling, row signatures, and partial repaint policy | diff --git a/test/test_companion_transport_selector.py b/test/test_companion_transport_selector.py index 67282c37..3e738db6 100644 --- a/test/test_companion_transport_selector.py +++ b/test/test_companion_transport_selector.py @@ -20,8 +20,8 @@ int main() { if (!layout.show_title) return 1; if (layout.wifi.x != 2 || layout.wifi.width != 76) return 2; if (layout.bluetooth.x != 82 || layout.bluetooth.width != 76) return 3; - if (layout.wifi.y != 40 || layout.wifi.height != 72) return 4; - if (layout.title_y != 17 || layout.prompt_y != 138) return 5; + if (layout.wifi.y != 36 || layout.wifi.height != 98) return 4; + if (layout.title_y != 17 || layout.prompt_y != 140) return 5; const auto small = mesh::ui::makeCompanionTransportSelectorLayout(128, 64); if (small.show_title) return 6; @@ -56,15 +56,21 @@ int main() { ) self.assertEqual(run_result.returncode, 0, run_result.stderr) - logical_glyph_width = 6 * 2 - logical_glyph_height = 8 * 2 - for text in ("Wi-Fi", "BLE", "ACTIVE", "NEXT"): - self.assertLessEqual(len(text) * logical_glyph_width, 76) + choice_glyph_width = 6 * 3 + choice_glyph_height = 8 * 3 + for text in ("WiFi", "BLE"): + self.assertLessEqual(len(text) * choice_glyph_width, 76) + status_glyph_width = 6 * 2 + status_glyph_height = 8 * 2 + for text in ("ACTIVE", "NEXT"): + self.assertLessEqual(len(text) * status_glyph_width, 76) + title_glyph_width = 6 * 2 for text in ("TRANSPORT", "tap a box"): - self.assertLessEqual(len(text) * logical_glyph_width, 160) - self.assertLessEqual(17 + logical_glyph_height, 40) - self.assertLessEqual(89 + logical_glyph_height, 112) - self.assertLessEqual(138 + logical_glyph_height, 160) + self.assertLessEqual(len(text) * title_glyph_width, 160) + self.assertLessEqual(17 + 8 * 2, 36) + self.assertLessEqual(53 + choice_glyph_height, 134) + self.assertLessEqual(109 + status_glyph_height, 134) + self.assertLessEqual(140 + 8, 160) def test_touch_split_selector_distinguishes_taps_from_swipes(self): source = r''' @@ -113,6 +119,35 @@ int main() { input.update(true, 130, 68, 160, 160, false, &selector); if (release(input, &selector) != TouchAction::Next) return 6; + // A quick selector tap may occupy only one 25 ms poll. Its bounded target + // remains unambiguous after the normal two-sample release debounce. + input.update(true, 30, 120, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::SelectLeft) return 7; + + input.update(true, 130, 120, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::SelectRight) return 8; + + // The gap, title, and exclusive bottom edge remain inert even for a quick + // contact, while the last pixel inside the taller box is selectable. + input.update(true, 80, 120, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::None) return 9; + + input.update(true, 30, layout.wifi.y - 1, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::None) return 10; + + input.update(true, 30, layout.wifi.y + layout.wifi.height, + 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::None) return 11; + + input.update(true, 30, layout.wifi.y + layout.wifi.height - 1, + 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::SelectLeft) return 12; + + // Ordinary pages keep the two-sample protection against a detached swipe + // endpoint being interpreted as a tap. + input.update(true, 30, 70, 160, 160, false, nullptr); + if (release(input, nullptr) != TouchAction::None) return 13; + return 0; } ''' @@ -144,11 +179,11 @@ int main() { source = UI.read_text(encoding="utf-8") self.assertIn("#ifdef COMPANION_EXCLUSIVE_WIFI_BLE", source) self.assertIn("TRANSPORT,", source) - self.assertIn('"Wi-Fi", wifi_active, wifi_selected', source) + self.assertIn('"WiFi", wifi_active, wifi_selected', source) self.assertIn('"BLE", !wifi_active, !wifi_selected', source) self.assertIn("display.fillRect(x, y, width, height);", source) self.assertIn('"ACTIVE"', source) - self.assertIn('large_transport_text ? 2 : 1', source) + self.assertIn('large_transport_text ? 3 : 1', source) self.assertIn("const bool show_status_label = height >= 44;", source) self.assertIn('active ? "ACTIVE"', source) self.assertIn('large_transport_text ? "NEXT" : "NEXT BOOT"', source) diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index 15d297df..bc3e5797 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -181,6 +181,36 @@ TEST(DisplayDriver, IndicatorHomeSeparatesInfoAndPairingGeometry) { layout.pairing, layout.pairing_value_y, 24)); } +TEST(DisplayDriver, NativeIndicatorExpandsHomeTypographyRegions) { + EXPECT_TRUE(mesh::ui::usesExpandedCompanionHomeTypography( + 160, 160, 480, 480)); + EXPECT_FALSE(mesh::ui::usesExpandedCompanionHomeTypography( + 160, 160, 320, 320)); + EXPECT_FALSE(mesh::ui::usesExpandedCompanionHomeTypography( + 128, 64, 480, 480)); + + const mesh::ui::CompanionHomeLayout layout = + mesh::ui::makeLargeCompanionHomeLayout(160, 160, true); + EXPECT_EQ(2, layout.info.x); + EXPECT_EQ(51, layout.info.y); + EXPECT_EQ(156, layout.info.width); + EXPECT_EQ(46, layout.info.height); + EXPECT_EQ(8, layout.pairing.x); + EXPECT_EQ(100, layout.pairing.y); + EXPECT_EQ(144, layout.pairing.width); + EXPECT_EQ(58, layout.pairing.height); + EXPECT_FALSE(mesh::ui::displayRegionsOverlap(layout.info, + layout.pairing)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.info, layout.instruction_y, 20)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.info, layout.network_y, 20)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.pairing, layout.pairing_label_y, 20)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.pairing, layout.pairing_value_y, 30)); +} + TEST(DisplayDriver, IndicatorPairingValuesFitAtRenderedTextSizes) { // Model the fallback font's fixed six-logical-pixel cell. It is the wider // of the two paths for CONNECTED; the profile test reads the recovered diff --git a/test/test_indicator_display_profile.py b/test/test_indicator_display_profile.py index 9ff48986..ba11862f 100644 --- a/test/test_indicator_display_profile.py +++ b/test/test_indicator_display_profile.py @@ -103,8 +103,13 @@ class IndicatorDisplayProfileTest(unittest.TestCase): requested_size * coordinate_scale / native_scale * profile_scale ) - width = sum(advances[ord(char)] for char in text) - height = max(heights[ord(char)] for char in text) + # Blank glyphs carry no bitmap record in the packed VLW. The + # checked-in font is monospaced with the documented 18px cell, so + # spaces still advance by one complete cell. + width = sum( + 18 if char == " " else advances[ord(char)] for char in text + ) + height = max(heights[ord(char)] for char in text if char != " ") return ( int((width * render_scale + coordinate_scale - 1) // coordinate_scale), @@ -129,6 +134,26 @@ class IndicatorDisplayProfileTest(unittest.TestCase): self.assertLessEqual(width, 144) self.assertLessEqual(height, 30) + # Native 480 reflows the spacious home page instead of globally + # enlarging every dense UI row. Its prominent strings fit at their + # requested sizes, while a two-digit inbox count takes the deliberate + # size-2 fallback. + native_title = dimensions("INBOX 0", 3, 3, 1.2) + native_two_digit_title = dimensions("INBOX 10", 3, 3, 1.2) + native_two_digit_fallback = dimensions("INBOX 10", 2, 3, 1.2) + self.assertLessEqual(native_title[0], 156) + self.assertGreater(native_two_digit_title[0], 156) + self.assertLessEqual(native_two_digit_fallback[0], 156) + + for text, region_width in ( + ("tap inbox", 156), + ("WiFi: OFF", 156), + ("BLE PIN", 144), + ): + width, height = dimensions(text, 2, 3, 1.2) + self.assertLessEqual(width, region_width) + self.assertLessEqual(height, 20) + if __name__ == "__main__": unittest.main() diff --git a/test/test_touch_input/test_touch_input.cpp b/test/test_touch_input/test_touch_input.cpp index 0fc285a2..fdb59d94 100644 --- a/test/test_touch_input/test_touch_input.cpp +++ b/test/test_touch_input/test_touch_input.cpp @@ -153,6 +153,32 @@ TEST(TouchInput, SplitSelectorMapsTapHalvesToExplicitChoices) { TouchAction::None); } +TEST(TouchInput, SplitSelectorAcceptsQuickBoundedTapsOnly) { + TouchInput input(true, true, 70); + const TouchSplitSelector selector{2, 76, 82, 76, 36, 98}; + + input.update(true, 30, 100, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::SelectLeft); + + input.update(true, 130, 100, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::SelectRight); + + input.update(true, 80, 100, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + + input.update(true, 30, 100, 160, 160); + EXPECT_EQ(release(input, 160, 160), TouchAction::None); +} + TEST(TouchInput, SplitSelectorKeepsSwipesAsNavigation) { TouchInput input(true, true, 70); const TouchSplitSelector selector{8, 68, 84, 68, 38, 72}; diff --git a/variants/sensecap_indicator-espnow/README.md b/variants/sensecap_indicator-espnow/README.md index a2ab648c..efbf4b93 100644 --- a/variants/sensecap_indicator-espnow/README.md +++ b/variants/sensecap_indicator-espnow/README.md @@ -16,6 +16,10 @@ wakes it without selecting anything. side performs Previous or Next; horizontal swipes remain available anywhere. - A gesture is committed after a stable release. Brief missing touch samples are ignored so one swipe cannot become a second, opposite endpoint tap. +- The transport page is the exception for a very quick stationary contact: + either tall, outlined WiFi/BLE box can accept one sampled point after the + same stable-release debounce. Taps in the title, center gap, or footer stay + inert, and a moving contact is still handled as a swipe first. ## Reading messages @@ -111,6 +115,13 @@ Bluetooth controller and host memory. BLE mode does not start infrastructure WiFi, WebConfig, MQTT, OTA networking, or TCP Companion services. USB remains available in both modes for recovery. +The Indicator transport page presents WiFi and BLE as full-height side-by-side +choices. Tapping either choice saves it and reboots only when it differs from +the active mode. Its size-3 choice labels render at the same physical size in +the 320 and 480 profiles. On a retained native 480 canvas, the first home page +also reflows its inbox heading and short status labels at larger sizes; long IP +addresses automatically fall back to the smaller row size instead of clipping. + LoRa remains the primary radio in both LoRa modes. On the ESP-NOW layout, selecting BLE leaves the primary ESP-NOW WiFi radio and its fixed channel running; it disables only infrastructure WiFi. Rendering follows the memory