feat: Merge mqtt handler and letsmesh handlers

This commit is contained in:
Rigear
2026-04-11 16:09:14 -07:00
parent 178eaf5b4b
commit acf8079761
7 changed files with 841 additions and 959 deletions
+8 -18
View File
@@ -11,13 +11,13 @@ logger = logging.getLogger("Config")
def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract node name, radio configuration, and LetsMesh settings from config.
Extract node name, radio configuration, and MQTT settings from config.
Args:
config: Configuration dictionary
Returns:
Dictionary with node_name, radio_config, and LetsMesh configuration
Dictionary with node_name, radio_config, and MQTT configuration
"""
node_name = config.get("repeater", {}).get("node_name", "PyMC-Repeater")
radio_config = config.get("radio", {})
@@ -30,26 +30,16 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
radio_bw_khz = radio_bw / 1_000
radio_config_str = f"{radio_freq_mhz},{radio_bw_khz},{radio_sf},{radio_cr}"
letsmesh_config = config.get("letsmesh", {})
from pymc_core.protocol.utils import PAYLOAD_TYPES
disallowed_types = letsmesh_config.get("disallowed_packet_types", [])
type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()}
disallowed_hex = [type_name_map.get(name.upper(), None) for name in disallowed_types]
disallowed_hex = [val for val in disallowed_hex if val is not None] # Filter out invalid names
mqtt_config = config.get("mqtt", {})
return {
"node_name": node_name,
"radio_config": radio_config_str,
"iata_code": letsmesh_config.get("iata_code", "TEST"),
"broker_index": letsmesh_config.get("broker_index", 0),
"status_interval": letsmesh_config.get("status_interval", 60),
"model": letsmesh_config.get("model", "PyMC-Repeater"),
"disallowed_packet_types": disallowed_hex,
"email": letsmesh_config.get("email", ""),
"owner": letsmesh_config.get("owner", ""),
"iata_code": mqtt_config.get("iata_code", "TEST"),
"status_interval": mqtt_config.get("status_interval", 60),
"model": mqtt_config.get("model", "PyMC-Repeater"),
"email": mqtt_config.get("email", ""),
"owner": mqtt_config.get("owner", ""),
}
+3 -2
View File
@@ -1,6 +1,7 @@
from .mqtt_handler import MQTTHandler
#from .mqtt_handler import MQTTHandler
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_collector import StorageCollector
__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"]
#__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"]
__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector"]
@@ -1,694 +0,0 @@
import base64
import binascii
import json
import logging
import threading
from datetime import datetime, timedelta
from typing import Callable, Dict, List, Optional
import paho.mqtt.client as mqtt
from nacl.signing import SigningKey
# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc
try:
from datetime import UTC
except Exception:
from datetime import timezone
UTC = timezone.utc
from repeater import __version__
# Try to import paho-mqtt error code mappings
try:
from paho.mqtt.reasoncodes import ReasonCode
HAS_REASON_CODES = True
except ImportError:
HAS_REASON_CODES = False
logger = logging.getLogger("LetsMeshHandler")
# --------------------------------------------------------------------
# Helper: Base64URL without padding
# --------------------------------------------------------------------
def b64url(x: bytes) -> str:
return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
# --------------------------------------------------------------------
# Let's Mesh MQTT Broker List (WebSocket Secure)
# --------------------------------------------------------------------
LETSMESH_BROKERS = [
{
"name": "Europe (LetsMesh v1)",
"host": "mqtt-eu-v1.letsmesh.net",
"port": 443,
"audience": "mqtt-eu-v1.letsmesh.net",
},
{
"name": "US West (LetsMesh v1)",
"host": "mqtt-us-v1.letsmesh.net",
"port": 443,
"audience": "mqtt-us-v1.letsmesh.net",
},
]
# ====================================================================
# Single Broker Connection Manager
# ====================================================================
class _BrokerConnection:
"""
Manages a single MQTT broker connection with independent lifecycle.
Internal class - not exposed publicly.
"""
def __init__(
self,
broker: dict,
local_identity,
public_key: str,
iata_code: str,
jwt_expiry_minutes: int,
use_tls: bool,
email: str,
owner: str,
broker_index: int = 0,
on_connect_callback: Optional[Callable] = None,
on_disconnect_callback: Optional[Callable] = None,
):
self.broker = broker
self.local_identity = local_identity
self.public_key = public_key.upper()
self.iata_code = iata_code
self.jwt_expiry_minutes = jwt_expiry_minutes
self.broker_index = broker_index
self.use_tls = use_tls
self.email = email
self.owner = owner
self._on_connect_callback = on_connect_callback
self._on_disconnect_callback = on_disconnect_callback
self._connect_time = None
self._tls_verified = False
self._running = False
self._reconnect_attempts = 0
self._reconnect_timer = None
self._max_reconnect_delay = 300 # 5 minutes max
self._jwt_refresh_timer = None
client_id = f"meshcore_{self.public_key}_{broker['host']}"
self.client = mqtt.Client(client_id=client_id, transport="websockets")
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
def _generate_jwt(self) -> str:
"""Generate MeshCore-style Ed25519 JWT token"""
now = datetime.now(UTC)
header = {"alg": "Ed25519", "typ": "JWT"}
payload = {
"publicKey": self.public_key.upper(),
"aud": self.broker["audience"],
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=self.jwt_expiry_minutes)).timestamp()),
}
# Only include email/owner for verified TLS connections
if self.use_tls and self._tls_verified and (self.email or self.owner):
payload["email"] = self.email
payload["owner"] = self.owner
else:
payload["email"] = ""
payload["owner"] = ""
# Encode header and payload (compact JSON - no spaces)
header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
# Sign using LocalIdentity (supports both standard and firmware keys)
try:
signature = self.local_identity.sign(signing_input)
except Exception as e:
logger.error(f"JWT signing failed for {self.broker['name']}: {e}")
logger.error(f" - public_key: {self.public_key}")
logger.error(f" - signing_input length: {len(signing_input)}")
raise
signature_hex = binascii.hexlify(signature).decode()
token = f"{header_b64}.{payload_b64}.{signature_hex}"
logger.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...")
return token
def _on_connect(self, client, userdata, flags, rc):
"""MQTT connection callback"""
if rc == 0:
logger.info(f"Connected to {self.broker['name']}")
self._running = True
self._reconnect_attempts = 0 # Reset counter on success
self._schedule_jwt_refresh() # Schedule proactive JWT refresh
if self._on_connect_callback:
self._on_connect_callback(self.broker["name"])
else:
error_msg = get_mqtt_error_message(rc, is_disconnect=False)
logger.error(f"Failed to connect to {self.broker['name']}: {error_msg}")
self._schedule_reconnect()
def _on_disconnect(self, client, userdata, rc):
"""MQTT disconnection callback"""
was_running = self._running
self._running = False
if rc != 0: # Unexpected disconnect
error_msg = get_mqtt_error_message(rc, is_disconnect=True)
logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}")
if was_running: # Only reconnect if we were intentionally connected
self._schedule_reconnect(reason=error_msg)
else:
logger.info(f"Clean disconnect from {self.broker['name']}")
if self._on_disconnect_callback:
self._on_disconnect_callback(self.broker["name"])
def _schedule_reconnect(self, reason: str = "connection lost"):
"""Schedule reconnection with exponential backoff"""
if self._reconnect_timer:
self._reconnect_timer.cancel()
# Exponential backoff: 5s, 10s, 20s, 40s, 80s, up to max
delay = min(5 * (2**self._reconnect_attempts), self._max_reconnect_delay)
self._reconnect_attempts += 1
logger.info(
f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})"
)
self._reconnect_timer = threading.Timer(delay, lambda: self._attempt_reconnect(reason))
self._reconnect_timer.daemon = True
self._reconnect_timer.start()
def _attempt_reconnect(self, reason: str = "connection lost"):
"""Attempt to reconnect to broker with fresh JWT"""
try:
logger.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...")
# Stop the loop if it's still running (websocket mode requires clean restart)
try:
self.client.loop_stop()
except:
pass
self._set_jwt_credentials()
# Reconnect and restart loop
self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
self.client.loop_start()
self._loop_running = True
except Exception as e:
logger.error(f"Reconnection failed for {self.broker['name']}: {e}")
self._schedule_reconnect() # Try again later
def _set_jwt_credentials(self):
"""Set JWT token credentials before connecting (CONNECT handshake only)"""
try:
token = self._generate_jwt()
username = f"v1_{self.public_key}"
self.client.username_pw_set(username=username, password=token)
self._connect_time = datetime.now(UTC)
logger.debug(f"JWT credentials set for {self.broker['name']}")
logger.debug(f"Using username: {username}")
logger.debug(f"Public key: {self.public_key[:16]}...{self.public_key[-16:]}")
except Exception as e:
logger.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}")
raise
def connect(self):
"""Establish connection to broker"""
# Conditional TLS setup
if self.use_tls:
import ssl
self.client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
self.client.tls_insecure_set(False)
self._tls_verified = True
protocol = "wss"
else:
protocol = "ws"
# Set JWT credentials before CONNECT handshake
self._set_jwt_credentials()
logger.info(
f"Connecting to {self.broker['name']} "
f"({protocol}://{self.broker['host']}:{self.broker['port']}) ..."
)
self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
self.client.loop_start()
self._loop_running = True
def disconnect(self):
"""Disconnect from broker"""
self._running = False
self._loop_running = False
# Cancel any pending timers
if self._reconnect_timer:
self._reconnect_timer.cancel()
self._reconnect_timer = None
if self._jwt_refresh_timer:
self._jwt_refresh_timer.cancel()
self._jwt_refresh_timer = None
self.client.loop_stop()
self.client.disconnect()
logger.info(f"Disconnected from {self.broker['name']}")
def publish(self, topic: str, payload: str, retain: bool = False):
"""Publish message to broker"""
if self._running:
result = self.client.publish(topic, payload, retain=retain)
return result
return None
def is_connected(self) -> bool:
"""Check if connection is active"""
return self._running
def has_pending_reconnect(self) -> bool:
"""Check if a reconnection is scheduled"""
return self._reconnect_timer is not None and self._reconnect_timer.is_alive()
def should_reconnect_for_token_expiry(self) -> bool:
"""Check if connection should be reconnected due to JWT expiry (at 80% of lifetime)"""
if not self._connect_time:
return False
elapsed = (datetime.now(UTC) - self._connect_time).total_seconds()
expiry_seconds = self.jwt_expiry_minutes * 60
# Stagger refresh by 5% per broker to prevent simultaneous disconnects
# Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
stagger_offset = self.broker_index * 0.05
refresh_threshold = 0.80 + stagger_offset
return elapsed >= expiry_seconds * refresh_threshold
def _schedule_jwt_refresh(self):
"""Schedule proactive JWT refresh before token expires"""
if self._jwt_refresh_timer:
self._jwt_refresh_timer.cancel()
expiry_seconds = self.jwt_expiry_minutes * 60
# Stagger refresh by 5% per broker to prevent simultaneous disconnects
# Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
stagger_offset = self.broker_index * 0.05
refresh_threshold = 0.80 + stagger_offset
refresh_delay = expiry_seconds * refresh_threshold
logger.info(
f"JWT refresh scheduled for {self.broker['name']} in {refresh_delay:.0f}s "
f"({refresh_threshold*100:.0f}% of {self.jwt_expiry_minutes}min token lifetime)"
)
self._jwt_refresh_timer = threading.Timer(refresh_delay, self.reconnect_for_token_expiry)
self._jwt_refresh_timer.daemon = True
self._jwt_refresh_timer.start()
def reconnect_for_token_expiry(self):
"""Proactively reconnect with new JWT before current one expires"""
if not self._running:
return
logger.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...")
self._running = False
self._jwt_refresh_timer = None
self._schedule_reconnect(reason="JWT token expiry")
self.client.disconnect()
# ====================================================================
# MeshCore → MQTT Publisher with Ed25519 auth token
# ====================================================================
class MeshCoreToMqttJwtPusher:
def __init__(
self,
local_identity,
config: dict,
jwt_expiry_minutes: int = 10,
use_tls: bool = True,
stats_provider: Optional[Callable[[], dict]] = None,
):
# Store local identity and get public key
self.local_identity = local_identity
public_key = local_identity.get_public_key().hex().upper()
# Extract values from config
from ..config import get_node_info
node_info = get_node_info(config)
iata_code = node_info["iata_code"]
broker_index = node_info.get("broker_index")
self.email = node_info.get("email", "")
self.owner = node_info.get("owner", "")
status_interval = node_info["status_interval"]
node_name = node_info["node_name"]
radio_config = node_info["radio_config"]
# Get additional brokers from config (optional)
letsmesh_config = config.get("letsmesh", {})
additional_brokers = letsmesh_config.get("additional_brokers", [])
# Determine which brokers to connect to
if broker_index == -2:
# Custom brokers only - no built-in brokers
self.brokers = []
logger.info("Custom broker mode: using only user-defined brokers")
elif broker_index is None or broker_index == -1:
# Connect to all built-in brokers + additional ones
self.brokers = LETSMESH_BROKERS.copy()
logger.info(
f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers"
)
else:
if broker_index >= len(LETSMESH_BROKERS):
raise ValueError(f"Invalid broker_index {broker_index}")
self.brokers = [LETSMESH_BROKERS[broker_index]]
logger.info(f"Single broker mode: connecting to {self.brokers[0]['name']}")
# Add additional brokers from config
if additional_brokers:
for broker_config in additional_brokers:
if all(k in broker_config for k in ["name", "host", "port", "audience"]):
self.brokers.append(broker_config)
logger.info(f"Added custom broker: {broker_config['name']}")
else:
logger.warning(f"Skipping invalid broker config: {broker_config}")
# Validate that we have at least one broker
if not self.brokers:
raise ValueError(
"No brokers configured. Either set broker_index to a valid value "
"or provide additional_brokers in config."
)
self.local_identity = local_identity
self.public_key = public_key
self.iata_code = iata_code
self.jwt_expiry_minutes = jwt_expiry_minutes
self.use_tls = use_tls
self.status_interval = status_interval
self.app_version = __version__
self.node_name = node_name
self.radio_config = radio_config
self.stats_provider = stats_provider
self._status_task = None
self._running = False
self._lock = threading.Lock()
# Create broker connections
self.connections: List[_BrokerConnection] = []
for idx, broker in enumerate(self.brokers):
conn = _BrokerConnection(
broker=broker,
local_identity=self.local_identity,
public_key=self.public_key,
iata_code=self.iata_code,
jwt_expiry_minutes=self.jwt_expiry_minutes,
use_tls=self.use_tls,
email=self.email,
owner=self.owner,
broker_index=idx,
on_connect_callback=self._on_broker_connected,
on_disconnect_callback=self._on_broker_disconnected,
)
self.connections.append(conn)
logger.info(f"Initialized with {len(self.connections)} broker connection(s)")
def _on_broker_connected(self, broker_name: str):
"""Callback when a broker connects"""
# Publish initial status on first connection
if not self._status_task and self.status_interval > 0:
self._running = True
self.publish_status(
state="online", origin=self.node_name, radio_config=self.radio_config
)
# Start heartbeat thread
self._status_task = threading.Thread(target=self._status_heartbeat_loop, daemon=True)
self._status_task.start()
logger.info(f"Started status heartbeat (interval: {self.status_interval}s)")
def _on_broker_disconnected(self, broker_name: str):
"""Callback when a broker disconnects"""
# Check if all connections are down AND none have pending reconnects
all_down = all(not conn.is_connected() for conn in self.connections)
any_reconnecting = any(conn.has_pending_reconnect() for conn in self.connections)
if all_down and not any_reconnecting:
logger.warning("All broker connections lost with no pending reconnects")
elif all_down:
logger.info("All brokers temporarily disconnected, reconnects pending")
def connect(self):
"""Establish connections to all configured brokers"""
for idx, conn in enumerate(self.connections):
try:
if idx == 0:
# Connect first broker immediately
conn.connect()
else:
# Stagger additional brokers using background timers
delay = idx * 30
logger.info(f"Staggering connection to {conn.broker['name']} by {delay}s")
timer = threading.Timer(delay, lambda c=conn: self._delayed_connect(c))
timer.daemon = True
timer.start()
except Exception as e:
logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
def _delayed_connect(self, conn):
"""Connect a broker after a delay (called by timer)"""
try:
conn.connect()
except Exception as e:
logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
def disconnect(self):
"""Disconnect from all brokers"""
# Stop the heartbeat loop
self._running = False
# Publish offline status before disconnecting
self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config)
import time
time.sleep(0.5) # Give time for messages to be sent
# Disconnect all brokers
for conn in self.connections:
try:
conn.disconnect()
except Exception as e:
logger.error(f"Error disconnecting from {conn.broker['name']}: {e}")
logger.info("Disconnected from all brokers")
def _status_heartbeat_loop(self):
"""Background thread that publishes periodic status updates"""
import time
while self._running:
try:
# Publish status (JWT refresh now handled by individual broker timers)
self.publish_status(
state="online", origin=self.node_name, radio_config=self.radio_config
)
logger.debug(f"Status heartbeat sent (next in {self.status_interval}s)")
time.sleep(self.status_interval)
except Exception as e:
logger.error(f"Status heartbeat error: {e}")
time.sleep(self.status_interval)
# ----------------------------------------------------------------
# Packet helpers
# ----------------------------------------------------------------
def _process_packet(self, pkt: dict) -> dict:
return {"timestamp": datetime.now(UTC).isoformat(), "origin_id": self.public_key, **pkt}
def _topic(self, subtopic: str) -> str:
return f"meshcore/{self.iata_code}/{self.public_key}/{subtopic}"
def publish_packet(self, pkt: dict, subtopic="packets", retain=False):
return self.publish(subtopic, self._process_packet(pkt), retain)
def publish_raw_data(self, raw_hex: str, subtopic="raw", retain=False):
pkt = {"type": "raw", "data": raw_hex, "bytes": len(raw_hex) // 2}
return self.publish_packet(pkt, subtopic, retain)
def publish_status(
self,
state: str = "online",
location: Optional[dict] = None,
extra_stats: Optional[dict] = None,
origin: Optional[str] = None,
radio_config: Optional[str] = None,
):
"""
Publish device status/heartbeat message
Args:
state: Device state (online/offline)
location: Optional dict with latitude/longitude
extra_stats: Optional additional statistics to include
origin: Node name/description
radio_config: Radio configuration string (freq,bw,sf,cr)
"""
# Get live stats from provider if available
if self.stats_provider:
live_stats = self.stats_provider()
else:
live_stats = {"uptime_secs": 0, "packets_sent": 0, "packets_received": 0}
status = {
"status": state,
"timestamp": datetime.now(UTC).isoformat(),
"origin": origin or self.node_name,
"origin_id": self.public_key,
"model": "PyMC-Repeater",
"firmware_version": self.app_version,
"radio": radio_config or self.radio_config,
"client_version": f"pyMC_repeater/{self.app_version}",
"stats": {**live_stats, "errors": 0, "queue_len": 0, **(extra_stats or {})},
}
if location:
status["location"] = location
return self.publish("status", status, retain=False)
def publish(self, subtopic: str, payload: dict, retain: bool = False):
"""Publish message to all connected brokers"""
topic = self._topic(subtopic)
message = json.dumps(payload)
results = []
with self._lock:
for conn in self.connections:
if conn.is_connected():
result = conn.publish(topic, message, retain=retain)
results.append((conn.broker["name"], result))
logger.debug(f"Published to {conn.broker['name']}/{topic}")
if not results:
logger.warning(f"No active broker connections for publishing to {topic}")
return results
# ====================================================================
# Helper Functions
# ====================================================================
def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str:
"""
Get human-readable MQTT error message.
Args:
rc: Return code from paho-mqtt
is_disconnect: True if from on_disconnect, False if from on_connect
Returns:
Human-readable error message
"""
if HAS_REASON_CODES:
try:
# ReasonCode object has getName() method and value property
reason = ReasonCode(mqtt.CONNACK if not is_disconnect else mqtt.DISCONNECT, identifier=rc)
name = reason.getName() if hasattr(reason, 'getName') else str(reason)
return f"{name} (code {rc})"
except Exception as e:
# Log the exception for debugging
logger.debug(f"Could not decode reason code {rc}: {e}")
# Fallback to manual mappings - Extended with MQTT v5 codes
connect_errors = {
0: "Connection accepted",
1: "Incorrect protocol version",
2: "Invalid client identifier",
3: "Server unavailable",
4: "Bad username or password (JWT invalid)",
5: "Not authorized (JWT signature/format invalid)",
# MQTT v5 codes
128: "Unspecified error",
129: "Malformed packet",
130: "Protocol error",
131: "Implementation specific error",
132: "Unsupported protocol version",
133: "Client identifier not valid",
134: "Bad username or password",
135: "Not authorized",
136: "Server unavailable",
137: "Server busy",
138: "Banned",
140: "Bad authentication method",
144: "Topic name invalid",
149: "Packet too large",
151: "Quota exceeded",
153: "Payload format invalid",
154: "Retain not supported",
155: "QoS not supported",
156: "Use another server",
157: "Server moved",
159: "Connection rate exceeded",
}
disconnect_errors = {
0: "Normal disconnect",
1: "Unacceptable protocol version",
2: "Identifier rejected",
3: "Server unavailable",
4: "Bad username or password",
5: "Not authorized",
7: "Connection lost / network error",
16: "Connection lost / protocol error",
17: "Client timeout",
# MQTT v5 codes
4: "Disconnect with Will message",
128: "Unspecified error",
129: "Malformed packet",
130: "Protocol error",
131: "Implementation specific error",
135: "Not authorized",
137: "Server busy",
139: "Server shutting down",
141: "Keep alive timeout",
142: "Session taken over",
143: "Topic filter invalid",
144: "Topic name invalid",
147: "Receive maximum exceeded",
148: "Topic alias invalid",
149: "Packet too large",
150: "Message rate too high",
151: "Quota exceeded",
152: "Administrative action",
153: "Payload format invalid",
154: "Retain not supported",
155: "QoS not supported",
156: "Use another server",
157: "Server moved",
158: "Shared subscriptions not supported",
159: "Connection rate exceeded",
160: "Maximum connect time",
161: "Subscription identifiers not supported",
162: "Wildcard subscriptions not supported",
}
error_dict = disconnect_errors if is_disconnect else connect_errors
return error_dict.get(rc, f"Unknown error code {rc}")
+699 -112
View File
@@ -1,133 +1,720 @@
import base64
import binascii
import json
import logging
import ssl
from typing import Any, Dict, Optional
import string
import threading
from datetime import datetime, timedelta
from typing import Callable, Dict, List, Optional
import paho.mqtt.client as mqtt
from nacl.signing import SigningKey
# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc
try:
import paho.mqtt.client as mqtt
from datetime import UTC
except Exception:
from datetime import timezone
UTC = timezone.utc
MQTT_AVAILABLE = True
from repeater import __version__
# Try to import paho-mqtt error code mappings
try:
from paho.mqtt.reasoncodes import ReasonCode
HAS_REASON_CODES = True
except ImportError:
MQTT_AVAILABLE = False
from .storage_utils import PacketRecord
HAS_REASON_CODES = False
logger = logging.getLogger("MQTTHandler")
class MQTTHandler:
def __init__(self, mqtt_config: dict, node_name: str = "unknown", node_id: str = "unknown"):
self.mqtt_config = mqtt_config
self.node_name = node_name
self.node_id = node_id
self.client = None
self.available = MQTT_AVAILABLE
self._init_client()
# --------------------------------------------------------------------
# Helper: Base64URL without padding
# --------------------------------------------------------------------
def b64url(x: bytes) -> str:
return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
def _init_client(self):
if not self.available or not self.mqtt_config.get("enabled", False):
logger.info("MQTT disabled or not available")
return
try:
# Use WebSocket transport if configured, otherwise use standard TCP
transport = "websockets" if self.mqtt_config.get("use_websockets", False) else "tcp"
self.client = mqtt.Client(transport=transport)
if transport == "websockets":
logger.info("Using WebSocket transport for MQTT")
# Configure TLS/SSL if enabled
tls_config = self.mqtt_config.get("tls", {})
if tls_config.get("enabled", False):
tls_params = {
"cert_reqs": ssl.CERT_REQUIRED,
"tls_version": ssl.PROTOCOL_TLS,
}
# CA certificate for server verification (optional - uses system certs if not specified)
ca_cert = tls_config.get("ca_cert")
if ca_cert:
tls_params["ca_certs"] = ca_cert
logger.info("Using custom CA certificate for MQTT TLS")
else:
logger.info("Using system default CA certificates for MQTT TLS")
# Client certificate and key (for mutual TLS)
client_cert = tls_config.get("client_cert")
client_key = tls_config.get("client_key")
if client_cert:
tls_params["certfile"] = client_cert
if client_key:
tls_params["keyfile"] = client_key
# Allow insecure connections (skip cert verification)
if tls_config.get("insecure", False):
tls_params["cert_reqs"] = ssl.CERT_NONE
logger.warning("MQTT TLS certificate verification disabled (insecure mode)")
self.client.tls_set(**tls_params)
logger.info("MQTT TLS/SSL configured")
username = self.mqtt_config.get("username")
password = self.mqtt_config.get("password")
if username:
self.client.username_pw_set(username, password)
broker = self.mqtt_config.get("broker", "localhost")
port = self.mqtt_config.get("port", 1883)
secure = "(TLS)" if tls_config.get("enabled", False) else ""
logger.info(f"Connecting to MQTT broker {broker}:{port} {secure}...")
self.client.connect(broker, port, 60)
self.client.loop_start()
logger.info(f"MQTT client successfully connected")
except Exception as e:
logger.error(f"Failed to initialize MQTT: {e}")
self.client = None
def publish(self, record: dict, record_type: str):
"""
Publish record to MQTT.
Packets MUST use PacketRecord format. Non-packet records use original format.
# # --------------------------------------------------------------------
# # Let's Mesh MQTT Broker List (WebSocket Secure)
# # --------------------------------------------------------------------
# LETSMESH_BROKERS = [
# {
# "name": "Europe (LetsMesh v1)",
# "host": "mqtt-eu-v1.letsmesh.net",
# "port": 443,
# "audience": "mqtt-eu-v1.letsmesh.net",
# "use_jwt_auth": True,
# "transport": "websockets",
# "enabled": True,
# },
# {
# "name": "US West (LetsMesh v1)",
# "host": "mqtt-us-v1.letsmesh.net",
# "port": 443,
# "audience": "mqtt-us-v1.letsmesh.net",
# "use_jwt_auth": True,
# "transport": "websockets",
# "enabled": True,
# },
# ]
# ====================================================================
# Single Broker Connection Manager
# ====================================================================
class _BrokerConnection:
"""
Manages a single MQTT broker connection with independent lifecycle.
Internal class - not exposed publicly.
"""
def __init__(
self,
broker: dict,
local_identity,
public_key: str,
iata_code: str,
jwt_expiry_minutes: int,
use_tls: bool,
email: str,
owner: str,
on_connect_callback: Optional[Callable] = None,
on_disconnect_callback: Optional[Callable] = None
):
self.broker = broker
self.local_identity = local_identity
self.public_key = public_key.upper()
self.iata_code = iata_code
self.jwt_expiry_minutes = jwt_expiry_minutes
self.use_tls = use_tls
self.email = email
self.owner = owner
self._on_connect_callback = on_connect_callback
self._on_disconnect_callback = on_disconnect_callback
self._connect_time = None
self._tls_verified = False
self._running = False
self._reconnect_attempts = 0
self._reconnect_timer = None
self._max_reconnect_delay = 300 # 5 minutes max
self._jwt_refresh_timer = None
self.transport= broker.get('transport', 'websockets')
client_id = f"meshcore_{self.public_key}_{broker['host']}"
self.client = mqtt.Client(client_id=client_id, transport=self.transport)
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.use_jwt_auth = broker.get('use_jwt_auth', False)
self.username = broker.get('username', None)
self.password = broker.get('password', None)
Args:
record: The record dictionary to publish
record_type: Type of record (packet, advert, noise_floor, etc.)
"""
if not self.client:
return
from pymc_core.protocol.utils import PAYLOAD_TYPES
disallowed_types = broker.get("disallowed_packet_types", [])
type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()}
self.disallowed_hex = [type_name_map.get(name.upper(), None) for name in disallowed_types]
self.disallowed_hex = [val for val in self.disallowed_hex if val is not None] # Filter out invalid names
def _generate_jwt(self) -> str:
"""Generate MeshCore-style Ed25519 JWT token"""
now = datetime.now(UTC)
header = {"alg": "Ed25519", "typ": "JWT"}
payload = {
"publicKey": self.public_key.upper(),
"aud": self.broker["audience"],
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=self.jwt_expiry_minutes)).timestamp()),
}
if "audience" in self.broker:
payload["aud"] = self.broker["audience"]
# Only include email/owner for verified TLS connections
if self.use_tls and self._tls_verified and (self.email or self.owner):
payload["email"] = self.email
payload["owner"] = self.owner
else:
payload["email"] = ""
payload["owner"] = ""
# Encode header and payload (compact JSON - no spaces)
header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
# Sign using LocalIdentity (supports both standard and firmware keys)
try:
base_topic = self.mqtt_config.get("base_topic", "meshcore/repeater")
topic = f"{base_topic}/{self.node_name}/{record_type}"
signature = self.local_identity.sign(signing_input)
except Exception as e:
logger.error(f"JWT signing failed for {self.broker['name']}: {e}")
logger.error(f" - public_key: {self.public_key}")
logger.error(f" - signing_input length: {len(signing_input)}")
raise
if record_type == "packet":
packet_record = PacketRecord.from_packet_record(
record, origin=self.node_name, origin_id=self.node_id
)
if not packet_record:
logger.debug(
"Skipping MQTT publish: packet missing required data for PacketRecord"
)
return
signature_hex = binascii.hexlify(signature).decode()
token = f"{header_b64}.{payload_b64}.{signature_hex}"
payload = packet_record.to_dict()
logger.debug("Publishing packet using PacketRecord format")
logger.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...")
return token
def _on_connect(self, client, userdata, flags, rc):
"""MQTT connection callback"""
if rc == 0:
logger.info(f"Connected to {self.broker['name']}")
self._running = True
self._reconnect_attempts = 0 # Reset counter on success
if self.use_jwt_auth:
self._schedule_jwt_refresh() # Schedule proactive JWT refresh
if self._on_connect_callback:
self._on_connect_callback(self.broker["name"])
else:
error_msg = get_mqtt_error_message(rc, is_disconnect=False)
logger.error(f"Failed to connect to {self.broker['name']}: {error_msg}")
self._schedule_reconnect()
def _on_disconnect(self, client, userdata, rc):
"""MQTT disconnection callback"""
was_running = self._running
self._running = False
if rc != 0: # Unexpected disconnect
error_msg = get_mqtt_error_message(rc, is_disconnect=True)
logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}")
if was_running: # Only reconnect if we were intentionally connected
self._schedule_reconnect(reason=error_msg)
else:
logger.info(f"Clean disconnect from {self.broker['name']}")
if self._on_disconnect_callback:
self._on_disconnect_callback(self.broker["name"])
def _schedule_reconnect(self, reason: str = "connection lost"):
"""Schedule reconnection with exponential backoff"""
if self._reconnect_timer:
self._reconnect_timer.cancel()
# Exponential backoff: 5s, 10s, 20s, 40s, 80s, up to max
delay = min(5 * (2**self._reconnect_attempts), self._max_reconnect_delay)
self._reconnect_attempts += 1
logger.info(
f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})"
)
self._reconnect_timer = threading.Timer(delay, lambda: self._attempt_reconnect(reason))
self._reconnect_timer.daemon = True
self._reconnect_timer.start()
def _attempt_reconnect(self, reason: str = "connection lost"):
"""Attempt to reconnect to broker with fresh JWT"""
try:
logger.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...")
# Stop the loop if it's still running (websocket mode requires clean restart)
try:
self.client.loop_stop()
except:
pass
self._set_credentials()
# Reconnect and restart loop
self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
self.client.loop_start()
self._loop_running = True
except Exception as e:
logger.error(f"Reconnection failed for {self.broker['name']}: {e}")
self._schedule_reconnect() # Try again later
def _set_credentials(self):
"""Set credentials before connecting (CONNECT handshake only)"""
try:
if self.use_jwt_auth:
logger.debug(f"Generating JWT credentials for {self.broker['name']}...")
token = self._generate_jwt()
username = f"v1_{self.public_key}"
self.client.username_pw_set(username=username, password=token)
logger.debug(f"Credentials set for {self.broker['name']}")
logger.debug(f"Using username: {username}")
logger.debug(f"Public key: {self.public_key[:16]}...{self.public_key[-16:]}")
elif self.username and self.password:
logger.info(f"Using provided credentials for {self.broker['name']} (username: {self.username})")
self.client.username_pw_set(username=self.username, password=self.password)
else:
payload = {k: v for k, v in record.items() if v is not None}
logger.info(f"No credentials set for {self.broker['name']} (JWT auth disabled and no username/password provided)")
message = json.dumps(payload, default=str)
self.client.publish(topic, message, qos=0, retain=False)
logger.debug(f"Published to {topic}")
self._connect_time = datetime.now(UTC)
except Exception as e:
logger.error(f"Failed to publish to MQTT: {e}")
logger.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}")
raise
def close(self):
if self.client:
self.client.loop_stop()
self.client.disconnect()
logger.info("MQTT client disconnected")
def connect(self):
"""Establish connection to broker"""
# Conditional TLS setup
if self.transport == "websockets":
if self.use_tls:
import ssl
self.client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
self.client.tls_insecure_set(False)
self._tls_verified = True
protocol = "wss"
else:
protocol = "ws"
elif self.transport == "tcp":
protocol = "mqtt"
else:
raise ValueError(f"Invalid transport '{self.transport}' for {self.broker['name']}")
# Set JWT credentials before CONNECT handshake
self._set_credentials()
logger.info(
f"Connecting to {self.broker['name']} "
f"({protocol}://{self.broker['host']}:{self.broker['port']}) ..."
)
self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
self.client.loop_start()
self._loop_running = True
def disconnect(self):
"""Disconnect from broker"""
self._running = False
self._loop_running = False
# Cancel any pending timers
if self._reconnect_timer:
self._reconnect_timer.cancel()
self._reconnect_timer = None
if self._jwt_refresh_timer:
self._jwt_refresh_timer.cancel()
self._jwt_refresh_timer = None
self.client.loop_stop()
self.client.disconnect()
logger.info(f"Disconnected from {self.broker['name']}")
def publish(self, topic: str, payload: str, retain: bool = False):
"""Publish message to broker"""
if self._running:
result = self.client.publish(topic, payload, retain=retain)
return result
return None
def is_connected(self) -> bool:
"""Check if connection is active"""
return self._running
def has_pending_reconnect(self) -> bool:
"""Check if a reconnection is scheduled"""
return self._reconnect_timer is not None and self._reconnect_timer.is_alive()
def should_reconnect_for_token_expiry(self) -> bool:
"""Check if connection should be reconnected due to JWT expiry (at 80% of lifetime)"""
if not self._connect_time:
return False
elapsed = (datetime.now(UTC) - self._connect_time).total_seconds()
expiry_seconds = self.jwt_expiry_minutes * 60
# Stagger refresh by 5% per broker to prevent simultaneous disconnects
# Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
stagger_offset = self.broker_index * 0.05
refresh_threshold = 0.80 + stagger_offset
return elapsed >= expiry_seconds * refresh_threshold
def _schedule_jwt_refresh(self):
"""Schedule proactive JWT refresh before token expires"""
if self._jwt_refresh_timer:
self._jwt_refresh_timer.cancel()
expiry_seconds = self.jwt_expiry_minutes * 60
# Stagger refresh by 5% per broker to prevent simultaneous disconnects
# Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
stagger_offset = self.broker_index * 0.05
refresh_threshold = 0.80 + stagger_offset
refresh_delay = expiry_seconds * refresh_threshold
logger.info(
f"JWT refresh scheduled for {self.broker['name']} in {refresh_delay:.0f}s "
f"({refresh_threshold*100:.0f}% of {self.jwt_expiry_minutes}min token lifetime)"
)
self._jwt_refresh_timer = threading.Timer(refresh_delay, self.reconnect_for_token_expiry)
self._jwt_refresh_timer.daemon = True
self._jwt_refresh_timer.start()
def reconnect_for_token_expiry(self):
"""Proactively reconnect with new JWT before current one expires"""
if not self._running:
return
logger.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...")
self._running = False
self._jwt_refresh_timer = None
self._schedule_reconnect(reason="JWT token expiry")
self.client.disconnect()
# ====================================================================
# MeshCore → MQTT Publisher with Ed25519 auth token
# ====================================================================
class MeshCoreToMqttPusher:
def __init__(
self,
local_identity,
config: dict,
jwt_expiry_minutes: int = 10,
use_tls: bool = True,
stats_provider: Optional[Callable[[], dict]] = None,
):
# Store local identity and get public key
self.local_identity = local_identity
public_key = local_identity.get_public_key().hex().upper()
# Extract values from config
from ..config import get_node_info
node_info = get_node_info(config)
iata_code = node_info["iata_code"]
broker_index = node_info.get("broker_index")
self.email = node_info.get("email", "")
self.owner = node_info.get("owner", "")
status_interval = node_info["status_interval"]
node_name = node_info["node_name"]
radio_config = node_info["radio_config"]
# Get additional brokers from config (optional)
mqtt_config = config.get("mqtt", {})
brokers = mqtt_config.get("brokers", [])
# Add additional brokers from config
if brokers:
for broker_config in brokers:
if all(k in broker_config for k in ["name", "host", "port", "enabled"]):
if broker_config["enabled"]:
self.brokers.append(broker_config)
logger.info(f"Added broker: {broker_config['name']}")
else:
logger.info(f"Broker disabled in config, skipping: {broker_config['name']}")
else:
logger.warning(f"Skipping invalid broker config: {broker_config}")
# Validate that we have at least one broker
# if not self.brokers:
# raise ValueError(
# "No brokers configured. Either set broker_index to a valid value "
# "or provide additional_brokers in config."
# )
self.local_identity = local_identity
self.public_key = public_key
self.iata_code = iata_code
self.jwt_expiry_minutes = jwt_expiry_minutes
self.use_tls = use_tls
self.status_interval = status_interval
self.app_version = __version__
self.node_name = node_name
self.radio_config = radio_config
self.stats_provider = stats_provider
self._status_task = None
self._running = False
self._lock = threading.Lock()
# Create broker connections
self.connections: List[_BrokerConnection] = []
for idx, broker in enumerate(self.brokers):
conn = _BrokerConnection(
broker=broker,
local_identity=self.local_identity,
public_key=self.public_key,
iata_code=self.iata_code,
jwt_expiry_minutes=self.jwt_expiry_minutes,
use_tls=self.use_tls,
email=self.email,
owner=self.owner,
broker_index=idx,
on_connect_callback=self._on_broker_connected,
on_disconnect_callback=self._on_broker_disconnected,
)
self.connections.append(conn)
logger.info(f"Initialized with {len(self.connections)} broker connection(s)")
def _on_broker_connected(self, broker_name: str):
"""Callback when a broker connects"""
# Publish initial status on first connection
if not self._status_task and self.status_interval > 0:
self._running = True
logger.info(f"Publishing initial status for {broker_name}...")
self.publish_status(
state="online", origin=self.node_name, radio_config=self.radio_config
)
# Start heartbeat thread
self._status_task = threading.Thread(target=self._status_heartbeat_loop, daemon=True)
self._status_task.start()
logger.info(f"Started status heartbeat (interval: {self.status_interval}s)")
def _on_broker_disconnected(self, broker_name: str):
"""Callback when a broker disconnects"""
# Check if all connections are down AND none have pending reconnects
all_down = all(not conn.is_connected() for conn in self.connections)
any_reconnecting = any(conn.has_pending_reconnect() for conn in self.connections)
if all_down and not any_reconnecting:
logger.warning("All broker connections lost with no pending reconnects")
elif all_down:
logger.info("All brokers temporarily disconnected, reconnects pending")
def connect(self):
"""Establish connections to all configured brokers"""
for idx, conn in enumerate(self.connections):
try:
if idx == 0:
# Connect first broker immediately
conn.connect()
else:
# Stagger additional brokers using background timers
delay = idx * 30
logger.info(f"Staggering connection to {conn.broker['name']} by {delay}s")
timer = threading.Timer(delay, lambda c=conn: self._delayed_connect(c))
timer.daemon = True
timer.start()
except Exception as e:
logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
def _delayed_connect(self, conn):
"""Connect a broker after a delay (called by timer)"""
try:
conn.connect()
except Exception as e:
logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
def disconnect(self):
"""Disconnect from all brokers"""
# Stop the heartbeat loop
self._running = False
# Publish offline status before disconnecting
self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config)
import time
time.sleep(0.5) # Give time for messages to be sent
# Disconnect all brokers
for conn in self.connections:
try:
conn.disconnect()
except Exception as e:
logger.error(f"Error disconnecting from {conn.broker['name']}: {e}")
logger.info("Disconnected from all brokers")
def _status_heartbeat_loop(self):
"""Background thread that publishes periodic status updates"""
import time
while self._running:
try:
# Publish status (JWT refresh now handled by individual broker timers)
self.publish_status(
state="online", origin=self.node_name, radio_config=self.radio_config
)
logger.debug(f"Status heartbeat sent (next in {self.status_interval}s)")
time.sleep(self.status_interval)
except Exception as e:
logger.error(f"Status heartbeat error: {e}")
time.sleep(self.status_interval)
# ----------------------------------------------------------------
# Packet helpers
# ----------------------------------------------------------------
def _process_packet(self, pkt: dict) -> dict:
return {"timestamp": datetime.now(UTC).isoformat(), "origin_id": self.public_key, **pkt}
def _topic(self, subtopic: str) -> str:
return f"meshcore/{self.iata_code}/{self.public_key}/{subtopic}"
def publish_packet(self, pkt: dict, packet_type: string, subtopic="packets", retain=False):
if packet_type in self.disallowed_packet_types:
logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
return
return self.publish(subtopic, self._process_packet(pkt), retain)
def publish_raw_data(self, raw_hex: str, subtopic="raw", retain=False):
pkt = {"type": "raw", "data": raw_hex, "bytes": len(raw_hex) // 2}
return self.publish_packet(pkt, "raw", subtopic, retain)
def publish_status(
self,
state: str = "online",
location: Optional[dict] = None,
extra_stats: Optional[dict] = None,
origin: Optional[str] = None,
radio_config: Optional[str] = None,
):
"""
Publish device status/heartbeat message
Args:
state: Device state (online/offline)
location: Optional dict with latitude/longitude
extra_stats: Optional additional statistics to include
origin: Node name/description
radio_config: Radio configuration string (freq,bw,sf,cr)
"""
# Get live stats from provider if available
if self.stats_provider:
live_stats = self.stats_provider()
else:
live_stats = {"uptime_secs": 0, "packets_sent": 0, "packets_received": 0}
status = {
"status": state,
"timestamp": datetime.now(UTC).isoformat(),
"origin": origin or self.node_name,
"origin_id": self.public_key,
"model": "PyMC-Repeater",
"firmware_version": self.app_version,
"radio": radio_config or self.radio_config,
"client_version": f"pyMC_repeater/{self.app_version}",
"stats": {**live_stats, "errors": 0, "queue_len": 0, **(extra_stats or {})},
}
if location:
status["location"] = location
return self.publish("status", status, retain=True, qos=1)
def publish(self, subtopic: str, payload: dict, retain: bool = False):
"""Publish message to all connected brokers"""
topic = self._topic(subtopic)
message = json.dumps(payload)
results = []
with self._lock:
for conn in self.connections:
if conn.is_connected():
result = conn.publish(topic, message, retain=retain)
results.append((conn.broker["name"], result))
logger.debug(f"Published to {conn.broker['name']}/{topic}")
if not results:
logger.warning(f"No active broker connections for publishing to {topic}")
return results
# ====================================================================
# Helper Functions
# ====================================================================
def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str:
"""
Get human-readable MQTT error message.
Args:
rc: Return code from paho-mqtt
is_disconnect: True if from on_disconnect, False if from on_connect
Returns:
Human-readable error message
"""
if HAS_REASON_CODES:
try:
# ReasonCode object has getName() method and value property
reason = ReasonCode(mqtt.CONNACK if not is_disconnect else mqtt.DISCONNECT, identifier=rc)
name = reason.getName() if hasattr(reason, 'getName') else str(reason)
return f"{name} (code {rc})"
except Exception as e:
# Log the exception for debugging
logger.debug(f"Could not decode reason code {rc}: {e}")
# Fallback to manual mappings - Extended with MQTT v5 codes
connect_errors = {
0: "Connection accepted",
1: "Incorrect protocol version",
2: "Invalid client identifier",
3: "Server unavailable",
4: "Bad username or password (JWT invalid)",
5: "Not authorized (JWT signature/format invalid)",
# MQTT v5 codes
128: "Unspecified error",
129: "Malformed packet",
130: "Protocol error",
131: "Implementation specific error",
132: "Unsupported protocol version",
133: "Client identifier not valid",
134: "Bad username or password",
135: "Not authorized",
136: "Server unavailable",
137: "Server busy",
138: "Banned",
140: "Bad authentication method",
144: "Topic name invalid",
149: "Packet too large",
151: "Quota exceeded",
153: "Payload format invalid",
154: "Retain not supported",
155: "QoS not supported",
156: "Use another server",
157: "Server moved",
159: "Connection rate exceeded",
}
disconnect_errors = {
0: "Normal disconnect",
1: "Unacceptable protocol version",
2: "Identifier rejected",
3: "Server unavailable",
4: "Bad username or password",
5: "Not authorized",
7: "Connection lost / network error",
16: "Connection lost / protocol error",
17: "Client timeout",
# MQTT v5 codes
4: "Disconnect with Will message",
128: "Unspecified error",
129: "Malformed packet",
130: "Protocol error",
131: "Implementation specific error",
135: "Not authorized",
137: "Server busy",
139: "Server shutting down",
141: "Keep alive timeout",
142: "Session taken over",
143: "Topic filter invalid",
144: "Topic name invalid",
147: "Receive maximum exceeded",
148: "Topic alias invalid",
149: "Packet too large",
150: "Message rate too high",
151: "Quota exceeded",
152: "Administrative action",
153: "Payload format invalid",
154: "Retain not supported",
155: "QoS not supported",
156: "Use another server",
157: "Server moved",
158: "Shared subscriptions not supported",
159: "Connection rate exceeded",
160: "Maximum connect time",
161: "Subscription identifiers not supported",
162: "Wildcard subscriptions not supported",
}
error_dict = disconnect_errors if is_disconnect else connect_errors
return error_dict.get(rc, f"Unknown error code {rc}")
+42 -42
View File
@@ -5,8 +5,8 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from .letsmesh_handler import MeshCoreToMqttJwtPusher
from .mqtt_handler import MQTTHandler
from .mqtt_handler import MeshCoreToMqttPusher
#from .old_mqtt_handler import MQTTHandler
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_utils import PacketRecord
@@ -32,40 +32,40 @@ class StorageCollector:
self.sqlite_handler = SQLiteHandler(self.storage_dir)
self.rrd_handler = RRDToolHandler(self.storage_dir)
self.mqtt_handler = MQTTHandler(config.get("mqtt", {}), node_name, node_id)
# self.old_mqtt_handler = MQTTHandler(config.get("mqtt", {}), node_name, node_id)
# Initialize LetsMesh handler if configured
self.letsmesh_handler = None
if config.get("letsmesh", {}).get("enabled", False) and local_identity:
# Initialize MQTT handler if configured
self.mqtt_handler = None
if config.get("mqtt", {}) and local_identity:
try:
# Pass local_identity directly (supports both standard and firmware keys)
self.letsmesh_handler = MeshCoreToMqttJwtPusher(
self.mqtt_handler = MeshCoreToMqttPusher(
local_identity=local_identity,
config=config,
stats_provider=self._get_live_stats,
)
self.letsmesh_handler.connect()
self.mqtt_handler.connect()
# Get disallowed packet types from config
from ..config import get_node_info
node_info = get_node_info(config)
self.disallowed_packet_types = set(node_info["disallowed_packet_types"])
#node_info = get_node_info(config)
#self.disallowed_packet_types = set(node_info["disallowed_packet_types"])
public_key_hex = local_identity.get_public_key().hex()
logger.info(
f"LetsMesh handler initialized with public key: {public_key_hex[:16]}..."
f"MQTT handler initialized with public key: {public_key_hex[:16]}..."
)
if self.disallowed_packet_types:
logger.info(f"Disallowed packet types: {sorted(self.disallowed_packet_types)}")
else:
logger.info("All packet types allowed")
#if self.disallowed_packet_types:
# logger.info(f"Disallowed packet types: {sorted(self.disallowed_packet_types)}")
#else:
# logger.info("All packet types allowed")
except Exception as e:
logger.error(f"Failed to initialize LetsMesh handler: {e}")
self.letsmesh_handler = None
self.disallowed_packet_types = set()
else:
self.disallowed_packet_types = set()
logger.error(f"Failed to initialize MQTT handler: {e}")
self.mqtt_handler = None
#self.disallowed_packet_types = set()
#else:
# self.disallowed_packet_types = set()
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
@@ -146,7 +146,7 @@ class StorageCollector:
self.sqlite_handler.store_packet(packet_record)
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
self.mqtt_handler.publish(packet_record, "packet")
#self.old_mqtt_handler.publish(packet_record, "packet")
# Broadcast to WebSocket clients for real-time updates
if self.websocket_available:
@@ -170,17 +170,17 @@ class StorageCollector:
except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}")
# Publish to LetsMesh if enabled (skip invalid packets if requested)
if skip_letsmesh_if_invalid and packet_record.get("drop_reason"):
logger.debug(
f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}"
)
else:
self._publish_to_letsmesh(packet_record)
# # Publish to LetsMesh if enabled (skip invalid packets if requested)
# if skip_letsmesh_if_invalid and packet_record.get("drop_reason"):
# logger.debug(
# f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}"
# )
# else:
self._publish_to_letsmesh(packet_record)
def _publish_to_letsmesh(self, packet_record: dict):
"""Publish packet to LetsMesh broker if enabled and allowed"""
if not self.letsmesh_handler:
if not self.mqtt_handler:
return
try:
@@ -189,17 +189,17 @@ class StorageCollector:
logger.error("Cannot publish to LetsMesh: packet_record missing 'type' field")
return
if packet_type in self.disallowed_packet_types:
logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
return
# if packet_type in self.disallowed_packet_types:
# logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
# return
node_name = self.config.get("repeater", {}).get("node_name", "Unknown")
packet = PacketRecord.from_packet_record(
packet_record, origin=node_name, origin_id=self.letsmesh_handler.public_key
packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key
)
if packet:
self.letsmesh_handler.publish_packet(packet.to_dict())
self.mqtt_handler.publish_packet(packet.to_dict(), packet_type)
logger.debug(f"Published packet type 0x{packet_type:02X} to LetsMesh")
else:
logger.debug("Skipped LetsMesh publish: packet missing raw_packet data")
@@ -209,18 +209,18 @@ class StorageCollector:
def record_advert(self, advert_record: dict):
self.sqlite_handler.store_advert(advert_record)
self.mqtt_handler.publish(advert_record, "advert")
#self.old_mqtt_handler.publish(advert_record, "advert")
def record_noise_floor(self, noise_floor_dbm: float):
noise_record = {"timestamp": time.time(), "noise_floor_dbm": noise_floor_dbm}
self.sqlite_handler.store_noise_floor(noise_record)
self.mqtt_handler.publish(noise_record, "noise_floor")
#self.old_mqtt_handler.publish(noise_record, "noise_floor")
def record_crc_errors(self, count: int):
"""Record a batch of CRC errors detected since last poll."""
crc_record = {"timestamp": time.time(), "count": count}
self.sqlite_handler.store_crc_errors(crc_record)
self.mqtt_handler.publish(crc_record, "crc_errors")
#self.old_mqtt_handler.publish(crc_record, "crc_errors")
def get_crc_error_count(self, hours: int = 24) -> int:
return self.sqlite_handler.get_crc_error_count(hours)
@@ -305,13 +305,13 @@ class StorageCollector:
return self.sqlite_handler.get_noise_floor_stats(hours)
def close(self):
self.mqtt_handler.close()
if self.letsmesh_handler:
#self.old_mqtt_handler.close()
if self.mqtt_handler:
try:
self.letsmesh_handler.disconnect()
logger.info("LetsMesh handler disconnected")
self.mqtt_handler.disconnect()
logger.info("MQTT handler disconnected")
except Exception as e:
logger.error(f"Error disconnecting LetsMesh handler: {e}")
logger.error(f"Error disconnecting MQTT handler: {e}")
def create_transport_key(
self,
+1 -1
View File
@@ -1138,7 +1138,7 @@ class RepeaterHandler(BaseHandler):
"unscoped_flood_allow": self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)),
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
},
"letsmesh": self.config.get("letsmesh", {}),
#"mqtt": self.config.get("mqtt", {}),
},
"public_key": None,
}
+88 -90
View File
@@ -54,8 +54,8 @@ logger = logging.getLogger("HTTPServer")
# POST /api/update_duty_cycle_config {"enabled": true, "on_time": 300, "off_time": 60} - Update duty cycle config
# POST /api/update_radio_config - Update radio configuration
# POST /api/update_advert_rate_limit_config - Update advert rate limiting settings
# GET /api/letsmesh_status - Get LetsMesh Observer connection status
# POST /api/update_letsmesh_config - Update LetsMesh Observer configuration
# GET /api/mqtt_status - Get MQTT Observer connection status
# POST /api/update_mqtt_config - Update MQTT Observer configuration
# Packets
# GET /api/packet_stats?hours=24 - Get packet statistics
@@ -999,18 +999,18 @@ class APIEndpoints:
@cherrypy.expose
@cherrypy.tools.json_out()
def letsmesh_status(self):
"""Get LetsMesh connection status and configuration."""
def mqtt_status(self):
"""Get MQTT connection status and configuration."""
self._set_cors_headers()
try:
letsmesh_cfg = self.config.get("letsmesh", {})
enabled = letsmesh_cfg.get("enabled", False)
mqtt_cfg = self.config.get("mqtt", {})
enabled = mqtt_cfg.get("enabled", False)
# Walk the chain to the letsmesh_handler
# Walk the chain to the mqtt_handler
handler = None
try:
storage = self._get_storage()
handler = getattr(storage, "letsmesh_handler", None)
handler = getattr(storage, "mqtt_handler", None)
except Exception:
pass
@@ -1030,100 +1030,98 @@ class APIEndpoints:
"brokers": connected_brokers,
})
except Exception as e:
logger.error(f"Error getting LetsMesh status: {e}")
logger.error(f"Error getting MQTT status: {e}")
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def update_letsmesh_config(self):
"""Update LetsMesh Observer configuration.
# @cherrypy.expose
# @cherrypy.tools.json_out()
# @cherrypy.tools.json_in()
# def update_mqtt_config(self):
# """Update MQTT Observer configuration.
POST /api/update_letsmesh_config
Body: {
"enabled": true,
"iata_code": "SFO",
"broker_index": 0,
"status_interval": 300,
"owner": "Callsign",
"email": "user@example.com",
"disallowed_packet_types": ["ACK"]
}
"""
self._set_cors_headers()
# POST /api/update_mqtt_config
# Body: {
# "iata_code": "SFO",
# "status_interval": 300,
# "owner": "Callsign",
# "email": "user@example.com",
# "disallowed_packet_types": ["ACK"]
# }
# """
# self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
# if cherrypy.request.method == "OPTIONS":
# return ""
try:
self._require_post()
data = cherrypy.request.json or {}
# try:
# self._require_post()
# data = cherrypy.request.json or {}
if not data:
return self._error("No configuration updates provided")
# if not data:
# return self._error("No configuration updates provided")
letsmesh_updates = {}
# letsmesh_updates = {}
if "enabled" in data:
letsmesh_updates["enabled"] = bool(data["enabled"])
if "iata_code" in data:
letsmesh_updates["iata_code"] = str(data["iata_code"]).strip()
if "broker_index" in data:
letsmesh_updates["broker_index"] = int(data["broker_index"])
if "status_interval" in data:
letsmesh_updates["status_interval"] = max(60, int(data["status_interval"]))
if "owner" in data:
letsmesh_updates["owner"] = str(data["owner"]).strip()
if "email" in data:
letsmesh_updates["email"] = str(data["email"]).strip()
if "disallowed_packet_types" in data:
letsmesh_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
if "additional_brokers" in data:
brokers = data["additional_brokers"]
if not isinstance(brokers, list):
return self._error("additional_brokers must be a list")
validated = []
for i, b in enumerate(brokers):
if not isinstance(b, dict):
return self._error(f"Broker at index {i} must be an object")
for field in ("name", "host", "audience"):
if not b.get(field, "").strip():
return self._error(f"Broker at index {i} missing required field: {field}")
try:
port = int(b.get("port", 443))
except (ValueError, TypeError):
return self._error(f"Broker at index {i} has invalid port")
validated.append({
"name": str(b["name"]).strip(),
"host": str(b["host"]).strip(),
"port": port,
"audience": str(b["audience"]).strip(),
})
letsmesh_updates["additional_brokers"] = validated
# if "enabled" in data:
# letsmesh_updates["enabled"] = bool(data["enabled"])
# if "iata_code" in data:
# letsmesh_updates["iata_code"] = str(data["iata_code"]).strip()
# if "broker_index" in data:
# letsmesh_updates["broker_index"] = int(data["broker_index"])
# if "status_interval" in data:
# letsmesh_updates["status_interval"] = max(60, int(data["status_interval"]))
# if "owner" in data:
# letsmesh_updates["owner"] = str(data["owner"]).strip()
# if "email" in data:
# letsmesh_updates["email"] = str(data["email"]).strip()
# if "disallowed_packet_types" in data:
# letsmesh_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
# if "additional_brokers" in data:
# brokers = data["additional_brokers"]
# if not isinstance(brokers, list):
# return self._error("additional_brokers must be a list")
# validated = []
# for i, b in enumerate(brokers):
# if not isinstance(b, dict):
# return self._error(f"Broker at index {i} must be an object")
# for field in ("name", "host", "audience"):
# if not b.get(field, "").strip():
# return self._error(f"Broker at index {i} missing required field: {field}")
# try:
# port = int(b.get("port", 443))
# except (ValueError, TypeError):
# return self._error(f"Broker at index {i} has invalid port")
# validated.append({
# "name": str(b["name"]).strip(),
# "host": str(b["host"]).strip(),
# "port": port,
# "audience": str(b["audience"]).strip(),
# })
# letsmesh_updates["additional_brokers"] = validated
if not letsmesh_updates:
return self._error("No valid settings provided")
# if not letsmesh_updates:
# return self._error("No valid settings provided")
result = self.config_manager.update_and_save(
updates={"letsmesh": letsmesh_updates},
live_update=False, # Restart required for LetsMesh handler changes
)
# result = self.config_manager.update_and_save(
# updates={"letsmesh": letsmesh_updates},
# live_update=False, # Restart required for LetsMesh handler changes
# )
if result.get("success"):
logger.info(f"LetsMesh config updated: {list(letsmesh_updates.keys())}")
return self._success({
"persisted": result.get("saved", False),
"restart_required": True,
"message": "Observer settings saved. Restart the service for changes to take effect.",
})
else:
return self._error(result.get("error", "Failed to update LetsMesh configuration"))
# if result.get("success"):
# logger.info(f"LetsMesh config updated: {list(letsmesh_updates.keys())}")
# return self._success({
# "persisted": result.get("saved", False),
# "restart_required": True,
# "message": "Observer settings saved. Restart the service for changes to take effect.",
# })
# else:
# return self._error(result.get("error", "Failed to update LetsMesh configuration"))
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"Error updating LetsMesh config: {e}")
return self._error(str(e))
# except cherrypy.HTTPError:
# raise
# except Exception as e:
# logger.error(f"Error updating LetsMesh config: {e}")
# return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()