Link ESP32 picker to exact partition expansion packages

This commit is contained in:
mikecarper committed 2026-09-27 21:13:25 -07:00
1 parent ba2187db2c
commit fd7b93c52d
7 files changed
+296 -11

No files matched your search

+96
View File
@@ -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,
+52 -5
View File
@@ -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,
+14 -4
View File
@@ -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.
<div class="firmware-picker" data-firmware-picker data-release-repo="mikecarper/MeshCore" data-controls-url="../_data/firmware_controls.json?v=1.17.1.7-runtime-1" data-share-url="https://mikecarper.github.io/MeshCore/firmware_picker/">
<div class="firmware-picker" data-firmware-picker data-release-repo="mikecarper/MeshCore" data-controls-url="../_data/firmware_controls.json?v=1.17.1.7-runtime-2" data-share-url="https://mikecarper.github.io/MeshCore/firmware_picker/">
<div class="firmware-picker-intro" role="note">
<strong>Current release set</strong>
<p data-role="release-set">Loading release information...</p>
@@ -29,9 +29,11 @@ from the published firmware assets.
</p>
<p>
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 <code>get storage.layout</code>
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:
+1 -1
View File
@@ -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
+15 -1
View File
@@ -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__':
+104
View File
@@ -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']);
+14
View File
@@ -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):