odoo_mqtt/iot_bridge/api/server.py
matthias.lotz a5929ed290 Phase 0: Reorganize directory structure for better code organization
- Create modular package structure (core/, clients/, parsers/, api/, config/, utils/)
- Move core business logic to core/ (session_detector, event_queue, device_manager)
- Move external clients to clients/ (mqtt_client, odoo_client)
- Split config.py into config/schema.py (dataclasses) and config/loader.py (I/O)
- Split config_server.py into api/server.py (FastAPI) and api/models.py (Pydantic)
- Create parsers/base.py with MessageParser protocol for extensible parser architecture
- Move utilities to utils/ (logging, status_monitor)
- Update all imports across project (main.py, tests)
- Add __init__.py to all packages with proper exports
- Update README.md with new project structure
- All git mv commands preserve file history

This reorganization improves:
- Code discoverability (clear package responsibilities)
- Maintainability (separation of concerns)
- Extensibility (protocols for parsers, clean API separation)
- Testing (isolated packages easier to mock/test)

See OPTIMIZATION_PLAN.md for full roadmap (Phase 0-5)
2026-02-18 21:48:14 +01:00

215 lines
8.8 KiB
Python

"""
HTTP Config Server for IoT Bridge
Receives configuration from Odoo via POST /config
"""
import os
import json
import yaml
from pathlib import Path
from datetime import datetime
from typing import List, Optional, Dict, Any
import structlog
from fastapi import FastAPI, HTTPException, Header, Depends
from api.models import BridgeConfig, MqttConfig, DeviceConfig
logger = structlog.get_logger()
class ConfigServer:
"""HTTP Server for receiving configuration from Odoo."""
def __init__(self, config_callback=None, mqtt_reconnect_callback=None, token: Optional[str] = None):
"""
Initialize Config Server.
Args:
config_callback: Callback function(new_config) called when device config is received
mqtt_reconnect_callback: Callback function(mqtt_config) called when MQTT broker changes
token: Optional Bearer token for authentication
"""
self.app = FastAPI(
title="IoT Bridge Config API",
description="Receives device configuration from Odoo",
version="1.0.0"
)
self.config_callback = config_callback
self.mqtt_reconnect_callback = mqtt_reconnect_callback
self.auth_token = token
self.current_config: Optional[BridgeConfig] = None
self.device_count = 0
self.subscription_count = 0
self.last_config_update: Optional[datetime] = None
# Register routes
self._setup_routes()
logger.info("config_server_initialized", auth_enabled=bool(token))
def _verify_token(self, authorization: Optional[str] = Header(None)):
"""Verify Bearer token if authentication is enabled."""
if not self.auth_token:
return True # No auth required
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header missing")
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization header format")
token = authorization.replace("Bearer ", "")
if token != self.auth_token:
raise HTTPException(status_code=403, detail="Invalid token")
return True
def _setup_routes(self):
"""Setup FastAPI routes."""
@self.app.get("/health")
async def health():
"""Health check endpoint."""
return {
"status": "ok",
"devices": self.device_count,
"subscriptions": self.subscription_count,
"last_config_update": self.last_config_update.isoformat() if self.last_config_update else None
}
@self.app.post("/config")
async def receive_config(config: BridgeConfig, authorized: bool = Depends(self._verify_token)):
"""
Receive new configuration from Odoo.
This endpoint accepts a complete device configuration and triggers:
1. Config validation
2. MQTT broker change detection & reconnect (if needed)
3. Device diff (added/updated/removed)
4. Dynamic MQTT subscription updates
5. Config persistence to /data/config-active.yaml
"""
try:
logger.info("config_received",
device_count=len(config.devices),
has_mqtt_config=config.mqtt is not None,
timestamp=config.timestamp)
# Check if MQTT broker config changed
mqtt_changed = False
if config.mqtt and self.current_config:
old_mqtt = self.current_config.mqtt
new_mqtt = config.mqtt
if old_mqtt:
# Compare all MQTT settings
mqtt_changed = (
old_mqtt.broker != new_mqtt.broker or
old_mqtt.port != new_mqtt.port or
old_mqtt.username != new_mqtt.username or
old_mqtt.password != new_mqtt.password or
old_mqtt.use_tls != new_mqtt.use_tls
)
if mqtt_changed:
logger.info("mqtt_config_changed",
old_broker=f"{old_mqtt.broker}:{old_mqtt.port}",
new_broker=f"{new_mqtt.broker}:{new_mqtt.port}")
else:
# First time MQTT config received
mqtt_changed = True
logger.info("mqtt_config_first_time",
broker=f"{new_mqtt.broker}:{new_mqtt.port}")
# Trigger MQTT reconnect if broker changed
if mqtt_changed and self.mqtt_reconnect_callback and config.mqtt:
logger.info("triggering_mqtt_reconnect")
await self.mqtt_reconnect_callback(config.mqtt)
# Persist config to disk
self._persist_config(config)
# Update internal state
self.current_config = config
self.device_count = len(config.devices)
self.subscription_count = len(config.devices) # Each device = 1 subscription
self.last_config_update = datetime.utcnow()
# Call callback to update device configuration
if self.config_callback:
await self.config_callback(config)
logger.info("config_applied",
devices=self.device_count,
mqtt_reconnected=mqtt_changed,
status="success")
return {
"status": "success",
"message": "Configuration applied",
"devices_configured": len(config.devices),
"mqtt_reconnected": mqtt_changed,
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("config_apply_failed", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to apply config: {str(e)}")
@self.app.get("/config")
async def get_current_config():
"""Get currently active configuration."""
if not self.current_config:
raise HTTPException(status_code=404, detail="No configuration loaded")
# Return config dict with all fields (including None values)
return self.current_config.dict(exclude_none=False)
def _persist_config(self, config: BridgeConfig):
"""Persist configuration to /data/config-active.yaml."""
try:
# Create /data directory if it doesn't exist
data_dir = Path("/data")
data_dir.mkdir(parents=True, exist_ok=True)
config_path = data_dir / "config-active.yaml"
# Convert Pydantic model to dict (include None values for optional fields)
config_dict = config.dict(exclude_none=False)
# Write to YAML
with open(config_path, 'w') as f:
yaml.dump(config_dict, f, default_flow_style=False, sort_keys=False)
logger.info("config_persisted", path=str(config_path), devices=len(config.devices))
except Exception as e:
logger.error("config_persist_failed", error=str(e))
# Don't raise - persistence failure shouldn't block config application
def load_persisted_config(self) -> Optional[BridgeConfig]:
"""Load configuration from /data/config-active.yaml if it exists."""
try:
config_path = Path("/data/config-active.yaml")
if not config_path.exists():
logger.info("no_persisted_config", path=str(config_path))
return None
with open(config_path, 'r') as f:
config_dict = yaml.safe_load(f)
config = BridgeConfig(**config_dict)
self.current_config = config
self.device_count = len(config.devices)
self.subscription_count = len(config.devices)
logger.info("persisted_config_loaded",
path=str(config_path),
devices=len(config.devices))
return config
except Exception as e:
logger.error("persisted_config_load_failed", error=str(e))
return None