# 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`.