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

View File

@@ -11,7 +11,7 @@ from typing import List, Dict, Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.config import config
from app.config import config, runtime_config
logger = logging.getLogger(__name__)
@@ -30,7 +30,7 @@ def get_archive_path(archive_date: str) -> Path:
Path to archive file
"""
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
@@ -66,7 +66,7 @@ def archive_messages(archive_date: Optional[str] = None) -> Dict[str, any]:
archive_dir.mkdir(parents=True, exist_ok=True)
# Get source .msgs file
source_file = config.msgs_file_path
source_file = runtime_config.get_msgs_file_path()
if not source_file.exists():
logger.warning(f"Source messages file not found: {source_file}")
return {
@@ -129,14 +129,14 @@ def list_archives() -> List[Dict]:
return []
# 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):
try:
# Extract date from filename
# Format: DeviceName.YYYY-MM-DD.msgs
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
try:

View File

@@ -3,7 +3,11 @@ Configuration module - loads settings from environment variables
"""
import os
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
class Config:
@@ -51,3 +55,40 @@ class Config:
# Global config instance
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()

View File

@@ -5,13 +5,15 @@ mc-webui - Flask application entry point
import logging
import re
import shlex
import threading
import requests
from flask import Flask, request as flask_request
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.api import api_bp
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)
SLOW_COMMANDS = {
@@ -57,6 +59,13 @@ def create_app():
else:
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"Messages file: {config.msgs_file_path}")
logger.info(f"Serial port: {config.MC_SERIAL_PORT}")

View File

@@ -5,6 +5,7 @@ MeshCore CLI wrapper - executes meshcli commands via HTTP bridge
import logging
import re
import json
import time
import requests
from pathlib import Path
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'
except Exception as 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"

View File

@@ -9,7 +9,7 @@ import time
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta
from app.config import config
from app.config import config, runtime_config
logger = logging.getLogger(__name__)
@@ -48,7 +48,7 @@ def parse_message(line: Dict, allowed_channels: Optional[List[int]] = None) -> O
# Extract sender name
if is_own:
# 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
else:
# 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:
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():
logger.warning(f"Messages file not found: {msgs_file}")
@@ -268,7 +268,7 @@ def delete_channel_messages(channel_idx: int) -> bool:
Returns:
True if successful, False otherwise
"""
msgs_file = config.msgs_file_path
msgs_file = runtime_config.get_msgs_file_path()
if not msgs_file.exists():
logger.warning(f"Messages file not found: {msgs_file}")
@@ -338,7 +338,7 @@ def _cleanup_old_dm_sent_log() -> None:
return
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():
dm_log_file.unlink()
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)
# Use 'recipient' field (added in meshcore-cli 1.3.12), fallback to 'name'
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
conversation_id = f"name_{recipient}"
@@ -471,7 +471,7 @@ def read_dm_messages(
_cleanup_old_dm_sent_log()
# --- Read DM messages from .msgs file ---
msgs_file = config.msgs_file_path
msgs_file = runtime_config.get_msgs_file_path()
if msgs_file.exists():
try:
with open(msgs_file, 'r', encoding='utf-8') as f:

View File

@@ -11,7 +11,7 @@ from datetime import datetime
from io import BytesIO
from flask import Blueprint, jsonify, request, send_file
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
logger = logging.getLogger(__name__)
@@ -209,7 +209,8 @@ def get_status():
return jsonify({
'success': True,
'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,
'message_count': message_count,
'latest_message_timestamp': latest_timestamp

View File

@@ -4,7 +4,7 @@ HTML views for mc-webui
import logging
from flask import Blueprint, render_template, request
from app.config import config
from app.config import config, runtime_config
logger = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ def index():
"""
return render_template(
'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(
'dm.html',
device_name=config.MC_DEVICE_NAME,
device_name=runtime_config.get_device_name(),
initial_conversation=initial_conversation
)
@@ -46,7 +46,7 @@ def contact_management():
"""
return render_template(
'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(
'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(
'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(
'console.html',
device_name=config.MC_DEVICE_NAME
device_name=runtime_config.get_device_name()
)

View File

@@ -19,6 +19,7 @@ import json
import queue
import uuid
import shlex
import re
from pathlib import Path
from flask import Flask, request, jsonify
from flask_socketio import SocketIO, emit
@@ -57,6 +58,10 @@ class MeshCLISession:
self.config_dir = Path(config_dir)
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
self.config_dir.mkdir(parents=True, exist_ok=True)
self.advert_log_path = self.config_dir / f"{device_name}.adverts.jsonl"
@@ -178,6 +183,37 @@ class MeshCLISession:
except Exception as 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):
"""Thread: Read stdout line-by-line, parse adverts vs CLI responses"""
logger.info("stdout reader thread started")
@@ -191,6 +227,14 @@ class MeshCLISession:
if not line:
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
if self._is_advert_json(line):
self._log_advert(line)
@@ -540,13 +584,24 @@ meshcli_session = None
@app.route('/health', methods=['GET'])
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"
# 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({
'status': session_status,
'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