feat: NUT (Network UPS Tools) service provider + provider-agnostic UI
Add full NUT support as a polling-based service provider: - Async TCP client for upsd protocol (port 3493, configurable) - 8 event types: online, on_battery, low_battery, battery_restored, comms_lost, comms_restored, replace_battery, overload - 3 bot commands: /status, /devices, /battery - 38 Jinja2 templates (EN+RU notification + command templates) - Database: tracking config fields, migration, seeds - Frontend: provider form with host/port/credentials, grid items, i18n Provider-agnostic UI improvements: - Remove hardcoded 'immich' defaults from all config forms - Dynamic collection labels per provider type (Albums/Repos/Boards/UPS Devices) - Capability-driven test types instead of provider type checks - Template variable helpers for all providers (was Immich-only) - Guard Immich-only shared link check to Immich providers - Auto-clear stale global provider filter from localStorage - EntitySelect search placeholder shows current selection - Fix noneLabel in linked target config selectors New CLAUDE.md rule #8: no provider-specific hardcoding
This commit is contained in:
@@ -52,6 +52,16 @@ class EventType(str, Enum):
|
||||
# Scheduler events
|
||||
SCHEDULED_MESSAGE = "scheduled_message"
|
||||
|
||||
# NUT (Network UPS Tools) events
|
||||
UPS_ONLINE = "ups_online"
|
||||
UPS_ON_BATTERY = "ups_on_battery"
|
||||
UPS_LOW_BATTERY = "ups_low_battery"
|
||||
UPS_BATTERY_RESTORED = "ups_battery_restored"
|
||||
UPS_COMMS_LOST = "ups_comms_lost"
|
||||
UPS_COMMS_RESTORED = "ups_comms_restored"
|
||||
UPS_REPLACE_BATTERY = "ups_replace_battery"
|
||||
UPS_OVERLOAD = "ups_overload"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceEvent:
|
||||
|
||||
@@ -18,6 +18,7 @@ class ServiceProviderType(str, Enum):
|
||||
GITEA = "gitea"
|
||||
PLANKA = "planka"
|
||||
SCHEDULER = "scheduler"
|
||||
NUT = "nut"
|
||||
|
||||
|
||||
class ServiceProvider(ABC):
|
||||
|
||||
@@ -300,6 +300,58 @@ PLANKA_CAPABILITIES = ProviderCapabilities(
|
||||
],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NUT (Network UPS Tools) provider capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NUT_CAPABILITIES = ProviderCapabilities(
|
||||
provider_type="nut",
|
||||
display_name="NUT (UPS)",
|
||||
webhook_based=False,
|
||||
supported_filters=[
|
||||
{"key": "collections", "label": "UPS Devices", "type": "select", "source": "api"},
|
||||
],
|
||||
notification_slots=[
|
||||
{"name": "message_ups_online", "description": "UPS back on mains power"},
|
||||
{"name": "message_ups_on_battery", "description": "UPS switched to battery"},
|
||||
{"name": "message_ups_low_battery", "description": "Battery critically low"},
|
||||
{"name": "message_ups_battery_restored", "description": "Battery charge recovered"},
|
||||
{"name": "message_ups_comms_lost", "description": "Communication with UPS lost"},
|
||||
{"name": "message_ups_comms_restored", "description": "Communication with UPS restored"},
|
||||
{"name": "message_ups_replace_battery", "description": "Battery needs replacement"},
|
||||
{"name": "message_ups_overload", "description": "UPS load exceeded capacity"},
|
||||
],
|
||||
events=[
|
||||
{"name": "ups_online", "description": "UPS back on mains power"},
|
||||
{"name": "ups_on_battery", "description": "UPS switched to battery"},
|
||||
{"name": "ups_low_battery", "description": "Battery critically low"},
|
||||
{"name": "ups_battery_restored", "description": "Battery charge recovered"},
|
||||
{"name": "ups_comms_lost", "description": "Communication lost"},
|
||||
{"name": "ups_comms_restored", "description": "Communication restored"},
|
||||
{"name": "ups_replace_battery", "description": "Battery needs replacement"},
|
||||
{"name": "ups_overload", "description": "UPS overloaded"},
|
||||
],
|
||||
command_slots=[
|
||||
{"name": "start", "description": "/start greeting message"},
|
||||
{"name": "help", "description": "/help command listing"},
|
||||
{"name": "status", "description": "/status UPS status summary"},
|
||||
{"name": "devices", "description": "/devices monitored UPS list"},
|
||||
{"name": "battery", "description": "/battery detailed battery report"},
|
||||
{"name": "rate_limited", "description": "Rate limit warning message"},
|
||||
{"name": "no_results", "description": "Empty results fallback"},
|
||||
{"name": "desc_help", "description": "Menu description for /help"},
|
||||
{"name": "desc_status", "description": "Menu description for /status"},
|
||||
{"name": "desc_devices", "description": "Menu description for /devices"},
|
||||
{"name": "desc_battery", "description": "Menu description for /battery"},
|
||||
],
|
||||
commands=[
|
||||
{"name": "status", "description": "Show UPS status"},
|
||||
{"name": "devices", "description": "List monitored UPS devices"},
|
||||
{"name": "battery", "description": "Detailed battery report"},
|
||||
{"name": "help", "description": "Show commands"},
|
||||
],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -309,6 +361,7 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
|
||||
"gitea": GITEA_CAPABILITIES,
|
||||
"planka": PLANKA_CAPABILITIES,
|
||||
"scheduler": SCHEDULER_CAPABILITIES,
|
||||
"nut": NUT_CAPABILITIES,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""NUT (Network UPS Tools) service provider implementation."""
|
||||
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import registry
|
||||
|
||||
from .client import NutClient, NutClientError
|
||||
from .provider import NutServiceProvider, NUT_VARIABLES
|
||||
|
||||
# Register NUT variables in the global registry
|
||||
registry.register_provider_variables(ServiceProviderType.NUT, NUT_VARIABLES)
|
||||
|
||||
__all__ = [
|
||||
"NutClient",
|
||||
"NutClientError",
|
||||
"NutServiceProvider",
|
||||
"NUT_VARIABLES",
|
||||
]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Async TCP client for the NUT (Network UPS Tools) upsd protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_PORT = 3493
|
||||
_READ_TIMEOUT = 10.0
|
||||
_CONNECT_TIMEOUT = 5.0
|
||||
|
||||
# Regex to parse VAR lines: VAR <ups> <name> "<value>"
|
||||
_VAR_RE = re.compile(r'^VAR\s+(\S+)\s+(\S+)\s+"(.*)"$')
|
||||
# Regex to parse UPS lines: UPS <name> "<description>"
|
||||
_UPS_RE = re.compile(r'^UPS\s+(\S+)\s+"(.*)"$')
|
||||
|
||||
|
||||
class NutClientError(Exception):
|
||||
"""Error communicating with NUT server."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class NutUpsDevice:
|
||||
"""A UPS device reported by upsd."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class NutClient:
|
||||
"""Async TCP client for the NUT upsd protocol.
|
||||
|
||||
Protocol reference: https://networkupstools.org/docs/developer-guide.chunked/ar01s09.html
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = _DEFAULT_PORT,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Open TCP connection and optionally authenticate."""
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self.host, self.port),
|
||||
timeout=_CONNECT_TIMEOUT,
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as exc:
|
||||
raise NutClientError(f"Cannot connect to {self.host}:{self.port}: {exc}") from exc
|
||||
|
||||
if self._username:
|
||||
await self._command(f"USERNAME {self._username}")
|
||||
if self._password:
|
||||
await self._command(f"PASSWORD {self._password}")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Send LOGOUT and close the TCP connection."""
|
||||
if self._writer is not None:
|
||||
try:
|
||||
self._writer.write(b"LOGOUT\n")
|
||||
await self._writer.drain()
|
||||
except OSError:
|
||||
pass
|
||||
self._writer.close()
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
|
||||
async def list_ups(self) -> list[NutUpsDevice]:
|
||||
"""List all UPS devices configured on the server."""
|
||||
lines = await self._list_command("LIST UPS")
|
||||
devices: list[NutUpsDevice] = []
|
||||
for line in lines:
|
||||
m = _UPS_RE.match(line)
|
||||
if m:
|
||||
devices.append(NutUpsDevice(name=m.group(1), description=m.group(2)))
|
||||
return devices
|
||||
|
||||
async def list_var(self, ups_name: str) -> dict[str, str]:
|
||||
"""Get all variables for a UPS device."""
|
||||
lines = await self._list_command(f"LIST VAR {ups_name}")
|
||||
variables: dict[str, str] = {}
|
||||
for line in lines:
|
||||
m = _VAR_RE.match(line)
|
||||
if m and m.group(1) == ups_name:
|
||||
variables[m.group(2)] = m.group(3)
|
||||
return variables
|
||||
|
||||
async def get_var(self, ups_name: str, var_name: str) -> str:
|
||||
"""Get a single variable value."""
|
||||
response = await self._command(f"GET VAR {ups_name} {var_name}")
|
||||
m = _VAR_RE.match(response)
|
||||
if m:
|
||||
return m.group(3)
|
||||
raise NutClientError(f"Unexpected response for GET VAR: {response}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send(self, cmd: str) -> None:
|
||||
"""Send a command line to upsd."""
|
||||
if self._writer is None:
|
||||
raise NutClientError("Not connected")
|
||||
self._writer.write(f"{cmd}\n".encode())
|
||||
await self._writer.drain()
|
||||
|
||||
async def _readline(self) -> str:
|
||||
"""Read one line from upsd, stripping trailing newline."""
|
||||
if self._reader is None:
|
||||
raise NutClientError("Not connected")
|
||||
try:
|
||||
data = await asyncio.wait_for(self._reader.readline(), timeout=_READ_TIMEOUT)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise NutClientError("Read timeout") from exc
|
||||
if not data:
|
||||
raise NutClientError("Connection closed by server")
|
||||
return data.decode("utf-8", errors="replace").rstrip("\n")
|
||||
|
||||
async def _command(self, cmd: str) -> str:
|
||||
"""Send a single command and return the response line."""
|
||||
await self._send(cmd)
|
||||
line = await self._readline()
|
||||
if line.startswith("ERR "):
|
||||
raise NutClientError(f"NUT error: {line}")
|
||||
return line
|
||||
|
||||
async def _list_command(self, cmd: str) -> list[str]:
|
||||
"""Send a LIST command and collect lines between BEGIN/END markers."""
|
||||
await self._send(cmd)
|
||||
# Expect: BEGIN LIST ...
|
||||
begin = await self._readline()
|
||||
if begin.startswith("ERR "):
|
||||
raise NutClientError(f"NUT error: {begin}")
|
||||
if not begin.startswith("BEGIN "):
|
||||
raise NutClientError(f"Expected BEGIN, got: {begin}")
|
||||
|
||||
lines: list[str] = []
|
||||
while True:
|
||||
line = await self._readline()
|
||||
if line.startswith("END "):
|
||||
break
|
||||
if line.startswith("ERR "):
|
||||
raise NutClientError(f"NUT error mid-list: {line}")
|
||||
lines.append(line)
|
||||
return lines
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Data models for NUT (Network UPS Tools) provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class NutUpsData:
|
||||
"""Parsed UPS telemetry from upsd variables."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
status: str = "" # e.g. "OL", "OB LB", "OL CHRG"
|
||||
battery_charge: float | None = None
|
||||
battery_runtime: int | None = None # seconds
|
||||
ups_load: float | None = None
|
||||
input_voltage: float | None = None
|
||||
output_voltage: float | None = None
|
||||
model: str = ""
|
||||
manufacturer: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_variables(cls, name: str, variables: dict[str, str]) -> NutUpsData:
|
||||
"""Build from a dict of NUT variable name -> value."""
|
||||
return cls(
|
||||
name=name,
|
||||
description=variables.get("ups.description", ""),
|
||||
status=variables.get("ups.status", ""),
|
||||
battery_charge=_float_or_none(variables.get("battery.charge")),
|
||||
battery_runtime=_int_or_none(variables.get("battery.runtime")),
|
||||
ups_load=_float_or_none(variables.get("ups.load")),
|
||||
input_voltage=_float_or_none(variables.get("input.voltage")),
|
||||
output_voltage=_float_or_none(variables.get("output.voltage")),
|
||||
model=variables.get("device.model", variables.get("ups.model", "")),
|
||||
manufacturer=variables.get("device.mfr", variables.get("ups.mfr", "")),
|
||||
)
|
||||
|
||||
@property
|
||||
def status_flags(self) -> set[str]:
|
||||
"""Parse status string into individual flags (e.g. {'OL', 'CHRG'})."""
|
||||
return set(self.status.split()) if self.status else set()
|
||||
|
||||
@property
|
||||
def battery_runtime_formatted(self) -> str:
|
||||
"""Format runtime seconds as H:MM:SS or M:SS."""
|
||||
if self.battery_runtime is None:
|
||||
return "N/A"
|
||||
total = self.battery_runtime
|
||||
hours, remainder = divmod(total, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
||||
return f"{minutes}:{seconds:02d}"
|
||||
|
||||
|
||||
def _float_or_none(val: str | None) -> float | None:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _int_or_none(val: str | None) -> int | None:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return int(float(val))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,348 @@
|
||||
"""NUT (Network UPS Tools) service provider — polling-based implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||
|
||||
from .client import NutClient, NutClientError
|
||||
from .models import NutUpsData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Map of status flag transitions to event types
|
||||
_STATUS_EVENTS: dict[str, tuple[str, EventType, str]] = {
|
||||
# flag, event when flag appears, human description
|
||||
"OB": ("ups_on_battery", EventType.UPS_ON_BATTERY, "UPS switched to battery power"),
|
||||
"LB": ("ups_low_battery", EventType.UPS_LOW_BATTERY, "Battery charge critically low"),
|
||||
"RB": ("ups_replace_battery", EventType.UPS_REPLACE_BATTERY, "Battery needs replacement"),
|
||||
"OVER": ("ups_overload", EventType.UPS_OVERLOAD, "UPS load exceeded capacity"),
|
||||
}
|
||||
|
||||
_RESTORE_EVENTS: dict[str, tuple[str, EventType, str]] = {
|
||||
# flag, event when flag disappears
|
||||
"OB": ("ups_online", EventType.UPS_ONLINE, "UPS back on mains power"),
|
||||
"LB": ("ups_battery_restored", EventType.UPS_BATTERY_RESTORED, "Battery charge recovered"),
|
||||
}
|
||||
|
||||
|
||||
# NUT-specific template variables
|
||||
NUT_VARIABLES: list[TemplateVariableDefinition] = [
|
||||
TemplateVariableDefinition(
|
||||
name="ups_name",
|
||||
type="string",
|
||||
description="UPS device name",
|
||||
example="myups",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="ups_model",
|
||||
type="string",
|
||||
description="UPS hardware model",
|
||||
example="Smart-UPS 1500",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="ups_manufacturer",
|
||||
type="string",
|
||||
description="UPS manufacturer",
|
||||
example="APC",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="battery_charge",
|
||||
type="int",
|
||||
description="Battery charge percentage",
|
||||
example="95",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="battery_runtime",
|
||||
type="string",
|
||||
description="Estimated runtime on battery (formatted)",
|
||||
example="1:23:45",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="battery_runtime_seconds",
|
||||
type="int",
|
||||
description="Estimated runtime on battery in seconds",
|
||||
example="5025",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="ups_load",
|
||||
type="int",
|
||||
description="UPS load as percentage of rated capacity",
|
||||
example="42",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="ups_status",
|
||||
type="string",
|
||||
description="Raw UPS status flags (e.g. OL, OB, LB)",
|
||||
example="OL",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="input_voltage",
|
||||
type="string",
|
||||
description="Input voltage",
|
||||
example="230.0",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="output_voltage",
|
||||
type="string",
|
||||
description="Output voltage",
|
||||
example="230.0",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="event_description",
|
||||
type="string",
|
||||
description="Human-readable description of the event",
|
||||
example="UPS switched to battery power",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="previous_status",
|
||||
type="string",
|
||||
description="Previous UPS status flags before this event",
|
||||
example="OL",
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class NutServiceProvider(ServiceProvider):
|
||||
"""Polling-based NUT service provider.
|
||||
|
||||
Connects to a NUT upsd daemon via TCP, queries UPS devices,
|
||||
and detects status transitions to generate notification events.
|
||||
"""
|
||||
|
||||
provider_type = ServiceProviderType.NUT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 3493,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
name: str = "NUT",
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._name = name
|
||||
self._client: NutClient | None = None
|
||||
|
||||
def _make_client(self) -> NutClient:
|
||||
return NutClient(self._host, self._port, self._username, self._password)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
try:
|
||||
self._client = self._make_client()
|
||||
await self._client.connect()
|
||||
return True
|
||||
except NutClientError as exc:
|
||||
_LOGGER.error("NUT connect failed: %s", exc)
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._client:
|
||||
await self._client.disconnect()
|
||||
self._client = None
|
||||
|
||||
async def poll(
|
||||
self,
|
||||
collection_ids: list[str],
|
||||
tracker_state: dict[str, Any],
|
||||
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
||||
"""Poll UPS devices and detect status transitions."""
|
||||
events: list[ServiceEvent] = []
|
||||
new_state: dict[str, Any] = {}
|
||||
|
||||
client = self._make_client()
|
||||
try:
|
||||
await client.connect()
|
||||
except NutClientError as exc:
|
||||
_LOGGER.error("NUT poll connect failed: %s", exc)
|
||||
# Generate comms_lost events for devices that were previously reachable
|
||||
for ups_name in collection_ids:
|
||||
prev = tracker_state.get(ups_name, {})
|
||||
was_ok = prev.get("comms_ok", True)
|
||||
if was_ok:
|
||||
events.append(self._make_event(
|
||||
EventType.UPS_COMMS_LOST,
|
||||
ups_name,
|
||||
prev.get("name", ups_name),
|
||||
{"event_description": f"Lost communication with {ups_name}",
|
||||
"previous_status": prev.get("status", ""),
|
||||
"ups_name": ups_name},
|
||||
))
|
||||
new_state[ups_name] = {
|
||||
**prev,
|
||||
"comms_ok": False,
|
||||
"asset_ids": [],
|
||||
"pending_asset_ids": [],
|
||||
"shared": False,
|
||||
}
|
||||
return events, new_state
|
||||
|
||||
try:
|
||||
for ups_name in collection_ids:
|
||||
prev = tracker_state.get(ups_name, {})
|
||||
try:
|
||||
variables = await client.list_var(ups_name)
|
||||
data = NutUpsData.from_variables(ups_name, variables)
|
||||
|
||||
# Check for comms restored
|
||||
if not prev.get("comms_ok", True):
|
||||
events.append(self._make_event(
|
||||
EventType.UPS_COMMS_RESTORED,
|
||||
ups_name,
|
||||
data.description or ups_name,
|
||||
self._build_extra(data, "Communication restored", prev.get("status", "")),
|
||||
))
|
||||
|
||||
# Detect status transitions
|
||||
prev_flags = set(prev.get("status", "").split()) if prev.get("status") else set()
|
||||
curr_flags = data.status_flags
|
||||
|
||||
for flag, (_, event_type, desc) in _STATUS_EVENTS.items():
|
||||
if flag in curr_flags and flag not in prev_flags:
|
||||
events.append(self._make_event(
|
||||
event_type, ups_name,
|
||||
data.description or ups_name,
|
||||
self._build_extra(data, desc, prev.get("status", "")),
|
||||
))
|
||||
|
||||
for flag, (_, event_type, desc) in _RESTORE_EVENTS.items():
|
||||
if flag not in curr_flags and flag in prev_flags:
|
||||
events.append(self._make_event(
|
||||
event_type, ups_name,
|
||||
data.description or ups_name,
|
||||
self._build_extra(data, desc, prev.get("status", "")),
|
||||
))
|
||||
|
||||
new_state[ups_name] = {
|
||||
"name": data.description or ups_name,
|
||||
"status": data.status,
|
||||
"battery_charge": data.battery_charge,
|
||||
"comms_ok": True,
|
||||
"asset_ids": [],
|
||||
"pending_asset_ids": [],
|
||||
"shared": False,
|
||||
}
|
||||
except NutClientError as exc:
|
||||
_LOGGER.warning("Failed to query UPS %s: %s", ups_name, exc)
|
||||
was_ok = prev.get("comms_ok", True)
|
||||
if was_ok:
|
||||
events.append(self._make_event(
|
||||
EventType.UPS_COMMS_LOST,
|
||||
ups_name,
|
||||
prev.get("name", ups_name),
|
||||
{"event_description": f"Lost communication with {ups_name}",
|
||||
"previous_status": prev.get("status", ""),
|
||||
"ups_name": ups_name},
|
||||
))
|
||||
new_state[ups_name] = {
|
||||
**prev,
|
||||
"comms_ok": False,
|
||||
"asset_ids": [],
|
||||
"pending_asset_ids": [],
|
||||
"shared": False,
|
||||
}
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
return events, new_state
|
||||
|
||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||
return list(NUT_VARIABLES)
|
||||
|
||||
def get_provider_config_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "NUT server hostname or IP"},
|
||||
"port": {"type": "integer", "default": 3493, "description": "NUT server port"},
|
||||
"username": {"type": "string", "description": "upsd username (optional)"},
|
||||
"password": {"type": "string", "description": "upsd password (optional)"},
|
||||
},
|
||||
"required": ["host"],
|
||||
}
|
||||
|
||||
async def list_collections(self) -> list[dict[str, Any]]:
|
||||
"""List UPS devices as collections."""
|
||||
client = self._make_client()
|
||||
try:
|
||||
await client.connect()
|
||||
devices = await client.list_ups()
|
||||
return [{"id": d.name, "name": d.description or d.name} for d in devices]
|
||||
except NutClientError as exc:
|
||||
_LOGGER.error("Failed to list UPS devices: %s", exc)
|
||||
return []
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
async def test_connection(self) -> dict[str, Any]:
|
||||
client = self._make_client()
|
||||
try:
|
||||
await client.connect()
|
||||
devices = await client.list_ups()
|
||||
await client.disconnect()
|
||||
count = len(devices)
|
||||
names = ", ".join(d.name for d in devices[:5])
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Connected — {count} UPS device(s) found: {names}",
|
||||
}
|
||||
except NutClientError as exc:
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _make_event(
|
||||
self,
|
||||
event_type: EventType,
|
||||
collection_id: str,
|
||||
collection_name: str,
|
||||
extra: dict[str, Any],
|
||||
) -> ServiceEvent:
|
||||
return ServiceEvent(
|
||||
event_type=event_type,
|
||||
provider_type=ServiceProviderType.NUT,
|
||||
provider_name=self._name,
|
||||
collection_id=collection_id,
|
||||
collection_name=collection_name,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_extra(data: NutUpsData, description: str, previous_status: str) -> dict[str, Any]:
|
||||
return {
|
||||
"ups_name": data.name,
|
||||
"ups_model": data.model,
|
||||
"ups_manufacturer": data.manufacturer,
|
||||
"battery_charge": int(data.battery_charge) if data.battery_charge is not None else None,
|
||||
"battery_runtime": data.battery_runtime_formatted,
|
||||
"battery_runtime_seconds": data.battery_runtime,
|
||||
"ups_load": int(data.ups_load) if data.ups_load is not None else None,
|
||||
"ups_status": data.status,
|
||||
"input_voltage": str(data.input_voltage) if data.input_voltage is not None else None,
|
||||
"output_voltage": str(data.output_voltage) if data.output_voltage is not None else None,
|
||||
"event_description": description,
|
||||
"previous_status": previous_status,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
🔋 <b>Battery Report</b>
|
||||
{%- for ups in devices %}
|
||||
<b>{{ ups.name }}</b>{% if ups.model %} ({{ ups.model }}){% endif %}
|
||||
Charge: {{ ups.battery_charge }}%
|
||||
Runtime: {{ ups.battery_runtime }}
|
||||
{%- if ups.input_voltage %}Input: {{ ups.input_voltage }}V{%- endif %}
|
||||
{%- if ups.output_voltage %} · Output: {{ ups.output_voltage }}V{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- if not devices %}
|
||||
No UPS devices found.
|
||||
{%- endif %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Detailed battery report
|
||||
+1
@@ -0,0 +1 @@
|
||||
List monitored UPS devices
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show available commands
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show UPS status summary
|
||||
@@ -0,0 +1,7 @@
|
||||
🔌 <b>Monitored UPS Devices</b>
|
||||
{%- for d in devices %}
|
||||
• <b>{{ d.name }}</b>{% if d.description %} — {{ d.description }}{% endif %}
|
||||
{%- endfor %}
|
||||
{%- if not devices %}
|
||||
No UPS devices configured.
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
🔌 <b>Available Commands</b>
|
||||
{%- for cmd in commands %}
|
||||
/{{ cmd.name }} — {{ cmd.description }}
|
||||
{%- endfor %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
No results found.
|
||||
+1
@@ -0,0 +1 @@
|
||||
⏳ Too many requests. Please wait a moment before trying again.
|
||||
@@ -0,0 +1,2 @@
|
||||
👋 Hi! I'm your Notify Bridge bot for <b>NUT (UPS)</b>.
|
||||
Use /help to see available commands.
|
||||
@@ -0,0 +1,9 @@
|
||||
🔌 <b>UPS Status</b>
|
||||
{%- for ups in devices %}
|
||||
<b>{{ ups.name }}</b>{% if ups.model %} ({{ ups.model }}){% endif %}
|
||||
Status: {{ ups.status }} · Battery: {{ ups.battery_charge }}%
|
||||
Load: {{ ups.ups_load }}% · Runtime: {{ ups.battery_runtime }}
|
||||
{%- endfor %}
|
||||
{%- if not devices %}
|
||||
No UPS devices found.
|
||||
{%- endif %}
|
||||
@@ -40,6 +40,13 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
|
||||
"desc_help", "desc_status", "desc_boards", "desc_cards",
|
||||
"desc_lists",
|
||||
],
|
||||
"nut": [
|
||||
# Response templates
|
||||
"start", "help", "status", "devices", "battery",
|
||||
"rate_limited", "no_results",
|
||||
# Description slots
|
||||
"desc_help", "desc_status", "desc_devices", "desc_battery",
|
||||
],
|
||||
}
|
||||
|
||||
# Backward-compatible aliases
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
🔋 <b>Отчёт о батарее</b>
|
||||
{%- for ups in devices %}
|
||||
<b>{{ ups.name }}</b>{% if ups.model %} ({{ ups.model }}){% endif %}
|
||||
Заряд: {{ ups.battery_charge }}%
|
||||
Время работы: {{ ups.battery_runtime }}
|
||||
{%- if ups.input_voltage %}Вход: {{ ups.input_voltage }}В{%- endif %}
|
||||
{%- if ups.output_voltage %} · Выход: {{ ups.output_voltage }}В{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- if not devices %}
|
||||
ИБП не найдены.
|
||||
{%- endif %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Подробный отчёт о батарее
|
||||
+1
@@ -0,0 +1 @@
|
||||
Список отслеживаемых ИБП
|
||||
+1
@@ -0,0 +1 @@
|
||||
Показать доступные команды
|
||||
+1
@@ -0,0 +1 @@
|
||||
Сводка статуса ИБП
|
||||
@@ -0,0 +1,7 @@
|
||||
🔌 <b>Отслеживаемые ИБП</b>
|
||||
{%- for d in devices %}
|
||||
• <b>{{ d.name }}</b>{% if d.description %} — {{ d.description }}{% endif %}
|
||||
{%- endfor %}
|
||||
{%- if not devices %}
|
||||
ИБП не настроены.
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
🔌 <b>Доступные команды</b>
|
||||
{%- for cmd in commands %}
|
||||
/{{ cmd.name }} — {{ cmd.description }}
|
||||
{%- endfor %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Результатов не найдено.
|
||||
+1
@@ -0,0 +1 @@
|
||||
⏳ Слишком много запросов. Подождите немного.
|
||||
@@ -0,0 +1,2 @@
|
||||
👋 Привет! Я бот Notify Bridge для <b>NUT (ИБП)</b>.
|
||||
Используйте /help для списка команд.
|
||||
@@ -0,0 +1,9 @@
|
||||
🔌 <b>Статус ИБП</b>
|
||||
{%- for ups in devices %}
|
||||
<b>{{ ups.name }}</b>{% if ups.model %} ({{ ups.model }}){% endif %}
|
||||
Статус: {{ ups.status }} · Батарея: {{ ups.battery_charge }}%
|
||||
Нагрузка: {{ ups.ups_load }}% · Время работы: {{ ups.battery_runtime }}
|
||||
{%- endfor %}
|
||||
{%- if not devices %}
|
||||
ИБП не найдены.
|
||||
{%- endif %}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
🔋 <b>{{ ups_name }}</b> — battery charge recovered
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Battery: {{ battery_charge }}% · Runtime: {{ battery_runtime }}
|
||||
@@ -0,0 +1,4 @@
|
||||
❌ <b>{{ ups_name }}</b> — communication lost
|
||||
{%- if previous_status %}
|
||||
Last known status: {{ previous_status }}
|
||||
{%- endif %}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
✅ <b>{{ ups_name }}</b> — communication restored
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Status: {{ ups_status }}
|
||||
{%- if battery_charge is not none %} · Battery: {{ battery_charge }}%{%- endif %}
|
||||
@@ -0,0 +1,6 @@
|
||||
🚨 <b>{{ ups_name }}</b> — battery critically low!
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Battery: {{ battery_charge }}% · Runtime: {{ battery_runtime }}
|
||||
{%- if ups_load %}
|
||||
Load: {{ ups_load }}%
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
🔋 <b>{{ ups_name }}</b> switched to battery power
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Battery: {{ battery_charge }}% · Runtime: {{ battery_runtime }}
|
||||
{%- if ups_load %} · Load: {{ ups_load }}%{%- endif %}
|
||||
{%- if input_voltage %}
|
||||
Input: {{ input_voltage }}V
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
✅ <b>{{ ups_name }}</b> back on mains power
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Battery: {{ battery_charge }}%
|
||||
{%- if ups_load %} · Load: {{ ups_load }}%{%- endif %}
|
||||
{%- if input_voltage %}
|
||||
Input: {{ input_voltage }}V
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
⚠️ <b>{{ ups_name }}</b> — load exceeded capacity!
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
{%- if ups_load %}
|
||||
Load: {{ ups_load }}%
|
||||
{%- endif %}
|
||||
{%- if battery_charge is not none %}
|
||||
Battery: {{ battery_charge }}% · Runtime: {{ battery_runtime }}
|
||||
{%- endif %}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
⚠️ <b>{{ ups_name }}</b> — battery needs replacement
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
{%- if battery_charge is not none %}
|
||||
Current charge: {{ battery_charge }}%
|
||||
{%- endif %}
|
||||
@@ -53,6 +53,16 @@ PROVIDER_SLOT_FILE_MAP: dict[str, dict[str, str]] = {
|
||||
"scheduler": {
|
||||
"message_scheduled_message": "scheduled_message.jinja2",
|
||||
},
|
||||
"nut": {
|
||||
"message_ups_online": "nut_ups_online.jinja2",
|
||||
"message_ups_on_battery": "nut_ups_on_battery.jinja2",
|
||||
"message_ups_low_battery": "nut_ups_low_battery.jinja2",
|
||||
"message_ups_battery_restored": "nut_ups_battery_restored.jinja2",
|
||||
"message_ups_comms_lost": "nut_ups_comms_lost.jinja2",
|
||||
"message_ups_comms_restored": "nut_ups_comms_restored.jinja2",
|
||||
"message_ups_replace_battery": "nut_ups_replace_battery.jinja2",
|
||||
"message_ups_overload": "nut_ups_overload.jinja2",
|
||||
},
|
||||
}
|
||||
|
||||
# Backward-compatible alias
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
🔋 <b>{{ ups_name }}</b> — заряд батареи восстановлен
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Батарея: {{ battery_charge }}% · Время работы: {{ battery_runtime }}
|
||||
@@ -0,0 +1,4 @@
|
||||
❌ <b>{{ ups_name }}</b> — связь потеряна
|
||||
{%- if previous_status %}
|
||||
Последний статус: {{ previous_status }}
|
||||
{%- endif %}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
✅ <b>{{ ups_name }}</b> — связь восстановлена
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Статус: {{ ups_status }}
|
||||
{%- if battery_charge is not none %} · Батарея: {{ battery_charge }}%{%- endif %}
|
||||
@@ -0,0 +1,6 @@
|
||||
🚨 <b>{{ ups_name }}</b> — критически низкий заряд!
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Батарея: {{ battery_charge }}% · Время работы: {{ battery_runtime }}
|
||||
{%- if ups_load %}
|
||||
Нагрузка: {{ ups_load }}%
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
🔋 <b>{{ ups_name }}</b> перешёл на батарею
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Батарея: {{ battery_charge }}% · Время работы: {{ battery_runtime }}
|
||||
{%- if ups_load %} · Нагрузка: {{ ups_load }}%{%- endif %}
|
||||
{%- if input_voltage %}
|
||||
Вход: {{ input_voltage }}В
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
✅ <b>{{ ups_name }}</b> снова на сети
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
Батарея: {{ battery_charge }}%
|
||||
{%- if ups_load %} · Нагрузка: {{ ups_load }}%{%- endif %}
|
||||
{%- if input_voltage %}
|
||||
Вход: {{ input_voltage }}В
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
⚠️ <b>{{ ups_name }}</b> — перегрузка!
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
{%- if ups_load %}
|
||||
Нагрузка: {{ ups_load }}%
|
||||
{%- endif %}
|
||||
{%- if battery_charge is not none %}
|
||||
Батарея: {{ battery_charge }}% · Время работы: {{ battery_runtime }}
|
||||
{%- endif %}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
⚠️ <b>{{ ups_name }}</b> — требуется замена батареи
|
||||
{%- if ups_model %} ({{ ups_model }}){%- endif %}
|
||||
{%- if battery_charge is not none %}
|
||||
Текущий заряд: {{ battery_charge }}%
|
||||
{%- endif %}
|
||||
Reference in New Issue
Block a user