Update CHANGELOG, enhance configuration loading with default values, and add tests for missing defaults

This commit is contained in:
sh4un
2026-01-22 21:40:34 -05:00
parent 397cbde6bf
commit 56623f9804
23 changed files with 1469 additions and 674 deletions

View File

@@ -5,4 +5,6 @@ Akita Meshtastic Meshcore Bridge (AMMB) Package.
from .bridge import Bridge from .bridge import Bridge
__all__ = ["Bridge", "__version__"]
__version__ = "0.2.1" __version__ = "0.2.1"

View File

@@ -3,15 +3,15 @@
REST API for monitoring and controlling the bridge. REST API for monitoring and controlling the bridge.
""" """
import json
import logging import logging
import threading import threading
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler, HTTPServer
import json
from typing import Optional from typing import Optional
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse
from .metrics import get_metrics
from .health import get_health_monitor from .health import get_health_monitor
from .metrics import get_metrics
class BridgeAPIHandler(BaseHTTPRequestHandler): class BridgeAPIHandler(BaseHTTPRequestHandler):
@@ -29,16 +29,16 @@ class BridgeAPIHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
"""Handle GET requests.""" """Handle GET requests."""
parsed_path = urlparse(self.path) parsed_path = urlparse(self.path)
path = parsed_path.path.rstrip('/') path = parsed_path.path.rstrip("/")
try: try:
if path == '/api/health': if path == "/api/health":
self._handle_health() self._handle_health()
elif path == '/api/metrics': elif path == "/api/metrics":
self._handle_metrics() self._handle_metrics()
elif path == '/api/status': elif path == "/api/status":
self._handle_status() self._handle_status()
elif path == '/api/info': elif path == "/api/info":
self._handle_info() self._handle_info()
else: else:
self._send_response(404, {"error": "Not found"}) self._send_response(404, {"error": "Not found"})
@@ -50,10 +50,10 @@ class BridgeAPIHandler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
"""Handle POST requests.""" """Handle POST requests."""
parsed_path = urlparse(self.path) parsed_path = urlparse(self.path)
path = parsed_path.path.rstrip('/') path = parsed_path.path.rstrip("/")
try: try:
if path == '/api/control': if path == "/api/control":
self._handle_control() self._handle_control()
else: else:
self._send_response(404, {"error": "Not found"}) self._send_response(404, {"error": "Not found"})
@@ -90,45 +90,54 @@ class BridgeAPIHandler(BaseHTTPRequestHandler):
info = { info = {
"name": "Akita Meshtastic Meshcore Bridge", "name": "Akita Meshtastic Meshcore Bridge",
"version": "1.0.0", "version": "1.0.0",
"external_transport": self.bridge.config.external_transport if self.bridge.config else "unknown", "external_transport": (
self.bridge.config.external_transport
if self.bridge.config
else "unknown"
),
"meshtastic_connected": ( "meshtastic_connected": (
self.bridge.meshtastic_handler._is_connected.is_set() self.bridge.meshtastic_handler._is_connected.is_set()
if self.bridge.meshtastic_handler else False if self.bridge.meshtastic_handler
else False
), ),
"external_connected": ( "external_connected": (
self.bridge.external_handler._is_connected.is_set() self.bridge.external_handler._is_connected.is_set()
if hasattr(self.bridge.external_handler, '_is_connected') and self.bridge.external_handler else False if hasattr(self.bridge.external_handler, "_is_connected")
and self.bridge.external_handler
else False
), ),
} }
self._send_response(200, info) self._send_response(200, info)
def _handle_control(self): def _handle_control(self):
"""Handle control requests.""" """Handle control requests."""
content_length = int(self.headers.get('Content-Length', 0)) content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0: if content_length == 0:
self._send_response(400, {"error": "No request body"}) self._send_response(400, {"error": "No request body"})
return return
body = self.rfile.read(content_length) body = self.rfile.read(content_length)
try: try:
data = json.loads(body.decode('utf-8')) data = json.loads(body.decode("utf-8"))
action = data.get('action') action = data.get("action")
if action == 'reset_metrics': if action == "reset_metrics":
metrics = get_metrics() metrics = get_metrics()
metrics.reset() metrics.reset()
self._send_response(200, {"message": "Metrics reset"}) self._send_response(200, {"message": "Metrics reset"})
else: else:
self._send_response(400, {"error": f"Unknown action: {action}"}) self._send_response(
400, {"error": f"Unknown action: {action}"}
)
except json.JSONDecodeError: except json.JSONDecodeError:
self._send_response(400, {"error": "Invalid JSON"}) self._send_response(400, {"error": "Invalid JSON"})
def _send_response(self, status_code: int, data: dict): def _send_response(self, status_code: int, data: dict):
"""Send JSON response.""" """Send JSON response."""
response = json.dumps(data, indent=2).encode('utf-8') response = json.dumps(data, indent=2).encode("utf-8")
self.send_response(status_code) self.send_response(status_code)
self.send_header('Content-Type', 'application/json') self.send_header("Content-Type", "application/json")
self.send_header('Content-Length', str(len(response))) self.send_header("Content-Length", str(len(response)))
self.end_headers() self.end_headers()
self.wfile.write(response) self.wfile.write(response)
@@ -136,7 +145,9 @@ class BridgeAPIHandler(BaseHTTPRequestHandler):
class BridgeAPIServer: class BridgeAPIServer:
"""REST API server for the bridge.""" """REST API server for the bridge."""
def __init__(self, bridge_instance, host: str = '127.0.0.1', port: int = 8080): def __init__(
self, bridge_instance, host: str = "127.0.0.1", port: int = 8080
):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.bridge = bridge_instance self.bridge = bridge_instance
self.host = host self.host = host
@@ -154,11 +165,17 @@ class BridgeAPIServer:
try: try:
self.server = HTTPServer((self.host, self.port), handler_factory) self.server = HTTPServer((self.host, self.port), handler_factory)
self.server_thread = threading.Thread(target=self._serve, daemon=True, name="BridgeAPI") self.server_thread = threading.Thread(
target=self._serve, daemon=True, name="BridgeAPI"
)
self.server_thread.start() self.server_thread.start()
self.logger.info(f"Bridge API server started on http://{self.host}:{self.port}") self.logger.info(
f"Bridge API server started on http://{self.host}:{self.port}"
)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to start API server: {e}", exc_info=True) self.logger.error(
f"Failed to start API server: {e}", exc_info=True
)
def stop(self): def stop(self):
"""Stop the API server.""" """Stop the API server."""
@@ -174,4 +191,3 @@ class BridgeAPIServer:
"""Run the server.""" """Run the server."""
if self.server: if self.server:
self.server.serve_forever() self.server.serve_forever()

View File

@@ -5,105 +5,141 @@ Main Bridge orchestrator class.
import logging import logging
import threading import threading
from queue import Queue
import time import time
from typing import Union, Optional from queue import Queue
from typing import Optional, Union
from .config_handler import BridgeConfig
from .meshtastic_handler import MeshtasticHandler
from .meshcore_handler import MeshcoreHandler
from .mqtt_handler import MQTTHandler
from .metrics import get_metrics
from .health import get_health_monitor, HealthStatus
from .api import BridgeAPIServer from .api import BridgeAPIServer
from .config_handler import BridgeConfig
from .health import HealthStatus, get_health_monitor
from .meshcore_handler import MeshcoreHandler
from .meshtastic_handler import MeshtasticHandler
from .metrics import get_metrics
from .mqtt_handler import MQTTHandler
ExternalHandler = Union[MeshcoreHandler, MQTTHandler] ExternalHandler = Union[MeshcoreHandler, MQTTHandler]
class Bridge: class Bridge:
"""Orchestrates the Meshtastic-External Network bridge operation.""" """Orchestrates the Meshtastic-External Network bridge operation."""
# Attribute annotations for mypy
to_meshtastic_queue: Queue
to_external_queue: Queue
meshtastic_handler: Optional[MeshtasticHandler]
external_handler: Optional[ExternalHandler]
handlers: list[object]
api_server: Optional[BridgeAPIServer]
def __init__(self, config: BridgeConfig): def __init__(self, config: BridgeConfig):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.config = config self.config = config
self.shutdown_event = threading.Event() self.shutdown_event = threading.Event()
self.to_meshtastic_queue = Queue(maxsize=config.queue_size) self.to_meshtastic_queue = Queue(maxsize=config.queue_size)
self.to_external_queue = Queue(maxsize=config.queue_size) self.to_external_queue = Queue(maxsize=config.queue_size)
self.logger.info(f"Message queues initialized with max size: {config.queue_size}") self.logger.info(
"Message queues initialized with max size: %s",
config.queue_size,
)
# Initialize metrics and health monitoring # Initialize metrics and health monitoring
self.metrics = get_metrics() self.metrics = get_metrics()
self.health_monitor = get_health_monitor() self.health_monitor = get_health_monitor()
self.health_monitor.register_component("meshtastic", HealthStatus.UNKNOWN) self.health_monitor.register_component(
self.health_monitor.register_component("external", HealthStatus.UNKNOWN) "meshtastic", HealthStatus.UNKNOWN
)
self.health_monitor.register_component(
"external", HealthStatus.UNKNOWN
)
self.health_monitor.start_monitoring() self.health_monitor.start_monitoring()
# Initialize API server if enabled # Initialize API server if enabled
self.api_server: Optional[BridgeAPIServer] = None self.api_server: Optional[BridgeAPIServer] = None
if config.api_enabled: if config.api_enabled:
self.api_server = BridgeAPIServer(self, host=config.api_host, port=config.api_port) api_host = config.api_host or "127.0.0.1"
api_port = int(config.api_port or 8080)
self.api_server = BridgeAPIServer(
self, host=api_host, port=api_port
)
self.logger.info("Initializing network handlers...") self.logger.info("Initializing network handlers...")
self.meshtastic_handler: Optional[MeshtasticHandler] = None self.meshtastic_handler: Optional[MeshtasticHandler] = None
self.external_handler: Optional[ExternalHandler] = None self.external_handler: Optional[ExternalHandler] = None
self.handlers = [] self.handlers: list[object] = []
try: try:
self.meshtastic_handler = MeshtasticHandler( self.meshtastic_handler = MeshtasticHandler(
config=config, config=config,
to_external_queue=self.to_external_queue, to_external_queue=self.to_external_queue,
from_external_queue=self.to_meshtastic_queue, from_external_queue=self.to_meshtastic_queue,
shutdown_event=self.shutdown_event shutdown_event=self.shutdown_event,
) )
self.handlers.append(self.meshtastic_handler) self.handlers.append(self.meshtastic_handler)
if config.external_transport == 'serial': if config.external_transport == "serial":
self.logger.info("Selected external transport: Serial") self.logger.info("Selected external transport: Serial")
self.external_handler = MeshcoreHandler( self.external_handler = MeshcoreHandler(
config=config, config=config,
to_meshtastic_queue=self.to_meshtastic_queue, to_meshtastic_queue=self.to_meshtastic_queue,
from_meshtastic_queue=self.to_external_queue, from_meshtastic_queue=self.to_external_queue,
shutdown_event=self.shutdown_event shutdown_event=self.shutdown_event,
) )
self.handlers.append(self.external_handler) self.handlers.append(self.external_handler)
elif config.external_transport == 'mqtt': elif config.external_transport == "mqtt":
self.logger.info("Selected external transport: MQTT") self.logger.info("Selected external transport: MQTT")
self.external_handler = MQTTHandler( self.external_handler = MQTTHandler(
config=config, config=config,
to_meshtastic_queue=self.to_meshtastic_queue, to_meshtastic_queue=self.to_meshtastic_queue,
from_meshtastic_queue=self.to_external_queue, from_meshtastic_queue=self.to_external_queue,
shutdown_event=self.shutdown_event shutdown_event=self.shutdown_event,
) )
self.handlers.append(self.external_handler) self.handlers.append(self.external_handler)
else: else:
raise ValueError(f"Invalid external_transport configured: {config.external_transport}") raise ValueError(
"Invalid external_transport configured: "
f"{config.external_transport}"
)
self.logger.info(f"All required handlers initialized successfully.") self.logger.info(
"All required handlers initialized successfully."
)
except (ValueError, Exception) as e: except (ValueError, Exception) as e:
self.logger.critical(f"Failed to initialize handlers: {e}. Bridge cannot start.", exc_info=True) self.logger.critical(
self.stop() "Failed to initialize handlers: %s. Bridge cannot start.",
self.external_handler = None e,
exc_info=True,
)
self.stop()
self.external_handler = None
def run(self): def run(self):
self.logger.info("Starting AMMB run sequence...") self.logger.info("Starting AMMB run sequence...")
if not self.meshtastic_handler or not self.external_handler: if not self.meshtastic_handler or not self.external_handler:
self.logger.error("One or more handlers failed to initialize. Bridge cannot run.") self.logger.error(
self.stop() "One or more handlers failed to initialize. Bridge cannot run."
return )
self.stop()
return
self.logger.info("Attempting initial network connections...") self.logger.info("Attempting initial network connections...")
if not self.meshtastic_handler.connect(): if not self.meshtastic_handler.connect():
self.logger.critical("Failed to connect to Meshtastic device on startup. Bridge cannot start.") self.logger.critical(
"Failed to connect to Meshtastic device on startup. "
"Bridge cannot start."
)
self.stop() self.stop()
return return
if not self.external_handler.connect(): if not self.external_handler.connect():
handler_type = type(self.external_handler).__name__ handler_type = type(self.external_handler).__name__
self.logger.warning(f"Failed to initiate connection for {handler_type} initially. Handler will keep trying in background.") self.logger.warning(
"Failed to initiate connection for %s initially. "
"Handler will keep trying in background.",
handler_type,
)
self.logger.info("Starting handler background tasks/threads...") self.logger.info("Starting handler background tasks/threads...")
try: try:
self.meshtastic_handler.start_sender() self.meshtastic_handler.start_sender()
@@ -113,29 +149,40 @@ class Bridge:
self.external_handler.start_publisher() self.external_handler.start_publisher()
except Exception as e: except Exception as e:
self.logger.critical(f"Failed to start handler background tasks: {e}", exc_info=True) self.logger.critical(
self.stop() "Failed to start handler background tasks: %s",
return e,
exc_info=True,
)
self.stop()
return
# Start API server if enabled # Start API server if enabled
if self.api_server: if self.api_server:
self.api_server.start() self.api_server.start()
self.logger.info("Bridge background tasks started. Running... (Press Ctrl+C to stop)") self.logger.info(
"Bridge background tasks started. Running... "
"(Press Ctrl+C to stop)"
)
try: try:
while not self.shutdown_event.is_set(): while not self.shutdown_event.is_set():
time.sleep(1) time.sleep(1)
except Exception as e: except Exception as e:
self.logger.critical(f"Unexpected error in main bridge loop: {e}", exc_info=True) self.logger.critical(
f"Unexpected error in main bridge loop: {e}", exc_info=True
)
finally: finally:
self.logger.info("Main loop exiting. Initiating shutdown sequence...") self.logger.info(
self.stop() "Main loop exiting. Initiating shutdown sequence..."
)
self.stop()
def stop(self): def stop(self):
if self.shutdown_event.is_set(): if self.shutdown_event.is_set():
return return
self.logger.info("Signaling shutdown to all components...") self.logger.info("Signaling shutdown to all components...")
self.shutdown_event.set() self.shutdown_event.set()
@@ -149,9 +196,11 @@ class Bridge:
self.logger.info(f"Stopping {len(self.handlers)} handlers...") self.logger.info(f"Stopping {len(self.handlers)} handlers...")
for handler in reversed(self.handlers): for handler in reversed(self.handlers):
try: try:
handler.stop() handler.stop()
except Exception as e: except Exception as e:
self.logger.error(f"Error stopping handler: {e}", exc_info=True) self.logger.error(
f"Error stopping handler: {e}", exc_info=True
)
self.logger.info("Bridge shutdown sequence complete.") self.logger.info("Bridge shutdown sequence complete.")

View File

