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