feat: add PeaNUT integration and UPS monitoring widget (#5862)

This commit is contained in:
Afonso Jorge Ramos authored and GitHub committed 2026-06-05 16:51:09 +02:00
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> = {
+1
View File
@@ -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({});
});
});
+1
View File
@@ -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";