@@ -6,15 +6,17 @@ Handles loading, validation, and access for the bridge configuration.
import configparser import configparser
import logging import logging
import os import os
from typing import NamedTuple, Optional, Literal from typing import Literal, NamedTuple, Optional, cast
class BridgeConfig(NamedTuple): class BridgeConfig(NamedTuple):
"""Stores all configuration settings for the bridge.""" """Stores all configuration settings for the bridge."""
# Meshtastic Settings # Meshtastic Settings
meshtastic_port: str meshtastic_port: str
# External Network Interface Settings # External Network Interface Settings
external_transport: Literal['serial', 'mqtt'] external_transport: Literal["serial", "mqtt"]
# Serial Specific (Optional) # Serial Specific (Optional)
serial_port: Optional[str] serial_port: Optional[str]
@@ -37,91 +39,122 @@ class BridgeConfig(NamedTuple):
bridge_node_id: str bridge_node_id: str
queue_size: int queue_size: int
log_level: str log_level: str
# API Settings (Optional) # API Settings (Optional)
api_enabled: Optional[bool] = False api_enabled: Optional[bool] = False
api_host: Optional[str] = '127.0.0.1' api_host: Optional[str] = "127.0.0.1"
api_port: Optional[int] = 8080 api_port: Optional[int] = 8080
# MQTT TLS Settings (Optional) # MQTT TLS Settings (Optional)
mqtt_tls_enabled: Optional[bool] = False mqtt_tls_enabled: Optional[bool] = False
mqtt_tls_ca_certs: Optional[str] = None mqtt_tls_ca_certs: Optional[str] = None
mqtt_tls_insecure: Optional[bool] = False mqtt_tls_insecure: Optional[bool] = False
CONFIG_FILE = "config.ini" CONFIG_FILE = "config.ini"
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
'MESHTASTIC_SERIAL_PORT': '/dev/ttyUSB0', "MESHTASTIC_SERIAL_PORT": "/dev/ttyUSB0",
'EXTERNAL_TRANSPORT': 'serial', "EXTERNAL_TRANSPORT": "serial",
'SERIAL_PORT': '/dev/ttyS0', "SERIAL_PORT": "/dev/ttyS0",
'SERIAL_BAUD_RATE': '9600', "SERIAL_BAUD_RATE": "9600",
'SERIAL_PROTOCOL': 'json_newline', "SERIAL_PROTOCOL": "json_newline",
'MQTT_BROKER': 'localhost', "MQTT_BROKER": "localhost",
'MQTT_PORT': '1883', "MQTT_PORT": "1883",
'MQTT_TOPIC_IN': 'ammb/to_meshtastic', "MQTT_TOPIC_IN": "ammb/to_meshtastic",
'MQTT_TOPIC_OUT': 'ammb/from_meshtastic', "MQTT_TOPIC_OUT": "ammb/from_meshtastic",
'MQTT_USERNAME': '', "MQTT_USERNAME": "",
'MQTT_PASSWORD': '', "MQTT_PASSWORD": "",
'MQTT_CLIENT_ID': 'ammb_bridge_client', "MQTT_CLIENT_ID": "ammb_bridge_client",
'MQTT_QOS': '0', "MQTT_QOS": "0",
'MQTT_RETAIN_OUT': 'False', "MQTT_RETAIN_OUT": "False",
'EXTERNAL_NETWORK_ID': 'default_external_net', "EXTERNAL_NETWORK_ID": "default_external_net",
'BRIDGE_NODE_ID': '!ammb_bridge', "BRIDGE_NODE_ID": "!ammb_bridge",
'MESSAGE_QUEUE_SIZE': '100', "MESSAGE_QUEUE_SIZE": "100",
'LOG_LEVEL': 'INFO', "LOG_LEVEL": "INFO",
'API_ENABLED': 'False', "API_ENABLED": "False",
'API_HOST': '127.0.0.1', "API_HOST": "127.0.0.1",
'API_PORT': '8080', "API_PORT": "8080",
'MQTT_TLS_ENABLED': 'False', "MQTT_TLS_ENABLED": "False",
'MQTT_TLS_CA_CERTS': '', "MQTT_TLS_CA_CERTS": "",
'MQTT_TLS_INSECURE': 'False', "MQTT_TLS_INSECURE": "False",
} }
VALID_LOG_LEVELS = {'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'} VALID_LOG_LEVELS = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"}
VALID_SERIAL_PROTOCOLS = {'json_newline', 'raw_serial'} VALID_SERIAL_PROTOCOLS = {"json_newline", "raw_serial"}
VALID_TRANSPORTS = {'serial', 'mqtt'} VALID_TRANSPORTS = {"serial", "mqtt"}
VALID_MQTT_QOS = {0, 1, 2} VALID_MQTT_QOS = {0, 1, 2}
def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]: def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
""" """
Loads and validates configuration from the specified INI file. Loads and validates configuration from the specified INI file.
""" """
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
config = configparser.ConfigParser(defaults=DEFAULT_CONFIG, interpolation=None) config = configparser.ConfigParser(
defaults=DEFAULT_CONFIG, interpolation=None
)
if not os.path.exists(config_path): if not os.path.exists(config_path):
logger.error(f"Configuration file not found: {config_path}") logger.error("Configuration file not found: %s", config_path)
logger.error("Please copy 'examples/config.ini.example' to 'config.ini' and configure it.") logger.error(
"Please copy 'examples/config.ini.example' to 'config.ini' "
"and configure it."
)
return None return None
try: try:
logger.info(f"Reading configuration from: {config_path}") logger.info("Reading configuration from: %s", config_path)
config.read(config_path) config.read(config_path)
if 'DEFAULT' not in config.sections(): if "DEFAULT" not in config.sections():
logger.warning(f"Configuration file '{config_path}' lacks the [DEFAULT] section. Using only defaults.") logger.warning(
cfg_section = config['DEFAULT'] "Configuration file '%s' lacks the [DEFAULT] section.",
config_path,
)
logger.warning("Using only defaults.")
cfg_section = config["DEFAULT"]
meshtastic_port = cfg_section.get(
"MESHTASTIC_SERIAL_PORT",
fallback=DEFAULT_CONFIG["MESHTASTIC_SERIAL_PORT"],
)
external_network_id = cfg_section.get(
"EXTERNAL_NETWORK_ID",
fallback=DEFAULT_CONFIG["EXTERNAL_NETWORK_ID"],
)
bridge_node_id = cfg_section.get(
"BRIDGE_NODE_ID", fallback=DEFAULT_CONFIG["BRIDGE_NODE_ID"]
)
log_level = cfg_section.get(
"LOG_LEVEL", fallback=DEFAULT_CONFIG["LOG_LEVEL"]
).upper()
meshtastic_port = cfg_section.get('MESHTASTIC_SERIAL_PORT', fallback=DEFAULT_CONFIG['MESHTASTIC_SERIAL_PORT'])
external_network_id = cfg_section.get('EXTERNAL_NETWORK_ID', fallback=DEFAULT_CONFIG['EXTERNAL_NETWORK_ID'])
bridge_node_id = cfg_section.get('BRIDGE_NODE_ID', fallback=DEFAULT_CONFIG['BRIDGE_NODE_ID'])
log_level = cfg_section.get('LOG_LEVEL', fallback=DEFAULT_CONFIG['LOG_LEVEL']).upper()
if log_level not in VALID_LOG_LEVELS: if log_level not in VALID_LOG_LEVELS:
logger.error(f"Invalid LOG_LEVEL '{log_level}'. Must be one of: {VALID_LOG_LEVELS}") logger.error(
"Invalid LOG_LEVEL '%s'. Must be one of: %s",
log_level,
VALID_LOG_LEVELS,
)
return None return None
try: try:
queue_size = cfg_section.getint('MESSAGE_QUEUE_SIZE') queue_size = cfg_section.getint("MESSAGE_QUEUE_SIZE")
if queue_size <= 0: if queue_size is None or queue_size <= 0:
raise ValueError("Queue size must be positive.") raise ValueError("Queue size must be positive.")
except ValueError as e: except ValueError as e:
logger.error(f"Invalid integer value for MESSAGE_QUEUE_SIZE: {e}") logger.error("Invalid integer value for MESSAGE_QUEUE_SIZE: %s", e)
return None return None
external_transport = cfg_section.get('EXTERNAL_TRANSPORT', fallback=DEFAULT_CONFIG['EXTERNAL_TRANSPORT']).lower() external_transport = cfg_section.get(
"EXTERNAL_TRANSPORT", fallback=DEFAULT_CONFIG["EXTERNAL_TRANSPORT"]
).lower()
if external_transport not in VALID_TRANSPORTS: if external_transport not in VALID_TRANSPORTS:
logger.error(f"Invalid EXTERNAL_TRANSPORT '{external_transport}'. Must be one of: {VALID_TRANSPORTS}") logger.error(
"Invalid EXTERNAL_TRANSPORT '%s'. Must be one of: %s",
external_transport,
VALID_TRANSPORTS,
)
return None return None
serial_port = None serial_port = None
@@ -137,73 +170,109 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
mqtt_qos = None mqtt_qos = None
mqtt_retain_out = None mqtt_retain_out = None
if external_transport == 'serial': if external_transport == "serial":
serial_port = cfg_section.get('SERIAL_PORT', fallback=DEFAULT_CONFIG['SERIAL_PORT']) serial_port = cfg_section.get(
serial_protocol = cfg_section.get('SERIAL_PROTOCOL', fallback=DEFAULT_CONFIG['SERIAL_PROTOCOL']).lower() "SERIAL_PORT", fallback=DEFAULT_CONFIG["SERIAL_PORT"]
)
serial_protocol = cfg_section.get(
"SERIAL_PROTOCOL", fallback=DEFAULT_CONFIG["SERIAL_PROTOCOL"]
).lower()
if not serial_port: if not serial_port:
logger.error("SERIAL_PORT must be set when EXTERNAL_TRANSPORT is 'serial'.") logger.error(
return None "SERIAL_PORT must be set when EXTERNAL_TRANSPORT "
"is 'serial'."
)
return None
if serial_protocol not in VALID_SERIAL_PROTOCOLS: if serial_protocol not in VALID_SERIAL_PROTOCOLS:
logger.warning( logger.warning(
f"Unrecognized SERIAL_PROTOCOL '{serial_protocol}'. " "Unrecognized SERIAL_PROTOCOL '%s'."
f"Valid built-in options are: {VALID_SERIAL_PROTOCOLS}. " " Valid options: %s.",
f"Attempting to use '{serial_protocol}' - ensure a corresponding handler exists." serial_protocol,
) VALID_SERIAL_PROTOCOLS,
)
logger.warning(
"Attempting to use '%s' - ensure a corresponding "
"handler exists.",
serial_protocol,
)
try: try:
serial_baud = cfg_section.getint('SERIAL_BAUD_RATE') serial_baud = cfg_section.getint("SERIAL_BAUD_RATE")
if serial_baud <= 0: if serial_baud is None or serial_baud <= 0:
raise ValueError("Serial baud rate must be positive.") raise ValueError("Serial baud rate must be positive.")
except ValueError as e: except ValueError as e:
logger.error(f"Invalid integer value for SERIAL_BAUD_RATE: {e}") logger.error(
"Invalid integer value for SERIAL_BAUD_RATE: %s",
e,
)
return None return None
elif external_transport == 'mqtt': elif external_transport == "mqtt":
mqtt_broker = cfg_section.get('MQTT_BROKER') mqtt_broker = cfg_section.get("MQTT_BROKER")
mqtt_topic_in = cfg_section.get('MQTT_TOPIC_IN') mqtt_topic_in = cfg_section.get("MQTT_TOPIC_IN")
mqtt_topic_out = cfg_section.get('MQTT_TOPIC_OUT') mqtt_topic_out = cfg_section.get("MQTT_TOPIC_OUT")
mqtt_username = cfg_section.get('MQTT_USERNAME') mqtt_username = cfg_section.get("MQTT_USERNAME")
mqtt_password = cfg_section.get('MQTT_PASSWORD') mqtt_password = cfg_section.get("MQTT_PASSWORD")
mqtt_client_id = cfg_section.get('MQTT_CLIENT_ID') mqtt_client_id = cfg_section.get("MQTT_CLIENT_ID")
if not mqtt_broker or not mqtt_topic_in or not mqtt_topic_out: if not mqtt_broker or not mqtt_topic_in or not mqtt_topic_out:
logger.error("MQTT_BROKER, MQTT_TOPIC_IN, and MQTT_TOPIC_OUT must be set when EXTERNAL_TRANSPORT is 'mqtt'.") logger.error(
return None "MQTT_BROKER, MQTT_TOPIC_IN and "
"MQTT_TOPIC_OUT must be set."
)
return None
if not mqtt_client_id: if not mqtt_client_id:
logger.warning("MQTT_CLIENT_ID is empty. Using default.") logger.warning(
mqtt_client_id = DEFAULT_CONFIG['MQTT_CLIENT_ID'] "MQTT_CLIENT_ID empty. Using default."
)
mqtt_client_id = DEFAULT_CONFIG["MQTT_CLIENT_ID"]
try: try:
mqtt_port = cfg_section.getint('MQTT_PORT') mqtt_port = cfg_section.getint("MQTT_PORT")
mqtt_qos = cfg_section.getint('MQTT_QOS') mqtt_qos = cfg_section.getint("MQTT_QOS")
mqtt_retain_out = cfg_section.getboolean('MQTT_RETAIN_OUT') mqtt_retain_out = cfg_section.getboolean("MQTT_RETAIN_OUT")
if mqtt_port <= 0 or mqtt_port > 65535: if mqtt_port is None or mqtt_port <= 0 or mqtt_port > 65535:
raise ValueError("MQTT port must be between 1 and 65535.") raise ValueError("MQTT port must be between 1 and 65535.")
if mqtt_qos not in VALID_MQTT_QOS: if mqtt_qos is None or mqtt_qos not in VALID_MQTT_QOS:
raise ValueError(f"MQTT_QOS must be one of {VALID_MQTT_QOS}.") msg = "MQTT_QOS must be one of %s" % (VALID_MQTT_QOS,)
raise ValueError(msg)
except ValueError as e: except ValueError as e:
logger.error(f"Invalid integer/boolean value in MQTT configuration: {e}") logger.error(
"Invalid integer/boolean value in MQTT configuration: %s",
e,
)
return None return None
# Parse API settings # Parse API settings
api_enabled = cfg_section.getboolean('API_ENABLED', fallback=False) api_enabled = cfg_section.getboolean("API_ENABLED", fallback=False)
api_host = cfg_section.get('API_HOST', fallback='127.0.0.1') api_host = cfg_section.get("API_HOST", fallback="127.0.0.1")
try: try:
api_port = cfg_section.getint('API_PORT', fallback=8080) api_port = cfg_section.getint("API_PORT", fallback=8080)
if api_port <= 0 or api_port > 65535: if api_port <= 0 or api_port > 65535:
logger.warning(f"Invalid API_PORT {api_port}, using default 8080") logger.warning(
"Invalid API_PORT %s, using default 8080",
api_port,
)
api_port = 8080 api_port = 8080
except ValueError: except ValueError:
logger.warning("Invalid API_PORT, using default 8080") logger.warning("Invalid API_PORT, using default 8080")
api_port = 8080 api_port = 8080
# Parse MQTT TLS settings # Parse MQTT TLS settings
mqtt_tls_enabled = cfg_section.getboolean('MQTT_TLS_ENABLED', fallback=False) mqtt_tls_enabled = cfg_section.getboolean(
mqtt_tls_ca_certs = cfg_section.get('MQTT_TLS_CA_CERTS', fallback='').strip() or None "MQTT_TLS_ENABLED", fallback=False
mqtt_tls_insecure = cfg_section.getboolean('MQTT_TLS_INSECURE', fallback=False) )
mqtt_tls_ca_certs = (
cfg_section.get("MQTT_TLS_CA_CERTS", fallback="").strip() or None
)
mqtt_tls_insecure = cfg_section.getboolean(
"MQTT_TLS_INSECURE", fallback=False
)
bridge_config = BridgeConfig( bridge_config = BridgeConfig(
meshtastic_port=meshtastic_port, meshtastic_port=meshtastic_port,
external_transport=external_transport, external_transport=cast(
Literal["serial", "mqtt"], external_transport
),
serial_port=serial_port, serial_port=serial_port,
serial_baud=serial_baud, serial_baud=serial_baud,
serial_protocol=serial_protocol, serial_protocol=serial_protocol,
@@ -218,7 +287,7 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
mqtt_retain_out=mqtt_retain_out, mqtt_retain_out=mqtt_retain_out,
external_network_id=external_network_id, external_network_id=external_network_id,
bridge_node_id=bridge_node_id, bridge_node_id=bridge_node_id,
queue_size=queue_size, queue_size=int(queue_size),
log_level=log_level, log_level=log_level,
api_enabled=api_enabled, api_enabled=api_enabled,
api_host=api_host, api_host=api_host,
@@ -227,9 +296,13 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
mqtt_tls_ca_certs=mqtt_tls_ca_certs, mqtt_tls_ca_certs=mqtt_tls_ca_certs,
mqtt_tls_insecure=mqtt_tls_insecure, mqtt_tls_insecure=mqtt_tls_insecure,
) )
logger.debug(f"Configuration loaded: {bridge_config}") logger.debug("Configuration loaded: %s", bridge_config)
return bridge_config return bridge_config
except Exception as e: except Exception as e:
logger.error(f"Unexpected error loading configuration: {e}", exc_info=True) logger.error(
"Unexpected error loading configuration: %s",
e,
exc_info=True,
)
return None return None

View File

@@ -6,14 +6,15 @@ Health monitoring and status checking for the bridge.
import logging import logging
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from typing import Dict, Optional, List from typing import Dict, Optional
class HealthStatus(Enum): class HealthStatus(Enum):
"""Health status levels.""" """Health status levels."""
HEALTHY = "healthy" HEALTHY = "healthy"
DEGRADED = "degraded" DEGRADED = "degraded"
UNHEALTHY = "unhealthy" UNHEALTHY = "unhealthy"
@@ -23,11 +24,12 @@ class HealthStatus(Enum):
@dataclass @dataclass
class ComponentHealth: class ComponentHealth:
"""Health status for a component.""" """Health status for a component."""
name: str name: str
status: HealthStatus status: HealthStatus
last_check: datetime last_check: datetime
message: str = "" message: str = ""
details: Dict = None details: Dict = field(default_factory=dict)
def __post_init__(self): def __post_init__(self):
if self.details is None: if self.details is None:
@@ -55,7 +57,9 @@ class HealthMonitor:
self._monitoring = False self._monitoring = False
self._monitor_thread: Optional[threading.Thread] = None self._monitor_thread: Optional[threading.Thread] = None
def register_component(self, name: str, initial_status: HealthStatus = HealthStatus.UNKNOWN): def register_component(
self, name: str, initial_status: HealthStatus = HealthStatus.UNKNOWN
):
"""Register a component for health monitoring.""" """Register a component for health monitoring."""
with self._lock: with self._lock:
self.components[name] = ComponentHealth( self.components[name] = ComponentHealth(
@@ -64,7 +68,13 @@ class HealthMonitor:
last_check=datetime.now(), last_check=datetime.now(),
) )
def update_component(self, name: str, status: HealthStatus, message: str = "", details: Optional[Dict] = None): def update_component(
self,
name: str,
status: HealthStatus,
message: str = "",
details: Optional[Dict] = None,
):
"""Update the health status of a component.""" """Update the health status of a component."""
with self._lock: with self._lock:
if name in self.components: if name in self.components:
@@ -72,9 +82,18 @@ class HealthMonitor:
self.components[name].last_check = datetime.now() self.components[name].last_check = datetime.now()
self.components[name].message = message self.components[name].message = message
if details: if details:
# Ensure details is a dict.
# Dataclass __post_init__ should set it.
# Be defensive if None.
if self.components[name].details is None:
self.components[name].details = {}
self.components[name].details.update(details) self.components[name].details.update(details)
else: else:
self.logger.warning(f"Component {name} not registered for health monitoring") self.logger.warning(
"Component %s not registered for health "
"monitoring",
name,
)
def get_component_health(self, name: str) -> Optional[ComponentHealth]: def get_component_health(self, name: str) -> Optional[ComponentHealth]:
"""Get health status for a specific component.""" """Get health status for a specific component."""
@@ -92,8 +111,8 @@ class HealthMonitor:
} }
statuses = [comp.status for comp in self.components.values()] statuses = [comp.status for comp in self.components.values()]
# Determine overall status # Determine overall status from component states
if HealthStatus.UNHEALTHY in statuses: if HealthStatus.UNHEALTHY in statuses:
overall = HealthStatus.UNHEALTHY overall = HealthStatus.UNHEALTHY
elif HealthStatus.DEGRADED in statuses: elif HealthStatus.DEGRADED in statuses:
@@ -103,10 +122,14 @@ class HealthMonitor:
else: else:
overall = HealthStatus.UNKNOWN overall = HealthStatus.UNKNOWN
components = {
name: comp.to_dict()
for name, comp in self.components.items()
}
return { return {
"status": overall.value, "status": overall.value,
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"components": {name: comp.to_dict() for name, comp in self.components.items()}, "components": components,
} }
def start_monitoring(self): def start_monitoring(self):
@@ -115,7 +138,9 @@ class HealthMonitor:
return return
self._monitoring = True self._monitoring = True
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True, name="HealthMonitor") self._monitor_thread = threading.Thread(
target=self._monitor_loop, daemon=True, name="HealthMonitor"
)
self._monitor_thread.start() self._monitor_thread.start()
self.logger.info("Health monitoring started") self.logger.info("Health monitoring started")
@@ -138,13 +163,20 @@ class HealthMonitor:
for name, comp in list(self.components.items()): for name, comp in list(self.components.items()):
if (now - comp.last_check) > stale_threshold: if (now - comp.last_check) > stale_threshold:
if comp.status != HealthStatus.UNHEALTHY: if comp.status != HealthStatus.UNHEALTHY:
self.logger.warning(f"Component {name} health check is stale") self.logger.warning(
"Component %s health check is stale",
name,
)
comp.status = HealthStatus.DEGRADED comp.status = HealthStatus.DEGRADED
comp.message = "Health check stale - no recent updates" comp.message = (
"Health check stale; no recent updates"
)
time.sleep(self.check_interval) time.sleep(self.check_interval)
except Exception as e: except Exception as e:
self.logger.error(f"Error in health monitor loop: {e}", exc_info=True) self.logger.error(
"Error in health monitor loop: %s", e, exc_info=True
)
time.sleep(self.check_interval) time.sleep(self.check_interval)
@@ -160,4 +192,3 @@ def get_health_monitor() -> HealthMonitor:
if _health_monitor is None: if _health_monitor is None:
_health_monitor = HealthMonitor() _health_monitor = HealthMonitor()
return _health_monitor return _health_monitor

