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:
archipelago
2026-07-14 08:40:18 -04:00
co-authored by Claude Fable 5
parent c014c4b3ee
commit 7b36b5cc34
38 changed files with 5210 additions and 71 deletions
@@ -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()),
}))
}
}
@@ -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
+1 -1
View File
@@ -1309,7 +1309,7 @@ fn derive_channel_psk(channel_name: &str) -> Vec<u8> {
/// 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<u32> {
pub(crate) fn region_name_to_code(name: &str) -> Option<u32> {
Some(match name.trim().to_uppercase().as_str() {
"UNSET" => 0,
"US" => 1,
+12
View File
@@ -401,6 +401,12 @@ pub struct MeshConfig {
pub reticulum_tcp: Option<types::ReticulumTcpConfig>,
}
/// 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<String> {
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::DetectedDeviceInfo> {
serial::detect_serial_devices_info().await
}
// ─── MeshService ────────────────────────────────────────────────────────
/// Top-level mesh networking service.
+77
View File
@@ -509,6 +509,83 @@ pub async fn detect_serial_devices() -> Vec<String> {
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<String>,
pub pid: Option<String>,
pub product: Option<String>,
pub manufacturer: Option<String>,
}
/// Like `detect_serial_devices`, but with USB metadata per port.
pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
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<String>,
Option<String>,
Option<String>,
Option<String>,
) {
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/<name>/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)> {