mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
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:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
11
app/main.py
11
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}")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user