View File

@@ -3,105 +3,167 @@
Handles interactions with an external device via a **Serial** port. Handles interactions with an external device via a **Serial** port.
""" """
import json
import logging import logging
import threading import threading
import time import time
import json from queue import Empty, Full, Queue
from queue import Queue, Empty, Full from typing import Any, Dict, Optional
from typing import Optional, Dict, Any
import serial import serial
from .config_handler import BridgeConfig from .config_handler import BridgeConfig
from .protocol import MeshcoreProtocolHandler, get_serial_protocol_handler from .health import HealthStatus, get_health_monitor
from .metrics import get_metrics from .metrics import get_metrics
from .health import get_health_monitor, HealthStatus from .protocol import MeshcoreProtocolHandler, get_serial_protocol_handler
from .validator import MessageValidator
from .rate_limiter import RateLimiter from .rate_limiter import RateLimiter
from .validator import MessageValidator
class MeshcoreHandler: class MeshcoreHandler:
"""Manages Serial connection and communication with an external device.""" """Manages Serial connection and communication with an external device."""
RECONNECT_DELAY_S = 10
def __init__(self, config: BridgeConfig, to_meshtastic_queue: Queue, from_meshtastic_queue: Queue, shutdown_event: threading.Event): RECONNECT_DELAY_S = 10
def __init__(
self,
config: BridgeConfig,
to_meshtastic_queue: Queue,
from_meshtastic_queue: Queue,
shutdown_event: threading.Event,
):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.config = config self.config = config
self.to_meshtastic_queue = to_meshtastic_queue self.to_meshtastic_queue = to_meshtastic_queue
self.to_serial_queue = from_meshtastic_queue self.to_serial_queue = from_meshtastic_queue
self.shutdown_event = shutdown_event self.shutdown_event = shutdown_event
self.serial_port: Optional[serial.Serial] = None self.serial_port: Optional[serial.Serial] = None
self.receiver_thread: Optional[threading.Thread] = None self.receiver_thread: Optional[threading.Thread] = None
self.sender_thread: Optional[threading.Thread] = None self.sender_thread: Optional[threading.Thread] = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._is_connected = threading.Event() self._is_connected = threading.Event()
# Initialize metrics, health, validator, and rate limiter # Initialize metrics, health, validator, and rate limiter
self.metrics = get_metrics() self.metrics = get_metrics()
self.health_monitor = get_health_monitor() self.health_monitor = get_health_monitor()
self.validator = MessageValidator() self.validator = MessageValidator()
self.rate_limiter = RateLimiter(max_messages=60, time_window=60.0) self.rate_limiter = RateLimiter(max_messages=60, time_window=60.0)
if not config.serial_port or not config.serial_baud or not config.serial_protocol: if (
raise ValueError("Serial transport selected, but required SERIAL configuration options are missing.") not config.serial_port
or not config.serial_baud
or not config.serial_protocol
):
raise ValueError(
"Serial transport selected, but required SERIAL "
"configuration options are missing."
)
try: try:
self.protocol_handler: MeshcoreProtocolHandler = get_serial_protocol_handler(config.serial_protocol) self.protocol_handler: MeshcoreProtocolHandler = (
get_serial_protocol_handler(config.serial_protocol)
)
except ValueError as e: except ValueError as e:
self.logger.critical(f"Failed to initialize serial protocol handler '{config.serial_protocol}': {e}.") self.logger.critical(
"Failed to initialize serial protocol handler '%s': %s.",
config.serial_protocol,
e,
)
class DummyHandler(MeshcoreProtocolHandler): class DummyHandler(MeshcoreProtocolHandler):
def read(self, port): return None def read(self, port):
def encode(self, data): return None return None
def decode(self, line): return None
def encode(self, data):
return None
def decode(self, line):
return None
self.protocol_handler = DummyHandler() self.protocol_handler = DummyHandler()
self.logger.info("Serial Handler (MeshcoreHandler) Initialized.") self.logger.info("Serial Handler (MeshcoreHandler) Initialized.")
def connect(self) -> bool: def connect(self) -> bool:
with self._lock: with self._lock:
if self.serial_port and self.serial_port.is_open: if self.serial_port and self.serial_port.is_open:
self.logger.info(f"Serial port {self.config.serial_port} already connected.") self.logger.info(
self._is_connected.set() "Serial port %s already connected.",
self.config.serial_port,
)
self._is_connected.set()
return True return True
try: try:
self.logger.info(f"Attempting connection to Serial device on {self.config.serial_port} at {self.config.serial_baud} baud...") self.logger.info(
"Connecting to Serial device %s at %s baud...",
self.config.serial_port,
self.config.serial_baud,
)
self._is_connected.clear() self._is_connected.clear()
if self.serial_port: if self.serial_port:
try: try:
self.serial_port.close() self.serial_port.close()
except Exception: pass except Exception:
pass
# Ensure baud rate is available for typing and runtime
assert self.config.serial_baud is not None
self.serial_port = serial.Serial( self.serial_port = serial.Serial(
port=self.config.serial_port, port=self.config.serial_port,
baudrate=self.config.serial_baud, baudrate=int(self.config.serial_baud),
timeout=1, timeout=1,
) )
if self.serial_port.is_open: if self.serial_port.is_open:
self.logger.info(f"Connected to Serial device on {self.config.serial_port}") self.logger.info(
"Connected to Serial device on %s",
self.config.serial_port,
)
self._is_connected.set() self._is_connected.set()
self.metrics.record_external_connection() self.metrics.record_external_connection()
self.health_monitor.update_component("external", HealthStatus.HEALTHY, "Serial connected") self.health_monitor.update_component(
"external", HealthStatus.HEALTHY, "Serial connected"
)
return True return True
else: else:
self.logger.error(f"Failed to open serial port {self.config.serial_port}, but no exception was raised.") self.logger.error(
"Failed to open serial port %s; no exception raised.",
self.config.serial_port,
)
self.serial_port = None self.serial_port = None
self._is_connected.clear() self._is_connected.clear()
return False return False
except serial.SerialException as e: except serial.SerialException as e:
self.logger.error(f"Serial error connecting to device {self.config.serial_port}: {e}") self.logger.error(
"Serial error connecting to device %s: %s",
self.config.serial_port,
e,
)
self.serial_port = None self.serial_port = None
self._is_connected.clear() self._is_connected.clear()
self.metrics.record_external_disconnection() self.metrics.record_external_disconnection()
self.health_monitor.update_component("external", HealthStatus.UNHEALTHY, f"Connection failed: {e}") self.health_monitor.update_component(
"external",
HealthStatus.UNHEALTHY,
f"Connection failed: {e}",
)
return False return False
except Exception as e: except Exception as e:
self.logger.error(f"Unexpected error connecting to serial device: {e}", exc_info=True) self.logger.error(
"Unexpected error connecting to serial device: %s",
e,
exc_info=True,
)
self.serial_port = None self.serial_port = None
self._is_connected.clear() self._is_connected.clear()
self.metrics.record_external_disconnection() self.metrics.record_external_disconnection()
self.health_monitor.update_component("external", HealthStatus.UNHEALTHY, f"Connection error: {e}") self.health_monitor.update_component(
"external",
HealthStatus.UNHEALTHY,
f"Connection error: {e}",
)
return False return False
def start_threads(self): def start_threads(self):
@@ -109,22 +171,30 @@ class MeshcoreHandler:
self.logger.warning("Serial receiver thread already started.") self.logger.warning("Serial receiver thread already started.")
else: else:
self.logger.info("Starting Serial receiver thread...") self.logger.info("Starting Serial receiver thread...")
self.receiver_thread = threading.Thread(target=self._serial_receiver_loop, daemon=True, name="SerialReceiver") self.receiver_thread = threading.Thread(
target=self._serial_receiver_loop,
daemon=True,
name="SerialReceiver",
)
self.receiver_thread.start() self.receiver_thread.start()
if self.sender_thread and self.sender_thread.is_alive(): if self.sender_thread and self.sender_thread.is_alive():
self.logger.warning("Serial sender thread already started.") self.logger.warning("Serial sender thread already started.")
else: else:
self.logger.info("Starting Serial sender thread...") self.logger.info("Starting Serial sender thread...")
self.sender_thread = threading.Thread(target=self._serial_sender_loop, daemon=True, name="SerialSender") self.sender_thread = threading.Thread(
target=self._serial_sender_loop,
daemon=True,
name="SerialSender",
)
self.sender_thread.start() self.sender_thread.start()
def stop(self): def stop(self):
self.logger.info("Stopping Serial handler...") self.logger.info("Stopping Serial handler...")
if self.receiver_thread and self.receiver_thread.is_alive(): if self.receiver_thread and self.receiver_thread.is_alive():
self.receiver_thread.join(timeout=2) self.receiver_thread.join(timeout=2)
if self.sender_thread and self.sender_thread.is_alive(): if self.sender_thread and self.sender_thread.is_alive():
self.sender_thread.join(timeout=5) self.sender_thread.join(timeout=5)
self._close_serial() self._close_serial()
self.logger.info("Serial handler stopped.") self.logger.info("Serial handler stopped.")
@@ -134,64 +204,98 @@ class MeshcoreHandler:
port_name = self.config.serial_port port_name = self.config.serial_port
try: try:
self.serial_port.close() self.serial_port.close()
self.logger.info(f"Serial port {port_name} closed.") self.logger.info("Serial port %s closed.", port_name)
except Exception as e: except Exception as e:
self.logger.error(f"Error closing serial port {port_name}: {e}", exc_info=True) self.logger.error(
"Error closing serial port %s: %s",
port_name,
e,
exc_info=True,
)
finally: finally:
self.serial_port = None self.serial_port = None
self._is_connected.clear() self._is_connected.clear()
self.metrics.record_external_disconnection() self.metrics.record_external_disconnection()
self.health_monitor.update_component("external", HealthStatus.UNHEALTHY, "Disconnected") self.health_monitor.update_component(
"external", HealthStatus.UNHEALTHY, "Disconnected"
)
def _serial_receiver_loop(self): def _serial_receiver_loop(self):
"""Continuously reads from serial using Protocol Handler, translates, and queues.""" """Continuously reads from serial using Protocol Handler, translates,
and queues."""
self.logger.info("Serial receiver loop started.") self.logger.info("Serial receiver loop started.")
while not self.shutdown_event.is_set(): while not self.shutdown_event.is_set():
# --- Connection Check --- # --- Connection Check ---
if not self._is_connected.is_set(): if not self._is_connected.is_set():
self.logger.warning(f"Serial port {self.config.serial_port} not connected. Attempting reconnect...") self.logger.warning(
if self.connect(): "Serial port %s not connected. "
self.logger.info(f"Serial device reconnected successfully on {self.config.serial_port}.") "Attempting reconnect...",
self.config.serial_port,
)
if self.connect():
self.logger.info(
"Serial device reconnected on %s",
self.config.serial_port,
)
else: else:
self.shutdown_event.wait(self.RECONNECT_DELAY_S) self.shutdown_event.wait(self.RECONNECT_DELAY_S)
continue continue
# --- Read and Process Data --- # --- Read and Process Data ---
try: try:
raw_data: Optional[bytes] = None raw_data: Optional[bytes] = None
with self._lock: with self._lock:
if self.serial_port and self.serial_port.is_open: if self.serial_port and self.serial_port.is_open:
# Delegate reading to protocol handler # Delegate reading to protocol handler
raw_data = self.protocol_handler.read(self.serial_port) raw_data = self.protocol_handler.read(self.serial_port)
else: else:
self._is_connected.clear() self._is_connected.clear()
continue continue
if raw_data: if raw_data:
self.logger.debug(f"Serial RAW RX: {raw_data!r}") self.logger.debug("Serial RAW RX: %r", raw_data)
# Decode using the selected protocol handler # Decode using the selected protocol handler
decoded_msg: Optional[Dict[str, Any]] = self.protocol_handler.decode(raw_data) decoded_msg: Optional[Dict[str, Any]] = (
self.protocol_handler.decode(raw_data)
)
if decoded_msg: if decoded_msg:
# Validate message # Validate message
is_valid, error_msg = self.validator.validate_external_message(decoded_msg) is_valid, error_msg = (
self.validator.validate_external_message(
decoded_msg
)
)
if not is_valid: if not is_valid:
self.logger.warning(f"Invalid external message rejected: {error_msg}") self.logger.warning(
"Invalid external message rejected: %s",
error_msg
)
self.metrics.record_error("external") self.metrics.record_error("external")
continue continue
# Check rate limit # Check rate limit
if not self.rate_limiter.check_rate_limit("serial_receiver"): if not self.rate_limiter.check_rate_limit(
self.logger.warning("Rate limit exceeded for Serial receiver") "serial_receiver"
self.metrics.record_rate_limit_violation("serial_receiver") ):
self.logger.warning(
"Rate limit exceeded for Serial receiver"
)
self.metrics.record_rate_limit_violation(
"serial_receiver"
)
continue continue
# Sanitize message # Sanitize message
decoded_msg = self.validator.sanitize_external_message(decoded_msg) decoded_msg = self.validator.sanitize_external_message(
decoded_msg
)
# Basic Translation Logic (Serial -> Meshtastic) # Basic Translation Logic (Serial -> Meshtastic)
dest_meshtastic_id = decoded_msg.get("destination_meshtastic_id") dest_meshtastic_id = decoded_msg.get(
"destination_meshtastic_id"
)
payload = decoded_msg.get("payload") payload = decoded_msg.get("payload")
payload_json = decoded_msg.get("payload_json") payload_json = decoded_msg.get("payload_json")
channel_index = decoded_msg.get("channel_index", 0) channel_index = decoded_msg.get("channel_index", 0)
@@ -204,9 +308,12 @@ class MeshcoreHandler:
try: try:
text_payload_str = json.dumps(payload_json) text_payload_str = json.dumps(payload_json)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
self.logger.error(f"Failed to serialize payload_json: {e}") self.logger.error(
elif payload is not None: "Failed to serialize payload_json: %s",
text_payload_str = str(payload) e
)
elif payload is not None:
text_payload_str = str(payload)
if dest_meshtastic_id and text_payload_str is not None: if dest_meshtastic_id and text_payload_str is not None:
meshtastic_msg = { meshtastic_msg = {
@@ -217,50 +324,83 @@ class MeshcoreHandler:
} }
try: try:
self.to_meshtastic_queue.put_nowait(meshtastic_msg) self.to_meshtastic_queue.put_nowait(
payload_size = len(text_payload_str.encode('utf-8')) if text_payload_str else 0 meshtastic_msg
self.metrics.record_external_received(payload_size) )
self.logger.info(f"Queued message from Serial for Meshtastic node {dest_meshtastic_id}") payload_size = (
len(text_payload_str.encode("utf-8"))
if text_payload_str
else 0
)
self.metrics.record_external_received(
payload_size
)
self.logger.info(
"Queued message for Meshtastic %s",
dest_meshtastic_id,
)
except Full: except Full:
self.logger.warning("Meshtastic send queue is full. Dropping incoming message from Serial.") self.logger.warning(
"Meshtastic queue full; dropping message."
)
self.metrics.record_dropped("external") self.metrics.record_dropped("external")
else: else:
self.logger.warning(f"Serial RX: Decoded message lacks required fields: {decoded_msg}") self.logger.warning(
"Decoded serial message missing fields."
)
self.logger.debug("Decoded: %s", decoded_msg)
else: else:
# No data available from serial, sleep briefly to prevent CPU spin # No data available - sleep briefly to avoid CPU spin
time.sleep(0.1) time.sleep(0.1)
except serial.SerialException as e: except serial.SerialException as e:
self.logger.error(f"Serial error in receiver loop ({self.config.serial_port}): {e}. Attempting to reconnect...") self.logger.error(
self._close_serial() "Serial error in receiver loop (%s): %s",
time.sleep(1) self.config.serial_port,
except Exception as e: e,
self.logger.error(f"Unexpected error in serial_receiver_loop: {e}", exc_info=True) )
self.logger.info("Attempting to reconnect...")
self._close_serial() self._close_serial()
time.sleep(self.RECONNECT_DELAY_S / 2) time.sleep(1)
except Exception as e:
self.logger.error(
"Unexpected error in serial_receiver_loop: %s",
e,
exc_info=True,
)
self._close_serial()
time.sleep(self.RECONNECT_DELAY_S / 2)
self.logger.info("Serial receiver loop stopped.") self.logger.info("Serial receiver loop stopped.")
def _serial_sender_loop(self): def _serial_sender_loop(self):
"""Continuously reads from the queue, encodes, and sends messages via Serial.""" """Continuously reads from the queue, encodes, and sends
messages via Serial."""
self.logger.info("Serial sender loop started.") self.logger.info("Serial sender loop started.")
while not self.shutdown_event.is_set(): while not self.shutdown_event.is_set():
if not self._is_connected.is_set(): if not self._is_connected.is_set():
time.sleep(self.RECONNECT_DELAY_S / 2) time.sleep(self.RECONNECT_DELAY_S / 2)
continue continue
try: try:
item: Optional[Dict[str, Any]] = self.to_serial_queue.get(timeout=1) item: Optional[Dict[str, Any]] = self.to_serial_queue.get(
timeout=1
)
if not item: if not item:
continue continue
encoded_message: Optional[bytes] = self.protocol_handler.encode(item) encoded_message: Optional[bytes] = (
self.protocol_handler.encode(item)
)
if encoded_message: if encoded_message:
# Truncate log for binary safety # Truncate log for binary safety
log_preview = repr(encoded_message[:50]) log_preview = repr(encoded_message[:50])
self.logger.info(f"Serial TX -> Port: {self.config.serial_port}, Payload: {log_preview}") self.logger.info(
"Serial TX port %s payload %s",
self.config.serial_port,
log_preview,
)
send_success = False send_success = False
with self._lock: with self._lock:
@@ -269,30 +409,52 @@ class MeshcoreHandler:
self.serial_port.write(encoded_message) self.serial_port.write(encoded_message)
self.serial_port.flush() self.serial_port.flush()
send_success = True send_success = True
self.metrics.record_external_sent(len(encoded_message)) self.metrics.record_external_sent(
len(encoded_message)
)
except serial.SerialException as e: except serial.SerialException as e:
self.logger.error(f"Serial error during send ({self.config.serial_port}): {e}.") self.logger.error(
self._close_serial() "Serial error during send (%s): %s.",
self.config.serial_port,
e
)
self._close_serial()
except Exception as e: except Exception as e:
self.logger.error(f"Unexpected error sending Serial message: {e}", exc_info=True) self.logger.error(
"Unexpected error sending Serial "
"message: %s",
e,
exc_info=True,
)
else: else:
self.logger.warning(f"Serial port disconnected just before send attempt.") self.logger.warning(
self._is_connected.clear() "Serial port disconnected before send."
)
self._is_connected.clear()
if send_success: if send_success:
self.to_serial_queue.task_done() self.to_serial_queue.task_done()
else: else:
self.logger.error("Failed to send Serial message. Discarding.") self.logger.error(
self.to_serial_queue.task_done() "Failed to send Serial message. Discarding."
)
self.to_serial_queue.task_done()
else: else:
self.logger.error(f"Failed to encode message for Serial: {item}") self.logger.error(
self.to_serial_queue.task_done() "Failed to encode message for Serial: %s",
item
)
self.to_serial_queue.task_done()
except Empty: except Empty:
continue continue
except Exception as e: except Exception as e:
self.logger.error(f"Critical error in serial_sender_loop: {e}", exc_info=True) self.logger.error(
self._is_connected.clear() "Critical error in serial_sender_loop: %s",
time.sleep(5) e,
exc_info=True,
)
self._is_connected.clear()
time.sleep(5)
self.logger.info("Serial sender loop stopped.") self.logger.info("Serial sender loop stopped.")

View File

@@ -6,41 +6,53 @@ Handles all interactions with the Meshtastic device and network.
import logging import logging
import threading import threading
import time import time
from queue import Queue, Empty, Full from queue import Empty, Full, Queue
from typing import Optional, Dict, Any from typing import Any, Dict, Optional
import meshtastic
import meshtastic.serial_interface import meshtastic # type: ignore[import]
import meshtastic.serial_interface # type: ignore[import]
from pubsub import pub from pubsub import pub
import serial
from .config_handler import BridgeConfig from .config_handler import BridgeConfig
from .health import HealthStatus, get_health_monitor
from .metrics import get_metrics from .metrics import get_metrics
from .health import get_health_monitor, HealthStatus
from .validator import MessageValidator
from .rate_limiter import RateLimiter from .rate_limiter import RateLimiter
from .validator import MessageValidator
class MeshtasticHandler: class MeshtasticHandler:
"""Manages connection and communication with the Meshtastic network.""" """Manages connection and communication with the Meshtastic network."""
RECONNECT_DELAY_S = 10 RECONNECT_DELAY_S = 10
def __init__(self, config: BridgeConfig, to_external_queue: Queue, from_external_queue: Queue, shutdown_event: threading.Event): def __init__(
self,
config: BridgeConfig,
to_external_queue: Queue,
from_external_queue: Queue,
shutdown_event: threading.Event,
):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.config = config self.config = config
self.to_external_queue = to_external_queue self.to_external_queue = to_external_queue
self.to_meshtastic_queue = from_external_queue self.to_meshtastic_queue = from_external_queue
self.shutdown_event = shutdown_event self.shutdown_event = shutdown_event
self.interface: Optional[meshtastic.serial_interface.SerialInterface] = None self.interface: Optional[
meshtastic.serial_interface.SerialInterface
] = None
self.my_node_id: Optional[str] = None self.my_node_id: Optional[str] = None
self.sender_thread: Optional[threading.Thread] = None self.sender_thread: Optional[threading.Thread] = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._is_connected = threading.Event() self._is_connected = threading.Event()
# Initialize metrics, health, validator, and rate limiter # Initialize metrics, health, validator, and rate limiter
self.metrics = get_metrics() self.metrics = get_metrics()
self.health_monitor = get_health_monitor() self.health_monitor = get_health_monitor()
self.validator = MessageValidator() self.validator = MessageValidator()
self.rate_limiter = RateLimiter(max_messages=60, time_window=60.0) # 60 messages per minute self.rate_limiter = RateLimiter(
max_messages=60, time_window=60.0
) # 60 messages per minute
def connect(self) -> bool: def connect(self) -> bool:
with self._lock: with self._lock:
@@ -48,12 +60,17 @@ class MeshtasticHandler:
return True return True
try: try:
self.logger.info(f"Attempting connection to Meshtastic on {self.config.meshtastic_port}...") self.logger.info(
"Attempting connection to Meshtastic on %s...",
self.config.meshtastic_port,
)
self._is_connected.clear() self._is_connected.clear()
self.my_node_id = None self.my_node_id = None
if self.interface: if self.interface:
try: self.interface.close() try:
except Exception: pass self.interface.close()
except Exception:
pass
self.interface = meshtastic.serial_interface.SerialInterface( self.interface = meshtastic.serial_interface.SerialInterface(
self.config.meshtastic_port self.config.meshtastic_port
@@ -61,111 +78,170 @@ class MeshtasticHandler:
my_info = self.interface.getMyNodeInfo() my_info = self.interface.getMyNodeInfo()
retry_count = 0 retry_count = 0
while (not my_info or 'num' not in my_info) and retry_count < 3: while (
time.sleep(2) not my_info or "num" not in my_info
my_info = self.interface.getMyNodeInfo() ) and retry_count < 3:
retry_count += 1 time.sleep(2)
my_info = self.interface.getMyNodeInfo()
retry_count += 1
if my_info and 'num' in my_info: if my_info and "num" in my_info:
self.my_node_id = f"!{my_info['num']:x}" self.my_node_id = f"!{my_info['num']:x}"
user_id = my_info.get('user', {}).get('id', 'N/A') user_id = my_info.get("user", {}).get("id", "N/A")
self.logger.info(f"Connected to Meshtastic device. Node ID: {self.my_node_id} ('{user_id}')") self.logger.info(
"Connected to Meshtastic device. Node ID: %s (%s)",
self.my_node_id,
user_id,
)
self._is_connected.set() self._is_connected.set()
self.metrics.record_meshtastic_connection() self.metrics.record_meshtastic_connection()
self.health_monitor.update_component("meshtastic", HealthStatus.HEALTHY, "Connected") self.health_monitor.update_component(
"meshtastic", HealthStatus.HEALTHY, "Connected"
)
else: else:
self.logger.warning("Connected to Meshtastic, but failed to retrieve node info. Loopback detection unreliable.") self.logger.warning(
self._is_connected.set() "Connected to Meshtastic but failed to retrieve "
self.metrics.record_meshtastic_connection() "node info."
self.health_monitor.update_component("meshtastic", HealthStatus.DEGRADED, "Connected but node info unavailable") )
self._is_connected.set()
self.metrics.record_meshtastic_connection()
self.health_monitor.update_component(
"meshtastic",
HealthStatus.DEGRADED,
"Connected but node info "
"unavailable",
)
pub.subscribe(self._on_meshtastic_receive, "meshtastic.receive", weak=False) pub.subscribe(
self._on_meshtastic_receive,
"meshtastic.receive",
weak=False,
)
self.logger.info("Meshtastic receive callback registered.") self.logger.info("Meshtastic receive callback registered.")
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error connecting to Meshtastic device {self.config.meshtastic_port}: {e}", exc_info=False) self.logger.error(
"Error connecting to Meshtastic device %s: %s",
self.config.meshtastic_port,
e,
exc_info=False,
)
if self.interface: if self.interface:
try: self.interface.close() try:
except Exception: pass self.interface.close()
except Exception:
pass
self.interface = None self.interface = None
self.my_node_id = None self.my_node_id = None
self._is_connected.clear() self._is_connected.clear()
self.metrics.record_meshtastic_disconnection() self.metrics.record_meshtastic_disconnection()
self.health_monitor.update_component("meshtastic", HealthStatus.UNHEALTHY, f"Connection failed: {e}") self.health_monitor.update_component(
"meshtastic",
HealthStatus.UNHEALTHY,
f"Connection failed: {e}",
)
return False return False
def start_sender(self): def start_sender(self):
if self.sender_thread and self.sender_thread.is_alive(): if self.sender_thread and self.sender_thread.is_alive():
return return
self.logger.info("Starting Meshtastic sender thread...") self.logger.info("Starting Meshtastic sender thread...")
self.sender_thread = threading.Thread(target=self._meshtastic_sender_loop, daemon=True, name="MeshtasticSender") self.sender_thread = threading.Thread(
target=self._meshtastic_sender_loop,
daemon=True,
name="MeshtasticSender",
)
self.sender_thread.start() self.sender_thread.start()
def stop(self): def stop(self):
self.logger.info("Stopping Meshtastic handler...") self.logger.info("Stopping Meshtastic handler...")
try: try:
pub.unsubscribe(self._on_meshtastic_receive, "meshtastic.receive") pub.unsubscribe(self._on_meshtastic_receive, "meshtastic.receive")
except Exception: pass except Exception:
pass
with self._lock: with self._lock:
if self.interface: if self.interface:
try: try:
self.interface.close() self.interface.close()
except Exception as e: except Exception as e:
self.logger.error(f"Error closing Meshtastic interface: {e}") self.logger.error(
"Error closing Meshtastic interface: %s",
e,
)
finally: finally:
self.interface = None self.interface = None
self.my_node_id = None self.my_node_id = None
self._is_connected.clear() self._is_connected.clear()
self.metrics.record_meshtastic_disconnection() self.metrics.record_meshtastic_disconnection()
self.health_monitor.update_component("meshtastic", HealthStatus.UNHEALTHY, "Disconnected") self.health_monitor.update_component(
"meshtastic", HealthStatus.UNHEALTHY, "Disconnected"
)
if self.sender_thread and self.sender_thread.is_alive(): if self.sender_thread and self.sender_thread.is_alive():
self.sender_thread.join(timeout=5) self.sender_thread.join(timeout=5)
self.logger.info("Meshtastic handler stopped.") self.logger.info("Meshtastic handler stopped.")
def _on_meshtastic_receive(self, packet: Dict[str, Any], interface: Any): def _on_meshtastic_receive(self, packet: Dict[str, Any], interface: Any):
try: try:
if not packet or 'from' not in packet: if not packet or "from" not in packet:
return return
sender_id_num = packet.get('from') sender_id_num = packet.get("from")
sender_id_hex = f"!{sender_id_num:x}" if isinstance(sender_id_num, int) else "UNKNOWN" sender_id_hex = (
portnum = packet.get('decoded', {}).get('portnum', 'UNKNOWN') f"!{sender_id_num:x}"
payload_bytes = packet.get('decoded', {}).get('payload') if isinstance(sender_id_num, int)
else "UNKNOWN"
)
portnum = packet.get("decoded", {}).get("portnum", "UNKNOWN")
payload_bytes = packet.get("decoded", {}).get("payload")
# Loopback Prevention # Loopback Prevention
bridge_id_lower = self.config.bridge_node_id.lower() if self.config.bridge_node_id else None bridge_id_lower = (
my_node_id_lower = self.my_node_id.lower() if self.my_node_id else None self.config.bridge_node_id.lower()
if self.config.bridge_node_id
else None
)
my_node_id_lower = (
self.my_node_id.lower() if self.my_node_id else None
)
sender_id_lower = sender_id_hex.lower() sender_id_lower = sender_id_hex.lower()
if (bridge_id_lower and sender_id_lower == bridge_id_lower) or \ if (bridge_id_lower and sender_id_lower == bridge_id_lower) or (
(my_node_id_lower and sender_id_lower == my_node_id_lower): my_node_id_lower and sender_id_lower == my_node_id_lower
return ):
return
portnum_str = str(portnum) if portnum else 'UNKNOWN' portnum_str = str(portnum) if portnum else "UNKNOWN"
translated_payload = None translated_payload = None
message_type = "meshtastic_message" message_type = "meshtastic_message"
if portnum_str == 'TEXT_MESSAGE_APP' and payload_bytes: if portnum_str == "TEXT_MESSAGE_APP" and payload_bytes:
try: try:
text_payload = payload_bytes.decode('utf-8', errors='replace') text_payload = payload_bytes.decode(
self.logger.info(f"Meshtastic RX <{portnum_str}> From {sender_id_hex}: '{text_payload}'") "utf-8", errors="replace"
)
self.logger.info(
"Meshtastic RX <%s> From %s: %r",
portnum_str,
sender_id_hex,
text_payload,
)
translated_payload = text_payload translated_payload = text_payload
except UnicodeDecodeError: except UnicodeDecodeError:
translated_payload = repr(payload_bytes) translated_payload = repr(payload_bytes)
elif portnum_str == 'POSITION_APP': elif portnum_str == "POSITION_APP":
pos_data = packet.get('decoded', {}).get('position', {}) pos_data = packet.get("decoded", {}).get("position", {})
translated_payload = { translated_payload = {
"latitude": pos_data.get('latitude'), "latitude": pos_data.get("latitude"),
"longitude": pos_data.get('longitude'), "longitude": pos_data.get("longitude"),
"altitude": pos_data.get('altitude'), "altitude": pos_data.get("altitude"),
"timestamp_gps": pos_data.get('time') "timestamp_gps": pos_data.get("time"),
} }
message_type = "meshtastic_position" message_type = "meshtastic_position"
else: else:
return return
@@ -177,62 +253,97 @@ class MeshtasticHandler:
"portnum": portnum_str, "portnum": portnum_str,
"payload": translated_payload, "payload": translated_payload,
"timestamp_rx": time.time(), "timestamp_rx": time.time(),
"rx_rssi": packet.get('rxRssi'), "rx_rssi": packet.get("rxRssi"),
"rx_snr": packet.get('rxSnr'), "rx_snr": packet.get("rxSnr"),
} }
try: try:
self.to_external_queue.put_nowait(external_message) self.to_external_queue.put_nowait(external_message)
payload_size = len(str(translated_payload).encode('utf-8')) if translated_payload else 0 payload_size = (
len(str(translated_payload).encode("utf-8"))
if translated_payload
else 0
)
self.metrics.record_meshtastic_received(payload_size) self.metrics.record_meshtastic_received(payload_size)
self.logger.debug(f"Queued message from {sender_id_hex} for external handler.") self.logger.debug(
"Queued message from %s for external handler.",
sender_id_hex,
)
except Full: except Full:
self.logger.warning("External handler send queue is full.") self.logger.warning("External handler send queue is full.")
self.metrics.record_dropped("meshtastic") self.metrics.record_dropped("meshtastic")
except Exception as e:
self.logger.error(
"Error processing incoming Meshtastic packet: %s",
e,
exc_info=True,
)
except Exception as e: except Exception as e:
self.logger.error(f"Error in _on_meshtastic_receive callback: {e}", exc_info=True) self.logger.error(
"Unhandled error processing Meshtastic packet: %s",
e,
exc_info=True,
)
def _meshtastic_sender_loop(self): def _meshtastic_sender_loop(self):
self.logger.info("Meshtastic sender loop started.") self.logger.info("Meshtastic sender loop started.")
while not self.shutdown_event.is_set(): while not self.shutdown_event.is_set():
try: try:
item: Optional[Dict[str, Any]] = self.to_meshtastic_queue.get(timeout=1) item: Optional[Dict[str, Any]] = self.to_meshtastic_queue.get(
if not item: continue timeout=1
)
if not item:
continue
if not self._is_connected.is_set(): if not self._is_connected.is_set():
self.to_meshtastic_queue.task_done() self.to_meshtastic_queue.task_done()
time.sleep(self.RECONNECT_DELAY_S / 2) time.sleep(self.RECONNECT_DELAY_S / 2)
continue continue
# Validate and sanitize message # Validate and sanitize message
is_valid, error_msg = self.validator.validate_meshtastic_message(item) is_valid, error_msg = (
self.validator.validate_meshtastic_message(item)
)
if not is_valid: if not is_valid:
self.logger.warning(f"Invalid message rejected: {error_msg}") self.logger.warning(
"Invalid message rejected: %s",
error_msg,
)
self.metrics.record_error("meshtastic") self.metrics.record_error("meshtastic")
self.to_meshtastic_queue.task_done() self.to_meshtastic_queue.task_done()
continue continue
# Check rate limit # Check rate limit
if not self.rate_limiter.check_rate_limit("meshtastic_sender"): if not self.rate_limiter.check_rate_limit("meshtastic_sender"):
self.logger.warning("Rate limit exceeded for Meshtastic sender") self.logger.warning(
self.metrics.record_rate_limit_violation("meshtastic_sender") "Rate limit exceeded for Meshtastic sender"
)
self.metrics.record_rate_limit_violation(
"meshtastic_sender"
)
self.to_meshtastic_queue.task_done() self.to_meshtastic_queue.task_done()
continue continue
# Sanitize message # Sanitize message
item = self.validator.sanitize_meshtastic_message(item) item = self.validator.sanitize_meshtastic_message(item)
destination = item.get('destination') destination = item.get("destination")
text_to_send = item.get('text') text_to_send = item.get("text")
channel_index = item.get('channel_index', 0) channel_index = item.get("channel_index", 0)
want_ack = item.get('want_ack', False) want_ack = item.get("want_ack", False)
if destination and isinstance(text_to_send, str): if destination and isinstance(text_to_send, str):
log_payload = (text_to_send[:100] + '...') if len(text_to_send) > 100 else text_to_send log_payload = (
self.logger.info(f"Meshtastic TX -> Dest: {destination}, Payload: '{log_payload}'") (text_to_send[:100] + "...")
if len(text_to_send) > 100
else text_to_send
)
self.logger.info(
"Meshtastic TX -> Dest: %s, Payload: '%s'",
destination,
log_payload,
)
send_success = False
with self._lock: with self._lock:
if self.interface and self._is_connected.is_set(): if self.interface and self._is_connected.is_set():
try: try:
@@ -240,26 +351,37 @@ class MeshtasticHandler:
text=text_to_send, text=text_to_send,
destinationId=destination, destinationId=destination,
channelIndex=channel_index, channelIndex=channel_index,
wantAck=want_ack wantAck=want_ack,
)
payload_size = len(
text_to_send.encode("utf-8")
)
self.metrics.record_meshtastic_sent(
payload_size
) )
send_success = True
payload_size = len(text_to_send.encode('utf-8'))
self.metrics.record_meshtastic_sent(payload_size)
except Exception as e: except Exception as e:
self.logger.error(f"Error sending Meshtastic message: {e}") self.logger.error(
if "Not connected" in str(e): self._is_connected.clear() "Error sending Meshtastic message: %s",
e,
)
if "Not connected" in str(e):
self._is_connected.clear()
else: else:
self._is_connected.clear() self._is_connected.clear()
self.to_meshtastic_queue.task_done() self.to_meshtastic_queue.task_done()
else: else:
self.to_meshtastic_queue.task_done() self.to_meshtastic_queue.task_done()
except Empty: except Empty:
if not self._is_connected.is_set(): time.sleep(self.RECONNECT_DELAY_S) if not self._is_connected.is_set():
time.sleep(self.RECONNECT_DELAY_S)
continue continue
except Exception as e: except Exception as e:
self.logger.error(f"Critical error in meshtastic_sender_loop: {e}", exc_info=True) self.logger.error(
f"Critical error in meshtastic_sender_loop: {e}",
exc_info=True,
)
self._is_connected.clear() self._is_connected.clear()
time.sleep(5) time.sleep(5)

