- Include device_status_timeout_s in Odoo bridge payload - Resolve status monitor timeout robustly with backward-compatible fallbacks - Update running status monitor timeout on POST /config without restart - Keep compatibility for legacy/local configs without device_status_timeout_s Result: shaperorigin uses configured 90s timeout for online/offline monitor, preventing 30s flapping.
446 lines
17 KiB
Python
446 lines
17 KiB
Python
"""
|
|
Service Manager for IoT Bridge.
|
|
|
|
Manages service lifecycle, graceful shutdown, and component coordination.
|
|
"""
|
|
|
|
import signal
|
|
import threading
|
|
from typing import Any
|
|
|
|
from api.models import BridgeConfig, DeviceConfig as ApiDeviceConfig, MqttConfig, SessionConfig as ApiSessionConfig
|
|
from api.server import ConfigServer
|
|
from clients.mqtt_client import MQTTClient
|
|
from clients.odoo_client import MockOdooClient, OdooClient
|
|
from core.device_manager import DeviceManager
|
|
from core.event_queue import EventQueue
|
|
from utils.status_monitor import DeviceStatusMonitor
|
|
|
|
|
|
class ServiceManager:
|
|
"""
|
|
Manages all IoT Bridge services and their lifecycle.
|
|
|
|
Coordinates:
|
|
- MQTT client connection and reconnection
|
|
- Event queue with Odoo integration
|
|
- Device manager and session detection
|
|
- HTTP config server
|
|
- Device status monitoring
|
|
- Graceful shutdown
|
|
"""
|
|
|
|
def __init__(self, config, logger, bridge_port: int, bridge_token: str | None) -> None:
|
|
"""
|
|
Initialize service manager.
|
|
|
|
Args:
|
|
config: Bridge configuration (or None)
|
|
logger: Logger instance
|
|
bridge_port: HTTP API port
|
|
bridge_token: Optional API authentication token
|
|
"""
|
|
self.config = config
|
|
self.logger = logger
|
|
self.bridge_port: int = bridge_port
|
|
self.bridge_token: str | None = bridge_token
|
|
|
|
# Service components
|
|
self.mqtt_client: MQTTClient | None = None
|
|
self.odoo_client: OdooClient | MockOdooClient | None = None
|
|
self.event_queue: EventQueue | None = None
|
|
self.device_manager: DeviceManager | None = None
|
|
self.status_monitor: DeviceStatusMonitor | None = None
|
|
self.config_server: ConfigServer | None = None
|
|
self.http_server_thread: threading.Thread | None = None
|
|
|
|
# Shutdown flag
|
|
self.shutdown_flag = False
|
|
|
|
def _resolve_device_status_timeout(self, devices: list[Any], fallback_timeout: int) -> int:
|
|
"""Resolve status monitor timeout from device config with backward-compatible fallbacks."""
|
|
if not devices:
|
|
return int(fallback_timeout)
|
|
|
|
first_device = devices[0]
|
|
|
|
if hasattr(first_device, "device_status_timeout_s"):
|
|
return int(getattr(first_device, "device_status_timeout_s"))
|
|
|
|
session_config = getattr(first_device, "session_config", None)
|
|
if session_config is not None and hasattr(session_config, "message_timeout_s"):
|
|
return int(getattr(session_config, "message_timeout_s"))
|
|
|
|
return int(fallback_timeout)
|
|
|
|
def setup_signal_handlers(self) -> None:
|
|
"""Register signal handlers for graceful shutdown."""
|
|
|
|
def signal_handler(signum, frame) -> None:
|
|
self.logger.info("shutdown_signal_received", signal=signum)
|
|
self.shutdown_flag = True
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
self.logger.info("signal_handlers_registered")
|
|
|
|
def initialize_odoo_client(self) -> OdooClient | MockOdooClient | None:
|
|
"""
|
|
Initialize Odoo client for event sending.
|
|
|
|
Returns:
|
|
Odoo client instance (mock or real) or None
|
|
"""
|
|
if not self.config:
|
|
self.logger.warning("no_odoo_client_config", message="Will use default values")
|
|
return None
|
|
|
|
if self.config.odoo.use_mock:
|
|
failure_rate: Any | float = getattr(self.config.odoo, "mock_failure_rate", 0.0)
|
|
self.logger.info("using_mock_odoo_client", failure_rate=failure_rate)
|
|
return MockOdooClient(self.config.devices, failure_rate=failure_rate)
|
|
else:
|
|
self.logger.info(
|
|
"using_real_odoo_client",
|
|
base_url=self.config.odoo.base_url,
|
|
database=self.config.odoo.database,
|
|
)
|
|
return OdooClient(
|
|
base_url=self.config.odoo.base_url,
|
|
database=self.config.odoo.database,
|
|
username=self.config.odoo.username,
|
|
api_key=self.config.odoo.api_key,
|
|
)
|
|
|
|
def initialize_event_queue(self) -> EventQueue:
|
|
"""
|
|
Initialize event queue with Odoo send callback.
|
|
|
|
Returns:
|
|
Event queue instance
|
|
"""
|
|
|
|
def send_to_odoo(event: dict[str, Any]) -> bool:
|
|
"""Send event to Odoo, returns True on success."""
|
|
if not self.odoo_client:
|
|
self.logger.warning("no_odoo_client_event_dropped")
|
|
return False
|
|
try:
|
|
self.odoo_client.send_event(event)
|
|
return True
|
|
except Exception as e:
|
|
self.logger.error("odoo_send_failed", error=str(e))
|
|
return False
|
|
|
|
queue = EventQueue(send_callback=send_to_odoo, logger=self.logger)
|
|
queue.start()
|
|
self.logger.info("event_queue_started")
|
|
return queue
|
|
|
|
def initialize_status_monitor(self) -> DeviceStatusMonitor | None:
|
|
"""
|
|
Initialize device status monitor if enabled.
|
|
|
|
Returns:
|
|
Status monitor instance or None if disabled
|
|
"""
|
|
if not self.config or not self.config.device_status.enabled:
|
|
self.logger.info("device_status_monitor_disabled")
|
|
return None
|
|
|
|
def status_event_callback(event: dict[str, Any]) -> None:
|
|
"""Callback for device_online/offline events."""
|
|
self.logger.info(
|
|
"status_event_generated", type=event["event_type"], device=event["device_id"]
|
|
)
|
|
if self.event_queue is None:
|
|
self.logger.warning("status_event_dropped_no_queue")
|
|
return
|
|
|
|
self.event_queue.enqueue(event)
|
|
|
|
# Use device_status_timeout_s from first device if available (Odoo-pushed config)
|
|
# Fallback to global config value
|
|
timeout_seconds = self._resolve_device_status_timeout(
|
|
self.config.devices,
|
|
int(self.config.device_status.timeout_s),
|
|
)
|
|
if self.config.devices:
|
|
self.logger.info(
|
|
"device_status_monitor_using_device_timeout",
|
|
timeout_s=timeout_seconds,
|
|
device_id=self.config.devices[0].device_id,
|
|
)
|
|
|
|
monitor = DeviceStatusMonitor(
|
|
timeout_seconds=timeout_seconds,
|
|
check_interval_seconds=self.config.device_status.check_interval_s,
|
|
persistence_path=self.config.device_status.persistence_path,
|
|
event_callback=status_event_callback,
|
|
)
|
|
monitor.start()
|
|
self.logger.info(
|
|
"device_status_monitor_started",
|
|
timeout_s=timeout_seconds,
|
|
check_interval_s=self.config.device_status.check_interval_s,
|
|
)
|
|
return monitor
|
|
|
|
def initialize_mqtt_client(self, mqtt_config) -> tuple[MQTTClient, bool]:
|
|
"""
|
|
Initialize and connect MQTT client.
|
|
|
|
Args:
|
|
mqtt_config: MQTT configuration namespace
|
|
|
|
Returns:
|
|
Tuple of (mqtt_client, connected: bool)
|
|
"""
|
|
client = MQTTClient(
|
|
broker=mqtt_config.broker,
|
|
port=mqtt_config.port,
|
|
topics=[], # Start with no topics - added dynamically
|
|
message_callback=None, # Set later via device_manager
|
|
username=mqtt_config.username,
|
|
password=mqtt_config.password,
|
|
client_id=mqtt_config.client_id,
|
|
use_tls=getattr(mqtt_config, "use_tls", False),
|
|
)
|
|
|
|
# Connect to MQTT (non-blocking - Bridge can start without MQTT)
|
|
mqtt_connected = False
|
|
if client.connect():
|
|
client.start()
|
|
mqtt_connected = client.wait_for_connection(timeout=10)
|
|
|
|
if mqtt_connected:
|
|
self.logger.info(
|
|
"mqtt_connected", broker=f"{mqtt_config.broker}:{mqtt_config.port}"
|
|
)
|
|
else:
|
|
self.logger.warning(
|
|
"mqtt_connection_timeout",
|
|
broker=f"{mqtt_config.broker}:{mqtt_config.port}",
|
|
message="Bridge will start anyway. Fix config via Odoo and push.",
|
|
)
|
|
else:
|
|
self.logger.warning(
|
|
"mqtt_connection_failed",
|
|
broker=f"{mqtt_config.broker}:{mqtt_config.port}",
|
|
message="Bridge will start anyway. Fix config via Odoo and push.",
|
|
)
|
|
|
|
return client, mqtt_connected
|
|
|
|
def initialize_config_server(self) -> ConfigServer:
|
|
"""
|
|
Initialize HTTP config server with callbacks.
|
|
|
|
Returns:
|
|
ConfigServer instance
|
|
"""
|
|
|
|
async def config_callback(new_config: BridgeConfig) -> None:
|
|
"""Called when new device config is received via POST /config."""
|
|
self.logger.info("config_push_received", devices=len(new_config.devices))
|
|
if self.device_manager is None:
|
|
self.logger.error("config_push_ignored_device_manager_not_ready")
|
|
return
|
|
self.device_manager.apply_config(new_config)
|
|
|
|
if self.status_monitor is not None:
|
|
old_timeout = int(self.status_monitor.timeout_seconds)
|
|
new_timeout = self._resolve_device_status_timeout(new_config.devices, old_timeout)
|
|
if new_timeout != old_timeout:
|
|
self.status_monitor.timeout_seconds = new_timeout
|
|
self.logger.info(
|
|
"device_status_monitor_timeout_updated",
|
|
old_timeout_s=old_timeout,
|
|
new_timeout_s=new_timeout,
|
|
)
|
|
|
|
async def mqtt_reconnect_callback(mqtt_config) -> None:
|
|
"""Called when MQTT broker config changes via POST /config."""
|
|
self.logger.info(
|
|
"mqtt_reconnect_requested",
|
|
broker=f"{mqtt_config.broker}:{mqtt_config.port}",
|
|
tls=mqtt_config.use_tls,
|
|
)
|
|
|
|
# Reconnect to new broker
|
|
if self.mqtt_client is None:
|
|
self.logger.error("mqtt_reconnect_failed_client_not_ready")
|
|
return
|
|
|
|
success: bool = self.mqtt_client.reconnect(
|
|
broker=mqtt_config.broker,
|
|
port=mqtt_config.port,
|
|
username=mqtt_config.username or None,
|
|
password=mqtt_config.password or None,
|
|
use_tls=mqtt_config.use_tls,
|
|
)
|
|
|
|
if success:
|
|
self.logger.info(
|
|
"mqtt_reconnect_completed",
|
|
broker=f"{mqtt_config.broker}:{mqtt_config.port}",
|
|
status="success",
|
|
)
|
|
else:
|
|
self.logger.error(
|
|
"mqtt_reconnect_failed",
|
|
broker=f"{mqtt_config.broker}:{mqtt_config.port}",
|
|
status="failed",
|
|
)
|
|
|
|
return ConfigServer(
|
|
config_callback=config_callback,
|
|
mqtt_reconnect_callback=mqtt_reconnect_callback,
|
|
token=self.bridge_token,
|
|
)
|
|
|
|
def apply_initial_config(self) -> None:
|
|
"""Apply initial configuration to device manager and config server."""
|
|
if not self.config or not self.config.devices:
|
|
return
|
|
if self.device_manager is None or self.config_server is None:
|
|
self.logger.warning("initial_config_skipped_components_not_ready")
|
|
return
|
|
|
|
# Convert config to BridgeConfig format
|
|
devices_data: list[ApiDeviceConfig] = []
|
|
for device in self.config.devices:
|
|
device_model: ApiDeviceConfig = ApiDeviceConfig(
|
|
device_id=device.device_id,
|
|
machine_name=device.machine_name,
|
|
mqtt_topic=device.mqtt_topic,
|
|
parser_type=getattr(device, "parser_type", "shelly_pm"),
|
|
device_status_timeout_s=getattr(
|
|
device, "device_status_timeout_s", int(device.session_config.message_timeout_s)
|
|
),
|
|
session_config=ApiSessionConfig(
|
|
standby_threshold_w=device.session_config.standby_threshold_w,
|
|
working_threshold_w=device.session_config.working_threshold_w,
|
|
start_debounce_s=device.session_config.start_debounce_s,
|
|
stop_debounce_s=device.session_config.stop_debounce_s,
|
|
message_timeout_s=device.session_config.message_timeout_s,
|
|
heartbeat_interval_s=device.session_config.heartbeat_interval_s,
|
|
),
|
|
)
|
|
devices_data.append(device_model)
|
|
|
|
mqtt_data = None
|
|
if self.config.mqtt:
|
|
mqtt_data = MqttConfig(
|
|
broker=self.config.mqtt.broker,
|
|
port=self.config.mqtt.port,
|
|
username=self.config.mqtt.username or "",
|
|
password=self.config.mqtt.password or "",
|
|
client_id=self.config.mqtt.client_id,
|
|
keepalive=getattr(self.config.mqtt, "keepalive", 60),
|
|
use_tls=getattr(self.config.mqtt, "use_tls", False),
|
|
)
|
|
|
|
bridge_config = BridgeConfig(mqtt=mqtt_data, devices=devices_data, version="1.0")
|
|
self.device_manager.apply_config(bridge_config)
|
|
|
|
# Update config_server state for GET /config and /health
|
|
self.config_server.current_config = bridge_config
|
|
self.config_server.device_count = len(bridge_config.devices)
|
|
self.config_server.subscription_count = len(bridge_config.devices)
|
|
|
|
self.logger.info("initial_config_applied", devices=len(bridge_config.devices))
|
|
|
|
def start_http_server(self) -> None:
|
|
"""Start HTTP config server in background thread."""
|
|
|
|
if self.config_server is None:
|
|
self.logger.error("http_server_start_failed_config_server_not_ready")
|
|
return
|
|
|
|
config_server: ConfigServer = self.config_server
|
|
|
|
def run_http_server() -> None:
|
|
import uvicorn
|
|
|
|
self.logger.info("http_server_starting", host="0.0.0.0", port=self.bridge_port)
|
|
uvicorn.run(
|
|
config_server.app,
|
|
host="0.0.0.0",
|
|
port=self.bridge_port,
|
|
log_level="warning",
|
|
)
|
|
|
|
self.http_server_thread = threading.Thread(target=run_http_server, daemon=True)
|
|
self.http_server_thread.start()
|
|
self.logger.info("http_server_thread_started", port=self.bridge_port)
|
|
|
|
def start_services(self, mqtt_config) -> None:
|
|
"""
|
|
Start all IoT Bridge services.
|
|
|
|
Args:
|
|
mqtt_config: MQTT configuration namespace
|
|
"""
|
|
self.logger.info(
|
|
"bridge_autonomous_mode",
|
|
message="Using local config. Odoo can push updates via POST /config",
|
|
)
|
|
|
|
# Initialize services in order
|
|
self.odoo_client = self.initialize_odoo_client()
|
|
self.event_queue = self.initialize_event_queue()
|
|
self.status_monitor = self.initialize_status_monitor()
|
|
self.mqtt_client, mqtt_connected = self.initialize_mqtt_client(mqtt_config)
|
|
|
|
# Initialize device manager
|
|
self.device_manager = DeviceManager(
|
|
self.mqtt_client, self.event_queue, status_monitor=self.status_monitor
|
|
)
|
|
self.logger.info("device_manager_initialized")
|
|
|
|
# Set MQTT callback to route messages through device_manager
|
|
self.mqtt_client.message_callback = self.device_manager.route_message
|
|
|
|
# Initialize and start config server
|
|
self.config_server = self.initialize_config_server()
|
|
self.apply_initial_config()
|
|
self.start_http_server()
|
|
|
|
# Print startup summary
|
|
self.logger.info("bridge_ready", status="running", http_port=self.bridge_port)
|
|
print("Bridge is running.")
|
|
print(f" - HTTP Config API: http://0.0.0.0:{self.bridge_port}")
|
|
print(f" - MQTT: {mqtt_config.broker}:{mqtt_config.port}")
|
|
print(f" - Devices: {len(self.device_manager.session_detectors)}")
|
|
print("Press Ctrl+C to stop.")
|
|
|
|
def run_main_loop(self) -> None:
|
|
"""Run main event loop with timeout checking."""
|
|
import time
|
|
|
|
while not self.shutdown_flag:
|
|
time.sleep(1)
|
|
# Check for timeouts in all detectors
|
|
if self.device_manager is not None:
|
|
self.device_manager.check_timeouts()
|
|
|
|
def shutdown(self) -> None:
|
|
"""Perform graceful shutdown of all services."""
|
|
self.logger.info("bridge_shutdown", status="stopping")
|
|
|
|
# Stop status monitor
|
|
if self.status_monitor:
|
|
self.status_monitor.stop()
|
|
|
|
# Stop event queue (process remaining events)
|
|
if self.event_queue:
|
|
self.event_queue.stop()
|
|
|
|
# Stop MQTT client
|
|
if self.mqtt_client:
|
|
self.mqtt_client.stop()
|
|
|
|
self.logger.info("bridge_shutdown", status="stopped")
|
|
print("Bridge stopped.")
|