mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-08 17:53:00 +02:00
feat: add BLE transport support for companion devices
Integrate meshcore library's BLE connection (via bleak) as a third transport option alongside serial and TCP. Priority: BLE > TCP > Serial. Config: MC_BLE_ADDRESS and MC_BLE_PIN environment variables. Docker: bluez/dbus packages, NET_ADMIN cap, D-Bus socket mount. UI: transport type badge in navbar, transport_type in /api/status. Watchdog: skip USB reset for BLE connections (same as TCP). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+12
-2
@@ -4,8 +4,8 @@
|
||||
# ============================================
|
||||
# MeshCore Device Connection
|
||||
# ============================================
|
||||
# Two transport options: Serial (USB) or TCP (network).
|
||||
# Set MC_TCP_HOST to use TCP; leave empty to use serial.
|
||||
# Three transport options: Serial (USB), TCP (network), or BLE (Bluetooth).
|
||||
# Priority: BLE > TCP > Serial. Set the relevant variable to activate.
|
||||
|
||||
# --- Option A: Serial (default) ---
|
||||
# Use "auto" for automatic detection (recommended if only one USB device)
|
||||
@@ -19,6 +19,16 @@ MC_SERIAL_PORT=auto
|
||||
# MC_TCP_HOST=192.168.1.100
|
||||
# MC_TCP_PORT=5555
|
||||
|
||||
# --- Option C: BLE (Bluetooth Low Energy companion devices) ---
|
||||
# Requires: USB BLE dongle on host, BlueZ installed, device pre-paired.
|
||||
# One-time setup on host:
|
||||
# bluetoothctl scan le (find your MeshCore device)
|
||||
# bluetoothctl pair <MAC> (enter PIN shown on device)
|
||||
# bluetoothctl trust <MAC>
|
||||
# When MC_BLE_ADDRESS is set, serial and TCP are ignored.
|
||||
# MC_BLE_ADDRESS=AA:BB:CC:DD:EE:FF
|
||||
# MC_BLE_PIN=123456
|
||||
|
||||
# Your MeshCore device name (used for .msgs file)
|
||||
# Use "auto" for automatic detection from device (recommended)
|
||||
# Or specify manually: MarWoj, SP5XYZ, MyNode
|
||||
|
||||
+3
-1
@@ -3,10 +3,12 @@
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system deps: curl (healthcheck), udev (serial device support)
|
||||
# Install system deps: curl (healthcheck), udev (serial), bluez+dbus (BLE)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
udev \
|
||||
bluez \
|
||||
dbus \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
|
||||
+24
-1
@@ -32,6 +32,10 @@ class Config:
|
||||
MC_TCP_HOST = os.getenv('MC_TCP_HOST', '') # empty = use serial
|
||||
MC_TCP_PORT = int(os.getenv('MC_TCP_PORT', '5555'))
|
||||
|
||||
# v2: BLE connection (alternative to serial/TCP, for BLE companion devices)
|
||||
MC_BLE_ADDRESS = os.getenv('MC_BLE_ADDRESS', '') # BLE MAC address or device name filter
|
||||
MC_BLE_PIN = os.getenv('MC_BLE_PIN', '') # PIN for BLE pairing
|
||||
|
||||
# v2: Backup
|
||||
MC_BACKUP_ENABLED = os.getenv('MC_BACKUP_ENABLED', 'true').lower() == 'true'
|
||||
MC_BACKUP_HOUR = int(os.getenv('MC_BACKUP_HOUR', '2'))
|
||||
@@ -64,13 +68,32 @@ class Config:
|
||||
return Path(self.MC_DB_PATH)
|
||||
return Path(self.MC_CONFIG_DIR) / 'mc-webui.db'
|
||||
|
||||
@property
|
||||
def use_ble(self) -> bool:
|
||||
"""True if BLE transport should be used (highest priority)"""
|
||||
return bool(self.MC_BLE_ADDRESS)
|
||||
|
||||
@property
|
||||
def use_tcp(self) -> bool:
|
||||
"""True if TCP transport should be used instead of serial"""
|
||||
return bool(self.MC_TCP_HOST)
|
||||
|
||||
@property
|
||||
def transport_type(self) -> str:
|
||||
"""Return active transport type: 'ble', 'tcp', or 'serial'"""
|
||||
if self.use_ble:
|
||||
return 'ble'
|
||||
if self.use_tcp:
|
||||
return 'tcp'
|
||||
return 'serial'
|
||||
|
||||
def __repr__(self):
|
||||
transport = f"tcp={self.MC_TCP_HOST}:{self.MC_TCP_PORT}" if self.use_tcp else f"serial={self.MC_SERIAL_PORT}"
|
||||
if self.use_ble:
|
||||
transport = f"ble={self.MC_BLE_ADDRESS}"
|
||||
elif self.use_tcp:
|
||||
transport = f"tcp={self.MC_TCP_HOST}:{self.MC_TCP_PORT}"
|
||||
else:
|
||||
transport = f"serial={self.MC_SERIAL_PORT}"
|
||||
return (
|
||||
f"Config(device={self.MC_DEVICE_NAME}, "
|
||||
f"{transport}, "
|
||||
|
||||
@@ -181,11 +181,18 @@ class DeviceManager:
|
||||
raise RuntimeError("No serial port detected. Set MC_SERIAL_PORT explicitly.")
|
||||
|
||||
async def _connect(self):
|
||||
"""Connect to device via serial or TCP and subscribe to events."""
|
||||
"""Connect to device via BLE, TCP, or serial and subscribe to events."""
|
||||
from meshcore import MeshCore
|
||||
|
||||
try:
|
||||
if self.config.use_tcp:
|
||||
if self.config.use_ble:
|
||||
logger.info(f"Connecting via BLE: {self.config.MC_BLE_ADDRESS}")
|
||||
self.mc = await MeshCore.create_ble(
|
||||
address=self.config.MC_BLE_ADDRESS,
|
||||
pin=self.config.MC_BLE_PIN or None,
|
||||
auto_reconnect=False,
|
||||
)
|
||||
elif self.config.use_tcp:
|
||||
logger.info(f"Connecting via TCP: {self.config.MC_TCP_HOST}:{self.config.MC_TCP_PORT}")
|
||||
self.mc = await MeshCore.create_tcp(
|
||||
host=self.config.MC_TCP_HOST,
|
||||
|
||||
+8
-4
@@ -225,10 +225,14 @@ def create_app():
|
||||
app.config['DEBUG'] = config.FLASK_DEBUG
|
||||
app.config['SECRET_KEY'] = 'mc-webui-secret-key-change-in-production'
|
||||
|
||||
# Inject version and branch into all templates
|
||||
# Inject version, branch, and transport type into all templates
|
||||
@app.context_processor
|
||||
def inject_version():
|
||||
return {'version': VERSION_STRING, 'git_branch': GIT_BRANCH}
|
||||
def inject_globals():
|
||||
return {
|
||||
'version': VERSION_STRING,
|
||||
'git_branch': GIT_BRANCH,
|
||||
'transport_type': config.transport_type,
|
||||
}
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(views_bp)
|
||||
@@ -339,7 +343,7 @@ def create_app():
|
||||
schedule_daily_archiving()
|
||||
init_retention_schedule(db=db)
|
||||
|
||||
logger.info(f"mc-webui v2 started — transport: {'TCP' if config.use_tcp else 'serial'}")
|
||||
logger.info(f"mc-webui v2 started — transport: {config.transport_type}")
|
||||
logger.info(f"Database: {db.db_path}")
|
||||
|
||||
return app
|
||||
|
||||
+6
-2
@@ -632,15 +632,19 @@ def get_status():
|
||||
latest = parser.get_latest_message()
|
||||
latest_timestamp = latest['timestamp'] if latest else None
|
||||
|
||||
return jsonify({
|
||||
status_data = {
|
||||
'success': True,
|
||||
'connected': connected,
|
||||
'device_name': runtime_config.get_device_name(),
|
||||
'device_name_source': runtime_config.get_device_name_source(),
|
||||
'transport_type': config.transport_type,
|
||||
'serial_port': config.MC_SERIAL_PORT,
|
||||
'message_count': message_count,
|
||||
'latest_message_timestamp': latest_timestamp
|
||||
}), 200
|
||||
}
|
||||
if config.use_ble:
|
||||
status_data['ble_address'] = config.MC_BLE_ADDRESS
|
||||
return jsonify(status_data), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting status: {e}")
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
{% if device_name %}
|
||||
<small class="text-white-50 d-none d-sm-inline">- {{ device_name }}</small>
|
||||
{% endif %}
|
||||
{% if transport_type == 'ble' %}
|
||||
<span class="badge bg-info ms-1 d-none d-sm-inline" title="Bluetooth Low Energy">BLE</span>
|
||||
{% elif transport_type == 'tcp' %}
|
||||
<span class="badge bg-warning text-dark ms-1 d-none d-sm-inline" title="TCP connection">TCP</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div id="notificationBell" class="btn btn-outline-light position-relative navbar-touch-btn" style="cursor: pointer;" onclick="markAllChannelsRead()" title="Mark all as read">
|
||||
|
||||
@@ -11,15 +11,22 @@ services:
|
||||
device_cgroup_rules:
|
||||
- 'c 188:* rmw'
|
||||
- 'c 166:* rmw'
|
||||
# NET_ADMIN + NET_RAW for BLE scanning (no overhead when BLE unused)
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
volumes:
|
||||
- "${MC_CONFIG_DIR:-./data}:/data:rw"
|
||||
- "/dev:/dev"
|
||||
- "/var/run/dbus:/var/run/dbus" # BlueZ D-Bus (for BLE)
|
||||
environment:
|
||||
- MC_SERIAL_PORT=${MC_SERIAL_PORT:-auto}
|
||||
- MC_DEVICE_NAME=${MC_DEVICE_NAME:-MeshCore}
|
||||
- MC_CONFIG_DIR=/data
|
||||
- MC_TCP_HOST=${MC_TCP_HOST:-}
|
||||
- MC_TCP_PORT=${MC_TCP_PORT:-5555}
|
||||
- MC_BLE_ADDRESS=${MC_BLE_ADDRESS:-}
|
||||
- MC_BLE_PIN=${MC_BLE_PIN:-}
|
||||
- MC_BACKUP_ENABLED=${MC_BACKUP_ENABLED:-true}
|
||||
- MC_BACKUP_HOUR=${MC_BACKUP_HOUR:-2}
|
||||
- MC_BACKUP_RETENTION_DAYS=${MC_BACKUP_RETENTION_DAYS:-7}
|
||||
|
||||
@@ -136,22 +136,26 @@ def auto_detect_usb_device() -> str:
|
||||
log(f"Error during USB device auto-detection: {e}", "ERROR")
|
||||
return None
|
||||
|
||||
def is_tcp_connection() -> bool:
|
||||
"""Check if the application is configured to use a TCP connection instead of a serial port."""
|
||||
def _read_env_value(key: str) -> str:
|
||||
"""Read a value from the .env file. Returns empty string if not found."""
|
||||
env_file = os.path.join(MCWEBUI_DIR, '.env')
|
||||
|
||||
if os.path.exists(env_file):
|
||||
try:
|
||||
with open(env_file, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('MC_TCP_HOST='):
|
||||
val = line.split('=', 1)[1].strip().strip('"\'')
|
||||
if val:
|
||||
return True
|
||||
if line.startswith(f'{key}='):
|
||||
return line.split('=', 1)[1].strip().strip('"\'')
|
||||
except Exception as e:
|
||||
log(f"Failed to read .env file for TCP host: {e}", "WARN")
|
||||
log(f"Failed to read .env file for {key}: {e}", "WARN")
|
||||
return ''
|
||||
|
||||
return False
|
||||
def is_tcp_connection() -> bool:
|
||||
"""Check if the application is configured to use a TCP connection instead of a serial port."""
|
||||
return bool(_read_env_value('MC_TCP_HOST'))
|
||||
|
||||
def is_ble_connection() -> bool:
|
||||
"""Check if the application is configured to use a BLE connection."""
|
||||
return bool(_read_env_value('MC_BLE_ADDRESS'))
|
||||
|
||||
def reset_esp32_device():
|
||||
"""Perform a hardware reset on ESP32/LoRa device using DTR/RTS lines via ioctl."""
|
||||
@@ -433,7 +437,8 @@ def handle_unhealthy_container(container_name: str, status: dict):
|
||||
restart_success = False
|
||||
if container_name == 'mc-webui':
|
||||
recent_restarts = count_recent_restarts(container_name, minutes=8)
|
||||
if recent_restarts >= 3 and not is_tcp_connection():
|
||||
uses_serial = not is_tcp_connection() and not is_ble_connection()
|
||||
if recent_restarts >= 3 and uses_serial:
|
||||
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Attempting hardware USB reset.", "WARN")
|
||||
# Stop the container first so it releases the serial port
|
||||
run_compose_command(['stop', container_name])
|
||||
@@ -442,8 +447,8 @@ def handle_unhealthy_container(container_name: str, status: dict):
|
||||
time.sleep(5) # Give OS time to re-enumerate the device
|
||||
restart_success = start_container(container_name)
|
||||
else:
|
||||
if recent_restarts >= 3 and is_tcp_connection():
|
||||
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. TCP connection used, skipping hardware USB reset.", "WARN")
|
||||
if recent_restarts >= 3 and not uses_serial:
|
||||
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Non-serial connection, skipping hardware USB reset.", "WARN")
|
||||
restart_success = restart_container(container_name)
|
||||
else:
|
||||
# Restart the container
|
||||
@@ -512,7 +517,8 @@ def handle_unresponsive_device(container_name: str, status: dict):
|
||||
restart_success = False
|
||||
if container_name == 'mc-webui':
|
||||
recent_restarts = count_recent_restarts(container_name, minutes=8)
|
||||
if recent_restarts >= 3 and not is_tcp_connection():
|
||||
uses_serial = not is_tcp_connection() and not is_ble_connection()
|
||||
if recent_restarts >= 3 and uses_serial:
|
||||
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Attempting hardware USB reset.", "WARN")
|
||||
# Stop the container first so it releases the serial port
|
||||
run_compose_command(['stop', container_name])
|
||||
@@ -521,8 +527,8 @@ def handle_unresponsive_device(container_name: str, status: dict):
|
||||
time.sleep(5) # Give OS time to re-enumerate the device
|
||||
restart_success = start_container(container_name)
|
||||
else:
|
||||
if recent_restarts >= 3 and is_tcp_connection():
|
||||
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. TCP connection used, skipping hardware USB reset.", "WARN")
|
||||
if recent_restarts >= 3 and not uses_serial:
|
||||
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Non-serial connection, skipping hardware USB reset.", "WARN")
|
||||
restart_success = restart_container(container_name)
|
||||
else:
|
||||
# Restart the container
|
||||
|
||||
Reference in New Issue
Block a user