Files
mc-webui/meshcore-bridge/bridge.py
MarekWo c19a5bac88 fix: Add request locking and caching to prevent USB conflicts
Root cause: Multiple concurrent meshcli calls were fighting for USB access,
causing "Protocol error" and 504 Gateway Timeouts.

Changes to meshcore-bridge:
- Add threading.Lock to serialize meshcli subprocess calls
- Prevent concurrent USB access that causes OSError [Errno 71]
- Reduce DEFAULT_TIMEOUT from 30s to 10s
- Add detailed logging for lock acquisition and release

Changes to main API:
- Implement 30s cache for get_channels() to reduce USB calls
- Cache invalidation after channel create/join/delete operations
- Use cached channels in /api/channels and /api/messages/updates
- Reduce HTTP timeout from 30s to 12s (10s bridge + 2s buffer)

Impact:
- Eliminates race conditions when page loads (multiple API calls)
- Prevents USB port conflicts and protocol errors
- Faster response times due to caching
- No need for manual USB resets after container restarts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 22:02:57 +01:00

183 lines
4.9 KiB
Python

"""
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
import threading
import time
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 = 10 # Reduced from 30s to 10s
RECV_TIMEOUT = 60
# Thread lock to prevent concurrent meshcli calls
# Only one meshcli command can execute at a time to avoid USB conflicts
meshcli_lock = threading.Lock()
lock_wait_timeout = 15 # Max time to wait for lock
def run_meshcli_command(args, timeout=DEFAULT_TIMEOUT):
"""
Execute meshcli command via subprocess with locking.
Uses a thread lock to ensure only one meshcli command runs at a time,
preventing USB port conflicts and protocol errors.
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"Waiting for lock to execute: {' '.join(full_command)}")
# Try to acquire lock with timeout
lock_acquired = meshcli_lock.acquire(timeout=lock_wait_timeout)
if not lock_acquired:
logger.error(f"Failed to acquire lock after {lock_wait_timeout}s - another command is running")
return {
'success': False,
'stdout': '',
'stderr': f'Another meshcli command is already running (timeout after {lock_wait_timeout}s)',
'returncode': -1
}
try:
logger.info(f"Lock acquired, executing: {' '.join(full_command)}")
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}")
else:
logger.info(f"Command completed successfully")
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
}
finally:
# Always release lock
meshcli_lock.release()
logger.info("Lock released")
@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)