mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
- 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/
359 lines
12 KiB
Python
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
|
|
|