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