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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c014c4b3ee
commit
7b36b5cc34
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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()]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Best-effort mapping from a detected serial port's USB identity to a board
|
||||
* image (SVGs vendored from the Meshtastic web-flasher, GPL-3.0 —
|
||||
* github.com/meshtastic/web-flasher, public/img/devices/).
|
||||
*
|
||||
* Native-USB boards (T-Deck, RAK4631, T1000-E…) report their name in the USB
|
||||
* product string, so those match precisely. UART-bridge boards only identify
|
||||
* the bridge chip (CP2102/CH340), so vid:pid picks the most common board for
|
||||
* that chip and the label stays honest ("LoRa radio").
|
||||
*/
|
||||
|
||||
export interface DetectedDeviceInfo {
|
||||
path: string
|
||||
vid?: string | null
|
||||
pid?: string | null
|
||||
product?: string | null
|
||||
manufacturer?: string | null
|
||||
}
|
||||
|
||||
export interface MeshDeviceImage {
|
||||
/** Path under public/ */
|
||||
image: string
|
||||
/** Human name shown in the modal */
|
||||
label: string
|
||||
/** True when the match is exact (product string), false for chip-level guesses */
|
||||
exact: boolean
|
||||
}
|
||||
|
||||
const IMG = '/assets/img/mesh-devices'
|
||||
|
||||
/** product-string keyword → image (checked in order, case-insensitive) */
|
||||
const PRODUCT_MATCHES: Array<{ match: RegExp; image: string; label: string }> = [
|
||||
{ match: /t-?deck/i, image: `${IMG}/t-deck.svg`, label: 'LILYGO T-Deck' },
|
||||
{ match: /t-?echo\s*plus/i, image: `${IMG}/t-echo_plus.svg`, label: 'LILYGO T-Echo Plus' },
|
||||
{ match: /t-?echo/i, image: `${IMG}/t-echo.svg`, label: 'LILYGO T-Echo' },
|
||||
{ match: /t-?beam\s*s3/i, image: `${IMG}/tbeam-s3-core.svg`, label: 'LILYGO T-Beam S3' },
|
||||
{ match: /t-?beam/i, image: `${IMG}/tbeam.svg`, label: 'LILYGO T-Beam' },
|
||||
{ match: /t3.?s3/i, image: `${IMG}/tlora-t3s3-v1.svg`, label: 'LILYGO T3-S3' },
|
||||
{ match: /t-?lora|tlora/i, image: `${IMG}/tlora-v2-1-1_6.svg`, label: 'LILYGO T-LoRa' },
|
||||
{ match: /rak.?4631|wisblock/i, image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631' },
|
||||
{ match: /wismesh|wistap/i, image: `${IMG}/rak-wismeshtap.svg`, label: 'RAK WisMesh Tap' },
|
||||
{ match: /t1000|tracker.?t1000/i, image: `${IMG}/tracker-t1000-e.svg`, label: 'Seeed Card Tracker T1000-E' },
|
||||
{ match: /xiao/i, image: `${IMG}/seeed-xiao-s3.svg`, label: 'Seeed XIAO S3' },
|
||||
{ match: /wio.?tracker|wm1110/i, image: `${IMG}/wio-tracker-wm1110.svg`, label: 'Seeed Wio Tracker' },
|
||||
{ match: /station\s*g2/i, image: `${IMG}/station-g2.svg`, label: 'B&Q Station G2' },
|
||||
{ match: /nano\s*g2/i, image: `${IMG}/nano-g2-ultra.svg`, label: 'B&Q Nano G2 Ultra' },
|
||||
{ match: /t114/i, image: `${IMG}/heltec-mesh-node-t114.svg`, label: 'Heltec Mesh Node T114' },
|
||||
{ match: /wireless\s*stick|wsl/i, image: `${IMG}/heltec-wsl-v3.svg`, label: 'Heltec Wireless Stick Lite V3' },
|
||||
{ match: /heltec.*v4/i, image: `${IMG}/heltec_v4.svg`, label: 'Heltec V4' },
|
||||
{ match: /heltec/i, image: `${IMG}/heltec-v3.svg`, label: 'Heltec LoRa 32 V3' },
|
||||
{ match: /thinknode/i, image: `${IMG}/thinknode_m1.svg`, label: 'Elecrow ThinkNode' },
|
||||
{ match: /pico/i, image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico' },
|
||||
{ match: /rnode/i, image: `${IMG}/diy.svg`, label: 'RNode' },
|
||||
]
|
||||
|
||||
/** vid:pid → most-likely board (bridge chips = chip-level guess) */
|
||||
const VIDPID_MATCHES: Record<string, { image: string; label: string; exact: boolean }> = {
|
||||
// Silicon Labs CP210x — Heltec V3 family ships this bridge
|
||||
'10c4:ea60': { image: `${IMG}/heltec-v3.svg`, label: 'LoRa radio (CP2102 serial)', exact: false },
|
||||
// WCH CH340 — most T-Beam / T-LoRa boards
|
||||
'1a86:7523': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH340 serial)', exact: false },
|
||||
'1a86:55d4': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH9102 serial)', exact: false },
|
||||
// Espressif native USB (S3 boards: T3-S3, T-Deck without product str)
|
||||
'303a:1001': { image: `${IMG}/tlora-t3s3-v1.svg`, label: 'ESP32-S3 LoRa board', exact: false },
|
||||
// RAK4631 nRF52 native USB
|
||||
'239a:8029': { image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631', exact: true },
|
||||
// Raspberry Pi (Pico W)
|
||||
'2e8a:0005': { image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico', exact: false },
|
||||
}
|
||||
|
||||
const FALLBACK: MeshDeviceImage = {
|
||||
image: `${IMG}/unknown-new.svg`,
|
||||
label: 'LoRa mesh radio',
|
||||
exact: false,
|
||||
}
|
||||
|
||||
export function resolveMeshDeviceImage(info: DetectedDeviceInfo | undefined): MeshDeviceImage {
|
||||
if (!info) return FALLBACK
|
||||
const product = `${info.manufacturer ?? ''} ${info.product ?? ''}`.trim()
|
||||
if (product) {
|
||||
for (const m of PRODUCT_MATCHES) {
|
||||
if (m.match.test(product)) return { image: m.image, label: m.label, exact: true }
|
||||
}
|
||||
}
|
||||
if (info.vid && info.pid) {
|
||||
const hit = VIDPID_MATCHES[`${info.vid}:${info.pid}`.toLowerCase()]
|
||||
if (hit) return hit
|
||||
}
|
||||
return FALLBACK
|
||||
}
|
||||
Reference in New Issue
Block a user