feat: Add TCP connection support for Meshtastic

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>
This commit is contained in:
Smittix
2026-01-30 23:01:46 +00:00
parent 333dc00ee2
commit 49fa02142d
5 changed files with 190 additions and 38 deletions
+49 -9
View File
@@ -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:
+21
View File
@@ -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;
+73 -16
View File
@@ -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,
+5
View File
@@ -1629,9 +1629,14 @@
<span class="mesh-strip-dot disconnected" id="meshStripDot"></span>
<span class="mesh-strip-status-text" id="meshStripStatus">Disconnected</span>
</div>
<select id="meshStripConnType" class="mesh-strip-select" title="Connection Type" onchange="Meshtastic.onConnectionTypeChange()" style="width: 70px;">
<option value="serial">Serial</option>
<option value="tcp">TCP</option>
</select>
<select id="meshStripDevice" class="mesh-strip-select" title="Device">
<option value="">Auto-detect</option>
</select>
<input type="text" id="meshStripHostname" class="mesh-strip-input" placeholder="IP address" title="Hostname/IP for TCP" style="display: none; width: 120px;">
<button class="mesh-strip-btn connect" id="meshStripConnectBtn" onclick="Meshtastic.start()">Connect</button>
<button class="mesh-strip-btn disconnect" id="meshStripDisconnectBtn" onclick="Meshtastic.stop()" style="display: none;">Disconnect</button>
</div>
+42 -13
View File
@@ -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: