From c7163aa0352343a4fbf0bf91ed1f10c293c400a4 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Thu, 15 Jan 2026 07:48:10 +0100 Subject: [PATCH] feat: Auto-detect device name from meshcli prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/archiver/manager.py | 10 +++---- app/config.py | 41 +++++++++++++++++++++++++++ app/main.py | 11 +++++++- app/meshcore/cli.py | 48 +++++++++++++++++++++++++++++++ app/meshcore/parser.py | 14 +++++----- app/routes/api.py | 5 ++-- app/routes/views.py | 14 +++++----- meshcore-bridge/bridge.py | 59 +++++++++++++++++++++++++++++++++++++-- 8 files changed, 178 insertions(+), 24 deletions(-) diff --git a/app/archiver/manager.py b/app/archiver/manager.py index a686244..17641d3 100644 --- a/app/archiver/manager.py +++ b/app/archiver/manager.py @@ -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: diff --git a/app/config.py b/app/config.py index 73f6f9f..29cbf0f 100644 --- a/app/config.py +++ b/app/config.py @@ -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() diff --git a/app/main.py b/app/main.py index 99828ce..2acb56b 100644 --- a/app/main.py +++ b/app/main.py @@ -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}") diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index dae93df..0eb4ae7 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -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" diff --git a/app/meshcore/parser.py b/app/meshcore/parser.py index 7720c34..d9f7230 100644 --- a/app/meshcore/parser.py +++ b/app/meshcore/parser.py @@ -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: diff --git a/app/routes/api.py b/app/routes/api.py index 143c9ff..75ae31b 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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 diff --git a/app/routes/views.py b/app/routes/views.py index 15f9927..22a984a 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -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() ) diff --git a/meshcore-bridge/bridge.py b/meshcore-bridge/bridge.py index 92ecac1..b8ccfaf 100644 --- a/meshcore-bridge/bridge.py +++ b/meshcore-bridge/bridge.py @@ -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