1
0
forked from iarv/mc-webui
Files
mc-webui/app/main.py
MarekWo ed8cab6dc5 feat: Add repeater commands to slow commands list
Added req_status, req_neighbours, and trace commands with 15s timeout
as they communicate with repeaters and need time for responses.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:18:07 +01:00

273 lines
9.0 KiB
Python

"""
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, runtime_config
from app.routes.views import views_bp
from app.routes.api import api_bp
from app.version import VERSION_STRING, GIT_BRANCH
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 = {
'node_discover': 15,
'recv': 60,
'send': 15,
'send_msg': 15,
# Repeater commands
'req_status': 15,
'req_neighbours': 15,
'trace': 15,
}
# Configure logging
logging.basicConfig(
level=logging.DEBUG if config.FLASK_DEBUG else logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Filter to suppress known werkzeug WebSocket errors (cosmetic issue with dev server)
class WerkzeugWebSocketFilter(logging.Filter):
def filter(self, record):
# Suppress "write() before start_response" errors during WebSocket upgrade
if record.levelno == logging.ERROR:
# Check message
if 'write() before start_response' in str(record.msg):
return False
# Check exception info (traceback)
if record.exc_info and record.exc_info[1]:
if 'write() before start_response' in str(record.exc_info[1]):
return False
return True
# Apply filter to werkzeug logger
logging.getLogger('werkzeug').addFilter(WerkzeugWebSocketFilter())
# Initialize SocketIO globally
socketio = SocketIO()
def create_app():
"""Create and configure Flask application"""
app = Flask(__name__)
# Load configuration
app.config['DEBUG'] = config.FLASK_DEBUG
app.config['SECRET_KEY'] = 'mc-webui-secret-key-change-in-production'
# Inject version and branch into all templates
@app.context_processor
def inject_version():
return {'version': VERSION_STRING, 'git_branch': GIT_BRANCH}
# Register blueprints
app.register_blueprint(views_bp)
app.register_blueprint(api_bp)
# Initialize SocketIO with the app
# Using 'threading' mode for better compatibility with regular HTTP requests
# (gevent mode requires monkey-patching and slows down non-WebSocket requests)
socketio.init_app(app, cors_allowed_origins="*", async_mode='threading')
# Initialize archive scheduler if enabled
if config.MC_ARCHIVE_ENABLED:
schedule_daily_archiving()
logger.info(f"Archive scheduler enabled - directory: {config.MC_ARCHIVE_DIR}")
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}")
return app
# ============================================================
# Console output helpers
# ============================================================
def clean_console_output(output: str, command: str) -> str:
"""
Clean meshcli console output by removing:
- Prompt lines (e.g., "MarWoj|*" or "DeviceName|*[E]")
- JSON packet lines (internal mesh protocol data)
- Echoed command line
- Leading/trailing whitespace
"""
if not output:
return output
lines = output.split('\n')
cleaned_lines = []
# Pattern to match any line containing the meshcli prompt "|*"
# Examples: "MarWoj|*", "MarWoj|*[E]", "MarWoj|*[E] infos"
# The prompt is: <name>|*<optional_status><optional_space><optional_command>
prompt_pattern = re.compile(r'^[^|]+\|\*')
for line in lines:
stripped = line.rstrip()
# Skip empty lines at start
if not cleaned_lines and not stripped:
continue
# Skip any line that starts with the meshcli prompt pattern
if prompt_pattern.match(stripped):
continue
# Skip JSON packet lines (internal mesh protocol data)
stripped_full = stripped.lstrip()
if stripped_full.startswith('{') and '"payload_typename"' in stripped_full:
continue
cleaned_lines.append(line)
# Remove leading empty lines
while cleaned_lines and not cleaned_lines[0].strip():
cleaned_lines.pop(0)
# Remove trailing empty lines
while cleaned_lines and not cleaned_lines[-1].strip():
cleaned_lines.pop()
# Strip leading whitespace from first line (leftover from prompt removal)
if cleaned_lines:
cleaned_lines[0] = cleaned_lines[0].lstrip()
return '\n'.join(cleaned_lines)
# ============================================================
# WebSocket handlers for Console
# ============================================================
@socketio.on('connect', namespace='/console')
def handle_console_connect():
"""Handle console WebSocket connection"""
logger.info("Console WebSocket client connected")
emit('console_status', {'message': 'Connected to mc-webui console proxy'})
@socketio.on('disconnect', namespace='/console')
def handle_console_disconnect():
"""Handle console WebSocket disconnection"""
logger.info("Console WebSocket client disconnected")
@socketio.on('send_command', namespace='/console')
def handle_send_command(data):
"""Handle command from console client - proxy to bridge via HTTP"""
command = data.get('command', '').strip()
# Capture socket ID for use in background task
sid = flask_request.sid
if not command:
emit('command_response', {
'success': False,
'error': 'Empty command'
})
return
logger.info(f"Console command received: {command}")
# Execute command via bridge HTTP API
# Parse command into args list (split by spaces, respecting quotes)
try:
args = shlex.split(command)
except ValueError:
args = command.split()
# Determine timeout based on command
base_command = args[0] if args else ''
cmd_timeout = SLOW_COMMANDS.get(base_command, 10)
def execute_and_respond():
try:
response = requests.post(
config.MC_BRIDGE_URL,
json={'args': args, 'timeout': cmd_timeout},
timeout=cmd_timeout + 5 # HTTP timeout slightly longer
)
if response.status_code == 200:
result = response.json()
if result.get('success'):
raw_output = result.get('stdout', '')
# Clean output: remove prompts and echoed commands
output = clean_console_output(raw_output, command)
if not output:
output = '(no output)'
socketio.emit('command_response', {
'success': True,
'command': command,
'output': output
}, room=sid, namespace='/console')
else:
error = result.get('stderr', 'Unknown error')
socketio.emit('command_response', {
'success': False,
'command': command,
'error': error
}, room=sid, namespace='/console')
else:
socketio.emit('command_response', {
'success': False,
'command': command,
'error': f'Bridge returned status {response.status_code}'
}, room=sid, namespace='/console')
except requests.exceptions.Timeout:
socketio.emit('command_response', {
'success': False,
'command': command,
'error': 'Command timed out'
}, room=sid, namespace='/console')
except requests.exceptions.ConnectionError:
socketio.emit('command_response', {
'success': False,
'command': command,
'error': 'Cannot connect to meshcore-bridge'
}, room=sid, namespace='/console')
except Exception as e:
logger.error(f"Console command error: {e}")
socketio.emit('command_response', {
'success': False,
'command': command,
'error': str(e)
}, room=sid, namespace='/console')
# Run in background to not block
socketio.start_background_task(execute_and_respond)
if __name__ == '__main__':
app = create_app()
socketio.run(
app,
host=config.FLASK_HOST,
port=config.FLASK_PORT,
debug=config.FLASK_DEBUG,
allow_unsafe_werkzeug=True # Required for threading mode
)