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:
@@ -43,6 +43,14 @@ Shared entities use a `$state`-based cache layer in `frontend/src/lib/stores/`:
|
|||||||
3. Add `fooCache.clear()` to `clearAllCaches()`
|
3. Add `fooCache.clear()` to `clearAllCaches()`
|
||||||
4. In page components: replace `let foo = $state<Foo[]>([])` with `let foo = $derived(fooCache.items)` and replace `api('/foo')` with `fooCache.fetch()`
|
4. In page components: replace `let foo = $state<Foo[]>([])` with `let foo = $derived(fooCache.items)` and replace `api('/foo')` with `fooCache.fetch()`
|
||||||
|
|
||||||
|
## Provider-Aware UI
|
||||||
|
|
||||||
|
**IMPORTANT**: UI labels for collections, template variables, and icons MUST be dynamic per provider type — never hardcode Immich-specific terms like "Albums" or `mdiImageMultiple` where other providers will appear.
|
||||||
|
|
||||||
|
- **TrackerForm** (`TrackerForm.svelte`): Uses `collectionMeta` lookup by `providerType` for collection label, icon, placeholder, and description.
|
||||||
|
- **Template variables** (`/api/template-configs/variables`): Must return variable definitions for ALL provider types (Immich, Gitea, Planka, NUT, Scheduler), not just Immich. When adding a new provider, add its slot variables to `_<provider>_variables()` in `template_configs.py`.
|
||||||
|
- **Grid items** (`grid-items.ts`): New provider types must be added to BOTH `providerTypeItems` AND `providerTypeFilterItems`.
|
||||||
|
|
||||||
## UI Conventions
|
## UI Conventions
|
||||||
|
|
||||||
### Selector Placeholders
|
### Selector Placeholders
|
||||||
|
|||||||
@@ -25,3 +25,10 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
|
|||||||
- Command slot mapping in `packages/core/src/notify_bridge_core/templates/command_defaults/loader.py` (`PROVIDER_COMMAND_SLOTS`)
|
- Command slot mapping in `packages/core/src/notify_bridge_core/templates/command_defaults/loader.py` (`PROVIDER_COMMAND_SLOTS`)
|
||||||
- Provider capabilities in `packages/core/src/notify_bridge_core/providers/capabilities.py`
|
- Provider capabilities in `packages/core/src/notify_bridge_core/providers/capabilities.py`
|
||||||
- Seed functions in `packages/server/src/notify_bridge_server/database/seeds.py` (notification templates, command templates, tracking configs, command configs)
|
- Seed functions in `packages/server/src/notify_bridge_server/database/seeds.py` (notification templates, command templates, tracking configs, command configs)
|
||||||
|
- Template variable definitions in `packages/server/src/notify_bridge_server/api/template_configs.py` (`get_template_variables()`)
|
||||||
|
8. **No provider-specific hardcoding** — UI labels, icons, form defaults, and feature checks MUST be provider-agnostic. NEVER hardcode a specific provider type (e.g. `'immich'`) where multiple providers could appear:
|
||||||
|
- Form defaults: use `provider_type: ''` (empty), not `'immich'`
|
||||||
|
- Collection labels: use the `collectionMeta` lookup in `TrackerForm.svelte`, not hardcoded "Albums"
|
||||||
|
- Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'`
|
||||||
|
- Provider-specific API calls (e.g. `/albums/.../shared-links`): guard with a provider type check
|
||||||
|
- Template variable helpers: ALL provider types must have entries in `get_template_variables()`
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder={placeholder}
|
placeholder={selected ? selected.label : placeholder}
|
||||||
class="ep-input"
|
class="ep-input"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const PROVIDER_TYPE_ICONS: Record<string, string> = {
|
|||||||
gitea: 'mdiGit',
|
gitea: 'mdiGit',
|
||||||
planka: 'mdiViewDashboard',
|
planka: 'mdiViewDashboard',
|
||||||
scheduler: 'mdiClockOutline',
|
scheduler: 'mdiClockOutline',
|
||||||
|
nut: 'mdiBatteryCharging80',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the default icon for a provider, falling back by type then generic. */
|
/** Get the default icon for a provider, falling back by type then generic. */
|
||||||
@@ -118,6 +119,7 @@ export const providerTypeFilterItems = (): GridItem[] => [
|
|||||||
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
|
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
|
||||||
{ value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
|
{ value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
|
||||||
{ value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
|
{ value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
|
||||||
|
{ value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- Provider type ---
|
// --- Provider type ---
|
||||||
@@ -127,4 +129,5 @@ export const providerTypeItems = (): GridItem[] => [
|
|||||||
{ value: 'gitea', icon: PROVIDER_TYPE_ICONS.gitea, label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
|
{ value: 'gitea', icon: PROVIDER_TYPE_ICONS.gitea, label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
|
||||||
{ value: 'planka', icon: PROVIDER_TYPE_ICONS.planka, label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
|
{ value: 'planka', icon: PROVIDER_TYPE_ICONS.planka, label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
|
||||||
{ value: 'scheduler', icon: PROVIDER_TYPE_ICONS.scheduler, label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
|
{ value: 'scheduler', icon: PROVIDER_TYPE_ICONS.scheduler, label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
|
||||||
|
{ value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
"typeGitea": "Gitea",
|
"typeGitea": "Gitea",
|
||||||
"typePlanka": "Planka",
|
"typePlanka": "Planka",
|
||||||
"typeScheduler": "Scheduler",
|
"typeScheduler": "Scheduler",
|
||||||
|
"typeNut": "NUT (UPS)",
|
||||||
"loadError": "Failed to load providers.",
|
"loadError": "Failed to load providers.",
|
||||||
"externalDomain": "External Domain",
|
"externalDomain": "External Domain",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
@@ -127,6 +128,13 @@
|
|||||||
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
||||||
"webhookUrl": "Webhook URL",
|
"webhookUrl": "Webhook URL",
|
||||||
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).",
|
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).",
|
||||||
|
"nutHost": "NUT Server Host",
|
||||||
|
"nutHostPlaceholder": "192.168.1.100 or ups.local",
|
||||||
|
"nutPort": "NUT Server Port",
|
||||||
|
"nutUsername": "Username",
|
||||||
|
"nutPassword": "Password",
|
||||||
|
"nutUsernameHint": "Optional — only needed if upsd requires authentication",
|
||||||
|
"nutPasswordHint": "Optional — upsd user password",
|
||||||
"testAndSave": "Test & Save",
|
"testAndSave": "Test & Save",
|
||||||
"saveWithoutTest": "Save without testing"
|
"saveWithoutTest": "Save without testing"
|
||||||
},
|
},
|
||||||
@@ -141,6 +149,12 @@
|
|||||||
"selectServer": "Select provider...",
|
"selectServer": "Select provider...",
|
||||||
"albums": "Albums",
|
"albums": "Albums",
|
||||||
"selectAlbums": "Select albums...",
|
"selectAlbums": "Select albums...",
|
||||||
|
"repositories": "Repositories",
|
||||||
|
"selectRepositories": "Select repositories...",
|
||||||
|
"boards": "Boards",
|
||||||
|
"selectBoards": "Select boards...",
|
||||||
|
"upsDevices": "UPS Devices",
|
||||||
|
"selectUpsDevices": "Select UPS devices...",
|
||||||
"eventTypes": "Event Types",
|
"eventTypes": "Event Types",
|
||||||
"notificationTargets": "Notification Targets",
|
"notificationTargets": "Notification Targets",
|
||||||
"scanInterval": "Scan Interval (seconds)",
|
"scanInterval": "Scan Interval (seconds)",
|
||||||
@@ -826,7 +840,8 @@
|
|||||||
"providerImmich": "Self-hosted photo server",
|
"providerImmich": "Self-hosted photo server",
|
||||||
"providerGitea": "Self-hosted Git service",
|
"providerGitea": "Self-hosted Git service",
|
||||||
"providerPlanka": "Self-hosted Kanban board",
|
"providerPlanka": "Self-hosted Kanban board",
|
||||||
"providerScheduler": "Time-based scheduled messages"
|
"providerScheduler": "Time-based scheduled messages",
|
||||||
|
"providerNut": "Network UPS monitoring"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"notFound": "Page not found",
|
"notFound": "Page not found",
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
"typeGitea": "Gitea",
|
"typeGitea": "Gitea",
|
||||||
"typePlanka": "Planka",
|
"typePlanka": "Planka",
|
||||||
"typeScheduler": "Планировщик",
|
"typeScheduler": "Планировщик",
|
||||||
|
"typeNut": "NUT (ИБП)",
|
||||||
"loadError": "Не удалось загрузить провайдеры.",
|
"loadError": "Не удалось загрузить провайдеры.",
|
||||||
"externalDomain": "Внешний домен",
|
"externalDomain": "Внешний домен",
|
||||||
"optional": "необязательно",
|
"optional": "необязательно",
|
||||||
@@ -127,6 +128,13 @@
|
|||||||
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
||||||
"webhookUrl": "URL вебхука",
|
"webhookUrl": "URL вебхука",
|
||||||
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).",
|
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).",
|
||||||
|
"nutHost": "Хост NUT-сервера",
|
||||||
|
"nutHostPlaceholder": "192.168.1.100 или ups.local",
|
||||||
|
"nutPort": "Порт NUT-сервера",
|
||||||
|
"nutUsername": "Имя пользователя",
|
||||||
|
"nutPassword": "Пароль",
|
||||||
|
"nutUsernameHint": "Необязательно — только если upsd требует аутентификации",
|
||||||
|
"nutPasswordHint": "Необязательно — пароль пользователя upsd",
|
||||||
"testAndSave": "Проверить и сохранить",
|
"testAndSave": "Проверить и сохранить",
|
||||||
"saveWithoutTest": "Сохранить без проверки"
|
"saveWithoutTest": "Сохранить без проверки"
|
||||||
},
|
},
|
||||||
@@ -141,6 +149,12 @@
|
|||||||
"selectServer": "Выберите провайдер...",
|
"selectServer": "Выберите провайдер...",
|
||||||
"albums": "Альбомы",
|
"albums": "Альбомы",
|
||||||
"selectAlbums": "Выберите альбомы...",
|
"selectAlbums": "Выберите альбомы...",
|
||||||
|
"repositories": "Репозитории",
|
||||||
|
"selectRepositories": "Выберите репозитории...",
|
||||||
|
"boards": "Доски",
|
||||||
|
"selectBoards": "Выберите доски...",
|
||||||
|
"upsDevices": "ИБП устройства",
|
||||||
|
"selectUpsDevices": "Выберите ИБП...",
|
||||||
"eventTypes": "Типы событий",
|
"eventTypes": "Типы событий",
|
||||||
"notificationTargets": "Получатели уведомлений",
|
"notificationTargets": "Получатели уведомлений",
|
||||||
"scanInterval": "Интервал проверки (секунды)",
|
"scanInterval": "Интервал проверки (секунды)",
|
||||||
@@ -826,7 +840,8 @@
|
|||||||
"providerImmich": "Фотосервер для самостоятельного размещения",
|
"providerImmich": "Фотосервер для самостоятельного размещения",
|
||||||
"providerGitea": "Git-сервер для самостоятельного размещения",
|
"providerGitea": "Git-сервер для самостоятельного размещения",
|
||||||
"providerPlanka": "Канбан-доска для самостоятельного размещения",
|
"providerPlanka": "Канбан-доска для самостоятельного размещения",
|
||||||
"providerScheduler": "Запланированные сообщения по расписанию"
|
"providerScheduler": "Запланированные сообщения по расписанию",
|
||||||
|
"providerNut": "Мониторинг ИБП через NUT"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"notFound": "Страница не найдена",
|
"notFound": "Страница не найдена",
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ function loadFromStorage(): void {
|
|||||||
loadFromStorage();
|
loadFromStorage();
|
||||||
|
|
||||||
export const globalProviderFilter = {
|
export const globalProviderFilter = {
|
||||||
get id() { return _providerId; },
|
get id() {
|
||||||
|
// If providers are loaded and the stored ID doesn't match any, auto-clear
|
||||||
|
if (_providerId != null && providersCache.items.length > 0 &&
|
||||||
|
!providersCache.items.some(p => p.id === _providerId)) {
|
||||||
|
globalProviderFilter.clear();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _providerId;
|
||||||
|
},
|
||||||
get initialized() { return _initialized; },
|
get initialized() { return _initialized; },
|
||||||
|
|
||||||
set(id: number | null) {
|
set(id: number | null) {
|
||||||
@@ -46,8 +54,9 @@ export const globalProviderFilter = {
|
|||||||
|
|
||||||
/** The currently selected provider object (reactive). */
|
/** The currently selected provider object (reactive). */
|
||||||
get provider() {
|
get provider() {
|
||||||
if (_providerId == null) return null;
|
const id = this.id; // triggers stale-ID auto-clear
|
||||||
return providersCache.items.find(p => p.id === _providerId) ?? null;
|
if (id == null) return null;
|
||||||
|
return providersCache.items.find(p => p.id === id) ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** The provider type string, or null. */
|
/** The provider type string, or null. */
|
||||||
|
|||||||
@@ -42,6 +42,14 @@
|
|||||||
globalProviderFilter.set(v === 0 ? null : v);
|
globalProviderFilter.set(v === 0 ? null : v);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync store → filter value (handles auto-clear of stale IDs)
|
||||||
|
$effect(() => {
|
||||||
|
const storeId = globalProviderFilter.id;
|
||||||
|
if (storeId === null && providerFilterValue !== 0) {
|
||||||
|
providerFilterValue = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let showPasswordForm = $state(false);
|
let showPasswordForm = $state(false);
|
||||||
let redirecting = $state(false);
|
let redirecting = $state(false);
|
||||||
let openSearch: (() => void) | undefined;
|
let openSearch: (() => void) | undefined;
|
||||||
|
|||||||
@@ -67,9 +67,9 @@
|
|||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
name: '',
|
name: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
provider_type: 'immich',
|
provider_type: '',
|
||||||
enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[],
|
enabled_commands: [] as string[],
|
||||||
response_mode: 'media',
|
response_mode: 'text',
|
||||||
default_count: 5,
|
default_count: 5,
|
||||||
rate_limits: { search: 30, default: 10 },
|
rate_limits: { search: 30, default: 10 },
|
||||||
command_template_config_id: null as number | null,
|
command_template_config_id: null as number | null,
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
form = {
|
form = {
|
||||||
name: cfg.name,
|
name: cfg.name,
|
||||||
icon: cfg.icon || '',
|
icon: cfg.icon || '',
|
||||||
provider_type: cfg.provider_type || 'immich',
|
provider_type: cfg.provider_type || '',
|
||||||
enabled_commands: [...(cfg.enabled_commands || [])],
|
enabled_commands: [...(cfg.enabled_commands || [])],
|
||||||
response_mode: cfg.response_mode || 'media',
|
response_mode: cfg.response_mode || 'media',
|
||||||
default_count: cfg.default_count ?? 5,
|
default_count: cfg.default_count ?? 5,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
provider_type: 'immich',
|
provider_type: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache } from '$lib/stores/caches.svelte';
|
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
(!effectiveProviderId || t.provider_id === effectiveProviderId)
|
(!effectiveProviderId || t.provider_id === effectiveProviderId)
|
||||||
));
|
));
|
||||||
let providers = $derived(providersCache.items);
|
let providers = $derived(providersCache.items);
|
||||||
|
let allCapabilities: Record<string, any> = $derived(capabilitiesCache.items || {});
|
||||||
const providerItems = $derived(providers
|
const providerItems = $derived(providers
|
||||||
.filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType)
|
.filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType)
|
||||||
.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
|
.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
|
||||||
@@ -78,24 +79,30 @@
|
|||||||
let testMenuOpen = $state<string | null>(null);
|
let testMenuOpen = $state<string | null>(null);
|
||||||
let testMenuStyle = $state('');
|
let testMenuStyle = $state('');
|
||||||
|
|
||||||
const immichTestTypes = [
|
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
||||||
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
// that have those notification slots in their capabilities
|
||||||
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
|
const allTestTypes: Record<string, { key: string; icon: string; labelKey: string; requiredSlot?: string }> = {
|
||||||
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
|
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
||||||
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
|
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message' },
|
||||||
];
|
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message' },
|
||||||
const defaultTestTypes = [
|
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message' },
|
||||||
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
};
|
||||||
];
|
|
||||||
|
|
||||||
let testMenuTrackerId = $state<number | null>(null);
|
let testMenuTrackerId = $state<number | null>(null);
|
||||||
let testTypes = $derived.by(() => {
|
let testTypes = $derived.by(() => {
|
||||||
if (!testMenuTrackerId) return defaultTestTypes;
|
const base = [allTestTypes.basic];
|
||||||
|
if (!testMenuTrackerId) return base;
|
||||||
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
|
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
|
||||||
if (!tracker) return defaultTestTypes;
|
if (!tracker) return base;
|
||||||
const provider = providers.find(p => p.id === tracker.provider_id);
|
const provider = providers.find(p => p.id === tracker.provider_id);
|
||||||
if (provider?.type === 'immich') return immichTestTypes;
|
if (!provider) return base;
|
||||||
return defaultTestTypes;
|
const caps = allCapabilities[provider.type];
|
||||||
|
if (!caps) return base;
|
||||||
|
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
|
||||||
|
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
|
||||||
|
if (tt.requiredSlot && slotNames.has(tt.requiredSlot)) base.push(tt);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
@@ -107,6 +114,7 @@
|
|||||||
api<Tracker[]>('/notification-trackers'),
|
api<Tracker[]>('/notification-trackers'),
|
||||||
providersCache.fetch(), targetsCache.fetch(),
|
providersCache.fetch(), targetsCache.fetch(),
|
||||||
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
||||||
|
capabilitiesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
loadError = err.message || 'Failed to load data';
|
loadError = err.message || 'Failed to load data';
|
||||||
@@ -146,7 +154,7 @@
|
|||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
|
|
||||||
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
|
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
|
||||||
if (newAlbumIds.length > 0 && form.provider_id) {
|
if (newAlbumIds.length > 0 && form.provider_id && selectedProviderType === 'immich') {
|
||||||
linkCheckLoading = true;
|
linkCheckLoading = true;
|
||||||
try {
|
try {
|
||||||
const missingAlbums: { id: string; name: string; issue: string }[] = [];
|
const missingAlbums: { id: string; name: string; issue: string }[] = [];
|
||||||
|
|||||||
@@ -85,12 +85,12 @@
|
|||||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||||
<div class="min-w-[140px]">
|
<div class="min-w-[140px]">
|
||||||
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
|
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
|
||||||
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('trackingConfig.title')}
|
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||||
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
|
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[140px]">
|
<div class="min-w-[140px]">
|
||||||
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
|
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
|
||||||
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('templateConfig.title')}
|
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||||
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
|
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -118,12 +118,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-w-[140px]">
|
<div class="min-w-[140px]">
|
||||||
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
|
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
|
||||||
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('trackingConfig.title')}
|
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||||
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
|
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[140px]">
|
<div class="min-w-[140px]">
|
||||||
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
|
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
|
||||||
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('templateConfig.title')}
|
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||||
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
|
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
|
||||||
</div>
|
</div>
|
||||||
<button onclick={onaddLink}
|
<button onclick={onaddLink}
|
||||||
|
|||||||
@@ -46,7 +46,16 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isScheduler = $derived(providerType === 'scheduler');
|
let isScheduler = $derived(providerType === 'scheduler');
|
||||||
let isWebhook = $derived(providerType === 'gitea');
|
let isWebhook = $derived(providerType === 'gitea' || providerType === 'planka');
|
||||||
|
|
||||||
|
// Collection label/icon/desc per provider type
|
||||||
|
const collectionMeta: Record<string, { label: string; icon: string; placeholder: string; desc: (col: any) => string }> = {
|
||||||
|
immich: { label: t('notificationTracker.albums'), icon: 'mdiImageMultiple', placeholder: t('notificationTracker.selectAlbums'), desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets` },
|
||||||
|
gitea: { label: t('notificationTracker.repositories'), icon: 'mdiGit', placeholder: t('notificationTracker.selectRepositories'), desc: () => '' },
|
||||||
|
planka: { label: t('notificationTracker.boards'), icon: 'mdiViewDashboard', placeholder: t('notificationTracker.selectBoards'), desc: () => '' },
|
||||||
|
nut: { label: t('notificationTracker.upsDevices'), icon: 'mdiBatteryCharging80', placeholder: t('notificationTracker.selectUpsDevices'), desc: (col) => col.description || '' },
|
||||||
|
};
|
||||||
|
let colMeta = $derived(collectionMeta[providerType] || { label: t('notificationTracker.albums'), icon: 'mdiServer', placeholder: t('notificationTracker.selectAlbums'), desc: () => '' });
|
||||||
|
|
||||||
// Custom variable management for scheduler
|
// Custom variable management for scheduler
|
||||||
function addVariable() {
|
function addVariable() {
|
||||||
@@ -92,16 +101,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if !isScheduler && collections.length > 0}
|
{#if !isScheduler && collections.length > 0}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')}</label>
|
<label class="block text-sm font-medium mb-1">{colMeta.label}</label>
|
||||||
<MultiEntitySelect
|
<MultiEntitySelect
|
||||||
items={collections.map(col => ({
|
items={collections.map(col => ({
|
||||||
value: col.id,
|
value: col.id,
|
||||||
label: col.albumName || col.name,
|
label: col.albumName || col.name,
|
||||||
icon: 'mdiImageMultiple',
|
icon: colMeta.icon,
|
||||||
desc: `${col.assetCount ?? col.asset_count ?? 0} assets`,
|
desc: colMeta.desc(col),
|
||||||
}))}
|
}))}
|
||||||
bind:values={form.collection_ids}
|
bind:values={form.collection_ids}
|
||||||
placeholder={t('notificationTracker.selectAlbums')}
|
placeholder={colMeta.placeholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
));
|
));
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' });
|
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' });
|
||||||
let nameManuallyEdited = $state(false);
|
let nameManuallyEdited = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
let confirmDelete = $state<ServiceProvider | null>(null);
|
let confirmDelete = $state<ServiceProvider | null>(null);
|
||||||
|
|
||||||
const providerDefaultNames: Record<string, string> = {
|
const providerDefaultNames: Record<string, string> = {
|
||||||
immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler',
|
immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler', nut: 'NUT',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-update name when provider type changes (unless user manually edited)
|
// Auto-update name when provider type changes (unless user manually edited)
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' };
|
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' };
|
||||||
nameManuallyEdited = false;
|
nameManuallyEdited = false;
|
||||||
editing = null; showForm = true;
|
editing = null; showForm = true;
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,8 @@
|
|||||||
name: p.name, type: p.type, url: cfg.url || '',
|
name: p.name, type: p.type, url: cfg.url || '',
|
||||||
api_key: '', api_token: '', webhook_secret: '',
|
api_key: '', api_token: '', webhook_secret: '',
|
||||||
external_domain: cfg.external_domain || '', icon: p.icon || '',
|
external_domain: cfg.external_domain || '', icon: p.icon || '',
|
||||||
|
nut_host: cfg.host || '', nut_port: cfg.port || 3493,
|
||||||
|
nut_username: '', nut_password: '',
|
||||||
};
|
};
|
||||||
nameManuallyEdited = true;
|
nameManuallyEdited = true;
|
||||||
editing = p.id; showForm = true;
|
editing = p.id; showForm = true;
|
||||||
@@ -85,7 +87,14 @@
|
|||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = ''; submitting = true;
|
e.preventDefault(); error = ''; submitting = true;
|
||||||
try {
|
try {
|
||||||
const config: any = { url: form.url };
|
let config: any;
|
||||||
|
if (form.type === 'nut') {
|
||||||
|
config = { host: form.nut_host, port: form.nut_port || 3493 };
|
||||||
|
if (form.nut_username) config.username = form.nut_username;
|
||||||
|
if (form.nut_password) config.password = form.nut_password;
|
||||||
|
} else {
|
||||||
|
config = { url: form.url };
|
||||||
|
}
|
||||||
if (form.type === 'immich') {
|
if (form.type === 'immich') {
|
||||||
if (form.api_key) config.api_key = form.api_key;
|
if (form.api_key) config.api_key = form.api_key;
|
||||||
if (form.external_domain) config.external_domain = form.external_domain;
|
if (form.external_domain) config.external_domain = form.external_domain;
|
||||||
@@ -110,7 +119,8 @@
|
|||||||
const hasConfigChange = form.url !== (providers.find(p => p.id === editing)?.config?.url || '') ||
|
const hasConfigChange = form.url !== (providers.find(p => p.id === editing)?.config?.url || '') ||
|
||||||
(form.type === 'immich' && (form.api_key || form.external_domain !== (providers.find(p => p.id === editing)?.config?.external_domain || ''))) ||
|
(form.type === 'immich' && (form.api_key || form.external_domain !== (providers.find(p => p.id === editing)?.config?.external_domain || ''))) ||
|
||||||
(form.type === 'gitea' && (form.api_token || form.webhook_secret)) ||
|
(form.type === 'gitea' && (form.api_token || form.webhook_secret)) ||
|
||||||
(form.type === 'planka' && (form.api_key || form.webhook_secret));
|
(form.type === 'planka' && (form.api_key || form.webhook_secret)) ||
|
||||||
|
(form.type === 'nut');
|
||||||
const body: any = { name: form.name, icon: form.icon };
|
const body: any = { name: form.name, icon: form.icon };
|
||||||
if (hasConfigChange) body.config = config;
|
if (hasConfigChange) body.config = config;
|
||||||
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
|
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
@@ -175,7 +185,7 @@
|
|||||||
<input id="prv-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="prv-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if form.type !== 'scheduler'}
|
{#if form.type !== 'scheduler' && form.type !== 'nut'}
|
||||||
<div>
|
<div>
|
||||||
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
||||||
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : form.type === 'planka' ? 'https://planka.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : form.type === 'planka' ? 'https://planka.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
@@ -226,6 +236,25 @@
|
|||||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookUrlHint')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookUrlHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if form.type === 'nut'}
|
||||||
|
<div>
|
||||||
|
<label for="prv-nut-host" class="block text-sm font-medium mb-1">{t('providers.nutHost')}</label>
|
||||||
|
<input id="prv-nut-host" bind:value={form.nut_host} required placeholder={t('providers.nutHostPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="prv-nut-port" class="block text-sm font-medium mb-1">{t('providers.nutPort')}</label>
|
||||||
|
<input id="prv-nut-port" bind:value={form.nut_port} type="number" min="1" max="65535" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="prv-nut-user" class="block text-sm font-medium mb-1">{t('providers.nutUsername')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||||
|
<input id="prv-nut-user" bind:value={form.nut_username} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.nutUsernameHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="prv-nut-pass" class="block text-sm font-medium mb-1">{t('providers.nutPassword')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||||
|
<input id="prv-nut-pass" bind:value={form.nut_password} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.nutPasswordHint')}</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="submit" disabled={submitting}
|
<button type="submit" disabled={submitting}
|
||||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||||
@@ -266,6 +295,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if provider.config?.url}
|
{#if provider.config?.url}
|
||||||
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
|
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
|
||||||
|
{:else if provider.config?.host}
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if provider.type === 'gitea'}
|
{#if provider.type === 'gitea'}
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/gitea/{provider.id}</span></p>
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/gitea/{provider.id}</span></p>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
provider_type: 'immich', name: '', description: '', icon: '',
|
provider_type: '', name: '', description: '', icon: '',
|
||||||
slots: {} as Record<string, Record<string, string>>,
|
slots: {} as Record<string, Record<string, string>>,
|
||||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||||
date_only_format: '%d.%m.%Y',
|
date_only_format: '%d.%m.%Y',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||||
|
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
provider_type: 'immich', name: '', icon: '',
|
provider_type: '', name: '', icon: '',
|
||||||
// Immich event tracking
|
// Immich event tracking
|
||||||
track_assets_added: true, track_assets_removed: false,
|
track_assets_added: true, track_assets_removed: false,
|
||||||
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ class EventType(str, Enum):
|
|||||||
# Scheduler events
|
# Scheduler events
|
||||||
SCHEDULED_MESSAGE = "scheduled_message"
|
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
|
@dataclass
|
||||||
class ServiceEvent:
|
class ServiceEvent:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ServiceProviderType(str, Enum):
|
|||||||
GITEA = "gitea"
|
GITEA = "gitea"
|
||||||
PLANKA = "planka"
|
PLANKA = "planka"
|
||||||
SCHEDULER = "scheduler"
|
SCHEDULER = "scheduler"
|
||||||
|
NUT = "nut"
|
||||||
|
|
||||||
|
|
||||||
class ServiceProvider(ABC):
|
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
|
# Registry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -309,6 +361,7 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
|
|||||||
"gitea": GITEA_CAPABILITIES,
|
"gitea": GITEA_CAPABILITIES,
|
||||||
"planka": PLANKA_CAPABILITIES,
|
"planka": PLANKA_CAPABILITIES,
|
||||||
"scheduler": SCHEDULER_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_help", "desc_status", "desc_boards", "desc_cards",
|
||||||
"desc_lists",
|
"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
|
# 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": {
|
"scheduler": {
|
||||||
"message_scheduled_message": "scheduled_message.jinja2",
|
"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
|
# 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 %}
|
||||||
@@ -13,7 +13,7 @@ import aiohttp
|
|||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import ServiceProvider, User
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,11 +69,19 @@ class SchedulerProviderConfig(BaseModel):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NutProviderConfig(BaseModel):
|
||||||
|
host: str
|
||||||
|
port: int = 3493
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
|
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
|
||||||
"immich": ImmichProviderConfig,
|
"immich": ImmichProviderConfig,
|
||||||
"gitea": GiteaProviderConfig,
|
"gitea": GiteaProviderConfig,
|
||||||
"planka": PlankaProviderConfig,
|
"planka": PlankaProviderConfig,
|
||||||
"scheduler": SchedulerProviderConfig,
|
"scheduler": SchedulerProviderConfig,
|
||||||
|
"nut": NutProviderConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +171,17 @@ async def create_provider(
|
|||||||
detail=test_result.get("message", "Cannot connect to Planka"),
|
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)
|
# Scheduler: no validation needed (virtual provider)
|
||||||
|
|
||||||
provider = ServiceProvider(
|
provider = ServiceProvider(
|
||||||
@@ -297,6 +316,14 @@ async def update_provider(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Connection error: {err}",
|
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)
|
session.add(provider)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -349,6 +376,10 @@ async def test_provider(
|
|||||||
if provider.type == "scheduler":
|
if provider.type == "scheduler":
|
||||||
return {"ok": True, "message": "Virtual provider — always available"}
|
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}"}
|
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)
|
planka = make_planka_provider(http_session, provider)
|
||||||
return await planka.list_collections()
|
return await planka.list_collections()
|
||||||
|
|
||||||
|
if provider.type == "nut":
|
||||||
|
nut = make_nut_provider(provider)
|
||||||
|
return await nut.list_collections()
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -475,7 +510,7 @@ def _provider_response(p: ServiceProvider) -> dict:
|
|||||||
"""Build a safe response dict for a provider."""
|
"""Build a safe response dict for a provider."""
|
||||||
config = dict(p.config)
|
config = dict(p.config)
|
||||||
# Mask sensitive fields
|
# 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:
|
if secret_field in config:
|
||||||
key = config[secret_field]
|
key = config[secret_field]
|
||||||
config[secret_field] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
|
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 %})"},
|
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
||||||
"asset_fields": asset_fields,
|
"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 .immich import ImmichCommandHandler
|
||||||
from .gitea_handler import GiteaCommandHandler
|
from .gitea_handler import GiteaCommandHandler
|
||||||
from .planka_handler import PlankaCommandHandler
|
from .planka_handler import PlankaCommandHandler
|
||||||
|
from .nut_handler import NutCommandHandler
|
||||||
|
|
||||||
register_handler(ImmichCommandHandler())
|
register_handler(ImmichCommandHandler())
|
||||||
register_handler(GiteaCommandHandler())
|
register_handler(GiteaCommandHandler())
|
||||||
register_handler(PlankaCommandHandler())
|
register_handler(PlankaCommandHandler())
|
||||||
|
register_handler(NutCommandHandler())
|
||||||
|
|
||||||
|
|
||||||
# Auto-register on import
|
# 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)
|
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
|
# 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"
|
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
|
||||||
if await _has_table(conn, state_table):
|
if await _has_table(conn, state_table):
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ class TrackingConfig(SQLModel, table=True):
|
|||||||
# Scheduler event tracking
|
# Scheduler event tracking
|
||||||
track_scheduled_message: bool = Field(default=True)
|
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
|
# Immich asset display
|
||||||
track_images: bool = Field(default=True)
|
track_images: bool = Field(default=True)
|
||||||
track_videos: 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, "gitea", "Gitea")
|
||||||
await _seed_provider_template(session, "planka", "Planka")
|
await _seed_provider_template(session, "planka", "Planka")
|
||||||
await _seed_provider_template(session, "scheduler", "Scheduler")
|
await _seed_provider_template(session, "scheduler", "Scheduler")
|
||||||
|
await _seed_provider_template(session, "nut", "NUT")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +172,9 @@ async def _seed_default_command_templates() -> None:
|
|||||||
await _seed_provider_command_template(
|
await _seed_provider_command_template(
|
||||||
session, "planka", "Default Planka Commands", "Default Planka command templates",
|
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()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +225,18 @@ async def _seed_default_tracking_configs() -> None:
|
|||||||
"name": "Default Scheduler",
|
"name": "Default Scheduler",
|
||||||
"track_scheduled_message": True,
|
"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:
|
for cfg in defaults:
|
||||||
@@ -279,6 +295,16 @@ async def _seed_default_command_configs() -> None:
|
|||||||
"default_count": 10,
|
"default_count": 10,
|
||||||
"rate_limits": {"api": 15, "default": 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:
|
for cfg in defaults:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||||
from notify_bridge_core.providers.gitea import GiteaServiceProvider
|
from notify_bridge_core.providers.gitea import GiteaServiceProvider
|
||||||
from notify_bridge_core.providers.planka import PlankaServiceProvider
|
from notify_bridge_core.providers.planka import PlankaServiceProvider
|
||||||
|
from notify_bridge_core.providers.nut import NutServiceProvider
|
||||||
|
|
||||||
from ..database.models import ServiceProvider
|
from ..database.models import ServiceProvider
|
||||||
|
|
||||||
@@ -39,3 +40,15 @@ def make_planka_provider(http_session, provider: ServiceProvider) -> PlankaServi
|
|||||||
config.get("api_key", ""),
|
config.get("api_key", ""),
|
||||||
provider.name,
|
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,
|
"task_completed": tc.track_task_completed,
|
||||||
# Scheduler events
|
# Scheduler events
|
||||||
"scheduled_message": tc.track_scheduled_message,
|
"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)
|
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,
|
custom_variables=custom_vars,
|
||||||
)
|
)
|
||||||
events, new_state = await sched.poll(collection_ids, state_dict)
|
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:
|
else:
|
||||||
return {"status": "error", "reason": f"unsupported provider type: {provider_type}"}
|
return {"status": "error", "reason": f"unsupported provider type: {provider_type}"}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user