mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 14:50:00 -07:00
Add full telemetry display in node popups including device metrics (voltage, channel utilization, air TX) and environment sensors (temperature, humidity, barometric pressure). Add traceroute functionality with interactive visualization showing hop paths and SNR values. Includes API endpoints for sending traceroutes and retrieving results, plus a modal UI for displaying route information. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
610 lines
17 KiB
Python
610 lines
17 KiB
Python
"""Meshtastic mesh network routes.
|
|
|
|
Provides endpoints for connecting to Meshtastic devices, configuring
|
|
channels with encryption keys, and streaming received messages.
|
|
|
|
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.)
|
|
connected via USB/Serial.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
import time
|
|
from typing import Generator
|
|
|
|
from flask import Blueprint, jsonify, request, Response
|
|
|
|
from utils.logging import get_logger
|
|
from utils.sse import format_sse
|
|
from utils.meshtastic import (
|
|
get_meshtastic_client,
|
|
start_meshtastic,
|
|
stop_meshtastic,
|
|
is_meshtastic_available,
|
|
MeshtasticMessage,
|
|
)
|
|
|
|
logger = get_logger('intercept.meshtastic')
|
|
|
|
meshtastic_bp = Blueprint('meshtastic', __name__, url_prefix='/meshtastic')
|
|
|
|
# Queue for SSE message streaming
|
|
_mesh_queue: queue.Queue = queue.Queue(maxsize=500)
|
|
|
|
# Store recent messages for history
|
|
_recent_messages: list[dict] = []
|
|
MAX_HISTORY = 500
|
|
|
|
|
|
def _message_callback(msg: MeshtasticMessage) -> None:
|
|
"""Callback to queue messages for SSE stream."""
|
|
msg_dict = msg.to_dict()
|
|
|
|
# Add to history
|
|
_recent_messages.append(msg_dict)
|
|
if len(_recent_messages) > MAX_HISTORY:
|
|
_recent_messages.pop(0)
|
|
|
|
# Queue for SSE
|
|
try:
|
|
_mesh_queue.put_nowait(msg_dict)
|
|
except queue.Full:
|
|
try:
|
|
_mesh_queue.get_nowait()
|
|
_mesh_queue.put_nowait(msg_dict)
|
|
except queue.Empty:
|
|
pass
|
|
|
|
|
|
@meshtastic_bp.route('/ports')
|
|
def list_ports():
|
|
"""
|
|
List available serial ports that may have Meshtastic devices.
|
|
|
|
Returns:
|
|
JSON with list of available serial ports.
|
|
"""
|
|
if not is_meshtastic_available():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'ports': [],
|
|
'message': 'Meshtastic SDK not installed'
|
|
})
|
|
|
|
try:
|
|
from meshtastic.util import findPorts
|
|
ports = findPorts()
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'ports': ports,
|
|
'count': len(ports)
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error listing ports: {e}")
|
|
return jsonify({
|
|
'status': 'error',
|
|
'ports': [],
|
|
'message': str(e)
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/status')
|
|
def get_status():
|
|
"""
|
|
Get Meshtastic connection status.
|
|
|
|
Returns:
|
|
JSON with connection status, device info, and node information.
|
|
"""
|
|
if not is_meshtastic_available():
|
|
return jsonify({
|
|
'available': False,
|
|
'running': False,
|
|
'error': 'Meshtastic SDK not installed. Install with: pip install meshtastic'
|
|
})
|
|
|
|
client = get_meshtastic_client()
|
|
|
|
if not client:
|
|
return jsonify({
|
|
'available': True,
|
|
'running': False,
|
|
'device': None,
|
|
'node_info': None,
|
|
})
|
|
|
|
node_info = client.get_node_info() if client.is_running else None
|
|
|
|
return jsonify({
|
|
'available': True,
|
|
'running': client.is_running,
|
|
'device': client.device_path,
|
|
'error': client.error,
|
|
'node_info': node_info.to_dict() if node_info else None,
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/start', methods=['POST'])
|
|
def start_mesh():
|
|
"""
|
|
Start Meshtastic listener.
|
|
|
|
Connects to a Meshtastic device and begins receiving messages.
|
|
The device must be connected via USB/Serial.
|
|
|
|
JSON body (optional):
|
|
{
|
|
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided.
|
|
}
|
|
|
|
Returns:
|
|
JSON with connection status.
|
|
"""
|
|
if not is_meshtastic_available():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Meshtastic SDK not installed. Install with: pip install meshtastic'
|
|
}), 400
|
|
|
|
client = get_meshtastic_client()
|
|
if client and client.is_running:
|
|
return jsonify({
|
|
'status': 'already_running',
|
|
'device': client.device_path
|
|
})
|
|
|
|
# Clear queue and history
|
|
while not _mesh_queue.empty():
|
|
try:
|
|
_mesh_queue.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
_recent_messages.clear()
|
|
|
|
# Get optional device path
|
|
data = request.get_json(silent=True) or {}
|
|
device = data.get('device')
|
|
|
|
# Validate device path if provided
|
|
if device:
|
|
device = str(device).strip()
|
|
if not device:
|
|
device = None
|
|
|
|
# Start client
|
|
success = start_meshtastic(device=device, callback=_message_callback)
|
|
|
|
if success:
|
|
client = get_meshtastic_client()
|
|
node_info = client.get_node_info() if client else None
|
|
return jsonify({
|
|
'status': 'started',
|
|
'device': client.device_path if client else None,
|
|
'node_info': node_info.to_dict() if node_info else None,
|
|
})
|
|
else:
|
|
client = get_meshtastic_client()
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': client.error if client else 'Failed to connect to Meshtastic device'
|
|
}), 500
|
|
|
|
|
|
@meshtastic_bp.route('/stop', methods=['POST'])
|
|
def stop_mesh():
|
|
"""
|
|
Stop Meshtastic listener.
|
|
|
|
Disconnects from the Meshtastic device and stops receiving messages.
|
|
|
|
Returns:
|
|
JSON confirmation.
|
|
"""
|
|
stop_meshtastic()
|
|
return jsonify({'status': 'stopped'})
|
|
|
|
|
|
@meshtastic_bp.route('/channels')
|
|
def get_channels():
|
|
"""
|
|
Get configured channels on the connected device.
|
|
|
|
Returns:
|
|
JSON with list of channel configurations.
|
|
Note: PSK values are not returned for security - only encryption status.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device'
|
|
}), 400
|
|
|
|
channels = client.get_channels()
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'channels': [ch.to_dict() for ch in channels],
|
|
'count': len(channels)
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/channels/<int:index>', methods=['POST'])
|
|
def configure_channel(index: int):
|
|
"""
|
|
Configure a channel with name and/or encryption key.
|
|
|
|
This allows joining encrypted channels by providing the PSK.
|
|
The configuration is written to the connected Meshtastic device.
|
|
|
|
Args:
|
|
index: Channel index (0-7). Channel 0 is typically the primary channel.
|
|
|
|
JSON body:
|
|
{
|
|
"name": "MyChannel", // Optional: Channel name
|
|
"psk": "base64:ABC123..." // Optional: Encryption key
|
|
}
|
|
|
|
PSK formats:
|
|
- "none" : Disable encryption
|
|
- "default" : Use default public key (NOT SECURE - known key)
|
|
- "random" : Generate new random AES-256 key
|
|
- "base64:..." : Base64-encoded 16-byte (AES-128) or 32-byte (AES-256) key
|
|
- "0x..." : Hex-encoded key
|
|
- "simple:passphrase" : Derive AES-256 key from passphrase using SHA-256
|
|
|
|
Returns:
|
|
JSON with configuration result.
|
|
|
|
Security note:
|
|
The "default" key is publicly known (shipped in source code).
|
|
Use "random" or provide your own key for secure communications.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device'
|
|
}), 400
|
|
|
|
if not 0 <= index <= 7:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Channel index must be 0-7'
|
|
}), 400
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
name = data.get('name')
|
|
psk = data.get('psk')
|
|
|
|
if not name and not psk:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Must provide name and/or psk'
|
|
}), 400
|
|
|
|
# Sanitize name if provided
|
|
if name:
|
|
name = str(name).strip()[:12] # Meshtastic channel names max 12 chars
|
|
|
|
# Validate PSK format if provided
|
|
if psk:
|
|
psk = str(psk).strip()
|
|
|
|
success, message = client.set_channel(index, name=name, psk=psk)
|
|
|
|
if success:
|
|
# Return updated channel info
|
|
channels = client.get_channels()
|
|
updated = next((ch for ch in channels if ch.index == index), None)
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'message': message,
|
|
'channel': updated.to_dict() if updated else None
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': message
|
|
}), 500
|
|
|
|
|
|
@meshtastic_bp.route('/send', methods=['POST'])
|
|
def send_message():
|
|
"""
|
|
Send a text message to the mesh network.
|
|
|
|
JSON body:
|
|
{
|
|
"text": "Hello mesh!", // Required: message text (max 237 chars)
|
|
"channel": 0, // Optional: channel index (default 0)
|
|
"to": "!a1b2c3d4" // Optional: destination node (default broadcast)
|
|
}
|
|
|
|
Returns:
|
|
JSON with send status.
|
|
"""
|
|
if not is_meshtastic_available():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Meshtastic SDK not installed'
|
|
}), 400
|
|
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device'
|
|
}), 400
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
text = data.get('text', '').strip()
|
|
|
|
if not text:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Message text is required'
|
|
}), 400
|
|
|
|
if len(text) > 237:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Message too long (max 237 characters)'
|
|
}), 400
|
|
|
|
channel = data.get('channel', 0)
|
|
if not isinstance(channel, int) or not 0 <= channel <= 7:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Channel must be 0-7'
|
|
}), 400
|
|
|
|
destination = data.get('to')
|
|
|
|
logger.info(f"Sending message: text='{text[:50]}...', channel={channel}, to={destination}")
|
|
success, error = client.send_text(text, channel=channel, destination=destination)
|
|
logger.info(f"Send result: success={success}, error={error}")
|
|
|
|
if success:
|
|
return jsonify({'status': 'sent'})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': error or 'Failed to send message'
|
|
}), 500
|
|
|
|
|
|
@meshtastic_bp.route('/messages')
|
|
def get_messages():
|
|
"""
|
|
Get recent message history.
|
|
|
|
Returns the most recent messages received since the listener was started.
|
|
Limited to the last 500 messages.
|
|
|
|
Query parameters:
|
|
limit: Maximum number of messages to return (default: all)
|
|
channel: Filter by channel index (optional)
|
|
|
|
Returns:
|
|
JSON with message list.
|
|
"""
|
|
limit = request.args.get('limit', type=int)
|
|
channel = request.args.get('channel', type=int)
|
|
|
|
messages = _recent_messages.copy()
|
|
|
|
# Filter by channel if specified
|
|
if channel is not None:
|
|
messages = [m for m in messages if m.get('channel') == channel]
|
|
|
|
# Apply limit
|
|
if limit and limit > 0:
|
|
messages = messages[-limit:]
|
|
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'messages': messages,
|
|
'count': len(messages)
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/stream')
|
|
def stream_messages():
|
|
"""
|
|
SSE stream of Meshtastic messages.
|
|
|
|
Provides real-time Server-Sent Events stream of incoming messages.
|
|
Connect to this endpoint with EventSource to receive live updates.
|
|
|
|
Event format:
|
|
data: {"type": "meshtastic", "from": "!a1b2c3d4", "message": "Hello", ...}
|
|
|
|
Keepalive events are sent every 30 seconds to maintain the connection.
|
|
|
|
Returns:
|
|
SSE stream (text/event-stream)
|
|
"""
|
|
def generate() -> Generator[str, None, None]:
|
|
last_keepalive = time.time()
|
|
keepalive_interval = 30.0
|
|
|
|
while True:
|
|
try:
|
|
msg = _mesh_queue.get(timeout=1)
|
|
last_keepalive = time.time()
|
|
yield format_sse(msg)
|
|
except queue.Empty:
|
|
now = time.time()
|
|
if now - last_keepalive >= keepalive_interval:
|
|
yield format_sse({'type': 'keepalive'})
|
|
last_keepalive = now
|
|
|
|
response = Response(generate(), mimetype='text/event-stream')
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
response.headers['X-Accel-Buffering'] = 'no'
|
|
response.headers['Connection'] = 'keep-alive'
|
|
return response
|
|
|
|
|
|
@meshtastic_bp.route('/node')
|
|
def get_node():
|
|
"""
|
|
Get local node information.
|
|
|
|
Returns information about the connected Meshtastic device including
|
|
its ID, name, hardware model, and current position (if available).
|
|
|
|
Returns:
|
|
JSON with node information.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device'
|
|
}), 400
|
|
|
|
node_info = client.get_node_info()
|
|
|
|
if node_info:
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'node': node_info.to_dict()
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Failed to get node information'
|
|
}), 500
|
|
|
|
|
|
@meshtastic_bp.route('/nodes')
|
|
def get_nodes():
|
|
"""
|
|
Get all tracked mesh nodes with their positions.
|
|
|
|
Returns all nodes that have been seen on the mesh network,
|
|
including their positions (if reported), battery levels, and signal info.
|
|
|
|
Query parameters:
|
|
with_position: If 'true', only return nodes with valid positions
|
|
|
|
Returns:
|
|
JSON with list of nodes.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device',
|
|
'nodes': []
|
|
}), 400
|
|
|
|
nodes = client.get_nodes()
|
|
nodes_list = [n.to_dict() for n in nodes]
|
|
|
|
# Filter to only nodes with positions if requested
|
|
with_position = request.args.get('with_position', '').lower() == 'true'
|
|
if with_position:
|
|
nodes_list = [n for n in nodes_list if n.get('has_position')]
|
|
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'nodes': nodes_list,
|
|
'count': len(nodes_list),
|
|
'with_position_count': sum(1 for n in nodes_list if n.get('has_position'))
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/traceroute', methods=['POST'])
|
|
def send_traceroute():
|
|
"""
|
|
Send a traceroute request to a mesh node.
|
|
|
|
JSON body:
|
|
{
|
|
"destination": "!a1b2c3d4", // Required: target node ID
|
|
"hop_limit": 7 // Optional: max hops (1-7, default 7)
|
|
}
|
|
|
|
Returns:
|
|
JSON with traceroute request status.
|
|
"""
|
|
if not is_meshtastic_available():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Meshtastic SDK not installed'
|
|
}), 400
|
|
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device'
|
|
}), 400
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
destination = data.get('destination')
|
|
|
|
if not destination:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Destination node ID is required'
|
|
}), 400
|
|
|
|
hop_limit = data.get('hop_limit', 7)
|
|
if not isinstance(hop_limit, int) or not 1 <= hop_limit <= 7:
|
|
hop_limit = 7
|
|
|
|
success, error = client.send_traceroute(destination, hop_limit=hop_limit)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'sent',
|
|
'destination': destination,
|
|
'hop_limit': hop_limit
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': error or 'Failed to send traceroute'
|
|
}), 500
|
|
|
|
|
|
@meshtastic_bp.route('/traceroute/results')
|
|
def get_traceroute_results():
|
|
"""
|
|
Get recent traceroute results.
|
|
|
|
Query parameters:
|
|
limit: Maximum number of results to return (default: 10)
|
|
|
|
Returns:
|
|
JSON with list of traceroute results.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device',
|
|
'results': []
|
|
}), 400
|
|
|
|
limit = request.args.get('limit', 10, type=int)
|
|
results = client.get_traceroute_results(limit=limit)
|
|
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'results': [r.to_dict() for r in results],
|
|
'count': len(results)
|
|
})
|