Show role-specific feature commands in firmware picker

This commit is contained in:
mikecarper committed 2026-09-06 04:52:02 -07:00
1 parent 58643258db
commit b781b3df89
7 files changed
+10358 -15

No files matched your search

File diff suppressed because it is too large. Load diff
+231 -11
View File
@@ -36,7 +36,7 @@
const LOGGING_LABELS = Object.freeze({
none: "None / normal operation",
usb: "USB logging / USB-connected MQTT",
wifi: "Wi-Fi MQTT observer",
wifi: "Wi-Fi MQTT",
both: "USB logging + Wi-Fi MQTT",
runtime: "Runtime selectable: off / USB / Wi-Fi / both",
"usb-runtime": "Runtime selectable: off / USB",
@@ -67,7 +67,7 @@
});
const INSTALL_LABELS = Object.freeze({
"merged-bin": "Erase & fresh install (merged .bin)",
"merged-bin": "Full install / layout migration (merged .bin)",
bin: "Update existing install (.bin)",
zip: "nRF52 update / Serial DFU (.zip)",
uf2: "UF2 install / update (.uf2)",
@@ -631,7 +631,7 @@
profiles.length <= CANDIDATE_RESULT_LIMIT;
}
function buildCatalog(releases) {
function buildCatalog(releases, controlData) {
const releaseSet = selectReleaseSet(releases);
if (!releaseSet) {
return { releaseSet: null, rows: [], profiles: [] };
@@ -685,6 +685,16 @@
return profile.hardware;
})));
profiles.forEach(function (profile) {
if (controlData && controlData.familyTag === releaseSet.familyTag &&
controlData.profiles && controlData.profiles[profile.target]) {
profile.controls = controlData.profiles[profile.target];
if (isFullCompanion(profile) && profile.controls.mqtt) {
profile.loggingModes = ["none", "usb", "wifi", "both"];
}
if (profile.controls.rs232 && profile.role === "repeater" && profile.mode === "standard") {
profile.connectionModes = ["standard", "rs232"];
}
}
profile.hardwareFamily = hardwareFamilyFor(
profile.hardware,
hardwareNames
@@ -858,10 +868,10 @@
const byKind = {
"merged-bin": [
"Flash the merged image over a data-capable USB connection.",
"Use this erase/fresh-install path for a first install, recovery, or partition/profile migration.",
"Use this path for first installation, recovery, or a partition migration. Erase flash only when you intend to reset saved data.",
],
bin: [
"Use this app-only image only when the installed board, role, and partition layout already match.",
"Use this app-only image for the exact board and role, with compatible installed application slots large enough for the image.",
"Do not use an app-only image to migrate partition layouts.",
],
zip: [
@@ -915,6 +925,201 @@
return common.concat(byKind[kind] || [], extra);
}
// These are instructions only: changing a picker choice never changes a node.
function runtimeDirections(profile, selection) {
const chosen = selection || {};
const info = profile.controls;
const full = isFullCompanion(profile);
const companion = profile.role === "companion";
const infrastructure = ["repeater", "room", "sensor"].includes(profile.role);
const sections = [];
function section(title, actions, note) {
sections.push({ title: title, actions: actions, note: note || "" });
}
function toggle(title, command, read, note) {
section(title, [
{ label: "On", commands: [command + " on"] },
{ label: "Off", commands: [command + " off"] },
{ label: "Check", commands: [read] },
], note);
}
if (profile.logging === "runtime" || profile.logging === "usb-runtime") {
const modes = chosen.logging ? [chosen.logging] : profile.loggingModes;
const actions = modes.filter(function (mode) {
return profile.loggingModes.includes(mode);
}).map(function (mode) {
const enabled = mode === "usb" || mode === "both";
const commands = profile.logging === "runtime"
? ["set logging.output " + (mode === "none" ? "off" : mode), "get logging.output"]
: ["set usb.logging " + (enabled ? "on" : "off") +
(profile.dedicatedUsbLogging ? " reboot" : "")];
return {
label: LOGGING_LABELS[mode], commands: commands,
text: full && info && info.mqtt
? (mode === "wifi" || mode === "both"
? "In WebConfig, enable the desired MQTT broker presets/settings and save."
: "In WebConfig, choose none for every MQTT broker slot and save to stop MQTT connections.")
: "",
};
});
section("Restore the selected logging mode", actions,
full ? (profile.dedicatedUsbLogging
? "Reboot adds/removes the second logging port. Keep Companion/MOTA on primary interface 00."
: "USB logs and binary Companion share one port. Turn logging off, send +++MESHCORE-TERM-STOP, and close the console before connecting the app or MOTA host.")
: "Saved settings survive updates. These commands restore your selected output mode; downloading alone does not change it.");
}
if (!info) return sections;
if (full || infrastructure) {
toggle("Device power saving", full ? "powersaving" : "set powersaving", "powersaving",
full ? "Controls device/GPS power saving separately from LoRa RXPS and WiFi modem sleep."
: "The set form saves/applies without the bare powersaving on command's USB/bridge guards. Actual sleep depends on the board and can interrupt WiFi. Bare powersaving on rejects local/USB requests on nRF52 and standalone ESP32, and is unavailable on ESP32 bridge builds.");
if (info.rxps && !info.primaryEspnow) toggle("LoRa RX power saving", "set radio.rxps", "get radio.rxps");
if (info.rxgain && !info.primaryEspnow) toggle("Radio RX boost", "set radio.rxgain", "get radio.rxgain");
if (info.femRx) toggle("External FEM receive gain", "set radio.fem.rxgain", "get radio.fem.rxgain", "Requires the controllable FEM on the installed board revision; check the reply for hardware support.");
if (info.femTx) toggle("External FEM transmit gain", "set radio.fem.txgain", "get radio.fem.txgain", "Requires the controllable PA on the installed board revision.");
}
if (info.gps && (companion || infrastructure)) {
if (companion) section("GPS", [
{ label: "On", text: "In the Companion app's custom sensor settings, set gps=1." },
{ label: "Off", text: "In the Companion app's custom sensor settings, set gps=0." },
], "These are app settings, not USB terminal commands. Connect the board's supported GPS hardware; sharing location is a separate setting.");
else toggle("GPS", "gps", "gps", "Requires the supported GPS hardware to be installed.");
}
if (info.webconfig) {
toggle("WiFi settings website (WebConfig)", "set webui", "get webui");
if (infrastructure) toggle("WebConfig command terminal", "set wifi.cli", "get wifi.cli", "This is the node's WiFi browser terminal. The USB web console is independent and needs no WiFi.");
}
if (info.platform === "ESP32_PLATFORM" && (info.webconfig || info.mqtt)) {
section("WiFi modem power saving", [
{ label: "On (minimum sleep)", commands: ["set wifi.powersave min"] },
{ label: "Off", commands: ["set wifi.powersave none"] },
{ label: "Check", commands: ["get wifi.powersave"] },
], "Separate from device sleep and LoRa RXPS. Bluetooth coexistence can constrain the effective mode.");
}
if (info.mqtt) {
if (companion) section("MQTT broker connections", [
{ label: "On", commands: ["set webui on", "get webui"], text: "Open the reported WebConfig URL, select/configure a broker preset, and save. Repeat for each desired slot." },
{ label: "Off", text: "Open WebConfig, set every broker slot's preset to none, and save. Turning status publication off does not disconnect a broker." },
], "Companion configures MQTT through WebConfig; infrastructure MQTT text commands are not available in its terminal.");
else {
toggle("MQTT bridge", "set bridge.enabled", "get bridge.running", "Configure WiFi and broker slots first. get mqtt.status shows connections. This switch does not disable LoRa repeating.");
["status", "packets", "raw", "rx"].forEach(function (name) {
toggle("MQTT " + (name === "rx" ? "receive capture" : name + " publication"), "set mqtt." + name, "get mqtt." + name,
name === "status" ? "Only controls status publishing; connections remain enabled. The check command reports connection status." : "");
});
section("MQTT transmit capture", ["off", "advert", "on"].map(function (mode) {
return { label: mode, commands: ["set mqtt.tx " + mode] };
}));
}
}
if (info.snmp && infrastructure) section("SNMP", [
{ label: "On", commands: ["set snmp on", "reboot"] },
{ label: "Off", commands: ["set snmp off", "reboot"] },
{ label: "Check", commands: ["get snmp"] },
]);
if (profile.role === "repeater") toggle("Repeat mesh traffic", "set repeat", "get repeat");
if (info.rs232 && infrastructure) {
const mode = chosen.mode;
section("RS232 bridge" + (mode === "rs232" ? " — selected" : ""), [
{ label: "On", commands: ["set bridge.enabled on", "get bridge.running"] },
{ label: "Off", commands: ["set bridge.enabled off"] },
{ label: "Set baud", commands: ["set bridge.enabled off", "set bridge.baud 115200", "set bridge.enabled on"] },
].sort(function (a, b) {
return mode === "standard" ? Number(b.label === "Off") - Number(a.label === "Off") : 0;
}), "Use the UART and pin map for this exact board. Canonical GPS-enabled RAK4631 uses UART2; select it with set bridge.uart 2 while the bridge is stopped. UART1 needs a compatible GPS-free image.");
}
if (info.espnowBridge && infrastructure) {
toggle("ESP-NOW bridge", "set bridge.enabled", "get bridge.running");
section("ESP-NOW bridge framing", ["wrapped", "raw"].map(function (mode) {
return { label: mode, commands: ["set bridge.format " + mode] };
}), "Set bridge.channel to the bridge channel. Primary ESP-NOW mesh uses set espnow.channel instead; see the board guide before changing channels.");
}
if (full && /sensecapindicator/i.test(profile.target)) section("Indicator wireless transport", [
{ label: "WiFi", commands: ["set companion.transport wifi", "reboot"] },
{ label: "Bluetooth", commands: ["set companion.transport ble", "reboot"] },
{ label: "Check", commands: ["get companion.transport"] },
], "USB stays available. This changes the secondary wireless transport, not the image's primary LoRa/ESP-NOW mesh.");
if ((info.updateMethods || []).includes("wifi")) section("WiFi firmware uploader", [
{ label: "On (existing WiFi)", commands: ["start ota"] },
{ label: "On (setup access point)", commands: ["start ota ap"] },
{ label: "Off", commands: ["stop ota"] },
], full ? "Upload the exact application .bin to the returned URL on port 8080. Requires a compatible two-slot layout; never upload a merged image here."
: "Stop WebConfig first if it is running. The uploader uses port 80; upload the exact application .bin, not a merged image.");
if (infrastructure && (info.updateMethods || []).includes("bluetooth")) section("Bluetooth firmware update", [
{ label: "Start DFU", commands: ["start ota"], text: "Use the exact board's application DFU ZIP with the matching Bluetooth-capable bootloader." },
]);
if (full && info.display) section("Display rotation", [0, 90, 180, 270].map(function (angle) {
return { label: angle === 0 ? "Board default" : angle + " degrees", commands: ["set display.rotation " + angle] };
}), "Use on supported displays; 0 restores the board default. Check with get display.rotation.");
return sections;
}
function renderRuntimeDirections(card, profile, selection) {
const panel = createElement("section");
panel.className = "firmware-picker-runtime";
panel.appendChild(createElement("h4", "Restore your settings after flashing"));
panel.appendChild(createElement("p", "These directions change with the selected firmware and logging mode. Choose On, Off, or Check below to see the exact steps. Nothing is sent to your device by this page."));
const links = createElement("p");
const consoleLink = createElement("a", "Open USB web console");
consoleLink.href = "https://flasher.meshcore.io/console";
links.appendChild(consoleLink);
links.appendChild(document.createTextNode(" · "));
const guide = createElement("a", "Complete role commands and board exceptions");
guide.href = "https://github.com/mikecarper/MeshCore/blob/keymindCascade/docs/role_feature_switches.md";
links.appendChild(guide);
panel.appendChild(links);
if (isFullCompanion(profile) || ["repeater", "room", "sensor"].includes(profile.role)) {
panel.appendChild(createElement("p", "Use a data-capable USB cable and 115200 baud. Full Companion and infrastructure start in ASCII mode. " + (profile.role === "companion"
? "If already in binary mode, send +++MESHCORE-TERM-START. Use board and version to identify the node."
: "Use board and ver to identify the node.")));
}
if (!profile.controls) panel.appendChild(createElement("p", "Additional hardware controls have not been verified for this exact release image. Use the role guide for those settings."));
runtimeDirections(profile, selection).forEach(function (item, index) {
const details = createElement("details");
details.open = index === 0;
details.appendChild(createElement("summary", item.title));
if (item.note) details.appendChild(createElement("p", item.note));
const label = createElement("label", "Show steps for ");
const select = createElement("select");
select.setAttribute("aria-label", item.title);
item.actions.forEach(function (action, i) {
const option = createElement("option", action.label);
option.value = String(i);
select.appendChild(option);
});
label.appendChild(select);
details.appendChild(label);
const output = createElement("div");
function show() {
output.replaceChildren();
const action = item.actions[Number(select.value) || 0];
if (action.text) output.appendChild(createElement("p", action.text));
if (action.commands) {
const pre = createElement("pre");
pre.appendChild(createElement("code", action.commands.join("\n")));
output.appendChild(pre);
const button = createElement("button", "Copy commands");
button.type = "button";
button.addEventListener("click", function () {
if (!global.navigator || !global.navigator.clipboard) {
button.textContent = "Select and copy the commands above";
return;
}
global.navigator.clipboard.writeText(action.commands.join("\n")).then(function () {
button.textContent = "Copied";
}).catch(function () { button.textContent = "Select and copy the commands above"; });
});
output.appendChild(button);
}
}
select.addEventListener("change", show);
show();
details.appendChild(output);
panel.appendChild(details);
});
card.appendChild(panel);
}
function replaceFacts(container, facts) {
container.replaceChildren();
facts.forEach(function (fact) {
@@ -923,7 +1128,7 @@
});
}
function renderProfileCard(container, profile, asset, installKind) {
function renderProfileCard(container, profile, asset, installKind, selection) {
const card = createElement("article");
card.className = "firmware-picker-card";
card.appendChild(createElement(
@@ -942,7 +1147,7 @@
return labelFor("mode", mode);
}).join(" / "),
],
["Logging", labelFor("logging", profile.logging)],
["Logging", labelFor("logging", selection && selection.logging || profile.logging)],
["OTA", labelFor("ota", profile.ota)],
["Feature profile", labelFor("feature", profile.feature)],
["Variant", labelFor("variant", profile.variant)],
@@ -980,6 +1185,7 @@
});
steps.appendChild(list);
card.appendChild(steps);
renderRuntimeDirections(card, profile, selection);
container.appendChild(card);
}
@@ -1155,7 +1361,8 @@
resultList,
entry.profile,
entry.asset,
entry.installKind
entry.installKind,
filters
);
});
result.hidden = false;
@@ -1185,7 +1392,8 @@
resultList,
entry.profile,
entry.asset,
entry.installKind
entry.installKind,
filters
);
});
result.hidden = false;
@@ -1247,15 +1455,25 @@
encodeURIComponent(repo.split("/")[0]) + "/" +
encodeURIComponent(repo.split("/")[1]) +
"/releases?per_page=20";
fetch(endpoint, { headers: { Accept: "application/vnd.github+json" } })
const embeddedElement = document.getElementById("firmware-picker-data");
const embedded = embeddedElement ? JSON.parse(embeddedElement.textContent) : null;
const controlUrl = root.getAttribute("data-controls-url");
const controlRequest = embedded ? Promise.resolve(embedded.controls) : controlUrl ? fetch(controlUrl).then(function (response) {
return response.ok ? response.json() : null;
}).catch(function () { return null; }) : Promise.resolve(null);
const releaseRequest = embedded ? Promise.resolve(embedded.releases) :
fetch(endpoint, { headers: { Accept: "application/vnd.github+json" } })
.then(function (response) {
if (!response.ok) {
throw new Error("GitHub returned HTTP " + response.status);
}
return response.json();
});
releaseRequest.then(function (data) {
return controlRequest.then(function (controls) { return { releases: data, controls: controls }; });
})
.then(function (data) {
catalog = buildCatalog(data);
catalog = buildCatalog(data.releases, data.controls);
if (!catalog.releaseSet || !catalog.profiles.length) {
throw new Error("no complete release family was found");
}
@@ -1329,6 +1547,8 @@
canonicalAsset: canonicalAsset,
resolveProfileAssets: resolveProfileAssets,
shouldShowCandidateResults: shouldShowCandidateResults,
runtimeDirections: runtimeDirections,
renderRuntimeDirections: renderRuntimeDirections,
installSteps: installSteps,
humanizeHardware: humanizeHardware,
humanizeVariant: humanizeVariant,
+37
View File
@@ -282,3 +282,40 @@
margin-top: 0.35rem;
}
}
.firmware-picker-runtime {
border-top: 1px solid var(--picker-border);
margin-top: 1rem;
padding-top: 0.5rem;
}
.firmware-picker-runtime details {
border: 1px solid var(--picker-border);
border-radius: 0.4rem;
margin: 0.65rem 0;
padding: 0.7rem;
}
.firmware-picker-runtime summary {
cursor: pointer;
font-weight: 650;
}
.firmware-picker-runtime select,
.firmware-picker-runtime button {
padding: 0.5rem;
margin: 0.4rem 0;
font: inherit;
color: inherit;
background: var(--md-default-bg-color, white);
border: 1px solid var(--picker-border);
border-radius: 0.3rem;
max-width: 100%;
}
.firmware-picker-runtime pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 0.8rem;
background: var(--md-code-bg-color, #f3f5f7);
}
+39 -3
View File
@@ -1,7 +1,10 @@
# Firmware picker
After selecting an image, use [feature switches by role](role_feature_switches.md)
to turn MQTT, logging, power saving, and other former variant settings on/off.
Each result now includes **Restore your settings after flashing**. Its commands
follow the selected logging mode and the exact image's verified role/hardware
controls. Select On, Off, or Check to view and copy the commands; Companion
MQTT and GPS show their app/WebConfig steps instead. See
[feature switches by role](role_feature_switches.md) for the full reference.
The [USB web console](https://flasher.meshcore.io/console) works with the
default ASCII terminal on Full Companion and infrastructure roles.
@@ -12,7 +15,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">
<div class="firmware-picker" data-firmware-picker data-release-repo="mikecarper/MeshCore" data-controls-url="../_data/firmware_controls.json">
<div class="firmware-picker-intro" role="note">
<strong>Current release set</strong>
<p data-role="release-set">Loading release information...</p>
@@ -294,3 +297,36 @@ plaintext CLI/logger and disables framed USB Companion until logging is turned
off and the normal ASCII-to-binary mode switch occurs. nRF52 Full Companion
retains its optional dedicated interface `02`.
Exact filename search still finds old aliases from earlier releases.
## Maintaining the runtime directions
The online picker and downloadable HTML use the same command renderer.
Hardware-specific controls are enabled only when `_data/firmware_controls.json`
matches the selected release family and exact target. If that metadata is
missing or belongs to another release, the picker retains basic role/logging
directions and links the complete guide without inventing hardware support.
After qualifying a new release, resolve its PlatformIO configuration with no
other PlatformIO process running, then generate the controls from that source
revision and its staged manifests:
```bash
pio project config --json-output > /tmp/meshcore-picker-pio-config.json
python3 scripts/generate_picker_controls.py \
--stage /path/to/staged-release \
--pio-config /tmp/meshcore-picker-pio-config.json
```
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:
```bash
python3 scripts/package_firmware_picker.py \
--releases-json /path/to/releases.json \
--output /path/to/FIRMWARE-PICKER.html
```
This HTML embeds its catalog and directions, so selections work without an
internet connection. Firmware downloads and the USB web console still need
network access. When replacing the downloadable picker on release pages,
update its entry in each page's `SHA256SUMS.txt` as well.
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Generate release-bound picker controls from qualified manifests and resolved PIO config.
Resolve PlatformIO configuration separately, with no other PIO process running:
pio project config --json-output > /tmp/pio-config.json
"""
import argparse
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def generate(stage, config):
plan = json.loads((stage / 'release-plan.json').read_text())
envs = {name: dict(options) for name, options in config}
profiles = {}
for group in plan['groups']:
for manifest in json.loads((stage / group['key'] / 'TARGET-MANIFEST.json').read_text()):
if not manifest.get('verified'):
raise ValueError('Unqualified target: ' + manifest['target'])
env = envs['env:' + manifest['platformio_env']]
flags = env.get('build_flags', [])
defines = {}
for flag in flags:
undefine = re.match(r'-U\s*(\w+)$', flag.strip())
if undefine:
defines.pop(undefine[1], None)
match = re.match(r'-D\s*([\w]+)(?:=(.*))?$', flag.strip())
if match:
defines[match[1]] = match[2] or '1'
def enabled(name):
return name in defines and defines[name] != '0'
board_source = ''
for flag in flags:
match = re.fullmatch(r'-I\s+(variants/[\w.-]+)', flag.strip())
if match:
for file in sorted((ROOT / match[1]).glob('*')):
if file.suffix in {'.h', '.cpp'}:
board_source += file.read_text(errors='replace')
caps = set(manifest['capabilities'])
controls = {
'platform': manifest['platform'],
'gps': enabled('ENV_INCLUDE_GPS'),
'display': defines.get('DISPLAY_CLASS', 'NullDisplayDriver') != 'NullDisplayDriver',
'rxgain': enabled('SX126X_RX_BOOSTED_GAIN') or enabled('LR1110_RX_BOOSTED_GAIN'),
'rxps': 'SX126' in defines.get('RADIO_CLASS', ''),
'femRx': bool(re.search(r'bool\s+\w*(?:::)?setLoRaFemLnaEnabled\s*\(', board_source)),
'femTx': bool(re.search(r'bool\s+\w*(?:::)?setLoRaFemPaGainEnabled\s*\(', board_source)),
'webconfig': 'web.webconfig' in caps,
'mqtt': enabled('WITH_MQTT_BRIDGE'),
'rs232': enabled('WITH_RS232_BRIDGE'),
'espnowBridge': enabled('WITH_ESPNOW_BRIDGE'),
'primaryEspnow': enabled('MESH_PRIMARY_ESPNOW'),
'snmp': enabled('WITH_SNMP'),
'updateMethods': manifest.get('ota_update_methods', []),
}
for name in manifest['files']:
if not name.endswith(('.bin', '.uf2', '.zip', '.hex')):
continue
# Match parseFirmwareAsset's target extraction, including the
# separate -ota- marker and firmware's unchanged source tag.
target = re.split(r'-(?:ota-)?v\d+\.', name, maxsplit=1)[0]
if target in profiles and profiles[target] != controls:
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}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--stage', type=Path, required=True)
parser.add_argument('--pio-config', type=Path, required=True)
parser.add_argument('--output', type=Path, default=ROOT / 'docs/_data/firmware_controls.json')
args = parser.parse_args()
result = generate(args.stage, json.loads(args.pio_config.read_text()))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2, sort_keys=True) + '\n')
print(f"Wrote controls for {len(result['profiles'])} qualified profiles")
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Package the web picker and release-bound controls as one offline HTML file."""
import argparse
import html
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def package(releases, controls):
if not any(r['tag_name'] == controls['familyTag'] for r in releases):
raise ValueError('Controls must match the supplied release family')
# Retain only data used by the picker; never embed authentication or API metadata.
compact = []
for release in releases:
row = {k: release.get(k) for k in ('name', 'tag_name', 'html_url', 'published_at', 'created_at', 'draft', 'prerelease')}
row['assets'] = [{k: asset[k] for k in ('name', 'browser_download_url', 'size')}
for asset in release['assets']]
compact.append(row)
markdown = (ROOT / 'docs/firmware_picker.md').read_text()
form = markdown[markdown.index('<div class="firmware-picker"'):markdown.index('\n## What the choices mean')]
form = form.replace('Loading release information...', 'Loading embedded release information...')
payload = json.dumps({'releases': compact, 'controls': controls}).replace('<', '\\u003c')
css = (ROOT / 'docs/_stylesheets/firmware_picker.css').read_text()
js = (ROOT / 'docs/_javascript/firmware_picker.js').read_text()
title = html.escape('MeshCore firmware picker — ' + controls['familyTag'])
return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
f'<title>{title}</title><style>'
':root{--md-default-bg-color:white;--md-code-bg-color:#f3f5f7;--md-default-fg-color--light:#555}'
'body{font:16px system-ui;max-width:1200px;margin:2rem auto;padding:0 1rem;color:#222}'
'a{color:#07695d}button,summary{cursor:pointer}[hidden]{display:none!important}'
+ css + '</style></head><body><h1>' + title + '</h1>'
'<p>Choose your exact hardware and desired settings. Release metadata and '
'command directions are included in this file; downloads and the USB '
'web console require an internet connection.</p>' + form
+ '<script type="application/json" id="firmware-picker-data">' + payload + '</script>'
+ '<script>' + js.replace('</script', '<\\/script') + '</script></body></html>\n')
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--releases-json', type=Path, required=True, help='JSON array of public GitHub releases')
parser.add_argument('--controls-json', type=Path, default=ROOT / 'docs/_data/firmware_controls.json')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(package(json.loads(args.releases_json.read_text()), json.loads(args.controls_json.read_text())))
print('Wrote ' + str(args.output))
+51 -1
View File
@@ -797,7 +797,7 @@ assert.deepStrictEqual(
);
assert.strictEqual(
picker.INSTALL_LABELS["merged-bin"],
"Erase & fresh install (merged .bin)"
"Full install / layout migration (merged .bin)"
);
assert.strictEqual(
picker.INSTALL_LABELS.bin,
@@ -863,3 +863,53 @@ assert.strictEqual(
);
console.log("generalized firmware picker tests passed");
// User-selected output paths must produce role-correct commands. In
// particular, a Full Companion with MQTT must never get CommonCLI setters.
const controls = require('../docs/_data/firmware_controls.json');
const liveFamily = controls.familyTag;
const controlledReleases = [release(liveFamily, '2026-09-01T00:00:00Z', [
asset('heltec_v4_2_v4_3_companion_radio_full_femon-' + liveFamily + '.bin'),
asset('RAK_4631_companion_radio_full-' + liveFamily + '.uf2'),
asset('SenseCapIndicator-LoRa_companion_radio_full-' + liveFamily + '.bin'),
]), release('full-profiles-' + liveFamily, '2026-09-01T00:00:00Z', [
asset('heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-' + liveFamily + '.bin'),
]), release('repeater-room-' + liveFamily, '2026-09-01T00:00:00Z', [
asset('RAK_4631_repeater-' + liveFamily + '.uf2'),
])];
const controlled = picker.buildCatalog(controlledReleases, controls);
const findControlled = name => controlled.profiles.find(p => p.target === name);
const commands = sections => sections.flatMap(s => s.actions.flatMap(a => a.commands || []));
const observer = findControlled('heltec_v4_repeater_observer_mqtt-full-usb-wifi');
for (const mode of ['none', 'usb', 'wifi', 'both']) {
const directions = picker.runtimeDirections(observer, {logging: mode});
assert.deepStrictEqual(directions[0].actions[0].commands,
['set logging.output ' + (mode === 'none' ? 'off' : mode), 'get logging.output']);
}
const mqttCompanion = findControlled('heltec_v4_2_v4_3_companion_radio_full_femon');
assert.deepStrictEqual(mqttCompanion.loggingModes, ['none', 'usb', 'wifi', 'both']);
const companionWifi = picker.runtimeDirections(mqttCompanion, {logging: 'wifi'});
assert.deepStrictEqual(companionWifi[0].actions[0].commands, ['set usb.logging off']);
assert(companionWifi[0].actions[0].text.includes('enable the desired MQTT'));
assert(commands(companionWifi).every(c => !/logging.output|bridge.enabled|set mqtt\./.test(c)));
assert(companionWifi.some(s => s.title === 'GPS' && s.actions[0].text.includes('gps=1')));
const companionOff = picker.runtimeDirections(mqttCompanion, {logging: 'none'});
assert(companionOff[0].actions[0].text.includes('none for every MQTT'));
const nrf = findControlled('RAK_4631_companion_radio_full');
assert.deepStrictEqual(picker.runtimeDirections(nrf, {logging: 'usb'})[0].actions[0].commands,
['set usb.logging on reboot']);
const rakRepeater = findControlled('RAK_4631_repeater');
const rakCommands = picker.runtimeDirections(rakRepeater, {logging: 'usb', mode: 'standard'});
assert.deepStrictEqual(rakCommands[0].actions[0].commands, ['set usb.logging on']);
assert.strictEqual(rakCommands.find(s => s.title.startsWith('RS232')).actions[0].label, 'Off');
assert(commands(rakCommands).includes('gps on'));
assert(!rakCommands.some(s => s.title === 'MQTT broker connections'));
const indicator = picker.runtimeDirections(findControlled('SenseCapIndicator-LoRa_companion_radio_full'), {});
assert(commands(indicator).includes('set companion.transport ble'));
assert(!indicator.some(s => s.title.startsWith('MQTT')));
// Never apply old hardware capabilities to another release, or to an unknown
// exact target. Fall back to role documentation rather than invented switches.
const stale = picker.buildCatalog(controlledReleases, {...controls, familyTag: 'v0.0.0'});
assert(stale.profiles.every(p => !p.controls));
assert(!picker.runtimeDirections({...mqttCompanion, controls: undefined}, {}).some(s => s.title === 'GPS'));
console.log('role-specific runtime directions tests passed');