"""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/', 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) })