feat: Add MQTT source adapter

This commit is contained in:
K7ZVX 2026-05-12 21:57:11 +00:00
commit ab7392c518
8 changed files with 1515 additions and 900 deletions

View file

@ -99,6 +99,16 @@ knowledge:
# api_token: "${MM_API_TOKEN}" # api_token: "${MM_API_TOKEN}"
# refresh_interval: 300 # refresh_interval: 300
# enabled: true # enabled: true
#
# - name: "mqtt-broker"
# type: mqtt
# host: "mqtt.meshtastic.org"
# port: 1883
# username: "meshdev"
# password: "large4cats"
# topic_root: "msh/US"
# use_tls: false
# enabled: true
mesh_sources: [] mesh_sources: []
# === MESH INTELLIGENCE === # === MESH INTELLIGENCE ===

View file

@ -111,6 +111,13 @@ interface MeshSourceConfig {
refresh_interval: number refresh_interval: number
polite_mode: boolean polite_mode: boolean
enabled: boolean enabled: boolean
// MQTT-specific fields
host?: string
port?: number
username?: string
password?: string
topic_root?: string
use_tls?: boolean
} }
interface RegionAnchor { interface RegionAnchor {
@ -697,13 +704,30 @@ function MeshSourceCard({ source, onChange, onDelete }: {
options={[ options={[
{ value: 'meshview', label: 'MeshView' }, { value: 'meshview', label: 'MeshView' },
{ value: 'meshmonitor', label: 'MeshMonitor' }, { value: 'meshmonitor', label: 'MeshMonitor' },
{ value: 'mqtt', label: 'MQTT Broker' },
]} ]}
/> />
</div> </div>
{source.type !== 'mqtt' && (
<TextInput label="URL" value={source.url} onChange={(v) => onChange({ ...source, url: v })} /> <TextInput label="URL" value={source.url} onChange={(v) => onChange({ ...source, url: v })} />
)}
{source.type === 'meshmonitor' && ( {source.type === 'meshmonitor' && (
<TextInput label="API Token" value={source.api_token} onChange={(v) => onChange({ ...source, api_token: v })} type="password" /> <TextInput label="API Token" value={source.api_token} onChange={(v) => onChange({ ...source, api_token: v })} type="password" />
)} )}
{source.type === 'mqtt' && (
<>
<div className="grid grid-cols-2 gap-4">
<TextInput label="Host" value={source.host || ''} onChange={(v) => onChange({ ...source, host: v })} />
<NumberInput label="Port" value={source.port || 1883} onChange={(v) => onChange({ ...source, port: v })} min={1} max={65535} />
</div>
<div className="grid grid-cols-2 gap-4">
<TextInput label="Username" value={source.username || ''} onChange={(v) => onChange({ ...source, username: v })} />
<TextInput label="Password" value={source.password || ''} onChange={(v) => onChange({ ...source, password: v })} type="password" />
</div>
<TextInput label="Topic Root" value={source.topic_root || 'msh/US'} onChange={(v) => onChange({ ...source, topic_root: v })} />
<Toggle label="Use TLS" checked={source.use_tls || false} onChange={(v) => onChange({ ...source, use_tls: v })} />
</>
)}
<NumberInput label="Refresh Interval (sec)" value={source.refresh_interval} onChange={(v) => onChange({ ...source, refresh_interval: v })} min={10} /> <NumberInput label="Refresh Interval (sec)" value={source.refresh_interval} onChange={(v) => onChange({ ...source, refresh_interval: v })} min={10} />
<Toggle label="Enabled" checked={source.enabled} onChange={(v) => onChange({ ...source, enabled: v })} /> <Toggle label="Enabled" checked={source.enabled} onChange={(v) => onChange({ ...source, enabled: v })} />
<Toggle label="Polite Mode" checked={source.polite_mode} onChange={(v) => onChange({ ...source, polite_mode: v })} /> <Toggle label="Polite Mode" checked={source.polite_mode} onChange={(v) => onChange({ ...source, polite_mode: v })} />
@ -723,6 +747,12 @@ function MeshSourcesSection({ data, onChange }: { data: MeshSourceConfig[]; onCh
refresh_interval: 30, refresh_interval: 30,
polite_mode: false, polite_mode: false,
enabled: true, enabled: true,
host: '',
port: 1883,
username: '',
password: '',
topic_root: 'msh/US',
use_tls: false,
}]) }])
} }

