From 4608665e82aa4df112de2980931826a8258ff451 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 23 Dec 2025 08:39:10 +0100 Subject: [PATCH] refactor: Implement 2-container architecture to solve USB stability issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Switched from single-container to multi-container setup This commit introduces a meshcore-bridge service that isolates USB device access from the main application, resolving persistent USB timeout and deadlock issues in Docker + VM environments. Changes: - Add meshcore-bridge/ - Lightweight HTTP API wrapper for meshcli - Flask server exposes /cli endpoint (port 5001, internal only) - Exclusive USB device access via --device flag - Health check endpoint at /health - Refactor app/meshcore/cli.py - Replace subprocess calls with HTTP requests to bridge - Add requests library dependency - Better error handling for bridge communication - Update docker-compose.yml - Define meshcore-bridge and mc-webui services - Create meshcore-net Docker network - Add depends_on with health check condition - Bridge gets USB device, main app uses HTTP only - Modify Dockerfile - Remove meshcore-cli installation from main app - Lighter image without gcc dependencies - Update config.py - Add MC_BRIDGE_URL environment variable - Remove meshcli_command property (no longer needed) - Update documentation (README.md, .claude/instructions.md) - Document 2-container architecture - Add troubleshooting section for bridge - Update prerequisites (no host meshcore-cli needed) - Add architecture diagram in project structure Benefits: ✅ Solves USB device locking after container restarts ✅ Restartable main app without USB reset ✅ Better separation of concerns ✅ Easier debugging (isolated meshcli logs) ✅ No manual USB recovery scripts needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Dockerfile | 10 +- README.md | 94 ++++++++++++++----- app/config.py | 8 +- app/meshcore/cli.py | 53 ++++++----- docker-compose.yml | 36 +++++++- meshcore-bridge/Dockerfile | 28 ++++++ meshcore-bridge/bridge.py | 152 +++++++++++++++++++++++++++++++ meshcore-bridge/requirements.txt | 3 + requirements.txt | 4 +- 9 files changed, 321 insertions(+), 67 deletions(-) create mode 100644 meshcore-bridge/Dockerfile create mode 100644 meshcore-bridge/bridge.py create mode 100644 meshcore-bridge/requirements.txt diff --git a/Dockerfile b/Dockerfile index e8d4e61..c8b8ef7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,17 @@ # mc-webui Dockerfile -# Python 3.11+ with Flask and meshcore-cli +# Python 3.11+ with Flask (meshcore-cli runs in separate bridge container) FROM python:3.11-slim # Set working directory WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - && rm -rf /var/lib/apt/lists/* - # Copy requirements first for better layer caching COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Install meshcore-cli -RUN pip install --no-cache-dir meshcore-cli - # Copy application code COPY app/ ./app/ diff --git a/README.md b/README.md index 0058414..26feaee 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ A lightweight web interface for meshcore-cli, providing browser-based access to - **Backend:** Python 3.11+, Flask - **Frontend:** HTML5, Bootstrap 5, vanilla JavaScript -- **Deployment:** Docker / Docker Compose -- **Communication:** subprocess calls to `meshcli` +- **Deployment:** Docker / Docker Compose (2-container architecture) +- **Communication:** HTTP bridge to meshcore-cli (USB isolation for stability) - **Data source:** `~/.config/meshcore/.msgs` (JSON Lines) ## Quick Start @@ -32,7 +32,8 @@ A lightweight web interface for meshcore-cli, providing browser-based access to - Docker and Docker Compose installed - Heltec V4 device connected via USB -- meshcore-cli configured on host system + +**Note:** meshcore-cli is automatically installed inside the Docker container - no host installation required! ### Installation @@ -90,38 +91,58 @@ All configuration is done via environment variables in the `.env` file: See [.env.example](.env.example) for a complete example. +## Architecture + +mc-webui uses a **2-container architecture** for improved USB stability: + +1. **meshcore-bridge** - Lightweight service with exclusive USB device access + - Runs meshcore-cli subprocess calls + - Exposes HTTP API on port 5001 (internal only) + - Automatically restarts on USB communication issues + +2. **mc-webui** - Main web application + - Flask-based web interface + - Communicates with bridge via HTTP + - No direct USB access (prevents device locking) + +This separation solves USB timeout/deadlock issues common in Docker + VM environments. + ## Project Structure ``` mc-webui/ -├── Dockerfile # Docker image definition -├── docker-compose.yml # Docker Compose configuration +├── Dockerfile # Main app Docker image +├── docker-compose.yml # Multi-container orchestration +├── meshcore-bridge/ +│ ├── Dockerfile # Bridge service image +│ ├── bridge.py # HTTP API wrapper for meshcli +│ └── requirements.txt # Bridge dependencies (Flask only) ├── app/ │ ├── __init__.py -│ ├── main.py # Flask entry point -│ ├── config.py # Configuration from env vars +│ ├── main.py # Flask entry point +│ ├── config.py # Configuration from env vars │ ├── meshcore/ │ │ ├── __init__.py -│ │ ├── cli.py # meshcli wrapper (subprocess) -│ │ └── parser.py # .msgs file parser +│ │ ├── cli.py # HTTP client for bridge API +│ │ └── parser.py # .msgs file parser │ ├── routes/ │ │ ├── __init__.py -│ │ ├── api.py # REST API endpoints -│ │ └── views.py # HTML views +│ │ ├── api.py # REST API endpoints +│ │ └── views.py # HTML views │ ├── static/ │ │ ├── css/ -│ │ │ └── style.css # Custom styles +│ │ │ └── style.css # Custom styles │ │ └── js/ -│ │ └── app.js # Frontend logic +│ │ └── app.js # Frontend logic │ └── templates/ -│ ├── base.html # Base template -│ ├── index.html # Main chat view -│ └── components/ # Reusable components -├── requirements.txt # Python dependencies -├── .env.example # Example environment config +│ ├── base.html # Base template +│ ├── index.html # Main chat view +│ └── components/ # Reusable components +├── requirements.txt # Python dependencies +├── .env.example # Example environment config ├── .gitignore -├── README.md # This file -└── PRD.md # Product Requirements Document +├── README.md # This file +└── PRD.md # Product Requirements Document ``` ## Development Status @@ -233,20 +254,43 @@ sudo chmod 666 /dev/serial/by-id/usb-Espressif* ### Container won't start ```bash -# Check logs +# Check logs for both services +docker compose logs meshcore-bridge docker compose logs mc-webui # Verify .env file exists ls -la .env -# Check if port 5000 is available -sudo netstat -tulpn | grep 5000 +# Check if ports are available +sudo netstat -tulpn | grep -E '5000|5001' +``` + +### USB Communication Issues +The 2-container architecture resolves common USB timeout/deadlock problems: +- **meshcore-bridge** has exclusive USB access +- **mc-webui** uses HTTP (no direct device access) +- Restarting `mc-webui` **does not** affect USB connection +- If bridge has USB issues, restart only that service: + ```bash + docker compose restart meshcore-bridge + ``` + +### Bridge connection errors +```bash +# Check bridge health +docker compose exec mc-webui curl http://meshcore-bridge:5001/health + +# Bridge logs +docker compose logs -f meshcore-bridge + +# Test meshcli directly in bridge container +docker compose exec meshcore-bridge meshcli -s /dev/ttyUSB0 infos ``` ### Messages not updating -- Ensure meshcore-cli is properly configured - Check that `.msgs` file exists in `MC_CONFIG_DIR` -- Verify serial device is accessible from container +- Verify bridge service is healthy: `docker compose ps` +- Check bridge logs for command errors ## Security Notes diff --git a/app/config.py b/app/config.py index 462e783..09430a4 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,9 @@ class Config: MC_DEVICE_NAME = os.getenv('MC_DEVICE_NAME', 'MeshCore') MC_CONFIG_DIR = os.getenv('MC_CONFIG_DIR', '/root/.config/meshcore') + # MeshCore Bridge configuration + MC_BRIDGE_URL = os.getenv('MC_BRIDGE_URL', 'http://meshcore-bridge:5001/cli') + # Application settings MC_REFRESH_INTERVAL = int(os.getenv('MC_REFRESH_INTERVAL', '60')) MC_INACTIVE_HOURS = int(os.getenv('MC_INACTIVE_HOURS', '48')) @@ -34,11 +37,6 @@ class Config: """Get the full path to the .msgs file""" return Path(self.MC_CONFIG_DIR) / f"{self.MC_DEVICE_NAME}.msgs" - @property - def meshcli_command(self) -> list: - """Get the base meshcli command with serial port""" - return ['meshcli', '-s', self.MC_SERIAL_PORT] - @property def archive_dir_path(self) -> Path: """Get the full path to archive directory""" diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 1ab054e..6cddd10 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -1,10 +1,10 @@ """ -MeshCore CLI wrapper - executes meshcli commands via subprocess +MeshCore CLI wrapper - executes meshcli commands via HTTP bridge """ -import subprocess import logging import re +import requests from typing import Tuple, Optional, List, Dict from app.config import config @@ -22,47 +22,54 @@ class MeshCLIError(Exception): def _run_command(args: list, timeout: int = DEFAULT_TIMEOUT) -> Tuple[bool, str, str]: """ - Execute a meshcli command and return result. + Execute meshcli command via HTTP bridge. Args: - args: Command arguments (will be prepended with meshcli -s ) + args: Command arguments (e.g., ['recv'], ['public', 'Hello']) timeout: Command timeout in seconds Returns: Tuple of (success, stdout, stderr) """ - cmd = config.meshcli_command + args - logger.info(f"Executing: {' '.join(cmd)}") + logger.info(f"Executing via bridge: {' '.join(args)}") try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=timeout, - check=False + response = requests.post( + config.MC_BRIDGE_URL, + json={ + 'args': args, + 'timeout': timeout + }, + timeout=timeout + 5 # Add 5s buffer for HTTP timeout ) - success = result.returncode == 0 - stdout = result.stdout.strip() - stderr = result.stderr.strip() + # Handle HTTP errors + if response.status_code != 200: + logger.error(f"Bridge HTTP error {response.status_code}: {response.text}") + return False, '', f'Bridge HTTP error: {response.status_code}' + + data = response.json() + + success = data.get('success', False) + stdout = data.get('stdout', '').strip() + stderr = data.get('stderr', '').strip() if not success: logger.warning(f"Command failed: {stderr or stdout}") return success, stdout, stderr - except subprocess.TimeoutExpired: - logger.error(f"Command timeout after {timeout}s: {' '.join(cmd)}") - return False, "", f"Command timeout after {timeout}s" + except requests.exceptions.Timeout: + logger.error(f"Bridge request timeout after {timeout}s") + return False, '', f'Bridge timeout after {timeout} seconds' - except FileNotFoundError: - logger.error("meshcli command not found") - return False, "", "meshcli not found - is meshcore-cli installed?" + except requests.exceptions.ConnectionError as e: + logger.error(f"Cannot connect to meshcore-bridge: {e}") + return False, '', 'Cannot connect to meshcore-bridge service' except Exception as e: - logger.error(f"Unexpected error: {e}") - return False, "", str(e) + logger.error(f"Bridge communication error: {e}") + return False, '', str(e) def recv_messages() -> Tuple[bool, str]: diff --git a/docker-compose.yml b/docker-compose.yml index e820861..7c21dd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,29 @@ version: '3.8' services: + # MeshCore Bridge - Handles USB communication with meshcli + meshcore-bridge: + build: + context: ./meshcore-bridge + dockerfile: Dockerfile + container_name: meshcore-bridge + restart: unless-stopped + devices: + - "${MC_SERIAL_PORT}:${MC_SERIAL_PORT}" + volumes: + - "${MC_CONFIG_DIR}:/root/.config/meshcore:rw" + environment: + - MC_SERIAL_PORT=${MC_SERIAL_PORT} + networks: + - meshcore-net + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Main Web UI - Communicates with bridge via HTTP mc-webui: build: context: . @@ -9,13 +32,11 @@ services: restart: unless-stopped ports: - "${FLASK_PORT:-5000}:5000" - devices: - - "${MC_SERIAL_PORT}:${MC_SERIAL_PORT}" volumes: - "${MC_CONFIG_DIR}:/root/.config/meshcore:rw" - "${MC_ARCHIVE_DIR:-./archive}:/root/.archive/meshcore:rw" environment: - - MC_SERIAL_PORT=${MC_SERIAL_PORT} + - MC_BRIDGE_URL=http://meshcore-bridge:5001/cli - MC_DEVICE_NAME=${MC_DEVICE_NAME} - MC_CONFIG_DIR=/root/.config/meshcore - MC_REFRESH_INTERVAL=${MC_REFRESH_INTERVAL:-60} @@ -28,9 +49,18 @@ services: - FLASK_DEBUG=${FLASK_DEBUG:-false} env_file: - .env + depends_on: + meshcore-bridge: + condition: service_healthy + networks: + - meshcore-net healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/status')"] interval: 30s timeout: 10s retries: 3 start_period: 10s + +networks: + meshcore-net: + driver: bridge diff --git a/meshcore-bridge/Dockerfile b/meshcore-bridge/Dockerfile new file mode 100644 index 0000000..231d3e6 --- /dev/null +++ b/meshcore-bridge/Dockerfile @@ -0,0 +1,28 @@ +# MeshCore Bridge Dockerfile +FROM python:3.11-slim + +LABEL maintainer="mc-webui" +LABEL description="MeshCore CLI Bridge - HTTP API wrapper for meshcli" + +WORKDIR /bridge + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install meshcore-cli (from PyPI) +RUN pip install --no-cache-dir meshcore-cli + +# Copy bridge application +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bridge.py . + +# Expose bridge API port +EXPOSE 5001 + +# Run bridge +CMD ["python", "bridge.py"] diff --git a/meshcore-bridge/bridge.py b/meshcore-bridge/bridge.py new file mode 100644 index 0000000..60fa5de --- /dev/null +++ b/meshcore-bridge/bridge.py @@ -0,0 +1,152 @@ +""" +MeshCore Bridge - HTTP API wrapper for meshcli subprocess calls + +This service runs as a separate container with exclusive USB device access. +The main mc-webui container communicates with this bridge via HTTP. +""" + +import os +import subprocess +import logging +from flask import Flask, request, jsonify + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Configuration +MC_SERIAL_PORT = os.getenv('MC_SERIAL_PORT', '/dev/ttyUSB0') +DEFAULT_TIMEOUT = 30 +RECV_TIMEOUT = 60 + +def run_meshcli_command(args, timeout=DEFAULT_TIMEOUT): + """ + Execute meshcli command via subprocess. + + Args: + args: List of command arguments + timeout: Command timeout in seconds + + Returns: + Dict with success, stdout, stderr + """ + full_command = ['meshcli', '-s', MC_SERIAL_PORT] + args + + logger.info(f"Executing: {' '.join(full_command)}") + + try: + result = subprocess.run( + full_command, + capture_output=True, + text=True, + timeout=timeout + ) + + success = result.returncode == 0 + + if not success: + logger.warning(f"Command failed with code {result.returncode}: {result.stderr}") + + return { + 'success': success, + 'stdout': result.stdout, + 'stderr': result.stderr, + 'returncode': result.returncode + } + + except subprocess.TimeoutExpired: + logger.error(f"Command timeout after {timeout}s") + return { + 'success': False, + 'stdout': '', + 'stderr': f'Command timeout after {timeout} seconds', + 'returncode': -1 + } + except Exception as e: + logger.error(f"Command execution error: {e}") + return { + 'success': False, + 'stdout': '', + 'stderr': str(e), + 'returncode': -1 + } + + +@app.route('/health', methods=['GET']) +def health(): + """Health check endpoint""" + return jsonify({ + 'status': 'healthy', + 'serial_port': MC_SERIAL_PORT + }), 200 + + +@app.route('/cli', methods=['POST']) +def execute_cli(): + """ + Execute meshcli command. + + Request JSON: + { + "args": ["recv"], + "timeout": 60 (optional) + } + + Response JSON: + { + "success": true, + "stdout": "...", + "stderr": "...", + "returncode": 0 + } + """ + try: + data = request.get_json() + + if not data or 'args' not in data: + return jsonify({ + 'success': False, + 'stdout': '', + 'stderr': 'Missing required field: args', + 'returncode': -1 + }), 400 + + args = data['args'] + timeout = data.get('timeout', DEFAULT_TIMEOUT) + + # Special handling for recv command (longer timeout) + if args and args[0] == 'recv': + timeout = data.get('timeout', RECV_TIMEOUT) + + if not isinstance(args, list): + return jsonify({ + 'success': False, + 'stdout': '', + 'stderr': 'args must be a list', + 'returncode': -1 + }), 400 + + result = run_meshcli_command(args, timeout) + + return jsonify(result), 200 + + except Exception as e: + logger.error(f"API error: {e}") + return jsonify({ + 'success': False, + 'stdout': '', + 'stderr': str(e), + 'returncode': -1 + }), 500 + + +if __name__ == '__main__': + logger.info(f"Starting MeshCore Bridge on port 5001") + logger.info(f"Serial port: {MC_SERIAL_PORT}") + + # Run on all interfaces to allow Docker network access + app.run(host='0.0.0.0', port=5001, debug=False) diff --git a/meshcore-bridge/requirements.txt b/meshcore-bridge/requirements.txt new file mode 100644 index 0000000..00a8d24 --- /dev/null +++ b/meshcore-bridge/requirements.txt @@ -0,0 +1,3 @@ +# MeshCore Bridge - Minimal dependencies +Flask==3.0.0 +Werkzeug==3.0.1 diff --git a/requirements.txt b/requirements.txt index beba707..c8d5df7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,5 +20,5 @@ python-dateutil==2.8.2 qrcode==7.4.2 Pillow==10.1.0 -# Additional utilities (if needed later) -# requests==2.31.0 # For future API integrations +# HTTP Client for MeshCore Bridge communication +requests==2.31.0