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:
MarekWo
2026-03-29 10:03:45 +02:00
parent bd59826504
commit 710f69c350
9 changed files with 95 additions and 27 deletions
+12 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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}, "
+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+5
View File
@@ -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">
+7
View File
@@ -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}
+21 -15
View File
@@ -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