Initial SolarEdge Optimizer Home Assistant App
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!dist/server.js
|
||||
!run.sh
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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>`.
|
||||
@@ -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)"
|
||||
+194
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user