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

@ -1,173 +1,183 @@
# MeshAI Configuration # MeshAI Configuration
# LLM-powered Meshtastic assistant # LLM-powered Meshtastic assistant
# #
# Copy this to config.yaml and customize as needed # Copy this to config.yaml and customize as needed
# For Docker: mount as /data/config.yaml # For Docker: mount as /data/config.yaml
# === BOT IDENTITY === # === BOT IDENTITY ===
bot: bot:
name: ai # Bot's display name name: ai # Bot's display name
owner: "" # Owner's callsign (optional) owner: "" # Owner's callsign (optional)
respond_to_dms: true # Respond to direct messages respond_to_dms: true # Respond to direct messages
filter_bbs_protocols: true # Ignore advBBS sync/notification messages filter_bbs_protocols: true # Ignore advBBS sync/notification messages
# === MESHTASTIC CONNECTION === # === MESHTASTIC CONNECTION ===
connection: connection:
type: tcp # serial | tcp type: tcp # serial | tcp
serial_port: /dev/ttyUSB0 # For serial connection serial_port: /dev/ttyUSB0 # For serial connection
tcp_host: localhost # For TCP connection (meshtasticd) tcp_host: localhost # For TCP connection (meshtasticd)
tcp_port: 4403 tcp_port: 4403
# === RESPONSE BEHAVIOR === # === RESPONSE BEHAVIOR ===
response: response:
delay_min: 2.2 # Min delay before responding (seconds) delay_min: 2.2 # Min delay before responding (seconds)
delay_max: 3.0 # Max delay before responding delay_max: 3.0 # Max delay before responding
max_length: 200 # Max chars per message chunk max_length: 200 # Max chars per message chunk
max_messages: 3 # Max message chunks per response max_messages: 3 # Max message chunks per response
# === CONVERSATION HISTORY === # === CONVERSATION HISTORY ===
history: history:
database: /data/conversations.db database: /data/conversations.db
max_messages_per_user: 50 # Messages to keep per user max_messages_per_user: 50 # Messages to keep per user
conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h) conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h)
auto_cleanup: true # Auto-delete old conversations auto_cleanup: true # Auto-delete old conversations
cleanup_interval_hours: 24 # How often to run cleanup cleanup_interval_hours: 24 # How often to run cleanup
max_age_days: 30 # Delete conversations older than this max_age_days: 30 # Delete conversations older than this
# === MEMORY OPTIMIZATION === # === MEMORY OPTIMIZATION ===
memory: memory:
enabled: true # Enable rolling summary memory enabled: true # Enable rolling summary memory
window_size: 4 # Recent message pairs to keep in full window_size: 4 # Recent message pairs to keep in full
summarize_threshold: 8 # Messages before re-summarizing summarize_threshold: 8 # Messages before re-summarizing
# === MESH CONTEXT === # === MESH CONTEXT ===
context: context:
enabled: true # Observe channel traffic for LLM context enabled: true # Observe channel traffic for LLM context
observe_channels: [] # Channel indices to observe (empty = all) observe_channels: [] # Channel indices to observe (empty = all)
ignore_nodes: [] # Node IDs to exclude from observation ignore_nodes: [] # Node IDs to exclude from observation
max_age: 2592000 # Max age in seconds (default 30 days) max_age: 2592000 # Max age in seconds (default 30 days)
max_context_items: 20 # Max observations injected into LLM context max_context_items: 20 # Max observations injected into LLM context
# === LLM BACKEND === # === LLM BACKEND ===
llm: llm:
backend: openai # openai | anthropic | google backend: openai # openai | anthropic | google
api_key: "" # API key (or use LLM_API_KEY env var) api_key: "" # API key (or use LLM_API_KEY env var)
base_url: https://api.openai.com/v1 # API base URL base_url: https://api.openai.com/v1 # API base URL
model: gpt-4o-mini # Model name model: gpt-4o-mini # Model name
timeout: 30 # Request timeout (seconds) timeout: 30 # Request timeout (seconds)
system_prompt: >- system_prompt: >-
You are a helpful assistant on a Meshtastic mesh network. You are a helpful assistant on a Meshtastic mesh network.
Keep responses very brief - 1-2 short sentences, under 300 characters. Keep responses very brief - 1-2 short sentences, under 300 characters.
Only give longer answers if the user explicitly asks for detail or explanation. Only give longer answers if the user explicitly asks for detail or explanation.
Be concise but friendly. No markdown formatting. Be concise but friendly. No markdown formatting.
google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries) google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries)
# === WEATHER === # === WEATHER ===
weather: weather:
primary: openmeteo # openmeteo | wttr | llm primary: openmeteo # openmeteo | wttr | llm
fallback: llm # openmeteo | wttr | llm | none fallback: llm # openmeteo | wttr | llm | none
default_location: "" # Default location for !weather (optional) default_location: "" # Default location for !weather (optional)
# === MESHMONITOR INTEGRATION === # === MESHMONITOR INTEGRATION ===
meshmonitor: meshmonitor:
enabled: false # Enable MeshMonitor trigger sync enabled: false # Enable MeshMonitor trigger sync
url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333) url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333)
inject_into_prompt: true # Include trigger list in LLM prompt inject_into_prompt: true # Include trigger list in LLM prompt
refresh_interval: 300 # Seconds between trigger refreshes refresh_interval: 300 # Seconds between trigger refreshes
# === KNOWLEDGE BASE (RAG) === # === KNOWLEDGE BASE (RAG) ===
knowledge: knowledge:
enabled: false # Enable knowledge base search enabled: false # Enable knowledge base search
db_path: "" # Path to knowledge SQLite database db_path: "" # Path to knowledge SQLite database
top_k: 5 # Number of chunks to retrieve per query top_k: 5 # Number of chunks to retrieve per query
# === MESH DATA SOURCES === # === MESH DATA SOURCES ===
# Connect to Meshview and/or MeshMonitor instances for live mesh # Connect to Meshview and/or MeshMonitor instances for live mesh
# network analysis. Supports multiple sources. Configure via TUI # network analysis. Supports multiple sources. Configure via TUI
# with meshai --config (Mesh Sources menu). # with meshai --config (Mesh Sources menu).
# #
# mesh_sources: # mesh_sources:
# - name: "my-meshview" # - name: "my-meshview"
# type: meshview # type: meshview
# url: "https://meshview.example.com" # url: "https://meshview.example.com"
# refresh_interval: 300 # refresh_interval: 300
# enabled: true # enabled: true
# #
# - name: "my-meshmonitor" # - name: "my-meshmonitor"
# type: meshmonitor # type: meshmonitor
# url: "http://192.168.1.100:3333" # url: "http://192.168.1.100:3333"
# api_token: "${MM_API_TOKEN}" # api_token: "${MM_API_TOKEN}"
# refresh_interval: 300 # refresh_interval: 300
# enabled: true # enabled: true
mesh_sources: [] #
# - name: "mqtt-broker"
# === MESH INTELLIGENCE === # type: mqtt
# Geographic clustering and health scoring for mesh analysis. # host: "mqtt.meshtastic.org"
# Requires mesh_sources to be configured with at least one data source. # port: 1883
# # username: "meshdev"
# mesh_intelligence: # password: "large4cats"
# enabled: true # topic_root: "msh/US"
# region_radius_miles: 40.0 # Radius for region clustering # use_tls: false
# locality_radius_miles: 8.0 # Radius for locality clustering # enabled: true
# offline_threshold_hours: 24 # Hours before node considered offline mesh_sources: []
# packet_threshold: 500 # Non-text packets per 24h to flag
# battery_warning_percent: 20 # Battery level for warnings # === MESH INTELLIGENCE ===
# infra_overrides: [] # Node IDs to exclude from infrastructure # Geographic clustering and health scoring for mesh analysis.
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"} # Requires mesh_sources to be configured with at least one data source.
mesh_intelligence: #
enabled: false # mesh_intelligence:
region_radius_miles: 40.0 # enabled: true
locality_radius_miles: 8.0 # region_radius_miles: 40.0 # Radius for region clustering
offline_threshold_hours: 24 # locality_radius_miles: 8.0 # Radius for locality clustering
packet_threshold: 500 # offline_threshold_hours: 24 # Hours before node considered offline
battery_warning_percent: 20 # packet_threshold: 500 # Non-text packets per 24h to flag
infra_overrides: [] # battery_warning_percent: 20 # Battery level for warnings
region_labels: {} # infra_overrides: [] # Node IDs to exclude from infrastructure
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
# === ENVIRONMENTAL FEEDS === mesh_intelligence:
# Live situational awareness from NWS, NOAA Space Weather, and Open-Meteo. enabled: false
# Provides weather alerts, HF propagation assessment, and tropospheric ducting. region_radius_miles: 40.0
# locality_radius_miles: 8.0
environmental: offline_threshold_hours: 24
enabled: false packet_threshold: 500
nws_zones: battery_warning_percent: 20
- "IDZ016" # Western Magic Valley infra_overrides: []
- "IDZ030" # Southern Twin Falls County region_labels: {}
# NWS Weather Alerts (api.weather.gov) # === ENVIRONMENTAL FEEDS ===
nws: # Live situational awareness from NWS, NOAA Space Weather, and Open-Meteo.
enabled: true # Provides weather alerts, HF propagation assessment, and tropospheric ducting.
tick_seconds: 60 #
areas: ["ID"] environmental:
severity_min: "moderate" enabled: false
user_agent: "(meshai.example.com, ops@example.com)" # REQUIRED by NWS nws_zones:
- "IDZ016" # Western Magic Valley
# NOAA Space Weather (services.swpc.noaa.gov) - "IDZ030" # Southern Twin Falls County
swpc:
enabled: true # NWS Weather Alerts (api.weather.gov)
nws:
# Tropospheric ducting assessment (Open-Meteo GFS, no auth) enabled: true
ducting: tick_seconds: 60
enabled: true areas: ["ID"]
tick_seconds: 10800 # 3 hours severity_min: "moderate"
latitude: 42.56 # center of mesh coverage area user_agent: "(meshai.example.com, ops@example.com)" # REQUIRED by NWS
longitude: -114.47
# NOAA Space Weather (services.swpc.noaa.gov)
# NIFC Fire Perimeters (Phase 2) swpc:
fires: enabled: true
enabled: false
tick_seconds: 600 # Tropospheric ducting assessment (Open-Meteo GFS, no auth)
state: "US-ID" ducting:
enabled: true
# Avalanche Advisories (Phase 2) tick_seconds: 10800 # 3 hours
avalanche: latitude: 42.56 # center of mesh coverage area
enabled: false longitude: -114.47
tick_seconds: 1800
center_ids: ["SNFAC"] # NIFC Fire Perimeters (Phase 2)
season_months: [12, 1, 2, 3, 4] fires:
enabled: false
# === WEB DASHBOARD === tick_seconds: 600
dashboard: state: "US-ID"
enabled: true
port: 8080 # Avalanche Advisories (Phase 2)
host: "0.0.0.0" avalanche:
enabled: false
tick_seconds: 1800
center_ids: ["SNFAC"]
season_months: [12, 1, 2, 3, 4]
# === WEB DASHBOARD ===
dashboard:
enabled: true
port: 8080
host: "0.0.0.0"

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>
<TextInput label="URL" value={source.url} onChange={(v) => onChange({ ...source, url: v })} /> {source.type !== 'mqtt' && (
<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,12 +203,20 @@ 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
@ -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"
@ -274,6 +314,14 @@ class SWPCConfig:
"""NOAA Space Weather settings.""" """NOAA Space Weather 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
@dataclass @dataclass
@ -281,6 +329,14 @@ 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"

File diff suppressed because it is too large Load diff

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