Make T114 screen detection the default firmware and picker choice

This commit is contained in:
mikecarper committed 2026-10-18 16:20:27 -07:00
1 parent 864d0cacff
commit 1ce3f0cdd7
22 files changed
+1686 -124

No files matched your search

+16
View File
@@ -35,6 +35,12 @@ jobs:
--firmware-version v1.17.1.10-halo-keymind-cascade-dev
--radio-preset usa-cascade-fixed --profile cascade --require-ota
- name: Qualify unified T114 Full Companion with automatic screen detection
run: >-
bash build.sh build-firmware Heltec_t114_companion_radio_full
--firmware-version v1.18.0.1-halo-keymind-cascade-dev
--radio-preset usa-cascade-fixed --profile cascade --require-ota
- name: Compile both legacy T096 BLE FEM settings with their RAM gates
# Sequential environments in one process; do not replace these with
# Full overlays, which have different OTA and runtime allocations.
@@ -52,6 +58,11 @@ jobs:
python3 -B test/test_endf_restamp.py -v
- name: Compile GPS Sensor tracker on nRF52 boards
run: pio run -e t1000e_sensor -e RAK_WisMesh_Tag_sensor
- name: Qualify unified T114 Repeater with screen detection and LoRa OTA
run: >-
bash build.sh build-firmware Heltec_t114_repeater_lora_ota_no_external_sensors
--firmware-version v1.18.0.1-halo-keymind-cascade-dev
--radio-preset usa-cascade-fixed --profile cascade --require-ota
- name: Compile optional RAK15002 SD archive (no hardware qualification)
run: pio run -e RAK_4631_repeater_rak15002_sd_seeder_only
@@ -786,9 +797,12 @@ jobs:
python3 -B test/test_picker_controls_generator.py
python3 -B test/test_firmware_picker_package.py
node test/test_firmware_picker.js
node test/test_t114_picker.js
python3 -B test/test_t114_release_profiles.py -v
- name: Verify Full logging compiler flags and packaged capabilities
run: |
python3 -B test/test_t114_autodisplay_recipes.py -v
python3 -B test/test_station_g3_radio_pins.py -v
python3 -B test/test_station_g3_cli_dispatch.py -v
python3 -B test/test_station_g3_fem_control.py -v
@@ -918,6 +932,8 @@ jobs:
python3 -B test/test_backlight_poll_rollover.py -v
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_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
python3 -B test/test_infrastructure_ui_version.py -v
+40
View File
@@ -3552,6 +3552,10 @@ declare_build_capability_contract() {
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" \
"meshcore.capability.display.autodetect.t114.v1"
fi
# Prove the real DFU service is linked, not merely a generic OTA CLI stub.
record_build_expectation "ota.update.bluetooth" "_ZN6BLEDfu5beginEv"
if { is_lora_ota_build "$env_name" \
@@ -5797,6 +5801,9 @@ is_runtime_setting_alias_target() {
if get_station_g3_shared_slot_replacement "$1" >/dev/null; then
return 0
fi
if get_t114_display_replacement "$1" >/dev/null; then
return 0
fi
if is_legacy_companion_power_saving_target "$1" \
|| is_legacy_companion_femoff_target "$1" \
|| is_legacy_radio_gain_profile_target "$1" \
@@ -5811,6 +5818,39 @@ is_runtime_setting_alias_target() {
return 1
}
is_t114_display_autodetect_build() {
local target=$1
[ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" = NRF52_PLATFORM ] \
&& [ "${PIO_ENV_BOARD_BY_NAME[$target]:-}" = heltec_t114 ] \
&& [ -n "$PIO_CONFIG_JSON" ] || return 1
python3 -c '
import json,re,sys
rows={name:dict(options) for name,options in json.load(sys.stdin)}
definitions={}
for flag in rows.get("env:"+sys.argv[1],{}).get("build_flags",[]):
match=re.fullmatch(r"-([DU])\s*(\w+)(?:=(.*))?",str(flag).strip())
if not match: continue
kind,key,value=match.groups()
if kind=="U": definitions.pop(key,None)
else: definitions[key]="1" if value is None else value
sys.exit(0 if definitions.get("HELTEC_T114","0") not in ("0","false")
and definitions.get("T114_DISPLAY_AUTODETECT")=="1"
and definitions.get("DISPLAY_CLASS")=="ST7789Display" else 1)
' "$target" <<<"$PIO_CONFIG_JSON"
}
get_t114_display_replacement() {
local target=$1 candidate lower=${1,,}
# Synthetic lean OTA environments are generated before bulk filtering;
# filter their exact aliases as well as the original five recipe names.
[[ "$lower" =~ ^heltec_t114_without_display_((repeater(_bridge_rs232)?|room_server)(_lora_ota_no_external_sensors)?|companion_radio_(ble|usb|full))$ ]] || return 1
candidate=Heltec_t114_${lower#heltec_t114_without_display_}
[ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" = NRF52_PLATFORM ] \
&& [ "${PIO_ENV_BOARD_BY_NAME[$target]:-}" = heltec_t114 ] || return 1
is_t114_display_autodetect_build "$(get_pio_build_env "$candidate")" || return 1
printf '%s\n' "$candidate"
}
get_station_g3_shared_slot_replacement() {
local target=$1 candidate
case "$target" in
+144 -15
View File
@@ -252,7 +252,27 @@
return release.published_at || release.created_at || "";
}
function selectReleaseSet(releases) {
function isT114PreviewTag(tag) {
return /^heltec-t114-v\d+(?:\.\d+){2,3}\.preview[1-9]\d*$/.test(tag || "");
}
function controlsForRelease(controlData, tag) {
if (!controlData) return null;
if ((controlData.releaseTag || controlData.familyTag) === tag) return controlData;
return (Array.isArray(controlData.supplementalReleases) ? controlData.supplementalReleases : [])
.find(function (entry) { return entry && entry.releaseTag === tag; }) || null;
}
function t114PreviewControls(controlData, tag) {
const entry = controlsForRelease(controlData, tag);
if (!isT114PreviewTag(tag) || !entry || !/^[0-9a-f]{40}$/.test(entry.source || "")) return null;
const family = tag.slice("heltec-t114-".length);
const hash = String(entry.familyTag || "").slice(family.length + 1);
return entry.familyTag === family + "-" + hash && /^[0-9a-f]{8,40}$/.test(hash) &&
entry.source.startsWith(hash) && entry.profiles ? entry : null;
}
function selectReleaseSet(releases, controlData) {
const publicReleases = (Array.isArray(releases) ? releases : []).filter(
function (release) {
return release && !release.draft && typeof release.tag_name === "string";
@@ -263,10 +283,14 @@
}).sort(function (a, b) {
return releaseDate(b).localeCompare(releaseDate(a));
});
const main = mainReleases[0];
const supplements = publicReleases.filter(function (release) {
return Boolean(t114PreviewControls(controlData, release.tag_name));
}).sort(function (a, b) { return releaseDate(b).localeCompare(releaseDate(a)); });
const main = mainReleases[0] || supplements[0];
if (!main) return null;
const familyTag = main.tag_name;
const mainSupplement = t114PreviewControls(controlData, main.tag_name);
const familyTag = mainSupplement ? mainSupplement.familyTag : main.tag_name;
const acceptedTags = new Set(RELEASE_PAGE_PREFIXES.map(function (prefix) {
return prefix + familyTag;
}));
@@ -276,7 +300,7 @@
const chunkTag = new RegExp("^(?:" + chunkGroups.map(escapeRegExp).join("|") +
")-(?:[2-9]|[1-9][0-9]+)-" + escapeRegExp(familyTag) + "$");
const familyReleases = publicReleases.filter(function (release) {
return acceptedTags.has(release.tag_name) || chunkTag.test(release.tag_name);
return acceptedTags.has(release.tag_name) || chunkTag.test(release.tag_name) || supplements.includes(release);
}).sort(function (a, b) {
return releaseDate(b).localeCompare(releaseDate(a));
});
@@ -287,6 +311,10 @@
url: isSafeGithubUrl(main.html_url) ? main.html_url : "",
prerelease: Boolean(main.prerelease),
releases: familyReleases,
supplementalReleases: supplements.map(function (release) {
const entry = t114PreviewControls(controlData, release.tag_name);
return { releaseTag: release.tag_name, familyTag: entry.familyTag, source: entry.source };
}),
};
}
@@ -309,6 +337,12 @@
? release.html_url
: "",
publishedAt: releaseDate(release),
familyTag: ((releaseSet.supplementalReleases || []).find(function (entry) {
return entry.releaseTag === release.tag_name;
}) || {}).familyTag || releaseSet.familyTag,
t114Preview: (releaseSet.supplementalReleases || []).some(function (entry) {
return entry.releaseTag === release.tag_name;
}),
});
}
);
@@ -686,6 +720,57 @@
}));
}
function t114AutoDisplayProfile(profile) {
const controls = profile && profile.controls;
return Boolean(controls && controls.platform === "NRF52_PLATFORM" &&
controls.display === true && controls.displayAuto === true && controls.displayController === "st7789" &&
/^[0-9a-f]{40}$/.test(controls.displaySource || "") &&
/^heltec_t114_/i.test(profile.target || "") &&
Array.isArray(profile.files) && profile.files.length > 0 && profile.files.every(function (file) {
const hash = String(file.name || "").match(/-([0-9a-f]{8,40})\.(?:hex|uf2|zip)$/i);
return hash && controls.displaySource.startsWith(hash[1].toLowerCase());
}));
}
function t114HeadlessModemProfile(profile) {
return Boolean(profile && profile.role === "kiss" && profile.controls &&
profile.controls.platform === "NRF52_PLATFORM" && profile.controls.display === false &&
/^[0-9a-f]{40}$/.test(profile.controlsSource || "") && profile.files.length > 0 &&
profile.files.every(function (file) {
const hash = file.name.match(/-([0-9a-f]{8,40})\.(?:hex|uf2|zip)$/i);
return hash && profile.controlsSource.startsWith(hash[1].toLowerCase());
}));
}
function omitT114DisplayDuplicates(profiles) {
const families = new Map();
function role(profile) { return profile.role || parseTargetProfile(profile.target).role; }
function family(profile) { return profile.releaseFamily || profile.files[0].familyTag || ""; }
function qualified(profile) {
return /^heltec_t114_/i.test(profile.target || "") && !/_without_display_/i.test(profile.target) &&
(t114AutoDisplayProfile(profile) || t114HeadlessModemProfile(profile));
}
(profiles || []).forEach(function (profile) {
if (!qualified(profile)) return;
const key = role(profile);
const previous = families.get(key);
const date = String(profile.files[0].publishedAt || "");
const previousDate = previous && String(previous.files[0].publishedAt || "");
if (!previous || date > previousDate ||
(date === previousDate && profile.t114Preview && !previous.t114Preview)) {
families.set(key, profile);
}
});
return (profiles || []).filter(function (profile) {
if (!/^heltec_t114_/i.test(profile.target || "")) return true;
const replacement = families.get(role(profile));
// Recommend the newest qualified family for this role, including its
// real sensor/OTA choices. Historical screen-assumed and headless
// policies stay in raw rows, never renamed into a new OTA identity.
return !replacement || qualified(profile) && family(profile) === family(replacement);
});
}
function hardwareChoiceLabel(field, value, profiles, family) {
const matches = (profiles || []).filter(function (profile) {
return profile[field] === value;
@@ -696,6 +781,8 @@
})) {
return "Station G3 ESP32 (automatic slot detection)";
}
if (String(value).toLowerCase() === "heltec_t114" && matches.length > 0 &&
matches.every(t114AutoDisplayProfile)) return "Heltec T114 (automatic screen detection)";
return field === "hardware" && family
? humanizeHardwareVariant(value, family) : humanizeHardware(value);
}
@@ -971,25 +1058,27 @@
}
function buildCatalog(releases, controlData) {
const releaseSet = selectReleaseSet(releases);
const releaseSet = selectReleaseSet(releases, controlData);
if (!releaseSet) {
return { releaseSet: null, rows: [], profiles: [] };
}
const rows = flattenReleaseAssets(releaseSet);
const firmwareRows = rows.map(function (row) {
return parseFirmwareAsset(row, releaseSet.familyTag);
return parseFirmwareAsset(row, row.familyTag);
}).filter(Boolean);
const grouped = new Map();
firmwareRows.forEach(function (file) {
let profile = grouped.get(file.target);
const key = file.familyTag + ":" + file.target;
let profile = grouped.get(key);
if (!profile) {
profile = Object.assign(parseTargetProfile(file.target), {
files: [],
otaPackaged: false,
releaseFamily: releaseSet.familyTag,
releaseFamily: file.familyTag,
t114Preview: file.t114Preview,
});
grouped.set(file.target, profile);
grouped.set(key, profile);
}
profile.files.push(file);
profile.otaPackaged = profile.otaPackaged || file.otaPackaged;
@@ -1004,8 +1093,25 @@
profile.files.sort(function (a, b) {
return a.name.localeCompare(b.name);
});
if (profile.t114Preview) {
const metadata = controlsForRelease(controlData, profile.files[0].releaseTag);
profile.controls = metadata && metadata.profiles[profile.target];
profile.controlsSource = metadata && metadata.source;
if (profile.controls) applyReleaseRuntimeCapabilities(profile, profile.controls);
}
return profile;
}).filter(function (profile) {
if (profile.t114Preview) {
const metadata = controlsForRelease(controlData, profile.files[0].releaseTag);
const hashesMatch = metadata && profile.files.every(function (file) {
const hash = file.name.match(/-([0-9a-f]{8,40})\.(?:hex|uf2|zip)$/i);
return hash && metadata.source.startsWith(hash[1].toLowerCase());
});
if (!hashesMatch || String(profile.hardware).toLowerCase() !== "heltec_t114" ||
!profile.controls || profile.controls.platform !== "NRF52_PLATFORM" ||
!(t114AutoDisplayProfile(profile) && profile.controls.displaySource === metadata.source ||
profile.role === "kiss" && profile.controls.display === false)) return false;
}
return !isHiddenLegacyProfile(profile);
});
const profiles = omitLegacyObserverCompatibilityProfiles(
@@ -1029,12 +1135,14 @@
return profile.hardware;
})));
profiles.forEach(function (profile) {
if (controlData && controlData.familyTag === releaseSet.familyTag &&
controlData.profiles && controlData.profiles[profile.target]) {
profile.controls = controlData.profiles[profile.target];
const metadata = profile.t114Preview ? controlsForRelease(controlData, profile.files[0].releaseTag) : controlData;
if (metadata && metadata.familyTag === profile.releaseFamily &&
metadata.profiles && metadata.profiles[profile.target]) {
profile.controls = metadata.profiles[profile.target];
profile.controlsSource = metadata.source;
applyReleaseRuntimeCapabilities(profile, profile.controls);
profile.migrationPackage = migrationPackageForProfile(
profile, controlData.partitionMigrations
profile, metadata.partitionMigrations
);
profile.migrationAsset = migrationAssetForProfile(profile, rows);
if (isFullCompanion(profile) && profile.controls.mqtt &&
@@ -1078,10 +1186,14 @@
return {
releaseSet: releaseSet,
rows: firmwareRows,
rows: firmwareRows.filter(function (file) {
return !file.t114Preview || profiles.some(function (profile) {
return profile.t114Preview && profile.files.includes(file);
});
}),
// Recommendation filters retain original files and target identities in
// the raw catalog, including when a qualified successor replaces them.
profiles: omitReplacedQspiProfiles(omitStationG3SlotDuplicates(profiles)),
profiles: omitT114DisplayDuplicates(omitReplacedQspiProfiles(omitStationG3SlotDuplicates(profiles))),
};
}
@@ -1394,6 +1506,17 @@
FACET_FIELDS.forEach(function (field) {
requested[field] = params.get(field) || "";
});
if (!profiles.some(function (profile) {
return String(profile.hardware).toLowerCase() === "heltec_t114_without_display" &&
(!requested.role || profile.role === requested.role);
}) && profiles.some(function (profile) {
return String(profile.hardware).toLowerCase() === "heltec_t114" && t114AutoDisplayProfile(profile) &&
(!requested.role || profile.role === requested.role);
})) {
["hardware", "hardwareFamily"].forEach(function (field) {
if (String(requested[field]).toLowerCase() === "heltec_t114_without_display") requested[field] = "Heltec_t114";
});
}
if (!profiles.some(function (profile) {
return profile.hardware === "Station_G3_ESP32_r2" &&
(!requested.role || profile.role === requested.role);
@@ -1632,6 +1755,10 @@
const target = profile.target;
const info = profile.controls || {};
const notes = [];
if (t114AutoDisplayProfile(profile)) {
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 (identity.hardware === "Station_G3_ESP32" && stationG3SharedSlotProfile(profile)) {
notes.push("Automatically selects physical radio slot 1 or 2. Use this same image for either slot.");
}
@@ -2756,6 +2883,8 @@
omitLegacyObserverCompatibilityProfiles:
omitLegacyObserverCompatibilityProfiles,
omitStationG3SlotDuplicates: omitStationG3SlotDuplicates,
t114AutoDisplayProfile: t114AutoDisplayProfile,
omitT114DisplayDuplicates: omitT114DisplayDuplicates,
omitTransportsReplacedByDualCdcFull:
omitTransportsReplacedByFull,
omitNrf52TransportsReplacedByFull:
+5
View File
@@ -147,6 +147,11 @@
## What the choices mean
New **Heltec T114** images detect the optional screen automatically, so the
same firmware works with or without it. Choose the board and firmware role;
there is no screen option for an automatic-detection image. See the
[T114 update guide](heltec_t114.md) when moving from an older screenless target.
Pick the choices in any order. The controls stay limited to compatible
firmware combinations that were actually built in the current release set.
The result count stays visible while you scroll through the filters on a phone.
+4
View File
@@ -14,6 +14,10 @@ not require rebuilding it. USB remains the normal way to update the Companion
itself. WiFi/Bluetooth updates are optional, and Full does not install LoRa
MOTA packages on itself.
New **Heltec T114** Full images automatically detect the optional screen and
also run without it. Choose **Heltec T114** in the firmware picker for either
board configuration; see the [T114 update guide](heltec_t114.md).
The Wireless Paper Full memory-sharing update keeps 350 contacts and 256
queued messages normally. During an mOTA session, 128 message slots are
available while the other half holds its workspace. If more than 128 unread
+37
View File
@@ -0,0 +1,37 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/heltec_t114/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# Heltec T114 screen detection
Choose **Heltec T114** and your firmware role in the [firmware picker](firmware_picker.md).
New T114 images with automatic screen detection work on boards with or without
the optional stock 1.14-inch screen. There is no separate screen selection.
For Companion, use the Full image for USB and Bluetooth support together.
The KISS modem role remains headless.
At startup, the firmware checks whether the screen responds before starting
the display. The backlight stays off during the check. If no screen responds,
the display stays powered off and the node continues without a screen.
The selected role's supported connections and radio functions continue to work
normally.
A detected screen uses the existing display settings and button wake behavior.
Detection does not reset saved screen timeouts, power-saving preferences,
identity, or radio settings. To add or remove the screen, power the board off
first, then restart it after changing the hardware.
## Updating an older T114
Use USB when moving from an older `Heltec_t114_without_display` firmware target
to the canonical `Heltec_t114` target. The old and new target names have
different LoRa update identities; a package built for one is not an update for
the other. Follow the normal update procedure and preserve your settings;
screen detection does not require a factory reset or a new bootloader.
Previously published firmware remains unchanged. The picker identifies
automatic detection from the selected release's verified firmware metadata;
an old display-enabled image does not gain detection from its filename.
See the [implementation and validation record](test-results/heltec_t114_display_detection.md)
for the startup checks and hardware-testing boundary.
@@ -0,0 +1,56 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/test-results/heltec_t114_display_detection/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# Heltec T114 automatic screen detection
The canonical T114 firmware now includes the optional ST7789 screen driver and
checks for the stock screen at boot. Screenless boards use the same firmware.
Legacy `without_display` build names remain compatibility aliases; automatic
release selection uses the canonical targets.
## Hardware basis
Heltec's [stock panel specification](https://resource.heltec.cn/download/Mesh_Node_T114/1.14inch%20LH114T-IF03%20VER%20C.pdf)
identifies the controller as ST7789V2 and describes SDA as an SPI input/output
pin. The existing T114 wiring supports register reads over this shared data
line; a separate MISO wire is unnecessary.
The [Sitronix controller specification](https://files.waveshare.com/wiki/common/ST7789V2.pdf)
documents the read-ID command and serial turnaround on pages 58–64 and 167,
and reset recovery on pages 48–49. Its ID bytes are programmable, so detection
does not require a single guessed factory ID.
## Expected behavior
- The backlight stays off during a bounded boot probe, before framebuffer
allocation and display SPI startup.
- Consistent ID responses with both data-line pull directions distinguish a
responding panel from floating, stuck, or unstable inputs.
- An absent panel or failed display initialization leaves display power off.
Later drawing and wake requests cannot start an absent panel.
- Companion button events remain safe without a display. Notification
acknowledgement, buzzer toggling and early-boot CLI rescue remain available;
visual navigation is skipped when there is no display.
- A detected panel retains normal rendering, sleep, wake, and display settings.
- Detection is specific to the T114 automatic-display recipe. Other boards
using the shared ST7789 driver retain their existing behavior.
- Picker consolidation requires evidence from the exact published application.
Historical releases retain their original screen behavior and update IDs.
## Validation
| Check | Result |
| --- | --- |
| Native probe, GPIO, headless and wake regressions | PASS, 4 methods with ASan/UBSan; late-turnaround, reset-delay and floating-line negative controls |
| Resolved recipe and compatibility-alias checks | PASS, 5 methods; actual PlatformIO configuration also confirms all 5 legacy aliases match their canonical targets |
| Companion headless buttons and related navigation, reader, pairing, startup and deadline suites | PASS, 55 methods, including native headless fault regression and 4 input/reader configurations |
| Picker and release-selection regressions | PASS, 78 Python methods and 3 Node suites; old assets, scoped previews, newest-role defaults and unavailable legacy choices covered |
| T114 firmware and package checks | Pending |
| Hosted documentation links | PASS, 7 methods |
| Physical T114 with and without a screen | Not performed |
Native tests simulate controller replies and GPIO state; they do not establish
physical detection reliability. Before calling the hardware qualified, test
cold USB and battery boots with a stock screen and with no screen, then verify
screen sleep/wake, Bluetooth/USB connections, and normal radio receive/transmit.
+18 -14
View File
@@ -2328,12 +2328,16 @@ void UITask::loop() {
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_ENTER);
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
#if COMPANION_FEATURE_READER
if (isReaderActive() || (curr == home && static_cast<HomeScreen*>(home)->isRadioPage()))
if (_display == NULL) {
c = handleLongPress(KEY_ENTER);
else
} else {
#if COMPANION_FEATURE_READER
if (isReaderActive() || (curr == home && static_cast<HomeScreen*>(home)->isRadioPage()))
c = handleLongPress(KEY_ENTER);
else
#endif
display.dismiss();
display.dismiss();
}
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
c = handleDoubleClick(KEY_SELECT);
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK || ev == BUTTON_EVENT_QUADRUPLE_CLICK) {
@@ -2476,7 +2480,7 @@ void UITask::loop() {
c = 0;
}
if (c != 0 && curr) {
if (c != 0 && curr && _display != NULL) {
_display->wake(mesh::ui::DisplayWake::Button);
curr->handleInput(c);
_next_refresh = millis(); // trigger refresh
@@ -2606,18 +2610,18 @@ char UITask::checkDisplayOn(char c) {
}
_next_refresh = millis(); // trigger refresh
}
return c;
return _display != NULL ? c : 0;
}
char UITask::handleLongPress(char c) {
// Exit an incoming message preview even during the startup rescue window.
// The on-screen reader hint must not unexpectedly enter CLI rescue.
if (curr == msg_preview) return c;
if (_display != NULL && curr == msg_preview) return c;
#if COMPANION_FEATURE_READER
// The radio page's reader gesture, including exit, takes precedence over
// the global early-boot rescue gesture just like the WiFi setup page.
if (isReaderActive()) { closeReader(); return 0; }
if (curr == home && static_cast<HomeScreen*>(home)->isRadioPage()) {
if (_display != NULL && isReaderActive()) { closeReader(); return 0; }
if (_display != NULL && curr == home && static_cast<HomeScreen*>(home)->isRadioPage()) {
showReader();
return 0;
}
@@ -2625,7 +2629,7 @@ char UITask::handleLongPress(char c) {
#if UI_WIFI_SETUP_HOME_PAGE == 1
// A setup AP may focus this page immediately at boot. Its documented HOLD
// action must win over the otherwise-global early-boot CLI rescue gesture.
if (curr == home && static_cast<HomeScreen*>(home)->isWiFiSetupPage()) {
if (_display != NULL && curr == home && static_cast<HomeScreen*>(home)->isWiFiSetupPage()) {
return c;
}
#endif
@@ -2633,22 +2637,22 @@ char UITask::handleLongPress(char c) {
the_mesh.enterCLIRescue();
c = 0; // consume event
}
return c;
return _display != NULL ? c : 0;
}
char UITask::handleDoubleClick(char c) {
MESH_DEBUG_PRINTLN("UITask: double-click triggered");
checkDisplayOn(c);
return c;
return _display != NULL ? c : 0;
}
char UITask::handleMultiClick(char c, bool backwards) {
MESH_DEBUG_PRINTLN("UITask: %s-click triggered", backwards ? "quadruple" : "triple");
if (curr == msg_preview
if (_display != NULL && (curr == msg_preview
#if COMPANION_FEATURE_READER
|| isReaderActive()
#endif
) {
)) {
return checkDisplayOn(backwards ? KEY_UP : KEY_DOWN);
}
checkDisplayOn(c);
+2
View File
@@ -20,6 +20,7 @@ from package_esp32_partition_migration import BOARDS, resolve_pio_build_dir
from package_cascade_release import (category, collect_artifacts, nrf52_sensor_profile,
omit_retired_onboard_qspi_records)
from station_g3_profiles import omit_redundant_slot_records
from t114_profiles import omit_legacy_display_records
ROOT = Path(__file__).resolve().parents[1]
@@ -291,6 +292,7 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]:
records = omit_redundant_slot_records(
[record for record in records if ordinary_record(record)])
records = omit_legacy_display_records(records)
records = omit_retired_onboard_qspi_records(records)
tower_primary = "heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors"
+2
View File
@@ -12,6 +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
ROOT = Path(__file__).resolve().parents[1]
@@ -162,6 +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']))
if manifest.get('ota_update_requirements'):
controls['updateRequirements'] = manifest['ota_update_requirements']
note = capacity_note(manifest.get('reductions', []), defines)
+1 -1
View File
@@ -9,7 +9,7 @@ ROOT = Path(__file__).resolve().parents[1]
def package(releases, controls, bootloaders=None):
if not any(r['tag_name'] == controls['familyTag'] for r in releases):
if not any(r['tag_name'] == controls.get('releaseTag', controls['familyTag']) for r in releases):
raise ValueError('Controls must match the supplied release family')
# Retain only data used by the picker; never embed authentication or API metadata.
compact = []
+87
View File
@@ -0,0 +1,87 @@
"""Release evidence for the canonical T114 image; legacy OTA names stay intact."""
import re
CAPABILITY = "display.autodetect"
MARKER = "meshcore.capability.display.autodetect.t114.v1"
_ALIASES = re.compile(r"^Heltec_t114_without_display_((?:repeater(?:_bridge_rs232)?|"
r"room_server)(?:_lora_ota_no_external_sensors)?"
r"(?:-(?:full|reduced)(?:-ota)?)?|companion_radio_(?:ble|usb|full))$", re.I)
def canonical_target(target):
match = _ALIASES.fullmatch(target) if isinstance(target, str) else None
return "Heltec_t114_" + match[1].lower() if match else None
def qualified_auto_display(manifest):
return (manifest.get("platform") == "NRF52_PLATFORM"
and manifest.get("verified") is True
and str(manifest.get("target", "")).lower().startswith("heltec_t114_")
and CAPABILITY in (manifest.get("capabilities") or [])
and any(check.get("capability") == CAPABILITY
and check.get("source") == "packaged application"
and check.get("evidence") == MARKER
and check.get("present") is True
for check in manifest.get("verification") or []))
def runtime_metadata(manifest, defines, release_source=None):
if CAPABILITY not in (manifest.get("capabilities") or []):
return {}
if (not qualified_auto_display(manifest)
or defines.get("HELTEC_T114", "0") in ("0", "false")
or defines.get("T114_DISPLAY_AUTODETECT") != "1"
or defines.get("DISPLAY_CLASS") != "ST7789Display"):
raise ValueError("T114 display metadata requires qualified automatic ST7789 application 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("T114 display metadata requires an exact source commit")
return {"displayAuto": True, "displayController": "st7789", "displaySource": source}
def omit_legacy_display_records(records):
"""Future releases need the exact proven successor, never a renamed old file."""
def key(manifest, target):
sensors = tuple(sorted(value for value in manifest.get("capabilities", [])
if value.startswith("sensor.profile.")))
return target.lower(), manifest.get("build_profile"), sensors
successors = {key(record["manifest"], record["manifest"]["target"])
for record in records if qualified_auto_display(record["manifest"])}
def combined_successor(manifest, target):
for record in records:
candidate = record["manifest"]
if not qualified_auto_display(candidate):
continue
linked = {check.get("capability") for check in candidate.get("verification", [])
if check.get("present") is True and check.get("source") == "linked image"
and check.get("evidence")}
capabilities = set(candidate.get("capabilities", []))
if target.endswith(("companion_radio_usb", "companion_radio_ble")):
required = {"companion.usb", "companion.bluetooth"}
if (candidate["target"].lower() == "heltec_t114_companion_radio_full"
and required <= capabilities and required <= linked):
return True
if target.endswith("repeater_bridge_rs232"):
merged = any(check.get("capability") == "bridge.rs232.merged"
and check.get("present") is True
and check.get("source") == "packaged application" and check.get("evidence")
for check in candidate.get("verification", []))
if (key(manifest, "Heltec_t114_repeater") == key(candidate, candidate["target"])
and {"bridge.rs232", "bridge.rs232.merged"} <= capabilities
and "bridge.rs232" in linked and merged):
return True
return False
result = []
for record in records:
manifest = record["manifest"]
target = canonical_target(manifest["target"])
if target:
if key(manifest, target) not in successors and not combined_successor(manifest, target):
raise ValueError(manifest["target"] + ": ordinary T114 release requires the qualified "
"automatic-display successor " + target)
else:
result.append(record)
return result
+66 -2
View File
@@ -2,6 +2,13 @@
#include "ST7789Display.h"
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
#include "T114DisplayProbe.h"
extern const char meshcore_capability_display_autodetect_t114[];
const char meshcore_capability_display_autodetect_t114[] =
"meshcore.capability.display.autodetect.t114.v1";
#endif
// Native coordinates; controller RAM offsets are handled by ST7789Spi.
// Color scheme
@@ -16,18 +23,43 @@ ColorVal UIColor::popup_txt = OLEDDISPLAY_COLOR::WHITE;
ColorVal UIColor::corp_blue = OLEDDISPLAY_COLOR::WHITE;
bool ST7789Display::begin() {
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
if (!_probeComplete) {
const volatile char* capability = meshcore_capability_display_autodetect_t114;
(void)*capability; // Anchor the capability in the executed detection path.
_probeComplete = true;
_present = mesh::ui::t114::probe();
}
if (!_present) return false;
#endif
if(!_isOn) {
pinMode(PIN_TFT_VDD_CTL, OUTPUT);
pinMode(PIN_TFT_LEDA_CTL, OUTPUT);
digitalWrite(PIN_TFT_VDD_CTL, LOW);
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
#ifdef PIN_TFT_LEDA_CTL_ACTIVE
digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE);
#else
digitalWrite(PIN_TFT_LEDA_CTL, HIGH);
#endif
#else
#ifdef PIN_TFT_LEDA_CTL_ACTIVE
digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE);
#else
digitalWrite(PIN_TFT_LEDA_CTL, LOW);
#endif
#endif
digitalWrite(PIN_TFT_RST, HIGH);
display.init();
if (!display.init()) {
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
_present = false;
mesh::ui::t114::powerOff();
#else
turnOff();
#endif
return false;
}
display.landscapeScreen();
#ifdef DISPLAY_FLIP_VERTICALLY
display.flipScreenVertically();
@@ -36,18 +68,36 @@ bool ST7789Display::begin() {
setCursor(0,0);
_isOn = true;
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
#ifdef PIN_TFT_LEDA_CTL_ACTIVE
digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE);
#else
digitalWrite(PIN_TFT_LEDA_CTL, LOW);
#endif
#endif
}
return true;
}
void ST7789Display::turnOn() {
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
if (!_present) return;
#endif
if (!_isOn) {
// Restore power to the display but keep backlight off
digitalWrite(PIN_TFT_VDD_CTL, LOW);
digitalWrite(PIN_TFT_RST, HIGH);
// Re-initialize the same native landscape geometry after wake.
display.init();
if (!display.init()) {
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
_present = false;
mesh::ui::t114::powerOff();
#else
turnOff();
#endif
return;
}
display.landscapeScreen();
display.displayOn();
#ifdef DISPLAY_FLIP_VERTICALLY
@@ -66,6 +116,9 @@ void ST7789Display::turnOn() {
}
void ST7789Display::turnOff() {
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
if (!_present) return;
#endif
digitalWrite(PIN_TFT_VDD_CTL, HIGH);
#ifdef PIN_TFT_LEDA_CTL_ACTIVE
digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE);
@@ -77,16 +130,19 @@ void ST7789Display::turnOff() {
}
void ST7789Display::clear() {
if (!canDraw()) return;
display.clear();
}
void ST7789Display::startFrame(ColorVal bkg) {
if (!canDraw()) return;
display.clear(); // TODO: use bkg
setColor(UIColor::primary_txt);
setTextSize(1);
}
void ST7789Display::setTextSize(int sz) {
if (!canDraw()) return;
_line_height = sz == 2 ? 26 : 18;
switch(sz) {
case 1 :
@@ -101,6 +157,7 @@ void ST7789Display::setTextSize(int sz) {
}
void ST7789Display::setColor(ColorVal c) {
if (!canDraw()) return;
_color = c;
display.setColor((OLEDDISPLAY_COLOR)_color);
display.setRGB(_color == OLEDDISPLAY_COLOR::WHITE ? ST77XX_WHITE : ST77XX_BLACK);
@@ -112,22 +169,27 @@ void ST7789Display::setCursor(int x, int y) {
}
void ST7789Display::print(const char* str) {
if (!canDraw()) return;
display.drawString(_x, _y, str);
}
void ST7789Display::printWordWrap(const char* str, int max_width) {
if (!canDraw()) return;
display.drawStringMaxWidth(_x, _y, max_width, str);
}
void ST7789Display::fillRect(int x, int y, int w, int h) {
if (!canDraw()) return;
display.fillRect(x, y, w, h);
}
void ST7789Display::drawRect(int x, int y, int w, int h) {
if (!canDraw()) return;
display.drawRect(x, y, w, h);
}
void ST7789Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
if (!canDraw()) return;
if (!bits || w <= 0 || h <= 0) return;
const int stride = (w + 7) / 8;
for (int row = 0; row < h; ++row)
@@ -137,10 +199,12 @@ void ST7789Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
}
uint16_t ST7789Display::getTextWidth(const char* str) {
if (!canDraw()) return 0;
return display.getStringWidth(str);
}
void ST7789Display::endFrame() {
if (!canDraw()) return;
display.display();
}
+12
View File
@@ -13,6 +13,18 @@ class ST7789Display : public DisplayDriver {
int _x=0, _y=0;
int _line_height = 18;
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
bool _probeComplete = false;
bool _present = false;
#endif
bool canDraw() const {
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
return _isOn;
#else
return true;
#endif
}
bool i2c_probe(TwoWire& wire, uint8_t addr);
public:
#if defined(HELTEC_VISION_MASTER_T190)
+5
View File
@@ -145,6 +145,11 @@ class ST7789Spi : public OLEDDisplay {
digitalWrite(_rst, LOW);
delay(10);
digitalWrite(_rst, HIGH);
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
// Reset reloads the panel's NVM. Allow commands after >=5 ms and
// Sleep Out after >=120 ms, including reinitialization after wake.
delay(120);
#endif
_spi->begin ();
//digitalWrite(_ledA, LED_A_ON);
return true;
+100
View File
@@ -0,0 +1,100 @@
#pragma once
#include <Arduino.h>
namespace mesh { namespace ui { namespace t114 {
// The stock ST7789V2 shares command/output data on SDA; SPI1's MISO is not
// connected to the panel. Read before starting SPI1 or allocating its buffers.
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); // RDDID
delayMicroseconds(5);
digitalWrite(PIN_TFT_SCL, HIGH);
delayMicroseconds(5);
// The panel may drive SDA on the last command bit's falling edge.
// Release our output after that bit was latched, before lowering SCL.
if (mask == 1) pinMode(PIN_TFT_SDA, bias);
digitalWrite(PIN_TFT_SCL, LOW);
}
digitalWrite(PIN_TFT_DC, HIGH);
// RDDID has one turnaround/dummy clock before its three ID bytes.
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 powerOff() {
#ifdef PIN_TFT_LEDA_CTL_ACTIVE
digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE);
#else
digitalWrite(PIN_TFT_LEDA_CTL, HIGH);
#endif
digitalWrite(PIN_TFT_RST, LOW);
digitalWrite(PIN_TFT_VDD_CTL, HIGH);
// Avoid feeding an unpowered/absent panel through its bus pins.
pinMode(PIN_TFT_CS, INPUT);
pinMode(PIN_TFT_DC, INPUT);
pinMode(PIN_TFT_SDA, INPUT);
pinMode(PIN_TFT_SCL, INPUT);
}
inline bool probe() {
#ifdef PIN_TFT_LEDA_CTL_ACTIVE
digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE);
#else
digitalWrite(PIN_TFT_LEDA_CTL, HIGH);
#endif
pinMode(PIN_TFT_LEDA_CTL, OUTPUT);
digitalWrite(PIN_TFT_VDD_CTL, HIGH);
pinMode(PIN_TFT_VDD_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);
digitalWrite(PIN_TFT_VDD_CTL, LOW);
for (uint8_t attempt = 0; attempt < 2; ++attempt) {
digitalWrite(PIN_TFT_RST, LOW);
delay(10);
digitalWrite(PIN_TFT_RST, HIGH);
// NVM ID loading needs >=5 ms after reset; 120 ms also covers Sleep Out.
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); // The stock panel needs an initial read.
ids[bias] = readDisplayId(mode);
stable = (readDisplayId(mode) == ids[bias]) && stable;
}
// IDs are factory-programmable. Check driven, stable data instead of a
// single magic value; opposite pulls expose a floating/absent SDA line.
if (stable && ids[0] == ids[1] && ids[0] != 0 && ids[0] != 0xFFFFFFu) {
pinMode(PIN_TFT_SDA, INPUT);
return true;
}
}
powerOff();
return false;
}
}}} // namespace mesh::ui::t114
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Run production Companion button routing and helpers with no display."""
from pathlib import Path
import os
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / "examples/companion_radio/ui-new/UITask.cpp"
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#include <string>
#define PIN_USER_BTN 7
#define UI_HAS_JOYSTICK 0
#define UI_WIFI_SETUP_HOME_PAGE 0
#define MESH_DEBUG_PRINTLN(...)
constexpr char KEY_ENTER='e', KEY_NEXT='n', KEY_PREV='p', KEY_SELECT='s', KEY_UP='u', KEY_DOWN='d';
constexpr int BUTTON_EVENT_CLICK=1, BUTTON_EVENT_LONG_PRESS=2, BUTTON_EVENT_DOUBLE_CLICK=3;
constexpr int BUTTON_EVENT_TRIPLE_CLICK=4, BUTTON_EVENT_QUADRUPLE_CLICK=5;
uint32_t now_ms=0;
uint32_t millis() { return now_ms; }
bool visual_allowed=false;
unsigned visual_checks=0;
void visual() { assert(visual_allowed && "headless visual path"); ++visual_checks; }
namespace mesh { namespace ui {
enum class DisplayWake { Button };
bool shouldDisplayBluetoothPairingPin(bool enabled, bool connected, uint32_t pin) {
return enabled && !connected && pin;
}
}}
struct Mesh {
unsigned notifications=0, rescues=0;
void notificationButton() { ++notifications; }
void enterCLIRescue() { ++rescues; }
uint32_t getBLEPin() const { return 123456; }
} the_mesh;
struct Button {
int event=0;
int check() { const int result=event; event=0; return result; }
} user_btn;
struct Display {
bool on=true;
unsigned wakes=0, dismissals=0;
bool isOn() const { visual(); return on; }
void wake(mesh::ui::DisplayWake) { visual(); ++wakes; on=true; }
void dismiss() { visual(); ++dismissals; on=false; }
} display;
struct UIScreen {
unsigned inputs=0;
char last=0;
void handleInput(char key) { visual(); ++inputs; last=key; }
};
struct HomeScreen : UIScreen {
bool radio_page=false;
bool isRadioPage() const { visual(); return radio_page; }
void showFirstPage() { visual(); }
};
struct UITask {
Display* _display=nullptr;
HomeScreen home_screen;
UIScreen preview_screen, reader_screen;
UIScreen *home=&home_screen, *msg_preview=&preview_screen, *reader=&reader_screen, *curr=home;
bool _pairing_from_button=false, _startup_complete=false;
uint32_t _pairing_screen_until=0, _next_refresh=0, ui_started_at=0;
unsigned buzzer_changes=0, reader_opens=0, reader_closes=0;
bool isPairingScreenActive() const { return false; }
bool isBluetoothEnabled() const { return false; }
bool hasBluetoothConnection() const { return false; }
void showPairingPin(bool) { visual(); }
bool isReaderActive() const { visual(); return reader && curr==reader; }
void closeReader() { visual(); ++reader_closes; curr=home; }
void showReader() { visual(); ++reader_opens; curr=reader; }
void setCurrScreen(UIScreen* screen) { visual(); curr=screen; }
void toggleBuzzer() { ++buzzer_changes; }
char checkDisplayOn(char);
char handleLongPress(char);
char handleDoubleClick(char);
char handleMultiClick(char, bool);
void pollButtons();
};
@HELPERS@
void UITask::pollButtons() {
@BUTTONS@
}
void event(UITask& ui, int code) { user_btn.event=code; ui.pollButtons(); }
void headless() {
visual_allowed=false;
for (int screen=0; screen<3; ++screen) {
for (int code : {BUTTON_EVENT_CLICK, BUTTON_EVENT_DOUBLE_CLICK,
BUTTON_EVENT_TRIPLE_CLICK, BUTTON_EVENT_QUADRUPLE_CLICK}) {
UITask ui;
ui.curr=screen==0 ? ui.home : screen==1 ? ui.msg_preview : ui.reader;
now_ms=10000; visual_checks=0; the_mesh={};
event(ui, code);
assert(visual_checks==0 && the_mesh.rescues==0);
assert(the_mesh.notifications==1 && "physical notification acknowledgement survives");
assert(ui.buzzer_changes==(code==BUTTON_EVENT_TRIPLE_CLICK || code==BUTTON_EVENT_QUADRUPLE_CLICK));
assert(ui.home_screen.inputs==0 && ui.preview_screen.inputs==0 && ui.reader_screen.inputs==0);
assert(ui.reader_opens==0 && ui.reader_closes==0);
}
// The early physical rescue remains available without consulting screens.
for (bool startup_complete : {false, true}) {
UITask ui;
ui.curr=screen==0 ? ui.home : screen==1 ? ui.msg_preview : ui.reader;
ui._startup_complete=startup_complete;
ui.ui_started_at=UINT32_MAX-4000u;
now_ms=500; the_mesh={}; visual_checks=0;
event(ui, BUTTON_EVENT_LONG_PRESS);
assert(the_mesh.rescues==unsigned(!startup_complete));
assert(visual_checks==0 && the_mesh.notifications==0);
now_ms=5000; the_mesh={};
event(ui, BUTTON_EVENT_LONG_PRESS);
assert(the_mesh.rescues==0 && visual_checks==0);
}
}
}
void visible() {
visual_allowed=true; now_ms=10000;
UITask ui; ui._display=&display; ui._startup_complete=true;
display={}; the_mesh={};
event(ui, BUTTON_EVENT_CLICK);
assert(ui.home_screen.inputs==1 && the_mesh.notifications==1);
#ifdef UI_HAS_NAV_INPUT
assert(ui.home_screen.last==KEY_ENTER);
#else
assert(ui.home_screen.last==KEY_NEXT);
#endif
// A single wake gesture remains consumed by the existing display policy.
display.on=false;
event(ui, BUTTON_EVENT_CLICK);
assert(display.on && ui.home_screen.inputs==1);
ui.curr=ui.msg_preview;
event(ui, BUTTON_EVENT_DOUBLE_CLICK);
#ifdef UI_HAS_NAV_INPUT
assert(ui.preview_screen.last==KEY_SELECT);
#else
assert(ui.preview_screen.last==KEY_PREV);
#endif
event(ui, BUTTON_EVENT_TRIPLE_CLICK);
assert(ui.preview_screen.last==KEY_DOWN && ui.buzzer_changes==0);
event(ui, BUTTON_EVENT_QUADRUPLE_CLICK);
assert(ui.preview_screen.last==KEY_UP && ui.buzzer_changes==0);
ui.curr=ui.home;
event(ui, BUTTON_EVENT_TRIPLE_CLICK);
assert(ui.buzzer_changes==1);
#if COMPANION_FEATURE_READER
ui.curr=ui.reader;
event(ui, BUTTON_EVENT_LONG_PRESS);
assert(ui.reader_closes==1 && ui.curr==ui.home);
ui.home_screen.radio_page=true;
event(ui, BUTTON_EVENT_LONG_PRESS);
assert(ui.reader_opens==1 && ui.curr==ui.reader);
#endif
}
int main(int argc, char** argv) {
assert(argc==2);
if (std::string(argv[1])=="headless") headless(); else visible();
}
'''
def fixture(source):
loop = extract_braced(source, "void UITask::loop()")
buttons = loop[loop.index(" char c = 0;"):loop.index(" userLedHandler();")]
helpers = "\n".join(extract_braced(source, "char UITask::" + name)
for name in ("checkDisplayOn(", "handleLongPress(",
"handleDoubleClick(", "handleMultiClick("))
return HARNESS.replace("@HELPERS@", helpers).replace("@BUTTONS@", buttons)
class CompanionHeadlessButtonTests(unittest.TestCase):
def build(self, directory, source, *, nav=False, reader=True):
cpp, binary = directory / "test.cpp", directory / "test"
directory.mkdir()
cpp.write_text(fixture(source))
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
result = subprocess.run([
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
*sanitizers, "-DCOMPANION_FEATURE_READER=" + str(int(reader)),
*(["-DUI_HAS_NAV_INPUT=1"] if nav else []), str(cpp), "-o", str(binary)
], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return binary
def run_case(self, binary, scenario, *, negative=False):
result = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10)
if negative:
self.assertNotEqual(result.returncode, 0, "old headless navigation unexpectedly survived")
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_actual_button_mapping_and_helpers_with_and_without_display(self):
with tempfile.TemporaryDirectory(prefix="companion-headless-") as directory:
for nav, reader in ((False, False), (False, True), (True, False), (True, True)):
with self.subTest(nav=nav, reader=reader):
binary = self.build(Path(directory) / f"{nav}-{reader}", UI.read_text(),
nav=nav, reader=reader)
self.run_case(binary, "headless")
self.run_case(binary, "visible")
def test_original_null_navigation_is_detected(self):
source = UI.read_text()
helper = extract_braced(source, "char UITask::checkDisplayOn(")
old_helper = helper.replace("return _display != NULL ? c : 0;", "return c;")
self.assertNotEqual(helper, old_helper)
source = source.replace(helper, old_helper)
old_dispatch = "if (c != 0 && curr && _display != NULL)"
self.assertIn(old_dispatch, source)
source = source.replace(old_dispatch, "if (c != 0 && curr)")
with tempfile.TemporaryDirectory(prefix="companion-headless-old-") as directory:
binary = self.build(Path(directory) / "old", source)
self.run_case(binary, "headless", negative=True)
if __name__ == "__main__":
unittest.main()
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Resolve T114 recipe contracts without PlatformIO or firmware builds."""
import shlex
import unittest
from test_upstream_observer_recipes import Recipes
ALIASES = {
"Heltec_t114_without_display_repeater": "Heltec_t114_repeater",
"Heltec_t114_without_display_repeater_bridge_rs232": "Heltec_t114_repeater_bridge_rs232",
"Heltec_t114_without_display_room_server": "Heltec_t114_room_server",
"Heltec_t114_without_display_companion_radio_ble": "Heltec_t114_companion_radio_ble",
"Heltec_t114_without_display_companion_radio_usb": "Heltec_t114_companion_radio_usb",
}
ROLES = {
"Heltec_t114_repeater": "simple_repeater",
"Heltec_t114_repeater_bridge_rs232": "simple_repeater",
"Heltec_t114_repeater_lora_ota_no_external_sensors": "simple_repeater",
"Heltec_t114_room_server": "simple_room_server",
"Heltec_t114_sensor": "simple_sensor",
"Heltec_t114_companion_radio_ble": "companion_radio",
"Heltec_t114_companion_radio_usb": "companion_radio",
}
def defines(flags):
"""Apply ordered compiler -D/-U options, including inherited overrides."""
lexer = shlex.shlex(flags, posix=True)
lexer.whitespace_split = True
lexer.commenters = ";"
words = iter(lexer)
result = {}
for word in words:
if word in ("-D", "-U"):
word += next(words)
if word.startswith("-D"):
name, _, value = word[2:].partition("=")
result[name] = value or "1"
elif word.startswith("-U"):
result.pop(word[2:], None)
return result
class T114AutoDisplayRecipeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.recipes = Recipes()
def flags(self, target):
return defines(self.recipes.value("env:" + target, "build_flags"))
def test_every_ui_role_detects_the_same_optional_display(self):
for target, role in ROLES.items():
section = "env:" + target
with self.subTest(target=target):
flags = self.flags(target)
for macro in ("HELTEC_T114", "HELTEC_T114_WITH_DISPLAY", "ST7789",
"T114_DISPLAY_AUTODETECT"):
self.assertEqual(flags[macro], "1")
self.assertEqual(flags["DISPLAY_CLASS"], "ST7789Display")
for source in ("ST7789Display", "MomentaryButton", "OLEDDisplay", "OLEDDisplayFonts"):
self.assertTrue(self.recipes.includes(section, "helpers/ui/" + source + ".cpp"))
self.assertFalse(self.recipes.includes(section, "helpers/ui/NullDisplayDriver.cpp"))
self.assertIn("Adafruit SSD1306", self.recipes.value(section, "lib_deps"))
for other in set(ROLES.values()):
self.assertEqual(self.recipes.includes(section, "../examples/" + other + "/main.cpp"),
other == role)
def test_legacy_names_are_exact_canonical_aliases(self):
# This also keeps the legacy and canonical USB bases identical for the
# build script's derived Full Companion overlay.
options = ("build_flags", "build_unflags", "build_src_filter", "lib_deps", "board",
"board_build.ldscript", "board_upload.maximum_size", "extra_scripts",
"debug_tool", "upload_protocol")
for legacy, canonical in ALIASES.items():
old, new = "env:" + legacy, "env:" + canonical
with self.subTest(legacy=legacy):
self.assertEqual(dict(self.recipes.config.items(old)), {"extends": new})
for option in options:
self.assertEqual(self.recipes.value(old, option), self.recipes.value(new, option), option)
def test_hardware_identity_pins_and_storage_are_preserved(self):
wiring = {
"P_LORA_DIO_1": "20", "P_LORA_NSS": "24", "P_LORA_RESET": "25",
"P_LORA_BUSY": "17", "P_LORA_SCLK": "19", "P_LORA_MISO": "23",
"P_LORA_MOSI": "22", "SX126X_POWER_EN": "37", "PIN_GPS_RX": "39",
"PIN_GPS_TX": "37", "PIN_GPS_EN": "21", "PIN_GPS_RESET": "38",
}
for target in (*ROLES, *ALIASES, "Heltec_t114_kiss_modem"):
section = "env:" + target
with self.subTest(target=target):
flags = self.flags(target)
self.assertEqual(flags["MOTA_HW_ID"], '"Heltec_t114"')
self.assertEqual(flags["NRF52_POWER_MANAGEMENT"], "1")
self.assertEqual(self.recipes.value(section, "board"), "heltec_t114")
for macro, value in wiring.items():
self.assertEqual(flags[macro], value, macro)
companion = "companion_radio" in target
linker = "boards/nrf52840_s140_v6" + ("_extrafs" if companion else "") + ".ld"
self.assertEqual(self.recipes.value(section, "board_build.ldscript"), linker)
if companion:
self.assertEqual(self.recipes.value(section, "board_upload.maximum_size"), "712704")
self.assertEqual(flags["MAX_CONTACTS"], "350")
self.assertEqual(flags["MAX_GROUP_CHANNELS"], "40")
def test_gps_and_in_place_ota_policy_remain_explicit(self):
for target in ROLES:
with self.subTest(target=target):
self.assertEqual(self.flags(target)["ENV_INCLUDE_GPS"], "1")
for target in ("Heltec_t114_repeater", "Heltec_t114_repeater_bridge_rs232"):
with self.subTest(target=target):
flags = self.flags(target)
self.assertNotIn("ENABLE_OTA", flags)
self.assertEqual(flags["DISABLE_LORA_OTA"], "1")
flags = self.flags("Heltec_t114_repeater_lora_ota_no_external_sensors")
self.assertEqual(flags["ENABLE_OTA"], "1")
self.assertEqual(flags["OTA_FLASH_STORE"], "1")
self.assertNotIn("DISABLE_LORA_OTA", flags)
for sensor in ("AHTX0", "BME280", "BMP280", "SHTC3", "SHT4X", "LPS22HB", "MLX90614",
"VL53L0X", "BME680", "BMP085", "RAK12035", "INA3221", "INA219", "INA226", "INA260"):
self.assertNotIn("ENV_INCLUDE_" + sensor, flags)
def test_transport_roles_and_headless_kiss_stay_distinct(self):
ble = self.flags("Heltec_t114_companion_radio_ble")
usb = self.flags("Heltec_t114_companion_radio_usb")
self.assertEqual(ble["BLE_PIN_CODE"], "123456")
self.assertEqual(ble["OFFLINE_QUEUE_SIZE"], "256")
self.assertNotIn("ENABLE_USB_INTERFACE", ble)
self.assertEqual(usb["ENABLE_USB_INTERFACE"], "1")
self.assertNotIn("BLE_PIN_CODE", usb)
for target in ("Heltec_t114_repeater", "Heltec_t114_repeater_lora_ota_no_external_sensors"):
self.assertEqual(self.flags(target)["RS232_BRIDGE_MERGED"], "1")
self.assertNotIn("RS232_BRIDGE_MERGED", self.flags("Heltec_t114_repeater_bridge_rs232"))
flags = self.flags("Heltec_t114_kiss_modem")
self.assertNotIn("DISPLAY_CLASS", flags)
self.assertNotIn("T114_DISPLAY_AUTODETECT", flags)
self.assertFalse(self.recipes.includes("env:Heltec_t114_kiss_modem", "helpers/ui/ST7789Display.cpp"))
self.assertTrue(self.recipes.includes("env:Heltec_t114_kiss_modem", "../examples/kiss_modem/main.cpp"))
if __name__ == "__main__":
unittest.main()
+332
View File
@@ -0,0 +1,332 @@
"""Exercise the T114 probe and real driver lifecycle without a panel or SPI MISO."""
from pathlib import Path
import os
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / "src/helpers/ui"
ARDUINO = r'''
#pragma once
#include <cassert>
#include <cstdint>
#include <cstring>
#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 40
#define PIN_TFT_SDA 41
#define PIN_TFT_RST 2
#define PIN_TFT_VDD_CTL 3
#define PIN_TFT_LEDA_CTL 15
#define PIN_TFT_CS 11
#define PIN_TFT_DC 12
#define SPI_CLOCK_DIV2 2
#define pgm_read_byte(p) (*(p))
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 init_calls = 0, allocations = 0, draws = 0, resets = 0;
inline unsigned clock_edges = 0, command_bits = 0, data_bits = 0;
inline uint8_t command_value = 0;
inline bool transaction = false, initialized = false, init_ok = true;
inline std::string scenario;
inline std::vector<int> biases;
inline uint32_t read_value = 0;
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_VDD_CTL || pin == PIN_TFT_LEDA_CTL)
&& "probe must only touch panel pins, not MISO or the radio bus");
}
inline bool automatic() {
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
return true;
#else
return false;
#endif
}
inline int backlight_on() {
#ifdef PIN_TFT_LEDA_CTL_ACTIVE
return PIN_TFT_LEDA_CTL_ACTIVE;
#else
return LOW;
#endif
}
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);
biases.push_back(mode);
const unsigned within_attempt = (transactions - 1) % 6;
read_value = 0x858552;
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 ? 0x858552 : 0x123456;
if (scenario == "noise") read_value = within_attempt == 2 ? 0x858553 : 0x858552;
if (scenario == "recover" && transactions <= 6) read_value = 0;
// The first read after changing bias can be stale on a real panel.
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 == backlight_on())
assert(initialized && "backlight must stay off until initialization succeeds");
if (pin == PIN_TFT_RST && value == HIGH && levels[pin] == LOW) {
reset_low_us = now_us - reset_low_at;
reset_high_at = now_us; ++resets;
}
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());
assert(now_us - reset_high_at >= 120000 && "probe reset settle");
assert(reset_low_us >= 10000 && "probe reset pulse");
assert(levels[PIN_TFT_VDD_CTL] == LOW);
assert(levels[PIN_TFT_LEDA_CTL] != backlight_on());
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);
assert(levels[PIN_TFT_SCL] == LOW && "sample after dummy/falling edge");
++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; }
'''
SPI = r'''
#pragma once
#include <Arduino.h>
struct SPIClass {
void begin() {
++spi_begins;
// The actual SPI1 opener takes SDA/SCK back from bitbang GPIO mode.
modes[PIN_TFT_SDA] = modes[PIN_TFT_SCL] = OUTPUT;
}
void setClockDivider(int) {}
};
inline SPIClass SPI, SPI1;
'''
PANEL = r'''
#pragma once
#include <SPI.h>
enum OLEDDISPLAY_COLOR { BLACK=0, WHITE=1 };
constexpr int GEOMETRY_RAWMODE=0, ST77XX_WHITE=0xffff, ST77XX_BLACK=0;
inline uint8_t ArialMT_Plain_16[1], ArialMT_Plain_24[1];
class ST7789Spi {
uint8_t _rst, _dc, _cs;
int _clk=40, _miso=-1, _mosi=41;
uint16_t displayHeight=135;
uint8_t _buffheight=0;
SPIClass* _spi;
public:
template<class... T> ST7789Spi(SPIClass* spi, uint8_t rst, uint8_t dc, uint8_t cs, T...)
: _rst(rst), _dc(dc), _cs(cs), _spi(spi) {}
@CONNECT@
bool init() {
++init_calls;
assert(connect());
if (automatic()) {
assert(transactions >= 6);
assert(now_us - reset_high_at >= 120000 && "initialization reset settle");
assert(reset_low_us >= 10000);
assert(levels[PIN_TFT_LEDA_CTL] != backlight_on());
}
++allocations;
initialized = init_ok;
return init_ok;
}
void landscapeScreen() { assert(initialized); }
void flipScreenVertically() { assert(initialized); }
void displayOn() { assert(initialized); }
void clear() { assert(initialized); ++draws; }
void display() { assert(initialized); ++draws; }
void setFont(const uint8_t*) { assert(initialized); ++draws; }
void setColor(OLEDDISPLAY_COLOR) { assert(initialized); ++draws; }
void setRGB(uint16_t) { assert(initialized); ++draws; }
void drawString(int,int,const char*) { assert(initialized); ++draws; }
void drawStringMaxWidth(int,int,int,const char*) { assert(initialized); ++draws; }
void fillRect(int,int,int,int) { assert(initialized); ++draws; }
void drawRect(int,int,int,int) { assert(initialized); ++draws; }
uint16_t getStringWidth(const char* s) { assert(initialized); ++draws; return strlen(s)*8; }
};
'''
MAIN = r'''
#include "ST7789Display.h"
void frame(ST7789Display& 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("a",10); d.fillRect(0,0,1,1); d.drawRect(0,0,1,1);
d.drawXbm(0,0,bits,1,1); d.endFrame(); (void)d.getTextWidth("a");
}
int main(int argc, char** argv) {
assert(argc == 2); scenario = argv[1];
ST7789Display d;
if (!automatic()) {
assert(d.begin() && d.isOn()); frame(d);
assert(reads == 0 && transactions == 0);
assert(now_us < 120000 && "legacy/other-board reset timing unchanged");
return 0;
}
d.turnOn(); frame(d);
assert(gpio_events == 0 && init_calls == 0 && allocations == 0 && draws == 0);
const bool invalid = scenario == "floating" || scenario == "stuck-high" || scenario == "stuck-low"
|| scenario == "noise" || scenario == "biased";
if (scenario == "init-fail") init_ok = false;
const bool begun = d.begin();
if (invalid || !init_ok) {
assert(!begun && !d.isOn() && "invalid panel must remain headless");
if (invalid) assert(init_calls == 0 && allocations == 0 && spi_begins == 0);
assert(levels[PIN_TFT_VDD_CTL] == HIGH && levels[PIN_TFT_RST] == LOW);
assert(levels[PIN_TFT_LEDA_CTL] != backlight_on());
for (int pin : {PIN_TFT_CS,PIN_TFT_DC,PIN_TFT_SDA,PIN_TFT_SCL}) assert(modes[pin] == INPUT);
const auto events=gpio_events, init=init_calls, allocated=allocations;
d.turnOn(); d.turnOff(); frame(d); assert(!d.begin());
assert(gpio_events == events && init_calls == init && allocations == allocated && draws == 0);
assert(transactions == (invalid ? 12u : 6u));
assert(now_us < 400000);
return 0;
}
assert(begun && d.isOn() && initialized);
assert(init_calls == 1 && allocations == 1);
assert(levels[PIN_TFT_LEDA_CTL] == backlight_on());
assert(transactions == (scenario == "recover" ? 12u : 6u));
assert(biases.size() == transactions && reads == 24*transactions);
for (unsigned i=0; i<transactions; ++i)
assert(biases[i] == ((i%6)<3 ? INPUT_PULLUP : INPUT_PULLDOWN));
frame(d); assert(draws > 0);
const auto count=transactions, drawn=draws;
d.turnOff(); assert(!d.isOn());
assert(levels[PIN_TFT_VDD_CTL] == HIGH && levels[PIN_TFT_LEDA_CTL] != backlight_on());
frame(d); assert(draws == drawn);
d.turnOn(); assert(d.isOn() && init_calls == 2 && transactions == count);
assert(levels[PIN_TFT_LEDA_CTL] == backlight_on());
frame(d); assert(draws > drawn);
if (scenario == "wake-fail") {
d.turnOff(); init_ok = false; d.turnOn();
assert(!d.isOn() && levels[PIN_TFT_VDD_CTL] == HIGH);
const auto events = gpio_events; frame(d); d.turnOn(); assert(!d.begin());
assert(events == gpio_events);
}
}
'''
class T114DisplayDetectionTest(unittest.TestCase):
def build(self, work, flags=(), *, old_bias=False, old_settle=False, old_turnaround=False):
work.mkdir()
for filename in ("ST7789Display.cpp", "ST7789Display.h", "T114DisplayProbe.h"):
source = (UI / filename).read_text()
if old_bias and filename == "T114DisplayProbe.h":
mutated = source.replace("ids[0] == ids[1]", "true")
self.assertNotEqual(mutated, source)
source = mutated
if old_turnaround and filename == "T114DisplayProbe.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)
connect = extract_braced((UI / "ST7789Spi.h").read_text(), "bool connect()")
if old_settle:
mutated = connect.replace("delay(120);", "delay(0);")
self.assertNotEqual(mutated, connect)
connect = mutated
for filename, source in {
"Arduino.h": ARDUINO, "SPI.h": SPI, "Wire.h": "#pragma once\nclass TwoWire {};\n",
"Adafruit_GFX.h": "#pragma once\n", "ST7789Spi.h": PANEL.replace("@CONNECT@", connect),
"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", *sanitizer,
"-DST7789=1", *flags, "-I"+str(work), "-I"+str(UI), "-I"+str(ROOT / "src"),
str(work / "main.cpp"), str(work / "ST7789Display.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_real_probe_rejects_floating_stuck_and_unstable_lines_without_allocating(self):
with tempfile.TemporaryDirectory(prefix="t114-display-") as directory:
binary = self.build(Path(directory)/"auto", ["-DHELTEC_T114", "-DT114_DISPLAY_AUTODETECT=1"])
self.assertIn(b"meshcore.capability.display.autodetect.t114.v1", binary.read_bytes())
for scenario in ("floating", "stuck-high", "stuck-low", "noise", "biased", "init-fail"):
with self.subTest(scenario=scenario): self.run_case(binary, scenario)
def test_real_probe_accepts_programmable_ids_recovery_and_preserves_wake(self):
with tempfile.TemporaryDirectory(prefix="t114-display-") as directory:
for active in (None, 1):
flags = ["-DHELTEC_T114", "-DT114_DISPLAY_AUTODETECT=1"]
if active is not None: flags.append(f"-DPIN_TFT_LEDA_CTL_ACTIVE={active}")
binary = self.build(Path(directory)/str(active), flags)
for scenario in ("valid", "prime", "programmable", "recover", "wake-fail"):
with self.subTest(active=active, scenario=scenario): self.run_case(binary, scenario)
def test_legacy_t114_and_other_boards_do_not_probe_or_change_reset_timing(self):
with tempfile.TemporaryDirectory(prefix="t114-display-") as directory:
for index, flags in enumerate((["-DHELTEC_T114"], ["-DHELTEC_T114", "-DT114_DISPLAY_AUTODETECT=0"],
["-DT114_DISPLAY_AUTODETECT=1"])):
binary = self.build(Path(directory)/str(index), flags)
self.run_case(binary, "valid")
self.assertNotIn(b"meshcore.capability.display.autodetect.t114.v1", binary.read_bytes())
def test_bias_and_reset_settle_negative_controls_are_detected(self):
with tempfile.TemporaryDirectory(prefix="t114-display-") as directory:
flags = ["-DHELTEC_T114", "-DT114_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)/"settle", flags, old_settle=True)
self.run_case(binary, "valid", negative="initialization reset settle")
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()
+144
View File
@@ -0,0 +1,144 @@
"use strict";
const assert = require("assert");
const picker = require("../docs/_javascript/firmware_picker.js");
const source = "a".repeat(40);
const family = "v1.18.0.1.preview1-aaaaaaaa";
const tag = "heltec-t114-v1.18.0.1.preview1";
const mainFamily = "v1.18.0.1-dev-bbbbbbbb";
const roles = ["repeater", "room_server", "sensor", "companion_radio_full"];
const canonical = role => "Heltec_t114_" + role;
const legacy = role => "Heltec_t114_without_display_" + role;
function asset(target, version = family, releaseTag = tag) {
const name = target + "-" + version + ".uf2";
return {name, size: 500000,
browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/" + releaseTag + "/" + name};
}
function release(releaseTag, assets, date) {
return {name: releaseTag, tag_name: releaseTag, draft: false, prerelease: true,
published_at: date, html_url: "https://github.com/mikecarper/MeshCore/releases/tag/" + releaseTag, assets};
}
function controls(extra = {}) {
return {platform: "NRF52_PLATFORM", display: true, displayAuto: true,
displayController: "st7789", displaySource: source, ...extra};
}
const oldTargets = roles.flatMap(role => [canonical(role), legacy(role)]);
const main = release(mainFamily, oldTargets.concat("Other_repeater").map(target =>
asset(target, mainFamily, mainFamily)), "2026-10-18T00:00:00Z");
const ordinary = picker.buildCatalog([main]);
assert.strictEqual(ordinary.profiles.length, oldTargets.length + 1);
assert(!ordinary.profiles.some(picker.t114AutoDisplayProfile));
assert(!ordinary.profiles.flatMap(picker.boardNotes).some(note => note.includes("detects")));
const singleton = picker.buildCatalog([{...main, assets: [asset(legacy("room_server"), mainFamily, mainFamily)]}]);
assert.strictEqual(singleton.profiles[0].hardware, "Heltec_t114_without_display", "an unmatched historical fallback stays accurate");
const previewTargets = roles.map(canonical).concat(canonical("kiss_modem"));
const preview = release(tag, previewTargets.concat("Other_repeater").map(target => asset(target)), "2026-10-19T00:00:00Z");
const supplemental = {releaseTag: tag, familyTag: family, source, profiles: Object.fromEntries(
previewTargets.map(target => [target, target.endsWith("kiss_modem") ?
{platform: "NRF52_PLATFORM", display: false} : controls()]))};
const metadata = {familyTag: mainFamily, profiles: {}, supplementalReleases: [supplemental]};
const releases = [main, preview];
const before = JSON.stringify(releases);
const catalog = picker.buildCatalog(releases, metadata);
assert.strictEqual(catalog.releaseSet.familyTag, mainFamily, "the latest main family is unchanged");
assert.deepStrictEqual(catalog.profiles.map(p => p.target).sort(), previewTargets.concat("Other_repeater").sort());
assert.strictEqual(catalog.profiles.find(p => p.target === "Other_repeater").files[0].releaseTag, mainFamily);
assert(catalog.rows.some(row => row.target === legacy("repeater")), "legacy exact downloads remain available");
assert(!catalog.rows.some(row => row.target === "Other_repeater" && row.releaseTag === tag), "a board preview cannot import another board's assets");
assert.strictEqual(JSON.stringify(releases), before, "published releases/assets are immutable");
for (const role of roles) {
const profile = catalog.profiles.find(p => p.target === canonical(role));
assert(picker.t114AutoDisplayProfile(profile));
assert(profile.files.every(file => file.releaseTag === tag && file.name.includes(family)));
assert(picker.boardNotes(profile).some(note => note.includes("without a screen")));
assert(picker.boardNotes(profile).some(note => note.includes("local USB") && note.includes("LoRa OTA")));
}
assert(!picker.t114AutoDisplayProfile(catalog.profiles.find(p => p.role === "kiss")));
const repeaters = catalog.profiles.filter(p => p.hardware === "Heltec_t114" && p.role === "repeater");
assert.strictEqual(picker.hardwareChoiceLabel("hardware", "Heltec_t114", repeaters), "Heltec T114 (automatic screen detection)");
assert(!picker.hardwareChoiceLabel("hardware", "Heltec_t114", ordinary.profiles).includes("automatic"));
const standalone = picker.buildCatalog([preview], supplemental);
assert.strictEqual(standalone.releaseSet.familyTag, family);
assert.strictEqual(standalone.profiles.length, previewTargets.length);
const newerSource = "c".repeat(40);
const newerFamily = "v1.19.0-dev-cccccccc";
const newer = release(newerFamily, previewTargets.map(target => asset(target, newerFamily, newerFamily)), "2026-11-01T00:00:00Z");
const newerMetadata = {...metadata, source: newerSource, familyTag: newerFamily, profiles: Object.fromEntries(
previewTargets.map(target => [target, target.endsWith("kiss_modem") ?
{platform: "NRF52_PLATFORM", display: false} : controls({displaySource: newerSource})]))};
const superseding = picker.buildCatalog([main, preview, newer], newerMetadata);
assert.strictEqual(superseding.profiles.length, previewTargets.length);
assert(superseding.profiles.every(profile => profile.files.every(file => file.releaseTag === newerFamily)),
"a later qualified main release must replace the earlier T114 preview, including KISS");
for (const changes of [undefined, {...metadata, supplementalReleases: []},
{...metadata, supplementalReleases: [{...supplemental, source: "b".repeat(40)}]},
{...metadata, supplementalReleases: [{...supplemental, familyTag: "v9.9.9-preview1-aaaaaaaa"}]},
{...metadata, supplementalReleases: [{...supplemental, releaseTag: "ikoka-v1.18.0.1.preview1"}]}]) {
const fallback = picker.buildCatalog(releases, changes);
assert.deepStrictEqual(fallback.profiles.map(p => p.target), ordinary.profiles.map(p => p.target), "wrong or absent supplemental proof must retain the main release");
}
for (const wrong of [{displayAuto: false}, {displayController: "ssd1306"}, {display: false},
{platform: "ESP32_PLATFORM"}, {displaySource: "b".repeat(40)}, {displaySource: "a".repeat(8)}]) {
const changed = {...supplemental, profiles: {...supplemental.profiles, [canonical("room_server")]: controls(wrong)}};
const partial = picker.buildCatalog(releases, {...metadata, supplementalReleases: [changed]});
assert.strictEqual(partial.profiles.filter(p => p.role === "room").length, 2, "a rejected new room cannot hide either historical choice");
assert(partial.profiles.some(p => p.target === legacy("room_server")));
assert.strictEqual(partial.profiles.filter(p => p.role === "repeater" && p.hardware === "Heltec_t114").length, 1);
}
const bookmark = "https://mikecarper.github.io/MeshCore/firmware_picker/?hardwareFamily=Heltec_t114&hardware=Heltec_t114_without_display&role=repeater";
assert.strictEqual(picker.selectionFromUrl(bookmark, ordinary.profiles).filters.hardware, "Heltec_t114_without_display");
const selected = picker.selectionFromUrl(bookmark, catalog.profiles);
assert.strictEqual(selected.filters.hardware, "Heltec_t114");
assert.strictEqual(selected.filters.role, "repeater");
assert.deepStrictEqual(selected.unavailable, []);
const wrongRoom = {...supplemental, profiles: {...supplemental.profiles, [canonical("room_server")]: controls({displayAuto: false})}};
const mixed = picker.buildCatalog(releases, {...metadata, supplementalReleases: [wrongRoom]});
assert.strictEqual(picker.selectionFromUrl(bookmark.replace("role=repeater", "role=room"), mixed.profiles).filters.hardware,
"Heltec_t114_without_display", "bookmark normalization is scoped to an available successor for that role");
for (const policy of ["full", "reduced"]) {
const target = canonical("repeater") + "-" + policy;
const oldTarget = legacy("repeater") + "-" + policy;
const profiles = [{target: oldTarget}, {target, controls: controls(), files: [asset(target)]}];
assert.deepStrictEqual(picker.omitT114DisplayDuplicates(profiles), [profiles[1]]);
assert.deepStrictEqual(picker.omitT114DisplayDuplicates([profiles[0]]), [profiles[0]]);
assert.strictEqual(picker.omitT114DisplayDuplicates([{...profiles[0], target: oldTarget + "-ota"}, profiles[1]]).length, 1,
"a migrated role must not retain an old headless recommendation, even when its old OTA policy is unavailable");
}
for (const role of ["repeater", "room_server"]) for (const policy of ["full", "reduced"]) {
const suffix = "_lora_ota_no_external_sensors-" + policy;
const old = {target: legacy(role) + suffix};
const current = {target: canonical(role) + suffix, controls: controls(), files: [asset(canonical(role) + suffix)]};
assert.deepStrictEqual(picker.omitT114DisplayDuplicates([old, current]), [current]);
const otherPolicy = {...current, target: canonical(role) + suffix.replace(policy, policy === "full" ? "reduced" : "full")};
assert.strictEqual(picker.omitT114DisplayDuplicates([old, otherPolicy]).length, 1);
}
const bridge = {target: legacy("repeater_bridge_rs232")};
const merged = {target: canonical("repeater"), controls: controls({rs232: true, rs232Merged: true, rs232Source: source}), files: [asset(canonical("repeater"))]};
assert.deepStrictEqual(picker.omitT114DisplayDuplicates([bridge, merged]), [merged]);
assert.strictEqual(picker.omitT114DisplayDuplicates([bridge, {...merged, controls: controls({rs232: true})}]).length, 1);
const historicalTargets = ["repeater", "room_server"].flatMap(role =>
["full", "reduced"].flatMap(policy => [canonical(role), legacy(role)].map(target =>
target + "_lora_ota_no_external_sensors-" + policy)));
const historicalMain = {...main, assets: main.assets.concat(historicalTargets.map(target => asset(target, mainFamily, mainFamily)))};
const historicalMetadata = {...metadata, profiles: Object.fromEntries(historicalTargets.map(target => [target,
{platform: "NRF52_PLATFORM", display: !target.includes("without_display"), otaRole: "lora-receiver"}]))};
const migrated = picker.buildCatalog([historicalMain, preview], historicalMetadata);
assert(!migrated.profiles.some(profile => historicalTargets.includes(profile.target)),
"a migrated role must recommend no old canonical screen-assumed or headless policy");
assert(historicalTargets.every(target => migrated.rows.some(file => file.target === target)),
"all exact old full/reduced OTA files remain raw compatibility downloads");
const otaBookmark = bookmark + "&ota=lora-receiver";
const stale = picker.selectionFromUrl(otaBookmark, migrated.profiles);
assert.strictEqual(stale.filters.hardware, "Heltec_t114");
assert.strictEqual(stale.filters.role, "repeater");
assert(stale.unavailable.some(value => value.includes("ota")), "an unavailable old OTA policy must be reported rather than silently remapped");
const newLeanTarget = canonical("repeater_lora_ota_no_external_sensors") + "-reduced";
const bothPreview = {...preview, assets: preview.assets.concat(asset(newLeanTarget))};
const bothMetadata = {...metadata, supplementalReleases: [{...supplemental,
profiles: {...supplemental.profiles, [newLeanTarget]: controls({otaRole: "lora-receiver"})}}]};
const both = picker.buildCatalog([historicalMain, bothPreview], bothMetadata);
assert.deepStrictEqual(both.profiles.filter(profile => profile.hardware === "Heltec_t114" && profile.role === "repeater")
.map(profile => profile.target).sort(), [canonical("repeater"), newLeanTarget].sort(),
"real standard and lean-OTA automatic images in the new role family can coexist");
console.log("T114 canonical display detection, scoped preview proof, legacy fallback and bookmark tests passed");
+233
View File
@@ -0,0 +1,233 @@
"""No-build T114 release selection, packaged evidence and picker metadata checks."""
from copy import deepcopy
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import generate_picker_controls as controls
import package_firmware_picker as offline
import t114_profiles as t114
SOURCE = "a" * 40
FAMILY = "v1.18.0.1.preview1-aaaaaaaa"
TAG = "heltec-t114-v1.18.0.1.preview1"
CANONICAL = "Heltec_t114_repeater"
LEGACY = "Heltec_t114_without_display_repeater"
ROLES = ("repeater", "repeater_bridge_rs232", "room_server",
"companion_radio_ble", "companion_radio_usb", "companion_radio_full")
OTA_ROLES = tuple(role + "_lora_ota_no_external_sensors" for role in
("repeater", "repeater_bridge_rs232", "room_server"))
DEFINES = dict(HELTEC_T114="1", T114_DISPLAY_AUTODETECT="1", DISPLAY_CLASS="ST7789Display")
def manifest(target=CANONICAL, proof=True):
return dict(target=target, platform="NRF52_PLATFORM", verified=True,
build_profile="full", source_commit=SOURCE,
capabilities=[t114.CAPABILITY] if proof else [],
verification=[dict(capability=t114.CAPABILITY, source="packaged application",
evidence=t114.MARKER, present=True)] if proof else [])
class T114ReleaseProfileTests(unittest.TestCase):
def test_only_exact_legacy_role_names_map_to_the_canonical_target(self):
for role in ROLES + OTA_ROLES + tuple(role + suffix for role in OTA_ROLES for suffix in ("-full", "-reduced", "-full-ota", "-reduced-ota")):
self.assertEqual(t114.canonical_target("Heltec_t114_without_display_" + role),
"Heltec_t114_" + role)
for target in (CANONICAL, None, "Heltec_t114_without_display_other",
LEGACY + "_unknown", "heltec_t096_without_display_repeater"):
self.assertIsNone(t114.canonical_target(target))
def test_metadata_needs_resolved_display_flags_packaged_proof_and_source(self):
self.assertEqual(t114.runtime_metadata(manifest(), DEFINES), dict(
displayAuto=True, displayController="st7789", displaySource=SOURCE))
self.assertEqual(t114.runtime_metadata(manifest(proof=False), DEFINES), {},
"today's recipes must not relabel an old published manifest")
for changed in (dict(HELTEC_T114="0"), dict(T114_DISPLAY_AUTODETECT="0"),
dict(DISPLAY_CLASS="NullDisplayDriver")):
with self.subTest(defines=changed), self.assertRaises(ValueError):
t114.runtime_metadata(manifest(), {**DEFINES, **changed})
for changed in (dict(platform="ESP32_PLATFORM"), dict(verified=False), dict(verification=[]),
dict(target="other_repeater"), dict(source_commit="a" * 8),
dict(verification=[dict(capability=t114.CAPABILITY, present=True,
source="linked image", evidence=t114.MARKER)]),
dict(verification=[dict(capability=t114.CAPABILITY, present=True,
source="packaged application", evidence="unrelated display text")])):
with self.subTest(manifest=changed), self.assertRaises(ValueError):
t114.runtime_metadata({**manifest(), **changed}, DEFINES)
def test_real_generator_emits_per_image_controls_and_honors_later_flag_overrides(self):
with tempfile.TemporaryDirectory(prefix="t114-controls-") as folder:
stage = Path(folder)
(stage / "companion").mkdir()
(stage / "release-plan.json").write_text(json.dumps(dict(source=SOURCE, groups=[
dict(key="companion", tag=FAMILY)])))
row = manifest("Heltec_t114_companion_radio_full")
row.update(platformio_env="sample", files=[row["target"] + "-" + FAMILY + ".uf2"])
path = stage / "companion/TARGET-MANIFEST.json"
path.write_text(json.dumps([row]))
flags = ["-D" + key + "=" + value for key, value in DEFINES.items()]
config = [["env:sample", [["build_flags", flags]]]]
generated = controls.generate(stage, config)
self.assertEqual(generated["familyTag"], FAMILY)
profile = generated["profiles"][row["target"]]
self.assertTrue(profile["display"] and profile["displayAuto"])
self.assertEqual(profile["displaySource"], SOURCE)
for tail in (["-UT114_DISPLAY_AUTODETECT"], ["-DT114_DISPLAY_AUTODETECT=0"],
["-UDISPLAY_CLASS", "-DDISPLAY_CLASS=NullDisplayDriver"]):
with self.subTest(tail=tail), self.assertRaises(ValueError):
controls.generate(stage, [["env:sample", [["build_flags", flags + tail]]]])
row["capabilities"] = []; row["verification"] = []
path.write_text(json.dumps([row]))
self.assertNotIn("displayAuto", controls.generate(stage, config)["profiles"][row["target"]])
@unittest.skipIf(os.name == "nt", "local release driver uses POSIX locking")
def test_resumed_release_drops_aliases_only_with_exact_qualified_profile(self):
from build_local_release import select_ordinary_full_records
for role in ROLES + OTA_ROLES:
canonical = dict(manifest=manifest("Heltec_t114_" + role), files=[])
legacy = dict(manifest=manifest("Heltec_t114_without_display_" + role, False), files=[])
before = deepcopy([legacy, canonical])
for inputs in ([legacy, canonical], [canonical, legacy]):
self.assertEqual(select_ordinary_full_records(inputs), [canonical])
self.assertEqual([legacy, canonical], before)
for changed in (dict(build_profile="standard"), dict(verification=[]),
dict(target="Heltec_t114_sensor"), dict(platform="ESP32_PLATFORM")):
with self.subTest(role=role, changed=changed), self.assertRaisesRegex(ValueError, "automatic-display successor"):
t114.omit_legacy_display_records([legacy, dict(manifest={**canonical["manifest"], **changed}, files=[])])
with self.assertRaisesRegex(ValueError, "automatic-display successor"):
select_ordinary_full_records([legacy])
for role in OTA_ROLES:
for suffix in ("-full", "-reduced"):
legacy = dict(manifest=manifest("Heltec_t114_without_display_" + role + suffix, False), files=[])
canonical = dict(manifest=manifest("Heltec_t114_" + role + suffix), files=[])
self.assertEqual(t114.omit_legacy_display_records([legacy, canonical]), [canonical])
canonical["manifest"]["target"] += "-ota"
with self.assertRaises(ValueError):
t114.omit_legacy_display_records([legacy, canonical])
for policy in ("full", "reduced"):
canonical = dict(manifest=manifest(), files=[])
canonical["manifest"]["capabilities"].append("sensor.profile." + policy)
legacy = dict(manifest=manifest(LEGACY, False), files=[])
legacy["manifest"]["capabilities"].append("sensor.profile." + policy)
self.assertEqual(t114.omit_legacy_display_records([legacy, canonical]), [canonical])
legacy["manifest"]["capabilities"] = ["sensor.profile." + ("reduced" if policy == "full" else "full")]
with self.assertRaises(ValueError):
t114.omit_legacy_display_records([legacy, canonical])
def test_capability_checker_requires_the_marker_in_the_uploadable_application(self):
with tempfile.TemporaryDirectory(prefix="t114-proof-") as folder:
work = Path(folder)
elf, app, output = work / "firmware.elf", work / "firmware.bin", work / "capabilities.json"
elf.write_bytes(t114.MARKER.encode())
command = [sys.executable, "-B", str(ROOT / "scripts/check_firmware_capabilities.py"),
"--image", str(elf), "--firmware-bin", str(app), "--output", str(output),
"--target", CANONICAL, "--platform", "NRF52_PLATFORM", "--build-profile", "full",
"--expect-application", t114.CAPABILITY + "=" + t114.MARKER]
app.write_bytes(b"display-capable old firmware without automatic detection")
result = subprocess.run(command, capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertFalse(json.loads(output.read_text())["verified"])
app.write_bytes(b"prefix\0" + t114.MARKER.encode() + b"\0suffix")
result = subprocess.run(command, capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertTrue(t114.qualified_auto_display(json.loads(output.read_text())))
def test_combined_full_companion_and_uart_replace_only_proven_legacy_functions(self):
for role, successor, required in (
("companion_radio_usb", "companion_radio_full", ("companion.usb", "companion.bluetooth")),
("companion_radio_ble", "companion_radio_full", ("companion.usb", "companion.bluetooth")),
("repeater_bridge_rs232", "repeater", ("bridge.rs232", "bridge.rs232.merged"))):
legacy = dict(manifest=manifest("Heltec_t114_without_display_" + role, False), files=[])
primary = dict(manifest=manifest("Heltec_t114_" + successor), files=[])
primary["manifest"]["capabilities"].extend(required)
for capability in required:
primary["manifest"]["verification"].append(dict(capability=capability, present=True,
source="packaged application" if capability.endswith(".merged") else "linked image",
evidence=capability))
self.assertEqual(t114.omit_legacy_display_records([legacy, primary]), [primary])
primary["manifest"]["verification"].pop()
with self.assertRaises(ValueError):
t114.omit_legacy_display_records([legacy, primary])
@unittest.skipIf(os.name == "nt", "Bash release contracts run on Linux/WSL")
def test_bulk_prefers_resolved_auto_recipe_but_direct_alias_builds_stay_available(self):
config = []
for role in ROLES + OTA_ROLES + ("kiss_modem",):
for prefix in ("Heltec_t114_", "Heltec_t114_without_display_"):
flags = ["-D" + key + "=" + value for key, value in DEFINES.items()]
if role == "kiss_modem": flags = ["-DHELTEC_T114=1"]
config.append(["env:" + prefix + role, [["build_flags", flags]]])
program = r'''
set -euo pipefail
source build.sh
pio() { echo 'PlatformIO must not run in this test' >&2; return 99; }
SUPPORTED_PIO_ENVS=()
for role in repeater repeater_bridge_rs232 room_server companion_radio_ble companion_radio_usb companion_radio_full repeater_lora_ota_no_external_sensors repeater_bridge_rs232_lora_ota_no_external_sensors room_server_lora_ota_no_external_sensors kiss_modem; do
for prefix in Heltec_t114_ Heltec_t114_without_display_; do
target=$prefix$role
SUPPORTED_PIO_ENVS+=("$target")
PIO_ENV_PLATFORM_BY_NAME[$target]=NRF52_PLATFORM
PIO_ENV_BOARD_BY_NAME[$target]=heltec_t114
done
done
ALL_PIO_ENVS=("${SUPPORTED_PIO_ENVS[@]}")
PIO_CONFIG_JSON=@CONFIG@
for role in repeater repeater_bridge_rs232 room_server; do
for prefix in Heltec_t114_ Heltec_t114_without_display_; do
# Model generated environments that are absent from pio project config.
PIO_ENV_BUILD_BASE_BY_NAME[${prefix}${role}_lora_ota_no_external_sensors]=${prefix}${role}
done
done
for role in repeater repeater_bridge_rs232 room_server companion_radio_ble companion_radio_usb companion_radio_full repeater_lora_ota_no_external_sensors repeater_bridge_rs232_lora_ota_no_external_sensors room_server_lora_ota_no_external_sensors; do
legacy=Heltec_t114_without_display_$role
canonical=Heltec_t114_$role
[ "$(get_t114_display_replacement "$legacy")" = "$canonical" ]
is_redundant_bulk_build_target "$legacy"
is_known_pio_env "$legacy"
case "$role" in
*_lora_ota_no_external_sensors) [ "$(get_pio_build_env "$legacy")" = "${legacy%_lora_ota_no_external_sensors}" ] ;;
*) [ "$(get_pio_build_env "$legacy")" = "$legacy" ] ;;
esac
! get_t114_display_replacement "$canonical"
done
! is_t114_display_autodetect_build Heltec_t114_kiss_modem
! get_t114_display_replacement Heltec_t114_without_display_kiss_modem
BUILD_PROFILE_FOR_TARGET=full
BUILD_APPLICATION_EXPECTATIONS=() BUILD_EXPECTATIONS=() BUILD_CAPABILITIES=() BUILD_REDUCTIONS=()
declare_build_capability_contract Heltec_t114_repeater NRF52_PLATFORM
[[ " ${BUILD_APPLICATION_EXPECTATIONS[*]} " == *" display.autodetect=meshcore.capability.display.autodetect.t114.v1 "* ]]
PIO_CONFIG_JSON=@OVERRIDDEN@
! get_t114_display_replacement Heltec_t114_without_display_repeater
PIO_CONFIG_JSON=@CONFIG@
PIO_ENV_BOARD_BY_NAME[Heltec_t114_repeater]=other-board
! get_t114_display_replacement Heltec_t114_without_display_repeater
PIO_ENV_BOARD_BY_NAME[Heltec_t114_repeater]=heltec_t114
unset 'PIO_ENV_PLATFORM_BY_NAME[Heltec_t114_repeater]'
! get_t114_display_replacement Heltec_t114_without_display_repeater
'''
overridden = deepcopy(config)
next(row for row in overridden if row[0] == "env:" + CANONICAL)[1][0][1].append("-UT114_DISPLAY_AUTODETECT")
program = program.replace("@CONFIG@", shlex.quote(json.dumps(config)))
program = program.replace("@OVERRIDDEN@", shlex.quote(json.dumps(overridden)))
result = subprocess.run(["bash", "-c", program], cwd=ROOT, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_standalone_picker_accepts_exact_scoped_tag_without_renaming_its_asset_family(self):
entry = dict(familyTag=FAMILY, releaseTag=TAG, source=SOURCE, profiles={})
release = dict(tag_name=TAG, name=TAG, assets=[])
html = offline.package([release], entry)
self.assertIn('"releaseTag": "' + TAG + '"', html)
self.assertIn('"familyTag": "' + FAMILY + '"', html)
with self.assertRaisesRegex(ValueError, "match the supplied release family"):
offline.package([{**release, "tag_name": "other-preview"}], entry)
if __name__ == "__main__":
unittest.main()
+13 -92
View File
@@ -1,5 +1,5 @@
;
; Heltec T114 without display
; Heltec T114 hardware shared by automatic-display roles and the KISS modem
;
[Heltec_t114]
extends = rak4631_hw
@@ -48,105 +48,23 @@ debug_tool = jlink
upload_protocol = nrfutil
[env:Heltec_t114_without_display_repeater]
extends = Heltec_t114
build_src_filter = ${Heltec_t114.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
build_flags =
${Heltec_t114.build_flags}
-D ADVERT_NAME='"Heltec_T114 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
-D RS232_BRIDGE_MERGED=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; Compatibility name: display presence is detected at startup.
extends = env:Heltec_t114_repeater
[env:Heltec_t114_without_display_repeater_bridge_rs232]
extends = Heltec_t114
build_flags =
${Heltec_t114.build_flags}
-D ADVERT_NAME='"RS232 Bridge"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_RS232_BRIDGE=Serial2
-D WITH_RS232_BRIDGE_UART=2
-D WITH_RS232_BRIDGE_RX=9
-D WITH_RS232_BRIDGE_TX=10
; -D BRIDGE_DEBUG=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${Heltec_t114.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
extends = env:Heltec_t114_repeater_bridge_rs232
[env:Heltec_t114_without_display_room_server]
extends = Heltec_t114
build_src_filter = ${Heltec_t114.build_src_filter}
+<../examples/simple_room_server>
build_flags =
${Heltec_t114.build_flags}
-D ADVERT_NAME='"Heltec_T114 Room"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
extends = env:Heltec_t114_room_server
[env:Heltec_t114_without_display_companion_radio_ble]
extends = Heltec_t114
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${Heltec_t114.build_flags}
-I examples/companion_radio/ui-new
-D DISPLAY_CLASS=NullDisplayDriver
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
; -D BLE_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${Heltec_t114.build_src_filter}
+<helpers/ui/NullDisplayDriver.cpp>
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${Heltec_t114.lib_deps}
densaugeo/base64 @ ~1.4.0
extends = env:Heltec_t114_companion_radio_ble
[env:Heltec_t114_without_display_companion_radio_usb]
extends = Heltec_t114
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${Heltec_t114.build_flags}
-I examples/companion_radio/ui-new
-D DISPLAY_CLASS=NullDisplayDriver
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D ENABLE_USB_INTERFACE
build_src_filter = ${Heltec_t114.build_src_filter}
+<helpers/nrf52/*.cpp>
+<helpers/ui/NullDisplayDriver.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${Heltec_t114.lib_deps}
densaugeo/base64 @ ~1.4.0
extends = env:Heltec_t114_companion_radio_usb
;
; Heltec T114 with ST7789 display
; Heltec T114 automatic optional ST7789 display
;
[Heltec_t114_with_display]
extends = Heltec_t114
@@ -155,6 +73,7 @@ board_build.ldscript = boards/nrf52840_s140_v6.ld
build_flags = ${Heltec_t114.build_flags}
-D ST7789
-D HELTEC_T114_WITH_DISPLAY
-D T114_DISPLAY_AUTODETECT=1
-D DISPLAY_CLASS=ST7789Display
build_src_filter = ${Heltec_t114.build_src_filter}
+<helpers/ui/ST7789Display.cpp>
@@ -217,9 +136,11 @@ lib_deps =
adafruit/Adafruit SSD1306 @ ^2.5.13
[env:Heltec_t114_repeater_bridge_rs232]
extends = Heltec_t114
extends = Heltec_t114_with_display
build_flags =
${Heltec_t114.build_flags}
${Heltec_t114_with_display.build_flags}
-UENABLE_OTA
-D DISABLE_LORA_OTA=1 ; Same full-sensor/display ceiling as the ordinary repeater
-D ADVERT_NAME='"RS232 Bridge"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0