View file

@ -60,6 +60,14 @@ class MemoryConfig:
"""Rolling summary memory settings.""" """Rolling summary memory settings."""
enabled: bool = True # Enable memory optimization enabled: bool = True # Enable memory optimization
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
window_size: int = 4 # Recent message pairs to keep in full window_size: int = 4 # Recent message pairs to keep in full
summarize_threshold: int = 8 # Messages before re-summarizing summarize_threshold: int = 8 # Messages before re-summarizing
@ -69,6 +77,14 @@ class ContextConfig:
"""Passive mesh context settings.""" """Passive mesh context settings."""
enabled: bool = True enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
observe_channels: list[int] = field(default_factory=list) # Empty = all channels observe_channels: list[int] = field(default_factory=list) # Empty = all channels
ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore
max_age: int = 2_592_000 # 30 days in seconds max_age: int = 2_592_000 # 30 days in seconds
@ -80,6 +96,14 @@ class CommandsConfig:
"""Command settings.""" """Command settings."""
enabled: bool = True enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
prefix: str = "!" prefix: str = "!"
disabled_commands: list[str] = field(default_factory=list) disabled_commands: list[str] = field(default_factory=list)
custom_commands: dict = field(default_factory=dict) custom_commands: dict = field(default_factory=dict)
@ -179,13 +203,21 @@ class MeshSourceConfig:
"""Configuration for a mesh data source.""" """Configuration for a mesh data source."""
name: str = "" name: str = ""
type: str = "" # "meshview" or "meshmonitor" type: str = "" # "meshview", "meshmonitor", or "mqtt"
url: str = "" url: str = ""
api_token: str = "" # MeshMonitor only, supports ${ENV_VAR} api_token: str = "" # MeshMonitor only, supports ${ENV_VAR}
refresh_interval: int = 30 # Tick interval in seconds (default 30) refresh_interval: int = 30 # Tick interval in seconds (default 30)
polite_mode: bool = False # Reduces polling frequency for shared instances polite_mode: bool = False # Reduces polling frequency for shared instances
enabled: bool = True enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
@dataclass @dataclass
class RegionAnchor: class RegionAnchor:
@ -263,6 +295,14 @@ class NWSConfig:
"""NWS weather alerts settings.""" """NWS weather alerts settings."""
enabled: bool = True enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
tick_seconds: int = 60 tick_seconds: int = 60
areas: list = field(default_factory=lambda: ["ID"]) areas: list = field(default_factory=lambda: ["ID"])
severity_min: str = "moderate" severity_min: str = "moderate"
@ -275,12 +315,28 @@ class SWPCConfig:
enabled: bool = True enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
@dataclass @dataclass
class DuctingConfig: class DuctingConfig:
"""Tropospheric ducting settings.""" """Tropospheric ducting settings."""
enabled: bool = True enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
tick_seconds: int = 10800 # 3 hours tick_seconds: int = 10800 # 3 hours
latitude: float = 42.56 # Twin Falls area default latitude: float = 42.56 # Twin Falls area default
longitude: float = -114.47 longitude: float = -114.47
@ -323,6 +379,14 @@ class DashboardConfig:
"""Web dashboard settings.""" """Web dashboard settings."""
enabled: bool = True enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
port: int = 8080 port: int = 8080
host: str = "0.0.0.0" host: str = "0.0.0.0"

View file

@ -178,6 +178,7 @@ class MeshAI:
if self.knowledge: if self.knowledge:
self.knowledge.close() self.knowledge.close()
if self.data_store: if self.data_store:
await self.data_store.stop_mqtt_sources()
self.data_store.close() self.data_store.close()
if self.subscription_manager: if self.subscription_manager:
self.subscription_manager.close() self.subscription_manager.close()
@ -266,6 +267,8 @@ class MeshAI:
) )
# Initial fetch and backfill # Initial fetch and backfill
self.data_store.force_refresh() self.data_store.force_refresh()
# Start MQTT source subscription loops
await self.data_store.start_mqtt_sources()
# Log status # Log status
for status in self.data_store.get_status(): for status in self.data_store.get_status():
if status["is_loaded"]: if status["is_loaded"]:

View file

