feat: Auto-detect device name from meshcli prompt

Bridge now detects device name from meshcli prompt ("DeviceName|*")
and exposes it via /health endpoint. mc-webui fetches this at startup
and uses RuntimeConfig for dynamic device name throughout the app.

Fallback chain: prompt detection → .infos command → MC_DEVICE_NAME env var

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-15 07:48:10 +01:00
parent 6000750e6c
commit c7163aa035
8 changed files with 178 additions and 24 deletions
+5 -5
View File
@@ -11,7 +11,7 @@ from typing import List, Dict, Optional
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from app.config import config from app.config import config, runtime_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +30,7 @@ def get_archive_path(archive_date: str) -> Path:
Path to archive file Path to archive file
""" """
archive_dir = config.archive_dir_path archive_dir = config.archive_dir_path
filename = f"{config.MC_DEVICE_NAME}.{archive_date}.msgs" filename = f"{runtime_config.get_device_name()}.{archive_date}.msgs"
return archive_dir / filename return archive_dir / filename
@@ -66,7 +66,7 @@ def archive_messages(archive_date: Optional[str] = None) -> Dict[str, any]:
archive_dir.mkdir(parents=True, exist_ok=True) archive_dir.mkdir(parents=True, exist_ok=True)
# Get source .msgs file # Get source .msgs file
source_file = config.msgs_file_path source_file = runtime_config.get_msgs_file_path()
if not source_file.exists(): if not source_file.exists():
logger.warning(f"Source messages file not found: {source_file}") logger.warning(f"Source messages file not found: {source_file}")
return { return {
@@ -129,14 +129,14 @@ def list_archives() -> List[Dict]:
return [] return []
# Pattern: {device_name}.YYYY-MM-DD.msgs # Pattern: {device_name}.YYYY-MM-DD.msgs
pattern = f"{config.MC_DEVICE_NAME}.*.msgs" pattern = f"{runtime_config.get_device_name()}.*.msgs"
for archive_file in archive_dir.glob(pattern): for archive_file in archive_dir.glob(pattern):
try: try:
# Extract date from filename # Extract date from filename
# Format: DeviceName.YYYY-MM-DD.msgs # Format: DeviceName.YYYY-MM-DD.msgs
filename = archive_file.name filename = archive_file.name
date_part = filename.replace(f"{config.MC_DEVICE_NAME}.", "").replace(".msgs", "") date_part = filename.replace(f"{runtime_config.get_device_name()}.", "").replace(".msgs", "")
# Validate date format # Validate date format
try: try:
+41
View File
@@ -3,7 +3,11 @@ Configuration module - loads settings from environment variables
""" """
import os import os
import logging
from pathlib import Path from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
class Config: class Config:
@@ -51,3 +55,40 @@ class Config:
# Global config instance # Global config instance
config = Config() config = Config()
class RuntimeConfig:
"""
Runtime configuration that can be updated after startup.
Used for values that are detected/fetched at runtime, like
device name from bridge auto-detection.
"""
_device_name: Optional[str] = None
_device_name_source: str = "config"
@classmethod
def set_device_name(cls, name: str, source: str = "detected"):
"""Set the runtime device name"""
cls._device_name = name
cls._device_name_source = source
logger.info(f"Runtime device name set: {name} (source: {source})")
@classmethod
def get_device_name(cls) -> str:
"""Get device name - prefers runtime value, falls back to config"""
return cls._device_name or config.MC_DEVICE_NAME
@classmethod
def get_device_name_source(cls) -> str:
"""Get the source of the device name (detected/config/fallback)"""
return cls._device_name_source
@classmethod
def get_msgs_file_path(cls) -> Path:
"""Get the full path to the .msgs file using runtime device name"""
return Path(config.MC_CONFIG_DIR) / f"{cls.get_device_name()}.msgs"
# Global runtime config instance
runtime_config = RuntimeConfig()
+10 -1
View File
@@ -5,13 +5,15 @@ mc-webui - Flask application entry point
import logging import logging
import re import re
import shlex import shlex
import threading
import requests import requests
from flask import Flask, request as flask_request from flask import Flask, request as flask_request
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
from app.config import config from app.config import config, runtime_config
from app.routes.views import views_bp from app.routes.views import views_bp
from app.routes.api import api_bp from app.routes.api import api_bp
from app.archiver.manager import schedule_daily_archiving from app.archiver.manager import schedule_daily_archiving
from app.meshcore.cli import fetch_device_name_from_bridge
# Commands that require longer timeout (in seconds) # Commands that require longer timeout (in seconds)
SLOW_COMMANDS = { SLOW_COMMANDS = {
@@ -57,6 +59,13 @@ def create_app():
else: else:
logger.info("Archive scheduler disabled") logger.info("Archive scheduler disabled")
# Fetch device name from bridge in background thread
def init_device_name():
device_name, source = fetch_device_name_from_bridge()
runtime_config.set_device_name(device_name, source)
threading.Thread(target=init_device_name, daemon=True).start()
logger.info(f"mc-webui started - device: {config.MC_DEVICE_NAME}") logger.info(f"mc-webui started - device: {config.MC_DEVICE_NAME}")
logger.info(f"Messages file: {config.msgs_file_path}") logger.info(f"Messages file: {config.msgs_file_path}")
logger.info(f"Serial port: {config.MC_SERIAL_PORT}") logger.info(f"Serial port: {config.MC_SERIAL_PORT}")
+48
View File
@@ -5,6 +5,7 @@ MeshCore CLI wrapper - executes meshcli commands via HTTP bridge
import logging import logging
import re import re
import json import json
import time
import requests import requests
from pathlib import Path from pathlib import Path
from typing import Tuple, Optional, List, Dict from typing import Tuple, Optional, List, Dict
@@ -965,3 +966,50 @@ def set_manual_add_contacts(enabled: bool) -> Tuple[bool, str]:
return False, 'Cannot connect to meshcore-bridge service' return False, 'Cannot connect to meshcore-bridge service'
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
# =============================================================================
# Device Name Detection
# =============================================================================
def fetch_device_name_from_bridge(max_retries: int = 3, retry_delay: float = 2.0) -> Tuple[Optional[str], str]:
"""
Fetch detected device name from meshcore-bridge /health endpoint.
The bridge auto-detects device name from meshcli prompt ("DeviceName|*")
and exposes it via /health endpoint.
Args:
max_retries: Number of retry attempts if bridge is unavailable
retry_delay: Delay between retries in seconds
Returns:
Tuple of (device_name, source)
- device_name: Detected name or fallback from config
- source: "detected", "config", or "fallback"
"""
bridge_health_url = config.MC_BRIDGE_URL.replace('/cli', '/health')
for attempt in range(max_retries):
try:
response = requests.get(bridge_health_url, timeout=5)
if response.status_code == 200:
data = response.json()
if data.get('status') == 'healthy':
device_name = data.get('device_name')
source = data.get('device_name_source', 'unknown')
if device_name:
logger.info(f"Got device name from bridge: {device_name} (source: {source})")
return device_name, source
except requests.exceptions.ConnectionError:
logger.warning(f"Bridge not reachable, attempt {attempt + 1}/{max_retries}")
except requests.exceptions.Timeout:
logger.warning(f"Bridge timeout, attempt {attempt + 1}/{max_retries}")
except Exception as e:
logger.warning(f"Attempt {attempt + 1}/{max_retries} failed: {e}")
if attempt < max_retries - 1:
time.sleep(retry_delay)
logger.warning(f"Using fallback device name: {config.MC_DEVICE_NAME}")
return config.MC_DEVICE_NAME, "fallback"
+7 -7
View File
@@ -9,7 +9,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.config import config from app.config import config, runtime_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,7 +48,7 @@ def parse_message(line: Dict, allowed_channels: Optional[List[int]] = None) -> O
# Extract sender name # Extract sender name
if is_own: if is_own:
# For sent messages, use 'sender' field (meshcore-cli 1.3.12+) # For sent messages, use 'sender' field (meshcore-cli 1.3.12+)
sender = line.get('sender', config.MC_DEVICE_NAME) sender = line.get('sender', runtime_config.get_device_name())
content = text content = text
else: else:
# For received messages, extract sender from "SenderName: message" format # For received messages, extract sender from "SenderName: message" format
@@ -91,7 +91,7 @@ def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Op
if archive_date: if archive_date:
return read_archive_messages(archive_date, limit, offset, channel_idx) return read_archive_messages(archive_date, limit, offset, channel_idx)
msgs_file = config.msgs_file_path msgs_file = runtime_config.get_msgs_file_path()
if not msgs_file.exists(): if not msgs_file.exists():
logger.warning(f"Messages file not found: {msgs_file}") logger.warning(f"Messages file not found: {msgs_file}")
@@ -268,7 +268,7 @@ def delete_channel_messages(channel_idx: int) -> bool:
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
""" """
msgs_file = config.msgs_file_path msgs_file = runtime_config.get_msgs_file_path()
if not msgs_file.exists(): if not msgs_file.exists():
logger.warning(f"Messages file not found: {msgs_file}") logger.warning(f"Messages file not found: {msgs_file}")
@@ -338,7 +338,7 @@ def _cleanup_old_dm_sent_log() -> None:
return return
try: try:
dm_log_file = Path(config.MC_CONFIG_DIR) / f"{config.MC_DEVICE_NAME}_dm_sent.jsonl" dm_log_file = Path(config.MC_CONFIG_DIR) / f"{runtime_config.get_device_name()}_dm_sent.jsonl"
if dm_log_file.exists(): if dm_log_file.exists():
dm_log_file.unlink() dm_log_file.unlink()
logger.info(f"Cleaned up old DM sent log: {dm_log_file}") logger.info(f"Cleaned up old DM sent log: {dm_log_file}")
@@ -420,7 +420,7 @@ def _parse_sent_msg(line: Dict) -> Optional[Dict]:
timestamp = line.get('timestamp', 0) timestamp = line.get('timestamp', 0)
# Use 'recipient' field (added in meshcore-cli 1.3.12), fallback to 'name' # Use 'recipient' field (added in meshcore-cli 1.3.12), fallback to 'name'
recipient = line.get('recipient', line.get('name', 'Unknown')) recipient = line.get('recipient', line.get('name', 'Unknown'))
sender = line.get('sender', config.MC_DEVICE_NAME) sender = line.get('sender', runtime_config.get_device_name())
# Generate conversation ID from recipient name # Generate conversation ID from recipient name
conversation_id = f"name_{recipient}" conversation_id = f"name_{recipient}"
@@ -471,7 +471,7 @@ def read_dm_messages(
_cleanup_old_dm_sent_log() _cleanup_old_dm_sent_log()
# --- Read DM messages from .msgs file --- # --- Read DM messages from .msgs file ---
msgs_file = config.msgs_file_path msgs_file = runtime_config.get_msgs_file_path()
if msgs_file.exists(): if msgs_file.exists():
try: try:
with open(msgs_file, 'r', encoding='utf-8') as f: with open(msgs_file, 'r', encoding='utf-8') as f:
+3 -2
View File
@@ -11,7 +11,7 @@ from datetime import datetime
from io import BytesIO from io import BytesIO
from flask import Blueprint, jsonify, request, send_file from flask import Blueprint, jsonify, request, send_file
from app.meshcore import cli, parser from app.meshcore import cli, parser
from app.config import config from app.config import config, runtime_config
from app.archiver import manager as archive_manager from app.archiver import manager as archive_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -209,7 +209,8 @@ def get_status():
return jsonify({ return jsonify({
'success': True, 'success': True,
'connected': connected, 'connected': connected,
'device_name': config.MC_DEVICE_NAME, 'device_name': runtime_config.get_device_name(),
'device_name_source': runtime_config.get_device_name_source(),
'serial_port': config.MC_SERIAL_PORT, 'serial_port': config.MC_SERIAL_PORT,
'message_count': message_count, 'message_count': message_count,
'latest_message_timestamp': latest_timestamp 'latest_message_timestamp': latest_timestamp
+7 -7
View File
@@ -4,7 +4,7 @@ HTML views for mc-webui
import logging import logging
from flask import Blueprint, render_template, request from flask import Blueprint, render_template, request
from app.config import config from app.config import config, runtime_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ def index():
""" """
return render_template( return render_template(
'index.html', 'index.html',
device_name=config.MC_DEVICE_NAME device_name=runtime_config.get_device_name()
) )
@@ -34,7 +34,7 @@ def direct_messages():
return render_template( return render_template(
'dm.html', 'dm.html',
device_name=config.MC_DEVICE_NAME, device_name=runtime_config.get_device_name(),
initial_conversation=initial_conversation initial_conversation=initial_conversation
) )
@@ -46,7 +46,7 @@ def contact_management():
""" """
return render_template( return render_template(
'contacts-manage.html', 'contacts-manage.html',
device_name=config.MC_DEVICE_NAME device_name=runtime_config.get_device_name()
) )
@@ -57,7 +57,7 @@ def contact_pending_list():
""" """
return render_template( return render_template(
'contacts-pending.html', 'contacts-pending.html',
device_name=config.MC_DEVICE_NAME device_name=runtime_config.get_device_name()
) )
@@ -68,7 +68,7 @@ def contact_existing_list():
""" """
return render_template( return render_template(
'contacts-existing.html', 'contacts-existing.html',
device_name=config.MC_DEVICE_NAME device_name=runtime_config.get_device_name()
) )
@@ -81,7 +81,7 @@ def console():
""" """
return render_template( return render_template(
'console.html', 'console.html',
device_name=config.MC_DEVICE_NAME device_name=runtime_config.get_device_name()
) )
+57 -2
View File
@@ -19,6 +19,7 @@ import json
import queue import queue
import uuid import uuid
import shlex import shlex
import re
from pathlib import Path from pathlib import Path
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
@@ -57,6 +58,10 @@ class MeshCLISession:
self.config_dir = Path(config_dir) self.config_dir = Path(config_dir)
self.device_name = device_name self.device_name = device_name
# Auto-detected device name from meshcli prompt
self.detected_name = None
self.name_detection_done = threading.Event()
# Ensure config directory exists # Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True) self.config_dir.mkdir(parents=True, exist_ok=True)
self.advert_log_path = self.config_dir / f"{device_name}.adverts.jsonl" self.advert_log_path = self.config_dir / f"{device_name}.adverts.jsonl"
@@ -178,6 +183,37 @@ class MeshCLISession:
except Exception as e: except Exception as e:
logger.error(f"Failed to apply session settings: {e}") logger.error(f"Failed to apply session settings: {e}")
# Wait for device name detection from prompt, then fallback to .infos
if not self.name_detection_done.wait(timeout=1.0):
logger.info("Device name not detected from prompt, trying .infos command")
self._detect_name_from_infos()
def _detect_name_from_infos(self):
"""Fallback: detect device name via .infos command"""
if self.detected_name:
return
try:
result = self.execute_command(['.infos'], timeout=5)
if result['success'] and result['stdout']:
# Try to parse JSON output from .infos
stdout = result['stdout'].strip()
# Find JSON object in output
for line in stdout.split('\n'):
line = line.strip()
if line.startswith('{'):
try:
data = json.loads(line)
if 'name' in data:
self.detected_name = data['name']
logger.info(f"Detected device name from .infos: {self.detected_name}")
self.name_detection_done.set()
return
except json.JSONDecodeError:
continue
except Exception as e:
logger.warning(f"Failed to detect device name from .infos: {e}")
def _read_stdout(self): def _read_stdout(self):
"""Thread: Read stdout line-by-line, parse adverts vs CLI responses""" """Thread: Read stdout line-by-line, parse adverts vs CLI responses"""
logger.info("stdout reader thread started") logger.info("stdout reader thread started")
@@ -191,6 +227,14 @@ class MeshCLISession:
if not line: if not line:
continue continue
# Detect device name from meshcli prompt: "DeviceName|*" or "DeviceName|*[E]"
if not self.detected_name and '|*' in line:
prompt_match = re.match(r'^(.+?)\|\*', line)
if prompt_match:
self.detected_name = prompt_match.group(1).strip()
logger.info(f"Detected device name from prompt: {self.detected_name}")
self.name_detection_done.set()
# Try to parse as JSON advert # Try to parse as JSON advert
if self._is_advert_json(line): if self._is_advert_json(line):
self._log_advert(line) self._log_advert(line)
@@ -540,13 +584,24 @@ meshcli_session = None
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health(): def health():
"""Health check endpoint""" """Health check endpoint with device name detection info"""
session_status = "healthy" if meshcli_session and meshcli_session.process and meshcli_session.process.poll() is None else "unhealthy" session_status = "healthy" if meshcli_session and meshcli_session.process and meshcli_session.process.poll() is None else "unhealthy"
# Determine device name and source
detected_name = meshcli_session.detected_name if meshcli_session else None
device_name = detected_name or MC_DEVICE_NAME
name_source = "detected" if detected_name else "config"
# Log warning if there's a mismatch between detected and configured names
if detected_name and detected_name != MC_DEVICE_NAME:
logger.warning(f"Device name mismatch: detected='{detected_name}', configured='{MC_DEVICE_NAME}'")
return jsonify({ return jsonify({
'status': session_status, 'status': session_status,
'serial_port': MC_SERIAL_PORT, 'serial_port': MC_SERIAL_PORT,
'advert_log': str(meshcli_session.advert_log_path) if meshcli_session else None 'advert_log': str(meshcli_session.advert_log_path) if meshcli_session else None,
'device_name': device_name,
'device_name_source': name_source
}), 200 }), 200