meshai/meshai/connector.py
zvx-echo6 32f6a238f8 fix: node_id int handling in connector + rule stats data path
- connector.send_message accepts int or string destination
- channels.py converts node_id to str before string operations
- Rule stats write to /data/ (Docker volume) not /opt/meshai/data/
2026-05-13 19:27:04 -06:00

359 lines
12 KiB
Python

"""Meshtastic connection management for MeshAI."""
import asyncio
import logging
import threading
from dataclasses import dataclass, field
from typing import Callable, Optional
import meshtastic
import meshtastic.serial_interface
import meshtastic.tcp_interface
from meshtastic import BROADCAST_NUM
from pubsub import pub
from .config import ConnectionConfig
logger = logging.getLogger(__name__)
@dataclass
class MeshMessage:
"""Represents an incoming mesh message."""
sender_id: str # Node ID (hex string like "!abcd1234")
sender_name: str # Short name or long name
text: str # Message content
channel: int # Channel index
is_dm: bool # True if direct message to us
packet: dict # Raw packet for additional data
_position: Optional[tuple[float, float]] = field(default=None, repr=False, init=False)
@property
def sender_position(self) -> Optional[tuple[float, float]]:
"""Get sender's GPS position if available (lat, lon)."""
return self._position
class MeshConnector:
"""Manages connection to Meshtastic node."""
def __init__(self, config: ConnectionConfig):
self.config = config
self._interface: Optional[meshtastic.MeshInterface] = None
self._my_node_id: Optional[str] = None
self._message_callback: Optional[Callable[[MeshMessage], None]] = None
self._node_positions: dict[str, tuple[float, float]] = {}
self._node_names: dict[str, str] = {}
self._connected = False
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._lock = threading.Lock()
@property
def connected(self) -> bool:
"""Check if connected to node."""
return self._connected and self._interface is not None
@property
def my_node_id(self) -> Optional[str]:
"""Get our node's ID."""
return self._my_node_id
def connect(self) -> None:
"""Establish connection to Meshtastic node."""
logger.info(f"Connecting to Meshtastic node via {self.config.type}...")
try:
if self.config.type == "serial":
self._interface = meshtastic.serial_interface.SerialInterface(
devPath=self.config.serial_port
)
elif self.config.type == "tcp":
self._interface = meshtastic.tcp_interface.TCPInterface(
hostname=self.config.tcp_host, portNumber=self.config.tcp_port
)
else:
raise ValueError(f"Unknown connection type: {self.config.type}")
# Get our node info
my_info = self._interface.getMyNodeInfo()
self._my_node_id = f"!{my_info['num']:08x}"
logger.info(f"Connected as node {self._my_node_id}")
# Cache node info
self._cache_node_info()
# Subscribe to messages
pub.subscribe(self._on_receive, "meshtastic.receive.text")
pub.subscribe(self._on_node_update, "meshtastic.node.updated")
self._connected = True
except Exception as e:
logger.error(f"Failed to connect: {e}")
self._connected = False
raise
def disconnect(self) -> None:
"""Close connection to Meshtastic node."""
if self._interface:
try:
pub.unsubscribe(self._on_receive, "meshtastic.receive.text")
pub.unsubscribe(self._on_node_update, "meshtastic.node.updated")
except Exception:
pass
try:
self._interface.close()
except Exception as e:
logger.warning(f"Error closing interface: {e}")
self._interface = None
self._connected = False
logger.info("Disconnected from Meshtastic node")
def set_message_callback(
self, callback: Callable[[MeshMessage], None], loop: asyncio.AbstractEventLoop
) -> None:
"""Set callback for incoming messages.
Args:
callback: Async function to call with MeshMessage
loop: Event loop to schedule callback on
"""
self._message_callback = callback
self._loop = loop
def _cache_node_info(self) -> None:
"""Cache node names and positions from node database."""
if not self._interface:
return
with self._lock:
for node_id, node in self._interface.nodes.items():
# Cache name
if user := node.get("user"):
name = user.get("shortName") or user.get("longName") or node_id
self._node_names[node_id] = name
# Cache position
if position := node.get("position"):
lat = position.get("latitude")
lon = position.get("longitude")
if lat is not None and lon is not None:
self._node_positions[node_id] = (lat, lon)
def _on_node_update(self, node, interface) -> None:
"""Handle node info updates."""
node_id = f"!{node['num']:08x}"
with self._lock:
# Update name cache
if user := node.get("user"):
name = user.get("shortName") or user.get("longName") or node_id
self._node_names[node_id] = name
# Update position cache
if position := node.get("position"):
lat = position.get("latitude")
lon = position.get("longitude")
if lat is not None and lon is not None:
self._node_positions[node_id] = (lat, lon)
def _on_receive(self, packet, interface) -> None:
"""Handle incoming text message."""
if not self._message_callback or not self._loop:
return
try:
# Extract message details
sender_num = packet.get("fromId") or f"!{packet['from']:08x}"
to_num = packet.get("toId") or f"!{packet['to']:08x}"
decoded = packet.get("decoded", {})
text = decoded.get("text", "")
channel = packet.get("channel", 0)
if not text:
return
# Determine if DM (sent directly to us, not broadcast)
is_dm = to_num == self._my_node_id
with self._lock:
# Get sender name
sender_name = self._node_names.get(sender_num, sender_num)
# Get position if available
position = self._node_positions.get(sender_num)
# Create message object
msg = MeshMessage(
sender_id=sender_num,
sender_name=sender_name,
text=text,
channel=channel,
is_dm=is_dm,
packet=packet,
)
# Attach position if available
if position:
msg._position = position
# Schedule callback on event loop
self._loop.call_soon_threadsafe(
lambda m=msg: asyncio.create_task(self._message_callback(m))
)
except Exception as e:
logger.error(f"Error processing received message: {e}")
def send_message(
self,
text: str,
destination: Optional[str] = None,
channel: int = 0,
) -> bool:
"""Send a text message.
Args:
text: Message text to send
destination: Node ID for DM, or None for broadcast
channel: Channel index to send on
Returns:
True if send was initiated successfully
"""
if not self._interface:
logger.error("Cannot send: not connected")
return False
try:
if destination:
# DM to specific node - handle int or string
if isinstance(destination, int):
dest_num = destination
elif destination.startswith("!"):
dest_num = int(destination[1:], 16)
elif destination.isdigit():
dest_num = int(destination)
else:
dest_num = int(destination, 16)
self._interface.sendText(
text=text,
destinationId=dest_num,
channelIndex=channel,
)
else:
# Broadcast
self._interface.sendText(
text=text,
destinationId=BROADCAST_NUM,
channelIndex=channel,
)
logger.debug(f"Sent message to {destination or 'broadcast'}: {text[:50]}...")
return True
except Exception as e:
logger.error(f"Failed to send message: {e}")
return False
def get_node_position(self, node_id: str) -> Optional[tuple[float, float]]:
"""Get cached position for a node.
Args:
node_id: Node ID (hex string like "!abcd1234")
Returns:
Tuple of (latitude, longitude) or None if not available
"""
with self._lock:
return self._node_positions.get(node_id)
def get_node_name(self, node_id: str) -> str:
"""Get cached name for a node.
Args:
node_id: Node ID (hex string like "!abcd1234")
Returns:
Node name or the node ID if name not available
"""
with self._lock:
return self._node_names.get(node_id, node_id)
def send_and_wait_ack(
self,
text: str,
destination: Optional[str] = None,
channel: int = 0,
timeout: float = 30.0,
) -> bool:
"""Send a text message and wait for ACK.
Args:
text: Message text
destination: Node ID for DM
channel: Channel index
timeout: Seconds to wait for ACK
Returns:
True if ACK received, False if timeout
"""
if not self._interface:
logger.error("Cannot send: not connected")
return False
ack_event = threading.Event()
ack_success = [False]
def onAckNak(packet):
# Check if this is an ACK (not a NACK or error)
routing = packet.get("decoded", {}).get("routing", {})
error_reason = routing.get("errorReason")
if error_reason is None or error_reason == "NONE":
ack_success[0] = True
else:
logger.warning(f"Message NACK: {error_reason}")
ack_event.set()
try:
if destination:
if destination.startswith("!"):
dest_num = int(destination[1:], 16)
else:
dest_num = int(destination, 16)
self._interface.sendText(
text=text,
destinationId=dest_num,
channelIndex=channel,
wantAck=True,
onResponse=onAckNak,
)
else:
self._interface.sendText(
text=text,
destinationId=BROADCAST_NUM,
channelIndex=channel,
wantAck=True,
onResponse=onAckNak,
)
# Wait for ACK or timeout
received = ack_event.wait(timeout=timeout)
if received and ack_success[0]:
logger.debug(f"ACK received for message to {destination or 'broadcast'}")
return True
elif received:
logger.warning(f"NACK received for message to {destination or 'broadcast'}")
return False
else:
logger.warning(f"ACK timeout ({timeout}s) for message to {destination or 'broadcast'}")
return False
except Exception as e:
logger.error(f"Failed to send message: {e}")
return False