Publish one auto-slot Station G3 firmware choice per profile

This commit is contained in:
mikecarper committed 2026-10-15 22:30:37 -07:00
1 parent 8eced48d34
commit 7a2bc9b8fa
8 files changed
+469 -7

No files matched your search

+18
View File
@@ -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: |
+51
View File
@@ -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.
+65 -6
View File
@@ -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,
+3 -1
View File
@@ -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 = {
+2
View File
@@ -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)
+53
View File
@@ -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}
+82
View File
@@ -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");
+195
View File
@@ -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()