feat: add PeaNUT integration and UPS monitoring widget (#5862)
This commit is contained in:
1 parent
dd7a4908a3
commit
0dc2558faf
19 files changed
+770
-1
No files matched your search
@@ -36,6 +36,7 @@ import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||
import { PeaNutIntegration } from "../peanut/peanut-integration";
|
||||
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
||||
import { PlexIntegration } from "../plex/plex-integration";
|
||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||
@@ -123,6 +124,7 @@ export const integrationCreators = {
|
||||
immich: ImmichIntegration,
|
||||
speedtestTracker: SpeedtestTrackerIntegration,
|
||||
umami: UmamiIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -34,6 +34,7 @@ export { ImmichIntegration } from "./immich/immich-integration";
|
||||
export { TracearrIntegration } from "./tracearr/tracearr-integration";
|
||||
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
|
||||
export { UmamiIntegration } from "./umami/umami-integration";
|
||||
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
||||
|
||||
// Types
|
||||
export type { IntegrationInput } from "./base/integration";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { UpsSummary } from "./ups-summary-types";
|
||||
|
||||
export interface IUpsSummaryIntegration {
|
||||
getUpsSummariesAsync(): Promise<UpsSummary[]>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Normalized UPS status, resolved from the raw NUT status flags by the integration so that
|
||||
* widgets and future UPS integrations share a single, typed vocabulary.
|
||||
*/
|
||||
export const upsStatuses = ["online", "charging", "onBattery", "lowBattery", "unknown"] as const;
|
||||
|
||||
export type UpsStatus = (typeof upsStatuses)[number];
|
||||
|
||||
export interface UpsSummary {
|
||||
/** Stable identifier of the UPS device on the NUT server */
|
||||
id: string;
|
||||
/** Human readable name, derived from the manufacturer and model */
|
||||
name: string;
|
||||
manufacturer: string | null;
|
||||
model: string | null;
|
||||
serial: string | null;
|
||||
/** Normalized status, resolved from the raw NUT `ups.status` flags */
|
||||
status: UpsStatus;
|
||||
/** Battery charge in percent */
|
||||
batteryCharge: number | null;
|
||||
/** Estimated remaining runtime on battery, in seconds */
|
||||
batteryRuntime: number | null;
|
||||
batteryVoltage: number | null;
|
||||
/** UPS load in percent */
|
||||
load: number | null;
|
||||
inputVoltage: number | null;
|
||||
outputVoltage: number | null;
|
||||
/** Real power drawn by the load, in watts */
|
||||
power: number | null;
|
||||
/** UPS temperature in degrees celsius */
|
||||
temperature: number | null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IUpsSummaryIntegration } from "../../interfaces/ups-summary/ups-summary-integration";
|
||||
import type { UpsSummary } from "../../types";
|
||||
|
||||
export class UpsSummaryMockService implements IUpsSummaryIntegration {
|
||||
public async getUpsSummariesAsync(): Promise<UpsSummary[]> {
|
||||
return await Promise.resolve([
|
||||
{
|
||||
id: "ups-rack-1",
|
||||
name: "CyberPower OR1500",
|
||||
manufacturer: "CyberPower",
|
||||
model: "OR1500LCDRM1U",
|
||||
serial: "CPS1500-001",
|
||||
status: "online",
|
||||
batteryCharge: 100,
|
||||
batteryRuntime: 2280,
|
||||
batteryVoltage: 27.3,
|
||||
load: 42,
|
||||
inputVoltage: 230,
|
||||
outputVoltage: 230,
|
||||
power: 315,
|
||||
temperature: 29,
|
||||
},
|
||||
{
|
||||
id: "ups-rack-2",
|
||||
name: "APC Back-UPS Pro",
|
||||
manufacturer: "APC",
|
||||
model: "BR1500G",
|
||||
serial: "APC1500-002",
|
||||
status: "onBattery",
|
||||
batteryCharge: 64,
|
||||
batteryRuntime: 840,
|
||||
batteryVoltage: 25.1,
|
||||
load: 58,
|
||||
inputVoltage: 0,
|
||||
outputVoltage: 229,
|
||||
power: 410,
|
||||
temperature: 31,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
|
||||
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
|
||||
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
|
||||
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
|
||||
import { CalendarMockService } from "./data/calendar";
|
||||
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
|
||||
import { DnsHoleMockService } from "./data/dns-hole";
|
||||
@@ -28,6 +29,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
|
||||
import { NotificationsMockService } from "./data/notifications";
|
||||
import { SmartHomeMockService } from "./data/smart-home";
|
||||
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
||||
import { UpsSummaryMockService } from "./data/ups-summary";
|
||||
|
||||
export class MockIntegration
|
||||
extends Integration
|
||||
@@ -43,7 +45,8 @@ export class MockIntegration
|
||||
IMediaServerIntegration,
|
||||
IMediaTranscodingIntegration,
|
||||
NetworkControllerSummaryIntegration,
|
||||
ISmartHomeIntegration
|
||||
ISmartHomeIntegration,
|
||||
IUpsSummaryIntegration
|
||||
{
|
||||
private static readonly dnsHole = new DnsHoleMockService();
|
||||
private static readonly calendar = new CalendarMockService();
|
||||
@@ -58,6 +61,7 @@ export class MockIntegration
|
||||
private static readonly networkController = new NetworkControllerSummaryMockService();
|
||||
private static readonly notifications = new NotificationsMockService();
|
||||
private static readonly smartHome = new SmartHomeMockService();
|
||||
private static readonly upsSummary = new UpsSummaryMockService();
|
||||
|
||||
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||
return await Promise.resolve({
|
||||
@@ -123,4 +127,7 @@ export class MockIntegration
|
||||
getEntityStateAsync = MockIntegration.smartHome.getEntityStateAsync.bind(MockIntegration.smartHome);
|
||||
triggerAutomationAsync = MockIntegration.smartHome.triggerAutomationAsync.bind(MockIntegration.smartHome);
|
||||
triggerToggleAsync = MockIntegration.smartHome.triggerToggleAsync.bind(MockIntegration.smartHome);
|
||||
|
||||
// UpsSummaryIntegration
|
||||
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
|
||||
import type { UpsStatus, UpsSummary } from "../interfaces/ups-summary/ups-summary-types";
|
||||
import type { PeaNutDevice } from "./peanut-types";
|
||||
import { peaNutDevicesSchema } from "./peanut-types";
|
||||
|
||||
export class PeaNutIntegration extends Integration implements IUpsSummaryIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/v1/devices"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
await response.json();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getUpsSummariesAsync(): Promise<UpsSummary[]> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/devices", { meta: true }), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const devices = peaNutDevicesSchema.parse(await response.json());
|
||||
return devices.map((device, index) => this.mapDeviceToSummary(device, index));
|
||||
}
|
||||
|
||||
private mapDeviceToSummary(device: PeaNutDevice, index: number): UpsSummary {
|
||||
const manufacturer = this.readString(device, "device.mfr") ?? this.readString(device, "ups.mfr");
|
||||
const model = this.readString(device, "device.model") ?? this.readString(device, "ups.model");
|
||||
const id = this.readString(device, "peanut.device_id") ?? `ups-${index}`;
|
||||
const name = [manufacturer, model].filter(Boolean).join(" ") || id;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
manufacturer,
|
||||
model,
|
||||
serial: this.readString(device, "device.serial"),
|
||||
status: this.parseStatus(this.readString(device, "ups.status")),
|
||||
batteryCharge: this.readNumber(device, "battery.charge"),
|
||||
batteryRuntime: this.readNumber(device, "battery.runtime"),
|
||||
batteryVoltage: this.readNumber(device, "battery.voltage"),
|
||||
load: this.readNumber(device, "ups.load"),
|
||||
inputVoltage: this.readNumber(device, "input.voltage"),
|
||||
outputVoltage: this.readNumber(device, "output.voltage"),
|
||||
power: this.readNumber(device, "ups.realpower") ?? this.readNumber(device, "ups.power"),
|
||||
temperature: this.readNumber(device, "ups.temperature"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the raw NUT `ups.status` flags (e.g. "OL CHRG", "OB DISCHRG LB") into a single
|
||||
* normalized status. The flags are checked in priority order so the most critical state wins.
|
||||
*/
|
||||
private parseStatus(rawStatus: string | null): UpsStatus {
|
||||
const flags = (rawStatus ?? "").toUpperCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
if (flags.includes("LB")) return "lowBattery";
|
||||
if (flags.includes("OB")) return "onBattery";
|
||||
if (flags.includes("OL")) {
|
||||
return flags.includes("CHRG") ? "charging" : "online";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private readString(device: PeaNutDevice, key: string): string | null {
|
||||
const value = device[key];
|
||||
if (value === undefined || value === null) return null;
|
||||
const stringValue = String(value).trim();
|
||||
return stringValue.length > 0 ? stringValue : null;
|
||||
}
|
||||
|
||||
private readNumber(device: PeaNutDevice, key: string): number | null {
|
||||
const value = device[key];
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : null;
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
|
||||
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// PeaNUT returns each device as a flat map of NUT variables (e.g. "ups.status",
|
||||
// "battery.charge"). Values are either strings or numbers depending on the
|
||||
// variable, so we accept both and coerce them when mapping to a UpsSummary.
|
||||
export const peaNutDeviceSchema = z.record(z.string(), z.union([z.string(), z.number()]));
|
||||
|
||||
export const peaNutDevicesSchema = z.array(peaNutDeviceSchema);
|
||||
|
||||
export type PeaNutDevice = z.infer<typeof peaNutDeviceSchema>;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { PeaNutIntegration } from "../peanut-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://peanut.example.com";
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const mockDevicesResponse = (devices: Record<string, string | number>[]) => {
|
||||
mockFetch.mockImplementation(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(devices), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
|
||||
);
|
||||
};
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
|
||||
new PeaNutIntegration({
|
||||
id: "test-peanut",
|
||||
name: "Test PeaNUT",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("PeaNutIntegration getUpsSummariesAsync", () => {
|
||||
test("maps NUT variables to a UPS summary and coerces string values to numbers", async () => {
|
||||
mockDevicesResponse([
|
||||
{
|
||||
"device.mfr": "CyberPower",
|
||||
"device.model": "CP1500EPFCLCD",
|
||||
"device.serial": "ABC123",
|
||||
"ups.status": "OL CHRG",
|
||||
"ups.load": "23",
|
||||
"ups.realpower": "120",
|
||||
"ups.temperature": "31.5",
|
||||
"battery.charge": "100",
|
||||
"battery.runtime": "3720",
|
||||
"battery.voltage": "27.3",
|
||||
"input.voltage": "230",
|
||||
"output.voltage": "229",
|
||||
"peanut.device_id": "ups",
|
||||
"peanut.server": "localhost:3493",
|
||||
},
|
||||
]);
|
||||
|
||||
const [summary] = await createIntegration().getUpsSummariesAsync();
|
||||
|
||||
expect(summary).toStrictEqual({
|
||||
id: "ups",
|
||||
name: "CyberPower CP1500EPFCLCD",
|
||||
manufacturer: "CyberPower",
|
||||
model: "CP1500EPFCLCD",
|
||||
serial: "ABC123",
|
||||
status: "charging",
|
||||
batteryCharge: 100,
|
||||
batteryRuntime: 3720,
|
||||
batteryVoltage: 27.3,
|
||||
load: 23,
|
||||
inputVoltage: 230,
|
||||
outputVoltage: 229,
|
||||
power: 120,
|
||||
temperature: 31.5,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back when optional variables are missing", async () => {
|
||||
mockDevicesResponse([
|
||||
{
|
||||
"ups.status": "OB DISCHRG LB",
|
||||
"ups.power": "90",
|
||||
"battery.charge": "15",
|
||||
},
|
||||
]);
|
||||
|
||||
const [summary] = await createIntegration().getUpsSummariesAsync();
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
id: "ups-0",
|
||||
name: "ups-0",
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
status: "lowBattery",
|
||||
batteryCharge: 15,
|
||||
batteryRuntime: null,
|
||||
load: null,
|
||||
inputVoltage: null,
|
||||
outputVoltage: null,
|
||||
power: 90,
|
||||
temperature: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a summary per device", async () => {
|
||||
mockDevicesResponse([
|
||||
{ "peanut.device_id": "ups-a", "ups.status": "OL" },
|
||||
{ "peanut.device_id": "ups-b", "ups.status": "OB" },
|
||||
]);
|
||||
|
||||
const summaries = await createIntegration().getUpsSummariesAsync();
|
||||
|
||||
expect(summaries.map((summary) => summary.id)).toStrictEqual(["ups-a", "ups-b"]);
|
||||
});
|
||||
|
||||
test("normalizes NUT status flags into a typed status", async () => {
|
||||
mockDevicesResponse([
|
||||
{ "ups.status": "OL" },
|
||||
{ "ups.status": "OL CHRG" },
|
||||
{ "ups.status": "OB DISCHRG" },
|
||||
{ "ups.status": "OB DISCHRG LB" },
|
||||
{ "ups.status": "OL LB" },
|
||||
{ "ups.status": "" },
|
||||
]);
|
||||
|
||||
const summaries = await createIntegration().getUpsSummariesAsync();
|
||||
|
||||
expect(summaries.map((summary) => summary.status)).toStrictEqual([
|
||||
"online",
|
||||
"charging",
|
||||
"onBattery",
|
||||
"lowBattery",
|
||||
"lowBattery",
|
||||
"unknown",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PeaNutIntegration authentication", () => {
|
||||
test("sends a Basic auth header when username and password are configured", async () => {
|
||||
mockDevicesResponse([]);
|
||||
|
||||
await createIntegration([
|
||||
{ kind: "username", value: "admin" },
|
||||
{ kind: "password", value: "secret" },
|
||||
]).getUpsSummariesAsync();
|
||||
|
||||
const expected = `Basic ${Buffer.from("admin:secret").toString("base64")}`;
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
|
||||
});
|
||||
|
||||
test("omits the auth header when no credentials are configured", async () => {
|
||||
mockDevicesResponse([]);
|
||||
|
||||
await createIntegration().getUpsSummariesAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(requestInit?.headers).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
|
||||
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
||||
export * from "./interfaces/health-monitoring/health-monitoring-types";
|
||||
export * from "./interfaces/ups-summary/ups-summary-types";
|
||||
export * from "./interfaces/indexer-manager/indexer-manager-types";
|
||||
export * from "./interfaces/media-requests/media-request-types";
|
||||
export * from "./base/searchable-integration";
|
||||
|
||||
Reference in new issue
Block a user