odoo_mqtt/iot_bridge/core/service_manager.py
matthias.lotz f7b5a28f9a Fix: sync device status timeout with Odoo message timeout
- 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.
2026-02-19 18:40:30 +01:00

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.")