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
@@ -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}