Merge pull request #187 from Rigear/feat/mqtt_merge

Feat/mqtt merge
This commit is contained in:
Lloyd
2026-04-22 08:37:22 +01:00
committed by GitHub
16 changed files with 1084 additions and 342 deletions
+34 -93
View File
@@ -273,49 +273,6 @@ duty_cycle:
# Maximum airtime per minute in milliseconds
max_airtime_per_minute: 3600
# MQTT Publishing Configuration (Optional)
mqtt:
# Enable/disable MQTT publishing
enabled: false
# MQTT broker settings
broker: "localhost"
port: 1883 # Use 8883 for TLS/SSL, 80/443/9001 for WebSockets
# Use WebSocket transport instead of standard TCP
# Typically uses ports: 80 (ws://), 443 (wss://), or 9001
use_websockets: false
# Authentication (optional)
username: null
password: null
# TLS/SSL configuration (optional)
# For public brokers with trusted certificates, just enable TLS:
# tls:
# enabled: true
tls:
enabled: false
# Advanced TLS options (usually not needed for public brokers):
# Custom CA certificate for server verification
# Leave null to use system default CA certificates (recommended)
ca_cert: null # e.g., "/etc/ssl/certs/ca-certificates.crt"
# Client certificate and key for mutual TLS (rarely needed)
client_cert: null # e.g., "/etc/pymc/client.crt"
client_key: null # e.g., "/etc/pymc/client.key"
# Skip certificate verification (insecure, not recommended)
insecure: false
# Base topic for publishing
# Messages will be published to: {base_topic}/{node_name}/{packet|advert}
base_topic: "meshcore/repeater"
# Storage Configuration
storage:
# Directory for persistent storage files (SQLite, RRD).
@@ -333,63 +290,31 @@ storage:
# - 1 hour resolution for 1 year
letsmesh:
enabled: false
mqtt:
iata_code: "Test" # e.g., "SFO", "LHR", "Test"
# ============================================================
# BROKER SELECTION MODE - Choose how to connect to brokers
# ============================================================
#
# EXAMPLE 1: Single built-in broker (default, most common)
# Connect to Europe only - simple, low bandwidth
broker_index: 0 # 0 = Europe, 1 = US West
# EXAMPLE 2: All built-in brokers for maximum redundancy
# Survives single broker failure, best uptime
# broker_index: -1 # or null - connects to both EU and US
# EXAMPLE 3: Only custom brokers (private/self-hosted)
# Ignores built-in LetsMesh brokers completely
# broker_index: -2
# additional_brokers:
# - name: "Private Server"
# host: "mqtt.myserver.com"
# port: 443
# audience: "mqtt.myserver.com"
# EXAMPLE 4: Single built-in + custom backup
# Use EU primary with your own backup
# broker_index: 0
# additional_brokers:
# - name: "Backup Server"
# host: "mqtt-backup.mydomain.com"
# port: 8883
# audience: "mqtt-backup.mydomain.com"
# EXAMPLE 5: All built-in + multiple custom (maximum redundancy)
# EU + US + your own servers - best for critical deployments
# broker_index: -1
# additional_brokers:
# - name: "Custom Primary"
# host: "mqtt-1.mydomain.com"
# port: 443
# audience: "mqtt-1.mydomain.com"
# - name: "Custom Backup"
# host: "mqtt-2.mydomain.com"
# port: 443
# audience: "mqtt-2.mydomain.com"
# ============================================================
status_interval: 300
status_interval: 300 # How often a status message is sent (in seconds)
owner: ""
email: ""
brokers: []
# Block specific packet types from being published to LetsMesh
# Below is the broker object schema:
# enabled: true|false # Enable this specific mqtt broker
# name: "" # Internal name for this broker
# host: "" # hostname or ip of mqtt endpoints
# port: # Typically 443 for websocket endpoints or 1883 for tcp
# transport: "tcp" or "websockets"
# audience: "" # For JWT auth'd endpoints, this is usually the host unless always stated by endpoint owners
# use_jwt_auth: true|false # Does this endpoint require JWT auth
# username: "" # Username for basic auth. If empty or missing, uses anonymous access
# password: "" # Password for basic auth. Required if username is set
# format: letsmesh|mqtt
# retain_status: true|false # Sets MQTT "retain" on status messages so they remain on the broker when disconnected. Also enforces a QOS of 1 (guaranteed delivery)
# Block specific packet types from being published to the MQTT endpoint
# If not specified or empty list, all types are published
# Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT,
# GRP_DATA, ANON_REQ, PATH, TRACE, RAW_CUSTOM
disallowed_packet_types: []
# disallowed_packet_types: []
# - REQ # Don't publish requests
# - RESPONSE # Don't publish responses
# - TXT_MSG # Don't publish text messages
@@ -402,6 +327,22 @@ letsmesh:
# - TRACE # Don't publish trace packets
# - RAW_CUSTOM # Don't publish custom raw packets
# Example of using the US and EU LetsMesh endpoints
# brokers:
# - name: US West (LetsMesh v1)
# host: mqtt-us-v1.letsmesh.net
# port: 443
# audience: mqtt-us-v1.letsmesh.net
# use_jwt_auth: true
# enabled: true
# - name: Europe (LetsMesh v1)
# host: mqtt-eu-v1.letsmesh.net
# port: 443
# audience: mqtt-eu-v1.letsmesh.net
# use_jwt_auth: true
# enabled: true
# pyMC_Glass control-plane integration (optional)
glass:
# Enable repeater -> pyMC_Glass /inform loop
+9 -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,17 @@ 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
# Handle getting the config from mqtt brokers, falling back to letsmesh if it doesn't exist
mqtt_config = config.get("mqtt_brokers", config.get("letsmesh", {}))
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", ""),
}
+1 -2
View File
@@ -1,6 +1,5 @@
from .glass_handler import GlassHandler
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", "GlassHandler"]
__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector", "GlassHandler"]
File diff suppressed because it is too large Load Diff
+37 -62
View File
@@ -6,8 +6,7 @@ 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 .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_utils import PacketRecord
@@ -30,45 +29,28 @@ class StorageCollector:
self.storage_dir = Path(storage_dir_cfg)
self.storage_dir.mkdir(parents=True, exist_ok=True)
node_name = config.get("repeater", {}).get("node_name", "unknown")
node_id = local_identity.get_public_key().hex() if local_identity else "unknown"
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)
# 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_brokers", {}) or config.get("letsmesh", {}) or 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()
# 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"])
self.mqtt_handler.connect()
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")
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
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
@@ -160,12 +142,12 @@ class StorageCollector:
return stats
def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True):
"""Record packet to storage and defer network publishing to background tasks.
def record_packet(self, packet_record: dict, skip_mqtt_if_invalid: bool = True):
"""Record packet to storage and publish to MQTT
Args:
packet_record: Dictionary containing packet information
skip_letsmesh_if_invalid: If True, don't publish packets with drop_reason to LetsMesh
skip_mqtt_if_invalid: If True, don't publish packets with drop_reason to mqtt
"""
logger.debug(
f"Recording packet: type={packet_record.get('type')}, "
@@ -182,20 +164,19 @@ class StorageCollector:
self._schedule_background(
self._deferred_publish,
packet_record,
skip_letsmesh_if_invalid,
skip_mqtt_if_invalid,
sync_fallback=self._publish_packet_sync,
)
async def _deferred_publish(self, packet_record: dict, skip_letsmesh: bool):
async def _deferred_publish(self, packet_record: dict, skip_mqtt: bool):
"""Deferred background task for all network publishing operations."""
try:
self._publish_packet_sync(packet_record, skip_letsmesh)
self._publish_packet_sync(packet_record, skip_mqtt)
except Exception as e:
logger.error(f"Deferred publish failed: {e}", exc_info=True)
def _publish_packet_sync(self, packet_record: dict, skip_letsmesh: bool):
def _publish_packet_sync(self, packet_record: dict, skip_mqtt: bool):
"""Publish packet updates synchronously (used when no asyncio loop is active)."""
self.mqtt_handler.publish(packet_record, "packet")
self._publish_to_glass(packet_record, "packet")
if self.websocket_available:
@@ -214,41 +195,33 @@ class StorageCollector:
except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}")
if skip_letsmesh 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:
self._publish_packet_to_mqtt(packet_record)
def _publish_packet_to_mqtt(self, packet_record: dict):
"""Publish packet to mqtt broker if enabled and allowed"""
if not self.mqtt_handler:
return
try:
packet_type = packet_record.get("type")
if packet_type is None:
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)")
logger.error("Cannot publish to mqtt: packet_record missing 'type' field")
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())
logger.debug(f"Published packet type 0x{packet_type:02X} to LetsMesh")
self.mqtt_handler.publish_packet(packet.to_dict())
logger.debug(f"Published packet type 0x{packet_type:02X} to mqtt")
else:
logger.debug("Skipped LetsMesh publish: packet missing raw_packet data")
logger.debug("Skipped mqtt publish: packet missing raw_packet data")
except Exception as e:
logger.error(f"Failed to publish packet to LetsMesh: {e}", exc_info=True)
logger.error(f"Failed to publish packet to mqtt: {e}", exc_info=True)
def record_advert(self, advert_record: dict):
"""Record advert to storage and defer network publishing to background tasks."""
@@ -267,7 +240,8 @@ class StorageCollector:
logger.error(f"Deferred advert publish failed: {e}", exc_info=True)
def _publish_advert_sync(self, advert_record: dict):
self.mqtt_handler.publish(advert_record, "advert")
if self.mqtt_handler:
self.mqtt_handler.publish_mqtt(advert_record, "advert")
self._publish_to_glass(advert_record, "advert")
def record_noise_floor(self, noise_floor_dbm: float):
@@ -288,7 +262,8 @@ class StorageCollector:
logger.error(f"Deferred noise floor publish failed: {e}", exc_info=True)
def _publish_noise_floor_sync(self, noise_record: dict):
self.mqtt_handler.publish(noise_record, "noise_floor")
if self.mqtt_handler:
self.mqtt_handler.publish_mqtt(noise_record, "noise_floor")
self._publish_to_glass(noise_record, "noise_floor")
def record_crc_errors(self, count: int):
@@ -309,7 +284,8 @@ class StorageCollector:
logger.error(f"Deferred CRC errors publish failed: {e}", exc_info=True)
def _publish_crc_errors_sync(self, crc_record: dict):
self.mqtt_handler.publish(crc_record, "crc_errors")
if self.mqtt_handler:
self.mqtt_handler.publish_mqtt(crc_record, "crc_errors")
self._publish_to_glass(crc_record, "crc_errors")
def get_crc_error_count(self, hours: int = 24) -> int:
@@ -422,13 +398,12 @@ class StorageCollector:
if not task.done():
task.cancel()
self.mqtt_handler.close()
if self.letsmesh_handler:
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 set_glass_publisher(self, publish_callback):
self.glass_publish_callback = publish_callback
+1 -1
View File
@@ -10,7 +10,7 @@ class PacketRecord:
"""
Data class for packet record format.
Converts internal packet_record format to standardized publish format.
Reusable across MQTT, LetsMesh, and other handlers.
Reusable across MQTT and other handlers.
"""
origin: str
+7 -7
View File
@@ -362,13 +362,13 @@ class RepeaterHandler(BaseHandler):
)
# Store packet record to persistent storage
# Skip LetsMesh only for invalid packets (not duplicates or operational drops)
# Skip mqtt only for invalid packets (not duplicates or operational drops)
if self.storage:
try:
# Only skip LetsMesh for actual invalid/bad packets
# Only skip mqtt for actual invalid/bad packets
invalid_reasons = ["Invalid advert packet", "Empty payload", "Path too long"]
skip_letsmesh = drop_reason in invalid_reasons if drop_reason else False
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=skip_letsmesh)
skip_mqtt = drop_reason in invalid_reasons if drop_reason else False
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=skip_mqtt)
except Exception as e:
logger.error(f"Failed to store packet record: {e}")
@@ -445,7 +445,7 @@ class RepeaterHandler(BaseHandler):
packet_hash=packet.calculate_packet_hash().hex().upper(),
)
try:
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False)
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=False)
except Exception as e:
logger.error(f"Failed to store packet record (record_packet_only): {e}")
return
@@ -487,7 +487,7 @@ class RepeaterHandler(BaseHandler):
if self.storage:
try:
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False)
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=False)
except Exception as e:
logger.error(f"Failed to store duplicate record: {e}")
@@ -1145,7 +1145,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_brokers": self.config.get("mqtt_brokers", {}),
},
"public_key": None,
}
+65 -45
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,17 @@ 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_brokers", {})
# 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
@@ -1018,36 +1017,40 @@ class APIEndpoints:
if handler:
for conn in getattr(handler, "connections", []):
connected_brokers.append({
"enabled": conn.enabled,
"name": conn.broker.get("name", ""),
"host": conn.broker.get("host", ""),
"connected": conn.is_connected(),
"reconnecting": conn.has_pending_reconnect(),
"status": {
"connected": conn.is_connected(),
"reconnecting": conn.has_pending_reconnect(),
},
"format": conn.format
})
return self._success({
"enabled": enabled,
"handler_active": handler is not None,
"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.
def update_mqtt_config(self):
"""Update MQTT Observer configuration.
POST /api/update_letsmesh_config
POST /api/update_mqtt_config
Body: {
"enabled": true,
"iata_code": "SFO",
"broker_index": 0,
"status_interval": 300,
"owner": "Callsign",
"email": "user@example.com",
"disallowed_packet_types": ["ACK"]
"brokers": [
{
}]
}
"""
self._set_cors_headers()
@@ -1062,55 +1065,72 @@ class APIEndpoints:
if not data:
return self._error("No configuration updates provided")
letsmesh_updates = {}
mqtt_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"])
mqtt_updates["iata_code"] = str(data["iata_code"]).strip()
if "status_interval" in data:
letsmesh_updates["status_interval"] = max(60, int(data["status_interval"]))
mqtt_updates["status_interval"] = max(60, int(data["status_interval"]))
if "owner" in data:
letsmesh_updates["owner"] = str(data["owner"]).strip()
mqtt_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"]
mqtt_updates["email"] = str(data["email"]).strip()
# if "disallowed_packet_types" in data:
# mqtt_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
if "brokers" in data:
brokers = data["brokers"]
if not isinstance(brokers, list):
return self._error("additional_brokers must be a list")
return self._error("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():
for field in ("name", "host", "port", "format"):
if not b.get(field, ""):
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
new_broker = {
"name": str(b["name"]).strip(),
"enabled": b.get("enabled", False),
"transport": str(b.get("transport", "websockets")).strip(),
"host": str(b["host"]).strip(),
"port": port,
"format": str(b["format"]).strip(),
"disallowed_packet_types": list(b.get("disallowed_packet_types", [])),
"retain_status": bool(b.get("retain_status", False)),
"tls": {
"enabled": bool(b.get("tls", {}).get("enabled", True if port == 443 else False)),
"insecure": bool(b.get("tls", {}).get("insecure", False)),
}
}
if b.get("use_jwt_auth", False):
new_broker["use_jwt_auth"] = True
new_broker["audience"] = str(b["audience"]).strip()
else:
new_broker["use_jwt_auth"] = False
new_broker["username"] = b.get("username", None)
new_broker["password"] = b.get("password", None)
if not letsmesh_updates:
validated.append(new_broker)
mqtt_updates["brokers"] = validated
if not mqtt_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
updates={"mqtt_brokers": mqtt_updates, "mqtt": None, "letsmesh": None},
live_update=False, # Restart required for MQTT handler changes
)
if result.get("success"):
logger.info(f"LetsMesh config updated: {list(letsmesh_updates.keys())}")
logger.info(f"MQTT config updated: {list(mqtt_updates.keys())}")
return self._success({
"persisted": result.get("saved", False),
"restart_required": True,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.bg-gradient-light[data-v-63d1c99c]{background:linear-gradient(#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-63d1c99c]{background:linear-gradient(#67e8f94d,#a5f3fc26)}.login-card[data-v-63d1c99c]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffb3}.dark .login-card[data-v-63d1c99c]{background:#11191c66}.input-glass[data-v-63d1c99c]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-63d1c99c]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-63d1c99c]:focus{background:#fff}.dark .input-glass[data-v-63d1c99c]:focus{background:#ffffff1a}.input-glass[data-v-63d1c99c]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-63d1c99c]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-63d1c99c]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-63d1c99c]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-63d1c99c]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-63d1c99c]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-63d1c99c]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-63d1c99c]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-63d1c99c]{filter:brightness(1.4)drop-shadow(0 0 12px #aae8e8b3);transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-63d1c99c]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-63d1c99c]{opacity:0}.dark .logo-glow[data-v-63d1c99c]{opacity:1}@keyframes float-63d1c99c{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-63d1c99c{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-63d1c99c{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-63d1c99c{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-63d1c99c]{animation:8s ease-in-out infinite pulse-slow-63d1c99c}.animate-pulse-slower[data-v-63d1c99c]{animation:10s ease-in-out infinite pulse-slower-63d1c99c}.animate-pulse-slowest[data-v-63d1c99c]{animation:12s ease-in-out infinite pulse-slowest-63d1c99c}@keyframes shake-63d1c99c{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-63d1c99c]{animation:.5s ease-in-out shake-63d1c99c}.form-group[data-v-63d1c99c]{position:relative}.form-group:hover label[data-v-63d1c99c]{color:#aae8e8e6;transition:color .3s}
@@ -0,0 +1 @@
import{n as e}from"./index-cutq4vvY.js";export{e as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long