Make T114 screen detection the default firmware and picker choice
This commit is contained in:
1 parent
864d0cacff
commit
1ce3f0cdd7
22 files changed
+1686
-124
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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");
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user