View File

@@ -3,19 +3,24 @@
Message persistence and logging for the bridge. Message persistence and logging for the bridge.
""" """
import logging
import json import json
import logging
import threading import threading
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional from queue import Empty, Queue
from queue import Queue, Empty from typing import Any, Dict, Optional
class MessageLogger: class MessageLogger:
"""Logs messages to file for persistence and analysis.""" """Logs messages to file for persistence and analysis."""
def __init__(self, log_file: Optional[str] = None, max_file_size_mb: int = 10, max_backups: int = 5): def __init__(
self,
log_file: Optional[str] = None,
max_file_size_mb: int = 10,
max_backups: int = 5,
):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.log_file = log_file self.log_file = log_file
self.max_file_size = max_file_size_mb * 1024 * 1024 # Convert to bytes self.max_file_size = max_file_size_mb * 1024 * 1024 # Convert to bytes
@@ -27,7 +32,8 @@ class MessageLogger:
self._shutdown_event = threading.Event() self._shutdown_event = threading.Event()
if self._enabled: if self._enabled:
self._log_path = Path(log_file) assert self.log_file is not None
self._log_path = Path(self.log_file)
self._log_path.parent.mkdir(parents=True, exist_ok=True) self._log_path.parent.mkdir(parents=True, exist_ok=True)
self._start_worker() self._start_worker()
@@ -36,9 +42,13 @@ class MessageLogger:
if self._worker_thread and self._worker_thread.is_alive(): if self._worker_thread and self._worker_thread.is_alive():
return return
self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True, name="MessageLogger") self._worker_thread = threading.Thread(
target=self._worker_loop, daemon=True, name="MessageLogger"
)
self._worker_thread.start() self._worker_thread.start()
self.logger.info(f"Message logger started, logging to: {self.log_file}") self.logger.info(
f"Message logger started, logging to: {self.log_file}"
)
def _worker_loop(self): def _worker_loop(self):
"""Background loop for writing messages.""" """Background loop for writing messages."""
@@ -51,7 +61,9 @@ class MessageLogger:
except Empty: except Empty:
continue continue
except Exception as e: except Exception as e:
self.logger.error(f"Error in message logger worker: {e}", exc_info=True) self.logger.error(
f"Error in message logger worker: {e}", exc_info=True
)
def _write_message(self, message: Dict[str, Any]): def _write_message(self, message: Dict[str, Any]):
"""Write a message to the log file.""" """Write a message to the log file."""
@@ -60,20 +72,22 @@ class MessageLogger:
try: try:
# Add timestamp if not present # Add timestamp if not present
if 'timestamp' not in message: if "timestamp" not in message:
message['timestamp'] = datetime.now().isoformat() message["timestamp"] = datetime.now().isoformat()
# Rotate log file if needed # Rotate log file if needed
self._rotate_if_needed() self._rotate_if_needed()
# Write message as JSON line # Write message as JSON line
with self._lock: with self._lock:
with open(self._log_path, 'a', encoding='utf-8') as f: with open(self._log_path, "a", encoding="utf-8") as f:
json.dump(message, f, ensure_ascii=False) json.dump(message, f, ensure_ascii=False)
f.write('\n') f.write("\n")
except Exception as e: except Exception as e:
self.logger.error(f"Error writing message to log file: {e}", exc_info=True) self.logger.error(
f"Error writing message to log file: {e}", exc_info=True
)
def _rotate_if_needed(self): def _rotate_if_needed(self):
"""Rotate log file if it exceeds max size.""" """Rotate log file if it exceeds max size."""
@@ -86,16 +100,18 @@ class MessageLogger:
try: try:
# Rotate existing backups # Rotate existing backups
for i in range(self.max_backups - 1, 0, -1): for i in range(self.max_backups - 1, 0, -1):
old_file = self._log_path.with_suffix(f'.{i}.log') old_file = self._log_path.with_suffix(f".{i}.log")
new_file = self._log_path.with_suffix(f'.{i + 1}.log') new_file = self._log_path.with_suffix(f".{i + 1}.log")
if old_file.exists(): if old_file.exists():
old_file.rename(new_file) old_file.rename(new_file)
# Move current log to .1.log # Move current log to .1.log
backup_file = self._log_path.with_suffix('.1.log') backup_file = self._log_path.with_suffix(".1.log")
self._log_path.rename(backup_file) self._log_path.rename(backup_file)
self.logger.info(f"Rotated log file: {self._log_path} -> {backup_file}") self.logger.info(
f"Rotated log file: {self._log_path} -> {backup_file}"
)
except Exception as e: except Exception as e:
self.logger.error(f"Error rotating log file: {e}", exc_info=True) self.logger.error(f"Error rotating log file: {e}", exc_info=True)
@@ -107,8 +123,8 @@ class MessageLogger:
log_entry = { log_entry = {
**message, **message,
'direction': direction, "direction": direction,
'logged_at': datetime.now().isoformat(), "logged_at": datetime.now().isoformat(),
} }
try: try:
@@ -134,4 +150,3 @@ class MessageLogger:
break break
self.logger.info("Message logger stopped") self.logger.info("Message logger stopped")

View File

@@ -5,16 +5,16 @@ Metrics and statistics collection for the bridge.
import logging import logging
import threading import threading
import time
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Optional from typing import Dict, Optional
from datetime import datetime, timedelta
@dataclass @dataclass
class MessageStats: class MessageStats:
"""Statistics for message processing.""" """Statistics for message processing."""
total_received: int = 0 total_received: int = 0
total_sent: int = 0 total_sent: int = 0
total_dropped: int = 0 total_dropped: int = 0
@@ -57,8 +57,14 @@ class MessageStats:
"total_sent": self.total_sent, "total_sent": self.total_sent,
"total_dropped": self.total_dropped, "total_dropped": self.total_dropped,
"total_errors": self.total_errors, "total_errors": self.total_errors,
"last_received": self.last_received.isoformat() if self.last_received else None, "last_received": (
"last_sent": self.last_sent.isoformat() if self.last_sent else None, self.last_received.isoformat()
if self.last_received
else None
),
"last_sent": (
self.last_sent.isoformat() if self.last_sent else None
),
"bytes_received": self.bytes_received, "bytes_received": self.bytes_received,
"bytes_sent": self.bytes_sent, "bytes_sent": self.bytes_sent,
} }
@@ -67,6 +73,7 @@ class MessageStats:
@dataclass @dataclass
class ConnectionStats: class ConnectionStats:
"""Statistics for connection health.""" """Statistics for connection health."""
connection_count: int = 0 connection_count: int = 0
disconnection_count: int = 0 disconnection_count: int = 0
last_connected: Optional[datetime] = None last_connected: Optional[datetime] = None
@@ -89,7 +96,9 @@ class ConnectionStats:
self.disconnection_count += 1 self.disconnection_count += 1
self.last_disconnected = datetime.now() self.last_disconnected = datetime.now()
if self.current_uptime_start: if self.current_uptime_start:
uptime = (datetime.now() - self.current_uptime_start).total_seconds() uptime = (
datetime.now() - self.current_uptime_start
).total_seconds()
self.total_uptime_seconds += uptime self.total_uptime_seconds += uptime
self.current_uptime_start = None self.current_uptime_start = None
@@ -97,7 +106,9 @@ class ConnectionStats:
"""Get current uptime in seconds.""" """Get current uptime in seconds."""
with self._lock: with self._lock:
if self.current_uptime_start: if self.current_uptime_start:
return (datetime.now() - self.current_uptime_start).total_seconds() return (
datetime.now() - self.current_uptime_start
).total_seconds()
return 0.0 return 0.0
def to_dict(self) -> Dict: def to_dict(self) -> Dict:
@@ -106,9 +117,18 @@ class ConnectionStats:
return { return {
"connection_count": self.connection_count, "connection_count": self.connection_count,
"disconnection_count": self.disconnection_count, "disconnection_count": self.disconnection_count,
"last_connected": self.last_connected.isoformat() if self.last_connected else None, "last_connected": (
"last_disconnected": self.last_disconnected.isoformat() if self.last_disconnected else None, self.last_connected.isoformat()
"total_uptime_seconds": self.total_uptime_seconds + self.get_current_uptime(), if self.last_connected
else None
),
"last_disconnected": (
self.last_disconnected.isoformat()
if self.last_disconnected
else None
),
"total_uptime_seconds": self.total_uptime_seconds
+ self.get_current_uptime(),
"current_uptime_seconds": self.get_current_uptime(), "current_uptime_seconds": self.get_current_uptime(),
} }
@@ -224,4 +244,3 @@ def get_metrics() -> MetricsCollector:
if _metrics is None: if _metrics is None:
_metrics = MetricsCollector() _metrics = MetricsCollector()
return _metrics return _metrics

View File

