mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Allow connecting to WiFi-enabled Meshtastic devices via TCP/IP in
addition to USB/Serial connections. This enables remote monitoring
of mesh nodes that have WiFi capability (T-Beam, Heltec WiFi LoRa, etc).
- Add connection_type parameter ('serial' or 'tcp') to /meshtastic/start
- Add hostname parameter for TCP connections
- Update UI with connection type dropdown and hostname input field
- Show connection type in status responses
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1054 lines
29 KiB
Python
1054 lines
29 KiB
Python
"""Meshtastic mesh network routes.
|
|
|
|
Provides endpoints for connecting to Meshtastic devices, configuring
|
|
channels with encryption keys, and streaming received messages.
|
|
|
|
Supports multiple connection types:
|
|
- USB/Serial: Physical device connected via USB
|
|
- TCP: WiFi-enabled devices accessible via IP address
|
|
"""
|
|
|
|
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, connection type, 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,
|
|
'connection_type': 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,
|
|
'connection_type': client.connection_type,
|
|
'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.
|
|
Supports both USB/Serial and TCP connections.
|
|
|
|
JSON body (optional):
|
|
{
|
|
"connection_type": "serial", // 'serial' (default) or 'tcp'
|
|
"device": "/dev/ttyUSB0", // Serial port path. Auto-discovers if not provided.
|
|
"hostname": "192.168.1.100" // IP address or hostname for TCP connections
|
|
}
|
|
|
|
Examples:
|
|
Serial (auto-discover): {}
|
|
Serial (specific port): {"device": "/dev/ttyUSB0"}
|
|
TCP: {"connection_type": "tcp", "hostname": "192.168.1.100"}
|
|
|
|
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,
|
|
'connection_type': client.connection_type
|
|
})
|
|
|
|
# Clear queue and history
|
|
while not _mesh_queue.empty():
|
|
try:
|
|
_mesh_queue.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
_recent_messages.clear()
|
|
|
|
# Parse connection parameters
|
|
data = request.get_json(silent=True) or {}
|
|
connection_type = data.get('connection_type', 'serial').lower().strip()
|
|
device = data.get('device')
|
|
hostname = data.get('hostname')
|
|
|
|
# Validate connection type
|
|
if connection_type not in ('serial', 'tcp'):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'"
|
|
}), 400
|
|
|
|
# Validate TCP parameters
|
|
if connection_type == 'tcp':
|
|
if not hostname:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'hostname is required for TCP connections'
|
|
}), 400
|
|
hostname = str(hostname).strip()
|
|
if not hostname:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'hostname cannot be empty'
|
|
}), 400
|
|
|
|
# Validate serial 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,
|
|
connection_type=connection_type,
|
|
hostname=hostname
|
|
)
|
|
|
|
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,
|
|
'connection_type': client.connection_type 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)
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/position/request', methods=['POST'])
|
|
def request_position():
|
|
"""
|
|
Request position from a specific node.
|
|
|
|
JSON body:
|
|
{
|
|
"node_id": "!a1b2c3d4" // Required: target node ID
|
|
}
|
|
|
|
Returns:
|
|
JSON with 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 {}
|
|
node_id = data.get('node_id')
|
|
|
|
if not node_id:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Node ID is required'
|
|
}), 400
|
|
|
|
success, error = client.request_position(node_id)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'sent',
|
|
'node_id': node_id
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': error or 'Failed to request position'
|
|
}), 500
|
|
|
|
|
|
@meshtastic_bp.route('/firmware/check')
|
|
def check_firmware():
|
|
"""
|
|
Check current firmware version and compare to latest release.
|
|
|
|
Returns:
|
|
JSON with current_version, latest_version, update_available, release_url.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device'
|
|
}), 400
|
|
|
|
result = client.check_firmware()
|
|
result['status'] = 'ok'
|
|
return jsonify(result)
|
|
|
|
|
|
@meshtastic_bp.route('/channels/<int:index>/qr')
|
|
def get_channel_qr(index: int):
|
|
"""
|
|
Generate QR code for a channel configuration.
|
|
|
|
Args:
|
|
index: Channel index (0-7)
|
|
|
|
Returns:
|
|
PNG image of QR code.
|
|
"""
|
|
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
|
|
|
|
png_data = client.generate_channel_qr(index)
|
|
|
|
if png_data:
|
|
return Response(png_data, mimetype='image/png')
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Failed to generate QR code. Make sure qrcode library is installed.'
|
|
}), 500
|
|
|
|
|
|
@meshtastic_bp.route('/telemetry/history')
|
|
def get_telemetry_history():
|
|
"""
|
|
Get telemetry history for a node.
|
|
|
|
Query parameters:
|
|
node_id: Node ID or number (required)
|
|
hours: Number of hours of history (default: 24)
|
|
|
|
Returns:
|
|
JSON with telemetry data points.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device',
|
|
'data': []
|
|
}), 400
|
|
|
|
node_id = request.args.get('node_id')
|
|
hours = request.args.get('hours', 24, type=int)
|
|
|
|
if not node_id:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'node_id is required',
|
|
'data': []
|
|
}), 400
|
|
|
|
# Parse node ID to number
|
|
try:
|
|
if node_id.startswith('!'):
|
|
node_num = int(node_id[1:], 16)
|
|
else:
|
|
node_num = int(node_id)
|
|
except ValueError:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Invalid node_id: {node_id}',
|
|
'data': []
|
|
}), 400
|
|
|
|
history = client.get_telemetry_history(node_num, hours=hours)
|
|
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'node_id': node_id,
|
|
'hours': hours,
|
|
'data': [p.to_dict() for p in history],
|
|
'count': len(history)
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/neighbors')
|
|
def get_neighbors():
|
|
"""
|
|
Get neighbor information for mesh topology visualization.
|
|
|
|
Query parameters:
|
|
node_id: Specific node ID (optional, returns all if not provided)
|
|
|
|
Returns:
|
|
JSON with neighbor relationships.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device',
|
|
'neighbors': {}
|
|
}), 400
|
|
|
|
node_id = request.args.get('node_id')
|
|
node_num = None
|
|
|
|
if node_id:
|
|
try:
|
|
if node_id.startswith('!'):
|
|
node_num = int(node_id[1:], 16)
|
|
else:
|
|
node_num = int(node_id)
|
|
except ValueError:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Invalid node_id: {node_id}',
|
|
'neighbors': {}
|
|
}), 400
|
|
|
|
neighbors = client.get_neighbors(node_num)
|
|
|
|
# Convert to JSON-serializable format
|
|
result = {}
|
|
for num, neighbor_list in neighbors.items():
|
|
node_key = f"!{num:08x}"
|
|
result[node_key] = [n.to_dict() for n in neighbor_list]
|
|
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'neighbors': result,
|
|
'node_count': len(result)
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/pending')
|
|
def get_pending_messages():
|
|
"""
|
|
Get messages waiting for ACK.
|
|
|
|
Returns:
|
|
JSON with pending messages and their status.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device',
|
|
'messages': []
|
|
}), 400
|
|
|
|
pending = client.get_pending_messages()
|
|
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'messages': [m.to_dict() for m in pending.values()],
|
|
'count': len(pending)
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/range-test/start', methods=['POST'])
|
|
def start_range_test():
|
|
"""
|
|
Start a range test.
|
|
|
|
JSON body:
|
|
{
|
|
"count": 10, // Number of packets to send (default 10)
|
|
"interval": 5 // Seconds between packets (default 5)
|
|
}
|
|
|
|
Returns:
|
|
JSON with start 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 {}
|
|
count = data.get('count', 10)
|
|
interval = data.get('interval', 5)
|
|
|
|
# Validate
|
|
if not isinstance(count, int) or count < 1 or count > 100:
|
|
count = 10
|
|
if not isinstance(interval, int) or interval < 1 or interval > 60:
|
|
interval = 5
|
|
|
|
success, error = client.start_range_test(count=count, interval=interval)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'started',
|
|
'count': count,
|
|
'interval': interval
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': error or 'Failed to start range test'
|
|
}), 500
|
|
|
|
|
|
@meshtastic_bp.route('/range-test/stop', methods=['POST'])
|
|
def stop_range_test():
|
|
"""
|
|
Stop an ongoing range test.
|
|
|
|
Returns:
|
|
JSON confirmation.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if client:
|
|
client.stop_range_test()
|
|
|
|
return jsonify({'status': 'stopped'})
|
|
|
|
|
|
@meshtastic_bp.route('/range-test/status')
|
|
def get_range_test_status():
|
|
"""
|
|
Get range test status and results.
|
|
|
|
Returns:
|
|
JSON with running status and results.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device',
|
|
'running': False,
|
|
'results': []
|
|
}), 400
|
|
|
|
status = client.get_range_test_status()
|
|
return jsonify({
|
|
'status': 'ok',
|
|
**status
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/store-forward/status')
|
|
def get_store_forward_status():
|
|
"""
|
|
Check if Store & Forward router is available.
|
|
|
|
Returns:
|
|
JSON with availability status and router info.
|
|
"""
|
|
client = get_meshtastic_client()
|
|
|
|
if not client or not client.is_running:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Not connected to Meshtastic device',
|
|
'available': False
|
|
}), 400
|
|
|
|
sf_status = client.check_store_forward_available()
|
|
return jsonify({
|
|
'status': 'ok',
|
|
**sf_status
|
|
})
|
|
|
|
|
|
@meshtastic_bp.route('/store-forward/request', methods=['POST'])
|
|
def request_store_forward():
|
|
"""
|
|
Request missed messages from Store & Forward router.
|
|
|
|
JSON body:
|
|
{
|
|
"window_minutes": 60 // Minutes of history to request (default 60)
|
|
}
|
|
|
|
Returns:
|
|
JSON with 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 {}
|
|
window_minutes = data.get('window_minutes', 60)
|
|
|
|
if not isinstance(window_minutes, int) or window_minutes < 1 or window_minutes > 1440:
|
|
window_minutes = 60
|
|
|
|
success, error = client.request_store_forward(window_minutes=window_minutes)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'sent',
|
|
'window_minutes': window_minutes
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': error or 'Failed to request S&F history'
|
|
}), 500
|