mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-02 10:53:53 +00:00
1005 lines
34 KiB
JavaScript
1005 lines
34 KiB
JavaScript
(function (global) {
|
|
"use strict";
|
|
|
|
const RELEASE_PAGE_PREFIXES = Object.freeze([
|
|
"",
|
|
"repeater-room-",
|
|
"utility-",
|
|
"logging-",
|
|
"lora-ota-",
|
|
"full-profiles-",
|
|
]);
|
|
|
|
const FILTER_FIELDS = Object.freeze([
|
|
"hardwareFamily",
|
|
"hardware",
|
|
"role",
|
|
"logging",
|
|
"ota",
|
|
"mode",
|
|
"feature",
|
|
"variant",
|
|
]);
|
|
|
|
const FACET_FIELDS = Object.freeze(FILTER_FIELDS.concat(["install"]));
|
|
|
|
const ROLE_LABELS = Object.freeze({
|
|
companion: "Companion",
|
|
repeater: "Repeater",
|
|
room: "Room Server",
|
|
sensor: "Sensor / telemetry",
|
|
terminal: "Terminal Chat",
|
|
kiss: "KISS modem",
|
|
other: "Other",
|
|
});
|
|
|
|
const LOGGING_LABELS = Object.freeze({
|
|
none: "None / normal operation",
|
|
usb: "USB logging / USB-connected MQTT",
|
|
wifi: "Wi-Fi MQTT observer",
|
|
});
|
|
|
|
const OTA_LABELS = Object.freeze({
|
|
none: "No explicit OTA profile",
|
|
"ota-enabled": "OTA-enabled profile",
|
|
"lora-receiver": "LoRa OTA receiver",
|
|
"lora-source": "LoRa OTA source (Full Companion)",
|
|
});
|
|
|
|
const MODE_LABELS = Object.freeze({
|
|
standard: "Standard",
|
|
full: "Full Companion transports",
|
|
ble: "Bluetooth LE",
|
|
usb: "USB",
|
|
wifi: "Wi-Fi",
|
|
serial: "Serial / UART",
|
|
ethernet: "Ethernet",
|
|
mqtt: "MQTT observer",
|
|
espnow: "ESP-NOW bridge",
|
|
rs232: "RS-232 bridge",
|
|
});
|
|
|
|
const FEATURE_LABELS = Object.freeze({
|
|
standard: "Standard profile",
|
|
full: "FULL / complete profile",
|
|
});
|
|
|
|
const INSTALL_LABELS = Object.freeze({
|
|
"merged-bin": "Erase & fresh install (merged .bin)",
|
|
bin: "Update existing install (.bin)",
|
|
zip: "nRF52 update / Serial DFU (.zip)",
|
|
uf2: "UF2 install / update (.uf2)",
|
|
hex: "Erase / recovery flash (.hex)",
|
|
});
|
|
|
|
const INSTALL_ORDER = Object.freeze([
|
|
"merged-bin",
|
|
"bin",
|
|
"zip",
|
|
"uf2",
|
|
"hex",
|
|
]);
|
|
|
|
const HARDWARE_ALIASES = Object.freeze({
|
|
// Generated Full Companion artifact name. It uses the standard Heltec V4
|
|
// board definition and auto-detects the V4.2 and V4.3 FEM hardware.
|
|
"heltec_v4_2_v4_3": "heltec_v4",
|
|
// These target prefixes select a radio profile on otherwise identical
|
|
// hardware. Keep that distinction in the firmware variant facet instead.
|
|
"heltec_v4_3": "heltec_v4",
|
|
"heltec_v4_3_expansionkit_tft": "heltec_v4_expansionkit_tft",
|
|
"heltec_v4_3_tft": "heltec_v4_tft",
|
|
"Station_G2_logging": "Station_G2",
|
|
"Station_G3_ESP32_logging": "Station_G3_ESP32",
|
|
});
|
|
|
|
const HIDDEN_PROFILE_HARDWARE = Object.freeze([
|
|
// This legacy environment only changes ADVERT_NAME. The standard G3
|
|
// target provides the same firmware behavior, while all legacy files stay
|
|
// available through exact filename search.
|
|
"Station_G3_ESP32_logging",
|
|
]);
|
|
|
|
let pickerInstanceCount = 0;
|
|
|
|
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 escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^$()|[\]\\{}]/g, "\\$&");
|
|
}
|
|
|
|
function releaseDate(release) {
|
|
return release.published_at || release.created_at || "";
|
|
}
|
|
|
|
function selectReleaseSet(releases) {
|
|
const publicReleases = (Array.isArray(releases) ? releases : []).filter(
|
|
function (release) {
|
|
return release && !release.draft && typeof release.tag_name === "string";
|
|
}
|
|
);
|
|
const mainReleases = publicReleases.filter(function (release) {
|
|
return /^v\d/i.test(release.tag_name);
|
|
}).sort(function (a, b) {
|
|
return releaseDate(b).localeCompare(releaseDate(a));
|
|
});
|
|
const main = mainReleases[0];
|
|
if (!main) return null;
|
|
|
|
const familyTag = main.tag_name;
|
|
const acceptedTags = new Set(RELEASE_PAGE_PREFIXES.map(function (prefix) {
|
|
return prefix + familyTag;
|
|
}));
|
|
const familyReleases = publicReleases.filter(function (release) {
|
|
return acceptedTags.has(release.tag_name);
|
|
}).sort(function (a, b) {
|
|
return releaseDate(b).localeCompare(releaseDate(a));
|
|
});
|
|
|
|
return {
|
|
familyTag: familyTag,
|
|
name: main.name || familyTag,
|
|
url: isSafeGithubUrl(main.html_url) ? main.html_url : "",
|
|
prerelease: Boolean(main.prerelease),
|
|
releases: familyReleases,
|
|
};
|
|
}
|
|
|
|
function flattenReleaseAssets(releaseSet) {
|
|
const rows = [];
|
|
if (!releaseSet) return rows;
|
|
releaseSet.releases.forEach(function (release) {
|
|
(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
|
|
: "",
|
|
publishedAt: releaseDate(release),
|
|
});
|
|
}
|
|
);
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
function installKindForAsset(extension, merged) {
|
|
if (extension === "bin") return merged ? "merged-bin" : "bin";
|
|
if (extension === "zip") return "zip";
|
|
if (extension === "uf2") return "uf2";
|
|
if (extension === "hex") return "hex";
|
|
return "";
|
|
}
|
|
|
|
function parseFirmwareAsset(row, familyTag) {
|
|
if (!row || typeof row.name !== "string" || !familyTag) return null;
|
|
const extensionMatch = row.name.match(/\.([^.]+)$/);
|
|
if (!extensionMatch) return null;
|
|
const extension = extensionMatch[1].toLowerCase();
|
|
if (!["bin", "zip", "uf2", "hex"].includes(extension)) return null;
|
|
|
|
let stem = row.name.slice(0, -extensionMatch[0].length);
|
|
const merged = stem.endsWith("-merged");
|
|
if (merged) stem = stem.slice(0, -"-merged".length);
|
|
|
|
const versionPattern = new RegExp(
|
|
"^(.*?)-(ota-)?" + escapeRegExp(familyTag) +
|
|
"(?:-[0-9a-f]{7,8})?$",
|
|
"i"
|
|
);
|
|
const versionMatch = stem.match(versionPattern);
|
|
if (!versionMatch) return null;
|
|
const kind = installKindForAsset(extension, merged);
|
|
if (!kind) return null;
|
|
|
|
return Object.assign({}, row, {
|
|
target: versionMatch[1],
|
|
otaPackaged: Boolean(versionMatch[2]),
|
|
extension: extension,
|
|
merged: merged,
|
|
installKind: kind,
|
|
});
|
|
}
|
|
|
|
function roleParts(target) {
|
|
const definitions = [
|
|
{
|
|
role: "companion",
|
|
pattern: /_(?:companion_radio_|companion_|comp_radio_)/i,
|
|
},
|
|
{
|
|
role: "room",
|
|
pattern: /_(?:room_server|room_svr)(?=$|[_-])/i,
|
|
},
|
|
{
|
|
role: "repeater",
|
|
pattern: /(?:_|-)(?:repeater|repeatr)(?=$|[_-])/i,
|
|
},
|
|
{
|
|
role: "sensor",
|
|
pattern: /_sensor(?=$|[_-])/i,
|
|
},
|
|
{
|
|
role: "terminal",
|
|
pattern: /_terminal_chat(?=$|[_-])/i,
|
|
},
|
|
{
|
|
role: "kiss",
|
|
pattern: /_kiss_modem(?=$|[_-])/i,
|
|
},
|
|
];
|
|
|
|
for (const definition of definitions) {
|
|
const match = definition.pattern.exec(target);
|
|
if (!match) continue;
|
|
return {
|
|
role: definition.role,
|
|
hardware: target.slice(0, match.index),
|
|
tail: target.slice(match.index + match[0].length),
|
|
};
|
|
}
|
|
return { role: "other", hardware: target, tail: "" };
|
|
}
|
|
|
|
function modeForRole(role, tail) {
|
|
const value = String(tail).toLowerCase().replace(/^[_-]+/, "");
|
|
if (role === "companion") {
|
|
if (/^full(?:$|[_-])/.test(value)) return "full";
|
|
if (/^ble(?:$|[_-])/.test(value)) return "ble";
|
|
if (/^usb(?:$|[_-])/.test(value)) return "usb";
|
|
if (/^wifi(?:$|[_-])/.test(value)) return "wifi";
|
|
if (/^serial(?:$|[_-])/.test(value)) return "serial";
|
|
if (/^ethernet(?:$|[_-])/.test(value)) return "ethernet";
|
|
return "standard";
|
|
}
|
|
if (value.includes("observer_mqtt")) return "mqtt";
|
|
if (value.includes("bridge_espnow")) return "espnow";
|
|
if (value.includes("bridge_rs232")) return "rs232";
|
|
if (value.includes("ethernet")) return "ethernet";
|
|
return "standard";
|
|
}
|
|
|
|
function variantForProfile(role, tail) {
|
|
let value = String(tail);
|
|
if (role === "companion") {
|
|
value = value.replace(
|
|
/^(?:full|ble|usb|wifi|serial|ethernet)(?=$|[_-])/i,
|
|
" "
|
|
);
|
|
}
|
|
value = value.replace(/-logging/gi, "");
|
|
value = value.replace(/(?:^|[_-])full(?=$|[_-])/gi, " ");
|
|
value = value.replace(/observer_mqtt/gi, " ");
|
|
value = value.replace(/bridge_espnow/gi, " ");
|
|
value = value.replace(/bridge_rs232/gi, " ");
|
|
value = value.replace(/lora_ota/gi, " ");
|
|
value = value.replace(/(?:^|[_-])ethernet(?=$|[_-])/gi, " ");
|
|
value = value.replace(/^[_\-\s]+|[_\-\s]+$/g, "");
|
|
if (!value) return "default";
|
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "") || "default";
|
|
}
|
|
|
|
function parseTargetProfile(target) {
|
|
const parts = roleParts(target);
|
|
const sourceHardware = parts.hardware || target;
|
|
const lowerTarget = target.toLowerCase();
|
|
const lowerTail = parts.tail.toLowerCase();
|
|
const mode = modeForRole(parts.role, parts.tail);
|
|
const logging = lowerTarget.includes("observer_mqtt")
|
|
? "wifi"
|
|
: lowerTarget.includes("-logging")
|
|
? "usb"
|
|
: "none";
|
|
const feature = lowerTail.includes("-full") ||
|
|
(parts.role === "companion" && mode === "full")
|
|
? "full"
|
|
: "standard";
|
|
const explicitOta = lowerTarget.includes("lora_ota")
|
|
? "lora-receiver"
|
|
: parts.role === "companion" && mode === "full"
|
|
? "lora-source"
|
|
: "";
|
|
let variant = variantForProfile(parts.role, parts.tail);
|
|
if (sourceHardware === "Station_G2_logging") {
|
|
variant = variant === "default"
|
|
? "rx-boosted"
|
|
: variant + "-rx-boosted";
|
|
}
|
|
|
|
return {
|
|
target: target,
|
|
sourceHardware: sourceHardware,
|
|
hardware: canonicalHardware(sourceHardware),
|
|
role: parts.role,
|
|
logging: logging,
|
|
mode: mode,
|
|
feature: feature,
|
|
variant: variant,
|
|
explicitOta: explicitOta,
|
|
};
|
|
}
|
|
|
|
function canonicalHardware(hardware) {
|
|
const value = String(hardware || "");
|
|
return HARDWARE_ALIASES[value] || value;
|
|
}
|
|
|
|
function hardwareFamilyFor(hardware, hardwareNames) {
|
|
const value = String(hardware || "");
|
|
const lowerValue = value.toLowerCase();
|
|
const parents = (hardwareNames || []).filter(function (candidate) {
|
|
const parent = String(candidate || "");
|
|
if (!parent || parent === value) return false;
|
|
const lowerParent = parent.toLowerCase();
|
|
return lowerValue.startsWith(lowerParent + "_") ||
|
|
lowerValue.startsWith(lowerParent + "-");
|
|
}).sort(function (a, b) {
|
|
return a.length - b.length || a.localeCompare(b);
|
|
});
|
|
return parents[0] || value;
|
|
}
|
|
|
|
function humanizeHardwareVariant(hardware, family) {
|
|
const value = String(hardware || "");
|
|
const parent = String(family || "");
|
|
if (!parent || value === parent) {
|
|
if (value === "heltec_v4") {
|
|
return "Base / standard (V4.2 / V4.3 auto-detect)";
|
|
}
|
|
return "Base / standard";
|
|
}
|
|
|
|
const knownLabels = {
|
|
"heltec_v4_expansionkit": "Expansion kit",
|
|
"heltec_v4_expansionkit_tft": "Expansion kit + TFT",
|
|
"heltec_v4_r8": "R8",
|
|
"heltec_v4_r8_tft": "R8 + TFT",
|
|
"heltec_v4_tft": "TFT",
|
|
"wio-e5-mini": "Mini",
|
|
};
|
|
if (knownLabels[value]) return knownLabels[value];
|
|
|
|
let suffix = value.slice(parent.length).replace(/^[_-]+/, "");
|
|
const suffixLabels = {
|
|
logging: "Logging hardware profile",
|
|
without_display: "Without display",
|
|
sdcard: "SD card",
|
|
"096": "0.96-inch display",
|
|
"096_rotated": "0.96-inch rotated display",
|
|
Lite_non_shell: "Lite (no shell)",
|
|
};
|
|
if (suffixLabels[suffix]) return suffixLabels[suffix];
|
|
suffix = humanizeHardware(suffix).replace(/\bTft\b/gi, "TFT")
|
|
.replace(/\bWio\b/gi, "WIO");
|
|
return suffix || humanizeHardware(value);
|
|
}
|
|
|
|
function canonicalAsset(files, installKind) {
|
|
return (Array.isArray(files) ? files : []).filter(function (file) {
|
|
return file.installKind === installKind;
|
|
}).sort(function (a, b) {
|
|
const byLength = a.name.length - b.name.length;
|
|
return byLength || a.name.localeCompare(b.name);
|
|
})[0] || null;
|
|
}
|
|
|
|
function buildCatalog(releases) {
|
|
const releaseSet = selectReleaseSet(releases);
|
|
if (!releaseSet) {
|
|
return { releaseSet: null, rows: [], profiles: [] };
|
|
}
|
|
const rows = flattenReleaseAssets(releaseSet);
|
|
const firmwareRows = rows.map(function (row) {
|
|
return parseFirmwareAsset(row, releaseSet.familyTag);
|
|
}).filter(Boolean);
|
|
const grouped = new Map();
|
|
|
|
firmwareRows.forEach(function (file) {
|
|
let profile = grouped.get(file.target);
|
|
if (!profile) {
|
|
profile = Object.assign(parseTargetProfile(file.target), {
|
|
files: [],
|
|
otaPackaged: false,
|
|
});
|
|
grouped.set(file.target, profile);
|
|
}
|
|
profile.files.push(file);
|
|
profile.otaPackaged = profile.otaPackaged || file.otaPackaged;
|
|
});
|
|
|
|
const profiles = Array.from(grouped.values()).map(function (profile) {
|
|
profile.ota = profile.explicitOta ||
|
|
(profile.otaPackaged ? "ota-enabled" : "none");
|
|
profile.installKinds = INSTALL_ORDER.filter(function (kind) {
|
|
return Boolean(canonicalAsset(profile.files, kind));
|
|
});
|
|
profile.files.sort(function (a, b) {
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
return profile;
|
|
}).filter(function (profile) {
|
|
return !HIDDEN_PROFILE_HARDWARE.includes(profile.sourceHardware);
|
|
}).sort(function (a, b) {
|
|
return a.target.localeCompare(b.target, undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
});
|
|
|
|
const hardwareNames = Array.from(new Set(profiles.map(function (profile) {
|
|
return profile.hardware;
|
|
})));
|
|
profiles.forEach(function (profile) {
|
|
profile.hardwareFamily = hardwareFamilyFor(
|
|
profile.hardware,
|
|
hardwareNames
|
|
);
|
|
});
|
|
|
|
return {
|
|
releaseSet: releaseSet,
|
|
rows: firmwareRows,
|
|
profiles: profiles,
|
|
};
|
|
}
|
|
|
|
function profileMatches(profile, filters, fields) {
|
|
const wantedFields = fields || FILTER_FIELDS;
|
|
return wantedFields.every(function (field) {
|
|
const value = filters && filters[field];
|
|
return !value || profile[field] === value;
|
|
});
|
|
}
|
|
|
|
function profileMatchesFacets(profile, filters, ignoredField) {
|
|
const ignoredFields = new Set(
|
|
Array.isArray(ignoredField) ? ignoredField : [ignoredField]
|
|
);
|
|
return FACET_FIELDS.every(function (field) {
|
|
if (ignoredFields.has(field)) return true;
|
|
const value = filters && filters[field];
|
|
if (!value) return true;
|
|
if (field === "install") {
|
|
return profile.installKinds.includes(value);
|
|
}
|
|
return profile[field] === value;
|
|
});
|
|
}
|
|
|
|
function facetValues(profiles, filters, field, additionallyIgnoredFields) {
|
|
const ignoredFields = [field].concat(additionallyIgnoredFields || []);
|
|
const compatible = (profiles || []).filter(function (profile) {
|
|
return profileMatchesFacets(profile, filters, ignoredFields);
|
|
});
|
|
const values = field === "install"
|
|
? compatible.flatMap(function (profile) {
|
|
return profile.installKinds;
|
|
})
|
|
: compatible.map(function (profile) {
|
|
return profile[field];
|
|
});
|
|
return Array.from(new Set(values.filter(Boolean)));
|
|
}
|
|
|
|
function uniqueValues(profiles, field) {
|
|
return Array.from(new Set((profiles || []).map(function (profile) {
|
|
return profile[field];
|
|
}).filter(Boolean)));
|
|
}
|
|
|
|
function humanizeHardware(value) {
|
|
return String(value || "").replace(/_/g, " ").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function humanizeVariant(value) {
|
|
if (!value || value === "default") return "Default";
|
|
const tokenLabels = {
|
|
ps: "Power save",
|
|
rx: "RX",
|
|
femon: "FEM on",
|
|
femoff: "FEM off",
|
|
serial1: "Serial 1",
|
|
serial2: "Serial 2",
|
|
sim: "SIM",
|
|
rak15001: "RAK15001",
|
|
qspi: "QSPI",
|
|
};
|
|
return value.split("-").map(function (token) {
|
|
return tokenLabels[token] ||
|
|
token.charAt(0).toUpperCase() + token.slice(1);
|
|
}).join(" ");
|
|
}
|
|
|
|
function labelFor(field, value) {
|
|
if (field === "hardwareFamily") return humanizeHardware(value);
|
|
if (field === "hardware") return humanizeHardware(value);
|
|
if (field === "role") return ROLE_LABELS[value] || value;
|
|
if (field === "logging") return LOGGING_LABELS[value] || value;
|
|
if (field === "ota") return OTA_LABELS[value] || value;
|
|
if (field === "mode") return MODE_LABELS[value] || value;
|
|
if (field === "feature") return FEATURE_LABELS[value] || value;
|
|
if (field === "variant") return humanizeVariant(value);
|
|
if (field === "install") return INSTALL_LABELS[value] || value;
|
|
return value;
|
|
}
|
|
|
|
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 createElement(tag, text) {
|
|
const element = document.createElement(tag);
|
|
if (text != null) element.textContent = text;
|
|
return element;
|
|
}
|
|
|
|
function optionSort(field, a, b) {
|
|
const roleOrder = ["companion", "repeater", "room", "sensor", "terminal", "kiss", "other"];
|
|
const loggingOrder = ["none", "usb", "wifi"];
|
|
const otaOrder = ["none", "ota-enabled", "lora-receiver", "lora-source"];
|
|
const featureOrder = ["standard", "full"];
|
|
const modeOrder = ["standard", "full", "ble", "usb", "wifi", "serial", "ethernet", "mqtt", "espnow", "rs232"];
|
|
const orders = {
|
|
role: roleOrder,
|
|
logging: loggingOrder,
|
|
ota: otaOrder,
|
|
feature: featureOrder,
|
|
mode: modeOrder,
|
|
install: INSTALL_ORDER,
|
|
};
|
|
const order = orders[field];
|
|
if (order) {
|
|
const left = order.indexOf(a);
|
|
const right = order.indexOf(b);
|
|
if (left !== right) return left - right;
|
|
}
|
|
return labelFor(field, a).localeCompare(labelFor(field, b), undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
}
|
|
|
|
function installSteps(profile, kind) {
|
|
const common = [
|
|
"Verify that the hardware name and every displayed variant match the physical board.",
|
|
"Back up configuration, keys, and radio settings before changing roles or profiles.",
|
|
];
|
|
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.",
|
|
],
|
|
bin: [
|
|
"Use this app-only image only when the installed board, role, and partition layout already match.",
|
|
"Do not use an app-only image to migrate partition layouts.",
|
|
],
|
|
zip: [
|
|
"Use the ZIP as the native nRF52 Serial DFU package; it is not an extra archive.",
|
|
"For LoRa OTA receiving, install the exact-board OTAFIX bootloader first.",
|
|
],
|
|
uf2: [
|
|
"Place the device in its UF2 bootloader mode and copy the UF2 to the mounted drive.",
|
|
"Confirm the role and radio settings after reboot.",
|
|
],
|
|
hex: [
|
|
"Flash the HEX with the board's supported wired programmer or recovery method.",
|
|
"Confirm the bootloader and application versions after reboot.",
|
|
],
|
|
};
|
|
const extra = [];
|
|
if (profile.ota === "lora-receiver") {
|
|
extra.push(
|
|
"This installs the LoRa OTA receiver profile. The later update package must still match the exact target and partition signature."
|
|
);
|
|
} else if (profile.ota === "lora-source") {
|
|
extra.push(
|
|
"Full Companion can serve a host-supplied update to another node; it does not self-install that LoRa update."
|
|
);
|
|
}
|
|
return common.concat(byKind[kind] || [], extra);
|
|
}
|
|
|
|
function replaceFacts(container, facts) {
|
|
container.replaceChildren();
|
|
facts.forEach(function (fact) {
|
|
container.appendChild(createElement("dt", fact[0]));
|
|
container.appendChild(createElement("dd", fact[1]));
|
|
});
|
|
}
|
|
|
|
function renderProfileCard(container, profile, asset, installKind) {
|
|
const card = createElement("article");
|
|
card.className = "firmware-picker-card";
|
|
card.appendChild(createElement(
|
|
"h3",
|
|
humanizeHardware(profile.hardware) + " — " +
|
|
(ROLE_LABELS[profile.role] || profile.role)
|
|
));
|
|
|
|
const facts = createElement("dl");
|
|
facts.className = "firmware-picker-facts";
|
|
replaceFacts(facts, [
|
|
["Target", profile.target],
|
|
["Connection / mode", labelFor("mode", profile.mode)],
|
|
["Logging", labelFor("logging", profile.logging)],
|
|
["OTA", labelFor("ota", profile.ota)],
|
|
["Feature profile", labelFor("feature", profile.feature)],
|
|
["Variant", labelFor("variant", profile.variant)],
|
|
["Install operation", labelFor("install", installKind)],
|
|
["File", asset.name],
|
|
["Size", formatBytes(asset.size)],
|
|
["Release", asset.releaseName],
|
|
]);
|
|
card.appendChild(facts);
|
|
|
|
const actions = createElement("div");
|
|
actions.className = "firmware-picker-actions";
|
|
const download = createElement("a", "Download exact firmware");
|
|
download.className = "firmware-picker-primary";
|
|
download.href = asset.url || asset.releaseUrl;
|
|
actions.appendChild(download);
|
|
const release = createElement("a", "Open release notes");
|
|
release.href = asset.releaseUrl;
|
|
actions.appendChild(release);
|
|
card.appendChild(actions);
|
|
|
|
const steps = createElement("div");
|
|
steps.className = "firmware-picker-steps";
|
|
steps.appendChild(createElement("strong", "What to do"));
|
|
const list = createElement("ol");
|
|
installSteps(profile, installKind).forEach(function (text) {
|
|
list.appendChild(createElement("li", text));
|
|
});
|
|
steps.appendChild(list);
|
|
card.appendChild(steps);
|
|
container.appendChild(card);
|
|
}
|
|
|
|
function initPicker(root) {
|
|
const repo = root.getAttribute("data-release-repo") || "mikecarper/MeshCore";
|
|
const form = root.querySelector('[data-role="form"]');
|
|
const status = root.querySelector('[data-role="status"]');
|
|
const releaseSetStatus = root.querySelector('[data-role="release-set"]');
|
|
const result = root.querySelector('[data-role="result"]');
|
|
const resultList = root.querySelector('[data-role="result-list"]');
|
|
const missing = root.querySelector('[data-role="missing"]');
|
|
const search = root.querySelector('[data-field="asset-search"]');
|
|
const assetResults = root.querySelector('[data-role="asset-results"]');
|
|
const clearButton = form.querySelector('[data-action="clear"]');
|
|
const controls = {};
|
|
FACET_FIELDS.forEach(function (field) {
|
|
controls[field] = form.querySelector('[data-field="' + field + '"]');
|
|
});
|
|
let catalog = { releaseSet: null, rows: [], profiles: [] };
|
|
const filters = {};
|
|
const groupPrefix = "firmware-picker-" + (++pickerInstanceCount);
|
|
|
|
function matchingProfiles(ignoredField) {
|
|
return catalog.profiles.filter(function (profile) {
|
|
return profileMatchesFacets(profile, filters, ignoredField);
|
|
});
|
|
}
|
|
|
|
function setSelectOptions(select, field, values, selected) {
|
|
select.replaceChildren();
|
|
const placeholders = {
|
|
hardwareFamily: "Any hardware",
|
|
hardware: "Choose a hardware variant",
|
|
mode: "Any connection / mode",
|
|
variant: "Any hardware/profile variant",
|
|
};
|
|
const blank = createElement("option", placeholders[field] || "Any");
|
|
blank.value = "";
|
|
select.appendChild(blank);
|
|
values.slice().sort(function (a, b) {
|
|
return optionSort(field, a, b);
|
|
}).forEach(function (value) {
|
|
const label = field === "hardware"
|
|
? humanizeHardwareVariant(value, filters.hardwareFamily)
|
|
: labelFor(field, value);
|
|
const option = createElement("option", label);
|
|
option.value = value;
|
|
select.appendChild(option);
|
|
});
|
|
select.disabled = values.length === 0;
|
|
select.value = selected && values.includes(selected) ? selected : "";
|
|
}
|
|
|
|
function setRadioOptions(container, field, values, selected) {
|
|
container.replaceChildren();
|
|
const options = [{ value: "", label: "Any" }].concat(
|
|
values.slice().sort(function (a, b) {
|
|
return optionSort(field, a, b);
|
|
}).map(function (value) {
|
|
return { value: value, label: labelFor(field, value) };
|
|
})
|
|
);
|
|
options.forEach(function (option) {
|
|
const label = createElement("label");
|
|
label.className = "firmware-picker-radio-option";
|
|
const input = createElement("input");
|
|
input.type = "radio";
|
|
input.name = groupPrefix + "-" + field;
|
|
input.value = option.value;
|
|
input.dataset.choiceField = field;
|
|
input.checked = selected === option.value;
|
|
label.appendChild(input);
|
|
label.appendChild(createElement("span", option.label));
|
|
container.appendChild(label);
|
|
});
|
|
const fieldset = container.closest("fieldset");
|
|
if (fieldset) fieldset.disabled = values.length === 0;
|
|
}
|
|
|
|
function setControlOptions(field, values) {
|
|
const control = controls[field];
|
|
if (!control) return;
|
|
if (control.tagName === "SELECT") {
|
|
setSelectOptions(control, field, values, filters[field] || "");
|
|
} else {
|
|
setRadioOptions(control, field, values, filters[field] || "");
|
|
}
|
|
}
|
|
|
|
function valuesForField(field) {
|
|
return facetValues(
|
|
catalog.profiles,
|
|
filters,
|
|
field,
|
|
field === "hardwareFamily" ? ["hardware"] : []
|
|
);
|
|
}
|
|
|
|
function stabilizeFilters() {
|
|
let changed = true;
|
|
let passes = 0;
|
|
while (changed && passes <= FACET_FIELDS.length) {
|
|
changed = false;
|
|
passes += 1;
|
|
FACET_FIELDS.forEach(function (field) {
|
|
if (field === "hardware" && !filters.hardwareFamily) {
|
|
if (filters.hardware) {
|
|
filters.hardware = "";
|
|
changed = true;
|
|
}
|
|
return;
|
|
}
|
|
const values = valuesForField(field);
|
|
if (filters[field] && !values.includes(filters[field])) {
|
|
filters[field] = "";
|
|
changed = true;
|
|
} else if (field === "hardware" && filters.hardwareFamily &&
|
|
!filters[field] && values.length === 1) {
|
|
filters[field] = values[0];
|
|
changed = true;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function refreshFacets() {
|
|
stabilizeFilters();
|
|
FACET_FIELDS.forEach(function (field) {
|
|
setControlOptions(field, valuesForField(field));
|
|
});
|
|
const hardwareVariants = valuesForField("hardware");
|
|
const hardwareVariantControl = root.querySelector(
|
|
'[data-role="hardware-variant-control"]'
|
|
);
|
|
hardwareVariantControl.hidden = !filters.hardwareFamily ||
|
|
hardwareVariants.length <= 1;
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
result.hidden = true;
|
|
missing.hidden = true;
|
|
resultList.replaceChildren();
|
|
const missingFields = FACET_FIELDS.filter(function (field) {
|
|
return !filters[field];
|
|
});
|
|
const matches = matchingProfiles();
|
|
|
|
if (missingFields.length) {
|
|
if (missingFields.length === FACET_FIELDS.length) {
|
|
status.textContent = "Pick options in any order. " +
|
|
matches.length + " compatible configurations are available.";
|
|
} else {
|
|
status.textContent = matches.length + " compatible configuration" +
|
|
(matches.length === 1 ? " remains. " : "s remain. ") +
|
|
"Choose " + missingFields.length + " more option" +
|
|
(missingFields.length === 1 ? "" : "s") + " in any order.";
|
|
}
|
|
return;
|
|
}
|
|
|
|
const installKind = filters.install;
|
|
const resolved = matches.map(function (profile) {
|
|
return {
|
|
profile: profile,
|
|
asset: canonicalAsset(profile.files, installKind),
|
|
};
|
|
}).filter(function (entry) {
|
|
return Boolean(entry.asset);
|
|
});
|
|
if (!resolved.length) {
|
|
missing.hidden = false;
|
|
root.querySelector('[data-role="missing-text"]').textContent =
|
|
"No exact file provides that installation method. Change the install method or one of the firmware options.";
|
|
status.textContent = "No exact firmware file matched.";
|
|
return;
|
|
}
|
|
|
|
status.textContent = resolved.length + " exact firmware configuration" +
|
|
(resolved.length === 1 ? " matched." : "s matched.");
|
|
resolved.forEach(function (entry) {
|
|
renderProfileCard(
|
|
resultList,
|
|
entry.profile,
|
|
entry.asset,
|
|
installKind
|
|
);
|
|
});
|
|
result.hidden = false;
|
|
}
|
|
|
|
function renderSearch() {
|
|
assetResults.replaceChildren();
|
|
const query = search.value.trim().toLowerCase();
|
|
if (query.length < 2) return;
|
|
const matches = catalog.rows.filter(function (row) {
|
|
return row.name.toLowerCase().includes(query);
|
|
}).sort(function (a, b) {
|
|
const byLength = a.name.length - b.name.length;
|
|
return byLength || a.name.localeCompare(b.name);
|
|
}).slice(0, 80);
|
|
if (!matches.length) {
|
|
assetResults.appendChild(
|
|
createElement("p", "No current 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 (event) {
|
|
const target = event.target;
|
|
const field = target.dataset.choiceField || target.dataset.field;
|
|
if (!FACET_FIELDS.includes(field)) return;
|
|
if (field === "hardwareFamily") filters.hardware = "";
|
|
filters[field] = target.value;
|
|
refreshFacets();
|
|
});
|
|
form.addEventListener("reset", function (event) {
|
|
event.preventDefault();
|
|
if (!catalog.profiles.length) return;
|
|
FACET_FIELDS.forEach(function (field) {
|
|
filters[field] = "";
|
|
});
|
|
refreshFacets();
|
|
});
|
|
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) {
|
|
catalog = buildCatalog(data);
|
|
if (!catalog.releaseSet || !catalog.profiles.length) {
|
|
throw new Error("no complete release family was found");
|
|
}
|
|
const set = catalog.releaseSet;
|
|
releaseSetStatus.replaceChildren();
|
|
releaseSetStatus.appendChild(
|
|
createElement(
|
|
"strong",
|
|
set.name + (set.prerelease ? " — prerelease" : "")
|
|
)
|
|
);
|
|
releaseSetStatus.appendChild(document.createTextNode(
|
|
" · " + catalog.profiles.length + " configurations · " +
|
|
catalog.rows.length + " firmware files · " +
|
|
set.releases.length + " release pages"
|
|
));
|
|
if (set.url) {
|
|
releaseSetStatus.appendChild(document.createTextNode(" · "));
|
|
const link = createElement("a", "open primary release");
|
|
link.href = set.url;
|
|
releaseSetStatus.appendChild(link);
|
|
}
|
|
clearButton.disabled = false;
|
|
refreshFacets();
|
|
})
|
|
.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 search for the exact hardware name.";
|
|
});
|
|
}
|
|
|
|
const api = Object.freeze({
|
|
FILTER_FIELDS: FILTER_FIELDS,
|
|
FACET_FIELDS: FACET_FIELDS,
|
|
ROLE_LABELS: ROLE_LABELS,
|
|
LOGGING_LABELS: LOGGING_LABELS,
|
|
OTA_LABELS: OTA_LABELS,
|
|
MODE_LABELS: MODE_LABELS,
|
|
FEATURE_LABELS: FEATURE_LABELS,
|
|
INSTALL_LABELS: INSTALL_LABELS,
|
|
selectReleaseSet: selectReleaseSet,
|
|
flattenReleaseAssets: flattenReleaseAssets,
|
|
parseFirmwareAsset: parseFirmwareAsset,
|
|
parseTargetProfile: parseTargetProfile,
|
|
canonicalHardware: canonicalHardware,
|
|
hardwareFamilyFor: hardwareFamilyFor,
|
|
humanizeHardwareVariant: humanizeHardwareVariant,
|
|
buildCatalog: buildCatalog,
|
|
profileMatches: profileMatches,
|
|
profileMatchesFacets: profileMatchesFacets,
|
|
facetValues: facetValues,
|
|
uniqueValues: uniqueValues,
|
|
canonicalAsset: canonicalAsset,
|
|
humanizeHardware: humanizeHardware,
|
|
humanizeVariant: humanizeVariant,
|
|
labelFor: labelFor,
|
|
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);
|