diff --git a/routes/meshtastic.py b/routes/meshtastic.py index 32c1e58..6e69925 100644 --- a/routes/meshtastic.py +++ b/routes/meshtastic.py @@ -3,8 +3,9 @@ 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. +Supports multiple connection types: +- USB/Serial: Physical device connected via USB +- TCP: WiFi-enabled devices accessible via IP address """ from __future__ import annotations @@ -95,7 +96,7 @@ def get_status(): Get Meshtastic connection status. Returns: - JSON with connection status, device info, and node information. + JSON with connection status, device info, connection type, and node information. """ if not is_meshtastic_available(): return jsonify({ @@ -111,6 +112,7 @@ def get_status(): 'available': True, 'running': False, 'device': None, + 'connection_type': None, 'node_info': None, }) @@ -120,6 +122,7 @@ def get_status(): '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, }) @@ -131,13 +134,20 @@ def start_mesh(): Start Meshtastic listener. Connects to a Meshtastic device and begins receiving messages. - The device must be connected via USB/Serial. + Supports both USB/Serial and TCP connections. JSON body (optional): { - "device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided. + "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. """ @@ -151,7 +161,8 @@ def start_mesh(): if client and client.is_running: return jsonify({ 'status': 'already_running', - 'device': client.device_path + 'device': client.device_path, + 'connection_type': client.connection_type }) # Clear queue and history @@ -162,18 +173,46 @@ def start_mesh(): break _recent_messages.clear() - # Get optional device path + # 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 device path if provided + # 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) + success = start_meshtastic( + device=device, + callback=_message_callback, + connection_type=connection_type, + hostname=hostname + ) if success: client = get_meshtastic_client() @@ -181,6 +220,7 @@ def start_mesh(): 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: diff --git a/static/css/modes/meshtastic.css b/static/css/modes/meshtastic.css index d39d459..3602dc0 100644 --- a/static/css/modes/meshtastic.css +++ b/static/css/modes/meshtastic.css @@ -259,6 +259,27 @@ max-width: 120px; } +.mesh-strip-input { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + padding: 4px 8px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + max-width: 140px; +} + +.mesh-strip-input::placeholder { + color: var(--text-secondary); + opacity: 0.7; +} + +.mesh-strip-input:focus { + outline: none; + border-color: var(--accent-cyan); +} + .mesh-strip-btn { font-family: 'JetBrains Mono', monospace; font-size: 10px; diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index b271dfd..38ab43c 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -158,21 +158,64 @@ const Meshtastic = (function() { } } + /** + * Handle connection type change (serial vs TCP) + */ + function onConnectionTypeChange() { + const connTypeSelect = document.getElementById('meshStripConnType'); + const deviceSelect = document.getElementById('meshStripDevice'); + const hostnameInput = document.getElementById('meshStripHostname'); + + if (!connTypeSelect) return; + + const connType = connTypeSelect.value; + + if (connType === 'tcp') { + // Show hostname input, hide device select + if (deviceSelect) deviceSelect.style.display = 'none'; + if (hostnameInput) hostnameInput.style.display = 'block'; + } else { + // Show device select, hide hostname input + if (deviceSelect) deviceSelect.style.display = 'block'; + if (hostnameInput) hostnameInput.style.display = 'none'; + } + } + /** * Start Meshtastic connection */ async function start() { - // Try strip device select first, then sidebar - const stripDeviceSelect = document.getElementById('meshStripDevice'); - const sidebarDeviceSelect = document.getElementById('meshDeviceSelect'); - let device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null; + // Get connection type + const connTypeSelect = document.getElementById('meshStripConnType'); + const connectionType = connTypeSelect?.value || 'serial'; - // Check if auto-detect is selected but multiple ports exist - if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) { - // Multiple ports available - prompt user to select one - showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning'); - updateStatusIndicator('disconnected', 'Select a device'); - return; + // Get connection parameters based on type + let device = null; + let hostname = null; + + if (connectionType === 'tcp') { + // TCP connection - get hostname + const hostnameInput = document.getElementById('meshStripHostname'); + hostname = hostnameInput?.value?.trim() || null; + + if (!hostname) { + showStatusMessage('Please enter a hostname or IP address for TCP connection', 'error'); + updateStatusIndicator('disconnected', 'Enter hostname'); + return; + } + } else { + // Serial connection - get device + const stripDeviceSelect = document.getElementById('meshStripDevice'); + const sidebarDeviceSelect = document.getElementById('meshDeviceSelect'); + device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null; + + // Check if auto-detect is selected but multiple ports exist + if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) { + // Multiple ports available - prompt user to select one + showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning'); + updateStatusIndicator('disconnected', 'Select a device'); + return; + } } updateStatusIndicator('connecting', 'Connecting...'); @@ -184,17 +227,27 @@ const Meshtastic = (function() { if (stripStatus) stripStatus.textContent = 'Connecting...'; try { + const requestBody = { + connection_type: connectionType + }; + + if (connectionType === 'tcp') { + requestBody.hostname = hostname; + } else if (device) { + requestBody.device = device; + } + const response = await fetch('/meshtastic/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ device: device || undefined }) + body: JSON.stringify(requestBody) }); const data = await response.json(); if (data.status === 'started' || data.status === 'already_running') { isConnected = true; - updateConnectionUI(true, data.device); + updateConnectionUI(true, data.device, data.connection_type); if (data.node_info) { updateNodeInfo(data.node_info); localNodeId = data.node_info.num; @@ -202,7 +255,8 @@ const Meshtastic = (function() { loadChannels(); loadNodes(); startStream(); - showNotification('Meshtastic', 'Connected to device'); + const connLabel = data.connection_type === 'tcp' ? 'TCP' : 'Serial'; + showNotification('Meshtastic', `Connected via ${connLabel}`); } else { updateStatusIndicator('disconnected', data.message || 'Connection failed'); showStatusMessage(data.message || 'Failed to connect', 'error'); @@ -232,7 +286,7 @@ const Meshtastic = (function() { /** * Update connection UI state */ - function updateConnectionUI(connected, device) { + function updateConnectionUI(connected, device, connectionType) { const connectBtn = document.getElementById('meshConnectBtn'); const disconnectBtn = document.getElementById('meshDisconnectBtn'); const nodeSection = document.getElementById('meshNodeSection'); @@ -248,7 +302,9 @@ const Meshtastic = (function() { const stripStatus = document.getElementById('meshStripStatus'); if (connected) { - updateStatusIndicator('connected', device ? `Connected to ${device}` : 'Connected'); + const connLabel = connectionType === 'tcp' ? 'TCP' : 'Serial'; + const statusText = device ? `${device} (${connLabel})` : `Connected (${connLabel})`; + updateStatusIndicator('connected', statusText); if (connectBtn) connectBtn.style.display = 'none'; if (disconnectBtn) disconnectBtn.style.display = 'block'; if (nodeSection) nodeSection.style.display = 'block'; @@ -263,7 +319,7 @@ const Meshtastic = (function() { if (stripDot) { stripDot.className = 'mesh-strip-dot connected'; } - if (stripStatus) stripStatus.textContent = device || 'Connected'; + if (stripStatus) stripStatus.textContent = statusText; } else { updateStatusIndicator('disconnected', 'Disconnected'); if (connectBtn) connectBtn.style.display = 'block'; @@ -2200,6 +2256,7 @@ const Meshtastic = (function() { init, start, stop, + onConnectionTypeChange, loadPorts, refreshChannels, openChannelModal, diff --git a/templates/index.html b/templates/index.html index 13700d2..668c213 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1629,9 +1629,14 @@ Disconnected + + diff --git a/utils/meshtastic.py b/utils/meshtastic.py index fc2ec67..f5b35eb 100644 --- a/utils/meshtastic.py +++ b/utils/meshtastic.py @@ -3,7 +3,10 @@ This module provides integration with Meshtastic mesh networking devices, allowing INTERCEPT to receive and decode messages from LoRa mesh networks. -Requires a physical Meshtastic device connected via USB/Serial. +Supports multiple connection types: +- USB/Serial: Physical device connected via USB +- TCP: WiFi-enabled devices (T-Beam, Heltec WiFi LoRa, etc.) + Install SDK with: pip install meshtastic """ @@ -28,6 +31,7 @@ logger = get_logger('intercept.meshtastic') try: import meshtastic import meshtastic.serial_interface + import meshtastic.tcp_interface from meshtastic import BROADCAST_ADDR from pubsub import pub HAS_MESHTASTIC = True @@ -278,6 +282,7 @@ class MeshtasticClient: self._lock = threading.Lock() self._nodes: dict[int, MeshNode] = {} # num -> MeshNode self._device_path: str | None = None + self._connection_type: str | None = None # 'serial' or 'tcp' self._error: str | None = None self._traceroute_results: list[TracerouteResult] = [] self._max_traceroute_results = 50 @@ -309,6 +314,10 @@ class MeshtasticClient: def device_path(self) -> str | None: return self._device_path + @property + def connection_type(self) -> str | None: + return self._connection_type + @property def error(self) -> str | None: return self._error @@ -317,13 +326,16 @@ class MeshtasticClient: """Set callback for received messages.""" self._callback = callback - def connect(self, device: str | None = None) -> bool: + def connect(self, device: str | None = None, connection_type: str = 'serial', + hostname: str | None = None) -> bool: """ Connect to a Meshtastic device. Args: device: Serial port path (e.g., /dev/ttyUSB0, /dev/ttyACM0). - If None, auto-discovers first available device. + Only used for serial connections. If None, auto-discovers. + connection_type: Connection type - 'serial' or 'tcp' (default: 'serial') + hostname: Hostname or IP address for TCP connections (e.g., '192.168.1.100') Returns: True if connected successfully. @@ -342,18 +354,30 @@ class MeshtasticClient: pub.subscribe(self._on_connection, "meshtastic.connection.established") pub.subscribe(self._on_disconnect, "meshtastic.connection.lost") - # Connect to device - if device: - self._interface = meshtastic.serial_interface.SerialInterface(device) - self._device_path = device + # Connect based on connection type + if connection_type == 'tcp': + if not hostname: + self._error = "Hostname is required for TCP connections" + self._cleanup_subscriptions() + return False + self._interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname) + self._device_path = hostname + self._connection_type = 'tcp' + logger.info(f"Connected to Meshtastic device via TCP: {hostname}") else: - # Auto-discover - self._interface = meshtastic.serial_interface.SerialInterface() - self._device_path = "auto" + # Serial connection (default) + if device: + self._interface = meshtastic.serial_interface.SerialInterface(device) + self._device_path = device + else: + # Auto-discover + self._interface = meshtastic.serial_interface.SerialInterface() + self._device_path = "auto" + self._connection_type = 'serial' + logger.info(f"Connected to Meshtastic device via serial: {self._device_path}") self._running = True self._error = None - logger.info(f"Connected to Meshtastic device: {self._device_path}") return True except Exception as e: @@ -375,6 +399,7 @@ class MeshtasticClient: self._cleanup_subscriptions() self._running = False self._device_path = None + self._connection_type = None logger.info("Disconnected from Meshtastic device") def _cleanup_subscriptions(self) -> None: @@ -1502,13 +1527,17 @@ def get_meshtastic_client() -> MeshtasticClient | None: def start_meshtastic(device: str | None = None, - callback: Callable[[MeshtasticMessage], None] | None = None) -> bool: + callback: Callable[[MeshtasticMessage], None] | None = None, + connection_type: str = 'serial', + hostname: str | None = None) -> bool: """ Start the Meshtastic client. Args: device: Serial port path (optional, auto-discovers if not provided) callback: Function to call when messages are received + connection_type: Connection type - 'serial' or 'tcp' (default: 'serial') + hostname: Hostname or IP address for TCP connections Returns: True if started successfully @@ -1522,7 +1551,7 @@ def start_meshtastic(device: str | None = None, if callback: _client.set_callback(callback) - return _client.connect(device) + return _client.connect(device, connection_type=connection_type, hostname=hostname) def stop_meshtastic() -> None: