Show role-specific feature commands in firmware picker
This commit is contained in:
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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user