From 4f746d1e3bc171e2f83478b342399b5a32b7e422 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 18 Oct 2026 18:55:38 -0700 Subject: [PATCH] Detect manufacturer-supported optional screens on RC32, Tracker V1 and V4 R8 --- .github/workflows/run-unit-tests.yml | 27 ++ build_legacy.sh | 8 + docs/_javascript/firmware_picker.js | 23 ++ docs/firmware_picker.md | 3 + docs/optional_screens.md | 88 +++++ scripts/generate_picker_controls.py | 4 +- scripts/optional_display_profiles.py | 81 +++++ src/helpers/ui/HeltecR8DisplayProbe.h | 108 ++++++ src/helpers/ui/NV3001BDisplay.cpp | 40 +++ src/helpers/ui/NV3001BDisplay.h | 4 + src/helpers/ui/RC32DisplayProbe.h | 102 ++++++ src/helpers/ui/ST7735Display.cpp | 47 ++- src/helpers/ui/ST7735Display.h | 12 + src/helpers/ui/ST7789LCDDisplay.cpp | 86 ++++- src/helpers/ui/ST7789LCDDisplay.h | 10 + src/helpers/ui/WirelessTrackerDisplayProbe.h | 86 +++++ test/test_heltec_r8_display_detection.py | 313 ++++++++++++++++++ test/test_optional_display_picker.js | 35 ++ test/test_optional_display_profiles.py | 125 +++++++ test/test_rc32_display_detection.py | 311 +++++++++++++++++ ...test_wireless_tracker_display_detection.py | 294 ++++++++++++++++ variants/heltec_rc32/platformio.ini | 1 + variants/heltec_tracker/platformio.ini | 1 + variants/heltec_tracker_v2/platformio.ini | 1 + variants/heltec_v4_r8/platformio.ini | 2 + 25 files changed, 1808 insertions(+), 4 deletions(-) create mode 100644 docs/optional_screens.md create mode 100644 scripts/optional_display_profiles.py create mode 100644 src/helpers/ui/HeltecR8DisplayProbe.h create mode 100644 src/helpers/ui/RC32DisplayProbe.h create mode 100644 src/helpers/ui/WirelessTrackerDisplayProbe.h create mode 100644 test/test_heltec_r8_display_detection.py create mode 100644 test/test_optional_display_picker.js create mode 100644 test/test_optional_display_profiles.py create mode 100644 test/test_rc32_display_detection.py create mode 100644 test/test_wireless_tracker_display_detection.py 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 @@ + + + + +# 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} +