diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js new file mode 100644 index 000000000..5b244786c --- /dev/null +++ b/docs/_javascript/firmware_picker.js @@ -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); diff --git a/docs/_stylesheets/firmware_picker.css b/docs/_stylesheets/firmware_picker.css new file mode 100644 index 000000000..ba53b9467 --- /dev/null +++ b/docs/_stylesheets/firmware_picker.css @@ -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; + } +} diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md new file mode 100644 index 000000000..4c4ea3d69 --- /dev/null +++ b/docs/firmware_picker.md @@ -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. + +
+ If the G2 normally stays connected to a computer and you want USB packet + logs, choose USB logging repeater. The recommended image + is the expanded FULL logging profile. The smaller legacy + logging image is mainly a compatibility fallback, not the normal choice + for a computer-powered logger. +
+Recommended firmware
+ + ++ This search is for uncommon hardware variants and expert recovery. A + filename match is not a board-identity check. +
+ + +