Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c056acdb | ||
|
|
d0463196a3 | ||
|
|
8e0939170c | ||
|
|
5c19effdd1 | ||
|
|
8e51164321 | ||
|
|
da14c135e4 | ||
|
|
d7c5d39747 | ||
|
|
6ba39041d0 | ||
|
|
c99f1c7b77 | ||
|
|
7326bb9262 | ||
|
|
3589c3a6b9 | ||
|
|
0aa3941c40 | ||
|
|
e62f911810 | ||
|
|
fb1f4bf0e3 | ||
|
|
79c3cc5947 | ||
|
|
c4f24f3efa | ||
|
|
0acfba40db | ||
|
|
3f76b4960a | ||
|
|
a8c4694c36 | ||
|
|
537c52d11c | ||
|
|
2971623145 | ||
|
|
dcb0618012 | ||
|
|
8403f2233e | ||
|
|
078f3b3619 | ||
|
|
4222a8507c | ||
|
|
5799c37111 | ||
|
|
b14af20d1a | ||
|
|
7547d03166 | ||
|
|
d6019e47a5 | ||
|
|
0ca8f25b1b | ||
|
|
76d14c3bf9 | ||
|
|
a1cb83dfb2 | ||
|
|
ff532465cf | ||
|
|
39e88529b3 | ||
|
|
992bf636e0 | ||
|
|
beff5dd577 | ||
|
|
7d31ca5d65 |
+13
-3
@@ -1,12 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.117-alpha (2026-07-27)
|
||||
## v1.7.117-alpha (2026-07-29)
|
||||
|
||||
- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.
|
||||
- Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.
|
||||
- Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.
|
||||
- Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.
|
||||
- Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.
|
||||
- The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.
|
||||
- Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.
|
||||
- Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome.
|
||||
- Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.
|
||||
- The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks.
|
||||
- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.
|
||||
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
|
||||
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
|
||||
- Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.
|
||||
- Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.
|
||||
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
|
||||
- Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.
|
||||
|
||||
## v1.7.116-alpha (2026-07-27)
|
||||
|
||||
|
||||
Generated
+50
@@ -84,6 +84,15 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
@@ -161,6 +170,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"zbase32",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1175,6 +1185,17 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@@ -6895,8 +6916,37 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.116-alpha"
|
||||
version = "1.7.117-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -108,6 +108,10 @@ bytes = "1"
|
||||
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
||||
serial2-tokio = "0.1"
|
||||
|
||||
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
|
||||
# per-platform release zip (see mesh/flash.rs).
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||
hkdf = "0.12.4"
|
||||
|
||||
|
||||
@@ -391,7 +391,12 @@ impl RpcHandler {
|
||||
// Mesh networking (Meshcore LoRa)
|
||||
"mesh.status" => self.handle_mesh_status().await,
|
||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
|
||||
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
|
||||
"mesh.flash-status" => self.handle_mesh_flash_status().await,
|
||||
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
|
||||
"mesh.peers" => self.handle_mesh_peers().await,
|
||||
"mesh.refresh" => self.handle_mesh_refresh().await,
|
||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||
"mesh.send" => self.handle_mesh_send(params).await,
|
||||
|
||||
@@ -198,8 +198,7 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|p| {
|
||||
p.get("payment_hash").and_then(|v| v.as_str())
|
||||
== Some(hash_lower.as_str())
|
||||
p.get("payment_hash").and_then(|v| v.as_str()) == Some(hash_lower.as_str())
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
|
||||
use crate::mesh::types::DeviceType;
|
||||
use anyhow::Result;
|
||||
|
||||
fn parse_family(s: &str) -> Result<DeviceType> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"meshcore" => Ok(DeviceType::Meshcore),
|
||||
"meshtastic" => Ok(DeviceType::Meshtastic),
|
||||
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
|
||||
other => anyhow::bail!(
|
||||
"Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_board(s: &str) -> Result<FlashBoard> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
|
||||
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
|
||||
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.flash-list-firmware — resolve the available firmware version(s)
|
||||
/// for a given family. v1 only ever surfaces "latest".
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let family = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("family"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
||||
let family = parse_family(family)?;
|
||||
let versions = flash::list_firmware(family).await?;
|
||||
Ok(serde_json::json!({ "versions": versions }))
|
||||
}
|
||||
|
||||
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
|
||||
/// latest firmware for the given family, defaulting to a full chip
|
||||
/// erase before write. `board` is optional: if the port's USB vid:pid
|
||||
/// unambiguously resolves to a known board, that's used; otherwise the
|
||||
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
|
||||
/// doc comment on why we refuse to guess).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let path = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
||||
.to_string();
|
||||
let family = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("family"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
||||
let family = parse_family(family)?;
|
||||
|
||||
let detected = mesh::detect_devices().await;
|
||||
anyhow::ensure!(
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
|
||||
let board = match params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("board"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
Some(explicit) => parse_board(explicit)?,
|
||||
None => {
|
||||
let info = mesh::detect_devices_info()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|d| d.path == path);
|
||||
info.as_ref()
|
||||
.and_then(flash::resolve_flash_board)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not auto-detect the board on {path} — specify board explicitly"
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
flash::start_flash_job(
|
||||
&self.flash_job,
|
||||
&self.mesh_service_arc(),
|
||||
self.config.data_dir.clone(),
|
||||
path,
|
||||
board,
|
||||
family,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "started": true }))
|
||||
}
|
||||
|
||||
/// mesh.flash-status — poll the current (or most recent) flash job.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
|
||||
let job = self.flash_job.read().await;
|
||||
match job.as_ref() {
|
||||
Some(j) => {
|
||||
let status: FlashJobStatus = j.snapshot().await;
|
||||
let mut value = serde_json::to_value(&status)?;
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("active".into(), (!status.done).into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
None => Ok(serde_json::json!({ "active": false })),
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.flash-cancel — best-effort; only honored before erase/write has
|
||||
/// started (see `FlashJob::cancel`'s doc comment).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
|
||||
let job = self.flash_job.read().await;
|
||||
match job.as_ref() {
|
||||
Some(j) => {
|
||||
j.cancel().await?;
|
||||
Ok(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
None => anyhow::bail!("No flash job in progress"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -131,7 +132,14 @@ impl RpcHandler {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
// Empty clears the custom mesh name (falls back to the server
|
||||
// name) — without this, a name could be set but never unset.
|
||||
let trimmed = name.trim();
|
||||
config.advert_name = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(announce) = params
|
||||
.get("announce_block_headers")
|
||||
@@ -202,10 +210,24 @@ impl RpcHandler {
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
// Apply to the running service in the background: configure() may
|
||||
// stop+start the listener (config changes now restart the session so
|
||||
// they actually take effect), and that can take seconds when the old
|
||||
// session is mid-probe. Holding the service write-lock for that long
|
||||
// inside this handler stalled every concurrent mesh.status/mesh.peers
|
||||
// poll behind it — the UI froze and nginx surfaced 502s. The config is
|
||||
// already persisted above; the UI observes progress via mesh.status.
|
||||
{
|
||||
let service_arc = Arc::clone(&self.mesh_service);
|
||||
let config_for_apply = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut service = service_arc.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
if let Err(e) = svc.configure(config_for_apply).await {
|
||||
tracing::error!("Applying mesh config to running service failed: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod assistant;
|
||||
mod bitcoin_ops;
|
||||
mod flash;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
mod status;
|
||||
|
||||
@@ -101,15 +101,61 @@ impl RpcHandler {
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
let service = self.mesh_service.read().await;
|
||||
let probe = match service.as_ref() {
|
||||
Some(svc) => svc.probe_device(&path).await?,
|
||||
// No mesh service yet (radio never enabled) — probe directly.
|
||||
None => mesh::listener::probe_device(&path).await?,
|
||||
};
|
||||
// Refuse to probe while a firmware flash is in flight. Confirmed
|
||||
// live 2026-07-23: esptool ("multiple access on port?") and
|
||||
// rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
|
||||
// failed with symptoms consistent with a second process holding the
|
||||
// same serial fd — the flash subprocess runs for minutes outside
|
||||
// our own async runtime, so nothing previously stopped a concurrent
|
||||
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
|
||||
// from opening the identical port at the same time and corrupting
|
||||
// both operations' handshakes.
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
anyhow::ensure!(
|
||||
job.snapshot().await.done,
|
||||
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
||||
);
|
||||
}
|
||||
// Only hold the mesh_service lock long enough for the quick
|
||||
// active-path guard check — NEVER across the actual probe, which
|
||||
// can take 15-60s across its internal collision retries. Confirmed
|
||||
// live 2026-07-23: holding this read lock for the full probe starved
|
||||
// a concurrent firmware-flash job's MeshService::stop() (which needs
|
||||
// the write lock) well past its own bounded timeout, surfacing as
|
||||
// "Mesh listener did not release the serial port" even though
|
||||
// stop() itself was fast.
|
||||
{
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
svc.ensure_probe_allowed(&path).await?;
|
||||
}
|
||||
}
|
||||
let probe = mesh::listener::probe_device(&path).await?;
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
|
||||
/// contact table and re-announce ourselves so quiet-but-alive neighbours
|
||||
/// answer. This is what the UI's Refresh button calls — before it existed
|
||||
/// the button only re-read server caches and never touched the radio.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let Some(svc) = service.as_ref() else {
|
||||
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
|
||||
};
|
||||
let status = svc.status().await;
|
||||
if status.device_connected {
|
||||
let state = svc.shared_state();
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
||||
.await;
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"refreshed": status.device_connected,
|
||||
"device_connected": status.device_connected,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
|
||||
@@ -575,14 +575,17 @@ impl RpcHandler {
|
||||
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let has_tor = peer_pubkey_hex
|
||||
.as_ref()
|
||||
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk))
|
||||
.unwrap_or(false)
|
||||
|| peer_did
|
||||
.as_ref()
|
||||
.map(|d| nodes.iter().any(|n| &n.did == d))
|
||||
.unwrap_or(false);
|
||||
let fed_node = nodes.iter().find(|n| {
|
||||
peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
|
||||
|| peer_did.as_deref() == Some(n.did.as_str())
|
||||
});
|
||||
let has_tor = fed_node.is_some();
|
||||
// Distinct FIPS capability so the frontend can offer FIPS as its own
|
||||
// pill (not just a generic "Tor") — the dial layer still picks
|
||||
// FIPS-first with Tor fallback on the actual send; these are honest
|
||||
// capability labels, with `last_transport` saying what worked last.
|
||||
let has_fips = fed_node.is_some_and(|n| n.fips_npub.is_some());
|
||||
let last_transport = fed_node.and_then(|n| n.last_transport.clone());
|
||||
|
||||
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||
|
||||
@@ -618,6 +621,8 @@ impl RpcHandler {
|
||||
"tier": tier,
|
||||
"est_seconds": est_seconds,
|
||||
"has_tor": has_tor,
|
||||
"has_fips": has_fips,
|
||||
"last_transport": last_transport,
|
||||
"reason": reason,
|
||||
"size": size,
|
||||
"mesh_auto_max": MESH_AUTO_MAX,
|
||||
|
||||
@@ -89,6 +89,9 @@ pub struct RpcHandler {
|
||||
endpoint_rate_limiter: EndpointRateLimiter,
|
||||
response_cache: ResponseCache,
|
||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
|
||||
/// job at a time, since flashing needs exclusive access to the port.
|
||||
flash_job: crate::mesh::flash::FlashJobHandle,
|
||||
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
||||
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
||||
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
||||
@@ -160,6 +163,7 @@ impl RpcHandler {
|
||||
endpoint_rate_limiter,
|
||||
response_cache: ResponseCache::new(5),
|
||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
flash_job: crate::mesh::flash::new_job_handle(),
|
||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
|
||||
@@ -61,6 +61,28 @@ impl RpcHandler {
|
||||
|
||||
info!("Server name updated to: {}", name);
|
||||
|
||||
// Propagate to the mesh: the listener advertises the server name (when
|
||||
// no explicit mesh advert_name overrides it), but it was only read at
|
||||
// process startup — a rename never reached the radio/RNS until the
|
||||
// next full restart. Push it into the service and bounce the listener
|
||||
// in the background (the restart re-probes the radio, which can take
|
||||
// seconds — don't block the rename response on it).
|
||||
{
|
||||
let mesh_arc = self.mesh_service_arc();
|
||||
let name_for_mesh = name.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut guard = mesh_arc.write().await;
|
||||
if let Some(svc) = guard.as_mut() {
|
||||
svc.set_server_name(Some(name_for_mesh));
|
||||
if svc.config().advert_name.is_none() {
|
||||
if let Err(e) = svc.restart_listener_if_running().await {
|
||||
warn!("Mesh listener restart after rename failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Push the new name to federation peers in background
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
|
||||
@@ -212,9 +212,21 @@ impl DockerPackageScanner {
|
||||
website: lan_address.clone(),
|
||||
tier: Some(metadata.tier.to_string()),
|
||||
interfaces: if lan_address.is_some() || tor_address.is_some() {
|
||||
// `ui` is no longer implied by a published port: a
|
||||
// headless backend with an exposed port is a service,
|
||||
// not a launchable app. ui_detection consults the
|
||||
// manifest declaration first, then HTTP-probes the
|
||||
// port. Addresses stay present either way so the
|
||||
// Services tab can still show where a backend lives.
|
||||
let has_ui = super::ui_detection::has_web_ui(
|
||||
&app_id,
|
||||
lan_address.as_deref(),
|
||||
package_state == PackageState::Running,
|
||||
)
|
||||
.await;
|
||||
Some(Interfaces {
|
||||
main: Some(MainInterface {
|
||||
ui: Some("true".to_string()),
|
||||
ui: has_ui.then(|| "true".to_string()),
|
||||
tor_config: tor_address.clone(),
|
||||
lan_config: None,
|
||||
}),
|
||||
@@ -729,7 +741,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
|
||||
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
|
||||
/// drops a reachable launch URL. Reading digits after the final colon mirrors
|
||||
/// `port_from_url` in the RPC layer and tolerates a trailing path.
|
||||
fn launch_url_port(url: &str) -> Option<u16> {
|
||||
pub(crate) fn launch_url_port(url: &str) -> Option<u16> {
|
||||
let after_colon = url.rsplit_once(':')?.1;
|
||||
after_colon
|
||||
.chars()
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod quadlet;
|
||||
pub mod registry;
|
||||
pub mod secrets;
|
||||
pub mod traits;
|
||||
pub mod ui_detection;
|
||||
pub mod version_config;
|
||||
|
||||
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Web-UI detection for discovered containers.
|
||||
//!
|
||||
//! The packages list used to mark every container that published a port (or
|
||||
//! carried an onion address) as a UI app, which gave headless backends —
|
||||
//! databases, media servers, self-deployed compose stacks — a Launch button.
|
||||
//! UI-ness is decided here instead, for manifest apps and ad-hoc containers
|
||||
//! alike:
|
||||
//!
|
||||
//! 1. A manifest that declares an `interfaces:` block is definitive: any
|
||||
//! entry of `type: ui` means a browsable UI, a block without one means a
|
||||
//! backend service. The signed catalog overlay is consulted before disk
|
||||
//! manifests (catalog supremacy).
|
||||
//! 2. Manifests without an `interfaces:` block (the overwhelming majority)
|
||||
//! and manifest-less containers fall through to a short HTTP probe of the
|
||||
//! launch port: an HTML page, a redirect, or a browser-auth wall means a
|
||||
//! UI; JSON APIs, raw TCP protocols, and dead ports mean a service.
|
||||
//!
|
||||
//! Verdicts are cached — positives longer than negatives, since "no UI yet"
|
||||
//! is often just an app that hasn't finished starting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
/// A confirmed UI stays a UI — re-check rarely.
|
||||
const POSITIVE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
/// A "no UI" verdict may be a slow-starting app — re-check sooner.
|
||||
const NEGATIVE_TTL: Duration = Duration::from_secs(2 * 60);
|
||||
|
||||
/// Decide whether `app_id` exposes a browsable web UI. `lan_address` is the
|
||||
/// launch candidate already computed by the package scanner (host-published),
|
||||
/// and `running` gates the probe: a stopped container can't answer, and a
|
||||
/// dead-port verdict against it would poison the cache.
|
||||
pub async fn has_web_ui(app_id: &str, lan_address: Option<&str>, running: bool) -> bool {
|
||||
if let Some(declared) = manifest_declares_ui(app_id) {
|
||||
return declared;
|
||||
}
|
||||
let Some(port) = lan_address.and_then(super::docker_packages::launch_url_port) else {
|
||||
return false;
|
||||
};
|
||||
if !running {
|
||||
// Serve a cached verdict if we have one, but never record one.
|
||||
return cached_verdict(app_id, port).unwrap_or(false);
|
||||
}
|
||||
if let Some(v) = cached_verdict(app_id, port) {
|
||||
return v;
|
||||
}
|
||||
let verdict = probe_port(port).await;
|
||||
debug!(app_id, port, verdict, "web-UI probe");
|
||||
cache_verdict(app_id, port, verdict);
|
||||
verdict
|
||||
}
|
||||
|
||||
// ─── Manifest declarations ───────────────────────────────────────────────
|
||||
|
||||
/// `Some(true)`: a manifest covers this app and declares a `type: ui`
|
||||
/// interface. `Some(false)`: a manifest declares interfaces, none of them UI.
|
||||
/// `None`: no manifest, or one without an `interfaces:` block — undeclared,
|
||||
/// let the probe decide.
|
||||
fn manifest_declares_ui(app_id: &str) -> Option<bool> {
|
||||
// Catalog overlay first: disk manifests don't apply to catalog-covered
|
||||
// apps, so the catalog must win where it speaks.
|
||||
for (id, value) in super::app_catalog::catalog_manifest_values() {
|
||||
if id == app_id {
|
||||
if let Some(v) = declared_ui_in_value(&value) {
|
||||
return Some(v);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
for path in disk_manifest_candidates(app_id) {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
match archipelago_container::AppManifest::from_file(&path) {
|
||||
Ok(m) => {
|
||||
if m.app.interfaces.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(m.app.interfaces.values().any(|i| i.interface_type == "ui"));
|
||||
}
|
||||
// Malformed manifests are already reported by the orchestrator's
|
||||
// loader; here they simply don't count as a declaration.
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Same declaration logic against a catalog manifest carried as raw JSON.
|
||||
fn declared_ui_in_value(manifest: &serde_json::Value) -> Option<bool> {
|
||||
let interfaces = manifest.get("app")?.get("interfaces")?.as_object()?;
|
||||
if interfaces.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(interfaces.values().any(|i| {
|
||||
// An omitted `type` defaults to "ui", mirroring the YAML schema.
|
||||
i.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|t| t == "ui")
|
||||
.unwrap_or(true)
|
||||
}))
|
||||
}
|
||||
|
||||
fn disk_manifest_candidates(app_id: &str) -> Vec<std::path::PathBuf> {
|
||||
let mut roots: Vec<std::path::PathBuf> = Vec::new();
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_APPS_DIR") {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
roots.push(v.into());
|
||||
}
|
||||
}
|
||||
roots.push("/opt/archipelago/apps".into());
|
||||
roots
|
||||
.into_iter()
|
||||
.map(|root| root.join(app_id).join("manifest.yml"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── HTTP probe ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn probe_port(port: u16) -> bool {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
let client = CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.timeout(PROBE_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("static probe client")
|
||||
});
|
||||
match client.get(format!("http://127.0.0.1:{port}/")).send().await {
|
||||
Ok(resp) => {
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let auth_challenge = resp
|
||||
.headers()
|
||||
.contains_key(reqwest::header::WWW_AUTHENTICATE);
|
||||
ui_verdict(resp.status().as_u16(), content_type, auth_challenge)
|
||||
}
|
||||
// Connection refused, timeout, or a non-HTTP protocol on the port.
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pure classification rule, split out for tests: a browsable UI is an
|
||||
/// HTML response (any status — error pages included), a redirect (login
|
||||
/// flows), or a browser auth prompt. JSON/plaintext APIs are services.
|
||||
fn ui_verdict(status: u16, content_type: &str, auth_challenge: bool) -> bool {
|
||||
if content_type.contains("text/html") {
|
||||
return true;
|
||||
}
|
||||
if (300..400).contains(&status) {
|
||||
return true;
|
||||
}
|
||||
if (status == 401 || status == 403) && auth_challenge {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ─── Verdict cache ───────────────────────────────────────────────────────
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<String, (bool, Instant)>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, (bool, Instant)>>> = OnceLock::new();
|
||||
CACHE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
fn cached_verdict(app_id: &str, port: u16) -> Option<bool> {
|
||||
let cache = cache().lock().ok()?;
|
||||
let (verdict, at) = cache.get(&format!("{app_id}:{port}"))?;
|
||||
let ttl = if *verdict { POSITIVE_TTL } else { NEGATIVE_TTL };
|
||||
(at.elapsed() < ttl).then_some(*verdict)
|
||||
}
|
||||
|
||||
fn cache_verdict(app_id: &str, port: u16, verdict: bool) {
|
||||
if let Ok(mut cache) = cache().lock() {
|
||||
cache.insert(format!("{app_id}:{port}"), (verdict, Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn html_is_ui_regardless_of_status() {
|
||||
assert!(ui_verdict(200, "text/html; charset=utf-8", false));
|
||||
assert!(ui_verdict(404, "text/html", false));
|
||||
assert!(ui_verdict(401, "text/html", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirects_are_ui() {
|
||||
assert!(ui_verdict(302, "", false));
|
||||
assert!(ui_verdict(307, "text/plain", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_walls_are_ui_only_with_challenge() {
|
||||
assert!(ui_verdict(401, "text/plain", true));
|
||||
assert!(!ui_verdict(401, "application/json", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apis_and_plain_ports_are_services() {
|
||||
assert!(!ui_verdict(200, "application/json", false));
|
||||
assert!(!ui_verdict(200, "text/plain", false));
|
||||
assert!(!ui_verdict(500, "", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_value_declaration() {
|
||||
let ui = json!({"app": {"interfaces": {"main": {"type": "ui", "port": 80}}}});
|
||||
assert_eq!(declared_ui_in_value(&ui), Some(true));
|
||||
|
||||
// Omitted type defaults to "ui", mirroring the YAML schema default.
|
||||
let default_ty = json!({"app": {"interfaces": {"main": {"port": 80}}}});
|
||||
assert_eq!(declared_ui_in_value(&default_ty), Some(true));
|
||||
|
||||
let api_only = json!({"app": {"interfaces": {"rpc": {"type": "api", "port": 80}}}});
|
||||
assert_eq!(declared_ui_in_value(&api_only), Some(false));
|
||||
|
||||
// No interfaces block ⇒ undeclared, not "no UI".
|
||||
let none = json!({"app": {"id": "x"}});
|
||||
assert_eq!(declared_ui_in_value(&none), None);
|
||||
let empty = json!({"app": {"interfaces": {}}});
|
||||
assert_eq!(declared_ui_in_value(&empty), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_cache_roundtrip() {
|
||||
cache_verdict("test-app", 1234, true);
|
||||
assert_eq!(cached_verdict("test-app", 1234), Some(true));
|
||||
assert_eq!(cached_verdict("test-app", 9999), None);
|
||||
}
|
||||
}
|
||||
@@ -6,39 +6,7 @@
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8088,
|
||||
8089,
|
||||
8090,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8240,
|
||||
8334,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
|
||||
18083, 23000, 32838, 50002,
|
||||
];
|
||||
|
||||
@@ -287,7 +287,10 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
app_install?;
|
||||
// Make the allowance live immediately; a no-op error when the
|
||||
// hardening baseline isn't installed on this node yet.
|
||||
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) {
|
||||
if tokio::fs::try_exists("/etc/fips/fips.nft")
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
match Command::new("sudo")
|
||||
.args(["nft", "-f", "/etc/fips/fips.nft"])
|
||||
.output()
|
||||
|
||||
@@ -376,13 +376,9 @@ impl<'a> PeerRequest<'a> {
|
||||
let onion = self.onion_host.to_string();
|
||||
let transport = kind.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&dir,
|
||||
None,
|
||||
Some(&onion),
|
||||
&transport,
|
||||
)
|
||||
.await;
|
||||
let _ =
|
||||
crate::federation::record_peer_transport(&dir, None, Some(&onion), &transport)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,17 +104,15 @@ pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let seed = anchors::load(&data_dir).await.unwrap_or_default();
|
||||
let mut warm_npubs: std::collections::BTreeSet<String> = nodes
|
||||
.iter()
|
||||
.filter_map(|n| n.fips_npub.clone())
|
||||
.collect();
|
||||
let mut warm_npubs: std::collections::BTreeSet<String> =
|
||||
nodes.iter().filter_map(|n| n.fips_npub.clone()).collect();
|
||||
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
||||
let mut handles = Vec::new();
|
||||
for npub in warm_npubs {
|
||||
// Service-active was checked once above for the whole batch.
|
||||
handles.push(tokio::spawn(
|
||||
async move { dial::warm_path_unchecked(&npub).await },
|
||||
));
|
||||
handles.push(tokio::spawn(async move {
|
||||
dial::warm_path_unchecked(&npub).await
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! Firmware flashing for LoRa mesh radios — Heltec V3/V4 in v1, across all
|
||||
//! three firmware families the mesh module already knows how to detect (see
|
||||
//! `mesh::types::DeviceType`). Firmware is always fetched from upstream at
|
||||
//! flash time (never bundled/pinned in the repo), and every flash defaults
|
||||
//! to a full chip erase before write.
|
||||
//!
|
||||
//! MeshCore and Meshtastic are flashed the same way: download a released
|
||||
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
|
||||
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
|
||||
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
|
||||
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
|
||||
//! this family, so we deliberately don't resolve a firmware URL ourselves
|
||||
//! for Reticulum; rnodeconf already knows how).
|
||||
|
||||
use super::serial::DetectedDeviceInfo;
|
||||
use super::types::DeviceType;
|
||||
use super::MeshService;
|
||||
use anyhow::{Context, Result};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Boards supported for v1. Both are ESP32-S3 (a single `--chip esp32s3`
|
||||
/// esptool target covers both), but ship different USB identities and
|
||||
/// different per-board firmware assets upstream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashBoard {
|
||||
HeltecV3,
|
||||
HeltecV4,
|
||||
}
|
||||
|
||||
impl FlashBoard {
|
||||
/// Meshtastic's board id (matches the release manifest's `board` field
|
||||
/// and its per-board asset naming, e.g. `firmware-heltec-v3-<ver>.factory.bin`).
|
||||
fn meshtastic_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::HeltecV3 => "heltec-v3",
|
||||
Self::HeltecV4 => "heltec-v4",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a detected USB vid:pid to a known flashable board, using the same
|
||||
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
|
||||
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
|
||||
/// auto-match since that vid:pid is bridge-chip-specific.
|
||||
///
|
||||
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
|
||||
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
|
||||
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
|
||||
/// with product string "USB JTAG/serial debug unit" — that descriptor is
|
||||
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
|
||||
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
|
||||
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
|
||||
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
|
||||
/// board's image. Callers (the RPC layer / frontend) must let the user pick
|
||||
/// the board manually whenever this returns `None`.
|
||||
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||
match (info.vid.as_deref(), info.pid.as_deref()) {
|
||||
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashStage {
|
||||
Downloading,
|
||||
Erasing,
|
||||
Writing,
|
||||
Autoinstalling,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FlashJobStatus {
|
||||
pub board: FlashBoard,
|
||||
pub family: DeviceType,
|
||||
pub path: String,
|
||||
pub stage: FlashStage,
|
||||
pub percent: Option<u8>,
|
||||
pub log_tail: Vec<String>,
|
||||
pub done: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
const LOG_TAIL_MAX: usize = 200;
|
||||
|
||||
/// How long to wait after a successful flash before resuming the mesh
|
||||
/// listener, so the board finishes its own post-flash boot/reset before we
|
||||
/// start opening the port (which itself toggles DTR/RTS) again.
|
||||
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Absolute ceiling on a whole flash job (download + erase + write, or
|
||||
/// autoinstall), regardless of what it's doing internally. Last-resort
|
||||
/// safety net so a hang anywhere can't wedge the single-flash-job guard
|
||||
/// forever — generous enough to never trigger on a legitimately slow
|
||||
/// multi-hundred-MB transfer.
|
||||
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||
|
||||
/// How long to wait for MeshService::stop() to release the serial port
|
||||
/// before giving up. Confirmed live 2026-07-23: the listener's own
|
||||
/// reconnect/multi-candidate-probe loop doesn't check its shutdown signal
|
||||
/// between candidates, so stop() can take a while (or, if the loop is
|
||||
/// wedged, never return) — 20s comfortably covers a normal handshake-probe
|
||||
/// cycle without leaving a flash request hanging indefinitely if the
|
||||
/// listener genuinely won't let go.
|
||||
const STOP_LISTENER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// How long to keep retrying the port-free check before giving up.
|
||||
const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Confirm nothing else has `path` open by actually opening (and immediately
|
||||
/// closing) it ourselves. Retries across the timeout since a just-stopped
|
||||
/// listener's fd can take a moment to actually release even after `stop()`
|
||||
/// returns (task abort is a request, not an instant guarantee the OS-level
|
||||
/// resource is gone yet).
|
||||
async fn wait_for_port_free(path: &str) -> Result<()> {
|
||||
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
|
||||
let mut last_err = None;
|
||||
loop {
|
||||
match serial2_tokio::SerialPort::open(path, 115200) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
|
||||
PORT_FREE_TIMEOUT.as_secs(),
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
/// Live state for the one flash job that can run at a time. A single global
|
||||
/// slot is sufficient because flashing needs exclusive serial access to the
|
||||
/// one port being flashed — there is no meaningful concept of two concurrent
|
||||
/// flash jobs on this node.
|
||||
pub struct FlashJob {
|
||||
status: RwLock<FlashJobStatus>,
|
||||
/// Set once the background task is spawned. Only used while `stage` is
|
||||
/// still `Downloading` — an interrupted erase/write can leave the chip
|
||||
/// in a worse state than either finished or unstarted, so cancellation
|
||||
/// is refused once erase begins (see `cancel()`).
|
||||
abort_handle: RwLock<Option<tokio::task::AbortHandle>>,
|
||||
}
|
||||
|
||||
impl FlashJob {
|
||||
fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
abort_handle: RwLock::new(None),
|
||||
status: RwLock::new(FlashJobStatus {
|
||||
board,
|
||||
family,
|
||||
path,
|
||||
stage: FlashStage::Downloading,
|
||||
percent: None,
|
||||
log_tail: Vec::new(),
|
||||
done: false,
|
||||
error: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> FlashJobStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
async fn set_stage(&self, stage: FlashStage) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = stage;
|
||||
s.percent = None;
|
||||
}
|
||||
|
||||
async fn set_percent(&self, percent: u8) {
|
||||
self.status.write().await.percent = Some(percent.min(100));
|
||||
}
|
||||
|
||||
async fn push_log(&self, line: impl Into<String>) {
|
||||
let mut s = self.status.write().await;
|
||||
s.log_tail.push(line.into());
|
||||
let overflow = s.log_tail.len().saturating_sub(LOG_TAIL_MAX);
|
||||
if overflow > 0 {
|
||||
s.log_tail.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
async fn fail(&self, err: &anyhow::Error) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = FlashStage::Failed;
|
||||
s.error = Some(format!("{err:#}"));
|
||||
s.done = true;
|
||||
}
|
||||
|
||||
async fn finish(&self) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = FlashStage::Done;
|
||||
s.done = true;
|
||||
}
|
||||
|
||||
/// Best-effort cancel: only honored before erase/write/autoinstall has
|
||||
/// started (i.e. still in `Downloading`). Once a stage that touches the
|
||||
/// chip begins, this refuses — interrupting an erase or write can leave
|
||||
/// the flash in a state worse than either finished or unstarted.
|
||||
pub async fn cancel(&self) -> Result<()> {
|
||||
let mut s = self.status.write().await;
|
||||
if s.done {
|
||||
anyhow::bail!("Flash job already finished");
|
||||
}
|
||||
if s.stage != FlashStage::Downloading {
|
||||
anyhow::bail!(
|
||||
"Cannot cancel once {:?} has started — let it finish or fail on its own",
|
||||
s.stage
|
||||
);
|
||||
}
|
||||
if let Some(handle) = self.abort_handle.write().await.take() {
|
||||
handle.abort();
|
||||
}
|
||||
s.stage = FlashStage::Failed;
|
||||
s.error = Some("Cancelled by user".to_string());
|
||||
s.done = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared handle held by `RpcHandler`, sibling to `mesh_service`.
|
||||
pub type FlashJobHandle = Arc<RwLock<Option<Arc<FlashJob>>>>;
|
||||
|
||||
pub fn new_job_handle() -> FlashJobHandle {
|
||||
Arc::new(RwLock::new(None))
|
||||
}
|
||||
|
||||
fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("mesh").join("firmware-cache")
|
||||
}
|
||||
|
||||
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
|
||||
/// the *entire* request including streaming the response body, which would
|
||||
/// kill a legitimate large download partway through (Meshtastic's esp32s3
|
||||
/// zip is ~170MB) — not just a hung connection. `download_to_file` instead
|
||||
/// applies a per-chunk stall timeout, and metadata calls (small JSON
|
||||
/// responses) get their own short timeout at the call site.
|
||||
fn github_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("archipelago-mesh-flash")
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Applied per-chunk while streaming a firmware download — if the transfer
|
||||
/// stalls (no bytes for this long) it's treated as a failure, but a slow
|
||||
/// download that's still making progress is never killed just for taking a
|
||||
/// while.
|
||||
const DOWNLOAD_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Applied to metadata calls (GitHub release JSON) — these are small
|
||||
/// responses with no reason to ever take this long.
|
||||
const METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Resolve what firmware is available for a board+family. v1 only ever
|
||||
/// offers "latest" — MeshCore/Meshtastic latest GitHub release, or, for
|
||||
/// Reticulum, "latest" meaning "whatever archy-rnodeconf --autoinstall
|
||||
/// resolves on its own" (it does its own version checking upstream).
|
||||
pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
|
||||
match family {
|
||||
DeviceType::Reticulum => Ok(vec!["latest".to_string()]),
|
||||
DeviceType::Meshtastic => {
|
||||
let client = github_client()?;
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching Meshtastic release list")?
|
||||
.error_for_status()
|
||||
.context("Meshtastic releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing Meshtastic release JSON")?;
|
||||
Ok(vec![release.tag_name])
|
||||
}
|
||||
DeviceType::Meshcore => {
|
||||
let client = github_client()?;
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching MeshCore release list")?
|
||||
.error_for_status()
|
||||
.context("MeshCore releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing MeshCore release JSON")?;
|
||||
Ok(vec![release.tag_name])
|
||||
}
|
||||
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before listing versions"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GithubAsset>,
|
||||
}
|
||||
|
||||
/// Start a flash job in the background. Returns as soon as the job has been
|
||||
/// registered and the listener released — callers poll `FlashJobHandle` via
|
||||
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
|
||||
pub async fn start_flash_job(
|
||||
handle: &FlashJobHandle,
|
||||
mesh_service: &Arc<RwLock<Option<MeshService>>>,
|
||||
data_dir: PathBuf,
|
||||
path: String,
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let existing = handle.read().await;
|
||||
if let Some(job) = existing.as_ref() {
|
||||
if !job.snapshot().await.done {
|
||||
anyhow::bail!("A firmware flash is already in progress on this node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let job = FlashJob::new(board, family, path.clone());
|
||||
*handle.write().await = Some(Arc::clone(&job));
|
||||
|
||||
let bg_job = Arc::clone(&job);
|
||||
let bg_service = Arc::clone(mesh_service);
|
||||
let task = tokio::spawn(async move {
|
||||
// esptool/archy-rnodeconf need exclusive serial access — release
|
||||
// the listener's hold on the port before touching it. This USED
|
||||
// TO run synchronously in start_flash_job before the job was even
|
||||
// spawned, blocking the RPC call itself on s.stop().await — a real
|
||||
// 2026-07-23 incident: the mesh listener was mid a multi-candidate
|
||||
// reconnect/probe sequence that doesn't check its shutdown signal
|
||||
// between candidates, so stop() never returned. The HTTP request
|
||||
// timed out client-side ("Operation failed"), while the job
|
||||
// (already inserted into `handle`) was permanently wedged — nothing
|
||||
// had been spawned yet to ever mark it done, so every later flash
|
||||
// attempt failed with "already in progress" until a full restart.
|
||||
// Now this runs inside the spawned task with its own bounded
|
||||
// timeout, so the RPC call always returns immediately regardless,
|
||||
// and a slow-to-stop listener fails the job cleanly instead of
|
||||
// hanging everything downstream of it forever.
|
||||
let stop_result = tokio::time::timeout(STOP_LISTENER_TIMEOUT, async {
|
||||
let mut svc = bg_service.write().await;
|
||||
if let Some(s) = svc.as_mut() {
|
||||
s.stop().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if stop_result.is_err() {
|
||||
let err = anyhow::anyhow!(
|
||||
"Mesh listener did not release the serial port within {}s — it may still be mid a reconnect attempt. Try again once mesh.status shows the device idle, or restart the archipelago service if this persists.",
|
||||
STOP_LISTENER_TIMEOUT.as_secs()
|
||||
);
|
||||
bg_job.push_log(format!("ERROR: {err:#}")).await;
|
||||
bg_job.fail(&err).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders port-free check. `stop()` above should have
|
||||
// fully released the port, but esptool/rnodeconf run as external
|
||||
// subprocesses for minutes outside our own async runtime — if
|
||||
// ANYTHING else still has it open (a racing probe, a not-yet-dropped
|
||||
// fd from an aborted task, anything we haven't anticipated), handing
|
||||
// the port to the flasher anyway risks exactly the corruption
|
||||
// confirmed live 2026-07-23: esptool's "device disconnected or
|
||||
// multiple access on port?" and rnodeconf's raw `OSError: [Errno 71]
|
||||
// Protocol error` on an RTS ioctl are both textbook two-openers-on-
|
||||
// one-fd symptoms. Verify by actually opening it ourselves — cheap,
|
||||
// and definitive — before ever starting the flasher.
|
||||
if let Err(e) = wait_for_port_free(&path).await {
|
||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||
bg_job.fail(&e).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Outer ceiling on top of run_flash's own internal timeouts —
|
||||
// belt-and-suspenders so that no future hang (network, subprocess,
|
||||
// anything) can ever wedge the single-flash-job guard permanently
|
||||
// again the way a stuck download did on 2026-07-23 (every
|
||||
// subsequent mesh.flash-device call failed with "already in
|
||||
// progress" until the service was restarted). Generous enough that
|
||||
// a legitimately slow multi-hundred-MB transfer still completes.
|
||||
let result = match tokio::time::timeout(
|
||||
MAX_JOB_DURATION,
|
||||
run_flash(board, family, &data_dir, &path, &bg_job),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"Flash job exceeded the {}-minute ceiling — aborted",
|
||||
MAX_JOB_DURATION.as_secs() / 60
|
||||
)),
|
||||
};
|
||||
let succeeded = result.is_ok();
|
||||
|
||||
match &result {
|
||||
Ok(()) => {
|
||||
bg_job
|
||||
.push_log("Flash completed successfully".to_string())
|
||||
.await;
|
||||
bg_job.finish().await;
|
||||
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
||||
}
|
||||
Err(e) => {
|
||||
// {:#} (alternate Display) walks the full anyhow context
|
||||
// chain — plain {} / %e only prints the outermost .context()
|
||||
// message, which made a real 2026-07-23 esptool failure
|
||||
// undiagnosable from journalctl alone (just "esptool
|
||||
// erase_flash failed", no actual esptool stderr).
|
||||
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
|
||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||
bg_job.fail(e).await;
|
||||
}
|
||||
}
|
||||
|
||||
// The board's firmware may now differ from whatever was pinned
|
||||
// before — clear the pin either way so a later reconnect's strict
|
||||
// auto-detect order picks up reality instead of getting wedged
|
||||
// trying the old protocol first.
|
||||
if let Ok(mut config) = super::load_config(&data_dir).await {
|
||||
config.device_kind = None;
|
||||
if let Err(e) = super::save_config(&data_dir, &config).await {
|
||||
warn!(error = %e, "Failed to clear device_kind pin after flash");
|
||||
}
|
||||
}
|
||||
|
||||
if !succeeded {
|
||||
// Deliberately do NOT auto-restart the listener here. A failed
|
||||
// flash means we can't vouch for the board's state — reopening
|
||||
// the port immediately (esptool/rnodeconf's own reset sequence
|
||||
// plus our open() toggling DTR/RTS again right after) risks
|
||||
// hammering a marginal device with reconnect attempts. Confirmed
|
||||
// live 2026-07-23: exactly this sequence left a real Heltec V3
|
||||
// boot-looping for 5+ minutes after a failed flash. Leave mesh
|
||||
// stopped; the user reconnects explicitly via the hot-swap
|
||||
// modal/Mesh page once they've confirmed the board is alive.
|
||||
warn!(
|
||||
path = %path,
|
||||
"Leaving mesh listener stopped after failed flash — reconnect manually once the board is confirmed responsive"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// On success, give the board a moment to finish booting after the
|
||||
// flash tool's own reset sequence before we start hammering it with
|
||||
// connection attempts — same reasoning as above, just the
|
||||
// lower-risk (successful-flash) side of it.
|
||||
tokio::time::sleep(POST_FLASH_SETTLE_DELAY).await;
|
||||
|
||||
let mut svc = bg_service.write().await;
|
||||
if let Some(s) = svc.as_mut() {
|
||||
match super::load_config(&data_dir).await {
|
||||
Ok(config) => {
|
||||
// Only resume if mesh is actually still enabled per the
|
||||
// CURRENT persisted config — confirmed live 2026-07-23:
|
||||
// unconditionally forcing a restart here, regardless of
|
||||
// `enabled`, overrode a user's own concurrent "disable
|
||||
// mesh" toggle and left the listener running while
|
||||
// config said disabled. That inconsistent state is what
|
||||
// made a later legitimate "Keep As Is" click (which
|
||||
// correctly tries to start on a false→true transition)
|
||||
// fail with "already running" — the listener had already
|
||||
// been force-started behind the config's back.
|
||||
let should_run = config.enabled;
|
||||
if let Err(e) = s.configure(config).await {
|
||||
warn!(error = %e, "Failed to resume mesh listener after flash");
|
||||
}
|
||||
if should_run {
|
||||
if let Err(e) = s.start() {
|
||||
warn!(error = %e, "Failed to restart mesh listener after flash");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
|
||||
}
|
||||
}
|
||||
});
|
||||
*job.abort_handle.write().await = Some(task.abort_handle());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_flash(
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
data_dir: &Path,
|
||||
path: &str,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
match family {
|
||||
DeviceType::Meshtastic | DeviceType::Meshcore => {
|
||||
let image = fetch_esptool_image(board, family, data_dir, job).await?;
|
||||
esptool_erase_and_write(path, &image, job).await
|
||||
}
|
||||
DeviceType::Reticulum => {
|
||||
let lora_region = super::load_config(data_dir)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|c| c.lora_region);
|
||||
rnodeconf_autoinstall(path, board, lora_region.as_deref(), job).await
|
||||
}
|
||||
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before flashing"),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MeshCore / Meshtastic: esptool ─────────────────────────────────────
|
||||
|
||||
async fn fetch_esptool_image(
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
data_dir: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let cache = firmware_cache_dir(data_dir);
|
||||
tokio::fs::create_dir_all(&cache)
|
||||
.await
|
||||
.context("Creating firmware cache dir")?;
|
||||
let client = github_client()?;
|
||||
|
||||
match family {
|
||||
DeviceType::Meshtastic => fetch_meshtastic_image(&client, board, &cache, job).await,
|
||||
DeviceType::Meshcore => fetch_meshcore_image(&client, board, &cache, job).await,
|
||||
_ => anyhow::bail!("{family} is not flashed via esptool"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_meshtastic_image(
|
||||
client: &reqwest::Client,
|
||||
board: FlashBoard,
|
||||
cache: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
|
||||
.timeout(METADATA_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching Meshtastic release list")?
|
||||
.error_for_status()
|
||||
.context("Meshtastic releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing Meshtastic release JSON")?;
|
||||
|
||||
// Meshtastic bundles all esp32s3 boards' images inside one per-platform
|
||||
// zip rather than shipping per-board top-level assets — both Heltec V3
|
||||
// and V4 are esp32s3, so this is the right zip for both (confirmed live
|
||||
// against v2.7.26.54e0d8d).
|
||||
let zip_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.starts_with("firmware-esp32s3-") && a.name.ends_with(".zip"))
|
||||
.ok_or_else(|| anyhow::anyhow!("No esp32s3 firmware zip in latest Meshtastic release"))?;
|
||||
|
||||
let version = zip_asset
|
||||
.name
|
||||
.strip_prefix("firmware-esp32s3-")
|
||||
.and_then(|s| s.strip_suffix(".zip"))
|
||||
.ok_or_else(|| anyhow::anyhow!("Unexpected Meshtastic asset name: {}", zip_asset.name))?
|
||||
.to_string();
|
||||
|
||||
let zip_path = cache.join(&zip_asset.name);
|
||||
if tokio::fs::metadata(&zip_path).await.is_err() {
|
||||
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
|
||||
} else {
|
||||
job.push_log(format!("Using cached {}", zip_asset.name))
|
||||
.await;
|
||||
}
|
||||
|
||||
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
|
||||
// partition table + app) meant to be written at offset 0x0 on a freshly
|
||||
// erased chip — confirmed by inspecting the real zip's contents, as
|
||||
// opposed to the plain "*.bin" OTA-update image which assumes an
|
||||
// existing bootloader/partition table already on the chip.
|
||||
let entry_name = format!("firmware-{}-{}.factory.bin", board.meshtastic_id(), version);
|
||||
let out_path = cache.join(&entry_name);
|
||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||
return Ok(out_path);
|
||||
}
|
||||
|
||||
job.push_log(format!("Extracting {entry_name} from {}", zip_asset.name))
|
||||
.await;
|
||||
let zip_path_owned = zip_path.clone();
|
||||
let entry_name_owned = entry_name.clone();
|
||||
let out_path_owned = out_path.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let file =
|
||||
std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
|
||||
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
|
||||
let mut entry = archive
|
||||
.by_name(&entry_name_owned)
|
||||
.with_context(|| format!("{entry_name_owned} not found in firmware zip"))?;
|
||||
let mut out =
|
||||
std::fs::File::create(&out_path_owned).context("Creating extracted firmware file")?;
|
||||
std::io::copy(&mut entry, &mut out).context("Extracting firmware image")?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("Firmware extraction task panicked")??;
|
||||
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
async fn fetch_meshcore_image(
|
||||
client: &reqwest::Client,
|
||||
board: FlashBoard,
|
||||
cache: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
|
||||
.timeout(METADATA_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching MeshCore release list")?
|
||||
.error_for_status()
|
||||
.context("MeshCore releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing MeshCore release JSON")?;
|
||||
|
||||
// Upstream's casing differs between boards (Heltec_v3_... vs
|
||||
// heltec_v4_...) — match case-insensitively on the exact per-board
|
||||
// substring so V4 isn't accidentally matched by "heltec_v4_tft_..."
|
||||
// variants (there's a "_tft_" in between, so a straight substring match
|
||||
// on "heltec_v4_companion_radio_usb" is already safe).
|
||||
let needle = match board {
|
||||
FlashBoard::HeltecV3 => "heltec_v3_companion_radio_usb",
|
||||
FlashBoard::HeltecV4 => "heltec_v4_companion_radio_usb",
|
||||
};
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| {
|
||||
let lower = a.name.to_lowercase();
|
||||
lower.contains(needle) && lower.ends_with("-merged.bin")
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
|
||||
})?;
|
||||
|
||||
let out_path = cache.join(&asset.name);
|
||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||
job.push_log(format!("Using cached {}", asset.name)).await;
|
||||
return Ok(out_path);
|
||||
}
|
||||
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
async fn download_to_file(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
job.set_stage(FlashStage::Downloading).await;
|
||||
// Bound only the wait for the response to *start* (headers) — NOT a
|
||||
// request-level `.timeout()`, which would cap the whole body transfer
|
||||
// again (the bug this replaced: a blanket 30s client timeout killed
|
||||
// large downloads mid-stream). If the server never responds at all,
|
||||
// this is what stops the job from hanging forever; the per-chunk stall
|
||||
// timeout below is what guards the body once streaming starts. Without
|
||||
// this, a server that accepts the TCP connection but never sends
|
||||
// headers back hangs this call indefinitely — confirmed live
|
||||
// 2026-07-23: a stuck `.send()` here wedged the single-flash-job guard
|
||||
// for good, permanently blocking every subsequent flash attempt with
|
||||
// "already in progress" until the service was restarted.
|
||||
let resp = tokio::time::timeout(METADATA_TIMEOUT, client.get(url).send())
|
||||
.await
|
||||
.context("Firmware download server did not respond")?
|
||||
.context("Starting firmware download")?
|
||||
.error_for_status()
|
||||
.context("Firmware download returned an error status")?;
|
||||
let total = resp.content_length();
|
||||
let tmp = dest.with_extension("part");
|
||||
let mut file = tokio::fs::File::create(&tmp)
|
||||
.await
|
||||
.context("Creating firmware download file")?;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
use futures_util::StreamExt;
|
||||
loop {
|
||||
let next = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next())
|
||||
.await
|
||||
.context("Firmware download stalled")?;
|
||||
let Some(chunk) = next else { break };
|
||||
let chunk = chunk.context("Reading firmware download stream")?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.context("Writing firmware download")?;
|
||||
downloaded += chunk.len() as u64;
|
||||
if let Some(total) = total {
|
||||
if total > 0 {
|
||||
job.set_percent(((downloaded.saturating_mul(100)) / total) as u8)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
file.flush().await.ok();
|
||||
tokio::fs::rename(&tmp, dest)
|
||||
.await
|
||||
.context("Finalizing firmware download")?;
|
||||
job.push_log(format!(
|
||||
"Downloaded {} ({downloaded} bytes)",
|
||||
dest.display()
|
||||
))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Both Heltec V3 and V4 are ESP32-S3 boards.
|
||||
const ESPTOOL_CHIP: &str = "esp32s3";
|
||||
|
||||
/// esptool's auto-reset-into-bootloader handshake (toggling DTR/RTS in a
|
||||
/// specific timed pattern) is well-known to be flaky on some CP2102/CH340
|
||||
/// board+adapter combinations — esptool's own docs recommend retrying at a
|
||||
/// lower baud rate when this happens. Rather than fail the whole job on the
|
||||
/// first hiccup, retry once at a conservative baud before giving up.
|
||||
const ESPTOOL_FALLBACK_BAUD: &str = "115200";
|
||||
|
||||
/// `write_flash --erase-all` erases the whole chip before writing, in one
|
||||
/// esptool invocation. This needs the esp32s3 stub flasher loaded (see
|
||||
/// esptool_global_args' doc comment) — without it, --erase-all hits the
|
||||
/// exact same ROM limitation a standalone `erase_flash` does ("ESP32-S3 ROM
|
||||
/// does not support function erase_flash", confirmed live 2026-07-23), since
|
||||
/// esptool's --erase-all is implemented as the same full-chip-erase command,
|
||||
/// not a per-sector loop.
|
||||
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> {
|
||||
job.set_stage(FlashStage::Writing).await;
|
||||
let image_str = image.to_string_lossy().to_string();
|
||||
esptool_with_retry(
|
||||
path,
|
||||
&["write_flash", "--erase-all", "0x0", &image_str],
|
||||
job,
|
||||
)
|
||||
.await
|
||||
.context("esptool write_flash failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// esptool's global flags (--chip/--port/--baud) MUST precede the subcommand
|
||||
/// token (erase_flash/write_flash/...) — confirmed live 2026-07-23:
|
||||
/// appending `--baud 115200` after the subcommand on the retry path
|
||||
/// produced "esptool: error: unrecognized arguments: --baud 115200" every
|
||||
/// time, so the fallback-baud retry never actually got a chance to run.
|
||||
/// Building global args separately from subcommand args keeps this correct
|
||||
/// by construction instead of relying on call-site ordering.
|
||||
///
|
||||
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
|
||||
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
|
||||
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
|
||||
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
|
||||
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
|
||||
/// fetches the exact same file from the matching upstream esptool release
|
||||
/// tag and installs it alongside the apt package (see the esptool install
|
||||
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
|
||||
/// stub) was tried first and works for connecting, but the ROM bootloader
|
||||
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
|
||||
/// so --no-stub broke our "always erase before write" default outright
|
||||
/// rather than just being slower. Restoring the real stub file is the
|
||||
/// correct fix, not routing around its absence.
|
||||
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
|
||||
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
|
||||
if let Some(b) = baud {
|
||||
args.push("--baud");
|
||||
args.push(b);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> {
|
||||
let mut cmd = Command::new("esptool");
|
||||
cmd.args(esptool_global_args(path, None));
|
||||
cmd.args(subcommand);
|
||||
match run_streamed(cmd, None, job).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first_err) => {
|
||||
job.push_log(format!(
|
||||
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
|
||||
))
|
||||
.await;
|
||||
let mut retry = Command::new("esptool");
|
||||
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
|
||||
retry.args(subcommand);
|
||||
run_streamed(retry, None, job)
|
||||
.await
|
||||
.context(format!("retry also failed (first attempt: {first_err:#})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
|
||||
|
||||
fn rnodeconf_bin() -> String {
|
||||
std::env::var("ARCHY_RNODECONF_BIN")
|
||||
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
|
||||
}
|
||||
|
||||
/// `--autoinstall`'s "which board is this" step is interactive by design —
|
||||
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
|
||||
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
|
||||
/// (their bootstrap-time USB identity is often generic, same root cause as
|
||||
/// `resolve_flash_board`'s doc comment), so it always asks. The full prompt
|
||||
/// sequence observed for a Heltec board that already has *some* RNode
|
||||
/// firmware installed (the common case — a truly blank chip likely skips
|
||||
/// straight to the same "Device Selection" menu):
|
||||
/// 1. numbered device-type menu → answer with the menu number
|
||||
/// 2. "Hit enter to continue" → answer with a blank line
|
||||
/// 3. numbered band menu → answer with the menu number
|
||||
/// 4. "Is the above correct? [y/N]" → answer "y"
|
||||
/// Feeding all four answers up front (rather than watching stdout for each
|
||||
/// prompt text) works because the menu is always asked in this fixed order
|
||||
/// for every board that needs (re)provisioning — verified by driving it
|
||||
/// through an unprovisioned real V4 end-to-end (erase → flash → EEPROM
|
||||
/// bootstrap → "Device signature validated" on the next probe).
|
||||
fn rnodeconf_device_menu_number(board: FlashBoard) -> &'static str {
|
||||
match board {
|
||||
FlashBoard::HeltecV3 => "8",
|
||||
FlashBoard::HeltecV4 => "9",
|
||||
}
|
||||
}
|
||||
|
||||
/// rnodeconf's band choice is a coarse RF-frontend bootstrap parameter
|
||||
/// (868/915/923 MHz), not the final operating frequency — that's still
|
||||
/// configured later via the daemon's interface config, same as today. This
|
||||
/// is a best-effort mapping from the node's persisted Meshtastic-style
|
||||
/// region code (see `mesh::meshtastic::region_name_to_code`) down to
|
||||
/// rnodeconf's 3-way menu; regions with no exact 868/923 match fall back to
|
||||
/// 915 MHz as the broadest-compatibility default.
|
||||
fn rnodeconf_band_menu_number(lora_region: Option<&str>) -> &'static str {
|
||||
match lora_region.map(|s| s.trim().to_uppercase()) {
|
||||
Some(r) if r.contains("868") => "1",
|
||||
Some(r) if r.contains("923") => "3",
|
||||
_ => "2",
|
||||
}
|
||||
}
|
||||
|
||||
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
|
||||
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
|
||||
/// --help` AND a real end-to-end flash on real hardware) — this is the
|
||||
/// RNode-side equivalent of our "always erase before write" default, since
|
||||
/// autoinstall doesn't try to preserve any existing on-device state.
|
||||
async fn rnodeconf_autoinstall(
|
||||
path: &str,
|
||||
board: FlashBoard,
|
||||
lora_region: Option<&str>,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
job.set_stage(FlashStage::Autoinstalling).await;
|
||||
let bin = rnodeconf_bin();
|
||||
let mut cmd = if Path::new(&bin).exists() {
|
||||
Command::new(bin)
|
||||
} else {
|
||||
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
|
||||
Command::new("rnodeconf")
|
||||
};
|
||||
cmd.args(["--autoinstall", path]);
|
||||
let stdin = format!(
|
||||
"{}\n\n{}\ny\n",
|
||||
rnodeconf_device_menu_number(board),
|
||||
rnodeconf_band_menu_number(lora_region)
|
||||
);
|
||||
run_streamed(cmd, Some(stdin.into_bytes()), job)
|
||||
.await
|
||||
.context("archy-rnodeconf --autoinstall failed")
|
||||
}
|
||||
|
||||
// ─── Subprocess streaming ────────────────────────────────────────────────
|
||||
|
||||
fn percent_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"\((\d{1,3})\s*%\)").expect("valid regex"))
|
||||
}
|
||||
|
||||
async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJob>) -> Result<()> {
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
if stdin.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
// Deliberately NOT kill_on_drop: an interrupted erase/write can leave
|
||||
// the chip in a worse state than either finished or unstarted (see the
|
||||
// cancellation-safety note in mesh flashing docs). The job is expected
|
||||
// to run to completion or fail on its own.
|
||||
let mut child = cmd.spawn().context("Failed to start subprocess")?;
|
||||
|
||||
if let Some(bytes) = stdin {
|
||||
if let Some(mut child_stdin) = child.stdin.take() {
|
||||
child_stdin
|
||||
.write_all(&bytes)
|
||||
.await
|
||||
.context("Writing to subprocess stdin")?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let job = Arc::clone(job);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(cap) = percent_regex().captures(&line) {
|
||||
if let Ok(pct) = cap[1].parse::<u8>() {
|
||||
job.set_percent(pct).await;
|
||||
}
|
||||
}
|
||||
job.push_log(line).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let job = Arc::clone(job);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
job.push_log(line).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let status = child.wait().await.context("Waiting for subprocess")?;
|
||||
for t in tasks {
|
||||
let _ = t.await;
|
||||
}
|
||||
if !status.success() {
|
||||
// Exit status alone isn't diagnosable — the actual esptool/rnodeconf
|
||||
// stderr (already captured into job.log_tail by the reader tasks
|
||||
// above) is what actually explains a failure. Confirmed live
|
||||
// 2026-07-23: a bare "Command exited with exit status: 1" told us
|
||||
// nothing when esptool's real error was sitting in the log tail the
|
||||
// whole time, only visible via the UI's live poll, not journald.
|
||||
let tail: Vec<String> = job
|
||||
.snapshot()
|
||||
.await
|
||||
.log_tail
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect();
|
||||
anyhow::bail!("Command exited with {status}\n{}", tail.join("\n"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -578,7 +578,7 @@ pub(super) async fn handle_identity_received(
|
||||
.insert(contact_id, shared_secret);
|
||||
|
||||
// Update peer record
|
||||
let peer = MeshPeer {
|
||||
let mut peer = MeshPeer {
|
||||
contact_id,
|
||||
// .get(): a malformed DID shorter than the "did:key:" prefix must
|
||||
// not panic the listener on a radio-supplied string.
|
||||
@@ -607,6 +607,24 @@ pub(super) async fn handle_identity_received(
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
let is_new = !peers.contains_key(&contact_id);
|
||||
if let Some(existing) = peers.get(&contact_id) {
|
||||
// This id is shared with the federation-seeded row for the same
|
||||
// node (that's the point — identity adverts MERGE, not duplicate).
|
||||
// The wholesale insert below must not stomp the federation row's
|
||||
// real node name with our synthetic "Archy-…" placeholder — with
|
||||
// Reticulum re-emitting identity adverts every announce tick,
|
||||
// that renamed every federated contact once a minute. Same for a
|
||||
// known position: keep it rather than nulling it out.
|
||||
if !existing.advert_name.trim().is_empty()
|
||||
&& !existing.advert_name.starts_with("Archy-")
|
||||
{
|
||||
peer.advert_name = existing.advert_name.clone();
|
||||
}
|
||||
if peer.lat.is_none() {
|
||||
peer.lat = existing.lat;
|
||||
peer.lon = existing.lon;
|
||||
}
|
||||
}
|
||||
peers.insert(contact_id, peer.clone());
|
||||
is_new
|
||||
};
|
||||
|
||||
@@ -557,6 +557,10 @@ pub fn spawn_mesh_listener(
|
||||
let mut shutdown = shutdown;
|
||||
let mut cmd_rx = cmd_rx;
|
||||
let mut reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
// Mutable so a successful auto-detect can pin the firmware kind for
|
||||
// the rest of this listener's lifetime — see the pin-on-first-success
|
||||
// block below for why.
|
||||
let mut device_kind = device_kind;
|
||||
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
|
||||
// builds a fresh device struct (contacts/current_region/etc. all
|
||||
// start empty), so per-device session state is naturally isolated
|
||||
@@ -628,6 +632,45 @@ pub fn spawn_mesh_listener(
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the firmware kind after the first successful auto-detect.
|
||||
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
|
||||
// after clearing a stale pin), EVERY reconnect re-runs the full
|
||||
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
|
||||
// candidate past the first does its own open() with the DTR/RTS
|
||||
// reset both boards need, so a device correctly identified as
|
||||
// Meshtastic still gets reset once for the failed Meshcore
|
||||
// attempt before Meshtastic's own open() resets it again. That
|
||||
// doubled the reset count on every single reconnect indefinitely,
|
||||
// not just during initial detection. Once auto-detect has
|
||||
// identified the device this listener is actually talking to,
|
||||
// there's no reason to keep guessing on subsequent reconnects —
|
||||
// pin it, both in this task's own loop (takes effect
|
||||
// immediately) and on disk (survives a service restart). A
|
||||
// genuine hot-swap to different firmware is still handled: the
|
||||
// setup modal's `mesh.probe-device` always re-probes unpinned,
|
||||
// and the flash flow already clears this pin on its own.
|
||||
if device_kind.is_none() {
|
||||
let detected = state.status.read().await.device_type;
|
||||
if detected != super::types::DeviceType::Unknown {
|
||||
device_kind = Some(detected);
|
||||
match super::load_config(&data_dir).await {
|
||||
Ok(mut cfg) if cfg.device_kind.is_none() => {
|
||||
cfg.device_kind = Some(detected);
|
||||
if let Err(e) = super::save_config(&data_dir, &cfg).await {
|
||||
warn!("Failed to persist auto-detected device_kind: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
kind = %detected,
|
||||
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to disconnected. device_type/firmware_version are
|
||||
// reset too — they were previously left holding the LAST radio's
|
||||
// identity, so after a hot-swap the UI showed the old firmware
|
||||
|
||||
@@ -270,6 +270,7 @@ async fn auto_detect_and_open(
|
||||
our_x25519_pubkey_hex: &str,
|
||||
device_kind: Option<DeviceType>,
|
||||
skip_path: Option<&str>,
|
||||
advert_name: Option<&str>,
|
||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let mut paths = super::super::serial::detect_serial_devices().await;
|
||||
// When falling back from a just-failed preferred path, don't probe that
|
||||
@@ -287,6 +288,7 @@ async fn auto_detect_and_open(
|
||||
None => "No serial devices found in /dev".to_string(),
|
||||
});
|
||||
}
|
||||
info!(candidates = ?paths, "Auto-detect candidate ports for this attempt");
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
|
||||
@@ -304,6 +306,7 @@ async fn auto_detect_and_open(
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
advert_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -465,6 +468,7 @@ async fn open_preferred_path(
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
device_kind: Option<DeviceType>,
|
||||
advert_name: Option<&str>,
|
||||
) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||
// Pinned: try only the configured firmware and surface its own error —
|
||||
// never fall through to (and inject probe bytes into) another firmware's
|
||||
@@ -497,6 +501,7 @@ async fn open_preferred_path(
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
advert_name,
|
||||
)
|
||||
.await
|
||||
.context("Could not open preferred path as Reticulum")?;
|
||||
@@ -510,40 +515,21 @@ async fn open_preferred_path(
|
||||
};
|
||||
}
|
||||
|
||||
// Reticulum first — see the matching comment on auto_detect_and_open:
|
||||
// its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while
|
||||
// trying Meshcore/Meshtastic first was observed leaving a real RNode
|
||||
// board unresponsive by the time Reticulum's turn came.
|
||||
match ReticulumLink::open(
|
||||
path,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
|
||||
Err(e) => {
|
||||
debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode")
|
||||
}
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
|
||||
}
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
Err(e) => Err(e).context("Preferred path is not a working Meshtastic device"),
|
||||
},
|
||||
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
|
||||
}
|
||||
// Unpinned: don't probe this path ourselves at all. Confirmed live
|
||||
// 2026-07-23 — this function used to run its own Reticulum→Meshcore→
|
||||
// Meshtastic sequence here, and the caller (run_mesh_session) falls
|
||||
// back to `auto_detect_and_open` on any error, which scans every
|
||||
// candidate path (this one included) with the exact same three-protocol
|
||||
// sequence. With a single physical radio — the overwhelmingly common
|
||||
// case — `path` here IS the one candidate `auto_detect_and_open` is
|
||||
// about to try, so every unpinned reconnect was resetting the board via
|
||||
// Reticulum/Meshcore/Meshtastic's DTR/RTS toggle TWICE: once here, once
|
||||
// again moments later in auto-detect. Bailing immediately (no port
|
||||
// access at all) means auto-detect's single pass is the only one that
|
||||
// ever touches the port when nothing is pinned yet. (auto_detect_and_open
|
||||
// carries the advert_name threading from main, so nothing is lost.)
|
||||
let _ = advert_name;
|
||||
anyhow::bail!("No device_kind pin — deferring to auto-detect for {path}")
|
||||
}
|
||||
|
||||
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
|
||||
@@ -556,6 +542,7 @@ async fn open_reticulum_tcp(
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
advert_name: Option<&str>,
|
||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let mut dev = match cfg {
|
||||
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
|
||||
@@ -563,6 +550,7 @@ async fn open_reticulum_tcp(
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
advert_name,
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP server interface")?,
|
||||
@@ -571,6 +559,7 @@ async fn open_reticulum_tcp(
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
advert_name,
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP client interface")?,
|
||||
@@ -987,49 +976,110 @@ pub(super) async fn run_mesh_session(
|
||||
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
|
||||
// for existing serial/RNode deployments where `reticulum_tcp` is None.
|
||||
//
|
||||
// The name we present on the mesh: the operator's configured mesh name /
|
||||
// server name, falling back to a DID fragment. Computed BEFORE the open
|
||||
// sequence because Reticulum needs it at daemon-spawn time — the RNS
|
||||
// announce carries it from the very first announce. (Meshcore/Meshtastic
|
||||
// still receive it via set_advert_name after connect, below.)
|
||||
let desired_advert_name: String = match server_name {
|
||||
// Meshcore firmware limits advert names — truncate to 20 chars.
|
||||
Some(name) => name.chars().take(20).collect(),
|
||||
None => format!(
|
||||
"Archy-{}",
|
||||
our_did.chars().skip(8).take(8).collect::<String>()
|
||||
),
|
||||
};
|
||||
|
||||
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
|
||||
// can't interleave its own handshakes on the same tty (see the lock's
|
||||
// doc comment). Held only until the device is opened, then released.
|
||||
let port_guard = PORT_OPEN_LOCK.lock().await;
|
||||
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
|
||||
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
|
||||
} else if let Some(path) = preferred_path {
|
||||
match open_preferred_path(
|
||||
path,
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((dev, info)) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
//
|
||||
// The sequence is raced against the shutdown signal: probes/handshakes
|
||||
// can take 10s+, and without this a stop() issued mid-probe (config
|
||||
// change, disable, rename) always burned the full listener-shutdown
|
||||
// timeout and ended in a hard abort — observed live on archi-dev-box
|
||||
// 2026-07-28. Dropping the open future mid-probe is safe: it holds no
|
||||
// session state yet and the port guard/serial handle close with it.
|
||||
let open_fut = async {
|
||||
let port_guard = PORT_OPEN_LOCK.lock().await;
|
||||
let result = if let Some(tcp_cfg) = &reticulum_tcp {
|
||||
open_reticulum_tcp(
|
||||
tcp_cfg,
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await
|
||||
} else if let Some(path) = preferred_path {
|
||||
if device_kind.is_none() {
|
||||
// Unpinned: open_preferred_path bails without ever touching
|
||||
// the port, so the preferred path has NOT been probed this
|
||||
// cycle — auto-detect must keep it as a candidate
|
||||
// (skip_path=None). Passing Some(path) here excluded the only
|
||||
// radio on single-device nodes and the mesh never came up.
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
Some(path),
|
||||
None,
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await?
|
||||
.await
|
||||
} else {
|
||||
match open_preferred_path(
|
||||
path,
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((dev, info)) => Ok((path.to_string(), dev, info)),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
Some(path),
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
None,
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await
|
||||
};
|
||||
drop(port_guard);
|
||||
result
|
||||
};
|
||||
let (device_path, mut device, device_info) = tokio::select! {
|
||||
res = open_fut => res?,
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
info!("Shutdown requested during device open — ending session");
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!("shutdown signal changed during device open");
|
||||
}
|
||||
} else {
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
drop(port_guard);
|
||||
|
||||
// Update status
|
||||
{
|
||||
@@ -1176,19 +1226,14 @@ pub(super) async fn run_mesh_session(
|
||||
}
|
||||
}
|
||||
|
||||
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
||||
// falling back to the DID fragment if no name is configured. Skipped in
|
||||
// keep-as-is mode — the radio keeps the name it came with (already
|
||||
// reflected in status from the connect handshake).
|
||||
if manage_radio {
|
||||
let advert_name = if let Some(name) = server_name {
|
||||
// Meshcore firmware limits advert names — truncate to 20 chars
|
||||
name.chars().take(20).collect::<String>()
|
||||
} else {
|
||||
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
||||
format!("Archy-{}", short_did)
|
||||
};
|
||||
if let Err(e) = device.set_advert_name(&advert_name).await {
|
||||
// Set advert name to the configured mesh/server name (computed above).
|
||||
// Skipped in keep-as-is mode for radio-held names — the radio keeps the
|
||||
// name it came with (already reflected in status from the connect
|
||||
// handshake). Reticulum is exempt from keep-as-is: its display name
|
||||
// lives in OUR daemon (the RNode holds no name), so "keep as is" has
|
||||
// nothing to preserve and an unnamed node would be anonymous on RNS.
|
||||
if manage_radio || matches!(device, MeshRadioDevice::Reticulum(_)) {
|
||||
if let Err(e) = device.set_advert_name(&desired_advert_name).await {
|
||||
warn!("Failed to set advert name: {}", e);
|
||||
} else {
|
||||
// Reflect the post-set name in MeshStatus too so the UI can filter
|
||||
@@ -1196,7 +1241,7 @@ pub(super) async fn run_mesh_session(
|
||||
// still carries whatever pre-set name the firmware reported and the
|
||||
// self-filter never matches.
|
||||
let mut status = state.status.write().await;
|
||||
status.self_advert_name = Some(advert_name.clone());
|
||||
status.self_advert_name = Some(desired_advert_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1489,6 +1534,15 @@ async fn handle_send_command(
|
||||
} else {
|
||||
*consecutive_write_failures = 0;
|
||||
}
|
||||
// The self-advert alone is a no-op for discovery on Meshtastic
|
||||
// (heartbeat + time carry no identity) — the NodeInfo broadcast
|
||||
// is what makes peers learn/refresh us. want_response=true so
|
||||
// neighbours answer with their own NodeInfo: the user pressed
|
||||
// Broadcast to be seen AND to see who's out there. No-op on
|
||||
// Meshcore/Reticulum, whose self-advert already carries identity.
|
||||
if let Err(e) = device.send_nodeinfo_advert(true).await {
|
||||
warn!("Failed to send NodeInfo advert: {}", e);
|
||||
}
|
||||
}
|
||||
MeshCommand::RebootRadio { seconds } => {
|
||||
if let Err(e) = device.reboot(seconds).await {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod alerts;
|
||||
pub mod bitcoin_relay;
|
||||
pub mod crypto;
|
||||
pub mod flash;
|
||||
pub mod listener;
|
||||
pub mod meshtastic;
|
||||
pub mod message_types;
|
||||
@@ -477,6 +478,31 @@ impl Default for MeshConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mesh config file has ever been written for this node — lets the
|
||||
/// boot path distinguish "operator explicitly disabled mesh" (file exists,
|
||||
/// enabled=false) from "never configured" (no file), which is the only case
|
||||
/// radio auto-enable should touch.
|
||||
pub fn config_file_exists(data_dir: &Path) -> bool {
|
||||
data_dir.join(MESH_CONFIG_FILE).exists()
|
||||
}
|
||||
|
||||
/// True when `new` differs from `old` in any field a running mesh session
|
||||
/// captured by value at spawn (device path/kind, advert name, region, PHY
|
||||
/// params, channel, manage_radio, TCP interface) — i.e. when applying `new`
|
||||
/// to a live service requires a listener restart. Fields the session reads
|
||||
/// live from shared state (broadcast flags, assistant settings, steganography
|
||||
/// mode, …) deliberately don't trigger a restart.
|
||||
fn session_config_changed(old: &MeshConfig, new: &MeshConfig) -> bool {
|
||||
old.device_path != new.device_path
|
||||
|| old.device_kind != new.device_kind
|
||||
|| old.advert_name != new.advert_name
|
||||
|| old.lora_region != new.lora_region
|
||||
|| old.lora_radio_params != new.lora_radio_params
|
||||
|| old.channel_name != new.channel_name
|
||||
|| old.manage_radio != new.manage_radio
|
||||
|| old.reticulum_tcp != new.reticulum_tcp
|
||||
}
|
||||
|
||||
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
|
||||
let path = data_dir.join(MESH_CONFIG_FILE);
|
||||
if !path.exists() {
|
||||
@@ -742,10 +768,18 @@ impl MeshService {
|
||||
self.server_name = name;
|
||||
}
|
||||
|
||||
/// Start the background mesh listener.
|
||||
/// Start the background mesh listener. Idempotent: if the listener is
|
||||
/// already running, this is a harmless no-op rather than an error —
|
||||
/// confirmed live 2026-07-23, a real race between the flash job's own
|
||||
/// post-flash restart and a concurrent user "Keep As Is" click (both
|
||||
/// legitimately trying to ensure the listener is running) surfaced this
|
||||
/// as a user-facing "Mesh listener already running" RPC error. Ensuring
|
||||
/// the listener is running is the intent every caller actually has;
|
||||
/// whichever caller's start() happens to win the race, the other
|
||||
/// finding it already satisfied is success, not failure.
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
if self.listener_handle.is_some() {
|
||||
anyhow::bail!("Mesh listener already running");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
@@ -764,7 +798,14 @@ impl MeshService {
|
||||
self.our_ed_pubkey_hex.clone(),
|
||||
self.our_x25519_secret,
|
||||
self.our_x25519_pubkey_hex.clone(),
|
||||
self.server_name.clone(),
|
||||
// The mesh-page "Name on the mesh" (config.advert_name) wins over
|
||||
// the server name — it existed as write-only config with no reader
|
||||
// until this line, which is why renaming on the Mesh page never
|
||||
// changed anything on the air.
|
||||
self.config
|
||||
.advert_name
|
||||
.clone()
|
||||
.or_else(|| self.server_name.clone()),
|
||||
self.config.lora_region.clone(),
|
||||
self.config.lora_radio_params,
|
||||
self.config.channel_name.clone(),
|
||||
@@ -1055,13 +1096,12 @@ impl MeshService {
|
||||
self.state.peers.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Probe a serial port for a mesh radio without provisioning or keeping
|
||||
/// it — powers the hot-swap "device detected" modal's current-details
|
||||
/// view. Refuses to probe the port the live session currently occupies
|
||||
/// (the probe would steal the serial port from under the session); a
|
||||
/// Refuse to probe the port the live session currently occupies (the
|
||||
/// probe would steal the serial port from under the session); a
|
||||
/// detected-but-not-connected port is fair game, accepting a benign race
|
||||
/// with the reconnect loop (whichever loses just retries).
|
||||
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
|
||||
/// with the reconnect loop (whichever loses just retries). Split out from
|
||||
/// the actual probe on purpose — see `probe_device`'s doc comment.
|
||||
pub async fn ensure_probe_allowed(&self, path: &str) -> Result<()> {
|
||||
let status = self.state.status.read().await;
|
||||
if status.device_connected {
|
||||
if let Some(active) = status.device_path.as_deref() {
|
||||
@@ -1076,8 +1116,7 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(status);
|
||||
listener::probe_device(path).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get message history.
|
||||
@@ -1124,7 +1163,32 @@ impl MeshService {
|
||||
let peer = peers
|
||||
.get(&contact_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
|
||||
let pubkey_hex = peer
|
||||
// Cross-transport twin resolution: callers frequently hold the
|
||||
// FEDERATION twin's contact_id (the UI's merged conversation row),
|
||||
// whose pubkey_hex is the Archipelago ed25519 key — NOT a radio
|
||||
// routing key. Sending a Reticulum resource with that prefix fails
|
||||
// with "Unknown Reticulum prefix" (observed live 2026-07-28,
|
||||
// image-over-LoRa to a merged contact). Route via the radio twin —
|
||||
// same arch identity, radio-range id — whose pubkey_hex is the
|
||||
// actual over-the-air routing key (RNS dest hash / firmware key).
|
||||
let radio_peer = if peer.contact_id >= FEDERATION_CONTACT_ID_BASE {
|
||||
peer.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.and_then(|arch| {
|
||||
peers.values().find(|p| {
|
||||
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
||||
&& p.arch_pubkey_hex.as_deref() == Some(arch)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Peer is federation-only (no radio twin) — not reachable over the radio"
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
peer
|
||||
};
|
||||
let pubkey_hex = radio_peer
|
||||
.pubkey_hex
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
|
||||
@@ -1262,12 +1326,45 @@ impl MeshService {
|
||||
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
// Transport policy: LoRa first when it can actually carry the message,
|
||||
// then FIPS, then Tor. A federation-synthetic id (what the UI's merged
|
||||
// conversation holds) used to ALWAYS take the federation path, even
|
||||
// when the very same node was sitting one LoRa hop away — so chats
|
||||
// between two radio-equipped nodes silently rode FIPS/Tor. If the
|
||||
// federation contact has a REACHABLE radio twin (same archipelago
|
||||
// identity, radio-range id) and the payload fits the radio, skip the
|
||||
// federation branch: the fall-through LoRa path twin-resolves the
|
||||
// routing key via peer_dest_prefix.
|
||||
let device_connected = self.state.status.read().await.device_connected;
|
||||
let radio_twin_reachable =
|
||||
is_federation_synthetic && !exceeds_lora && device_connected && {
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||
.map(|arch| {
|
||||
peers.values().any(|p| {
|
||||
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
||||
&& p.reachable
|
||||
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mesh_only_mode = load_config(&self.data_dir)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|cfg| cfg.mesh_only_mode)
|
||||
.unwrap_or(false);
|
||||
if radio_twin_reachable && !mesh_only_mode {
|
||||
tracing::info!(
|
||||
contact_id,
|
||||
bytes = wire.len(),
|
||||
"Radio-first routing: federation contact has a reachable radio twin — sending over LoRa"
|
||||
);
|
||||
}
|
||||
if !mesh_only_mode
|
||||
&& !radio_twin_reachable
|
||||
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
|
||||
{
|
||||
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
||||
@@ -2091,6 +2188,7 @@ impl MeshService {
|
||||
save_config(&self.data_dir, &config).await?;
|
||||
|
||||
let was_enabled = self.config.enabled;
|
||||
let needs_session_restart = session_config_changed(&self.config, &config);
|
||||
self.config = config.clone();
|
||||
|
||||
// Update the status to reflect new config
|
||||
@@ -2115,11 +2213,31 @@ impl MeshService {
|
||||
status.firmware_version = None;
|
||||
status.self_node_id = None;
|
||||
status.peer_count = 0;
|
||||
} else if config.enabled && was_enabled && needs_session_restart {
|
||||
info!("Mesh session config changed — restarting listener to apply");
|
||||
self.stop().await;
|
||||
self.start()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The service's current (last-applied) config.
|
||||
pub fn config(&self) -> &MeshConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Restart the listener (if running) so it picks up out-of-band state a
|
||||
/// spawn captured by value — currently the server name pushed by
|
||||
/// `server.set-name`.
|
||||
pub async fn restart_listener_if_running(&mut self) -> Result<()> {
|
||||
if self.listener_handle.is_some() {
|
||||
self.stop().await;
|
||||
self.start()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a reference to shared state (for RPC handlers).
|
||||
pub fn shared_state(&self) -> Arc<MeshState> {
|
||||
Arc::clone(&self.state)
|
||||
@@ -2241,6 +2359,46 @@ async fn bitcoin_rpc_getblockheader_by_height(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_config_change_detection() {
|
||||
let base = MeshConfig::default();
|
||||
|
||||
// Same config → no restart.
|
||||
assert!(!session_config_changed(&base, &base.clone()));
|
||||
|
||||
// Every session-captured field individually triggers a restart.
|
||||
let mut c = base.clone();
|
||||
c.device_kind = Some(types::DeviceType::Reticulum);
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.device_path = Some("/dev/ttyUSB0".into());
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.advert_name = Some("RNode Shaza".into());
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.manage_radio = !base.manage_radio;
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.lora_region = Some("EU_868".into());
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.channel_name = Some("private-net".into());
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
// Live-read fields must NOT force a session restart.
|
||||
let mut c = base.clone();
|
||||
c.broadcast_identity = !base.broadcast_identity;
|
||||
c.announce_block_headers = !base.announce_block_headers;
|
||||
c.assistant_enabled = !base.assistant_enabled;
|
||||
assert!(!session_config_changed(&base, &c));
|
||||
}
|
||||
|
||||
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id,
|
||||
|
||||
@@ -121,6 +121,7 @@ fn daemon_command(
|
||||
identity_key: &Path,
|
||||
archy_ed_pubkey_hex: Option<&str>,
|
||||
archy_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Command {
|
||||
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
|
||||
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
|
||||
@@ -153,12 +154,27 @@ fn daemon_command(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Archy nodes are RNS transport nodes: they relay traffic and rebroadcast
|
||||
// announces, so archy nodes (and Sideband/NomadNet peers) beyond direct RF
|
||||
// range discover and reach each other through any archy node in between.
|
||||
// Edge-only operation (`enable_transport = no`) left every node an island
|
||||
// limited to its own radio horizon.
|
||||
cmd.arg("--enable-transport");
|
||||
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
|
||||
cmd.arg("--archy-ed-pubkey-hex")
|
||||
.arg(ed)
|
||||
.arg("--archy-x25519-pubkey-hex")
|
||||
.arg(x);
|
||||
}
|
||||
// The RNS-visible display name (what Sideband/NomadNet/other archy nodes
|
||||
// show for us). Without this the daemon falls back to its argparse
|
||||
// default and every archy node announces the same anonymous name.
|
||||
if let Some(name) = display_name {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() {
|
||||
cmd.arg("--display-name").arg(name);
|
||||
}
|
||||
}
|
||||
// Run the daemon as its own process-group leader. The packaged binary is
|
||||
// a PyInstaller one-file bootloader that forks the real Python process;
|
||||
// making it a group leader lets shutdown signal the WHOLE group so the
|
||||
@@ -207,6 +223,10 @@ struct ReticulumPeer {
|
||||
/// `bind_federation_twins`, which those two transports rely on instead).
|
||||
arch_pubkey_hex: Option<String>,
|
||||
reachable: bool,
|
||||
/// Unix time of the last announce heard from this peer over the air.
|
||||
/// In-memory only (a persisted value would be stale by definition) —
|
||||
/// `0` after a restart until the peer re-announces.
|
||||
last_advert_at: u64,
|
||||
}
|
||||
|
||||
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
||||
@@ -245,6 +265,11 @@ pub struct ReticulumLink {
|
||||
/// matching `resource_progress`/`resource_sent`/`resource_failed` events
|
||||
/// back to a log line; sends are fire-and-forget (see `send_resource`).
|
||||
resource_id_counter: u64,
|
||||
/// Set when the daemon's RPC socket closes or its process exits. Once
|
||||
/// true, `try_recv_frame` returns an error so the session loop tears
|
||||
/// down and the outer reconnect loop respawns the daemon — without this
|
||||
/// a dead daemon was invisible until the 30-minute RX-stall watchdog.
|
||||
daemon_gone: bool,
|
||||
}
|
||||
|
||||
impl ReticulumLink {
|
||||
@@ -269,6 +294,7 @@ impl ReticulumLink {
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
probe_rnode(path)
|
||||
.await
|
||||
@@ -278,6 +304,7 @@ impl ReticulumLink {
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -290,6 +317,7 @@ impl ReticulumLink {
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
|
||||
anyhow::ensure!(
|
||||
@@ -302,6 +330,7 @@ impl ReticulumLink {
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -313,6 +342,7 @@ impl ReticulumLink {
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
anyhow::ensure!(
|
||||
!targets.is_empty(),
|
||||
@@ -323,6 +353,7 @@ impl ReticulumLink {
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -332,6 +363,7 @@ impl ReticulumLink {
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
// Keep the RPC socket under the archipelago-owned data dir (not the
|
||||
// shared system temp dir) so its access is bounded by the same
|
||||
@@ -379,6 +411,7 @@ impl ReticulumLink {
|
||||
&identity_key,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
);
|
||||
cmd.env("TMPDIR", &tmp_dir);
|
||||
let child = cmd
|
||||
@@ -450,6 +483,7 @@ impl ReticulumLink {
|
||||
peers_file: runtime_dir.join("peers.json"),
|
||||
inbound: std::collections::VecDeque::new(),
|
||||
resource_id_counter: 0,
|
||||
daemon_gone: false,
|
||||
};
|
||||
link.load_persisted_peers();
|
||||
Ok(link)
|
||||
@@ -472,21 +506,53 @@ impl ReticulumLink {
|
||||
};
|
||||
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
||||
self.prefix_to_hash.insert(prefix, hash);
|
||||
// Heal names persisted by pre-2026-07-28 builds, which could
|
||||
// store a raw `ARCHY:…` identity blob as the display name (seen
|
||||
// live on archi-dev-box). Blob-only announces assert no name, so
|
||||
// nothing would ever overwrite it — swap in the placeholder.
|
||||
let display_name = if p.display_name.starts_with("ARCHY:") {
|
||||
format!("Reticulum {}", hex::encode(&hash[..4]))
|
||||
} else {
|
||||
p.display_name
|
||||
};
|
||||
self.peers.insert(
|
||||
hash,
|
||||
ReticulumPeer {
|
||||
dest_hash: hash,
|
||||
display_name: p.display_name,
|
||||
display_name,
|
||||
arch_pubkey_hex: p.arch_pubkey_hex,
|
||||
// Reachability is a live property, not a persisted fact —
|
||||
// start conservative and let the first real event refresh it.
|
||||
reachable: false,
|
||||
last_advert_at: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
|
||||
}
|
||||
|
||||
/// Resolve a caller-supplied 6-byte routing prefix to a full 16-byte RNS
|
||||
/// destination hash. Two shapes arrive here depending on how the contact
|
||||
/// record was born (observed live 2026-07-28, image-to-merged-contact):
|
||||
/// 1. the peer's RNS dest-hash prefix (contacts created from an RNS
|
||||
/// announce) — direct `prefix_to_hash` hit;
|
||||
/// 2. the peer's Archipelago ed25519 pubkey prefix (radio twins bound
|
||||
/// via the ARCHY announce blob store the arch key as `pubkey_hex`) —
|
||||
/// no dest-hash match possible, so fall back to scanning peers whose
|
||||
/// announce-bound `arch_pubkey_hex` starts with the prefix.
|
||||
fn resolve_dest_hash(&self, prefix: &[u8; 6]) -> Option<[u8; 16]> {
|
||||
if let Some(hash) = self.prefix_to_hash.get(prefix) {
|
||||
return Some(*hash);
|
||||
}
|
||||
let hex_prefix = hex::encode(prefix);
|
||||
self.peers.values().find_map(|p| {
|
||||
p.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.filter(|arch| arch.starts_with(&hex_prefix))
|
||||
.map(|_| p.dest_hash)
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort sync write of the current peer table — called after any
|
||||
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
|
||||
/// not per-message) so a blocking write here is a fine trade for keeping
|
||||
@@ -533,10 +599,12 @@ impl ReticulumLink {
|
||||
}
|
||||
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
// The daemon's display_name is fixed at spawn time (CLI arg); changing
|
||||
// it live would require an RPC verb we haven't added. Track locally so
|
||||
// `advert_name()` reflects the caller's intent even though the
|
||||
// RNS-visible name doesn't change until the daemon restarts.
|
||||
// Live rename: the daemon's `set_name` verb updates the LXMF delivery
|
||||
// destination's display_name and re-announces, so peers pick the new
|
||||
// name up on their next announce receipt. Also tracked locally so
|
||||
// `advert_name()` reflects it immediately.
|
||||
self.send_rpc(serde_json::json!({"cmd": "set_name", "name": name}))
|
||||
.await?;
|
||||
self.display_name = Some(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
@@ -562,9 +630,7 @@ impl ReticulumLink {
|
||||
payload: &[u8],
|
||||
) -> Result<()> {
|
||||
let dest_hash = self
|
||||
.prefix_to_hash
|
||||
.get(dest_pubkey_prefix)
|
||||
.copied()
|
||||
.resolve_dest_hash(dest_pubkey_prefix)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
@@ -606,9 +672,7 @@ impl ReticulumLink {
|
||||
) -> Result<()> {
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let dest_hash = self
|
||||
.prefix_to_hash
|
||||
.get(dest_pubkey_prefix)
|
||||
.copied()
|
||||
.resolve_dest_hash(dest_pubkey_prefix)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
@@ -638,9 +702,7 @@ impl ReticulumLink {
|
||||
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let dest_hash = self
|
||||
.prefix_to_hash
|
||||
.get(dest_pubkey_prefix)
|
||||
.copied()
|
||||
.resolve_dest_hash(dest_pubkey_prefix)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
@@ -685,7 +747,7 @@ impl ReticulumLink {
|
||||
.map(|p| ParsedContact {
|
||||
public_key_hex: hex::encode(p.dest_hash),
|
||||
advert_name: p.display_name.clone(),
|
||||
last_advert: 0,
|
||||
last_advert: p.last_advert_at as u32,
|
||||
// Deliberately not 1 ("friend"/meshcore type), so the
|
||||
// meshcore-only auto-heal `reset_contact_path` loop in
|
||||
// `refresh_contacts` (session.rs) skips these — RNS does its
|
||||
@@ -718,6 +780,12 @@ impl ReticulumLink {
|
||||
|
||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||
self.drain_events().await;
|
||||
if self.daemon_gone {
|
||||
// Surface the dead daemon as a hard error so run_mesh_session
|
||||
// bails and the outer reconnect loop respawns it, instead of
|
||||
// idling on an empty queue until the RX-stall watchdog fires.
|
||||
anyhow::bail!("reticulum-daemon is gone (process exited or RPC socket closed)");
|
||||
}
|
||||
Ok(self.inbound.pop_front())
|
||||
}
|
||||
|
||||
@@ -743,6 +811,15 @@ impl ReticulumLink {
|
||||
/// Drain any buffered daemon events (non-blocking) and translate them into
|
||||
/// peer-table updates / synthetic InboundFrames.
|
||||
async fn drain_events(&mut self) {
|
||||
// A daemon that died without closing the socket cleanly (SIGKILL,
|
||||
// OOM) leaves the socket readable-with-EOF or just silent — poll the
|
||||
// child's exit status too so death is never mistaken for quiet.
|
||||
if !self.daemon_gone {
|
||||
if let Ok(Some(status)) = self.child.try_wait() {
|
||||
warn!(%status, "reticulum-daemon process exited");
|
||||
self.daemon_gone = true;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read =
|
||||
@@ -750,10 +827,16 @@ impl ReticulumLink {
|
||||
.await;
|
||||
let n = match read {
|
||||
Ok(Ok(n)) => n,
|
||||
_ => break, // timeout (no data) or read error — stop draining
|
||||
Ok(Err(e)) => {
|
||||
warn!("Reticulum daemon RPC read failed: {}", e);
|
||||
self.daemon_gone = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => break, // timeout — no data buffered
|
||||
};
|
||||
if n == 0 {
|
||||
warn!("Reticulum daemon RPC connection closed");
|
||||
self.daemon_gone = true;
|
||||
break;
|
||||
}
|
||||
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
|
||||
@@ -775,6 +858,23 @@ impl ReticulumLink {
|
||||
};
|
||||
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
||||
self.prefix_to_hash.insert(prefix, hash);
|
||||
// Current daemons decode the LXMF announce app_data themselves
|
||||
// and hand us clean fields: `display_name` (LXMF-standard
|
||||
// msgpack name, Sideband-interoperable) and `archy_blob` (the
|
||||
// `ARCHY:n:` identity string, carried as an extra msgpack list
|
||||
// element stock clients ignore). The raw `app_data` text path
|
||||
// below remains for announces from pre-upgrade archy nodes,
|
||||
// whose app_data was EITHER the blob OR a bare-utf8 name.
|
||||
let explicit_name = ev
|
||||
.get("display_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let explicit_blob = ev
|
||||
.get("archy_blob")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.is_empty());
|
||||
let app_data_text = ev
|
||||
.get("app_data")
|
||||
.and_then(Value::as_str)
|
||||
@@ -794,12 +894,19 @@ impl ReticulumLink {
|
||||
// now carry the same `arch_pubkey_hex`, instead of relying on
|
||||
// `bind_federation_twins`'s advert_name matching, which never
|
||||
// matches here — see `display_name` below.
|
||||
let parsed_identity = app_data_text
|
||||
let legacy_identity = app_data_text
|
||||
.as_deref()
|
||||
.and_then(protocol::parse_identity_broadcast);
|
||||
let is_identity_blob = parsed_identity.is_some();
|
||||
if is_identity_blob {
|
||||
let text = app_data_text.clone().unwrap();
|
||||
let is_legacy_blob = legacy_identity.is_some();
|
||||
let identity_blob_text =
|
||||
explicit_blob.or_else(|| app_data_text.clone().filter(|_| is_legacy_blob));
|
||||
let parsed_identity = identity_blob_text
|
||||
.as_deref()
|
||||
.and_then(protocol::parse_identity_broadcast);
|
||||
if let Some(text) = identity_blob_text
|
||||
.as_deref()
|
||||
.filter(|_| parsed_identity.is_some())
|
||||
{
|
||||
let mut data = Vec::with_capacity(7 + text.len());
|
||||
data.push(0); // channel index — unused by the identity path
|
||||
data.extend_from_slice(&prefix);
|
||||
@@ -812,23 +919,31 @@ impl ReticulumLink {
|
||||
}
|
||||
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
|
||||
|
||||
let display_name = app_data_text
|
||||
.filter(|_| !is_identity_blob)
|
||||
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4])));
|
||||
let announced_name =
|
||||
pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
self.peers
|
||||
.entry(hash)
|
||||
.and_modify(|p| {
|
||||
p.display_name = display_name.clone();
|
||||
if let Some(name) = announced_name.clone() {
|
||||
p.display_name = name;
|
||||
}
|
||||
p.reachable = true;
|
||||
p.last_advert_at = now;
|
||||
if arch_pubkey_hex.is_some() {
|
||||
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
||||
}
|
||||
})
|
||||
.or_insert(ReticulumPeer {
|
||||
.or_insert_with(|| ReticulumPeer {
|
||||
dest_hash: hash,
|
||||
display_name,
|
||||
display_name: announced_name
|
||||
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))),
|
||||
arch_pubkey_hex,
|
||||
reachable: true,
|
||||
last_advert_at: now,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
@@ -844,16 +959,24 @@ impl ReticulumLink {
|
||||
// A peer that messages us without ever announcing still needs
|
||||
// to survive a restart — give it a placeholder name (the real
|
||||
// one, if any, arrives via a later "announce" and overwrites
|
||||
// this) so its routing entry alone doesn't get lost.
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
||||
{
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
});
|
||||
self.persist_peers();
|
||||
// this) so its routing entry alone doesn't get lost. An
|
||||
// existing entry is proof of life too: mark it reachable so a
|
||||
// restart-restored (reachable=false) peer that DMs us doesn't
|
||||
// stay red-dotted until its next announce.
|
||||
match self.peers.entry(source_hash) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
last_advert_at: 0,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
e.get_mut().reachable = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
|
||||
@@ -924,23 +1047,37 @@ impl ReticulumLink {
|
||||
.push_back(build_synthetic_frame(&prefix, &content));
|
||||
}
|
||||
Some("resource_recv") => {
|
||||
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let source_hex = ev
|
||||
.get("source_hash")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let Ok(source_hash) = parse_hash16(source_hex) else {
|
||||
// An empty source_hash means the sender's link wasn't
|
||||
// identified (pre-identify daemon build on the far end) —
|
||||
// the blob is undeliverable without attribution, but say
|
||||
// so instead of vanishing it.
|
||||
warn!(
|
||||
source = source_hex,
|
||||
"Dropping inbound Reticulum resource without valid source identity"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
||||
self.prefix_to_hash.insert(prefix, source_hash);
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
||||
{
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
});
|
||||
self.persist_peers();
|
||||
match self.peers.entry(source_hash) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
last_advert_at: 0,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
e.get_mut().reachable = true;
|
||||
}
|
||||
}
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let Some(data) = ev
|
||||
@@ -1089,8 +1226,10 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
||||
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
|
||||
// bridge chip) treat a DTR/RTS transition on open as a reset signal, the
|
||||
// same mechanism esptool uses to force bootloader entry. Deassert both
|
||||
// and let the board settle before writing the probe, or the reboot eats
|
||||
// the DETECT_RESP window below.
|
||||
// before writing the probe. Boards behind a USB-UART bridge (CP2102 on
|
||||
// the Heltec V3) get the reset pulse from the open() itself, before we
|
||||
// can deassert anything — that case is handled by the boot-settle retry
|
||||
// below.
|
||||
let _ = port.set_dtr(false);
|
||||
let _ = port.set_rts(false);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
@@ -1109,26 +1248,81 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
||||
0x00,
|
||||
KISS_FEND,
|
||||
];
|
||||
// Attempt 1: probe immediately. A board that did NOT reset on open (it
|
||||
// was already up — e.g. a re-probe of a running RNode) answers in well
|
||||
// under a second, so the fast path stays fast.
|
||||
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
||||
.await
|
||||
.context("RNode probe write timed out")?
|
||||
.context("RNode probe write failed")?;
|
||||
|
||||
let mut buf = [0u8; 256];
|
||||
let mut seen = Vec::new();
|
||||
let deadline = tokio::time::Instant::now() + PROBE_READ_TIMEOUT;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No DETECT_RESP. If the open() power-cycled the board (verified live on
|
||||
// a Heltec V3 RNode behind a CP2102: the ESP32 spends ~2.5-3s in boot
|
||||
// ROM + app init and silently eats anything written meanwhile, so the
|
||||
// first probe lands in the void), wait for its boot chatter to go quiet
|
||||
// and probe once more with a fresh response window.
|
||||
const BOOT_QUIET_WINDOW: Duration = Duration::from_millis(800);
|
||||
const BOOT_SETTLE_MAX: Duration = Duration::from_secs(6);
|
||||
let settle_deadline = tokio::time::Instant::now() + BOOT_SETTLE_MAX;
|
||||
let mut last_data = tokio::time::Instant::now();
|
||||
while tokio::time::Instant::now() < settle_deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
seen.extend_from_slice(&buf[..n]);
|
||||
// A late DETECT_RESP to the first write still counts.
|
||||
if contains_detect_resp(&seen) {
|
||||
return Ok(());
|
||||
}
|
||||
last_data = tokio::time::Instant::now();
|
||||
}
|
||||
_ => {
|
||||
if last_data.elapsed() >= BOOT_QUIET_WINDOW {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
||||
.await
|
||||
.context("RNode probe rewrite timed out")?
|
||||
.context("RNode probe rewrite failed")?;
|
||||
seen.clear();
|
||||
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No RNode DETECT_RESP within {:?} (incl. post-boot-settle retry)",
|
||||
PROBE_READ_TIMEOUT
|
||||
)
|
||||
}
|
||||
|
||||
/// Read from `port` for up to `window`, accumulating into `seen`; true once
|
||||
/// the KISS DETECT_RESP sequence shows up anywhere in the stream.
|
||||
async fn await_detect_resp(
|
||||
port: &serial2_tokio::SerialPort,
|
||||
buf: &mut [u8],
|
||||
seen: &mut Vec<u8>,
|
||||
window: Duration,
|
||||
) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + window;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(150), port.read(buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
seen.extend_from_slice(&buf[..n]);
|
||||
if contains_detect_resp(seen) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT)
|
||||
false
|
||||
}
|
||||
|
||||
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
|
||||
@@ -1138,6 +1332,33 @@ fn contains_detect_resp(buf: &[u8]) -> bool {
|
||||
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
|
||||
}
|
||||
|
||||
/// The display name an announce actually asserted, if any.
|
||||
///
|
||||
/// Precedence: the daemon-decoded LXMF display name (`display_name` event
|
||||
/// field), then — legacy peers only — bare-utf8 app_data that wasn't an
|
||||
/// identity blob. The bare-utf8 fallback must actually look like text:
|
||||
/// lossy-decoded msgpack (a new-format announce whose name the daemon failed
|
||||
/// to decode) is full of U+FFFD/control chars and would otherwise become a
|
||||
/// mojibake display name. `None` (e.g. a blob-only legacy announce) means
|
||||
/// "no name asserted" and must NOT stomp a previously-learned name.
|
||||
fn pick_announced_name(
|
||||
explicit_name: Option<String>,
|
||||
app_data_text: Option<String>,
|
||||
is_legacy_blob: bool,
|
||||
) -> Option<String> {
|
||||
explicit_name
|
||||
// A legacy blob-only announce utf8-decodes cleanly, so LXMF's
|
||||
// display_name_from_app_data hands the daemon the ENTIRE `ARCHY:…`
|
||||
// string as a "name" — seen live from a pre-upgrade Framework PT.
|
||||
// An identity blob is never a display name.
|
||||
.filter(|s| !s.starts_with("ARCHY:"))
|
||||
.or_else(|| {
|
||||
app_data_text
|
||||
.filter(|_| !is_legacy_blob)
|
||||
.filter(|s| !s.chars().any(|c| c.is_control() || c == '\u{FFFD}'))
|
||||
})
|
||||
}
|
||||
|
||||
impl Drop for ReticulumLink {
|
||||
fn drop(&mut self) {
|
||||
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
|
||||
@@ -1154,6 +1375,40 @@ impl Drop for ReticulumLink {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn announced_name_precedence() {
|
||||
// Daemon-decoded LXMF name always wins.
|
||||
assert_eq!(
|
||||
pick_announced_name(
|
||||
Some("RNode Shaza".into()),
|
||||
Some("ARCHY:2:aa:bb".into()),
|
||||
true
|
||||
),
|
||||
Some("RNode Shaza".to_string())
|
||||
);
|
||||
// Legacy bare-utf8 name (old daemon, no explicit field).
|
||||
assert_eq!(
|
||||
pick_announced_name(None, Some("zaza".into()), false),
|
||||
Some("zaza".to_string())
|
||||
);
|
||||
// Legacy blob-only announce asserts NO name (must not stomp).
|
||||
assert_eq!(
|
||||
pick_announced_name(None, Some("ARCHY:2:aa:bb".into()), true),
|
||||
None
|
||||
);
|
||||
// Lossy-decoded msgpack must not become a mojibake name.
|
||||
assert_eq!(
|
||||
pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
pick_announced_name(None, Some("has\u{1}ctl".into()), false),
|
||||
None
|
||||
);
|
||||
// Nothing at all.
|
||||
assert_eq!(pick_announced_name(None, None, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_resp_found_in_kiss_stream() {
|
||||
let stream = [
|
||||
|
||||
@@ -557,29 +557,39 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
///
|
||||
/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev
|
||||
/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so
|
||||
/// without this one physical board shows up (and gets probed, and gets its
|
||||
/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which
|
||||
/// keeps the stable /dev/mesh-radio name when the symlink exists.
|
||||
/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
|
||||
/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
|
||||
/// primary radio currently enumerates as, so both names always pointed at
|
||||
/// the same candidate list entry and both passed this scan — confirmed live
|
||||
/// 2026-07-23, this made an already-connected, working radio (connected via
|
||||
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
|
||||
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
|
||||
/// hot-swap UI's active-session guard compares path strings, so it didn't
|
||||
/// recognize the two aliases as the same port, showed the "device detected"
|
||||
/// modal for a radio that was already set up, and probing it there opened
|
||||
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
|
||||
/// with — a continuous, UI-driven reset loop that only ran while that view
|
||||
/// was open (matches the reported "stops when I leave, resumes when I come
|
||||
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
|
||||
/// dedup and is what's reported when both alias and target are present.
|
||||
/// (Independently re-discovered and fixed on main 2026-07-26 — both sides
|
||||
/// of the 2026-07-28 merge carried an equivalent implementation.)
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
let mut seen_canonical: Vec<std::path::PathBuf> = Vec::new();
|
||||
let mut seen_real_paths = std::collections::HashSet::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
let canonical = tokio::fs::canonicalize(path)
|
||||
let real_path = tokio::fs::canonicalize(path)
|
||||
.await
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||
if seen_canonical.contains(&canonical) {
|
||||
debug!(path = %path, canonical = %canonical.display(),
|
||||
"Skipping alias of an already-detected serial device");
|
||||
if !seen_real_paths.insert(real_path.clone()) {
|
||||
debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
|
||||
continue;
|
||||
}
|
||||
seen_canonical.push(canonical);
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
@@ -610,12 +620,23 @@ 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;
|
||||
let plugged_at = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
// Birth time (btime), falling back to inode-change time (ctime) —
|
||||
// NOT mtime: a tty node's mtime bumps on every open()/write, so with
|
||||
// mtime here each probe/session open minted a "new" plugged_at, the
|
||||
// UI's (path, plugged_at) dismissal key never matched again, and the
|
||||
// setup modal re-fired forever on a device that never left the port
|
||||
// (observed live on archi-dev-box 2026-07-28). btime/ctime only
|
||||
// change when udev (re)creates/chowns the node — i.e. on real plugs.
|
||||
let plugged_at = tokio::fs::metadata(&path).await.ok().and_then(|m| {
|
||||
m.created()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.or_else(|| {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
u64::try_from(m.ctime()).ok()
|
||||
})
|
||||
});
|
||||
out.push(DetectedDeviceInfo {
|
||||
path,
|
||||
vid: usb.0,
|
||||
|
||||
@@ -381,21 +381,16 @@ pub async fn send_to_peer(
|
||||
if let Some(dir) = record_data_dir {
|
||||
req = req.record_transport(dir);
|
||||
}
|
||||
let (resp, transport) = req
|
||||
.send_json(&body)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("connection refused") || msg.contains("Connection refused") {
|
||||
anyhow::anyhow!(
|
||||
"Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status."
|
||||
)
|
||||
} else if msg.contains("timeout") || msg.contains("timed out") {
|
||||
anyhow::anyhow!("Connection timed out. The peer may be offline.")
|
||||
} else {
|
||||
anyhow::anyhow!("Failed to send: {}", msg)
|
||||
}
|
||||
})?;
|
||||
let (resp, transport) = req.send_json(&body).await.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("connection refused") || msg.contains("Connection refused") {
|
||||
anyhow::anyhow!("Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status.")
|
||||
} else if msg.contains("timeout") || msg.contains("timed out") {
|
||||
anyhow::anyhow!("Connection timed out. The peer may be offline.")
|
||||
} else {
|
||||
anyhow::anyhow!("Failed to send: {}", msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!(
|
||||
|
||||
@@ -294,8 +294,13 @@ impl Server {
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Auto-enable mesh if a radio is detected and no config exists yet
|
||||
if !mesh_config.enabled {
|
||||
// Auto-enable mesh if a radio is detected and no config exists
|
||||
// yet. Only on a genuinely missing config: an existing file
|
||||
// with enabled=false is an explicit operator decision (e.g.
|
||||
// via mesh.configure) and force-re-enabling it on every boot
|
||||
// made "disable mesh" impossible on any node with a radio
|
||||
// plugged in.
|
||||
if !mesh_config.enabled && !crate::mesh::config_file_exists(&data_dir) {
|
||||
let devices = crate::mesh::detect_devices().await;
|
||||
if !devices.is_empty() {
|
||||
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
|
||||
@@ -1322,11 +1327,46 @@ async fn accept_loop(
|
||||
}
|
||||
};
|
||||
let handler = handler.clone();
|
||||
let permit = active_connections.clone().acquire_owned().await;
|
||||
// NEVER park the accept loop on the connection budget.
|
||||
// `acquire_owned().await` here froze accept() entirely when
|
||||
// permits drained — and permits drained because half-open
|
||||
// clients and hung upstreams held them forever (the .228
|
||||
// session-flapping / CLOSE-WAIT `inode: 0` signature). Shed
|
||||
// load instead: accept, answer 503, close.
|
||||
let permit = match active_connections.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"{} connection budget exhausted — shedding {}",
|
||||
local_addr, peer_addr
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stream = stream;
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
stream.write_all(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let _ = stream.shutdown().await;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
// Set when a request carries an Upgrade header (websocket):
|
||||
// upgraded connections are legitimately long-lived and are
|
||||
// exempt from the non-upgraded connection deadline below.
|
||||
let upgraded = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let upgraded_flag = upgraded.clone();
|
||||
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
|
||||
let handler = handler.clone();
|
||||
if req.headers().contains_key(hyper::header::UPGRADE) {
|
||||
upgraded_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
async move {
|
||||
// Record the TCP peer so rate limiting only trusts
|
||||
// forwarded headers on loopback (nginx) connections.
|
||||
@@ -1345,13 +1385,48 @@ async fn accept_loop(
|
||||
.map_err(|e| std::io::Error::other(format!("{}", e)))
|
||||
}
|
||||
});
|
||||
if let Err(e) = Http::new()
|
||||
// header_read_timeout: a client that connects and never
|
||||
// sends a request (slowloris / half-open) is dropped
|
||||
// instead of holding a permit until the heat death of the
|
||||
// node. Long RPCs are safe — the clock only covers header
|
||||
// read.
|
||||
let conn = Http::new()
|
||||
.http1_keep_alive(false)
|
||||
.http1_header_read_timeout(std::time::Duration::from_secs(30))
|
||||
.serve_connection(stream, service)
|
||||
.with_upgrades()
|
||||
.await
|
||||
{
|
||||
error!("Error serving connection from {}: {}", peer_addr, e);
|
||||
.with_upgrades();
|
||||
tokio::pin!(conn);
|
||||
// Deadline watchdog for NON-upgraded connections. With
|
||||
// keep-alive off a plain connection serves one exchange;
|
||||
// 15 min bounds even the slowest legitimate RPC/stream
|
||||
// while guaranteeing a hung upstream can't hold a permit
|
||||
// forever. Upgraded (websocket) connections are exempt.
|
||||
const NON_UPGRADED_DEADLINE: std::time::Duration =
|
||||
std::time::Duration::from_secs(900);
|
||||
let started = std::time::Instant::now();
|
||||
let watchdog = async {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
if !upgraded.load(std::sync::atomic::Ordering::Relaxed)
|
||||
&& started.elapsed() >= NON_UPGRADED_DEADLINE
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
r = &mut conn => {
|
||||
if let Err(e) = r {
|
||||
error!("Error serving connection from {}: {}", peer_addr, e);
|
||||
}
|
||||
}
|
||||
_ = watchdog => {
|
||||
warn!(
|
||||
"connection from {} exceeded {}s without completing or upgrading — dropping",
|
||||
peer_addr,
|
||||
NON_UPGRADED_DEADLINE.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,33 @@ Full plan: `.claude/plans/enchanted-strolling-rocket.md`. Memory pointer:
|
||||
avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work
|
||||
owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions.
|
||||
|
||||
## Checkpoint 2026-07-28 — RNode connect + names FIXED, live-verified E2E (read this first)
|
||||
|
||||
The fleet reflash back to RNode firmware exposed a stack of bugs that made Reticulum
|
||||
unusable on CP2102-bridged boards (Heltec V3 etc.) and left every archy node nameless on
|
||||
RNS. All fixed in `a8c4694c` (backend) + `3f76b496` (UI), live-verified on archi-dev-box
|
||||
and archy-x250-dev with a real RNode-to-RNode LXMF message (`transport: "reticulum"` in
|
||||
mesh-messages) plus a cross-transport reply:
|
||||
|
||||
1. **probe_rnode boot race** — serial open pulses DTR/RTS via the USB-UART bridge → ESP32
|
||||
power-cycles → KISS DETECT written 300ms later is eaten during ~2.5-3s of boot. Fix:
|
||||
immediate probe (fast path) + drain-until-quiet boot settle + second DETECT window.
|
||||
2. **configure() was a no-op on a running listener** (only enable/disable restarted it) —
|
||||
the setup modal's apply/keep-as-is and every rename did nothing until process restart.
|
||||
3. **Name propagation** — `config.advert_name` had no reader; `server.set-name` never
|
||||
reached mesh; daemon display name fixed at spawn to the "Archy" default; the ARCHY:2
|
||||
announce blob REPLACED the LXMF name. Now: announces carry msgpack
|
||||
`[name, stamp_cost, sf, ARCHY-blob]` (Sideband-compatible, blob invisible to stock
|
||||
clients), daemon has a `set_name` verb, renames bounce the session live.
|
||||
4. **Daemon-death detection** (was invisible up to the 30-min RX-stall watchdog),
|
||||
**modal re-trigger loop** (plugged_at used tty mtime → bumps on every open; now
|
||||
btime/ctime), **ARCHY:2 federation-name clobber**, **mesh.refresh RPC** (Refresh button
|
||||
now actually re-queries the radio), **Meshtastic mesh.broadcast now sends NodeInfo**.
|
||||
|
||||
Still open here: legacy-format peers (old fleet builds) show as `Reticulum <hex4>` until
|
||||
they OTA; RNode RF params still daemon-hardcoded (EU-868 869.525/125k/SF8/CR5); Phase 4
|
||||
multi-radio; duty-cycle guard.
|
||||
|
||||
## Status at a glance
|
||||
|
||||
| Phase | What | Status |
|
||||
|
||||
@@ -110,6 +110,61 @@ lands].
|
||||
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
|
||||
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
|
||||
|
||||
## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E)
|
||||
|
||||
Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell
|
||||
tested on real hardware vs. code-reviewed only as this is run.
|
||||
|
||||
1. ❑ From the hot-swap modal's step 1 (device already probed), press
|
||||
**Flash Firmware…** → new step shows firmware-family + board pickers and
|
||||
the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until
|
||||
family, board, AND the checkbox are all set.
|
||||
2. ❑ Confirm what's currently on the test stick via the existing probe
|
||||
BEFORE flashing it — don't flash the only known-good device without a
|
||||
fallback board on hand.
|
||||
3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run;
|
||||
only exercise a primary/in-use stick once the flow is proven safe.
|
||||
4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log
|
||||
tail update live, ends at "Flash complete".
|
||||
5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from
|
||||
the esp32s3 release zip.
|
||||
6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path
|
||||
completes (no raw esptool erase/write step for this family — see
|
||||
`mesh/flash.rs` doc comment).
|
||||
7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware:
|
||||
V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid
|
||||
303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific)
|
||||
— so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable
|
||||
by vid:pid. Board auto-detect should fail closed for it every time
|
||||
(manual board selection required, "couldn't confirm automatically"
|
||||
warning shown) — this is expected steady-state behavior, not a gap to
|
||||
close later.
|
||||
8. ❑ After a successful flash, the modal automatically re-probes and shows
|
||||
the NEW firmware's badge/details — same as unplugging and replugging
|
||||
(Section E item 3), but without physically touching the cable.
|
||||
9. ❑ Deliberately test a failure path once (disconnect the board mid-write,
|
||||
or point at a bad cached asset) — confirm the error surfaces in the
|
||||
progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware
|
||||
flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/
|
||||
rnodeconf command) actually get the board back to a flashable state.
|
||||
10. ❑ Cancel button only appears (and only works) while still in the
|
||||
"Downloading firmware…" stage — once erasing/writing starts, no cancel
|
||||
affordance is offered.
|
||||
11. ❑ **Boot-loop regression (2026-07-23 incident)**: after a *failed* flash
|
||||
(e.g. kill network access mid-download to force a failure), confirm the
|
||||
mesh listener does NOT auto-resume — `journalctl -u archipelago` should
|
||||
show a single `Leaving mesh listener stopped after failed flash` line
|
||||
and then go quiet for that device, not a repeating `mesh::serial:
|
||||
Opened serial port... Starting Meshcore handshake` cycle every few
|
||||
seconds. Reconnect manually via the hot-swap modal afterward and confirm
|
||||
it connects normally (the board itself should be untouched — the
|
||||
download fails before esptool/rnodeconf ever runs).
|
||||
12. ❑ Separately, force a device to flap connected/disconnected a few times
|
||||
in under 20s each (e.g. a marginal USB connection) and confirm
|
||||
`reconnect_delay` in the logs actually escalates (5s → 10s → 20s → ...)
|
||||
rather than resetting to 5s on every attempt — see
|
||||
`STABLE_SESSION_THRESHOLD` in `mesh/listener/mod.rs`.
|
||||
|
||||
---
|
||||
|
||||
After this passes: fold the batch + other agent's work into the next release
|
||||
|
||||
@@ -443,6 +443,86 @@ free -h
|
||||
- Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade`
|
||||
- If on WiFi, try wired Ethernet for more stable connectivity
|
||||
|
||||
### 21. LoRa radio firmware flash failed / board unresponsive
|
||||
|
||||
**Symptoms**: The "Erase & Flash Now" flow in the mesh hot-swap modal reports
|
||||
an error, or the radio no longer enumerates as a serial device after a flash
|
||||
attempt.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Poll the flash job's last-known stage/error directly
|
||||
curl -s http://localhost:5678/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"mesh.flash-status","params":{}}'
|
||||
|
||||
# Confirm the board is still enumerating at all
|
||||
ls -la /dev/ttyUSB* /dev/ttyACM* /dev/mesh-radio 2>&1
|
||||
|
||||
# esptool/rnodeconf binaries present?
|
||||
which esptool; ls -la /usr/local/bin/archy-rnodeconf
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
- A failure during `erasing`/`writing` (MeshCore/Meshtastic) or
|
||||
`autoinstalling` (Reticulum) can leave the chip erased or half-written —
|
||||
this is expected risk of the "always erase first" default, not a bug.
|
||||
- Heltec V3/V4 boards can be forced back into bootloader mode manually: hold
|
||||
**BOOT**, tap **RST**, then release **BOOT** — this puts the chip in a
|
||||
state esptool can always talk to, regardless of what firmware (if any) is
|
||||
currently on it.
|
||||
- With the board in bootloader mode, a manual recovery flash can be run
|
||||
directly over SSH without the UI:
|
||||
```bash
|
||||
esptool --chip esp32s3 --port /dev/ttyACM0 erase_flash
|
||||
esptool --chip esp32s3 --port /dev/ttyACM0 write_flash 0x0 <known-good-image.bin>
|
||||
```
|
||||
- For Reticulum/RNode boards, the equivalent manual recovery is
|
||||
`archy-rnodeconf /dev/ttyACM0 --autoinstall` (or `/usr/local/bin/archy-rnodeconf`
|
||||
if it's not on `PATH`) — it re-runs the same fetch+erase+flash+bootstrap
|
||||
sequence the UI triggers.
|
||||
- If `esptool`/`archy-rnodeconf` are missing entirely, they should have been
|
||||
installed by the last `self-update.sh` run — check
|
||||
`sudo journalctl -u archipelago-update` for install failures, or install
|
||||
`esptool` via `sudo apt-get install esptool` directly.
|
||||
- Once a fresh image is confirmed written, unplug/replug the radio (or wait
|
||||
for the next detection poll) — the hot-swap modal re-probes automatically
|
||||
and shows whatever firmware is actually on the board now.
|
||||
|
||||
**Known incident (2026-07-23) — reconnect storm / device boot-loop after a
|
||||
failed flash**: a real Heltec V3 got stuck cycling "connect → partial
|
||||
handshake → drop" every 5-15s for 5+ minutes after a `mesh.flash-device`
|
||||
attempt failed with `Reading firmware download stream`. Root cause was two
|
||||
compounding issues, both now fixed:
|
||||
1. `spawn_mesh_listener`'s reconnect backoff (`core/archipelago/src/mesh/listener/mod.rs`)
|
||||
reset to its 5s minimum any time the prior session had been `device_connected`
|
||||
at all, even for under a second — so a device that connects-then-drops
|
||||
repeatedly never actually backed off. Every retry's `open()` toggles
|
||||
DTR/RTS, which resets many ESP32 boards' MCU (native-USB *and*
|
||||
CP2102/CH340 auto-reset-circuit boards), so the aggressive retries were
|
||||
themselves *causing* the boot loop, not just observing one. Fixed by only
|
||||
resetting backoff when a session ran for at least `STABLE_SESSION_THRESHOLD`
|
||||
(20s) — see that constant's doc comment.
|
||||
2. `mesh::flash::start_flash_job`'s post-completion handler auto-resumed the
|
||||
listener unconditionally, even after a *failed* flash, immediately
|
||||
re-entering the reconnect loop above with no cooldown. Fixed: on failure
|
||||
the listener is now deliberately left stopped (reconnect manually via the
|
||||
UI once the board is confirmed alive); on success there's a 5s settle
|
||||
delay before resuming, so the board finishes booting from the flash
|
||||
tool's own reset before Archipelago starts probing it again.
|
||||
3. Separately, the download itself was failing because `mesh::flash`'s HTTP
|
||||
client had a blanket 30s request timeout that covered the *entire*
|
||||
download (including streaming a 170MB Meshtastic zip), not just
|
||||
connection setup — fixed with a per-chunk stall timeout instead of a
|
||||
fixed total-transfer cap.
|
||||
|
||||
If this symptom recurs (rapid repeating `mesh::serial: Opened serial
|
||||
port... Starting Meshcore handshake` lines in `journalctl -u archipelago`
|
||||
without a `LoRa firmware flash` job in progress), it's a NEW instance of the
|
||||
same class of bug, not the one above — check whether backoff is actually
|
||||
escalating (`Mesh session error: ... (retry in Xs)` — X should grow past 5s
|
||||
within a few cycles) before assuming it's flashing-related.
|
||||
|
||||
---
|
||||
|
||||
## General Maintenance
|
||||
|
||||
@@ -365,6 +365,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
ca-certificates \
|
||||
openssl \
|
||||
chrony \
|
||||
iputils-ping \
|
||||
esptool \
|
||||
python3-venv \
|
||||
binutils \
|
||||
libpython3.13 \
|
||||
locales \
|
||||
console-setup \
|
||||
keyboard-configuration \
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.115-alpha",
|
||||
"version": "1.7.117-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.115-alpha",
|
||||
"version": "1.7.117-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.116-alpha",
|
||||
"version": "1.7.117-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
|
||||
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
|
||||
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
||||
on-chain/Lightning rows; chips keep the standard txs reachable. -->
|
||||
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
|
||||
on-chain/Lightning rows; chips keep the standard txs reachable.
|
||||
Sticky inside the modal's scroll region so the tabs stay pinned
|
||||
while rows blur past underneath. -->
|
||||
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-black/70 backdrop-blur-md">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
@@ -26,7 +28,12 @@
|
||||
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
|
||||
<!-- Must NOT be its own scroll container: BaseModal's slot wrapper is the
|
||||
scroller, and modal-scroll-locked gives every .overflow-y-auto inside
|
||||
the overlay overscroll-behavior:contain — a nested scroller with no
|
||||
overflow then swallows touch scrolling entirely (phones only; wheel
|
||||
latches onto the scrollable ancestor and never sees the bug). -->
|
||||
<div v-else class="-mx-2 px-2 divide-y divide-white/5">
|
||||
<div
|
||||
v-for="tx in filteredTransactions"
|
||||
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'"
|
||||
:title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Set Recommended' : 'Flash Firmware'"
|
||||
max-width="max-w-lg"
|
||||
content-class="max-h-[90vh] overflow-y-auto"
|
||||
@close="dismiss"
|
||||
@@ -35,9 +35,17 @@
|
||||
|
||||
<!-- What's currently flashed / configured on it -->
|
||||
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
|
||||
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1">
|
||||
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span>
|
||||
Reading what's on the radio…
|
||||
<div v-if="probing" class="py-1">
|
||||
<div class="flex items-center justify-between text-white/60 text-sm mb-1.5">
|
||||
<span>{{ probeStage }}</span>
|
||||
<span class="text-white/40 text-xs tabular-nums">{{ Math.round(probeProgress) }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-orange-400/80 transition-[width] duration-500 ease-linear"
|
||||
:style="{ width: probeProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="probe">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -94,21 +102,123 @@
|
||||
:disabled="!!connecting"
|
||||
@click="step = 2"
|
||||
>
|
||||
Set Up with Archipelago Settings
|
||||
Set Recommended
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-white/40 text-[11px] mt-3">
|
||||
"Keep As Is" uses the radio exactly as it is — nothing on it is changed,
|
||||
and you can hot-swap radios any time.
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-center text-white/40 hover:text-white/70 text-[11px] mt-3 underline underline-offset-2"
|
||||
:disabled="!!connecting"
|
||||
@click="openFlashStep"
|
||||
>
|
||||
Flash Firmware…
|
||||
</button>
|
||||
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: erase + reflash — destructive, opt-in only -->
|
||||
<div v-else-if="step === 'flash'">
|
||||
<!-- Once a job exists (started via startFlash), ALWAYS show the
|
||||
progress/result view below — including on failure. The old
|
||||
condition (`!active && stage !== 'done'`) was also true for a
|
||||
FAILED job (active:false, stage:'failed'), which silently sent
|
||||
the user back to this picker instead of showing the error. -->
|
||||
<template v-if="!flashJob">
|
||||
<p class="text-white/60 text-xs mb-3">
|
||||
Downloads the latest firmware from upstream and writes it to
|
||||
<span class="font-mono text-orange-300">{{ devicePath }}</span>.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-white/80 mb-1">Firmware family</label>
|
||||
<select v-model="flashFamily" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="">Choose…</option>
|
||||
<option value="meshcore">MeshCore</option>
|
||||
<option value="meshtastic">Meshtastic</option>
|
||||
<option value="reticulum">Reticulum RNode</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/80 mb-1">Board</label>
|
||||
<select v-model="flashBoard" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="">Choose…</option>
|
||||
<option value="heltec-v3">Heltec LoRa 32 V3</option>
|
||||
<option value="heltec-v4">Heltec LoRa 32 V4</option>
|
||||
</select>
|
||||
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
|
||||
Couldn't confirm the board automatically — double check before flashing.
|
||||
Flashing the wrong board's image can brick it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-red-500/10 border border-red-500/30 p-3 mt-4">
|
||||
<label class="flex items-start gap-2 text-xs text-red-300">
|
||||
<input type="checkbox" v-model="flashConfirmed" class="mt-0.5" />
|
||||
<span>
|
||||
This <strong>erases the entire chip</strong>, including any existing
|
||||
keys, identity, and contacts. This cannot be undone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
|
||||
<button
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/80 hover:bg-red-500 text-white disabled:opacity-50"
|
||||
:disabled="!flashFamily || !flashBoard || !flashConfirmed || starting"
|
||||
@click="startFlash"
|
||||
>
|
||||
{{ starting ? 'Starting…' : 'Erase & Flash Now' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Progress -->
|
||||
<template v-else>
|
||||
<div class="text-center py-2">
|
||||
<p class="text-white text-sm font-medium">{{ flashStageLabel }}</p>
|
||||
<div class="mt-3 h-2 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-orange-400 transition-all"
|
||||
:style="{ width: (flashJob?.percent ?? (flashJob?.stage === 'done' ? 100 : 8)) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p v-if="flashJob?.error" class="text-xs text-red-400 mt-3">{{ flashJob.error }}</p>
|
||||
</div>
|
||||
<div class="mt-3 rounded-xl bg-black/30 border border-white/10 p-2 h-32 overflow-y-auto font-mono text-[10px] text-white/50 leading-relaxed">
|
||||
<div v-for="(line, i) in (flashJob?.log_tail ?? []).slice(-40)" :key="i">{{ line }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
v-if="flashJob?.stage === 'downloading' && flashJob?.active"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm"
|
||||
@click="cancelFlash"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="!flashJob?.active"
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
|
||||
@click="closeFlashStep"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: our latest parameters, shown before anything is written -->
|
||||
<div v-else>
|
||||
<p class="text-white/60 text-xs mb-3">
|
||||
These are the latest Archipelago settings — nothing is written to the
|
||||
radio until you confirm.
|
||||
These are the recommended Archipelago settings — nothing is written to
|
||||
the radio until you confirm.
|
||||
</p>
|
||||
|
||||
<!-- Summary of what will be applied -->
|
||||
@@ -190,7 +300,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams } from '@/stores/mesh'
|
||||
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
|
||||
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
|
||||
@@ -199,14 +309,38 @@ const mesh = useMeshStore()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
const step = ref<1 | 2>(1)
|
||||
const step = ref<1 | 2 | 'flash'>(1)
|
||||
const connecting = ref<false | 'keep' | 'setup'>(false)
|
||||
const error = ref('')
|
||||
const probing = ref(false)
|
||||
const probe = ref<MeshDeviceProbe | null>(null)
|
||||
const probeError = ref('')
|
||||
|
||||
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
|
||||
// Time-driven probe progress: the probe RPC is a single opaque call that can
|
||||
// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
|
||||
// advances on a clock toward 92% and snaps to 100% when the result lands.
|
||||
const probeProgress = ref(0)
|
||||
const probeStage = ref('Waiting for the radio to boot…')
|
||||
let probeTicker: ReturnType<typeof setInterval> | null = null
|
||||
function startProbeProgress() {
|
||||
stopProbeProgress()
|
||||
probeProgress.value = 0
|
||||
probeStage.value = 'Waiting for the radio to boot…'
|
||||
const startedAt = Date.now()
|
||||
probeTicker = setInterval(() => {
|
||||
const elapsed = (Date.now() - startedAt) / 1000
|
||||
// ~92% at 30s, decelerating — never looks stuck, never lies "done".
|
||||
probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
|
||||
if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
|
||||
if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
|
||||
}, 400)
|
||||
}
|
||||
function stopProbeProgress(done = false) {
|
||||
if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
|
||||
if (done) probeProgress.value = 100
|
||||
}
|
||||
|
||||
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
|
||||
const show = computed(() => !!devicePath.value)
|
||||
const imageFailed = ref(false)
|
||||
const deviceImage = computed(() =>
|
||||
@@ -264,7 +398,27 @@ const rfPreset = computed(() => {
|
||||
|
||||
// (Re)probe + (re)apply presets each time a new device surfaces the modal
|
||||
watch([show, devicePath], async ([visible]) => {
|
||||
if (!visible) return
|
||||
if (!visible) {
|
||||
stopFlashPoll()
|
||||
return
|
||||
}
|
||||
if (mesh.flashFlowPath) {
|
||||
// Manual "Flash LoRa" entry: jump straight to the flash step. Skip the
|
||||
// read-only probe — the port is usually the live session's, and a second
|
||||
// opener on the tty corrupts the running connection; status already
|
||||
// knows the firmware kind for the connected radio.
|
||||
probe.value = null
|
||||
probeError.value = ''
|
||||
probing.value = false
|
||||
stopProbeProgress()
|
||||
imageFailed.value = false
|
||||
openFlashStep()
|
||||
const t = (mesh.status?.device_type ?? '').toLowerCase()
|
||||
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
|
||||
flashFamily.value = t as FlashFirmwareFamily
|
||||
}
|
||||
return
|
||||
}
|
||||
step.value = 1
|
||||
error.value = ''
|
||||
imageFailed.value = false
|
||||
@@ -275,6 +429,7 @@ watch([show, devicePath], async ([visible]) => {
|
||||
probe.value = null
|
||||
probeError.value = ''
|
||||
probing.value = true
|
||||
startProbeProgress()
|
||||
const path = devicePath.value
|
||||
try {
|
||||
const res = await mesh.probeDevice(path)
|
||||
@@ -284,11 +439,16 @@ watch([show, devicePath], async ([visible]) => {
|
||||
probeError.value = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
} finally {
|
||||
stopProbeProgress(true)
|
||||
if (devicePath.value === path) probing.value = false
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
function dismiss() {
|
||||
if (mesh.flashFlowPath) {
|
||||
mesh.closeFlashFlow()
|
||||
return
|
||||
}
|
||||
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
|
||||
}
|
||||
|
||||
@@ -349,6 +509,123 @@ async function applySetup() {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 3: erase + reflash ─────────────────────────────────────────────
|
||||
const flashFamily = ref<FlashFirmwareFamily | ''>('')
|
||||
const flashBoard = ref<FlashBoard | ''>('')
|
||||
const flashConfirmed = ref(false)
|
||||
const starting = ref(false)
|
||||
const flashJob = ref<FlashJobStatus | null>(null)
|
||||
let flashPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const detectedInfo = computed(() =>
|
||||
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
|
||||
)
|
||||
|
||||
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
|
||||
// exactly — matching on the display label was wrong: a Heltec V3's CP2102
|
||||
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
|
||||
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
|
||||
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
|
||||
// "couldn't confirm automatically" warning even though the backend CAN
|
||||
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
|
||||
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
|
||||
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
|
||||
// safely auto-matched and always requires manual selection.
|
||||
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
|
||||
const info = detectedInfo.value
|
||||
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
|
||||
return ''
|
||||
})
|
||||
|
||||
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
|
||||
|
||||
const flashStageLabel = computed(() => {
|
||||
switch (flashJob.value?.stage) {
|
||||
case 'downloading': return 'Downloading firmware…'
|
||||
case 'erasing': return 'Erasing chip…'
|
||||
case 'writing': return 'Writing firmware…'
|
||||
case 'autoinstalling': return 'Installing (rnodeconf)…'
|
||||
case 'done': return 'Flash complete'
|
||||
case 'failed': return 'Flash failed'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
function openFlashStep() {
|
||||
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
|
||||
flashBoard.value = resolvedFlashBoard.value
|
||||
flashConfirmed.value = false
|
||||
flashJob.value = null
|
||||
error.value = ''
|
||||
step.value = 'flash'
|
||||
}
|
||||
|
||||
function stopFlashPoll() {
|
||||
if (flashPollTimer) {
|
||||
clearInterval(flashPollTimer)
|
||||
flashPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollFlashStatus() {
|
||||
try {
|
||||
const status = await mesh.flashStatus()
|
||||
flashJob.value = status
|
||||
if (!status.active) {
|
||||
stopFlashPoll()
|
||||
if (status.done && !status.error) {
|
||||
// Mirrors the unplug/replug hot-swap flow: re-probe so the details
|
||||
// card reflects whatever firmware is actually on the board now.
|
||||
const path = devicePath.value
|
||||
probing.value = true
|
||||
try {
|
||||
probe.value = await mesh.probeDevice(path)
|
||||
} catch {
|
||||
probe.value = null
|
||||
} finally {
|
||||
probing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
stopFlashPoll()
|
||||
}
|
||||
}
|
||||
|
||||
async function startFlash() {
|
||||
if (!flashFamily.value || !flashBoard.value || !flashConfirmed.value) return
|
||||
starting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await mesh.flashDevice(devicePath.value, flashFamily.value, flashBoard.value)
|
||||
flashJob.value = { active: true, stage: 'downloading', log_tail: [] }
|
||||
stopFlashPoll()
|
||||
flashPollTimer = setInterval(pollFlashStatus, 1500)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start flashing'
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelFlash() {
|
||||
try {
|
||||
await mesh.flashCancel()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to cancel'
|
||||
}
|
||||
}
|
||||
|
||||
function closeFlashStep() {
|
||||
stopFlashPoll()
|
||||
if (mesh.flashFlowPath) {
|
||||
// Manual entry has no detection step to go back to — close the modal.
|
||||
mesh.closeFlashFlow()
|
||||
return
|
||||
}
|
||||
step.value = 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+101
-10
@@ -57,6 +57,23 @@ export interface MeshDeviceProbe {
|
||||
max_contacts: number | null
|
||||
}
|
||||
|
||||
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
|
||||
export type FlashBoard = 'heltec-v3' | 'heltec-v4'
|
||||
export type FlashStage = 'downloading' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
|
||||
|
||||
/** Live progress for the one flash job that can run at a time. */
|
||||
export interface FlashJobStatus {
|
||||
active: boolean
|
||||
board?: FlashBoard
|
||||
family?: FlashFirmwareFamily
|
||||
path?: string
|
||||
stage?: FlashStage
|
||||
percent?: number | null
|
||||
log_tail?: string[]
|
||||
done?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
/** Params accepted by mesh.configure (superset of the status fields). */
|
||||
export interface MeshConfigureParams {
|
||||
enabled?: boolean
|
||||
@@ -264,8 +281,14 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
|
||||
// Track unread message counts per peer (contact_id -> count)
|
||||
const unreadCounts = ref<Record<number, number>>({})
|
||||
// Currently viewing chat for this contact_id (clears unread)
|
||||
const viewingChatId = ref<number | null>(null)
|
||||
// Contact ids of the chat currently on screen — ALL twins of the merged
|
||||
// conversation, not just the clicked row's id, since the unread badge sums
|
||||
// across every underlying contact_id (a message can land on any twin).
|
||||
const viewingChatIds = ref<number[]>([])
|
||||
// Whether the open chat is scrolled to (near) the bottom. New messages only
|
||||
// auto-clear as read while the latest messages are actually in view —
|
||||
// standard chat-app semantics; scrolled-up-in-history still counts unread.
|
||||
const viewingAtBottom = ref(true)
|
||||
// Total unread count for nav badge
|
||||
const totalUnread = computed(() =>
|
||||
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
|
||||
@@ -274,12 +297,16 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
|
||||
status.value = res
|
||||
trackDetectedDevices(res)
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
|
||||
// Don't clobber a user-action error (broadcast/configure/send) — this
|
||||
// runs on a 5s poll, and the old `error.value = null` on entry meant
|
||||
// any real error banner survived at most one poll tick.
|
||||
if (!error.value) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -350,6 +377,16 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
}
|
||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
||||
}
|
||||
// Manual "Flash LoRa" entry (Mesh header button): forces the global setup
|
||||
// modal open at the flash step for this port — including the live session's
|
||||
// port, which detection-driven opening deliberately excludes.
|
||||
const flashFlowPath = ref<string | null>(null)
|
||||
function openFlashFlow(path: string) {
|
||||
flashFlowPath.value = path
|
||||
}
|
||||
function closeFlashFlow() {
|
||||
flashFlowPath.value = null
|
||||
}
|
||||
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
|
||||
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
|
||||
return rpcClient.call<MeshDeviceProbe>({
|
||||
@@ -358,6 +395,32 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
timeout: 45000, // serial probes are slow (multi-firmware handshakes)
|
||||
})
|
||||
}
|
||||
/** Available firmware version(s) for a family — v1 only ever returns
|
||||
* ["latest"], since firmware is always fetched from upstream at flash
|
||||
* time rather than pinned/bundled. */
|
||||
async function flashListFirmware(family: FlashFirmwareFamily): Promise<string[]> {
|
||||
const res = await rpcClient.call<{ versions: string[] }>({
|
||||
method: 'mesh.flash-list-firmware',
|
||||
params: { family },
|
||||
})
|
||||
return res.versions
|
||||
}
|
||||
/** Erase + reflash a detected radio. `board` is optional — omit it to let
|
||||
* the backend auto-resolve from the port's USB vid:pid; if that fails
|
||||
* (e.g. Heltec V4 not yet in the vid:pid table), it errors and the UI
|
||||
* must ask the user to pick the board explicitly. Always erases first. */
|
||||
async function flashDevice(path: string, family: FlashFirmwareFamily, board?: FlashBoard): Promise<void> {
|
||||
await rpcClient.call({
|
||||
method: 'mesh.flash-device',
|
||||
params: board ? { path, family, board } : { path, family },
|
||||
})
|
||||
}
|
||||
async function flashStatus(): Promise<FlashJobStatus> {
|
||||
return rpcClient.call<FlashJobStatus>({ method: 'mesh.flash-status' })
|
||||
}
|
||||
async function flashCancel(): Promise<void> {
|
||||
await rpcClient.call({ method: 'mesh.flash-cancel' })
|
||||
}
|
||||
let globalDetectTimer: ReturnType<typeof setInterval> | 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). */
|
||||
@@ -397,8 +460,11 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
|
||||
)
|
||||
for (const msg of newMsgs) {
|
||||
// Don't count as unread if we're currently viewing that chat
|
||||
if (msg.peer_contact_id !== viewingChatId.value) {
|
||||
// Don't count as unread if we're currently viewing that chat AND the
|
||||
// bottom (latest messages) is in view — i.e. the user actually sees it.
|
||||
const seenLive =
|
||||
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
|
||||
if (!seenLive) {
|
||||
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
|
||||
}
|
||||
}
|
||||
@@ -526,13 +592,15 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
federatedPositions.value = next
|
||||
}
|
||||
|
||||
function markChatRead(contactId: number) {
|
||||
viewingChatId.value = contactId
|
||||
delete unreadCounts.value[contactId]
|
||||
function markChatRead(contactId: number | number[]) {
|
||||
const ids = Array.isArray(contactId) ? contactId : [contactId]
|
||||
viewingChatIds.value = ids
|
||||
for (const id of ids) delete unreadCounts.value[id]
|
||||
}
|
||||
|
||||
function clearViewingChat() {
|
||||
viewingChatId.value = null
|
||||
viewingChatIds.value = []
|
||||
viewingAtBottom.value = true
|
||||
}
|
||||
|
||||
async function sendMessage(contactId: number, message: string) {
|
||||
@@ -677,6 +745,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
|
||||
est_seconds: number
|
||||
has_tor: boolean
|
||||
has_fips: boolean
|
||||
last_transport: string | null
|
||||
reason: string
|
||||
size: number
|
||||
mesh_auto_max: number
|
||||
@@ -975,6 +1045,18 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
|
||||
}
|
||||
|
||||
/** Ask the backend to actively re-query the radio's contact table (and by
|
||||
* extension re-drain daemon events for Reticulum) — the server-side half
|
||||
* of the Refresh button; refreshAll() alone only re-reads caches. */
|
||||
async function refreshRadio(): Promise<boolean> {
|
||||
try {
|
||||
const res = await rpcClient.call<{ refreshed: boolean }>({ method: 'mesh.refresh' })
|
||||
return !!res.refreshed
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
peers,
|
||||
@@ -983,6 +1065,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
error,
|
||||
sending,
|
||||
unreadCounts,
|
||||
viewingAtBottom,
|
||||
totalUnread,
|
||||
nodePositions,
|
||||
federatedPositions,
|
||||
@@ -993,7 +1076,14 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
fetchStatus,
|
||||
undismissedDetectedDevices,
|
||||
dismissDetectedDevice,
|
||||
flashFlowPath,
|
||||
openFlashFlow,
|
||||
closeFlashFlow,
|
||||
probeDevice,
|
||||
flashListFirmware,
|
||||
flashDevice,
|
||||
flashStatus,
|
||||
flashCancel,
|
||||
startGlobalDetection,
|
||||
fetchPeers,
|
||||
fetchMessages,
|
||||
@@ -1002,6 +1092,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
broadcastIdentity,
|
||||
configure,
|
||||
refreshAll,
|
||||
refreshRadio,
|
||||
markChatRead,
|
||||
clearViewingChat,
|
||||
sendInvoice,
|
||||
|
||||
+285
-31
@@ -21,6 +21,18 @@ const mesh = useMeshStore()
|
||||
const transport = useTransportStore()
|
||||
const route = useRoute()
|
||||
|
||||
// "Flash LoRa" header button target: the connected radio wins, else any
|
||||
// detected-but-unconnected stick. Opens the global setup modal at its
|
||||
// flash step (backend stops the listener and frees the port itself).
|
||||
const flashLoraPath = computed(() =>
|
||||
(mesh.status?.device_connected && mesh.status.device_path) ||
|
||||
mesh.status?.detected_devices?.[0] ||
|
||||
''
|
||||
)
|
||||
function openFlashLora() {
|
||||
if (flashLoraPath.value) mesh.openFlashFlow(flashLoraPath.value)
|
||||
}
|
||||
|
||||
// Responsive layout breakpoints
|
||||
const isWideDesktop = ref(window.innerWidth >= 1536)
|
||||
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
|
||||
@@ -38,6 +50,8 @@ const activeChatChannel = ref<{ index: number; name: string } | null>(null)
|
||||
const messageText = ref('')
|
||||
const sendError = ref('')
|
||||
const broadcasting = ref(false)
|
||||
const broadcastResult = ref<string | null>(null) // 'ok' | error message
|
||||
const refreshing = ref(false)
|
||||
const configuring = ref(false)
|
||||
const connectingDevice = ref<string | null>(null)
|
||||
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
|
||||
@@ -383,12 +397,22 @@ onMounted(async () => {
|
||||
archPollInterval = setInterval(loadArchMessages, 15000)
|
||||
}
|
||||
if (!pollInterval) {
|
||||
let tick = 0
|
||||
pollInterval = setInterval(() => {
|
||||
mesh.fetchStatus()
|
||||
mesh.fetchPeers()
|
||||
mesh.fetchMessages()
|
||||
mesh.fetchDeadmanStatus()
|
||||
mesh.fetchBlockHeaders()
|
||||
// Contacts/aliases, federation nodes and the outbox badge previously
|
||||
// loaded ONCE at mount and went permanently stale — new federation
|
||||
// peers or renames never appeared without a full page reload. Every
|
||||
// 6th tick (~30s) keeps them fresh without adding per-5s load.
|
||||
if (++tick % 6 === 0) {
|
||||
void refreshContacts()
|
||||
void refreshFederationNodes()
|
||||
void refreshOutboxCount()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
@@ -884,8 +908,14 @@ function openChat(peer: MeshPeer) {
|
||||
messageText.value = ''
|
||||
activeTab.value = 'chat'
|
||||
mobileShowChat.value = true
|
||||
mesh.markChatRead(peer.contact_id)
|
||||
nextTick(() => scrollChatToBottom())
|
||||
nextTick(() => {
|
||||
// Clear unread only after the chat has rendered and auto-scrolled to the
|
||||
// latest messages ("you've actually seen them"), and clear ALL twins of
|
||||
// the merged conversation — the badge sums across every contact_id, so
|
||||
// clearing just the clicked row's id left the badge stuck.
|
||||
scrollChatToBottom()
|
||||
mesh.markChatRead(activeMergedPeer.value?.contact_ids ?? peer.contact_id)
|
||||
})
|
||||
}
|
||||
|
||||
function openChannelChat(channel: { index: number; name: string }) {
|
||||
@@ -997,9 +1027,25 @@ async function handleSendMessage() {
|
||||
function scrollChatToBottom() {
|
||||
if (chatScrollEl.value) {
|
||||
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
|
||||
mesh.viewingAtBottom = true
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll-position read tracking: while the latest messages are in view the
|
||||
// open conversation stays "read" (new arrivals don't badge); scrolled up into
|
||||
// history, incoming messages accumulate unread like any normal chat app —
|
||||
// scrolling back down clears them.
|
||||
function onChatScroll() {
|
||||
const el = chatScrollEl.value
|
||||
if (!el) return
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||
mesh.viewingAtBottom = nearBottom
|
||||
if (nearBottom && activeMergedPeer.value) {
|
||||
mesh.markChatRead(activeMergedPeer.value.contact_ids)
|
||||
}
|
||||
scheduleReadReceipt()
|
||||
}
|
||||
|
||||
// Keep the compose field focused after a send so the user can keep typing
|
||||
// without re-clicking it (Enter re-focuses natively, but the Send button
|
||||
// click otherwise leaves focus on the button).
|
||||
@@ -1021,7 +1067,37 @@ function onChatWheel(e: WheelEvent) {
|
||||
|
||||
async function handleBroadcast() {
|
||||
broadcasting.value = true
|
||||
try { await mesh.broadcastIdentity() } finally { broadcasting.value = false }
|
||||
broadcastResult.value = null
|
||||
try {
|
||||
await mesh.broadcastIdentity()
|
||||
broadcastResult.value = 'ok'
|
||||
} catch (e) {
|
||||
broadcastResult.value = e instanceof Error ? e.message : 'Broadcast failed'
|
||||
} finally {
|
||||
broadcasting.value = false
|
||||
setTimeout(() => { broadcastResult.value = null }, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
// Backend first: re-query the radio's contact table (mesh.refresh), then
|
||||
// re-read EVERYTHING the list is built from — peers, contacts/aliases,
|
||||
// federation nodes, outbox — not just the mesh caches.
|
||||
await Promise.allSettled([
|
||||
mesh.refreshRadio(),
|
||||
mesh.refreshAll(),
|
||||
refreshContacts(),
|
||||
refreshFederationNodes(),
|
||||
refreshOutboxCount(),
|
||||
])
|
||||
// Radio contact refresh is async on the backend — pick up its result.
|
||||
await mesh.fetchPeers()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleEnabled() {
|
||||
@@ -1094,14 +1170,15 @@ interface PendingReply {
|
||||
}
|
||||
const pendingReply = ref<PendingReply | null>(null)
|
||||
const actionMenuForId = ref<number | null>(null)
|
||||
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
||||
|
||||
function openActionMenu(msgId: number, ev?: Event) {
|
||||
ev?.stopPropagation()
|
||||
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
|
||||
reactionPickerForId.value = null
|
||||
}
|
||||
function closeActionMenu() {
|
||||
actionMenuForId.value = null
|
||||
reactionPickerForId.value = null
|
||||
}
|
||||
function handleDocClickForMenu(ev: MouseEvent) {
|
||||
if (actionMenuForId.value === null) return
|
||||
@@ -1211,6 +1288,43 @@ function scheduleReadReceipt() {
|
||||
}, 400)
|
||||
}
|
||||
|
||||
// Hop visualization modal — click a message's transport pill to see how it
|
||||
// traveled: radio hops + signal quality for LoRa transports, overlay/circuit
|
||||
// shape for FIPS/Tor. Signal numbers are the PEER's current values (per-link,
|
||||
// not stored per-message) — labelled as such in the modal.
|
||||
const hopVizMsg = ref<MeshMessage | null>(null)
|
||||
const hopVizPeer = computed<MeshPeer | null>(() => {
|
||||
const m = hopVizMsg.value
|
||||
if (!m) return null
|
||||
return mesh.peers.find(p => p.contact_id === m.peer_contact_id) ?? activeChatPeer.value
|
||||
})
|
||||
function hopVizHops(): number | null {
|
||||
const p = hopVizPeer.value
|
||||
if (!p || p.hops == null || p.hops === 0xff) return null
|
||||
return p.hops
|
||||
}
|
||||
function signalQualityLabel(snr: number | null, rssi: number | null): string {
|
||||
if (snr == null && rssi == null) return 'signal unknown'
|
||||
if (snr != null) {
|
||||
if (snr > 5) return 'excellent signal'
|
||||
if (snr > 0) return 'good signal'
|
||||
if (snr > -10) return 'fair signal'
|
||||
return 'weak signal'
|
||||
}
|
||||
if (rssi != null) {
|
||||
if (rssi > -80) return 'excellent signal'
|
||||
if (rssi > -100) return 'good signal'
|
||||
if (rssi > -115) return 'fair signal'
|
||||
return 'weak signal'
|
||||
}
|
||||
return 'signal unknown'
|
||||
}
|
||||
|
||||
// Reaction dropdown: the quick-reaction row lives behind a "React" toggle so
|
||||
// the action menu stays compact and a larger emoji palette fits.
|
||||
const reactionPickerForId = ref<number | null>(null)
|
||||
const REACTION_PALETTE = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🎉', '💯', '👀', '😡', '🫡']
|
||||
|
||||
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
|
||||
async function reactTo(msg: MeshMessage, emoji: string) {
|
||||
const key = messageKeyFor(msg)
|
||||
@@ -1398,6 +1512,14 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
|
||||
const imageQualityChoice = ref<{ file: File } | null>(null)
|
||||
const imageQualityEstimates = ref<Map<string, string>>(new Map())
|
||||
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
|
||||
// Transport pills at the bottom of the image modal: when the peer is also a
|
||||
// federation node (FIPS overlay and/or Tor), the user can send the image that
|
||||
// way instead of over LoRa. 'lora' keeps the existing radio flow; 'fips'/'tor'
|
||||
// route via the blob/content-ref path (the dial layer prefers FIPS with Tor
|
||||
// fallback — the pills are capability labels, defaulted to whichever of the
|
||||
// two actually worked last for this peer).
|
||||
const imageSendTransport = ref<'lora' | 'fips' | 'tor'>('lora')
|
||||
const imageTransportOptions = ref<{ fips: boolean; tor: boolean }>({ fips: false, tor: false })
|
||||
|
||||
function formatEstSeconds(seconds: number): string {
|
||||
if (seconds < 60) return `~${seconds}s`
|
||||
@@ -1407,6 +1529,8 @@ function formatEstSeconds(seconds: number): string {
|
||||
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
|
||||
imageQualityChoice.value = { file }
|
||||
imageQualityEstimates.value = new Map()
|
||||
imageSendTransport.value = 'lora'
|
||||
imageTransportOptions.value = { fips: false, tor: false }
|
||||
// Fire off estimates for all presets in parallel — each preset's nominal
|
||||
// target size (or the real file size for 'original') against the SAME
|
||||
// mesh.transport-advice RPC the non-image attach flow already uses.
|
||||
@@ -1418,6 +1542,7 @@ async function openImageQualityDialog(file: File, peerContactId: number): Promis
|
||||
const label =
|
||||
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
|
||||
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
|
||||
imageTransportOptions.value = { fips: advice.has_fips, tor: advice.has_tor }
|
||||
} catch {
|
||||
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
|
||||
}
|
||||
@@ -1546,12 +1671,18 @@ async function handleAttachFile(ev: Event) {
|
||||
attaching.value = true
|
||||
attachError.value = null
|
||||
try {
|
||||
let forcedTransport: 'fips' | 'tor' | null = null
|
||||
if (file.type.startsWith('image/')) {
|
||||
const preset = await openImageQualityDialog(file, peer.contact_id)
|
||||
if (!preset) return // user cancelled
|
||||
if (imageSendTransport.value !== 'lora') forcedTransport = imageSendTransport.value
|
||||
file = await compressImage(file, preset)
|
||||
}
|
||||
if (!(await sendFileViaBestTransport(file, peer))) return
|
||||
if (forcedTransport) {
|
||||
// User picked a FIPS/Tor pill in the image modal — skip the LoRa
|
||||
// advice/chooser flow entirely and ship via the content-ref path.
|
||||
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
|
||||
} else if (!(await sendFileViaBestTransport(file, peer))) return
|
||||
messageText.value = ''
|
||||
nextTick(() => scrollChatToBottom())
|
||||
} catch (e) {
|
||||
@@ -1741,14 +1872,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<span v-else-if="mesh.status?.device_present" class="mesh-subtitle-badge">Mesh device detected</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://flasher.meshcore.co.uk/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
class="glass-button mesh-flasher-btn"
|
||||
:disabled="!flashLoraPath"
|
||||
:title="flashLoraPath ? '' : 'Plug in a LoRa radio first'"
|
||||
@click="openFlashLora"
|
||||
>
|
||||
Flash Meshcore <span class="mesh-flasher-sep">|</span> Choose Companion USB
|
||||
</a>
|
||||
Flash LoRa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
@@ -1830,8 +1961,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
|
||||
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
|
||||
</button>
|
||||
<button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast">
|
||||
{{ broadcasting ? 'Sending...' : 'Broadcast' }}
|
||||
<button
|
||||
class="glass-button mesh-action-btn"
|
||||
:class="broadcastResult === 'ok' ? 'mesh-action-ok' : ''"
|
||||
:disabled="!mesh.status?.device_connected || broadcasting"
|
||||
:title="broadcastResult && broadcastResult !== 'ok' ? broadcastResult : 'Announce this node so nearby radios learn about it'"
|
||||
@click="handleBroadcast"
|
||||
>
|
||||
{{ broadcasting ? 'Sending…' : broadcastResult === 'ok' ? 'Sent ✓' : broadcastResult ? 'Failed ✕' : 'Broadcast' }}
|
||||
</button>
|
||||
<button
|
||||
class="glass-button mesh-action-btn"
|
||||
@@ -1841,7 +1978,12 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
>
|
||||
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
|
||||
</button>
|
||||
<button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button>
|
||||
<!-- While refreshing show ONLY the spinner — spinner + "Refreshing…"
|
||||
together overflow the button's fixed width. -->
|
||||
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh" :title="refreshing ? 'Refreshing…' : 'Re-query the radio and reload peers, contacts and federation nodes'">
|
||||
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
|
||||
<template v-if="!refreshing">Refresh</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Peers list -->
|
||||
@@ -1871,7 +2013,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
||||
<!-- Only claim "no peers" when the MERGED list (radio + federation)
|
||||
is truly empty — with no radio attached the federation rows and
|
||||
the two channel rows must still render. -->
|
||||
<div v-if="displayedPeers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
||||
No peers discovered yet.
|
||||
</div>
|
||||
|
||||
@@ -2028,7 +2173,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="onChatScroll" @wheel.stop.prevent="onChatWheel">
|
||||
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
||||
No messages yet. Say hello!
|
||||
</div>
|
||||
@@ -2140,8 +2285,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<button
|
||||
class="mesh-typed-content-download-btn"
|
||||
title="Download"
|
||||
aria-label="Download image"
|
||||
@click="downloadAttachment(msg.typed_payload as any)"
|
||||
>⬇</button>
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<audio
|
||||
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
|
||||
@@ -2165,10 +2316,19 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
@click="openMeshLightbox(msg.typed_payload as any)"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
class="mesh-typed-content-fetch-btn"
|
||||
:disabled="fetchingCids.has(msg.typed_payload.cid)"
|
||||
@click="handleFetchContent(msg.typed_payload as any)"
|
||||
>
|
||||
<span
|
||||
v-if="fetchingCids.has(msg.typed_payload.cid)"
|
||||
class="mesh-refresh-spinner"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
{{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -2183,7 +2343,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<!-- Default: plain text -->
|
||||
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
||||
<div class="mesh-chat-bubble-meta">
|
||||
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span>
|
||||
<span v-if="transportLabel(msg)" class="mesh-chat-transport mesh-chat-transport-clickable" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg) + ' — click for route details'" @click.stop="hopVizMsg = msg">{{ transportLabel(msg) }}</span>
|
||||
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
||||
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
|
||||
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">✓✓</span>
|
||||
@@ -2214,17 +2374,25 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
|
||||
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
|
||||
<button
|
||||
v-for="emoji in QUICK_REACTIONS"
|
||||
:key="emoji"
|
||||
class="mesh-chat-reaction-btn"
|
||||
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
|
||||
class="mesh-chat-action-btn"
|
||||
:class="{ active: reactionPickerForId === msg.id }"
|
||||
:disabled="reactionInFlight !== null"
|
||||
@click="reactTo(msg, emoji)"
|
||||
>
|
||||
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<span v-else>{{ emoji }}</span>
|
||||
</button>
|
||||
@click="reactionPickerForId = reactionPickerForId === msg.id ? null : msg.id"
|
||||
>😀 React ▾</button>
|
||||
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu">✕</button>
|
||||
<div v-if="reactionPickerForId === msg.id" class="mesh-chat-reaction-dropdown">
|
||||
<button
|
||||
v-for="emoji in REACTION_PALETTE"
|
||||
:key="emoji"
|
||||
class="mesh-chat-reaction-btn"
|
||||
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
|
||||
:disabled="reactionInFlight !== null"
|
||||
@click="reactTo(msg, emoji)"
|
||||
>
|
||||
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<span v-else>{{ emoji }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2363,7 +2531,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
</Teleport>
|
||||
|
||||
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
||||
(inline-chunked) and Tor. User picks which path to send it over. -->
|
||||
(inline-chunked) and Tor. User picks which path to send it over.
|
||||
Teleported to body so the fixed backdrop covers the FULL viewport —
|
||||
rendered in place it sits inside a transformed/filtered glass panel,
|
||||
which traps position:fixed to just the right chat panel. -->
|
||||
<Teleport to="body">
|
||||
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
||||
<div class="glass-card mesh-transport-modal">
|
||||
<h3 class="mesh-transport-title">📎 How should I send this?</h3>
|
||||
@@ -2392,10 +2564,13 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Image quality-picker modal: shown before sending an image attachment.
|
||||
Each preset shows its nominal size target + a transfer-time estimate
|
||||
from the same mesh.transport-advice RPC the file-attach flow uses. -->
|
||||
from the same mesh.transport-advice RPC the file-attach flow uses.
|
||||
Teleported to body — see the transport chooser above. -->
|
||||
<Teleport to="body">
|
||||
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
|
||||
<div class="glass-card mesh-transport-modal">
|
||||
<h3 class="mesh-transport-title">🖼️ Choose Image Quality</h3>
|
||||
@@ -2412,12 +2587,91 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
>
|
||||
<span class="mesh-transport-icon">🖼️</span>
|
||||
<span class="mesh-transport-label">{{ preset.displayName }} — {{ preset.description }}</span>
|
||||
<span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span>
|
||||
<span class="mesh-transport-meta">{{
|
||||
imageSendTransport === 'lora' ? (imageQualityEstimates.get(preset.key) ?? '…') : 'instant'
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Transport pills: LoRa is always available (it's a mesh chat);
|
||||
FIPS/Tor appear when the peer is a reachable federation node,
|
||||
letting the user send the image that way instead. -->
|
||||
<div
|
||||
v-if="imageTransportOptions.fips || imageTransportOptions.tor"
|
||||
class="mesh-image-transport-row"
|
||||
>
|
||||
<span class="mesh-image-transport-caption">Send via</span>
|
||||
<button
|
||||
class="mesh-image-transport-pill"
|
||||
:class="{ active: imageSendTransport === 'lora' }"
|
||||
@click="imageSendTransport = 'lora'"
|
||||
>📡 LoRa</button>
|
||||
<button
|
||||
v-if="imageTransportOptions.fips"
|
||||
class="mesh-image-transport-pill"
|
||||
:class="{ active: imageSendTransport === 'fips' }"
|
||||
@click="imageSendTransport = 'fips'"
|
||||
>⚡ FIPS</button>
|
||||
<button
|
||||
v-if="imageTransportOptions.tor"
|
||||
class="mesh-image-transport-pill"
|
||||
:class="{ active: imageSendTransport === 'tor' }"
|
||||
@click="imageSendTransport = 'tor'"
|
||||
>🧅 Tor</button>
|
||||
</div>
|
||||
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Hop visualization modal: click a message's transport pill to see how
|
||||
it traveled — radio hops + live signal for LoRa transports, overlay/
|
||||
circuit shape for FIPS/Tor. Teleported to body (full-screen backdrop). -->
|
||||
<Teleport to="body">
|
||||
<div v-if="hopVizMsg" class="mesh-transport-modal-backdrop" @click.self="hopVizMsg = null">
|
||||
<div class="glass-card mesh-transport-modal">
|
||||
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) }} route</h3>
|
||||
<p class="mesh-transport-sub">
|
||||
{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}
|
||||
</p>
|
||||
<div class="mesh-hopviz-chain">
|
||||
<div class="mesh-hopviz-node">
|
||||
<span class="mesh-hopviz-icon">🏝️</span>
|
||||
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }}</span>
|
||||
</div>
|
||||
<template v-if="hopVizMsg.transport === 'tor'">
|
||||
<div class="mesh-hopviz-link">🧅 3 anonymous relays</div>
|
||||
</template>
|
||||
<template v-else-if="hopVizMsg.transport === 'fips'">
|
||||
<div class="mesh-hopviz-link">⚡ FIPS overlay · direct peer-to-peer</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mesh-hopviz-link">
|
||||
📡 {{ hopVizHops() === null || hopVizHops() === 0 ? 'direct radio link' : `${hopVizHops()} hop${hopVizHops() === 1 ? '' : 's'}` }}
|
||||
<span v-if="hopVizHops() !== null && hopVizHops()! > 0" class="mesh-hopviz-dots">
|
||||
<span v-for="i in Math.min(hopVizHops()!, 6)" :key="i" class="mesh-hopviz-dot" title="relay node">•</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mesh-hopviz-node">
|
||||
<span class="mesh-hopviz-icon">🏝️</span>
|
||||
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hopVizMsg.transport !== 'tor' && hopVizMsg.transport !== 'fips'" class="mesh-hopviz-signal">
|
||||
<span>{{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }}</span>
|
||||
<span v-if="hopVizPeer?.snr != null" class="mesh-hopviz-meta">SNR {{ hopVizPeer.snr.toFixed(1) }} dB</span>
|
||||
<span v-if="hopVizPeer?.rssi != null" class="mesh-hopviz-meta">RSSI {{ hopVizPeer.rssi }} dBm</span>
|
||||
<p class="mesh-hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
|
||||
</div>
|
||||
<div class="mesh-hopviz-signal">
|
||||
<span v-if="hopVizMsg.encrypted" class="mesh-chat-e2e">E2E</span>
|
||||
<span class="mesh-hopviz-meta">{{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }}</span>
|
||||
<span class="mesh-hopviz-meta">{{ timeAgo(hopVizMsg.timestamp) }}</span>
|
||||
</div>
|
||||
<button class="mesh-transport-cancel" @click="hopVizMsg = null">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- The "mesh device detected" setup flow is now the global
|
||||
MeshDeviceSetupModal mounted in App.vue (fires on every page,
|
||||
|
||||
@@ -98,6 +98,36 @@ describe('appsConfig service filtering', () => {
|
||||
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
|
||||
})
|
||||
|
||||
it('never offers Launch for an unknown container with a bare exposed port', () => {
|
||||
// A self-deployed compose stack (e.g. podsteadr) publishes a port, so it
|
||||
// has a runtime lan-address — but no manifest-declared or probed UI. It
|
||||
// must classify as a service and must NOT get a Launch button.
|
||||
const selfDeployed = makePkg('podsteadr', 'podsteadr', 'other')
|
||||
selfDeployed.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
expect(isWebsitePackage('podsteadr', selfDeployed)).toBe(true)
|
||||
expect(canLaunch(selfDeployed)).toBe(false)
|
||||
})
|
||||
|
||||
it('offers Launch for an unknown container once the backend confirms a UI', () => {
|
||||
const confirmedUi = makePkg('podsteadr', 'podsteadr', 'other')
|
||||
;(confirmedUi.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
||||
confirmedUi.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
expect(canLaunch(confirmedUi)).toBe(true)
|
||||
})
|
||||
|
||||
it('never offers Launch for curated service containers even with a UI flag', () => {
|
||||
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
|
||||
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
|
||||
service.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9100' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
expect(canLaunch(service)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Launch for curated apps that rely on a runtime address alone', () => {
|
||||
const known = makePkg('jellyfin', 'Jellyfin', 'media')
|
||||
known.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8096' } }, status: 'running' } as unknown as PackageDataEntry['installed']
|
||||
expect(canLaunch(known)).toBe(true)
|
||||
})
|
||||
|
||||
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
|
||||
const pkg = makePkg('fedimint', 'Fedimint', 'money')
|
||||
pkg.state = PackageState.Starting
|
||||
|
||||
@@ -255,9 +255,17 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
|
||||
|
||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||
// Headless backends never get a Launch button, even with a published port.
|
||||
if (isServicePackage(pkg.manifest.id, pkg)) return false
|
||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
||||
// A bare runtime address is only a launch signal for curated apps: the
|
||||
// backend now sets interfaces.main.ui strictly for confirmed web UIs
|
||||
// (manifest declaration or HTTP probe), so an unknown container with an
|
||||
// exposed non-UI port must not become launchable just for having one.
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui
|
||||
|| hasKnownLaunchUrl
|
||||
|| (hasRuntimeAddress && isKnownApp(pkg.manifest.id, pkg))
|
||||
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
||||
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
||||
}
|
||||
|
||||
@@ -142,12 +142,15 @@ async function saveSettings() {
|
||||
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() } : {}),
|
||||
// Always sent: an empty string CLEARS the custom mesh name (backend
|
||||
// maps "" -> None -> fall back to the server name). The old omit-when-
|
||||
// empty made clearing impossible once a name was ever set.
|
||||
advert_name: form.value.name.trim(),
|
||||
broadcast_identity: form.value.broadcastIdentity,
|
||||
...(rfParams ? { lora_radio_params: rfParams } : {}),
|
||||
})
|
||||
saveDone.value = true
|
||||
setTimeout(() => { saveDone.value = false }, 3000)
|
||||
setTimeout(() => { saveDone.value = false }, 5000)
|
||||
} catch (e) {
|
||||
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
|
||||
} finally {
|
||||
@@ -286,7 +289,7 @@ async function saveSettings() {
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save Settings' }}
|
||||
</button>
|
||||
<span v-if="saveDone" class="text-xs text-green-400">Saved — applies on next radio session</span>
|
||||
<span v-if="saveDone" class="text-xs text-green-400">Saved — applying to the radio now…</span>
|
||||
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
|
||||
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
|
||||
.mesh-flasher-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
|
||||
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
|
||||
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
|
||||
@@ -83,6 +84,19 @@
|
||||
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
|
||||
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
|
||||
.mesh-action-ok { color: #34d399; border-color: rgba(52, 211, 153, 0.4); }
|
||||
.mesh-refresh-spinner {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 4px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(251, 146, 60, 0.7);
|
||||
border-top-color: transparent;
|
||||
animation: mesh-refresh-spin 0.8s linear infinite;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
@keyframes mesh-refresh-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
|
||||
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||
@@ -361,13 +375,35 @@
|
||||
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
|
||||
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
|
||||
.mesh-typed-content-download-btn {
|
||||
position: absolute; bottom: 6px; right: 6px; width: 1.75rem; height: 1.75rem;
|
||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.15);
|
||||
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); font-size: 0.85rem;
|
||||
position: absolute; bottom: 8px; right: 8px;
|
||||
width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
|
||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
|
||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.mesh-typed-content-download-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
||||
.mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
|
||||
.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
|
||||
.mesh-typed-content-download-btn:active { transform: scale(0.96); }
|
||||
/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
|
||||
replaced collapsed to its text width inside the narrow mobile bubble and
|
||||
looked squashed — this is a full-width glass pill in the house style. */
|
||||
.mesh-typed-content-fetch-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||
width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
|
||||
border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
|
||||
font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
|
||||
.mesh-typed-content-fetch-btn:hover:not(:disabled) {
|
||||
background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
|
||||
}
|
||||
.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
|
||||
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
@@ -546,3 +582,27 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
|
||||
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
|
||||
.mesh-transport-cancel:hover { color: #fff; }
|
||||
|
||||
/* Transport pills at the bottom of the image quality modal — pick LoRa vs
|
||||
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
|
||||
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
|
||||
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
|
||||
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
|
||||
|
||||
/* Hop visualization modal + clickable transport pill */
|
||||
.mesh-chat-transport-clickable { cursor: pointer; }
|
||||
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
|
||||
.mesh-hopviz-chain { display: flex; align-items: center; gap: 10px; justify-content: center; padding: 14px 8px; flex-wrap: wrap; }
|
||||
.mesh-hopviz-node { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
|
||||
.mesh-hopviz-icon { font-size: 1.8rem; }
|
||||
.mesh-hopviz-name { font-size: 0.8rem; font-weight: 600; color: #fff; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mesh-hopviz-link { flex: 1 1 auto; text-align: center; font-size: 0.8rem; color: rgba(255,255,255,0.75); border-top: 2px dashed rgba(251,146,60,0.5); padding-top: 6px; min-width: 120px; }
|
||||
.mesh-hopviz-dots { margin-left: 6px; letter-spacing: 6px; color: rgba(251,146,60,0.9); }
|
||||
.mesh-hopviz-signal { display: flex; align-items: center; gap: 10px; justify-content: center; flex-wrap: wrap; font-size: 0.8rem; color: rgba(255,255,255,0.75); padding: 4px 0; }
|
||||
.mesh-hopviz-meta { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||
.mesh-hopviz-note { flex-basis: 100%; text-align: center; font-size: 0.68rem; color: rgba(255,255,255,0.35); margin: 2px 0 0; }
|
||||
|
||||
/* Reaction dropdown inside the message action menu */
|
||||
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { safeClipboardWrite } from '@/views/web5/utils'
|
||||
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
||||
|
||||
@@ -136,7 +137,14 @@ interface FipsStatus {
|
||||
anchor_connected?: boolean
|
||||
}
|
||||
|
||||
const status = ref<FipsStatus>({
|
||||
// Shares `server.fips-summary` with the Local Network card's FIPS row, so
|
||||
// both paint from the same cache instantly on revisit and never disagree.
|
||||
const statusRes = useCachedResource<FipsStatus>({
|
||||
key: 'server.fips-summary',
|
||||
ttlMs: 15_000,
|
||||
fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
|
||||
})
|
||||
const status = computed<FipsStatus>(() => statusRes.data.value ?? {
|
||||
installed: false,
|
||||
version: null,
|
||||
service_state: 'unknown',
|
||||
@@ -199,18 +207,11 @@ function flash(msg: string, isError = false) {
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('fips.status failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function installAndActivate() {
|
||||
installing.value = true
|
||||
try {
|
||||
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
||||
const next = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
||||
statusRes.optimistic(() => next) // confirmed server state, not a guess
|
||||
flash('FIPS started')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
@@ -235,7 +236,7 @@ async function reconnectAnchor() {
|
||||
}>({ method: 'fips.reconnect', timeout: 60_000 })
|
||||
// Update the card with the post-reconnect status returned by the
|
||||
// backend — avoids an extra status fetch race.
|
||||
status.value = { ...status.value, ...res.after }
|
||||
statusRes.optimistic((cur) => ({ ...(cur ?? status.value), ...res.after }))
|
||||
if (res.recovered) {
|
||||
flash('Anchor reconnected.')
|
||||
} else if (res.likely_cause === 'connected') {
|
||||
@@ -258,8 +259,10 @@ async function reconnectAnchor() {
|
||||
// stuck showing whatever anchor state existed at mount time forever.
|
||||
let statusInterval: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
statusInterval = setInterval(loadStatus, 15000)
|
||||
statusInterval = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
void statusRes.refresh()
|
||||
}, 15000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (statusInterval) clearInterval(statusInterval)
|
||||
|
||||
@@ -88,8 +88,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
|
||||
defineProps<{ closable?: boolean }>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
@@ -107,7 +108,16 @@ interface ApplyResult {
|
||||
message: string
|
||||
}
|
||||
|
||||
const anchors = ref<SeedAnchor[]>([])
|
||||
const anchorsRes = useCachedResource<SeedAnchor[]>({
|
||||
key: 'server.fips-seed-anchors',
|
||||
fetcher: async (signal) => {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
|
||||
method: 'fips.list-seed-anchors', signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return res.seed_anchors
|
||||
},
|
||||
})
|
||||
const anchors = computed(() => anchorsRes.data.value ?? [])
|
||||
const adding = ref(false)
|
||||
const applying = ref(false)
|
||||
const statusMessage = ref('')
|
||||
@@ -126,15 +136,6 @@ function flash(msg: string, isError = false) {
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
|
||||
anchors.value = res.seed_anchors
|
||||
} catch (e: unknown) {
|
||||
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addAnchor() {
|
||||
if (!draft.npub.trim() || !draft.address.trim()) return
|
||||
adding.value = true
|
||||
@@ -148,7 +149,7 @@ async function addAnchor() {
|
||||
label: draft.label.trim(),
|
||||
},
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
anchorsRes.optimistic(() => res.seed_anchors) // authoritative post-add list
|
||||
draft.npub = ''
|
||||
draft.address = ''
|
||||
draft.label = ''
|
||||
@@ -168,7 +169,7 @@ async function removeAnchor(npub: string) {
|
||||
method: 'fips.remove-seed-anchor',
|
||||
params: { npub },
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
anchorsRes.optimistic(() => res.seed_anchors)
|
||||
flash('Anchor removed.')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
@@ -189,6 +190,4 @@ async function applyAll() {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -366,14 +366,24 @@ init()
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.117-alpha</span>
|
||||
<span class="text-xs text-white/40">July 27, 2026</span>
|
||||
<span class="text-xs text-white/40">July 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.</p>
|
||||
<p>Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.</p>
|
||||
<p>Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.</p>
|
||||
<p>Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.</p>
|
||||
<p>Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.</p>
|
||||
<p>The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.</p>
|
||||
<p>Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.</p>
|
||||
<p>Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome.</p>
|
||||
<p>Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.</p>
|
||||
<p>The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks.</p>
|
||||
<p>FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.</p>
|
||||
<p>FIPS startup is more reliable on nodes that have the packaged fips.service instead of Archipelago's archipelago-fips.service. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.</p>
|
||||
<p>App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.</p>
|
||||
<p>Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.</p>
|
||||
<p>Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.</p>
|
||||
<p>Public-source preparation now includes a Nostr Git hosting plan using ngit, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.</p>
|
||||
<p>Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.116-alpha -->
|
||||
|
||||
+24
-17
@@ -1,29 +1,36 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Nodes no longer get stuck on \"server starting up\" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.",
|
||||
"Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.",
|
||||
"Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts."
|
||||
"Flash your LoRa radio from inside the app. The Mesh page now has a \"Flash LoRa\" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.",
|
||||
"Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.",
|
||||
"Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.",
|
||||
"Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.",
|
||||
"Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.",
|
||||
"The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.",
|
||||
"Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.",
|
||||
"Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest \"pending\" state, and reports the true final outcome.",
|
||||
"Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.",
|
||||
"The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.116-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago",
|
||||
"current_version": "1.7.117-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.117-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.116-alpha",
|
||||
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
|
||||
"size_bytes": 51787208
|
||||
"new_version": "1.7.117-alpha",
|
||||
"sha256": "a63bf41ac00e0b77da7bf3796e55a95e2973d4fc335580c5157bd7a506b4a59b",
|
||||
"size_bytes": 53577760
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.116-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.116-alpha.tar.gz",
|
||||
"new_version": "1.7.116-alpha",
|
||||
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
|
||||
"size_bytes": 178062490
|
||||
"current_version": "1.7.117-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.117-alpha/archipelago-frontend-1.7.117-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.117-alpha.tar.gz",
|
||||
"new_version": "1.7.117-alpha",
|
||||
"sha256": "10d409308736a664ede4415d086e1fd9b9b8c25aed4649c3b89562df97d169a0",
|
||||
"size_bytes": 178025421
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-27",
|
||||
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
|
||||
"release_date": "2026-07-29",
|
||||
"signature": "d1a00037db19cdadf28950e43037dea4b3316d606001593f59afadf13c8fc43502a1404732eb9701f2f2ad9b1803f023a81bbd30d884432499276aeca0711a09",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.116-alpha"
|
||||
"version": "1.7.117-alpha"
|
||||
}
|
||||
|
||||
+24
-17
@@ -1,29 +1,36 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Nodes no longer get stuck on \"server starting up\" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.",
|
||||
"Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.",
|
||||
"Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts."
|
||||
"Flash your LoRa radio from inside the app. The Mesh page now has a \"Flash LoRa\" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.",
|
||||
"Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.",
|
||||
"Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.",
|
||||
"Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.",
|
||||
"Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.",
|
||||
"The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.",
|
||||
"Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.",
|
||||
"Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest \"pending\" state, and reports the true final outcome.",
|
||||
"Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.",
|
||||
"The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.116-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago",
|
||||
"current_version": "1.7.117-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.117-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.116-alpha",
|
||||
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
|
||||
"size_bytes": 51787208
|
||||
"new_version": "1.7.117-alpha",
|
||||
"sha256": "a63bf41ac00e0b77da7bf3796e55a95e2973d4fc335580c5157bd7a506b4a59b",
|
||||
"size_bytes": 53577760
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.116-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.116-alpha.tar.gz",
|
||||
"new_version": "1.7.116-alpha",
|
||||
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
|
||||
"size_bytes": 178062490
|
||||
"current_version": "1.7.117-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.117-alpha/archipelago-frontend-1.7.117-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.117-alpha.tar.gz",
|
||||
"new_version": "1.7.117-alpha",
|
||||
"sha256": "10d409308736a664ede4415d086e1fd9b9b8c25aed4649c3b89562df97d169a0",
|
||||
"size_bytes": 178025421
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-27",
|
||||
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
|
||||
"release_date": "2026-07-29",
|
||||
"signature": "d1a00037db19cdadf28950e43037dea4b3316d606001593f59afadf13c8fc43502a1404732eb9701f2f2ad9b1803f023a81bbd30d884432499276aeca0711a09",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.116-alpha"
|
||||
"version": "1.7.117-alpha"
|
||||
}
|
||||
|
||||
@@ -47,11 +47,16 @@ if [ -n "$RNODECONF_SRC" ] && [ -f "$RNODECONF_SRC" ]; then
|
||||
# exit()/quit() builtins, which only exist in interactive Python (site.py
|
||||
# injects them) — a frozen app hits NameError right as it tries to quit
|
||||
# cleanly, after all the real work already succeeded. See
|
||||
# pyi_rthook_exit_builtins.py.
|
||||
# pyi_rthook_exit_builtins.py. A second hook fixes rnodeconf's board-flash
|
||||
# step, which shells out to a bundled esptool.py via `sys.executable` —
|
||||
# under a frozen binary that's the binary itself, not a real interpreter,
|
||||
# so the flash subprocess call breaks. See
|
||||
# pyi_rthook_fix_flasher_executable.py.
|
||||
.venv/bin/pyinstaller --onefile --name archy-rnodeconf --clean --noconfirm \
|
||||
--collect-submodules RNS \
|
||||
--collect-data RNS \
|
||||
--runtime-hook pyi_rthook_exit_builtins.py \
|
||||
--runtime-hook pyi_rthook_fix_flasher_executable.py \
|
||||
-d noarchive \
|
||||
"$RNODECONF_SRC"
|
||||
echo "Built dist/archy-rnodeconf ($(du -h dist/archy-rnodeconf | cut -f1))"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# PyInstaller runtime hook — see build.sh.
|
||||
#
|
||||
# rnodeconf's own board-flashing code shells out to a bundled esptool.py as
|
||||
# `[sys.executable, flasher_path, "--chip", ..., "write_flash", ...]` (RNS's
|
||||
# rnodeconf.py, ~line 2794 as of RNS 1.3.5). That's correct for a normal
|
||||
# `python rnodeconf.py` invocation, but under a frozen PyInstaller binary
|
||||
# `sys.executable` is the frozen binary itself, not a real interpreter — so
|
||||
# the "subprocess" just re-invokes archy-rnodeconf's OWN argparse CLI with
|
||||
# esptool-shaped flags, which it doesn't recognize, and the flash step fails
|
||||
# immediately with "unrecognized arguments: --chip ...". Confirmed live
|
||||
# against a real Heltec V4 (2026-07-23): device selection, band selection,
|
||||
# and firmware download all worked; only the final `write_flash` subprocess
|
||||
# call broke this way.
|
||||
#
|
||||
# Fix: point sys.executable at a real Python interpreter that has rnodeconf's
|
||||
# own runtime deps available (esptool.py only needs pyserial, which RNS
|
||||
# already depends on) before any of rnodeconf's code runs. Prefer the build
|
||||
# venv this exact binary was frozen from — see build.sh — falling back to a
|
||||
# bare `python3` on PATH if that venv isn't present on this node.
|
||||
import os
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
_candidates = [
|
||||
os.environ.get("ARCHY_RNODECONF_PYTHON", ""),
|
||||
os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), "..", "reticulum-daemon", ".venv", "bin", "python3"),
|
||||
os.path.expanduser("~/archy/reticulum-daemon/.venv/bin/python3"),
|
||||
]
|
||||
for _candidate in _candidates:
|
||||
if _candidate and os.path.isfile(_candidate):
|
||||
sys.executable = _candidate
|
||||
break
|
||||
else:
|
||||
sys.executable = "python3"
|
||||
@@ -14,12 +14,13 @@ Security posture (see the plan's "most secure way" section):
|
||||
RPC (one JSON object per line, both directions):
|
||||
in : {"cmd":"send","dest_hash":"<hex16>","content":"…","title":"…","method":"direct|opportunistic"}
|
||||
{"cmd":"announce"}
|
||||
{"cmd":"set_name","name":"…"}
|
||||
{"cmd":"status"}
|
||||
{"cmd":"send_resource","id":"<correlation>","dest_hash":"<hex16>","data_b64":"…"}
|
||||
{"cmd":"shutdown"}
|
||||
out: {"event":"ready","dest_hash":"<hex16>","display_name":"…"}
|
||||
{"event":"recv","source_hash":"<hex16>","content":"…","title":"…","fields":{…},"app_data":"<hex>","rssi":n,"snr":n,"stamp":t}
|
||||
{"event":"announce","dest_hash":"<hex16>","app_data":"<hex>"}
|
||||
{"event":"announce","dest_hash":"<hex16>","app_data":"<hex>","display_name":"…"|null,"archy_blob":"ARCHY:2:…"|null}
|
||||
{"event":"delivered","dest_hash":"<hex16>","state":"delivered|failed","id":"<hex>"}
|
||||
{"event":"status","connected":bool,"dest_hash":"<hex16>","interfaces":[…]}
|
||||
{"event":"resource_progress","id":"<correlation>","transferred":n,"total":n}
|
||||
@@ -81,6 +82,7 @@ def _write_rns_config(
|
||||
no_radio: bool,
|
||||
tcp_listen: str | None = None,
|
||||
tcp_connect: list[str] | None = None,
|
||||
enable_transport: bool = False,
|
||||
) -> None:
|
||||
"""Materialise an RNS config file. RNode interface for real radios; plain-TCP
|
||||
server/client interface(s) for radio-less dev/verification (e.g. Aurora
|
||||
@@ -135,7 +137,7 @@ def _write_rns_config(
|
||||
)
|
||||
cfg.write_text(
|
||||
"[reticulum]\n"
|
||||
" enable_transport = no\n"
|
||||
f" enable_transport = {'yes' if enable_transport else 'no'}\n"
|
||||
" share_instance = no\n"
|
||||
" panic_on_interface_error = no\n\n"
|
||||
"[interfaces]\n" + interfaces
|
||||
@@ -190,6 +192,7 @@ class ReticulumDaemon:
|
||||
no_radio=self.args.no_radio,
|
||||
tcp_listen=self.args.tcp_listen,
|
||||
tcp_connect=self.args.tcp_connect,
|
||||
enable_transport=self.args.enable_transport,
|
||||
)
|
||||
self.reticulum = RNS.Reticulum(configdir=str(configdir))
|
||||
self.identity = load_identity(self.seed)
|
||||
@@ -227,31 +230,47 @@ class ReticulumDaemon:
|
||||
if self.delivery_destination is not None:
|
||||
self.delivery_destination.announce(app_data=self._announce_app_data())
|
||||
|
||||
def _announce_app_data(self) -> bytes:
|
||||
"""Carry the Archy identity so peers bind this RNS destination onto the
|
||||
existing contact, the same way a meshcore/Meshtastic identity advert does.
|
||||
|
||||
Reuses the exact ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` wire format the
|
||||
Rust side already parses (``protocol::parse_identity_broadcast``) and
|
||||
binds via ``handle_identity_received``/``bind_federation_twins`` — so a
|
||||
Reticulum-carried identity merges into the SAME conversation as the
|
||||
meshcore/Meshtastic/federation twins of the same Archy node, satisfying
|
||||
cross-protocol DM convergence. The keys are the node's real Archipelago
|
||||
ed25519/x25519 pubkeys (passed in by the Rust side, which already has
|
||||
them) — NOT this daemon's internally-HKDF-derived RNS keys, which exist
|
||||
only to make the RNS destination hash deterministic and are never
|
||||
themselves treated as an Archy identity.
|
||||
|
||||
Falls back to a plain display-name string (undetected as an identity
|
||||
blob — no `ARCHY:2:` prefix) if the Archy pubkeys weren't supplied, e.g.
|
||||
a dev/selftest run with no `--archy-ed-pubkey-hex`.
|
||||
"""
|
||||
def _archy_identity_blob(self):
|
||||
"""The ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` identity string the Rust
|
||||
side parses (``protocol::parse_identity_broadcast``) and binds via
|
||||
``handle_identity_received`` — so a Reticulum-carried identity merges
|
||||
into the SAME conversation as the meshcore/Meshtastic/federation twins
|
||||
of the same Archy node. The keys are the node's real Archipelago
|
||||
pubkeys (passed in by the Rust side) — NOT this daemon's
|
||||
internally-HKDF-derived RNS keys, which exist only to make the RNS
|
||||
destination hash deterministic. ``None`` when the pubkeys weren't
|
||||
supplied (dev/selftest run)."""
|
||||
if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex:
|
||||
return (
|
||||
f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:"
|
||||
f"{self.args.archy_x25519_pubkey_hex}"
|
||||
).encode("ascii")
|
||||
return (self.args.display_name or "").encode("utf-8")
|
||||
return None
|
||||
|
||||
def _announce_app_data(self) -> bytes:
|
||||
"""LXMF-standard announce app_data — msgpack ``[display_name,
|
||||
stamp_cost, supported_functionality]`` via the router, so Sideband/
|
||||
NomadNet/MeshChat (and upgraded archy nodes) all see our real display
|
||||
name — with the Archy identity blob appended as an EXTRA list element.
|
||||
Stock clients only read the elements they know ([0]/[1]), so the blob
|
||||
rides along invisibly instead of replacing the name the way the old
|
||||
blob-only app_data did (which left every archy node nameless on RNS).
|
||||
"""
|
||||
import RNS.vendor.umsgpack as msgpack
|
||||
app_data = self.router.get_announce_app_data(self.delivery_destination.hash)
|
||||
blob = self._archy_identity_blob()
|
||||
if blob is None:
|
||||
return app_data
|
||||
try:
|
||||
peer_data = msgpack.unpackb(app_data)
|
||||
if not isinstance(peer_data, list):
|
||||
raise ValueError("unexpected announce app_data shape")
|
||||
peer_data.append(blob)
|
||||
return msgpack.packb(peer_data)
|
||||
except Exception:
|
||||
# Never let announce formatting kill announcing entirely — fall
|
||||
# back to the legacy blob-only format (identity binding > name).
|
||||
return blob
|
||||
|
||||
# ---- RNS-thread callbacks → asyncio ----
|
||||
def _on_lxmf_delivery(self, message):
|
||||
@@ -328,6 +347,15 @@ class ReticulumDaemon:
|
||||
self._send(req)
|
||||
elif cmd == "announce":
|
||||
self.announce()
|
||||
elif cmd == "set_name":
|
||||
# Live rename: update the LXMF delivery destination's display name
|
||||
# (what get_announce_app_data reads) and re-announce immediately so
|
||||
# peers learn the new name without waiting for the next advert tick.
|
||||
name = (req.get("name") or "").strip()
|
||||
if name and self.delivery_destination is not None:
|
||||
self.args.display_name = name
|
||||
self.delivery_destination.display_name = name
|
||||
self.announce()
|
||||
elif cmd == "status":
|
||||
self._broadcast(self._status())
|
||||
elif cmd == "send_resource":
|
||||
@@ -411,13 +439,29 @@ class ReticulumDaemon:
|
||||
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
|
||||
"archy", "resource",
|
||||
)
|
||||
link = RNS.Link(out_dest)
|
||||
# established_callback identifies us to the peer BEFORE any Resource
|
||||
# goes out: the receiving daemon attributes an inbound resource via
|
||||
# link.get_remote_identity() (see _on_resource_received), which is
|
||||
# None unless the initiator calls identify() — without this every
|
||||
# transfer arrives with an empty source_hash and the Rust side can't
|
||||
# route it to a contact (observed live 2026-07-28: 5KB image sent,
|
||||
# concluded COMPLETE, never surfaced on the receiving node).
|
||||
link = RNS.Link(
|
||||
out_dest,
|
||||
established_callback=lambda lk: self._on_out_link_established(
|
||||
resource_hash, lk
|
||||
),
|
||||
)
|
||||
link.set_link_closed_callback(
|
||||
lambda lk: self.links.pop(resource_hash, None)
|
||||
)
|
||||
self.links[resource_hash] = link
|
||||
return link, resource_hash
|
||||
|
||||
def _on_out_link_established(self, resource_hash: bytes, link):
|
||||
link.identify(self.identity)
|
||||
self._flush_pending_resource_sends(resource_hash, link)
|
||||
|
||||
def _send_resource(self, req: dict):
|
||||
import RNS
|
||||
req_id = req.get("id", "")
|
||||
@@ -437,14 +481,14 @@ class ReticulumDaemon:
|
||||
if link.status == RNS.Link.ACTIVE:
|
||||
self._start_resource(link, data, req_id)
|
||||
else:
|
||||
# Link is establishing — queue and flush from the established
|
||||
# callback. set_link_established_callback only fires once per Link
|
||||
# object (the cache is reused across sends), so re-set it here to
|
||||
# make sure THIS send's queue entry gets flushed too.
|
||||
# Link is establishing — queue; the creation-time established
|
||||
# callback (_on_out_link_established) identifies us then flushes
|
||||
# the queue. Re-check afterwards to close the race where the
|
||||
# link went ACTIVE between the status check and the append
|
||||
# (the callback fires once, on the RNS thread).
|
||||
self.pending_resource_sends[resource_hash].append((data, req_id))
|
||||
link.set_link_established_callback(
|
||||
lambda lk: self._flush_pending_resource_sends(resource_hash, lk)
|
||||
)
|
||||
if link.status == RNS.Link.ACTIVE:
|
||||
self._flush_pending_resource_sends(resource_hash, link)
|
||||
|
||||
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
|
||||
import RNS
|
||||
@@ -492,10 +536,17 @@ class ReticulumDaemon:
|
||||
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
|
||||
if identity is not None else ""
|
||||
)
|
||||
# RNS hands a concluded Resource's data as a file-like object
|
||||
# (BufferedReader over the assembled stream), not bytes —
|
||||
# observed live 2026-07-28: b64encode raised "a bytes-like
|
||||
# object is required" and every received transfer was lost.
|
||||
data = resource.data
|
||||
if hasattr(data, "read"):
|
||||
data = data.read()
|
||||
self._emit_threadsafe({
|
||||
"event": "resource_recv",
|
||||
"source_hash": source_hash,
|
||||
"data_b64": base64.b64encode(resource.data).decode("ascii"),
|
||||
"data_b64": base64.b64encode(data).decode("ascii"),
|
||||
})
|
||||
except Exception as e: # never let a callback kill the RNS thread
|
||||
self._emit_threadsafe({"event": "error", "where": "resource_recv",
|
||||
@@ -505,7 +556,14 @@ class ReticulumDaemon:
|
||||
sock_path = self.args.socket
|
||||
if os.path.exists(sock_path):
|
||||
os.unlink(sock_path)
|
||||
server = await asyncio.start_unix_server(self._handle_client, path=sock_path)
|
||||
# limit: asyncio's default per-line StreamReader cap is 64KiB, but
|
||||
# send_resource requests carry the whole payload base64-encoded on one
|
||||
# JSON line — a ~48KB+ attachment overflows the default and the raised
|
||||
# LimitOverrunError tears down the RPC connection (the Rust side then
|
||||
# sees "reticulum-daemon is gone" and restarts the whole session).
|
||||
server = await asyncio.start_unix_server(
|
||||
self._handle_client, path=sock_path, limit=16 * 1024 * 1024
|
||||
)
|
||||
os.chmod(sock_path, 0o600)
|
||||
self.announce()
|
||||
async with server:
|
||||
@@ -521,10 +579,41 @@ class _AnnounceHandler:
|
||||
self.receive_path_responses = True
|
||||
|
||||
def received_announce(self, destination_hash, announced_identity, app_data):
|
||||
# Decode what we can here (both the LXMF-standard display name and our
|
||||
# appended ARCHY identity blob — see _announce_app_data) so the Rust
|
||||
# side gets clean typed fields instead of re-implementing msgpack.
|
||||
display_name = None
|
||||
archy_blob = None
|
||||
raw = app_data or b""
|
||||
try:
|
||||
import LXMF
|
||||
display_name = LXMF.display_name_from_app_data(raw)
|
||||
# A legacy blob-only announce is plain ascii, so LXMF's decoder
|
||||
# returns the whole ARCHY identity blob as a "name" — drop it.
|
||||
if display_name and display_name.startswith("ARCHY:"):
|
||||
display_name = None
|
||||
except Exception:
|
||||
display_name = None
|
||||
try:
|
||||
if raw[:1] and ((0x90 <= raw[0] <= 0x9F) or raw[0] == 0xDC):
|
||||
import RNS.vendor.umsgpack as msgpack
|
||||
peer_data = msgpack.unpackb(raw)
|
||||
if isinstance(peer_data, list):
|
||||
for el in peer_data[3:]:
|
||||
if isinstance(el, bytes) and el.startswith(b"ARCHY:"):
|
||||
archy_blob = el.decode("ascii", "ignore")
|
||||
break
|
||||
elif raw.startswith(b"ARCHY:"):
|
||||
# Legacy (pre-upgrade archy node): app_data IS the blob.
|
||||
archy_blob = raw.decode("ascii", "ignore")
|
||||
except Exception:
|
||||
archy_blob = None
|
||||
self.daemon._emit_threadsafe({
|
||||
"event": "announce",
|
||||
"dest_hash": destination_hash.hex(),
|
||||
"app_data": (app_data or b"").hex(),
|
||||
"app_data": raw.hex(),
|
||||
"display_name": display_name,
|
||||
"archy_blob": archy_blob,
|
||||
})
|
||||
|
||||
|
||||
@@ -554,6 +643,10 @@ def _parse_args(argv):
|
||||
p.add_argument("--txpower", type=int, default=17)
|
||||
p.add_argument("--spreadingfactor", type=int, default=8)
|
||||
p.add_argument("--codingrate", type=int, default=5)
|
||||
p.add_argument("--enable-transport", action="store_true",
|
||||
help="run as an RNS transport node: relay traffic and rebroadcast "
|
||||
"announces so nodes beyond direct RF range discover each other "
|
||||
"through this one")
|
||||
p.add_argument("--no-radio", action="store_true", help="bring up with no RNode (selftest)")
|
||||
p.add_argument("--check", action="store_true", help="print derived dest hash and exit (no RNS)")
|
||||
p.add_argument("--selftest", action="store_true", help="bring up RNS+LXMF with no radio, verify, exit")
|
||||
@@ -604,8 +697,30 @@ def main(argv=None) -> int:
|
||||
if args.selftest:
|
||||
args.no_radio = True
|
||||
daemon.bring_up()
|
||||
# Announce app_data round-trip: the LXMF-standard msgpack name must be
|
||||
# decodable by stock clients AND (with archy keys set) the appended
|
||||
# identity blob must survive as an extra list element — this is the
|
||||
# exact wire contract the Rust announce handler and Sideband both
|
||||
# depend on, so verify it here where there's a real router to build it.
|
||||
import LXMF as _LXMF
|
||||
import RNS.vendor.umsgpack as _msgpack
|
||||
args.archy_ed_pubkey_hex = args.archy_ed_pubkey_hex or "ab" * 32
|
||||
args.archy_x25519_pubkey_hex = args.archy_x25519_pubkey_hex or "cd" * 32
|
||||
app_data = daemon._announce_app_data()
|
||||
decoded_name = _LXMF.display_name_from_app_data(app_data)
|
||||
assert decoded_name == args.display_name, (
|
||||
f"announce name round-trip failed: {decoded_name!r} != {args.display_name!r}"
|
||||
)
|
||||
blob_elems = [e for e in _msgpack.unpackb(app_data)[3:]
|
||||
if isinstance(e, bytes) and e.startswith(b"ARCHY:")]
|
||||
assert blob_elems, "identity blob missing from announce app_data"
|
||||
# Live rename: set_name must change what the next announce carries.
|
||||
daemon.delivery_destination.display_name = "selftest-renamed"
|
||||
renamed = _LXMF.display_name_from_app_data(daemon._announce_app_data())
|
||||
assert renamed == "selftest-renamed", f"rename round-trip failed: {renamed!r}"
|
||||
print(f"selftest ok — dest_hash={daemon.dest_hash_hex} "
|
||||
f"display_name={args.display_name!r} lxmf_router=up")
|
||||
f"display_name={args.display_name!r} lxmf_router=up "
|
||||
f"announce_app_data=verified set_name=verified")
|
||||
return 0
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
|
||||
@@ -84,6 +84,64 @@ if ! command -v nano >/dev/null 2>&1; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v ping >/dev/null 2>&1; then
|
||||
log "Installing iputils-ping..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq iputils-ping 2>>"$LOG_FILE"; then
|
||||
ok "ping installed"
|
||||
else
|
||||
warn "Unable to install ping automatically; continuing update"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v esptool >/dev/null 2>&1; then
|
||||
log "Installing esptool for LoRa radio firmware flashing..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq esptool 2>>"$LOG_FILE"; then
|
||||
ok "esptool installed"
|
||||
else
|
||||
warn "Unable to install esptool automatically; radio firmware flashing will be unavailable"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Debian's esptool package (4.7.0+dfsg-0.1) ships without the precompiled
|
||||
# esp32s3 "stub flasher" blob (stripped for DFSG compliance — no
|
||||
# buildable-from-source path Debian could verify). Without it, esptool's
|
||||
# normal stub-loader mode fails outright (FileNotFoundError), and the ROM
|
||||
# bootloader fallback (--no-stub) doesn't implement a full-chip erase at
|
||||
# all — confirmed live 2026-07-23 flashing a real Heltec V4, both ways.
|
||||
# Fetching the exact same file from the matching upstream esptool release
|
||||
# tag restores full (and correct) flashing behavior — it's the same
|
||||
# open-source codebase, just the one blob Debian's packaging couldn't
|
||||
# include.
|
||||
if command -v esptool >/dev/null 2>&1; then
|
||||
STUB_DIR="/usr/lib/python3/dist-packages/esptool/targets/stub_flasher"
|
||||
STUB_FILE="$STUB_DIR/stub_flasher_32s3.json"
|
||||
if [ ! -f "$STUB_FILE" ]; then
|
||||
log "Fetching esptool's esp32s3 stub flasher (missing from the Debian package)..."
|
||||
ESPTOOL_VERSION=$(esptool version 2>/dev/null | tail -1 | tr -d ' \t')
|
||||
if [ -n "$ESPTOOL_VERSION" ] && sudo curl -fsSL -o "$STUB_FILE" \
|
||||
"https://raw.githubusercontent.com/espressif/esptool/v${ESPTOOL_VERSION}/esptool/targets/stub_flasher/stub_flasher_32s3.json" \
|
||||
2>>"$LOG_FILE"; then
|
||||
sudo chmod 644 "$STUB_FILE"
|
||||
ok "esp32s3 stub flasher installed"
|
||||
else
|
||||
sudo rm -f "$STUB_FILE" 2>/dev/null
|
||||
warn "Unable to fetch esp32s3 stub flasher; LoRa firmware flashing will be unavailable"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build-time prerequisites for reticulum-daemon/build.sh's PyInstaller step
|
||||
# below (discovered the hard way: ensurepip needs python3-venv, and
|
||||
# PyInstaller itself needs objdump + libpython3.13.so at build time — none
|
||||
# of these are pulled in by a bare `python3` package on Debian trixie).
|
||||
for pkg in python3-venv binutils libpython3.13; do
|
||||
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
|
||||
log "Installing $pkg (reticulum-daemon build prerequisite)..."
|
||||
sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq "$pkg" 2>>"$LOG_FILE" \
|
||||
|| warn "Unable to install $pkg automatically; reticulum-daemon tools build may fail"
|
||||
fi
|
||||
done
|
||||
|
||||
# Fetch latest
|
||||
log "Fetching from origin..."
|
||||
git fetch origin main --quiet 2>>"$LOG_FILE"
|
||||
@@ -155,6 +213,30 @@ sudo cp "$BUILT_BIN" "$INSTALL_BIN"
|
||||
sudo chmod +x "$INSTALL_BIN"
|
||||
ok "Backend installed"
|
||||
|
||||
# Build + install reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf).
|
||||
# Non-fatal: archipelago falls back to its dev venv path if the packaged
|
||||
# binaries aren't present, so a missing/failed build here degrades mesh
|
||||
# Reticulum support rather than breaking the update. This mirrors
|
||||
# deploy-to-target.sh's existing manual-deploy step, which until now was the
|
||||
# only path that ever installed these — a node that only ever received OTA
|
||||
# self-updates had neither binary.
|
||||
if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then
|
||||
log "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)..."
|
||||
if (cd "$REPO_DIR/reticulum-daemon" && ./build.sh) 2>>"$LOG_FILE"; then
|
||||
for tool in archy-reticulum-daemon archy-rnodeconf; do
|
||||
if [ -f "$REPO_DIR/reticulum-daemon/dist/$tool" ]; then
|
||||
sudo cp "$REPO_DIR/reticulum-daemon/dist/$tool" /usr/local/bin/
|
||||
sudo chmod +x "/usr/local/bin/$tool"
|
||||
ok "$tool installed"
|
||||
else
|
||||
warn "$tool not built — leaving existing /usr/local/bin/$tool (if any) in place"
|
||||
fi
|
||||
done
|
||||
else
|
||||
warn "reticulum-daemon tools build failed — continuing without updating them"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build frontend
|
||||
log "Building Vue frontend (production)..."
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# Mesh / Reticulum test suite — the "is the mesh stack healthy" gate.
|
||||
#
|
||||
# Three layers, cheapest first:
|
||||
# 1. Rust unit tests (no hardware, ~2s once built)
|
||||
# 2. Daemon selftest (full RNS+LXMF bring-up, no radio; also verifies
|
||||
# the announce app_data wire contract + set_name)
|
||||
# 3. Live-node assertions (optional; needs a running archipelago with a
|
||||
# radio — set MESH_TEST_LIVE=1 MESH_TEST_PW=...)
|
||||
#
|
||||
# Usage:
|
||||
# tests/mesh/run-mesh-tests.sh # layers 1+2
|
||||
# MESH_TEST_LIVE=1 MESH_TEST_PW='...' tests/mesh/run-mesh-tests.sh
|
||||
# MESH_TEST_HOST=100.113.100.55 ... # live-test a remote node
|
||||
set -u
|
||||
cd "$(dirname "$0")/../.."
|
||||
FAIL=0
|
||||
ok() { echo "ok - $1"; }
|
||||
bad() { echo "not ok - $1"; FAIL=1; }
|
||||
|
||||
# ── 1. Rust unit tests ────────────────────────────────────────────────
|
||||
RUST_RESULTS=$(cd core && cargo test -p archipelago --bin archipelago mesh 2>&1 | grep "^test result:")
|
||||
if [ -n "$RUST_RESULTS" ] && ! echo "$RUST_RESULTS" | grep -vq " 0 failed"; then
|
||||
ok "rust mesh unit tests ($(echo "$RUST_RESULTS" | grep -o '[0-9]* passed' | head -1))"
|
||||
else
|
||||
bad "rust mesh unit tests"
|
||||
fi
|
||||
|
||||
# ── 2. Reticulum daemon selftest (no radio) ───────────────────────────
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
head -c 32 /dev/urandom > "$TMP/key"
|
||||
DAEMON=reticulum-daemon/.venv/bin/python
|
||||
if [ -x "$DAEMON" ]; then
|
||||
if "$DAEMON" reticulum-daemon/reticulum_daemon.py \
|
||||
--identity-key "$TMP/key" --rns-config "$TMP/rns" \
|
||||
--socket "$TMP/sock" --display-name "SelftestNode" --selftest 2>/dev/null \
|
||||
| grep -q "announce_app_data=verified set_name=verified"; then
|
||||
ok "daemon selftest (announce wire contract + set_name)"
|
||||
else
|
||||
bad "daemon selftest"
|
||||
fi
|
||||
else
|
||||
echo "skip - daemon selftest (no venv at $DAEMON)"
|
||||
fi
|
||||
|
||||
# ── 3. Live node assertions (opt-in) ──────────────────────────────────
|
||||
if [ "${MESH_TEST_LIVE:-0}" = "1" ]; then
|
||||
HOST="${MESH_TEST_HOST:-127.0.0.1}"
|
||||
PW="${MESH_TEST_PW:?set MESH_TEST_PW}"
|
||||
# Nodes differ: dev boxes serve plain http on :80, ISO installs https.
|
||||
RPC=""
|
||||
for base in "http://$HOST" "https://$HOST" "http://$HOST:5678"; do
|
||||
code=$(curl -ksS -o /dev/null -w '%{http_code}' -m 5 -X POST "$base/rpc/v1" 2>/dev/null || true)
|
||||
case "$code" in 000|"") continue ;; *) RPC="$base/rpc/v1"; break ;; esac
|
||||
done
|
||||
[ -n "$RPC" ] || { bad "live: no RPC endpoint reachable on $HOST"; echo FAIL; exit 1; }
|
||||
JAR="$TMP/jar"
|
||||
curl -ksS -c "$JAR" -H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"$PW\"},\"id\":1}" \
|
||||
"$RPC" > "$TMP/login"
|
||||
if grep -q '"error":null' "$TMP/login"; then ok "live: rpc login"; else bad "live: rpc login"; fi
|
||||
call() {
|
||||
local csrf; csrf=$(awk '/^[^#]/ && /csrf_token/ {print $7; exit}' "$JAR")
|
||||
curl -ksS -b "$JAR" -c "$JAR" -H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: $csrf" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":${2:-{\}},\"id\":2}" \
|
||||
--max-time 60 "$RPC"
|
||||
}
|
||||
ST=$(call mesh.status)
|
||||
echo "$ST" | grep -q '"device_connected":true' \
|
||||
&& ok "live: radio connected ($(echo "$ST" | grep -o '"device_type":"[a-z]*"'))" \
|
||||
|| bad "live: radio connected"
|
||||
echo "$ST" | grep -q '"self_advert_name":"[^"]' \
|
||||
&& ok "live: node has a mesh name" || bad "live: node has a mesh name"
|
||||
call mesh.refresh | grep -q '"refreshed":true' \
|
||||
&& ok "live: mesh.refresh" || bad "live: mesh.refresh"
|
||||
call mesh.broadcast | grep -q '"broadcast":true' \
|
||||
&& ok "live: mesh.broadcast" || bad "live: mesh.broadcast"
|
||||
# No peer may ever display a raw identity blob as its name.
|
||||
call mesh.peers | grep -q '"advert_name":"ARCHY:' \
|
||||
&& bad "live: no ARCHY-blob peer names" || ok "live: no ARCHY-blob peer names"
|
||||
fi
|
||||
|
||||
[ "$FAIL" = 0 ] && echo "PASS" || { echo "FAIL"; exit 1; }
|
||||
Reference in New Issue
Block a user