odoo_mqtt/iot_bridge/core/bootstrap.py
matthias.lotz 527b5645ed refactor(phase2.3): Migrate to Pydantic V2 and modernize config management
Complete Pydantic V2 migration and config modernization:

api/models.py:
- Migrated @validator → @field_validator (Pydantic V2)
- Added @classmethod decorators (required in V2)
- Updated validator signatures: (cls, v, values) → (cls, v, info)
- Consolidated unique validators into one method
- Fixed: 3 PydanticDeprecatedSince20 warnings 

config/schema.py:
- Migrated from @dataclass → Pydantic BaseModel
- Added Field() with validation constraints:
  * Port: ge=1, le=65535
  * Floats: gt=0, ge=0
  * Strings: min_length constraints
- Added descriptive field descriptions
- Better type safety and runtime validation

core/bootstrap.py:
- Replaced SimpleNamespace → MQTTConfig (Pydantic)
- Now returns properly typed Pydantic models
- Better IDE autocomplete and type checking

Benefits:
 No more Pydantic V1 deprecation warnings
 Runtime validation for all config fields
 Better error messages on invalid config
 Type-safe config throughout the application
 Automatic validation (port ranges, positive numbers, etc.)
 Prepared for Pydantic V3 migration

Migration verified:
- Config loading works: load_config('config.example.yaml') ✓
- Bootstrap works with Pydantic models ✓
- Field validation works (tested port ranges, thresholds) ✓
- All existing functionality preserved ✓

Test: python3 -c 'from core.bootstrap import bootstrap; bootstrap()'
 Works perfectly with new Pydantic V2 models

See OPTIMIZATION_PLAN.md Phase 2.3 for details.
2026-02-18 22:53:36 +01:00

235 lines
6.9 KiB
Python

"""
Bootstrap module for IoT Bridge.
Handles configuration loading, logging setup, and command-line argument parsing.
"""
import argparse
import os
import sys
from pathlib import Path
import structlog
import yaml
from config import load_config
from config.loader import load_devices_from_config
from config.schema import MQTTConfig
from exceptions import ConfigurationError
class BootstrapConfig:
"""Container for bootstrap configuration."""
def __init__(
self,
config,
logger,
config_path: str,
config_active_path: Path,
bridge_port: int,
bridge_token: str | None,
):
"""
Initialize bootstrap configuration.
Args:
config: Bridge configuration (or None if no config)
logger: Configured logger instance
config_path: Path to main config file
config_active_path: Path to persisted config file
bridge_port: HTTP API port
bridge_token: Optional API authentication token
"""
self.config = config
self.logger = logger
self.config_path = config_path
self.config_active_path = config_active_path
self.bridge_port = bridge_port
self.bridge_token = bridge_token
def parse_arguments() -> argparse.Namespace:
"""
Parse command-line arguments.
Returns:
Parsed arguments namespace
"""
parser = argparse.ArgumentParser(description="IoT MQTT Bridge for Odoo")
parser.add_argument(
"--config",
type=str,
default="config.yaml",
help="Path to configuration file (default: config.yaml)",
)
return parser.parse_args()
def load_bridge_config(config_path: str, config_active_path: Path, logger):
"""
Load bridge configuration with fallback to persisted config.
Priority:
1. Load base config from config_path
2. Try to load persisted devices from config_active_path
3. Merge persisted config (devices + MQTT) with base config
Args:
config_path: Path to main configuration file
config_active_path: Path to persisted configuration file
logger: Logger instance
Returns:
Bridge configuration or None if no config available
Raises:
SystemExit: If critical configuration errors occur
"""
# Load base configuration
print(f"Loading base configuration from {config_path}")
try:
config = load_config(config_path)
except ConfigurationError as e:
print(f"ERROR: {e}")
print("⚠ No config file found. Bridge will wait for config push from Odoo.")
return None
except Exception as e:
print(f"ERROR: Failed to load config: {e}")
sys.exit(1)
# Try to load persisted config (devices + MQTT)
if config and config_active_path.exists():
print(f"Loading persisted config from {config_active_path}")
try:
with open(config_active_path) as f:
persisted_config = yaml.safe_load(f)
# Load devices
devices = load_devices_from_config(str(config_active_path))
config.devices = devices
print(f"✓ Loaded {len(devices)} devices from persisted config")
# Load MQTT config if present
if "mqtt" in persisted_config and persisted_config["mqtt"]:
mqtt_dict = persisted_config["mqtt"]
config.mqtt = MQTTConfig(
broker=mqtt_dict.get("broker", "localhost"),
port=mqtt_dict.get("port", 1883),
username=mqtt_dict.get("username", ""),
password=mqtt_dict.get("password", ""),
client_id=mqtt_dict.get("client_id", "iot_bridge"),
keepalive=mqtt_dict.get("keepalive", 60),
use_tls=mqtt_dict.get("use_tls", False),
)
print(f"✓ Loaded MQTT config: {config.mqtt.broker}:{config.mqtt.port}")
except Exception as e:
print(f"⚠ Failed to load persisted config: {e}")
print(f" Using config from {config_path}")
return config
def setup_logger(config):
"""
Setup structured logging.
Args:
config: Bridge configuration (or None for basic logging)
Returns:
Configured logger instance
"""
if config:
from utils.logging import setup_logging
return setup_logging(config.logging)
else:
# Basic logging without config
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
]
)
logger = structlog.get_logger()
logger.warning("bridge_started_without_config", message="Waiting for config push from Odoo")
return logger
def get_mqtt_config(config):
"""
Get MQTT configuration with fallback to environment variables.
Args:
config: Bridge configuration (or None)
Returns:
MQTT configuration (Pydantic MQTTConfig)
"""
if config:
return config.mqtt
# Default MQTT config from environment variables
return MQTTConfig(
broker=os.getenv("MQTT_BROKER", "mosquitto"),
port=int(os.getenv("MQTT_PORT", "1883")),
username=os.getenv("MQTT_USERNAME"),
password=os.getenv("MQTT_PASSWORD"),
client_id=os.getenv("MQTT_CLIENT_ID", "iot_bridge"),
use_tls=os.getenv("MQTT_USE_TLS", "false").lower() == "true",
)
def bootstrap() -> BootstrapConfig:
"""
Bootstrap the IoT Bridge application.
Handles:
- Command-line argument parsing
- Configuration loading (with persisted config fallback)
- Logging setup
- Environment variable processing
Returns:
BootstrapConfig with all initialization data
Raises:
SystemExit: If critical configuration errors occur
"""
# Parse arguments
args = parse_arguments()
# Determine config paths
config_active_path = Path("/data/config-active.yaml")
config_path = (
args.config if args.config != "config.yaml" else os.getenv("BRIDGE_CONFIG", "config.yaml")
)
# Get HTTP server settings from environment
bridge_port = int(os.getenv("BRIDGE_PORT", "8080"))
bridge_token = os.getenv("BRIDGE_API_TOKEN")
# Load configuration (returns None if not available)
config = load_bridge_config(config_path, config_active_path, None)
# Setup logging
logger = setup_logger(config)
# Log startup info
if config:
logger.info("bridge_started", config_file=config_path, devices=len(config.devices))
else:
logger.warning("bridge_started_without_config", message="Waiting for config push from Odoo")
return BootstrapConfig(
config=config,
logger=logger,
config_path=config_path,
config_active_path=config_active_path,
bridge_port=bridge_port,
bridge_token=bridge_token,
)