@ -27,6 +27,7 @@ from .mesh_models import (
) )
from .sources.meshmonitor_data import MeshMonitorDataSource from .sources.meshmonitor_data import MeshMonitorDataSource
from .sources.meshview import MeshviewSource from .sources.meshview import MeshviewSource
from .sources.mqtt_source import MQTTSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -236,7 +237,7 @@ class MeshDataStore:
source_configs: List of source configurations source_configs: List of source configurations
db_path: Path to SQLite database for historical data db_path: Path to SQLite database for historical data
""" """
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource] = {} self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {}
self._db_path = db_path self._db_path = db_path
self._db: Optional[sqlite3.Connection] = None self._db: Optional[sqlite3.Connection] = None
@ -316,6 +317,42 @@ class MeshDataStore:
) )
logger.info(f"Registered MeshMonitor source '{name}' -> {url} (polite={polite})") logger.info(f"Registered MeshMonitor source '{name}' -> {url} (polite={polite})")
elif src_type == "mqtt":
# Extract MQTT-specific config
if isinstance(cfg, dict):
host = cfg.get('host', '')
port = cfg.get('port', 1883)
username = cfg.get('username', '')
password = cfg.get('password', '')
topic_root = cfg.get('topic_root', 'msh/US')
use_tls = cfg.get('use_tls', False)
else:
host = getattr(cfg, 'host', '')
port = getattr(cfg, 'port', 1883)
username = getattr(cfg, 'username', '')
password = getattr(cfg, 'password', '')
topic_root = getattr(cfg, 'topic_root', 'msh/US')
use_tls = getattr(cfg, 'use_tls', False)
if not host:
logger.warning(f"MQTT source '{name}' missing host, skipping")
return
self._sources[name] = MQTTSource(
host=host,
port=port,
username=username,
password=password,
topic_root=topic_root,
use_tls=use_tls,
name=name,
)
# Track MQTT sources separately for async start
if not hasattr(self, '_mqtt_sources'):
self._mqtt_sources = []
self._mqtt_sources.append(name)
logger.info(f"Registered MQTT source '{name}' -> {host}:{port} topic={topic_root}")
else: else:
logger.warning(f"Unknown source type '{src_type}' for '{name}'") logger.warning(f"Unknown source type '{src_type}' for '{name}'")
@ -359,6 +396,24 @@ class MeshDataStore:
# ========================================================================= # =========================================================================
async def start_mqtt_sources(self) -> None:
"""Start all MQTT source subscription loops."""
if not hasattr(self, '_mqtt_sources'):
return
for name in self._mqtt_sources:
source = self._sources.get(name)
if source and hasattr(source, 'start'):
await source.start()
async def stop_mqtt_sources(self) -> None:
"""Stop all MQTT source subscription loops."""
if not hasattr(self, '_mqtt_sources'):
return
for name in self._mqtt_sources:
source = self._sources.get(name)
if source and hasattr(source, 'stop'):
await source.stop()
def _purge_stale_nodes(self): def _purge_stale_nodes(self):
"""Remove nodes not heard from in more than 7 days. """Remove nodes not heard from in more than 7 days.
@ -2120,11 +2175,19 @@ class MeshDataStore:
"""Get status of all sources.""" """Get status of all sources."""
status_list = [] status_list = []
for name, source in self._sources.items(): for name, source in self._sources.items():
# Determine source type
if isinstance(source, MeshviewSource):
src_type = "meshview"
elif isinstance(source, MeshMonitorDataSource):
src_type = "meshmonitor"
elif isinstance(source, MQTTSource):
src_type = "mqtt"
else:
src_type = "unknown"
status = { status = {
"name": name, "name": name,
"type": "meshview" "type": src_type,
if isinstance(source, MeshviewSource)
else "meshmonitor",
"enabled": True, "enabled": True,
"is_loaded": source.is_loaded, "is_loaded": source.is_loaded,
"last_refresh": source.last_refresh, "last_refresh": source.last_refresh,
@ -2138,6 +2201,14 @@ class MeshDataStore:
status["telemetry_count"] = len(source.telemetry) status["telemetry_count"] = len(source.telemetry)
status["traceroute_count"] = len(source.traceroutes) status["traceroute_count"] = len(source.traceroutes)
status["channel_count"] = len(source.channels) status["channel_count"] = len(source.channels)
elif isinstance(source, MQTTSource):
health = source.health_status
status["is_connected"] = health.get("is_connected", False)
status["message_count"] = health.get("message_count", 0)
status["last_message"] = health.get("last_message", 0)
status["host"] = health.get("host", "")
status["port"] = health.get("port", 0)
status["topic_root"] = health.get("topic_root", "")
status_list.append(status) status_list.append(status)

View file

@ -0,0 +1,435 @@
"""MQTT source adapter for Meshtastic broker subscriptions.
Push-based source that subscribes to MQTT topics and decodes
ServiceEnvelope-wrapped MeshPackets. Provides live node/packet
data without polling.
"""
import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__)
# Port number to name mapping (from portnums_pb2)
PORTNUM_NAMES = {
0: "UNKNOWN_APP",
1: "TEXT_MESSAGE_APP",
2: "REMOTE_HARDWARE_APP",
3: "POSITION_APP",
4: "NODEINFO_APP",
5: "ROUTING_APP",
6: "ADMIN_APP",
7: "TEXT_MESSAGE_COMPRESSED_APP",
8: "WAYPOINT_APP",
9: "AUDIO_APP",
10: "DETECTION_SENSOR_APP",
11: "ALERT_APP",
32: "REPLY_APP",
33: "IP_TUNNEL_APP",
34: "PAXCOUNTER_APP",
64: "SERIAL_APP",
65: "STORE_FORWARD_APP",
66: "RANGE_TEST_APP",
67: "TELEMETRY_APP",
68: "ZPS_APP",
69: "SIMULATOR_APP",
70: "TRACEROUTE_APP",
71: "NEIGHBORINFO_APP",
72: "ATAK_PLUGIN",
73: "MAP_REPORT_APP",
74: "POWERSTRESS_APP",
256: "PRIVATE_APP",
257: "ATAK_FORWARDER",
}
@dataclass
class MQTTNodeInfo:
"""Cached node info from MQTT."""
node_num: int
node_id_hex: str = ""
short_name: str = ""
long_name: str = ""
hw_model: str = ""
role: int = 0
latitude: Optional[float] = None
longitude: Optional[float] = None
altitude: Optional[float] = None
last_heard: float = 0.0
battery_percent: Optional[float] = None
voltage: Optional[float] = None
channel_utilization: Optional[float] = None
air_util_tx: Optional[float] = None
snr: Optional[float] = None
rssi: Optional[int] = None
via_mqtt: bool = True
@dataclass
class MQTTPacketInfo:
"""Packet received from MQTT."""
packet_id: int
from_node: int
to_node: int
portnum: int
portnum_name: str
channel: int
timestamp: float
snr: Optional[float] = None
rssi: Optional[int] = None
hop_limit: Optional[int] = None
hop_start: Optional[int] = None
payload_size: int = 0
gateway_id: str = ""
class MQTTSource:
"""MQTT source adapter subscribing to Meshtastic broker topics.
Maintains a subscription loop that processes ServiceEnvelope messages
and updates node/packet caches. Unlike poll-based sources, this is
push-based and receives data as it arrives.
"""
def __init__(
self,
host: str,
port: int = 1883,
username: str = "",
password: str = "",
topic_root: str = "msh/US",
use_tls: bool = False,
name: str = "mqtt",
):
"""Initialize MQTT source.
Args:
host: MQTT broker hostname
port: MQTT broker port (1883 for plain, 8883 for TLS)
username: MQTT username (optional)
password: MQTT password (optional, supports ${ENV_VAR})
topic_root: Topic root to subscribe to (default: msh/US)
use_tls: Enable TLS for connection
name: Source name for logging/attribution
"""
self._host = host
self._port = port
self._username = username
self._password = self._resolve_env(password)
self._topic_root = topic_root.rstrip("/")
self._use_tls = use_tls
self._name = name
# State
self._nodes: dict[int, MQTTNodeInfo] = {}
self._packets: list[MQTTPacketInfo] = []
self._max_packets = 1000 # Ring buffer
self._is_connected: bool = False
self._is_loaded: bool = False
self._last_message: float = 0.0
self._last_error: str = ""
self._message_count: int = 0
self._data_changed: bool = False
# Subscription task
self._task: Optional[asyncio.Task] = None
self._stop_event: Optional[asyncio.Event] = None
# Retry settings
self._retry_delay = 5 # Initial retry delay
self._max_retry_delay = 300 # Max 5 minutes between retries
def _resolve_env(self, value: str) -> str:
"""Resolve ${ENV_VAR} references in value."""
if value and value.startswith("${") and value.endswith("}"):
env_var = value[2:-1]
return os.environ.get(env_var, "")
return value
@property
def nodes(self) -> dict[int, MQTTNodeInfo]:
"""Return cached nodes."""
return self._nodes
@property
def packets(self) -> list[dict]:
"""Return packets as dicts for compatibility."""
return [
{
"packet_id": p.packet_id,
"from_node": p.from_node,
"to_node": p.to_node,
"portnum": p.portnum,
"portnum_name": p.portnum_name,
"channel": p.channel,
"timestamp": p.timestamp,
"snr": p.snr,
"rssi": p.rssi,
"hop_limit": p.hop_limit,
"hop_start": p.hop_start,
"payload_size": p.payload_size,
"gateway_id": p.gateway_id,
}
for p in self._packets
]
@property
def is_loaded(self) -> bool:
"""Return True if we have received any data."""
return self._is_loaded
@property
def data_changed(self) -> bool:
"""Return True if data changed since last check, then reset."""
changed = self._data_changed
self._data_changed = False
return changed
@property
def health_status(self) -> dict:
"""Return health status for dashboard."""
return {
"name": self._name,
"type": "mqtt",
"host": self._host,
"port": self._port,
"topic_root": self._topic_root,
"is_connected": self._is_connected,
"is_loaded": self._is_loaded,
"last_message": self._last_message,
"last_error": self._last_error,
"message_count": self._message_count,
"node_count": len(self._nodes),
"packet_count": len(self._packets),
}
async def start(self) -> None:
"""Start the subscription loop."""
if self._task is not None:
logger.warning(f"MQTT source '{self._name}' already started")
return
self._stop_event = asyncio.Event()
self._task = asyncio.create_task(self._subscription_loop())
logger.info(f"Started MQTT source '{self._name}' -> {self._host}:{self._port}")
async def stop(self) -> None:
"""Stop the subscription loop."""
if self._stop_event:
self._stop_event.set()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self._is_connected = False
logger.info(f"Stopped MQTT source '{self._name}'")
async def _subscription_loop(self) -> None:
"""Main subscription loop with reconnection logic."""
try:
import aiomqtt
except ImportError:
logger.error("aiomqtt not installed. Run: pip install aiomqtt")
self._last_error = "aiomqtt not installed"
return
retry_delay = self._retry_delay
while not self._stop_event.is_set():
try:
# Build connection kwargs
kwargs = {
"hostname": self._host,
"port": self._port,
}
if self._username:
kwargs["username"] = self._username
if self._password:
kwargs["password"] = self._password
# TLS setup
if self._use_tls:
import ssl
tls_context = ssl.create_default_context()
kwargs["tls_context"] = tls_context
async with aiomqtt.Client(**kwargs) as client:
self._is_connected = True
self._last_error = ""
retry_delay = self._retry_delay # Reset on successful connect
logger.info(f"MQTT '{self._name}' connected to {self._host}:{self._port}")
# Subscribe to all topics under root
# Meshtastic uses: msh/{region}/{channel}/json/{node_id}
# and: msh/{region}/{channel}/!{node_id}
topic = f"{self._topic_root}/#"
await client.subscribe(topic)
logger.info(f"MQTT '{self._name}' subscribed to {topic}")
async for message in client.messages:
if self._stop_event.is_set():
break
await self._process_message(message)
except asyncio.CancelledError:
break
except Exception as e:
self._is_connected = False
self._last_error = str(e)
logger.warning(f"MQTT '{self._name}' error: {e}. Retrying in {retry_delay}s")
# Exponential backoff
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, self._max_retry_delay)
async def _process_message(self, message) -> None:
"""Process an incoming MQTT message."""
try:
topic = str(message.topic)
payload = message.payload
# Skip JSON topics (we want binary ServiceEnvelope)
if "/json/" in topic:
return
# Skip map reports (stat/ or map/ topics)
if "/stat/" in topic or "/map/" in topic:
return
# Parse ServiceEnvelope
from meshtastic.protobuf import mqtt_pb2
envelope = mqtt_pb2.ServiceEnvelope()
envelope.ParseFromString(payload)
if not envelope.packet:
return
packet = envelope.packet
gateway_id = envelope.gateway_id or ""
channel_id = envelope.channel_id or ""
# Update stats
self._last_message = time.time()
self._message_count += 1
self._is_loaded = True
self._data_changed = True
# Extract packet info
pkt_info = MQTTPacketInfo(
packet_id=packet.id,
from_node=packet.from_,
to_node=packet.to,
portnum=packet.decoded.portnum if packet.HasField("decoded") else 0,
portnum_name=PORTNUM_NAMES.get(
packet.decoded.portnum if packet.HasField("decoded") else 0,
"UNKNOWN"
),
channel=packet.channel,
timestamp=time.time(),
snr=packet.rx_snr if packet.rx_snr else None,
rssi=packet.rx_rssi if packet.rx_rssi else None,
hop_limit=packet.hop_limit if packet.hop_limit else None,
hop_start=packet.hop_start if packet.hop_start else None,
payload_size=len(packet.decoded.payload) if packet.HasField("decoded") else 0,
gateway_id=gateway_id,
)
# Add to packet ring buffer
self._packets.append(pkt_info)
if len(self._packets) > self._max_packets:
self._packets = self._packets[-self._max_packets:]
# Process decoded payload by portnum
if packet.HasField("decoded"):
await self._process_decoded(packet, gateway_id)
except Exception as e:
logger.debug(f"MQTT message parse error: {e}")
async def _process_decoded(self, packet, gateway_id: str) -> None:
"""Process decoded packet payload."""
decoded = packet.decoded
portnum = decoded.portnum
from_node = packet.from_
# Ensure node exists in cache
if from_node not in self._nodes:
self._nodes[from_node] = MQTTNodeInfo(
node_num=from_node,
node_id_hex=f"!{from_node:08x}",
)
node = self._nodes[from_node]
node.last_heard = time.time()
node.snr = packet.rx_snr if packet.rx_snr else node.snr
node.rssi = packet.rx_rssi if packet.rx_rssi else node.rssi
# NODEINFO_APP (4)
if portnum == 4:
from meshtastic.protobuf import mesh_pb2
user = mesh_pb2.User()
try:
user.ParseFromString(decoded.payload)
node.short_name = user.short_name or node.short_name
node.long_name = user.long_name or node.long_name
node.hw_model = mesh_pb2.HardwareModel.Name(user.hw_model) if user.hw_model else ""
node.role = user.role
except Exception:
pass
# POSITION_APP (3)
elif portnum == 3:
from meshtastic.protobuf import mesh_pb2
pos = mesh_pb2.Position()
try:
pos.ParseFromString(decoded.payload)
if pos.latitude_i:
node.latitude = pos.latitude_i * 1e-7
if pos.longitude_i:
node.longitude = pos.longitude_i * 1e-7
if pos.altitude:
node.altitude = pos.altitude
except Exception:
pass
# TELEMETRY_APP (67)
elif portnum == 67:
from meshtastic.protobuf import telemetry_pb2
telem = telemetry_pb2.Telemetry()
try:
telem.ParseFromString(decoded.payload)
if telem.HasField("device_metrics"):
dm = telem.device_metrics
if dm.battery_level and dm.battery_level <= 100:
node.battery_percent = dm.battery_level
if dm.voltage:
node.voltage = dm.voltage
if dm.channel_utilization:
node.channel_utilization = dm.channel_utilization
if dm.air_util_tx:
node.air_util_tx = dm.air_util_tx
except Exception:
pass
# Compatibility methods for MeshDataStore integration
def tick(self) -> Optional[str]:
"""Tick method for compatibility. MQTT is push-based, not polled.
Returns None since we do not poll endpoints.
"""
return None
def maybe_refresh(self) -> bool:
"""Check if data changed (for legacy compatibility)."""
return self.data_changed

View file

@ -38,6 +38,7 @@ dependencies = [
"httpx>=0.25.0", "httpx>=0.25.0",
"fastapi>=0.110.0", "fastapi>=0.110.0",
"uvicorn[standard]>=0.27.0", "uvicorn[standard]>=0.27.0",
"aiomqtt>=2.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -11,3 +11,4 @@ sqlite-vec>=0.1.0
numpy numpy
fastapi>=0.110.0 fastapi>=0.110.0
uvicorn[standard]>=0.27.0 uvicorn[standard]>=0.27.0
aiomqtt>=2.0.0