mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
Initial commit: MeshAI - LLM-powered Meshtastic assistant
Features: - Multi-backend LLM support (OpenAI, Anthropic, Google) - Rolling summary memory for token optimization (~70-80% reduction) - Per-user conversation history with SQLite persistence - Bang commands (!help, !ping, !reset, !status, !weather) - Meshtastic integration via serial or TCP - Message chunking for mesh network constraints (150 char limit) - Rate limiting to prevent network congestion - Rich TUI configurator - Docker support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
fd3f995ebb
43 changed files with 7947 additions and 0 deletions
273
meshai/connector.py
Normal file
273
meshai/connector.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""Meshtastic connection management for MeshAI."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
|
||||
@property
|
||||
def sender_position(self) -> Optional[tuple[float, float]]:
|
||||
"""Get sender's GPS position if available (lat, lon)."""
|
||||
# Position comes from node info, not the message itself
|
||||
# This will be populated by the connector if available
|
||||
return self._position if hasattr(self, "_position") else None
|
||||
|
||||
|
||||
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
|
||||
|
||||
@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
|
||||
|
||||
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}"
|
||||
|
||||
# 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
|
||||
|
||||
# Get sender name
|
||||
sender_name = self._node_names.get(sender_num, 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 sender_num in self._node_positions:
|
||||
msg._position = self._node_positions[sender_num]
|
||||
|
||||
# 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
|
||||
# Convert hex string to int if needed
|
||||
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,
|
||||
)
|
||||
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
|
||||
"""
|
||||
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
|
||||
"""
|
||||
return self._node_names.get(node_id, node_id)
|
||||
Loading…
Add table
Add a link
Reference in a new issue