@@ -3,29 +3,42 @@
Handles interactions with an MQTT broker as the external network interface. Handles interactions with an MQTT broker as the external network interface.
""" """
import json
import logging import logging
import threading import threading
import time import time
import json from queue import Empty, Full, Queue
from queue import Queue, Empty, Full from typing import Any, Dict, Optional
from typing import Optional, Dict, Any
# External dependencies # External dependencies
import paho.mqtt.client as paho_mqtt import paho.mqtt.client as paho_mqtt
import paho.mqtt.enums as paho_enums # For MQTTv5 properties if used from typing import TYPE_CHECKING
if TYPE_CHECKING:
import paho.mqtt.enums as paho_enums # type: ignore[import-not-found]
else:
paho_enums: Any = None
# Project dependencies # Project dependencies
from .config_handler import BridgeConfig from .config_handler import BridgeConfig
from .health import HealthStatus, get_health_monitor
from .metrics import get_metrics from .metrics import get_metrics
from .health import get_health_monitor, HealthStatus
from .validator import MessageValidator
from .rate_limiter import RateLimiter from .rate_limiter import RateLimiter
from .validator import MessageValidator
class MQTTHandler: class MQTTHandler:
"""Manages connection and communication with an MQTT broker.""" """Manages connection and communication with an MQTT broker."""
RECONNECT_DELAY_S = 10 RECONNECT_DELAY_S = 10
def __init__(self, config: BridgeConfig, to_meshtastic_queue: Queue, from_meshtastic_queue: Queue, shutdown_event: threading.Event): def __init__(
self,
config: BridgeConfig,
to_meshtastic_queue: Queue,
from_meshtastic_queue: Queue,
shutdown_event: threading.Event,
):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.config = config self.config = config
self.to_meshtastic_queue = to_meshtastic_queue self.to_meshtastic_queue = to_meshtastic_queue
@@ -36,15 +49,28 @@ class MQTTHandler:
self.publisher_thread: Optional[threading.Thread] = None self.publisher_thread: Optional[threading.Thread] = None
self._mqtt_connected = threading.Event() self._mqtt_connected = threading.Event()
self._lock = threading.Lock() self._lock = threading.Lock()
# Initialize metrics, health, validator, and rate limiter # Initialize metrics, health, validator, and rate limiter
self.metrics = get_metrics() self.metrics = get_metrics()
self.health_monitor = get_health_monitor() self.health_monitor = get_health_monitor()
self.validator = MessageValidator() self.validator = MessageValidator()
self.rate_limiter = RateLimiter(max_messages=60, time_window=60.0) self.rate_limiter = RateLimiter(max_messages=60, time_window=60.0)
if not all([config.mqtt_broker, config.mqtt_port is not None, config.mqtt_topic_in, config.mqtt_topic_out, config.mqtt_client_id, config.mqtt_qos is not None, config.mqtt_retain_out is not None]): if not all(
raise ValueError("MQTT transport selected, but required MQTT configuration options seem missing.") [
config.mqtt_broker,
config.mqtt_port is not None,
config.mqtt_topic_in,
config.mqtt_topic_out,
config.mqtt_client_id,
config.mqtt_qos is not None,
config.mqtt_retain_out is not None,
]
):
raise ValueError(
"MQTT transport selected, but required MQTT "
"configuration options seem missing."
)
self.logger.info("MQTT Handler Initialized.") self.logger.info("MQTT Handler Initialized.")
@@ -55,20 +81,28 @@ class MQTTHandler:
return True return True
if self.client: if self.client:
try: try:
self.client.reconnect() self.client.reconnect()
return True return True
except Exception: except Exception:
try: self.client.loop_stop(force=True) try:
except: pass self.client.loop_stop(force=True)
self.client = None except Exception:
self._mqtt_connected.clear() pass
self.client = None
self._mqtt_connected.clear()
try: try:
self.client = paho_mqtt.Client(client_id=self.config.mqtt_client_id, self.client = paho_mqtt.Client(
protocol=paho_mqtt.MQTTv311, client_id=self.config.mqtt_client_id,
clean_session=True) protocol=paho_mqtt.MQTTv311,
self.logger.info(f"Attempting connection to MQTT broker {self.config.mqtt_broker}:{self.config.mqtt_port}...") clean_session=True,
)
self.logger.info(
"Attempting connection to MQTT broker %s:%s...",
self.config.mqtt_broker,
self.config.mqtt_port,
)
self.client.on_connect = self._on_connect self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect self.client.on_disconnect = self._on_disconnect
@@ -76,30 +110,59 @@ class MQTTHandler:
self.client.on_log = self._on_log self.client.on_log = self._on_log
# TLS/SSL support # TLS/SSL support
if hasattr(self.config, 'mqtt_tls_enabled') and self.config.mqtt_tls_enabled: if (
hasattr(self.config, "mqtt_tls_enabled")
and self.config.mqtt_tls_enabled
):
import ssl import ssl
context = ssl.create_default_context() context = ssl.create_default_context()
if hasattr(self.config, 'mqtt_tls_ca_certs') and self.config.mqtt_tls_ca_certs: if (
context.load_verify_locations(self.config.mqtt_tls_ca_certs) hasattr(self.config, "mqtt_tls_ca_certs")
if hasattr(self.config, 'mqtt_tls_insecure') and self.config.mqtt_tls_insecure: and self.config.mqtt_tls_ca_certs
):
context.load_verify_locations(
self.config.mqtt_tls_ca_certs
)
if (
hasattr(self.config, "mqtt_tls_insecure")
and self.config.mqtt_tls_insecure
):
context.check_hostname = False context.check_hostname = False
context.verify_mode = ssl.CERT_NONE context.verify_mode = ssl.CERT_NONE
self.client.tls_set_context(context) self.client.tls_set_context(context)
self.logger.info("MQTT TLS/SSL enabled") self.logger.info("MQTT TLS/SSL enabled")
if self.config.mqtt_username: if self.config.mqtt_username:
self.client.username_pw_set(self.config.mqtt_username, self.config.mqtt_password) self.client.username_pw_set(
self.config.mqtt_username, self.config.mqtt_password
)
self.client.connect_async(self.config.mqtt_broker, self.config.mqtt_port, keepalive=60) # Ensure configuration values present for mypy
assert (
self.config.mqtt_broker is not None
and self.config.mqtt_port is not None
)
self.client.connect_async(
self.config.mqtt_broker,
self.config.mqtt_port,
keepalive=60,
)
self.client.loop_start() self.client.loop_start()
self.logger.info("MQTT client network loop started.") self.logger.info("MQTT client network loop started.")
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error initiating MQTT connection or starting loop: {e}", exc_info=True) self.logger.error(
if self.client: "Error initiating MQTT connection or starting loop: %s",
try: self.client.loop_stop(force=True) e,
except: pass exc_info=True,
)
if self.client:
try:
self.client.loop_stop(force=True)
except Exception:
pass
self.client = None self.client = None
self._mqtt_connected.clear() self._mqtt_connected.clear()
return False return False
@@ -108,24 +171,32 @@ class MQTTHandler:
if self.publisher_thread and self.publisher_thread.is_alive(): if self.publisher_thread and self.publisher_thread.is_alive():
return return
self.logger.info("Starting MQTT publisher thread...") self.logger.info("Starting MQTT publisher thread...")
self.publisher_thread = threading.Thread(target=self._mqtt_publisher_loop, daemon=True, name="MQTTPublisher") self.publisher_thread = threading.Thread(
target=self._mqtt_publisher_loop, daemon=True, name="MQTTPublisher"
)
self.publisher_thread.start() self.publisher_thread.start()
def stop(self): def stop(self):
self.logger.info("Stopping MQTT handler...") self.logger.info("Stopping MQTT handler...")
if self.publisher_thread and self.publisher_thread.is_alive(): if self.publisher_thread and self.publisher_thread.is_alive():
self.publisher_thread.join(timeout=5) self.publisher_thread.join(timeout=5)
with self._lock: with self._lock:
if self.client: if self.client:
try: self.client.loop_stop(force=True) try:
except Exception: pass self.client.loop_stop(force=True)
try: self.client.disconnect() except Exception:
except Exception: pass pass
try:
self.client.disconnect()
except Exception:
pass
self.client = None self.client = None
self._mqtt_connected.clear() self._mqtt_connected.clear()
self.metrics.record_external_disconnection() self.metrics.record_external_disconnection()
self.health_monitor.update_component("external", HealthStatus.UNHEALTHY, "Stopped") self.health_monitor.update_component(
"external", HealthStatus.UNHEALTHY, "Stopped"
)
self.logger.info("MQTT handler stopped.") self.logger.info("MQTT handler stopped.")
# --- MQTT Callbacks (Executed by Paho's Network Thread) --- # --- MQTT Callbacks (Executed by Paho's Network Thread) ---
@@ -133,60 +204,90 @@ class MQTTHandler:
def _on_connect(self, client, userdata, flags, rc, properties=None): def _on_connect(self, client, userdata, flags, rc, properties=None):
connack_str = paho_mqtt.connack_string(rc) connack_str = paho_mqtt.connack_string(rc)
if rc == 0: if rc == 0:
self.logger.info(f"Successfully connected to MQTT broker: {self.config.mqtt_broker} ({connack_str})") self.logger.info(
"Successfully connected to MQTT broker: %s (%s)",
self.config.mqtt_broker,
connack_str,
)
self._mqtt_connected.set() self._mqtt_connected.set()
self.metrics.record_external_connection() self.metrics.record_external_connection()
self.health_monitor.update_component("external", HealthStatus.HEALTHY, "MQTT connected") self.health_monitor.update_component(
"external", HealthStatus.HEALTHY, "MQTT connected"
)
try: try:
client.subscribe(self.config.mqtt_topic_in, qos=self.config.mqtt_qos) client.subscribe(
self.config.mqtt_topic_in, qos=self.config.mqtt_qos
)
except Exception as e: except Exception as e:
self.logger.error(f"Error during MQTT subscription: {e}") self.logger.error("Error during MQTT subscription: %s", e)
else: else:
self.logger.error(f"MQTT connection failed. Result code: {rc} - {connack_str}") self.logger.error(
"MQTT connection failed. Result code: %s - %s",
rc,
connack_str,
)
self._mqtt_connected.clear() self._mqtt_connected.clear()
self.metrics.record_external_disconnection() self.metrics.record_external_disconnection()
self.health_monitor.update_component("external", HealthStatus.UNHEALTHY, f"Connection failed: {connack_str}") self.health_monitor.update_component(
"external",
HealthStatus.UNHEALTHY,
"Connection failed: %s" % connack_str,
)
def _on_disconnect(self, client, userdata, rc, properties=None): def _on_disconnect(self, client, userdata, rc, properties=None):
self._mqtt_connected.clear() self._mqtt_connected.clear()
self.metrics.record_external_disconnection() self.metrics.record_external_disconnection()
if rc != 0: if rc != 0:
self.logger.warning(f"Unexpected MQTT disconnection. RC: {rc}") self.logger.warning(f"Unexpected MQTT disconnection. RC: {rc}")
self.health_monitor.update_component("external", HealthStatus.UNHEALTHY, f"Unexpected disconnect: RC {rc}") self.health_monitor.update_component(
"external",
HealthStatus.UNHEALTHY,
f"Unexpected disconnect: RC {rc}",
)
else: else:
self.health_monitor.update_component("external", HealthStatus.UNHEALTHY, "Disconnected") self.health_monitor.update_component(
"external", HealthStatus.UNHEALTHY, "Disconnected"
)
def _on_log(self, client, userdata, level, buf): def _on_log(self, client, userdata, level, buf):
"""MQTT client logging callback.""" """MQTT client logging callback."""
# Only log at DEBUG level to avoid noise # Only log at DEBUG level to avoid noise
if level <= paho_mqtt.MQTT_LOG_DEBUG: if level <= paho_mqtt.MQTT_LOG_DEBUG:
self.logger.debug(f"MQTT: {buf}") self.logger.debug("MQTT: %s", buf)
def _on_message(self, client, userdata, msg: paho_mqtt.MQTTMessage): def _on_message(self, client, userdata, msg: paho_mqtt.MQTTMessage):
try: try:
payload_bytes = msg.payload payload_bytes = msg.payload
if not payload_bytes: return if not payload_bytes:
return
try: try:
payload_str = payload_bytes.decode('utf-8', errors='replace') payload_str = payload_bytes.decode("utf-8", errors="replace")
mqtt_data = json.loads(payload_str) mqtt_data = json.loads(payload_str)
# Validate message # Validate message
is_valid, error_msg = self.validator.validate_external_message(mqtt_data) is_valid, error_msg = self.validator.validate_external_message(
mqtt_data
)
if not is_valid: if not is_valid:
self.logger.warning(f"Invalid MQTT message rejected: {error_msg}") self.logger.warning(
"Invalid MQTT message rejected: %s",
error_msg,
)
self.metrics.record_error("external") self.metrics.record_error("external")
return return
# Check rate limit # Check rate limit
if not self.rate_limiter.check_rate_limit("mqtt_receiver"): if not self.rate_limiter.check_rate_limit("mqtt_receiver"):
self.logger.warning("Rate limit exceeded for MQTT receiver") self.logger.warning(
"Rate limit exceeded for MQTT receiver"
)
self.metrics.record_rate_limit_violation("mqtt_receiver") self.metrics.record_rate_limit_violation("mqtt_receiver")
return return
# Sanitize message # Sanitize message
mqtt_data = self.validator.sanitize_external_message(mqtt_data) mqtt_data = self.validator.sanitize_external_message(mqtt_data)
dest_meshtastic_id = mqtt_data.get("destination_meshtastic_id") dest_meshtastic_id = mqtt_data.get("destination_meshtastic_id")
payload = mqtt_data.get("payload") payload = mqtt_data.get("payload")
payload_json = mqtt_data.get("payload_json") payload_json = mqtt_data.get("payload_json")
@@ -197,23 +298,36 @@ class MQTTHandler:
if isinstance(payload, str): if isinstance(payload, str):
text_payload_str = payload text_payload_str = payload
elif payload_json is not None: elif payload_json is not None:
try: text_payload_str = json.dumps(payload_json) try:
except Exception: pass text_payload_str = json.dumps(payload_json)
except Exception:
pass
elif payload is not None: elif payload is not None:
text_payload_str = str(payload) text_payload_str = str(payload)
if dest_meshtastic_id and text_payload_str is not None: if dest_meshtastic_id and text_payload_str is not None:
meshtastic_msg = { meshtastic_msg = {
"destination": dest_meshtastic_id, "destination": dest_meshtastic_id,
"text": text_payload_str, "text": text_payload_str,
"channel_index": int(channel_index) if str(channel_index).isdigit() else 0, "channel_index": (
int(channel_index)
if str(channel_index).isdigit()
else 0
),
"want_ack": bool(want_ack), "want_ack": bool(want_ack),
} }
try: try:
self.to_meshtastic_queue.put_nowait(meshtastic_msg) self.to_meshtastic_queue.put_nowait(meshtastic_msg)
payload_size = len(text_payload_str.encode('utf-8')) if text_payload_str else 0 payload_size = (
len(text_payload_str.encode("utf-8"))
if text_payload_str
else 0
)
self.metrics.record_external_received(payload_size) self.metrics.record_external_received(payload_size)
self.logger.info(f"Queued MQTT message for {dest_meshtastic_id}") self.logger.info(
"Queued MQTT message for %s",
dest_meshtastic_id,
)
except Full: except Full:
self.logger.warning("Meshtastic send queue full.") self.logger.warning("Meshtastic send queue full.")
self.metrics.record_dropped("external") self.metrics.record_dropped("external")
@@ -231,40 +345,57 @@ class MQTTHandler:
continue continue
try: try:
item: Optional[Dict[str, Any]] = self.to_mqtt_queue.get(timeout=1) item: Optional[Dict[str, Any]] = self.to_mqtt_queue.get(
if not item: continue timeout=1
)
if not item:
continue
try: try:
payload_str = json.dumps(item) payload_str = json.dumps(item)
topic = self.config.mqtt_topic_out topic = self.config.mqtt_topic_out
if not topic: if not topic:
self.logger.error("MQTT_TOPIC_OUT is not configured. Cannot publish.") self.logger.error(
self.to_mqtt_queue.task_done() "MQTT_TOPIC_OUT is not configured. Cannot publish."
continue )
self.to_mqtt_queue.task_done()
continue
qos = self.config.mqtt_qos qos = self.config.mqtt_qos
if qos not in [0, 1, 2]: if qos not in [0, 1, 2]:
self.logger.error(f"Invalid MQTT_QOS ({qos}) for publishing. Using QoS 0.") self.logger.error(
qos = 0 "Invalid MQTT_QOS (%s); using QoS 0.",
qos,
)
qos = 0
with self._lock: with self._lock:
if self.client and self.client.is_connected(): if self.client and self.client.is_connected():
self.client.publish(topic, payload=payload_str, qos=qos, retain=self.config.mqtt_retain_out) self.client.publish(
self.metrics.record_external_sent(len(payload_str.encode('utf-8'))) topic,
else: payload=payload_str,
self._mqtt_connected.clear() qos=qos,
retain=self.config.mqtt_retain_out,
)
self.metrics.record_external_sent(
len(payload_str.encode("utf-8"))
)
else:
self._mqtt_connected.clear()
self.to_mqtt_queue.task_done() self.to_mqtt_queue.task_done()
except Exception as e: except Exception as e:
self.logger.error(f"Error during MQTT publish: {e}") self.logger.error(f"Error during MQTT publish: {e}")
self.to_mqtt_queue.task_done() self.to_mqtt_queue.task_done()
except Empty: except Empty:
continue continue
except Exception as e: except Exception as e:
self.logger.error(f"Critical error in mqtt_publisher_loop: {e}", exc_info=True) self.logger.error(
f"Critical error in mqtt_publisher_loop: {e}",
exc_info=True,
)
time.sleep(5) time.sleep(5)
self.logger.info("MQTT publisher loop stopped.") self.logger.info("MQTT publisher loop stopped.")

View File

@@ -1,55 +1,70 @@
# ammb/protocol.py # ammb/protocol.py
""" """
Defines handlers for different **Serial** communication protocols. Defines handlers for different **Serial** communication
protocols.
Allows the bridge (specifically the MeshcoreHandler) to encode/decode Allows the bridge (specifically the MeshcoreHandler) to
messages based on the serial protocol specified in the configuration. encode/decode messages based on the serial protocol specified
in the configuration.
Includes: Includes:
- JsonNewlineProtocol: For text-based JSON (default). - JsonNewlineProtocol: For text-based JSON (default).
- RawSerialProtocol: For binary/companion modes (fallback). - RawSerialProtocol: For binary/companion modes (fallback).
""" """
import binascii
import json import json
import logging import logging
import binascii
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Optional, Any from typing import Any, Dict, Optional
# --- Base Class --- # --- Base Class ---
class MeshcoreProtocolHandler(ABC): class MeshcoreProtocolHandler(ABC):
""" """
Abstract base class for handling **Serial** protocols for the external device. Abstract base class for **Serial** protocol handling.
Subclasses implement read, encode, and decode methods.
""" """
def __init__(self): def __init__(self):
# Get logger named after the specific subclass implementing the protocol # Logger named after the subclass
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") name = __name__ + "." + self.__class__.__name__
self.logger = logging.getLogger(name)
self.logger.debug("Serial protocol handler initialized.") self.logger.debug("Serial protocol handler initialized.")
@abstractmethod @abstractmethod
def read(self, serial_port) -> Optional[bytes]: def read(self, serial_port) -> Optional[bytes]:
""" """
Reads data from the serial port according to the protocol's needs. Reads data from the serial port.
Returns bytes read, or None.
Implementations should return bytes read, or None.
""" """
pass pass
@abstractmethod @abstractmethod
def encode(self, data: Dict[str, Any]) -> Optional[bytes]: def encode(self, data: Dict[str, Any]) -> Optional[bytes]:
"""Encodes a dictionary payload into bytes suitable for sending over serial.""" """
Encodes a dictionary payload into bytes for sending over serial.
"""
pass pass
@abstractmethod @abstractmethod
def decode(self, raw_data: bytes) -> Optional[Dict[str, Any]]: def decode(self, raw_data: bytes) -> Optional[Dict[str, Any]]:
"""Decodes bytes received from serial into a dictionary.""" """
Decodes bytes received from serial into a dictionary.
"""
pass pass
# --- Concrete Implementations --- # --- Concrete Implementations ---
class JsonNewlineProtocol(MeshcoreProtocolHandler): class JsonNewlineProtocol(MeshcoreProtocolHandler):
""" """
Handles newline-terminated JSON strings encoded in UTF-8 over **Serial**. Handles newline-terminated JSON strings encoded in UTF-8 over **Serial**.
""" """
def read(self, serial_port) -> Optional[bytes]: def read(self, serial_port) -> Optional[bytes]:
"""Reads a single line ending in \\n.""" """Reads a single line ending in \\n."""
if serial_port.in_waiting > 0: if serial_port.in_waiting > 0:
@@ -58,32 +73,51 @@ class JsonNewlineProtocol(MeshcoreProtocolHandler):
def encode(self, data: Dict[str, Any]) -> Optional[bytes]: def encode(self, data: Dict[str, Any]) -> Optional[bytes]:
try: try:
encoded_message = json.dumps(data).encode('utf-8') + b'\n' encoded_message = json.dumps(data).encode("utf-8") + b"\n"
self.logger.debug(f"Encoded: {encoded_message!r}") self.logger.debug("Encoded: %r", encoded_message)
return encoded_message return encoded_message
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
self.logger.error(f"JSON Encode Error: {e} - Data: {data}", exc_info=True) self.logger.error(
"JSON Encode Error: %s - Data: %s",
e,
data,
exc_info=True,
)
return None return None
except Exception as e: except Exception as e:
self.logger.error(f"Unexpected serial encoding error: {e}", exc_info=True) self.logger.error(
"Unexpected serial encoding error: %s",
e,
exc_info=True,
)
return None return None
def decode(self, raw_data: bytes) -> Optional[Dict[str, Any]]: def decode(self, raw_data: bytes) -> Optional[Dict[str, Any]]:
try: try:
decoded_str = raw_data.decode('utf-8', errors='replace').strip() decoded_str = raw_data.decode("utf-8", errors="replace").strip()
if not decoded_str: if not decoded_str:
return None return None
decoded_data = json.loads(decoded_str) decoded_data = json.loads(decoded_str)
if not isinstance(decoded_data, dict): if not isinstance(decoded_data, dict):
self.logger.warning(f"Decoded JSON is not a dictionary: {decoded_str!r}") self.logger.warning(
return None "Decoded JSON is not a dictionary: %r",
decoded_str,
)
return None
return decoded_data return decoded_data
except json.JSONDecodeError: except json.JSONDecodeError:
self.logger.warning(f"Received non-JSON data or incomplete JSON line: {raw_data!r}") self.logger.warning(
"Received non-JSON or incomplete JSON line: %r",
raw_data,
)
return None return None
except Exception as e: except Exception as e:
self.logger.error(f"Error decoding serial data: {e} - Raw: {raw_data!r}") self.logger.error(
"Error decoding serial data: %s - Raw: %r",
e,
raw_data,
)
return None return None
@@ -91,6 +125,7 @@ class RawSerialProtocol(MeshcoreProtocolHandler):
""" """
Handles RAW Binary data from the serial port. Use for 'Companion USB Mode'. Handles RAW Binary data from the serial port. Use for 'Companion USB Mode'.
""" """
def read(self, serial_port) -> Optional[bytes]: def read(self, serial_port) -> Optional[bytes]:
"""Reads all currently available bytes from the buffer.""" """Reads all currently available bytes from the buffer."""
if serial_port.in_waiting > 0: if serial_port.in_waiting > 0:
@@ -100,48 +135,59 @@ class RawSerialProtocol(MeshcoreProtocolHandler):
def encode(self, data: Dict[str, Any]) -> Optional[bytes]: def encode(self, data: Dict[str, Any]) -> Optional[bytes]:
"""Encodes outgoing data.""" """Encodes outgoing data."""
try: try:
payload = data.get('payload', '') payload = data.get("payload", "")
if isinstance(payload, str): if isinstance(payload, str):
return payload.encode('utf-8') return payload.encode("utf-8")
elif isinstance(payload, (bytes, bytearray)): elif isinstance(payload, (bytes, bytearray)):
return payload return payload
return None return None
except Exception as e: except Exception as e:
self.logger.error(f"Error encoding raw data: {e}") self.logger.error("Error encoding raw data: %s", e)
return None return None
def decode(self, raw_data: bytes) -> Optional[Dict[str, Any]]: def decode(self, raw_data: bytes) -> Optional[Dict[str, Any]]:
"""Wraps received raw bytes into a bridge-compatible dictionary.""" """Wraps received raw bytes into a bridge-compatible dictionary."""
if not raw_data: if not raw_data:
return None return None
try: try:
hex_str = binascii.hexlify(raw_data).decode('ascii') hex_str = binascii.hexlify(raw_data).decode("ascii")
return { return {
"destination_meshtastic_id": "^all", "destination_meshtastic_id": "^all",
"payload": f"MC_BIN: {hex_str}", "payload": "MC_BIN: " + hex_str,
"raw_binary": True "raw_binary": True,
} }
except Exception as e: except Exception as e:
self.logger.error(f"Error processing raw binary: {e}") self.logger.error("Error processing raw binary: %s", e)
return None return None
# --- Factory Function --- # --- Factory Function ---
_serial_protocol_handlers = { _serial_protocol_handlers = {
'json_newline': JsonNewlineProtocol, "json_newline": JsonNewlineProtocol,
'raw_serial': RawSerialProtocol, "raw_serial": RawSerialProtocol,
} }
def get_serial_protocol_handler(protocol_name: str) -> MeshcoreProtocolHandler: def get_serial_protocol_handler(protocol_name: str) -> MeshcoreProtocolHandler:
"""Factory function to get an instance of the appropriate **Serial** protocol handler.""" """Factory function to get an instance of the appropriate
**Serial** protocol handler."""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
protocol_name_lower = protocol_name.lower() protocol_name_lower = protocol_name.lower()
handler_class = _serial_protocol_handlers.get(protocol_name_lower) handler_class = _serial_protocol_handlers.get(protocol_name_lower)
if handler_class: if handler_class:
logger.info(f"Using Serial protocol handler: {handler_class.__name__}") logger.info(
"Using Serial protocol handler: %s",
handler_class.__name__,
)
return handler_class() return handler_class()
else: else:
logger.error(f"Unsupported Serial protocol: '{protocol_name}'. Available: {list(_serial_protocol_handlers.keys())}") logger.error(
"Unsupported Serial protocol: %s. "
"Available: %s",
protocol_name,
list(_serial_protocol_handlers.keys()),
)
raise ValueError(f"Unsupported Serial protocol: {protocol_name}") raise ValueError(f"Unsupported Serial protocol: {protocol_name}")

