Adding the new rewrite

This commit is contained in:
Ketan Patel committed 2026-04-04 13:53:57 -04:00
1 parent 4aca9f08d1
commit 9b48b01d1d
62 files changed
+4510 -1406

No files matched your search

+51 -98
View File
@@ -1,27 +1,20 @@
from __future__ import annotations
from typing import List, Optional, Dict, Any
import asyncio
import logging
import socket
from typing import Any
try:
from pydantic import BaseModel, Field, ValidationError
except ImportError:
# Fallback for environments without pydantic
BaseModel = object
def Field(default=None, **kwargs):
return default
ValidationError = ValueError
from pydantic import BaseModel, Field, ValidationError
from .config import UPSConfig, SMTPConfig, AppConfig
from .config import AppConfig, SMTPConfig, UIConfig, UPSConfig
from .config_store import load_config_redis, save_config_redis
from .settings import settings
logger = logging.getLogger(__name__)
# Incremented every time configuration is modified so
# SSE clients can detect changes
# Incremented every time configuration is modified so SSE clients can
# detect changes
_config_version: int = 0
@@ -29,175 +22,127 @@ def get_config_version() -> int:
return _config_version
class ConfigWriteError(Exception): # retained for API compatibility
class ConfigWriteError(Exception):
"""Raised when configuration cannot be written (kept for compatibility)."""
class UPSConfigUpdate(BaseModel):
"""Model for updating UPS configuration"""
name: Optional[str] = Field(None, description="Friendly UPS name")
host: Optional[str] = Field(None, description="apcupsd NIS host/IP")
port: Optional[int] = Field(None, description="apcupsd NIS port")
interval_seconds: Optional[int] = Field(
None, description="Polling interval"
)
alert_loadpct_high: Optional[float] = Field(
None, description="Trigger if LOADPCT >= value"
)
alert_bcharge_low: Optional[float] = Field(
None, description="Trigger if BCHARGE <= value"
)
alert_on_battery: Optional[bool] = Field(
None, description="Trigger when STATUS indicates on battery"
)
alert_runtime_low_minutes: Optional[float] = Field(
None, description="Trigger if TIMELEFT <= minutes"
)
"""Model for updating UPS configuration."""
name: str | None = None
host: str | None = None
port: int | None = Field(None, ge=1, le=65535)
interval_seconds: int | None = Field(None, ge=5, le=3600)
alert_loadpct_high: float | None = Field(None, ge=0, le=100)
alert_bcharge_low: float | None = Field(None, ge=0, le=100)
alert_on_battery: bool | None = None
alert_runtime_low_minutes: float | None = Field(None, ge=0)
alert_itemp_high: float | None = Field(None, ge=0, le=120)
class ConfigManager:
def __init__(self):
def __init__(self) -> None:
self._lock = asyncio.Lock()
async def load_config(self) -> AppConfig:
async with self._lock:
return load_config_redis()
async def save_config(self, config: AppConfig) -> None:
async with self._lock:
save_config_redis(config)
logger.info("Configuration saved to Redis")
# Invalidate cached global config so subsequent
# load_config() calls see changes
try:
from . import config as config_module
config_module._cached = None
except Exception: # pragma: no cover - defensive
logger.debug(
"Failed to invalidate config cache", exc_info=True
)
# bump config version
logger.debug("Failed to invalidate config cache", exc_info=True)
global _config_version
_config_version += 1
async def get_ups_list(self) -> List[UPSConfig]:
"""Get list of all UPS configurations"""
async def get_ups_list(self) -> list[UPSConfig]:
config = await self.load_config()
return config.ups
async def get_ups(self, name: str) -> Optional[UPSConfig]:
"""Get UPS configuration by name"""
async def get_ups(self, name: str) -> UPSConfig | None:
config = await self.load_config()
for ups in config.ups:
if ups.name == name:
return ups
return None
async def add_ups(self, ups_config: UPSConfig) -> bool:
"""Add new UPS configuration"""
config = await self.load_config()
# Check if UPS with same name already exists
if any(ups.name == ups_config.name for ups in config.ups):
raise ValueError(
f"UPS with name '{ups_config.name}' already exists"
)
config.ups.append(ups_config)
await self.save_config(config)
# No file cache now
return True
async def update_ups(self, name: str, updates: UPSConfigUpdate) -> bool:
"""Update existing UPS configuration"""
config = await self.load_config()
ups_index = None
for i, ups in enumerate(config.ups):
if ups.name == name:
ups_index = i
break
if ups_index is None:
return False
# Apply updates
ups_dict = config.ups[ups_index].model_dump()
update_dict = updates.model_dump(exclude_none=True)
ups_dict.update(update_dict)
# Validate updated configuration
try:
updated_ups = UPSConfig(**ups_dict)
except ValidationError as e:
raise ValueError(f"Invalid configuration: {e}")
config.ups[ups_index] = updated_ups
await self.save_config(config)
# No file cache now
return True
async def delete_ups(self, name: str) -> bool:
"""Delete UPS configuration"""
config = await self.load_config()
original_count = len(config.ups)
config.ups = [ups for ups in config.ups if ups.name != name]
if len(config.ups) == original_count:
return False # UPS not found
return False
await self.save_config(config)
# No file cache now
return True
async def get_smtp_config(self) -> Optional[SMTPConfig]:
"""Get SMTP configuration"""
async def get_smtp_config(self) -> SMTPConfig | None:
config = await self.load_config()
return config.smtp
async def update_smtp_config(self, smtp_config: SMTPConfig) -> None:
"""Update SMTP configuration"""
config = await self.load_config()
config.smtp = smtp_config
await self.save_config(config)
# Clear cached config
from . import config as config_module
config_module._cached = None
async def update_ui_config(self, ui: UIConfig) -> None:
config = await self.load_config()
config.ui = ui
await self.save_config(config)
async def validate_ups_connection(
self, ups_config: UPSConfig, timeout: float = 3.0
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""Port-only connectivity test (no protocol / CLI call)."""
result: Dict[str, Any] = {
result: dict[str, Any] = {
"success": False,
"message": "",
"connectivity": {"ok": False, "error": None},
"protocol": {"ok": False, "error": None},
"data": None,
}
# Raw TCP connectivity test
try:
# Use low-level socket to distinguish DNS/timeouts
with socket.create_connection(
(ups_config.host, ups_config.port), timeout=timeout
):
result["connectivity"]["ok"] = True
except Exception as e: # broad to surface any network issue
except Exception as e:
result["connectivity"]["error"] = str(e)
result["message"] = f"TCP connectivity failed: {e}"
return result
# For port-only test we just mirror connectivity result
if result["connectivity"]["ok"]:
result["protocol"]["ok"] = True
result["success"] = True
@@ -207,5 +152,13 @@ class ConfigManager:
return result
# Global instance
def smtp_redacted_dict(smtp: SMTPConfig | None) -> dict[str, Any] | None:
"""Return SMTP config dict with password redacted from env."""
if smtp is None:
return None
d = smtp.model_dump()
d["password"] = "***" if settings.smtp_password else None
return d
config_manager = ConfigManager()