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
+4
View File
@@ -0,0 +1,4 @@
*
!Dockerfile
!dist/server.js
!run.sh
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 0.1.0
- Initial Home Assistant App package.
- Cached optimizer daily energy and current power API.
- Headless SolarEdge portal authentication.
- Ten-minute background refresh with stale-data fallback.
+132
View File
@@ -0,0 +1,132 @@
# SolarEdge Optimizer Data App
Diese Home Assistant App stellt die Optimierer-Daten einer SolarEdge-Anlage als
lokale, gecachte HTTP-API bereit. Sie läuft unabhängig von einem Browser und
aktualisiert die Daten standardmäßig alle zehn Minuten.
> Die verwendeten Optimierer-Endpunkte sind interne, nicht offiziell
> dokumentierte SolarEdge-Portal-Endpunkte. SolarEdge kann sie jederzeit ändern.
> Verwende die App nur für Anlagen, auf die du zugreifen darfst.
## Konfiguration
```yaml
username: owner@example.com
password: dein-portal-passwort
site_id: "4886699"
poll_interval_minutes: 10
```
- `username`: Benutzername des SolarEdge Monitoring Portals.
- `password`: Portal-Passwort; wird weder geloggt noch von der API ausgegeben.
- `site_id`: numerische SolarEdge Site-ID.
- `poll_interval_minutes`: Aktualisierungsintervall, mindestens zehn Minuten.
Die Zeitzone liest die App automatisch aus Home Assistant über die
Supervisor-API. Dadurch wird der Tageswechsel passend zur Home-Assistant-Zeit
berechnet.
## HTTP API
### `GET /api/optimizers`
Liefert den letzten gecachten Datensatz:
```json
{
"status": "ok",
"siteId": "4886699",
"date": "2026-08-10",
"timeZone": "Europe/Berlin",
"fetchedAt": "2026-08-10T08:00:00.000Z",
"nextRefreshAt": "2026-08-10T08:10:00.000Z",
"optimizerCount": 34,
"successfulOptimizerCount": 34,
"failedOptimizerCount": 0,
"totalDailyEnergyWh": 12345,
"totalCurrentPowerW": 4567,
"optimizers": [
{
"serial": "OPTIMIZER_SERIAL",
"inverterId": "1",
"stringId": "1.1",
"optimizerId": "1.1.1",
"dailyEnergyWh": 321.5,
"currentPowerW": 123.4,
"lastMeasurement": "2026-08-10T07:59:00Z"
}
]
}
```
`status` ist:
- `ok`: alle Tagesenergie-Werte wurden gelesen.
- `partial`: einzelne Optimierer-Abfragen sind fehlgeschlagen.
- `stale`: der letzte vollständige Abruf ist fehlgeschlagen; die vorherigen
Daten bleiben verfügbar.
Der API-Aufruf selbst löst keinen SolarEdge-Abruf aus. Er liefert immer den
Cache und kann daher auch häufiger als alle zehn Minuten gelesen werden.
### `GET /health`
Kompakter Status für Überwachung und Docker Healthchecks. Während des ersten
Abrufs antworten `/health` und `/api/optimizers` mit HTTP 503. Danach liefern sie
HTTP 200, solange mindestens ein gecachter Datensatz vorhanden ist.
### `GET /`
Liefert dieselben Daten wie `/api/optimizers` und ist über Home Assistant Ingress
im App-Panel erreichbar.
## Zugriff aus Home Assistant
Die App veröffentlicht standardmäßig keinen ungeschützten Port im lokalen
Netz. Home Assistant und andere Apps können sie über das interne App-Netzwerk
erreichen. Bei einer lokalen Installation lautet der Hostname typischerweise
`local-solaredge-optimizer-data`:
```text
http://local-solaredge-optimizer-data:8099/api/optimizers
```
Bei Installation aus einem App-Repository ersetzt dessen Repository-ID den
Präfix `local`. Die JSON-Ansicht für Menschen ist unabhängig davon über Ingress
im Home-Assistant-Seitenpanel verfügbar.
## Installation als App-Repository
Füge im Home-Assistant-App-Store unter **⋮ → Repositories** diese URL hinzu:
```text
https://git.jensneuber.de/jens/solaredge-optimizers
```
Installiere anschließend **SolarEdge Optimizer Data**, pflege Benutzername,
Passwort und Site-ID im Tab **Konfiguration** und starte die App. Der Supervisor
lädt das zur App-Version passende Multi-Arch-Image aus der Registry.
## Lokale Installation
Der Ordner ist ein eigenständig baubares Home-Assistant-App-Paket. Kopiere ihn
für eine lokale Installation nach:
```text
/addons/solaredge-optimizer-data
```
Lade anschließend den App Store neu, installiere **SolarEdge Optimizer Data**,
pflege Benutzername, Passwort und Site-ID im Tab **Konfiguration** und starte
die App. Der Supervisor lädt dabei das zur App-Version passende Multi-Arch-Image
`git.jensneuber.de/jens/solaredgeoptimizers:0.1.0`.
## Sicherheit und Betrieb
- Anmeldedaten werden ausschließlich aus `/data/options.json` gelesen.
- Tokens, Cookies und Passwörter werden nicht persistiert oder geloggt.
- SolarEdge-Sitzung und Cookies existieren nur im Arbeitsspeicher.
- Gleichzeitige Energie-Abfragen sind auf drei begrenzt.
- Das Mindestintervall von zehn Minuten reduziert die Last auf den internen,
nicht dokumentierten SolarEdge-Endpunkten.
- MFA-/OTP-Anmeldungen werden derzeit nicht unterstützt.
+19
View File
@@ -0,0 +1,19 @@
FROM oven/bun:1.3.14-alpine
ARG BUILD_VERSION=dev
ARG BUILD_ARCH=amd64
LABEL \
io.hass.version="${BUILD_VERSION}" \
io.hass.type="app" \
io.hass.arch="${BUILD_ARCH}"
RUN apk add --no-cache tzdata
WORKDIR /app
COPY dist/server.js /app/server.js
COPY run.sh /run.sh
RUN chmod 0755 /run.sh
EXPOSE 8099
CMD ["/run.sh"]
+13
View File
@@ -0,0 +1,13 @@
# SolarEdge Optimizer Data
Home Assistant App providing a cached HTTP API with today's energy and current
power for every optimizer/module in a SolarEdge Monitoring site.
The App logs in headlessly with the configured portal account. It refreshes in
the background every ten minutes by default, so API clients only read cached
data and do not trigger more SolarEdge requests.
See [DOCS.md](DOCS.md) for configuration, endpoints and installation notes.
Published multi-architecture images are available as
`git.jensneuber.de/jens/solaredgeoptimizers:<version>`.
+31
View File
@@ -0,0 +1,31 @@
name: "SolarEdge Optimizer Data"
version: "0.1.0"
slug: "solaredge_optimizer_data"
description: >-
Cached daily energy and current power data for every SolarEdge optimizer.
arch:
- aarch64
- amd64
image: git.jensneuber.de/jens/solaredgeoptimizers
startup: application
boot: auto
init: false
stage: experimental
ingress: true
ingress_port: 8099
watchdog: "tcp://[HOST]:[PORT:8099]"
panel_icon: mdi:solar-power
panel_title: SolarEdge Optimizers
panel_admin: true
tmpfs: true
backup: cold
options:
username: null
password: null
site_id: null
poll_interval_minutes: 10
schema:
username: str
password: password
site_id: "match(^[0-9]+$)"
poll_interval_minutes: "int(10,1440)"
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@solar-dash/solaredge-optimizer-app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "bun build src/index.ts --target=bun --minify --outfile=dist/server.js",
"start": "bun src/index.ts",
"test": "bun test src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@solar-dash/solaredgeapi": "workspace:*"
},
"devDependencies": {
"bun-types": "^1.3.14",
"typescript": "~6.0.3"
}
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
set -eu
exec bun /app/server.js
@@ -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";
}
@@ -0,0 +1,13 @@
configuration:
username:
name: SolarEdge Benutzername
description: E-Mail-Adresse oder Benutzername des SolarEdge Monitoring Portals.
password:
name: SolarEdge Passwort
description: Passwort des SolarEdge Monitoring Portals. Es wird niemals ausgegeben.
site_id:
name: SolarEdge Site-ID
description: Numerische ID der SolarEdge-Anlage.
poll_interval_minutes:
name: Aktualisierungsintervall
description: Abstand zwischen SolarEdge-Abrufen in Minuten; mindestens 10 Minuten.
@@ -0,0 +1,13 @@
configuration:
username:
name: SolarEdge username
description: Email address or username for the SolarEdge Monitoring Portal.
password:
name: SolarEdge password
description: Password for the SolarEdge Monitoring Portal. It is never included in output.
site_id:
name: SolarEdge site ID
description: Numeric ID of the SolarEdge site.
poll_interval_minutes:
name: Refresh interval
description: Minutes between SolarEdge refreshes; the minimum is 10 minutes.
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}