diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index cccc8715..61cd976f 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -145,6 +145,32 @@ impl RpcHandler { { config.receive_block_headers = receive; } + // LoRa region (Meshtastic): validated against the driver's region + // table so a typo can't be persisted and silently ignored on connect. + // Empty string clears the setting (radio keeps/uses its own region). + if let Some(region) = params.get("lora_region").and_then(|v| v.as_str()) { + let trimmed = region.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") { + config.lora_region = None; + } else if mesh::meshtastic_region_is_valid(trimmed) { + config.lora_region = Some(trimmed.to_uppercase()); + } else { + anyhow::bail!("Unknown LoRa region: {trimmed}"); + } + } + // Firmware pin: probe only the named firmware on the port ("auto"/"" + // clears the pin and restores strict-probe auto-detect). + if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) { + config.device_kind = match kind.trim().to_lowercase().as_str() { + "" | "auto" => None, + "meshcore" => Some(mesh::types::DeviceType::Meshcore), + "meshtastic" => Some(mesh::types::DeviceType::Meshtastic), + "reticulum" | "rnode" => Some(mesh::types::DeviceType::Reticulum), + other => anyhow::bail!( + "Unknown device_kind: {other} (expected auto|meshcore|meshtastic|reticulum)" + ), + }; + } mesh::save_config(&self.config.data_dir, &config).await?; @@ -161,6 +187,8 @@ impl RpcHandler { "device_path": config.device_path, "announce_block_headers": config.announce_block_headers, "receive_block_headers": config.receive_block_headers, + "lora_region": config.lora_region, + "device_kind": config.device_kind.map(|k| k.to_string()), })) } } diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 71fba9b9..fd8c2813 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -37,6 +37,24 @@ impl RpcHandler { "receive_block_headers".into(), config.receive_block_headers.into(), ); + // Persisted config values the settings UI edits (distinct from the + // live radio-reported `region`): the configured LoRa region and + // the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto). + obj.insert("lora_region".into(), config.lora_region.clone().into()); + obj.insert( + "device_kind".into(), + config + .device_kind + .map(|k| k.to_string().to_lowercase()) + .into(), + ); + // USB identity per detected port so the setup modal can show the + // actual board (product string on native-USB boards, vid:pid as + // the fallback for bridge chips). + obj.insert( + "detected_device_info".into(), + serde_json::to_value(mesh::detect_devices_info().await).unwrap_or_default(), + ); // Raw serial-device presence, in BOTH branches. MeshStatus has no // such field, so while the service was running the UI couldn't // tell "no radio plugged in" from "radio present but the session diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index bebb461b..c57bf3af 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -1309,7 +1309,7 @@ fn derive_channel_psk(channel_name: &str) -> Vec { /// Map a Meshtastic `RegionCode` name (as set in `mesh-config.json`, e.g. /// "EU_868", "US", "ANZ") to its protobuf enum value. Case-insensitive. /// Returns `None` for an unrecognised name so we never write a bogus region. -fn region_name_to_code(name: &str) -> Option { +pub(crate) fn region_name_to_code(name: &str) -> Option { Some(match name.trim().to_uppercase().as_str() { "UNSET" => 0, "US" => 1, diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 99788234..d0ee096a 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -401,6 +401,12 @@ pub struct MeshConfig { pub reticulum_tcp: Option, } +/// True when `name` is a LoRa region the Meshtastic driver can program +/// (the canonical region list for the mesh settings UI). +pub fn meshtastic_region_is_valid(name: &str) -> bool { + meshtastic::region_name_to_code(name).is_some() +} + fn default_assistant_backend() -> String { "claude".to_string() } @@ -545,6 +551,12 @@ pub async fn detect_devices() -> Vec { serial::detect_serial_devices().await } +/// Detected serial ports with USB identity metadata (for the device-detected +/// setup modal's hardware graphic). +pub async fn detect_devices_info() -> Vec { + serial::detect_serial_devices_info().await +} + // ─── MeshService ──────────────────────────────────────────────────────── /// Top-level mesh networking service. diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index 5358ff0f..e9106d5e 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -509,6 +509,83 @@ pub async fn detect_serial_devices() -> Vec { devices } +/// USB identity of a detected serial port, read from sysfs — lets the UI +/// show the actual board (native-USB boards like T-Deck/RAK4631 report their +/// name in `product`; bridge chips like CP2102/CH340 only identify the chip, +/// so vid:pid is the fallback signal). +#[derive(Debug, Clone, serde::Serialize)] +pub struct DetectedDeviceInfo { + pub path: String, + pub vid: Option, + pub pid: Option, + pub product: Option, + pub manufacturer: Option, +} + +/// Like `detect_serial_devices`, but with USB metadata per port. +pub async fn detect_serial_devices_info() -> Vec { + let mut out = Vec::new(); + for path in detect_serial_devices().await { + let usb = usb_info_for_tty(&path).await; + out.push(DetectedDeviceInfo { + path, + vid: usb.0, + pid: usb.1, + product: usb.2, + manufacturer: usb.3, + }); + } + out +} + +/// Resolve a tty path (following the /dev/mesh-radio symlink) to its USB +/// device sysfs node and read idVendor/idProduct/product/manufacturer. +/// Best-effort: any miss returns None fields (e.g. non-USB UARTs). +async fn usb_info_for_tty( + path: &str, +) -> ( + Option, + Option, + Option, + Option, +) { + let resolved = tokio::fs::canonicalize(path) + .await + .unwrap_or_else(|_| std::path::PathBuf::from(path)); + let Some(name) = resolved.file_name().and_then(|n| n.to_str()) else { + return (None, None, None, None); + }; + // /sys/class/tty//device -> .../usbN/N-M/N-M:1.0/(ttyUSBx|tty). Walk + // up from the device link until a directory with idVendor appears. + let mut dir = std::path::PathBuf::from(format!("/sys/class/tty/{name}/device")); + for _ in 0..6 { + if tokio::fs::metadata(dir.join("idVendor")).await.is_ok() { + let read = |f: &str| { + let p = dir.join(f); + async move { + tokio::fs::read_to_string(p) + .await + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + }; + return ( + read("idVendor").await, + read("idProduct").await, + read("product").await, + read("manufacturer").await, + ); + } + dir.push(".."); + let Ok(canon) = tokio::fs::canonicalize(&dir).await else { + break; + }; + dir = canon; + } + (None, None, None, None) +} + /// Try to open and handshake with each detected serial device. /// Returns the first device that responds as Meshcore. pub async fn probe_for_meshcore(paths: &[String]) -> Option<(String, DeviceInfo)> { diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 313557b2..262ac4cd 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -2632,20 +2632,32 @@ app.post('/rpc/v1', (req, res) => { // ===================================================================== case 'mesh.status': { globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true } + // Stateful enable/disable so the dev UI can demo the global + // "mesh device detected" setup modal: disable mesh on the Mesh page + // and the modal fires (device present but not connected). + globalThis.__meshCfg ||= { enabled: true, lora_region: 'EU_868', device_kind: null, channel_name: 'archipelago', advert_name: 'archy-228' } + const mc = globalThis.__meshCfg return res.json({ result: { - enabled: true, - device_type: 'Meshcore', + enabled: mc.enabled, + device_type: mc.enabled ? 'Meshcore' : 'unknown', device_path: '/dev/ttyUSB0', - device_connected: true, - firmware_version: '2.3.1', - self_node_id: 42, - self_advert_name: 'archy-228', - peer_count: 4, - channel_name: 'archipelago', + device_connected: mc.enabled, + firmware_version: mc.enabled ? '2.3.1' : null, + self_node_id: mc.enabled ? 42 : null, + self_advert_name: mc.advert_name, + peer_count: mc.enabled ? 4 : 0, + channel_name: mc.channel_name, messages_sent: 23, messages_received: 47, detected_devices: ['/dev/ttyUSB0'], + device_present: true, + detected_device_info: [ + { path: '/dev/ttyUSB0', vid: '10c4', pid: 'ea60', product: 'HELTEC LoRa 32 V3', manufacturer: 'Heltec' }, + ], + lora_region: mc.lora_region, + device_kind: mc.device_kind, + region: mc.enabled ? mc.lora_region : null, announce_block_headers: globalThis.__meshHeaders.announce_block_headers, receive_block_headers: globalThis.__meshHeaders.receive_block_headers, }, @@ -2756,7 +2768,16 @@ app.post('/rpc/v1', (req, res) => { globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true } if (params && typeof params.announce_block_headers === 'boolean') globalThis.__meshHeaders.announce_block_headers = params.announce_block_headers if (params && typeof params.receive_block_headers === 'boolean') globalThis.__meshHeaders.receive_block_headers = params.receive_block_headers - return res.json({ result: { configured: true, ...globalThis.__meshHeaders } }) + // Stateful radio settings (see mesh.status) — lets the dev UI demo + // the detected-device modal + the Device panel settings round-trip. + globalThis.__meshCfg ||= { enabled: true, lora_region: 'EU_868', device_kind: null, channel_name: 'archipelago', advert_name: 'archy-228' } + const cfg = globalThis.__meshCfg + if (params && typeof params.enabled === 'boolean') cfg.enabled = params.enabled + if (params && typeof params.lora_region === 'string') cfg.lora_region = params.lora_region || null + if (params && typeof params.device_kind === 'string') cfg.device_kind = params.device_kind === 'auto' ? null : params.device_kind + if (params && typeof params.channel_name === 'string') cfg.channel_name = params.channel_name + if (params && typeof params.advert_name === 'string') cfg.advert_name = params.advert_name + return res.json({ result: { configured: true, enabled: cfg.enabled, lora_region: cfg.lora_region, device_kind: cfg.device_kind, ...globalThis.__meshHeaders } }) } case 'mesh.send-invoice': { diff --git a/neode-ui/public/assets/img/mesh-devices/diy.svg b/neode-ui/public/assets/img/mesh-devices/diy.svg new file mode 100644 index 00000000..81d0da13 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/diy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114-case.svg b/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114-case.svg new file mode 100644 index 00000000..dc84fd1b --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114-case.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114.svg b/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114.svg new file mode 100644 index 00000000..fc44238c --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-v3-case.svg b/neode-ui/public/assets/img/mesh-devices/heltec-v3-case.svg new file mode 100644 index 00000000..86879653 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/heltec-v3-case.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-v3.svg b/neode-ui/public/assets/img/mesh-devices/heltec-v3.svg new file mode 100644 index 00000000..759dbe90 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/heltec-v3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-wsl-v3.svg b/neode-ui/public/assets/img/mesh-devices/heltec-wsl-v3.svg new file mode 100644 index 00000000..c61b676a --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/heltec-wsl-v3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/heltec_v4.svg b/neode-ui/public/assets/img/mesh-devices/heltec_v4.svg new file mode 100644 index 00000000..e7cb8943 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/heltec_v4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/nano-g2-ultra.svg b/neode-ui/public/assets/img/mesh-devices/nano-g2-ultra.svg new file mode 100644 index 00000000..5ad5c5bf --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/nano-g2-ultra.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/rak-wismeshtap.svg b/neode-ui/public/assets/img/mesh-devices/rak-wismeshtap.svg new file mode 100644 index 00000000..1085736a --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/rak-wismeshtap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/rak4631.svg b/neode-ui/public/assets/img/mesh-devices/rak4631.svg new file mode 100644 index 00000000..0eaea9b6 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/rak4631.svg @@ -0,0 +1,3514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/neode-ui/public/assets/img/mesh-devices/rak4631_case.svg b/neode-ui/public/assets/img/mesh-devices/rak4631_case.svg new file mode 100644 index 00000000..4ffa23c9 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/rak4631_case.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/rpipicow.svg b/neode-ui/public/assets/img/mesh-devices/rpipicow.svg new file mode 100644 index 00000000..519648ba --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/rpipicow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/seeed-xiao-s3.svg b/neode-ui/public/assets/img/mesh-devices/seeed-xiao-s3.svg new file mode 100644 index 00000000..768093b2 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/seeed-xiao-s3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/station-g2.svg b/neode-ui/public/assets/img/mesh-devices/station-g2.svg new file mode 100644 index 00000000..158d456a --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/station-g2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/t-deck.svg b/neode-ui/public/assets/img/mesh-devices/t-deck.svg new file mode 100644 index 00000000..c13a445c --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/t-deck.svg @@ -0,0 +1 @@ +QWERTYIUPOASDFGHKJLaltZXCVBMN \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/t-echo.svg b/neode-ui/public/assets/img/mesh-devices/t-echo.svg new file mode 100644 index 00000000..006f77f5 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/t-echo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/t-echo_plus.svg b/neode-ui/public/assets/img/mesh-devices/t-echo_plus.svg new file mode 100644 index 00000000..ebc76c89 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/t-echo_plus.svg @@ -0,0 +1,502 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/neode-ui/public/assets/img/mesh-devices/tbeam-s3-core.svg b/neode-ui/public/assets/img/mesh-devices/tbeam-s3-core.svg new file mode 100644 index 00000000..ed304d99 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/tbeam-s3-core.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/tbeam.svg b/neode-ui/public/assets/img/mesh-devices/tbeam.svg new file mode 100644 index 00000000..6119e7da --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/tbeam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/thinknode_m1.svg b/neode-ui/public/assets/img/mesh-devices/thinknode_m1.svg new file mode 100644 index 00000000..d4f95045 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/thinknode_m1.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/tlora-t3s3-v1.svg b/neode-ui/public/assets/img/mesh-devices/tlora-t3s3-v1.svg new file mode 100644 index 00000000..fb8297d3 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/tlora-t3s3-v1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/tlora-v2-1-1_6.svg b/neode-ui/public/assets/img/mesh-devices/tlora-v2-1-1_6.svg new file mode 100644 index 00000000..53064569 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/tlora-v2-1-1_6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/tracker-t1000-e.svg b/neode-ui/public/assets/img/mesh-devices/tracker-t1000-e.svg new file mode 100644 index 00000000..1d54ac53 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/tracker-t1000-e.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/neode-ui/public/assets/img/mesh-devices/unknown-new.svg b/neode-ui/public/assets/img/mesh-devices/unknown-new.svg new file mode 100644 index 00000000..daece9e5 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/unknown-new.svg @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/neode-ui/public/assets/img/mesh-devices/wio-tracker-wm1110.svg b/neode-ui/public/assets/img/mesh-devices/wio-tracker-wm1110.svg new file mode 100644 index 00000000..14b8f528 --- /dev/null +++ b/neode-ui/public/assets/img/mesh-devices/wio-tracker-wm1110.svg @@ -0,0 +1 @@ +LoRaWI FILEDRESETGNSSBLE \ No newline at end of file diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index e5f3231a..55dc47f3 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -37,6 +37,9 @@ + + + @@ -88,6 +91,8 @@ import ToastStack from './components/ToastStack.vue' import Screensaver from './components/Screensaver.vue' import HelpGuideModal from './components/HelpGuideModal.vue' import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue' +import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue' +import { useMeshStore } from './stores/mesh' import { useControllerNav } from '@/composables/useControllerNav' import { playKeyboardTypingSound } from '@/composables/useLoginSounds' @@ -360,6 +365,9 @@ function onVisibilityChange() { onMounted(async () => { syncKioskSafeArea() + // Light app-wide mesh poll so a freshly plugged-in radio surfaces the + // setup modal on any page (the Mesh view's own poll takes over there). + useMeshStore().startGlobalDetection() document.addEventListener('visibilitychange', onVisibilityChange) window.addEventListener('keydown', onKeyDown, true) window.addEventListener('mousemove', onUserActivity) diff --git a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue new file mode 100644 index 00000000..f21790f7 --- /dev/null +++ b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index e9d547ce..ec6e2b6a 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -23,6 +23,31 @@ export interface MeshStatus { receive_block_headers?: boolean /** Operator-configured LoRa region (e.g. "EU_868"), for the Device tab. */ region?: string | null + /** Persisted config: LoRa region the driver programs on fresh radios. */ + lora_region?: string | null + /** Persisted config: firmware pin (meshcore|meshtastic|reticulum), null = auto-detect. */ + device_kind?: string | null + /** USB identity per detected port (for the setup modal's board image). */ + detected_device_info?: Array<{ + path: string + vid?: string | null + pid?: string | null + product?: string | null + manufacturer?: string | null + }> +} + +/** Params accepted by mesh.configure (superset of the status fields). */ +export interface MeshConfigureParams { + enabled?: boolean + device_path?: string + channel_name?: string + broadcast_identity?: boolean + advert_name?: string + announce_block_headers?: boolean + receive_block_headers?: boolean + lora_region?: string + device_kind?: string } export interface MeshPeer { @@ -234,6 +259,33 @@ export const useMeshStore = defineStore('mesh', () => { } } + // ── Global device-detection (for the app-wide "device detected" modal) ── + // Ports the user dismissed the setup modal for, persisted per browser. + const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1' + const dismissedDetectedPaths = ref>(new Set( + JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[] + )) + const undismissedDetectedDevices = computed(() => { + const s = status.value + if (!s || s.device_connected || s.enabled) return [] + return (s.detected_devices || []).filter(p => !dismissedDetectedPaths.value.has(p)) + }) + function dismissDetectedDevice(path: string) { + dismissedDetectedPaths.value.add(path) + dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value) + localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value])) + } + let globalDetectTimer: ReturnType | null = null + /** App-wide light poll so the detected-device modal works on every page + * (the Mesh view's own 5s poll takes over while it is mounted). */ + function startGlobalDetection(intervalMs = 15000) { + if (globalDetectTimer) return + void fetchStatus() + globalDetectTimer = setInterval(() => { + if (document.visibilityState === 'visible') void fetchStatus() + }, intervalMs) + } + async function fetchPeers() { try { const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({ @@ -455,12 +507,12 @@ export const useMeshStore = defineStore('mesh', () => { } } - async function configure(config: Partial) { + async function configure(config: MeshConfigureParams) { try { error.value = null await rpcClient.call<{ configured: boolean }>({ method: 'mesh.configure', - params: config, + params: config as Record, }) await fetchStatus() } catch (err: unknown) { @@ -851,6 +903,9 @@ export const useMeshStore = defineStore('mesh', () => { blockHeaders, latestBlockHeight, fetchStatus, + undismissedDetectedDevices, + dismissDetectedDevice, + startGlobalDetection, fetchPeers, fetchMessages, sendMessage, diff --git a/neode-ui/src/utils/loraRegions.ts b/neode-ui/src/utils/loraRegions.ts new file mode 100644 index 00000000..07a86941 --- /dev/null +++ b/neode-ui/src/utils/loraRegions.ts @@ -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 = { + 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()] +} diff --git a/neode-ui/src/utils/meshDeviceImages.ts b/neode-ui/src/utils/meshDeviceImages.ts new file mode 100644 index 00000000..c3b371c6 --- /dev/null +++ b/neode-ui/src/utils/meshDeviceImages.ts @@ -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 = { + // 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 +} diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index f039ae62..10bdac71 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -40,13 +40,7 @@ const sendError = ref('') const broadcasting = ref(false) const configuring = ref(false) const connectingDevice = ref(null) -// Onboarding modal (#6): guides a first-time connect for a freshly-detected, -// not-yet-connected device — a friendlier wrapper around the same Connect -// action the "Detected USB devices" list already offers, not a new setup -// engine. `onboardingDismissed` remembers paths the user closed without -// connecting, so it doesn't reappear every poll tick for the same device. -const showOnboardingModal = ref(false) -const onboardingDismissed = ref>(new Set()) +// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue). const chatScrollEl = ref(null) const messageInputRef = ref(null) const mobileShowChat = ref(false) @@ -1041,35 +1035,12 @@ async function handleToggleEnabled() { async function handleConnectDevice(devicePath: string) { connectingDevice.value = devicePath try { - await mesh.configure({ enabled: true, device_path: devicePath } as Partial) - showOnboardingModal.value = false + await mesh.configure({ enabled: true, device_path: devicePath }) } finally { connectingDevice.value = null } } -const undismissedDetectedDevices = computed(() => - (mesh.status?.detected_devices ?? []).filter((d) => !onboardingDismissed.value.has(d)) -) - -function dismissOnboarding() { - for (const d of undismissedDetectedDevices.value) onboardingDismissed.value.add(d) - showOnboardingModal.value = false -} - -// Pop the onboarding modal the moment a device is detected but not yet -// connected — same trigger condition the inline "Detected USB devices" list -// already uses (mesh.status.detected_devices non-empty + not connected), -// just surfaced as a guided prompt instead of requiring the user to notice -// the collapsed Device card. -watch( - () => [mesh.status?.device_connected, undismissedDetectedDevices.value.length] as const, - ([connected, count]) => { - if (!connected && count > 0) showOnboardingModal.value = true - }, - { immediate: true }, -) - function signalBars(rssi: number | null, snr: number | null = null): number { if (rssi !== null) { if (rssi > -60) return 4 @@ -2448,31 +2419,9 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { - -
-
-

📡 Mesh Device Found

-

- A radio was detected but isn't connected yet. Connect it to start using off-grid mesh chat. -

-
- -
- -
-
+ -import { ref } from 'vue' +import { ref, computed, watch } from 'vue' import { useMeshStore } from '@/stores/mesh' +import { LORA_REGIONS, regionByCode, meshcorePlanFor } from '@/utils/loraRegions' const mesh = useMeshStore() @@ -18,6 +19,64 @@ async function handleReboot() { rebooting.value = false } } + +// ── Editable settings (persisted via mesh.configure) ── +const form = ref({ + region: '', + deviceKind: 'auto', + channel: 'archipelago', + name: '', + broadcastIdentity: true, +}) +const saving = ref(false) +const saveError = ref(null) +const saveDone = ref(false) +let seeded = false + +// Seed the form once from status (don't clobber in-progress edits on poll) +watch( + () => mesh.status, + (s) => { + if (!s || seeded) return + seeded = true + form.value.region = s.lora_region ?? '' + form.value.deviceKind = s.device_kind ?? 'auto' + form.value.channel = s.channel_name || 'archipelago' + form.value.name = s.self_advert_name ?? '' + }, + { immediate: true }, +) + +const selectedRegion = computed(() => regionByCode(form.value.region)) +const deviceType = computed(() => mesh.status?.device_type ?? 'unknown') +// Firmware whose options apply: explicit pin wins, else the connected type. +const effectiveKind = computed(() => { + if (form.value.deviceKind !== 'auto') return form.value.deviceKind + const t = deviceType.value.toLowerCase() + return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto' +}) +const meshcorePlan = computed(() => meshcorePlanFor(form.value.region)) + +async function saveSettings() { + saving.value = true + saveError.value = null + saveDone.value = false + try { + await mesh.configure({ + lora_region: form.value.region, + device_kind: form.value.deviceKind, + channel_name: form.value.channel.trim() || 'archipelago', + ...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}), + broadcast_identity: form.value.broadcastIdentity, + }) + saveDone.value = true + setTimeout(() => { saveDone.value = false }, 3000) + } catch (e) { + saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings' + } finally { + saving.value = false + } +}