diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index d467f9e9..70725dc3 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -576,6 +576,39 @@ }); } + function applyReleaseRuntimeCapabilities(profile, controls) { + if (["none", "lora-receiver", "lora-source"].includes(controls.otaRole)) { + profile.ota = controls.otaRole; + } + + const modes = controls.loggingModes; + const source = controls.loggingSource; + const sourceMatches = !source || (typeof source === "string" && + /^[0-9a-f]{40}$/i.test(source) && profile.files.length > 0 && + profile.files.every(function (file) { + return file.name.toLowerCase().replace(/\.(bin|hex|uf2|zip)$/i, "") + .replace(/-merged$/, "") + .endsWith("-" + source.slice(0, 8).toLowerCase()); + })); + if (sourceMatches && Array.isArray(modes)) { + const key = modes.join(","); + if (key === "none") { + profile.logging = "none"; + profile.loggingModes = ["none"]; + } else if (key === "none,usb" && controls.loggingControl === "usb.logging") { + profile.logging = "usb-runtime"; + profile.loggingModes = modes.slice(); + } else if (key === "none,usb,wifi,both" && + controls.loggingControl === "logging.output") { + profile.logging = "runtime"; + profile.loggingModes = modes.slice(); + } + } + if (typeof controls.dedicatedUsbLogging === "boolean") { + profile.dedicatedUsbLogging = controls.dedicatedUsbLogging; + } + } + function hardwareFamilyFor(hardware, hardwareNames) { const value = String(hardware || ""); const lowerValue = value.toLowerCase(); @@ -723,7 +756,9 @@ if (controlData && controlData.familyTag === releaseSet.familyTag && controlData.profiles && controlData.profiles[profile.target]) { profile.controls = controlData.profiles[profile.target]; - if (isFullCompanion(profile) && profile.controls.mqtt) { + applyReleaseRuntimeCapabilities(profile, profile.controls); + if (isFullCompanion(profile) && profile.controls.mqtt && + !Array.isArray(profile.controls.loggingModes)) { profile.loggingModes = ["none", "usb", "wifi", "both"]; } if (profile.controls.rs232 && profile.role === "repeater" && profile.mode === "standard") { diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index a7a56b78..21587f73 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -17,7 +17,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. -
Loading release information...
@@ -438,6 +438,11 @@ 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. +For 1.17.1.6, that JSON also records the verified LoRa OTA role of each image +and USB logging modes where the packaged application was checked. Logging +metadata is tied to the source hash of its firmware file. The normal iKoka +30 dBm repeater has USB logging and LoRa OTA; its compact no-external-sensors +image has LoRa OTA without logging. Capacity directions come from each qualified profile's recorded reductions and are tied to the source hash of the selected download. The older 1.17.1.5 USB sleep workaround stays limited to that release. diff --git a/mkdocs.yml b/mkdocs.yml index 8b2b4299..e5486326 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,7 +26,7 @@ extra_css: extra_javascript: - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - - _javascript/firmware_picker.js + - _javascript/firmware_picker.js?v=20260921-1 - _javascript/telemetry_decoder.js - _javascript/management_decoder.js - _javascript/filter_tool.js diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index b996fb83..ae0e8ee2 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1012,7 +1012,7 @@ console.log('role-specific runtime directions tests passed'); // above keep the older release's USB and capacity workarounds intact. const currentControls = require('../docs/_data/firmware_controls.json'); const currentAssets = Object.entries(currentControls.profiles).map(([target, info]) => { - const source = info.memorySource || currentControls.source; + const source = info.loggingSource || info.memorySource || currentControls.source; const tag = currentControls.familyTag.replace(/-[0-9a-f]{8}$/, '-' + source.slice(0, 8)); return asset(target + '-' + tag + (info.platform === 'NRF52_PLATFORM' ? '.uf2' : '.bin')); }); @@ -1021,6 +1021,37 @@ const currentCatalog = picker.buildCatalog([ ], currentControls); assert.strictEqual(currentCatalog.rows.length, currentAssets.length); assert(currentCatalog.profiles.every(profile => profile.controls && profile.chipFamily !== 'unknown')); +const ikokaNormal = currentCatalog.profiles.find(profile => + profile.target === 'ikoka_stick_nrf_30dbm_repeater'); +const ikokaLean = currentCatalog.profiles.find(profile => + profile.target === 'ikoka_stick_nrf_30dbm_repeater_lora_ota_no_external_sensors'); +assert.strictEqual(ikokaNormal.ota, 'lora-receiver'); +assert.strictEqual(ikokaNormal.logging, 'usb-runtime'); +assert.deepStrictEqual(ikokaNormal.loggingModes, ['none', 'usb']); +assert.deepStrictEqual(picker.runtimeDirections(ikokaNormal, {logging: 'usb'})[0].actions[0].commands, + ['set usb.logging on']); +assert.strictEqual(ikokaLean.ota, 'lora-receiver'); +assert.strictEqual(ikokaLean.logging, 'none'); +assert.deepStrictEqual(ikokaLean.loggingModes, ['none']); +const ikokaUrl = 'https://example.com/firmware_picker/?chipFamily=nrf52&hardwareFamily=ikoka_stick_nrf_30dbm&hardware=ikoka_stick_nrf_30dbm&role=repeater&variant=default&install=zip&chipAuto=1'; +const ikokaRelease = release(currentControls.familyTag, '2026-09-13T00:00:00Z', + [ikokaNormal, ikokaLean].map(profile => { + const source = currentControls.profiles[profile.target].loggingSource; + const tag = currentControls.familyTag.replace(/-[0-9a-f]{8}$/, '-' + source.slice(0, 8)); + return asset(profile.target + '-ota-' + tag + '.zip'); + })); +const ikokaCatalog = picker.buildCatalog([ikokaRelease], currentControls); +const ikokaSelection = picker.selectionFromUrl(ikokaUrl, ikokaCatalog.profiles); +assert.deepStrictEqual(ikokaSelection.unavailable, []); +assert.deepStrictEqual(ikokaCatalog.profiles.filter(profile => + picker.profileMatchesFacets(profile, ikokaSelection.filters)).map(profile => profile.target), + ['ikoka_stick_nrf_30dbm_repeater']); +const mismatchedIkoka = picker.buildCatalog([ + release(currentControls.familyTag, '2026-09-13T00:00:00Z', [ + asset('ikoka_stick_nrf_30dbm_repeater-ota-' + currentControls.familyTag + '.zip'), + ]), +], currentControls).profiles[0]; +assert.strictEqual(mismatchedIkoka.logging, 'none', 'Logging metadata must match the published build source'); const capacityProfiles = currentCatalog.profiles.filter(profile => profile.controls.memoryNote); assert(capacityProfiles.length > 0, 'Regeneration must preserve capacity directions'); for (const profile of capacityProfiles) {