Files
homarr/packages/integrations/src/peanut/peanut-integration.ts
T

104 lines
4.2 KiB
TypeScript

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 {};
}
}