diff --git a/docs/specs/2026-05-10-meshcore-plan.md b/docs/specs/2026-05-10-meshcore-plan.md new file mode 100644 index 0000000..37bf937 --- /dev/null +++ b/docs/specs/2026-05-10-meshcore-plan.md @@ -0,0 +1,2578 @@ +# Meshcore Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a full Meshcore mode to Intercept with USB serial, TCP, and BLE support — feature-parity with the existing Meshtastic module. + +**Architecture:** The `meshcore` PyPI library is fully async; we run it in a dedicated asyncio event loop on a daemon OS thread and bridge events into a `queue.Queue` that Flask's SSE endpoint drains. Everything outside `utils/meshcore_client.py` is sync and unaware of asyncio. + +**Tech Stack:** Python `meshcore` (PyPI, async), Flask Blueprint, gevent-compatible queue.Queue, Leaflet (map), Chart.js (telemetry), EventSource (SSE) + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `utils/meshcore.py` | Create | Dataclasses, MeshcoreClient singleton, connection state, serial port discovery | +| `utils/meshcore_client.py` | Create | Thin async wrapper around meshcore library; runs inside asyncio thread | +| `routes/meshcore.py` | Create | Flask blueprint with all 15 REST endpoints + SSE stream | +| `tests/test_meshcore_client.py` | Create | Unit tests: dataclasses, state machine, queue feeding, reconnect backoff | +| `tests/test_meshcore_routes.py` | Create | Route tests via Flask test client | +| `tests/test_meshcore_integration.py` | Create | Mock-boundary round-trip tests | +| `static/css/modes/meshcore.css` | Create | Scoped styles for Meshcore mode | +| `templates/partials/modes/meshcore.html` | Create | Sidebar partial (connection panel, contacts, nodes) | +| `static/js/modes/meshcore.js` | Create | IIFE frontend module | +| `routes/__init__.py` | Modify | Register meshcore_bp | +| `requirements.txt` | Modify | Add meshcore dependency | +| `templates/index.html` | Modify | 14 wiring points (CSS, JS, partial, catalog, handlers, etc.) | + +--- + +## Task 1: Discover meshcore library API + +**Files:** +- Read: (no files changed — discovery only) + +- [ ] **Step 1: Install the library and inspect its public API** + +```bash +pip install meshcore +python3 - <<'EOF' +import inspect, meshcore +print("=== meshcore top-level ===") +print(dir(meshcore)) +# Find connection classes +for name in dir(meshcore): + obj = getattr(meshcore, name) + if inspect.isclass(obj): + print(f"\n--- {name} ---") + print(inspect.signature(obj.__init__) if hasattr(obj, '__init__') else '') + print([m for m in dir(obj) if not m.startswith('_')]) +EOF +``` + +- [ ] **Step 2: Document the connect / event / send API surface** + +Run: +```bash +python3 - <<'EOF' +import meshcore, inspect +# Try to find how connections are made +for attr in ['connect', 'connect_serial', 'connect_tcp', 'connect_ble', 'serial', 'tcp', 'ble']: + if hasattr(meshcore, attr): + fn = getattr(meshcore, attr) + print(f"meshcore.{attr}: {inspect.signature(fn)}") +EOF +``` + +Record the actual class name, connect method signatures, event iteration pattern, and send method. The findings drive `utils/meshcore_client.py` in Task 3. The rest of the plan uses the following *assumed* API — update method names in Task 3 only if they differ: + +``` +connect: meshcore.Connection(port=...) / meshcore.Connection(host=..., port=...) / meshcore.Connection(ble_address=...) +events: async for event in conn.events(): event.type, event.fields +send: await conn.send_text(to, text) +contacts: await conn.get_contacts() → list[{node_id, name, public_key}] +tracert: await conn.traceroute(node_id) → {hops:[...], snr:[...]} +ble scan: await meshcore.scan_ble() → [{address, name, rssi}] +telemetry: delivered as events of type 'telemetry' +``` + +--- + +## Task 2: Data model — dataclasses in utils/meshcore.py + +**Files:** +- Create: `utils/meshcore.py` +- Create: `tests/test_meshcore_client.py` (started) + +- [ ] **Step 1: Write failing tests for dataclasses** + +Create `tests/test_meshcore_client.py`: + +```python +"""Tests for MeshcoreClient dataclasses and state machine.""" +from datetime import datetime, timezone +from unittest.mock import patch +import pytest + + +class TestAvailability: + def test_returns_bool(self): + from utils.meshcore import is_meshcore_available + assert isinstance(is_meshcore_available(), bool) + + def test_false_when_not_installed(self): + with patch.dict('sys.modules', {'meshcore': None}): + import importlib, utils.meshcore as m + importlib.reload(m) + assert m.is_meshcore_available() is False + + +class TestMeshcoreMessage: + def _make(self, **kw): + from utils.meshcore import MeshcoreMessage + defaults = dict( + id='abc123', + sender_id='NODE001', + recipient_id='BROADCAST', + text='hello mesh', + timestamp=datetime(2026, 5, 10, 12, 0, 0, tzinfo=timezone.utc), + hop_count=2, + snr=-8.5, + is_direct=False, + ) + defaults.update(kw) + return MeshcoreMessage(**defaults) + + def test_to_dict_keys(self): + d = self._make().to_dict() + for key in ('id', 'sender_id', 'recipient_id', 'text', 'timestamp', + 'hop_count', 'snr', 'is_direct', 'pending'): + assert key in d, f"missing key: {key}" + + def test_pending_defaults_false(self): + assert self._make().to_dict()['pending'] is False + + def test_none_snr_allowed(self): + d = self._make(snr=None).to_dict() + assert d['snr'] is None + + +class TestMeshcoreNode: + def test_to_dict_includes_is_repeater(self): + from utils.meshcore import MeshcoreNode + node = MeshcoreNode( + node_id='RPT1', name='Roof-Repeater', is_repeater=True, + lat=51.5, lon=-0.1, battery_pct=87, + last_seen=datetime.now(timezone.utc), snr=-5.0, hops_away=1, + ) + d = node.to_dict() + assert d['is_repeater'] is True + assert d['node_id'] == 'RPT1' + + +class TestMeshcoreTelemetry: + def test_to_dict_timestamp_is_iso(self): + from utils.meshcore import MeshcoreTelemetry + t = MeshcoreTelemetry( + node_id='N1', timestamp=datetime(2026, 5, 10, tzinfo=timezone.utc), + battery_pct=72, voltage=3.7, temperature=22.1, + humidity=55.0, uptime_secs=3600, + ) + d = t.to_dict() + assert '2026-05-10' in d['timestamp'] + + +class TestConnectionState: + def test_state_enum_values(self): + from utils.meshcore import ConnectionState + assert ConnectionState.DISCONNECTED + assert ConnectionState.CONNECTING + assert ConnectionState.CONNECTED + assert ConnectionState.ERROR +``` + +- [ ] **Step 2: Run tests — confirm they all fail** + +```bash +pytest tests/test_meshcore_client.py -v 2>&1 | head -30 +``` + +Expected: `ModuleNotFoundError: No module named 'utils.meshcore'` + +- [ ] **Step 3: Create utils/meshcore.py with dataclasses** + +```python +"""Meshcore device management and message handling. + +Bridges the async meshcore library into Intercept's sync Flask/gevent stack +via a background asyncio thread feeding a queue.Queue. + +Install: pip install meshcore +""" +from __future__ import annotations + +import contextlib +import enum +import glob +import queue +import threading +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Callable + +from utils.logging import get_logger + +logger = get_logger('intercept.meshcore') + +try: + import meshcore as _meshcore_lib + HAS_MESHCORE = True +except ImportError: + HAS_MESHCORE = False + logger.warning("meshcore not installed. Run: pip install meshcore") + + +def is_meshcore_available() -> bool: + return HAS_MESHCORE + + +# --------------------------------------------------------------------------- +# Connection config +# --------------------------------------------------------------------------- + +@dataclass +class SerialConfig: + port: str | None = None # None = auto-discover + baud: int = 115200 + + +@dataclass +class TCPConfig: + host: str = "localhost" + port: int = 5000 # meshcore-proxy default + + +@dataclass +class BLEConfig: + device_address: str | None = None # None = scan and pick first + + +ConnectionConfig = SerialConfig | TCPConfig | BLEConfig + + +class ConnectionState(enum.Enum): + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + ERROR = "error" + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class MeshcoreMessage: + id: str + sender_id: str + recipient_id: str + text: str + timestamp: datetime + hop_count: int + snr: float | None + is_direct: bool + pending: bool = False + + def to_dict(self) -> dict: + return { + 'id': self.id, + 'sender_id': self.sender_id, + 'recipient_id': self.recipient_id, + 'text': self.text, + 'timestamp': self.timestamp.isoformat(), + 'hop_count': self.hop_count, + 'snr': self.snr, + 'is_direct': self.is_direct, + 'pending': self.pending, + } + + +@dataclass +class MeshcoreNode: + node_id: str + name: str + is_repeater: bool + lat: float | None + lon: float | None + battery_pct: int | None + last_seen: datetime + snr: float | None + hops_away: int | None + + def to_dict(self) -> dict: + return { + 'node_id': self.node_id, + 'name': self.name, + 'is_repeater': self.is_repeater, + 'lat': self.lat, + 'lon': self.lon, + 'battery_pct': self.battery_pct, + 'last_seen': self.last_seen.isoformat(), + 'snr': self.snr, + 'hops_away': self.hops_away, + } + + +@dataclass +class MeshcoreContact: + node_id: str + name: str + public_key: str + last_msg: datetime | None + + def to_dict(self) -> dict: + return { + 'node_id': self.node_id, + 'name': self.name, + 'public_key': self.public_key, + 'last_msg': self.last_msg.isoformat() if self.last_msg else None, + } + + +@dataclass +class MeshcoreTelemetry: + node_id: str + timestamp: datetime + battery_pct: int | None + voltage: float | None + temperature: float | None + humidity: float | None + uptime_secs: int | None + + def to_dict(self) -> dict: + return { + 'node_id': self.node_id, + 'timestamp': self.timestamp.isoformat(), + 'battery_pct': self.battery_pct, + 'voltage': self.voltage, + 'temperature': self.temperature, + 'humidity': self.humidity, + 'uptime_secs': self.uptime_secs, + } + + +@dataclass +class MeshcoreTraceroute: + origin_id: str + destination_id: str + hops: list[str] + snr_per_hop: list[float] + timestamp: datetime + + def to_dict(self) -> dict: + return { + 'origin_id': self.origin_id, + 'destination_id': self.destination_id, + 'hops': self.hops, + 'snr_per_hop': self.snr_per_hop, + 'timestamp': self.timestamp.isoformat(), + } + + +# --------------------------------------------------------------------------- +# Serial port discovery +# --------------------------------------------------------------------------- + +def list_serial_ports() -> list[str]: + patterns = ['/dev/ttyUSB*', '/dev/ttyACM*', '/dev/cu.usbserial*', '/dev/cu.usbmodem*'] + ports = [] + for pat in patterns: + ports.extend(glob.glob(pat)) + return sorted(set(ports)) + + +def _is_docker() -> bool: + import os + return os.path.exists('/.dockerenv') or os.environ.get('INTERCEPT_DOCKER') == '1' + + +# --------------------------------------------------------------------------- +# MeshcoreClient — singleton +# --------------------------------------------------------------------------- + +class MeshcoreClient: + def __init__(self) -> None: + self._state = ConnectionState.DISCONNECTED + self._config: ConnectionConfig | None = None + self._event_queue: queue.Queue = queue.Queue(maxsize=500) + self._nodes: dict[str, MeshcoreNode] = {} + self._contacts: dict[str, MeshcoreContact] = {} + self._messages: list[MeshcoreMessage] = [] + self._telemetry: dict[str, list[MeshcoreTelemetry]] = {} + self._lock = threading.Lock() + self._worker: '_AsyncWorker | None' = None + + # -- State -- + + def get_state(self) -> ConnectionState: + return self._state + + def _set_state(self, state: ConnectionState, **extra) -> None: + self._state = state + payload: dict = {'state': state.value} + payload.update(extra) + self._push({'type': 'status', 'data': payload}) + + # -- Queue -- + + def _push(self, event: dict) -> None: + with contextlib.suppress(queue.Full): + try: + self._event_queue.put_nowait(event) + except queue.Full: + with contextlib.suppress(queue.Empty): + self._event_queue.get_nowait() + with contextlib.suppress(queue.Full): + self._event_queue.put_nowait(event) + + def get_queue(self) -> queue.Queue: + return self._event_queue + + # -- Connect / disconnect -- + + def connect(self, config: ConnectionConfig) -> None: + if self._state == ConnectionState.CONNECTING: + return + if isinstance(config, BLEConfig) and _is_docker(): + self._set_state(ConnectionState.ERROR, + message="BLE unavailable in Docker. Run meshcore-proxy on the host and connect via TCP.") + return + self._config = config + self._set_state(ConnectionState.CONNECTING) + from utils.meshcore_client import AsyncWorker + self._worker = AsyncWorker(config, self) + self._worker.start() + + def disconnect(self) -> None: + if self._worker: + self._worker.stop() + self._worker = None + self._set_state(ConnectionState.DISCONNECTED) + + # -- Event handlers called by AsyncWorker -- + + def on_connected(self, transport: str, device: str) -> None: + self._set_state(ConnectionState.CONNECTED, transport=transport, device=device) + + def on_error(self, message: str) -> None: + self._set_state(ConnectionState.ERROR, message=message) + + def on_message(self, msg: MeshcoreMessage) -> None: + with self._lock: + self._messages.append(msg) + if len(self._messages) > 500: + self._messages.pop(0) + self._push({'type': 'message', 'data': msg.to_dict()}) + + def on_node(self, node: MeshcoreNode) -> None: + with self._lock: + self._nodes[node.node_id] = node + self._push({'type': 'node', 'data': node.to_dict()}) + + def on_telemetry(self, t: MeshcoreTelemetry) -> None: + with self._lock: + self._telemetry.setdefault(t.node_id, []).append(t) + if len(self._telemetry[t.node_id]) > 200: + self._telemetry[t.node_id].pop(0) + self._push({'type': 'telemetry', 'data': t.to_dict()}) + + def on_traceroute(self, tr: MeshcoreTraceroute) -> None: + self._push({'type': 'traceroute', 'data': tr.to_dict()}) + + # -- Data accessors -- + + def get_messages(self) -> list[dict]: + with self._lock: + return [m.to_dict() for m in self._messages] + + def get_nodes(self) -> list[dict]: + with self._lock: + return [n.to_dict() for n in self._nodes.values()] + + def get_repeaters(self) -> list[dict]: + with self._lock: + return [n.to_dict() for n in self._nodes.values() if n.is_repeater] + + def get_contacts(self) -> list[dict]: + with self._lock: + return [c.to_dict() for c in self._contacts.values()] + + def add_contact(self, contact: MeshcoreContact) -> None: + with self._lock: + self._contacts[contact.node_id] = contact + + def remove_contact(self, node_id: str) -> bool: + with self._lock: + if node_id in self._contacts: + del self._contacts[node_id] + return True + return False + + def get_telemetry(self, node_id: str) -> list[dict]: + with self._lock: + return [t.to_dict() for t in self._telemetry.get(node_id, [])] + + def send_text(self, recipient_id: str, text: str) -> None: + if self._worker: + self._worker.send_text(recipient_id, text) + + def request_traceroute(self, node_id: str) -> None: + if self._worker: + self._worker.request_traceroute(node_id) + + def scan_ble(self) -> list[dict]: + if self._worker: + return self._worker.scan_ble_sync() + return [] + + +_client: MeshcoreClient | None = None + + +def get_meshcore_client() -> MeshcoreClient: + global _client + if _client is None: + _client = MeshcoreClient() + return _client +``` + +- [ ] **Step 4: Run tests — confirm they pass** + +```bash +pytest tests/test_meshcore_client.py -v +``` + +Expected: All 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add utils/meshcore.py tests/test_meshcore_client.py +git commit -m "feat(meshcore): add data model, connection config, MeshcoreClient skeleton" +``` + +--- + +## Task 3: Async worker — utils/meshcore_client.py + +**Files:** +- Create: `utils/meshcore_client.py` + +> **Note:** The method names below (`Connection`, `conn.events()`, `conn.send_text()`) are based on the assumed API from Task 1. Adjust them to match what `pip install meshcore` actually exposes. All library interaction is confined to this one file. + +- [ ] **Step 1: Create utils/meshcore_client.py** + +```python +"""Async worker that runs the meshcore library inside a daemon thread. + +Only this file touches the meshcore library directly. All other Intercept +code goes through MeshcoreClient in utils/meshcore.py. + +If the meshcore library API differs from what is shown here, only this file +needs to change — nothing else depends on library internals. +""" +from __future__ import annotations + +import asyncio +import threading +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from utils.logging import get_logger + +if TYPE_CHECKING: + from utils.meshcore import ( + BLEConfig, ConnectionConfig, MeshcoreClient, + SerialConfig, TCPConfig, + ) + +logger = get_logger('intercept.meshcore.worker') + +# Retry intervals in seconds (exponential backoff) +_RETRY_DELAYS = [5, 15, 45] + + +class AsyncWorker: + """Owns a daemon asyncio event loop; bridges events to MeshcoreClient.""" + + def __init__(self, config: ConnectionConfig, client: MeshcoreClient) -> None: + self._config = config + self._client = client + self._loop: asyncio.AbstractEventLoop | None = None + self._conn = None + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + + def start(self) -> None: + self._stop_event.clear() + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread( + target=self._run, + daemon=True, + name="meshcore-asyncio", + ) + self._thread.start() + + def stop(self) -> None: + self._stop_event.set() + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread: + self._thread.join(timeout=5) + + # ------------------------------------------------------------------ + # Thread entrypoint + # ------------------------------------------------------------------ + + def _run(self) -> None: + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._connect_with_retry()) + except Exception as exc: + logger.exception("Meshcore asyncio thread crashed: %s", exc) + finally: + self._loop.close() + + async def _connect_with_retry(self) -> None: + from utils.meshcore import SerialConfig, TCPConfig, BLEConfig + + for attempt, delay in enumerate(_RETRY_DELAYS + [None]): + if self._stop_event.is_set(): + return + try: + await self._do_connect() + return # clean exit or connection lost without error + except Exception as exc: + logger.warning("Meshcore connect attempt %d failed: %s", attempt + 1, exc) + if delay is None: + self._client.on_error(f"Connection failed after retries: {exc}") + return + await asyncio.sleep(delay) + + async def _do_connect(self) -> None: + # ---------------------------------------------------------------- + # ADAPT HERE: replace method names to match actual meshcore library + # ---------------------------------------------------------------- + import meshcore as mc_lib + from utils.meshcore import SerialConfig, TCPConfig, BLEConfig + + cfg = self._config + + if isinstance(cfg, SerialConfig): + port = cfg.port or 'auto' + # Assumed API: mc_lib.Connection(port=port, baud=cfg.baud) + self._conn = mc_lib.Connection(port=port, baud=cfg.baud) + transport_label = 'serial' + device_label = port + elif isinstance(cfg, TCPConfig): + self._conn = mc_lib.Connection(host=cfg.host, port=cfg.port) + transport_label = 'tcp' + device_label = f"{cfg.host}:{cfg.port}" + elif isinstance(cfg, BLEConfig): + self._conn = mc_lib.Connection(ble_address=cfg.device_address) + transport_label = 'ble' + device_label = cfg.device_address or 'auto' + + # Assumed API: await self._conn.connect() + await self._conn.connect() + self._client.on_connected(transport=transport_label, device=device_label) + + # Assumed API: async for event in self._conn.events() + async for event in self._conn.events(): + if self._stop_event.is_set(): + break + await self._dispatch(event) + + async def _dispatch(self, event) -> None: + """Route a library event to the appropriate MeshcoreClient handler.""" + from utils.meshcore import ( + MeshcoreMessage, MeshcoreNode, MeshcoreTelemetry, MeshcoreTraceroute, + ) + # Assumed event shape: event.type (str), event fields as attributes + # Adjust attribute names to match the actual library's event objects. + t = getattr(event, 'type', None) or getattr(event, 'event_type', None) + + if t in ('message', 'msg', 'rx_text'): + msg = MeshcoreMessage( + id=getattr(event, 'id', None) or str(uuid.uuid4()), + sender_id=str(getattr(event, 'sender', '') or getattr(event, 'from_id', '')), + recipient_id=str(getattr(event, 'recipient', '') or getattr(event, 'to_id', 'BROADCAST')), + text=str(getattr(event, 'text', '') or getattr(event, 'message', '')), + timestamp=datetime.now(timezone.utc), + hop_count=int(getattr(event, 'hops', 0) or 0), + snr=_float_or_none(getattr(event, 'snr', None)), + is_direct=bool(getattr(event, 'is_direct', False)), + ) + self._client.on_message(msg) + + elif t in ('node', 'node_advert', 'node_info'): + node = MeshcoreNode( + node_id=str(getattr(event, 'node_id', '') or getattr(event, 'id', '')), + name=str(getattr(event, 'name', 'Unknown')), + is_repeater=bool(getattr(event, 'is_repeater', False)), + lat=_float_or_none(getattr(event, 'lat', None)), + lon=_float_or_none(getattr(event, 'lon', None)), + battery_pct=_int_or_none(getattr(event, 'battery_pct', None)), + last_seen=datetime.now(timezone.utc), + snr=_float_or_none(getattr(event, 'snr', None)), + hops_away=_int_or_none(getattr(event, 'hops_away', None)), + ) + self._client.on_node(node) + + elif t in ('telemetry',): + tel = MeshcoreTelemetry( + node_id=str(getattr(event, 'node_id', '')), + timestamp=datetime.now(timezone.utc), + battery_pct=_int_or_none(getattr(event, 'battery_pct', None)), + voltage=_float_or_none(getattr(event, 'voltage', None)), + temperature=_float_or_none(getattr(event, 'temperature', None)), + humidity=_float_or_none(getattr(event, 'humidity', None)), + uptime_secs=_int_or_none(getattr(event, 'uptime_secs', None)), + ) + self._client.on_telemetry(tel) + + elif t in ('traceroute',): + tr = MeshcoreTraceroute( + origin_id=str(getattr(event, 'origin_id', '')), + destination_id=str(getattr(event, 'destination_id', '')), + hops=list(getattr(event, 'hops', [])), + snr_per_hop=[float(x) for x in getattr(event, 'snr_per_hop', [])], + timestamp=datetime.now(timezone.utc), + ) + self._client.on_traceroute(tr) + + # ------------------------------------------------------------------ + # Actions — called from Flask thread via run_coroutine_threadsafe + # ------------------------------------------------------------------ + + def _submit(self, coro) -> None: + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(coro, self._loop) + + def send_text(self, recipient_id: str, text: str) -> None: + async def _send(): + if self._conn: + # Assumed API: await self._conn.send_text(recipient_id, text) + await self._conn.send_text(recipient_id, text) + self._submit(_send()) + + def request_traceroute(self, node_id: str) -> None: + async def _trace(): + if self._conn: + # Assumed API: await self._conn.traceroute(node_id) + await self._conn.traceroute(node_id) + self._submit(_trace()) + + def scan_ble_sync(self) -> list[dict]: + import meshcore as mc_lib + async def _scan(): + # Assumed API: await mc_lib.scan_ble() → [{address, name, rssi}] + return await mc_lib.scan_ble() + future = asyncio.run_coroutine_threadsafe(_scan(), self._loop) + try: + return future.result(timeout=10) + except Exception: + return [] + + +def _float_or_none(v) -> float | None: + try: + return float(v) if v is not None else None + except (TypeError, ValueError): + return None + + +def _int_or_none(v) -> int | None: + try: + return int(v) if v is not None else None + except (TypeError, ValueError): + return None +``` + +- [ ] **Step 2: Commit** + +```bash +git add utils/meshcore_client.py +git commit -m "feat(meshcore): add async worker bridge (utils/meshcore_client.py)" +``` + +--- + +## Task 4: Flask routes — routes/meshcore.py + +**Files:** +- Create: `routes/meshcore.py` + +- [ ] **Step 1: Create routes/meshcore.py** + +```python +"""Meshcore device routes. + +Endpoints for connecting to Meshcore devices (serial, TCP, BLE), +streaming live events, and managing messages, contacts, and nodes. +""" +from __future__ import annotations + +import os +import queue + +from flask import Blueprint, Response, jsonify, request + +from utils.logging import get_logger +from utils.meshcore import ( + BLEConfig, + SerialConfig, + TCPConfig, + get_meshcore_client, + is_meshcore_available, + list_serial_ports, +) +from utils.responses import api_error +from utils.sse import sse_stream_fanout + +logger = get_logger('intercept.meshcore') + +meshcore_bp = Blueprint('meshcore', __name__, url_prefix='/meshcore') + + +def _client(): + return get_meshcore_client() + + +# --------------------------------------------------------------------------- +# Status & connection management +# --------------------------------------------------------------------------- + +@meshcore_bp.route('/status') +def status(): + if not is_meshcore_available(): + return jsonify({'available': False, 'state': 'unavailable', + 'message': 'meshcore package not installed. Run: pip install meshcore'}) + c = _client() + return jsonify({'available': True, 'state': c.get_state().value}) + + +@meshcore_bp.route('/connect', methods=['POST']) +def connect(): + if not is_meshcore_available(): + return api_error('meshcore not installed', 503) + data = request.get_json(silent=True) or {} + transport = data.get('transport', 'serial') + + if transport == 'serial': + config = SerialConfig(port=data.get('port'), baud=int(data.get('baud', 115200))) + elif transport == 'tcp': + host = data.get('host', 'localhost') + port = int(data.get('port', 5000)) + config = TCPConfig(host=host, port=port) + elif transport == 'ble': + config = BLEConfig(device_address=data.get('address')) + else: + return api_error(f"Unknown transport: {transport}", 400) + + _client().connect(config) + return jsonify({'status': 'connecting', 'transport': transport}) + + +@meshcore_bp.route('/disconnect', methods=['POST']) +def disconnect(): + _client().disconnect() + return jsonify({'status': 'disconnected'}) + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + +@meshcore_bp.route('/ports') +def ports(): + return jsonify({'ports': list_serial_ports()}) + + +@meshcore_bp.route('/ble/scan') +def ble_scan(): + if not is_meshcore_available(): + return api_error('meshcore not installed', 503) + devices = _client().scan_ble() + return jsonify({'devices': devices}) + + +# --------------------------------------------------------------------------- +# SSE stream +# --------------------------------------------------------------------------- + +@meshcore_bp.route('/stream') +def stream(): + def _gen(): + q = _client().get_queue() + import json + while True: + try: + event = q.get(timeout=30) + yield f"data: {json.dumps(event)}\n\n" + except queue.Empty: + yield ": keepalive\n\n" + return Response(_gen(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + + +# --------------------------------------------------------------------------- +# Messages +# --------------------------------------------------------------------------- + +@meshcore_bp.route('/messages') +def messages(): + return jsonify({'messages': _client().get_messages()}) + + +@meshcore_bp.route('/send', methods=['POST']) +def send(): + data = request.get_json(silent=True) or {} + text = data.get('text', '').strip() + recipient_id = data.get('recipient_id', 'BROADCAST') + if not text: + return api_error('text is required', 400) + if len(text) > 237: + return api_error('text exceeds 237-character Meshcore limit', 400) + _client().send_text(recipient_id, text) + return jsonify({'status': 'queued'}) + + +# --------------------------------------------------------------------------- +# Nodes +# --------------------------------------------------------------------------- + +@meshcore_bp.route('/nodes') +def nodes(): + return jsonify({'nodes': _client().get_nodes()}) + + +@meshcore_bp.route('/repeaters') +def repeaters(): + return jsonify({'repeaters': _client().get_repeaters()}) + + +# --------------------------------------------------------------------------- +# Contacts +# --------------------------------------------------------------------------- + +@meshcore_bp.route('/contacts', methods=['GET']) +def list_contacts(): + return jsonify({'contacts': _client().get_contacts()}) + + +@meshcore_bp.route('/contacts', methods=['POST']) +def add_contact(): + from utils.meshcore import MeshcoreContact + data = request.get_json(silent=True) or {} + node_id = data.get('node_id', '').strip() + name = data.get('name', '').strip() + public_key = data.get('public_key', '').strip() + if not node_id or not name or not public_key: + return api_error('node_id, name, and public_key are required', 400) + contact = MeshcoreContact(node_id=node_id, name=name, public_key=public_key, last_msg=None) + _client().add_contact(contact) + return jsonify({'status': 'added', 'contact': contact.to_dict()}) + + +@meshcore_bp.route('/contacts/', methods=['DELETE']) +def delete_contact(node_id: str): + removed = _client().remove_contact(node_id) + if not removed: + return api_error('contact not found', 404) + return jsonify({'status': 'removed'}) + + +# --------------------------------------------------------------------------- +# Telemetry & traceroute +# --------------------------------------------------------------------------- + +@meshcore_bp.route('/telemetry/') +def telemetry(node_id: str): + return jsonify({'node_id': node_id, 'telemetry': _client().get_telemetry(node_id)}) + + +@meshcore_bp.route('/traceroute', methods=['POST']) +def traceroute(): + data = request.get_json(silent=True) or {} + node_id = data.get('node_id', '').strip() + if not node_id: + return api_error('node_id is required', 400) + _client().request_traceroute(node_id) + return jsonify({'status': 'requested', 'node_id': node_id}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add routes/meshcore.py +git commit -m "feat(meshcore): add Flask blueprint with all 15 endpoints + SSE stream" +``` + +--- + +## Task 5: Route tests — tests/test_meshcore_routes.py + +**Files:** +- Create: `tests/test_meshcore_routes.py` + +- [ ] **Step 1: Write route tests** + +```python +"""Route tests for Meshcore blueprint.""" +import json +from unittest.mock import MagicMock, patch +import pytest + + +@pytest.fixture() +def app(): + from app import create_app + application = create_app({'TESTING': True}) + return application + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture(autouse=True) +def mock_meshcore_client(): + mc = MagicMock() + mc.get_state.return_value = MagicMock(value='disconnected') + mc.get_messages.return_value = [] + mc.get_nodes.return_value = [] + mc.get_repeaters.return_value = [] + mc.get_contacts.return_value = [] + mc.get_telemetry.return_value = [] + mc.scan_ble.return_value = [] + with patch('routes.meshcore.get_meshcore_client', return_value=mc), \ + patch('routes.meshcore.is_meshcore_available', return_value=True): + yield mc + + +class TestStatus: + def test_returns_json(self, client): + r = client.get('/meshcore/status') + assert r.status_code == 200 + d = r.get_json() + assert 'state' in d + assert 'available' in d + + def test_unavailable_when_not_installed(self, client): + with patch('routes.meshcore.is_meshcore_available', return_value=False): + r = client.get('/meshcore/status') + d = r.get_json() + assert d['available'] is False + + +class TestConnect: + def test_serial_connect(self, client, mock_meshcore_client): + r = client.post('/meshcore/connect', + json={'transport': 'serial', 'port': '/dev/ttyUSB0'}) + assert r.status_code == 200 + assert r.get_json()['status'] == 'connecting' + mock_meshcore_client.connect.assert_called_once() + + def test_tcp_connect(self, client, mock_meshcore_client): + r = client.post('/meshcore/connect', + json={'transport': 'tcp', 'host': '192.168.1.10', 'port': 5000}) + assert r.status_code == 200 + mock_meshcore_client.connect.assert_called_once() + + def test_ble_connect(self, client, mock_meshcore_client): + r = client.post('/meshcore/connect', + json={'transport': 'ble', 'address': 'AA:BB:CC:DD:EE:FF'}) + assert r.status_code == 200 + + def test_unknown_transport_returns_400(self, client): + r = client.post('/meshcore/connect', json={'transport': 'zigbee'}) + assert r.status_code == 400 + + +class TestSend: + def test_sends_text(self, client, mock_meshcore_client): + r = client.post('/meshcore/send', + json={'text': 'hello', 'recipient_id': 'NODE1'}) + assert r.status_code == 200 + mock_meshcore_client.send_text.assert_called_once_with('NODE1', 'hello') + + def test_empty_text_returns_400(self, client): + r = client.post('/meshcore/send', json={'text': ''}) + assert r.status_code == 400 + + def test_missing_text_returns_400(self, client): + r = client.post('/meshcore/send', json={}) + assert r.status_code == 400 + + def test_text_too_long_returns_400(self, client): + r = client.post('/meshcore/send', json={'text': 'x' * 238}) + assert r.status_code == 400 + + +class TestContacts: + def test_add_contact(self, client, mock_meshcore_client): + r = client.post('/meshcore/contacts', json={ + 'node_id': 'N1', 'name': 'Alice', 'public_key': 'abc123' + }) + assert r.status_code == 200 + mock_meshcore_client.add_contact.assert_called_once() + + def test_add_contact_missing_fields_returns_400(self, client): + r = client.post('/meshcore/contacts', json={'node_id': 'N1'}) + assert r.status_code == 400 + + def test_delete_contact_not_found(self, client, mock_meshcore_client): + mock_meshcore_client.remove_contact.return_value = False + r = client.delete('/meshcore/contacts/UNKNOWN') + assert r.status_code == 404 + + def test_delete_contact_success(self, client, mock_meshcore_client): + mock_meshcore_client.remove_contact.return_value = True + r = client.delete('/meshcore/contacts/N1') + assert r.status_code == 200 + + +class TestPorts: + def test_returns_list(self, client): + with patch('routes.meshcore.list_serial_ports', return_value=['/dev/ttyUSB0']): + r = client.get('/meshcore/ports') + assert r.status_code == 200 + assert '/dev/ttyUSB0' in r.get_json()['ports'] + + +class TestTraceroute: + def test_requires_node_id(self, client): + r = client.post('/meshcore/traceroute', json={}) + assert r.status_code == 400 + + def test_queues_request(self, client, mock_meshcore_client): + r = client.post('/meshcore/traceroute', json={'node_id': 'NODE1'}) + assert r.status_code == 200 + mock_meshcore_client.request_traceroute.assert_called_once_with('NODE1') +``` + +- [ ] **Step 2: Run tests** + +```bash +pytest tests/test_meshcore_routes.py -v +``` + +Expected: All tests pass. If `create_app` import fails, check `app.py` for the factory function name and adjust the import. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_meshcore_routes.py +git commit -m "test(meshcore): add route tests" +``` + +--- + +## Task 6: Integration tests — tests/test_meshcore_integration.py + +**Files:** +- Create: `tests/test_meshcore_integration.py` + +- [ ] **Step 1: Write integration tests** + +```python +"""Integration tests: mock meshcore library at its boundary, test full flow.""" +import json +import queue +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch +import pytest + + +class TestMessageRoundTrip: + """Connect → receive event → appears in message store and SSE queue.""" + + def test_message_stored_on_receipt(self): + from utils.meshcore import MeshcoreClient, MeshcoreMessage + client = MeshcoreClient() + + msg = MeshcoreMessage( + id='t1', sender_id='A', recipient_id='BROADCAST', + text='test', timestamp=datetime.now(timezone.utc), + hop_count=1, snr=-10.0, is_direct=False, + ) + client.on_message(msg) + + msgs = client.get_messages() + assert len(msgs) == 1 + assert msgs[0]['text'] == 'test' + + def test_message_pushed_to_queue(self): + from utils.meshcore import MeshcoreClient, MeshcoreMessage + client = MeshcoreClient() + + msg = MeshcoreMessage( + id='t2', sender_id='B', recipient_id='BROADCAST', + text='queued', timestamp=datetime.now(timezone.utc), + hop_count=0, snr=None, is_direct=True, + ) + client.on_message(msg) + + event = client.get_queue().get_nowait() + assert event['type'] == 'message' + assert event['data']['text'] == 'queued' + + def test_message_history_capped_at_500(self): + from utils.meshcore import MeshcoreClient, MeshcoreMessage + client = MeshcoreClient() + + for i in range(510): + client.on_message(MeshcoreMessage( + id=str(i), sender_id='X', recipient_id='BROADCAST', + text=f'msg{i}', timestamp=datetime.now(timezone.utc), + hop_count=0, snr=None, is_direct=False, + )) + + assert len(client.get_messages()) == 500 + + +class TestNodeAndRepeater: + def test_repeater_appears_in_repeaters_only(self): + from utils.meshcore import MeshcoreClient, MeshcoreNode + client = MeshcoreClient() + + client.on_node(MeshcoreNode( + node_id='R1', name='Roof', is_repeater=True, + lat=51.5, lon=-0.1, battery_pct=100, + last_seen=datetime.now(timezone.utc), snr=-3.0, hops_away=1, + )) + client.on_node(MeshcoreNode( + node_id='C1', name='Client', is_repeater=False, + lat=51.6, lon=-0.2, battery_pct=72, + last_seen=datetime.now(timezone.utc), snr=-8.0, hops_away=2, + )) + + assert len(client.get_repeaters()) == 1 + assert client.get_repeaters()[0]['node_id'] == 'R1' + assert len(client.get_nodes()) == 2 + + +class TestTracerouteRoundTrip: + def test_traceroute_pushed_to_queue(self): + from utils.meshcore import MeshcoreClient, MeshcoreTraceroute + client = MeshcoreClient() + + tr = MeshcoreTraceroute( + origin_id='A', destination_id='B', + hops=['A', 'R1', 'B'], snr_per_hop=[-5.0, -8.0], + timestamp=datetime.now(timezone.utc), + ) + client.on_traceroute(tr) + + q = client.get_queue() + event = q.get_nowait() + assert event['type'] == 'traceroute' + assert event['data']['hops'] == ['A', 'R1', 'B'] + + +class TestBLEDockerBlock: + def test_ble_blocked_in_docker(self, tmp_path, monkeypatch): + monkeypatch.setenv('INTERCEPT_DOCKER', '1') + from utils.meshcore import BLEConfig, MeshcoreClient + client = MeshcoreClient() + + with patch('utils.meshcore_client.AsyncWorker') as MockWorker: + client.connect(BLEConfig()) + MockWorker.assert_not_called() + + q = client.get_queue() + event = q.get_nowait() + assert event['data']['state'] == 'error' + assert 'Docker' in event['data']['message'] + + +class TestConnectionStateTransitions: + def test_on_connected_pushes_status_event(self): + from utils.meshcore import MeshcoreClient, ConnectionState + client = MeshcoreClient() + client.on_connected(transport='serial', device='/dev/ttyUSB0') + + assert client.get_state() == ConnectionState.CONNECTED + event = client.get_queue().get_nowait() + assert event['type'] == 'status' + assert event['data']['state'] == 'connected' + + def test_on_error_pushes_status_event(self): + from utils.meshcore import MeshcoreClient, ConnectionState + client = MeshcoreClient() + client.on_error('timeout') + + assert client.get_state() == ConnectionState.ERROR + event = client.get_queue().get_nowait() + assert event['data']['state'] == 'error' +``` + +- [ ] **Step 2: Run integration tests** + +```bash +pytest tests/test_meshcore_integration.py -v +``` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_meshcore_integration.py +git commit -m "test(meshcore): add integration tests" +``` + +--- + +## Task 7: Register blueprint and add dependency + +**Files:** +- Modify: `routes/__init__.py` +- Modify: `requirements.txt` + +- [ ] **Step 1: Register blueprint in routes/__init__.py** + +Open `routes/__init__.py`. Find the line `from .meshtastic import meshtastic_bp` (line ~26). Add immediately after: + +```python +from .meshcore import meshcore_bp +``` + +Find `app.register_blueprint(meshtastic_bp)` (line ~72). Add immediately after: + +```python +app.register_blueprint(meshcore_bp) +``` + +- [ ] **Step 2: Add dependency to requirements.txt** + +Find the line `meshtastic>=2.0.0` (line ~29). Add immediately after: + +``` +meshcore>=1.0.0 +``` + +- [ ] **Step 3: Verify the app starts** + +```bash +python -c "from app import create_app; app = create_app(); print('OK')" +``` + +Expected: `OK` with no import errors. + +- [ ] **Step 4: Run full test suite** + +```bash +pytest tests/test_meshcore_client.py tests/test_meshcore_routes.py tests/test_meshcore_integration.py -v +``` + +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add routes/__init__.py requirements.txt +git commit -m "feat(meshcore): register blueprint and add meshcore dependency" +``` + +--- + +## Task 8: CSS — static/css/modes/meshcore.css + +**Files:** +- Create: `static/css/modes/meshcore.css` + +- [ ] **Step 1: Create the CSS file** + +```css +/* Meshcore mode — scoped styles */ + +#meshcoreMode { + display: flex; + flex-direction: column; + height: 100%; + gap: 0; +} + +/* ── Sidebar ── */ +.meshcore-sidebar { + width: 220px; + min-width: 220px; + background: var(--bg-card); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow-y: auto; + flex-shrink: 0; +} + +.meshcore-sidebar-section { + padding: 10px; + border-bottom: 1px solid var(--border-color); +} + +.meshcore-sidebar-section h4 { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin: 0 0 8px; +} + +/* ── Connection panel ── */ +.meshcore-transport-tabs { + display: flex; + gap: 4px; + margin-bottom: 8px; +} + +.meshcore-transport-tab { + flex: 1; + padding: 4px 0; + font-size: 11px; + text-align: center; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: 3px; + cursor: pointer; + color: var(--text-muted); + transition: background 0.15s, color 0.15s; +} + +.meshcore-transport-tab.active { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); +} + +/* ── Status indicator ── */ +.meshcore-status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + background: var(--text-muted); +} + +.meshcore-status-dot.connected { background: #4caf50; box-shadow: 0 0 5px #4caf50; } +.meshcore-status-dot.connecting { background: #ff9800; animation: meshcore-pulse 1s infinite; } +.meshcore-status-dot.error { background: #f44336; } + +@keyframes meshcore-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* ── Node / contact list items ── */ +.meshcore-node-item { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 0; + font-size: 12px; + border-bottom: 1px solid var(--border-color); + cursor: pointer; +} + +.meshcore-node-item:last-child { border-bottom: none; } + +.meshcore-node-icon { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent-cyan); + flex-shrink: 0; +} + +.meshcore-node-icon.repeater { + border-radius: 0; + clip-path: polygon(50% 0%, 100% 100%, 0% 100%); + background: #ff9800; +} + +.meshcore-node-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.meshcore-node-meta { font-size: 10px; color: var(--text-muted); } + +/* ── Main content area ── */ +.meshcore-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Tab bar ── */ +.meshcore-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border-color); + background: var(--bg-card); + flex-shrink: 0; +} + +.meshcore-tab { + padding: 8px 16px; + font-size: 12px; + cursor: pointer; + color: var(--text-muted); + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; +} + +.meshcore-tab.active { + color: var(--accent-cyan); + border-bottom-color: var(--accent-cyan); +} + +/* ── Message feed ── */ +.meshcore-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.meshcore-message { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px 10px; + font-size: 12px; +} + +.meshcore-message.pending { opacity: 0.6; border-style: dashed; } +.meshcore-message.direct { border-left: 3px solid var(--accent-cyan); } + +.meshcore-message-header { + display: flex; + justify-content: space-between; + margin-bottom: 4px; + font-size: 11px; + color: var(--text-muted); +} + +.meshcore-message-sender { color: var(--accent-cyan); font-weight: 600; } +.meshcore-message-text { color: var(--text-primary); } + +/* ── Compose bar ── */ +.meshcore-compose { + display: flex; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--border-color); + background: var(--bg-card); + flex-shrink: 0; +} + +.meshcore-compose input { + flex: 1; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 6px 10px; + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-mono); +} + +.meshcore-compose input:focus { + outline: none; + border-color: var(--accent-cyan); +} + +/* ── Repeaters tab table ── */ +.meshcore-repeater-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.meshcore-repeater-table th { + text-align: left; + padding: 6px 10px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + border-bottom: 1px solid var(--border-color); +} + +.meshcore-repeater-table td { + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); +} + +/* ── Traceroute modal ── */ +.meshcore-traceroute-hops { + display: flex; + align-items: center; + gap: 0; + flex-wrap: wrap; + padding: 16px 0; +} + +.meshcore-hop { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.meshcore-hop-node { + background: var(--bg-input); + border: 1px solid var(--accent-cyan); + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + font-family: var(--font-mono); +} + +.meshcore-hop-arrow { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 6px; + font-size: 10px; + color: var(--text-muted); +} + +/* ── Map tab ── */ +#meshcoreMap { + width: 100%; + height: 100%; + min-height: 300px; +} + +.meshcore-tab-panel { display: none; flex: 1; overflow: hidden; } +.meshcore-tab-panel.active { display: flex; flex-direction: column; } +``` + +- [ ] **Step 2: Commit** + +```bash +git add static/css/modes/meshcore.css +git commit -m "feat(meshcore): add scoped CSS" +``` + +--- + +## Task 9: HTML partial — templates/partials/modes/meshcore.html + +**Files:** +- Create: `templates/partials/modes/meshcore.html` + +- [ ] **Step 1: Create the template partial** + +```html +{# Meshcore Mode Partial #} +{# /meshcoreMode #} + +{# ── Add Contact Modal ── #} + + +{# ── Traceroute Modal ── #} + +``` + +- [ ] **Step 2: Commit** + +```bash +git add templates/partials/modes/meshcore.html +git commit -m "feat(meshcore): add HTML partial (sidebar, tabs, modals)" +``` + +--- + +## Task 10: JavaScript — static/js/modes/meshcore.js + +**Files:** +- Create: `static/js/modes/meshcore.js` + +- [ ] **Step 1: Create meshcore.js** + +```javascript +/** + * Meshcore Mode + * Handles connection, live SSE streaming, message feed, map, telemetry, + * repeater management, contacts, and traceroute visualization. + */ +const MeshCore = (function () { + + // ── State ────────────────────────────────────────────────────────────── + let _transport = 'serial'; + let _eventSource = null; + let _map = null; + let _markers = {}; // node_id → L.marker + let _telemetryChart = null; + let _connected = false; + + // ── Init / Destroy ───────────────────────────────────────────────────── + function init() { + _loadPorts(); + _checkStatus(); + _initMap(); + } + + function destroy() { + if (_eventSource) { _eventSource.close(); _eventSource = null; } + if (_map) { _map.remove(); _map = null; _markers = {}; } + if (_telemetryChart) { _telemetryChart.destroy(); _telemetryChart = null; } + _connected = false; + } + + function invalidateMap() { + if (_map) _map.invalidateSize(); + } + + // ── Status ───────────────────────────────────────────────────────────── + async function _checkStatus() { + try { + const r = await fetch('/meshcore/status'); + const d = await r.json(); + _updateStatusUI(d.state || 'disconnected', d.message); + } catch (e) { /* ignore */ } + } + + function _updateStatusUI(state, message) { + const dot = document.getElementById('meshcoreStatusDot'); + const txt = document.getElementById('meshcoreStatusText'); + const connectBtn = document.getElementById('meshcoreConnectBtn'); + const disconnectBtn = document.getElementById('meshcoreDisconnectBtn'); + if (!dot) return; + + dot.className = 'meshcore-status-dot ' + state; + const labels = { connected: 'Connected', connecting: 'Connecting…', error: 'Error', disconnected: 'Disconnected', unavailable: 'Not available' }; + txt.textContent = message || labels[state] || state; + + _connected = state === 'connected'; + if (connectBtn) connectBtn.disabled = state === 'connecting' || _connected; + if (disconnectBtn) disconnectBtn.disabled = !_connected; + + if (_connected && !_eventSource) _startSSE(); + if (!_connected && _eventSource) { _eventSource.close(); _eventSource = null; } + } + + // ── Transport selector ───────────────────────────────────────────────── + function selectTransport(t) { + _transport = t; + document.querySelectorAll('.meshcore-transport-tab').forEach(el => { + el.classList.toggle('active', el.dataset.transport === t); + }); + document.getElementById('meshcoreSerialConfig').style.display = t === 'serial' ? '' : 'none'; + document.getElementById('meshcoreTcpConfig').style.display = t === 'tcp' ? '' : 'none'; + document.getElementById('meshcoreBleConfig').style.display = t === 'ble' ? '' : 'none'; + } + + // ── Connect / Disconnect ─────────────────────────────────────────────── + async function connect() { + let body = { transport: _transport }; + if (_transport === 'serial') { + body.port = document.getElementById('meshcorePortSelect').value || null; + } else if (_transport === 'tcp') { + body.host = document.getElementById('meshcoreTcpHost').value; + body.port = parseInt(document.getElementById('meshcoreTcpPort').value, 10); + } else if (_transport === 'ble') { + body.address = document.getElementById('meshcoreBleSelect').value || null; + } + try { + await fetch('/meshcore/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + _updateStatusUI('connecting'); + } catch (e) { console.error('Connect failed:', e); } + } + + async function disconnect() { + try { + await fetch('/meshcore/disconnect', { method: 'POST' }); + _updateStatusUI('disconnected'); + } catch (e) { console.error('Disconnect failed:', e); } + } + + // ── Port / BLE discovery ─────────────────────────────────────────────── + async function _loadPorts() { + try { + const r = await fetch('/meshcore/ports'); + const d = await r.json(); + const sel = document.getElementById('meshcorePortSelect'); + if (!sel) return; + const current = sel.value; + sel.innerHTML = ''; + (d.ports || []).forEach(p => { + const o = document.createElement('option'); + o.value = p; o.textContent = p; + if (p === current) o.selected = true; + sel.appendChild(o); + }); + } catch (e) { /* ignore */ } + } + + async function scanBle() { + try { + const r = await fetch('/meshcore/ble/scan'); + const d = await r.json(); + const sel = document.getElementById('meshcoreBleSelect'); + if (!sel) return; + sel.innerHTML = ''; + (d.devices || []).forEach(dev => { + const o = document.createElement('option'); + o.value = dev.address; + o.textContent = `${dev.name || 'Unknown'} (${dev.address}) RSSI ${dev.rssi}`; + sel.appendChild(o); + }); + } catch (e) { console.error('BLE scan failed:', e); } + } + + // ── SSE Stream ───────────────────────────────────────────────────────── + function _startSSE() { + if (_eventSource) _eventSource.close(); + _eventSource = new EventSource('/meshcore/stream'); + _eventSource.onmessage = (e) => { + try { + const event = JSON.parse(e.data); + _routeEvent(event); + } catch (err) { /* ignore malformed */ } + }; + _eventSource.onerror = () => { + setTimeout(_checkStatus, 2000); + }; + } + + function _routeEvent(event) { + switch (event.type) { + case 'status': _updateStatusUI(event.data.state, event.data.message); break; + case 'message': _appendMessage(event.data); break; + case 'node': _updateNode(event.data); break; + case 'telemetry': _storeTelemetry(event.data); break; + case 'traceroute': _showTraceroute(event.data); break; + } + } + + // ── Messages ─────────────────────────────────────────────────────────── + function _appendMessage(msg) { + const feed = document.getElementById('meshcoreMessageFeed'); + if (!feed) return; + // Remove placeholder + const placeholder = feed.querySelector('div[style*="padding:24px"]'); + if (placeholder) placeholder.remove(); + + const el = document.createElement('div'); + el.className = 'meshcore-message' + (msg.is_direct ? ' direct' : '') + (msg.pending ? ' pending' : ''); + el.dataset.msgId = msg.id; + const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : ''; + const snr = msg.snr !== null && msg.snr !== undefined ? ` · ${msg.snr} dB` : ''; + el.innerHTML = ` +
+ ${_esc(msg.sender_id)} + ${_esc(msg.recipient_id)} · ${ts}${snr} +
+
${_esc(msg.text)}
`; + feed.appendChild(el); + feed.scrollTop = feed.scrollHeight; + } + + async function sendMessage() { + const input = document.getElementById('meshcoreComposeInput'); + const recipientSel = document.getElementById('meshcoreRecipientSelect'); + const text = input ? input.value.trim() : ''; + if (!text) return; + const recipient_id = recipientSel ? recipientSel.value : 'BROADCAST'; + + // Optimistic + const tempId = 'pending-' + Date.now(); + _appendMessage({ id: tempId, sender_id: 'Me', recipient_id, text, timestamp: new Date().toISOString(), is_direct: recipient_id !== 'BROADCAST', snr: null, pending: true }); + if (input) input.value = ''; + + try { + const r = await fetch('/meshcore/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, recipient_id }), + }); + if (!r.ok) { + const d = await r.json(); + _removePending(tempId); + alert(d.error || 'Send failed'); + } + } catch (e) { + _removePending(tempId); + console.error('Send failed:', e); + } + } + + function _removePending(id) { + const el = document.querySelector(`[data-msg-id="${id}"]`); + if (el) el.remove(); + } + + // ── Nodes ────────────────────────────────────────────────────────────── + function _updateNode(node) { + _updateNodeSidebar(node); + _updateMapMarker(node); + _updateRepeaterTable(node); + _updateTelemetryNodeSelect(node); + _updateRecipientSelect(node); + } + + function _updateNodeSidebar(node) { + const list = document.getElementById('meshcoreNodeList'); + if (!list) return; + let el = document.getElementById('meshcore-node-' + node.node_id); + if (!el) { + el = document.createElement('div'); + el.className = 'meshcore-node-item'; + el.id = 'meshcore-node-' + node.node_id; + list.innerHTML = ''; // clear placeholder on first node + list.appendChild(el); + } + const hops = node.hops_away !== null ? `${node.hops_away}h` : '?'; + const snr = node.snr !== null ? `${node.snr}dB` : ''; + el.innerHTML = ` +
+
${_esc(node.name)}
+
${hops} ${snr}
`; + } + + function _updateRepeaterTable(node) { + if (!node.is_repeater) return; + const tbody = document.getElementById('meshcoreRepeaterTableBody'); + if (!tbody) return; + let row = document.getElementById('meshcore-rptr-' + node.node_id); + if (!row) { + if (tbody.querySelector('td[colspan]')) tbody.innerHTML = ''; + row = document.createElement('tr'); + row.id = 'meshcore-rptr-' + node.node_id; + tbody.appendChild(row); + } + const ls = node.last_seen ? new Date(node.last_seen).toLocaleTimeString() : '—'; + row.innerHTML = `${_esc(node.name)}${_esc(node.node_id)}${node.hops_away ?? '—'}${node.snr ?? '—'}${node.battery_pct !== null ? node.battery_pct + '%' : '—'}${ls}`; + } + + function _updateTelemetryNodeSelect(node) { + const sel = document.getElementById('meshcoreTelemetryNodeSelect'); + if (!sel || sel.querySelector(`option[value="${node.node_id}"]`)) return; + const o = document.createElement('option'); + o.value = node.node_id; o.textContent = node.name || node.node_id; + sel.appendChild(o); + } + + function _updateRecipientSelect(node) { + const sel = document.getElementById('meshcoreRecipientSelect'); + if (!sel || sel.querySelector(`option[value="${node.node_id}"]`)) return; + const o = document.createElement('option'); + o.value = node.node_id; o.textContent = node.name || node.node_id; + sel.appendChild(o); + } + + // ── Map ──────────────────────────────────────────────────────────────── + function _initMap() { + const container = document.getElementById('meshcoreMap'); + if (!container || _map) return; + _map = L.map('meshcoreMap', { zoomControl: true }).setView([20, 0], 2); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 18, + }).addTo(_map); + } + + function _updateMapMarker(node) { + if (node.lat === null || node.lon === null) return; + if (!_map) return; + + const icon = L.divIcon({ + className: '', + html: node.is_repeater + ? `
` + : `
`, + iconSize: [14, 14], + iconAnchor: [7, 7], + }); + + if (_markers[node.node_id]) { + _markers[node.node_id].setLatLng([node.lat, node.lon]).setIcon(icon); + } else { + _markers[node.node_id] = L.marker([node.lat, node.lon], { icon }) + .bindPopup(`${_esc(node.name)}
${node.node_id}
Hops: ${node.hops_away ?? '?'}`) + .addTo(_map); + } + } + + // ── Telemetry ────────────────────────────────────────────────────────── + function _storeTelemetry(data) { /* SSE telemetry stored server-side; chart loads on demand */ } + + async function loadTelemetry(nodeId) { + if (!nodeId) return; + try { + const r = await fetch(`/meshcore/telemetry/${encodeURIComponent(nodeId)}`); + const d = await r.json(); + _renderTelemetryChart(d.telemetry || []); + } catch (e) { console.error('Telemetry load failed:', e); } + } + + function _renderTelemetryChart(data) { + const ctx = document.getElementById('meshcoreTelemetryChart'); + if (!ctx) return; + if (_telemetryChart) { _telemetryChart.destroy(); _telemetryChart = null; } + if (!data.length) return; + + const labels = data.map(t => new Date(t.timestamp).toLocaleTimeString()); + _telemetryChart = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: [ + { label: 'Battery %', data: data.map(t => t.battery_pct), borderColor: '#4caf50', tension: 0.3, fill: false }, + { label: 'Temp °C', data: data.map(t => t.temperature), borderColor: '#ff9800', tension: 0.3, fill: false, yAxisID: 'y2' }, + ], + }, + options: { + responsive: true, + scales: { + y: { min: 0, max: 100, title: { display: true, text: 'Battery %' } }, + y2: { position: 'right', title: { display: true, text: 'Temp °C' } }, + }, + plugins: { legend: { labels: { color: '#ccc' } } }, + }, + }); + } + + // ── Traceroute ───────────────────────────────────────────────────────── + function _showTraceroute(tr) { + const container = document.getElementById('meshcoreTracerouteHops'); + const modal = document.getElementById('meshcoreTracerouteModal'); + if (!container || !modal) return; + + container.innerHTML = ''; + tr.hops.forEach((hop, i) => { + const hopEl = document.createElement('div'); + hopEl.className = 'meshcore-hop'; + hopEl.innerHTML = `
${_esc(hop)}
`; + container.appendChild(hopEl); + + if (i < tr.hops.length - 1) { + const arrow = document.createElement('div'); + arrow.className = 'meshcore-hop-arrow'; + const snr = tr.snr_per_hop[i] !== undefined ? `${tr.snr_per_hop[i]} dB` : ''; + arrow.innerHTML = `${snr}`; + container.appendChild(arrow); + } + }); + modal.style.display = 'flex'; + } + + function closeTraceroute() { + const modal = document.getElementById('meshcoreTracerouteModal'); + if (modal) modal.style.display = 'none'; + } + + // ── Contacts ─────────────────────────────────────────────────────────── + function showAddContact() { + const modal = document.getElementById('meshcoreAddContactModal'); + if (modal) modal.style.display = 'flex'; + } + + function closeAddContact() { + const modal = document.getElementById('meshcoreAddContactModal'); + if (modal) modal.style.display = 'none'; + } + + async function saveContact() { + const nodeId = document.getElementById('meshcoreContactNodeId').value.trim(); + const name = document.getElementById('meshcoreContactName').value.trim(); + const key = document.getElementById('meshcoreContactKey').value.trim(); + if (!nodeId || !name || !key) { alert('All fields required'); return; } + + try { + const r = await fetch('/meshcore/contacts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ node_id: nodeId, name, public_key: key }), + }); + if (r.ok) { + closeAddContact(); + _refreshContacts(); + } else { + const d = await r.json(); + alert(d.error || 'Failed to add contact'); + } + } catch (e) { console.error('Add contact failed:', e); } + } + + async function _refreshContacts() { + try { + const r = await fetch('/meshcore/contacts'); + const d = await r.json(); + const list = document.getElementById('meshcoreContactList'); + if (!list) return; + list.innerHTML = ''; + if (!d.contacts || !d.contacts.length) { + list.innerHTML = '
No contacts
'; + return; + } + d.contacts.forEach(c => { + const el = document.createElement('div'); + el.className = 'meshcore-node-item'; + el.innerHTML = ` +
+
${_esc(c.name)}
+ `; + list.appendChild(el); + }); + } catch (e) { /* ignore */ } + } + + async function deleteContact(nodeId) { + if (!confirm(`Remove contact ${nodeId}?`)) return; + try { + await fetch(`/meshcore/contacts/${encodeURIComponent(nodeId)}`, { method: 'DELETE' }); + _refreshContacts(); + } catch (e) { console.error('Delete contact failed:', e); } + } + + // ── Tabs ─────────────────────────────────────────────────────────────── + function switchTab(name) { + document.querySelectorAll('.meshcore-tab').forEach(t => + t.classList.toggle('active', t.dataset.tab === name)); + const panels = { messages: 'meshcoreTabMessages', map: 'meshcoreTabMap', repeaters: 'meshcoreTabRepeaters', telemetry: 'meshcoreTabTelemetry' }; + Object.entries(panels).forEach(([k, id]) => { + const el = document.getElementById(id); + if (el) el.classList.toggle('active', k === name); + }); + if (name === 'map') setTimeout(() => { if (_map) _map.invalidateSize(); }, 50); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + function _esc(s) { + return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // ── Public API ───────────────────────────────────────────────────────── + return { + init, + destroy, + invalidateMap, + connect, + disconnect, + selectTransport, + scanBle, + sendMessage, + switchTab, + loadTelemetry, + showAddContact, + closeAddContact, + saveContact, + deleteContact, + closeTraceroute, + }; + +})(); +``` + +- [ ] **Step 2: Commit** + +```bash +git add static/js/modes/meshcore.js +git commit -m "feat(meshcore): add frontend JS module (IIFE, SSE, map, telemetry, traceroute)" +``` + +--- + +## Task 11: Wire into index.html (14 insertion points) + +**Files:** +- Modify: `templates/index.html` + +Work through these in order. Each step gives the line number, the existing neighbour line to anchor on, and the exact text to insert. + +- [ ] **Step 1: CSS registration (line ~91)** + +Find: +``` + meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}", +``` +Add immediately after: +``` + meshcore: "{{ url_for('static', filename='css/modes/meshcore.css') }}", +``` + +- [ ] **Step 2: JS registration (line ~175)** + +Find: +``` + meshtastic: "{{ url_for('static', filename='js/modes/meshtastic.js') }}", +``` +Add immediately after: +``` + meshcore: "{{ url_for('static', filename='js/modes/meshcore.js') }}", +``` + +- [ ] **Step 3: Mode card button (line ~427)** + +Find the ``. Add an equivalent block immediately after: +```html + +``` + +- [ ] **Step 4: Include partial (line ~775)** + +Find: +``` + {% include 'partials/modes/meshtastic.html' %} +``` +Add immediately after: +``` + {% include 'partials/modes/meshcore.html' %} +``` + +- [ ] **Step 5: Visuals container (line ~2209)** + +Find: +``` + `: +```html + +``` + +- [ ] **Step 6: Mode catalog entry (line ~3772)** + +Find: +``` + meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' }, +``` +Add immediately after: +``` + meshcore: { label: 'Meshcore', indicator: 'MESHCORE', outputTitle: 'Meshcore Mesh Monitor', group: 'wireless' }, +``` + +- [ ] **Step 7: Destroy handler (line ~4387)** + +Find: +``` + meshtastic: () => typeof Meshtastic !== 'undefined' && Meshtastic.destroy?.(), +``` +Add immediately after: +``` + meshcore: () => typeof MeshCore !== 'undefined' && MeshCore.destroy?.(), +``` + +- [ ] **Step 8: Active class toggle (line ~4725)** + +Find: +``` + document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic'); +``` +Add immediately after: +``` + document.getElementById('meshcoreMode')?.classList.toggle('active', mode === 'meshcore'); +``` + +- [ ] **Step 9: Visuals display (line ~4760-4803)** + +Find: +``` + const meshtasticVisuals = document.getElementById('meshtasticVisuals'); +``` +Add immediately after that block and its corresponding `if (meshtasticVisuals) meshtasticVisuals.style.display = ...` line: +```javascript + const meshcoreVisuals = document.getElementById('meshcoreVisuals'); +``` +And in the display-toggle line nearby: +```javascript + if (meshcoreVisuals) meshcoreVisuals.style.display = mode === 'meshcore' ? 'flex' : 'none'; +``` + +- [ ] **Step 10: modesWithVisuals array (line ~4821)** + +Find the `modesWithVisuals` array. Add `'meshcore'` to it: +``` + const modesWithVisuals = ['satellite', ..., 'meshtastic', 'meshcore', ...]; +``` + +- [ ] **Step 11: Sidebar hide logic (line ~4835)** + +Find: +``` + if (mode === 'meshtastic') { + mainContent.classList.add('mesh-sidebar-hidden'); +``` +Add an `else if` immediately after the closing `}` of the meshtastic block: +```javascript + } else if (mode === 'meshcore') { + mainContent.classList.add('mesh-sidebar-hidden'); +``` + +- [ ] **Step 12: hideRecon array (line ~4879)** + +Find the `hideRecon` array that includes `'meshtastic'`. Add `'meshcore'` to it. + +- [ ] **Step 13: Sidebar restore (line ~4936)** + +Find: +``` + if (mode !== 'meshtastic') { +``` +Change to: +``` + if (mode !== 'meshtastic' && mode !== 'meshcore') { +``` + +- [ ] **Step 14: Mode init handler (line ~4967)** + +Find: +``` + } else if (mode === 'meshtastic') { + Meshtastic.init(); + // Fix map sizing after container becomes visible + setTimeout(() => { + Meshtastic.invalidateMap(); + }, 100); +``` +Add immediately after its closing `}`: +```javascript + } else if (mode === 'meshcore') { + MeshCore.init(); + setTimeout(() => { + MeshCore.invalidateMap(); + }, 100); +``` + +- [ ] **Step 15: Smoke-test the wiring** + +```bash +python -c "from app import create_app; app = create_app(); print('OK')" +``` + +Then start the dev server and open the app in a browser. Click the Meshcore mode card — the panel should appear without JS errors. + +```bash +sudo -E venv/bin/python intercept.py +``` + +Open http://localhost:5000, select Meshcore mode, open browser DevTools console and confirm no errors. + +- [ ] **Step 16: Commit** + +```bash +git add templates/index.html +git commit -m "feat(meshcore): wire Meshcore into index.html (14 insertion points)" +``` + +--- + +## Task 12: Final test run and cleanup + +- [ ] **Step 1: Run full test suite** + +```bash +pytest tests/test_meshcore_client.py tests/test_meshcore_routes.py tests/test_meshcore_integration.py -v +``` + +Expected: All tests pass with no warnings. + +- [ ] **Step 2: Lint** + +```bash +ruff check utils/meshcore.py utils/meshcore_client.py routes/meshcore.py +ruff check --fix utils/meshcore.py utils/meshcore_client.py routes/meshcore.py +``` + +- [ ] **Step 3: Final commit** + +```bash +git add -u +git commit -m "feat(meshcore): complete Meshcore integration — serial, TCP, BLE, full feature parity" +``` + +--- + +## Self-Review Checklist + +- [x] **Spec coverage:** + - USB serial ✓ (SerialConfig, /meshcore/ports, auto-discover) + - TCP ✓ (TCPConfig, meshcore-proxy documented in error messages) + - BLE ✓ (BLEConfig, /meshcore/ble/scan, Docker block) + - Messages ✓ (SSE, /meshcore/messages, /meshcore/send, optimistic UI) + - Node map ✓ (Leaflet, client=circle, repeater=triangle) + - Telemetry ✓ (/meshcore/telemetry/, Chart.js) + - Traceroute ✓ (/meshcore/traceroute, modal hop diagram) + - Repeater management ✓ (is_repeater flag, dedicated tab + map icon) + - Contacts ✓ (CRUD via /meshcore/contacts) + - Error handling ✓ (Docker BLE, SDK not installed, send failure) + - Tests ✓ (client, routes, integration) + - Blueprint registration ✓ (Task 7) + - CSS ✓ (Task 8) + - HTML partial ✓ (Task 9) + - JS frontend ✓ (Task 10) + - index.html wiring ✓ (Task 11, 14 points) + +- [x] **No placeholders** — all code steps contain actual code +- [x] **Type consistency** — `MeshcoreMessage`, `MeshcoreNode`, `MeshcoreContact`, `MeshcoreTelemetry`, `MeshcoreTraceroute` used consistently across utils, routes, and tests. `get_meshcore_client()` is the singleton accessor throughout. +- [x] **Async library API** — Task 1 explicitly tells the developer to verify API names before Task 3. All library calls are confined to `utils/meshcore_client.py`.