Adding the new rewrite
This commit is contained in:
1 parent
4aca9f08d1
commit
9b48b01d1d
62 files changed
+4510
-1406
No files matched your search
+111
-39
@@ -1,74 +1,146 @@
|
||||
from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
CONFIG_PATH = Path(
|
||||
os.environ.get("UPS_CONFIG_PATH", "/config/ups.yaml")
|
||||
) # legacy path for migration
|
||||
import ipaddress
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from .settings import settings
|
||||
|
||||
CONFIG_PATH = Path(settings.ups_config_path) # legacy path for migration
|
||||
|
||||
_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||
_HOSTNAME_RE = re.compile(
|
||||
r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
|
||||
)
|
||||
|
||||
_BLOCKED_LITERALS = {"localhost", "localhost.localdomain"}
|
||||
|
||||
|
||||
def _validate_host_string(host: str) -> str:
|
||||
"""Validate a host field. Rejects loopback/link-local always; rejects
|
||||
private ranges when ALLOW_PRIVATE_IPS is false.
|
||||
"""
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise ValueError("host must not be empty")
|
||||
if host.lower() in _BLOCKED_LITERALS:
|
||||
raise ValueError(f"host '{host}' is not permitted (loopback alias)")
|
||||
# Try as IP first
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast:
|
||||
raise ValueError(f"host IP {host} is not a valid polling target")
|
||||
if ip.is_private and not settings.allow_private_ips:
|
||||
raise ValueError(
|
||||
f"private IP {host} not allowed (set ALLOW_PRIVATE_IPS=true)"
|
||||
)
|
||||
return host
|
||||
except ValueError as ip_err:
|
||||
# Fall through to hostname validation only when the string wasn't a
|
||||
# parseable IP address
|
||||
if "does not appear to be an IPv4 or IPv6 address" not in str(ip_err):
|
||||
raise
|
||||
# Validate as hostname
|
||||
if not _HOSTNAME_RE.match(host):
|
||||
raise ValueError(f"host '{host}' is not a valid hostname or IP")
|
||||
return host
|
||||
|
||||
|
||||
class UPSConfig(BaseModel):
|
||||
name: str = Field(..., description="Friendly UPS name")
|
||||
host: str = Field(..., description="apcupsd NIS host/IP")
|
||||
port: int = Field(3551, description="apcupsd NIS port")
|
||||
interval_seconds: int = Field(30, description="Polling interval")
|
||||
# Alert thresholds (any optional)
|
||||
alert_loadpct_high: Optional[float] = Field(
|
||||
None, description="Trigger if LOADPCT >= value"
|
||||
port: int = Field(3551, ge=1, le=65535, description="apcupsd NIS port")
|
||||
interval_seconds: int = Field(
|
||||
30, ge=5, le=3600, description="Polling interval"
|
||||
)
|
||||
alert_bcharge_low: Optional[float] = Field(
|
||||
None, description="Trigger if BCHARGE <= value"
|
||||
# Alert thresholds (any optional)
|
||||
alert_loadpct_high: float | None = Field(
|
||||
None, ge=0, le=100, description="Trigger if LOADPCT >= value"
|
||||
)
|
||||
alert_bcharge_low: float | None = Field(
|
||||
None, ge=0, le=100, description="Trigger if BCHARGE <= value"
|
||||
)
|
||||
alert_on_battery: bool = Field(
|
||||
False, description="Trigger when STATUS indicates on battery"
|
||||
)
|
||||
alert_runtime_low_minutes: Optional[float] = Field(
|
||||
None, description="Trigger if TIMELEFT <= minutes"
|
||||
alert_runtime_low_minutes: float | None = Field(
|
||||
None, ge=0, description="Trigger if TIMELEFT <= minutes"
|
||||
)
|
||||
alert_itemp_high: float | None = Field(
|
||||
None, ge=0, le=120, description="Trigger if internal temp >= C"
|
||||
)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _validate_name(cls, v: str) -> str:
|
||||
if not _NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
"name must be 1-32 chars, alphanumeric / underscore / dash only"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("host")
|
||||
@classmethod
|
||||
def _validate_host(cls, v: str) -> str:
|
||||
return _validate_host_string(v)
|
||||
|
||||
|
||||
class SMTPConfig(BaseModel):
|
||||
host: str = Field(..., description="SMTP server host/IP")
|
||||
port: int = Field(..., description="SMTP port")
|
||||
username: Optional[str] = Field(None)
|
||||
password: Optional[str] = Field(
|
||||
None, description="Plain password or set via env SMTP_PASSWORD"
|
||||
)
|
||||
port: int = Field(..., ge=1, le=65535, description="SMTP port")
|
||||
username: str | None = Field(None)
|
||||
# password is never persisted in Redis; read from env SMTP_PASSWORD
|
||||
use_tls: bool = Field(False, description="STARTTLS if true")
|
||||
use_ssl: bool = Field(False, description="SSL (smtplib.SMTP_SSL) if true")
|
||||
from_addr: Optional[str] = Field(None, description="From email address")
|
||||
to_addrs: List[str] = Field(
|
||||
from_addr: str | None = Field(None, description="From email address")
|
||||
to_addrs: list[str] = Field(
|
||||
default_factory=list, description="Recipient list"
|
||||
)
|
||||
subject_prefix: str = Field("[UPS]", description="Subject prefix")
|
||||
silent_hours_start: int | None = Field(
|
||||
None, ge=0, le=23, description="Silent window start hour (local time)"
|
||||
)
|
||||
silent_hours_end: int | None = Field(
|
||||
None, ge=0, le=23, description="Silent window end hour (local time)"
|
||||
)
|
||||
daily_summary_hour: int | None = Field(
|
||||
None, ge=0, le=23, description="Hour to send daily summary"
|
||||
)
|
||||
|
||||
|
||||
class UIConfig(BaseModel):
|
||||
show_events: bool = True
|
||||
show_energy: bool = False
|
||||
color_badges: bool = True
|
||||
show_headroom: bool = True
|
||||
show_watts: bool = True
|
||||
show_runtime: bool = True
|
||||
allow_resize: bool = True
|
||||
enable_transfer_burst_alert: bool = False
|
||||
enable_voltage_deviation_alert: bool = False
|
||||
energy_cost_per_kwh: float = 0.0
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
ups: List[UPSConfig]
|
||||
smtp: Optional[SMTPConfig] = None
|
||||
# UI feature flags (optional; default values used if missing)
|
||||
# Added for dashboard toggleable features
|
||||
class UIConfig(BaseModel):
|
||||
show_events: bool = True
|
||||
show_energy: bool = False
|
||||
color_badges: bool = True
|
||||
show_headroom: bool = True
|
||||
show_watts: bool = True
|
||||
show_runtime: bool = True
|
||||
allow_resize: bool = True
|
||||
enable_transfer_burst_alert: bool = False
|
||||
enable_voltage_deviation_alert: bool = False
|
||||
ups: list[UPSConfig] = Field(default_factory=list)
|
||||
smtp: SMTPConfig | None = None
|
||||
ui: UIConfig = Field(default_factory=UIConfig)
|
||||
|
||||
ui: UIConfig = UIConfig()
|
||||
# Backward-compat alias: older code references AppConfig.UIConfig
|
||||
UIConfig: ClassVar[type[UIConfig]] = UIConfig
|
||||
|
||||
|
||||
_cached: AppConfig | None = None
|
||||
|
||||
|
||||
def load_config(path: Path = CONFIG_PATH) -> AppConfig:
|
||||
# Use redis store (lazy import to avoid circular)
|
||||
"""Load config from Redis, cached in-process.
|
||||
|
||||
Invalidate via ``config_module._cached = None`` after any write.
|
||||
"""
|
||||
from .config_store import load_config_redis
|
||||
global _cached
|
||||
if _cached:
|
||||
|
||||
Reference in new issue
Block a user