Compare commits
2 Commits
7804223152
...
e05e356d64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e05e356d64 | ||
|
|
cfb304a001 |
14
CHANGELOG.md
14
CHANGELOG.md
@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.58-alpha (2026-05-17)
|
||||
|
||||
- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.
|
||||
- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.
|
||||
- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.
|
||||
- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.
|
||||
- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.
|
||||
- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.
|
||||
- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.
|
||||
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
|
||||
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
|
||||
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
|
||||
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
|
||||
|
||||
## v1.7.57-alpha (2026-05-17)
|
||||
|
||||
- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.
|
||||
|
||||
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.57-alpha"
|
||||
version = "1.7.58-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.57-alpha"
|
||||
version = "1.7.58-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@ -233,6 +233,24 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
}
|
||||
|
||||
let configs = runtime_dir.join("image-recipe/configs");
|
||||
let nginx_src = configs.join("nginx-archipelago.conf");
|
||||
if nginx_src.exists() {
|
||||
let src_s = nginx_src.to_string_lossy().to_string();
|
||||
let status = host_sudo(&[
|
||||
"install",
|
||||
"-m",
|
||||
"644",
|
||||
&src_s,
|
||||
"/etc/nginx/sites-available/archipelago",
|
||||
])
|
||||
.await
|
||||
.context("install nginx-archipelago.conf")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
@ -250,6 +268,19 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
if nginx_src.exists() {
|
||||
match host_sudo(&["nginx", "-t"]).await {
|
||||
Ok(status) if status.success() => {
|
||||
let _ = host_sudo(&["systemctl", "reload", "nginx"]).await;
|
||||
}
|
||||
Ok(status) => {
|
||||
tracing::warn!("nginx config test failed after runtime sync: {}", status);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to test nginx config after runtime sync: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
@ -1,42 +1,140 @@
|
||||
//! Mesh session lifecycle: connect, initialize, main loop.
|
||||
|
||||
use super::super::meshtastic::MeshtasticDevice;
|
||||
use super::super::serial::MeshcoreDevice;
|
||||
use super::super::types::*;
|
||||
use super::{
|
||||
frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Scan all candidate serial ports and open the first Meshcore device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> {
|
||||
enum MeshRadioDevice {
|
||||
Meshcore(MeshcoreDevice),
|
||||
Meshtastic(MeshtasticDevice),
|
||||
}
|
||||
|
||||
impl MeshRadioDevice {
|
||||
fn device_type(&self) -> DeviceType {
|
||||
match self {
|
||||
Self::Meshcore(_) => DeviceType::Meshcore,
|
||||
Self::Meshtastic(_) => DeviceType::Meshtastic,
|
||||
}
|
||||
}
|
||||
|
||||
fn advert_name(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.advert_name.clone(),
|
||||
Self::Meshtastic(device) => device.advert_name(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.set_advert_name(name).await,
|
||||
Self::Meshtastic(device) => device.set_advert_name(name).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_self_advert(&mut self) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_self_advert().await,
|
||||
Self::Meshtastic(device) => device.send_self_advert().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,
|
||||
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.get_contacts().await,
|
||||
Self::Meshtastic(device) => device.get_contacts().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
|
||||
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_messages(&mut self) -> Result<Vec<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.sync_messages().await,
|
||||
Self::Meshtastic(device) => device.sync_messages().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_recv_frame(&mut self) -> Result<Option<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.try_recv_frame().await,
|
||||
Self::Meshtastic(device) => device.try_recv_frame().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all candidate serial ports and open the first supported mesh device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let paths = super::super::serial::detect_serial_devices().await;
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found in /dev");
|
||||
}
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for Meshcore device");
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
|
||||
return Ok((path.clone(), dev, info));
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No Meshcore device found on {} candidate ports: {:?}",
|
||||
"No supported mesh radio found on {} candidate ports: {:?}",
|
||||
paths.len(),
|
||||
paths
|
||||
)
|
||||
}
|
||||
|
||||
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||
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 Meshtastic"),
|
||||
},
|
||||
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII marker for the original DM-via-channel format:
|
||||
/// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire,
|
||||
/// so the receiver has to guess the sender from its contact table — which
|
||||
@ -90,7 +188,7 @@ fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||
/// with both the recipient and sender prefixes so attribution works on
|
||||
/// the receiver side.
|
||||
async fn send_dm_via_channel(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
payload: &[u8],
|
||||
@ -166,7 +264,7 @@ async fn send_dm_via_channel(
|
||||
}
|
||||
|
||||
/// Fetch the contacts list from the device and update the peer cache.
|
||||
async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
// Skip firmware contacts the user has explicitly wiped via
|
||||
@ -239,7 +337,7 @@ async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
/// Drain any queued messages from the device.
|
||||
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
||||
async fn sync_queued_messages(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) -> bool {
|
||||
@ -274,20 +372,11 @@ pub(super) async fn run_mesh_session(
|
||||
) -> Result<()> {
|
||||
// Detect device — try preferred path first, fall back to auto-detect
|
||||
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} handshake failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
}
|
||||
},
|
||||
match open_preferred_path(path).await {
|
||||
Ok((dev, info)) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} open failed: {} — trying auto-detect",
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
@ -301,11 +390,11 @@ pub(super) async fn run_mesh_session(
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = true;
|
||||
status.device_type = DeviceType::Meshcore;
|
||||
status.device_type = device.device_type();
|
||||
status.device_path = Some(device_path.clone());
|
||||
status.firmware_version = Some(device_info.firmware_version.clone());
|
||||
status.self_node_id = Some(device_info.node_id);
|
||||
status.self_advert_name = device.advert_name.clone();
|
||||
status.self_advert_name = device.advert_name();
|
||||
}
|
||||
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
||||
@ -434,7 +523,7 @@ pub(super) async fn run_mesh_session(
|
||||
/// Process a single outbound command from MeshService.
|
||||
async fn handle_send_command(
|
||||
cmd: MeshCommand,
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
|
||||
577
core/archipelago/src/mesh/meshtastic.rs
Normal file
577
core/archipelago/src/mesh/meshtastic.rs
Normal file
@ -0,0 +1,577 @@
|
||||
//! Async serial driver for Meshtastic devices.
|
||||
//!
|
||||
//! Meshtastic uses protobuf payloads over a SLIP-like serial stream. This
|
||||
//! module implements only the small subset Archipelago needs: connect,
|
||||
//! discover the local node, send/receive text packets, and provide synthetic
|
||||
//! contacts to the existing mesh listener.
|
||||
|
||||
use super::protocol::{InboundFrame, ParsedContact};
|
||||
use super::types::{DeviceInfo, DeviceType};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const READ_BUF_SIZE: usize = 512;
|
||||
|
||||
const START1: u8 = 0x94;
|
||||
const START2: u8 = 0xc3;
|
||||
const TO_RADIO_MAX: usize = 512;
|
||||
const BROADCAST_NUM: u32 = 0xffff_ffff;
|
||||
const TEXT_MESSAGE_APP: u32 = 1;
|
||||
|
||||
const TO_RADIO_PACKET: u64 = 1;
|
||||
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
|
||||
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;
|
||||
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
|
||||
const FROM_RADIO_REBOOTED: u64 = 8;
|
||||
|
||||
/// Async Meshtastic device handle.
|
||||
pub struct MeshtasticDevice {
|
||||
port: serial2_tokio::SerialPort,
|
||||
read_buf: Vec<u8>,
|
||||
node_num: Option<u32>,
|
||||
user_id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
contacts: HashMap<u32, ParsedContact>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl MeshtasticDevice {
|
||||
pub async fn open(path: &str) -> Result<Self> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => debug!(path = %path, permissions = ?meta.permissions(), "Device node exists"),
|
||||
Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e),
|
||||
}
|
||||
|
||||
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!(
|
||||
"Failed to open serial port {} (permission denied? device busy?)",
|
||||
path
|
||||
))?;
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
read_buf: Vec::with_capacity(READ_BUF_SIZE),
|
||||
node_num: None,
|
||||
user_id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
|
||||
info!(path = %self.device_path, "Starting Meshtastic handshake");
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + READ_TIMEOUT;
|
||||
let mut saw_meshtastic_frame = false;
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(remaining.min(Duration::from_millis(250)), self.read_from_radio()).await {
|
||||
Ok(Ok(Some(frame))) => {
|
||||
saw_meshtastic_frame = true;
|
||||
self.handle_from_radio(&frame);
|
||||
if self.node_num.is_some() && self.user_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) | Err(_) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_meshtastic_frame {
|
||||
anyhow::bail!("No Meshtastic serial API response");
|
||||
}
|
||||
|
||||
let node_id = self.node_num.unwrap_or(0);
|
||||
let firmware_version = self
|
||||
.long_name
|
||||
.clone()
|
||||
.or_else(|| self.user_id.clone())
|
||||
.unwrap_or_else(|| "Meshtastic".to_string());
|
||||
|
||||
info!(node_id, name = %firmware_version, "Meshtastic identity");
|
||||
Ok(DeviceInfo {
|
||||
firmware_version,
|
||||
node_id,
|
||||
max_contacts: 200,
|
||||
device_type: DeviceType::Meshtastic,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.long_name = Some(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_to_radio(&encode_heartbeat()).await
|
||||
}
|
||||
|
||||
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());
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
||||
Ok(self.contacts.values().cloned().collect())
|
||||
}
|
||||
|
||||
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||
let Some(frame) = self.read_from_radio().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.handle_from_radio(&frame))
|
||||
}
|
||||
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.long_name
|
||||
.clone()
|
||||
.or_else(|| self.short_name.clone())
|
||||
.or_else(|| self.user_id.clone())
|
||||
}
|
||||
|
||||
async fn send_to_radio(&mut self, payload: &[u8]) -> Result<()> {
|
||||
if payload.len() > TO_RADIO_MAX {
|
||||
anyhow::bail!("Meshtastic payload too large: {} bytes", payload.len());
|
||||
}
|
||||
let mut frame = Vec::with_capacity(4 + payload.len());
|
||||
frame.push(START1);
|
||||
frame.push(START2);
|
||||
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(&frame))
|
||||
.await
|
||||
.context("Meshtastic serial write timed out")?
|
||||
.context("Meshtastic serial write failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_from_radio(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if let Some(frame) = decode_serial_frame(&mut self.read_buf) {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
Ok(decode_serial_frame(&mut self.read_buf))
|
||||
}
|
||||
|
||||
fn handle_from_radio(&mut self, frame: &[u8]) -> Option<InboundFrame> {
|
||||
let Some((field, value)) = decode_top_level_variant(frame) else {
|
||||
return None;
|
||||
};
|
||||
match field {
|
||||
FROM_RADIO_MY_INFO => {
|
||||
if let Some((node_num, user_id)) = parse_my_info(value) {
|
||||
self.node_num = Some(node_num);
|
||||
if let Some(user_id) = user_id {
|
||||
self.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
FROM_RADIO_NODE_INFO => {
|
||||
self.update_node_info(value);
|
||||
None
|
||||
}
|
||||
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
|
||||
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
|
||||
other => {
|
||||
debug!(field = other, len = value.len(), "Unhandled Meshtastic FromRadio field");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_node_info(&mut self, data: &[u8]) {
|
||||
if let Some(node) = parse_node_info(data) {
|
||||
let key = synthetic_pubkey(node.num);
|
||||
let name = node
|
||||
.long_name
|
||||
.or(node.short_name)
|
||||
.or(node.id)
|
||||
.unwrap_or_else(|| format!("Meshtastic !{:08x}", node.num));
|
||||
if Some(node.num) == self.node_num {
|
||||
self.long_name = Some(name.clone());
|
||||
}
|
||||
self.contacts.insert(
|
||||
node.num,
|
||||
ParsedContact {
|
||||
public_key_hex: hex::encode(key),
|
||||
advert_name: name,
|
||||
last_advert: node.last_heard.unwrap_or_default(),
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
|
||||
let packet = parse_mesh_packet(data)?;
|
||||
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let from = packet.from.unwrap_or(0);
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
advert_name: format!("Meshtastic !{:08x}", from),
|
||||
last_advert: 0,
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
});
|
||||
|
||||
let mut payload = Vec::with_capacity(15 + packet.payload.len());
|
||||
payload.push(0); // SNR unknown
|
||||
payload.extend_from_slice(&[0, 0]); // reserved
|
||||
payload.extend_from_slice(&from_key[..6]);
|
||||
payload.push(0xff); // unknown/flood path
|
||||
payload.push(0); // text type
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&packet.payload);
|
||||
Some(InboundFrame {
|
||||
code: super::protocol::RESP_CONTACT_MSG_V3,
|
||||
data: payload,
|
||||
bytes_consumed: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
let start = buf.windows(2).position(|w| w == [START1, START2])?;
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
}
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
||||
if buf.len() < 4 + len {
|
||||
return None;
|
||||
}
|
||||
let payload = buf[4..4 + len].to_vec();
|
||||
buf.drain(..4 + len);
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn encode_want_config() -> Vec<u8> {
|
||||
encode_to_radio_variant(TO_RADIO_WANT_CONFIG_ID, &encode_varint_field(1, 1))
|
||||
}
|
||||
|
||||
fn encode_heartbeat() -> Vec<u8> {
|
||||
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
|
||||
}
|
||||
|
||||
fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_len_field(field, bytes, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut decoded = Vec::new();
|
||||
encode_varint_field_into(1, portnum as u64, &mut decoded);
|
||||
encode_len_field(2, payload, &mut decoded);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(2, to, &mut packet);
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
packet
|
||||
}
|
||||
|
||||
fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
|
||||
let mut idx = 0;
|
||||
while idx < buf.len() {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let field = key >> 3;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (_, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let end = idx.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
if matches!(field, FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO) {
|
||||
return Some((field, &buf[idx..end]));
|
||||
}
|
||||
idx = end;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_my_info(data: &[u8]) -> Option<(u32, Option<String>)> {
|
||||
let mut idx = 0;
|
||||
let mut node_num = None;
|
||||
let mut user_id = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node_num = Some(v as u32),
|
||||
(1, FieldValue::Fixed32(v)) => node_num = Some(v),
|
||||
(3, FieldValue::Bytes(b)) => user_id = parse_user(b).and_then(|u| u.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
node_num.map(|n| (n, user_id))
|
||||
}
|
||||
|
||||
struct ParsedNode {
|
||||
num: u32,
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
last_heard: Option<u32>,
|
||||
}
|
||||
|
||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
let mut idx = 0;
|
||||
let mut node = ParsedNode {
|
||||
num: 0,
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
last_heard: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node.num = v as u32,
|
||||
(1, FieldValue::Fixed32(v)) => node.num = v,
|
||||
(2, FieldValue::Bytes(b)) => {
|
||||
if let Some(user) = parse_user(b) {
|
||||
node.id = user.id;
|
||||
node.long_name = user.long_name;
|
||||
node.short_name = user.short_name;
|
||||
}
|
||||
}
|
||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if node.num == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(node)
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedUser {
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
let mut idx = 0;
|
||||
let mut user = ParsedUser {
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(user)
|
||||
}
|
||||
|
||||
struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let decoded = decoded?;
|
||||
let mut didx = 0;
|
||||
let mut portnum = 0;
|
||||
let mut payload = Vec::new();
|
||||
while didx < decoded.len() {
|
||||
let (field, value, next) = next_field(decoded, didx)?;
|
||||
didx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => portnum = v as u32,
|
||||
(2, FieldValue::Bytes(b)) => payload = b.to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(ParsedPacket {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
enum FieldValue<'a> {
|
||||
Varint(u64),
|
||||
Fixed32(u32),
|
||||
Bytes(&'a [u8]),
|
||||
}
|
||||
|
||||
fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
let field = key >> 3;
|
||||
let mut pos = idx + n;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (v, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
Some((field, FieldValue::Varint(v), pos))
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
let end = pos.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
5 => {
|
||||
let end = pos.checked_add(4)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
let value = u32::from_le_bytes([
|
||||
buf[pos],
|
||||
buf[pos + 1],
|
||||
buf[pos + 2],
|
||||
buf[pos + 3],
|
||||
]);
|
||||
Some((field, FieldValue::Fixed32(value), end))
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let end = pos.checked_add(8)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
}
|
||||
wire => {
|
||||
warn!(wire, "Unsupported Meshtastic protobuf wire type");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_varint(buf: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut out = 0u64;
|
||||
for (i, b) in buf.iter().copied().enumerate().take(10) {
|
||||
out |= ((b & 0x7f) as u64) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
return Some((out, i + 1));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn encode_varint_field(field: u64, value: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_varint_field_into(field, value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_varint_field_into(field: u64, value: u64, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 0, out);
|
||||
write_varint(value, out);
|
||||
}
|
||||
|
||||
fn encode_len_field(field: u64, bytes: &[u8], out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 2, out);
|
||||
write_varint(bytes.len() as u64, out);
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn encode_fixed32_field(field: u64, value: u32, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 5, out);
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
|
||||
while value >= 0x80 {
|
||||
out.push((value as u8 & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
out.push(value as u8);
|
||||
}
|
||||
|
||||
fn string_field(bytes: &[u8]) -> Option<String> {
|
||||
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
out[..4].copy_from_slice(&node_num.to_le_bytes());
|
||||
out[4..15].copy_from_slice(b"meshtastic:");
|
||||
out
|
||||
}
|
||||
@ -1,13 +1,15 @@
|
||||
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
|
||||
//! Mesh networking: LoRa radio integration for offline peer discovery
|
||||
//! and encrypted messaging between Archipelago nodes.
|
||||
//!
|
||||
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
|
||||
//! Supports Meshcore firmware via Companion USB and Meshtastic firmware via
|
||||
//! the Meshtastic serial API on Heltec, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards.
|
||||
|
||||
pub mod alerts;
|
||||
pub mod bitcoin_relay;
|
||||
pub mod crypto;
|
||||
pub mod listener;
|
||||
pub mod meshtastic;
|
||||
pub mod message_types;
|
||||
pub mod outbox;
|
||||
pub mod protocol;
|
||||
|
||||
@ -323,6 +323,7 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
}
|
||||
|
||||
/// Parsed contact from RESP_CONTACT (0x03).
|
||||
#[derive(Clone)]
|
||||
pub struct ParsedContact {
|
||||
pub public_key_hex: String,
|
||||
pub advert_name: String,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
# Stable symlink for USB serial adapters used as mesh radios.
|
||||
# Creates /dev/mesh-radio pointing to the underlying ttyUSB device.
|
||||
# Supports: CP2102 (Heltec V3), CH340 (T-Beam), FTDI (RAK WisBlock).
|
||||
# Supports MeshCore and Meshtastic radios using CP2102 (Heltec V3),
|
||||
# CH340 (T-Beam), FTDI (RAK WisBlock), and USB CDC ACM devices.
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
|
||||
@ -105,9 +105,25 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Versioned Vite assets must never fall through to index.html. During OTA
|
||||
# a browser can keep an old HTML shell that references now-removed hashed
|
||||
# chunks; returning HTML for /assets/*.js triggers strict MIME failures.
|
||||
# A real 404 plus immutable/no-cache split lets the app/browser recover on
|
||||
# refresh without caching the wrong content type.
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
|
||||
location ~* ^/(registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Serve static files (Vue.js SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Peer-to-peer node messaging (receives from other nodes over Tor)
|
||||
|
||||
4
neode-ui/package-lock.json
generated
4
neode-ui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.58-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.58-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.58-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@ -1,29 +1,34 @@
|
||||
{
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.58-alpha",
|
||||
"release_date": "2026-05-17",
|
||||
"changelog": [
|
||||
"Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.",
|
||||
"App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.",
|
||||
"Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.",
|
||||
"Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.",
|
||||
"Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory."
|
||||
"Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.",
|
||||
"The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.",
|
||||
"Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.",
|
||||
"Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.",
|
||||
"Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.",
|
||||
"Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.",
|
||||
"Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.",
|
||||
"Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.",
|
||||
"The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.",
|
||||
"OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago",
|
||||
"sha256": "e96d657c28f84c72c1358fdbbc014184530763341a2d02242b3a479f36dc2aef",
|
||||
"size_bytes": 42647224
|
||||
"current_version": "1.7.58-alpha",
|
||||
"new_version": "1.7.58-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.58-alpha/archipelago",
|
||||
"sha256": "2aa6e5f5b3d2cc74360ee2ce76098f9b0543d043cac23492062ece11c009c673",
|
||||
"size_bytes": 42886144
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"sha256": "a09c1507afb4b7e9f5279b92215e92ebc0b86155ae53ac2c0baaecd07522c7ee",
|
||||
"size_bytes": 78322316
|
||||
"name": "archipelago-frontend-1.7.58-alpha.tar.gz",
|
||||
"current_version": "1.7.58-alpha",
|
||||
"new_version": "1.7.58-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.58-alpha/archipelago-frontend-1.7.58-alpha.tar.gz",
|
||||
"sha256": "a800c9e88a0cf088559b3ae227b6d1ed2ebdbb8c1e562b7c95cb35def7623804",
|
||||
"size_bytes": 166466572
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,29 +1,34 @@
|
||||
{
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.58-alpha",
|
||||
"release_date": "2026-05-17",
|
||||
"changelog": [
|
||||
"Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.",
|
||||
"App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.",
|
||||
"Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.",
|
||||
"Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.",
|
||||
"Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory."
|
||||
"Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.",
|
||||
"The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.",
|
||||
"Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.",
|
||||
"Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.",
|
||||
"Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.",
|
||||
"Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.",
|
||||
"Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.",
|
||||
"Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.",
|
||||
"The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.",
|
||||
"OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago",
|
||||
"sha256": "e96d657c28f84c72c1358fdbbc014184530763341a2d02242b3a479f36dc2aef",
|
||||
"size_bytes": 42647224
|
||||
"current_version": "1.7.58-alpha",
|
||||
"new_version": "1.7.58-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.58-alpha/archipelago",
|
||||
"sha256": "2aa6e5f5b3d2cc74360ee2ce76098f9b0543d043cac23492062ece11c009c673",
|
||||
"size_bytes": 42886144
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"sha256": "a09c1507afb4b7e9f5279b92215e92ebc0b86155ae53ac2c0baaecd07522c7ee",
|
||||
"size_bytes": 78322316
|
||||
"name": "archipelago-frontend-1.7.58-alpha.tar.gz",
|
||||
"current_version": "1.7.58-alpha",
|
||||
"new_version": "1.7.58-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.58-alpha/archipelago-frontend-1.7.58-alpha.tar.gz",
|
||||
"sha256": "a800c9e88a0cf088559b3ae227b6d1ed2ebdbb8c1e562b7c95cb35def7623804",
|
||||
"size_bytes": 166466572
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user