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:
MarekWo
2025-12-23 08:39:10 +01:00
parent b223493183
commit 4608665e82
9 changed files with 321 additions and 67 deletions

View File

@@ -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/

View File

@@ -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

View File

@@ -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"""

View File

@@ -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]:

View File

@@ -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

View 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
View 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)

View File

@@ -0,0 +1,3 @@
# MeshCore Bridge - Minimal dependencies
Flask==3.0.0
Werkzeug==3.0.1

View File

@@ -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