feat(mesh): Meshtastic region + shared-channel auto-provisioning (MeshCore parity)
Fresh Meshtastic radios ship region-UNSET (RF-silent) and on mismatched channels, so nodes only ever saw themselves. Bring them to MeshCore parity using the official Meshtastic admin API: - Auto-provision LoRa region (set_config, AdminMessage field 34) from a new mesh-config `lora_region` (e.g. EU_868) when the radio's region differs. - Auto-provision a shared primary channel (set_channel, field 33) with a PSK derived deterministically from channel_name, so every node converges on one mesh — the parity equivalent of MeshCore's named "archipelago" channel. - Read current region/channel from want_config; only write when different (no reboot loop); cap attempts so a radio that won't persist can't loop. - Active NodeInfo advert scaffolding + aggressive serial drain. Verified on .116+.228: region+channel persist, discovery works (both see each other as named reachable contacts), bidirectional RF + sending confirmed. Receiving in the running driver is still under diagnosis (instrumentation added). Also removes the unwanted `meshtastic` daemon app from the registry (it was never meant to be a container — native driver provides system-level support): deletes apps/meshtastic + catalog entries (app-catalog, neode-ui, releases) + test refs. Meshtastic stays native, like MeshCore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fd3a4ee4ef
commit
f9a6ae3f32
@ -214,31 +214,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "meshtastic",
|
||||
"title": "Meshtastic",
|
||||
"version": "2-daily-alpine",
|
||||
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
|
||||
"icon": "/assets/img/app-icons/meshcore.svg",
|
||||
"author": "Meshtastic",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine",
|
||||
"repoUrl": "https://github.com/meshtastic/firmware",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"4403:4403"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/meshtastic:/var/lib/meshtasticd"
|
||||
],
|
||||
"env": [
|
||||
"MESHTASTIC_PORT=/dev/ttyUSB0",
|
||||
"MESHTASTIC_SERIAL=true"
|
||||
],
|
||||
"notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
|
||||
@ -373,6 +373,8 @@ pub fn spawn_mesh_listener(
|
||||
our_x25519_secret: [u8; 32],
|
||||
our_x25519_pubkey_hex: String,
|
||||
server_name: Option<String>,
|
||||
lora_region: Option<String>,
|
||||
channel_name: Option<String>,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
@ -394,6 +396,8 @@ pub fn spawn_mesh_listener(
|
||||
&our_x25519_secret,
|
||||
&our_x25519_pubkey_hex,
|
||||
server_name.as_deref(),
|
||||
lora_region.as_deref(),
|
||||
channel_name.as_deref(),
|
||||
&mut shutdown,
|
||||
&mut cmd_rx,
|
||||
)
|
||||
|
||||
@ -39,6 +39,30 @@ impl MeshRadioDevice {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision the operator-configured LoRa region. Meshcore radios manage
|
||||
/// their own band on the device, so this is a no-op for them; Meshtastic
|
||||
/// radios ship region-UNSET (RF-silent) and must be set or they never mesh.
|
||||
/// Returns `Ok(true)` when a region was written (the device reboots to
|
||||
/// apply, so the caller should restart the session).
|
||||
async fn ensure_lora_region(&mut self, region: Option<&str>) -> Result<bool> {
|
||||
match self {
|
||||
Self::Meshcore(_) => Ok(false),
|
||||
Self::Meshtastic(device) => device.ensure_lora_region(region).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision the shared archy primary channel so all nodes can decode each
|
||||
/// other. No-op for meshcore (it joins its channel by name on the device);
|
||||
/// Meshtastic radios can sit on mismatched channels otherwise and silently
|
||||
/// drop every packet as undecryptable. Returns `Ok(true)` when a channel was
|
||||
/// written (device reboots; caller should restart the session).
|
||||
async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result<bool> {
|
||||
match self {
|
||||
Self::Meshcore(_) => Ok(false),
|
||||
Self::Meshtastic(device) => device.ensure_channel(channel_name).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_self_advert(&mut self) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_self_advert().await,
|
||||
@ -46,6 +70,17 @@ impl MeshRadioDevice {
|
||||
}
|
||||
}
|
||||
|
||||
/// Actively advertise our identity over the air. Meshcore already does this
|
||||
/// inside `send_self_advert` (CMD_SEND_SELF_ADVERT), so this is a no-op for
|
||||
/// it; Meshtastic needs an explicit NodeInfo broadcast or peers never learn
|
||||
/// about an already-running node.
|
||||
async fn send_nodeinfo_advert(&mut self, want_response: bool) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(_) => Ok(()),
|
||||
Self::Meshtastic(device) => device.send_nodeinfo_broadcast(want_response).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
|
||||
@ -471,6 +506,23 @@ async fn sync_queued_messages(
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times we will try to write the LoRa region across reconnects before
|
||||
/// giving up. A healthy radio accepts it on the first try (the reboot-and-verify
|
||||
/// resolves on the next session). A radio that silently refuses to persist
|
||||
/// config — corrupt/full flash, managed mode, etc. — would otherwise reboot-loop
|
||||
/// forever; after this many attempts we stop, log, and run without it.
|
||||
const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Process-global count of LoRa-region writes attempted (one radio per process).
|
||||
/// Reset to 0 whenever the radio reports the desired region, so genuine later
|
||||
/// drift re-provisions but a broken radio doesn't loop.
|
||||
static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Same retry-cap idea as the region, for the shared-channel write.
|
||||
static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Run a single mesh session (connect, initialize, main loop).
|
||||
pub(super) async fn run_mesh_session(
|
||||
state: &Arc<MeshState>,
|
||||
@ -480,6 +532,8 @@ pub(super) async fn run_mesh_session(
|
||||
our_x25519_secret: &[u8; 32],
|
||||
our_x25519_pubkey_hex: &str,
|
||||
server_name: Option<&str>,
|
||||
lora_region: Option<&str>,
|
||||
channel_name: Option<&str>,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
) -> Result<()> {
|
||||
@ -512,6 +566,73 @@ pub(super) async fn run_mesh_session(
|
||||
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
||||
|
||||
// Provision the LoRa region before anything else. A fresh Meshtastic radio
|
||||
// is region-UNSET and therefore RF-silent — it can neither hear nor be
|
||||
// heard, so contact discovery and DMs would all silently fail. If we write
|
||||
// a new region the firmware reboots to apply it; restart the session so we
|
||||
// re-handshake the freshly-rebooted radio (and then set its name on the
|
||||
// reconnect, where the region already matches and no reboot occurs).
|
||||
use std::sync::atomic::Ordering;
|
||||
let region_attempts = REGION_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||
if region_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
match device.ensure_lora_region(lora_region).await {
|
||||
Ok(true) => {
|
||||
REGION_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
info!(
|
||||
region = lora_region.unwrap_or(""),
|
||||
attempt = region_attempts + 1,
|
||||
max = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
"Provisioned LoRa region — radio rebooting, restarting mesh session"
|
||||
);
|
||||
// Give the radio time to reboot before the reconnect re-opens it.
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
return Ok(());
|
||||
}
|
||||
// Radio reports the desired region (or none configured): clear the
|
||||
// attempt counter so a future genuine drift re-provisions cleanly.
|
||||
Ok(false) => REGION_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
|
||||
Err(e) => warn!("Failed to provision LoRa region: {}", e),
|
||||
}
|
||||
} else if lora_region.is_some() {
|
||||
warn!(
|
||||
region = lora_region.unwrap_or(""),
|
||||
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
"Radio did not persist the configured LoRa region after repeated \
|
||||
attempts — continuing without it. The radio likely needs a manual \
|
||||
factory reset / reflash; mesh discovery stays offline until its \
|
||||
region is set."
|
||||
);
|
||||
}
|
||||
|
||||
// Provision the shared primary channel (after the region, since both reboot
|
||||
// the radio). Without a matching channel two same-region radios still can't
|
||||
// decode each other's traffic. Same retry-cap + restart-on-change pattern.
|
||||
let channel_attempts = CHANNEL_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||
if channel_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
match device.ensure_channel(channel_name).await {
|
||||
Ok(true) => {
|
||||
CHANNEL_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
info!(
|
||||
channel = channel_name.unwrap_or(""),
|
||||
attempt = channel_attempts + 1,
|
||||
max = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
"Provisioned shared mesh channel — radio rebooting, restarting mesh session"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(false) => CHANNEL_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
|
||||
Err(e) => warn!("Failed to provision mesh channel: {}", e),
|
||||
}
|
||||
} else if channel_name.is_some() {
|
||||
warn!(
|
||||
channel = channel_name.unwrap_or(""),
|
||||
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
"Radio did not persist the shared mesh channel after repeated \
|
||||
attempts — continuing without it; the radio may need a manual reset."
|
||||
);
|
||||
}
|
||||
|
||||
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
||||
// falling back to the DID fragment if no name is configured.
|
||||
let advert_name = if let Some(name) = server_name {
|
||||
@ -536,6 +657,13 @@ pub(super) async fn run_mesh_session(
|
||||
if let Err(e) = device.send_self_advert().await {
|
||||
warn!("Failed to send initial advert: {}", e);
|
||||
}
|
||||
// Actively announce our identity over the air with want_response, so any
|
||||
// already-running neighbour both learns about us and replies with its own
|
||||
// NodeInfo — immediate two-way discovery instead of waiting for the radio's
|
||||
// multi-hour NodeInfo cycle. (No-op for meshcore.)
|
||||
if let Err(e) = device.send_nodeinfo_advert(true).await {
|
||||
warn!("Failed to send initial NodeInfo advert: {}", e);
|
||||
}
|
||||
|
||||
// NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally
|
||||
// NOT broadcast on the shared public channel (channel 0). Doing so spams every
|
||||
@ -615,6 +743,13 @@ pub(super) async fn run_mesh_session(
|
||||
} else {
|
||||
consecutive_write_failures = 0;
|
||||
}
|
||||
// Periodic over-air identity beacon (no want_response, to avoid
|
||||
// reply storms) so peers that come online later still discover
|
||||
// us between the radio's own infrequent NodeInfo broadcasts.
|
||||
// No-op for meshcore (its self-advert above already goes out).
|
||||
if let Err(e) = device.send_nodeinfo_advert(false).await {
|
||||
debug!("Periodic NodeInfo advert failed: {}", e);
|
||||
}
|
||||
// (Identity re-broadcast on the public channel intentionally
|
||||
// removed — see the note at session startup. It spammed the
|
||||
// shared channel every advert tick.)
|
||||
|
||||
@ -22,6 +22,10 @@ const START2: u8 = 0xc3;
|
||||
const TO_RADIO_MAX: usize = 512;
|
||||
const BROADCAST_NUM: u32 = 0xffff_ffff;
|
||||
const TEXT_MESSAGE_APP: u32 = 1;
|
||||
/// Meshtastic PortNum for NodeInfo (identity) packets — used to actively
|
||||
/// advertise ourselves over the air so neighbours discover us, the parity
|
||||
/// equivalent of meshcore's self-advert.
|
||||
const NODEINFO_APP: u32 = 4;
|
||||
/// Meshtastic PortNum for admin (config) packets.
|
||||
const ADMIN_APP: u32 = 6;
|
||||
/// AdminMessage.set_owner oneof field number (carries a `User`).
|
||||
@ -37,9 +41,31 @@ const TO_RADIO_HEARTBEAT: u64 = 7;
|
||||
const FROM_RADIO_PACKET: u64 = 2;
|
||||
const FROM_RADIO_MY_INFO: u64 = 3;
|
||||
const FROM_RADIO_NODE_INFO: u64 = 4;
|
||||
/// FromRadio.config (field 5): a `Config` block streamed during want_config.
|
||||
const FROM_RADIO_CONFIG: u64 = 5;
|
||||
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
|
||||
const FROM_RADIO_REBOOTED: u64 = 8;
|
||||
|
||||
/// AdminMessage.set_config oneof field number (carries a `Config`). NB: 33 is
|
||||
/// `set_channel` — `set_config` is 34 (verified against meshtastic/protobufs).
|
||||
const ADMIN_SET_CONFIG_FIELD: u64 = 34;
|
||||
/// AdminMessage.set_channel oneof field number (carries a `Channel`).
|
||||
const ADMIN_SET_CHANNEL_FIELD: u64 = 33;
|
||||
/// FromRadio.channel (field 10): a `Channel` streamed during want_config.
|
||||
const FROM_RADIO_CHANNEL: u64 = 10;
|
||||
/// Channel.role value for the PRIMARY channel (broadcasts ride here).
|
||||
const CHANNEL_ROLE_PRIMARY: u64 = 1;
|
||||
/// Config.lora oneof field number (carries a `LoRaConfig`).
|
||||
const CONFIG_LORA_FIELD: u64 = 6;
|
||||
/// LoRaConfig field numbers we set when provisioning the radio's region.
|
||||
const LORA_USE_PRESET_FIELD: u64 = 1;
|
||||
const LORA_REGION_FIELD: u64 = 7;
|
||||
const LORA_HOP_LIMIT_FIELD: u64 = 8;
|
||||
const LORA_TX_ENABLED_FIELD: u64 = 9;
|
||||
/// RegionCode::UNSET — a radio in this state refuses to transmit or receive on
|
||||
/// LoRa, so it can never mesh. Fresh-flashed radios ship UNSET.
|
||||
const REGION_UNSET: u32 = 0;
|
||||
|
||||
/// Async Meshtastic device handle.
|
||||
pub struct MeshtasticDevice {
|
||||
port: serial2_tokio::SerialPort,
|
||||
@ -57,6 +83,19 @@ pub struct MeshtasticDevice {
|
||||
/// records which peers are PKC-capable, so we can tell a true end-to-end
|
||||
/// (PKI) DM from a channel-PSK fallback.
|
||||
peer_pubkeys: HashMap<u32, Vec<u8>>,
|
||||
/// The radio's currently-configured LoRa region code, learned from the
|
||||
/// `Config.lora` block during `initialize`. `None` until that frame is
|
||||
/// seen; `Some(REGION_UNSET)` for a fresh radio that has never had a region
|
||||
/// set (which means it is RF-silent). Used to decide whether we need to
|
||||
/// provision the operator-configured region — and to avoid a reboot loop by
|
||||
/// only writing when it actually differs.
|
||||
current_region: Option<u32>,
|
||||
/// The radio's current PRIMARY channel as `(name, psk)`, learned from the
|
||||
/// `Channel` blocks during `initialize`. Two radios only decode each other
|
||||
/// when their primary channel (name + psk → channel hash) matches, so archy
|
||||
/// provisions a shared channel here the same way it provisions the region.
|
||||
/// `None` until a primary `Channel` frame is seen.
|
||||
current_primary_channel: Option<(String, Vec<u8>)>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
@ -84,6 +123,8 @@ impl MeshtasticDevice {
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
peer_pubkeys: HashMap::new(),
|
||||
current_region: None,
|
||||
current_primary_channel: None,
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
@ -203,10 +244,207 @@ impl MeshtasticDevice {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the radio is provisioned for the operator-configured LoRa region.
|
||||
/// A freshly-flashed Meshtastic radio ships with `region = UNSET`, which
|
||||
/// makes the firmware refuse to transmit or receive anything — so two such
|
||||
/// radios can never see each other and the mesh appears empty. This is the
|
||||
/// Meshtastic analog of how a meshcore radio comes up on its configured
|
||||
/// band: archy brings every node onto the same region automatically.
|
||||
///
|
||||
/// Returns `Ok(true)` when it actually wrote a new region (the device then
|
||||
/// reboots to apply it, so the caller should restart the session). Returns
|
||||
/// `Ok(false)` when no change was needed (already correct, no region
|
||||
/// configured, or an unrecognised region string) — never reboot-loops.
|
||||
pub async fn ensure_lora_region(&mut self, region: Option<&str>) -> Result<bool> {
|
||||
let Some(region_str) = region else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(code) = region_name_to_code(region_str) else {
|
||||
warn!(
|
||||
region = region_str,
|
||||
"Unknown LoRa region in mesh-config — leaving radio region unchanged"
|
||||
);
|
||||
return Ok(false);
|
||||
};
|
||||
if code == REGION_UNSET {
|
||||
// Operator explicitly asked for UNSET (or blank) — don't fight it.
|
||||
return Ok(false);
|
||||
}
|
||||
match self.current_region {
|
||||
Some(cur) if cur == code => Ok(false),
|
||||
_ => {
|
||||
self.set_lora_region(code).await?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a LoRa region to the locally-connected radio via an
|
||||
/// `AdminMessage { set_config: Config { lora: LoRaConfig { … } } }` on the
|
||||
/// ADMIN_APP port — the same local-admin path `set_advert_name` uses (no
|
||||
/// session passkey needed over serial). We send a minimal, valid preset
|
||||
/// config: `use_preset` + `LONG_FAST` (the default modem preset), the
|
||||
/// chosen `region`, a sane `hop_limit`, and `tx_enabled`. The firmware
|
||||
/// reboots to apply the change.
|
||||
pub async fn set_lora_region(&mut self, region_code: u32) -> Result<()> {
|
||||
let Some(node_num) = self.node_num else {
|
||||
anyhow::bail!("Meshtastic set_lora_region: node_num unknown");
|
||||
};
|
||||
|
||||
// LoRaConfig { use_preset(1)=true, region(7)=code, hop_limit(8)=3,
|
||||
// tx_enabled(9)=true }. modem_preset defaults to LONG_FAST (0) and
|
||||
// tx_power defaults to max, which is what we want for a stock mesh.
|
||||
let mut lora = Vec::new();
|
||||
encode_varint_field_into(LORA_USE_PRESET_FIELD, 1, &mut lora);
|
||||
encode_varint_field_into(LORA_REGION_FIELD, region_code as u64, &mut lora);
|
||||
encode_varint_field_into(LORA_HOP_LIMIT_FIELD, 3, &mut lora);
|
||||
encode_varint_field_into(LORA_TX_ENABLED_FIELD, 1, &mut lora);
|
||||
|
||||
// Config { lora(6): LoRaConfig }
|
||||
let mut config = Vec::new();
|
||||
encode_len_field(CONFIG_LORA_FIELD, &lora, &mut config);
|
||||
|
||||
// AdminMessage { set_config(33): Config }
|
||||
let mut admin = Vec::new();
|
||||
encode_len_field(ADMIN_SET_CONFIG_FIELD, &config, &mut admin);
|
||||
|
||||
let packet = encode_mesh_packet(node_num, ADMIN_APP, &admin);
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
.context("Failed to send Meshtastic set_config(LoRa region) admin packet")?;
|
||||
|
||||
info!(
|
||||
node_num,
|
||||
region_code, "Set Meshtastic LoRa region (device will reboot to apply)"
|
||||
);
|
||||
self.current_region = Some(region_code);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the radio's PRIMARY channel matches the shared archy channel so
|
||||
/// all nodes can decode each other. Region gets two radios onto the same
|
||||
/// band; a matching channel (name + psk → channel hash) gets them decoding
|
||||
/// each other's traffic — without it they hear each other but drop every
|
||||
/// packet as undecryptable. The psk is derived deterministically from the
|
||||
/// channel name, so every archy node with the same `channel_name` converges
|
||||
/// on the same channel (the parity equivalent of meshcore's named channel).
|
||||
///
|
||||
/// Returns `Ok(true)` when it wrote a new channel (the device reboots to
|
||||
/// apply, so the caller should restart the session); `Ok(false)` when no
|
||||
/// change was needed — never reboot-loops.
|
||||
pub async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result<bool> {
|
||||
let Some(channel_name) = channel_name else {
|
||||
return Ok(false);
|
||||
};
|
||||
if channel_name.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let desired_psk = derive_channel_psk(channel_name);
|
||||
let already = matches!(
|
||||
&self.current_primary_channel,
|
||||
Some((name, psk)) if name == channel_name && psk == &desired_psk
|
||||
);
|
||||
if already {
|
||||
Ok(false)
|
||||
} else {
|
||||
self.set_channel(channel_name, &desired_psk).await?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the PRIMARY channel via `AdminMessage { set_channel: Channel { … } }`
|
||||
/// (the same local-admin path as `set_advert_name`). The firmware reboots to
|
||||
/// apply it.
|
||||
pub async fn set_channel(&mut self, name: &str, psk: &[u8]) -> Result<()> {
|
||||
let Some(node_num) = self.node_num else {
|
||||
anyhow::bail!("Meshtastic set_channel: node_num unknown");
|
||||
};
|
||||
|
||||
// ChannelSettings { psk(2), name(3) }
|
||||
let mut settings = Vec::new();
|
||||
encode_len_field(2, psk, &mut settings);
|
||||
encode_len_field(3, name.as_bytes(), &mut settings);
|
||||
|
||||
// Channel { index(1)=0, settings(2), role(3)=PRIMARY }
|
||||
let mut channel = Vec::new();
|
||||
encode_varint_field_into(1, 0, &mut channel);
|
||||
encode_len_field(2, &settings, &mut channel);
|
||||
encode_varint_field_into(3, CHANNEL_ROLE_PRIMARY, &mut channel);
|
||||
|
||||
// AdminMessage { set_channel(33): Channel }
|
||||
let mut admin = Vec::new();
|
||||
encode_len_field(ADMIN_SET_CHANNEL_FIELD, &channel, &mut admin);
|
||||
|
||||
let packet = encode_mesh_packet(node_num, ADMIN_APP, &admin);
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
.context("Failed to send Meshtastic set_channel admin packet")?;
|
||||
|
||||
info!(node_num, channel = %name, "Set Meshtastic primary channel (device will reboot to apply)");
|
||||
self.current_primary_channel = Some((name.to_string(), psk.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_to_radio(&encode_heartbeat()).await
|
||||
}
|
||||
|
||||
/// Build our own `User` protobuf (id/long_name/short_name) for a NodeInfo
|
||||
/// advert. Returns `None` until the handshake has learned our identity.
|
||||
fn build_self_user(&self) -> Option<Vec<u8>> {
|
||||
let mut user = Vec::new();
|
||||
if let Some(id) = &self.user_id {
|
||||
encode_len_field(1, id.as_bytes(), &mut user);
|
||||
}
|
||||
if let Some(long_name) = &self.long_name {
|
||||
encode_len_field(2, long_name.as_bytes(), &mut user);
|
||||
}
|
||||
if let Some(short_name) = &self.short_name {
|
||||
encode_len_field(3, short_name.as_bytes(), &mut user);
|
||||
}
|
||||
if user.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Actively advertise our identity over the air by broadcasting a NodeInfo
|
||||
/// packet (our `User`) on the primary channel. Meshtastic radios otherwise
|
||||
/// only emit NodeInfo on boot and every few hours, so without this two
|
||||
/// already-running nodes can sit forever without discovering each other.
|
||||
/// This is the Meshtastic analog of meshcore's periodic self-advert.
|
||||
///
|
||||
/// `want_response` solicits each neighbour to reply with its own NodeInfo —
|
||||
/// use it on connect for immediate two-way discovery; leave it off for the
|
||||
/// periodic beacon so a busy mesh doesn't trigger reply storms.
|
||||
pub async fn send_nodeinfo_broadcast(&mut self, want_response: bool) -> Result<()> {
|
||||
let Some(user) = self.build_self_user() else {
|
||||
debug!("Meshtastic NodeInfo advert skipped — local identity not known yet");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Data { portnum(1)=NODEINFO_APP, payload(2)=User, want_response(3)? }
|
||||
let mut data = Vec::new();
|
||||
encode_varint_field_into(1, NODEINFO_APP as u64, &mut data);
|
||||
encode_len_field(2, &user, &mut data);
|
||||
if want_response {
|
||||
encode_varint_field_into(3, 1, &mut data);
|
||||
}
|
||||
|
||||
// MeshPacket { to(2)=BROADCAST (fixed32), decoded(4)=Data }. The firmware
|
||||
// fills in `from` = our node-num when it transmits.
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(2, BROADCAST_NUM, &mut packet);
|
||||
encode_len_field(4, &data, &mut packet);
|
||||
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
.context("Failed to send Meshtastic NodeInfo broadcast")?;
|
||||
debug!(want_response, "Broadcast Meshtastic NodeInfo advert");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> {
|
||||
let text = String::from_utf8_lossy(msg);
|
||||
let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes());
|
||||
@ -339,12 +577,36 @@ impl MeshtasticDevice {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
// Drain aggressively. Meshtastic firmware interleaves verbose debug-log
|
||||
// text with protobuf frames on the same serial line, so a single small
|
||||
// read per poll can fall behind the byte stream, overflow the OS serial
|
||||
// buffer, and corrupt/drop inbound frames — which silently kills message
|
||||
// reception while leaving sends working. Pull up to a bounded burst of
|
||||
// bytes per call, decoding as soon as a complete frame appears.
|
||||
let mut tmp = [0u8; READ_BUF_SIZE];
|
||||
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
|
||||
Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"),
|
||||
Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]),
|
||||
Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"),
|
||||
Err(_) => return Ok(None),
|
||||
for _ in 0..32 {
|
||||
match tokio::time::timeout(Duration::from_millis(30), self.port.read(&mut tmp)).await {
|
||||
Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"),
|
||||
Ok(Ok(n)) => {
|
||||
self.read_buf.extend_from_slice(&tmp[..n]);
|
||||
if let Some(frame) = decode_serial_frame(&mut self.read_buf) {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
// Bound memory if it's a pure-debug flood with no frames:
|
||||
// keep only from the last possible frame-start marker.
|
||||
if self.read_buf.len() > 64 * 1024 {
|
||||
if let Some(pos) =
|
||||
self.read_buf.windows(2).rposition(|w| w == [START1, START2])
|
||||
{
|
||||
self.read_buf.drain(..pos);
|
||||
} else {
|
||||
self.read_buf.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"),
|
||||
Err(_) => break, // no more bytes available right now
|
||||
}
|
||||
}
|
||||
|
||||
Ok(decode_serial_frame(&mut self.read_buf))
|
||||
@ -352,8 +614,14 @@ impl MeshtasticDevice {
|
||||
|
||||
fn handle_from_radio(&mut self, frame: &[u8]) -> Option<InboundFrame> {
|
||||
let Some((field, value)) = decode_top_level_variant(frame) else {
|
||||
debug!(
|
||||
len = frame.len(),
|
||||
head = %hex::encode(&frame[..frame.len().min(8)]),
|
||||
"Meshtastic FromRadio frame did not decode to a known top-level field"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
debug!(field, value_len = value.len(), "Meshtastic FromRadio field");
|
||||
match field {
|
||||
FROM_RADIO_MY_INFO => {
|
||||
if let Some((node_num, user_id)) = parse_my_info(value) {
|
||||
@ -369,6 +637,22 @@ impl MeshtasticDevice {
|
||||
None
|
||||
}
|
||||
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
|
||||
FROM_RADIO_CONFIG => {
|
||||
// Only the LoRa sub-config carries a region; other Config
|
||||
// variants (device/position/…) return None and are ignored.
|
||||
if let Some(region) = parse_config_lora_region(value) {
|
||||
self.current_region = Some(region);
|
||||
debug!(region, "Meshtastic LoRa region from device config");
|
||||
}
|
||||
None
|
||||
}
|
||||
FROM_RADIO_CHANNEL => {
|
||||
if let Some((name, psk)) = parse_primary_channel(value) {
|
||||
debug!(name = %name, psk_len = psk.len(), "Meshtastic primary channel from device");
|
||||
self.current_primary_channel = Some((name, psk));
|
||||
}
|
||||
None
|
||||
}
|
||||
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
|
||||
other => {
|
||||
debug!(
|
||||
@ -424,6 +708,12 @@ impl MeshtasticDevice {
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
info!(
|
||||
from = format!("!{:08x}", from),
|
||||
len = packet.payload.len(),
|
||||
pki = packet.pki_encrypted,
|
||||
"Meshtastic received text packet over the air"
|
||||
);
|
||||
// Record E2E status: a `pki_encrypted` packet (or one carrying the
|
||||
// sender's `public_key`) proves this DM arrived end-to-end encrypted via
|
||||
// the PKI, not the shared channel PSK. We learn the sender's key here too
|
||||
@ -504,6 +794,116 @@ fn encode_heartbeat() -> Vec<u8> {
|
||||
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
|
||||
}
|
||||
|
||||
/// Extract `LoRaConfig.region` from a `Config` message, returning the region
|
||||
/// code. Returns `Some(REGION_UNSET)` when the LoRa block is present but has no
|
||||
/// region field (a fresh radio), and `None` when this Config carries a
|
||||
/// non-LoRa variant (device/position/…) so the caller keeps the prior value.
|
||||
fn parse_config_lora_region(data: &[u8]) -> Option<u32> {
|
||||
let mut idx = 0;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
if field == CONFIG_LORA_FIELD {
|
||||
if let FieldValue::Bytes(b) = value {
|
||||
let mut j = 0;
|
||||
let mut region = REGION_UNSET;
|
||||
while j < b.len() {
|
||||
let (lf, lv, ln) = next_field(b, j)?;
|
||||
j = ln;
|
||||
if lf == LORA_REGION_FIELD {
|
||||
if let FieldValue::Varint(v) = lv {
|
||||
region = v as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some(region);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract `(name, psk)` from a `Channel` message, but only for the PRIMARY
|
||||
/// channel (role == 1) — that's the one broadcasts ride on and whose hash must
|
||||
/// match for two radios to decode each other. Returns `None` for secondary /
|
||||
/// disabled channels so the caller keeps the primary it already learned.
|
||||
fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec<u8>)> {
|
||||
let mut role = 0u64;
|
||||
let mut name = String::new();
|
||||
let mut psk = Vec::new();
|
||||
let mut idx = 0;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(3, FieldValue::Varint(v)) => role = v,
|
||||
(2, FieldValue::Bytes(b)) => {
|
||||
let mut j = 0;
|
||||
while j < b.len() {
|
||||
let (sf, sv, sn) = next_field(b, j)?;
|
||||
j = sn;
|
||||
match (sf, sv) {
|
||||
(2, FieldValue::Bytes(p)) => psk = p.to_vec(),
|
||||
(3, FieldValue::Bytes(n)) => {
|
||||
name = String::from_utf8_lossy(n).to_string()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if role == CHANNEL_ROLE_PRIMARY {
|
||||
Some((name, psk))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the 32-byte channel PSK deterministically from the channel name, so
|
||||
/// every archy node configured with the same `channel_name` converges on the
|
||||
/// exact same primary channel (identical hash) and meshes automatically.
|
||||
fn derive_channel_psk(channel_name: &str) -> Vec<u8> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"archipelago-mesh:");
|
||||
hasher.update(channel_name.as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Map a Meshtastic `RegionCode` name (as set in `mesh-config.json`, e.g.
|
||||
/// "EU_868", "US", "ANZ") to its protobuf enum value. Case-insensitive.
|
||||
/// Returns `None` for an unrecognised name so we never write a bogus region.
|
||||
fn region_name_to_code(name: &str) -> Option<u32> {
|
||||
Some(match name.trim().to_uppercase().as_str() {
|
||||
"UNSET" => 0,
|
||||
"US" => 1,
|
||||
"EU_433" => 2,
|
||||
"EU_868" | "EU868" => 3,
|
||||
"CN" => 4,
|
||||
"JP" => 5,
|
||||
"ANZ" => 6,
|
||||
"KR" => 7,
|
||||
"TW" => 8,
|
||||
"RU" => 9,
|
||||
"IN" => 10,
|
||||
"NZ_865" => 11,
|
||||
"TH" => 12,
|
||||
"LORA_24" => 13,
|
||||
"UA_433" => 14,
|
||||
"UA_868" => 15,
|
||||
"MY_433" => 16,
|
||||
"MY_919" => 17,
|
||||
"SG_923" => 18,
|
||||
"PH_433" => 19,
|
||||
"PH_868" => 20,
|
||||
"PH_915" => 21,
|
||||
"ANZ_433" => 22,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_len_field(field, bytes, &mut out);
|
||||
@ -544,7 +944,11 @@ fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
|
||||
}
|
||||
if matches!(
|
||||
field,
|
||||
FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO
|
||||
FROM_RADIO_PACKET
|
||||
| FROM_RADIO_MY_INFO
|
||||
| FROM_RADIO_NODE_INFO
|
||||
| FROM_RADIO_CONFIG
|
||||
| FROM_RADIO_CHANNEL
|
||||
) {
|
||||
return Some((field, &buf[idx..end]));
|
||||
}
|
||||
|
||||
@ -326,6 +326,14 @@ pub struct MeshConfig {
|
||||
/// Channel name for broadcasts.
|
||||
#[serde(default)]
|
||||
pub channel_name: Option<String>,
|
||||
/// Meshtastic LoRa region (e.g. "EU_868", "US", "ANZ"). Fresh-flashed
|
||||
/// Meshtastic radios ship region-UNSET and are RF-silent until a region is
|
||||
/// set, so archy provisions this region on connect to bring every node onto
|
||||
/// the same band automatically (the parity equivalent of a meshcore radio
|
||||
/// coming up on its configured band). Ignored for meshcore devices and when
|
||||
/// unset/None.
|
||||
#[serde(default)]
|
||||
pub lora_region: Option<String>,
|
||||
/// Whether to periodically broadcast our identity.
|
||||
#[serde(default)]
|
||||
pub broadcast_identity: bool,
|
||||
@ -385,6 +393,7 @@ impl Default for MeshConfig {
|
||||
enabled: false,
|
||||
device_path: None,
|
||||
channel_name: Some("archipelago".to_string()),
|
||||
lora_region: None,
|
||||
broadcast_identity: true,
|
||||
advert_name: None,
|
||||
mesh_only_mode: None,
|
||||
@ -675,6 +684,8 @@ impl MeshService {
|
||||
self.our_x25519_secret,
|
||||
self.our_x25519_pubkey_hex.clone(),
|
||||
self.server_name.clone(),
|
||||
self.config.lora_region.clone(),
|
||||
self.config.channel_name.clone(),
|
||||
shutdown_rx,
|
||||
cmd_rx,
|
||||
);
|
||||
|
||||
@ -214,31 +214,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "meshtastic",
|
||||
"title": "Meshtastic",
|
||||
"version": "2-daily-alpine",
|
||||
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
|
||||
"icon": "/assets/img/app-icons/meshcore.svg",
|
||||
"author": "Meshtastic",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine",
|
||||
"repoUrl": "https://github.com/meshtastic/firmware",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"4403:4403"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/meshtastic:/var/lib/meshtasticd"
|
||||
],
|
||||
"env": [
|
||||
"MESHTASTIC_PORT=/dev/ttyUSB0",
|
||||
"MESHTASTIC_SERIAL=true"
|
||||
],
|
||||
"notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
|
||||
@ -3046,90 +3046,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"meshtastic": {
|
||||
"version": "2-daily-alpine",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"id": "meshtastic",
|
||||
"name": "Meshtastic",
|
||||
"version": "2-daily-alpine",
|
||||
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
|
||||
"container": {
|
||||
"image": "docker.io/meshtastic/meshtasticd:daily-alpine",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "1Gi"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 1,
|
||||
"memory_limit": "512Mi",
|
||||
"disk_limit": "1Gi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [
|
||||
"NET_ADMIN",
|
||||
"SYS_ADMIN"
|
||||
],
|
||||
"readonly_root": false,
|
||||
"no_new_privileges": true,
|
||||
"user": 1000,
|
||||
"seccomp_profile": "default",
|
||||
"network_policy": "host",
|
||||
"apparmor_profile": "meshtastic"
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"host": 4403,
|
||||
"container": 4403,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"devices": [
|
||||
"/dev/ttyUSB0"
|
||||
],
|
||||
"volumes": [
|
||||
{
|
||||
"type": "bind",
|
||||
"source": "/var/lib/archipelago/meshtastic",
|
||||
"target": "/var/lib/meshtasticd",
|
||||
"options": [
|
||||
"rw"
|
||||
]
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "/var/lib/archipelago/meshtastic/config.yaml",
|
||||
"content": "General:\n MACAddress: AA:BB:CC:DD:EE:01\nWebserver:\n Port: 4403\n"
|
||||
}
|
||||
],
|
||||
"environment": [
|
||||
"MESHTASTIC_PORT=/dev/ttyUSB0",
|
||||
"MESHTASTIC_SERIAL=true"
|
||||
],
|
||||
"health_check": {
|
||||
"type": "cmd",
|
||||
"endpoint": "test -f /var/lib/meshtasticd/config.yaml",
|
||||
"interval": "30s",
|
||||
"timeout": "30s",
|
||||
"retries": 5
|
||||
},
|
||||
"networking": {
|
||||
"mesh_enabled": true,
|
||||
"local_network_access": true
|
||||
},
|
||||
"metadata": {
|
||||
"icon": "/assets/img/app-icons/meshcore.svg",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"repo": "https://github.com/meshtastic/firmware"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"morphos-server": {
|
||||
"version": "1.0.0",
|
||||
"manifest": {
|
||||
|
||||
@ -157,7 +157,6 @@ image_for() {
|
||||
indeedhub) echo "146.59.87.168:3000/lfg2025/indeedhub:1.0.0" ;;
|
||||
botfights) echo "146.59.87.168:3000/lfg2025/botfights:1.1.0" ;;
|
||||
gitea) echo "docker.io/gitea/gitea:1.23" ;;
|
||||
meshtastic) echo "docker.io/meshtastic/meshtasticd:daily-alpine" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user