From 710f69c3503604963a4170889ed62ce350763b25 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 29 Mar 2026 10:03:45 +0200 Subject: [PATCH] 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 --- .env.example | 14 ++++++++++++-- Dockerfile | 4 +++- app/config.py | 25 ++++++++++++++++++++++++- app/device_manager.py | 11 +++++++++-- app/main.py | 12 ++++++++---- app/routes/api.py | 8 ++++++-- app/templates/base.html | 5 +++++ docker-compose.yml | 7 +++++++ scripts/watchdog/watchdog.py | 36 +++++++++++++++++++++--------------- 9 files changed, 95 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index f8f04cf..9044989 100644 --- a/.env.example +++ b/.env.example @@ -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 (enter PIN shown on device) +# bluetoothctl trust +# 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 diff --git a/Dockerfile b/Dockerfile index e504477..3fce8dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/config.py b/app/config.py index b3e09f0..f1eec94 100644 --- a/app/config.py +++ b/app/config.py @@ -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}, " diff --git a/app/device_manager.py b/app/device_manager.py index 06b8737..bedb7f3 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -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, diff --git a/app/main.py b/app/main.py index 88c9b99..e3801d9 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/routes/api.py b/app/routes/api.py index 3692e13..109568f 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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}") diff --git a/app/templates/base.html b/app/templates/base.html index 5d142a1..50b548c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -47,6 +47,11 @@ {% if device_name %} - {{ device_name }} {% endif %} + {% if transport_type == 'ble' %} + BLE + {% elif transport_type == 'tcp' %} + TCP + {% endif %}