feat(mesh): device-detected setup modal with board images + full radio settings
Plugging in a LoRa radio now pops a global two-step setup modal on any
page: step 1 shows the ACTUAL detected board (25 board SVGs vendored
from the Meshtastic web-flasher, GPL-3.0; matched by USB product string
on native-USB boards, vid:pid heuristics for bridge chips, animated
fallback otherwise); step 2 configures with Archipelago presets — the
LoRa region pre-selected from the node's location (new lat/lon→region
mapping), channel 'archipelago', node name, and an advanced firmware
pin. Options adapt per protocol: region+channel program Meshtastic;
MeshCore shows its community frequency plan (firmware owns RF); RNode
defers to the Reticulum daemon config.
Backend: mesh.configure now accepts lora_region (validated against the
driver's region table) and device_kind (probe pin); mesh.status returns
the persisted values plus per-port USB identity (sysfs vid/pid/product)
for board identification. The Mesh Device tab gains a full settings
form (region with duty-cycle/legality hints, firmware pin, channel,
name, identity broadcast) replacing the read-only panel; the old
Mesh-page-only onboarding modal is superseded by the global flow.
Mock backend: stateful mesh config so the dev UI demos the full
detect→configure round-trip (disable mesh → modal fires with Heltec V3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 08:40:18 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* LoRa regional frequency plans — the canonical region list the backend's
|
|
|
|
|
|
* Meshtastic driver can program (core/.../mesh/meshtastic.rs region table),
|
|
|
|
|
|
* annotated with band / duty-cycle / power constraints from the Meshtastic
|
|
|
|
|
|
* firmware `regions[]` table so the UI can surface legality hints.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Also used to derive suggested radio parameters for MeshCore and RNode
|
|
|
|
|
|
* presets (those firmwares take raw frequency/BW/SF/CR, not a region enum).
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
export interface LoraRegion {
|
|
|
|
|
|
/** Wire value for mesh.configure { lora_region } */
|
|
|
|
|
|
code: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
/** Frequency range, MHz (display) */
|
|
|
|
|
|
band: string
|
|
|
|
|
|
/** Regional duty-cycle cap, percent (100 = none) */
|
|
|
|
|
|
dutyCyclePct: number
|
|
|
|
|
|
/** Max TX power, dBm */
|
|
|
|
|
|
maxPowerDbm: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const LORA_REGIONS: LoraRegion[] = [
|
|
|
|
|
|
{ code: 'US', label: 'United States / Americas (915 MHz)', band: '902–928', dutyCyclePct: 100, maxPowerDbm: 30 },
|
|
|
|
|
|
{ code: 'EU_868', label: 'Europe (868 MHz)', band: '869.4–869.65', dutyCyclePct: 10, maxPowerDbm: 27 },
|
|
|
|
|
|
{ code: 'EU_433', label: 'Europe (433 MHz)', band: '433–434', dutyCyclePct: 10, maxPowerDbm: 10 },
|
|
|
|
|
|
{ code: 'ANZ', label: 'Australia / New Zealand (915 MHz)', band: '915–928', dutyCyclePct: 100, maxPowerDbm: 30 },
|
|
|
|
|
|
{ code: 'ANZ_433', label: 'Australia / New Zealand (433 MHz)', band: '433.05–434.79', dutyCyclePct: 100, maxPowerDbm: 14 },
|
|
|
|
|
|
{ code: 'NZ_865', label: 'New Zealand (865 MHz)', band: '864–868', dutyCyclePct: 100, maxPowerDbm: 36 },
|
|
|
|
|
|
{ code: 'CN', label: 'China (470 MHz)', band: '470–510', dutyCyclePct: 100, maxPowerDbm: 19 },
|
|
|
|
|
|
{ code: 'JP', label: 'Japan (920 MHz)', band: '920.5–923.5', dutyCyclePct: 100, maxPowerDbm: 13 },
|
|
|
|
|
|
{ code: 'KR', label: 'South Korea (920 MHz)', band: '920–923', dutyCyclePct: 100, maxPowerDbm: 23 },
|
|
|
|
|
|
{ code: 'TW', label: 'Taiwan (920 MHz)', band: '920–925', dutyCyclePct: 100, maxPowerDbm: 27 },
|
|
|
|
|
|
{ code: 'RU', label: 'Russia (868 MHz)', band: '868.7–869.2', dutyCyclePct: 100, maxPowerDbm: 20 },
|
|
|
|
|
|
{ code: 'IN', label: 'India (865 MHz)', band: '865–867', dutyCyclePct: 100, maxPowerDbm: 30 },
|
|
|
|
|
|
{ code: 'TH', label: 'Thailand (920 MHz)', band: '920–925', dutyCyclePct: 10, maxPowerDbm: 27 },
|
|
|
|
|
|
{ code: 'UA_868', label: 'Ukraine (868 MHz)', band: '868–868.6', dutyCyclePct: 1, maxPowerDbm: 14 },
|
|
|
|
|
|
{ code: 'UA_433', label: 'Ukraine (433 MHz)', band: '433–434.7', dutyCyclePct: 10, maxPowerDbm: 10 },
|
|
|
|
|
|
{ code: 'MY_919', label: 'Malaysia (919 MHz)', band: '919–924', dutyCyclePct: 100, maxPowerDbm: 27 },
|
|
|
|
|
|
{ code: 'MY_433', label: 'Malaysia (433 MHz)', band: '433–435', dutyCyclePct: 100, maxPowerDbm: 20 },
|
|
|
|
|
|
{ code: 'SG_923', label: 'Singapore (923 MHz)', band: '917–925', dutyCyclePct: 100, maxPowerDbm: 20 },
|
|
|
|
|
|
{ code: 'PH_915', label: 'Philippines (915 MHz)', band: '915–918', dutyCyclePct: 100, maxPowerDbm: 24 },
|
|
|
|
|
|
{ code: 'PH_868', label: 'Philippines (868 MHz)', band: '868–869.4', dutyCyclePct: 100, maxPowerDbm: 14 },
|
|
|
|
|
|
{ code: 'PH_433', label: 'Philippines (433 MHz)', band: '433–434.7', dutyCyclePct: 100, maxPowerDbm: 10 },
|
|
|
|
|
|
{ code: 'LORA_24', label: 'Worldwide (2.4 GHz, SX1280 only)', band: '2400–2483.5', dutyCyclePct: 100, maxPowerDbm: 10 },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
export function regionByCode(code: string | null | undefined): LoraRegion | undefined {
|
|
|
|
|
|
if (!code) return undefined
|
|
|
|
|
|
return LORA_REGIONS.find(r => r.code === code.toUpperCase())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Coarse lat/lon → LoRa region suggestion. Bounding boxes, most-specific
|
|
|
|
|
|
* first — good enough to preselect the dropdown; the user confirms.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function suggestRegionFromLatLon(lat: number, lon: number): LoraRegion | undefined {
|
|
|
|
|
|
const boxes: Array<{ code: string; latMin: number; latMax: number; lonMin: number; lonMax: number }> = [
|
|
|
|
|
|
{ code: 'JP', latMin: 24, latMax: 46, lonMin: 123, lonMax: 146 },
|
|
|
|
|
|
{ code: 'KR', latMin: 33, latMax: 39, lonMin: 124, lonMax: 132 },
|
|
|
|
|
|
{ code: 'TW', latMin: 21.5, latMax: 25.5, lonMin: 119.5, lonMax: 122.5 },
|
|
|
|
|
|
{ code: 'PH_915', latMin: 4, latMax: 21, lonMin: 116, lonMax: 127 },
|
|
|
|
|
|
{ code: 'SG_923', latMin: 1, latMax: 1.6, lonMin: 103.5, lonMax: 104.2 },
|
|
|
|
|
|
{ code: 'MY_919', latMin: 0.8, latMax: 7.5, lonMin: 99, lonMax: 119.5 },
|
|
|
|
|
|
{ code: 'TH', latMin: 5.5, latMax: 20.5, lonMin: 97, lonMax: 106 },
|
|
|
|
|
|
{ code: 'IN', latMin: 6, latMax: 36, lonMin: 68, lonMax: 97.5 },
|
|
|
|
|
|
{ code: 'CN', latMin: 18, latMax: 54, lonMin: 73, lonMax: 135 },
|
|
|
|
|
|
{ code: 'UA_868', latMin: 44, latMax: 52.5, lonMin: 22, lonMax: 40.5 },
|
|
|
|
|
|
{ code: 'RU', latMin: 41, latMax: 82, lonMin: 27, lonMax: 180 },
|
|
|
|
|
|
{ code: 'ANZ', latMin: -48, latMax: -9, lonMin: 112, lonMax: 180 },
|
|
|
|
|
|
{ code: 'EU_868', latMin: 34, latMax: 72, lonMin: -25, lonMax: 45 },
|
|
|
|
|
|
// Americas
|
|
|
|
|
|
{ code: 'US', latMin: -56, latMax: 72, lonMin: -170, lonMax: -30 },
|
|
|
|
|
|
]
|
|
|
|
|
|
for (const b of boxes) {
|
|
|
|
|
|
if (lat >= b.latMin && lat <= b.latMax && lon >= b.lonMin && lon <= b.lonMax) {
|
|
|
|
|
|
return regionByCode(b.code)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Community MeshCore radio plan for a region, where one is well established.
|
|
|
|
|
|
* MeshCore has no region enum — the firmware takes raw freq/BW/SF/CR — so
|
|
|
|
|
|
* these are display-level suggestions (EU is the verified community default;
|
|
|
|
|
|
* other regions show the legal band and defer to the local community plan). */
|
|
|
|
|
|
export interface MeshcorePlan {
|
|
|
|
|
|
freqMhz: number
|
|
|
|
|
|
bwKhz: number
|
|
|
|
|
|
sf: number
|
|
|
|
|
|
cr: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const MESHCORE_PLANS: Record<string, MeshcorePlan> = {
|
|
|
|
|
|
EU_868: { freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
|
|
|
|
|
|
EU_433: { freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function meshcorePlanFor(code: string | null | undefined): MeshcorePlan | undefined {
|
|
|
|
|
|
if (!code) return undefined
|
|
|
|
|
|
return MESHCORE_PLANS[code.toUpperCase()]
|
|
|
|
|
|
}
|
2026-07-21 08:36:59 -04:00
|
|
|
|
|
|
|
|
|
|
/** A named MeshCore RF preset for the Device-panel dropdown. */
|
|
|
|
|
|
export interface MeshcoreRfPreset {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
freqMhz: number
|
|
|
|
|
|
bwKhz: number
|
|
|
|
|
|
sf: number
|
|
|
|
|
|
cr: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Prepopulated MeshCore RF plans by country/region for the Device panel's
|
|
|
|
|
|
* preset dropdown ("Custom" in the UI unlocks free entry). Values are only
|
|
|
|
|
|
* added here when verifiable: the MeshCore community plans above, the
|
|
|
|
|
|
* MeshCore firmware's own build defaults (platformio.ini `arduino_base`:
|
|
|
|
|
|
* 869.618/62.5/SF8, CR default 5; companion example: 915.0/250/SF10/CR5),
|
|
|
|
|
|
* and operator-attested deployment plans.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [
|
|
|
|
|
|
{ id: 'eu_868', label: 'Europe / UK — 869.525 MHz, 250 kHz, SF11, CR4/5', freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
|
|
|
|
|
|
{ id: 'pt', label: 'Portugal — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 },
|
|
|
|
|
|
{ id: 'fw_default', label: 'MeshCore firmware default — 869.618 MHz, 62.5 kHz, SF8, CR4/5', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 5 },
|
|
|
|
|
|
{ id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 },
|
|
|
|
|
|
{ id: 'eu_433', label: 'Europe (433 MHz) — 433.65 MHz, 250 kHz, SF11, CR4/5', freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
|
|
|
|
|
|
]
|