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:
2026-03-23 23:23:58 +03:00
parent c451f3dd72
commit 68ac13b452
73 changed files with 1385 additions and 45 deletions
@@ -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 %}
@@ -0,0 +1 @@
Detailed battery report
@@ -0,0 +1 @@
List monitored UPS devices
@@ -0,0 +1 @@
Show available commands
@@ -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 %}
@@ -0,0 +1 @@
No results found.
@@ -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 %}
@@ -0,0 +1 @@
Подробный отчёт о батарее
@@ -0,0 +1 @@
Список отслеживаемых ИБП
@@ -0,0 +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 %}
@@ -0,0 +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 %}
@@ -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 %}
@@ -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 %}
@@ -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
@@ -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 %}
@@ -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 %}
@@ -0,0 +1,5 @@
⚠️ <b>{{ ups_name }}</b> — требуется замена батареи
{%- if ups_model %} ({{ ups_model }}){%- endif %}
{%- if battery_charge is not none %}
Текущий заряд: {{ battery_charge }}%
{%- endif %}
@@ -13,7 +13,7 @@ import aiohttp
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
from ..services import make_immich_provider, make_gitea_provider, make_planka_provider
from ..services import make_immich_provider, make_gitea_provider, make_planka_provider, make_nut_provider
_LOGGER = logging.getLogger(__name__)
@@ -69,11 +69,19 @@ class SchedulerProviderConfig(BaseModel):
pass
class NutProviderConfig(BaseModel):
host: str
port: int = 3493
username: str | None = None
password: str | None = None
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
"immich": ImmichProviderConfig,
"gitea": GiteaProviderConfig,
"planka": PlankaProviderConfig,
"scheduler": SchedulerProviderConfig,
"nut": NutProviderConfig,
}
@@ -163,6 +171,17 @@ async def create_provider(
detail=test_result.get("message", "Cannot connect to Planka"),
)
elif body.type == "nut":
nut = make_nut_provider(ServiceProvider(
id=0, user_id=0, type="nut", name=body.name, config=body.config,
))
test_result = await nut.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", "Cannot connect to NUT server"),
)
# Scheduler: no validation needed (virtual provider)
provider = ServiceProvider(
@@ -297,6 +316,14 @@ async def update_provider(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
elif config_changed and provider.type == "nut":
nut = make_nut_provider(provider)
test_result = await nut.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", "Cannot connect to NUT server"),
)
session.add(provider)
await session.commit()
@@ -349,6 +376,10 @@ async def test_provider(
if provider.type == "scheduler":
return {"ok": True, "message": "Virtual provider — always available"}
if provider.type == "nut":
nut = make_nut_provider(provider)
return await nut.test_connection()
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
@@ -417,6 +448,10 @@ async def list_collections(
planka = make_planka_provider(http_session, provider)
return await planka.list_collections()
if provider.type == "nut":
nut = make_nut_provider(provider)
return await nut.list_collections()
return []
@@ -475,7 +510,7 @@ def _provider_response(p: ServiceProvider) -> dict:
"""Build a safe response dict for a provider."""
config = dict(p.config)
# Mask sensitive fields
for secret_field in ("api_key", "api_token", "webhook_secret"):
for secret_field in ("api_key", "api_token", "webhook_secret", "password"):
if secret_field in config:
key = config[secret_field]
config[secret_field] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
@@ -249,6 +249,138 @@ async def get_template_variables():
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"asset_fields": asset_fields,
},
# --- Gitea slots ---
**_gitea_variables(),
# --- Planka slots ---
**_planka_variables(),
# --- NUT (UPS) slots ---
**_nut_variables(),
# --- Scheduler slots ---
"message_scheduled_message": {
"description": "Notification for scheduled message events",
"variables": {
"tracker_name": "Name of the tracker that fired",
"fire_count": "How many times this tracker has fired",
"current_date": "Current date (formatted)",
"current_time": "Current time (formatted)",
"current_datetime": "Current date and time (formatted)",
},
},
}
def _gitea_variables() -> dict:
common = {
"sender": "Username who triggered the event",
"sender_name": "Display name of the sender",
"repo_name": "Repository full name (owner/repo)",
"repo_url": "Repository URL",
}
return {
"message_push": {
"description": "Code pushed to repository",
"variables": {**common, "branch": "Branch name", "commit_count": "Number of commits",
"compare_url": "Comparison URL", "commits": "List of commit dicts"},
},
"message_issue_opened": {
"description": "Issue opened",
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
"issue_url": "Issue URL", "issue_body": "Issue body text", "issue_labels": "Labels list"},
},
"message_issue_closed": {
"description": "Issue closed",
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
"issue_url": "Issue URL", "issue_state": "Issue state"},
},
"message_issue_commented": {
"description": "Comment on issue",
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
"issue_url": "Issue URL", "comment_body": "Comment text",
"comment_url": "Comment URL", "comment_author": "Comment author"},
},
"message_pr_opened": {
"description": "Pull request opened",
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
"pr_url": "PR URL", "pr_body": "PR body text",
"pr_base": "Base branch", "pr_head": "Head branch", "pr_labels": "Labels list"},
},
"message_pr_closed": {
"description": "Pull request closed",
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
"pr_url": "PR URL", "pr_state": "PR state", "pr_merged": "Whether PR was merged"},
},
"message_pr_merged": {
"description": "Pull request merged",
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
"pr_url": "PR URL", "pr_base": "Base branch", "pr_head": "Head branch"},
},
"message_pr_commented": {
"description": "Comment on pull request",
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
"pr_url": "PR URL", "comment_body": "Comment text",
"comment_url": "Comment URL", "comment_author": "Comment author"},
},
"message_release_published": {
"description": "Release published",
"variables": {**common, "release_tag": "Release tag", "release_name": "Release name",
"release_url": "Release URL", "release_body": "Release notes",
"release_draft": "Is draft (boolean)", "release_prerelease": "Is prerelease (boolean)"},
},
}
def _planka_variables() -> dict:
common = {
"sender": "Username who triggered the event",
"sender_name": "Display name of the sender",
"board_name": "Board name",
"board_id": "Board ID",
"board_url": "Board URL",
}
card = {**common, "card_name": "Card name", "card_id": "Card ID", "card_url": "Card URL"}
return {
"message_card_created": {"description": "Card created", "variables": {**card, "list_name": "List name", "card_description": "Card description"}},
"message_card_updated": {"description": "Card updated", "variables": {**card, "card_description": "Card description", "card_due_date": "Due date"}},
"message_card_moved": {"description": "Card moved between lists", "variables": {**card, "old_list_name": "Previous list", "new_list_name": "New list"}},
"message_card_deleted": {"description": "Card deleted", "variables": card},
"message_card_commented": {"description": "Comment added to card", "variables": {**card, "comment_text": "Comment text"}},
"message_comment_updated": {"description": "Comment updated", "variables": {**card, "comment_text": "Updated comment text"}},
"message_board_created": {"description": "Board created", "variables": common},
"message_board_updated": {"description": "Board updated", "variables": common},
"message_board_deleted": {"description": "Board deleted", "variables": common},
"message_list_created": {"description": "List created", "variables": {**common, "list_name": "List name"}},
"message_list_updated": {"description": "List updated", "variables": {**common, "list_name": "List name"}},
"message_list_deleted": {"description": "List deleted", "variables": {**common, "list_name": "List name"}},
"message_attachment_created": {"description": "Attachment added", "variables": {**card, "attachment_name": "Attachment filename"}},
"message_card_label_added": {"description": "Label added to card", "variables": {**card, "label_name": "Label name", "label_color": "Label color"}},
"message_task_completed": {"description": "Task completed", "variables": {**card, "task_name": "Task name", "task_completed": "Completed (boolean)"}},
}
def _nut_variables() -> dict:
common = {
"ups_name": "UPS device name",
"ups_model": "UPS hardware model",
"ups_manufacturer": "UPS manufacturer",
"battery_charge": "Battery charge percentage",
"battery_runtime": "Estimated runtime (formatted)",
"battery_runtime_seconds": "Estimated runtime in seconds",
"ups_load": "UPS load percentage",
"ups_status": "Raw status flags (e.g. OL, OB, LB)",
"input_voltage": "Input voltage",
"output_voltage": "Output voltage",
"event_description": "Human-readable event description",
"previous_status": "Previous UPS status flags",
}
return {
"message_ups_online": {"description": "UPS back on mains power", "variables": common},
"message_ups_on_battery": {"description": "UPS switched to battery", "variables": common},
"message_ups_low_battery": {"description": "Battery critically low", "variables": common},
"message_ups_battery_restored": {"description": "Battery charge recovered", "variables": common},
"message_ups_comms_lost": {"description": "Communication with UPS lost", "variables": {"ups_name": common["ups_name"], "previous_status": common["previous_status"], "event_description": common["event_description"]}},
"message_ups_comms_restored": {"description": "Communication restored", "variables": common},
"message_ups_replace_battery": {"description": "Battery needs replacement", "variables": common},
"message_ups_overload": {"description": "UPS load exceeded capacity", "variables": common},
}
@@ -32,10 +32,12 @@ def _auto_register() -> None:
from .immich import ImmichCommandHandler
from .gitea_handler import GiteaCommandHandler
from .planka_handler import PlankaCommandHandler
from .nut_handler import NutCommandHandler
register_handler(ImmichCommandHandler())
register_handler(GiteaCommandHandler())
register_handler(PlankaCommandHandler())
register_handler(NutCommandHandler())
# Auto-register on import
@@ -0,0 +1,112 @@
"""NUT (UPS)-specific bot command handler."""
from __future__ import annotations
import logging
from typing import Any
from ..database.models import CommandConfig, CommandTracker, ServiceProvider, TelegramBot
from ..services import make_nut_provider
from .base import ProviderCommandHandler
from .handler import _render_cmd_template
_LOGGER = logging.getLogger(__name__)
_NUT_COMMANDS = {"status", "devices", "battery"}
class NutCommandHandler(ProviderCommandHandler):
"""Handles NUT-specific bot commands."""
provider_type = "nut"
def get_provider_commands(self) -> set[str]:
return _NUT_COMMANDS
def get_rate_categories(self) -> dict[str, str]:
return {"devices": "api", "battery": "api", "status": "api"}
async def handle(
self,
cmd: str,
args: str,
count: int,
locale: str,
response_mode: str,
providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, dict[str, str]],
bot: TelegramBot,
ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> str | list[dict[str, Any]] | None:
if cmd == "status":
ctx = await _cmd_status(providers_map)
return _render_cmd_template(cmd_templates, "status", locale, ctx)
if cmd == "devices":
ctx = await _cmd_devices(providers_map)
return _render_cmd_template(cmd_templates, "devices", locale, ctx)
if cmd == "battery":
ctx = await _cmd_battery(providers_map)
return _render_cmd_template(cmd_templates, "battery", locale, ctx)
return None
async def _query_all_ups(
providers_map: dict[int, ServiceProvider],
) -> list[dict[str, Any]]:
"""Connect to all NUT providers and query UPS data."""
from notify_bridge_core.providers.nut.models import NutUpsData
results: list[dict[str, Any]] = []
for provider in providers_map.values():
if provider.type != "nut":
continue
nut = make_nut_provider(provider)
try:
client = nut._make_client()
await client.connect()
try:
devices = await client.list_ups()
for dev in devices:
variables = await client.list_var(dev.name)
data = NutUpsData.from_variables(dev.name, variables)
results.append({
"name": data.name,
"description": data.description,
"model": data.model,
"manufacturer": data.manufacturer,
"status": data.status,
"battery_charge": int(data.battery_charge) if data.battery_charge is not None else None,
"battery_runtime": data.battery_runtime_formatted,
"ups_load": int(data.ups_load) if data.ups_load is not None else None,
"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,
})
finally:
await client.disconnect()
except Exception as exc:
_LOGGER.warning("Failed to query NUT provider %s: %s", provider.name, exc)
return results
async def _cmd_status(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
devices = await _query_all_ups(providers_map)
return {"devices": devices}
async def _cmd_devices(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
devices: list[dict[str, Any]] = []
for provider in providers_map.values():
if provider.type != "nut":
continue
nut = make_nut_provider(provider)
try:
device_list = await nut.list_collections()
devices.extend(device_list)
except Exception as exc:
_LOGGER.warning("Failed to list devices from %s: %s", provider.name, exc)
return {"devices": devices}
async def _cmd_battery(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
devices = await _query_all_ups(providers_map)
return {"devices": devices}
@@ -185,6 +185,41 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added %s column to tracking_config table", col_name)
# Add NUT (UPS) tracking flags to tracking_config if missing
if await _has_table(conn, "tracking_config"):
nut_flags = [
("track_ups_online", "INTEGER DEFAULT 1"),
("track_ups_on_battery", "INTEGER DEFAULT 1"),
("track_ups_low_battery", "INTEGER DEFAULT 1"),
("track_ups_battery_restored", "INTEGER DEFAULT 1"),
("track_ups_comms_lost", "INTEGER DEFAULT 1"),
("track_ups_comms_restored", "INTEGER DEFAULT 1"),
("track_ups_replace_battery", "INTEGER DEFAULT 1"),
("track_ups_overload", "INTEGER DEFAULT 1"),
]
for col_name, col_type in nut_flags:
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
)
logger.info("Added %s column to tracking_config table", col_name)
# Drop legacy template content columns from template_config
# (template content moved to template_slot child rows)
if await _has_table(conn, "template_config"):
legacy_cols = [
"message_assets_added", "message_assets_removed",
"message_collection_renamed", "message_collection_deleted",
"message_sharing_changed", "periodic_summary_message",
"scheduled_assets_message", "memory_mode_message",
]
for col_name in legacy_cols:
if await _has_column(conn, "template_config", col_name):
await conn.execute(
text(f"ALTER TABLE template_config DROP COLUMN {col_name}")
)
logger.info("Dropped legacy column %s from template_config", col_name)
# Add collection_name and shared to tracker_state if missing
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
if await _has_table(conn, state_table):
@@ -150,6 +150,16 @@ class TrackingConfig(SQLModel, table=True):
# Scheduler event tracking
track_scheduled_message: bool = Field(default=True)
# NUT (UPS) event tracking
track_ups_online: bool = Field(default=True)
track_ups_on_battery: bool = Field(default=True)
track_ups_low_battery: bool = Field(default=True)
track_ups_battery_restored: bool = Field(default=True)
track_ups_comms_lost: bool = Field(default=True)
track_ups_comms_restored: bool = Field(default=True)
track_ups_replace_battery: bool = Field(default=True)
track_ups_overload: bool = Field(default=True)
# Immich asset display
track_images: bool = Field(default=True)
track_videos: bool = Field(default=True)
@@ -151,6 +151,7 @@ async def _seed_default_templates() -> None:
await _seed_provider_template(session, "gitea", "Gitea")
await _seed_provider_template(session, "planka", "Planka")
await _seed_provider_template(session, "scheduler", "Scheduler")
await _seed_provider_template(session, "nut", "NUT")
await session.commit()
@@ -171,6 +172,9 @@ async def _seed_default_command_templates() -> None:
await _seed_provider_command_template(
session, "planka", "Default Planka Commands", "Default Planka command templates",
)
await _seed_provider_command_template(
session, "nut", "Default NUT Commands", "Default NUT command templates",
)
await session.commit()
@@ -221,6 +225,18 @@ async def _seed_default_tracking_configs() -> None:
"name": "Default Scheduler",
"track_scheduled_message": True,
},
{
"provider_type": "nut",
"name": "Default NUT",
"track_ups_online": True,
"track_ups_on_battery": True,
"track_ups_low_battery": True,
"track_ups_battery_restored": True,
"track_ups_comms_lost": True,
"track_ups_comms_restored": True,
"track_ups_replace_battery": True,
"track_ups_overload": True,
},
]
for cfg in defaults:
@@ -279,6 +295,16 @@ async def _seed_default_command_configs() -> None:
"default_count": 10,
"rate_limits": {"api": 15, "default": 10},
},
{
"provider_type": "nut",
"name": "Default NUT",
"enabled_commands": [
"help", "status", "devices", "battery",
],
"response_mode": "text",
"default_count": 5,
"rate_limits": {"api": 15, "default": 10},
},
]
for cfg in defaults:
@@ -3,6 +3,7 @@
from notify_bridge_core.providers.immich import ImmichServiceProvider
from notify_bridge_core.providers.gitea import GiteaServiceProvider
from notify_bridge_core.providers.planka import PlankaServiceProvider
from notify_bridge_core.providers.nut import NutServiceProvider
from ..database.models import ServiceProvider
@@ -39,3 +40,15 @@ def make_planka_provider(http_session, provider: ServiceProvider) -> PlankaServi
config.get("api_key", ""),
provider.name,
)
def make_nut_provider(provider: ServiceProvider) -> NutServiceProvider:
"""Create a NutServiceProvider from a DB provider model."""
config = provider.config or {}
return NutServiceProvider(
host=config.get("host", "localhost"),
port=config.get("port", 3493),
username=config.get("username"),
password=config.get("password"),
name=provider.name,
)
@@ -83,6 +83,15 @@ def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"task_completed": tc.track_task_completed,
# Scheduler events
"scheduled_message": tc.track_scheduled_message,
# NUT (UPS) events
"ups_online": tc.track_ups_online,
"ups_on_battery": tc.track_ups_on_battery,
"ups_low_battery": tc.track_ups_low_battery,
"ups_battery_restored": tc.track_ups_battery_restored,
"ups_comms_lost": tc.track_ups_comms_lost,
"ups_comms_restored": tc.track_ups_comms_restored,
"ups_replace_battery": tc.track_ups_replace_battery,
"ups_overload": tc.track_ups_overload,
}
return flag_map.get(event_type, True)
@@ -123,6 +123,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
custom_variables=custom_vars,
)
events, new_state = await sched.poll(collection_ids, state_dict)
elif provider_type == "nut":
from notify_bridge_core.providers.nut import NutServiceProvider
nut = NutServiceProvider(
host=provider_config.get("host", "localhost"),
port=provider_config.get("port", 3493),
username=provider_config.get("username"),
password=provider_config.get("password"),
name=provider_name,
)
events, new_state = await nut.poll(collection_ids, state_dict)
else:
return {"status": "error", "reason": f"unsupported provider type: {provider_type}"}