View File

@@ -19,7 +19,8 @@ class RateLimiter:
Args: Args:
max_messages: Maximum number of messages allowed max_messages: Maximum number of messages allowed
time_window: Time window in seconds (default: 60 seconds = 1 minute) time_window: Time window in seconds (default: 60 seconds)
# (1 minute)
""" """
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.max_messages = max_messages self.max_messages = max_messages
@@ -36,18 +37,24 @@ class RateLimiter:
True if message should be allowed, False if rate limit exceeded True if message should be allowed, False if rate limit exceeded
""" """
now = time.time() now = time.time()
with self._lock: with self._lock:
# Remove old message timestamps outside the time window # Remove old message timestamps outside the time window
while self.message_times and (now - self.message_times[0]) > self.time_window: while (
self.message_times
and (now - self.message_times[0]) > self.time_window
):
self.message_times.popleft() self.message_times.popleft()
# Check if we're at the limit # Check if we're at the limit
if len(self.message_times) >= self.max_messages: if len(self.message_times) >= self.max_messages:
self.violations += 1 self.violations += 1
self.logger.warning( self.logger.warning(
f"Rate limit exceeded for {source}: " "Rate limit exceeded for %s: %s/%s messages in %ss",
f"{len(self.message_times)}/{self.max_messages} messages in {self.time_window}s" source,
len(self.message_times),
self.max_messages,
self.time_window,
) )
return False return False
@@ -60,10 +67,12 @@ class RateLimiter:
with self._lock: with self._lock:
if not self.message_times: if not self.message_times:
return 0.0 return 0.0
now = time.time() now = time.time()
# Count messages in the last minute # Count messages in the last minute
recent_count = sum(1 for t in self.message_times if (now - t) <= 60.0) recent_count = sum(
1 for t in self.message_times if (now - t) <= 60.0
)
return recent_count return recent_count
def reset(self): def reset(self):
@@ -97,7 +106,9 @@ class MultiSourceRateLimiter:
"""Check rate limit for a specific source.""" """Check rate limit for a specific source."""
with self._lock: with self._lock:
if source not in self.limiters: if source not in self.limiters:
self.limiters[source] = RateLimiter(self.max_messages, self.time_window) self.limiters[source] = RateLimiter(
self.max_messages, self.time_window
)
return self.limiters[source].check_rate_limit(source) return self.limiters[source].check_rate_limit(source)
def get_stats(self) -> dict: def get_stats(self) -> dict:
@@ -117,4 +128,3 @@ class MultiSourceRateLimiter:
else: else:
for limiter in self.limiters.values(): for limiter in self.limiters.values():
limiter.reset() limiter.reset()

View File

@@ -5,8 +5,11 @@ Shared utilities for the AMMB application, primarily logging setup.
import logging import logging
LOG_FORMAT = '%(asctime)s - %(threadName)s - %(levelname)s - %(name)s - %(message)s' LOG_FORMAT = (
DATE_FORMAT = '%Y-%m-%d %H:%M:%S' "%(asctime)s - %(threadName)s - %(levelname)s - %(name)s - %(message)s"
)
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
def setup_logging(log_level_str: str): def setup_logging(log_level_str: str):
""" """
@@ -15,15 +18,15 @@ def setup_logging(log_level_str: str):
numeric_level = getattr(logging, log_level_str.upper(), None) numeric_level = getattr(logging, log_level_str.upper(), None)
if not isinstance(numeric_level, int): if not isinstance(numeric_level, int):
logging.warning( logging.warning(
f"Invalid log level specified: '{log_level_str}'. Defaulting to INFO." "Invalid log level specified: '%s'. Defaulting to INFO.",
log_level_str,
) )
numeric_level = logging.INFO numeric_level = logging.INFO
# Reconfigure the root logger # Reconfigure the root logger
logging.basicConfig(level=numeric_level, logging.basicConfig(
format=LOG_FORMAT, level=numeric_level, format=LOG_FORMAT, datefmt=DATE_FORMAT, force=True
datefmt=DATE_FORMAT, )
force=True)
# Adjust logging levels for noisy libraries # Adjust logging levels for noisy libraries
logging.getLogger("pypubsub").setLevel(logging.WARNING) logging.getLogger("pypubsub").setLevel(logging.WARNING)
@@ -31,4 +34,8 @@ def setup_logging(log_level_str: str):
logging.getLogger("meshtastic").setLevel(logging.INFO) logging.getLogger("meshtastic").setLevel(logging.INFO)
logging.getLogger("paho").setLevel(logging.WARNING) logging.getLogger("paho").setLevel(logging.WARNING)
logging.info(f"Logging configured to level {logging.getLevelName(numeric_level)} ({numeric_level})") logging.info(
"Logging configured to level %s (%s)",
logging.getLevelName(numeric_level),
numeric_level,
)

View File

@@ -5,21 +5,32 @@ Message validation and sanitization utilities.
import logging import logging
import re import re
from typing import Dict, Any, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from datetime import datetime
class MessageValidator: class MessageValidator:
"""Validates and sanitizes messages.""" """Validates and sanitizes messages."""
def __init__(self, max_message_length: int = 240, max_payload_length: int = 1000): def __init__(
self,
max_message_length: int = 240,
max_payload_length: int = 1000,
):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.max_message_length = max_message_length self.max_message_length = max_message_length
self.max_payload_length = max_payload_length self.max_payload_length = max_payload_length
# Patterns for validation # Patterns for validation
self.meshtastic_id_pattern = re.compile(r'^!?[0-9a-fA-F]{8}$|^\^all$|^\^broadcast$') self.meshtastic_id_pattern = re.compile(
self.safe_string_pattern = re.compile(r'^[\x20-\x7E\n\r\t]*$') # Printable ASCII + newlines/tabs (
r"^!?[0-9a-fA-F]{8}$|"
r"^\^all$|"
r"^\^broadcast$"
)
)
self.safe_string_pattern = re.compile(
r"^[\x20-\x7E\n\r\t]*$"
) # Printable ASCII + newlines/tabs
def validate_meshtastic_id(self, node_id: str) -> bool: def validate_meshtastic_id(self, node_id: str) -> bool:
"""Validate a Meshtastic node ID format.""" """Validate a Meshtastic node ID format."""
@@ -27,48 +38,52 @@ class MessageValidator:
return False return False
return bool(self.meshtastic_id_pattern.match(node_id)) return bool(self.meshtastic_id_pattern.match(node_id))
def sanitize_string(self, text: str, max_length: Optional[int] = None) -> str: def sanitize_string(
self, text: str, max_length: Optional[int] = None
) -> str:
"""Sanitize a string for safe transmission.""" """Sanitize a string for safe transmission."""
if not isinstance(text, str): if not isinstance(text, str):
text = str(text) text = str(text)
# Remove null bytes and control characters (except newline, tab, carriage return) # Remove nulls and control chars except newline/tab/CR
sanitized = ''.join( sanitized = "".join(c for c in text if ord(c) >= 32 or c in "\n\r\t")
c for c in text
if ord(c) >= 32 or c in '\n\r\t'
)
# Truncate if needed # Truncate if needed
max_len = max_length or self.max_message_length max_len = max_length or self.max_message_length
if len(sanitized) > max_len: if len(sanitized) > max_len:
sanitized = sanitized[:max_len] sanitized = sanitized[:max_len]
self.logger.warning(f"String truncated to {max_len} characters") self.logger.warning("String truncated to %s characters", max_len)
return sanitized return sanitized
def validate_meshtastic_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]: def validate_meshtastic_message(
self, message: Dict[str, Any]
) -> Tuple[bool, Optional[str]]:
"""Validate a message destined for Meshtastic.""" """Validate a message destined for Meshtastic."""
if not isinstance(message, dict): if not isinstance(message, dict):
return False, "Message must be a dictionary" return False, "Message must be a dictionary"
destination = message.get('destination') destination = message.get("destination")
if not destination: if not destination:
return False, "Missing 'destination' field" return False, "Missing 'destination' field"
if not self.validate_meshtastic_id(destination): if not self.validate_meshtastic_id(destination):
return False, f"Invalid destination format: {destination}" return False, f"Invalid destination format: {destination}"
text = message.get('text') text = message.get("text")
if not isinstance(text, str): if not isinstance(text, str):
return False, "Missing or invalid 'text' field" return False, "Missing or invalid 'text' field"
if len(text) > self.max_message_length: if len(text) > self.max_message_length:
return False, f"Message too long: {len(text)} > {self.max_message_length}" msg = "Message too long: %s > %s" % (
len(text), self.max_message_length
)
return False, msg
channel_index = message.get('channel_index', 0) channel_index = message.get("channel_index", 0)
if not isinstance(channel_index, (int, str)): if not isinstance(channel_index, (int, str)):
return False, "Invalid 'channel_index' type" return False, "Invalid 'channel_index' type"
try: try:
channel_index = int(channel_index) channel_index = int(channel_index)
if channel_index < 0 or channel_index > 7: if channel_index < 0 or channel_index > 7:
@@ -78,72 +93,99 @@ class MessageValidator:
return True, None return True, None
def validate_external_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]: def validate_external_message(
self, message: Dict[str, Any]
) -> Tuple[bool, Optional[str]]:
"""Validate a message from external system.""" """Validate a message from external system."""
if not isinstance(message, dict): if not isinstance(message, dict):
return False, "Message must be a dictionary" return False, "Message must be a dictionary"
# Check for required fields based on message type # Check for required fields based on message type
payload = message.get('payload') payload = message.get("payload")
payload_json = message.get('payload_json') payload_json = message.get("payload_json")
if payload is None and payload_json is None: if payload is None and payload_json is None:
return False, "Missing 'payload' or 'payload_json' field" return False, "Missing 'payload' or 'payload_json' field"
destination = message.get('destination_meshtastic_id') destination = message.get("destination_meshtastic_id")
if destination and not self.validate_meshtastic_id(destination): if destination and not self.validate_meshtastic_id(destination):
return False, f"Invalid destination_meshtastic_id format: {destination}" msg = (
"Invalid destination_meshtastic_id format: %s" % (destination,)
)
return False, msg
# Validate payload length # Validate payload length
if payload and isinstance(payload, str) and len(payload) > self.max_payload_length: if (
return False, f"Payload too long: {len(payload)} > {self.max_payload_length}" payload
and isinstance(payload, str)
and len(payload) > self.max_payload_length
):
msg = "Payload too long: %s > %s" % (
len(payload), self.max_payload_length
)
return False, msg
return True, None return True, None
def sanitize_meshtastic_message(self, message: Dict[str, Any]) -> Dict[str, Any]: def sanitize_meshtastic_message(
self, message: Dict[str, Any]
) -> Dict[str, Any]:
"""Sanitize a message for Meshtastic.""" """Sanitize a message for Meshtastic."""
sanitized = message.copy() sanitized = message.copy()
# Sanitize destination # Sanitize destination
if 'destination' in sanitized: if "destination" in sanitized:
dest = str(sanitized['destination']).strip() dest = str(sanitized["destination"]).strip()
if not self.validate_meshtastic_id(dest): if not self.validate_meshtastic_id(dest):
self.logger.warning(f"Invalid destination, using broadcast: {dest}") self.logger.warning(
dest = '^all' "Invalid destination, using broadcast: %s",
sanitized['destination'] = dest dest,
)
dest = "^all"
sanitized["destination"] = dest
# Sanitize text # Sanitize text
if 'text' in sanitized: if "text" in sanitized:
sanitized['text'] = self.sanitize_string(sanitized['text'], self.max_message_length) sanitized["text"] = self.sanitize_string(
sanitized["text"], self.max_message_length
)
# Ensure channel_index is valid # Ensure channel_index is valid
if 'channel_index' in sanitized: if "channel_index" in sanitized:
try: try:
sanitized['channel_index'] = max(0, min(7, int(sanitized['channel_index']))) sanitized["channel_index"] = max(
0, min(7, int(sanitized["channel_index"]))
)
except (ValueError, TypeError): except (ValueError, TypeError):
sanitized['channel_index'] = 0 sanitized["channel_index"] = 0
# Ensure want_ack is boolean # Ensure want_ack is boolean
if 'want_ack' in sanitized: if "want_ack" in sanitized:
sanitized['want_ack'] = bool(sanitized['want_ack']) sanitized["want_ack"] = bool(sanitized["want_ack"])
return sanitized return sanitized
def sanitize_external_message(self, message: Dict[str, Any]) -> Dict[str, Any]: def sanitize_external_message(
self, message: Dict[str, Any]
) -> Dict[str, Any]:
"""Sanitize a message from external system.""" """Sanitize a message from external system."""
sanitized = message.copy() sanitized = message.copy()
# Sanitize destination if present # Sanitize destination if present
if 'destination_meshtastic_id' in sanitized: if "destination_meshtastic_id" in sanitized:
dest = str(sanitized['destination_meshtastic_id']).strip() dest = str(sanitized["destination_meshtastic_id"]).strip()
if not self.validate_meshtastic_id(dest): if not self.validate_meshtastic_id(dest):
self.logger.warning(f"Invalid destination, using broadcast: {dest}") self.logger.warning(
dest = '^all' "Invalid destination, using broadcast: %s",
sanitized['destination_meshtastic_id'] = dest dest,
)
dest = "^all"
sanitized["destination_meshtastic_id"] = dest
# Sanitize payload if it's a string # Sanitize payload if it's a string
if 'payload' in sanitized and isinstance(sanitized['payload'], str): if "payload" in sanitized and isinstance(sanitized["payload"], str):
sanitized['payload'] = self.sanitize_string(sanitized['payload'], self.max_payload_length) sanitized["payload"] = self.sanitize_string(
sanitized["payload"], self.max_payload_length
)
return sanitized return sanitized

View File

