From 687896fa334753013fd81c4285d678edbce931c5 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 27 Sep 2026 21:13:25 -0700 Subject: [PATCH] Link ESP32 picker to exact partition expansion packages --- docs/_data/firmware_controls.json | 96 +++++++++++++++++++++++ docs/_javascript/firmware_picker.js | 57 ++++++++++++-- docs/firmware_picker.md | 18 ++++- mkdocs.yml | 2 +- scripts/generate_picker_controls.py | 16 +++- test/test_firmware_picker.js | 104 +++++++++++++++++++++++++ test/test_picker_controls_generator.py | 14 ++++ 7 files changed, 296 insertions(+), 11 deletions(-) diff --git a/docs/_data/firmware_controls.json b/docs/_data/firmware_controls.json index d471756c7..a18e0abb1 100644 --- a/docs/_data/firmware_controls.json +++ b/docs/_data/firmware_controls.json @@ -1,5 +1,101 @@ { "familyTag": "v1.17.1.7-halo-keymind-cascade-dev-2d03e098", + "partitionMigrations": { + "Ebyte_EoRa-S3_Repeater": "ebyte-eora-s3-repeater", + "Ebyte_EoRa-S3_room_server": "ebyte-eora-s3-room-server", + "Generic_E22_sx1262_repeater": "generic-e22-sx1262-repeater", + "Generic_E22_sx1268_repeater": "generic-e22-sx1268-repeater", + "Heltec_E213_repeater": "heltec-e213-repeater", + "Heltec_E213_room_server": "heltec-e213-room-server", + "Heltec_E290_repeater": "heltec-e290-repeater", + "Heltec_E290_room_server": "heltec-e290-room-server", + "Heltec_T190_repeater_": "heltec-t190-repeater", + "Heltec_T190_room_server_": "heltec-t190-room-server", + "Heltec_WSL3_repeater": "heltec-wsl3-repeater", + "Heltec_WSL3_room_server": "heltec-wsl3-room-server", + "Heltec_WSL3_sensor": "heltec-wsl3-sensor", + "Heltec_Wireless_Paper_repeater": "heltec-wireless-paper-repeater", + "Heltec_Wireless_Paper_room_server": "heltec-wireless-paper-room-server", + "Heltec_Wireless_Tracker_repeater": "heltec-wireless-tracker-repeater", + "Heltec_Wireless_Tracker_room_server": "heltec-wireless-tracker-room-server", + "Heltec_ct62_repeater": "heltec-ct62-repeater", + "Heltec_ct62_sensor": "heltec-ct62-sensor", + "Heltec_v2_repeater": "heltec-v2-repeater", + "Heltec_v2_room_server": "heltec-v2-room-server", + "Heltec_v3_repeater": "heltec-v3-repeater", + "Heltec_v3_room_server": "heltec-v3-room-server", + "Heltec_v3_sensor": "heltec-v3-sensor", + "LilyGo_T3S3_sx1262_repeater": "t3s3-sx1262-repeater", + "LilyGo_T3S3_sx1262_room_server": "t3s3-sx1262-room-server", + "LilyGo_T3S3_sx1276_repeater": "t3s3-sx1276-repeater", + "LilyGo_T3S3_sx1276_room_server": "t3s3-sx1276-room-server", + "LilyGo_TBeam_1W_repeater": "lilygo-tbeam-1w-repeater", + "LilyGo_TBeam_1W_room_server": "lilygo-tbeam-1w-room-server", + "LilyGo_TDeck_repeater": "lilygo-tdeck-repeater", + "LilyGo_TETH_Elite_sx1262_repeater": "lilygo-teth-elite-sx1262-repeater", + "LilyGo_TETH_Elite_sx1262_room_server": "lilygo-teth-elite-sx1262-room-server", + "MKE_s3_repeater": "mke-s3-repeater", + "MKE_s3_room_server": "mke-s3-room-server", + "MKE_s3_sensor": "mke-s3-sensor", + "RAK_3112_repeater": "rak-3112-repeater", + "RAK_3112_room_server": "rak-3112-room-server", + "RAK_3112_sensor": "rak-3112-sensor", + "Station_G2_logging_repeater": "station-g2-logging-repeater", + "Station_G2_repeater": "station-g2-repeater", + "Station_G2_room_server": "station-g2-room-server", + "Station_G3_ESP32_logging_repeater": "station-g3-esp32-logging-repeater", + "Station_G3_ESP32_repeater": "station-g3-esp32-repeater", + "Station_G3_ESP32_room_server": "station-g3-esp32-room-server", + "T_Beam_S3_Supreme_SX1262_repeater": "t-beam-s3-supreme-sx1262-repeater", + "T_Beam_S3_Supreme_SX1262_room_server": "t-beam-s3-supreme-sx1262-room-server", + "Tenstar_C3_sx1262_repeater": "tenstar-c3-sx1262-repeater", + "Tenstar_C3_sx1268_repeater": "tenstar-c3-sx1268-repeater", + "ThinkNode_M2_Repeater": "thinknode-m2-repeater", + "ThinkNode_M2_room_server": "thinknode-m2-room-server", + "ThinkNode_M5_Repeater": "thinknode-m5-repeater", + "ThinkNode_M5_room_server": "thinknode-m5-room-server", + "ThinkNode_M7_repeater": "thinknode-m7-repeater", + "ThinkNode_M7_room_server": "thinknode-m7-room-server", + "ThinkNode_M9_repeater_": "thinknode-m9-repeater", + "ThinkNode_M9_room_server_": "thinknode-m9-room-server", + "Xiao_C3_repeater": "xiao-c3-repeater", + "Xiao_C3_room_server": "xiao-c3-room-server", + "Xiao_S3_WIO_repeater": "xiao-s3-wio", + "Xiao_S3_WIO_room_server": "xiao-s3-wio-room-server", + "Xiao_S3_WIO_sensor": "xiao-s3-wio-sensor", + "Xiao_S3_repeater": "xiao-s3-repeater", + "Xiao_S3_room_server": "xiao-s3-room-server", + "Xiao_S3_sensor": "xiao-s3-sensor", + "heltec_rc32_repeater": "heltec-rc32-repeater", + "heltec_rc32_room_server": "heltec-rc32-room-server", + "heltec_rc32_sensor": "heltec-rc32-sensor", + "heltec_rc32_without_display_repeater": "heltec-rc32-no-display-repeater", + "heltec_rc32_without_display_room_server": "heltec-rc32-no-display-room-server", + "heltec_rc32_without_display_sensor": "heltec-rc32-no-display-sensor", + "heltec_tracker_v2_repeater": "heltec-tracker-v2-repeater", + "heltec_tracker_v2_room_server": "heltec-tracker-v2-room-server", + "heltec_tracker_v2_sensor": "heltec-tracker-v2-sensor", + "heltec_v4_expansionkit_repeater": "heltec-v4-expansionkit-repeater", + "heltec_v4_r8_repeater": "heltec-v4-r8-repeater", + "heltec_v4_r8_room_server": "heltec-v4-r8-room-server", + "heltec_v4_r8_sensor": "heltec-v4-r8-sensor", + "heltec_v4_r8_tft_repeater": "heltec-v4-r8-tft-repeater", + "heltec_v4_r8_tft_room_server": "heltec-v4-r8-tft-room-server", + "heltec_v4_r8_tft_sensor": "heltec-v4-r8-tft-sensor", + "heltec_v4_repeater": "heltec-v4", + "heltec_v4_room_server": "heltec-v4-room-server", + "heltec_v4_sensor": "heltec-v4-sensor", + "heltec_v4_tft_repeater": "heltec-v4-tft-repeater", + "heltec_v4_tft_room_server": "heltec-v4-tft-room-server", + "heltec_v4_tft_sensor": "heltec-v4-tft-sensor", + "meshnology_w12_repeater": "meshnology-w12-repeater", + "meshnology_w12_room_server": "meshnology-w12-room-server", + "meshnology_w12_sensor": "meshnology-w12-sensor", + "nibble_screen_connect_repeater_": "nibble-screen-repeater", + "nibble_screen_connect_room_server_": "nibble-screen-room-server", + "nibble_zero_connect_repeater_": "nibble-zero-repeater", + "nibble_zero_connect_room_server_": "nibble-zero-room-server" + }, "profiles": { "Ebyte_EoRa-S3_Repeater": { "display": true, diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index a3376ee2b..c2a3492e6 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -779,6 +779,10 @@ controlData.profiles && controlData.profiles[profile.target]) { profile.controls = controlData.profiles[profile.target]; applyReleaseRuntimeCapabilities(profile, profile.controls); + profile.migrationPackage = migrationPackageForProfile( + profile, controlData.partitionMigrations + ); + profile.migrationAsset = migrationAssetForProfile(profile, rows); if (isFullCompanion(profile) && profile.controls.mqtt && !Array.isArray(profile.controls.loggingModes)) { profile.loggingModes = ["none", "usb", "wifi", "both"]; @@ -1060,6 +1064,46 @@ return url !== asset.releaseUrl && isSafeGithubUrl(url) ? url : ""; } + function migrationPackageForProfile(profile, lookup) { + if (!isExpandedEsp32Infrastructure(profile) || !lookup) return ""; + const target = profile.target.replace(/-full-(?:logging|usb-wifi)$/i, ""); + // The packages name canonical physical board/role identities. Observer + // Full profiles use that same board/role's recipe; the package README + // still governs the installed OTA target/layout. Preserve every hardware + // variant (including logging hardware and trailing underscores). + const canonical = target.replace(/_observer_mqtt(?=_?$)/i, ""); + const key = Object.prototype.hasOwnProperty.call(lookup, target) + ? target : canonical; + const packageName = Object.prototype.hasOwnProperty.call(lookup, key) + ? lookup[key] : ""; + return typeof packageName === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(packageName) + ? packageName : ""; + } + + function migrationAssetForProfile(profile, rows) { + if (!profile.migrationPackage) return null; + const releaseUrl = migrationReleaseUrl(profile, profile.files[0]); + if (!releaseUrl) return null; + const name = profile.migrationPackage + "-" + profile.releaseFamily + "-migration.zip"; + const downloadUrl = releaseUrl.replace("/releases/tag/", "/releases/download/") + + "/" + encodeURIComponent(name); + const matches = rows.filter(function (row) { + return row.name === name && row.releaseTag === "utility-" + profile.releaseFamily && + row.releaseUrl === releaseUrl && row.url === downloadUrl; + }); + // Only offer a download actually published for this release and exact + // board/role. Missing, unsafe, or ambiguous assets retain the fallback. + return matches.length === 1 ? matches[0] : null; + } + + function migrationLink(profile, asset) { + const fallback = migrationReleaseUrl(profile, asset); + if (!fallback) return null; + return profile.migrationAsset + ? { url: profile.migrationAsset.url, label: "Download exact partition-expansion ZIP" } + : { url: fallback, label: "Check partition-expansion availability" }; + } + function installSteps(profile, kind) { const common = [ "Verify that the hardware name and every displayed variant match the physical board.", @@ -1111,7 +1155,7 @@ const extra = []; if (isExpandedEsp32Infrastructure(profile) && kind === "bin") { extra.push( - "If the node still has a smaller layout, first flash the matching merged image over USB or, if listed, use the exact board/role migration ZIP from the utility release and follow its README. Never send this app-only image across a partition change." + "If the node still has a smaller layout, first flash the matching merged image over USB or use the linked exact board/role migration ZIP, when available. Follow its README to check the installed OTA target and source layout before starting. Never send this app-only image across a partition change." ); } if (profile.variant.includes("w25q16")) { @@ -1449,10 +1493,10 @@ const release = createElement("a", "Open release notes"); release.href = asset.releaseUrl; actions.appendChild(release); - const migrationUrl = migrationReleaseUrl(profile, asset); - if (migrationUrl) { - const migration = createElement("a", "Check exact ESP32 migration ZIP"); - migration.href = migrationUrl; + const migrationAction = migrationLink(profile, asset); + if (migrationAction) { + const migration = createElement("a", migrationAction.label); + migration.href = migrationAction.url; actions.appendChild(migration); } card.appendChild(actions); @@ -1918,6 +1962,9 @@ renderRuntimeDirections: renderRuntimeDirections, installSteps: installSteps, migrationReleaseUrl: migrationReleaseUrl, + migrationPackageForProfile: migrationPackageForProfile, + migrationAssetForProfile: migrationAssetForProfile, + migrationLink: migrationLink, humanizeHardware: humanizeHardware, humanizeVariant: humanizeVariant, labelFor: labelFor, diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 87a5714bf..af5577b8f 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -17,7 +17,7 @@ The picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly from the published firmware assets. -
+
Current release set

Loading release information...

@@ -29,9 +29,11 @@ from the published firmware assets.

Upgrading an existing ESP32 infrastructure node from a smaller partition - layout? The selected Full result links to the utility release. If an - exact board and role migration ZIP is listed, follow its included README - for a supported Wi-Fi or LoRa route. USB installation uses the matching + layout? The selected Full result links directly to its exact board and + role partition-expansion ZIP when published. Follow its included README + to check the installed OTA target and source layout for a supported Wi-Fi + or LoRa route. If no unique matching ZIP is available, the picker links + to the utility release to check availability. USB installation uses the matching merged image instead. An app-only .bin cannot change the partition table. If the installed firmware supports it, run get storage.layout to see the live flash size and OTA slots. Firmware version alone does not @@ -481,6 +483,14 @@ python3 scripts/generate_picker_controls.py \ Refresh the `data-controls-url` version query when publishing the generated metadata so browsers fetch the new release's controls. +The generated `partitionMigrations` lookup comes from `BOARDS` in +`scripts/package_esp32_partition_migration.py`, the same exact board/role table +used to build the migration packages. Expanded ESP32 Full results use this +lookup (including canonical board/role recipes for Observer profiles), then +require one matching published ZIP in the same release family's utility page. +Hardware variants and roles remain distinct. Unlisted targets, missing or +ambiguous assets, and stale metadata never produce a guessed download link. + For the downloadable version, save the release family's public GitHub release objects as a JSON array, then package the same picker UI and controls: diff --git a/mkdocs.yml b/mkdocs.yml index 891894dc7..d89934d14 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,7 +26,7 @@ extra_css: extra_javascript: - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - - _javascript/firmware_picker.js?v=20260927-3 + - _javascript/firmware_picker.js?v=20260927-4 - _javascript/telemetry_decoder.js - _javascript/management_decoder.js - _javascript/filter_tool.js diff --git a/scripts/generate_picker_controls.py b/scripts/generate_picker_controls.py index 4b881a880..337cea85a 100644 --- a/scripts/generate_picker_controls.py +++ b/scripts/generate_picker_controls.py @@ -9,9 +9,22 @@ import json import re from pathlib import Path +from package_esp32_partition_migration import BOARDS + ROOT = Path(__file__).resolve().parents[1] +def partition_migrations(): + """Use the packager's exact board/role lookup, not guessed ZIP slugs.""" + result = {} + for package, spec in BOARDS.items(): + target = spec['target'] + if target in result: + raise ValueError('Ambiguous partition migration target: ' + target) + result[target] = package + return result + + def capacity_note(reductions, defines): notes = [] for reduction in reductions: @@ -141,7 +154,8 @@ def generate(stage, config): raise ValueError('Conflicting target metadata: ' + target) profiles[target] = controls family = next(g['tag'] for g in plan['groups'] if g['key'] == 'companion') - return {'familyTag': family, 'source': plan['source'], 'profiles': profiles} + return {'familyTag': family, 'source': plan['source'], 'profiles': profiles, + 'partitionMigrations': partition_migrations()} if __name__ == '__main__': diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 47354eee7..a541a3820 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1134,6 +1134,110 @@ assert.strictEqual(commands(combinedDirections).filter(command => command === 's assert.strictEqual(commands(combinedDirections).filter(command => command === 'set bridge.enabled on').length, 1); console.log('current release metadata and capacity directions tests passed'); +// Partition expansion uses the packager's lookup and published utility assets, +// not hardware labels or guessed slugs (several historical names differ). +const migrationFamily = currentControls.familyTag; +const utilityTag = 'utility-' + migrationFamily; +const utilityUrl = 'https://github.com/mikecarper/MeshCore/releases/tag/' + utilityTag; +function migrationZip(packageName, tag = migrationFamily) { + const name = packageName + '-' + tag + '-migration.zip'; + return {...asset(name), browser_download_url: + 'https://github.com/mikecarper/MeshCore/releases/download/utility-' + tag + '/' + name}; +} +const migrationAssets = Object.values(currentControls.partitionMigrations).map(name => migrationZip(name)); +function migrationCatalog(assets = migrationAssets, metadata = currentControls, utilityOverrides = {}) { + return picker.buildCatalog([ + release(migrationFamily, '2026-09-13T00:00:00Z', currentAssets), + release(utilityTag, '2026-09-13T00:00:01Z', assets, utilityOverrides), + ], metadata); +} +const withMigrations = migrationCatalog(); +assert.strictEqual(withMigrations.rows.length, currentCatalog.rows.length, + 'Migration ZIPs must not become firmware/Serial DFU choices'); +const g2Target = 'Station_G2_repeater_observer_mqtt-full-usb-wifi'; +function g2MigrationLink(catalog) { + const profile = catalog.profiles.find(item => item.target === g2Target); + return picker.migrationLink(profile, profile.files[0]); +} +const directG2Link = { + url: migrationZip('station-g2-repeater').browser_download_url, + label: 'Download exact partition-expansion ZIP', +}; +const fallbackMigrationLink = { + url: utilityUrl, label: 'Check partition-expansion availability', +}; +assert.deepStrictEqual(g2MigrationLink(withMigrations), directG2Link); +const g2PickerUrl = 'https://mikecarper.github.io/MeshCore/firmware_picker/' + + '?chipFamily=esp32&hardwareFamily=Station_G2&hardware=Station_G2&role=repeater&mode=espnow&install=bin&chipAuto=1'; +const g2Selection = picker.selectionFromUrl(g2PickerUrl, withMigrations.profiles); +assert.deepStrictEqual(g2Selection.unavailable, []); +const g2Selected = withMigrations.profiles.filter(profile => + picker.profileMatchesFacets(profile, g2Selection.filters)); +assert.deepStrictEqual(g2Selected.map(profile => profile.target), [g2Target]); +assert.deepStrictEqual(picker.migrationLink(g2Selected[0], g2Selected[0].files[0]), directG2Link); + +// Exercise every row in the packager lookup, even recipes whose canonical +// image lives only inside its ZIP rather than the ordinary firmware matrix. +for (const [target, packageName] of Object.entries(currentControls.partitionMigrations)) { + for (const suffix of ['-full-logging', '-full-usb-wifi']) { + const fullTarget = target + suffix; + const profile = Object.assign(picker.parseTargetProfile(fullTarget), { + releaseFamily: migrationFamily, controls: {platform: 'ESP32_PLATFORM'}, + files: [{releaseUrl: 'https://github.com/mikecarper/MeshCore/releases/tag/' + migrationFamily}], + }); + profile.migrationPackage = picker.migrationPackageForProfile(profile, currentControls.partitionMigrations); + profile.migrationAsset = picker.migrationAssetForProfile(profile, migrationAssets.map(item => ({ + name: item.name, url: item.browser_download_url, releaseTag: utilityTag, releaseUrl: utilityUrl, + }))); + assert.strictEqual(profile.migrationPackage, packageName, fullTarget); + assert.strictEqual(picker.migrationLink(profile, profile.files[0]).url, + migrationZip(packageName).browser_download_url, fullTarget); + } +} +const currentMigrationProfiles = withMigrations.profiles.filter(profile => profile.migrationPackage); +assert(currentMigrationProfiles.length > 0); +for (const profile of currentMigrationProfiles) { + assert(profile.migrationAsset, profile.target); + assert.strictEqual(picker.migrationLink(profile, profile.files[0]).url, + migrationZip(profile.migrationPackage).browser_download_url, profile.target); +} +assert.strictEqual(picker.migrationLink(ordinaryEsp32, ordinaryEsp32.files[0]), null); +assert.strictEqual(picker.migrationLink(ikokaNormal, ikokaNormal.files[0]), null); +const unmappedVariant = withMigrations.profiles.find(profile => + profile.target === 'Station_G3_ESP32_r2_repeater_observer_mqtt-full-usb-wifi'); +assert(unmappedVariant); +assert.deepStrictEqual(picker.migrationLink(unmappedVariant, unmappedVariant.files[0]), fallbackMigrationLink, + 'An unlisted board revision must not borrow another board recipe'); + +const noG2Assets = migrationAssets.filter(item => item.name !== migrationZip('station-g2-repeater').name); +assert.deepStrictEqual(g2MigrationLink(migrationCatalog(noG2Assets)), fallbackMigrationLink, + 'Room-server and logging-hardware packages must not stand in for G2 repeater'); +assert.deepStrictEqual(g2MigrationLink(migrationCatalog([ + ...noG2Assets, migrationZip('station-g2-repeater', family), +])), fallbackMigrationLink, 'A ZIP for another release is not a match'); +assert.deepStrictEqual(g2MigrationLink(migrationCatalog(migrationAssets, currentControls, + {tag_name: 'utility-' + family})), fallbackMigrationLink, 'Ignore other release families'); +assert.deepStrictEqual(g2MigrationLink(migrationCatalog(migrationAssets, currentControls, + {tag_name: 'logging-' + migrationFamily})), fallbackMigrationLink, 'Require the utility page'); +assert.deepStrictEqual(g2MigrationLink(migrationCatalog([ + ...migrationAssets, migrationZip('station-g2-repeater'), +])), fallbackMigrationLink, 'Duplicate matches must fail closed'); +for (const badUrl of ['javascript:alert(1)', 'https://example.com/download.zip', + migrationZip('station-g2-repeater').browser_download_url.replace('/mikecarper/', '/someone-else/'), + migrationZip('station-g2-room-server').browser_download_url]) { + assert.deepStrictEqual(g2MigrationLink(migrationCatalog([ + ...noG2Assets, {...migrationZip('station-g2-repeater'), browser_download_url: badUrl}, + ])), fallbackMigrationLink, badUrl); +} +assert.deepStrictEqual(g2MigrationLink(migrationCatalog(migrationAssets, currentControls, + {html_url: utilityUrl.replace('/mikecarper/', '/someone-else/')})), fallbackMigrationLink); +assert.deepStrictEqual(g2MigrationLink(migrationCatalog(migrationAssets, + {...currentControls, partitionMigrations: undefined})), fallbackMigrationLink, + 'Older controls without a lookup retain a clearly labeled fallback'); +assert.strictEqual(g2MigrationLink(migrationCatalog(migrationAssets, + {...currentControls, familyTag: family})), null, 'Stale controls must not supply a migration'); +console.log('exact partition-expansion links passed for all ' + migrationAssets.length + ' lookup recipes'); + assert.strictEqual(nrf.chipFamily, 'nrf52'); assert.strictEqual(mqttCompanion.chipFamily, 'esp32'); assert.deepStrictEqual(picker.facetValues(controlled.profiles, {chipFamily: 'nrf52'}, 'hardwareFamily'), ['RAK_4631']); diff --git a/test/test_picker_controls_generator.py b/test/test_picker_controls_generator.py index 00ff22c0f..03950dfde 100644 --- a/test/test_picker_controls_generator.py +++ b/test/test_picker_controls_generator.py @@ -1,16 +1,29 @@ import importlib.util import json +import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'scripts')) SPEC = importlib.util.spec_from_file_location('picker_controls', ROOT / 'scripts/generate_picker_controls.py') GENERATOR = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(GENERATOR) class PickerControlsTests(unittest.TestCase): + def test_partition_migrations_follow_the_complete_packager_lookup(self): + expected = {spec['target']: package + for package, spec in GENERATOR.BOARDS.items()} + self.assertEqual(len(expected), len(GENERATOR.BOARDS)) + self.assertEqual(GENERATOR.partition_migrations(), expected) + checked_in = json.loads((ROOT / 'docs/_data/firmware_controls.json').read_text()) + self.assertEqual(checked_in['partitionMigrations'], expected) + self.assertEqual(expected['Station_G2_repeater'], 'station-g2-repeater') + self.assertEqual(expected['Station_G2_logging_repeater'], 'station-g2-logging-repeater') + self.assertEqual(expected['nibble_zero_connect_room_server_'], 'nibble-zero-room-server') + def test_runtime_metadata_uses_verified_image_capabilities(self): source = 'a' * 40 manifest = dict(source_commit=source, @@ -51,6 +64,7 @@ class PickerControlsTests(unittest.TestCase): (stage / 'companion/TARGET-MANIFEST.json').write_text(json.dumps([manifest])) config = [('env:sample', [('build_flags', list(flags))])] result = GENERATOR.generate(stage, config) + self.assertEqual(result['partitionMigrations'], GENERATOR.partition_migrations()) return result['profiles']['sample_companion_radio_full'] def test_compiled_capacity_overrides_unreduced_config_and_binds_repair_source(self):