diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 72ce076a6..544e5c1dc 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -50,6 +50,9 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Compile GPS-capable Sensor tracker on ESP32-S3 run: pio run -e heltec_v4_sensor + - name: Compile shared-slot Station G3 and legacy alias with buffered KISS + # One PlatformIO process: these environments share the checkout build tree. + run: pio run -e Station_G3_ESP32_repeater -e Station_G3_ESP32_r2_repeater -e Station_G3_ESP32_kiss_modem esp32-ethernet-companion-smoke: runs-on: ubuntu-latest @@ -207,6 +210,9 @@ jobs: python3 -B test/test_upstream_20261015_wifi_boot.py -v python3 -B test/test_wio_tracker_l2_gps_power.py -v python3 -B test/test_lr1121_power_merge.py -v + python3 -B test/test_lr2021_power.py -v + python3 -B test/test_meshtracker_x1_battery.py -v + python3 -B test/test_drv2605_power.py -v python3 -B test/test_radio_interrupt_recovery.py -v python3 -B test/test_bridge_recovery.py -v python3 -B test/test_bridge_ingress.py -v @@ -295,6 +301,8 @@ jobs: python3 -B test/test_sensor_tracker_gps.py -v python3 -B test/test_sensor_tracker_sleep.py -v python3 -B test/test_power_saving_deadlines.py -v + python3 -B test/test_esp32_scanner_power_yield.py -v + python3 -B test/test_esp32_gpio_hold.py -v - name: Verify Companion contact and channel TX routing run: | @@ -542,6 +550,8 @@ jobs: python3 -B test/test_gps_sync_interval.py -v python3 -B test/test_gps_sync_preferences.py -v python3 -B test/test_rak_gps_power.py -v + python3 -B test/test_rak_shutdown_power.py -v + python3 -B test/test_xiao_battery_safety.py -v python3 -B test/test_peripheral_mode_prefs.py -v python3 -B test/test_t1000e_sensor_power.py -v python3 -B test/test_sensor_battery_alerts.py -v @@ -646,6 +656,13 @@ jobs: - name: Verify Full logging compiler flags and packaged capabilities run: | + python3 -B test/test_station_g3_radio_pins.py -v + python3 -B test/test_station_g3_slot_settings.py -v + python3 -B test/test_station_g3_slot_detection.py -v + python3 -B test/test_station_g3_boot_recovery.py -v + python3 -B test/test_kiss_radio_slot.py -v + python3 -B test/test_station_g3_release_profiles.py -v + node test/test_station_g3_picker.js python3 -B test/test_upstream_observer_recipes.py -v python3 -B test/test_station_g3_sensor_recipes.py -v python3 -B test/test_full_build_flag_dedup.py @@ -701,6 +718,7 @@ jobs: python3 -B test/test_rak_combined_ota.py -v python3 -B test/test_rak_ethernet_transmit.py -v python3 -B test/test_rak4631_ethernet_runtime.py -v + python3 -B test/test_ethernet_terminal_shutdown.py -v - name: Verify nRF52 infrastructure trims and unchanged Companion scope run: | diff --git a/build_legacy.sh b/build_legacy.sh index 7af15515c..93ad57ed7 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3459,6 +3459,16 @@ declare_build_capability_contract() { fi if [ "$env_platform" = "ESP32_PLATFORM" ]; then + case "$env_name" in + Station_G3_ESP32_*) + # Only the uploadable application, not ELF metadata or a guessed name, + # can qualify future shared-slot picker/release consolidation. + if [ "${PIO_ENV_BOARD_BY_NAME[$env_name]:-}" = station-g3-esp32 ]; then + record_application_expectation "radio.station_g3.auto_slots" \ + "meshcore.capability.station_g3.auto_slots.v1" + fi + ;; + esac if is_companion_radio_full_target "$env_name" \ || { [ "$BUILD_PROFILE_FOR_TARGET" = "standard" ] \ && requires_esp32_field_browser_ota "$env_name"; }; then @@ -5633,6 +5643,12 @@ get_merged_espnow_repeater_replacement() { } is_runtime_setting_alias_target() { + # Physical-slot selection is now runtime/automatic on Station G3. Retain + # exact R2 environment names for explicit historical builds, not duplicate + # ordinary releases. This does not rewrite their deployed OTA identities. + if get_station_g3_shared_slot_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" \ @@ -5647,6 +5663,41 @@ is_runtime_setting_alias_target() { return 1 } +get_station_g3_shared_slot_replacement() { + local target=$1 candidate + case "$target" in + Station_G3_ESP32_r2_repeater|Station_G3_ESP32_r2_room_server|\ + Station_G3_ESP32_r2_sensor|Station_G3_ESP32_r2_repeater_observer_mqtt|\ + Station_G3_ESP32_r2_room_server_observer_mqtt) + candidate=${target/Station_G3_ESP32_r2_/Station_G3_ESP32_} + ;; + *) return 1 ;; + esac + # Do not collapse another radio family, role, platform or missing recipe. + [ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" = ESP32_PLATFORM ] \ + && [ "${PIO_ENV_PLATFORM_BY_NAME[$candidate]:-}" = ESP32_PLATFORM ] \ + && [ "${PIO_ENV_BOARD_BY_NAME[$target]:-}" = station-g3-esp32 ] \ + && [ "${PIO_ENV_BOARD_BY_NAME[$candidate]:-}" = station-g3-esp32 ] || return 1 + [ -n "$PIO_CONFIG_JSON" ] || return 1 + # Use effective flags (including -U/later overrides), not a substring that + # could mistake an overridden SX1262 declaration for the running radio. + python3 -c ' +import json,re,sys +rows = {name:dict(options) for name,options in json.load(sys.stdin)} +for target in sys.argv[1:]: + definitions={} + for flag in rows.get("env:"+target,{}).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 + if definitions.get("RADIO_CLASS")!="CustomSX1262": sys.exit(1) + if definitions.get("USE_SX1262","0") in ("0","false"): sys.exit(1) +' "$target" "$candidate" <<<"$PIO_CONFIG_JSON" || return 1 + printf '%s\n' "$candidate" +} + is_redundant_bulk_build_target() { # Bench fixtures, migration utilities and the unqualified optional SD # source use exact recipes, not the ordinary release/OTA receiver matrix. diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 6dfb9afc2..f59b0c923 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -658,6 +658,42 @@ }); } + function stationG3SharedSlotProfile(profile) { + const controls = profile && profile.controls; + return Boolean(controls && controls.platform === "ESP32_PLATFORM" && + controls.radioSlotAuto === true && controls.radioSlotFamily === "sx1262" && + /^[0-9a-f]{40}$/.test(controls.radioSlotSource || "") && + Array.isArray(profile.files) && profile.files.length > 0 && + profile.files.every(function (file) { + const hash = String(file.name || "").match(/-([0-9a-f]{8,40})(?:-merged)?\.(?:bin|hex|uf2|zip)$/i); + return hash && controls.radioSlotSource.startsWith(hash[1].toLowerCase()); + })); + } + + function hardwareChoiceLabel(field, value, profiles, family) { + const matches = (profiles || []).filter(function (profile) { + return profile[field] === value; + }); + if (value === "Station_G3_ESP32" && matches.length > 0 && + matches.every(function (profile) { + return profile.hardware === "Station_G3_ESP32" && stationG3SharedSlotProfile(profile); + })) { + return "Station G3 ESP32 (automatic slot detection)"; + } + return field === "hardware" && family + ? humanizeHardwareVariant(value, family) : humanizeHardware(value); + } + + function omitStationG3SlotDuplicates(profiles) { + const successors = new Set((profiles || []).filter(stationG3SharedSlotProfile) + .map(function (profile) { return profile.target; })); + return (profiles || []).filter(function (profile) { + const target = String(profile.target || ""); + const match = /^Station_G3_ESP32_r2_(repeater(?:_observer_mqtt)?|room_server(?:_observer_mqtt)?|sensor)((?:-(?:full|reduced|usb|wifi|logging|ota))*)$/.exec(target); + return !match || !successors.has("Station_G3_ESP32_" + match[1] + match[2]); + }); + } + function omitRakStorageProfilesWhenUnified(profiles) { const targets = new Set((profiles || []).map(function (profile) { return String(profile && profile.target || "").toLowerCase(); @@ -972,7 +1008,9 @@ return { releaseSet: releaseSet, rows: firmwareRows, - profiles: profiles, + // Old releases have no shared-slot proof and retain both choices. Raw + // assets remain searchable even when a new exact successor replaces R2. + profiles: omitStationG3SlotDuplicates(profiles), }; } @@ -1285,6 +1323,18 @@ FACET_FIELDS.forEach(function (field) { requested[field] = params.get(field) || ""; }); + if (!profiles.some(function (profile) { + return profile.hardware === "Station_G3_ESP32_r2" && + (!requested.role || profile.role === requested.role); + }) && + profiles.some(function (profile) { + return profile.hardware === "Station_G3_ESP32" && stationG3SharedSlotProfile(profile) && + (!requested.role || profile.role === requested.role); + })) { + ["hardware", "hardwareFamily"].forEach(function (field) { + if (requested[field] === "Station_G3_ESP32_r2") requested[field] = "Station_G3_ESP32"; + }); + } // Handwritten links may specify just the exact hardware variant. if (requested.hardware && !requested.hardwareFamily) { const profile = profiles.find(function (item) { @@ -1511,6 +1561,9 @@ const target = profile.target; const info = profile.controls || {}; const notes = []; + 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."); + } const rak = /^RAK_(3401|4631)_repeater_unified_lora_ota(?:-(?:full|reduced)(?:-ota)?)?$/i.exec(target); const lora = info.updateRequirements && info.updateRequirements.lora; if (rak && info.platform === "NRF52_PLATFORM" && profile.ota === "lora-receiver" && @@ -1976,7 +2029,7 @@ card.className = "firmware-picker-card"; card.appendChild(createElement( "h3", - humanizeHardware(profile.hardware) + " - " + + hardwareChoiceLabel("hardware", profile.hardware, [profile]) + " - " + (ROLE_LABELS[profile.role] || profile.role) )); @@ -2140,6 +2193,8 @@ function setSelectOptions(select, field, values, selected) { select.replaceChildren(); + const hardwareProfiles = field === "hardware" || field === "hardwareFamily" + ? matchingProfiles(field) : []; const placeholders = { chipFamily: "Any chip family - skip this filter", hardwareFamily: "Any hardware", @@ -2153,8 +2208,8 @@ values.slice().sort(function (a, b) { return optionSort(field, a, b); }).forEach(function (value) { - const label = field === "hardware" - ? humanizeHardwareVariant(value, filters.hardwareFamily) + const label = field === "hardware" || field === "hardwareFamily" + ? hardwareChoiceLabel(field, value, hardwareProfiles, filters.hardwareFamily) : labelFor(field, value, filters.hardware || filters.hardwareFamily); const option = createElement("option", label); option.value = value; @@ -2165,12 +2220,14 @@ } function setRadioOptions(container, field, values, selected) { + const hardwareProfiles = field === "hardware" || field === "hardwareFamily" + ? matchingProfiles(field) : []; const options = [{ value: "", label: "Any" }].concat( values.slice().sort(function (a, b) { return optionSort(field, a, b); }).map(function (value) { - return { value: value, label: field === "hardware" - ? humanizeHardwareVariant(value, filters.hardwareFamily) + return { value: value, label: field === "hardware" || field === "hardwareFamily" + ? hardwareChoiceLabel(field, value, hardwareProfiles, filters.hardwareFamily) : labelFor(field, value, filters.hardware || filters.hardwareFamily) }; }) ); @@ -2627,6 +2684,7 @@ omitTransportsReplacedByFull: omitTransportsReplacedByFull, omitLegacyObserverCompatibilityProfiles: omitLegacyObserverCompatibilityProfiles, + omitStationG3SlotDuplicates: omitStationG3SlotDuplicates, omitTransportsReplacedByDualCdcFull: omitTransportsReplacedByFull, omitNrf52TransportsReplacedByFull: @@ -2663,6 +2721,7 @@ migrationAssetForProfile: migrationAssetForProfile, migrationLink: migrationLink, humanizeHardware: humanizeHardware, + hardwareChoiceLabel: hardwareChoiceLabel, humanizeVariant: humanizeVariant, labelFor: labelFor, formatBytes: formatBytes, diff --git a/scripts/build_local_release.py b/scripts/build_local_release.py index f3fefe21a..9fc1429ee 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -18,6 +18,7 @@ import tempfile from build_esp32_partition_migration import bundle_release, verify_archive from package_esp32_partition_migration import BOARDS, resolve_pio_build_dir from package_cascade_release import category, collect_artifacts, nrf52_sensor_profile +from station_g3_profiles import omit_redundant_slot_records ROOT = Path(__file__).resolve().parents[1] @@ -286,7 +287,8 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: return False return manifest["build_profile"] == "full" - records = [record for record in records if ordinary_record(record)] + records = omit_redundant_slot_records( + [record for record in records if ordinary_record(record)]) tower_primary = "heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" tower_legacy = { diff --git a/scripts/generate_picker_controls.py b/scripts/generate_picker_controls.py index 0a2940ca7..869d62491 100644 --- a/scripts/generate_picker_controls.py +++ b/scripts/generate_picker_controls.py @@ -11,6 +11,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 ROOT = Path(__file__).resolve().parents[1] @@ -145,6 +146,7 @@ def generate(stage, config): 'updateMethods': manifest.get('ota_update_methods', []), } controls.update(runtime_metadata(manifest, controls['mqtt'], plan['source'])) + controls.update(station_g3_metadata(manifest, defines, plan['source'])) if manifest.get('ota_update_requirements'): controls['updateRequirements'] = manifest['ota_update_requirements'] note = capacity_note(manifest.get('reductions', []), defines) diff --git a/scripts/station_g3_profiles.py b/scripts/station_g3_profiles.py new file mode 100644 index 000000000..43acca001 --- /dev/null +++ b/scripts/station_g3_profiles.py @@ -0,0 +1,53 @@ +"""Release selection for verified shared-slot G3 images; no OTA identities change.""" +import re + +CAPABILITY = "radio.station_g3.auto_slots" +MARKER = "meshcore.capability.station_g3.auto_slots.v1" +_LEGACY = re.compile(r"^Station_G3_ESP32_r2_(repeater(?:_observer_mqtt)?|" + r"room_server(?:_observer_mqtt)?|sensor)$") + + +def canonical_target(target): + """Only the five known SX1262 slot aliases, with the same exact role/profile.""" + if not isinstance(target, str) or not _LEGACY.fullmatch(target): + return None + return target.replace("Station_G3_ESP32_r2_", "Station_G3_ESP32_", 1) + + +def qualified_auto_slots(manifest): + return (manifest.get("platform") == "ESP32_PLATFORM" + and manifest.get("verified") is True + 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 omit_redundant_slot_records(records): + """Discard resumed R2 duplicates only after the exact shared successor is proven. + + Never mutate files/manifests or suppress an unmatched historical artifact. + This is a future ordinary-release preference, not an OTA migration path. + """ + successors = {(record["manifest"]["target"], record["manifest"].get("build_profile")) + for record in records if qualified_auto_slots(record["manifest"])} + return [record for record in records + if (canonical_target(record["manifest"]["target"]), + record["manifest"].get("build_profile")) not in successors] + + +def runtime_metadata(manifest, defines, release_source=None): + if CAPABILITY not in (manifest.get("capabilities") or []): + return {} + if (not qualified_auto_slots(manifest) + or defines.get("RADIO_CLASS") != "CustomSX1262" + or defines.get("USE_SX1262", "0") in ("0", "false") + or defines.get("STATION_G3_ESP32", "0") in ("0", "false") + or not str(manifest.get("target", "")).startswith("Station_G3_ESP32_")): + raise ValueError("Station G3 shared-slot metadata needs qualified SX1262 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("Station G3 slot metadata requires an exact source commit") + return {"radioSlotAuto": True, "radioSlotFamily": "sx1262", "radioSlotSource": source} diff --git a/test/test_station_g3_picker.js b/test/test_station_g3_picker.js new file mode 100644 index 000000000..3e58fde1e --- /dev/null +++ b/test/test_station_g3_picker.js @@ -0,0 +1,82 @@ +"use strict"; +const assert = require("assert"); +const picker = require("../docs/_javascript/firmware_picker.js"); +const source = "a".repeat(40); +const family = "v2.0.0-dev-aaaaaaaa"; +const suffix = "-full-usb-wifi"; +const canonical = "Station_G3_ESP32_repeater" + suffix; +const legacy = "Station_G3_ESP32_r2_repeater" + suffix; +function asset(target) { + const name = target + "-" + family + ".bin"; + return {name, size: 1000000, + browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/test/" + name}; +} +function controls(extra = {}) { + return {platform: "ESP32_PLATFORM", radioSlotAuto: true, + radioSlotFamily: "sx1262", radioSlotSource: source, ...extra}; +} +function profile(target, metadata) { + return {target, files: [asset(target)], controls: metadata}; +} +const r2 = profile(legacy); +const shared = profile(canonical, controls()); +assert.deepStrictEqual(picker.omitStationG3SlotDuplicates([r2, shared]), [shared]); +for (const wrong of [undefined, controls({radioSlotAuto: false}), + controls({radioSlotFamily: "lr1121"}), controls({platform: "NRF52_PLATFORM"}), + controls({radioSlotSource: "b".repeat(40)}), controls({radioSlotSource: "a".repeat(8)})]) { + const old = profile(canonical, wrong); + assert.deepStrictEqual(picker.omitStationG3SlotDuplicates([r2, old]), [r2, old]); +} +for (const target of ["Station_G3_ESP32_room_server"+suffix, + "Station_G3_ESP32_repeater_observer_mqtt"+suffix, + "Station_G3_ESP32_repeater-full-logging-ota", "Station_G2_repeater"+suffix]) { + const other = profile(target, controls()); + assert.deepStrictEqual(picker.omitStationG3SlotDuplicates([r2, other]), [r2, other]); +} +assert.deepStrictEqual(picker.omitStationG3SlotDuplicates([r2]), [r2]); +const releases = [{name: family, tag_name: family, draft: false, prerelease: true, + published_at: "2026-10-15T00:00:00Z", + html_url: "https://github.com/mikecarper/MeshCore/releases/tag/"+family, + assets: [asset(canonical), asset(legacy)]}]; +const before = JSON.stringify(releases); +const oldCatalog = picker.buildCatalog(releases); +assert.strictEqual(oldCatalog.profiles.length, 2, "old releases retain both physical-slot choices"); +const catalog = picker.buildCatalog(releases, {familyTag: family, profiles: {[canonical]: controls()}}); +assert.deepStrictEqual(catalog.profiles.map(p => p.target), [canonical]); +assert.strictEqual(catalog.rows.length, 2, "historical file stays in exact raw-asset search"); +assert.strictEqual(JSON.stringify(releases), before, "no old release or asset mutation"); +const wrongFamily = picker.buildCatalog(releases, {familyTag: "other", profiles: {[canonical]: controls()}}); +assert.strictEqual(wrongFamily.profiles.length, 2, "other-release metadata cannot collapse slots"); +const url = "https://mikecarper.github.io/MeshCore/firmware_picker/?hardwareFamily=" + + "Station_G3_ESP32&hardware=Station_G3_ESP32_r2&role=repeater"; +const oldSelection = picker.selectionFromUrl(url, oldCatalog.profiles); +assert.strictEqual(oldSelection.filters.hardware, "Station_G3_ESP32_r2"); +const selection = picker.selectionFromUrl(url, catalog.profiles); +assert.strictEqual(selection.filters.hardware, "Station_G3_ESP32"); +assert.strictEqual(selection.filters.role, "repeater"); +assert.deepStrictEqual(selection.unavailable, []); +const roomTarget = "Station_G3_ESP32_r2_room_server"+suffix; +const mixed = picker.buildCatalog([{...releases[0], assets: [ + ...releases[0].assets, asset(roomTarget), +]}], {familyTag: family, profiles: {[canonical]: controls()}}); +assert.deepStrictEqual(mixed.profiles.map(p => p.target), [canonical, roomTarget].sort()); +const mixedSelection = picker.selectionFromUrl(url, mixed.profiles); +assert.strictEqual(mixedSelection.filters.hardware, "Station_G3_ESP32"); +assert.strictEqual(mixedSelection.filters.role, "repeater"); +assert.deepStrictEqual(mixedSelection.unavailable, []); +const oldRoom = picker.selectionFromUrl(url.replace("role=repeater", "role=room"), mixed.profiles); +assert.strictEqual(oldRoom.filters.hardware, "Station_G3_ESP32_r2"); +assert.strictEqual(oldRoom.filters.role, "room"); +const autoLabel = "Station G3 ESP32 (automatic slot detection)"; +assert.strictEqual(picker.hardwareChoiceLabel("hardwareFamily", "Station_G3_ESP32", catalog.profiles), autoLabel); +assert.strictEqual(picker.hardwareChoiceLabel("hardware", "Station_G3_ESP32", catalog.profiles, + "Station_G3_ESP32"), autoLabel); +assert.strictEqual(picker.hardwareChoiceLabel("hardware", "Station_G3_ESP32", catalog.profiles), autoLabel); +assert(!picker.hardwareChoiceLabel("hardwareFamily", "Station_G3_ESP32", oldCatalog.profiles).includes("automatic")); +assert(!picker.hardwareChoiceLabel("hardwareFamily", "Station_G3_ESP32", mixed.profiles).includes("automatic"), + "a partially old family must not claim all its images are automatic"); +assert.strictEqual(picker.hardwareChoiceLabel("hardwareFamily", "Station_G3_ESP32", + mixed.profiles.filter(p => p.role === "repeater")), autoLabel); +assert(picker.boardNotes(catalog.profiles[0]).some(note => note.includes("Automatically selects physical radio slot"))); +assert(!oldCatalog.profiles.flatMap(picker.boardNotes).some(note => note.includes("Automatically selects physical radio slot"))); +console.log("Station G3 shared-slot picker qualification, old catalogs and scoped bookmarks passed"); diff --git a/test/test_station_g3_release_profiles.py b/test/test_station_g3_release_profiles.py new file mode 100644 index 000000000..38573f6cf --- /dev/null +++ b/test/test_station_g3_release_profiles.py @@ -0,0 +1,195 @@ +"""No-build shared-slot release contracts; existing OTA identities stay distinct.""" +from copy import deepcopy +from pathlib import Path +import hashlib +import json +import os +import shlex +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "tools/mota")) +import station_g3_profiles as slots +import motalib +import generate_picker_controls as controls + +SOURCE = "a"*40 +CANONICAL = "Station_G3_ESP32_repeater" +LEGACY = "Station_G3_ESP32_r2_repeater" +ROLES = ("repeater", "room_server", "sensor", "repeater_observer_mqtt", + "room_server_observer_mqtt") +DEFINES = {"STATION_G3_ESP32": "1", "USE_SX1262": "1", "RADIO_CLASS": "CustomSX1262"} + + +def manifest(target=CANONICAL, proof=True): + return dict(target=target, platform="ESP32_PLATFORM", verified=True, + build_profile="full", source_commit=SOURCE, + capabilities=[slots.CAPABILITY] if proof else [], + verification=[dict(capability=slots.CAPABILITY, source="packaged application", + evidence=slots.MARKER, present=True)] if proof else []) + + +class StationG3ReleaseTests(unittest.TestCase): + def test_exact_known_aliases_preserve_every_role_and_profile(self): + for role in ROLES: + self.assertEqual(slots.canonical_target("Station_G3_ESP32_r2_"+role), + "Station_G3_ESP32_"+role) + for value in (CANONICAL, "Station_G2_r2_repeater", "heltec_v4_r2_repeater", + LEGACY+"_sx1280", LEGACY+"_lr1121", LEGACY+"_unknown", None): + self.assertIsNone(slots.canonical_target(value)) + + def test_resume_prefers_proven_exact_successor_without_mutation(self): + for role in ROLES: + canonical = dict(manifest=manifest("Station_G3_ESP32_"+role), files=[]) + legacy = dict(manifest=manifest("Station_G3_ESP32_r2_"+role, False), files=[]) + original = deepcopy([legacy, canonical]) + for records in ([legacy, canonical], [canonical, legacy]): + self.assertEqual(slots.omit_redundant_slot_records(records), [canonical]) + self.assertEqual([legacy, canonical], original) + self.assertEqual(slots.omit_redundant_slot_records([legacy]), [legacy]) + + def test_missing_proof_wrong_role_profile_platform_do_not_replace_legacy(self): + legacy = dict(manifest=manifest(LEGACY, False), files=[]) + for change in ( + dict(verification=[]), dict(capabilities=[]), dict(verified=False), + dict(platform="NRF52_PLATFORM"), dict(build_profile="standard"), + dict(target="Station_G3_ESP32_room_server"), + dict(verification=[dict(capability=slots.CAPABILITY, present=True, + source="linked image", evidence=slots.MARKER)]), + dict(verification=[dict(capability=slots.CAPABILITY, present=True, + source="packaged application", evidence="unrelated CLI text")])): + canonical = dict(manifest={**manifest(), **change}, files=[]) + with self.subTest(change=change): + self.assertEqual(slots.omit_redundant_slot_records([legacy, canonical]), + [legacy, canonical]) + + def test_metadata_requires_linked_board_radio_proof_and_exact_source(self): + expected = dict(radioSlotAuto=True, radioSlotFamily="sx1262", radioSlotSource=SOURCE) + self.assertEqual(slots.runtime_metadata(manifest(), DEFINES), expected) + self.assertEqual(slots.runtime_metadata(manifest(proof=False), DEFINES), {}) + for changed in (dict(RADIO_CLASS="CustomLR1121"), dict(USE_SX1262="0"), + dict(STATION_G3_ESP32="0")): + with self.subTest(defines=changed), self.assertRaises(ValueError): + slots.runtime_metadata(manifest(), {**DEFINES, **changed}) + for changed in (dict(verification=[]), dict(source_commit="a"*8), + dict(target="Station_G2_repeater"), dict(platform="NRF52_PLATFORM")): + with self.subTest(manifest=changed), self.assertRaises(ValueError): + slots.runtime_metadata({**manifest(), **changed}, DEFINES) + + def test_real_controls_generator_publishes_only_release_bound_application_proof(self): + with tempfile.TemporaryDirectory(prefix="g3-slot-controls-") as folder: + stage=Path(folder) + (stage/"companion").mkdir() + plan=dict(source=SOURCE, groups=[dict(key="companion", tag="v2.0.0-dev-aaaaaaaa")]) + (stage/"release-plan.json").write_text(json.dumps(plan)) + row=manifest() + row["platformio_env"]=CANONICAL + row["files"]=[CANONICAL+"-full-usb-wifi-v2.0.0-dev-aaaaaaaa.bin"] + (stage/"companion/TARGET-MANIFEST.json").write_text(json.dumps([row])) + config=[["env:"+CANONICAL, [["build_flags", [ + "-D"+key+"="+value for key,value in DEFINES.items()]]]]] + result=controls.generate(stage, config) + self.assertEqual(result["profiles"][CANONICAL+"-full-usb-wifi"]["radioSlotAuto"], True) + row["verification"][0]["source"]="linked image" + (stage/"companion/TARGET-MANIFEST.json").write_text(json.dumps([row])) + with self.assertRaises(ValueError): + controls.generate(stage, config) + + def test_capability_checker_rejects_elf_only_marker_and_accepts_packaged_application(self): + with tempfile.TemporaryDirectory(prefix="g3-slot-proof-") as folder: + work=Path(folder) + elf,app,output=work/"firmware.elf",work/"firmware.bin",work/"capabilities.json" + elf.write_bytes(slots.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", "ESP32_PLATFORM", "--build-profile", "full", + "--expect-application", slots.CAPABILITY+"="+slots.MARKER] + app.write_bytes(b"unrelated CLI strings, no shared-slot implementation") + failed=subprocess.run(command, text=True, capture_output=True, timeout=20) + self.assertEqual(failed.returncode, 1, failed.stdout+failed.stderr) + rejected=json.loads(output.read_text()) + self.assertFalse(rejected["verified"]) + self.assertFalse(slots.qualified_auto_slots(rejected)) + app.write_bytes(b"application prefix\0"+slots.MARKER.encode()+b"\0suffix") + passed=subprocess.run(command, text=True, capture_output=True, timeout=20) + self.assertEqual(passed.returncode, 0, passed.stdout+passed.stderr) + accepted=json.loads(output.read_text()) + self.assertTrue(slots.qualified_auto_slots(accepted)) + + @unittest.skipIf(os.name=="nt", "local release driver uses POSIX locking") + def test_real_local_release_selection_preserves_unmatched_old_records(self): + from build_local_release import select_ordinary_full_records + canonical=dict(manifest=manifest(), files=[]) + legacy=dict(manifest=manifest(LEGACY, False), files=[]) + self.assertEqual(select_ordinary_full_records([legacy, canonical]), [canonical]) + self.assertEqual(select_ordinary_full_records([legacy]), [legacy]) + + def test_no_ota_identity_alias_or_rehash_was_introduced(self): + for role in ROLES: + ordinary, legacy = "Station_G3_ESP32_"+role, "Station_G3_ESP32_r2_"+role + self.assertNotEqual(motalib.target_id_for_env(ordinary), motalib.target_id_for_env(legacy)) + self.assertNotEqual(motalib.hardware_id_for_env(ordinary), motalib.hardware_id_for_env(legacy)) + for target in (ordinary, legacy): + expected = int.from_bytes(hashlib.sha256(target.encode()).digest()[:4], "little") + self.assertEqual(motalib.target_id_for_env(target), expected) + + @unittest.skipIf(os.name=="nt", "Bash contract is exercised on Linux/WSL") + def test_bulk_skips_only_matching_sx1262_alias_but_direct_build_stays_available(self): + config=[] + for role in ROLES: + for prefix in ("Station_G3_ESP32_", "Station_G3_ESP32_r2_"): + config.append(["env:"+prefix+role, [["build_flags", [ + "-DUSE_SX1262=1", "-DRADIO_CLASS=CustomSX1262"]]]]) + program=r''' +set -euo pipefail +source build.sh +pio() { echo 'forbidden PlatformIO call' >&2;return 99; } +SUPPORTED_PIO_ENVS=() +for role in repeater room_server sensor repeater_observer_mqtt room_server_observer_mqtt; do + for prefix in Station_G3_ESP32_ Station_G3_ESP32_r2_; do + target=$prefix$role + SUPPORTED_PIO_ENVS+=("$target") + PIO_ENV_PLATFORM_BY_NAME[$target]=ESP32_PLATFORM + PIO_ENV_BOARD_BY_NAME[$target]=station-g3-esp32 + done +done +ALL_PIO_ENVS=("${SUPPORTED_PIO_ENVS[@]}") +PIO_CONFIG_JSON=@CONFIG@ +for role in repeater room_server sensor repeater_observer_mqtt room_server_observer_mqtt; do + legacy=Station_G3_ESP32_r2_$role + canonical=Station_G3_ESP32_$role + [ "$(get_station_g3_shared_slot_replacement "$legacy")" = "$canonical" ] + is_redundant_bulk_build_target "$legacy" + is_known_pio_env "$legacy" + [ "$(get_pio_build_env "$legacy")" = "$legacy" ] + ! get_station_g3_shared_slot_replacement "$canonical" +done +legacy=Station_G3_ESP32_r2_sensor +canonical=Station_G3_ESP32_sensor +PIO_ENV_BOARD_BY_NAME[$canonical]=other-board +! get_station_g3_shared_slot_replacement "$legacy" +PIO_ENV_BOARD_BY_NAME[$canonical]=station-g3-esp32 +PIO_ENV_PLATFORM_BY_NAME[$canonical]=NRF52_PLATFORM +! get_station_g3_shared_slot_replacement "$legacy" +PIO_ENV_PLATFORM_BY_NAME[$canonical]=ESP32_PLATFORM +PIO_CONFIG_JSON=@WRONG_RADIO@ +! get_station_g3_shared_slot_replacement "$legacy" +unset 'PIO_ENV_PLATFORM_BY_NAME[Station_G3_ESP32_sensor]' +! get_station_g3_shared_slot_replacement "$legacy" +''' + bad=deepcopy(config) + next(row for row in bad if row[0]=="env:Station_G3_ESP32_sensor")[1][0][1].extend( + ["-URADIO_CLASS", "-DRADIO_CLASS=CustomLR1121"]) + program=program.replace("@CONFIG@", shlex.quote(json.dumps(config))).replace( + "@WRONG_RADIO@", shlex.quote(json.dumps(bad))) + result=subprocess.run(["bash", "-c", program], cwd=ROOT, text=True, + capture_output=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + + +if __name__=="__main__": + unittest.main()