mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
feat: Add MQTT source adapter
This commit is contained in:
parent
c5f4dac8b6
commit
ab7392c518
8 changed files with 1515 additions and 900 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
1445
meshai/main.py
1445
meshai/main.py
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
435
meshai/sources/mqtt_source.py
Normal file
435
meshai/sources/mqtt_source.py
Normal 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
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue