Detect manufacturer-supported optional screens on RC32, Tracker V1 and V4 R8

This commit is contained in:
mikecarper committed 2026-10-18 18:55:38 -07:00
1 parent 3a425db6a9
commit 4f746d1e3b
25 files changed
+1808 -4

No files matched your search

+27
View File
@@ -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
+8
View File
@@ -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" \
+23
View File
@@ -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,
+3
View File
@@ -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
+88
View File
@@ -0,0 +1,88 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/optional_screens/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# 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.
+2 -2
View File
@@ -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)
+81
View File
@@ -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))
+108
View File
@@ -0,0 +1,108 @@
#pragma once
#include <Arduino.h>
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
+40
View File
@@ -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();
}
+4
View File
@@ -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;
+102
View File
@@ -0,0 +1,102 @@
#pragma once
#include <Arduino.h>
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
+46 -1
View File
@@ -5,6 +5,11 @@
#endif
#include <TFT_eSPI.h>
#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);
+12
View File
@@ -5,11 +5,23 @@
#include <SPI.h>
#include <helpers/RefCountedDigitalPin.h>
// 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:
+85 -1
View File
@@ -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);
+10
View File
@@ -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;
@@ -0,0 +1,86 @@
#pragma once
#include <Arduino.h>
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
+313
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstring>
#include <string>
#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 <Arduino.h>
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 <SPI.h>
#define ST77XX_BLACK 0
#define ST77XX_WHITE 0xffff
class Adafruit_ST7789 {
int _w=240, _h=320;
public:
template<class... T> 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()
+35
View File
@@ -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");
+125
View File
@@ -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()
+311
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstring>
#include <initializer_list>
#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 <Arduino.h>
#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()
@@ -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 <cassert>
#include <cstdint>
#include <string>
#include <vector>
#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 <Arduino.h>
#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()
+1
View File
@@ -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}
+<helpers/ui/NV3001BDisplay.cpp>
+1
View File
@@ -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)
@@ -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
+2
View File
@@ -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}
+<helpers/ui/buzzer.cpp>