Add GitHub Pages firmware picker

This commit is contained in:
mikecarper committed 2026-08-23 00:29:00 -07:00
1 parent 7d2f69e34f
commit ae1b928e81
6 files changed
+768

No files matched your search

+359
View File
@@ -0,0 +1,359 @@
(function (global) {
"use strict";
const PURPOSES = Object.freeze({
"usb-logging": Object.freeze({
title: "Station G2 FULL USB logging repeater",
summary: "Best for a G2 that normally remains connected to a computer collecting serial packet logs.",
pattern: /^Station_G2_repeater-full-logging-ota-/,
expected: "Station_G2_repeater-full-logging-ota-...",
full: true,
notes: [
"Back up the node configuration before the first FULL install.",
"Use the merged image once to install the expanded partition table.",
"Reconnect at 115200 baud and leave the USB data connection attached for logging.",
],
}),
field: Object.freeze({
title: "Station G2 standard repeater",
summary: "The simplest sensor-enabled repeater for local USB installation and ordinary field use.",
pattern: /^Station_G2_repeater-v/,
expected: "Station_G2_repeater-v...",
full: false,
notes: [
"Choose this when USB logging, MQTT, ESP-NOW, and remote LoRa updates are not required.",
"This keeps the standard Station G2 partition profile.",
],
}),
"field-ota": Object.freeze({
title: "Station G2 lean OTA repeater",
summary: "A standalone repeater with LoRa/WiFi update support and no optional external environmental-sensor drivers.",
pattern: /^Station_G2_repeater_lora_ota_no_external_sensors-ota-/,
expected: "Station_G2_repeater_lora_ota_no_external_sensors-ota-...",
full: false,
notes: [
"Board-native GPS support remains available; only optional external sensor drivers are removed.",
"Use the exact matching target for every later LoRa package.",
],
}),
mqtt: Object.freeze({
title: "Station G2 FULL MQTT observer/repeater",
summary: "For direct radio-to-MQTT forwarding over WiFi with the complete CLI and expanded partitions.",
pattern: /^Station_G2_repeater_observer_mqtt-full-ota-/,
expected: "Station_G2_repeater_observer_mqtt-full-ota-...",
full: true,
notes: [
"This is an MQTT observer role, not USB packet logging.",
"Use its merged image once before any app-only WiFi or LoRa update.",
],
}),
espnow: Object.freeze({
title: "Station G2 FULL ESP-NOW bridge repeater",
summary: "For a repeater that bridges packets through ESP-NOW and needs the complete CLI.",
pattern: /^Station_G2_repeater_bridge_espnow-full-ota-/,
expected: "Station_G2_repeater_bridge_espnow-full-ota-...",
full: true,
notes: ["ESP-NOW bridge builds use the expanded FULL partition profile."],
}),
room: Object.freeze({
title: "Station G2 room server",
summary: "A standard room-server role without MQTT.",
pattern: /^Station_G2_room_server-v/,
expected: "Station_G2_room_server-v...",
full: false,
notes: ["Do not install this when the node is intended to remain a repeater."],
}),
"room-mqtt": Object.freeze({
title: "Station G2 FULL MQTT room observer",
summary: "A room server with direct MQTT/WiFi forwarding and expanded partitions.",
pattern: /^Station_G2_room_server_observer_mqtt-full-ota-/,
expected: "Station_G2_room_server_observer_mqtt-full-ota-...",
full: true,
notes: ["Use its merged image once before later same-profile OTA updates."],
}),
"companion-usb": Object.freeze({
title: "Station G2 USB Companion",
summary: "For a Station G2 controlled as a Companion through a USB serial connection.",
pattern: /^Station_G2_companion_radio_usb-ota-/,
expected: "Station_G2_companion_radio_usb-ota-...",
full: false,
notes: ["This is a Companion, not a standalone repeater or USB packet logger."],
}),
"companion-ble": Object.freeze({
title: "Station G2 Bluetooth Companion",
summary: "For a Station G2 controlled by a phone or computer through Bluetooth.",
pattern: /^Station_G2_companion_radio_ble-/,
expected: "Station_G2_companion_radio_ble-...",
full: false,
notes: ["Choose USB Companion instead when the data connection is normally wired."],
}),
"companion-wifi": Object.freeze({
title: "Station G2 WiFi Companion",
summary: "For Companion protocol access over WiFi.",
pattern: /^Station_G2_companion_radio_wifi-ota-/,
expected: "Station_G2_companion_radio_wifi-ota-...",
full: false,
notes: ["This is not the standalone MQTT observer role."],
}),
"companion-full": Object.freeze({
title: "Station G2 Full Companion",
summary: "A host-backed Companion that can serve LoRa OTA content and exposes the complete transport set.",
pattern: /^Station_G2_companion_radio_full-v/,
expected: "Station_G2_companion_radio_full-...",
full: true,
notes: [
"Full Companion is an OTA source and host-backed controller, not a self-updating repeater.",
"Install its merged image over USB.",
],
}),
});
function isSafeGithubUrl(value) {
try {
const url = new URL(value);
return url.protocol === "https:" &&
(url.hostname === "github.com" || url.hostname === "objects.githubusercontent.com");
} catch (_) {
return false;
}
}
function flattenReleases(releases, channel) {
const allowPrerelease = channel === "development";
const rows = [];
(Array.isArray(releases) ? releases : []).forEach(function (release) {
if (!release || release.draft || (!allowPrerelease && release.prerelease)) return;
(Array.isArray(release.assets) ? release.assets : []).forEach(function (asset) {
if (!asset || typeof asset.name !== "string") return;
rows.push({
name: asset.name,
url: isSafeGithubUrl(asset.browser_download_url) ? asset.browser_download_url : "",
size: Number(asset.size) || 0,
releaseName: release.name || release.tag_name || "Release",
releaseTag: release.tag_name || "",
releaseUrl: isSafeGithubUrl(release.html_url) ? release.html_url : "",
prerelease: Boolean(release.prerelease),
publishedAt: release.published_at || release.created_at || "",
});
});
});
return rows.sort(function (a, b) {
return String(b.publishedAt).localeCompare(String(a.publishedAt));
});
}
function wantedSuffix(install) {
return install === "first" ? "-merged.bin" : ".bin";
}
function selectAsset(rows, purposeKey, install) {
const purpose = PURPOSES[purposeKey];
if (!purpose) return null;
const suffix = wantedSuffix(install);
const matches = rows.filter(function (row) {
if (!purpose.pattern.test(row.name) || !row.name.endsWith(suffix)) return false;
if (install !== "first" && row.name.endsWith("-merged.bin")) return false;
return true;
});
matches.sort(function (a, b) {
const byDate = String(b.publishedAt).localeCompare(String(a.publishedAt));
if (byDate) return byDate;
const byLength = a.name.length - b.name.length;
return byLength || a.name.localeCompare(b.name);
});
return matches[0] || null;
}
function formatBytes(value) {
const size = Number(value) || 0;
if (size >= 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) + " MiB";
if (size >= 1024) return (size / 1024).toFixed(1) + " KiB";
return size + " bytes";
}
function installExplanation(install) {
if (install === "first") {
return {
label: "USB merged-image install",
warning: "This can replace the partition table. Back up configuration first.",
};
}
if (install === "lora") {
return {
label: "LoRa OTA source image",
warning: "Package the non-merged application for the exact target. The installed partition signature must already match.",
};
}
return {
label: "Browser/WiFi app-only update",
warning: "Use only when the installed board, role, and partition layout already match.",
};
}
function createElement(tag, text) {
const element = document.createElement(tag);
if (text != null) element.textContent = text;
return element;
}
function replaceFacts(container, facts) {
container.replaceChildren();
facts.forEach(function (fact) {
container.appendChild(createElement("dt", fact[0]));
const dd = createElement("dd", fact[1]);
if (fact[2]) dd.title = fact[2];
container.appendChild(dd);
});
}
function renderSteps(container, purpose, install) {
container.replaceChildren();
container.appendChild(createElement("strong", "What to do"));
const list = createElement("ol");
const common = install === "first"
? [
"Back up configuration, keys, and radio settings.",
"Verify that the filename begins with Station_G2 before flashing.",
"Flash the merged image over a data-capable USB cable.",
"Wait for the board to reboot, reconnect, and verify its role and radio settings.",
]
: install === "lora"
? [
"Confirm the running target and partition signature already match this profile.",
"Use the non-merged application as input to the LoRa OTA packaging workflow.",
"Verify the target identity and completed update after reboot.",
]
: [
"Confirm the running target and partition signature already match this profile.",
"Upload the non-merged application through the firmware's browser/WiFi updater.",
"Verify the role and version after reboot.",
];
common.concat(purpose.notes).forEach(function (text) {
list.appendChild(createElement("li", text));
});
container.appendChild(list);
}
function initPicker(root) {
const repo = root.getAttribute("data-release-repo") || "mikecarper/MeshCore";
const form = root.querySelector('[data-role="form"]');
const channelField = root.querySelector('[data-field="channel"]');
const purposeField = root.querySelector('[data-field="purpose"]');
const installField = root.querySelector('[data-field="install"]');
const status = root.querySelector('[data-role="status"]');
const result = root.querySelector('[data-role="result"]');
const missing = root.querySelector('[data-role="missing"]');
const search = root.querySelector('[data-field="asset-search"]');
const assetResults = root.querySelector('[data-role="asset-results"]');
let releases = [];
function currentRows() {
return flattenReleases(releases, channelField.value);
}
function render() {
const purpose = PURPOSES[purposeField.value];
const install = installField.value;
const rows = currentRows();
const asset = selectAsset(rows, purposeField.value, install);
const installInfo = installExplanation(install);
result.hidden = true;
missing.hidden = true;
if (!asset) {
missing.hidden = false;
const suffix = wantedSuffix(install);
root.querySelector('[data-role="missing-text"]').textContent =
"Expected a recent asset beginning with " + purpose.expected +
" and ending in " + suffix + ". Try the other release channel or open the release collection.";
return;
}
root.querySelector('[data-role="result-title"]').textContent = purpose.title;
root.querySelector('[data-role="result-summary"]').textContent = purpose.summary;
replaceFacts(root.querySelector('[data-role="result-facts"]'), [
["File", asset.name],
["Release", asset.releaseName],
["Install path", installInfo.label],
["Size", formatBytes(asset.size)],
["Important", installInfo.warning],
]);
const download = root.querySelector('[data-role="download"]');
download.href = asset.url || asset.releaseUrl;
download.hidden = !(asset.url || asset.releaseUrl);
const releaseLink = root.querySelector('[data-role="release"]');
releaseLink.href = asset.releaseUrl || "https://github.com/" + repo + "/releases";
renderSteps(root.querySelector('[data-role="steps"]'), purpose, install);
result.hidden = false;
}
function renderSearch() {
assetResults.replaceChildren();
const query = search.value.trim().toLowerCase();
if (query.length < 2) return;
const matches = currentRows().filter(function (row) {
return row.name.toLowerCase().includes(query);
}).slice(0, 80);
if (!matches.length) {
assetResults.appendChild(createElement("p", "No recent release filename matched."));
return;
}
const list = createElement("ul");
list.className = "firmware-asset-list";
matches.forEach(function (row) {
const item = createElement("li");
const link = createElement("a", row.name);
link.href = row.url || row.releaseUrl;
item.appendChild(link);
const meta = createElement("span", row.releaseName + " - " + formatBytes(row.size));
meta.className = "firmware-asset-meta";
item.appendChild(meta);
list.appendChild(item);
});
assetResults.appendChild(list);
}
form.addEventListener("change", function () {
render();
renderSearch();
});
search.addEventListener("input", renderSearch);
const endpoint = "https://api.github.com/repos/" + encodeURIComponent(repo.split("/")[0]) +
"/" + encodeURIComponent(repo.split("/")[1]) + "/releases?per_page=20";
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();
})
.then(function (data) {
releases = Array.isArray(data) ? data : [];
const count = flattenReleases(releases, "development").length;
status.textContent = "Loaded " + count + " firmware assets from the 20 most recent releases.";
render();
})
.catch(function (error) {
status.textContent = "Could not load the GitHub release catalog: " + error.message;
missing.hidden = false;
root.querySelector('[data-role="missing-text"]').textContent =
"Open the release collection and look for the expected filename shown by this picker.";
});
}
const api = Object.freeze({
PURPOSES: PURPOSES,
flattenReleases: flattenReleases,
selectAsset: selectAsset,
wantedSuffix: wantedSuffix,
formatBytes: formatBytes,
});
if (typeof module !== "undefined" && module.exports) module.exports = api;
global.MeshCoreFirmwarePicker = api;
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("[data-firmware-picker]").forEach(initPicker);
});
}
})(typeof globalThis !== "undefined" ? globalThis : this);
+173
View File
@@ -0,0 +1,173 @@
.firmware-picker {
--picker-accent: #0f766e;
--picker-accent-dark: #115e59;
--picker-border: color-mix(in srgb, currentColor 18%, transparent);
margin: 1.25rem 0 2rem;
}
.firmware-picker-intro,
.firmware-picker-result,
.firmware-picker-missing,
.firmware-asset-browser {
border: 1px solid var(--picker-border);
border-radius: 0.7rem;
padding: 1rem 1.15rem;
}
.firmware-picker-intro {
border-left: 0.35rem solid var(--picker-accent);
background: color-mix(in srgb, var(--picker-accent) 8%, transparent);
}
.firmware-picker-intro p,
.firmware-picker-result p,
.firmware-picker-missing p {
margin-bottom: 0;
}
.firmware-picker-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.9rem;
margin: 1.2rem 0;
}
.firmware-picker-form label,
.firmware-asset-browser label {
display: grid;
gap: 0.35rem;
font-weight: 650;
}
.firmware-picker-form select,
.firmware-asset-browser input {
width: 100%;
min-height: 2.7rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--picker-border);
border-radius: 0.35rem;
color: inherit;
background: var(--md-default-bg-color);
font: inherit;
}
.firmware-picker-status {
min-height: 1.5rem;
margin: 0.75rem 0;
color: var(--md-default-fg-color--light);
}
.firmware-picker-result {
border-top: 0.3rem solid var(--picker-accent);
}
.firmware-picker-eyebrow {
margin: 0 0 0.15rem !important;
color: var(--picker-accent);
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.firmware-picker-result h2,
.firmware-picker-missing h2 {
margin-top: 0.2rem;
}
.firmware-picker-facts {
display: grid;
grid-template-columns: minmax(7rem, auto) 1fr;
gap: 0.35rem 0.9rem;
margin: 1rem 0;
}
.firmware-picker-facts dt {
font-weight: 700;
}
.firmware-picker-facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.firmware-picker-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin: 1rem 0;
}
.firmware-picker-actions a {
display: inline-block;
padding: 0.55rem 0.8rem;
border: 1px solid var(--picker-accent);
border-radius: 0.4rem;
font-weight: 700;
text-decoration: none;
}
.firmware-picker-actions .firmware-picker-primary {
color: white;
background: var(--picker-accent);
}
.firmware-picker-actions .firmware-picker-primary:hover {
background: var(--picker-accent-dark);
}
.firmware-picker-steps {
border-top: 1px solid var(--picker-border);
margin-top: 1rem;
padding-top: 0.8rem;
}
.firmware-picker-steps ol {
margin-bottom: 0;
}
.firmware-picker-missing {
border-left: 0.35rem solid #b45309;
}
.firmware-asset-browser {
margin-top: 1.2rem;
}
.firmware-asset-browser summary {
cursor: pointer;
font-weight: 700;
}
.firmware-asset-list {
max-height: 24rem;
margin-top: 0.8rem;
padding-left: 1.2rem;
overflow: auto;
}
.firmware-asset-list li {
margin: 0.35rem 0;
overflow-wrap: anywhere;
}
.firmware-asset-meta {
display: block;
color: var(--md-default-fg-color--light);
font-size: 0.78rem;
}
@media (max-width: 44rem) {
.firmware-picker-form {
grid-template-columns: 1fr;
}
.firmware-picker-facts {
grid-template-columns: 1fr;
}
.firmware-picker-facts dd + dt {
margin-top: 0.35rem;
}
}
+148
View File
@@ -0,0 +1,148 @@
# Firmware picker
Choose what the board will do, then let the picker reduce the release assets
to one recommended firmware. The first guided board is Station G2 because its
standard, logging, FULL, LoRa-OTA, MQTT, bridge, room-server, and Companion
profiles are easy to confuse.
The picker reads public release metadata from GitHub. It does not upload any
device information.
<div class="firmware-picker" data-firmware-picker data-release-repo="mikecarper/MeshCore">
<div class="firmware-picker-intro" role="note">
<strong>Station G2 shortcut</strong>
<p>
If the G2 normally stays connected to a computer and you want USB packet
logs, choose <strong>USB logging repeater</strong>. The recommended image
is the expanded <strong>FULL logging</strong> profile. The smaller legacy
logging image is mainly a compatibility fallback, not the normal choice
for a computer-powered logger.
</p>
</div>
<form class="firmware-picker-form" data-role="form">
<label>
Release channel
<select data-field="channel">
<option value="development" selected>Current development and pre-releases</option>
<option value="stable">Stable releases</option>
</select>
</label>
<label>
What should the Station G2 do?
<select data-field="purpose">
<option value="usb-logging">USB logging repeater - recommended when connected to a computer</option>
<option value="field">Simple standalone repeater</option>
<option value="field-ota">Standalone repeater with LoRa/WiFi OTA, no external sensors</option>
<option value="mqtt">Full MQTT observer/repeater</option>
<option value="espnow">ESP-NOW bridge repeater</option>
<option value="room">Room server</option>
<option value="room-mqtt">Full MQTT room observer</option>
<option value="companion-usb">USB Companion</option>
<option value="companion-ble">Bluetooth Companion</option>
<option value="companion-wifi">WiFi Companion</option>
<option value="companion-full">Full Companion and LoRa-OTA source</option>
</select>
</label>
<label>
How will this firmware be installed?
<select data-field="install">
<option value="first">First install, recovery, or changing partition/profile - USB cable</option>
<option value="wifi">Same profile and partition - browser/WiFi OTA</option>
<option value="lora">Same profile and partition - LoRa OTA</option>
</select>
</label>
</form>
<div class="firmware-picker-status" data-role="status" aria-live="polite">
Loading release catalog...
</div>
<section class="firmware-picker-result" data-role="result" aria-live="polite" hidden>
<p class="firmware-picker-eyebrow">Recommended firmware</p>
<h2 data-role="result-title"></h2>
<p data-role="result-summary"></p>
<dl class="firmware-picker-facts" data-role="result-facts"></dl>
<div class="firmware-picker-actions">
<a class="firmware-picker-primary" data-role="download" href="#">Download recommended file</a>
<a data-role="release" href="#">Open its release</a>
</div>
<div class="firmware-picker-steps" data-role="steps"></div>
</section>
<section class="firmware-picker-missing" data-role="missing" aria-live="polite" hidden>
<h2>The matching asset is not in this release channel yet</h2>
<p data-role="missing-text"></p>
<a href="https://github.com/mikecarper/MeshCore/releases">Browse all firmware releases</a>
</section>
<details class="firmware-asset-browser">
<summary>Advanced: search all recent release filenames</summary>
<p>
This search is for uncommon hardware variants and expert recovery. A
filename match is not a board-identity check.
</p>
<label>
Filename contains
<input data-field="asset-search" placeholder="Station_G2, heltec_v4, repeater, ...">
</label>
<div data-role="asset-results"></div>
</details>
</div>
## What FULL means for a USB logger
A Station G2 that is powered from and monitored by a computer normally should
use the FULL logging profile:
1. Save its name, radio settings, keys, and other configuration.
2. Download the exact Station G2 `full-logging-ota` merged image recommended
above.
3. Flash that `-merged.bin` over USB once. It installs the expanded partition
table as well as the application.
4. Reconnect the serial terminal at 115200 baud and restore any settings that
were not retained.
5. Leave the data-capable USB connection attached to collect logs. Use
`set usb.logging off` temporarily if the runtime output needs to be quiet.
Choose the standalone or lean OTA repeater instead when the board is normally
battery/solar powered, has no computer collecting USB output, or must retain a
known legacy partition layout.
## Can a Heltec V4 partition be expanded by OTA?
No. WiFi OTA and LoRa OTA write an application into the inactive application
partition. They do not replace the partition table at flash offset `0x8000`.
Allowing a running application to move its own active/inactive partitions
would risk overwriting the running image, staged image, NVS, or filesystem.
The current Heltec V4 definition already uses its 16 MiB flash efficiently:
| Region | Size |
|---|---:|
| Application slot A | `0x640000` (6.25 MiB) |
| Application slot B | `0x640000` (6.25 MiB) |
| SPIFFS | `0x360000` (3.375 MiB) |
That is much larger than current MeshCore V4 applications. A different layout
is possible only as a custom build, and it requires a one-time exact-board
merged-image flash over USB. Expanding the app slots further would shrink or
remove SPIFFS, or remove the second slot and therefore remove safe OTA.
After installing a new partition table over USB, later WiFi or LoRa updates
must use a non-merged application built for the same board, role, and partition
signature. Never use an app-only FULL image to try to migrate a device that is
still running another partition layout.
## Merged versus non-merged files
| File | Use |
|---|---|
| `-merged.bin` | First install, recovery, role/profile migration, or partition-table change over USB |
| `.bin` without `-merged` | Browser/WiFi application update when the installed partition layout already matches |
| `.mota` | LoRa OTA package for the exact target identity and installed partition layout |
When uncertain, back up the configuration and use the exact-board merged image
over USB. Do not send a merged image through browser OTA or LoRa OTA.
+1
View File
@@ -5,6 +5,7 @@ Welcome to the MeshCore documentation.
Below are a few quick start guides.
- [Frequently Asked Questions](./faq.md)
- [Firmware Picker](./firmware_picker.md)
- [CLI Commands](./cli_commands.md)
- [Filter Policy Playground](./filter_tool.md)
- [Telemetry Decoder](./telemetry_decoder.md)
+2
View File
@@ -17,9 +17,11 @@ theme:
extra_css:
- _stylesheets/extra.css
- _stylesheets/firmware_picker.css
- _stylesheets/telemetry_decoder.css
- _stylesheets/filter_tool.css
extra_javascript:
- _javascript/firmware_picker.js
- _javascript/telemetry_decoder.js
- _javascript/filter_tool.js
+85
View File
@@ -0,0 +1,85 @@
"use strict";
const assert = require("assert");
const picker = require("../docs/_javascript/firmware_picker.js");
const releases = [
{
name: "Stable FULL profiles",
tag_name: "full-profiles-v1",
html_url: "https://github.com/mikecarper/MeshCore/releases/tag/full-profiles-v1",
published_at: "2026-08-20T12:00:00Z",
prerelease: false,
draft: false,
assets: [
{
name: "Station_G2_repeater-full-logging-ota-v1-deadbee-merged.bin",
browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/full-profiles-v1/Station_G2_repeater-full-logging-ota-v1-deadbee-merged.bin",
size: 2000000,
},
{
name: "Station_G2_repeater-full-logging-ota-v1-merged.bin",
browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/full-profiles-v1/Station_G2_repeater-full-logging-ota-v1-merged.bin",
size: 2000000,
},
{
name: "Station_G2_repeater-full-logging-ota-v1.bin",
browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/full-profiles-v1/Station_G2_repeater-full-logging-ota-v1.bin",
size: 1500000,
},
{
name: "Station_G2_companion_radio_full-logging-v1-merged.bin",
browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/full-profiles-v1/Station_G2_companion_radio_full-logging-v1-merged.bin",
size: 1800000,
},
{
name: "Station_G2_companion_radio_full-v1-merged.bin",
browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/full-profiles-v1/Station_G2_companion_radio_full-v1-merged.bin",
size: 1700000,
},
],
},
{
name: "Development FULL profiles",
tag_name: "full-profiles-v2-dev",
html_url: "https://github.com/mikecarper/MeshCore/releases/tag/full-profiles-v2-dev",
published_at: "2026-08-21T12:00:00Z",
prerelease: true,
draft: false,
assets: [
{
name: "Station_G2_repeater-full-logging-ota-v2-merged.bin",
browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/full-profiles-v2-dev/Station_G2_repeater-full-logging-ota-v2-merged.bin",
size: 2100000,
},
],
},
];
const stable = picker.flattenReleases(releases, "stable");
const development = picker.flattenReleases(releases, "development");
assert.strictEqual(stable.length, 5);
assert.strictEqual(development.length, 6);
assert.strictEqual(picker.wantedSuffix("first"), "-merged.bin");
assert.strictEqual(picker.wantedSuffix("wifi"), ".bin");
assert.strictEqual(
picker.selectAsset(stable, "usb-logging", "first").name,
"Station_G2_repeater-full-logging-ota-v1-merged.bin"
);
assert.strictEqual(
picker.selectAsset(stable, "usb-logging", "wifi").name,
"Station_G2_repeater-full-logging-ota-v1.bin"
);
assert.strictEqual(
picker.selectAsset(development, "usb-logging", "first").name,
"Station_G2_repeater-full-logging-ota-v2-merged.bin"
);
assert.strictEqual(picker.selectAsset(stable, "field", "first"), null);
assert.strictEqual(
picker.selectAsset(stable, "companion-full", "first").name,
"Station_G2_companion_radio_full-v1-merged.bin"
);
assert.strictEqual(picker.formatBytes(2097152), "2.00 MiB");
console.log("firmware picker tests passed");