Initial SolarEdge Optimizer Home Assistant App

This commit is contained in:
2026-08-10 09:04:35 +02:00
commit f2887a0ab4
38 changed files with 4253 additions and 0 deletions
@@ -0,0 +1,69 @@
import { describe, expect, it } from "bun:test";
import { OptimizerDataCache } from "./cache";
import type { OptimizerDataSnapshot } from "./collector";
const snapshot: OptimizerDataSnapshot = {
siteId: "42",
date: "2026-08-10",
timeZone: "Europe/Berlin",
fetchedAt: "2026-08-10T08:00:00.000Z",
optimizerCount: 1,
successfulOptimizerCount: 1,
failedOptimizerCount: 0,
totalDailyEnergyWh: 100,
totalCurrentPowerW: 50,
optimizers: [
{
serial: "OPT-1",
inverterId: "1",
stringId: "1.1",
optimizerId: "1.1.1",
dailyEnergyWh: 100,
currentPowerW: 50,
lastMeasurement: "2026-08-10T07:59:00Z",
},
],
};
describe("optimizer data cache and HTTP API", () => {
it("serves a successful cached snapshot", async () => {
const cache = new OptimizerDataCache({
pollIntervalMs: 600_000,
loadSnapshot: async () => snapshot,
});
await cache.refresh();
expect(cache.getPayload()).toMatchObject({
status: "ok",
siteId: "42",
optimizerCount: 1,
optimizers: [{ optimizerId: "1.1.1", currentPowerW: 50 }],
});
cache.stop();
});
it("keeps the previous data if a later refresh fails", async () => {
let attempts = 0;
const cache = new OptimizerDataCache({
pollIntervalMs: 600_000,
loadSnapshot: async () => {
attempts += 1;
if (attempts > 1) {
throw new Error("credentials must not appear here");
}
return snapshot;
},
});
await cache.refresh();
await cache.refresh();
expect(cache.getPayload()).toMatchObject({
status: "stale",
siteId: "42",
lastError: "Error",
});
expect(JSON.stringify(cache.getPayload())).not.toContain("credentials");
cache.stop();
});
});
+104
View File
@@ -0,0 +1,104 @@
import type { OptimizerDataSnapshot } from "./collector";
export type OptimizerDataApiPayload = OptimizerDataSnapshot & {
status: "ok" | "partial" | "stale";
nextRefreshAt: string;
lastError?: string;
};
export class OptimizerDataCache {
private readonly loadSnapshot: () => Promise<OptimizerDataSnapshot>;
private readonly pollIntervalMs: number;
private snapshot?: OptimizerDataSnapshot;
private nextRefreshAt?: string;
private lastError?: string;
private refreshPromise?: Promise<void>;
private timer?: ReturnType<typeof setTimeout>;
constructor({
loadSnapshot,
pollIntervalMs,
}: {
loadSnapshot: () => Promise<OptimizerDataSnapshot>;
pollIntervalMs: number;
}) {
if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
throw new Error("pollIntervalMs must be positive");
}
this.loadSnapshot = loadSnapshot;
this.pollIntervalMs = pollIntervalMs;
}
start(): void {
void this.refresh();
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
}
async refresh(): Promise<void> {
if (this.refreshPromise) {
return this.refreshPromise;
}
this.stop();
this.refreshPromise = this.performRefresh().finally(() => {
this.refreshPromise = undefined;
this.scheduleNextRefresh();
});
return this.refreshPromise;
}
getPayload(): OptimizerDataApiPayload | undefined {
if (!this.snapshot || !this.nextRefreshAt) {
return undefined;
}
return {
status: this.lastError
? "stale"
: this.snapshot.failedOptimizerCount > 0
? "partial"
: "ok",
...this.snapshot,
nextRefreshAt: this.nextRefreshAt,
...(this.lastError ? { lastError: this.lastError } : {}),
};
}
getLastError(): string | undefined {
return this.lastError;
}
private async performRefresh(): Promise<void> {
try {
this.snapshot = await this.loadSnapshot();
this.lastError = undefined;
} catch (error) {
this.lastError = formatSafeError(error);
}
}
private scheduleNextRefresh(): void {
this.nextRefreshAt = new Date(Date.now() + this.pollIntervalMs).toISOString();
this.timer = setTimeout(() => void this.refresh(), this.pollIntervalMs);
}
}
function formatSafeError(error: unknown): string {
if (!error || typeof error !== "object") {
return "Unknown error";
}
const metadata = error as { name?: unknown; code?: unknown; status?: unknown };
return [
metadata.name ? String(metadata.name) : "Error",
metadata.code === undefined ? undefined : `code=${String(metadata.code)}`,
metadata.status === undefined
? undefined
: `status=${String(metadata.status)}`,
]
.filter(Boolean)
.join(" ");
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from "bun:test";
import { collectOptimizerData } from "./collector";
describe("optimizer data collector", () => {
it("collects daily energy and live power for every optimizer", async () => {
let activeEnergyRequests = 0;
let maximumActiveEnergyRequests = 0;
const snapshot = await collectOptimizerData({
siteId: "42",
timeZone: "Europe/Berlin",
now: new Date("2026-08-09T23:30:00Z"),
concurrency: 2,
reader: {
listOptimizerMappings: async () => [
{
serial: "OPT-1",
inverterId: "1",
stringId: "1.1",
optimizerId: "1.1.1",
},
{
serial: "OPT-2",
inverterId: "1",
stringId: "1.1",
optimizerId: "1.1.2",
},
{
serial: "OPT-3",
inverterId: "1",
stringId: "1.1",
optimizerId: "1.1.3",
},
],
getOptimizerInformation: async () => ({
basicInformationList: [],
serialToLiveData: {
"OPT-1": { power_W: 10, lastMeasurement: "measurement-1" },
"OPT-2": { power_W: 20, lastMeasurement: "measurement-2" },
"OPT-3": { power_W: null, lastMeasurement: "measurement-3" },
},
}),
getOptimizerEnergy: async ({ optimizerSerials, startDate }) => {
expect(startDate).toBe("2026-08-10");
activeEnergyRequests += 1;
maximumActiveEnergyRequests = Math.max(
maximumActiveEnergyRequests,
activeEnergyRequests,
);
await new Promise((resolve) => setTimeout(resolve, 2));
activeEnergyRequests -= 1;
if (optimizerSerials[0] === "OPT-3") {
const error = new Error("must not be exposed") as Error & {
status: number;
};
error.status = 429;
throw error;
}
return {
totalEnergy: optimizerSerials[0] === "OPT-1" ? 100 : 200,
energyBars: [],
};
},
},
});
expect(maximumActiveEnergyRequests).toBe(2);
expect(snapshot).toMatchObject({
siteId: "42",
date: "2026-08-10",
timeZone: "Europe/Berlin",
optimizerCount: 3,
successfulOptimizerCount: 2,
failedOptimizerCount: 1,
totalDailyEnergyWh: 300,
totalCurrentPowerW: 30,
optimizers: [
{
serial: "OPT-1",
optimizerId: "1.1.1",
dailyEnergyWh: 100,
currentPowerW: 10,
lastMeasurement: "measurement-1",
},
{
serial: "OPT-2",
optimizerId: "1.1.2",
dailyEnergyWh: 200,
currentPowerW: 20,
lastMeasurement: "measurement-2",
},
{
serial: "OPT-3",
optimizerId: "1.1.3",
dailyEnergyWh: null,
currentPowerW: null,
error: "Error status=429",
},
],
});
expect(JSON.stringify(snapshot)).not.toContain("must not be exposed");
});
});
+179
View File
@@ -0,0 +1,179 @@
import type {
GetSolarEdgeOptimizerEnergyOptions,
SolarEdgeOptimizerEnergy,
SolarEdgeOptimizerInformation,
SolarEdgeOptimizerMapping,
SolarEdgeOptimizerSerial,
SolarEdgeSiteId,
} from "@solar-dash/solaredgeapi";
export type OptimizerDataReader = {
listOptimizerMappings(
siteId?: SolarEdgeSiteId,
): Promise<SolarEdgeOptimizerMapping[]>;
getOptimizerInformation(
optimizerSerials: SolarEdgeOptimizerSerial[],
): Promise<SolarEdgeOptimizerInformation>;
getOptimizerEnergy(
options: GetSolarEdgeOptimizerEnergyOptions,
): Promise<SolarEdgeOptimizerEnergy>;
};
export type OptimizerModuleData = {
serial: string;
inverterId: string | null;
stringId: string | null;
optimizerId: string | null;
dailyEnergyWh: number | null;
currentPowerW: number | null;
lastMeasurement: string | null;
error?: string;
};
export type OptimizerDataSnapshot = {
siteId: string;
date: string;
timeZone: string;
fetchedAt: string;
optimizerCount: number;
successfulOptimizerCount: number;
failedOptimizerCount: number;
totalDailyEnergyWh: number;
totalCurrentPowerW: number;
optimizers: OptimizerModuleData[];
};
export async function collectOptimizerData({
reader,
siteId,
timeZone,
concurrency = 3,
now = new Date(),
}: {
reader: OptimizerDataReader;
siteId: string;
timeZone: string;
concurrency?: number;
now?: Date;
}): Promise<OptimizerDataSnapshot> {
const date = formatDateInTimeZone(now, timeZone);
const mappings = await reader.listOptimizerMappings(siteId);
if (mappings.length === 0) {
throw new Error("SolarEdge returned no optimizer mappings");
}
const information = await reader.getOptimizerInformation(
mappings.map((mapping) => mapping.serial),
);
const optimizers = await mapWithConcurrency(
mappings,
concurrency,
async (mapping): Promise<OptimizerModuleData> => {
const liveData = information.serialToLiveData[mapping.serial];
const base = {
serial: mapping.serial,
inverterId: mapping.inverterId ?? null,
stringId: mapping.stringId ?? null,
optimizerId: mapping.optimizerId ?? null,
currentPowerW: finiteNumberOrNull(liveData?.power_W),
lastMeasurement: liveData?.lastMeasurement ?? null,
};
try {
const energy = await reader.getOptimizerEnergy({
siteId,
startDate: date,
endDate: date,
optimizerSerials: [mapping.serial],
chartTimeUnit: "hours",
});
return {
...base,
dailyEnergyWh: finiteNumberOrNull(energy.totalEnergy),
};
} catch (error) {
return {
...base,
dailyEnergyWh: null,
error: formatSafeError(error),
};
}
},
);
const successful = optimizers.filter(
(optimizer) => optimizer.dailyEnergyWh !== null,
);
return {
siteId,
date,
timeZone,
fetchedAt: new Date().toISOString(),
optimizerCount: optimizers.length,
successfulOptimizerCount: successful.length,
failedOptimizerCount: optimizers.length - successful.length,
totalDailyEnergyWh: successful.reduce(
(sum, optimizer) => sum + (optimizer.dailyEnergyWh ?? 0),
0,
),
totalCurrentPowerW: optimizers.reduce(
(sum, optimizer) => sum + (optimizer.currentPowerW ?? 0),
0,
),
optimizers,
};
}
export function formatDateInTimeZone(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}-${values.month}-${values.day}`;
}
export async function mapWithConcurrency<TInput, TOutput>(
values: TInput[],
concurrency: number,
mapper: (value: TInput, index: number) => Promise<TOutput>,
): Promise<TOutput[]> {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new Error("concurrency must be a positive integer");
}
const results = new Array<TOutput>(values.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < values.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(values[index] as TInput, index);
}
};
await Promise.all(
Array.from({ length: Math.min(concurrency, values.length) }, worker),
);
return results;
}
function finiteNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function formatSafeError(error: unknown): string {
if (!error || typeof error !== "object") {
return "Unknown error";
}
const name = "name" in error ? String(error.name) : "Error";
const code = "code" in error ? String(error.code) : undefined;
const status = "status" in error ? Number(error.status) : undefined;
return [
name,
code ? `code=${code}` : undefined,
Number.isFinite(status) ? `status=${status}` : undefined,
]
.filter(Boolean)
.join(" ");
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "bun:test";
import { parseAppOptions, resolveHomeAssistantTimeZone } from "./config";
describe("SolarEdge Optimizer Data App config", () => {
it("parses the required Home Assistant App options", () => {
expect(
parseAppOptions({
username: " owner@example.com ",
password: "portal-secret",
site_id: "4886699",
poll_interval_minutes: 10,
}),
).toEqual({
username: "owner@example.com",
password: "portal-secret",
siteId: "4886699",
pollIntervalMinutes: 10,
});
});
it("rejects missing credentials and unsafe polling intervals", () => {
expect(() =>
parseAppOptions({ password: "secret", site_id: "42" }),
).toThrow("username");
expect(() =>
parseAppOptions({
username: "owner@example.com",
password: "secret",
site_id: "42",
poll_interval_minutes: 5,
}),
).toThrow("10 to 1440");
});
it("reads the Home Assistant time zone from the Supervisor", async () => {
const timeZone = await resolveHomeAssistantTimeZone({
supervisorToken: "supervisor-token",
fetchImplementation: async (input, init) => {
expect(String(input)).toBe("http://supervisor/info");
expect(new Headers(init?.headers).get("authorization")).toBe(
"Bearer supervisor-token",
);
return Response.json({ data: { timezone: "Europe/Berlin" } });
},
});
expect(timeZone).toBe("Europe/Berlin");
});
});
+108
View File
@@ -0,0 +1,108 @@
export const DEFAULT_OPTIONS_PATH = "/data/options.json";
export const DEFAULT_PORT = 8099;
export const DEFAULT_POLL_INTERVAL_MINUTES = 10;
export type SolarEdgeOptimizerAppOptions = {
username: string;
password: string;
siteId: string;
pollIntervalMinutes: number;
};
export type SupervisorFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
export function parseAppOptions(value: unknown): SolarEdgeOptimizerAppOptions {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("App options must be a JSON object");
}
const options = value as Record<string, unknown>;
const username = requireString(options.username, "username");
const password = requireString(options.password, "password", false);
const siteId = requireString(options.site_id, "site_id");
if (!/^\d+$/.test(siteId)) {
throw new Error("site_id must contain digits only");
}
const pollIntervalMinutes =
options.poll_interval_minutes === undefined
? DEFAULT_POLL_INTERVAL_MINUTES
: Number(options.poll_interval_minutes);
if (
!Number.isInteger(pollIntervalMinutes) ||
pollIntervalMinutes < 10 ||
pollIntervalMinutes > 1440
) {
throw new Error("poll_interval_minutes must be an integer from 10 to 1440");
}
return { username, password, siteId, pollIntervalMinutes };
}
export async function readAppOptions(
path = process.env.SOLAREDGE_OPTIONS_PATH ?? DEFAULT_OPTIONS_PATH,
): Promise<SolarEdgeOptimizerAppOptions> {
const file = Bun.file(path);
if (!(await file.exists())) {
throw new Error(`App options file does not exist: ${path}`);
}
return parseAppOptions(await file.json());
}
export async function resolveHomeAssistantTimeZone({
fetchImplementation = globalThis.fetch,
supervisorToken = process.env.SUPERVISOR_TOKEN,
}: {
fetchImplementation?: SupervisorFetch;
supervisorToken?: string;
} = {}): Promise<string> {
if (!supervisorToken) {
const fallback = process.env.TZ;
if (fallback) {
return validateTimeZone(fallback);
}
throw new Error("SUPERVISOR_TOKEN is missing and TZ is not configured");
}
const response = await fetchImplementation("http://supervisor/info", {
headers: { Authorization: `Bearer ${supervisorToken}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`Could not read Home Assistant time zone (${response.status})`);
}
const body = (await response.json()) as {
data?: { timezone?: unknown };
};
return validateTimeZone(
requireString(body.data?.timezone, "Supervisor timezone"),
);
}
function requireString(
value: unknown,
name: string,
trim = true,
): string {
if (typeof value !== "string") {
throw new Error(`${name} must be a string`);
}
const normalized = trim ? value.trim() : value;
if (!normalized) {
throw new Error(`${name} must not be empty`);
}
return normalized;
}
function validateTimeZone(value: string): string {
try {
new Intl.DateTimeFormat("en", { timeZone: value }).format();
return value;
} catch {
throw new Error(`Invalid Home Assistant time zone: ${value}`);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "bun:test";
import { OptimizerDataCache } from "./cache";
import type { OptimizerDataSnapshot } from "./collector";
import { createRequestHandler } from "./http";
describe("optimizer data HTTP API", () => {
it("returns 503 while starting and JSON after refresh", async () => {
const snapshot: OptimizerDataSnapshot = {
siteId: "42",
date: "2026-08-10",
timeZone: "Europe/Berlin",
fetchedAt: "2026-08-10T08:00:00.000Z",
optimizerCount: 0,
successfulOptimizerCount: 0,
failedOptimizerCount: 0,
totalDailyEnergyWh: 0,
totalCurrentPowerW: 0,
optimizers: [],
};
const cache = new OptimizerDataCache({
pollIntervalMs: 600_000,
loadSnapshot: async () => snapshot,
});
const handle = createRequestHandler(cache);
const starting = handle(new Request("http://app/api/optimizers"));
expect(starting.status).toBe(503);
expect(await starting.json()).toMatchObject({ status: "starting" });
await cache.refresh();
const response = handle(new Request("http://app/api/optimizers"));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toMatchObject({ status: "ok", siteId: "42" });
const missing = handle(new Request("http://app/unknown"));
expect(missing.status).toBe(404);
cache.stop();
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { OptimizerDataCache } from "./cache";
export function createRequestHandler(cache: OptimizerDataCache) {
return (request: Request): Response => {
if (request.method !== "GET") {
return jsonResponse({ error: "Method not allowed" }, 405, {
Allow: "GET",
});
}
const path = new URL(request.url).pathname.replace(/\/+$/, "") || "/";
if (path === "/health") {
const payload = cache.getPayload();
return jsonResponse(
payload
? {
status: payload.status,
fetchedAt: payload.fetchedAt,
nextRefreshAt: payload.nextRefreshAt,
optimizerCount: payload.optimizerCount,
failedOptimizerCount: payload.failedOptimizerCount,
...(payload.lastError ? { lastError: payload.lastError } : {}),
}
: {
status: cache.getLastError() ? "error" : "starting",
...(cache.getLastError()
? { lastError: cache.getLastError() }
: {}),
},
payload ? 200 : 503,
);
}
if (path === "/" || path === "/api/optimizers") {
const payload = cache.getPayload();
return payload
? jsonResponse(payload)
: jsonResponse(
{
status: cache.getLastError() ? "error" : "starting",
message: "No optimizer data is available yet",
...(cache.getLastError()
? { lastError: cache.getLastError() }
: {}),
},
503,
);
}
return jsonResponse({ error: "Not found" }, 404);
};
}
function jsonResponse(
value: unknown,
status = 200,
additionalHeaders: Record<string, string> = {},
): Response {
return Response.json(value, {
status,
headers: {
"Cache-Control": "no-store",
...additionalHeaders,
},
});
}
+84
View File
@@ -0,0 +1,84 @@
import { SolarEdgePortalClient } from "@solar-dash/solaredgeapi";
import { OptimizerDataCache } from "./cache";
import { collectOptimizerData } from "./collector";
import {
DEFAULT_PORT,
readAppOptions,
resolveHomeAssistantTimeZone,
} from "./config";
import { createRequestHandler } from "./http";
export async function main(): Promise<void> {
const options = await readAppOptions();
const timeZone = await resolveHomeAssistantTimeZone();
const portalClient = new SolarEdgePortalClient({
username: options.username,
password: options.password,
siteId: options.siteId,
});
const cache = new OptimizerDataCache({
pollIntervalMs: options.pollIntervalMinutes * 60_000,
loadSnapshot: async () => {
log("info", `Refreshing optimizer data for site ${options.siteId}`);
const snapshot = await collectOptimizerData({
reader: portalClient,
siteId: options.siteId,
timeZone,
});
log(
snapshot.failedOptimizerCount === 0 ? "info" : "warning",
`Refresh finished: ${snapshot.successfulOptimizerCount}/${snapshot.optimizerCount} optimizer energy values`,
);
return snapshot;
},
});
const port = readPort(process.env.PORT);
const server = Bun.serve({
hostname: "0.0.0.0",
port,
fetch: createRequestHandler(cache),
});
const shutDown = (): void => {
log("info", "Stopping SolarEdge Optimizer Data App");
cache.stop();
void server.stop(true);
};
process.once("SIGTERM", shutDown);
process.once("SIGINT", shutDown);
log(
"info",
`Listening on port ${port}; refresh interval ${options.pollIntervalMinutes} minutes; time zone ${timeZone}`,
);
cache.start();
}
function readPort(value: string | undefined): number {
const port = value === undefined ? DEFAULT_PORT : Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error("PORT must be an integer from 1 to 65535");
}
return port;
}
function log(level: "info" | "warning" | "error", message: string): void {
console.log(`${new Date().toISOString()} [${level}] ${message}`);
}
if (import.meta.main) {
try {
await main();
} catch (error) {
log("error", formatStartupError(error));
process.exit(1);
}
}
function formatStartupError(error: unknown): string {
if (error instanceof Error) {
return `${error.name}: ${error.message}`;
}
return "Unknown startup error";
}