mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
refactor: Implement 2-container architecture to solve USB stability issues
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 <noreply@anthropic.com>
This commit is contained in:
10
Dockerfile
10
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/
|
||||
|
||||
|
||||
94
README.md
94
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/<device_name>.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
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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 <port>)
|
||||
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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
28
meshcore-bridge/Dockerfile
Normal file
28
meshcore-bridge/Dockerfile
Normal file
@@ -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"]
|
||||
152
meshcore-bridge/bridge.py
Normal file
152
meshcore-bridge/bridge.py
Normal file
@@ -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)
|
||||
3
meshcore-bridge/requirements.txt
Normal file
3
meshcore-bridge/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# MeshCore Bridge - Minimal dependencies
|
||||
Flask==3.0.0
|
||||
Werkzeug==3.0.1
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user