diff --git a/scripts/update-firmware-list.js b/scripts/update-firmware-list.js index 408e0f7..33403ee 100644 --- a/scripts/update-firmware-list.js +++ b/scripts/update-firmware-list.js @@ -68,12 +68,20 @@ const DMC_REPEATER_ROLE = { separator: '_repeater-', role: 'dutchmeshcore_repeat // Repeater firmware with packet logging enabled (dmc-repeater-packetlog-* releases) const DMC_PACKETLOG_ROLE = { separator: '_repeater-', role: 'dutchmeshcore_packetlog', icon: 'πŸ“', title: 'DutchMeshCore Repeater PacketLog', subTitle: 'Repeater + packet logging' } +// DMC companion firmware with the receive filter (dmc-companion-* releases). Role keys +// are kept distinct from meshcomod's `companion_radio_*` roles. +const DMC_COMPANION_ROLES = [ + { separator: '_companion_radio_ble-', role: 'dutchmeshcore_companion_ble', icon: 'πŸ“»', title: 'DutchMeshCore Companion', subTitle: 'BLE + receive filter' }, + { separator: '_companion_radio_wifi-', role: 'dutchmeshcore_companion_wifi', icon: 'πŸ“Ά', title: 'DutchMeshCore Companion', subTitle: 'Wi-Fi + receive filter' }, +] + const ALL_ROLE_DEFS = [ ...MESHCORE_DEV_ROLES, ...MESHCOMOD_ROLES, ...DMC_ROLE_DEFS, DMC_REPEATER_ROLE, DMC_PACKETLOG_ROLE, + ...DMC_COMPANION_ROLES, ] // MQTT and non-MQTT repeater firmware render as two separate maker groups. @@ -95,6 +103,12 @@ const DMC_PACKETLOG_MAKER_META = { website: 'https://dutchmeshcore.nl', } +const DMC_COMPANION_MAKER_META = { + name: 'DutchMeshCore Companion Filter Firmware', + repo: 'https://github.com/Dutch-MeshCore/MeshCore/releases?q=dmc-companion', + website: 'https://dutchmeshcore.nl', +} + const ROLE_DISPLAY = Object.fromEntries( ALL_ROLE_DEFS.map(rd => [rd.role, { icon: rd.icon, title: rd.title, subTitle: rd.subTitle }]) ) @@ -170,15 +184,17 @@ function extOf(name) { return null } -// Display version for a `dmc-repeater[-packetlog]-*` asset: from the tag -// (dmc-repeater-v1.17.1 β†’ 1.17.1). A rolling dev tag (`dmc-repeater-dev`) has no -// semver, so it comes from the filename (`{Device}_repeater-v1.17.1-{hash}.bin`) and -// is labelled `1.17.1-dev`, matching the MQTT `observer-mqtt-dev` builds. -function repeaterVersion(tag, prefix, assetName) { +// Display version for a `dmc-repeater[-packetlog]-*` / `dmc-companion-*` asset: from +// the tag (dmc-repeater-v1.17.1 β†’ 1.17.1). A rolling dev tag (`dmc-repeater-dev`, +// `dmc-companion-dev`) has no semver, so it comes from the filename right after +// `separator` (`{Device}_repeater-v1.17.1-{hash}.bin`) and is labelled `1.17.1-dev`, +// matching the MQTT `observer-mqtt-dev` builds. +export function repeaterVersion(tag, prefix, assetName, separator = '_repeater-') { const fromTag = tag.replace(prefix, '').replace(/^v/, '') if (/^\d+\.\d+\.\d+/.test(fromTag) || !/(?:^|[-_])dev$/i.test(fromTag)) return fromTag - const tail = assetName.slice(assetName.indexOf('_repeater-') + '_repeater'.length) - const semver = (tail.match(/^-v?(\d+\.\d+\.\d+)/) || [])[1] + const sepIdx = assetName.indexOf(separator) + const tail = sepIdx < 0 ? '' : assetName.slice(sepIdx + separator.length) + const semver = (tail.match(/^v?(\d+\.\d+\.\d+)/) || [])[1] return semver ? `${semver}-dev` : fromTag } @@ -509,6 +525,42 @@ async function buildDmcPacketlogConfig() { return buildConfig(files, 'dutchmeshcore_packetlog', DMC_PACKETLOG_MAKER_META) } +// DMC companion firmware with the receive filter – GitHub Releases tagged +// `dmc-companion-*` on the fork: the rolling `dmc-companion-dev` prerelease and the +// versioned `dmc-companion-vX.Y.Z` ones. Assets are `{Device}_companion_radio_ble-…` and +// `{Device}_companion_radio_wifi-…` (ESP32 app/merged .bin, nRF52 .uf2/.zip, RP2040 +// .bin + .uf2). Drafts are skipped (their assets are not public). +export function dmcCompanionConfigFromReleases(releases) { + const picked = releases.filter(r => + typeof r.tag_name === 'string' && r.tag_name.startsWith('dmc-companion-') && !r.draft) + const files = picked.flatMap(rel => (rel.assets ?? []).flatMap(a => { + const ext = extOf(a.name) + if (!ext) return [] + const rd = DMC_COMPANION_ROLES.find(r => a.name.includes(r.separator)) + if (!rd) return [] + return [{ + deviceKey: a.name.slice(0, a.name.indexOf(rd.separator)), + role: rd.role, + versionKey: repeaterVersion(rel.tag_name, 'dmc-companion-', a.name, rd.separator), + isMerged: a.name.endsWith('-merged.' + ext), + ext, + url: a.browser_download_url, + }] + })) + // RP2040 Wi-Fi builds ship a .bin next to the .uf2. The flasher cannot write an RP2040 + // .bin over serial, so a device/role/version that has a .uf2 or .zip keeps only that. + const imageKey = f => `${f.deviceKey}/${f.role}/${f.versionKey}` + const hasImage = new Set(files.filter(f => f.ext !== 'bin').map(imageKey)) + const kept = files.filter(f => f.ext !== 'bin' || !hasImage.has(imageKey(f))) + console.log(` β†’ ${picked.length} dmc-companion releases, ${kept.length} firmware files`) + return buildConfig(kept, 'dutchmeshcore_companion', DMC_COMPANION_MAKER_META) +} + +async function buildDmcCompanionConfig() { + console.log('Dutch-MeshCore/MeshCore (dmc-companion releases)') + return dmcCompanionConfigFromReleases(await fetchReleases('Dutch-MeshCore', 'MeshCore')) +} + // ─── Main ───────────────────────────────────────────────────────────────────── async function main() { @@ -550,21 +602,24 @@ async function main() { // ── DMC firmware (MQTT + non-MQTT repeater) ─────────────────────────────── console.log('\nBuilding DutchMeshCore firmware list…') try { - const [mqttRes, repeaterRes, packetlogRes] = await Promise.allSettled([ + const [mqttRes, repeaterRes, packetlogRes, companionRes] = await Promise.allSettled([ buildDmcFirmwareConfig(), buildDmcRepeaterConfig(), buildDmcPacketlogConfig(), + buildDmcCompanionConfig(), ]) const emptyConfig = { staticPath: '', role: {}, notice: {}, maker: {}, device: [] } const dmcMqtt = mqttRes.status === 'fulfilled' ? mqttRes.value : emptyConfig const dmcRepeater = repeaterRes.status === 'fulfilled' ? repeaterRes.value : emptyConfig const dmcPacketlog = packetlogRes.status === 'fulfilled' ? packetlogRes.value : emptyConfig + const dmcCompanion = companionRes.status === 'fulfilled' ? companionRes.value : emptyConfig if (mqttRes.status === 'rejected') console.error('DMC MQTT build failed:', mqttRes.reason) if (repeaterRes.status === 'rejected') console.error('DMC repeater build failed:', repeaterRes.reason) if (packetlogRes.status === 'rejected') console.error('DMC packetlog build failed:', packetlogRes.reason) + if (companionRes.status === 'rejected') console.error('DMC companion build failed:', companionRes.reason) - // Three maker groups (repeater, then PacketLog, then MQTT) in one config. - const dmcConfig = mergeConfigs([dmcRepeater, dmcPacketlog, dmcMqtt]) + // Four maker groups (repeater, PacketLog, MQTT, companion) in one config. + const dmcConfig = mergeConfigs([dmcRepeater, dmcPacketlog, dmcMqtt, dmcCompanion]) if (dmcConfig.device.length) { const dmcOutput = { generated: new Date().toISOString(), ...dmcConfig } writeFileSync(DMC_OUT, JSON.stringify(dmcOutput, null, 2)) diff --git a/src/components/flasher/DeviceList.tsx b/src/components/flasher/DeviceList.tsx index 8eac644..54e64f7 100644 --- a/src/components/flasher/DeviceList.tsx +++ b/src/components/flasher/DeviceList.tsx @@ -48,7 +48,9 @@ function deviceHasVersion(device: FlasherDevice, version: string): boolean { ) } -const MAKER_ORDER_FIRST = ['dutchmeshcore_repeater', 'dutchmeshcore', 'dutchmeshcore_packetlog'] +// The DMC groups stay together at the top: the repeater builds, then the companion +// (receive filter) builds; every other maker follows alphabetically. +const MAKER_ORDER_FIRST = ['dutchmeshcore_repeater', 'dutchmeshcore', 'dutchmeshcore_packetlog', 'dutchmeshcore_companion'] interface Props { devices: FlasherDevice[] diff --git a/src/test/DeviceList.test.tsx b/src/test/DeviceList.test.tsx index 98572bf..1bfeacd 100644 --- a/src/test/DeviceList.test.tsx +++ b/src/test/DeviceList.test.tsx @@ -112,4 +112,38 @@ describe('DeviceList', () => { fireEvent.mouseEnter(screen.getByText('Meshtiny').closest('button') as HTMLButtonElement) expect(screen.queryByTestId('device-preview')).not.toBeInTheDocument() }) + + it('pins the DMC groups first, with the companion group after the repeater groups', () => { + const dev = (maker: string): FlasherDevice => + ({ maker, class: 'community', name: `${maker} board`, type: 'esp32', firmware: [] }) + const { container } = render( + + + , + ) + const titles = [...container.querySelectorAll('.device-group-title')].map(el => el.textContent) + expect(titles).toEqual([ + 'DutchMeshCore Firmware', + 'DutchMeshCore-MQTT-Firmware', + 'DutchMeshCore-PacketLog-Firmware', + 'DutchMeshCore Companion Filter Firmware', + 'AAA Fork Firmware', + ]) + }) }) diff --git a/src/test/dutchFlasherConfig.test.ts b/src/test/dutchFlasherConfig.test.ts index ec9b850..8673be2 100644 --- a/src/test/dutchFlasherConfig.test.ts +++ b/src/test/dutchFlasherConfig.test.ts @@ -1,11 +1,14 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { buildDmcConfig, buildDmcRepeaterConfig, buildDmcPacketlogConfig, + buildDmcCompanionConfig, + fetchDmcConfig, mergeDmcConfigs, PREBUILT_RAW_BASE, } from '../utils/dutchFlasherConfig' +import type { FlasherConfig } from '../types' describe('buildDmcConfig', () => { it('builds repeater observer MQTT firmware from prebuilt bin pairs', () => { @@ -358,3 +361,229 @@ describe('mergeDmcConfigs', () => { expect(merged.device.map(d => d.name).sort()).toEqual(['Heltec T190', 'Xiao S3 WIO']) }) }) + +// ─── DMC companion (receive filter) firmware ───────────────────────────────────── + +const asset = (name: string) => ({ name, browser_download_url: `https://example.test/${name}` }) + +const COMPANION_STABLE = { + tag_name: 'dmc-companion-v1.18.0', + assets: [ + asset('Heltec_v3_companion_radio_ble-v1.18.0-abc1234.bin'), + asset('Heltec_v3_companion_radio_ble-v1.18.0-abc1234-merged.bin'), + asset('Heltec_v3_companion_radio_wifi-v1.18.0-abc1234.bin'), + asset('Heltec_v3_companion_radio_wifi-v1.18.0-abc1234-merged.bin'), + asset('RAK_4631_companion_radio_ble-v1.18.0-abc1234.uf2'), + asset('RAK_4631_companion_radio_ble-v1.18.0-abc1234.zip'), + asset('PicoW_companion_radio_wifi-v1.18.0-abc1234.bin'), + asset('PicoW_companion_radio_wifi-v1.18.0-abc1234.uf2'), + ], +} + +const COMPANION_DEV = { + tag_name: 'dmc-companion-dev', + assets: [ + asset('Heltec_v3_companion_radio_ble-v1.18.0-def5678.bin'), + asset('Heltec_v3_companion_radio_ble-v1.18.0-def5678-merged.bin'), + asset('RAK_4631_companion_radio_ble-v1.18.0-def5678.uf2'), + ], +} + +describe('buildDmcCompanionConfig', () => { + it('builds the companion group with the version from a stable dmc-companion-vX.Y.Z tag', () => { + const config = buildDmcCompanionConfig([COMPANION_STABLE]) + + expect(config.maker).toEqual({ + dutchmeshcore_companion: { + name: 'DutchMeshCore Companion Filter Firmware', + repo: 'https://github.com/Dutch-MeshCore/MeshCore/releases?q=dmc-companion', + website: 'https://dutchmeshcore.nl', + }, + }) + expect(config.device.map(d => d.name)).toEqual(['Heltec V3', 'PicoW', 'RAK4631']) + expect(config.device.every(d => d.maker === 'dutchmeshcore_companion')).toBe(true) + const v3 = config.device.find(d => d.name === 'Heltec V3')! + expect(Object.keys(v3.firmware[0].version)).toEqual(['1.18.0 - App update', '1.18.0 - Full flash']) + }) + + it('splits BLE and Wi-Fi builds into two roles, distinct from the meshcomod companion roles', () => { + const config = buildDmcCompanionConfig([COMPANION_STABLE]) + const v3 = config.device.find(d => d.name === 'Heltec V3')! + + expect(v3.firmware.map(fw => fw.role)).toEqual(['dutchmeshcore_companion_ble', 'dutchmeshcore_companion_wifi']) + expect(v3.firmware[0].version['1.18.0 - App update'].files[0].name) + .toBe('https://example.test/Heltec_v3_companion_radio_ble-v1.18.0-abc1234.bin') + expect(v3.firmware[1].version['1.18.0 - App update'].files[0].name) + .toBe('https://example.test/Heltec_v3_companion_radio_wifi-v1.18.0-abc1234.bin') + expect(config.role).toEqual({ + dutchmeshcore_companion_ble: { icon: 'πŸ“»', title: 'DutchMeshCore Companion', subTitle: 'BLE + receive filter' }, + dutchmeshcore_companion_wifi: { icon: 'πŸ“Ά', title: 'DutchMeshCore Companion', subTitle: 'Wi-Fi + receive filter' }, + }) + expect(config.role.companion_radio_ble).toBeUndefined() + }) + + it('maps the ESP32 app bin to an app update and the -merged bin to a full flash', () => { + const v3 = buildDmcCompanionConfig([COMPANION_STABLE]).device.find(d => d.name === 'Heltec V3')! + expect(v3.type).toBe('esp32') + expect(v3.firmware[1].version['1.18.0 - App update'].files[0]).toMatchObject({ + type: 'flash-update', + name: 'https://example.test/Heltec_v3_companion_radio_wifi-v1.18.0-abc1234.bin', + }) + expect(v3.firmware[1].version['1.18.0 - Full flash'].files[0]).toMatchObject({ + type: 'flash-wipe', + name: 'https://example.test/Heltec_v3_companion_radio_wifi-v1.18.0-abc1234-merged.bin', + }) + }) + + it('lists nRF52 builds as one entry preferring the DFU zip, and RP2040 builds as uf2 only', () => { + const config = buildDmcCompanionConfig([COMPANION_STABLE]) + const rak = config.device.find(d => d.name === 'RAK4631')! + expect(rak.type).toBe('nrf52') + expect(rak.firmware.map(fw => fw.role)).toEqual(['dutchmeshcore_companion_ble']) + expect(rak.firmware[0].version['1.18.0'].files[0]).toMatchObject({ + type: 'nrf-dfu-zip', + name: 'https://example.test/RAK_4631_companion_radio_ble-v1.18.0-abc1234.zip', + }) + + // The RP2040 Wi-Fi build ships a .bin next to the .uf2; only the uf2 is flashable. + const pico = config.device.find(d => d.name === 'PicoW')! + expect(pico.type).toBe('nrf52') + expect(pico.firmware.map(fw => fw.role)).toEqual(['dutchmeshcore_companion_wifi']) + expect(pico.firmware[0].version).toEqual({ + '1.18.0': { files: [{ type: 'uf2', name: 'https://example.test/PicoW_companion_radio_wifi-v1.18.0-abc1234.uf2', title: 'Firmware update' }] }, + }) + }) + + it('labels the rolling dmc-companion-dev build `-dev` from the filename, next to the stable build', () => { + const config = buildDmcCompanionConfig([COMPANION_DEV, COMPANION_STABLE]) + const v3 = config.device.find(d => d.name === 'Heltec V3')! + expect(Object.keys(v3.firmware[0].version)).toEqual([ + '1.18.0-dev - App update', '1.18.0-dev - Full flash', '1.18.0 - App update', '1.18.0 - Full flash', + ]) + expect(v3.firmware[0].version['1.18.0-dev - App update'].files[0].name) + .toBe('https://example.test/Heltec_v3_companion_radio_ble-v1.18.0-def5678.bin') + const rak = config.device.find(d => d.name === 'RAK4631')! + expect(rak.firmware[0].version['1.18.0-dev'].files[0]).toMatchObject({ type: 'uf2' }) + }) + + it('ignores drafts, other tags and assets without a companion separator', () => { + const config = buildDmcCompanionConfig([ + { ...COMPANION_STABLE, tag_name: 'dmc-companion-v1.19.0', draft: true }, + { tag_name: 'companion-v1.18.0.2', assets: [asset('heltec_v4_companion_radio_ble-v1.18.0.2-abc-merged.bin')] }, + { tag_name: 'dmc-repeater-v1.18.0', assets: [asset('Heltec_v3_companion_radio_ble-v1.18.0-abc.bin')] }, + { tag_name: 'dmc-companion-v1.18.0', assets: [ + asset('Heltec_v3_companion_radio_usb-v1.18.0-abc.bin'), + asset('Heltec_v3_repeater-v1.18.0-abc.bin'), + asset('README.md'), + ] }, + ]) + expect(config.device).toEqual([]) + }) +}) + +describe('DMC group isolation', () => { + const RELEASES = [ + COMPANION_DEV, + COMPANION_STABLE, + { tag_name: 'dmc-repeater-v1.18.0', assets: [asset('Heltec_v3_repeater-v1.18.0-r1.bin')] }, + { tag_name: 'dmc-repeater-dev', assets: [asset('Heltec_v3_repeater-v1.18.0-r2.bin')] }, + { tag_name: 'dmc-repeater-packetlog-v1.18.0', assets: [asset('Heltec_v3_repeater-v1.18.0-packetlog-p1.bin')] }, + { tag_name: 'repeater-mqtt-v1.18.0', assets: [asset('Heltec_v3_repeater_observer_mqtt-v1.18.0-m1.bin')] }, + ] + + const urlsByMaker = (config: FlasherConfig) => { + const out: Record = {} + for (const d of config.device) { + for (const fw of d.firmware) { + for (const v of Object.values(fw.version)) { + for (const f of v.files) (out[d.maker] ??= []).push(f.name) + } + } + } + return out + } + + afterEach(() => { + vi.unstubAllGlobals() + sessionStorage.clear() + }) + + it('keeps companion assets out of the repeater/PacketLog/MQTT groups and theirs out of the companion group', async () => { + sessionStorage.clear() + vi.stubGlobal('fetch', vi.fn(async (url: string) => url === '/dmc-firmware.json' + ? { ok: false, json: async () => ({}) } + : { ok: true, json: async () => RELEASES })) + + const config = await fetchDmcConfig() + const urls = urlsByMaker(config) + + expect(Object.keys(urls).sort()).toEqual([ + 'dutchmeshcore', 'dutchmeshcore_companion', 'dutchmeshcore_packetlog', 'dutchmeshcore_repeater', + ]) + expect(urls.dutchmeshcore_companion.length).toBeGreaterThan(0) + expect(urls.dutchmeshcore_companion.every(u => u.includes('_companion_radio_'))).toBe(true) + for (const maker of ['dutchmeshcore', 'dutchmeshcore_packetlog', 'dutchmeshcore_repeater']) { + expect(urls[maker].length).toBeGreaterThan(0) + expect(urls[maker].some(u => u.includes('_companion_radio_'))).toBe(false) + } + expect(urls.dutchmeshcore_repeater.some(u => u.includes('packetlog'))).toBe(false) + expect(config.maker.dutchmeshcore_companion.name).toBe('DutchMeshCore Companion Filter Firmware') + }) +}) + +describe('scripts/update-firmware-list.js DMC companion mirror', () => { + // The generator script (public/dmc-firmware.json) and this TS live-API fallback must + // list the same companion firmware. The script labels versions with an en dash. + const loadScript = async () => { + const path = '../../scripts/update-firmware-list.js' + return import(/* @vite-ignore */ path) + } + + const normalize = (config: FlasherConfig) => config.device.map(d => ({ + maker: d.maker, + name: d.name, + type: d.type, + roles: Object.fromEntries(d.firmware.map(fw => [fw.role, Object.fromEntries( + Object.entries(fw.version).map(([key, v]) => [ + key.replace(' – ', ' - '), + v.files.map(f => ({ type: f.type, name: f.name })), + ]), + )])), + })) + + it('repeaterVersion keeps the repeater/PacketLog labels and reads a companion separator', async () => { + const { repeaterVersion } = await loadScript() + expect(repeaterVersion('dmc-repeater-v1.17.1', 'dmc-repeater-', 'Heltec_v3_repeater-v1.17.1-85c7273.bin')).toBe('1.17.1') + expect(repeaterVersion('dmc-repeater-v1.16.0-dev', 'dmc-repeater-', 'Heltec_v3_repeater-dev-0130fc3.bin')).toBe('1.16.0-dev') + expect(repeaterVersion('dmc-repeater-dev', 'dmc-repeater-', 'Heltec_v3_repeater-v1.17.1-a75332f9.bin')).toBe('1.17.1-dev') + expect(repeaterVersion('dmc-repeater-packetlog-dev', 'dmc-repeater-packetlog-', + 'RAK_4631_repeater-v1.17.1-packetlog-1ebe0339.zip')).toBe('1.17.1-dev') + expect(repeaterVersion('dmc-companion-dev', 'dmc-companion-', + 'Heltec_v3_companion_radio_wifi-v1.18.0-def5678.bin', '_companion_radio_wifi-')).toBe('1.18.0-dev') + expect(repeaterVersion('dmc-companion-v1.18.0', 'dmc-companion-', + 'Heltec_v3_companion_radio_ble-v1.18.0-abc1234.bin', '_companion_radio_ble-')).toBe('1.18.0') + }) + + it('matches the TS companion config for stable, dev, draft and foreign releases', async () => { + const { dmcCompanionConfigFromReleases } = await loadScript() + const releases = [ + COMPANION_DEV, + COMPANION_STABLE, + { ...COMPANION_STABLE, tag_name: 'dmc-companion-v1.19.0', draft: true }, + { tag_name: 'dmc-repeater-v1.18.0', assets: [asset('Heltec_v3_repeater-v1.18.0-r1.bin')] }, + { tag_name: 'companion-v1.18.0.2', assets: [asset('heltec_v4_companion_radio_ble-v1.18.0.2-abc-merged.bin')] }, + ] + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + const fromScript = dmcCompanionConfigFromReleases(releases) as FlasherConfig + const fromTs = buildDmcCompanionConfig(releases) + expect(normalize(fromScript)).toEqual(normalize(fromTs)) + expect(fromScript.maker).toEqual(fromTs.maker) + for (const role of ['dutchmeshcore_companion_ble', 'dutchmeshcore_companion_wifi']) { + expect(fromScript.role[role]).toEqual(fromTs.role[role]) + } + } finally { + log.mockRestore() + } + }) +}) diff --git a/src/utils/dutchFlasherConfig.ts b/src/utils/dutchFlasherConfig.ts index 8b3162d..227871b 100644 --- a/src/utils/dutchFlasherConfig.ts +++ b/src/utils/dutchFlasherConfig.ts @@ -8,9 +8,9 @@ * repeater_observer_mqtt * room_server_observer_mqtt / room_server_mqtt / roomserver_mqtt * - * The non-MQTT repeater and PacketLog releases are handled as their own maker - * groups (see scripts/update-firmware-list.js), so the flasher shows three - * DutchMeshCore groups in total. + * The non-MQTT repeater, PacketLog and companion (receive filter) releases are + * handled as their own maker groups (see scripts/update-firmware-list.js), so the + * flasher shows four DutchMeshCore groups in total. * * The GitHub API is queried at runtime so the flasher automatically picks up * new firmware releases whenever the branch is updated. @@ -32,6 +32,8 @@ const MESHCORE_REPO = 'Dutch-MeshCore/MeshCore' const REPEATER_TAG_PREFIX = 'dmc-repeater-' // PacketLog shares the `dmc-repeater-` prefix but is its own maker group. const PACKETLOG_TAG_PREFIX = 'dmc-repeater-packetlog-' +// Companion firmware with the receive filter: `dmc-companion-dev` / `dmc-companion-vX.Y.Z`. +const COMPANION_TAG_PREFIX = 'dmc-companion-' const DMC_RELEASES_URL = `https://api.github.com/repos/${MESHCORE_REPO}/releases?per_page=100` @@ -40,6 +42,7 @@ const DMC_RELEASES_URL = const DMC_MQTT_MAKER = 'dutchmeshcore' const DMC_REPEATER_MAKER = 'dutchmeshcore_repeater' const DMC_PACKETLOG_MAKER = 'dutchmeshcore_packetlog' +const DMC_COMPANION_MAKER = 'dutchmeshcore_companion' const DMC_MQTT_MAKER_META = { name: 'DutchMeshCore-MQTT-Firmware', @@ -59,6 +62,12 @@ const DMC_PACKETLOG_MAKER_META = { website: 'https://dutchmeshcore.nl', } +const DMC_COMPANION_MAKER_META = { + name: 'DutchMeshCore Companion Filter Firmware', + repo: 'https://github.com/Dutch-MeshCore/MeshCore/releases?q=dmc-companion', + website: 'https://dutchmeshcore.nl', +} + /** Human-readable labels for each device key found in the filenames. */ const DEVICE_LABELS: Record = { Heltec_T190: 'Heltec T190', @@ -260,35 +269,70 @@ export function buildDmcConfig(files: GHFile[]): FlasherConfig { // ─── Non-MQTT repeater firmware (GitHub Releases) ─────────────────────────────── -const REPEATER_SEPARATOR = '_repeater-' -const REPEATER_ROLE = { - role: 'dutchmeshcore_repeater', - icon: 'πŸ“‘', - title: 'DutchMeshCore Repeater', - subTitle: 'Repeater (non-MQTT)', -} as const - -const PACKETLOG_ROLE = { - role: 'dutchmeshcore_packetlog', - icon: 'πŸ“', - title: 'DutchMeshCore Repeater PacketLog', - subTitle: 'Repeater + packet logging', -} as const - -// buildDmcRepeaterConfig is shared by the plain repeater and PacketLog groups; these -// options select the maker/role/tag-prefix for each. Their release assets share the -// same `{Device}_repeater-…` filename shape. -interface RepeaterBuildOpts { +interface ReleaseRoleDef { + separator: string + role: string + icon: string + title: string + subTitle: string +} + +const REPEATER_ROLE: ReleaseRoleDef = { + separator: '_repeater-', + role: 'dutchmeshcore_repeater', + icon: 'πŸ“‘', + title: 'DutchMeshCore Repeater', + subTitle: 'Repeater (non-MQTT)', +} + +const PACKETLOG_ROLE: ReleaseRoleDef = { + separator: '_repeater-', + role: 'dutchmeshcore_packetlog', + icon: 'πŸ“', + title: 'DutchMeshCore Repeater PacketLog', + subTitle: 'Repeater + packet logging', +} + +// DMC companion firmware with the receive filter. Role keys are kept distinct from +// meshcomod's `companion_radio_*` roles (see communityFirmwareConfigs.ts). +const COMPANION_ROLES: ReleaseRoleDef[] = [ + { + separator: '_companion_radio_ble-', + role: 'dutchmeshcore_companion_ble', + icon: 'πŸ“»', + title: 'DutchMeshCore Companion', + subTitle: 'BLE + receive filter', + }, + { + separator: '_companion_radio_wifi-', + role: 'dutchmeshcore_companion_wifi', + icon: 'πŸ“Ά', + title: 'DutchMeshCore Companion', + subTitle: 'Wi-Fi + receive filter', + }, +] + +// buildReleaseGroupConfig is shared by the plain repeater, PacketLog and companion +// groups; these options select the maker, the tag prefix and the roles (one per asset +// separator) for each. +interface ReleaseGroupOpts { maker: string makerMeta: { name: string; repo: string; website: string } - role: { role: string; icon: string; title: string; subTitle: string } + roles: ReleaseRoleDef[] tagPrefix: string + /** Drop an app .bin when the same device/role/version also ships a .uf2/.zip (the + * RP2040 companion Wi-Fi builds ship both; the flasher cannot write an RP2040 .bin). */ + preferImage?: boolean } -const REPEATER_OPTS: RepeaterBuildOpts = { - maker: DMC_REPEATER_MAKER, makerMeta: DMC_REPEATER_MAKER_META, role: REPEATER_ROLE, tagPrefix: REPEATER_TAG_PREFIX, +const REPEATER_OPTS: ReleaseGroupOpts = { + maker: DMC_REPEATER_MAKER, makerMeta: DMC_REPEATER_MAKER_META, roles: [REPEATER_ROLE], tagPrefix: REPEATER_TAG_PREFIX, } -const PACKETLOG_OPTS: RepeaterBuildOpts = { - maker: DMC_PACKETLOG_MAKER, makerMeta: DMC_PACKETLOG_MAKER_META, role: PACKETLOG_ROLE, tagPrefix: PACKETLOG_TAG_PREFIX, +const PACKETLOG_OPTS: ReleaseGroupOpts = { + maker: DMC_PACKETLOG_MAKER, makerMeta: DMC_PACKETLOG_MAKER_META, roles: [PACKETLOG_ROLE], tagPrefix: PACKETLOG_TAG_PREFIX, +} +const COMPANION_OPTS: ReleaseGroupOpts = { + maker: DMC_COMPANION_MAKER, makerMeta: DMC_COMPANION_MAKER_META, roles: COMPANION_ROLES, tagPrefix: COMPANION_TAG_PREFIX, + preferImage: true, } interface GHReleaseAsset { @@ -298,6 +342,7 @@ interface GHReleaseAsset { interface GHRelease { tag_name: string + draft?: boolean assets: GHReleaseAsset[] } @@ -310,122 +355,166 @@ function extOf(name: string): 'bin' | 'uf2' | 'zip' | null { /** * Derive the display version from a release tag: dmc-repeater-v1.16.0-dev β†’ 1.16.0-dev. - * A rolling dev tag (`dmc-repeater-dev`, `dmc-repeater-packetlog-dev`) has no semver, so - * the semver comes from the asset filename (`{Device}_repeater-v1.17.1-{hash}.bin`) and is - * labelled `1.17.1-dev`, matching the MQTT `observer-mqtt-dev` builds. + * A rolling dev tag (`dmc-repeater-dev`, `dmc-repeater-packetlog-dev`, `dmc-companion-dev`) + * has no semver, so the semver comes from the asset filename after the role separator + * (`{Device}_repeater-v1.17.1-{hash}.bin`) and is labelled `1.17.1-dev`, matching the + * MQTT `observer-mqtt-dev` builds. Mirrors repeaterVersion() in update-firmware-list.js. */ -function repeaterVersion(tag: string, prefix: string = REPEATER_TAG_PREFIX, assetTail = ''): string { +function repeaterVersion(tag: string, prefix: string, assetName: string, separator: string): string { const fromTag = tag.replace(prefix, '').replace(/^v/, '') if (/^\d+\.\d+\.\d+/.test(fromTag) || !/(?:^|[-_])dev$/i.test(fromTag)) return fromTag - const semver = assetTail.match(/^-v?(\d+\.\d+\.\d+)/)?.[1] + const sepIdx = assetName.indexOf(separator) + const tail = sepIdx < 0 ? '' : assetName.slice(sepIdx + separator.length) + const semver = tail.match(/^v?(\d+\.\d+\.\d+)/)?.[1] return semver ? `${semver}-dev` : fromTag } /** - * Build a FlasherConfig from `dmc-repeater-*` GitHub Releases. Filenames carry no - * semver (`{Device}_repeater-dev-{hash}[-merged].{ext}`) so the version comes from - * the release tag. Handles esp32 (.bin app/merged) and nRF52 (.uf2/.zip) devices. - * `opts` selects the maker/role/tag-prefix (repeater by default; PacketLog reuses it). + * Build a FlasherConfig for one maker group from its GitHub Releases. The version + * comes from the release tag (see repeaterVersion). Handles esp32 (.bin app/merged) + * and nRF52 (.uf2/.zip) devices; each matching role becomes one firmware entry. */ -export function buildDmcRepeaterConfig(releases: GHRelease[], opts: RepeaterBuildOpts = REPEATER_OPTS): FlasherConfig { +function buildReleaseGroupConfig(releases: GHRelease[], opts: ReleaseGroupOpts): FlasherConfig { type Variant = { type: 'esp32' | 'nrf52'; appUrl: string; mergedUrl: string } - // deviceKey -> versionKey -> Variant - const deviceMap = new Map>() - - const setVariant = (deviceKey: string, versionKey: string, patch: Partial & { type: Variant['type'] }) => { - if (!deviceMap.has(deviceKey)) deviceMap.set(deviceKey, new Map()) - const versions = deviceMap.get(deviceKey)! - const entry = versions.get(versionKey) ?? { type: patch.type, appUrl: '', mergedUrl: '' } - versions.set(versionKey, { ...entry, ...patch }) - } + type ParsedAsset = { deviceKey: string; role: string; versionKey: string; ext: 'bin' | 'uf2' | 'zip'; isMerged: boolean; url: string } + const parsed: ParsedAsset[] = [] for (const release of releases) { for (const asset of release.assets) { const ext = extOf(asset.name) if (!ext) continue - const sepIdx = asset.name.indexOf(REPEATER_SEPARATOR) - if (sepIdx < 0) continue - - const deviceKey = asset.name.slice(0, sepIdx) - const versionKey = repeaterVersion(release.tag_name, opts.tagPrefix, - asset.name.slice(sepIdx + REPEATER_SEPARATOR.length - 1)) - const url = asset.browser_download_url - - if (ext === 'bin') { - const isMerged = asset.name.endsWith('-merged.bin') - setVariant(deviceKey, versionKey, isMerged - ? { type: 'esp32', mergedUrl: url } - : { type: 'esp32', appUrl: url }) - } else { - // nRF52: single file per version, prefer .zip (DFU) over .uf2 - const existing = deviceMap.get(deviceKey)?.get(versionKey) - if (!existing || (ext === 'zip' && existing.appUrl.endsWith('.uf2'))) { - setVariant(deviceKey, versionKey, { type: 'nrf52', appUrl: url }) - } - } + const roleDef = opts.roles.find(r => asset.name.includes(r.separator)) + if (!roleDef) continue + parsed.push({ + deviceKey: asset.name.slice(0, asset.name.indexOf(roleDef.separator)), + role: roleDef.role, + versionKey: repeaterVersion(release.tag_name, opts.tagPrefix, asset.name, roleDef.separator), + ext, + isMerged: asset.name.endsWith(`-merged.${ext}`), + url: asset.browser_download_url, + }) + } + } + + const imageKey = (a: ParsedAsset) => `${a.deviceKey}/${a.role}/${a.versionKey}` + const hasImage = new Set(opts.preferImage ? parsed.filter(a => a.ext !== 'bin').map(imageKey) : []) + const assets = parsed.filter(a => a.ext !== 'bin' || !hasImage.has(imageKey(a))) + + // deviceKey -> role -> versionKey -> Variant + const deviceMap = new Map>>() + + for (const a of assets) { + if (!deviceMap.has(a.deviceKey)) deviceMap.set(a.deviceKey, new Map()) + const roles = deviceMap.get(a.deviceKey)! + if (!roles.has(a.role)) roles.set(a.role, new Map()) + const versions = roles.get(a.role)! + const existing = versions.get(a.versionKey) + + if (a.ext === 'bin') { + const entry = existing ?? { type: 'esp32' as const, appUrl: '', mergedUrl: '' } + versions.set(a.versionKey, a.isMerged + ? { ...entry, type: 'esp32', mergedUrl: a.url } + : { ...entry, type: 'esp32', appUrl: a.url }) + } else if (!existing || (a.ext === 'zip' && existing.appUrl.endsWith('.uf2'))) { + // nRF52: single file per version, prefer .zip (DFU) over .uf2 + versions.set(a.versionKey, { ...(existing ?? { mergedUrl: '' }), type: 'nrf52', appUrl: a.url }) } } const devices: FlasherDevice[] = [...deviceMap.entries()] - .map(([deviceKey, versionMap]) => { - const deviceType = [...versionMap.values()][0]?.type ?? 'esp32' - const version: FlasherDevice['firmware'][number]['version'] = {} - - for (const [versionKey, entry] of [...versionMap.entries()].sort(([a], [b]) => b.localeCompare(a))) { - if (entry.type === 'esp32') { - if (entry.appUrl) { - version[`${versionKey} - App update`] = { - files: [{ type: 'flash-update', name: entry.appUrl, title: 'App update - keeps bootloader, partition table & config' }], - notes: 'Updates firmware only. Bootloader and saved settings (pubkey, config) are preserved.', - } - } - if (entry.mergedUrl) { - version[`${versionKey} - Full flash`] = { - files: [{ type: 'flash-wipe', name: entry.mergedUrl, title: 'Full flash - merged bin (bootloader + partition + app)' }], - notes: 'Flashes the complete merged binary to 0x0. Use for new devices or factory resets. ⚠ Overwrites all existing firmware.', + .map(([deviceKey, roleMap]) => { + const deviceType = [...([...roleMap.values()][0]?.values() ?? [])][0]?.type ?? 'esp32' + + const firmware = opts.roles + .filter((roleDef, i, all) => all.findIndex(r => r.role === roleDef.role) === i) + .filter(roleDef => roleMap.has(roleDef.role)) + .map(roleDef => { + const versionMap = roleMap.get(roleDef.role)! + const version: FlasherDevice['firmware'][number]['version'] = {} + + for (const [versionKey, entry] of [...versionMap.entries()].sort(([a], [b]) => b.localeCompare(a))) { + if (entry.type === 'esp32') { + if (entry.appUrl) { + version[`${versionKey} - App update`] = { + files: [{ type: 'flash-update', name: entry.appUrl, title: 'App update - keeps bootloader, partition table & config' }], + notes: 'Updates firmware only. Bootloader and saved settings (pubkey, config) are preserved.', + } + } + if (entry.mergedUrl) { + version[`${versionKey} - Full flash`] = { + files: [{ type: 'flash-wipe', name: entry.mergedUrl, title: 'Full flash - merged bin (bootloader + partition + app)' }], + notes: 'Flashes the complete merged binary to 0x0. Use for new devices or factory resets. ⚠ Overwrites all existing firmware.', + } + } + } else { + const fileType = entry.appUrl.endsWith('.zip') ? 'nrf-dfu-zip' : 'uf2' + version[versionKey] = { + files: [{ type: fileType, name: entry.appUrl, title: 'Firmware update' }], + } } } - } else { - const fileType = entry.appUrl.endsWith('.zip') ? 'nrf-dfu-zip' : 'uf2' - version[versionKey] = { - files: [{ type: fileType, name: entry.appUrl, title: 'Firmware update' }], + + return { + role: roleDef.role, + tooltip: deviceType === 'esp32' + ? 'App update keeps bootloader & settings. Full flash is for new or factory-reset devices.' + : undefined, + version, } - } - } + }) return { maker: opts.maker, class: 'community' as const, name: DEVICE_LABELS[deviceKey] ?? deviceKey.replace(/_/g, ' '), type: deviceType, - firmware: [{ - role: opts.role.role, - tooltip: deviceType === 'esp32' - ? 'App update keeps bootloader & settings. Full flash is for new or factory-reset devices.' - : undefined, - version, - }], + firmware, } }) .sort((a, b) => a.name.localeCompare(b.name)) return { staticPath: '', - role: { [opts.role.role]: { icon: opts.role.icon, title: opts.role.title, subTitle: opts.role.subTitle } }, + role: Object.fromEntries( + opts.roles.map(({ role, icon, title, subTitle }) => [role, { icon, title, subTitle }]) + ), notice: {}, maker: { [opts.maker]: opts.makerMeta }, device: devices, } } +/** + * Build a FlasherConfig from `dmc-repeater-*` GitHub Releases. Filenames carry no + * semver (`{Device}_repeater-dev-{hash}[-merged].{ext}`) so the version comes from + * the release tag. Handles esp32 (.bin app/merged) and nRF52 (.uf2/.zip) devices. + */ +export function buildDmcRepeaterConfig(releases: GHRelease[]): FlasherConfig { + return buildReleaseGroupConfig(releases, REPEATER_OPTS) +} + /** PacketLog repeater firmware (`dmc-repeater-packetlog-*` releases) – same builder, * own maker/role/tag-prefix. Rendered as a separate group in the flasher. */ export function buildDmcPacketlogConfig(releases: GHRelease[]): FlasherConfig { - return buildDmcRepeaterConfig(releases, PACKETLOG_OPTS) + return buildReleaseGroupConfig(releases, PACKETLOG_OPTS) +} + +/** + * DMC companion firmware with the receive filter (`dmc-companion-*` releases: the + * rolling `dmc-companion-dev` prerelease and versioned `dmc-companion-vX.Y.Z`). Assets + * are `{Device}_companion_radio_ble-…` / `{Device}_companion_radio_wifi-…`, one role + * each. Releases with another tag prefix and drafts are ignored. Mirrors + * dmcCompanionConfigFromReleases() in update-firmware-list.js. + */ +export function buildDmcCompanionConfig(releases: GHRelease[]): FlasherConfig { + return buildReleaseGroupConfig( + releases.filter(r => r.tag_name.startsWith(COMPANION_TAG_PREFIX) && !r.draft), + COMPANION_OPTS, + ) } /** - * Combine DMC configs (repeater, PacketLog, MQTT) into one FlasherConfig carrying every + * Combine DMC configs (repeater, PacketLog, MQTT, companion) into one FlasherConfig carrying every * maker - they render as separate groups (repeater first) in the flasher. */ export function mergeDmcConfigs(...configs: FlasherConfig[]): FlasherConfig { @@ -476,7 +565,8 @@ export async function fetchDmcConfig(): Promise { // Fall back to the live GitHub API (may hit rate limits unauthenticated). All DMC // firmware lives in MeshCore releases: `mqtt`-tagged releases feed the MQTT bridge // config; `dmc-repeater-*` releases feed the non-MQTT repeater config; and the - // `dmc-repeater-packetlog-*` subset feeds the PacketLog config. + // `dmc-repeater-packetlog-*` subset feeds the PacketLog config; `dmc-companion-*` + // releases feed the companion (receive filter) config. const releases = await fetchMeshcoreReleases() const mqttFiles: GHFile[] = releases @@ -497,6 +587,7 @@ export async function fetchDmcConfig(): Promise { buildDmcRepeaterConfig(repeaterReleases), buildDmcPacketlogConfig(packetlogReleases), buildDmcConfig(mqttFiles), + buildDmcCompanionConfig(releases), ) writeCache(DMC_CACHE_KEY, config) return config