@@ -3,11 +3,12 @@
Simple Meshcore Serial Device Simulator. Simple Meshcore Serial Device Simulator.
""" """
import serial
import time
import json import json
import threading
import random import random
import threading
import time
import serial
SIMULATOR_PORT = "/dev/ttyS1" SIMULATOR_PORT = "/dev/ttyS1"
BAUD_RATE = 9600 BAUD_RATE = 9600
@@ -17,6 +18,7 @@ SENSOR_INTERVAL_S = 15
shutdown_event = threading.Event() shutdown_event = threading.Event()
serial_port = None serial_port = None
def serial_reader(): def serial_reader():
print("[Reader] Serial reader thread started.") print("[Reader] Serial reader thread started.")
while not shutdown_event.is_set(): while not shutdown_event.is_set():
@@ -26,18 +28,26 @@ def serial_reader():
line = serial_port.readline() line = serial_port.readline()
if line: if line:
try: try:
decoded_line = line.decode('utf-8').strip() decoded_line = line.decode("utf-8").strip()
print(f"\n[Reader] <<< Received: {decoded_line}") print(
"[Reader] <<< Received: %s" % decoded_line
)
except UnicodeDecodeError: except UnicodeDecodeError:
print(f"\n[Reader] <<< Received non-UTF8 data: {line!r}") print(
"[Reader] <<< Received non-UTF8 data: %r"
% (line,)
)
else: else:
time.sleep(0.1) time.sleep(0.1)
except Exception as e: except Exception as e:
print(f"\n[Reader] Error: {e}") print(
f"\n[Reader] Error: {e}"
)
time.sleep(1) time.sleep(1)
else: else:
time.sleep(1) time.sleep(1)
def periodic_sender(): def periodic_sender():
print("[Periodic Sender] Started.") print("[Periodic Sender] Started.")
while not shutdown_event.is_set(): while not shutdown_event.is_set():
@@ -46,28 +56,35 @@ def periodic_sender():
sensor_value = round(20 + random.uniform(-2, 2), 2) sensor_value = round(20 + random.uniform(-2, 2), 2)
message = { message = {
"destination_meshtastic_id": "!aabbccdd", "destination_meshtastic_id": "!aabbccdd",
"payload_json": {"type": "temp", "val": sensor_value} "payload_json": {"type": "temp", "val": sensor_value},
} }
message_str = json.dumps(message) + '\n' message_str = json.dumps(message) + "\n"
print(f"\n[Sender] >>> {message_str.strip()}") print(
serial_port.write(message_str.encode('utf-8')) f"\n[Sender] >>> {message_str.strip()}"
)
serial_port.write(message_str.encode("utf-8"))
except Exception as e: except Exception as e:
print(f"\n[Sender] Error: {e}") print(
f"\n[Sender] Error: {e}"
)
shutdown_event.wait(SENSOR_INTERVAL_S) shutdown_event.wait(SENSOR_INTERVAL_S)
def main(): def main():
global serial_port global serial_port
print("--- Meshcore Simulator ---") print("--- Meshcore Simulator ---")
reader = threading.Thread(target=serial_reader, daemon=True) reader = threading.Thread(target=serial_reader, daemon=True)
reader.start() reader.start()
if SEND_PERIODIC_SENSOR_DATA: if SEND_PERIODIC_SENSOR_DATA:
sender = threading.Thread(target=periodic_sender, daemon=True) sender = threading.Thread(target=periodic_sender, daemon=True)
sender.start() sender.start()
while not shutdown_event.is_set(): while not shutdown_event.is_set():
if not serial_port or not serial_port.is_open: if not serial_port or not serial_port.is_open:
try: try:
serial_port = serial.Serial(SIMULATOR_PORT, BAUD_RATE, timeout=0.5) serial_port = serial.Serial(
SIMULATOR_PORT, BAUD_RATE, timeout=0.5
)
print(f"[Main] Opened {SIMULATOR_PORT}") print(f"[Main] Opened {SIMULATOR_PORT}")
except Exception: except Exception:
time.sleep(5) time.sleep(5)
@@ -75,13 +92,15 @@ def main():
try: try:
user_input = input() user_input = input()
if user_input: if user_input:
msg = user_input.encode('utf-8') + b'\n' msg = user_input.encode("utf-8") + b"\n"
serial_port.write(msg) serial_port.write(msg)
except KeyboardInterrupt: except KeyboardInterrupt:
break break
shutdown_event.set() shutdown_event.set()
if serial_port: serial_port.close() if serial_port:
serial_port.close()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# run_bridge.py # run_bridge.py
""" """
Executable script to initialize and run the Akita Meshtastic Meshcore Bridge (AMMB). Executable script to initialize and run the Akita Meshtastic Meshcore
Bridge (AMMB).
This script handles: This script handles:
- Checking for essential dependencies. - Checking for essential dependencies.
@@ -11,9 +12,9 @@ This script handles:
- Handling graceful shutdown on KeyboardInterrupt (Ctrl+C). - Handling graceful shutdown on KeyboardInterrupt (Ctrl+C).
""" """
import sys
import logging import logging
import os import os
import sys
# Ensure the script can find the 'ammb' package # Ensure the script can find the 'ammb' package
project_root = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(os.path.abspath(__file__))
@@ -21,37 +22,46 @@ sys.path.insert(0, project_root)
# --- Dependency Check --- # --- Dependency Check ---
try: try:
import configparser import configparser # noqa: F401
import queue import json # noqa: F401
import threading import queue # noqa: F401
import time import threading # noqa: F401
import json import time # noqa: F401
import serial
import paho.mqtt.client as paho_mqtt import meshtastic # noqa: F401
from pubsub import pub import meshtastic.serial_interface # noqa: F401
import meshtastic import paho.mqtt.client as paho_mqtt # noqa: F401
import meshtastic.serial_interface import serial # noqa: F401
from pubsub import pub # noqa: F401
except ImportError as e: except ImportError as e:
print(f"ERROR: Missing required library - {e.name}", file=sys.stderr) print("ERROR: Missing required library - %s" % e.name, file=sys.stderr)
print("Please install required libraries by running:", file=sys.stderr) print("Please install required libraries by running:", file=sys.stderr)
print(f" pip install -r {os.path.join(project_root, 'requirements.txt')}", file=sys.stderr) print(
" pip install -r %s" % os.path.join(project_root, 'requirements.txt'),
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
# --- Imports --- # --- Imports ---
try: try:
from ammb import Bridge from ammb import Bridge
from ammb.config_handler import CONFIG_FILE, load_config
from ammb.utils import setup_logging from ammb.utils import setup_logging
from ammb.config_handler import load_config, CONFIG_FILE
except ImportError as e: except ImportError as e:
print(f"ERROR: Failed to import AMMB modules: {e}", file=sys.stderr) print(f"ERROR: Failed to import AMMB modules: {e}", file=sys.stderr)
print("Ensure the script is run from the project root directory", file=sys.stderr) print(
"Ensure the script is run from the project root directory",
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
# --- Main Execution --- # --- Main Execution ---
if __name__ == "__main__": if __name__ == "__main__":
# Basic logging setup until config is loaded # Basic logging setup until config is loaded
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logging.info("--- Akita Meshtastic Meshcore Bridge Starting ---") logging.info("--- Akita Meshtastic Meshcore Bridge Starting ---")
# --- Configuration Loading --- # --- Configuration Loading ---
@@ -71,19 +81,26 @@ if __name__ == "__main__":
# --- Bridge Initialization and Execution --- # --- Bridge Initialization and Execution ---
logging.info("Initializing bridge instance...") logging.info("Initializing bridge instance...")
bridge = Bridge(config) bridge = Bridge(config)
# Check if external handler was successfully created # Check if external handler was successfully created
if not bridge.external_handler: if not bridge.external_handler:
logging.critical("Bridge initialization failed (likely handler issue). Exiting.") logging.critical(
sys.exit(1) "Bridge initialization failed (likely handler issue). Exiting."
)
sys.exit(1)
try: try:
logging.info("Starting bridge run loop...") logging.info("Starting bridge run loop...")
bridge.run() bridge.run()
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info("KeyboardInterrupt received. Initiating graceful shutdown...") logging.info(
"KeyboardInterrupt received. Initiating graceful shutdown..."
)
except Exception as e: except Exception as e:
logging.critical(f"Unhandled critical exception in bridge execution: {e}", exc_info=True) logging.critical(
f"Unhandled critical exception in bridge execution: {e}",
exc_info=True,
)
logging.info("Attempting emergency shutdown...") logging.info("Attempting emergency shutdown...")
bridge.stop() bridge.stop()
sys.exit(1) sys.exit(1)

View File

@@ -2,4 +2,5 @@
""" """
Initialization file for the AMMB test suite package. Initialization file for the AMMB test suite package.
""" """
# This file can be empty or contain package-level test setup/fixtures if needed.
# This file can be empty or contain package-level setup.

View File

@@ -1,20 +1,21 @@
import pytest
import configparser import configparser
import pytest
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def temp_config_file(tmp_path): def temp_config_file(tmp_path):
config_path = tmp_path / "config.ini" config_path = tmp_path / "config.ini"
parser = configparser.ConfigParser() parser = configparser.ConfigParser()
parser['DEFAULT'] = { parser["DEFAULT"] = {
'MESHTASTIC_SERIAL_PORT': '/dev/test_meshtastic', "MESHTASTIC_SERIAL_PORT": "/dev/test_meshtastic",
'EXTERNAL_TRANSPORT': 'serial', "EXTERNAL_TRANSPORT": "serial",
'SERIAL_PORT': '/dev/test_meshcore', "SERIAL_PORT": "/dev/test_meshcore",
'SERIAL_BAUD_RATE': '19200', "SERIAL_BAUD_RATE": "19200",
'SERIAL_PROTOCOL': 'json_newline', "SERIAL_PROTOCOL": "json_newline",
'MESSAGE_QUEUE_SIZE': '50', "MESSAGE_QUEUE_SIZE": "50",
'LOG_LEVEL': 'DEBUG', "LOG_LEVEL": "DEBUG",
} }
with open(config_path, 'w') as f: with open(config_path, "w") as f:
parser.write(f) parser.write(f)
yield str(config_path) yield str(config_path)

View File

@@ -1,6 +1,5 @@
import pytest from ammb.protocol import JsonNewlineProtocol, get_serial_protocol_handler
import json
from ammb.protocol import get_serial_protocol_handler, JsonNewlineProtocol
def test_json_newline_encode(): def test_json_newline_encode():
handler = JsonNewlineProtocol() handler = JsonNewlineProtocol()
@@ -8,12 +7,14 @@ def test_json_newline_encode():
encoded = handler.encode(data) encoded = handler.encode(data)
assert encoded == b'{"key": "value"}\n' assert encoded == b'{"key": "value"}\n'
def test_factory_function(): def test_factory_function():
handler = get_serial_protocol_handler('json_newline') handler = get_serial_protocol_handler("json_newline")
assert isinstance(handler, JsonNewlineProtocol) assert isinstance(handler, JsonNewlineProtocol)
def test_raw_serial_handler(): def test_raw_serial_handler():
handler = get_serial_protocol_handler('raw_serial') handler = get_serial_protocol_handler("raw_serial")
raw_data = b'\x01\x02\x03' raw_data = b"\x01\x02\x03"
decoded = handler.decode(raw_data) decoded = handler.decode(raw_data)
assert decoded['payload'] == "MC_BIN: 010203" assert decoded["payload"] == "MC_BIN: 010203"

View File

@@ -5,10 +5,12 @@ def test_load_config_missing_default(tmp_path):
cfgfile = tmp_path / "no_default.ini" cfgfile = tmp_path / "no_default.ini"
cfgfile.write_text("[serial]\nSERIAL_PORT=/dev/ttyS1\n") cfgfile.write_text("[serial]\nSERIAL_PORT=/dev/ttyS1\n")
spec = importlib.util.spec_from_file_location('config_handler', 'ammb/config_handler.py') spec = importlib.util.spec_from_file_location(
"config_handler", "ammb/config_handler.py"
)
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
cfg = mod.load_config(str(cfgfile)) cfg = mod.load_config(str(cfgfile))
assert cfg is not None assert cfg is not None
assert cfg.log_level == 'INFO' assert cfg.log_level == "INFO"

View File

@@ -4,81 +4,104 @@ Tests for the ammb.protocol module, focusing on protocol handlers.
""" """
import pytest import pytest
import json
# Module to test # Module to test
from ammb.protocol import get_serial_protocol_handler, JsonNewlineProtocol, MeshcoreProtocolHandler from ammb.protocol import (
JsonNewlineProtocol,
get_serial_protocol_handler,
)
# --- Test JsonNewlineProtocol --- # --- Test JsonNewlineProtocol ---
@pytest.fixture @pytest.fixture
def json_handler() -> JsonNewlineProtocol: def json_handler() -> JsonNewlineProtocol:
"""Provides an instance of the JsonNewlineProtocol handler.""" """Provides an instance of the JsonNewlineProtocol handler."""
return JsonNewlineProtocol() return JsonNewlineProtocol()
# Parameterize test data for encoding # Parameterize test data for encoding
encode_test_data = [ encode_test_data = [
({"key": "value", "num": 123}, b'{"key": "value", "num": 123}\n'), ({"key": "value", "num": 123}, b'{"key": "value", "num": 123}\n'),
({"list": [1, 2, None]}, b'{"list": [1, 2, null]}\n'), ({"list": [1, 2, None]}, b'{"list": [1, 2, null]}\n'),
({}, b'{}\n'), ({}, b"{}\n"),
] ]
@pytest.mark.parametrize("input_dict, expected_bytes", encode_test_data) @pytest.mark.parametrize("input_dict, expected_bytes", encode_test_data)
def test_json_newline_encode_success(json_handler: JsonNewlineProtocol, input_dict: dict, expected_bytes: bytes): def test_json_newline_encode_success(
json_handler: JsonNewlineProtocol, input_dict: dict, expected_bytes: bytes
):
"""Test successful encoding with JsonNewlineProtocol.""" """Test successful encoding with JsonNewlineProtocol."""
result = json_handler.encode(input_dict) result = json_handler.encode(input_dict)
assert result == expected_bytes assert result == expected_bytes
def test_json_newline_encode_error(json_handler: JsonNewlineProtocol): def test_json_newline_encode_error(json_handler: JsonNewlineProtocol):
"""Test encoding data that cannot be JSON serialized.""" """Test encoding data that cannot be JSON serialized."""
# Sets cannot be directly JSON serialized # Sets cannot be directly JSON serialized
result = json_handler.encode({"data": {1, 2, 3}}) result = json_handler.encode({"data": {1, 2, 3}})
assert result is None assert result is None
# Parameterize test data for decoding # Parameterize test data for decoding
decode_test_data = [ decode_test_data = [
(b'{"key": "value", "num": 123}\n', {"key": "value", "num": 123}), (b'{"key": "value", "num": 123}\n', {"key": "value", "num": 123}),
(b'{"list": [1, 2, null]} \r\n', {"list": [1, 2, None]}), # Handle trailing whitespace/CR (
(b'{}', {}), b'{"list": [1, 2, null]} \r\n',
{"list": [1, 2, None]},
), # Handle trailing whitespace/CR
(b"{}", {}),
] ]
@pytest.mark.parametrize("input_bytes, expected_dict", decode_test_data) @pytest.mark.parametrize("input_bytes, expected_dict", decode_test_data)
def test_json_newline_decode_success(json_handler: JsonNewlineProtocol, input_bytes: bytes, expected_dict: dict): def test_json_newline_decode_success(
json_handler: JsonNewlineProtocol, input_bytes: bytes, expected_dict: dict
):
"""Test successful decoding with JsonNewlineProtocol.""" """Test successful decoding with JsonNewlineProtocol."""
result = json_handler.decode(input_bytes) result = json_handler.decode(input_bytes)
assert result == expected_dict assert result == expected_dict
# Parameterize invalid data for decoding # Parameterize invalid data for decoding
decode_error_data = [ decode_error_data = [
b'this is not json\n', # Invalid JSON b"this is not json\n", # Invalid JSON
b'{"key": "value",\n', # Incomplete JSON b'{"key": "value",\n', # Incomplete JSON
b'{"key": value_without_quotes}\n', # Invalid JSON syntax b'{"key": value_without_quotes}\n', # Invalid JSON syntax
b'\x80\x81\x82\n', # Invalid UTF-8 start bytes b"\x80\x81\x82\n", # Invalid UTF-8 start bytes
b'', # Empty bytes b"", # Empty bytes
b' \n', # Whitespace only line b" \n", # Whitespace only line
b'["list", "not_dict"]\n', # Valid JSON, but not a dictionary b'["list", "not_dict"]\n', # Valid JSON, but not a dictionary
] ]
@pytest.mark.parametrize("invalid_bytes", decode_error_data) @pytest.mark.parametrize("invalid_bytes", decode_error_data)
def test_json_newline_decode_errors(json_handler: JsonNewlineProtocol, invalid_bytes: bytes): def test_json_newline_decode_errors(
json_handler: JsonNewlineProtocol, invalid_bytes: bytes
):
"""Test decoding various forms of invalid input.""" """Test decoding various forms of invalid input."""
result = json_handler.decode(invalid_bytes) result = json_handler.decode(invalid_bytes)
assert result is None assert result is None
# --- Test Factory Function --- # --- Test Factory Function ---
def test_get_protocol_handler_success(): def test_get_protocol_handler_success():
"""Test getting a known protocol handler.""" """Test getting a known protocol handler."""
handler = get_serial_protocol_handler('json_newline') handler = get_serial_protocol_handler("json_newline")
assert isinstance(handler, JsonNewlineProtocol) assert isinstance(handler, JsonNewlineProtocol)
# Test case insensitivity # Test case insensitivity
handler_upper = get_serial_protocol_handler('JSON_NEWLINE') handler_upper = get_serial_protocol_handler("JSON_NEWLINE")
assert isinstance(handler_upper, JsonNewlineProtocol) assert isinstance(handler_upper, JsonNewlineProtocol)
def test_get_protocol_handler_unsupported(): def test_get_protocol_handler_unsupported():
"""Test getting an unknown protocol handler raises ValueError.""" """Test getting an unknown protocol handler raises ValueError."""
with pytest.raises(ValueError): with pytest.raises(ValueError):
get_serial_protocol_handler('unknown_protocol') get_serial_protocol_handler("unknown_protocol")
# Add tests for other protocol handlers (e.g., PlainTextProtocol) when implemented.
# Add tests for other protocol handlers (e.g., PlainTextProtocol)
# when implemented.

View File

@@ -1,6 +1,11 @@
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location('config_handler', 'ammb/config_handler.py')
spec = importlib.util.spec_from_file_location(
"config_handler", "ammb/config_handler.py"
)
if spec is None or spec.loader is None:
raise SystemExit("Failed to locate module spec for config_handler")
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
print('Using file: tmp_no_default.ini') print("Using file: tmp_no_default.ini")
print(mod.load_config('tmp_no_default.ini')) print(mod.load_config("tmp_no_default.ini"))

View File

@@ -1,7 +1,8 @@
from ammb import config_handler from ammb import config_handler
path='tmp_no_default.ini'
with open(path,'w') as f: path = "tmp_no_default.ini"
f.write('[serial]\nSERIAL_PORT=/dev/ttyS1\n') with open(path, "w") as f:
print('Using file:', path) f.write("[serial]\nSERIAL_PORT=/dev/ttyS1\n")
print("Using file:", path)
cfg = config_handler.load_config(path) cfg = config_handler.load_config(path)
print('Result:', cfg) print("Result:", cfg)