diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index 7c982d18b..9b4b9a639 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -66,6 +66,28 @@ jobs:
- name: Compile optional RAK15002 SD archive (no hardware qualification)
run: pio run -e RAK_4631_repeater_rak15002_sd_seeder_only
+ optional-display-smoke:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Build Environment
+ uses: ./.github/actions/setup-build-environment
+ - name: Qualify RC32 Full Companion with optional T108 detection
+ run: >-
+ bash build.sh build-firmware heltec_rc32_companion_radio_full
+ --firmware-version v1.18.0.1-halo-keymind-cascade-dev
+ --radio-preset usa-cascade-fixed --profile cascade --require-ota
+ - name: Qualify Wireless Tracker V1 with optional screen detection
+ run: >-
+ bash build.sh build-firmware Heltec_Wireless_Tracker_repeater
+ --firmware-version v1.18.0.1-halo-keymind-cascade-dev
+ --radio-preset usa-cascade-fixed --profile cascade --require-ota
+ - name: Qualify V4 R8 TFT Full Companion with optional screen detection
+ run: >-
+ bash build.sh build-firmware heltec_v4_r8_tft_companion_radio_full
+ --firmware-version v1.18.0.1-halo-keymind-cascade-dev
+ --radio-preset usa-cascade-fixed --profile cascade --require-ota
+
esp32-sensor-smoke:
runs-on: ubuntu-latest
steps:
@@ -798,7 +820,9 @@ jobs:
python3 -B test/test_firmware_picker_package.py
node test/test_firmware_picker.js
node test/test_t114_picker.js
+ node test/test_optional_display_picker.js
python3 -B test/test_t114_release_profiles.py -v
+ python3 -B test/test_optional_display_profiles.py -v
- name: Verify Full logging compiler flags and packaged capabilities
run: |
@@ -933,6 +957,9 @@ jobs:
python3 -B test/test_rgb_led_rollover.py -v
python3 -B test/test_display_power.py
python3 -B test/test_t114_display_detection.py -v
+ python3 -B test/test_rc32_display_detection.py -v
+ python3 -B test/test_wireless_tracker_display_detection.py -v
+ python3 -B test/test_heltec_r8_display_detection.py -v
python3 -B test/test_companion_headless_buttons.py -v
python3 -B test/test_st7735_display_wake_profile.py -v
python3 -B test/test_legacy_ui_version_bounds.py -v
diff --git a/build_legacy.sh b/build_legacy.sh
index 93c6dfca1..31d15a956 100755
--- a/build_legacy.sh
+++ b/build_legacy.sh
@@ -3551,6 +3551,14 @@ declare_build_capability_contract() {
fi
fi
+ if [ "$env_platform" = "ESP32_PLATFORM" ] && ! is_kiss_modem_target "$env_name"; then
+ local optional_display_marker
+ if [ -n "$PIO_CONFIG_JSON" ] \
+ && optional_display_marker=$(python3 scripts/optional_display_profiles.py "$pio_env_name" <<<"$PIO_CONFIG_JSON"); then
+ record_application_expectation "display.autodetect" "$optional_display_marker"
+ fi
+ fi
+
if [ "$env_platform" = "NRF52_PLATFORM" ] && ! is_kiss_modem_target "$env_name"; then
if is_t114_display_autodetect_build "$pio_env_name"; then
record_application_expectation "display.autodetect" \
diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js
index a8a9d9ea1..3794aa208 100644
--- a/docs/_javascript/firmware_picker.js
+++ b/docs/_javascript/firmware_picker.js
@@ -742,6 +742,22 @@
}));
}
+ function optionalAutoDisplayProfile(profile) {
+ const controls = profile && profile.controls;
+ const target = String(profile && profile.target || "").toLowerCase();
+ const controller = /^heltec_rc32_(?!without_display_)/.test(target) ? "nv3001b"
+ : /^heltec_(?:wireless_tracker|tracker_v1_1)_/.test(target) ? "st7735"
+ : /^heltec_v4_r8_tft_/.test(target) ? "st7789" : "";
+ return Boolean(controller && controls && controls.platform === "ESP32_PLATFORM" &&
+ controls.display === true && controls.displayAuto === true && controls.displayController === controller &&
+ /^[0-9a-f]{40}$/.test(controls.displaySource || "") &&
+ (!profile.controlsSource || controls.displaySource === profile.controlsSource) &&
+ Array.isArray(profile.files) && profile.files.length > 0 && profile.files.every(function (file) {
+ const hash = String(file.name || "").match(/-([0-9a-f]{8,40})(?:-merged)?\.(?:bin|hex|uf2|zip)$/i);
+ return hash && controls.displaySource.startsWith(hash[1].toLowerCase());
+ }));
+ }
+
function omitT114DisplayDuplicates(profiles) {
const families = new Map();
function role(profile) { return profile.role || parseTargetProfile(profile.target).role; }
@@ -783,6 +799,9 @@
}
if (String(value).toLowerCase() === "heltec_t114" && matches.length > 0 &&
matches.every(t114AutoDisplayProfile)) return "Heltec T114 (automatic screen detection)";
+ if (field === "hardware" && matches.length > 0 && matches.every(optionalAutoDisplayProfile)) {
+ return (family ? humanizeHardwareVariant(value, family) : humanizeHardware(value)) + " (automatic screen detection)";
+ }
return field === "hardware" && family
? humanizeHardwareVariant(value, family) : humanizeHardware(value);
}
@@ -1759,6 +1778,9 @@
notes.push("One T114 image detects the supported ST7789 screen at startup and also runs without a screen.");
notes.push("Switching from an older without_display image requires a local USB update; do not use a different target's LoRa OTA package. Keep your existing identity and settings; no erase is required.");
}
+ if (optionalAutoDisplayProfile(profile)) {
+ notes.push("This image detects the manufacturer's optional screen at startup and also runs without that screen. Power the board off before attaching or removing the display.");
+ }
if (identity.hardware === "Station_G3_ESP32" && stationG3SharedSlotProfile(profile)) {
notes.push("Automatically selects physical radio slot 1 or 2. Use this same image for either slot.");
}
@@ -2884,6 +2906,7 @@
omitLegacyObserverCompatibilityProfiles,
omitStationG3SlotDuplicates: omitStationG3SlotDuplicates,
t114AutoDisplayProfile: t114AutoDisplayProfile,
+ optionalAutoDisplayProfile: optionalAutoDisplayProfile,
omitT114DisplayDuplicates: omitT114DisplayDuplicates,
omitTransportsReplacedByDualCdcFull:
omitTransportsReplacedByFull,
diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md
index 667abf3e0..651335112 100644
--- a/docs/firmware_picker.md
+++ b/docs/firmware_picker.md
@@ -579,6 +579,9 @@ partition signature.
## Maintaining the runtime directions
+See [optional screens](optional_screens.md) for manufacturer-confirmed screen
+options, automatic detection support, and integral-screen exclusions.
+
The online picker and downloadable HTML use the same command renderer.
Hardware-specific controls are enabled only when `_data/firmware_controls.json`
matches the selected release family and exact target. If that metadata is
diff --git a/docs/optional_screens.md b/docs/optional_screens.md
new file mode 100644
index 000000000..fb22769a9
--- /dev/null
+++ b/docs/optional_screens.md
@@ -0,0 +1,88 @@
+
+
View this page on MeshCore Docs.
+
+
+# Optional screens
+
+Use the normal display-capable firmware for your exact board and role. When
+automatic detection is included, that image starts the supported screen if
+it responds, or continues headless if it is absent. There is no screen-presence
+setting. Power the board off before attaching or removing an optional screen.
+
+The [firmware picker](firmware_picker.md) describes automatic screen detection
+only when the selected application has been verified to contain it. Previously
+published firmware retains its original behavior. These additions become
+available as new firmware is built and qualified; changing the picker does not
+add detection to an older download.
+
+## Manufacturer-confirmed optional screens
+
+| Hardware | Manufacturer evidence | Firmware behavior |
+| --- | --- | --- |
+| Heltec T114 | [Heltec](https://heltec.org/project/mesh-node-t114/) offers the optional stock screen. | Automatic ST7789 detection; see the [T114 guide](heltec_t114.md). |
+| Heltec RC32 with T108 | The [T108](https://heltec.org/project/t108/) is a separate pin-header display module. The [manufacturer example](https://github.com/HelTecAutomation/RadioCore_Library/blob/45f20ff79f66d84022497040fda7cb7d0824639b/examples/NV3001B_Display/README.md) documents its pins and power controls. | New display-capable images detect the NV3001B panel. Legacy `without_display` targets remain headless compatibility builds. |
+| Heltec Wireless Tracker V1/V1.1 | The [manufacturer listing](https://old.heltec.org/project/wireless-tracker/) explicitly offers Screen Yes/No. | New display-capable images detect the ST7735 panel. Its absence releases only the display's power claim; GPS retains its own claim on the shared supply. |
+| Heltec V4 R8 Expansion Kit | The [manufacturer kit](https://heltec.org/project/v4-r8-ex/) offers front-panel configurations; its V2 expansion schematic connects the display's separate return line. | New R8 TFT images detect the panel through the documented MISO pin. Existing touch reset and backlight-only sleep/wake behavior are retained. |
+| Heltec V4/V4 R8 OLED | The [manufacturer listing](https://heltec.org/project/wifi-lora-32-v4/) offers OLED and No Display configurations. | Existing SSD1306 images already check whether the OLED responds. |
+| RAK WisBlock | The [RAK4631 kit guide](https://downloads.rakwireless.com/Meshtastic/Quick%20Start%20Guide_WisMesh%20RAK4631%20Starter%20Kit.pdf) labels the RAK1921 OLED optional. | Existing display-capable SSD1306 images already check presence. |
+| iKoka Stick and Handheld | The designer documents an [optional Stick OLED](https://github.com/ndoo/ikoka-stick-meshtastic-device#oled-display-optional) and [optional Handheld OLED](https://github.com/ndoo/ikoka-handheld-meshtastic-device#oled-display-optional). | Existing display-capable images already check presence. Handheld Companion supports the screen; currently headless infrastructure recipes remain headless. |
+| Original LilyGo T-Beam | [LilyGo](https://lilygo.cc/products/t-beam-v1-1-esp32-lora-module) offers soldered/non-soldered OLED configurations. | Existing display-capable SSD1306 images already check presence. |
+| XIAO ESP32-S3 with Wio-SX1262 | [Seeed](https://wiki.seeedstudio.com/wio_sx1262_with_xiao_esp32s3_kit/#recommended-accessories) lists the expansion board as an accessory for adding screens and sensors. | Existing display-capable Companion images already check presence. |
+
+The older V4 TFT expansion configuration is also optional, but its current
+recipe has no verified panel readback connection. Detection is not enabled for
+that wiring. A touchscreen acknowledgement alone does not establish that the
+LCD responds. The V4 R8 TFT and older V4 TFT pin mappings are distinct.
+
+The R8 readback route is documented in Heltec's
+[Expansion Kit V2.03 schematic](https://resource.heltec.cn/download/WiFi_LoRa_32_Expansion_Kit-V2/schematic/Expansion_board_V2.03.pdf).
+Tracker V1 wiring is documented in the
+[V1.1 schematic](https://resource.heltec.cn/download/Wireless_Tracker/Wireless_Tacker1.1/HTIT-Tracker_V0.5.pdf).
+
+Screen presence does not establish controller type, dimensions, physical
+orientation, or board revision. Continue choosing the exact hardware profile.
+Use USB when changing an old firmware target name; a different target's LoRa
+update package is not interchangeable. Saved identity and settings do not need
+to be erased for screen detection.
+
+## Integral screens
+
+Manufacturer documentation identifies built-in screens on the
+[LilyGo T-Echo Card](https://lilygo.cc/products/t-echo-card),
+[Heltec T190](https://wiki.heltec.org/docs/devices/open-source-hardware/esp32-series/vision-master/vison-master-t190/),
+[T1](https://heltec.org/project/mesh-node-t1/),
+[T096](https://heltec.org/project/t096/),
+[Wireless Tracker V2](https://heltec.org/project/wireless-tracker-v2/),
+[ThinkNode M9](https://www.elecrow.com/wiki/ThinkNode_M9_MeshCore_Communication_Terminal_with_Full_Keyboard.html)
+and [T-Deck](https://wiki.lilygo.cc/products/t-deck-series/t-deck/).
+No new optional-screen detection is enabled for these boards. In particular,
+the T190's optional module is its LoRa radio, not its screen.
+
+## Validation
+
+The new probes run before display SPI initialization and framebuffer
+allocation, with the backlight off. Bounded repeated reads under opposite
+pull directions reject floating, stuck and unstable return lines. An absent
+screen stays disabled until reboot; later drawing or wake requests cannot
+power it back up. Shared supplies retain other peripherals' ownership.
+
+Native tests cover controller replies, timing, pin turnaround, power ownership,
+headless behavior, rendering, sleep/wake, and exclusions for integral screens.
+Release checks require the detection marker in the uploadable application,
+the exact resolved board/driver flags and the matching source commit before
+the picker can advertise detection.
+
+| Check | Result |
+| --- | --- |
+| Native detection, rendering, power and headless regressions | PASS, 31 test methods, including 12 new detection methods with sanitizers and negative controls |
+| Recipe, release-proof, controls and offline-picker regressions | PASS, 41 test methods |
+| Resolved PlatformIO configuration | PASS, 31 optional-screen environments across the three hardware families; integral-screen recipes excluded |
+| Picker JavaScript regressions | PASS, 4 suites |
+| Complete build-profile selection suite | PASS |
+| Hosted documentation links | PASS, 7 test methods |
+| Packaged ESP32 firmware smoke builds | Pending |
+
+Physical verification on RC32, Tracker V1 and V4 R8 with and without their
+manufacturer-supported screen has not been performed. Hardware qualification
+requires cold USB/battery boots, screen sleep/wake, headless input, normal radio
+transmit/receive, and GPS or touch operation where fitted.
diff --git a/scripts/generate_picker_controls.py b/scripts/generate_picker_controls.py
index 0f5fa3836..d2595c1ca 100644
--- a/scripts/generate_picker_controls.py
+++ b/scripts/generate_picker_controls.py
@@ -12,7 +12,7 @@ from pathlib import Path
from package_esp32_partition_migration import BOARDS
from package_cascade_release import nrf52_sensor_profile
from station_g3_profiles import runtime_metadata as station_g3_metadata
-from t114_profiles import runtime_metadata as t114_metadata
+from optional_display_profiles import runtime_metadata as display_metadata
ROOT = Path(__file__).resolve().parents[1]
@@ -163,7 +163,7 @@ def generate(stage, config):
}
controls.update(runtime_metadata(manifest, controls['mqtt'], plan['source']))
controls.update(station_g3_metadata(manifest, defines, plan['source']))
- controls.update(t114_metadata(manifest, defines, plan['source']))
+ controls.update(display_metadata(manifest, defines, plan['source'], env.get('board')))
if manifest.get('ota_update_requirements'):
controls['updateRequirements'] = manifest['ota_update_requirements']
note = capacity_note(manifest.get('reductions', []), defines)
diff --git a/scripts/optional_display_profiles.py b/scripts/optional_display_profiles.py
new file mode 100644
index 000000000..d2b117fbc
--- /dev/null
+++ b/scripts/optional_display_profiles.py
@@ -0,0 +1,81 @@
+"""Bind optional-screen directions to the exact qualified application."""
+import json
+import re
+import sys
+
+from t114_profiles import runtime_metadata as t114_metadata
+
+CAPABILITY = "display.autodetect"
+SPECS = (
+ dict(flag="RC32_DISPLAY_AUTODETECT", board="heltec-rc32", guard="HELTEC_RC32",
+ driver="NV3001BDisplay", controller="nv3001b", marker="rc32",
+ targets=r"heltec_rc32_(?!without_display_).+"),
+ dict(flag="WIRELESS_TRACKER_DISPLAY_AUTODETECT", board="esp32-s3-devkitc-1",
+ guard="HELTEC_LORA_V3", driver="ST7735Display", controller="st7735", marker="tracker",
+ targets=r"heltec_wireless_tracker_.+"),
+ dict(flag="WIRELESS_TRACKER_DISPLAY_AUTODETECT", board="heltec_tracker_v1_1",
+ guard="HELTEC_TRACKER_V1_1", driver="ST7735Display", controller="st7735", marker="tracker",
+ targets=r"heltec_tracker_v1_1_.+"),
+ dict(flag="HELTEC_TFT_DISPLAY_AUTODETECT", board="heltec_v4_r8",
+ guard="HELTEC_V4_R8_TFT", driver="ST7789LCDDisplay", controller="st7789", marker="heltec_r8",
+ targets=r"heltec_v4_r8_tft_.+"),
+)
+
+
+def marker(spec):
+ return "meshcore.capability.display.autodetect." + spec["marker"] + ".v1"
+
+
+def spec_for_defines(defines, board=None):
+ for spec in SPECS:
+ if (defines.get(spec["flag"]) == "1"
+ and defines.get(spec["guard"], "0") not in ("0", "false")
+ and defines.get("DISPLAY_CLASS") == spec["driver"]
+ and (board is None or board == spec["board"])):
+ return spec
+ return None
+
+
+def runtime_metadata(manifest, defines, release_source=None, board=None):
+ if CAPABILITY not in (manifest.get("capabilities") or []):
+ return {}
+ if str(manifest.get("target", "")).lower().startswith("heltec_t114_"):
+ return t114_metadata(manifest, defines, release_source)
+ spec = spec_for_defines(defines, board)
+ if (not spec or board is None or manifest.get("verified") is not True
+ or manifest.get("platform") != "ESP32_PLATFORM"
+ or not re.fullmatch(spec["targets"], str(manifest.get("target", "")).lower())
+ or not any(row.get("capability") == CAPABILITY and row.get("present") is True
+ and row.get("source") == "packaged application"
+ and row.get("evidence") == marker(spec)
+ for row in manifest.get("verification") or [])):
+ raise ValueError("Optional display metadata requires exact board, flags and packaged detection proof")
+ source = manifest.get("source_commit", release_source)
+ if not isinstance(source, str) or not re.fullmatch(r"[0-9a-f]{40}", source):
+ raise ValueError("Optional display metadata requires an exact source commit")
+ if release_source is not None and source != release_source:
+ raise ValueError("Optional display metadata source must match its release")
+ return dict(displayAuto=True, displayController=spec["controller"], displaySource=source)
+
+
+def resolved_defines(flags):
+ result = {}
+ for flag in flags:
+ match = re.fullmatch(r"-([DU])\s*(\w+)(?:=(.*))?", str(flag).strip())
+ if match:
+ kind, key, value = match.groups()
+ if kind == "U":
+ result.pop(key, None)
+ else:
+ result[key] = "1" if value is None else value
+ return result
+
+
+if __name__ == "__main__":
+ # Configuration resolution is owned by build.sh; this command never runs PIO.
+ envs = {name: dict(options) for name, options in json.load(sys.stdin)}
+ env = envs.get("env:" + sys.argv[1], {})
+ spec = spec_for_defines(resolved_defines(env.get("build_flags", [])), env.get("board"))
+ if not spec:
+ sys.exit(1)
+ print(marker(spec))
diff --git a/src/helpers/ui/HeltecR8DisplayProbe.h b/src/helpers/ui/HeltecR8DisplayProbe.h
new file mode 100644
index 000000000..a2be90294
--- /dev/null
+++ b/src/helpers/ui/HeltecR8DisplayProbe.h
@@ -0,0 +1,108 @@
+#pragma once
+
+#include
+
+namespace mesh { namespace ui { namespace heltec_r8 {
+
+// Expansion Kit V2 routes the LCD's separate SDO to GPIO 45 (LCD_MISO).
+// This is not the T114's bidirectional SDA interface. Probe before the SPI
+// peripheral owns these pads, and never use the touch controller as proof of
+// LCD presence. The LCD/TP reset is shared and must finish HIGH even on failure.
+static_assert(PIN_TFT_MISO >= 0 && PIN_TFT_RST >= 0,
+ "R8 display detection requires LCD SDO and shared reset");
+static_assert(SDCARD_CS >= 0 && SDCARD_CS != PIN_TFT_CS
+ && SDCARD_CS != PIN_TFT_SDA && SDCARD_CS != PIN_TFT_SCL
+ && SDCARD_CS != PIN_TFT_MISO && SDCARD_CS != PIN_TFT_RST,
+ "R8 LCD probe requires the separate expansion microSD chip select");
+static_assert(PIN_TFT_MISO != PIN_TFT_SDA && PIN_TFT_MISO != PIN_TFT_SCL
+ && PIN_TFT_MISO != PIN_TFT_CS && PIN_TFT_MISO != PIN_TFT_DC
+ && PIN_TFT_MISO != PIN_TFT_RST,
+ "R8 LCD SDO must be a separate input");
+
+inline void backlightOff() {
+ if (PIN_TFT_LEDA_CTL >= 0) {
+ // Preload the inactive latch before enabling the output to avoid a flash.
+ digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE);
+ pinMode(PIN_TFT_LEDA_CTL, OUTPUT);
+ }
+}
+
+inline uint32_t readDisplayId(uint8_t bias) {
+ pinMode(PIN_TFT_MISO, bias);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_CS, LOW);
+ digitalWrite(PIN_TFT_DC, LOW);
+ for (uint8_t mask = 0x80; mask; mask >>= 1) {
+ digitalWrite(PIN_TFT_SDA, (0x04 & mask) ? HIGH : LOW); // RDDID
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ }
+ digitalWrite(PIN_TFT_DC, HIGH);
+ // ST7789 RDDID has one dummy clock followed by three ID bytes on SDO.
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ uint32_t id = 0;
+ for (uint8_t bit = 0; bit < 24; ++bit) {
+ delayMicroseconds(5);
+ id = (id << 1) | (digitalRead(PIN_TFT_MISO) ? 1u : 0u);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ }
+ digitalWrite(PIN_TFT_CS, HIGH);
+ return id;
+}
+
+inline void releaseBus() {
+ pinMode(PIN_TFT_CS, INPUT);
+ pinMode(PIN_TFT_DC, INPUT);
+ pinMode(PIN_TFT_SDA, INPUT);
+ pinMode(PIN_TFT_SCL, INPUT);
+ pinMode(PIN_TFT_MISO, INPUT);
+}
+
+inline bool probe() {
+ backlightOff();
+ // V2.03 shares LCD SCK/MOSI/MISO with microSD; GPIO 3 selects the card.
+ // A warm reset can leave it selected. Deselect before any probe clock and
+ // retain that level when parking the LCD bus, including the failure path.
+ digitalWrite(SDCARD_CS, HIGH);
+ pinMode(SDCARD_CS, OUTPUT);
+ digitalWrite(PIN_TFT_CS, HIGH);
+ pinMode(PIN_TFT_CS, OUTPUT);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ pinMode(PIN_TFT_SCL, OUTPUT);
+ pinMode(PIN_TFT_SDA, OUTPUT);
+ pinMode(PIN_TFT_DC, OUTPUT);
+ digitalWrite(PIN_TFT_RST, HIGH);
+ pinMode(PIN_TFT_RST, OUTPUT);
+
+ for (uint8_t attempt = 0; attempt < 2; ++attempt) {
+ digitalWrite(PIN_TFT_RST, LOW);
+ delay(10);
+ digitalWrite(PIN_TFT_RST, HIGH);
+ delay(120); // Reset/NVM settling, including the sleep-out interval.
+ uint32_t ids[2];
+ bool stable = true;
+ for (uint8_t bias = 0; bias < 2; ++bias) {
+ const uint8_t mode = bias == 0 ? INPUT_PULLUP : INPUT_PULLDOWN;
+ (void)readDisplayId(mode); // Discard the first read after changing bias.
+ ids[bias] = readDisplayId(mode);
+ stable = (readDisplayId(mode) == ids[bias]) && stable;
+ }
+ // IDs are programmable: require stable, actively driven data rather than
+ // guessing one factory ID. Opposite pulls expose a floating absent panel.
+ if (stable && ids[0] == ids[1] && ids[0] != 0 && ids[0] != 0xFFFFFFu) {
+ pinMode(PIN_TFT_MISO, INPUT);
+ return true;
+ }
+ }
+ releaseBus();
+ return false;
+}
+
+}}} // namespace mesh::ui::heltec_r8
diff --git a/src/helpers/ui/NV3001BDisplay.cpp b/src/helpers/ui/NV3001BDisplay.cpp
index 7df875b79..7bbfdcfdc 100644
--- a/src/helpers/ui/NV3001BDisplay.cpp
+++ b/src/helpers/ui/NV3001BDisplay.cpp
@@ -51,6 +51,13 @@
#define DISPLAY_ROTATION 0
#endif
+#if defined(HELTEC_RC32) && defined(RC32_DISPLAY_AUTODETECT) && RC32_DISPLAY_AUTODETECT
+#include "RC32DisplayProbe.h"
+extern const char meshcore_capability_display_autodetect_rc32[];
+const char meshcore_capability_display_autodetect_rc32[] =
+ "meshcore.capability.display.autodetect.rc32.v1";
+#endif
+
#define NV3001B_SWRESET 0x01
#define NV3001B_SLPOUT 0x11
#define NV3001B_DISPON 0x29
@@ -364,8 +371,23 @@ void NV3001BDisplay::drawChar(int x, int y, char ch) {
bool NV3001BDisplay::begin() {
if (is_on) return true;
+#if defined(HELTEC_RC32) && defined(RC32_DISPLAY_AUTODETECT) && RC32_DISPLAY_AUTODETECT
+ if (probe_complete && !panel_present) return false;
+#endif
if (periph_power) periph_power->claim();
+#if defined(HELTEC_RC32) && defined(RC32_DISPLAY_AUTODETECT) && RC32_DISPLAY_AUTODETECT
+ if (!probe_complete) {
+ const volatile char* capability = meshcore_capability_display_autodetect_rc32;
+ (void)*capability; // Retain proof in the executed optional-screen path.
+ probe_complete = true;
+ panel_present = mesh::ui::rc32::probe();
+ if (!panel_present) {
+ if (periph_power) periph_power->release();
+ return false;
+ }
+ }
+#endif
setupOptionalOutput(PIN_TFT_EN, PIN_TFT_EN_ACTIVE);
setupOptionalOutput(PIN_TFT_BL, !PIN_TFT_BL_ACTIVE);
pinMode(PIN_TFT_CS, OUTPUT);
@@ -374,7 +396,17 @@ bool NV3001BDisplay::begin() {
digitalWrite(PIN_TFT_DC, HIGH);
delay(20);
+#if defined(HELTEC_RC32) && defined(RC32_DISPLAY_AUTODETECT) && RC32_DISPLAY_AUTODETECT
+ if (!spi.begin(PIN_TFT_SCL, PIN_TFT_MISO, PIN_TFT_SDA, PIN_TFT_CS)) {
+ panel_present = false;
+ spi.end();
+ mesh::ui::rc32::powerOff();
+ if (periph_power) periph_power->release();
+ return false;
+ }
+#else
spi.begin(PIN_TFT_SCL, PIN_TFT_MISO, PIN_TFT_SDA, PIN_TFT_CS);
+#endif
if (PIN_TFT_RST >= 0) {
pinMode(PIN_TFT_RST, OUTPUT);
digitalWrite(PIN_TFT_RST, HIGH);
@@ -398,6 +430,9 @@ bool NV3001BDisplay::begin() {
}
void NV3001BDisplay::turnOn() {
+#if defined(HELTEC_RC32) && defined(RC32_DISPLAY_AUTODETECT) && RC32_DISPLAY_AUTODETECT
+ if (!panel_present) return;
+#endif
begin();
}
@@ -405,7 +440,12 @@ void NV3001BDisplay::turnOff() {
if (!is_on) return;
writeOptionalPin(PIN_TFT_BL, !PIN_TFT_BL_ACTIVE);
+#if defined(HELTEC_RC32) && defined(RC32_DISPLAY_AUTODETECT) && RC32_DISPLAY_AUTODETECT
+ spi.end();
+ mesh::ui::rc32::powerOff();
+#else
writeOptionalPin(PIN_TFT_EN, !PIN_TFT_EN_ACTIVE);
+#endif
is_on = false;
if (periph_power) periph_power->release();
}
diff --git a/src/helpers/ui/NV3001BDisplay.h b/src/helpers/ui/NV3001BDisplay.h
index 59043fe8a..41e388d19 100644
--- a/src/helpers/ui/NV3001BDisplay.h
+++ b/src/helpers/ui/NV3001BDisplay.h
@@ -28,6 +28,10 @@ class NV3001BDisplay : public DisplayDriver {
SPIClass spi;
RefCountedDigitalPin* periph_power;
bool is_on = false;
+#if defined(HELTEC_RC32) && defined(RC32_DISPLAY_AUTODETECT) && RC32_DISPLAY_AUTODETECT
+ bool probe_complete = false;
+ bool panel_present = false;
+#endif
uint16_t color = 0xffff;
uint8_t text_size = 1;
int cursor_x = 0;
diff --git a/src/helpers/ui/RC32DisplayProbe.h b/src/helpers/ui/RC32DisplayProbe.h
new file mode 100644
index 000000000..d5c9ddb97
--- /dev/null
+++ b/src/helpers/ui/RC32DisplayProbe.h
@@ -0,0 +1,102 @@
+#pragma once
+
+#include
+
+namespace mesh { namespace ui { namespace rc32 {
+
+// Heltec's optional T108 uses NV3001B on the shared, bidirectional SDA pin.
+// Read commands/ID: meshtastic/firmware src/mesh/NodeDB.cpp, TFT_NV3001B_DETECT.
+// Pins/power: HelTecAutomation/RadioCore_Library examples/NV3001B_Display.
+// Probe before claiming the SPI peripheral; no independent MISO is connected.
+inline uint32_t readIdCommand(uint8_t command, uint8_t bits, uint8_t dummy, uint8_t bias) {
+ digitalWrite(PIN_TFT_CS, LOW);
+ digitalWrite(PIN_TFT_DC, LOW);
+ pinMode(PIN_TFT_SDA, OUTPUT);
+ for (uint8_t mask = 0x80; mask; mask >>= 1) {
+ digitalWrite(PIN_TFT_SDA, (command & mask) ? HIGH : LOW);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ // Release after the final command bit is latched, before the panel can
+ // drive its first response bit on the falling edge.
+ if (mask == 1) pinMode(PIN_TFT_SDA, bias);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ }
+ digitalWrite(PIN_TFT_DC, HIGH);
+ for (uint8_t i = 0; i < dummy; ++i) {
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ }
+ uint32_t id = 0;
+ for (uint8_t i = 0; i < bits; ++i) {
+ delayMicroseconds(5);
+ id = (id << 1) | (digitalRead(PIN_TFT_SDA) ? 1u : 0u);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ }
+ digitalWrite(PIN_TFT_CS, HIGH);
+ return id;
+}
+
+inline uint32_t readId(uint8_t bias, bool individual_bytes) {
+ if (!individual_bytes) return readIdCommand(0x04, 24, 1, bias);
+ const uint32_t first = readIdCommand(0xDA, 8, 0, bias);
+ const uint32_t second = readIdCommand(0xDB, 8, 0, bias);
+ return (first << 16) | (second << 8) | readIdCommand(0xDC, 8, 0, bias);
+}
+
+inline bool stableId(bool individual_bytes) {
+ uint32_t ids[2];
+ bool stable = true;
+ for (uint8_t i = 0; i < 2; ++i) {
+ const uint8_t bias = i == 0 ? INPUT_PULLUP : INPUT_PULLDOWN;
+ (void)readId(bias, individual_bytes); // Discard stale data after changing pull.
+ ids[i] = readId(bias, individual_bytes);
+ stable = (readId(bias, individual_bytes) == ids[i]) && stable;
+ }
+ // Recognize this controller, not merely nonzero data from another header
+ // peripheral. Opposing pulls also reject an absent or weakly driven line.
+ return stable && ids[0] == ids[1] && ids[0] == 0x300101;
+}
+
+inline void powerOff() {
+ digitalWrite(PIN_TFT_BL, !PIN_TFT_BL_ACTIVE);
+ digitalWrite(PIN_TFT_RST, LOW);
+ // Do not back-power an unpowered panel through GPIO or the SPI peripheral.
+ pinMode(PIN_TFT_CS, INPUT);
+ pinMode(PIN_TFT_DC, INPUT);
+ pinMode(PIN_TFT_SDA, INPUT);
+ pinMode(PIN_TFT_SCL, INPUT);
+ digitalWrite(PIN_TFT_EN, !PIN_TFT_EN_ACTIVE);
+}
+
+inline bool probe() {
+ digitalWrite(PIN_TFT_BL, !PIN_TFT_BL_ACTIVE);
+ pinMode(PIN_TFT_BL, OUTPUT);
+ digitalWrite(PIN_TFT_EN, !PIN_TFT_EN_ACTIVE);
+ pinMode(PIN_TFT_EN, OUTPUT);
+ digitalWrite(PIN_TFT_CS, HIGH);
+ pinMode(PIN_TFT_CS, OUTPUT);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ pinMode(PIN_TFT_SCL, OUTPUT);
+ pinMode(PIN_TFT_DC, OUTPUT);
+ digitalWrite(PIN_TFT_RST, LOW);
+ pinMode(PIN_TFT_RST, OUTPUT);
+ digitalWrite(PIN_TFT_EN, PIN_TFT_EN_ACTIVE);
+ delay(120);
+ for (uint8_t attempt = 0; attempt < 2; ++attempt) {
+ digitalWrite(PIN_TFT_RST, LOW);
+ delay(120);
+ digitalWrite(PIN_TFT_RST, HIGH);
+ delay(120);
+ // Some T108 panels answer only the individual ID-byte commands.
+ if (stableId(false) || stableId(true)) return true;
+ }
+ powerOff();
+ return false;
+}
+
+}}} // namespace mesh::ui::rc32
diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp
index b2ba281c1..b0b2e76c9 100644
--- a/src/helpers/ui/ST7735Display.cpp
+++ b/src/helpers/ui/ST7735Display.cpp
@@ -5,6 +5,11 @@
#endif
#include
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+extern const char meshcore_capability_display_autodetect_tracker[] =
+ "meshcore.capability.display.autodetect.tracker.v1";
+#endif
+
// Most ST7735 recipes keep the original RGB565 canvas. RAM-constrained
// targets can retain the complete UI in RGB332 and expand only one scanline
// while sending it to the panel; the panel still receives RGB565 pixels.
@@ -448,6 +453,10 @@ bool ST7735Display::i2c_probe(TwoWire& wire, uint8_t addr) {
#define PIN_TFT_LEDA_CTL_ACTIVE HIGH
#endif
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ #include "WirelessTrackerDisplayProbe.h"
+#endif
+
// Dark colors reduce panel current on the PowerSaving builds.
ColorVal UIColor::window_bkg = ST77XX_BLACK;
ColorVal UIColor::title_bkg = ST77XX_BLACK;
@@ -460,11 +469,34 @@ ColorVal UIColor::popup_txt = ST77XX_WHITE;
ColorVal UIColor::corp_blue = ST77XX_BLUE;
bool ST7735Display::begin() {
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ bool probePowerClaimed = false;
+ if (!_probeComplete) {
+ if (_peripher_power) {
+ _peripher_power->claim();
+ probePowerClaimed = true;
+ }
+ delay(100);
+ const volatile char* capability = meshcore_capability_display_autodetect_tracker;
+ (void)*capability;
+ _probeComplete = true;
+ _present = mesh::ui::wireless_tracker::probe();
+ if (!_present) {
+ if (probePowerClaimed) _peripher_power->release();
+ return false;
+ }
+ }
+ if (!_present) return false;
+#endif
if (!sprite) {
// alloc offscreen canvas
sprite = new TFT_eSprite(&lcd);
if (!sprite) {
mesh::usbLoggingPort().println("ST7735Display: failed to alloc canvas");
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ mesh::ui::wireless_tracker::parkAbsentPanel();
+ if (probePowerClaimed) _peripher_power->release();
+#endif
return false;
}
sprite->setColorDepth(ST7735_BUFFER_COLOR_DEPTH);
@@ -473,16 +505,29 @@ bool ST7735Display::begin() {
"ST7735Display: failed to alloc canvas pixels");
delete sprite;
sprite = NULL;
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ mesh::ui::wireless_tracker::parkAbsentPanel();
+ if (probePowerClaimed) _peripher_power->release();
+#endif
return false;
}
sprite->fillScreen(ST77XX_BLACK);
sprite->setTextColor(curr_color = ST77XX_WHITE);
}
- if (!spriteReady()) return false;
+ if (!spriteReady()) {
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ if (probePowerClaimed) _peripher_power->release();
+#endif
+ return false;
+ }
if (!_isOn) {
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ if (_peripher_power && !probePowerClaimed) _peripher_power->claim();
+#else
if (_peripher_power) _peripher_power->claim();
+#endif
delay(100); // allow the peripheral rail to stabilize
pinMode(PIN_TFT_RST, OUTPUT);
diff --git a/src/helpers/ui/ST7735Display.h b/src/helpers/ui/ST7735Display.h
index f0995fe59..c9a3aa892 100644
--- a/src/helpers/ui/ST7735Display.h
+++ b/src/helpers/ui/ST7735Display.h
@@ -5,11 +5,23 @@
#include
#include
+// The optional-display hardware is Tracker V1/V1.1. Other boards using this
+// renderer (including Tracker V2, T096 and T1) keep their existing behavior.
+#if defined(WIRELESS_TRACKER_DISPLAY_AUTODETECT) && WIRELESS_TRACKER_DISPLAY_AUTODETECT \
+ && (defined(HELTEC_LORA_V3) || defined(HELTEC_TRACKER_V1_1)) \
+ && !defined(HELTEC_TRACKER_V2) && !defined(HELTEC_T096) && !defined(HELTEC_T1)
+ #define MESHCORE_TRACKER_DISPLAY_AUTODETECT 1
+#endif
+
class ST7735Display : public DisplayDriver {
// One UI coordinate is one pixel on every 160x80 ST7735 panel.
static constexpr int kLogicalWidth = 160, kLogicalHeight = 80;
bool _isOn;
RefCountedDigitalPin* _peripher_power;
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ bool _probeComplete = false;
+ bool _present = false;
+#endif
bool i2c_probe(TwoWire& wire, uint8_t addr);
public:
diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp
index 6ba5b4551..75f58639d 100644
--- a/src/helpers/ui/ST7789LCDDisplay.cpp
+++ b/src/helpers/ui/ST7789LCDDisplay.cpp
@@ -5,6 +5,13 @@
#define PIN_TFT_MISO -1
#endif
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+#include "HeltecR8DisplayProbe.h"
+extern const char meshcore_capability_display_autodetect_heltec_r8[];
+const char meshcore_capability_display_autodetect_heltec_r8[] =
+ "meshcore.capability.display.autodetect.heltec_r8.v1";
+#endif
+
#ifndef DISPLAY_ROTATION
#define DISPLAY_ROTATION 3
#endif
@@ -22,7 +29,15 @@ uint8_t ST7789LCDDisplay::effectiveRotation() const {
void ST7789LCDDisplay::setFlipped(bool flipped) {
if (_flipped == flipped) return;
_flipped = flipped;
- if (_panel_ready) display.setRotation(effectiveRotation());
+ if (_panel_ready) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) {
+ _rotation_pending = true;
+ return;
+ }
+#endif
+ display.setRotation(effectiveRotation());
+ }
}
bool ST7789LCDDisplay::i2c_probe(TwoWire& wire, uint8_t addr) {
@@ -41,6 +56,10 @@ ColorVal UIColor::popup_txt = mesh::ui::color_theme::TEXT;
ColorVal UIColor::corp_blue = mesh::ui::color_theme::ACCENT;
bool ST7789LCDDisplay::begin() {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (_probe_complete && !_present) return false;
+ if (!_probe_complete) mesh::ui::heltec_r8::backlightOff();
+#endif
if (!_isOn) {
#ifdef HELTEC_V4_R8_TFT
// turnOff() leaves this panel configured and powered - its reset line is
@@ -50,6 +69,14 @@ bool ST7789LCDDisplay::begin() {
// and stalling the UI loop for ~500 ms of reset delays.
if (_panel_ready) {
if (_peripher_power) _peripher_power->claim();
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ // A preference may change while the backlight is off. Apply only that
+ // deferred change; ordinary wakes remain free of SPI/reset work.
+ if (_rotation_pending) {
+ display.setRotation(effectiveRotation());
+ _rotation_pending = false;
+ }
+#endif
if (PIN_TFT_LEDA_CTL != -1) {
digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE);
}
@@ -65,6 +92,21 @@ bool ST7789LCDDisplay::begin() {
#endif
}
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_probe_complete) {
+ const volatile char* capability = meshcore_capability_display_autodetect_heltec_r8;
+ (void)*capability; // Keep the marker bound to the executed probe path.
+ _probe_complete = true;
+ _present = mesh::ui::heltec_r8::probe();
+ if (!_present) {
+ // Release exactly this display's claim. VEXT also powers the radio
+ // and other peripherals; never turn the shared rail off directly.
+ if (_peripher_power) _peripher_power->release();
+ return false;
+ }
+ }
+#endif
+
if (PIN_TFT_LEDA_CTL != -1) {
pinMode(PIN_TFT_LEDA_CTL, OUTPUT);
#ifdef HELTEC_V4_R8_TFT
@@ -78,6 +120,18 @@ bool ST7789LCDDisplay::begin() {
#if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) || defined(HELTEC_V4_R8_TFT)
displaySPI.begin(PIN_TFT_SCL, PIN_TFT_MISO, PIN_TFT_SDA, PIN_TFT_CS);
#endif
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ // Arduino-ESP32 2.x begin() returns void, so inspect its public bus handle
+ // before handing an unavailable SPI peripheral to the panel library.
+ if (!displaySPI.bus()) {
+ displaySPI.end();
+ _present = false; // Latch failure just like an absent LCD.
+ mesh::ui::heltec_r8::backlightOff();
+ mesh::ui::heltec_r8::releaseBus();
+ if (_peripher_power) _peripher_power->release();
+ return false;
+ }
+#endif
display.init(DISPLAY_WIDTH, DISPLAY_HEIGHT);
display.setRotation(effectiveRotation());
@@ -137,10 +191,16 @@ void ST7789LCDDisplay::turnOff() {
}
void ST7789LCDDisplay::clear() {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
display.fillScreen(ST77XX_BLACK);
}
void ST7789LCDDisplay::startFrame(ColorVal bkg) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
display.fillScreen(bkg);
display.setTextColor(_color = UIColor::primary_txt);
display.setTextSize(1);
@@ -148,35 +208,59 @@ void ST7789LCDDisplay::startFrame(ColorVal bkg) {
}
void ST7789LCDDisplay::setTextSize(int sz) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
display.setTextSize(sz > 0 ? sz : 1);
}
void ST7789LCDDisplay::setColor(ColorVal c) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
display.setTextColor(_color = c);
}
void ST7789LCDDisplay::setCursor(int x, int y) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
display.setCursor(x, y);
}
void ST7789LCDDisplay::print(const char* str) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
display.print(str);
}
void ST7789LCDDisplay::fillRect(int x, int y, int w, int h) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
display.fillRect(x, y, w, h, _color);
}
void ST7789LCDDisplay::drawRect(int x, int y, int w, int h) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
display.drawRect(x, y, w, h, _color);
}
void ST7789LCDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return;
+#endif
// The common UI bitmaps are MSB-first, as expected by drawBitmap.
display.drawBitmap(x, y, bits, w, h, _color);
}
uint16_t ST7789LCDDisplay::getTextWidth(const char* str) {
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ if (!_isOn) return 0;
+#endif
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h);
diff --git a/src/helpers/ui/ST7789LCDDisplay.h b/src/helpers/ui/ST7789LCDDisplay.h
index 5de5af3f5..7c73d69fb 100644
--- a/src/helpers/ui/ST7789LCDDisplay.h
+++ b/src/helpers/ui/ST7789LCDDisplay.h
@@ -11,6 +11,11 @@
#define DISPLAY_ROTATION 3
#endif
+#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_DISPLAY_AUTODETECT) \
+ && HELTEC_TFT_DISPLAY_AUTODETECT == 1
+ #define MESHCORE_HELTEC_R8_TFT_DETECTION 1
+#endif
+
class ST7789LCDDisplay : public DisplayDriver {
#if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) || defined(HELTEC_V4_R8_TFT)
SPIClass displaySPI;
@@ -18,6 +23,11 @@ class ST7789LCDDisplay : public DisplayDriver {
Adafruit_ST7789 display;
bool _isOn;
bool _panel_ready = false; // panel has been configured at least once
+#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION
+ bool _probe_complete = false;
+ bool _present = false;
+ bool _rotation_pending = false;
+#endif
bool _flipped = false;
uint16_t _color;
RefCountedDigitalPin* _peripher_power;
diff --git a/src/helpers/ui/WirelessTrackerDisplayProbe.h b/src/helpers/ui/WirelessTrackerDisplayProbe.h
new file mode 100644
index 000000000..5a87998b2
--- /dev/null
+++ b/src/helpers/ui/WirelessTrackerDisplayProbe.h
@@ -0,0 +1,86 @@
+#pragma once
+
+#include
+
+namespace mesh { namespace ui { namespace wireless_tracker {
+
+// Wireless Tracker V1's optional ST7735 panel is connected directly to SDA
+// (GPIO42), not radio MISO. The ST7735 serial read protocol returns data on
+// that same pin: release it before the last command clock's falling edge.
+// The caller owns a reference to the shared TFT/GNSS peripheral power rail.
+inline uint32_t readDisplayId(uint8_t bias) {
+ digitalWrite(PIN_TFT_CS, LOW);
+ digitalWrite(PIN_TFT_DC, LOW);
+ pinMode(PIN_TFT_SDA, OUTPUT);
+ for (uint8_t mask = 0x80; mask; mask >>= 1) {
+ digitalWrite(PIN_TFT_SDA, (0x04 & mask) ? HIGH : LOW);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ if (mask == 1) pinMode(PIN_TFT_SDA, bias);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ }
+ digitalWrite(PIN_TFT_DC, HIGH);
+ // RDDID returns 24 bits after one turnaround/dummy clock.
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ uint32_t id = 0;
+ for (uint8_t bit = 0; bit < 24; ++bit) {
+ delayMicroseconds(5);
+ id = (id << 1) | (digitalRead(PIN_TFT_SDA) ? 1u : 0u);
+ digitalWrite(PIN_TFT_SCL, HIGH);
+ delayMicroseconds(5);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ }
+ digitalWrite(PIN_TFT_CS, HIGH);
+ return id;
+}
+
+inline void parkAbsentPanel() {
+ digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE);
+ digitalWrite(PIN_TFT_RST, LOW);
+ pinMode(PIN_TFT_CS, INPUT);
+ pinMode(PIN_TFT_DC, INPUT);
+ pinMode(PIN_TFT_SDA, INPUT);
+ pinMode(PIN_TFT_SCL, INPUT);
+ // Do not drive Vext: the caller releases only the display's power claim,
+ // allowing GNSS or another peripheral to retain its independent claim.
+}
+
+inline bool probe() {
+ digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE);
+ pinMode(PIN_TFT_LEDA_CTL, OUTPUT);
+ digitalWrite(PIN_TFT_CS, HIGH);
+ pinMode(PIN_TFT_CS, OUTPUT);
+ digitalWrite(PIN_TFT_SCL, LOW);
+ pinMode(PIN_TFT_SCL, OUTPUT);
+ pinMode(PIN_TFT_DC, OUTPUT);
+ digitalWrite(PIN_TFT_RST, LOW);
+ pinMode(PIN_TFT_RST, OUTPUT);
+
+ for (uint8_t attempt = 0; attempt < 2; ++attempt) {
+ digitalWrite(PIN_TFT_RST, LOW);
+ delay(10);
+ digitalWrite(PIN_TFT_RST, HIGH);
+ delay(120);
+ uint32_t ids[2];
+ bool stable = true;
+ for (uint8_t bias = 0; bias < 2; ++bias) {
+ const uint8_t mode = bias == 0 ? INPUT_PULLUP : INPUT_PULLDOWN;
+ (void)readDisplayId(mode);
+ ids[bias] = readDisplayId(mode);
+ stable = (readDisplayId(mode) == ids[bias]) && stable;
+ }
+ // IDs are programmable; require stable driven data under opposite pulls.
+ if (stable && ids[0] == ids[1] && ids[0] != 0 && ids[0] != 0xFFFFFFu) {
+ pinMode(PIN_TFT_SDA, INPUT);
+ return true;
+ }
+ }
+ parkAbsentPanel();
+ return false;
+}
+
+}}} // namespace mesh::ui::wireless_tracker
diff --git a/test/test_heltec_r8_display_detection.py b/test/test_heltec_r8_display_detection.py
new file mode 100644
index 000000000..9f9a6b0ee
--- /dev/null
+++ b/test/test_heltec_r8_display_detection.py
@@ -0,0 +1,313 @@
+"""Run the real R8 LCD driver/probe against recording GPIO and panel libraries."""
+from pathlib import Path
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+UI = ROOT / "src/helpers/ui"
+
+ARDUINO = r'''
+#pragma once
+#include
+#include
+#include
+#include
+#define LOW 0
+#define HIGH 1
+#define INPUT 0
+#define OUTPUT 1
+#define INPUT_PULLUP 2
+#define INPUT_PULLDOWN 3
+#define HSPI 1
+#define PIN_TFT_CS 47
+#define PIN_TFT_DC 48
+#define PIN_TFT_SCL 16
+#define PIN_TFT_SDA 15
+#ifndef PIN_TFT_MISO
+#define PIN_TFT_MISO 45
+#endif
+#define PIN_TFT_RST 21
+#define PIN_TFT_LEDA_CTL 44
+#define PIN_TFT_LEDA_CTL_ACTIVE HIGH
+#define SDCARD_CS 3
+inline int modes[64] = {}, levels[64] = {};
+inline uint64_t now_us=0, reset_low_at=0, reset_high_at=0, reset_low_us=0;
+inline unsigned events=0, transactions=0, reads=0, resets=0, spi_begins=0, init_calls=0, draws=0;
+inline unsigned spi_ends=0;
+inline unsigned rotations=0, last_rotation=0;
+inline unsigned command_bits=0, clocks=0, data_bits=0, command=0;
+inline uint32_t response=0;
+inline bool transaction=false, initialized=false;
+inline std::string scenario;
+inline bool automatic() {
+#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_DISPLAY_AUTODETECT) && HELTEC_TFT_DISPLAY_AUTODETECT == 1
+ return true;
+#else
+ return false;
+#endif
+}
+inline void check_pin(int pin) {
+ assert(pin==PIN_TFT_CS || pin==PIN_TFT_DC || pin==PIN_TFT_SCL || pin==PIN_TFT_SDA
+ || pin==PIN_TFT_MISO || pin==PIN_TFT_RST || pin==PIN_TFT_LEDA_CTL || pin==40 || pin==SDCARD_CS);
+}
+inline void pinMode(int pin,int mode) {
+ check_pin(pin); ++events;
+ if (pin==PIN_TFT_MISO) assert(mode!=OUTPUT && "LCD SDO must never be driven");
+ if (pin==PIN_TFT_LEDA_CTL && automatic() && !initialized)
+ assert(levels[pin]==LOW && "preload inactive backlight before enabling output");
+ if (pin==SDCARD_CS && mode==OUTPUT && automatic())
+ assert(levels[pin]==HIGH && "preload microSD deselect before enabling output");
+ modes[pin]=mode;
+}
+inline void digitalWrite(int pin,int value) {
+ check_pin(pin); ++events;
+ if (pin==PIN_TFT_LEDA_CTL && automatic() && value==HIGH)
+ assert(initialized && "backlight must remain dark until LCD init completes");
+ if (pin==PIN_TFT_RST) {
+ if (value==LOW && levels[pin]==HIGH) reset_low_at=now_us;
+ if (value==HIGH && levels[pin]==LOW) {
+ reset_low_us=now_us-reset_low_at; reset_high_at=now_us; ++resets;
+ }
+ }
+ if (pin==PIN_TFT_CS && value==LOW && levels[pin]==HIGH && automatic() && spi_begins==0) {
+ assert(now_us-reset_high_at>=120000 && "wait for reset/NVM before RDDID");
+ assert(reset_low_us>=10000 && levels[PIN_TFT_LEDA_CTL]==LOW);
+ assert(levels[40]==HIGH && "shared peripheral rail must be claimed");
+ transaction=true; ++transactions; command_bits=clocks=data_bits=command=0;
+ const unsigned within=(transactions-1)%6;
+ response=0x858552;
+ if (scenario=="floating") response=modes[PIN_TFT_MISO]==INPUT_PULLUP ? 0xFFFFFF : 0;
+ if (scenario=="high") response=0xFFFFFF;
+ if (scenario=="low") response=0;
+ if (scenario=="biased") response=modes[PIN_TFT_MISO]==INPUT_PULLUP ? 0x858552 : 0x123456;
+ if (scenario=="noise") response=within==2 ? 0x858553 : 0x858552;
+ if (scenario=="recover" && transactions<=6) response=0;
+ if (scenario=="prime" && within%3==0) response=0;
+ if (scenario=="programmable") response=0x123456;
+ }
+ if (pin==PIN_TFT_SCL && value==HIGH && transaction) {
+ assert(levels[SDCARD_CS]==HIGH && modes[SDCARD_CS]==OUTPUT && "deselect shared microSD before LCD clocks");
+ ++clocks;
+ if (clocks<=8) {
+ assert(levels[PIN_TFT_DC]==LOW && modes[PIN_TFT_SDA]==OUTPUT);
+ command=(command<<1)|levels[PIN_TFT_SDA]; ++command_bits;
+ }
+ }
+ if (pin==PIN_TFT_CS && value==HIGH && transaction) {
+ assert(command==0x04 && command_bits==8 && data_bits==24 && clocks==33);
+ transaction=false;
+ }
+ levels[pin]=value;
+}
+inline int digitalRead(int pin) {
+ assert(pin==PIN_TFT_MISO && "read dedicated LCD SDO, never MOSI/touch/I2C");
+ assert(transaction && command==0x04 && levels[PIN_TFT_DC]==HIGH);
+ assert(modes[pin]==((transactions-1)%6<3 ? INPUT_PULLUP : INPUT_PULLDOWN));
+ assert(clocks==9+data_bits && levels[PIN_TFT_SCL]==LOW && data_bits<24);
+ ++reads; return (response>>(23-data_bits++))&1;
+}
+inline void delay(unsigned ms) { now_us+=uint64_t(ms)*1000; }
+inline void delayMicroseconds(unsigned us) { now_us+=us; }
+'''
+
+SPI = r'''
+#pragma once
+#include
+struct SPIClass {
+ bool live=false;
+ explicit SPIClass(int=0) {}
+#ifdef TEST_BOOL_SPI_BEGIN
+ bool begin(int clk,int miso,int mosi,int cs) {
+#else
+ void begin(int clk,int miso,int mosi,int cs) {
+#endif
+ assert(clk==PIN_TFT_SCL && miso==PIN_TFT_MISO && mosi==PIN_TFT_SDA && cs==PIN_TFT_CS);
+ if (automatic()) assert(transactions>=6 && levels[PIN_TFT_LEDA_CTL]==LOW);
+ ++spi_begins;
+ live=scenario!="spi-fail";
+#ifdef TEST_BOOL_SPI_BEGIN
+ return live;
+#endif
+ }
+ void* bus() { return live ? this : nullptr; }
+ void end() { ++spi_ends; live=false; }
+};
+inline SPIClass SPI;
+'''
+
+PANEL = r'''
+#pragma once
+#include
+#define ST77XX_BLACK 0
+#define ST77XX_WHITE 0xffff
+class Adafruit_ST7789 {
+ int _w=240, _h=320;
+public:
+ template explicit Adafruit_ST7789(T...) {}
+ void init(int w,int h) {
+ if (automatic()) assert(spi_begins==1 && transactions>=6 && levels[PIN_TFT_LEDA_CTL]==LOW);
+ ++init_calls; initialized=true; _w=w; _h=h;
+ }
+ void setRotation(int rotation) { assert(initialized); _w=rotation&1 ? 320 : 240; _h=rotation&1 ? 240 : 320; ++draws; ++rotations; last_rotation=rotation; }
+ int width() const { return _w; }
+ int height() const { return _h; }
+ void setSPISpeed(double) { assert(initialized); }
+ void fillScreen(uint16_t) { assert(initialized); ++draws; }
+ void setTextColor(uint16_t) { assert(initialized); ++draws; }
+ void setTextSize(int) { assert(initialized); ++draws; }
+ void setTextWrap(bool) { assert(initialized); ++draws; }
+ void cp437(bool) { assert(initialized); ++draws; }
+ void setCursor(int,int) { assert(initialized); ++draws; }
+ void print(const char*) { assert(initialized); ++draws; }
+ void fillRect(int,int,int,int,uint16_t) { assert(initialized); ++draws; }
+ void drawRect(int,int,int,int,uint16_t) { assert(initialized); ++draws; }
+ void drawBitmap(int,int,const uint8_t*,int,int,uint16_t) { assert(initialized); ++draws; }
+ void getTextBounds(const char* s,int,int,int16_t* x,int16_t* y,uint16_t* w,uint16_t* h) {
+ assert(initialized); ++draws; *x=*y=0; *w=strlen(s)*6; *h=8;
+ }
+};
+'''
+
+MAIN = r'''
+#include "ST7789LCDDisplay.h"
+void frame(ST7789LCDDisplay& d) {
+ const uint8_t bits[]={0x80};
+ d.clear(); d.startFrame(); d.setTextSize(2); d.setColor(1); d.setCursor(0,0);
+ d.print("text"); d.fillRect(0,0,1,1); d.drawRect(0,0,1,1);
+ d.drawXbm(0,0,bits,1,1); d.endFrame(); (void)d.getTextWidth("text");
+}
+int main(int argc,char** argv) {
+ assert(argc==2); scenario=argv[1];
+ RefCountedDigitalPin rail(40); rail.begin();
+ const bool other_owner=scenario!="unshared";
+ if (other_owner) rail.claim();
+ ST7789LCDDisplay d(&rail);
+ if (!automatic()) {
+ assert(d.begin() && d.isOn()); frame(d);
+ assert(reads==0 && transactions==0 && init_calls==1);
+ return 0;
+ }
+ const auto before=events; frame(d); d.setFlipped(true);
+ assert(events==before && draws==0 && init_calls==0);
+ // Simulate a warm reset after a card transaction left DAT3/CS asserted.
+ if (scenario=="sd-selected") { modes[SDCARD_CS]=OUTPUT; levels[SDCARD_CS]=LOW; }
+ const bool absent=scenario=="floating" || scenario=="high" || scenario=="low"
+ || scenario=="noise" || scenario=="biased" || scenario=="unshared";
+ if (scenario=="unshared") scenario="floating";
+ const bool begun=d.begin();
+ if (absent || scenario=="spi-fail") {
+ assert(!begun && !d.isOn() && "invalid LCD must remain headless");
+ assert(init_calls==0 && spi_begins==(scenario=="spi-fail" ? 1u : 0u) && draws==0);
+ assert(spi_ends==(scenario=="spi-fail" ? 1u : 0u));
+ assert(levels[PIN_TFT_RST]==HIGH && "shared touch reset must not remain LOW");
+ assert(levels[PIN_TFT_LEDA_CTL]==LOW && levels[40]==other_owner);
+ for (int pin:{PIN_TFT_CS,PIN_TFT_DC,PIN_TFT_SDA,PIN_TFT_SCL,PIN_TFT_MISO}) assert(modes[pin]==INPUT);
+ const auto count=events; frame(d); d.turnOn(); d.turnOff(); d.setFlipped(false);
+ assert(!d.begin() && events==count && draws==0 && reads==24*transactions);
+ assert(transactions==(scenario=="spi-fail" ? 6u : 12u) && now_us<450000);
+ assert(levels[SDCARD_CS]==HIGH && modes[SDCARD_CS]==OUTPUT);
+ if (other_owner) { rail.release(); assert(levels[40]==LOW && "probe must not leak its power claim"); }
+ return 0;
+ }
+ assert(begun && d.isOn() && init_calls==1 && spi_begins==1 && initialized);
+ assert(last_rotation==((DISPLAY_ROTATION+2)&3));
+ assert(transactions==(scenario=="recover" ? 12u : 6u));
+ assert(reads==24*transactions && levels[PIN_TFT_LEDA_CTL]==HIGH);
+ assert(levels[SDCARD_CS]==HIGH && modes[SDCARD_CS]==OUTPUT);
+ frame(d); const auto painted=draws, reset_count=resets, probes=transactions;
+ assert(d.begin() && init_calls==1 && transactions==probes);
+ d.turnOff(); assert(!d.isOn() && levels[PIN_TFT_RST]==HIGH && levels[PIN_TFT_LEDA_CTL]==LOW);
+ assert(levels[40]==HIGH && "display off must retain the other peripheral claim");
+ frame(d); d.setFlipped(false); assert(draws==painted);
+ for (unsigned i=0;i<100;++i) { d.turnOn(); assert(d.isOn()); d.turnOff(); }
+ assert(resets==reset_count && init_calls==1 && spi_begins==1 && transactions==probes);
+ assert(rotations==2 && last_rotation==(DISPLAY_ROTATION&3) && "apply deferred flip once on wake");
+ d.turnOn(); frame(d); assert(draws>painted);
+ d.turnOff(); rail.release(); assert(levels[40]==LOW && "wake/sleep claims must remain balanced");
+}
+'''
+
+
+class HeltecR8DisplayDetectionTest(unittest.TestCase):
+ def build(self, directory, flags=(), mutation=None, expect_failure=False):
+ directory.mkdir()
+ for name in ("ST7789LCDDisplay.h", "ST7789LCDDisplay.cpp", "HeltecR8DisplayProbe.h"):
+ source = (UI / name).read_text()
+ if name == "HeltecR8DisplayProbe.h" and mutation:
+ old, new = mutation
+ self.assertIn(old, source)
+ source = source.replace(old, new)
+ (directory / name).write_text(source)
+ for name, source in {"Arduino.h": ARDUINO, "SPI.h": SPI,
+ "Wire.h": "#pragma once\nclass TwoWire {};\n",
+ "Adafruit_GFX.h": "#pragma once\n",
+ "Adafruit_ST7789.h": PANEL, "main.cpp": MAIN}.items():
+ (directory / name).write_text(source)
+ binary = directory / "probe"
+ sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
+ if sys.platform.startswith("linux") else [])
+ compiled = subprocess.run([
+ os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
+ "-Wno-unused-parameter", *sanitizer, *flags,
+ "-I" + str(directory), "-I" + str(UI), "-I" + str(ROOT / "src"),
+ str(directory / "main.cpp"), str(directory / "ST7789LCDDisplay.cpp"), "-o", str(binary)
+ ], capture_output=True, text=True, timeout=60)
+ if expect_failure:
+ self.assertNotEqual(compiled.returncode, 0)
+ self.assertIn("R8 display detection requires LCD SDO", compiled.stderr)
+ else:
+ self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
+ return binary
+
+ def run_case(self, binary, scenario, negative=None):
+ ran = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10)
+ if negative:
+ self.assertNotEqual(ran.returncode, 0)
+ self.assertIn(negative, ran.stderr)
+ else:
+ self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
+
+ def test_absent_floating_stuck_unstable_and_power_ownership(self):
+ with tempfile.TemporaryDirectory(prefix="heltec-r8-display-") as temp:
+ binary = self.build(Path(temp) / "auto", ["-DHELTEC_V4_R8_TFT", "-DHELTEC_TFT_DISPLAY_AUTODETECT=1"])
+ self.assertIn(b"meshcore.capability.display.autodetect.heltec_r8.v1", binary.read_bytes())
+ for scenario in ("floating", "high", "low", "noise", "biased", "unshared", "spi-fail"):
+ with self.subTest(scenario=scenario): self.run_case(binary, scenario)
+
+ def test_panel_ids_retry_and_shared_touch_reset_sleep_wake(self):
+ with tempfile.TemporaryDirectory(prefix="heltec-r8-display-") as temp:
+ for api in ("void", "bool"):
+ flags = ["-DHELTEC_V4_R8_TFT", "-DHELTEC_TFT_DISPLAY_AUTODETECT=1"]
+ if api == "bool": flags.append("-DTEST_BOOL_SPI_BEGIN")
+ binary = self.build(Path(temp) / api, flags)
+ for scenario in ("valid", "programmable", "prime", "recover", "sd-selected", "spi-fail"):
+ with self.subTest(api=api, scenario=scenario): self.run_case(binary, scenario)
+
+ def test_other_boards_and_explicitly_disabled_r8_keep_existing_behavior(self):
+ with tempfile.TemporaryDirectory(prefix="heltec-r8-display-") as temp:
+ for index, flags in enumerate(([], ["-DHELTEC_V4_R8_TFT"],
+ ["-DHELTEC_V4_R8_TFT", "-DHELTEC_TFT_DISPLAY_AUTODETECT=0"],
+ ["-DHELTEC_LORA_V4_TFT", "-DHELTEC_TFT_DISPLAY_AUTODETECT=1"],
+ ["-DLILYGO_TDECK", "-DHELTEC_TFT_DISPLAY_AUTODETECT=1"])):
+ binary = self.build(Path(temp) / str(index), flags)
+ self.run_case(binary, "valid")
+ self.assertNotIn(b"meshcore.capability.display.autodetect.heltec_r8.v1", binary.read_bytes())
+
+ def test_negative_controls_detect_bias_settle_and_wrong_read_pin(self):
+ flags = ["-DHELTEC_V4_R8_TFT", "-DHELTEC_TFT_DISPLAY_AUTODETECT=1"]
+ with tempfile.TemporaryDirectory(prefix="heltec-r8-display-") as temp:
+ cases = [("bias", ("ids[0] == ids[1]", "true"), "biased", "invalid LCD must remain headless"),
+ ("settle", ("delay(120);", "delay(0);"), "valid", "wait for reset/NVM before RDDID"),
+ ("pin", ("digitalRead(PIN_TFT_MISO)", "digitalRead(PIN_TFT_SDA)"), "valid", "read dedicated LCD SDO"),
+ ("sd", (" digitalWrite(SDCARD_CS, HIGH);\n pinMode(SDCARD_CS, OUTPUT);", ""), "sd-selected", "deselect shared microSD before LCD clocks")]
+ for name, mutation, scenario, message in cases:
+ binary = self.build(Path(temp) / name, flags, mutation)
+ self.run_case(binary, scenario, negative=message)
+ self.build(Path(temp) / "no-miso", flags + ["-DPIN_TFT_MISO=-1"], expect_failure=True)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_optional_display_picker.js b/test/test_optional_display_picker.js
new file mode 100644
index 000000000..6954d0e03
--- /dev/null
+++ b/test/test_optional_display_picker.js
@@ -0,0 +1,35 @@
+"use strict";
+const assert = require("assert");
+const picker = require("../docs/_javascript/firmware_picker.js");
+const source = "a".repeat(40);
+for (const [target, controller] of [
+ ["heltec_rc32_companion_radio_full", "nv3001b"],
+ ["Heltec_Wireless_Tracker_repeater", "st7735"],
+ ["heltec_tracker_v1_1_repeater_observer_mqtt", "st7735"],
+ ["heltec_v4_r8_tft_portrait_repeater_observer_mqtt", "st7789"]
+]) {
+ const profile = {...picker.parseTargetProfile(target), target,
+ controls: {platform: "ESP32_PLATFORM", display: true, displayAuto: true,
+ displayController: controller, displaySource: source},
+ files: [".bin", "-merged.bin"].map(suffix => ({name: target + "-v1.18.0.1-dev-aaaaaaaa" + suffix}))};
+ assert(picker.optionalAutoDisplayProfile(profile), target);
+ assert(picker.boardNotes(profile).some(note => note.includes("optional screen")), target);
+ assert(picker.hardwareChoiceLabel("hardware", profile.hardware, [profile]).includes("automatic screen detection"));
+ for (const change of [{display: false}, {displayAuto: false}, {displayController: "unknown"},
+ {displaySource: source.slice(0, 8)}, {displaySource: "b".repeat(40)}, {platform: "NRF52_PLATFORM"}]) {
+ const old = {...profile, controls: {...profile.controls, ...change}};
+ assert(!picker.optionalAutoDisplayProfile(old));
+ assert(!picker.boardNotes(old).some(note => note.includes("optional screen")));
+ }
+ const mixed = {...profile, files: [...profile.files, {name: target + "-v1.18.0.1-dev-bbbbbbbb.bin"}]};
+ assert(!picker.optionalAutoDisplayProfile(mixed), "every download must match the detection source");
+ assert(!picker.optionalAutoDisplayProfile({...profile, controlsSource: "b".repeat(40)}),
+ "the detection source must match attached metadata too");
+}
+for (const target of ["heltec_rc32_without_display_repeater", "heltec_tracker_v2_repeater",
+ "Heltec_t096_repeater", "Heltec_T190_repeater_", "LilyGo_T-Echo_Card_repeater", "heltec_v4_tft_repeater"]) {
+ assert(!picker.optionalAutoDisplayProfile({target, controls: {platform: "ESP32_PLATFORM",
+ display: true, displayAuto: true, displayController: "st7789", displaySource: source},
+ files: [{name: target + "-v1.18.0.1-dev-aaaaaaaa.bin"}]}));
+}
+console.log("Optional display picker directions require the exact supported board, controller and application source");
diff --git a/test/test_optional_display_profiles.py b/test/test_optional_display_profiles.py
new file mode 100644
index 000000000..f45b7d80b
--- /dev/null
+++ b/test/test_optional_display_profiles.py
@@ -0,0 +1,125 @@
+"""Optional-screen defaults and exact packaged detection evidence."""
+from copy import deepcopy
+import json
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "scripts"))
+import optional_display_profiles as displays
+import generate_picker_controls as generator
+from test_upstream_observer_recipes import Recipes
+from test_t114_autodisplay_recipes import defines
+
+SOURCE = "a" * 40
+TARGETS = ("heltec_rc32_repeater", "Heltec_Wireless_Tracker_repeater",
+ "heltec_tracker_v1_1_repeater_observer_mqtt", "heltec_v4_r8_tft_repeater")
+
+
+def manifest(spec, target):
+ return dict(target=target, platform="ESP32_PLATFORM", verified=True, source_commit=SOURCE,
+ capabilities=[displays.CAPABILITY], verification=[dict(capability=displays.CAPABILITY,
+ present=True, source="packaged application", evidence=displays.marker(spec))])
+
+
+class OptionalDisplayProfilesTest(unittest.TestCase):
+ def test_optional_hardware_defaults_and_integral_screens_are_separate(self):
+ recipes = Recipes()
+ for spec, target in zip(displays.SPECS, TARGETS):
+ with self.subTest(target=target):
+ flags = defines(recipes.value("env:" + target, "build_flags"))
+ self.assertEqual(displays.spec_for_defines(flags, recipes.value("env:" + target, "board")), spec)
+ if spec["marker"] == "heltec_r8":
+ self.assertEqual(flags["SDCARD_CS"], "3")
+ for target in ("Heltec_T190_repeater_", "LilyGo_T-Echo_Card_repeater",
+ "Heltec_t096_repeater", "Heltec_t1_repeater", "heltec_tracker_v2_repeater",
+ "ThinkNode_M9_repeater_", "heltec_v4_repeater", "heltec_v4_tft_repeater",
+ "heltec_rc32_without_display_repeater", "heltec_rc32_kiss_modem"):
+ with self.subTest(target=target):
+ flags = defines(recipes.value("env:" + target, "build_flags"))
+ self.assertIsNone(displays.spec_for_defines(flags))
+
+ def test_exact_flags_application_marker_controller_and_source_are_required(self):
+ recipes = Recipes()
+ for spec, target in zip(displays.SPECS, TARGETS):
+ flags = defines(recipes.value("env:" + target, "build_flags"))
+ row = manifest(spec, target)
+ with self.subTest(target=target):
+ self.assertEqual(displays.runtime_metadata(row, flags, board=spec["board"]), dict(displayAuto=True,
+ displayController=spec["controller"], displaySource=SOURCE))
+ old = {**row, "capabilities": []}
+ self.assertEqual(displays.runtime_metadata(old, flags), {})
+ for changed in ({spec["flag"]: "0"}, {spec["guard"]: "0"}, {"DISPLAY_CLASS": "NullDisplayDriver"}):
+ with self.assertRaises(ValueError):
+ displays.runtime_metadata(row, {**flags, **changed}, board=spec["board"])
+ for changed in ({"verified": False}, {"platform": "NRF52_PLATFORM"},
+ {"source_commit": SOURCE[:8]}, {"verification": []}, {"target": "unrelated_repeater"}):
+ with self.assertRaises(ValueError):
+ displays.runtime_metadata({**row, **changed}, flags, board=spec["board"])
+ for key, value in (("present", False), ("source", "linked image"), ("evidence", "unrelated")):
+ bad = deepcopy(row)
+ bad["verification"][0][key] = value
+ with self.assertRaises(ValueError):
+ displays.runtime_metadata(bad, flags, board=spec["board"])
+ for board in (None, "different_board"):
+ with self.assertRaises(ValueError):
+ displays.runtime_metadata(row, flags, board=board)
+ with self.assertRaisesRegex(ValueError, "source must match"):
+ displays.runtime_metadata(row, flags, "b" * 40, board=spec["board"])
+
+ def test_builder_uses_ordered_flags_and_exact_board_without_starting_platformio(self):
+ spec = displays.SPECS[0]
+ flags = ["-D HELTEC_RC32", "-DDISPLAY_CLASS=NV3001BDisplay", "-D RC32_DISPLAY_AUTODETECT=1"]
+ for board, extra, expected in ((spec["board"], [], 0), (spec["board"], ["-U RC32_DISPLAY_AUTODETECT"], 1),
+ (spec["board"], ["-D RC32_DISPLAY_AUTODETECT=0"], 1), ("other_board", [], 1)):
+ config = [["env:sample", [["board", board], ["build_flags", flags + extra]]]]
+ result = subprocess.run([sys.executable, str(ROOT / "scripts/optional_display_profiles.py"), "sample"],
+ input=json.dumps(config), text=True, capture_output=True, check=False)
+ self.assertEqual(result.returncode, expected, result.stderr)
+ if not expected:
+ self.assertEqual(result.stdout.strip(), displays.marker(spec))
+
+ def test_real_generator_binds_each_optional_board_to_uploadable_files(self):
+ recipes = Recipes()
+ for spec, target in zip(displays.SPECS, TARGETS):
+ with self.subTest(target=target), tempfile.TemporaryDirectory() as folder:
+ stage = Path(folder)
+ (stage / "companion").mkdir()
+ family = "v1.18.0.1-dev-aaaaaaaa"
+ (stage / "release-plan.json").write_text(json.dumps(dict(source=SOURCE,
+ groups=[dict(key="companion", tag=family)])))
+ row = manifest(spec, target)
+ row.update(platformio_env="sample", files=[target + "-" + family + suffix for suffix in (".bin", "-merged.bin")])
+ (stage / "companion/TARGET-MANIFEST.json").write_text(json.dumps([row]))
+ flags = defines(recipes.value("env:" + target, "build_flags"))
+ result = generator.generate(stage, [["env:sample", [["board", spec["board"]],
+ ["build_flags", ["-D" + k + "=" + v for k, v in flags.items()]]]]])
+ self.assertEqual(result["profiles"][target]["displaySource"], SOURCE)
+ self.assertEqual(result["profiles"][target]["displayController"], spec["controller"])
+ self.assertTrue(result["profiles"][target]["displayAuto"])
+
+ def test_elf_marker_alone_cannot_qualify_the_downloaded_esp32_application(self):
+ spec = displays.SPECS[0]
+ with tempfile.TemporaryDirectory() as folder:
+ root = Path(folder)
+ elf, app, output = root / "firmware.elf", root / "firmware.bin", root / "capabilities.json"
+ elf.write_bytes(displays.marker(spec).encode())
+ command = [sys.executable, "-B", str(ROOT / "scripts/check_firmware_capabilities.py"),
+ "--image", str(elf), "--firmware-bin", str(app), "--output", str(output),
+ "--target", TARGETS[0], "--platform", "ESP32_PLATFORM", "--build-profile", "full",
+ "--expect-application", displays.CAPABILITY + "=" + displays.marker(spec)]
+ app.write_bytes(b"old display firmware without detection")
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
+ self.assertFalse(json.loads(output.read_text())["verified"])
+ app.write_bytes(b"prefix\0" + displays.marker(spec).encode() + b"\0suffix")
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+ self.assertTrue(json.loads(output.read_text())["verified"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_rc32_display_detection.py b/test/test_rc32_display_detection.py
new file mode 100644
index 000000000..f862079c9
--- /dev/null
+++ b/test/test_rc32_display_detection.py
@@ -0,0 +1,311 @@
+"""Exercise the RC32 optional T108 probe and actual NV3001B driver lifecycle."""
+from pathlib import Path
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+UI = ROOT / "src/helpers/ui"
+
+ARDUINO = r'''
+#pragma once
+#include
+#include
+#include
+#include
+#define LOW 0
+#define HIGH 1
+#define INPUT 0
+#define OUTPUT 1
+#define INPUT_PULLUP 2
+#define INPUT_PULLDOWN 3
+#define PROGMEM
+#define pgm_read_byte(p) (*(p))
+#define PIN_TFT_SCL 17
+#define PIN_TFT_SDA 38
+#define PIN_TFT_CS 39
+#define PIN_TFT_DC 16
+#define PIN_TFT_RST 4
+#define PIN_TFT_EN 6
+#define PIN_TFT_BL 5
+#ifndef PIN_TFT_EN_ACTIVE
+#define PIN_TFT_EN_ACTIVE LOW
+#endif
+#ifndef PIN_TFT_BL_ACTIVE
+#define PIN_TFT_BL_ACTIVE HIGH
+#endif
+inline int modes[48] = {}, levels[48] = {};
+inline uint64_t now_us = 0, reset_high_at = 0, reset_low_at = 0, reset_low_us = 0;
+inline unsigned gpio_events = 0, reads = 0, transactions = 0, spi_begins = 0, spi_ends = 0;
+inline unsigned spi_transfers = 0, pixel_bytes = 0, reset_count = 0;
+inline unsigned clock_edges = 0, command_bits = 0, data_bits = 0;
+inline unsigned per_command[2][4] = {};
+inline uint8_t command_value = 0, spi_command = 0;
+inline bool transaction = false, spi_active = false, init_done = false, rail_on = false;
+inline const char* scenario = "valid";
+inline uint32_t read_value = 0;
+inline bool is(const char* value) { return strcmp(scenario, value) == 0; }
+inline bool automatic() {
+#if defined(HELTEC_RC32) && defined(RC32_DISPLAY_AUTODETECT) && RC32_DISPLAY_AUTODETECT
+ return true;
+#else
+ return false;
+#endif
+}
+inline void panelPin(int pin) {
+ assert((pin == PIN_TFT_CS || pin == PIN_TFT_DC || pin == PIN_TFT_SDA || pin == PIN_TFT_SCL
+ || pin == PIN_TFT_RST || pin == PIN_TFT_EN || pin == PIN_TFT_BL || pin == 30)
+ && "touch only panel pins and explicitly shared power");
+}
+inline int commandIndex() {
+ if (command_value == 0x04) return 0;
+ if (command_value == 0xDA) return 1;
+ if (command_value == 0xDB) return 2;
+ assert(command_value == 0xDC); return 3;
+}
+inline unsigned responseBits() { return command_value == 0x04 ? 24 : 8; }
+inline void pinMode(int pin, int mode) {
+ panelPin(pin); ++gpio_events; modes[pin] = mode;
+ if (pin == PIN_TFT_SDA && (mode == INPUT_PULLUP || mode == INPUT_PULLDOWN)) {
+ assert(transaction && command_bits == 8);
+ const unsigned occurrence = per_command[mode == INPUT_PULLUP ? 0 : 1][commandIndex()]++;
+ uint32_t id = 0x300101;
+ if (is("wrong-id")) id = 0x123456;
+ if (is("biased") && mode == INPUT_PULLDOWN) id = 0x300102;
+ if (is("noise") && occurrence % 3 == 2) id = 0x300102;
+ if (is("prime") && occurrence % 3 == 0) id = 0;
+ if (is("recover") && reset_count == 1) id = 0;
+ if ((is("fallback") || is("bad-fallback")) && command_value == 0x04) id = 0;
+ if (is("bad-fallback") && command_value == 0xDC) id = 0x300102;
+ if (is("floating")) id = mode == INPUT_PULLUP ? 0xFFFFFF : 0;
+ if (is("stuck-high")) id = 0xFFFFFF;
+ if (is("stuck-low")) id = 0;
+ if (command_value == 0x04) read_value = id;
+ else read_value = (id >> ((3-commandIndex())*8)) & 0xFF;
+ }
+}
+inline void digitalWrite(int pin, int value) {
+ panelPin(pin); ++gpio_events;
+ if (automatic() && pin == PIN_TFT_BL && value == PIN_TFT_BL_ACTIVE)
+ assert(init_done && pixel_bytes >= 220u*128u*2u && "backlight only after initialization and clear");
+ if (pin == PIN_TFT_EN) {
+ if (automatic() && rail_on && value != PIN_TFT_EN_ACTIVE) {
+ assert(!spi_active);
+ for (int p : {PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_SDA, PIN_TFT_SCL})
+ assert(modes[p] == INPUT && "park bus before removing panel power");
+ }
+ rail_on = value == PIN_TFT_EN_ACTIVE;
+ }
+ if (pin == PIN_TFT_RST && value == LOW) reset_low_at = now_us;
+ if (pin == PIN_TFT_RST && value == HIGH && levels[pin] == LOW) {
+ reset_low_us = now_us-reset_low_at; reset_high_at = now_us; ++reset_count;
+ }
+ if (pin == PIN_TFT_CS && value == LOW && levels[pin] == HIGH && !spi_active) {
+ assert(automatic());
+ assert(rail_on && levels[PIN_TFT_BL] != PIN_TFT_BL_ACTIVE);
+ assert(now_us-reset_high_at >= 120000 && "probe reset settle");
+ assert(reset_low_us >= 120000 && "probe reset pulse");
+ transaction = true; ++transactions;
+ clock_edges = command_bits = data_bits = 0; command_value = 0;
+ }
+ if (pin == PIN_TFT_SCL && value == HIGH && transaction) {
+ ++clock_edges;
+ if (modes[PIN_TFT_SDA] == OUTPUT) {
+ assert(levels[PIN_TFT_DC] == LOW && command_bits < 8);
+ command_value = uint8_t((command_value << 1) | levels[PIN_TFT_SDA]);
+ ++command_bits;
+ }
+ }
+ if (pin == PIN_TFT_SCL && value == LOW && transaction && clock_edges == 8)
+ assert(modes[PIN_TFT_SDA] != OUTPUT && "release SDA before command falling edge");
+ if (pin == PIN_TFT_CS && value == HIGH && transaction) {
+ assert(command_bits == 8 && data_bits == responseBits());
+ assert(clock_edges == 8+responseBits()+(command_value == 0x04 ? 1 : 0));
+ transaction = false;
+ }
+ levels[pin] = value;
+}
+inline int digitalRead(int pin) {
+ assert(pin == PIN_TFT_SDA && transaction && levels[PIN_TFT_DC] == HIGH);
+ assert(modes[pin] == INPUT_PULLUP || modes[pin] == INPUT_PULLDOWN);
+ assert(clock_edges == 8+data_bits+(command_value == 0x04 ? 1 : 0));
+ assert(data_bits < responseBits() && levels[PIN_TFT_SCL] == LOW && "sample after turnaround falling edge");
+ ++reads;
+ return (read_value >> (responseBits()-1-data_bits++)) & 1;
+}
+inline void delay(unsigned ms) { now_us += uint64_t(ms)*1000; }
+inline void delayMicroseconds(unsigned us) { now_us += us; }
+'''
+
+SPI = r'''
+#pragma once
+#include
+#define HSPI 1
+#define MSBFIRST 1
+#define SPI_MODE0 0
+struct SPISettings { SPISettings(int, int, int) {} };
+struct SPIClass {
+ explicit SPIClass(int) {}
+ bool begin(int sck, int miso, int mosi, int cs) {
+ assert(sck == PIN_TFT_SCL && miso == -1 && mosi == PIN_TFT_SDA && cs == PIN_TFT_CS);
+ ++spi_begins; spi_active = true; init_done = false; pixel_bytes = 0;
+ modes[sck] = modes[mosi] = OUTPUT;
+ return !is("init-fail") && !(is("wake-fail") && spi_begins == 2);
+ }
+ void end() { assert(spi_active); ++spi_ends; spi_active = false; }
+ void beginTransaction(SPISettings) { assert(spi_active); }
+ void endTransaction() { assert(spi_active); }
+ uint8_t transfer(uint8_t data) {
+ assert(spi_active && rail_on);
+ assert(levels[PIN_TFT_CS] == LOW); ++spi_transfers;
+ if (levels[PIN_TFT_DC] == LOW) {
+ assert(now_us-reset_high_at >= 120000);
+ spi_command = data;
+ if (data == 0x29) init_done = true;
+ } else if (spi_command == 0x2C) {
+ assert(init_done); ++pixel_bytes;
+ }
+ return 0;
+ }
+};
+'''
+
+MAIN = r'''
+#include "NV3001BDisplay.h"
+void frame(NV3001BDisplay& d) {
+ const uint8_t bits[] = {0x80};
+ d.clear(); d.startFrame(); d.setTextSize(1); d.setColor(1); d.setCursor(0,0);
+ d.print("a"); d.printWordWrap("b",10); d.fillRect(0,0,1,1); d.drawRect(0,0,1,1);
+ d.drawXbm(0,0,bits,1,1); d.endFrame(); assert(d.getTextWidth("a") == 6);
+}
+int main(int argc, char** argv) {
+ assert(argc == 2); scenario = argv[1];
+ RefCountedDigitalPin power(30, HIGH); power.begin();
+ NV3001BDisplay d(&power);
+ if (automatic()) {
+ const auto events = gpio_events; d.turnOn(); frame(d); d.turnOff();
+ assert(gpio_events == events && spi_begins == 0 && spi_transfers == 0);
+ }
+ const bool absent = is("floating") || is("stuck-high") || is("stuck-low")
+ || is("noise") || is("biased") || is("wrong-id") || is("bad-fallback");
+ const bool begun = d.begin();
+ if (automatic() && is("init-fail")) {
+ assert(!begun && !d.isOn() && spi_begins == 1 && spi_transfers == 0 && spi_ends == 1);
+ assert(transactions == 6 && levels[30] == LOW && !rail_on);
+ assert(levels[PIN_TFT_BL] != PIN_TFT_BL_ACTIVE && levels[PIN_TFT_RST] == LOW);
+ const auto events=gpio_events; d.turnOn(); d.turnOff(); frame(d); assert(!d.begin());
+ assert(gpio_events == events);
+ return 0;
+ }
+ if (automatic() && absent) {
+ assert(!begun && !d.isOn() && "invalid panel must remain headless");
+ assert(spi_begins == 0 && spi_transfers == 0 && spi_ends == 0);
+ assert(transactions == 48 && reads == 576 && now_us < 800000);
+ assert(levels[PIN_TFT_EN] != PIN_TFT_EN_ACTIVE && levels[PIN_TFT_RST] == LOW);
+ assert(levels[PIN_TFT_BL] != PIN_TFT_BL_ACTIVE && levels[30] == LOW);
+ for (int p : {PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_SDA, PIN_TFT_SCL}) assert(modes[p] == INPUT);
+ const auto events=gpio_events; d.turnOn(); d.turnOff(); frame(d); assert(!d.begin());
+ assert(events == gpio_events && spi_begins == 0 && transactions == 48);
+ return 0;
+ }
+ assert(begun && d.isOn() && init_done && spi_begins == 1);
+ assert(levels[PIN_TFT_BL] == PIN_TFT_BL_ACTIVE && levels[30] == HIGH);
+ if (automatic()) assert(transactions == (is("fallback") ? 24u : is("recover") ? 30u : 6u));
+ else assert(transactions == 0 && reads == 0);
+ frame(d); const auto count=transactions, tx=spi_transfers;
+ assert(d.begin() && spi_begins == 1 && spi_transfers == tx);
+ d.turnOff(); assert(!d.isOn() && levels[PIN_TFT_EN] != PIN_TFT_EN_ACTIVE && levels[30] == LOW);
+ frame(d); assert(spi_transfers == tx);
+ if (automatic()) {
+ assert(spi_ends == 1 && !spi_active);
+ for (int p : {PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_SDA, PIN_TFT_SCL}) assert(modes[p] == INPUT);
+ }
+ d.turnOn();
+ if (automatic() && is("wake-fail")) {
+ assert(!d.isOn() && spi_begins == 2 && spi_ends == 2 && transactions == count);
+ assert(!rail_on && levels[30] == LOW && levels[PIN_TFT_RST] == LOW);
+ const auto events=gpio_events; d.turnOn(); d.turnOff(); frame(d); assert(!d.begin());
+ assert(gpio_events == events && spi_transfers == tx);
+ return 0;
+ }
+ assert(d.isOn() && spi_begins == 2 && transactions == count);
+ assert(levels[PIN_TFT_BL] == PIN_TFT_BL_ACTIVE && levels[30] == HIGH);
+ frame(d); assert(spi_transfers > tx);
+ d.turnOff(); d.turnOff(); assert(levels[30] == LOW);
+}
+'''
+
+
+class RC32DisplayDetectionTest(unittest.TestCase):
+ def build(self, work, flags=(), *, old_bias=False, old_turnaround=False):
+ work.mkdir()
+ for filename in ("NV3001BDisplay.cpp", "NV3001BDisplay.h", "RC32DisplayProbe.h"):
+ source = (UI / filename).read_text()
+ if old_bias and filename == "RC32DisplayProbe.h":
+ mutated = source.replace("ids[0] == ids[1]", "true")
+ self.assertNotEqual(mutated, source)
+ source = mutated
+ if old_turnaround and filename == "RC32DisplayProbe.h":
+ mutated = source.replace("if (mask == 1) pinMode(PIN_TFT_SDA, bias);", "")
+ self.assertNotEqual(mutated, source)
+ source = mutated.replace(" digitalWrite(PIN_TFT_DC, HIGH);",
+ " pinMode(PIN_TFT_SDA, bias);\n digitalWrite(PIN_TFT_DC, HIGH);", 1)
+ (work / filename).write_text(source)
+ for filename, source in {"Arduino.h": ARDUINO, "SPI.h": SPI, "main.cpp": MAIN}.items():
+ (work / filename).write_text(source)
+ sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
+ if sys.platform.startswith("linux") else [])
+ binary = work / "test"
+ compiled = subprocess.run([
+ os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
+ "-Wno-unused-parameter", "-Wno-type-limits", *sanitizer, *flags,
+ "-I"+str(work), "-I"+str(UI), "-I"+str(ROOT / "src"),
+ str(work / "main.cpp"), str(work / "NV3001BDisplay.cpp"), "-o", str(binary)
+ ], capture_output=True, text=True, timeout=60)
+ self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
+ return binary
+
+ def run_case(self, binary, scenario, *, negative=None):
+ ran = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10)
+ if negative:
+ self.assertNotEqual(ran.returncode, 0)
+ self.assertIn(negative, ran.stderr)
+ else:
+ self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
+
+ def test_absent_unstable_wrong_controller_stays_headless(self):
+ with tempfile.TemporaryDirectory(prefix="rc32-display-") as directory:
+ binary = self.build(Path(directory)/"auto", ["-DHELTEC_RC32", "-DRC32_DISPLAY_AUTODETECT=1"])
+ self.assertIn(b"meshcore.capability.display.autodetect.rc32.v1", binary.read_bytes())
+ for scenario in ("floating", "stuck-high", "stuck-low", "noise", "biased", "wrong-id", "bad-fallback", "init-fail"):
+ with self.subTest(scenario=scenario): self.run_case(binary, scenario)
+
+ def test_both_id_protocols_recovery_and_sleep_wake_preserve_screen(self):
+ with tempfile.TemporaryDirectory(prefix="rc32-display-") as directory:
+ for index, active in enumerate(([], ["-DPIN_TFT_EN_ACTIVE=HIGH", "-DPIN_TFT_BL_ACTIVE=LOW"])):
+ flags = ["-DHELTEC_RC32", "-DRC32_DISPLAY_AUTODETECT=1", *active]
+ binary = self.build(Path(directory)/str(index), flags)
+ for scenario in ("valid", "prime", "fallback", "recover", "wake-fail"):
+ with self.subTest(active=active, scenario=scenario): self.run_case(binary, scenario)
+
+ def test_legacy_and_other_nv3001b_boards_do_not_probe(self):
+ with tempfile.TemporaryDirectory(prefix="rc32-display-") as directory:
+ for index, flags in enumerate((["-DHELTEC_RC32"], ["-DHELTEC_RC32", "-DRC32_DISPLAY_AUTODETECT=0"],
+ ["-DRC32_DISPLAY_AUTODETECT=1"])):
+ binary = self.build(Path(directory)/str(index), flags)
+ self.run_case(binary, "valid")
+ self.assertNotIn(b"meshcore.capability.display.autodetect.rc32.v1", binary.read_bytes())
+
+ def test_negative_controls_detect_bias_and_sda_turnaround_regressions(self):
+ with tempfile.TemporaryDirectory(prefix="rc32-display-") as directory:
+ flags = ["-DHELTEC_RC32", "-DRC32_DISPLAY_AUTODETECT=1"]
+ binary = self.build(Path(directory)/"bias", flags, old_bias=True)
+ self.run_case(binary, "biased", negative="invalid panel must remain headless")
+ binary = self.build(Path(directory)/"turnaround", flags, old_turnaround=True)
+ self.run_case(binary, "valid", negative="release SDA before command falling edge")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_wireless_tracker_display_detection.py b/test/test_wireless_tracker_display_detection.py
new file mode 100644
index 000000000..db43b11c0
--- /dev/null
+++ b/test/test_wireless_tracker_display_detection.py
@@ -0,0 +1,294 @@
+"""Exercise optional Tracker V1 display reads and shared-rail driver lifecycle."""
+from pathlib import Path
+import os
+import subprocess
+import tempfile
+import unittest
+
+from cpp_source import body
+from test_serial_wifi_sessions import SANITIZER_FLAGS
+
+ROOT = Path(__file__).resolve().parents[1]
+UI = ROOT / "src/helpers/ui"
+
+ARDUINO = r'''
+#pragma once
+#include
+#include
+#include
+#include
+#define LOW 0
+#define HIGH 1
+#define INPUT 0
+#define OUTPUT 1
+#define INPUT_PULLUP 2
+#define INPUT_PULLDOWN 3
+#define PIN_TFT_SCL 41
+#define PIN_TFT_SDA 42
+#define PIN_TFT_RST 39
+#define PIN_TFT_LEDA_CTL 21
+#define PIN_TFT_CS 38
+#define PIN_TFT_DC 40
+#ifndef PIN_TFT_LEDA_CTL_ACTIVE
+#define PIN_TFT_LEDA_CTL_ACTIVE HIGH
+#endif
+inline int modes[48] = {}, levels[48] = {};
+inline uint64_t now_us = 0, reset_high_at = 0, reset_low_at = 0, reset_low_us = 0;
+inline unsigned gpio_events = 0, reads = 0, transactions = 0, spi_begins = 0;
+inline unsigned initializations = 0, allocations = 0, objects = 0, draws = 0;
+inline unsigned clock_edges = 0, command_bits = 0, data_bits = 0;
+inline uint8_t command_value = 0;
+inline bool transaction = false, fail_pixels = false, initialized = false;
+inline std::string scenario;
+inline uint32_t read_value = 0;
+inline bool automatic() {
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ return true;
+#else
+ return false;
+#endif
+}
+inline void check_panel_pin(int pin) {
+ assert((pin == PIN_TFT_CS || pin == PIN_TFT_DC || pin == PIN_TFT_SDA
+ || pin == PIN_TFT_SCL || pin == PIN_TFT_RST || pin == PIN_TFT_LEDA_CTL)
+ && "probe must never drive Vext, GNSS, or radio pins");
+}
+inline void pinMode(int pin, int mode) {
+ check_panel_pin(pin); ++gpio_events; modes[pin] = mode;
+ if (pin == PIN_TFT_SDA && (mode == INPUT_PULLUP || mode == INPUT_PULLDOWN)) {
+ assert(transaction && command_bits == 8 && command_value == 0x04);
+ const unsigned within_attempt = (transactions - 1) % 6;
+ read_value = 0x7c89f0;
+ if (scenario == "floating") read_value = mode == INPUT_PULLUP ? 0xffffff : 0;
+ if (scenario == "stuck-high") read_value = 0xffffff;
+ if (scenario == "stuck-low") read_value = 0;
+ if (scenario == "biased") read_value = mode == INPUT_PULLUP ? 0x7c89f0 : 0x123456;
+ if (scenario == "noise") read_value = within_attempt == 2 ? 0x7c89f1 : 0x7c89f0;
+ if (scenario == "recover" && transactions <= 6) read_value = 0;
+ if (scenario == "prime" && within_attempt % 3 == 0) read_value = 0;
+ if (scenario == "programmable") read_value = 0x123456;
+ }
+}
+inline void digitalWrite(int pin, int value) {
+ check_panel_pin(pin); ++gpio_events;
+ if (automatic() && pin == PIN_TFT_LEDA_CTL && value == PIN_TFT_LEDA_CTL_ACTIVE)
+ assert(initialized && "backlight must remain off until initialization");
+ if (pin == PIN_TFT_RST && value == HIGH && levels[pin] == LOW) {
+ reset_low_us = now_us - reset_low_at; reset_high_at = now_us;
+ }
+ if (pin == PIN_TFT_RST && value == LOW && levels[pin] == HIGH) reset_low_at = now_us;
+ if (pin == PIN_TFT_CS && value == LOW && levels[pin] == HIGH && spi_begins == 0) {
+ assert(automatic() && now_us - reset_high_at >= 120000 && reset_low_us >= 10000);
+ assert(levels[PIN_TFT_LEDA_CTL] != PIN_TFT_LEDA_CTL_ACTIVE);
+ transaction = true; ++transactions;
+ clock_edges = command_bits = data_bits = 0; command_value = 0;
+ }
+ if (pin == PIN_TFT_SCL && value == HIGH && transaction) {
+ ++clock_edges;
+ if (modes[PIN_TFT_SDA] == OUTPUT) {
+ assert(levels[PIN_TFT_DC] == LOW && command_bits < 8);
+ command_value = uint8_t((command_value << 1) | levels[PIN_TFT_SDA]);
+ ++command_bits;
+ }
+ }
+ if (pin == PIN_TFT_SCL && value == LOW && transaction && clock_edges == 8)
+ assert(modes[PIN_TFT_SDA] != OUTPUT && "release SDA before command falling edge");
+ if (pin == PIN_TFT_CS && value == HIGH && transaction) {
+ assert(command_bits == 8 && data_bits == 24 && clock_edges == 33);
+ transaction = false;
+ }
+ levels[pin] = value;
+}
+inline int digitalRead(int pin) {
+ assert(pin == PIN_TFT_SDA && transaction && levels[PIN_TFT_DC] == HIGH);
+ assert(modes[pin] == INPUT_PULLUP || modes[pin] == INPUT_PULLDOWN);
+ assert(clock_edges == 9 + data_bits && data_bits < 24 && levels[PIN_TFT_SCL] == LOW);
+ ++reads;
+ return (read_value >> (23 - data_bits++)) & 1;
+}
+inline void delay(unsigned ms) { now_us += uint64_t(ms) * 1000; }
+inline void delayMicroseconds(unsigned us) { now_us += us; }
+'''
+
+HARNESS = r'''
+@POLICY@
+#include
+#include "WirelessTrackerDisplayProbe.h"
+constexpr int SPI_CLOCK_DIV2=2, ST77XX_NORON=0x13, ST77XX_DISPON=0x29;
+constexpr int ST77XX_DISPOFF=0x28, ST77XX_SLPIN=0x10;
+constexpr uint16_t ST77XX_BLACK=0, ST77XX_WHITE=0xffff;
+constexpr int ST7735_BUFFER_COLOR_DEPTH=16;
+struct Spi {
+ void begin(int scl, int miso, int sda, int cs) {
+ assert(scl==41 && miso==-1 && sda==42 && cs==-1);
+ if (automatic()) assert(transactions>=6);
+ ++spi_begins;
+ }
+ void setClockDivider(int) {}
+} spi;
+static Spi* _spi=&spi;
+static int _height, _width, _colstart, _rowstart, lcd;
+struct TFT_eSprite {
+ bool valid=false;
+ explicit TFT_eSprite(int*) { ++objects; }
+ void setColorDepth(int) {}
+ void* createSprite(int w,int h) {
+ assert(w==160 && h==80); ++allocations;
+ valid=!fail_pixels; fail_pixels=false;
+ return getPointer();
+ }
+ void* getPointer() { return valid ? this : nullptr; }
+ void fillScreen(uint16_t) { assert(valid); ++draws; }
+ void setTextColor(uint16_t) { assert(valid); }
+};
+static TFT_eSprite* sprite=nullptr;
+static uint32_t curr_color;
+@READY@
+namespace mesh {
+struct Logger { void println(const char*) {} } logger;
+Logger& usbLoggingPort() { return logger; }
+}
+struct Rail {
+ unsigned claims;
+ unsigned taken=0,released=0;
+ void claim() { ++claims; ++taken; }
+ void release() { assert(claims); --claims; ++released; }
+};
+void sendCommand(int) {}
+@MARKER@
+class ST7735Display {
+public:
+ bool _isOn=false;
+ Rail* _peripher_power;
+#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT
+ bool _probeComplete=false,_present=false;
+#endif
+ explicit ST7735Display(Rail* power) : _peripher_power(power) {}
+ bool begin();
+ void turnOn();
+ void turnOff();
+ void _resetAndInit() { ++initializations; initialized=true; }
+ void endFrame() { if (spriteReady()) { assert(initialized); ++draws; } }
+};
+@METHODS@
+int main(int argc,char** argv) {
+ assert(argc==3); scenario=argv[1];
+ const unsigned gps_claims=unsigned(std::stoul(argv[2]));
+ Rail rail{gps_claims}; ST7735Display display(&rail);
+ const bool invalid=scenario=="floating" || scenario=="stuck-high" || scenario=="stuck-low"
+ || scenario=="biased" || scenario=="noise";
+ if (scenario=="alloc-fail") fail_pixels=true;
+ const bool began=display.begin();
+ if (automatic() && invalid) {
+ assert(!began && !display._isOn && "absent panel must remain headless");
+ assert(allocations==0 && objects==0 && spi_begins==0 && initializations==0);
+ assert(rail.claims==gps_claims && rail.taken==1 && rail.released==1);
+ assert(levels[PIN_TFT_RST]==LOW && levels[PIN_TFT_LEDA_CTL]!=PIN_TFT_LEDA_CTL_ACTIVE);
+ for (int pin : {PIN_TFT_CS,PIN_TFT_DC,PIN_TFT_SDA,PIN_TFT_SCL}) assert(modes[pin]==INPUT);
+ const auto events= gpio_events;
+ assert(!display.begin()); display.turnOn(); display.turnOff(); display.endFrame();
+ assert(events==gpio_events && transactions==12 && now_us<400000);
+ assert(rail.claims==gps_claims && allocations==0 && draws==0);
+ return 0;
+ }
+ if (scenario=="alloc-fail") {
+ assert(!began && sprite==nullptr && allocations==1 && spi_begins==0);
+ assert(rail.claims==gps_claims);
+ assert(display.begin());
+ assert(allocations==2 && rail.claims==gps_claims+1);
+ } else {
+ assert(began && allocations==1 && rail.claims==gps_claims+1);
+ }
+ assert(display._isOn && initializations==1);
+ const auto probe_count=transactions;
+ if (automatic()) assert(probe_count==(scenario=="recover" ? 12u : 6u));
+ else assert(probe_count==0 && reads==0);
+ assert(levels[PIN_TFT_LEDA_CTL]==PIN_TFT_LEDA_CTL_ACTIVE);
+ const auto pixels=sprite; const auto allocated=allocations; const auto events=gpio_events;
+ assert(display.begin() && gpio_events==events && rail.claims==gps_claims+1);
+ display.turnOff(); assert(rail.claims==gps_claims && !display._isOn);
+ display.turnOn();
+ assert(display._isOn && rail.claims==gps_claims+1 && initializations==2);
+ assert(transactions==probe_count && allocations==allocated && sprite==pixels);
+ display.turnOff(); assert(rail.claims==gps_claims);
+ delete sprite;
+}
+'''
+
+
+class WirelessTrackerDisplayDetectionTests(unittest.TestCase):
+ def build(self, work, flags, *, old_bias=False, old_turnaround=False):
+ work.mkdir()
+ driver = (UI / "ST7735Display.cpp").read_text()
+ header = (UI / "ST7735Display.h").read_text()
+ policy = header[header.index("#if defined(WIRELESS_TRACKER_DISPLAY_AUTODETECT)"):header.index("class ST7735Display")]
+ marker = driver[driver.index("#ifdef MESHCORE_TRACKER_DISPLAY_AUTODETECT"):driver.index("// Most ST7735")]
+ methods = "\n".join(body(driver, signature) for signature in (
+ "bool ST7735Display::begin()", "void ST7735Display::turnOn()", "void ST7735Display::turnOff()"))
+ source = (HARNESS.replace("@POLICY@", policy).replace("@MARKER@", marker)
+ .replace("@READY@", body(driver, "static bool spriteReady()"))
+ .replace("@METHODS@", methods))
+ probe = (UI / "WirelessTrackerDisplayProbe.h").read_text()
+ if old_bias:
+ probe = probe.replace("ids[0] == ids[1]", "true")
+ if old_turnaround:
+ probe = probe.replace("if (mask == 1) pinMode(PIN_TFT_SDA, bias);", "")
+ probe = probe.replace(" digitalWrite(PIN_TFT_DC, HIGH);", " pinMode(PIN_TFT_SDA, bias);\n digitalWrite(PIN_TFT_DC, HIGH);", 1)
+ (work / "Arduino.h").write_text(ARDUINO)
+ (work / "WirelessTrackerDisplayProbe.h").write_text(probe)
+ (work / "test.cpp").write_text(source)
+ binary = work / "test"
+ compiled = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
+ "-Wno-unused-variable", *SANITIZER_FLAGS, "-DESP_PLATFORM=1", *flags, "-I"+str(work),
+ str(work / "test.cpp"), "-o", str(binary)], capture_output=True, text=True, timeout=60)
+ self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
+ return binary
+
+ def run_case(self, binary, scenario, gps=1, negative=None):
+ result = subprocess.run([str(binary), scenario, str(gps)], 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_optional_tracker_rejects_absence_noise_and_preserves_gps_power(self):
+ with tempfile.TemporaryDirectory(prefix="tracker-detect-") as directory:
+ for board in ("HELTEC_LORA_V3", "HELTEC_TRACKER_V1_1"):
+ binary = self.build(Path(directory)/board, ["-D"+board, "-DWIRELESS_TRACKER_DISPLAY_AUTODETECT=1"])
+ self.assertIn(b"meshcore.capability.display.autodetect.tracker.v1", binary.read_bytes())
+ for scenario in ("floating", "stuck-high", "stuck-low", "biased", "noise"):
+ for gps in (0, 1):
+ with self.subTest(board=board, scenario=scenario, gps=gps): self.run_case(binary, scenario, gps)
+
+ def test_present_programmable_id_reset_recovery_and_allocation_retry(self):
+ with tempfile.TemporaryDirectory(prefix="tracker-detect-") as directory:
+ for active in (0, 1):
+ binary = self.build(Path(directory)/str(active), ["-DHELTEC_LORA_V3", "-DWIRELESS_TRACKER_DISPLAY_AUTODETECT=1",
+ f"-DPIN_TFT_LEDA_CTL_ACTIVE={active}"])
+ for scenario in ("valid", "programmable", "prime", "recover", "alloc-fail"):
+ for gps in (0, 1):
+ with self.subTest(active=active, scenario=scenario, gps=gps): self.run_case(binary, scenario, gps)
+
+ def test_other_boards_and_disabled_option_keep_existing_initialization(self):
+ with tempfile.TemporaryDirectory(prefix="tracker-detect-") as directory:
+ variants = (["-DHELTEC_LORA_V3"], ["-DHELTEC_LORA_V3", "-DWIRELESS_TRACKER_DISPLAY_AUTODETECT=0"],
+ ["-DWIRELESS_TRACKER_DISPLAY_AUTODETECT=1"],
+ *[["-DWIRELESS_TRACKER_DISPLAY_AUTODETECT=1", "-DHELTEC_LORA_V3", "-D"+board]
+ for board in ("HELTEC_TRACKER_V2", "HELTEC_T096", "HELTEC_T1")])
+ for index, flags in enumerate(variants):
+ binary = self.build(Path(directory)/str(index), flags)
+ self.assertNotIn(b"meshcore.capability.display.autodetect.tracker.v1", binary.read_bytes())
+ self.run_case(binary, "floating")
+
+ def test_opposite_pull_and_turnaround_negative_controls_fail(self):
+ with tempfile.TemporaryDirectory(prefix="tracker-detect-") as directory:
+ flags = ["-DHELTEC_LORA_V3", "-DWIRELESS_TRACKER_DISPLAY_AUTODETECT=1"]
+ binary = self.build(Path(directory)/"bias", flags, old_bias=True)
+ self.run_case(binary, "biased", negative="absent panel must remain headless")
+ binary = self.build(Path(directory)/"turnaround", flags, old_turnaround=True)
+ self.run_case(binary, "valid", negative="release SDA before command falling edge")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/variants/heltec_rc32/platformio.ini b/variants/heltec_rc32/platformio.ini
index 9ac08f570..72e2d1425 100644
--- a/variants/heltec_rc32/platformio.ini
+++ b/variants/heltec_rc32/platformio.ini
@@ -71,6 +71,7 @@ extends = Heltec_RC32
build_flags =
${Heltec_RC32.build_flags}
-D HELTEC_RC32_WITH_DISPLAY
+ -D RC32_DISPLAY_AUTODETECT=1
-D DISPLAY_CLASS=NV3001BDisplay
build_src_filter = ${Heltec_RC32.build_src_filter}
+
diff --git a/variants/heltec_tracker/platformio.ini b/variants/heltec_tracker/platformio.ini
index c2798e888..262b9d7b6 100644
--- a/variants/heltec_tracker/platformio.ini
+++ b/variants/heltec_tracker/platformio.ini
@@ -29,6 +29,7 @@ build_flags =
-D PIN_TFT_DC=40 ; RS (register select)
-D PIN_TFT_RST=39 ; RES
-D PIN_TFT_CS=38
+ -D WIRELESS_TRACKER_DISPLAY_AUTODETECT=1
-D USE_PIN_TFT=1
-D PIN_VEXT_EN=3 ; Vext is connected to VDD which is also connected to OLED & GPS
-D PIN_TFT_LEDA_CTL=21 ; LEDK (switches on/off via mosfet to create the ground)
diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini
index cf069a04e..cb8329905 100644
--- a/variants/heltec_tracker_v2/platformio.ini
+++ b/variants/heltec_tracker_v2/platformio.ini
@@ -66,6 +66,7 @@ build_flags =
${sensor_base.build_flags}
-I variants/heltec_tracker_v2
-D HELTEC_TRACKER_V1_1
+ -D WIRELESS_TRACKER_DISPLAY_AUTODETECT=1
-D ESP32_CPU_FREQ=240
-D USE_SX1262
-D RADIO_CLASS=CustomSX1262
diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini
index 0953b9973..ddb9ee7c8 100644
--- a/variants/heltec_v4_r8/platformio.ini
+++ b/variants/heltec_v4_r8/platformio.ini
@@ -62,6 +62,7 @@ extends = Heltec_v4_r8
build_flags =
${Heltec_v4_r8.build_flags}
-D HELTEC_V4_R8_TFT
+ -D HELTEC_TFT_DISPLAY_AUTODETECT=1
-D PIN_BOARD_SDA=17
-D PIN_BOARD_SCL=18
-D PIN_TFT_RST=21
@@ -73,6 +74,7 @@ build_flags =
-D PIN_TFT_SCL=16
-D PIN_TFT_SDA=15
-D PIN_TFT_MISO=45
+ -D SDCARD_CS=3 ; Expansion V2 microSD shares the LCD bus; keep deselected.
-D PIN_BUZZER=4
build_src_filter = ${Heltec_v4_r8.build_src_filter}
+