Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,195 @@
//! Mesh-AI assistant RPCs (issue #50): read/update the local assistant config
//! and report whether a local Ollama is available (for the install deep-link).
use super::super::RpcHandler;
use anyhow::Result;
use std::time::Duration;
/// Default model when the node hasn't picked one (kept in sync with the mesh
/// assistant handler's `DEFAULT_MODEL`).
const DEFAULT_MODEL: &str = "qwen2.5-coder";
impl RpcHandler {
/// mesh.assistant-status — current settings + local Ollama availability.
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
&self,
) -> Result<serde_json::Value> {
let (cfg, denied_askers) = {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
(
svc.assistant_config().await,
svc.assistant_denied_askers().await,
)
};
let (ollama_detected, models) = detect_ollama().await;
let claude_available =
tokio::fs::metadata(self.config.data_dir.join("secrets/claude-api-key"))
.await
.is_ok();
Ok(serde_json::json!({
"enabled": cfg.enabled,
"model": cfg.model,
"trusted_only": cfg.trusted_only,
"backend": cfg.backend,
"allowed_contacts": cfg.allowed_contacts,
"default_model": DEFAULT_MODEL,
"ollama_detected": ollama_detected,
"claude_available": claude_available,
"models": models,
"denied_askers": denied_askers,
}))
}
/// mesh.assistant-configure — update assistant settings live.
/// Params: `enabled?: bool`, `trusted_only?: bool`,
/// `model?: string|null` (string sets, null clears to default, absent leaves).
pub(in crate::api::rpc) async fn handle_mesh_assistant_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let enabled = params.get("enabled").and_then(|v| v.as_bool());
let trusted_only = params.get("trusted_only").and_then(|v| v.as_bool());
let backend = params
.get("backend")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// model: key present + string => set; present + null => clear; absent => leave
let model = if let Some(v) = params.get("model") {
Some(v.as_str().map(|s| s.to_string()))
} else {
None
};
// allowed_contacts: present + array => replace the allowlist (pubkey hex
// strings); absent => leave unchanged.
let allowed_contacts = params
.get("allowed_contacts")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| e.as_str().map(|s| s.to_string()))
.collect::<Vec<String>>()
});
svc.configure_assistant(enabled, model, trusted_only, backend, allowed_contacts)
.await?;
let cfg = svc.assistant_config().await;
Ok(serde_json::json!({
"enabled": cfg.enabled,
"model": cfg.model,
"trusted_only": cfg.trusted_only,
"backend": cfg.backend,
"allowed_contacts": cfg.allowed_contacts,
}))
}
/// mesh.schedule-message — queue a message to send at a future time.
/// Params: `body: string`, `fire_at: i64` (unix secs), and one of
/// `contact_id: u32` (DM) or `channel: u8` (broadcast).
pub(in crate::api::rpc) async fn handle_mesh_schedule_message(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let p = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let body = p
.get("body")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("body is required"))?
.to_string();
let fire_at = p
.get("fire_at")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("fire_at (unix seconds) is required"))?;
let contact_id = p
.get("contact_id")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let channel = p.get("channel").and_then(|v| v.as_u64()).map(|v| v as u8);
if contact_id.is_none() && channel.is_none() {
anyhow::bail!("either contact_id or channel is required");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let msg = svc
.scheduler
.add(contact_id, channel, body, fire_at)
.await?;
Ok(serde_json::to_value(msg)?)
}
/// mesh.list-scheduled — list queued messages (sorted by fire time).
pub(in crate::api::rpc) async fn handle_mesh_list_scheduled(
&self,
) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let messages = svc.scheduler.list().await;
Ok(serde_json::json!({ "messages": messages }))
}
/// mesh.cancel-scheduled — remove a queued message by id.
pub(in crate::api::rpc) async fn handle_mesh_cancel_scheduled(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let id = params
.as_ref()
.and_then(|p| p.get("id"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let cancelled = svc.scheduler.cancel(id).await?;
Ok(serde_json::json!({ "cancelled": cancelled }))
}
}
/// Probe the local Ollama HTTP API; return (detected, model_names).
///
/// `pub(crate)`: reused directly by `crate::assistant::backends::select_backend`
/// (13-10, D-04) rather than a second probe being written there.
pub(crate) async fn detect_ollama() -> (bool, Vec<String>) {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(c) => c,
Err(_) => return (false, Vec::new()),
};
match client.get("http://localhost:11434/api/tags").send().await {
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp.json().await.unwrap_or_default();
let models = json
.get("models")
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| {
m.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
})
.collect()
})
.unwrap_or_default();
(true, models)
}
_ => (false, Vec::new()),
}
}
@@ -0,0 +1,299 @@
use super::super::RpcHandler;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
pub(in crate::api::rpc) async fn handle_mesh_relay_tx(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let tx_hex = params["tx_hex"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
let relay_mode = params["relay_mode"].as_str().unwrap_or("archy");
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
anyhow::bail!("Invalid tx_hex length");
}
// Validate hex
if hex::decode(tx_hex).is_err() {
anyhow::bail!("tx_hex is not valid hexadecimal");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker
.track_tx_relay(request_id, svc.our_did())
.await;
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
let mut sent_count = 0u32;
if relay_mode == "broadcast" {
// Broadcast mode: send on channel 0 (all mesh nodes relay)
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
// Encrypt with first available Archy peer's shared secret
// (any Archy node that receives it can try decrypting)
let payload = shared_secrets
.values()
.next()
.and_then(|secret| {
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
let mut encrypted = Vec::with_capacity(1 + ct.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ct);
encrypted
})
})
.unwrap_or_else(|| wire.clone());
drop(shared_secrets);
{
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let _ = shared_state
.send_cmd(crate::mesh::listener::MeshCommand::BroadcastChannel {
channel: 0,
payload: b64.into_bytes(),
})
.await;
}
info!(
request_id,
tx_len = tx_hex.len(),
"TX relay broadcast on mesh channel 0 (encrypted)"
);
} else {
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") {
continue;
}
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id)
{
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted =
Vec::with_capacity(1 + ciphertext.len());
encrypted.push(
crate::mesh::message_types::ENCRYPTED_TYPED_MARKER,
);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
Err(_) => wire.clone(),
}
} else {
wire.clone()
};
let _ = svc
.shared_state()
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
.await;
sent_count += 1;
}
}
}
}
drop(shared_secrets);
info!(
request_id,
tx_len = tx_hex.len(),
archy_peers = sent_count,
"TX relay sent to Archy peers (E2E encrypted)"
);
}
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
"tx_hex_len": tx_hex.len(),
}))
}
/// mesh.relay-status — Check the status of a pending or completed TX relay.
pub(in crate::api::rpc) async fn handle_mesh_relay_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let request_id = params["request_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Check completed results first
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
return Ok(serde_json::json!({
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
"request_id": result.request_id,
"txid": result.txid,
"error": result.error,
"error_code": result.error_code,
"completed_at": result.completed_at,
}));
}
// Check if still pending
if svc.relay_tracker.is_pending(request_id).await {
return Ok(serde_json::json!({
"status": "pending",
"request_id": request_id,
}));
}
// Unknown — either expired or never existed
Ok(serde_json::json!({
"status": "unknown",
"request_id": request_id,
}))
}
/// mesh.block-headers — Get cached block headers received from mesh peers.
pub(in crate::api::rpc) async fn handle_mesh_block_headers(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let count = params
.as_ref()
.and_then(|p| p["count"].as_u64())
.unwrap_or(10) as usize;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let headers = svc.block_header_cache.recent_headers(count).await;
let latest = svc.block_header_cache.latest_height().await;
Ok(serde_json::json!({
"headers": headers.iter().map(|h| serde_json::json!({
"height": h.height,
"hash": h.hash,
"prev_hash": h.prev_hash,
"timestamp": h.timestamp,
"announced_by": h.announced_by,
})).collect::<Vec<_>>(),
"latest_height": latest,
"count": headers.len(),
}))
}
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
pub(in crate::api::rpc) async fn handle_mesh_relay_lightning(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let bolt11 = params["bolt11"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
let amount_sats = params["amount_sats"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker
.track_lightning_relay(request_id, svc.our_did())
.await;
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
bolt11,
amount_sats,
request_id,
)?;
// Send to Archipelago peers — E2E encrypted per-peer
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
let mut sent_count = 0u32;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") {
continue;
}
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted
.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
Err(_) => wire.clone(),
}
} else {
wire.clone()
};
let _ = svc
.shared_state()
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
.await;
sent_count += 1;
}
}
}
}
drop(shared_secrets);
info!(
request_id,
amount_sats,
archy_peers = sent_count,
"Lightning relay sent (E2E encrypted)"
);
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
"amount_sats": amount_sats,
}))
}
}
+134
View File
@@ -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"),
}
}
}
@@ -0,0 +1,349 @@
use super::super::RpcHandler;
use crate::mesh;
use anyhow::Result;
use std::sync::Arc;
use tracing::info;
impl RpcHandler {
/// mesh.send — Send an encrypted message to a mesh peer.
pub(in crate::api::rpc) async fn handle_mesh_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params
.get("contact_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
if message.is_empty() {
anyhow::bail!("Message cannot be empty");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let msg = svc.send_message(contact_id, message).await?;
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"encrypted": msg.encrypted,
}))
}
/// mesh.send-channel — Send a text message to a mesh channel (broadcast).
pub(in crate::api::rpc) async fn handle_mesh_send_channel(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let channel = params.get("channel").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
if message.is_empty() {
anyhow::bail!("Message cannot be empty");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let msg = svc.send_channel_message(channel, message).await?;
info!(channel, "Sent mesh channel message");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"channel": channel,
}))
}
/// mesh.broadcast — Broadcast our node identity over mesh.
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
svc.broadcast_identity().await?;
info!("Broadcast identity over mesh");
Ok(serde_json::json!({ "broadcast": true }))
}
/// mesh.reboot-radio — Reboot the locally-connected radio firmware to
/// recover a wedged / RX-deaf radio. Optional `seconds` delay (default 2).
pub(in crate::api::rpc) async fn handle_mesh_reboot_radio(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let seconds = params
.as_ref()
.and_then(|p| p.get("seconds"))
.and_then(|v| v.as_i64())
.unwrap_or(2);
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let message = svc.reboot_radio(seconds).await?;
info!(seconds, "Mesh radio reboot requested via RPC");
Ok(serde_json::json!({ "reboot": true, "seconds": seconds, "message": message }))
}
/// mesh.rnode-config — persisted RF settings + the live radio state
/// (radio-confirmed values) for the LoRa settings panel. `live` is best-
/// effort: null with `live_error` when no Reticulum radio is connected.
pub(in crate::api::rpc) async fn handle_mesh_rnode_config(&self) -> Result<serde_json::Value> {
let settings = mesh::rnode_settings::RNodeRfSettings::load(&self.config.data_dir).await;
let (live, live_error) = match self.mesh_service.read().await.as_ref() {
Some(svc) => match svc.radio_state().await {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(format!("{e:#}"))),
},
None => (None, Some("Mesh service not running".to_string())),
};
Ok(serde_json::json!({
"settings": settings,
"live": live,
"live_error": live_error,
}))
}
/// mesh.rnode-config-apply — validate + persist the RF settings, restart
/// the radio daemon so they take effect, then read back the radio-
/// confirmed values as proof. Returns { applied, live, message }; a
/// failed read-back still reports the persisted settings with a clear
/// message instead of pretending success.
pub(in crate::api::rpc) async fn handle_mesh_rnode_config_apply(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let settings: mesh::rnode_settings::RNodeRfSettings = serde_json::from_value(
params
.get("settings")
.cloned()
.ok_or_else(|| anyhow::anyhow!("Missing 'settings'"))?,
)
.map_err(|e| anyhow::anyhow!("Invalid settings: {e}"))?;
settings.validate()?;
settings.save(&self.config.data_dir).await?;
info!(?settings, "RNode RF settings persisted");
// Restart the radio daemon so the new args apply. No radio connected
// is fine — the settings apply on the next connect.
let service = self.mesh_service.read().await;
let Some(svc) = service.as_ref() else {
return Ok(serde_json::json!({
"applied": false,
"message": "Settings saved. They apply when the mesh service next connects to the radio.",
}));
};
if let Err(e) = svc.reboot_radio(2).await {
return Ok(serde_json::json!({
"applied": false,
"message": format!(
"Settings saved, but the radio daemon restart failed: {e:#}. \
They apply on the next reconnect."
),
}));
}
// Read-back: poll until the respawned daemon reports the radio online
// with our applied values (the respawn re-detects the RNode, ~15s).
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(45);
let mut last_live = None;
while tokio::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
if let Ok(state) = svc.radio_state().await {
let online = state
.get("online")
.and_then(|v| v.as_bool())
.unwrap_or(false);
last_live = Some(state);
if online {
break;
}
}
}
match last_live {
Some(live) => {
let confirmed = live
.get("r_frequency")
.and_then(|v| v.as_u64())
.map(|f| f == settings.frequency)
.unwrap_or(false);
Ok(serde_json::json!({
"applied": true,
"confirmed": confirmed,
"live": live,
"message": if confirmed {
"The radio confirmed it is now using the applied settings."
} else {
"Settings applied and the daemon restarted; the radio has not \
confirmed the new values yet — recheck in a few seconds."
},
}))
}
None => Ok(serde_json::json!({
"applied": true,
"confirmed": false,
"live": null,
"message": "Settings applied and the daemon restarted, but it has not \
reported the radio state yet — recheck in a few seconds.",
})),
}
}
/// mesh.configure — Enable/disable mesh and set device path.
pub(in crate::api::rpc) async fn handle_mesh_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mut config = mesh::load_config(&self.config.data_dir).await?;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.enabled = enabled;
}
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
config.device_path = Some(device.to_string());
}
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
config.channel_name = Some(channel.to_string());
}
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
config.broadcast_identity = broadcast;
}
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
// 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")
.and_then(|v| v.as_bool())
{
config.announce_block_headers = announce;
}
if let Some(receive) = params
.get("receive_block_headers")
.and_then(|v| v.as_bool())
{
config.receive_block_headers = receive;
}
// LoRa region (Meshtastic): validated against the driver's region
// table so a typo can't be persisted and silently ignored on connect.
// Empty string clears the setting (radio keeps/uses its own region).
if let Some(region) = params.get("lora_region").and_then(|v| v.as_str()) {
let trimmed = region.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
config.lora_region = None;
} else if mesh::meshtastic_region_is_valid(trimmed) {
config.lora_region = Some(trimmed.to_uppercase());
} else {
anyhow::bail!("Unknown LoRa region: {trimmed}");
}
}
// Meshcore LoRa PHY params (freq/bw/sf/cr, firmware field units — see
// mesh::LoraRadioParams). Validated against the firmware's accepted
// ranges here so a bad value errors at the API instead of being sent
// to the radio and rejected on-device. `null` clears the setting.
if let Some(rp) = params.get("lora_radio_params") {
if rp.is_null() {
config.lora_radio_params = None;
} else {
let parsed: mesh::LoraRadioParams = serde_json::from_value(rp.clone())
.map_err(|e| anyhow::anyhow!("Invalid lora_radio_params: {e}"))?;
anyhow::ensure!(
(150_000..=2_500_000).contains(&parsed.freq_khz),
"freq_khz out of range (150000..=2500000)"
);
anyhow::ensure!(
(7_000..=500_000).contains(&parsed.bw_hz),
"bw_hz out of range (7000..=500000)"
);
anyhow::ensure!((5..=12).contains(&parsed.sf), "sf out of range (5..=12)");
anyhow::ensure!((5..=8).contains(&parsed.cr), "cr out of range (5..=8)");
config.lora_radio_params = Some(parsed);
}
}
// Hot-swap "keep as is": false = never write config to the radio
// (region/channel/PHY/advert name) — use it exactly as flashed.
if let Some(manage) = params.get("manage_radio").and_then(|v| v.as_bool()) {
config.manage_radio = manage;
}
// Firmware pin: probe only the named firmware on the port ("auto"/""
// clears the pin and restores strict-probe auto-detect).
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
config.device_kind = match kind.trim().to_lowercase().as_str() {
"" | "auto" => None,
"meshcore" => Some(mesh::types::DeviceType::Meshcore),
"meshtastic" => Some(mesh::types::DeviceType::Meshtastic),
"reticulum" | "rnode" => Some(mesh::types::DeviceType::Reticulum),
other => anyhow::bail!(
"Unknown device_kind: {other} (expected auto|meshcore|meshtastic|reticulum)"
),
};
}
mesh::save_config(&self.config.data_dir, &config).await?;
// 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");
Ok(serde_json::json!({
"configured": true,
"enabled": config.enabled,
"device_path": config.device_path,
"announce_block_headers": config.announce_block_headers,
"receive_block_headers": config.receive_block_headers,
"lora_region": config.lora_region,
"device_kind": config.device_kind.map(|k| k.to_string()),
}))
}
}
+10
View File
@@ -0,0 +1,10 @@
// pub(crate): `detect_ollama()` is reused by
// `crate::assistant::backends::select_backend` (13-10, D-04) — the
// existing probe, not a second one.
pub(crate) mod assistant;
mod bitcoin_ops;
mod flash;
mod messaging;
mod safety;
mod status;
mod typed_messages;
+245
View File
@@ -0,0 +1,245 @@
use super::super::RpcHandler;
use crate::mesh;
use crate::mesh::message_types::Coordinate;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.outbox — List pending store-and-forward messages.
pub(in crate::api::rpc) async fn handle_mesh_outbox(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let limit = params
.as_ref()
.and_then(|p| p["limit"].as_u64())
.map(|n| n as usize);
// Check if outbox file exists
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
let messages = outbox.list(limit).await;
let count = outbox.count().await;
Ok(serde_json::json!({
"messages": messages.iter().map(|m| serde_json::json!({
"id": m.id,
"dest_did": m.dest_did,
"from_did": m.from_did,
"created_at": m.created_at,
"ttl_secs": m.ttl_secs,
"retry_count": m.retry_count,
"relay_hops": m.relay_hops,
"expired": m.is_expired(),
})).collect::<Vec<_>>(),
"count": count,
}))
}
/// mesh.deadman-status — Get dead man's switch status.
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(
&self,
) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let status = svc.dead_man_switch.status().await;
Ok(serde_json::to_value(status)?)
}
/// mesh.deadman-configure — Configure the dead man's switch.
pub(in crate::api::rpc) async fn handle_mesh_deadman_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut config = svc.dead_man_switch.get_config().await;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.dead_man_enabled = enabled;
}
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
if interval < 60 {
anyhow::bail!("Interval must be at least 60 seconds");
}
config.dead_man_interval_secs = interval;
}
if let (Some(lat), Some(lng)) = (
params.get("lat").and_then(|v| v.as_f64()),
params.get("lng").and_then(|v| v.as_f64()),
) {
let label = params
.get("label")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
}
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
config.emergency_contacts = contacts
.iter()
.filter_map(|c| c.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
config.custom_message = Some(msg.to_string());
}
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
config.auto_include_gps = auto_gps;
}
svc.dead_man_switch.configure(config).await?;
// Reset timer on configure
svc.dead_man_switch.check_in().await;
let status = svc.dead_man_switch.status().await;
info!("Dead man's switch configured");
Ok(serde_json::to_value(status)?)
}
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(
&self,
) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
svc.dead_man_check_in().await;
let remaining = svc.dead_man_switch.time_remaining_secs().await;
Ok(serde_json::json!({
"checked_in": true,
"time_remaining_secs": remaining,
}))
}
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(
&self,
) -> Result<serde_json::Value> {
// Load identity signing key
let identity_dir = self.config.data_dir.join("identity");
let node_key_path = identity_dir.join("node_key");
let key_bytes = tokio::fs::read(&node_key_path)
.await
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
if key_bytes.len() != 32 {
anyhow::bail!("Invalid node key");
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&key_bytes);
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
// Generate new prekey bundle
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
// Save bundle for distribution
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
let prekey_dir = self.config.data_dir.join("prekeys");
tokio::fs::create_dir_all(&prekey_dir).await?;
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
info!(
one_time_keys = bundle.one_time_prekeys.len(),
"Prekey bundle rotated"
);
Ok(serde_json::json!({
"rotated": true,
"signed_prekey_id": bundle.signed_prekey.id,
"one_time_prekeys": bundle.one_time_prekeys.len(),
}))
}
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
/// Sends plain text markers that the receiver can count.
pub(in crate::api::rpc) async fn handle_mesh_test_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
let mode = params["mode"].as_str().unwrap_or("ping");
let count = params["count"].as_u64().unwrap_or(3) as usize;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut sent = 0usize;
let test_id = chrono::Utc::now().timestamp() as u32;
for i in 0..count {
let payload = match mode {
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
"chunked" => {
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
let fake_tx = "0".repeat(400); // simulates TX hex
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(
&fake_tx,
test_id as u64 + i as u64,
)?;
// Send via SendRaw which handles base64 + chunking
let peers = svc.peers().await;
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let _ = svc
.shared_state()
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire,
})
.await;
sent += 1;
}
}
}
}
// Delay between chunked sends
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
continue;
}
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
};
// Send as plain text for ping/medium/large
let _msg = svc.send_message(contact_id, &payload).await?;
sent += 1;
info!(
test_id,
seq = i,
mode,
len = payload.len(),
"Test message sent"
);
// Small delay between sends
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
}
Ok(serde_json::json!({
"test_id": test_id,
"mode": mode,
"sent": sent,
"count": count,
}))
}
}
+447
View File
@@ -0,0 +1,447 @@
use super::super::RpcHandler;
use crate::mesh;
use anyhow::Result;
use tracing::warn;
impl RpcHandler {
/// mesh.status — Get mesh radio status, device info, and peer count.
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
// Block-header send/receive prefs live in MeshConfig; surface them in
// status so the UI toggles (issue #28) can show the persisted state.
let config = mesh::load_config(&self.config.data_dir).await?;
let service = self.mesh_service.read().await;
let mut value = if let Some(svc) = service.as_ref() {
let status = svc.status().await;
serde_json::to_value(status)?
} else {
// No service running — return basic config + device detection
let devices = mesh::detect_devices().await;
serde_json::json!({
"enabled": config.enabled,
"device_connected": false,
"device_type": "unknown",
"device_path": config.device_path,
"channel_name": config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string()),
"detected_devices": devices,
"peer_count": 0,
"messages_sent": 0,
"messages_received": 0,
})
};
if let Some(obj) = value.as_object_mut() {
obj.insert(
"announce_block_headers".into(),
config.announce_block_headers.into(),
);
obj.insert(
"receive_block_headers".into(),
config.receive_block_headers.into(),
);
// Persisted config values the settings UI edits (distinct from the
// live radio-reported `region`): the configured LoRa region and
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
obj.insert("lora_region".into(), config.lora_region.clone().into());
obj.insert(
"lora_radio_params".into(),
serde_json::to_value(config.lora_radio_params).unwrap_or_default(),
);
obj.insert(
"device_kind".into(),
config
.device_kind
.map(|k| k.to_string().to_lowercase())
.into(),
);
// Hot-swap "keep as is" state: false = archipelago never writes
// config to the radio (see MeshConfig::manage_radio).
obj.insert("manage_radio".into(), config.manage_radio.into());
// USB identity per detected port so the setup modal can show the
// actual board (product string on native-USB boards, vid:pid as
// the fallback for bridge chips).
obj.insert(
"detected_device_info".into(),
serde_json::to_value(mesh::detect_devices_info().await).unwrap_or_default(),
);
// Raw serial-device presence, in BOTH branches. MeshStatus has no
// such field, so while the service was running the UI couldn't
// tell "no radio plugged in" from "radio present but the session
// can't open it yet" — both looked like device_connected=false.
if !obj.contains_key("detected_devices") {
let devices = mesh::detect_devices().await;
obj.insert("device_present".into(), (!devices.is_empty()).into());
obj.insert("detected_devices".into(), devices.into());
} else {
let present = obj
.get("detected_devices")
.and_then(|v| v.as_array())
.is_some_and(|a| !a.is_empty());
obj.insert("device_present".into(), present.into());
}
}
Ok(value)
}
/// mesh.probe-device — Identify the firmware on a detected serial port and
/// read its current configuration WITHOUT provisioning it. Powers the
/// hot-swap "device detected" modal's current-details view. The path must
/// be one of the currently detected candidate ports (no arbitrary device
/// paths), and probing the live session's own port is refused.
pub(in crate::api::rpc) async fn handle_mesh_probe_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 detected = mesh::detect_devices().await;
anyhow::ensure!(
detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port"
);
// 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;
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
Ok(serde_json::json!({
"peers": peers,
"count": peers.len(),
}))
} else {
Ok(serde_json::json!({
"peers": [],
"count": 0,
}))
}
}
/// mesh.messages — Get recent mesh message history.
pub(in crate::api::rpc) async fn handle_mesh_messages(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let limit = params
.as_ref()
.and_then(|p| p.get("limit"))
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let messages = svc.messages(limit).await;
Ok(serde_json::json!({
"messages": messages,
"count": messages.len(),
}))
} else {
Ok(serde_json::json!({
"messages": [],
"count": 0,
}))
}
}
/// conversations.list — Unified inbox across mesh peers, mesh channels,
/// and federation nodes. Each conversation returns its latest message
/// timestamp + snippet + transport tag so the UI can render one sorted list.
pub(in crate::api::rpc) async fn handle_conversations_list(
&self,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let mut conversations: Vec<serde_json::Value> = Vec::new();
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
let messages = svc.messages(None).await;
// Collapse radio/federation twins into one conversation per identity
// so a node reachable both ways shows once, with its messages unioned
// across both twin contact_ids (#12).
let groups = mesh::group_peer_twins(&peers);
for group in &groups {
let peer = &group.canonical;
// Newest message across ALL twin contact_ids in this group.
let last = messages
.iter()
.rev()
.find(|m| group.contact_ids.contains(&m.peer_contact_id));
let is_federation = peer.contact_id & 0x8000_0000 != 0;
conversations.push(serde_json::json!({
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
"transport": if is_federation { "federation" } else { "mesh" },
"contact_id": peer.contact_id,
"name": peer.advert_name,
"pubkey": peer.pubkey_hex,
"last_text": last.map(|m| m.plaintext.clone()),
"last_timestamp": last.map(|m| m.timestamp.clone()),
"last_direction": last.map(|m| format!("{:?}", m.direction).to_lowercase()),
}));
}
// Channel 0 ("Archipelago") as a synthetic conversation.
let channel_last = messages
.iter()
.rev()
.find(|m| m.message_type == "text" && m.peer_contact_id == 0);
conversations.push(serde_json::json!({
"id": "channel:0",
"transport": "channel",
"channel": 0,
"name": "Archipelago",
"last_text": channel_last.map(|m| m.plaintext.clone()),
"last_timestamp": channel_last.map(|m| m.timestamp.clone()),
}));
}
// Sort by last_timestamp desc (missing timestamps sink).
conversations.sort_by(|a, b| {
let at = a
.get("last_timestamp")
.and_then(|v| v.as_str())
.unwrap_or("");
let bt = b
.get("last_timestamp")
.and_then(|v| v.as_str())
.unwrap_or("");
bt.cmp(at)
});
Ok(serde_json::json!({ "conversations": conversations }))
}
/// conversations.messages — Return messages for a ConversationId string
/// (format: `mesh:<contact_id>` | `federation:<contact_id>` | `channel:<u8>`).
pub(in crate::api::rpc) async fn handle_conversations_messages(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let id = params["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let (kind, rest) = id
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("Invalid conversation id"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let all = svc.messages(None).await;
let filtered: Vec<_> = match kind {
"mesh" | "federation" => {
let contact_id: u32 = rest.parse().unwrap_or(0);
// Resolve this id's twin group and union messages across all of
// its contact_ids, so opening either twin shows the full thread
// (federation-injected + radio messages) (#12).
let ids: Vec<u32> = mesh::group_peer_twins(&svc.peers().await)
.into_iter()
.find(|g| g.contact_ids.contains(&contact_id))
.map(|g| g.contact_ids)
.unwrap_or_else(|| vec![contact_id]);
all.into_iter()
.filter(|m| ids.contains(&m.peer_contact_id))
.collect()
}
"channel" => {
// For now the channel bucket keeps contact_id = 0.
all.into_iter().filter(|m| m.peer_contact_id == 0).collect()
}
_ => Vec::new(),
};
Ok(serde_json::json!({ "messages": filtered }))
}
/// mesh.debug-dump — Full in-memory state snapshot for debugging.
/// Returns peers, all messages, status, shared-secret peer ids, encrypt_relay
/// flag, and stego mode. Intended for smoke tests and bug investigation.
pub(in crate::api::rpc) async fn handle_mesh_debug_dump(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
Ok(svc.debug_dump().await)
} else {
Ok(serde_json::json!({ "running": false }))
}
}
/// mesh.session-status — Get ratchet session info for a peer.
pub(in crate::api::rpc) async fn handle_mesh_session_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
// Look up peer DID from mesh service
let service = self.mesh_service.read().await;
let peer_did = if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
peers
.iter()
.find(|p| p.contact_id == contact_id)
.and_then(|p| p.did.clone())
} else {
None
};
if let Some(did) = peer_did {
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
if let Some(info) = session_mgr.session_info(&did).await {
Ok(serde_json::json!({
"has_session": info.has_session,
"forward_secrecy": info.forward_secrecy,
"message_count": info.message_count,
"ratchet_generation": info.ratchet_generation,
"peer_did": did,
}))
} else {
Ok(serde_json::json!({
"has_session": false,
"forward_secrecy": false,
"message_count": 0,
"ratchet_generation": 0,
"peer_did": did,
}))
}
} else {
Ok(serde_json::json!({
"has_session": false,
"forward_secrecy": false,
"message_count": 0,
"ratchet_generation": 0,
"peer_did": null,
}))
}
}
/// mesh.clear-all — Nuclear reset: wipe all mesh state files and restart
/// the service for a completely clean slate.
pub(in crate::api::rpc) async fn handle_mesh_clear_all(&self) -> Result<serde_json::Value> {
let data_dir = self.config.data_dir.clone();
// Delete all mesh state files
for filename in &[
"messages.json",
"mesh-contacts.json",
"sessions.json",
"mesh-outbox.json",
] {
let _ = tokio::fs::remove_file(data_dir.join(filename)).await;
}
// Clear in-memory state
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let state = svc.state();
// NOTE: `clear-all` intentionally does NOT build a radio-contact
// blocklist. Permanently ignoring firmware contacts meant a cleared
// peer could never return even when it re-advertised (it also broke
// re-pairing a phone after a clear). Real per-contact blocking will
// be a separate, explicit feature. Here we just wipe the app-side
// view and ALSO clear any blocklist left over from older builds, so
// previously-hidden contacts can re-appear when next heard. The
// firmware's own contact table is the source of truth on refresh.
{
let mut set = state.radio_contact_blocklist.write().await;
set.clear();
}
if let Err(e) = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await {
warn!("Failed to persist cleared radio-contact blocklist: {e:#}");
}
// Actually DELETE each radio contact from the firmware table (via
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
// refresh. They come back only when they re-advertise (reachable).
// Federation-synthetic peers (high contact_id bit) aren't firmware
// contacts, so skip those.
let firmware_pubkeys: Vec<[u8; 32]> = state
.peers
.read()
.await
.values()
.filter(|p| p.contact_id & 0x8000_0000 == 0)
.filter_map(|p| p.pubkey_hex.as_deref())
.filter_map(|h| hex::decode(h).ok())
.filter(|b| b.len() == 32)
.map(|b| {
let mut k = [0u8; 32];
k.copy_from_slice(&b);
k
})
.collect();
for pk in firmware_pubkeys {
let _ = state
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
.await;
}
state.peers.write().await.clear();
state.messages.write().await.clear();
state.contacts.write().await.clear();
state.presence.write().await.clear();
state.chunk_buffer.write().await.clear();
state.shared_secrets.write().await.clear();
// Re-seed federation peers
crate::mesh::seed_federation_peers_into_mesh(state, &data_dir).await;
// Trigger a contact refresh from the radio device
let _ = state
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
.await;
}
Ok(serde_json::json!({ "status": "cleared" }))
}
}
File diff suppressed because it is too large Load Diff