diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py
index b4fe045..c1a0ec9 100644
--- a/app/meshcore/cli.py
+++ b/app/meshcore/cli.py
@@ -397,6 +397,36 @@ def send_dm(recipient: str, text: str) -> Tuple[bool, str]:
return success, stdout or stderr
+def check_dm_delivery(ack_codes: list) -> Tuple[bool, Dict, str]:
+ """
+ Check delivery status for sent DMs by their expected_ack codes.
+
+ Args:
+ ack_codes: List of expected_ack hex strings from SENT_MSG log entries
+
+ Returns:
+ Tuple of (success, ack_status_dict, error_message)
+ ack_status_dict maps ack_code -> ack_info dict or None
+ """
+ try:
+ response = requests.get(
+ f"{config.MC_BRIDGE_URL.replace('/cli', '/ack_status')}",
+ params={'ack_codes': ','.join(ack_codes)},
+ timeout=DEFAULT_TIMEOUT
+ )
+
+ if response.status_code != 200:
+ return False, {}, f"Bridge error: {response.status_code}"
+
+ data = response.json()
+ return data.get('success', False), data.get('acks', {}), ''
+
+ except requests.exceptions.ConnectionError:
+ return False, {}, 'Cannot connect to bridge'
+ except Exception as e:
+ return False, {}, str(e)
+
+
# =============================================================================
# Contact Management (Existing & Pending Contacts)
# =============================================================================
diff --git a/app/meshcore/parser.py b/app/meshcore/parser.py
index d9f7230..b652881 100644
--- a/app/meshcore/parser.py
+++ b/app/meshcore/parser.py
@@ -440,7 +440,8 @@ def _parse_sent_msg(line: Dict) -> Optional[Dict]:
'is_own': True,
'txt_type': txt_type,
'conversation_id': conversation_id,
- 'dedup_key': dedup_key
+ 'dedup_key': dedup_key,
+ 'expected_ack': line.get('expected_ack'),
}
diff --git a/app/routes/api.py b/app/routes/api.py
index 23396f3..a7d9be8 100644
--- a/app/routes/api.py
+++ b/app/routes/api.py
@@ -1592,6 +1592,23 @@ def get_dm_messages():
elif msg['direction'] == 'outgoing' and msg.get('recipient'):
display_name = msg['recipient']
+ # Merge delivery status from ACK tracking
+ ack_codes = [msg['expected_ack'] for msg in messages
+ if msg.get('direction') == 'outgoing' and msg.get('expected_ack')]
+ if ack_codes:
+ try:
+ success_ack, acks, _ = cli.check_dm_delivery(ack_codes)
+ if success_ack:
+ for msg in messages:
+ ack_code = msg.get('expected_ack')
+ if ack_code and acks.get(ack_code):
+ ack_info = acks[ack_code]
+ msg['status'] = 'delivered'
+ msg['delivery_snr'] = ack_info.get('snr')
+ msg['delivery_route'] = ack_info.get('route')
+ except Exception as e:
+ logger.debug(f"ACK status fetch failed (non-critical): {e}")
+
return jsonify({
'success': True,
'conversation_id': conversation_id,
diff --git a/app/static/js/dm.js b/app/static/js/dm.js
index a5b503a..9824c90 100644
--- a/app/static/js/dm.js
+++ b/app/static/js/dm.js
@@ -425,12 +425,20 @@ function displayMessages(messages) {
// Status icon for own messages
let statusIcon = '';
if (msg.is_own && msg.status) {
- const icons = {
- 'pending': '',
- 'delivered': '',
- 'timeout': ''
- };
- statusIcon = icons[msg.status] || '';
+ if (msg.status === 'delivered') {
+ let title = 'Delivered';
+ if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) {
+ title += `, SNR: ${msg.delivery_snr.toFixed(1)} dB`;
+ }
+ if (msg.delivery_route) title += ` (${msg.delivery_route})`;
+ statusIcon = ``;
+ } else {
+ const icons = {
+ 'pending': '',
+ 'timeout': ''
+ };
+ statusIcon = icons[msg.status] || '';
+ }
}
// Metadata for incoming messages
diff --git a/meshcore-bridge/bridge.py b/meshcore-bridge/bridge.py
index f6c3115..16245a2 100644
--- a/meshcore-bridge/bridge.py
+++ b/meshcore-bridge/bridge.py
@@ -153,19 +153,29 @@ class MeshCLISession:
self.echo_lock = threading.Lock()
self.echo_log_path = self.config_dir / f"{device_name}.echoes.jsonl"
- # Load persisted echo data from disk
+ # ACK tracking for DM delivery status
+ self.acks = {} # ack_code -> {snr, rssi, route, path, ts}
+ self.acks_file = self.config_dir / f"{device_name}.acks.jsonl"
+
+ # Load persisted data from disk
self._load_echoes()
+ self._load_acks()
# Start session
self._start_session()
def _update_log_paths(self, new_name):
- """Update advert/echo log paths after device name detection, renaming existing files."""
+ """Update advert/echo/ack log paths after device name detection, renaming existing files."""
new_advert = self.config_dir / f"{new_name}.adverts.jsonl"
new_echo = self.config_dir / f"{new_name}.echoes.jsonl"
+ new_acks = self.config_dir / f"{new_name}.acks.jsonl"
# Rename existing files if they use the old (configured) name
- for old_path, new_path in [(self.advert_log_path, new_advert), (self.echo_log_path, new_echo)]:
+ for old_path, new_path in [
+ (self.advert_log_path, new_advert),
+ (self.echo_log_path, new_echo),
+ (self.acks_file, new_acks),
+ ]:
if old_path != new_path and old_path.exists() and not new_path.exists():
try:
old_path.rename(new_path)
@@ -175,6 +185,7 @@ class MeshCLISession:
self.advert_log_path = new_advert
self.echo_log_path = new_echo
+ self.acks_file = new_acks
logger.info(f"Log paths updated for device: {new_name}")
def _start_session(self):
@@ -344,6 +355,12 @@ class MeshCLISession:
self._process_echo(echo_data)
continue
+ # Try to parse as ACK packet (for DM delivery tracking)
+ ack_data = self._parse_ack_packet(line)
+ if ack_data:
+ self._process_ack(ack_data)
+ continue
+
# Otherwise, append to current CLI response
self._append_to_current_response(line)
@@ -680,6 +697,99 @@ class MeshCLISession:
except Exception as e:
logger.error(f"Failed to load echoes: {e}")
+ # =========================================================================
+ # ACK tracking for DM delivery status
+ # =========================================================================
+
+ def _parse_ack_packet(self, line):
+ """Parse ACK JSON packet from stdout, return data dict or None."""
+ try:
+ data = json.loads(line)
+ if isinstance(data, dict) and data.get("payload_typename") == "ACK":
+ return {
+ 'ack_code': data.get('pkt_payload'),
+ 'snr': data.get('snr'),
+ 'rssi': data.get('rssi'),
+ 'route': data.get('route_typename'),
+ 'path': data.get('path', ''),
+ 'path_len': data.get('path_len', 0),
+ }
+ except (json.JSONDecodeError, ValueError):
+ pass
+ return None
+
+ def _process_ack(self, ack_data):
+ """Process an ACK packet: store delivery confirmation."""
+ ack_code = ack_data.get('ack_code')
+ if not ack_code:
+ return
+
+ # Only store the first ACK per code (ignore duplicates from multi_acks)
+ if ack_code in self.acks:
+ logger.debug(f"ACK duplicate ignored: code={ack_code}")
+ return
+
+ record = {
+ 'ack_code': ack_code,
+ 'snr': ack_data.get('snr'),
+ 'rssi': ack_data.get('rssi'),
+ 'route': ack_data.get('route'),
+ 'path': ack_data.get('path', ''),
+ 'ts': time.time(),
+ }
+
+ self.acks[ack_code] = record
+ self._save_ack(record)
+ logger.info(f"ACK received: code={ack_code}, snr={ack_data.get('snr')}, route={ack_data.get('route')}")
+
+ def _save_ack(self, record):
+ """Append ACK record to .acks.jsonl file."""
+ try:
+ with open(self.acks_file, 'a', encoding='utf-8') as f:
+ f.write(json.dumps(record, ensure_ascii=False) + '\n')
+ except Exception as e:
+ logger.error(f"Failed to save ACK: {e}")
+
+ def _load_acks(self):
+ """Load ACK data from .acks.jsonl on startup with 7-day cleanup."""
+ if not self.acks_file.exists():
+ return
+
+ cutoff = time.time() - (7 * 24 * 3600) # 7 days
+ kept_lines = []
+ loaded = 0
+
+ try:
+ with open(self.acks_file, 'r', encoding='utf-8') as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+
+ ts = record.get('ts', 0)
+ if ts < cutoff:
+ continue # Skip old records
+
+ kept_lines.append(line)
+ ack_code = record.get('ack_code')
+ if ack_code:
+ self.acks[ack_code] = record
+ loaded += 1
+
+ # Rewrite file with only recent records (compact)
+ with open(self.acks_file, 'w', encoding='utf-8') as f:
+ for line in kept_lines:
+ f.write(line + '\n')
+
+ logger.info(f"Loaded ACKs from disk: {loaded} records (kept {len(kept_lines)})")
+
+ except Exception as e:
+ logger.error(f"Failed to load ACKs: {e}")
+
def _log_advert(self, json_line):
"""Log advert JSON to .jsonl file with timestamp"""
try:
@@ -1314,6 +1424,40 @@ def get_echo_counts():
}), 200
+# =============================================================================
+# ACK tracking endpoint for DM delivery status
+# =============================================================================
+
+@app.route('/ack_status', methods=['GET'])
+def get_ack_status():
+ """
+ Get ACK status for sent DMs by their expected_ack codes.
+
+ Query params:
+ ack_codes: comma-separated list of expected_ack hex codes
+
+ Response JSON:
+ {
+ "success": true,
+ "acks": {
+ "544a4d8f": {"snr": 13.0, "rssi": -32, "route": "DIRECT", "ts": 1706500000.123},
+ "ff3b55ce": null
+ }
+ }
+ """
+ if not meshcli_session:
+ return jsonify({'success': False, 'error': 'Not initialized'}), 503
+
+ requested = request.args.get('ack_codes', '')
+ codes = [c.strip() for c in requested.split(',') if c.strip()]
+
+ result = {}
+ for code in codes:
+ result[code] = meshcli_session.acks.get(code)
+
+ return jsonify({'success': True, 'acks': result}), 200
+
+
# =============================================================================
# WebSocket handlers for console
# =============================================================================