feat(chat): show the region of a channel message and shorten its route line

The meta line under an incoming channel message used to print the first
route inline ("Route (3): 142C→1122→271C→EE6C"), which with 2- or 3-byte
hashes and a few hops crowded everything else off a phone screen. It now
names only the per-hop hash size ("Route (3): 2 bytes") and stays the tap
target for the routes popup, which lists every route as before.

The line also ends with "Region: <name>" when the packet was flood-scoped
to a region this instance knows. The region name never travels: a
TRANSPORT_FLOOD packet carries a 16-bit transport code (HMAC of the scope
key over the payload) that _on_rx_log_data used to discard. Echoes now
keep those 4 header bytes in a new echoes.transport_codes column, and the
API recomputes the code for every configured region (Settings → Channels)
to find the one that reproduces it; own messages fall back to their
raw_packet snapshot. calc_transport_code() is shared with the raw-resend
packet builder, so both sides derive the stamp from one place.

Verified against on-air packets from the test server on #mcwebui: pl,
pl-ma and pl-kra each resolved to exactly one configured region.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
MarekWoandClaude Fable 5.1 committed 2026-09-07 16:58:01 +02:00
1 parent b1f1992bd8
commit 0c94bf0ee5
13 files changed
+366 -97

No files matched your search

+10 -3
View File
@@ -70,6 +70,11 @@ class Database:
conn.execute("ALTER TABLE echoes ADD COLUMN hash_size INTEGER NOT NULL DEFAULT 1")
logger.info("Migration: added echoes.hash_size column")
# Add transport_codes column to echoes (the packet's region-scope stamp)
if 'transport_codes' not in echo_columns:
conn.execute("ALTER TABLE echoes ADD COLUMN transport_codes TEXT")
logger.info("Migration: added echoes.transport_codes column")
# Add is_favorite column to read_status (channel favorites)
rs_columns = {r[1] for r in conn.execute("PRAGMA table_info(read_status)").fetchall()}
if 'is_favorite' not in rs_columns:
@@ -1209,14 +1214,16 @@ class Database:
def insert_echo(self, pkt_payload: str, **kwargs) -> None:
with self._connect() as conn:
conn.execute(
"""INSERT INTO echoes (pkt_payload, path, snr, direction, cm_id, hash_size)
VALUES (?, ?, ?, ?, ?, ?)""",
"""INSERT INTO echoes (pkt_payload, path, snr, direction, cm_id, hash_size,
transport_codes)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(pkt_payload,
kwargs.get('path'),
kwargs.get('snr'),
kwargs.get('direction', 'incoming'),
kwargs.get('cm_id'),
kwargs.get('hash_size', 1))
kwargs.get('hash_size', 1),
kwargs.get('transport_codes'))
)
def get_echoes_for_message(self, pkt_payload: str) -> List[Dict]:
+49 -20
View File
@@ -18,6 +18,8 @@ from concurrent.futures import TimeoutError as FuturesTimeoutError
from typing import Optional, Any, Dict, List, Tuple
from urllib.parse import urlparse, parse_qs
from app.meshcore.regions import PAYLOAD_TYPE_GRP_TXT, calc_transport_code
from Crypto.Cipher import AES
GRP_TXT_TYPE_BYTE = 0x05
@@ -101,17 +103,18 @@ def _compute_pkt_payload(channel_secret_hex, sender_timestamp, txt_type, text, a
# header(1) [transport_codes(4) if TRANSPORT_FLOOD] path_len(1) payload(N)
# Packet hash (SHA256 over payload_type||payload) depends only on payload, so
# resending identical bytes lets repeaters dedupe via Mesh::hasSeen.
_PAYLOAD_TYPE_GRP_TXT = 0x05
_PAYLOAD_TYPE_GRP_TXT = PAYLOAD_TYPE_GRP_TXT
_ROUTE_TYPE_FLOOD = 0x01
_ROUTE_TYPE_TRANSPORT_FLOOD = 0x00
_ROUTE_TYPE_TRANSPORT_DIRECT = 0x03
def _build_grp_txt_raw_packet(pkt_payload_hex, scope_key_hex=None, path_hash_size=1):
"""Build raw wire bytes for a GRP_TXT packet, suitable for CMD_SEND_RAW_PACKET.
Replicates the firmware's TransportKey::calcTransportCode when a region
scope key is provided (HMAC-SHA256 over payload_type||payload, first 2
bytes, with 0x0000 and 0xFFFF reserved per TransportKeyStore.cpp).
With a region scope key the packet goes out as TRANSPORT_FLOOD, stamped
with the transport code the firmware would compute for that key (see
calc_transport_code in app.meshcore.regions).
Returns hex string for storage in channel_messages.raw_packet, or None if
pkt_payload_hex is missing.
@@ -126,19 +129,34 @@ def _build_grp_txt_raw_packet(pkt_payload_hex, scope_key_hex=None, path_hash_siz
out = bytes([header])
if use_transport:
mac_input = bytes([_PAYLOAD_TYPE_GRP_TXT]) + payload
digest = hmac_mod.new(bytes.fromhex(scope_key_hex), mac_input, hashlib.sha256).digest()
code = digest[:2]
if code == b'\x00\x00':
code = b'\x01\x00'
elif code == b'\xff\xff':
code = b'\xfe\xff'
code = calc_transport_code(bytes.fromhex(scope_key_hex), _PAYLOAD_TYPE_GRP_TXT, payload)
out += code + b'\x00\x00' # transport_codes[1] is always 0 (set by sendFloodScoped)
out += bytes([path_len_byte])
out += payload
return out.hex()
def transport_codes_from_raw_packet(raw_packet_hex) -> Optional[str]:
"""The 4 transport-code bytes (8 hex chars) of a raw packet snapshot.
None when the packet carries none (plain FLOOD / DIRECT) or cannot be
parsed. Lets an own message resolve its region without waiting for an
echo: the snapshot taken at send time already holds the stamp its scope
key produced.
"""
if not raw_packet_hex:
return None
try:
raw = raw_packet_hex if isinstance(raw_packet_hex, bytes) else bytes.fromhex(raw_packet_hex)
except ValueError:
return None
if len(raw) < 5:
return None
if (raw[0] & 0x03) not in (_ROUTE_TYPE_TRANSPORT_FLOOD, _ROUTE_TYPE_TRANSPORT_DIRECT):
return None
return raw[1:5].hex()
def _payload_from_raw_packet(raw_packet_hex) -> Optional[str]:
"""Recover pkt_payload from a raw packet snapshot — inverse of
_build_grp_txt_raw_packet.
@@ -155,7 +173,7 @@ def _payload_from_raw_packet(raw_packet_hex) -> Optional[str]:
raw = raw_packet_hex if isinstance(raw_packet_hex, bytes) else bytes.fromhex(raw_packet_hex)
try:
route_type = raw[0] & 0x03
off = 1 + (4 if route_type in (_ROUTE_TYPE_TRANSPORT_FLOOD, 0x03) else 0)
off = 1 + (4 if route_type in (_ROUTE_TYPE_TRANSPORT_FLOOD, _ROUTE_TYPE_TRANSPORT_DIRECT) else 0)
_, _, path_byte_len = decode_path_len(raw[off])
payload = raw[off + 1 + path_byte_len:]
except IndexError:
@@ -1347,9 +1365,14 @@ class DeviceManager:
route_type = header & 0x03
payload_type = (header & 0x3C) >> 2
# Skip transport code for route_type 0 (flood) and 3
if route_type == 0x00 or route_type == 0x03:
pbuf.read(4) # discard transport code
# TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3) carry 4 bytes of
# transport codes: the sender's region-scope stamp, then a reply
# code the companion firmware leaves at zero. Kept with the echo so
# the message can be attributed to a region later (the name itself
# is not in the packet — see api._resolve_message_region).
transport_codes = None
if route_type in (_ROUTE_TYPE_TRANSPORT_FLOOD, _ROUTE_TYPE_TRANSPORT_DIRECT):
transport_codes = pbuf.read(4).hex()
path_len_raw = pbuf.read(1)[0]
hop_count, hash_size, path_byte_len = decode_path_len(path_len_raw)
@@ -1357,14 +1380,15 @@ class DeviceManager:
pkt_payload = pbuf.read().hex()
# Only process GRP_TXT channel message echoes
if payload_type != 0x05:
if payload_type != _PAYLOAD_TYPE_GRP_TXT:
return
if not pkt_payload:
return
snr = data.get('snr')
self._process_echo(pkt_payload, path, snr, hash_size=hash_size)
self._process_echo(pkt_payload, path, snr, hash_size=hash_size,
transport_codes=transport_codes)
except Exception as e:
logger.error(f"Error handling RX_LOG_DATA: {e}")
@@ -1442,11 +1466,13 @@ class DeviceManager:
del self._pending_echoes[:-self._MAX_PENDING_ECHOES]
def _process_echo(self, pkt_payload: str, path: str, snr: float = None,
hash_size: int = 1):
hash_size: int = 1, transport_codes: str = None):
"""Classify and store an echo: sent echo or incoming echo.
For sent messages: correlate with a pending send to get pkt_payload.
For incoming: store as echo keyed by pkt_payload for route display.
`transport_codes` is the packet's 4-byte region-scope stamp (hex), or
None for an unscoped packet; stored as-is for region attribution.
"""
with self._echo_lock:
current_time = time.time()
@@ -1521,7 +1547,8 @@ class DeviceManager:
if diag_on:
diag.record('echo', direction=direction, path=path, snr=snr,
hash_size=hash_size, pkt=pkt_payload,
hash_size=hash_size, codes=transport_codes,
pkt=pkt_payload,
match=match_stage,
msg_id=matched['msg_id'] if matched else None,
pending=pending_snapshot)
@@ -1533,9 +1560,11 @@ class DeviceManager:
snr=snr,
direction=direction,
hash_size=hash_size,
transport_codes=transport_codes,
)
logger.debug(f"Echo ({direction}): path={path} snr={snr} hash_size={hash_size} pkt={pkt_payload[:16]}...")
logger.debug(f"Echo ({direction}): path={path} snr={snr} hash_size={hash_size} "
f"codes={transport_codes} pkt={pkt_payload[:16]}...")
# Carry msg_id when the echo was correlated to a sent message —
# the UI uses it to force-refresh that specific badge, bypassing
+56 -1
View File
@@ -11,7 +11,8 @@ Firmware references:
"""
import hashlib
from typing import Tuple
import hmac
from typing import Optional, Tuple
MAX_NAME_LEN = 30 # firmware NodePrefs.default_scope_name[31] = 30 chars + NUL
@@ -51,3 +52,57 @@ def derive_scope_key(name: str) -> bytes:
def derive_scope_key_hex(name: str) -> str:
"""Hex-encoded variant of derive_scope_key()."""
return derive_scope_key(name).hex()
# Transport code — the 16-bit region stamp carried in the header of
# ROUTE_TYPE_TRANSPORT_FLOOD / _DIRECT packets (TransportKey::calcTransportCode
# in MeshCore Core/src/helpers/TransportKeyStore.cpp). The region *name* never
# travels: a receiver can only recognise a region whose key it already holds,
# by recomputing the code and comparing.
PAYLOAD_TYPE_GRP_TXT = 0x05
def calc_transport_code(key: bytes, payload_type: int, payload: bytes) -> bytes:
"""First 2 bytes of HMAC-SHA256(key, payload_type || payload), in wire order.
0x0000 and 0xFFFF are reserved: the firmware bumps them to 0x0001 / 0xFFFE
as a little-endian uint16, hence the byte order of the substitutes.
"""
digest = hmac.new(key, bytes([payload_type & 0x0F]) + payload, hashlib.sha256).digest()
code = digest[:2]
if code == b'\x00\x00':
return b'\x01\x00'
if code == b'\xff\xff':
return b'\xfe\xff'
return code
def match_region_by_transport_code(transport_codes_hex: str, payload_type: int,
payload: bytes, regions) -> Optional[str]:
"""Name of the region whose key reproduces the packet's transport code.
`transport_codes_hex` is the 4-byte header field (8 hex chars); only the
first code — the scope the sender stamped — is compared. `regions` is any
iterable of {'name', 'key_hex'} dicts, normally the instance's own region
table. Returns None when nothing matches: the packet was scoped to a region
this instance does not know, or to a private ($name) region whose key is
not derivable from its name. A 16-bit code leaves a 1-in-65536 chance per
known region of a false match, which is fine for a display hint.
"""
if not transport_codes_hex or len(transport_codes_hex) < 4:
return None
try:
code = bytes.fromhex(transport_codes_hex[:4])
except ValueError:
return None
for region in regions:
key_hex = region.get('key_hex')
if not key_hex:
continue
try:
key = bytes.fromhex(key_hex)
except ValueError:
continue
if calc_transport_code(key, payload_type, payload) == code:
return region.get('name')
return None
+48 -9
View File
@@ -19,9 +19,14 @@ from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, current_app
from app import i18n
from app.meshcore import cli, parser
from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name
from app.meshcore.regions import (
PAYLOAD_TYPE_GRP_TXT,
derive_scope_key_hex,
is_valid_region_name,
match_region_by_transport_code,
)
from app.config import config, runtime_config
from app.device_manager import decode_path_len
from app.device_manager import decode_path_len, transport_codes_from_raw_packet
from app.archiver import manager as archive_manager
from app.contacts_cache import get_all_names, get_all_contacts
from app.geo import sanitize_latlon, scrub_geo
@@ -609,13 +614,20 @@ def get_messages():
# prohibitively slow on connection-per-call over bind mounts)
payloads = [m['pkt_payload'] for m in messages if m.get('pkt_payload')]
echoes_by_payload = db.get_echoes_for_payloads(payloads)
for msg in messages:
echoes = echoes_by_payload.get(msg.get('pkt_payload'))
regions = db.list_regions()
# messages and db_messages are still aligned 1:1 here (blocked
# senders are filtered out below); the row is needed for the
# raw_packet fallback of own messages.
for msg, row in zip(messages, db_messages):
echoes = echoes_by_payload.get(msg.get('pkt_payload')) or []
if echoes:
msg['echo_count'] = len(echoes)
msg['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
msg['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
region = _resolve_message_region(row, msg.get('pkt_payload'), echoes, regions)
if region:
msg['region'] = region
# Filter out blocked contacts' messages
blocked_names = db.get_blocked_contact_names()
@@ -744,11 +756,33 @@ def get_path_analyzer_messages():
return jsonify({'success': False, 'error': str(e)}), 500
def _build_message_meta(row: dict, pkt_payload, echoes: list) -> dict:
"""Assemble the meta payload (SNR, hops, route, analyzer hash) for one row.
def _resolve_message_region(row: dict, pkt_payload, echoes: list, regions):
"""Name of the region this packet was flood-scoped to, or None.
Pure: takes the already-resolved pkt_payload and its echoes so callers can
batch the DB work."""
The packet carries only a 16-bit transport code, never the name, so this
recomputes the code for every region the instance knows (Settings →
Channels) and picks the one that reproduces it. The code comes from the
first echo that recorded one; an own message falls back to the raw_packet
snapshot taken at send time, so it resolves before any echo comes back.
Unscoped packets, and scoped ones from a region not configured here,
yield None.
"""
if not regions or not pkt_payload:
return None
codes = next((e.get('transport_codes') for e in echoes if e.get('transport_codes')), None)
if not codes:
codes = transport_codes_from_raw_packet(row.get('raw_packet'))
if not codes:
return None
return match_region_by_transport_code(codes, PAYLOAD_TYPE_GRP_TXT,
bytes.fromhex(pkt_payload), regions)
def _build_message_meta(row: dict, pkt_payload, echoes: list, regions=None) -> dict:
"""Assemble the meta payload (SNR, hops, route, region, analyzer hash) for one row.
Pure: takes the already-resolved pkt_payload, its echoes and the region
table so callers can batch the DB work."""
path_len_raw = row.get('path_len')
hop_count = None
path_hash_size = 1
@@ -771,6 +805,9 @@ def _build_message_meta(row: dict, pkt_payload, echoes: list) -> dict:
meta['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
meta['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
meta['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
region = _resolve_message_region(row, pkt_payload, echoes, regions)
if region:
meta['region'] = region
return meta
@@ -810,12 +847,14 @@ def get_messages_meta_batch():
echoes_by_payload = db.get_echoes_for_payloads(
list({p for p in payload_by_id.values() if p})
)
regions = db.list_regions()
metas = {
str(mid): _build_message_meta(
row,
payload_by_id[mid],
echoes_by_payload.get(payload_by_id[mid], []),
regions,
)
for mid, row in rows.items()
}
@@ -840,7 +879,7 @@ def get_message_meta(msg_id):
pkt_payload = _get_row_pkt_payload(row, _build_channel_secrets(db))
echoes = db.get_echoes_for_message(pkt_payload) if pkt_payload else []
return jsonify(_build_message_meta(row, pkt_payload, echoes))
return jsonify(_build_message_meta(row, pkt_payload, echoes, db.list_regions()))
except Exception as e:
logger.error(f"Error fetching message meta: {e}")
+1
View File
@@ -149,6 +149,7 @@ CREATE TABLE IF NOT EXISTS echoes (
direction TEXT DEFAULT 'incoming', -- 'sent' or 'incoming'
cm_id INTEGER, -- FK to channel_messages (nullable)
hash_size INTEGER NOT NULL DEFAULT 1, -- bytes per hop hash: 1, 2, or 3
transport_codes TEXT, -- 4 header bytes (8 hex) of a TRANSPORT_FLOOD/_DIRECT packet: the sender's region-scope stamp; NULL when unscoped
FOREIGN KEY (cm_id) REFERENCES channel_messages(id) ON DELETE SET NULL
);
+38 -62
View File
@@ -1499,24 +1499,48 @@ async function refreshMessagesMeta(forceIds = []) {
}
}
/**
* Build the "SNR | Hops | Route | Region" line shown under an incoming
* channel message. `src` is a /api/messages row or a /api/messages/meta
* entry (same field names); `paths` is the echo-derived paths array.
*
* The route part names only the per-hop hash size ("2 bytes"): the routes
* themselves live in the popup, since a 3-byte, 5-hop route no longer fits
* next to SNR and hops on a phone. Region shows only when the packet was
* flood-scoped to a region this instance knows (Settings \u2192 Channels).
*/
function buildMessageMetaInfo(src, paths) {
const metaParts = [];
// Use message SNR, or fall back to first echo path SNR
const displaySnr = (src.snr !== undefined && src.snr !== null) ? src.snr
: (src.echo_snrs && src.echo_snrs.length > 0) ? src.echo_snrs[0] : null;
if (displaySnr !== null) {
metaParts.push(`SNR: ${displaySnr.toFixed(1)} dB`);
}
const hopCount = src.hop_count ?? (src.path_len !== null && src.path_len !== undefined ? (src.path_len & 0x3F) : null);
if (hopCount !== null) {
metaParts.push(tHtml('chat.route_hops', { count: hopCount }));
}
if (paths && paths.length > 0) {
const sizeLabel = tn('chat.route_hash_bytes', paths[0].hash_size || 1);
const pathsData = encodeURIComponent(JSON.stringify(paths));
const routeText = paths.length > 1
? tHtml('chat.route_multi', { count: paths.length, route: sizeLabel })
: tHtml('chat.route', { route: sizeLabel });
metaParts.push(`<span class="path-info" title="${tHtml('chat.route_hash_title')}" onclick="showPathsPopup(this, '${pathsData}', '${src.packet_hash || ''}')">${routeText}</span>`);
}
if (src.region) {
metaParts.push(tHtml('chat.route_region', { name: src.region }));
}
return metaParts.join(' | ');
}
/**
* Update metadata and action buttons in-place for a single message wrapper.
*/
function updateMessageMetaDOM(wrapper, meta) {
const isOwn = wrapper.classList.contains('own');
// Build meta info string
let metaParts = [];
const displaySnr = (meta.snr !== undefined && meta.snr !== null) ? meta.snr
: (meta.echo_snrs && meta.echo_snrs.length > 0) ? meta.echo_snrs[0] : null;
if (displaySnr !== null) {
metaParts.push(`SNR: ${displaySnr.toFixed(1)} dB`);
}
const hopCount = meta.hop_count ?? (meta.path_len !== null && meta.path_len !== undefined ? (meta.path_len & 0x3F) : null);
if (hopCount !== null) {
metaParts.push(tHtml('chat.route_hops', { count: hopCount }));
}
// Build paths from echo data
let paths = null;
if (meta.echo_paths && meta.echo_paths.length > 0) {
@@ -1526,25 +1550,7 @@ function updateMessageMetaDOM(wrapper, meta) {
hash_size: meta.echo_hash_sizes ? meta.echo_hash_sizes[i] : (meta.path_hash_size || 1),
}));
}
if (paths && paths.length > 0) {
const firstPath = paths[0];
const chunkLen = (firstPath.hash_size || 1) * 2;
const segments = [];
if (firstPath.path) {
for (let i = 0; i < firstPath.path.length; i += chunkLen) {
segments.push(firstPath.path.substring(i, i + chunkLen).toUpperCase());
}
}
const shortPath = segments.length > 4
? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`
: segments.join('\u2192');
const pathsData = encodeURIComponent(JSON.stringify(paths));
const routeText = paths.length > 1
? tHtml('chat.route_multi', { count: paths.length, route: shortPath })
: tHtml('chat.route', { route: shortPath });
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}', '${meta.packet_hash || ''}')">${routeText}</span>`);
}
const metaInfo = metaParts.join(' | ');
const metaInfo = buildMessageMetaInfo(meta, paths);
if (!isOwn) {
// Update or insert .message-meta div
@@ -1659,37 +1665,7 @@ function createMessageElement(msg) {
}));
}
let metaParts = [];
// Use message SNR, or fall back to first echo path SNR
const displaySnr = (msg.snr !== undefined && msg.snr !== null) ? msg.snr
: (msg.echo_snrs && msg.echo_snrs.length > 0) ? msg.echo_snrs[0] : null;
if (displaySnr !== null) {
metaParts.push(`SNR: ${displaySnr.toFixed(1)} dB`);
}
const msgHopCount = msg.hop_count ?? (msg.path_len !== null && msg.path_len !== undefined ? (msg.path_len & 0x3F) : null);
if (msgHopCount !== null) {
metaParts.push(tHtml('chat.route_hops', { count: msgHopCount }));
}
if (msg.paths && msg.paths.length > 0) {
// Show first path inline (shortest/first arrival)
const firstPath = msg.paths[0];
const chunkLen = (firstPath.hash_size || 1) * 2;
const segments = [];
if (firstPath.path) {
for (let i = 0; i < firstPath.path.length; i += chunkLen) {
segments.push(firstPath.path.substring(i, i + chunkLen).toUpperCase());
}
}
const shortPath = segments.length > 4
? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`
: segments.join('\u2192');
const pathsData = encodeURIComponent(JSON.stringify(msg.paths));
const routeText = msg.paths.length > 1
? tHtml('chat.route_multi', { count: msg.paths.length, route: shortPath })
: tHtml('chat.route', { route: shortPath });
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}', '${msg.packet_hash || ''}')">${routeText}</span>`);
}
const metaInfo = metaParts.join(' | ');
const metaInfo = buildMessageMetaInfo(msg, msg.paths);
if (msg.is_own) {
// Own messages: right-aligned, no avatar
+6
View File
@@ -148,8 +148,14 @@
"chat.region_title": "Click to change region for this channel",
"chat.route": "Route: {route}",
"chat.route_analyzer_title": "Show this route on the Path Analyzer map",
"chat.route_hash_bytes": {
"one": "{count} byte",
"other": "{count} bytes"
},
"chat.route_hash_title": "Per-hop hash size of the route. Tap to list every route this message was heard on",
"chat.route_hops": "Hops: {count}",
"chat.route_multi": "Route ({count}): {route}",
"chat.route_region": "Region: {name}",
"chat.scroll_bottom_title": "Scroll to bottom",
"chat.title": "Chat",
"chat.toast.load_error": "Error loading messages: {error}",
+8
View File
@@ -150,8 +150,16 @@
"chat.region_title": "Kliknij, aby zmienić region dla tego kanału",
"chat.route": "Trasa: {route}",
"chat.route_analyzer_title": "Pokaż tę trasę na mapie Analizatora ścieżek",
"chat.route_hash_bytes": {
"one": "{count} bajt",
"few": "{count} bajty",
"many": "{count} bajtów",
"other": "{count} bajtu"
},
"chat.route_hash_title": "Rozmiar hasha na hop w trasie. Dotknij, aby zobaczyć wszystkie trasy, którymi dotarła ta wiadomość",
"chat.route_hops": "Hopy: {count}",
"chat.route_multi": "Trasa ({count}): {route}",
"chat.route_region": "Region: {name}",
"chat.scroll_bottom_title": "Przewiń na dół",
"chat.title": "Czat",
"chat.toast.load_error": "Błąd wczytywania wiadomości: {error}",
+3 -1
View File
@@ -219,7 +219,7 @@ Key tables:
- `messages` - All channel and direct messages (with FTS5 index for full-text search)
- `contacts` - Contact list with sync status, types, block/ignore flags, `no_auto_flood` flag
- `channels` - Channel configuration and keys
- `echoes` - Sent message tracking and repeater paths, `hash_size` for path_hash_mode
- `echoes` - Sent message tracking and repeater paths, `hash_size` for path_hash_mode, `transport_codes` (the packet's region-scope stamp, NULL when unscoped)
- `direct_messages` - DM messages with delivery tracking (`delivery_status`, `delivery_attempt`, `delivery_max_attempts`, `delivery_path`)
- `acks` - DM delivery status
- `settings` - Application settings (migrated from .webui_settings.json)
@@ -233,6 +233,8 @@ Key tables:
`direct_messages` gained a `delivery_path_hash_size` column (auto-migrated, defaults to 1) so reloaded DM bubbles render multi-byte routes correctly. The `path_len` column on `channel_messages`, `direct_messages`, and `paths` now stores the raw firmware byte (masked hop count plus path_hash_mode in the upper bits), recombined at write time via `pack_path_len()`; the API endpoints decode it back into `path_hash_size` on read. `channel_messages` also gained a `raw_packet` column (the full hex wire snapshot captured at send time, indexed by `idx_cm_pkt` on `pkt_payload` for fast self-echo lookups) that powers raw resend; it is `NULL` for received and pre-migration rows, so the resend button stays disabled there.
`echoes` gained a `transport_codes` column (auto-migrated, `NULL` for pre-migration rows): the 4 header bytes a `TRANSPORT_FLOOD` / `TRANSPORT_DIRECT` packet carries after the header byte, which `_on_rx_log_data` used to discard. The first two are the sender's region-scope stamp — `TransportKey::calcTransportCode`, the first 2 bytes of `HMAC-SHA256(scope_key, payload_type || payload)` with `0x0000`/`0xFFFF` bumped to `0x0001`/`0xFFFE` — and the second pair is a reply code the companion firmware leaves at zero. The region *name* never travels, so `_resolve_message_region()` in `api.py` recomputes the code for every row of the `regions` table (`calc_transport_code()` / `match_region_by_transport_code()` in `app/meshcore/regions.py`, shared with the raw-resend packet builder) and reports the one that reproduces it as `region` on `/api/messages` and `/api/messages/meta`. The code is read from the first echo that recorded one; own rows fall back to the `raw_packet` snapshot, so they resolve before any echo. A message stamped with a region the instance has not configured, or a private `$name` region whose key is not derivable, simply gets no `region`.
The use of SQLite allows for fast queries, reliable data storage, full-text search, and complex filtering (such as contact ignoring/blocking) without the risk of file corruption inherent to flat JSON files.
### Retention scheduler
+3 -1
View File
@@ -213,13 +213,15 @@ Your own messages carry a small row of action buttons:
### Group Chat Message Routes
When your node overheard how a channel message travelled, a **Route** line appears under the message (e.g. `Route (3): 92→AD→40→D1`). Tap it to open a popup listing every route the message arrived by, each with its SNR and hop count:
When your node overheard how a channel message travelled, a **Route** line appears under the message, saying how many routes were heard and how wide their per-hop hashes are (e.g. `Route (3): 2 bytes`). Tap it to open a popup listing every route the message arrived by, each with its SNR and hop count:
- Tap a route to open the **[Path Analyzer](#path-analyzer)** on the Map view, with that message selected and that exact route drawn — the quickest way to see where a message physically travelled.
- Each route also has a small clipboard icon to copy it in comma-separated form (e.g. `92,AD,40,D1`) for use in the console `change_path` command.
(In direct messages the same popup copies the route on tap, since only channel messages feed the Path Analyzer.)
If the sender stamped the message with a **[region](#region-scopes)** — a flood scope such as `pl` that only repeaters allowing it pass on — the line ends with **Region: pl**. The packet carries only a code derived from the region's key, never the name, so this works for the regions you have added under **Settings → Channels**; a message from any other region, or an unstamped one, shows no region at all.
---
## Message Content Features
+4
View File
@@ -10,6 +10,10 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
## Unreleased
### Features
- **The line under an incoming channel message now names the region it was sent in, and no longer spells out the route.** A MeshCore sender can stamp a packet with a *region* — a flood scope such as `pl` that only repeaters allowing it pass on (see the previous release). The stamp is not the name but a 16-bit code derived from the region's key, so the interface can recognise it only for regions you have added under **Settings → Channels**: a message stamped with one of them now ends its `SNR | Hops | Route` line with **Region: pl**, while an unstamped message, or one from a region you have not configured, shows nothing extra. The route part changed too. It used to print the first route inline (`Route (3): 142C→1122→271C→EE6C`), which with 2- or 3-byte hashes and a few hops crowded everything else off a phone screen. It now only says how wide the per-hop hashes are — `Route (3): 2 bytes` — and tapping it opens the same popup as before, listing every route the message arrived by, each one openable in the Path Analyzer or copied to the clipboard. The region stamp is kept from this version on, so messages received before the update have no region to show.
---
## 2.17.0 — 2026-09-07
+68
View File
@@ -13,12 +13,17 @@ import pytest
pytest.importorskip('meshcore', reason='app.device_manager needs the meshcore lib')
import asyncio # noqa: E402
from types import SimpleNamespace # noqa: E402
from app.device_manager import ( # noqa: E402
DeviceManager,
_build_grp_txt_raw_packet,
_compute_pkt_payload,
_payload_from_raw_packet,
transport_codes_from_raw_packet,
)
from app.meshcore.regions import PAYLOAD_TYPE_GRP_TXT, calc_transport_code # noqa: E402
SECRET = '5c' * 32
SCOPE_KEY = 'a7' * 32
@@ -237,3 +242,66 @@ class TestPayloadFromRawPacket:
assert dm.db.payloads == {71179: payload}
assert dm.db.echoes[-1]['direction'] == 'sent'
# ================================================================
# Transport codes — the packet's region stamp, kept with the echo
# ================================================================
class TestTransportCodesFromRawPacket:
def test_scoped_packet_yields_its_codes(self):
payload = payload_for(1000, 'MarWoj: region-scoped')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=SCOPE_KEY, path_hash_size=2)
codes = transport_codes_from_raw_packet(raw)
assert codes == raw[2:10]
expected = calc_transport_code(bytes.fromhex(SCOPE_KEY), PAYLOAD_TYPE_GRP_TXT,
bytes.fromhex(payload)).hex()
assert codes == expected + '0000' # transport_codes[1] is always 0
def test_unscoped_packet_has_none(self):
payload = payload_for(1000, 'MarWoj: plain flood')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=None, path_hash_size=1)
assert transport_codes_from_raw_packet(raw) is None
@pytest.mark.parametrize('bad', [None, '', '14', '14aabb', 'zz'])
def test_returns_none_when_unparsable(self, bad):
assert transport_codes_from_raw_packet(bad) is None
class TestEchoKeepsTransportCodes:
def test_stored_on_the_echo_row(self, dm):
payload = payload_for(1000, 'MarWoj: stamped')
dm._process_echo(payload, path='d103df', hash_size=3, transport_codes='1a2b0000')
assert dm.db.echoes[-1]['transport_codes'] == '1a2b0000'
def test_unscoped_echo_stores_none(self, dm):
payload = payload_for(1000, 'MarWoj: unstamped')
dm._process_echo(payload, path='d103df', hash_size=3)
assert dm.db.echoes[-1]['transport_codes'] is None
def test_rx_log_frame_codes_reach_the_echo(self, dm):
"""The RX-log frame is the packet as heard on air: header, 4 bytes of
transport codes, path_len, the path a repeater appended, payload."""
payload = payload_for(1000, 'MarWoj: over the air')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=SCOPE_KEY, path_hash_size=1)
# one 1-byte hop (0xAB) added by the repeater that relayed it to us
heard = raw[:10] + '01' + 'ab' + raw[12:]
event = SimpleNamespace(payload={'payload': heard, 'snr': 7.5, 'rssi': -90})
asyncio.run(dm._on_rx_log_data(event))
echo = dm.db.echoes[-1]
assert echo['pkt_payload'] == payload
assert echo['path'] == 'ab'
assert echo['hash_size'] == 1
assert echo['transport_codes'] == raw[2:10]
assert echo['snr'] == 7.5
def test_rx_log_unscoped_frame_has_no_codes(self, dm):
payload = payload_for(1000, 'MarWoj: plain on air')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=None, path_hash_size=1)
event = SimpleNamespace(payload={'payload': raw, 'snr': 1.0, 'rssi': -100})
asyncio.run(dm._on_rx_log_data(event))
assert dm.db.echoes[-1]['transport_codes'] is None
+72
View File
@@ -13,9 +13,12 @@ import pytest
from app.database import Database
from app.meshcore.regions import (
MAX_NAME_LEN,
PAYLOAD_TYPE_GRP_TXT,
calc_transport_code,
derive_scope_key,
derive_scope_key_hex,
is_valid_region_name,
match_region_by_transport_code,
)
@@ -244,3 +247,72 @@ class TestSchema:
).fetchall()}
assert 'regions' in tables
assert 'channel_scopes' in tables
# ================================================================
# Transport code — the region stamp in a packet header
# ================================================================
class TestTransportCode:
KEY = derive_scope_key('pl')
def test_known_vector(self):
# HMAC-SHA256(key('pl'), 0x05 || b'hello')[:2], computed offline
assert calc_transport_code(self.KEY, PAYLOAD_TYPE_GRP_TXT, b'hello') == bytes.fromhex('9414')
def test_payload_type_is_part_of_the_mac(self):
assert (calc_transport_code(self.KEY, PAYLOAD_TYPE_GRP_TXT, b'hello')
!= calc_transport_code(self.KEY, 0x04, b'hello'))
def test_key_is_part_of_the_mac(self):
assert (calc_transport_code(self.KEY, PAYLOAD_TYPE_GRP_TXT, b'hello')
!= calc_transport_code(derive_scope_key('pl-ma'), PAYLOAD_TYPE_GRP_TXT, b'hello'))
# Payloads whose raw HMAC prefix lands on a reserved code (found by search).
# The firmware bumps 0x0000 to 0x0001 and 0xFFFF to 0xFFFE, little-endian.
def test_reserved_zero_becomes_one(self):
assert calc_transport_code(self.KEY, PAYLOAD_TYPE_GRP_TXT, bytes.fromhex('ca620100')) == b'\x01\x00'
def test_reserved_ffff_becomes_fffe(self):
assert calc_transport_code(self.KEY, PAYLOAD_TYPE_GRP_TXT, bytes.fromhex('2d560100')) == b'\xfe\xff'
class TestMatchRegionByTransportCode:
REGIONS = [
{'name': 'pl', 'key_hex': derive_scope_key_hex('pl')},
{'name': 'pl-ma', 'key_hex': derive_scope_key_hex('pl-ma')},
]
PAYLOAD = bytes.fromhex('5c' * 24)
def stamp(self, name):
"""The header field as a receiver sees it: scope code, then the zero reply code."""
return calc_transport_code(derive_scope_key(name), PAYLOAD_TYPE_GRP_TXT, self.PAYLOAD).hex() + '0000'
def test_finds_the_stamping_region(self):
assert match_region_by_transport_code(self.stamp('pl-ma'), PAYLOAD_TYPE_GRP_TXT,
self.PAYLOAD, self.REGIONS) == 'pl-ma'
assert match_region_by_transport_code(self.stamp('pl'), PAYLOAD_TYPE_GRP_TXT,
self.PAYLOAD, self.REGIONS) == 'pl'
def test_unknown_region_is_none(self):
assert match_region_by_transport_code(self.stamp('de'), PAYLOAD_TYPE_GRP_TXT,
self.PAYLOAD, self.REGIONS) is None
def test_same_key_different_payload_does_not_match(self):
other = bytes.fromhex('a1' * 24)
assert match_region_by_transport_code(self.stamp('pl'), PAYLOAD_TYPE_GRP_TXT,
other, self.REGIONS) is None
def test_no_regions_is_none(self):
assert match_region_by_transport_code(self.stamp('pl'), PAYLOAD_TYPE_GRP_TXT,
self.PAYLOAD, []) is None
@pytest.mark.parametrize('codes', [None, '', 'ab', 'zzzz0000'])
def test_garbage_codes_are_none(self, codes):
assert match_region_by_transport_code(codes, PAYLOAD_TYPE_GRP_TXT,
self.PAYLOAD, self.REGIONS) is None
def test_tolerates_a_region_without_a_key(self):
regions = [{'name': 'broken', 'key_hex': None}] + self.REGIONS
assert match_region_by_transport_code(self.stamp('pl'), PAYLOAD_TYPE_GRP_TXT,
self.PAYLOAD, regions) == 'pl'