Files
mc-webui/app/device_manager.py
MarekWo 7dbbba57b9 feat(dm): add real-time retry status and persistent delivery info
Show retry progress in DM message bubble via WebSocket:
- "attempt X/Y" counter updates in real-time during retries
- Failed icon (✗) when all retries exhausted
- Delivery info persisted in DB (attempt number, path used)

Backend: emit dm_retry_status/dm_retry_failed socket events,
store delivery_attempt/delivery_path in direct_messages table.
Frontend: socket listeners update status icon and counter,
delivered tooltip shows attempt info and path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:25:35 +01:00

2666 lines
116 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
DeviceManager — manages MeshCore device connection for mc-webui v2.
Runs the meshcore async event loop in a dedicated background thread.
Flask routes call sync command methods that bridge to the async loop.
Event handlers capture incoming data and write to Database + emit SocketIO.
"""
import asyncio
import hashlib
import json
import logging
import threading
import time
from typing import Optional, Any, Dict, List, Tuple
from urllib.parse import urlparse, parse_qs
ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash='
GRP_TXT_TYPE_BYTE = 0x05
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
def _to_str(val) -> str:
"""Convert bytes or other types to string. Used for expected_ack, pkt_payload, etc."""
if val is None:
return ''
if isinstance(val, bytes):
return val.hex()
return str(val)
def parse_meshcore_uri(uri: str) -> Optional[Dict]:
"""Parse meshcore://contact/add?name=...&public_key=...&type=... URI.
Returns dict with 'name', 'public_key', 'type' keys, or None if not a valid mobile-app URI.
"""
if not uri or not uri.startswith('meshcore://'):
return None
try:
# urlparse needs a scheme it recognizes; meshcore:// works fine
parsed = urlparse(uri)
if parsed.netloc != 'contact' or parsed.path != '/add':
return None
params = parse_qs(parsed.query)
public_key = params.get('public_key', [None])[0]
name = params.get('name', [None])[0]
if not public_key or not name:
return None
# Validate public_key: 64 hex characters
public_key = public_key.strip().lower()
if len(public_key) != 64:
return None
bytes.fromhex(public_key) # validate hex
contact_type = int(params.get('type', ['1'])[0])
if contact_type not in (1, 2, 3, 4):
contact_type = 1
return {
'name': name.strip(),
'public_key': public_key,
'type': contact_type,
}
except (ValueError, IndexError, KeyError):
return None
class DeviceManager:
"""
Manages MeshCore device connection.
Usage:
dm = DeviceManager(config, db, socketio)
dm.start() # spawns background thread, connects to device
...
dm.stop() # disconnect and stop background thread
"""
def __init__(self, config, db, socketio=None):
self.config = config
self.db = db
self.socketio = socketio
self.mc = None # meshcore.MeshCore instance
self._loop = None # asyncio event loop (in background thread)
self._thread = None # background thread
self._connected = False
self._device_name = None
self._self_info = None
self._subscriptions = [] # active event subscriptions
self._channel_secrets = {} # {channel_idx: secret_hex} for pkt_payload
self._max_channels = 8 # updated from device_info at connect
self._pending_echo = None # {'timestamp': float, 'channel_idx': int, 'msg_id': int, 'pkt_payload': str|None}
self._echo_lock = threading.Lock()
self._pending_acks = {} # {ack_code_hex: dm_id} — maps retry acks to DM
self._retry_tasks = {} # {dm_id: asyncio.Task} — active retry coroutines
@property
def is_connected(self) -> bool:
return self._connected and self.mc is not None
@property
def device_name(self) -> str:
return self._device_name or self.config.MC_DEVICE_NAME
@property
def self_info(self) -> Optional[dict]:
return self._self_info
# ================================================================
# Lifecycle
# ================================================================
def start(self):
"""Start the device manager background thread and connect."""
if self._thread and self._thread.is_alive():
logger.warning("DeviceManager already running")
return
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(
target=self._run_loop, daemon=True, name="device-manager"
)
self._thread.start()
logger.info("DeviceManager background thread started")
def _run_loop(self):
"""Run the async event loop in the background thread."""
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._connect_with_retry())
self._loop.run_forever()
async def _connect_with_retry(self, max_retries: int = 10, base_delay: float = 5.0):
"""Try to connect to device, retrying on failure."""
for attempt in range(1, max_retries + 1):
try:
await self._connect()
if self._connected:
return # success
except Exception as e:
logger.error(f"Connection attempt {attempt}/{max_retries} failed: {e}")
if attempt < max_retries:
delay = min(base_delay * attempt, 30.0)
logger.info(f"Retrying in {delay:.0f}s...")
await asyncio.sleep(delay)
logger.error(f"Failed to connect after {max_retries} attempts")
def _detect_serial_port(self) -> str:
"""Auto-detect serial port when configured as 'auto'."""
port = self.config.MC_SERIAL_PORT
if port.lower() != 'auto':
return port
from pathlib import Path
by_id = Path('/dev/serial/by-id')
if by_id.exists():
devices = list(by_id.iterdir())
if len(devices) == 1:
resolved = str(devices[0].resolve())
logger.info(f"Auto-detected serial port: {resolved}")
return resolved
elif len(devices) > 1:
logger.warning(f"Multiple serial devices found: {[d.name for d in devices]}")
else:
logger.warning("No serial devices found in /dev/serial/by-id")
# Fallback: try common paths
for candidate in ['/dev/ttyUSB0', '/dev/ttyACM0', '/dev/ttyUSB1', '/dev/ttyACM1']:
if Path(candidate).exists():
logger.info(f"Auto-detected serial port (fallback): {candidate}")
return candidate
raise RuntimeError("No serial port detected. Set MC_SERIAL_PORT explicitly.")
async def _connect(self):
"""Connect to device via serial or TCP and subscribe to events."""
from meshcore import MeshCore
try:
if self.config.use_tcp:
logger.info(f"Connecting via TCP: {self.config.MC_TCP_HOST}:{self.config.MC_TCP_PORT}")
self.mc = await MeshCore.create_tcp(
host=self.config.MC_TCP_HOST,
port=self.config.MC_TCP_PORT,
# Disable library auto-reconnect — it has a bug where old
# connection's close callback triggers infinite reconnect loop.
# We handle reconnection ourselves in _on_disconnected().
auto_reconnect=False,
)
else:
port = self._detect_serial_port()
logger.info(f"Connecting via serial: {port}")
self.mc = await MeshCore.create_serial(
port=port,
# Disable library auto-reconnect — same bug as TCP.
# We handle reconnection ourselves in _on_disconnected().
auto_reconnect=False,
)
# Read device info
self._self_info = getattr(self.mc, 'self_info', None)
if not self._self_info:
logger.error("Device connected but self_info is empty — device may not be responding")
self.mc = None
return
self._device_name = self._self_info.get('name', self.config.MC_DEVICE_NAME)
self._connected = True
# Store device info in database
self.db.set_device_info(
public_key=self._self_info.get('public_key', ''),
name=self._device_name,
self_info=json.dumps(self._self_info, default=str)
)
# Fetch device_info for max_channels
try:
dev_info_event = await self.mc.commands.send_device_query()
if dev_info_event and hasattr(dev_info_event, 'payload'):
dev_info = dev_info_event.payload or {}
self._max_channels = dev_info.get('max_channels', 8)
logger.info(f"Device max_channels: {self._max_channels}")
except Exception as e:
logger.warning(f"Could not fetch device_info: {e}")
# Workaround: meshcore lib 2.2.21 has a bug where list.extend()
# return value (None) corrupts reader.channels for idx >= 20.
# Pre-allocate the channels list to max_channels to avoid this.
reader = getattr(self.mc, '_reader', None)
if reader and hasattr(reader, 'channels'):
current = reader.channels or []
if len(current) < self._max_channels:
reader.channels = current + [{} for _ in range(self._max_channels - len(current))]
logger.debug(f"Pre-allocated reader.channels to {len(reader.channels)} slots")
logger.info(f"Connected to device: {self._device_name} "
f"(key: {self._self_info.get('public_key', '?')[:8]}...)")
# Subscribe to events
await self._subscribe_events()
# Enable auto-refresh of contacts on adverts/path updates
# Keep auto_update_contacts OFF to avoid serial blocking on every
# ADVERTISEMENT event (324 contacts = several seconds of serial I/O).
# We sync contacts at startup and handle NEW_CONTACT events individually.
self.mc.auto_update_contacts = False
# Fetch initial contacts from device
await self.mc.ensure_contacts()
self._sync_contacts_to_db()
# Cache channel secrets for pkt_payload computation
await self._load_channel_secrets()
# Start auto message fetching (events fire on new messages)
await self.mc.start_auto_message_fetching()
except Exception as e:
logger.error(f"Device connection failed: {e}")
self._connected = False
async def _load_channel_secrets(self):
"""Load channel secrets from device for pkt_payload computation and persist to DB."""
consecutive_empty = 0
try:
for idx in range(self._max_channels):
try:
event = await self.mc.commands.get_channel(idx)
except Exception:
consecutive_empty += 1
if consecutive_empty >= 3:
break # likely past last configured channel
continue
if event:
data = getattr(event, 'payload', None) or {}
secret = data.get('channel_secret', data.get('secret', b''))
if isinstance(secret, bytes):
secret = secret.hex()
name = data.get('channel_name', data.get('name', ''))
if isinstance(name, str):
name = name.strip('\x00').strip()
if secret and len(secret) == 32:
self._channel_secrets[idx] = secret
# Persist to DB so API endpoints can read without device calls
self.db.upsert_channel(idx, name or f'Channel {idx}', secret)
consecutive_empty = 0
elif name:
# Channel exists but has no secret (e.g. Public)
self.db.upsert_channel(idx, name, None)
consecutive_empty = 0
else:
consecutive_empty += 1
else:
consecutive_empty += 1
if consecutive_empty >= 3:
break # stop after 3 consecutive empty channels
logger.info(f"Cached {len(self._channel_secrets)} channel secrets")
except Exception as e:
logger.error(f"Failed to load channel secrets: {e}")
async def _subscribe_events(self):
"""Subscribe to all relevant device events."""
from meshcore.events import EventType
handlers = [
(EventType.CHANNEL_MSG_RECV, self._on_channel_message),
(EventType.CONTACT_MSG_RECV, self._on_dm_received),
(EventType.MSG_SENT, self._on_msg_sent),
(EventType.ACK, self._on_ack),
(EventType.ADVERTISEMENT, self._on_advertisement),
(EventType.PATH_UPDATE, self._on_path_update),
(EventType.NEW_CONTACT, self._on_new_contact),
(EventType.RX_LOG_DATA, self._on_rx_log_data),
(EventType.DISCONNECTED, self._on_disconnected),
]
for event_type, handler in handlers:
sub = self.mc.subscribe(event_type, handler)
self._subscriptions.append(sub)
logger.debug(f"Subscribed to {event_type.value}")
def _sync_contacts_to_db(self):
"""Sync device contacts to database (bidirectional).
- Upserts device contacts with source='device'
- Downgrades DB contacts marked 'device' that are no longer on device to 'advert'
"""
if not self.mc or not self.mc.contacts:
return
device_keys = set()
for pubkey, contact in self.mc.contacts.items():
# last_advert from meshcore is Unix timestamp (int) or None
last_adv = contact.get('last_advert')
last_advert_val = str(int(last_adv)) if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0 else None
self.db.upsert_contact(
public_key=pubkey,
name=contact.get('adv_name', ''),
type=contact.get('type', 0),
flags=contact.get('flags', 0),
out_path=contact.get('out_path', ''),
out_path_len=contact.get('out_path_len', 0),
last_advert=last_advert_val,
adv_lat=contact.get('adv_lat'),
adv_lon=contact.get('adv_lon'),
source='device',
)
device_keys.add(pubkey.lower())
# Downgrade stale 'device' contacts to 'advert' (cache-only)
stale = self.db.downgrade_stale_device_contacts(device_keys)
if stale:
logger.info(f"Downgraded {stale} stale device contacts to cache")
logger.info(f"Synced {len(device_keys)} contacts from device to database")
def execute(self, coro, timeout: float = 30) -> Any:
"""
Execute an async coroutine from sync Flask context.
Blocks until the coroutine completes and returns the result.
"""
if not self._loop or not self._loop.is_running():
raise RuntimeError("DeviceManager event loop not running")
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return future.result(timeout=timeout)
def stop(self):
"""Disconnect from device and stop the background thread."""
logger.info("Stopping DeviceManager...")
if self.mc and self._loop and self._loop.is_running():
try:
future = asyncio.run_coroutine_threadsafe(
self.mc.disconnect(), self._loop
)
future.result(timeout=5)
except Exception as e:
logger.warning(f"Error during disconnect: {e}")
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
if self._thread:
self._thread.join(timeout=5)
self._connected = False
self.mc = None
self._subscriptions.clear()
logger.info("DeviceManager stopped")
# ================================================================
# Event Handlers (async — run in device manager thread)
# ================================================================
async def _on_channel_message(self, event):
"""Handle incoming channel message."""
try:
data = getattr(event, 'payload', {})
ts = data.get('timestamp', int(time.time()))
raw_text = data.get('text', '')
channel_idx = data.get('channel_idx', 0)
# Parse sender from "SenderName: message" format
if ':' in raw_text:
sender, content = raw_text.split(':', 1)
sender = sender.strip()
content = content.strip()
else:
sender = 'Unknown'
content = raw_text
# Check if sender is blocked (store but don't emit)
blocked_names = self.db.get_blocked_contact_names()
is_blocked = sender in blocked_names
msg_id = self.db.insert_channel_message(
channel_idx=channel_idx,
sender=sender,
content=content,
timestamp=ts,
sender_timestamp=data.get('sender_timestamp'),
snr=data.get('SNR', data.get('snr')),
path_len=data.get('path_len'),
pkt_payload=data.get('pkt_payload'),
raw_json=json.dumps(data, default=str),
)
logger.info(f"Channel msg #{msg_id} from {sender} on ch{channel_idx}")
if is_blocked:
logger.debug(f"Blocked channel msg from {sender}, stored but not emitted")
return
if self.socketio:
snr = data.get('SNR', data.get('snr'))
path_len = data.get('path_len')
pkt_payload = data.get('pkt_payload')
# Compute analyzer URL from pkt_payload
analyzer_url = None
if pkt_payload:
try:
raw = bytes([GRP_TXT_TYPE_BYTE]) + bytes.fromhex(pkt_payload)
packet_hash = hashlib.sha256(raw).hexdigest()[:16].upper()
analyzer_url = f"{ANALYZER_BASE_URL}{packet_hash}"
except (ValueError, TypeError):
pass
self.socketio.emit('new_message', {
'type': 'channel',
'channel_idx': channel_idx,
'sender': sender,
'content': content,
'timestamp': ts,
'id': msg_id,
'snr': snr,
'path_len': path_len,
'pkt_payload': pkt_payload,
'analyzer_url': analyzer_url,
}, namespace='/chat')
logger.debug(f"SocketIO emitted new_message for ch{channel_idx} msg #{msg_id}")
except Exception as e:
logger.error(f"Error handling channel message: {e}")
async def _on_dm_received(self, event):
"""Handle incoming direct message."""
try:
data = getattr(event, 'payload', {})
ts = data.get('timestamp', int(time.time()))
content = data.get('text', '')
sender_key = data.get('public_key', data.get('pubkey_prefix', ''))
# Look up sender from contacts — resolve prefix to full public key
sender_name = ''
if sender_key and self.mc:
contact = self.mc.get_contact_by_key_prefix(sender_key)
if contact:
sender_name = contact.get('name', '')
full_key = contact.get('public_key', '')
if full_key:
sender_key = full_key
elif len(sender_key) < 64:
# Prefix not resolved from in-memory contacts — try DB
db_contact = self.db.get_contact_by_prefix(sender_key)
if db_contact and len(db_contact['public_key']) == 64:
sender_key = db_contact['public_key']
sender_name = db_contact.get('name', '')
# Receiver-side dedup: skip duplicate retries
sender_ts = data.get('sender_timestamp')
if sender_key and content:
if sender_ts:
existing = self.db.find_dm_duplicate(sender_key, content,
sender_timestamp=sender_ts)
else:
existing = self.db.find_dm_duplicate(sender_key, content,
window_seconds=300)
if existing:
logger.info(f"DM dedup: skipping retry from {sender_key[:8]}...")
return
# Check if sender is blocked
is_blocked = sender_key and self.db.is_contact_blocked(sender_key)
if sender_key:
# Only upsert with name if we have a real name (not just a prefix)
self.db.upsert_contact(
public_key=sender_key,
name=sender_name, # empty string won't overwrite existing name
source='message',
)
dm_id = self.db.insert_direct_message(
contact_pubkey=sender_key,
direction='in',
content=content,
timestamp=ts,
sender_timestamp=data.get('sender_timestamp'),
snr=data.get('SNR', data.get('snr')),
path_len=data.get('path_len'),
pkt_payload=data.get('pkt_payload'),
raw_json=json.dumps(data, default=str),
)
logger.info(f"DM #{dm_id} from {sender_name or sender_key[:12]}")
if is_blocked:
logger.debug(f"Blocked DM from {sender_key[:12]}, stored but not emitted")
return
if self.socketio:
self.socketio.emit('new_message', {
'type': 'dm',
'contact_pubkey': sender_key,
'sender': sender_name or sender_key[:12],
'content': content,
'timestamp': ts,
'id': dm_id,
}, namespace='/chat')
except Exception as e:
logger.error(f"Error handling DM: {e}")
async def _on_msg_sent(self, event):
"""Handle confirmation that our message was sent."""
try:
data = getattr(event, 'payload', {})
expected_ack = _to_str(data.get('expected_ack'))
msg_type = data.get('txt_type', 0)
# txt_type 0 = DM, 1 = channel
if msg_type == 0 and expected_ack:
# DM sent confirmation — store expected_ack for delivery tracking
logger.debug(f"DM sent, expected_ack={expected_ack}")
except Exception as e:
logger.error(f"Error handling msg_sent: {e}")
async def _on_ack(self, event):
"""Handle ACK (delivery confirmation for DM)."""
try:
data = getattr(event, 'payload', {})
# FIX: ACK event payload uses 'code', not 'expected_ack'
ack_code = _to_str(data.get('code', data.get('expected_ack')))
if not ack_code:
return
# Check if this ACK belongs to a pending DM retry
dm_id = self._pending_acks.get(ack_code)
# Only store if not already stored (retry task may have handled it)
existing = self.db.get_ack_for_dm(ack_code)
if existing:
return
self.db.insert_ack(
expected_ack=ack_code,
snr=data.get('snr'),
rssi=data.get('rssi'),
route_type=data.get('route_type', ''),
dm_id=dm_id,
)
logger.info(f"ACK received: {ack_code}" +
(f" (dm_id={dm_id})" if dm_id else ""))
if self.socketio:
# Emit the ORIGINAL expected_ack (from DB) so frontend can match
# the DOM element. Retry sends generate new ack codes, but the
# DOM still has the original expected_ack from the first send.
original_ack = ack_code
if dm_id:
dm = self.db.get_dm_by_id(dm_id)
if dm and dm.get('expected_ack'):
original_ack = dm['expected_ack']
self.socketio.emit('ack', {
'expected_ack': original_ack,
'dm_id': dm_id,
'snr': data.get('snr'),
'rssi': data.get('rssi'),
'route_type': data.get('route_type', ''),
}, namespace='/chat')
# Cancel retry task if ACK confirms delivery for a pending DM
if dm_id:
task = self._retry_tasks.get(dm_id)
if task and not task.done():
task.cancel()
logger.info(f"Cancelled retry task for dm_id={dm_id} (ACK received)")
# Cleanup all pending acks for this DM
stale = [k for k, v in self._pending_acks.items() if v == dm_id]
for k in stale:
self._pending_acks.pop(k, None)
self._retry_tasks.pop(dm_id, None)
except Exception as e:
logger.error(f"Error handling ACK: {e}")
async def _on_advertisement(self, event):
"""Handle received advertisement from another node.
ADVERTISEMENT payload only contains {'public_key': '...'}.
Full contact details (name, type, lat/lon) must be looked up
from mc.contacts which is synced at startup.
If the contact is unknown (new auto-add by firmware), refresh contacts.
"""
try:
data = getattr(event, 'payload', {})
pubkey = data.get('public_key', '')
if not pubkey:
return
# Look up full contact details from meshcore's contact list
contact = (self.mc.contacts or {}).get(pubkey, {})
name = contact.get('adv_name', contact.get('name', ''))
# Also check pending contacts (manual approval mode)
if not name:
pending = (self.mc.pending_contacts or {}).get(pubkey, {})
if pending:
name = pending.get('adv_name', pending.get('name', ''))
if not contact:
contact = pending
# If contact is still unknown, firmware may have just auto-added it.
if not name and pubkey not in (self.mc.contacts or {}):
logger.info(f"Unknown advert from {pubkey[:8]}..., refreshing contacts")
await self.mc.ensure_contacts(follow=True)
contact = (self.mc.contacts or {}).get(pubkey, {})
name = contact.get('adv_name', contact.get('name', ''))
adv_type = contact.get('type', data.get('adv_type', 0))
adv_lat = contact.get('adv_lat', data.get('adv_lat'))
adv_lon = contact.get('adv_lon', data.get('adv_lon'))
self.db.insert_advertisement(
public_key=pubkey,
name=name,
type=adv_type,
lat=adv_lat,
lon=adv_lon,
timestamp=int(time.time()),
snr=data.get('snr'),
)
# Upsert to contacts with last_advert timestamp
self.db.upsert_contact(
public_key=pubkey,
name=name,
type=adv_type,
adv_lat=adv_lat,
adv_lon=adv_lon,
last_advert=str(int(time.time())),
source='advert',
)
# If manual mode: add cache-only contacts to pending list
# (meshcore may fire ADVERTISEMENT instead of NEW_CONTACT for
# contacts already in mc.pending_contacts or after restart)
if (self._is_manual_approval_enabled()
and pubkey not in (self.mc.contacts or {})
and pubkey not in (self.mc.pending_contacts or {})
and not self.db.is_contact_ignored(pubkey)
and not self.db.is_contact_blocked(pubkey)):
# Add to pending_contacts so it shows in pending list
if self.mc.pending_contacts is None:
self.mc.pending_contacts = {}
self.mc.pending_contacts[pubkey] = {
'public_key': pubkey,
'adv_name': name,
'name': name,
'type': adv_type,
'adv_lat': adv_lat,
'adv_lon': adv_lon,
'last_advert': int(time.time()),
}
logger.info(f"Cache contact added to pending (advert): {name} ({pubkey[:8]}...)")
if self.socketio:
self.socketio.emit('pending_contact', {
'public_key': pubkey,
'name': name,
'type': adv_type,
}, namespace='/chat')
logger.info(f"Advert from '{name}' ({pubkey[:8]}...) type={adv_type}")
except Exception as e:
logger.error(f"Error handling advertisement: {e}")
async def _on_path_update(self, event):
"""Handle path update for a contact.
Also serves as backup delivery confirmation: when firmware sends
piggybacked ACK via flood, it fires both ACK and PATH_UPDATE events.
If the ACK event was missed, PATH_UPDATE can confirm delivery.
"""
try:
data = getattr(event, 'payload', {})
pubkey = data.get('public_key', '')
if not pubkey:
return
# Store path record (existing behavior)
self.db.insert_path(
contact_pubkey=pubkey,
path=data.get('path', ''),
snr=data.get('snr'),
path_len=data.get('path_len'),
)
logger.debug(f"Path update for {pubkey[:8]}...")
# Invalidate contacts cache so UI gets fresh path data
try:
from app.routes.api import invalidate_contacts_cache
invalidate_contacts_cache()
except ImportError:
pass
# Notify UI about path change
if self.socketio:
self.socketio.emit('path_changed', {
'public_key': pubkey,
}, namespace='/chat')
# Backup: check for pending DM to this contact
for ack_code, dm_id in list(self._pending_acks.items()):
dm = self.db.get_dm_by_id(dm_id)
if dm and dm.get('contact_pubkey') == pubkey and dm.get('direction') == 'out':
existing_ack = self.db.get_ack_for_dm(ack_code)
if not existing_ack:
self.db.insert_ack(
expected_ack=ack_code,
route_type='PATH_FLOOD',
dm_id=dm_id,
)
logger.info(f"PATH delivery confirmed for dm_id={dm_id}")
if self.socketio:
self.socketio.emit('ack', {
'expected_ack': ack_code,
'dm_id': dm_id,
'route_type': 'PATH_FLOOD',
}, namespace='/chat')
# Cancel retry task — delivery already confirmed
task = self._retry_tasks.get(dm_id)
if task and not task.done():
task.cancel()
logger.info(f"Cancelled retry task for dm_id={dm_id} (PATH confirmed)")
stale_acks = [k for k, v in self._pending_acks.items() if v == dm_id]
for k in stale_acks:
self._pending_acks.pop(k, None)
self._retry_tasks.pop(dm_id, None)
break # Only confirm the most recent pending DM to this contact
except Exception as e:
logger.error(f"Error handling path update: {e}")
async def _on_rx_log_data(self, event):
"""Handle RX_LOG_DATA — RF log containing echoed/repeated packets.
Firmware sends LOG_DATA (0x88) packets for every repeated radio frame.
Payload format: header(1) [transport_code(4)] path_len(1) path(N) pkt_payload(rest)
We only process GRP_TXT (payload_type=0x05) for channel message echoes.
"""
try:
import io
data = getattr(event, 'payload', {})
payload_hex = data.get('payload', '')
logger.debug(f"RX_LOG_DATA received: {len(payload_hex)//2} bytes, snr={data.get('snr')}")
if not payload_hex:
return
pkt = bytes.fromhex(payload_hex)
pbuf = io.BytesIO(pkt)
header = pbuf.read(1)[0]
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
path_len = pbuf.read(1)[0]
path = pbuf.read(path_len).hex()
pkt_payload = pbuf.read().hex()
# Only process GRP_TXT channel message echoes
if payload_type != 0x05:
return
if not pkt_payload:
return
snr = data.get('snr')
self._process_echo(pkt_payload, path, snr)
except Exception as e:
logger.error(f"Error handling RX_LOG_DATA: {e}")
def _get_channel_hash(self, channel_idx: int) -> str:
"""Get the expected channel hash byte (hex) for a channel index."""
import hashlib
secret_hex = self._channel_secrets.get(channel_idx)
if not secret_hex:
return None
return hashlib.sha256(bytes.fromhex(secret_hex)).digest()[0:1].hex()
def _process_echo(self, pkt_payload: str, path: str, snr: float = None):
"""Classify and store an echo: sent echo or incoming echo.
For sent messages: correlate with pending echo to get pkt_payload.
For incoming: store as echo keyed by pkt_payload for route display.
"""
with self._echo_lock:
current_time = time.time()
direction = 'incoming'
# Check if this matches a pending sent message
if self._pending_echo:
pe = self._pending_echo
age = current_time - pe['timestamp']
# Expire stale pending echo
if age > 60:
self._pending_echo = None
elif pe['pkt_payload'] is None:
# Validate channel hash before correlating — the first byte
# of pkt_payload is sha256(channel_secret)[0], must match
# the channel we sent on to avoid cross-channel mismatches
expected_hash = self._get_channel_hash(pe['channel_idx'])
echo_hash = pkt_payload[:2] if pkt_payload else None
if expected_hash and echo_hash and expected_hash == echo_hash:
# First echo after send — correlate pkt_payload with sent message
pe['pkt_payload'] = pkt_payload
direction = 'sent'
self.db.update_message_pkt_payload(pe['msg_id'], pkt_payload)
logger.info(f"Echo: correlated pkt_payload with sent msg #{pe['msg_id']}, path={path}")
elif expected_hash and echo_hash and expected_hash != echo_hash:
logger.debug(f"Echo: channel hash mismatch (expected {expected_hash}, got {echo_hash}) — not our sent msg")
elif pe['pkt_payload'] == pkt_payload:
# Additional echo for same sent message
direction = 'sent'
# Store echo in DB
self.db.insert_echo(
pkt_payload=pkt_payload,
path=path,
snr=snr,
direction=direction,
)
logger.debug(f"Echo ({direction}): path={path} snr={snr} pkt={pkt_payload[:16]}...")
# Emit SocketIO event for real-time UI update
if self.socketio:
self.socketio.emit('echo', {
'pkt_payload': pkt_payload,
'path': path,
'snr': snr,
'direction': direction,
}, namespace='/chat')
def _is_manual_approval_enabled(self) -> bool:
"""Check if manual contact approval is enabled (from database)."""
try:
return bool(self.db.get_setting_json('manual_add_contacts', False))
except Exception:
pass
return False
async def _on_new_contact(self, event):
"""Handle new contact discovered.
When manual approval is enabled, contacts go to pending list only.
When manual approval is off, contacts are auto-added to DB.
"""
try:
data = getattr(event, 'payload', {})
pubkey = data.get('public_key', '')
name = data.get('adv_name', data.get('name', ''))
if not pubkey:
return
# Ignored/blocked: still update cache but don't add to pending or device
if self.db.is_contact_ignored(pubkey) or self.db.is_contact_blocked(pubkey):
last_adv = data.get('last_advert')
last_advert_val = (
str(int(last_adv))
if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0
else str(int(time.time()))
)
self.db.upsert_contact(
public_key=pubkey,
name=name,
type=data.get('type', data.get('adv_type', 0)),
adv_lat=data.get('adv_lat'),
adv_lon=data.get('adv_lon'),
last_advert=last_advert_val,
source='advert',
)
logger.info(f"Ignored/blocked contact advert: {name} ({pubkey[:8]}...)")
return
if self._is_manual_approval_enabled():
# Check if contact already exists on the device (firmware edge case:
# firmware may fire NEW_CONTACT for a contact that was previously
# on the device but got removed by firmware-level cleanup)
if pubkey in (self.mc.contacts or {}):
logger.warning(
f"NEW_CONTACT fired for contact already on device: {name} ({pubkey[:8]}...) "
f"— skipping pending, updating DB cache only"
)
# Just update cache, don't add to pending
last_adv = data.get('last_advert')
last_advert_val = (
str(int(last_adv))
if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0
else str(int(time.time()))
)
self.db.upsert_contact(
public_key=pubkey,
name=name,
type=data.get('type', data.get('adv_type', 0)),
adv_lat=data.get('adv_lat'),
adv_lon=data.get('adv_lon'),
last_advert=last_advert_val,
source='device',
)
return
# Check if contact was previously known (in DB cache)
existing = self.db.get_contact(pubkey)
if existing:
logger.info(
f"Pending contact (manual mode): {name} ({pubkey[:8]}...) "
f"— previously known (source={existing['source']}, "
f"protected={existing['is_protected']})"
)
else:
logger.info(f"Pending contact (manual mode): {name} ({pubkey[:8]}...) — first time seen")
# Manual mode: meshcore puts it in mc.pending_contacts for approval
# Also add to DB cache for @mentions and Cache filter
last_adv = data.get('last_advert')
last_advert_val = (
str(int(last_adv))
if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0
else str(int(time.time()))
)
self.db.upsert_contact(
public_key=pubkey,
name=name,
type=data.get('type', data.get('adv_type', 0)),
adv_lat=data.get('adv_lat'),
adv_lon=data.get('adv_lon'),
last_advert=last_advert_val,
source='advert', # cache-only until approved
)
if self.socketio:
self.socketio.emit('pending_contact', {
'public_key': pubkey,
'name': name,
'type': data.get('type', data.get('adv_type', 0)),
}, namespace='/chat')
return
# Auto mode: add to DB immediately
last_adv = data.get('last_advert')
last_advert_val = (
str(int(last_adv))
if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0
else str(int(time.time()))
)
self.db.upsert_contact(
public_key=pubkey,
name=name,
type=data.get('type', data.get('adv_type', 0)),
adv_lat=data.get('adv_lat'),
adv_lon=data.get('adv_lon'),
last_advert=last_advert_val,
source='device',
)
logger.info(f"New contact (auto-add): {name} ({pubkey[:8]}...)")
except Exception as e:
logger.error(f"Error handling new contact: {e}")
async def _on_disconnected(self, event):
"""Handle device disconnection with auto-reconnect."""
logger.warning("Device disconnected")
self._connected = False
if self.socketio:
self.socketio.emit('device_status', {
'connected': False,
}, namespace='/chat')
# Auto-reconnect with backoff
for attempt in range(1, 4):
delay = 5 * attempt
logger.info(f"Reconnecting in {delay}s (attempt {attempt}/3)...")
await asyncio.sleep(delay)
try:
await self._connect()
if self._connected:
logger.info("Reconnected successfully")
if self.socketio:
self.socketio.emit('device_status', {
'connected': True,
}, namespace='/chat')
return
except Exception as e:
logger.error(f"Reconnect attempt {attempt} failed: {e}")
logger.error("Failed to reconnect after 3 attempts")
# ================================================================
# Command Methods (sync — called from Flask routes)
# ================================================================
def send_channel_message(self, channel_idx: int, text: str) -> Dict:
"""Send a message to a channel. Returns result dict."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
event = self.execute(self.mc.commands.send_chan_msg(channel_idx, text))
# Store the sent message in database
ts = int(time.time())
msg_id = self.db.insert_channel_message(
channel_idx=channel_idx,
sender=self.device_name,
content=text,
timestamp=ts,
is_own=True,
pkt_payload=getattr(event, 'data', {}).get('pkt_payload') if event else None,
)
# Register for echo correlation — first RX_LOG_DATA echo will
# provide the actual pkt_payload for this sent message
with self._echo_lock:
self._pending_echo = {
'timestamp': time.time(),
'channel_idx': channel_idx,
'msg_id': msg_id,
'pkt_payload': None,
}
# Emit SocketIO event so sender's UI updates immediately
if self.socketio:
self.socketio.emit('new_message', {
'type': 'channel',
'channel_idx': channel_idx,
'sender': self.device_name,
'content': text,
'timestamp': ts,
'is_own': True,
'id': msg_id,
}, namespace='/chat')
return {'success': True, 'message': 'Message sent', 'id': msg_id}
except Exception as e:
logger.error(f"Failed to send channel message: {e}")
return {'success': False, 'error': str(e)}
def send_dm(self, recipient_pubkey: str, text: str) -> Dict:
"""Send a direct message with background retry. Returns result dict."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
# Find contact in device's contact table
contact = self.mc.contacts.get(recipient_pubkey)
if not contact:
contact = self.mc.get_contact_by_key_prefix(recipient_pubkey)
if not contact:
# Contact must exist on device to send DM
return {'success': False,
'error': f'Contact not on device. '
f'Re-add {recipient_pubkey[:12]}... via Contacts page.'}
# Generate timestamp once — same for all retries (enables receiver dedup)
timestamp = int(time.time())
event = self.execute(
self.mc.commands.send_msg(contact, text,
timestamp=timestamp, attempt=0)
)
from meshcore.events import EventType
event_data = getattr(event, 'payload', {})
if event.type == EventType.ERROR:
err_detail = event_data.get('error', event_data.get('message', ''))
logger.warning(f"Device error sending DM to {recipient_pubkey[:12]}: "
f"payload={event_data}, contact_type={type(contact).__name__}")
return {'success': False, 'error': f'Device error sending DM: {err_detail}'}
ack = _to_str(event_data.get('expected_ack'))
suggested_timeout = event_data.get('suggested_timeout', 15000)
# Store sent DM in database (single record, not per-retry)
dm_id = self.db.insert_direct_message(
contact_pubkey=recipient_pubkey.lower(),
direction='out',
content=text,
timestamp=timestamp,
expected_ack=ack or None,
pkt_payload=_to_str(event_data.get('pkt_payload')) or None,
)
# Register ack → dm_id mapping for _on_ack handler
if ack:
self._pending_acks[ack] = dm_id
# Launch background retry task
task = asyncio.run_coroutine_threadsafe(
self._dm_retry_task(
dm_id, contact, text, timestamp,
ack, suggested_timeout
),
self._loop
)
self._retry_tasks[dm_id] = task
return {
'success': True,
'message': 'DM sent',
'id': dm_id,
'expected_ack': ack,
}
except Exception as e:
logger.error(f"Failed to send DM: {e}")
return {'success': False, 'error': str(e)}
async def _change_path_async(self, contact, path_hex: str, hash_size: int = 1):
"""Change contact path on device with proper hash_size encoding."""
path_hash_mode = hash_size - 1 # 0=1B, 1=2B, 2=3B
await self.mc.commands.change_contact_path(contact, path_hex, path_hash_mode=path_hash_mode)
# Invalidate contacts cache so UI gets fresh path data
try:
from app.routes.api import invalidate_contacts_cache
invalidate_contacts_cache()
except ImportError:
pass
async def _restore_primary_path(self, contact, contact_pubkey: str):
"""Restore the primary configured path on the device after retry exhaustion."""
try:
primary = self.db.get_primary_contact_path(contact_pubkey)
if primary:
await self._change_path_async(contact, primary['path_hex'], primary['hash_size'])
logger.info(f"Restored primary path for {contact_pubkey[:12]}")
else:
logger.debug(f"No primary path to restore for {contact_pubkey[:12]}")
except Exception as e:
logger.warning(f"Failed to restore primary path for {contact_pubkey[:12]}: {e}")
async def _dm_retry_send_and_wait(self, contact, text, timestamp, attempt,
dm_id, suggested_timeout, min_wait):
"""Send a DM retry attempt and wait for ACK. Returns True if delivered."""
from meshcore.events import EventType
logger.debug(f"DM retry attempt #{attempt}: sending dm_id={dm_id}")
try:
result = await self.mc.commands.send_msg(
contact, text, timestamp=timestamp, attempt=attempt
)
except Exception as e:
logger.warning(f"DM retry #{attempt}: send error: {e}")
return False
if result.type == EventType.ERROR:
logger.warning(f"DM retry #{attempt}: device error")
return False
retry_ack = _to_str(result.payload.get('expected_ack'))
if retry_ack:
self._pending_acks[retry_ack] = dm_id
new_timeout = result.payload.get('suggested_timeout', suggested_timeout)
wait_s = max(new_timeout / 1000 * 1.2, min_wait)
logger.debug(f"DM retry #{attempt}: waiting {wait_s:.0f}s for ACK {retry_ack[:8]}...")
ack_event = await self.mc.dispatcher.wait_for_event(
EventType.ACK,
attribute_filters={"code": retry_ack},
timeout=wait_s
)
if ack_event:
self._confirm_delivery(dm_id, retry_ack, ack_event)
return True
logger.debug(f"DM retry #{attempt}: no ACK received (timeout)")
return False
def _emit_retry_status(self, dm_id: int, expected_ack: str,
attempt: int, max_attempts: int):
"""Notify frontend about retry progress."""
if self.socketio:
self.socketio.emit('dm_retry_status', {
'dm_id': dm_id,
'expected_ack': expected_ack,
'attempt': attempt,
'max_attempts': max_attempts,
}, namespace='/chat')
def _emit_retry_failed(self, dm_id: int, expected_ack: str):
"""Notify frontend that all retry attempts were exhausted."""
if self.socketio:
self.socketio.emit('dm_retry_failed', {
'dm_id': dm_id,
'expected_ack': expected_ack,
}, namespace='/chat')
@staticmethod
def _paths_match(contact_out_path: str, contact_out_path_len: int,
configured_path: dict) -> bool:
"""Check if device's current path matches a configured path."""
if contact_out_path_len <= 0:
return False
cfg_hash_size = configured_path['hash_size']
device_hash_size = (contact_out_path_len >> 6) + 1
if device_hash_size != cfg_hash_size:
return False
hop_count = contact_out_path_len & 0x3F
meaningful_len = hop_count * device_hash_size * 2
return (contact_out_path.lower()[:meaningful_len] ==
configured_path['path_hex'].lower()[:meaningful_len])
async def _dm_retry_task(self, dm_id: int, contact, text: str,
timestamp: int, initial_ack: str,
suggested_timeout: int):
"""Background retry with same timestamp for dedup on receiver.
4-scenario matrix based on (has_path × has_configured_paths):
- Scenario 1: No path, no configured paths → FLOOD only
- Scenario 2: Has path, no configured paths → DIRECT + optional FLOOD
- Scenario 3: No path, has configured paths → FLOOD first, then ŚD rotation
- Scenario 4: Has path, has configured paths → DIRECT on ŚK, ŚD rotation, optional FLOOD
The no_auto_flood per-contact flag prevents automatic DIRECT→FLOOD reset
in Scenarios 2 and 4. Ignored in Scenarios 1 and 3.
Settings loaded from app_settings DB table (key: dm_retry_settings).
"""
from meshcore.events import EventType
# ── Load configurable retry settings from DB ──
_defaults = {
'direct_max_retries': 3, 'direct_flood_retries': 1,
'flood_max_retries': 3, 'direct_interval': 30,
'flood_interval': 60, 'grace_period': 60,
}
saved = self.db.get_setting_json('dm_retry_settings', {})
cfg = {**_defaults, **(saved or {})}
contact_pubkey = contact.get('public_key', '').lower()
has_path = contact.get('out_path_len', -1) > 0
# Capture original device path for dedup (contact dict may mutate)
original_out_path = contact.get('out_path', '').lower()
original_out_path_len = contact.get('out_path_len', -1)
# Load user-configured paths and no_auto_flood flag
configured_paths = self.db.get_contact_paths(contact_pubkey) if contact_pubkey else []
no_auto_flood = self.db.get_contact_no_auto_flood(contact_pubkey) if contact_pubkey else False
has_configured_paths = bool(configured_paths)
min_wait = float(cfg['direct_interval']) if has_path else float(cfg['flood_interval'])
wait_s = max(suggested_timeout / 1000 * 1.2, min_wait)
# Determine scenario for logging
if has_path and has_configured_paths:
scenario = "S4_DIRECT_SD_FLOOD"
elif has_path:
scenario = "S2_DIRECT_FLOOD"
elif has_configured_paths:
scenario = "S3_FLOOD_SD"
else:
scenario = "S1_FLOOD"
# ── Pre-compute path split and max_attempts ──
def _split_primary_and_others(paths):
primary = None
others = []
for p in paths:
if p.get('is_primary') and primary is None:
primary = p
else:
others.append(p)
return primary, others
primary_path = None
other_paths = []
rotation_order = []
if has_configured_paths:
primary_path, other_paths = _split_primary_and_others(configured_paths)
rotation_order = ([primary_path] if primary_path else []) + other_paths
retries_per_path = max(1, cfg['direct_max_retries'])
if scenario == "S1_FLOOD":
max_attempts = 1 + cfg['flood_max_retries']
elif scenario == "S2_DIRECT_FLOOD":
max_attempts = 1 + cfg['direct_max_retries']
if not no_auto_flood:
max_attempts += cfg['direct_flood_retries']
elif scenario == "S3_FLOOD_SD":
max_attempts = (1 + cfg['flood_max_retries']
+ len(rotation_order) * retries_per_path)
else: # S4
deduped = sum(1 for p in rotation_order
if self._paths_match(original_out_path, original_out_path_len, p))
effective_sd = len(rotation_order) - deduped
max_attempts = 1 + cfg['direct_max_retries'] + effective_sd * retries_per_path
if not no_auto_flood:
max_attempts += cfg['flood_max_retries']
# Track current path description for delivery info
path_desc = "FLOOD" if not has_path else "DIRECT"
logger.info(f"DM retry task started: dm_id={dm_id}, scenario={scenario}, "
f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, "
f"max_attempts={max_attempts}, wait={wait_s:.0f}s")
# ── Local helper: emit status, send, store delivery info on success ──
async def _retry(attempt_num, min_wait_s):
display = attempt_num + 1 # attempt 0 = initial send = display 1
self._emit_retry_status(dm_id, initial_ack, display, max_attempts)
delivered = await self._dm_retry_send_and_wait(
contact, text, timestamp, attempt_num, dm_id,
suggested_timeout, min_wait_s
)
if delivered:
self.db.update_dm_delivery_info(
dm_id, display, max_attempts, path_desc)
return delivered
# ── Emit status for initial send (attempt 1) and wait for ACK ──
self._emit_retry_status(dm_id, initial_ack, 1, max_attempts)
if initial_ack:
logger.debug(f"DM retry: waiting {wait_s:.0f}s for initial ACK {initial_ack[:8]}...")
ack_event = await self.mc.dispatcher.wait_for_event(
EventType.ACK,
attribute_filters={"code": initial_ack},
timeout=wait_s
)
if ack_event:
self._confirm_delivery(dm_id, initial_ack, ack_event)
self.db.update_dm_delivery_info(dm_id, 1, max_attempts, path_desc)
return
logger.debug(f"DM retry: initial ACK not received (timeout)")
attempt = 0 # Global attempt counter (0 = initial send already done)
# ════════════════════════════════════════════════════════════
# Scenario 1: No path, no configured paths → FLOOD only
# ════════════════════════════════════════════════════════════
if not has_path and not has_configured_paths:
for _ in range(cfg['flood_max_retries']):
attempt += 1
if await _retry(attempt, float(cfg['flood_interval'])):
return
# ════════════════════════════════════════════════════════════
# Scenario 2: Has path, no configured paths → DIRECT + optional FLOOD
# ════════════════════════════════════════════════════════════
elif has_path and not has_configured_paths:
# Phase 1: Direct retries on current ŚK
for _ in range(cfg['direct_max_retries']):
attempt += 1
if await _retry(attempt, float(cfg['direct_interval'])):
return
# Phase 2: Optional FLOOD fallback (controlled by no_auto_flood)
if not no_auto_flood:
try:
await self.mc.commands.reset_path(contact)
logger.info("DM retry: direct exhausted, resetting to FLOOD")
except Exception:
pass
path_desc = "FLOOD"
for _ in range(cfg['direct_flood_retries']):
attempt += 1
if await _retry(attempt, float(cfg['flood_interval'])):
return
# ════════════════════════════════════════════════════════════
# Scenario 3: No path, has configured paths → FLOOD first, then ŚD rotation
# ════════════════════════════════════════════════════════════
elif not has_path and has_configured_paths:
# Phase 1: FLOOD retries per NoPath settings (discover new path)
logger.info("DM retry: FLOOD first to discover new path")
for _ in range(cfg['flood_max_retries']):
attempt += 1
if await _retry(attempt, float(cfg['flood_interval'])):
return # Firmware sets discovered path as ŚK
# Phase 2: ŚD rotation (primary first, then others by sort_order)
logger.info("DM retry: FLOOD exhausted, rotating through configured paths")
direct_interval = float(cfg['direct_interval'])
for path_info in rotation_order:
try:
await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size'])
label = path_info.get('label', '')
path_desc = f"{label} ({path_info['path_hex']})" if label else path_info['path_hex']
logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})")
except Exception as e:
logger.warning(f"DM retry: failed to switch path: {e}")
continue
for _ in range(retries_per_path):
attempt += 1
if await _retry(attempt, direct_interval):
await self._restore_primary_path(contact, contact_pubkey)
return
# Restore ŚG regardless of outcome
await self._restore_primary_path(contact, contact_pubkey)
# ════════════════════════════════════════════════════════════
# Scenario 4: Has path + has configured paths → DIRECT on ŚK, ŚD rotation, optional FLOOD
# ════════════════════════════════════════════════════════════
else: # has_path and has_configured_paths
# Phase 1: Direct retries on current ŚK
for _ in range(cfg['direct_max_retries']):
attempt += 1
if await _retry(attempt, float(cfg['direct_interval'])):
return # Delivered on ŚK, no path change needed
# Phase 2: ŚD rotation with dedup
logger.info("DM retry: direct on ŚK exhausted, rotating through configured paths")
direct_interval = float(cfg['direct_interval'])
for path_info in rotation_order:
# Dedup: skip if this configured path matches original ŚK
if self._paths_match(original_out_path, original_out_path_len, path_info):
logger.debug(f"DM retry: skipping path '{path_info.get('label', '')}' "
f"({path_info['path_hex']}) — matches current ŚK")
continue
try:
await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size'])
label = path_info.get('label', '')
path_desc = f"{label} ({path_info['path_hex']})" if label else path_info['path_hex']
logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})")
except Exception as e:
logger.warning(f"DM retry: failed to switch path: {e}")
continue
for _ in range(retries_per_path):
attempt += 1
if await _retry(attempt, direct_interval):
await self._restore_primary_path(contact, contact_pubkey)
return
# Phase 3: Optional FLOOD fallback (controlled by no_auto_flood)
if not no_auto_flood:
try:
await self.mc.commands.reset_path(contact)
logger.info("DM retry: all paths exhausted, falling back to FLOOD")
except Exception:
pass
path_desc = "FLOOD"
for _ in range(cfg['flood_max_retries']):
attempt += 1
if await _retry(attempt, float(cfg['flood_interval'])):
await self._restore_primary_path(contact, contact_pubkey)
return
# Restore ŚG regardless of outcome
await self._restore_primary_path(contact, contact_pubkey)
# ── Common epilogue: mark failed, grace period for late ACKs ──
self.db.update_dm_delivery_status(dm_id, 'failed')
self._emit_retry_failed(dm_id, initial_ack)
logger.warning(f"DM retry exhausted ({attempt + 1} total attempts, scenario={scenario}) "
f"for dm_id={dm_id}")
self._retry_tasks.pop(dm_id, None)
await asyncio.sleep(cfg['grace_period'])
stale = [k for k, v in self._pending_acks.items() if v == dm_id]
if stale:
for k in stale:
self._pending_acks.pop(k, None)
logger.debug(f"Grace period expired, cleaned {len(stale)} pending acks for dm_id={dm_id}")
def _confirm_delivery(self, dm_id: int, ack_code: str, ack_event):
"""Store ACK and notify frontend."""
data = getattr(ack_event, 'payload', {})
# Only store if not already stored by _on_ack handler
existing = self.db.get_ack_for_dm(ack_code)
if not existing:
self.db.insert_ack(
expected_ack=ack_code,
snr=data.get('snr'),
rssi=data.get('rssi'),
route_type=data.get('route_type', ''),
dm_id=dm_id,
)
logger.info(f"DM delivery confirmed: dm_id={dm_id}, ack={ack_code}")
if self.socketio:
# Emit original expected_ack so frontend can match the DOM element
original_ack = ack_code
dm = self.db.get_dm_by_id(dm_id)
if dm and dm.get('expected_ack'):
original_ack = dm['expected_ack']
self.socketio.emit('ack', {
'expected_ack': original_ack,
'dm_id': dm_id,
'snr': data.get('snr'),
}, namespace='/chat')
# Cleanup pending acks for this DM
stale = [k for k, v in self._pending_acks.items() if v == dm_id]
for k in stale:
self._pending_acks.pop(k, None)
self._retry_tasks.pop(dm_id, None)
def get_contacts_from_device(self) -> List[Dict]:
"""Refresh contacts from device and return the list."""
if not self.is_connected:
return []
try:
self.execute(self.mc.ensure_contacts(follow=True))
self._sync_contacts_to_db()
return self.db.get_contacts()
except Exception as e:
logger.error(f"Failed to get contacts: {e}")
return self.db.get_contacts() # return cached
def delete_contact(self, pubkey: str) -> Dict:
"""Delete a contact from device and soft-delete in database."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
self.execute(self.mc.commands.remove_contact(pubkey))
self.db.delete_contact(pubkey) # soft-delete: sets source='advert'
# Also remove from in-memory contacts cache
if self.mc.contacts and pubkey in self.mc.contacts:
del self.mc.contacts[pubkey]
return {'success': True, 'message': 'Contact deleted'}
except Exception as e:
logger.error(f"Failed to delete contact: {e}")
return {'success': False, 'error': str(e)}
def delete_cached_contact(self, pubkey: str) -> Dict:
"""Hard-delete a cache-only contact from the database."""
try:
# Don't delete if contact is on device
if self.mc and self.mc.contacts and pubkey in self.mc.contacts:
return {'success': False, 'error': 'Contact is on device, use delete_contact instead'}
deleted = self.db.hard_delete_contact(pubkey)
if deleted:
return {'success': True, 'message': 'Cache contact deleted'}
return {'success': False, 'error': 'Contact not found in cache'}
except Exception as e:
logger.error(f"Failed to delete cached contact: {e}")
return {'success': False, 'error': str(e)}
def push_to_device(self, pubkey: str) -> Dict:
"""Push a cache-only contact to the device."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
# Already on device?
if self.mc.contacts and pubkey in self.mc.contacts:
return {'success': False, 'error': 'Contact is already on device'}
db_contact = self.db.get_contact(pubkey)
if not db_contact:
return {'success': False, 'error': 'Contact not found in cache'}
name = db_contact.get('name', '')
contact_type = db_contact.get('type', 1)
if contact_type == 0:
contact_type = 1 # NONE → COM
return self.add_contact_manual(
name=name,
public_key=pubkey,
contact_type=contact_type,
)
def move_to_cache(self, pubkey: str) -> Dict:
"""Move a device contact to cache (remove from device, keep in DB)."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
if not self.mc.contacts or pubkey not in self.mc.contacts:
return {'success': False, 'error': 'Contact not on device'}
contact = self.mc.contacts[pubkey]
name = contact.get('adv_name', contact.get('name', ''))
try:
self.execute(self.mc.commands.remove_contact(pubkey))
self.db.delete_contact(pubkey) # soft-delete: sets source='advert'
if self.mc.contacts and pubkey in self.mc.contacts:
del self.mc.contacts[pubkey]
logger.info(f"Moved to cache: {name} ({pubkey[:12]}...)")
return {'success': True, 'message': f'{name} moved to cache'}
except Exception as e:
logger.error(f"Failed to move contact to cache: {e}")
return {'success': False, 'error': str(e)}
def reset_path(self, pubkey: str) -> Dict:
"""Reset path to a contact."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
logger.info(f"Executing reset_path for {pubkey[:12]}...")
result = self.execute(self.mc.commands.reset_path(pubkey))
logger.info(f"reset_path result: {result}")
return {'success': True, 'message': 'Path reset'}
except Exception as e:
logger.error(f"Failed to reset path: {e}")
return {'success': False, 'error': str(e)}
def get_device_info(self) -> Dict:
"""Get device info. Returns info dict or empty dict."""
if self._self_info:
return dict(self._self_info)
if not self.is_connected:
return {}
try:
event = self.execute(self.mc.commands.send_appstart())
if event and hasattr(event, 'data'):
self._self_info = getattr(event, 'payload', {})
return dict(self._self_info)
except Exception as e:
logger.error(f"Failed to get device info: {e}")
return {}
def get_channel_info(self, idx: int) -> Optional[Dict]:
"""Get info for a specific channel."""
if not self.is_connected:
return None
try:
event = self.execute(self.mc.commands.get_channel(idx))
if event:
data = getattr(event, 'payload', None) or getattr(event, 'data', None)
if data and isinstance(data, dict):
# Normalize keys: channel_name -> name, channel_secret -> secret
secret = data.get('channel_secret', data.get('secret', ''))
if isinstance(secret, bytes):
secret = secret.hex()
name = data.get('channel_name', data.get('name', ''))
if isinstance(name, str):
name = name.strip('\x00').strip()
return {
'name': name,
'secret': secret,
'channel_idx': data.get('channel_idx', idx),
}
except Exception as e:
logger.error(f"Failed to get channel {idx}: {e}")
return None
def set_channel(self, idx: int, name: str, secret: bytes = None) -> Dict:
"""Set/create a channel on the device."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
self.execute(self.mc.commands.set_channel(idx, name, secret))
self.db.upsert_channel(idx, name, secret.hex() if secret else None)
return {'success': True, 'message': f'Channel {idx} set'}
except Exception as e:
logger.error(f"Failed to set channel: {e}")
return {'success': False, 'error': str(e)}
def remove_channel(self, idx: int) -> Dict:
"""Remove a channel from the device."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
# Set channel with empty name removes it
self.execute(self.mc.commands.set_channel(idx, '', None))
self.db.delete_channel(idx)
return {'success': True, 'message': f'Channel {idx} removed'}
except Exception as e:
logger.error(f"Failed to remove channel: {e}")
return {'success': False, 'error': str(e)}
def send_advert(self, flood: bool = False) -> Dict:
"""Send advertisement."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
self.execute(self.mc.commands.send_advert(flood=flood))
return {'success': True, 'message': 'Advert sent'}
except Exception as e:
logger.error(f"Failed to send advert: {e}")
return {'success': False, 'error': str(e)}
def check_connection(self) -> bool:
"""Check if device is connected and responsive."""
if not self.is_connected:
return False
try:
self.execute(self.mc.commands.send_appstart(), timeout=5)
return True
except Exception:
return False
def set_manual_add_contacts(self, enabled: bool) -> Dict:
"""Enable/disable manual contact approval mode."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
self.execute(self.mc.commands.set_manual_add_contacts(enabled))
return {'success': True, 'message': f'Manual add contacts: {enabled}'}
except KeyError as e:
# Firmware may not support all fields needed by meshcore lib
logger.warning(f"set_manual_add_contacts unsupported by firmware: {e}")
return {'success': False, 'error': f'Firmware does not support this setting: {e}'}
except Exception as e:
logger.error(f"Failed to set manual_add_contacts: {e}")
return {'success': False, 'error': str(e)}
def get_pending_contacts(self) -> List[Dict]:
"""Get contacts pending manual approval."""
if not self.is_connected:
return []
try:
pending = self.mc.pending_contacts or {}
return [
{
'public_key': pk,
'name': c.get('adv_name', c.get('name', '')),
'type': c.get('type', c.get('adv_type', 0)),
'adv_lat': c.get('adv_lat'),
'adv_lon': c.get('adv_lon'),
'last_advert': c.get('last_advert'),
}
for pk, c in pending.items()
]
except Exception as e:
logger.error(f"Failed to get pending contacts: {e}")
return []
def approve_contact(self, pubkey: str) -> Dict:
"""Approve a pending contact."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
contact = (self.mc.pending_contacts or {}).get(pubkey)
# Also check DB cache for contacts not in meshcore's pending list
if not contact:
db_contact = self.db.get_contact(pubkey)
if db_contact and db_contact.get('source') == 'advert':
contact = {
'public_key': pubkey,
'name': db_contact.get('name', ''),
'adv_name': db_contact.get('name', ''),
'type': db_contact.get('type', 0),
'adv_lat': db_contact.get('adv_lat'),
'adv_lon': db_contact.get('adv_lon'),
'last_advert': db_contact.get('last_advert'),
}
if not contact:
return {'success': False, 'error': 'Contact not in pending list'}
self.execute(self.mc.commands.add_contact(contact))
# Refresh mc.contacts so send_dm can find the new contact
self.execute(self.mc.ensure_contacts(follow=True))
# Fallback: if ensure_contacts didn't pick up the new contact,
# add it manually to mc.contacts (firmware may need time)
if pubkey not in (self.mc.contacts or {}):
if self.mc.contacts is None:
self.mc.contacts = {}
self.mc.contacts[pubkey] = contact
logger.info(f"Manually added {pubkey[:12]}... to mc.contacts")
last_adv = contact.get('last_advert')
last_advert_val = (
str(int(last_adv))
if last_adv and isinstance(last_adv, (int, float)) and last_adv > 0
else str(int(time.time()))
)
self.db.upsert_contact(
public_key=pubkey,
name=contact.get('adv_name', contact.get('name', '')),
type=contact.get('type', contact.get('adv_type', 0)),
adv_lat=contact.get('adv_lat'),
adv_lon=contact.get('adv_lon'),
last_advert=last_advert_val,
source='device',
)
# Re-link orphaned DMs (from previous ON DELETE SET NULL)
contact_name = contact.get('adv_name', contact.get('name', ''))
self.db.relink_orphaned_dms(pubkey, name=contact_name)
# Remove from pending list after successful approval
self.mc.pending_contacts.pop(pubkey, None)
return {'success': True, 'message': 'Contact approved'}
except Exception as e:
logger.error(f"Failed to approve contact: {e}")
return {'success': False, 'error': str(e)}
def add_contact_manual(self, name: str, public_key: str, contact_type: int = 1) -> Dict:
"""Add a contact manually from name, public_key and type.
This bypasses the pending/advert mechanism entirely — uses CMD_ADD_UPDATE_CONTACT
(same as the MeshCore mobile app's QR code / URI sharing).
"""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
# Validate inputs
public_key = public_key.strip().lower()
name = name.strip()
if not name:
return {'success': False, 'error': 'Name is required'}
if len(public_key) != 64:
return {'success': False, 'error': 'Public key must be 64 hex characters'}
try:
bytes.fromhex(public_key)
except ValueError:
return {'success': False, 'error': 'Public key must be valid hex'}
if contact_type not in (1, 2, 3, 4):
return {'success': False, 'error': 'Type must be 1 (COM), 2 (REP), 3 (ROOM), or 4 (SENS)'}
try:
contact = {
'public_key': public_key,
'type': contact_type,
'flags': 0,
'out_path_len': -1,
'out_path': '',
'out_path_hash_mode': 0,
'adv_name': name,
'last_advert': 0,
'adv_lat': 0.0,
'adv_lon': 0.0,
}
self.execute(self.mc.commands.add_contact(contact))
# Refresh mc.contacts from device
self.execute(self.mc.ensure_contacts(follow=True))
# Fallback: add to in-memory contacts if firmware needs time
if public_key not in (self.mc.contacts or {}):
if self.mc.contacts is None:
self.mc.contacts = {}
self.mc.contacts[public_key] = contact
logger.info(f"Manually added {public_key[:12]}... to mc.contacts")
self.db.upsert_contact(
public_key=public_key,
name=name,
type=contact_type,
adv_lat=0.0,
adv_lon=0.0,
last_advert=str(int(time.time())),
source='device',
)
# Re-link orphaned DMs
self.db.relink_orphaned_dms(public_key, name=name)
logger.info(f"Manual add contact: {name} ({public_key[:12]}...) type={contact_type}")
return {'success': True, 'message': f'Contact {name} added to device'}
except Exception as e:
logger.error(f"Failed to add contact manually: {e}")
return {'success': False, 'error': str(e)}
def reject_contact(self, pubkey: str) -> Dict:
"""Reject a pending contact (remove from pending list without adding)."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
removed = self.mc.pending_contacts.pop(pubkey, None)
if removed:
return {'success': True, 'message': 'Contact rejected'}
# Also check DB cache - remove cache-only contacts on reject
db_contact = self.db.get_contact(pubkey)
if db_contact and db_contact.get('source') == 'advert':
self.db.hard_delete_contact(pubkey)
return {'success': True, 'message': 'Contact rejected'}
return {'success': False, 'error': 'Contact not in pending list'}
except Exception as e:
logger.error(f"Failed to reject contact: {e}")
return {'success': False, 'error': str(e)}
def clear_pending_contacts(self) -> Dict:
"""Clear all pending contacts."""
try:
count = len(self.mc.pending_contacts) if self.mc and self.mc.pending_contacts else 0
if self.mc and self.mc.pending_contacts is not None:
self.mc.pending_contacts.clear()
return {'success': True, 'message': f'Cleared {count} pending contacts'}
except Exception as e:
logger.error(f"Failed to clear pending contacts: {e}")
return {'success': False, 'error': str(e)}
def get_battery(self) -> Optional[Dict]:
"""Get battery status."""
if not self.is_connected:
return None
try:
event = self.execute(self.mc.commands.get_bat(), timeout=5)
if event and hasattr(event, 'data'):
return getattr(event, 'payload', {})
except Exception as e:
logger.error(f"Failed to get battery: {e}")
return None
def get_device_stats(self) -> Dict:
"""Get combined device statistics (core + radio + packets)."""
if not self.is_connected:
return {}
stats = {}
try:
event = self.execute(self.mc.commands.get_stats_core(), timeout=5)
if event and hasattr(event, 'payload'):
stats['core'] = event.payload
except Exception as e:
logger.debug(f"get_stats_core failed: {e}")
try:
event = self.execute(self.mc.commands.get_stats_radio(), timeout=5)
if event and hasattr(event, 'payload'):
stats['radio'] = event.payload
except Exception as e:
logger.debug(f"get_stats_radio failed: {e}")
try:
event = self.execute(self.mc.commands.get_stats_packets(), timeout=5)
if event and hasattr(event, 'payload'):
stats['packets'] = event.payload
except Exception as e:
logger.debug(f"get_stats_packets failed: {e}")
return stats
def request_telemetry(self, contact_name: str) -> Optional[Dict]:
"""Request telemetry data from a remote sensor node."""
if not self.is_connected:
return None
contact = self.mc.get_contact_by_name(contact_name)
if not contact:
return {'error': f"Contact '{contact_name}' not found"}
try:
event = self.execute(
self.mc.commands.req_telemetry_sync(contact),
timeout=30
)
if event and hasattr(event, 'payload'):
return event.payload
return {'error': 'No telemetry response (timeout)'}
except Exception as e:
logger.error(f"Telemetry request failed: {e}")
return {'error': str(e)}
def request_neighbors(self, contact_name: str) -> Optional[Dict]:
"""Request neighbor list from a remote node."""
if not self.is_connected:
return None
contact = self.mc.get_contact_by_name(contact_name)
if not contact:
return {'error': f"Contact '{contact_name}' not found"}
try:
event = self.execute(
self.mc.commands.req_neighbours_sync(contact),
timeout=30
)
if event and hasattr(event, 'payload'):
return event.payload
return {'error': 'No neighbors response (timeout)'}
except Exception as e:
logger.error(f"Neighbors request failed: {e}")
return {'error': str(e)}
def send_trace(self, path: str) -> Dict:
"""Send a trace packet and wait for trace data response."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
async def _trace():
from meshcore.events import EventType
res = await self.mc.commands.send_trace(path=path)
if res is None or res.type == EventType.ERROR:
return None
tag = int.from_bytes(res.payload['expected_ack'], byteorder="little")
timeout = res.payload["suggested_timeout"] / 1000 * 1.2
ev = await self.mc.wait_for_event(
EventType.TRACE_DATA,
attribute_filters={"tag": tag},
timeout=timeout
)
if ev is None or ev.type == EventType.ERROR:
return None
return ev.payload
result = self.execute(_trace(), timeout=120)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': f'Timeout waiting trace for path {path}'}
except Exception as e:
logger.error(f"Trace failed: {e}")
return {'success': False, 'error': str(e)}
def resolve_contact(self, name_or_key: str) -> Optional[Dict]:
"""Resolve a contact by name or public key prefix."""
if not self.is_connected or not self.mc:
return None
contact = self.mc.get_contact_by_name(name_or_key)
if not contact:
contact = self.mc.get_contact_by_key_prefix(name_or_key)
return contact
# ── Repeater Management ──────────────────────────────────────────
def repeater_login(self, name_or_key: str, password: str) -> Dict:
"""Log into a repeater with given password."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
from meshcore.events import EventType
res = self.execute(
self.mc.commands.send_login(contact, password),
timeout=10
)
# Wait for LOGIN_SUCCESS or LOGIN_FAILED
timeout = 30
if res and hasattr(res, 'payload') and 'suggested_timeout' in res.payload:
timeout = res.payload['suggested_timeout'] / 800
timeout = max(timeout, contact.get('timeout', 0) or 30)
event = self.execute(
self.mc.wait_for_event(EventType.LOGIN_SUCCESS, timeout=timeout),
timeout=timeout + 5
)
if event and hasattr(event, 'type') and event.type == EventType.LOGIN_SUCCESS:
return {'success': True, 'message': f'Logged into {contact.get("adv_name", name_or_key)}'}
return {'success': False, 'error': 'Login failed (timeout)'}
except Exception as e:
err = str(e)
if 'LOGIN_FAILED' in err or 'login' in err.lower():
return {'success': False, 'error': 'Login failed (wrong password?)'}
logger.error(f"Repeater login failed: {e}")
return {'success': False, 'error': err}
def repeater_logout(self, name_or_key: str) -> Dict:
"""Log out of a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
self.execute(self.mc.commands.send_logout(contact), timeout=10)
return {'success': True, 'message': f'Logged out of {contact.get("adv_name", name_or_key)}'}
except Exception as e:
logger.error(f"Repeater logout failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_cmd(self, name_or_key: str, cmd: str) -> Dict:
"""Send a command to a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
res = self.execute(self.mc.commands.send_cmd(contact, cmd), timeout=10)
msg = f'Command sent to {contact.get("adv_name", name_or_key)}: {cmd}'
return {'success': True, 'message': msg}
except Exception as e:
logger.error(f"Repeater cmd failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_status(self, name_or_key: str) -> Dict:
"""Request status from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
self.mc.commands.req_status_sync(contact, contact_timeout, min_timeout=15),
timeout=120
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No status response (timeout)'}
except Exception as e:
logger.error(f"req_status failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_regions(self, name_or_key: str) -> Dict:
"""Request regions from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
self.mc.commands.req_regions_sync(contact, contact_timeout),
timeout=120
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No regions response (timeout)'}
except Exception as e:
logger.error(f"req_regions failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_owner(self, name_or_key: str) -> Dict:
"""Request owner info from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
self.mc.commands.req_owner_sync(contact, contact_timeout),
timeout=120
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No owner response (timeout)'}
except Exception as e:
logger.error(f"req_owner failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_acl(self, name_or_key: str) -> Dict:
"""Request access control list from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
self.mc.commands.req_acl_sync(contact, contact_timeout, min_timeout=15),
timeout=120
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No ACL response (timeout)'}
except Exception as e:
logger.error(f"req_acl failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_clock(self, name_or_key: str) -> Dict:
"""Request clock/basic info from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
self.mc.commands.req_basic_sync(contact, contact_timeout),
timeout=120
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No clock response (timeout)'}
except Exception as e:
logger.error(f"req_clock failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_mma(self, name_or_key: str, from_secs: int, to_secs: int) -> Dict:
"""Request min/max/avg sensor data from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
self.mc.commands.req_mma_sync(contact, from_secs, to_secs, contact_timeout, min_timeout=15),
timeout=120
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No MMA response (timeout)'}
except Exception as e:
logger.error(f"req_mma failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_neighbours(self, name_or_key: str) -> Dict:
"""Request neighbours from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
self.mc.commands.fetch_all_neighbours(contact, timeout=contact_timeout, min_timeout=15),
timeout=120
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No neighbours response (timeout)'}
except Exception as e:
logger.error(f"req_neighbours failed: {e}")
return {'success': False, 'error': str(e)}
def resolve_contact_name(self, pubkey_prefix: str) -> str:
"""Resolve a contact name from pubkey prefix using device memory and DB cache."""
if self.mc:
contact = self.mc.get_contact_by_key_prefix(pubkey_prefix)
if contact:
return contact.get('adv_name', '') or contact.get('name', '')
db_contact = self.db.get_contact_by_prefix(pubkey_prefix)
if db_contact:
return db_contact.get('name', '')
return ''
# ── Contact Management (extended) ────────────────────────────
def contact_info(self, name_or_key: str) -> Dict:
"""Get full info for a contact."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
return {'success': True, 'data': dict(contact)}
def contact_path(self, name_or_key: str) -> Dict:
"""Get path info for a contact."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
return {'success': True, 'data': {
'out_path': contact.get('out_path', ''),
'out_path_len': contact.get('out_path_len', -1),
'out_path_hash_len': contact.get('out_path_hash_len', 0),
}}
def discover_path(self, name_or_key: str) -> Dict:
"""Discover a new path to a contact."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
from meshcore.events import EventType
res = self.execute(
self.mc.commands.send_path_discovery(contact),
timeout=10
)
timeout = 30
if res and hasattr(res, 'payload') and 'suggested_timeout' in res.payload:
timeout = res.payload['suggested_timeout'] / 600
timeout = max(timeout, contact.get('timeout', 0) or 30)
event = self.execute(
self.mc.wait_for_event(EventType.PATH_RESPONSE, timeout=timeout),
timeout=timeout + 5
)
if event and hasattr(event, 'payload'):
return {'success': True, 'data': event.payload}
return {'success': False, 'error': 'No path response (timeout)'}
except Exception as e:
logger.error(f"discover_path failed: {e}")
return {'success': False, 'error': str(e)}
def change_path(self, name_or_key: str, path: str) -> Dict:
"""Change the path to a contact."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
self.execute(self.mc.commands.change_contact_path(contact, path), timeout=10)
return {'success': True, 'message': f'Path changed for {contact.get("adv_name", name_or_key)}'}
except Exception as e:
logger.error(f"change_path failed: {e}")
return {'success': False, 'error': str(e)}
def advert_path(self, name_or_key: str) -> Dict:
"""Get advertisement path for a contact."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
event = self.execute(self.mc.commands.get_advert_path(contact), timeout=10)
if event and hasattr(event, 'payload'):
return {'success': True, 'data': event.payload}
return {'success': False, 'error': 'No advert path response'}
except Exception as e:
logger.error(f"advert_path failed: {e}")
return {'success': False, 'error': str(e)}
def share_contact(self, name_or_key: str) -> Dict:
"""Share a contact with others on the mesh."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
self.execute(self.mc.commands.share_contact(contact), timeout=10)
return {'success': True, 'message': f'Contact shared: {contact.get("adv_name", name_or_key)}'}
except Exception as e:
logger.error(f"share_contact failed: {e}")
return {'success': False, 'error': str(e)}
def export_contact(self, name_or_key: str) -> Dict:
"""Export a contact as URI."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
event = self.execute(self.mc.commands.export_contact(contact), timeout=10)
if event and hasattr(event, 'payload'):
uri = event.payload.get('uri', '')
if isinstance(uri, bytes):
uri = 'meshcore://' + uri.hex()
return {'success': True, 'data': {'uri': uri}}
return {'success': False, 'error': 'No export response'}
except Exception as e:
logger.error(f"export_contact failed: {e}")
return {'success': False, 'error': str(e)}
def import_contact_uri(self, uri: str) -> Dict:
"""Import a contact from meshcore:// URI.
Supports two formats:
- Mobile app URI: meshcore://contact/add?name=...&public_key=...&type=...
- Hex blob URI: meshcore://<hex_data> (signed advert blob)
"""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
# Try mobile app URI format first
parsed = parse_meshcore_uri(uri)
if parsed:
return self.add_contact_manual(parsed['name'], parsed['public_key'], parsed['type'])
# Fallback: hex blob (signed advert) format
try:
if uri.startswith('meshcore://'):
hex_data = uri[11:]
else:
hex_data = uri
contact_bytes = bytes.fromhex(hex_data)
self.execute(self.mc.commands.import_contact(contact_bytes), timeout=10)
# Refresh contacts
self.execute(self.mc.commands.get_contacts(), timeout=10)
return {'success': True, 'message': 'Contact imported'}
except ValueError:
return {'success': False, 'error': 'Invalid URI format (expected mobile app URI or hex data)'}
except Exception as e:
logger.error(f"import_contact failed: {e}")
return {'success': False, 'error': str(e)}
def change_contact_flags(self, name_or_key: str, flags: int) -> Dict:
"""Change flags for a contact."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
self.execute(self.mc.commands.change_contact_flags(contact, flags), timeout=10)
return {'success': True, 'message': f'Flags changed for {contact.get("adv_name", name_or_key)}'}
except Exception as e:
logger.error(f"change_flags failed: {e}")
return {'success': False, 'error': str(e)}
# ── Device Management ────────────────────────────────────────
def query_device(self) -> Dict:
"""Query device for firmware version and hardware info."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
event = self.execute(self.mc.commands.send_device_query(), timeout=5)
if event and hasattr(event, 'payload'):
return {'success': True, 'data': event.payload}
return {'success': False, 'error': 'No device query response'}
except Exception as e:
logger.error(f"query_device failed: {e}")
return {'success': False, 'error': str(e)}
def get_clock(self) -> Dict:
"""Get device clock time."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
event = self.execute(self.mc.commands.get_time(), timeout=5)
if event and hasattr(event, 'payload'):
return {'success': True, 'data': event.payload}
return {'success': False, 'error': 'No time response'}
except Exception as e:
logger.error(f"get_clock failed: {e}")
return {'success': False, 'error': str(e)}
def set_clock(self, epoch: int) -> Dict:
"""Set device clock to given epoch timestamp."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
self.execute(self.mc.commands.set_time(epoch), timeout=5)
return {'success': True, 'message': f'Clock set to {epoch}'}
except Exception as e:
logger.error(f"set_clock failed: {e}")
return {'success': False, 'error': str(e)}
def reboot_device(self) -> Dict:
"""Reboot the device."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
self.execute(self.mc.commands.reboot(), timeout=5)
return {'success': True, 'message': 'Device rebooting...'}
except Exception as e:
logger.error(f"reboot failed: {e}")
return {'success': False, 'error': str(e)}
def set_flood_scope(self, scope: str) -> Dict:
"""Set flood message scope."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
self.execute(self.mc.commands.set_flood_scope(scope), timeout=5)
return {'success': True, 'message': f'Scope set to: {scope}'}
except Exception as e:
logger.error(f"set_flood_scope failed: {e}")
return {'success': False, 'error': str(e)}
def get_self_telemetry(self) -> Dict:
"""Get own telemetry data."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
event = self.execute(self.mc.commands.get_self_telemetry(), timeout=5)
if event and hasattr(event, 'payload'):
return {'success': True, 'data': event.payload}
return {'success': False, 'error': 'No telemetry response'}
except Exception as e:
logger.error(f"get_self_telemetry failed: {e}")
return {'success': False, 'error': str(e)}
def get_param(self, param: str) -> Dict:
"""Get a device parameter."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
info = self.get_device_info()
if param == 'name':
return {'success': True, 'data': {'name': info.get('name', info.get('adv_name', '?'))}}
elif param == 'tx':
return {'success': True, 'data': {'tx': info.get('tx_power', '?')}}
elif param in ('coords', 'lat', 'lon'):
return {'success': True, 'data': {'lat': info.get('lat', 0), 'lon': info.get('lon', 0)}}
elif param == 'bat':
bat = self.get_battery()
return {'success': True, 'data': bat or {}}
elif param == 'radio':
return {'success': True, 'data': {
'freq': info.get('freq', '?'),
'bw': info.get('bw', '?'),
'sf': info.get('sf', '?'),
'cr': info.get('cr', '?'),
}}
elif param == 'stats':
stats = self.get_device_stats()
return {'success': True, 'data': stats}
elif param == 'custom':
event = self.execute(self.mc.commands.get_custom_vars(), timeout=5)
if event and hasattr(event, 'payload'):
return {'success': True, 'data': event.payload}
return {'success': False, 'error': 'No custom vars response'}
elif param == 'path_hash_mode':
# get_path_hash_mode() returns int, not Event
value = self.execute(self.mc.commands.get_path_hash_mode(), timeout=5)
return {'success': True, 'data': {'path_hash_mode': value}}
elif param == 'help':
return {'success': True, 'help': 'get'}
else:
return {'success': False, 'error': f"Unknown param: {param}. Type 'get help' for list."}
except Exception as e:
logger.error(f"get_param failed: {e}")
return {'success': False, 'error': str(e)}
def set_param(self, param: str, value: str) -> Dict:
"""Set a device parameter."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
if param == 'name':
self.execute(self.mc.commands.set_name(value), timeout=5)
return {'success': True, 'message': f'Name set to: {value}'}
elif param == 'tx':
self.execute(self.mc.commands.set_tx_power(value), timeout=5)
return {'success': True, 'message': f'TX power set to: {value}'}
elif param == 'coords':
parts = value.split(',')
if len(parts) != 2:
return {'success': False, 'error': 'Format: set coords <lat>,<lon>'}
lat, lon = float(parts[0].strip()), float(parts[1].strip())
self.execute(self.mc.commands.set_coords(lat, lon), timeout=5)
return {'success': True, 'message': f'Coords set to: {lat}, {lon}'}
elif param == 'lat':
info = self.get_device_info()
lon = info.get('lon', 0)
self.execute(self.mc.commands.set_coords(float(value), lon), timeout=5)
return {'success': True, 'message': f'Lat set to: {value}'}
elif param == 'lon':
info = self.get_device_info()
lat = info.get('lat', 0)
self.execute(self.mc.commands.set_coords(lat, float(value)), timeout=5)
return {'success': True, 'message': f'Lon set to: {value}'}
elif param == 'pin':
self.execute(self.mc.commands.set_devicepin(value), timeout=5)
return {'success': True, 'message': 'PIN set'}
elif param == 'telemetry_mode_base':
self.execute(self.mc.commands.set_telemetry_mode_base(int(value)), timeout=5)
return {'success': True, 'message': f'Telemetry mode base set to: {value}'}
elif param == 'telemetry_mode_loc':
self.execute(self.mc.commands.set_telemetry_mode_loc(int(value)), timeout=5)
return {'success': True, 'message': f'Telemetry mode loc set to: {value}'}
elif param == 'telemetry_mode_env':
self.execute(self.mc.commands.set_telemetry_mode_env(int(value)), timeout=5)
return {'success': True, 'message': f'Telemetry mode env set to: {value}'}
elif param == 'advert_loc_policy':
self.execute(self.mc.commands.set_advert_loc_policy(int(value)), timeout=5)
return {'success': True, 'message': f'Advert loc policy set to: {value}'}
elif param == 'manual_add_contacts':
enabled = value.lower() in ('true', '1', 'yes', 'on')
self.execute(self.mc.commands.set_manual_add_contacts(enabled), timeout=5)
return {'success': True, 'message': f'Manual add contacts: {enabled}'}
elif param == 'multi_acks':
enabled = value.lower() in ('true', '1', 'yes', 'on')
self.execute(self.mc.commands.set_multi_acks(enabled), timeout=5)
return {'success': True, 'message': f'Multi acks: {enabled}'}
elif param == 'path_hash_mode':
self.execute(self.mc.commands.set_path_hash_mode(int(value)), timeout=5)
return {'success': True, 'message': f'Path hash mode set to: {value}'}
elif param == 'help':
return {'success': True, 'help': 'set'}
else:
# Try as custom variable
self.execute(self.mc.commands.set_custom_var(param, value), timeout=5)
return {'success': True, 'message': f'Custom var {param} set to: {value}'}
except Exception as e:
logger.error(f"set_param failed: {e}")
return {'success': False, 'error': str(e)}
def node_discover(self, type_filter: str = None) -> Dict:
"""Discover nodes on the mesh."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
try:
from meshcore.events import EventType
types = 0xFF # all types
if type_filter:
type_map = {'com': 1, 'rep': 2, 'room': 3, 'sensor': 4, 'sens': 4}
t = type_map.get(type_filter.lower())
if t:
types = t
res = self.execute(
self.mc.commands.send_node_discover_req(types),
timeout=10
)
# Collect responses with timeout
results = []
try:
while True:
ev = self.execute(
self.mc.wait_for_event(EventType.DISCOVER_RESPONSE, timeout=5),
timeout=10
)
if ev and hasattr(ev, 'payload'):
results.append(ev.payload)
else:
break
except Exception:
pass # timeout = no more responses
return {'success': True, 'data': results}
except Exception as e:
logger.error(f"node_discover failed: {e}")
return {'success': False, 'error': str(e)}