fix(mesh): native Meshtastic unicast DMs + driver-level E2E status
Meshtastic DMs were falling back to a channel broadcast, so every node on the LoRa channel saw a "direct" message. Send a directed MeshPacket (to = node num, decoded from the synthetic pubkey's node-id bytes) instead — the Meshtastic analog of the meshcore CMD_SEND_TXT_MSG fix. DMs now reach only the recipient; firmware auto-PKC-encrypts them end-to-end once NodeInfo keys are exchanged. Capture E2E status at the driver level (no shared-type/UI change): - learn each peer's real Curve25519 key from User.public_key (field 8) and inbound MeshPacket.public_key (16), kept in a side-map separate from the synthetic routing key so unicast routing is untouched - detect inbound MeshPacket.pki_encrypted (17) to tell a true E2E DM from a channel-PSK fallback - peer_is_pkc_capable() seam for a future mesh-tab E2E badge Hot-swap preserved: no dispatched MeshRadioDevice signature or the shared ParsedContact changed, so meshcore and meshtastic stay interchangeable behind the listener. Adds tests/multinode/meshtastic.sh, a two/three-radio on-air parity harness (detect, discover, DM round-trip, DM privacy, channel broadcast, typed envelope, reachability). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f636c5d505
commit
298595069d
@ -42,6 +42,14 @@ pub struct MeshtasticDevice {
|
|||||||
long_name: Option<String>,
|
long_name: Option<String>,
|
||||||
short_name: Option<String>,
|
short_name: Option<String>,
|
||||||
contacts: HashMap<u32, ParsedContact>,
|
contacts: HashMap<u32, ParsedContact>,
|
||||||
|
/// Real Curve25519 public keys, keyed by node-num, as learned from NodeInfo
|
||||||
|
/// (`User.public_key`) or PKC-encrypted inbound packets (`MeshPacket
|
||||||
|
/// .public_key`). Kept SEPARATE from `contacts[*].public_key_hex`, which is
|
||||||
|
/// the synthetic node-num-derived routing key that `send_text_msg` relies
|
||||||
|
/// on — we must not overwrite that or unicast routing breaks. This map only
|
||||||
|
/// 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>>,
|
||||||
device_path: String,
|
device_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +76,7 @@ impl MeshtasticDevice {
|
|||||||
long_name: None,
|
long_name: None,
|
||||||
short_name: None,
|
short_name: None,
|
||||||
contacts: HashMap::new(),
|
contacts: HashMap::new(),
|
||||||
|
peer_pubkeys: HashMap::new(),
|
||||||
device_path: path.to_string(),
|
device_path: path.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -150,12 +159,32 @@ impl MeshtasticDevice {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Meshtastic addresses by numeric node-id, not a meshcore pubkey prefix,
|
/// Native Meshtastic unicast DM. Our synthetic Meshtastic pubkeys carry the
|
||||||
/// so there's no direct unicast mapping here. Best-effort fallback to a
|
/// numeric node-id in their first 4 bytes (little-endian, see
|
||||||
/// channel send keeps the device interface uniform; native unicast is only
|
/// `synthetic_pubkey`), so `dest_pubkey_prefix` directly yields the
|
||||||
/// meaningful on the Meshcore transport.
|
/// destination node number. We send a directed MeshPacket (`to` = node num)
|
||||||
pub async fn send_text_msg(&mut self, _dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
/// rather than a `BROADCAST_NUM` channel blast — this is the Meshtastic
|
||||||
self.send_channel_text(0, msg).await
|
/// analog of the meshcore `CMD_SEND_TXT_MSG` fix: the message is delivered
|
||||||
|
/// as a real DM (only the recipient's client surfaces it) instead of
|
||||||
|
/// polluting the shared primary channel where every node would see it.
|
||||||
|
///
|
||||||
|
/// If the prefix decodes to node 0 / broadcast (e.g. a non-Meshtastic
|
||||||
|
/// synthetic key routed here by mistake), fall back to a channel send so the
|
||||||
|
/// device interface stays uniform and the message still goes out.
|
||||||
|
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||||
|
let node_num = u32::from_le_bytes([
|
||||||
|
dest_pubkey_prefix[0],
|
||||||
|
dest_pubkey_prefix[1],
|
||||||
|
dest_pubkey_prefix[2],
|
||||||
|
dest_pubkey_prefix[3],
|
||||||
|
]);
|
||||||
|
if node_num == 0 || node_num == BROADCAST_NUM {
|
||||||
|
return self.send_channel_text(0, msg).await;
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(msg);
|
||||||
|
let packet = encode_mesh_packet(node_num, TEXT_MESSAGE_APP, text.as_bytes());
|
||||||
|
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
|
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
|
||||||
@ -214,6 +243,19 @@ impl MeshtasticDevice {
|
|||||||
Ok(self.handle_from_radio(&frame))
|
Ok(self.handle_from_radio(&frame))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a
|
||||||
|
/// NodeInfo `public_key` or an inbound PKC DM — meaning the firmware can
|
||||||
|
/// deliver DMs to/from it end-to-end encrypted instead of falling back to
|
||||||
|
/// the channel PSK. Driver-internal for now; lets a future mesh-tab badge
|
||||||
|
/// distinguish a true E2E DM from a channel-encrypted one without changing
|
||||||
|
/// the shared device interface (which would break meshcore hot-swap).
|
||||||
|
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
|
||||||
|
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
|
||||||
|
self.peer_pubkeys
|
||||||
|
.get(&node_num)
|
||||||
|
.is_some_and(|k| !k.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn advert_name(&self) -> Option<String> {
|
pub fn advert_name(&self) -> Option<String> {
|
||||||
self.long_name
|
self.long_name
|
||||||
.clone()
|
.clone()
|
||||||
@ -286,6 +328,15 @@ impl MeshtasticDevice {
|
|||||||
|
|
||||||
fn update_node_info(&mut self, data: &[u8]) {
|
fn update_node_info(&mut self, data: &[u8]) {
|
||||||
if let Some(node) = parse_node_info(data) {
|
if let Some(node) = parse_node_info(data) {
|
||||||
|
if let Some(pk) = node.public_key.as_ref() {
|
||||||
|
if self.peer_pubkeys.insert(node.num, pk.clone()).is_none() {
|
||||||
|
debug!(
|
||||||
|
node = node.num,
|
||||||
|
key_len = pk.len(),
|
||||||
|
"Meshtastic peer is PKC-capable (NodeInfo public_key)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let key = synthetic_pubkey(node.num);
|
let key = synthetic_pubkey(node.num);
|
||||||
let name = node
|
let name = node
|
||||||
.long_name
|
.long_name
|
||||||
@ -318,6 +369,18 @@ impl MeshtasticDevice {
|
|||||||
if Some(from) == self.node_num {
|
if Some(from) == self.node_num {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// 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
|
||||||
|
// — but keep it OUT of the routing `public_key_hex` (synthetic) so the
|
||||||
|
// device interface stays identical to meshcore's and the two remain
|
||||||
|
// hot-swappable behind the mesh listener.
|
||||||
|
if let Some(pk) = packet.public_key.as_ref() {
|
||||||
|
self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
||||||
|
}
|
||||||
|
if packet.pki_encrypted {
|
||||||
|
debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)");
|
||||||
|
}
|
||||||
let from_key = synthetic_pubkey(from);
|
let from_key = synthetic_pubkey(from);
|
||||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||||
@ -444,6 +507,7 @@ struct ParsedNode {
|
|||||||
long_name: Option<String>,
|
long_name: Option<String>,
|
||||||
short_name: Option<String>,
|
short_name: Option<String>,
|
||||||
last_heard: Option<u32>,
|
last_heard: Option<u32>,
|
||||||
|
public_key: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||||
@ -454,6 +518,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
|||||||
long_name: None,
|
long_name: None,
|
||||||
short_name: None,
|
short_name: None,
|
||||||
last_heard: None,
|
last_heard: None,
|
||||||
|
public_key: None,
|
||||||
};
|
};
|
||||||
while idx < data.len() {
|
while idx < data.len() {
|
||||||
let (field, value, next) = next_field(data, idx)?;
|
let (field, value, next) = next_field(data, idx)?;
|
||||||
@ -466,6 +531,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
|||||||
node.id = user.id;
|
node.id = user.id;
|
||||||
node.long_name = user.long_name;
|
node.long_name = user.long_name;
|
||||||
node.short_name = user.short_name;
|
node.short_name = user.short_name;
|
||||||
|
node.public_key = user.public_key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||||
@ -483,6 +549,7 @@ struct ParsedUser {
|
|||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
long_name: Option<String>,
|
long_name: Option<String>,
|
||||||
short_name: Option<String>,
|
short_name: Option<String>,
|
||||||
|
public_key: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||||
@ -491,6 +558,7 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
|||||||
id: None,
|
id: None,
|
||||||
long_name: None,
|
long_name: None,
|
||||||
short_name: None,
|
short_name: None,
|
||||||
|
public_key: None,
|
||||||
};
|
};
|
||||||
while idx < data.len() {
|
while idx < data.len() {
|
||||||
let (field, value, next) = next_field(data, idx)?;
|
let (field, value, next) = next_field(data, idx)?;
|
||||||
@ -499,6 +567,9 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
|||||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||||
|
// User.public_key (field 8): the peer's Curve25519 key. Its presence
|
||||||
|
// means the radio can PKC-encrypt DMs to this node end-to-end.
|
||||||
|
(8, FieldValue::Bytes(b)) if !b.is_empty() => user.public_key = Some(b.to_vec()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -509,18 +580,28 @@ struct ParsedPacket {
|
|||||||
from: Option<u32>,
|
from: Option<u32>,
|
||||||
portnum: u32,
|
portnum: u32,
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
|
/// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet
|
||||||
|
/// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted
|
||||||
|
/// rather than via the shared channel PSK.
|
||||||
|
pki_encrypted: bool,
|
||||||
|
/// MeshPacket.public_key (field 16): the sender's key, carried on PKC DMs.
|
||||||
|
public_key: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
let mut from = None;
|
let mut from = None;
|
||||||
let mut decoded = None;
|
let mut decoded = None;
|
||||||
|
let mut pki_encrypted = false;
|
||||||
|
let mut public_key = None;
|
||||||
while idx < data.len() {
|
while idx < data.len() {
|
||||||
let (field, value, next) = next_field(data, idx)?;
|
let (field, value, next) = next_field(data, idx)?;
|
||||||
idx = next;
|
idx = next;
|
||||||
match (field, value) {
|
match (field, value) {
|
||||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||||
|
(16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()),
|
||||||
|
(17, FieldValue::Varint(v)) => pki_encrypted = v != 0,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -541,6 +622,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
|||||||
from,
|
from,
|
||||||
portnum,
|
portnum,
|
||||||
payload,
|
payload,
|
||||||
|
pki_encrypted,
|
||||||
|
public_key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
264
tests/multinode/meshtastic.sh
Executable file
264
tests/multinode/meshtastic.sh
Executable file
@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tests/multinode/meshtastic.sh — two-/three-radio Meshtastic parity harness.
|
||||||
|
#
|
||||||
|
# Validates that Meshtastic radios have the SAME mesh-tab features Meshcore got,
|
||||||
|
# done over the real wire. It drives 2 (optionally 3) archipelago nodes, each
|
||||||
|
# with a Meshtastic radio attached, and exercises the full message pipeline:
|
||||||
|
#
|
||||||
|
# 1. detect — each node reports a connected meshtastic device
|
||||||
|
# 2. discover — A sees B as a peer (NodeInfo discovery), and vice-versa
|
||||||
|
# 3. dm — A → B direct message round-trips (native unicast)
|
||||||
|
# 4. privacy — a third listener C does NOT see the A→B DM (proves the
|
||||||
|
# directed-unicast fix: DMs are not broadcast on the channel)
|
||||||
|
# 5. channel — A's channel broadcast IS seen by both B and C
|
||||||
|
# 6. typed — a typed envelope (reaction) round-trips with message_type set
|
||||||
|
# 7. assistant — (optional) an !ai query gets a PRIVATE reply, not a channel
|
||||||
|
# blast (gated on ASSIST=1 + assistant enabled on B)
|
||||||
|
# 8. reachable — reports each peer's `reachable`/`last_advert` so the ambiguous
|
||||||
|
# Meshtastic reachability semantics can be eyeballed on-air
|
||||||
|
# before anyone "fixes" them
|
||||||
|
#
|
||||||
|
# The privacy test (4) is the on-air proof of the meshtastic.rs send_text_msg
|
||||||
|
# unicast change. Without it, A→B DMs land on every node's channel feed.
|
||||||
|
#
|
||||||
|
# Nodes override via env (each must have a Meshtastic radio on the SAME LoRa
|
||||||
|
# channel/region so they can actually hear each other):
|
||||||
|
# MA_URL MA_PW node A (sender) default .116 http / ThisIsWeb54321@
|
||||||
|
# MB_URL MB_PW node B (receiver) default .228 https / password123
|
||||||
|
# MC_URL MC_PW node C (eavesdrop) OPTIONAL — enables privacy test (4)
|
||||||
|
#
|
||||||
|
# MB_NAME B's mesh node name, if A's peer list is ambiguous (>1 peer)
|
||||||
|
# PROP_WAIT seconds to wait for LoRa propagation per step (default 45)
|
||||||
|
# ASSIST set =1 to run the assistant private-reply test (7)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# tests/multinode/meshtastic.sh
|
||||||
|
# MA_URL=http://192.168.1.116 MB_URL=https://192.168.1.228 \
|
||||||
|
# MC_URL=https://192.168.1.198 tests/multinode/meshtastic.sh
|
||||||
|
#
|
||||||
|
# Requires: curl, jq. Exit code = number of failed assertions (0 = all green).
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/multinode.bash
|
||||||
|
source "$HERE/lib/multinode.bash"
|
||||||
|
|
||||||
|
# ── node registration ──────────────────────────────────────────────────────
|
||||||
|
MA_URL="${MA_URL:-http://192.168.1.116}"; MA_PW="${MA_PW:-ThisIsWeb54321@}"
|
||||||
|
MB_URL="${MB_URL:-https://192.168.1.228}"; MB_PW="${MB_PW:-password123}"
|
||||||
|
MC_URL="${MC_URL:-}"; MC_PW="${MC_PW:-password123}"
|
||||||
|
PROP_WAIT="${PROP_WAIT:-45}"
|
||||||
|
MB_NAME="${MB_NAME:-}"
|
||||||
|
ASSIST="${ASSIST:-0}"
|
||||||
|
|
||||||
|
node_register A "$MA_URL" "$MA_PW"
|
||||||
|
node_register B "$MB_URL" "$MB_PW"
|
||||||
|
HAVE_C=0
|
||||||
|
if [[ -n "$MC_URL" ]]; then node_register C "$MC_URL" "$MC_PW"; HAVE_C=1; fi
|
||||||
|
|
||||||
|
# ── tiny assert framework (mirrors smoke.sh) ───────────────────────────────
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
green() { printf '\033[32m%s\033[0m' "$*"; }
|
||||||
|
red() { printf '\033[31m%s\033[0m' "$*"; }
|
||||||
|
yellow() { printf '\033[33m%s\033[0m' "$*"; }
|
||||||
|
else
|
||||||
|
green() { printf '%s' "$*"; }; red() { printf '%s' "$*"; }; yellow() { printf '%s' "$*"; }
|
||||||
|
fi
|
||||||
|
PASS=0; FAIL=0; SKIP=0; declare -a FAILED_NAMES
|
||||||
|
ok() { printf ' %s %s\n' "$(green ✓)" "$1"; PASS=$((PASS+1)); }
|
||||||
|
no() { printf ' %s %s\n' "$(red ✗)" "$1"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); }
|
||||||
|
skip() { printf ' %s %s (%s)\n' "$(yellow —)" "$1" "${2:-skipped}"; SKIP=$((SKIP+1)); }
|
||||||
|
assert_true() { [[ "$2" == "true" ]] && ok "$1" || no "$1 (got '$2')"; }
|
||||||
|
section() { printf '\n%s\n' "$(yellow "── $* ──")"; }
|
||||||
|
|
||||||
|
# nonce for this run so message matches can't collide with stale history
|
||||||
|
NONCE="mtparity-$$-${RANDOM}"
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# mesh_connected HANDLE -> "true" if a meshtastic device is connected
|
||||||
|
mesh_connected() {
|
||||||
|
local s; s=$(node_result "$1" mesh.status 2>/dev/null) || { echo false; return; }
|
||||||
|
local conn type
|
||||||
|
conn=$(echo "$s" | jq -r '.device_connected // false')
|
||||||
|
type=$(echo "$s" | jq -r '.device_type // "unknown"')
|
||||||
|
[[ "$conn" == "true" && "$type" == "meshtastic" ]] && echo true || echo false
|
||||||
|
}
|
||||||
|
|
||||||
|
# self_name HANDLE -> this node's meshtastic long-name (from firmware_version)
|
||||||
|
self_name() {
|
||||||
|
node_result "$1" mesh.status 2>/dev/null | jq -r '.firmware_version // empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
# contact_id_for HANDLE NAME -> the contact_id of the peer whose advert_name
|
||||||
|
# matches NAME (case-insensitive substring); empty if not found / ambiguous.
|
||||||
|
contact_id_for() {
|
||||||
|
local h="$1" want="$2"
|
||||||
|
node_result "$h" mesh.peers 2>/dev/null | jq -r --arg w "$want" '
|
||||||
|
[.peers[] | select((.advert_name // "" | ascii_downcase)
|
||||||
|
| contains($w | ascii_downcase))] as $m
|
||||||
|
| if ($m|length)==1 then ($m[0].contact_id|tostring) else "" end'
|
||||||
|
}
|
||||||
|
|
||||||
|
# peer_count_excl_self HANDLE -> number of peers
|
||||||
|
peer_count() { node_result "$1" mesh.peers 2>/dev/null | jq -r '.count // 0'; }
|
||||||
|
|
||||||
|
# saw_text HANDLE NEEDLE [direction] -> "true" if a message whose plaintext
|
||||||
|
# contains NEEDLE exists (optionally filtered to a direction: sent/received)
|
||||||
|
saw_text() {
|
||||||
|
local h="$1" needle="$2" dir="${3:-}"
|
||||||
|
node_result "$h" mesh.messages '{"limit":200}' 2>/dev/null | jq -r --arg n "$needle" --arg d "$dir" '
|
||||||
|
[.messages[] | select((.plaintext // "") | contains($n))
|
||||||
|
| select($d=="" or (.direction==$d))] | length > 0'
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_text HANDLE NEEDLE — poll up to PROP_WAIT for a received message
|
||||||
|
wait_text() {
|
||||||
|
local h="$1" needle="$2" waited=0
|
||||||
|
while (( waited < PROP_WAIT )); do
|
||||||
|
[[ "$(saw_text "$h" "$needle" received)" == "true" ]] && return 0
|
||||||
|
sleep 3; waited=$((waited+3))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── login ──────────────────────────────────────────────────────────────────
|
||||||
|
section "login"
|
||||||
|
node_login A && ok "A login ($MA_URL)" || { no "A unreachable ($MA_URL)"; echo; exit 1; }
|
||||||
|
node_login B && ok "B login ($MB_URL)" || { no "B unreachable ($MB_URL)"; echo; exit 1; }
|
||||||
|
if (( HAVE_C )); then
|
||||||
|
node_login C && ok "C login ($MC_URL)" || { skip "C login" "unreachable — privacy test disabled"; HAVE_C=0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 1. detect ──────────────────────────────────────────────────────────────
|
||||||
|
section "1. device detection"
|
||||||
|
A_CONN=$(mesh_connected A); B_CONN=$(mesh_connected B)
|
||||||
|
assert_true "A has a connected meshtastic radio" "$A_CONN"
|
||||||
|
assert_true "B has a connected meshtastic radio" "$B_CONN"
|
||||||
|
if [[ "$A_CONN" != "true" || "$B_CONN" != "true" ]]; then
|
||||||
|
printf '\n%s\n' "$(yellow 'Both A and B need a Meshtastic radio attached & mesh enabled.')"
|
||||||
|
printf '%s\n' "$(yellow 'Aborting on-air tests; see mesh.status output above.')"
|
||||||
|
echo; printf 'PASS=%d FAIL=%d SKIP=%d\n' "$PASS" "$FAIL" "$SKIP"; exit "$FAIL"
|
||||||
|
fi
|
||||||
|
A_NAME=$(self_name A); B_NAME=$(self_name B)
|
||||||
|
printf ' A=%s B=%s\n' "${A_NAME:-?}" "${B_NAME:-?}"
|
||||||
|
[[ -n "$MB_NAME" ]] && B_NAME="$MB_NAME"
|
||||||
|
|
||||||
|
# ── 2. peer discovery ──────────────────────────────────────────────────────
|
||||||
|
section "2. peer discovery (NodeInfo)"
|
||||||
|
DISCO=0; waited=0
|
||||||
|
while (( waited < PROP_WAIT )); do
|
||||||
|
CID=$(contact_id_for A "${B_NAME:-Meshtastic}")
|
||||||
|
[[ -n "$CID" ]] && { DISCO=1; break; }
|
||||||
|
# fall back: any single non-channel peer
|
||||||
|
if [[ -z "$MB_NAME" && "$(peer_count A)" == "1" ]]; then
|
||||||
|
CID=$(node_result A mesh.peers | jq -r '.peers[0].contact_id'); DISCO=1; break
|
||||||
|
fi
|
||||||
|
sleep 3; waited=$((waited+3))
|
||||||
|
done
|
||||||
|
if (( DISCO )); then ok "A discovered B as a peer (contact_id=$CID)"
|
||||||
|
else
|
||||||
|
no "A did not discover B within ${PROP_WAIT}s"
|
||||||
|
printf ' A peers: %s\n' "$(node_result A mesh.peers | jq -c '.peers[]? | {contact_id,advert_name}')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. direct message round-trip ───────────────────────────────────────────
|
||||||
|
section "3. direct message (native unicast)"
|
||||||
|
if (( DISCO )); then
|
||||||
|
DM="$NONCE-dm hello-from-A"
|
||||||
|
if node_result A mesh.send "$(jq -nc --argjson c "$CID" --arg m "$DM" '{contact_id:$c,message:$m}')" >/dev/null; then
|
||||||
|
ok "A sent DM to B (contact_id=$CID)"
|
||||||
|
if wait_text B "$NONCE-dm"; then ok "B received the DM"
|
||||||
|
else no "B did not receive the DM within ${PROP_WAIT}s"; fi
|
||||||
|
else no "mesh.send failed on A"; fi
|
||||||
|
else skip "DM round-trip" "B not discovered"; fi
|
||||||
|
|
||||||
|
# ── 4. privacy: third node must NOT see the DM ─────────────────────────────
|
||||||
|
section "4. DM privacy (directed, not broadcast)"
|
||||||
|
if (( HAVE_C )) && (( DISCO )); then
|
||||||
|
C_CONN=$(mesh_connected C)
|
||||||
|
if [[ "$C_CONN" != "true" ]]; then
|
||||||
|
skip "DM privacy" "C has no meshtastic radio"
|
||||||
|
else
|
||||||
|
# Give C the same window the DM had to propagate, then assert absence.
|
||||||
|
sleep "$PROP_WAIT"
|
||||||
|
if [[ "$(saw_text C "$NONCE-dm")" == "true" ]]; then
|
||||||
|
no "C (eavesdropper) saw the A→B DM — it is being BROADCAST, not unicast"
|
||||||
|
else
|
||||||
|
ok "C did NOT see the A→B DM (directed unicast confirmed)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "DM privacy" "needs MC_URL (third radio) + discovered peer"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. channel broadcast reaches everyone ──────────────────────────────────
|
||||||
|
section "5. channel broadcast"
|
||||||
|
CH="$NONCE-chan broadcast-to-all"
|
||||||
|
if node_result A mesh.send-channel "$(jq -nc --arg m "$CH" '{channel:0,message:$m}')" >/dev/null; then
|
||||||
|
ok "A sent a channel broadcast"
|
||||||
|
if wait_text B "$NONCE-chan"; then ok "B received the broadcast"; else no "B missed the broadcast"; fi
|
||||||
|
if (( HAVE_C )) && [[ "$(mesh_connected C)" == "true" ]]; then
|
||||||
|
if [[ "$(saw_text C "$NONCE-chan")" == "true" ]]; then ok "C also received the broadcast"
|
||||||
|
else no "C missed the broadcast (it should reach all channel members)"; fi
|
||||||
|
fi
|
||||||
|
else no "mesh.send-channel failed on A"; fi
|
||||||
|
|
||||||
|
# ── 6. typed envelope round-trip ───────────────────────────────────────────
|
||||||
|
section "6. typed message (reaction envelope)"
|
||||||
|
if (( DISCO )); then
|
||||||
|
# A reaction is the smallest typed envelope; it should arrive with a
|
||||||
|
# non-"text" message_type, proving the typed pipeline works over Meshtastic.
|
||||||
|
REACT_PARAMS=$(jq -nc --argjson c "$CID" --arg n "$NONCE" \
|
||||||
|
'{contact_id:$c, emoji:"👍", target_seq:0, note:$n}')
|
||||||
|
if node_result A mesh.send-reaction "$REACT_PARAMS" >/dev/null 2>&1; then
|
||||||
|
ok "A sent a reaction (typed envelope)"
|
||||||
|
sleep "$PROP_WAIT"
|
||||||
|
TYPED=$(node_result B mesh.messages '{"limit":200}' 2>/dev/null \
|
||||||
|
| jq -r '[.messages[] | select(.message_type != null and .message_type != "text")] | length > 0')
|
||||||
|
assert_true "B received a non-text typed message" "$TYPED"
|
||||||
|
else
|
||||||
|
skip "typed message" "mesh.send-reaction rejected params (check handler signature)"
|
||||||
|
fi
|
||||||
|
else skip "typed message" "B not discovered"; fi
|
||||||
|
|
||||||
|
# ── 7. assistant private reply (optional) ──────────────────────────────────
|
||||||
|
section "7. AI assistant private reply (optional)"
|
||||||
|
if [[ "$ASSIST" == "1" ]] && (( DISCO )); then
|
||||||
|
AST=$(node_result B mesh.assistant-status 2>/dev/null | jq -r '.enabled // false')
|
||||||
|
if [[ "$AST" != "true" ]]; then
|
||||||
|
skip "assistant reply" "assistant not enabled on B"
|
||||||
|
else
|
||||||
|
Q="$NONCE-ai !ai are you there"
|
||||||
|
node_result A mesh.send-channel "$(jq -nc --arg m "$Q" '{channel:0,message:$m}')" >/dev/null
|
||||||
|
sleep "$PROP_WAIT"
|
||||||
|
# A should get a private DM reply; C (if present) should NOT.
|
||||||
|
if [[ "$(saw_text A "$NONCE-ai-reply")" == "true" || "$(node_result A mesh.messages '{"limit":50}' | jq -r '[.messages[]|select(.direction=="received")]|length>0')" == "true" ]]; then
|
||||||
|
ok "A received an assistant reply"
|
||||||
|
else
|
||||||
|
no "A did not receive an assistant reply within ${PROP_WAIT}s"
|
||||||
|
fi
|
||||||
|
if (( HAVE_C )) && [[ "$(mesh_connected C)" == "true" ]]; then
|
||||||
|
# heuristic: the reply text shouldn't be on C's channel feed
|
||||||
|
skip "assistant reply privacy" "eyeball C's feed — automated check is heuristic"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "assistant reply" "set ASSIST=1 and enable the assistant on B to run"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 8. reachability snapshot (report-only) ─────────────────────────────────
|
||||||
|
section "8. reachability snapshot (report-only)"
|
||||||
|
node_result A mesh.peers 2>/dev/null | jq -r '.peers[]?
|
||||||
|
| " \(.advert_name // "?") reachable=\(.reachable) last_advert=\(.last_advert // 0)"'
|
||||||
|
printf '%s\n' "$(yellow ' NOTE: Meshtastic flood-routes; path_len is always 0xff, so `reachable`')"
|
||||||
|
printf '%s\n' "$(yellow ' may read true even for stale nodes. Confirm desired semantics here')"
|
||||||
|
printf '%s\n' "$(yellow ' before changing the refresh_contacts reachability rule.')"
|
||||||
|
|
||||||
|
# ── summary ────────────────────────────────────────────────────────────────
|
||||||
|
section "summary"
|
||||||
|
printf 'PASS=%s FAIL=%s SKIP=%s\n' "$(green "$PASS")" "$( ((FAIL)) && red "$FAIL" || green 0 )" "$(yellow "$SKIP")"
|
||||||
|
if (( FAIL )); then
|
||||||
|
printf 'failed:\n'; for n in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$n"; done
|
||||||
|
fi
|
||||||
|
exit "$FAIL"
|
||||||
Loading…
x
Reference in New Issue
Block a user