Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
88 KiB
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
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:
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:
"""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
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
"""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
pytest tests/test_meshcore_client.py -v
Expected: All 8 tests pass.
- Step 5: Commit
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 whatpip install meshcoreactually exposes. All library interaction is confined to this one file.
- Step 1: Create utils/meshcore_client.py
"""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
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
"""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/<node_id>', 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/<node_id>')
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
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
"""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
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
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
"""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
pytest tests/test_meshcore_integration.py -v
Expected: All tests pass.
- Step 3: Commit
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:
from .meshcore import meshcore_bp
Find app.register_blueprint(meshtastic_bp) (line ~72). Add immediately after:
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
python -c "from app import create_app; app = create_app(); print('OK')"
Expected: OK with no import errors.
- Step 4: Run full test suite
pytest tests/test_meshcore_client.py tests/test_meshcore_routes.py tests/test_meshcore_integration.py -v
Expected: All tests pass.
- Step 5: Commit
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
/* 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
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
{# Meshcore Mode Partial #}
<div id="meshcoreMode" class="mode-content" style="display:none; flex-direction: row; height: 100%;">
{# ── Sidebar ── #}
<div class="meshcore-sidebar">
{# Connection section #}
<div class="meshcore-sidebar-section">
<h4>Connection</h4>
<div id="meshcoreStatusBar" style="display:flex;align-items:center;font-size:12px;margin-bottom:8px;">
<span class="meshcore-status-dot" id="meshcoreStatusDot"></span>
<span id="meshcoreStatusText">Disconnected</span>
</div>
{# Transport selector #}
<div class="meshcore-transport-tabs" id="meshcoreTransportTabs">
<div class="meshcore-transport-tab active" data-transport="serial" onclick="MeshCore.selectTransport('serial')">Serial</div>
<div class="meshcore-transport-tab" data-transport="tcp" onclick="MeshCore.selectTransport('tcp')">TCP</div>
<div class="meshcore-transport-tab" data-transport="ble" onclick="MeshCore.selectTransport('ble')">BLE</div>
</div>
{# Serial config #}
<div id="meshcoreSerialConfig">
<select id="meshcorePortSelect" style="width:100%;background:var(--bg-input);border:1px solid var(--border-color);color:var(--text-primary);padding:4px;font-size:12px;border-radius:3px;margin-bottom:6px;">
<option value="">Auto-detect</option>
</select>
</div>
{# TCP config #}
<div id="meshcoreTcpConfig" style="display:none;">
<input id="meshcoreTcpHost" type="text" placeholder="Host / IP" value="localhost"
style="width:100%;box-sizing:border-box;background:var(--bg-input);border:1px solid var(--border-color);color:var(--text-primary);padding:4px 6px;font-size:12px;border-radius:3px;margin-bottom:4px;">
<input id="meshcoreTcpPort" type="number" placeholder="Port" value="5000"
style="width:100%;box-sizing:border-box;background:var(--bg-input);border:1px solid var(--border-color);color:var(--text-primary);padding:4px 6px;font-size:12px;border-radius:3px;">
</div>
{# BLE config #}
<div id="meshcoreBleConfig" style="display:none;">
<select id="meshcoreBleSelect" style="width:100%;background:var(--bg-input);border:1px solid var(--border-color);color:var(--text-primary);padding:4px;font-size:12px;border-radius:3px;margin-bottom:4px;">
<option value="">Scan for devices...</option>
</select>
<button class="preset-btn" onclick="MeshCore.scanBle()" style="width:100%;font-size:11px;">Scan BLE</button>
</div>
<div style="display:flex;gap:6px;margin-top:8px;">
<button class="run-btn" id="meshcoreConnectBtn" onclick="MeshCore.connect()" style="flex:1;font-size:12px;">Connect</button>
<button class="preset-btn" id="meshcoreDisconnectBtn" onclick="MeshCore.disconnect()" style="flex:1;font-size:12px;" disabled>Disconnect</button>
</div>
</div>
{# Contacts section #}
<div class="meshcore-sidebar-section">
<h4>Contacts <button onclick="MeshCore.showAddContact()" style="float:right;background:none;border:none;color:var(--accent-cyan);cursor:pointer;font-size:14px;padding:0;line-height:1;">+</button></h4>
<div id="meshcoreContactList" style="max-height:120px;overflow-y:auto;">
<div style="font-size:11px;color:var(--text-muted);">No contacts</div>
</div>
</div>
{# Nodes section #}
<div class="meshcore-sidebar-section" style="flex:1;overflow-y:auto;">
<h4>Nodes</h4>
<div id="meshcoreNodeList">
<div style="font-size:11px;color:var(--text-muted);">No nodes seen</div>
</div>
</div>
</div>{# /sidebar #}
{# ── Main content ── #}
<div class="meshcore-main">
{# Tab bar #}
<div class="meshcore-tabs">
<div class="meshcore-tab active" data-tab="messages" onclick="MeshCore.switchTab('messages')">Messages</div>
<div class="meshcore-tab" data-tab="map" onclick="MeshCore.switchTab('map')">Map</div>
<div class="meshcore-tab" data-tab="repeaters" onclick="MeshCore.switchTab('repeaters')">Repeaters</div>
<div class="meshcore-tab" data-tab="telemetry" onclick="MeshCore.switchTab('telemetry')">Telemetry</div>
</div>
{# Messages tab #}
<div class="meshcore-tab-panel active" id="meshcoreTabMessages">
<div class="meshcore-messages" id="meshcoreMessageFeed">
<div style="text-align:center;color:var(--text-muted);font-size:12px;padding:24px;">
Connect to a Meshcore device to see messages
</div>
</div>
<div class="meshcore-compose">
<select id="meshcoreRecipientSelect" style="background:var(--bg-input);border:1px solid var(--border-color);color:var(--text-primary);padding:6px;font-size:12px;border-radius:4px;width:140px;">
<option value="BROADCAST">Broadcast</option>
</select>
<input id="meshcoreComposeInput" type="text" placeholder="Type a message (max 237 chars)..." maxlength="237"
onkeydown="if(event.key==='Enter')MeshCore.sendMessage()">
<button class="run-btn" onclick="MeshCore.sendMessage()" style="white-space:nowrap;">Send</button>
</div>
</div>
{# Map tab #}
<div class="meshcore-tab-panel" id="meshcoreTabMap">
<div id="meshcoreMap"></div>
</div>
{# Repeaters tab #}
<div class="meshcore-tab-panel" id="meshcoreTabRepeaters" style="overflow-y:auto;padding:12px;">
<table class="meshcore-repeater-table">
<thead>
<tr>
<th>Name</th><th>Node ID</th><th>Hops</th><th>SNR</th><th>Battery</th><th>Last Seen</th>
</tr>
</thead>
<tbody id="meshcoreRepeaterTableBody">
<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:16px;">No repeaters detected</td></tr>
</tbody>
</table>
</div>
{# Telemetry tab #}
<div class="meshcore-tab-panel" id="meshcoreTabTelemetry" style="padding:12px;overflow-y:auto;">
<div style="margin-bottom:8px;">
<label style="font-size:12px;color:var(--text-muted);">Node: </label>
<select id="meshcoreTelemetryNodeSelect" onchange="MeshCore.loadTelemetry(this.value)"
style="background:var(--bg-input);border:1px solid var(--border-color);color:var(--text-primary);padding:4px;font-size:12px;border-radius:3px;">
<option value="">Select a node</option>
</select>
</div>
<canvas id="meshcoreTelemetryChart" style="max-height:300px;"></canvas>
</div>
</div>{# /main #}
</div>{# /meshcoreMode #}
{# ── Add Contact Modal ── #}
<div id="meshcoreAddContactModal" class="signal-details-modal" style="display:none;">
<div class="signal-details-modal-backdrop" onclick="MeshCore.closeAddContact()"></div>
<div class="signal-details-modal-content" style="max-width:400px;">
<div class="signal-details-modal-header">
<h3>Add Contact</h3>
<button class="signal-details-modal-close" onclick="MeshCore.closeAddContact()">×</button>
</div>
<div class="signal-details-modal-body" style="display:flex;flex-direction:column;gap:10px;">
<div>
<label style="font-size:11px;color:var(--text-muted);display:block;margin-bottom:3px;">Node ID</label>
<input id="meshcoreContactNodeId" type="text" placeholder="e.g. NODE001" class="mock-input" style="width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:11px;color:var(--text-muted);display:block;margin-bottom:3px;">Name</label>
<input id="meshcoreContactName" type="text" placeholder="Display name" class="mock-input" style="width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:11px;color:var(--text-muted);display:block;margin-bottom:3px;">Public Key</label>
<input id="meshcoreContactKey" type="text" placeholder="Base64 public key" class="mock-input" style="width:100%;box-sizing:border-box;font-family:var(--font-mono);font-size:11px;">
</div>
</div>
<div class="signal-details-modal-footer" style="display:flex;gap:8px;">
<button class="preset-btn" onclick="MeshCore.closeAddContact()" style="flex:1;">Cancel</button>
<button class="run-btn" onclick="MeshCore.saveContact()" style="flex:1;">Add Contact</button>
</div>
</div>
</div>
{# ── Traceroute Modal ── #}
<div id="meshcoreTracerouteModal" class="signal-details-modal" style="display:none;">
<div class="signal-details-modal-backdrop" onclick="MeshCore.closeTraceroute()"></div>
<div class="signal-details-modal-content" style="max-width:600px;">
<div class="signal-details-modal-header">
<h3>Traceroute Result</h3>
<button class="signal-details-modal-close" onclick="MeshCore.closeTraceroute()">×</button>
</div>
<div class="signal-details-modal-body">
<div class="meshcore-traceroute-hops" id="meshcoreTracerouteHops"></div>
</div>
</div>
</div>
- Step 2: Commit
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
/**
* 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 = '<option value="">Auto-detect</option>';
(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 = '<option value="">Select device</option>';
(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 = `
<div class="meshcore-message-header">
<span class="meshcore-message-sender">${_esc(msg.sender_id)}</span>
<span>${_esc(msg.recipient_id)} · ${ts}${snr}</span>
</div>
<div class="meshcore-message-text">${_esc(msg.text)}</div>`;
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 = `
<div class="meshcore-node-icon${node.is_repeater ? ' repeater' : ''}"></div>
<div class="meshcore-node-name" title="${_esc(node.node_id)}">${_esc(node.name)}</div>
<div class="meshcore-node-meta">${hops} ${snr}</div>`;
}
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 = `<td>${_esc(node.name)}</td><td style="font-family:var(--font-mono);font-size:11px">${_esc(node.node_id)}</td><td>${node.hops_away ?? '—'}</td><td>${node.snr ?? '—'}</td><td>${node.battery_pct !== null ? node.battery_pct + '%' : '—'}</td><td>${ls}</td>`;
}
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
? `<div style="width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #ff9800;" title="${node.name}"></div>`
: `<div style="width:12px;height:12px;border-radius:50%;background:#00bcd4;border:2px solid #fff;box-shadow:0 0 4px rgba(0,0,0,.4);" title="${node.name}"></div>`,
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(`<strong>${_esc(node.name)}</strong><br>${node.node_id}<br>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 = `<div class="meshcore-hop-node">${_esc(hop)}</div>`;
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 = `<span>${snr}</span><span>→</span>`;
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 = '<div style="font-size:11px;color:var(--text-muted);">No contacts</div>';
return;
}
d.contacts.forEach(c => {
const el = document.createElement('div');
el.className = 'meshcore-node-item';
el.innerHTML = `
<div class="meshcore-node-icon"></div>
<div class="meshcore-node-name">${_esc(c.name)}</div>
<button onclick="MeshCore.deleteContact('${_esc(c.node_id)}')" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:12px;padding:0;">✕</button>`;
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,'>').replace(/"/g,'"');
}
// ── Public API ─────────────────────────────────────────────────────────
return {
init,
destroy,
invalidateMap,
connect,
disconnect,
selectTransport,
scanBle,
sendMessage,
switchTab,
loadTelemetry,
showAddContact,
closeAddContact,
saveContact,
deleteContact,
closeTraceroute,
};
})();
- Step 2: Commit
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 <button class="mode-card mode-card-sm" onclick="selectMode('meshtastic')"> block and the closing </button>. Add an equivalent block immediately after:
<button class="mode-card mode-card-sm" onclick="selectMode('meshcore')">
<div class="mode-card-icon">📡</div>
<div class="mode-card-label">Meshcore</div>
</button>
- 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:
<div id="meshtasticVisuals" class="mesh-visuals-container" style="display: none;">
Add a sibling immediately after its closing </div>:
<div id="meshcoreVisuals" class="mesh-visuals-container" style="display: none;">
</div>
- 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:
const meshcoreVisuals = document.getElementById('meshcoreVisuals');
And in the display-toggle line nearby:
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:
} 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 }:
} else if (mode === 'meshcore') {
MeshCore.init();
setTimeout(() => {
MeshCore.invalidateMap();
}, 100);
- Step 15: Smoke-test the wiring
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.
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
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
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
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
git add -u
git commit -m "feat(meshcore): complete Meshcore integration — serial, TCP, BLE, full feature parity"
Self-Review Checklist
-
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/<node_id>, 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)
-
No placeholders — all code steps contain actual code
-
Type consistency —
MeshcoreMessage,MeshcoreNode,MeshcoreContact,MeshcoreTelemetry,MeshcoreTracerouteused consistently across utils, routes, and tests.get_meshcore_client()is the singleton accessor throughout. -
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.