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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
Reference in New Issue
Block a user