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 @@
node_modules/
coverage/
.DS_Store
*.log
+54
View File
@@ -0,0 +1,54 @@
# SolarEdge Optimizer Apps
Home-Assistant-App-Repository für den browserlosen Abruf von Optimierer-Daten
aus dem SolarEdge Monitoring Portal.
## In Home Assistant installieren
Voraussetzung ist Home Assistant OS.
1. Öffne **Einstellungen → Apps → App Store**.
2. Öffne oben rechts **⋮ → Repositories**.
3. Füge diese Repository-URL hinzu:
```text
https://git.jensneuber.de/jens/solaredge-optimizers
```
4. Installiere **SolarEdge Optimizer Data**.
5. Hinterlege im Tab **Konfiguration** Benutzername, Passwort und Site-ID.
6. Speichere die Konfiguration und starte die App.
## SolarEdge Optimizer Data
Die App aktualisiert standardmäßig alle zehn Minuten und liefert für jedes
Modul beziehungsweise jeden Optimierer:
- logische ID, zum Beispiel `1.1.1`
- Seriennummer
- heutige Energie in Wh
- aktuelle Leistung in W
- Zeitpunkt der letzten Telemetrie
Die Daten sind über Home Assistant Ingress und intern unter
`GET /api/optimizers` verfügbar. Details stehen in
[`solaredge-optimizer-data/DOCS.md`](solaredge-optimizer-data/DOCS.md).
Das zur App-Version passende Multi-Arch-Image wird von
`git.jensneuber.de/jens/solaredgeoptimizers` geladen. Unterstützt werden
`amd64` und `aarch64`.
## Entwicklung
```sh
bun install --frozen-lockfile
bun run typecheck
bun run test
bun run build
```
Das erzeugte `solaredge-optimizer-data/dist/server.js` ist ein eigenständiges
Bun-Bundle und wird vom Docker-Image direkt ausgeführt.
> Die Optimierer-Endpunkte sind interne, nicht offiziell dokumentierte
> SolarEdge-Portal-Endpunkte und können sich ohne Ankündigung ändern.
+61
View File
@@ -0,0 +1,61 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "solaredge-optimizers",
},
"packages/solaredgeapi": {
"name": "@solar-dash/solaredgeapi",
"version": "1.0.0",
"dependencies": {
"tough-cookie": "^4.1.4",
},
"devDependencies": {
"@types/tough-cookie": "^4.0.5",
"bun-types": "^1.3.14",
"typescript": "~6.0.3",
},
},
"solaredge-optimizer-data": {
"name": "@solar-dash/solaredge-optimizer-app",
"version": "0.1.0",
"dependencies": {
"@solar-dash/solaredgeapi": "workspace:*",
},
"devDependencies": {
"bun-types": "^1.3.14",
"typescript": "~6.0.3",
},
},
},
"packages": {
"@solar-dash/solaredge-optimizer-app": ["@solar-dash/solaredge-optimizer-app@workspace:solaredge-optimizer-data"],
"@solar-dash/solaredgeapi": ["@solar-dash/solaredgeapi@workspace:packages/solaredgeapi"],
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="],
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
"tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
"url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="],
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "solaredge-optimizers",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.3.14",
"workspaces": [
"solaredge-optimizer-data",
"packages/*"
],
"scripts": {
"build": "bun --filter @solar-dash/solaredge-optimizer-app build",
"test": "bun --filter '*' test",
"typecheck": "bun --filter '*' typecheck"
}
}
+67
View File
@@ -0,0 +1,67 @@
# SolarEdge Monitoring API
Typed, dependency-free client for the SolarEdge Monitoring API at
`https://monitoringapi.solaredge.com`.
Canonical reference: [SolarEdge Monitoring API documentation (March 2026)](https://knowledge-center.solaredge.com/sites/kc/files/se_monitoring_api.pdf)
Directly readable local reference:
[`docs/monitoring-api.md`](docs/monitoring-api.md)
Keep `SOLAREDGE_API_KEY` and `SOLAREDGE_SITE_ID` on the server. Do not expose
them through `EXPO_PUBLIC_` environment variables or bundle them into a client
app.
```ts
import { createSolarEdgeClientFromEnv } from "@solar-dash/solaredgeapi";
const client = createSolarEdgeClientFromEnv();
const overview = await client.getSiteOverview();
```
The client exposes typed methods for site metadata, overview, energy, power,
detailed meter series, current power flow, inventory, and equipment. Use
`client.get<T>(path, query)` for other read-only Monitoring API endpoints.
The site-specific methods use `SOLAREDGE_SITE_ID` by default, while still
accepting an explicit site ID when needed.
The client limits itself to three concurrent requests, matching SolarEdge's
documented concurrency limit. SolarEdge also applies daily quotas of 300
requests per account token and 300 requests per site ID and source IP, plus
endpoint-specific maximum date ranges. Callers remain responsible for staying
within those daily and date-range limits.
SolarEdge date/time parameters are strings in the site's local time zone:
- dates: `yyyy-MM-dd`
- date-times: `yyyy-MM-dd HH:mm:ss`
## Experimental optimizer access
Optimizer values are not part of the official Monitoring API. The package also
contains an explicitly unsupported, server-only portal client which performs
the same OAuth2/PKCE login and session exchange as the Monitoring web portal.
It does not require a browser at runtime.
```env
SOLAREDGE_USERNAME=owner@example.com
SOLAREDGE_PASSWORD=...
```
```ts
import { createSolarEdgePortalClientFromEnv } from "@solar-dash/solaredgeapi";
const portal = createSolarEdgePortalClientFromEnv();
const optimizerSerials = await portal.listOptimizerSerials();
const optimizerMappings = await portal.listOptimizerMappings();
const energy = await portal.getOptimizerEnergy({
startDate: "2026-08-09",
endDate: "2026-08-09",
optimizerSerials,
});
```
Never expose the portal credentials through `EXPO_PUBLIC_` variables or a
mobile/web bundle. Additional MFA challenges are detected but are not currently
automated. Internal endpoints and response formats may change without notice.
See [`docs/internal-optimizer-api.md`](docs/internal-optimizer-api.md).
@@ -0,0 +1,172 @@
# SolarEdge internal optimizer API
> Status: experimental and unsupported. This document describes calls observed
> in the SolarEdge Monitoring web portal on 2026-08-09. SolarEdge can change or
> remove them without notice.
## Why a second client is required
The official API at `https://monitoringapi.solaredge.com` does not expose
per-optimizer measurements. Its API key is rejected by the internal portal
endpoints with HTTP 401.
The Monitoring portal instead uses an OAuth2 authorization-code flow with PKCE:
1. Load the hosted SolarEdge/AWS Cognito login form.
2. Submit the configured portal username and password together with the CSRF
token and login cookies.
3. Exchange the returned authorization code for OAuth tokens.
4. Exchange those tokens at `/services/auth/token?legacy=false` for a Monitoring
portal session.
5. Send both the bearer access token and session cookies to internal services.
6. Refresh the session through `POST /services/auth/refresh` when necessary.
`SolarEdgePortalClient` performs this flow with HTTP requests and a memory-only
cookie jar. It does not start or depend on a browser. Tokens and cookies are not
persisted to disk.
## Configuration
Keep all values on the server:
```env
SOLAREDGE_SITE_ID=...
SOLAREDGE_USERNAME=owner@example.com
SOLAREDGE_PASSWORD=...
```
Do not use the `EXPO_PUBLIC_` prefix. Values with that prefix are embedded in
the client application and are not secret.
```ts
import { createSolarEdgePortalClientFromEnv } from "@solar-dash/solaredgeapi";
const portal = createSolarEdgePortalClientFromEnv();
await portal.authenticate();
```
## Observed endpoints
### Logical layout including optimizers
```http
GET /services/layout/logical/generic/v2/site/{siteId}?include-optimizers=true
```
```ts
const layout = await portal.getLogicalLayout();
const optimizerSerials = await portal.listOptimizerSerials();
const optimizerMappings = await portal.listOptimizerMappings();
```
The response contains the site's logical device tree and optimizer serials.
`listOptimizerSerials()` traverses that tree and returns only nodes whose type
is `OPTIMIZER`. The raw layout is deliberately typed as a generic object because
this undocumented structure is more likely to change.
`listOptimizerMappings()` preserves SolarEdge's logical position fields:
```ts
type SolarEdgeOptimizerMapping = {
serial: string;
inverterId?: string; // e.g. "1"
stringId?: string; // e.g. "1.1"
optimizerId?: string; // e.g. "1.1.16"
inverterOrder?: number;
stringOrder?: number;
optimizerOrder?: number;
};
```
The IDs come from the portal's `displayOrder` properties and are therefore
preferable to deriving positions from array indexes.
### Optimizer information
```http
POST /services/layout/information/optimizers
Content-Type: application/json
["OPTIMIZER_SERIAL_1", "OPTIMIZER_SERIAL_2"]
```
Observed response container:
```ts
type SolarEdgeOptimizerInformation = {
basicInformationList: Array<{
serial: string;
type_?: string;
model?: string;
bundleType?: string;
multiOptimizers?: boolean;
modules?: Array<{
orientation?: string;
tilt?: number;
azimuth?: number;
manufacturer?: string;
model?: string;
}>;
[property: string]: unknown;
}>;
serialToLiveData: Record<string, {
lastMeasurement?: string;
current_A?: number | null;
optimizerVoltage_V?: number | null;
power_W?: number | null;
voltage_V?: number | null;
total_last_telemetry_energy_WH?: number | null;
[property: string]: unknown;
}>;
};
```
```ts
const information = await portal.getOptimizerInformation([
"OPTIMIZER_SERIAL_1",
"OPTIMIZER_SERIAL_2",
]);
```
### Optimizer energy graph
```http
GET /services/layout/energy-graph/site/{siteId}/optimizers
?chart-time-unit=hours
&start-date=yyyy-MM-dd
&end-date=yyyy-MM-dd
&optimizer-serials=SERIAL_1,SERIAL_2
```
Observed response:
```ts
type SolarEdgeOptimizerEnergy = {
totalEnergy: number;
energyBars: Array<{
measurementTime: string;
energy: number | null;
}>;
};
```
```ts
const energy = await portal.getOptimizerEnergy({
startDate: "2026-08-09",
endDate: "2026-08-09",
optimizerSerials: ["OPTIMIZER_SERIAL_1"],
chartTimeUnit: "hours",
});
```
## Limitations and operational safety
- The implementation currently supports username/password login without an
additional MFA challenge. It throws `AUTH_CHALLENGE_REQUIRED` if SolarEdge
asks for an OTP, authenticator, passkey, or another step.
- Credentials remain in process memory because they are needed for a fresh
login after session expiry. Do not log client options or error request bodies.
- Use conservative polling and caching. These endpoints have no published rate
limits or stability guarantees.
- Use this only for an account and site you are authorized to access. Review the
SolarEdge Monitoring Portal terms before production use.
@@ -0,0 +1,498 @@
# SolarEdge Monitoring API reference
Local working reference for the SolarEdge Monitoring API. This file summarizes
the official March 2026 documentation in a compact, implementation-oriented
form. The [official PDF](https://knowledge-center.solaredge.com/sites/kc/files/se_monitoring_api.pdf)
remains the authoritative source.
## Base URL and authentication
```text
https://monitoringapi.solaredge.com
```
All requests use `GET` over HTTPS and pass the API key as the `api_key` query
parameter:
```text
GET /site/{siteId}/overview?api_key={apiKey}
Accept: application/json
```
Only JSON is supported. CSV and XML support were removed in March 2026.
The local server-side configuration is:
```dotenv
SOLAREDGE_API_KEY=...
SOLAREDGE_SITE_ID=...
```
Never expose the API key in browser code, an `EXPO_PUBLIC_` variable, logs, or a
repository. SolarEdge recommends using a site-level key where possible and
rotating keys periodically.
## Dates, time zones, encoding, and units
- Date: `YYYY-MM-DD`
- Date and time: `YYYY-MM-DD hh:mm:ss`
- All dates and times use the site's local time zone.
- URL query values, including spaces in date-times, must be URL encoded.
- Text is UTF-8.
- Physical measurements use metric units; temperatures are Celsius.
- Common time units are `QUARTER_OF_AN_HOUR`, `HOUR`, `DAY`, `WEEK`, `MONTH`,
and `YEAR`. Each endpoint defines which values it accepts.
- A time-series value can be `null` or entirely absent when no measurement is
available.
## Limits and errors
SolarEdge uses standard HTTP status codes.
| Status | Meaning |
| --- | --- |
| `403` | Invalid parameter range, endpoint-specific limit exceeded, or access to a requested site is forbidden. |
| `404` | Resource does not exist. |
| `429` | Daily request quota or concurrency limit exceeded. |
Global limits from the March 2026 documentation:
- 300 requests per account token.
- 300 requests per specific site ID and source IP.
- At most 3 concurrent API calls from the same source IP.
- Bulk endpoints accept at most 100 comma-separated site IDs.
- A bulk call consumes one quota unit for every site included in the call.
The package client automatically limits itself to three concurrent requests.
It does not track daily quotas or split date ranges automatically.
## Endpoint index
`{siteIds}` means a comma-separated list of at most 100 site IDs.
| Area | Endpoint | Typed client method |
| --- | --- | --- |
| Sites | `GET /sites/list` | `listSites()` |
| Sites | `GET /site/{siteId}/details` | `getSiteDetails()` |
| Sites | `GET /site/{siteId}/dataPeriod` | `getSiteDataPeriod()` |
| Sites | `GET /sites/{siteIds}/dataPeriod` | Use `get<T>()` |
| Energy | `GET /site/{siteId}/energy` | `getSiteEnergy()` |
| Energy | `GET /sites/{siteIds}/energy` | Use `get<T>()` |
| Energy | `GET /site/{siteId}/timeFrameEnergy` | Use `get<T>()` |
| Energy | `GET /sites/{siteIds}/timeFrameEnergy` | Use `get<T>()` |
| Power | `GET /site/{siteId}/power` | `getSitePower()` |
| Power | `GET /sites/{siteIds}/power` | Use `get<T>()` |
| Overview | `GET /site/{siteId}/overview` | `getSiteOverview()` |
| Overview | `GET /sites/{siteIds}/overview` | Use `get<T>()` |
| Meters | `GET /site/{siteId}/powerDetails` | `getSitePowerDetails()` |
| Meters | `GET /site/{siteId}/energyDetails` | `getSiteEnergyDetails()` |
| Flow | `GET /site/{siteId}/currentPowerFlow` | `getCurrentPowerFlow()` |
| Storage | `GET /site/{siteId}/storageData` | Use `get<T>()` |
| Environment | `GET /site/{siteId}/envBenefits` | Use `get<T>()` |
| Equipment | `GET /equipment/{siteId}/list` | `listSiteEquipment()` |
| Equipment | `GET /site/{siteId}/inventory` | `getSiteInventory()` |
| Equipment | `GET /equipment/{siteId}/{serialNumber}/data` | Use `get<T>()` |
| Meters | `GET /site/{siteId}/meters` | Use `get<T>()` |
## Site endpoints
### List sites
```text
GET /sites/list
```
Returns sites available to an account-level key. A site-level key may not be
able to use this endpoint meaningfully.
| Parameter | Required | Default | Description |
| --- | --- | --- | --- |
| `size` | No | `100` | Page size, maximum 100. |
| `startIndex` | No | `0` | Zero-based first result. |
| `searchText` | No | - | Searches name, notes, address, city, zip, and country. Full-address search is no longer supported. |
| `sortProperty` | No | - | `name`, `country`, `state`, `city`, `address`, `zip`, `status`, `peakPower`, `installationDate`, `amount`, or `maxSeverity`. |
| `sortOrder` | No | `ASC` | `ASC` or `DESC`. |
| `Status` | No | `Active,Pending` | `Active`, `Pending`, `Disabled`, or `All`. The parameter name begins with uppercase `S`. |
Site objects can contain:
- `id`, `accountId`, `name`, `status`, `type`, and `notes`
- `peakPower`, `currency`, `installationDate`, and `ptoDate`
- `location` with country, state, city, address, address2, zip, and time zone
- `alertQuantity` and `alertSeverity` for account-level keys
- `publicSettings` with public name and visibility
- API-specific `uris`
The PDF examples use `Sites.list`, while the live JSON API currently returns
`sites.site`. The package follows the live response and exposes `{ count, site }`.
### Site details
```text
GET /site/{siteId}/details
```
Returns one site object with the same core metadata as the site list. Response
root: `details`.
### Site data period
```text
GET /site/{siteId}/dataPeriod
GET /sites/{siteIds}/dataPeriod
```
Returns the first and last available production timestamps. For a site that is
not transmitting, `startDate` and `endDate` can be `null`.
Single-site response root:
```json
{
"dataPeriod": {
"startDate": "YYYY-MM-DD hh:mm:ss",
"endDate": "YYYY-MM-DD hh:mm:ss"
}
}
```
The bulk response adds `count` and a `list` whose entries include `id`.
## Energy endpoints
### Energy time series
```text
GET /site/{siteId}/energy
GET /sites/{siteIds}/energy
```
| Parameter | Required | Description |
| --- | --- | --- |
| `startDate` | Yes | Start date in `YYYY-MM-DD`. |
| `endDate` | Yes | End date in `YYYY-MM-DD`. |
| `timeUnit` | No | Defaults to `DAY`; accepts `QUARTER_OF_AN_HOUR`, `HOUR`, `DAY`, `WEEK`, `MONTH`, or `YEAR`. |
Limits:
- `DAY`: at most one year.
- `QUARTER_OF_AN_HOUR` or `HOUR`: at most one month.
- The PDF does not state an additional range limit for week, month, or year.
Response root `energy` contains `timeUnit`, `unit`, and `values`. Each value has
`date` and a numeric or `null` `value`. The regular energy endpoint matches the
Site Dashboard calculation.
The bulk response adds `count` and a `list` of `{ id, values }`.
### Total energy for a date range
```text
GET /site/{siteId}/timeFrameEnergy
GET /sites/{siteIds}/timeFrameEnergy
```
| Parameter | Required | Description |
| --- | --- | --- |
| `startDate` | Yes | Start date in `YYYY-MM-DD`. |
| `endDate` | Yes | End date in `YYYY-MM-DD`. |
Maximum range: one year.
Response root `timeFrameEnergy` contains `energy` and `unit`. The bulk response
contains `unit`, `count`, and `{ id, energy }` entries.
This endpoint reports on-grid energy. On storage or backup sites it may differ
from the Site Dashboard; use `/energy` when dashboard-equivalent values are
required.
### Detailed energy by meter
```text
GET /site/{siteId}/energyDetails
```
| Parameter | Required | Description |
| --- | --- | --- |
| `startTime` | Yes | Start in site-local `YYYY-MM-DD hh:mm:ss`. |
| `endTime` | Yes | End in site-local `YYYY-MM-DD hh:mm:ss`. |
| `timeUnit` | No | Defaults to `DAY`; accepts all six common time units. |
| `meters` | No | Comma-separated `PRODUCTION`, `CONSUMPTION`, `SELFCONSUMPTION`, `FEEDIN`, and/or `PURCHASED`. |
Limits:
- `QUARTER_OF_AN_HOUR` or `HOUR`: at most one month.
- `DAY`: at most one year.
- `WEEK`, `MONTH`, or `YEAR`: no documented period limit.
Response root `energyDetails` contains `timeUnit`, `unit`, and `meters`. Each
meter has `type` and `values`; a missing measurement may omit `value` entirely.
## Power endpoints
### Site power
```text
GET /site/{siteId}/power
GET /sites/{siteIds}/power
```
| Parameter | Required | Description |
| --- | --- | --- |
| `startTime` | Yes | Start in site-local `YYYY-MM-DD hh:mm:ss`. |
| `endTime` | Yes | End in site-local `YYYY-MM-DD hh:mm:ss`. |
Maximum range: one month. Resolution is 15 minutes. Response root `power`
contains `timeUnit`, `unit`, and `values`. Missing values can be `null`.
The bulk response adds `count` and a list of `{ id, values }`.
### Detailed power by meter
```text
GET /site/{siteId}/powerDetails
```
| Parameter | Required | Description |
| --- | --- | --- |
| `startTime` | Yes | Start in site-local `YYYY-MM-DD hh:mm:ss`. |
| `endTime` | Yes | End in site-local `YYYY-MM-DD hh:mm:ss`. |
| `meters` | No | Comma-separated `PRODUCTION`, `CONSUMPTION`, `SELFCONSUMPTION`, `FEEDIN`, and/or `PURCHASED`. |
Maximum range: one month. Response root `powerDetails` contains a fixed
`QUARTER_OF_AN_HOUR` time unit, the measurement unit, and per-meter series.
A missing measurement may omit `value` entirely.
## Overview and live power flow
### Site overview
```text
GET /site/{siteId}/overview
GET /sites/{siteIds}/overview
```
Response root `overview` contains:
- `lastUpdateTime`
- `currentPower.power`
- `lastDayData.energy`
- `lastMonthData.energy`
- `lastYearData.energy`
- `lifeTimeData.energy` and lifetime revenue
- Revenue fields can also appear in the period summaries.
The bulk response contains `count` and a list with `id` on every overview.
### Current power flow
```text
GET /site/{siteId}/currentPowerFlow
```
If unsupported, the API may return an empty power-flow object. Otherwise,
`siteCurrentPowerFlow` contains:
- `unit`
- `connections` entries with `from` and `to`
- `GRID` and `LOAD`
- Optional `PV`
- Optional `STORAGE`
Every component provides `status` (`Active`, `Idle`, or `Disabled`) and a
positive `currentPower`. Direction comes from `connections`, not the sign.
Storage can additionally provide `chargeLevel`, `critical`, and `timeLeft`.
## Storage data
```text
GET /site/{siteId}/storageData
```
| Parameter | Required | Description |
| --- | --- | --- |
| `startTime` | Yes | Start in site-local `YYYY-MM-DD hh:mm:ss`. |
| `endTime` | Yes | End in site-local `YYYY-MM-DD hh:mm:ss`. |
| `serials` | No | Comma-separated battery serial numbers. Defaults to every battery at the site. |
Maximum range: seven days.
Response root `storageData` contains `batteryCount` and `batteries`. A battery
contains `serialNumber`, `nameplate`, `modelNumber`, `telemetryCount`, and
`telemetries`. Telemetry can include:
- `timeStamp`
- `power` (positive means charging, negative means discharging)
- `batteryState`: `0` invalid, `1` standby, `2` thermal management, `3` enabled,
`4` fault
- Lifetime energy charged and discharged
- Full-pack energy available
- Internal temperature
- AC grid charging energy
- State of charge from 0 to 100 percent
The documentation warns that some lifetime battery energy values are aggregated
and can be incomplete when telemetry is missing. They are not revenue-grade.
## Environmental benefits
```text
GET /site/{siteId}/envBenefits
```
| Parameter | Required | Description |
| --- | --- | --- |
| `systemUnits` | No | Case-sensitive `Metrics` or `Imperial`. |
Response root `envBenefits` contains `gasEmissionSaved` (`units`, `co2`, `so2`,
and `nox`), `treesPlanted`, and `lightBulbs`.
## Equipment endpoints
### Components list
```text
GET /equipment/{siteId}/list
```
Returns inverters and SMIs with `name`, `manufacturer`, `model`, and
`serialNumber`.
The PDF example shows a top-level `list`; the live API currently wraps the
result in `reporters` with `count` and `list`. The package follows the live
response.
### Inventory
```text
GET /site/{siteId}/inventory
```
Response root `Inventory` can contain arrays for:
- `inverters`
- `thirdPartyInverters`
- `smiList`
- `meters`
- `sensors`
- `gateways`
- `batteries`
Fields vary by device type. Common fields include names, manufacturer, model,
serial number (`SN` or `serialNumber`), firmware versions, communication method,
connected device serials, connected optimizer count, and battery nameplate
capacity.
### Inverter technical data
```text
GET /equipment/{siteId}/{serialNumber}/data
```
| Parameter | Required | Description |
| --- | --- | --- |
| `startTime` | Yes | Start in site-local `YYYY-MM-DD hh:mm:ss`. |
| `endTime` | Yes | End in site-local `YYYY-MM-DD hh:mm:ss`. |
Maximum range: seven days.
Response root `data` contains `count` and inverter telemetry. Unsupported fields
are omitted. Depending on inverter type and firmware, telemetry can contain:
- Timestamp, inverter mode, and operation mode
- Total active power, DC voltage, power limit, total energy, temperature, and
ground-fault resistance
- Per-phase current, voltage, frequency, apparent power, active power, reactive
power, and power factor
- Phase-to-neutral and phase-to-phase voltages where applicable
Inverter modes include normal states such as off, sleeping, starting, MPPT,
throttled, shutdown, fault, and standby, plus several locked states. Operation
mode `0` is on-grid, `1` is off-grid using PV or battery, and `2` is off-grid
with a generator present.
## Meter lifetime data
```text
GET /site/{siteId}/meters
```
| Parameter | Required | Description |
| --- | --- | --- |
| `startTime` | Yes | Start in site-local `YYYY-MM-DD hh:mm:ss`. |
| `endTime` | Yes | End in site-local `YYYY-MM-DD hh:mm:ss`. |
| `timeUnit` | No | Defaults to `DAY`; accepts all six common time units. |
| `meters` | No | Comma-separated `Production`, `Consumption`, `FeedIn`, and/or `Purchased`. |
Response root `meterEnergyDetails` contains `timeUnit`, `unit`, and `meters`.
Each meter includes `meterSerialNumber`, `connectedSolaredgeDeviceSN`, `model`,
`meterType`, and lifetime-energy `values` by timestamp.
## Bulk endpoint behavior
Bulk endpoints use comma-separated IDs directly in the path:
```text
GET /sites/1,4,8/overview
```
- Maximum 100 site IDs.
- Responses normally contain `count` and `list` inside the endpoint root.
- Each list item includes the site `id`.
- If the key lacks permission for any requested site, the entire request can
fail with `403`.
- Dates are interpreted in each site's own time zone.
## March 2026 removals and changes
Do not build new integrations against these removed endpoints or capabilities:
- Site Image
- Installer Logo Image
- Equipment Change Log
- Account List
- Sensor List and Sensor Data
- API Versions: Current and Supported
- CSV and XML response formats
- `CreationTime` site-list sorting
- Full-address matching in `searchText`
## Package usage
```ts
import { createSolarEdgeClientFromEnv } from "@solar-dash/solaredgeapi";
const client = createSolarEdgeClientFromEnv();
const [overview, powerFlow] = await Promise.all([
client.getSiteOverview(),
client.getCurrentPowerFlow(),
]);
```
For an endpoint without a typed method, use the authenticated generic reader:
```ts
type EnvironmentalBenefitsResponse = {
envBenefits: {
gasEmissionSaved: {
units: string;
co2: number;
so2: number;
nox: number;
};
treesPlanted: number;
lightBulbs: number;
};
};
const siteId = process.env.SOLAREDGE_SITE_ID;
if (!siteId) {
throw new Error("Missing SOLAREDGE_SITE_ID");
}
const response = await client.get<EnvironmentalBenefitsResponse>(
`/site/${siteId}/envBenefits`,
{ systemUnits: "Metrics" },
);
```
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@solar-dash/solaredgeapi",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "bun test src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"tough-cookie": "^4.1.4"
},
"devDependencies": {
"@types/tough-cookie": "^4.0.5",
"bun-types": "^1.3.14",
"typescript": "~6.0.3"
}
}
+241
View File
@@ -0,0 +1,241 @@
import { describe, expect, it } from "bun:test";
import {
SolarEdgeApiError,
SolarEdgeClient,
buildSolarEdgeApiUrl,
createSolarEdgeClientFromEnv,
} from "./index";
describe("buildSolarEdgeApiUrl", () => {
it("normalizes slashes and omits undefined query values", () => {
const url = buildSolarEdgeApiUrl(
"https://monitoringapi.solaredge.com/",
"/sites/list",
{
size: 10,
searchText: undefined,
sortOrder: "DESC",
},
);
expect(url.toString()).toBe(
"https://monitoringapi.solaredge.com/sites/list?size=10&sortOrder=DESC",
);
});
});
describe("SolarEdgeClient", () => {
it("creates a client from SOLAREDGE_API_KEY and SOLAREDGE_SITE_ID", async () => {
const client = createSolarEdgeClientFromEnv({
env: {
SOLAREDGE_API_KEY: "from-env",
SOLAREDGE_SITE_ID: "42",
},
fetch: async (input) => {
const url = new URL(String(input));
expect(url.pathname).toBe("/site/42/overview");
expect(url.searchParams.get("api_key")).toBe("from-env");
return Response.json({
overview: {
lastUpdateTime: "2026-08-09 12:00:00",
lifeTimeData: { energy: 100 },
lastYearData: { energy: 50 },
lastMonthData: { energy: 10 },
lastDayData: { energy: 1 },
currentPower: { power: 500 },
},
});
},
});
await expect(client.getSiteOverview()).resolves.toMatchObject({
currentPower: { power: 500 },
});
expect(() => createSolarEdgeClientFromEnv({ env: {} })).toThrow(
"Missing SOLAREDGE_API_KEY",
);
expect(() =>
createSolarEdgeClientFromEnv({
env: { SOLAREDGE_API_KEY: "from-env" },
}),
).toThrow("Missing SOLAREDGE_SITE_ID");
});
it("requires a site id for site requests without a configured default", async () => {
const client = new SolarEdgeClient({ apiKey: "secret" });
await expect(client.getSiteOverview()).rejects.toThrow(
"Missing SolarEdge siteId",
);
});
it("lists sites and authenticates with the api_key query parameter", async () => {
const requestedUrls: URL[] = [];
const client = new SolarEdgeClient({
apiKey: "secret key",
fetch: async (input) => {
requestedUrls.push(new URL(String(input)));
return Response.json({
sites: {
count: 1,
site: [{ id: 42, name: "Roof", status: "Active" }],
},
});
},
});
await expect(
client.listSites({ size: 10, startIndex: 20, status: "Active" }),
).resolves.toEqual({
count: 1,
site: [{ id: 42, name: "Roof", status: "Active" }],
});
expect(requestedUrls[0].pathname).toBe("/sites/list");
expect(requestedUrls[0].searchParams.get("size")).toBe("10");
expect(requestedUrls[0].searchParams.get("startIndex")).toBe("20");
expect(requestedUrls[0].searchParams.get("Status")).toBe("Active");
expect(requestedUrls[0].searchParams.has("status")).toBe(false);
expect(requestedUrls[0].searchParams.get("api_key")).toBe("secret key");
});
it("builds site energy and detailed meter requests", async () => {
const requestedUrls: URL[] = [];
const client = new SolarEdgeClient({
apiKey: "secret",
fetch: async (input) => {
const url = new URL(String(input));
requestedUrls.push(url);
if (url.pathname.endsWith("/energy")) {
return Response.json({
energy: { timeUnit: "DAY", unit: "Wh", values: [] },
});
}
return Response.json({
energyDetails: { timeUnit: "HOUR", unit: "Wh", meters: [] },
});
},
});
await client.getSiteEnergy({
siteId: 42,
startDate: "2026-08-01",
endDate: "2026-08-08",
timeUnit: "DAY",
});
await client.getSiteEnergyDetails({
siteId: 42,
startTime: "2026-08-08 00:00:00",
endTime: "2026-08-08 23:59:59",
timeUnit: "HOUR",
meters: ["PRODUCTION", "CONSUMPTION"],
});
expect(requestedUrls[0].pathname).toBe("/site/42/energy");
expect(requestedUrls[0].searchParams.get("startDate")).toBe("2026-08-01");
expect(requestedUrls[0].searchParams.get("timeUnit")).toBe("DAY");
expect(requestedUrls[1].pathname).toBe("/site/42/energyDetails");
expect(requestedUrls[1].searchParams.get("meters")).toBe(
"PRODUCTION,CONSUMPTION",
);
});
it("unwraps current power flow responses", async () => {
const client = new SolarEdgeClient({
apiKey: "secret",
fetch: async () =>
Response.json({
siteCurrentPowerFlow: {
updateRefreshRate: 3,
unit: "kW",
connections: [{ from: "PV", to: "Load" }],
PV: { status: "Active", currentPower: 4.2 },
},
}),
});
await expect(client.getCurrentPowerFlow(42)).resolves.toEqual({
updateRefreshRate: 3,
unit: "kW",
connections: [{ from: "PV", to: "Load" }],
PV: { status: "Active", currentPower: 4.2 },
});
});
it("throws structured errors without exposing the api key", async () => {
const client = new SolarEdgeClient({
apiKey: "do-not-leak",
fetch: async () =>
Response.json(
{ error: { code: 429, message: "Too many requests" } },
{ status: 429 },
),
});
try {
await client.listSites();
throw new Error("Expected listSites to fail");
} catch (error) {
expect(error).toBeInstanceOf(SolarEdgeApiError);
expect(error).toMatchObject({ status: 429, code: 429 });
expect(String(error)).toContain("Too many requests");
expect(String(error)).not.toContain("do-not-leak");
}
});
it("wraps network and invalid JSON errors", async () => {
const networkClient = new SolarEdgeClient({
apiKey: "secret",
fetch: async () => {
throw new TypeError("offline");
},
});
const invalidJsonClient = new SolarEdgeClient({
apiKey: "secret",
fetch: async () => new Response("not json"),
});
await expect(networkClient.listSites()).rejects.toMatchObject({ status: 0 });
await expect(invalidJsonClient.listSites()).rejects.toMatchObject({
status: 200,
});
});
it("supports abort signals", async () => {
const controller = new AbortController();
const client = new SolarEdgeClient({
apiKey: "secret",
fetch: async (_input, init) => {
expect(init?.signal).toBe(controller.signal);
return Response.json({ sites: { count: 0, site: [] } });
},
});
await client.listSites({}, { signal: controller.signal });
});
it("limits concurrent API requests to three", async () => {
let activeRequests = 0;
let maximumActiveRequests = 0;
const client = new SolarEdgeClient({
apiKey: "secret",
fetch: async () => {
activeRequests += 1;
maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests);
await new Promise((resolve) => setTimeout(resolve, 5));
activeRequests -= 1;
return Response.json({ sites: { count: 0, site: [] } });
},
});
await Promise.all(Array.from({ length: 8 }, () => client.listSites()));
expect(maximumActiveRequests).toBe(3);
expect(
() => new SolarEdgeClient({ apiKey: "secret", maxConcurrency: 4 }),
).toThrow("maxConcurrency must be an integer from 1 to 3");
});
});
+391
View File
@@ -0,0 +1,391 @@
import type {
GetSolarEdgeEnergyOptions,
GetSolarEdgeMeterSeriesOptions,
GetSolarEdgePowerOptions,
GetSolarEdgePowerDetailsOptions,
ListSolarEdgeSitesOptions,
SolarEdgeCurrentPowerFlow,
SolarEdgeDataPeriod,
SolarEdgeEquipmentList,
SolarEdgeInventory,
SolarEdgeMeterTimeSeries,
SolarEdgeOverview,
SolarEdgeSite,
SolarEdgeSiteId,
SolarEdgeSites,
SolarEdgeTimeSeries,
} from "./types";
export const SOLAREDGE_API_BASE_URL = "https://monitoringapi.solaredge.com";
export const SOLAREDGE_API_KEY_ENV_NAME = "SOLAREDGE_API_KEY";
export const SOLAREDGE_SITE_ID_ENV_NAME = "SOLAREDGE_SITE_ID";
export type SolarEdgeFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
export type SolarEdgeClientOptions = {
apiKey: string;
siteId?: SolarEdgeSiteId;
baseUrl?: string;
fetch?: SolarEdgeFetch;
maxConcurrency?: number;
};
export type SolarEdgeClientFromEnvOptions = Omit<
SolarEdgeClientOptions,
"apiKey" | "siteId"
> & {
env?: Record<string, string | undefined>;
};
export type SolarEdgeQueryValue = string | number | boolean | undefined;
export type SolarEdgeRequestOptions = {
signal?: AbortSignal;
};
type SolarEdgeErrorPayload = {
error?: {
code?: number | string;
message?: string;
};
};
export class SolarEdgeApiError extends Error {
readonly status: number;
readonly code?: number | string;
constructor(
message: string,
options: {
status: number;
code?: number | string;
cause?: unknown;
},
) {
super(message, { cause: options.cause });
this.name = "SolarEdgeApiError";
this.status = options.status;
this.code = options.code;
}
}
export class SolarEdgeClient {
private readonly apiKey: string;
private readonly defaultSiteId?: SolarEdgeSiteId;
private readonly baseUrl: string;
private readonly fetchImplementation: SolarEdgeFetch;
private readonly maxConcurrency: number;
private activeRequestCount = 0;
private readonly requestQueue: Array<() => void> = [];
constructor({
apiKey,
siteId,
baseUrl = SOLAREDGE_API_BASE_URL,
fetch: fetchImplementation = globalThis.fetch,
maxConcurrency = 3,
}: SolarEdgeClientOptions) {
if (!apiKey.trim()) {
throw new Error("SolarEdge apiKey must not be empty");
}
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 3) {
throw new Error("SolarEdge maxConcurrency must be an integer from 1 to 3");
}
this.apiKey = apiKey;
this.defaultSiteId = siteId;
this.baseUrl = baseUrl.replace(/\/+$/, "");
this.fetchImplementation = fetchImplementation;
this.maxConcurrency = maxConcurrency;
}
async listSites(
options: ListSolarEdgeSitesOptions = {},
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeSites> {
const { status, ...query } = options;
const response = await this.get<{ sites: SolarEdgeSites }>(
"/sites/list",
{
...query,
Status: status,
},
requestOptions,
);
return response.sites;
}
async getSiteDetails(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeSite> {
const response = await this.get<{ details: SolarEdgeSite }>(
`/site/${this.resolveSiteId(siteId)}/details`,
{},
requestOptions,
);
return response.details;
}
async getSiteDataPeriod(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeDataPeriod> {
const response = await this.get<{ dataPeriod: SolarEdgeDataPeriod }>(
`/site/${this.resolveSiteId(siteId)}/dataPeriod`,
{},
requestOptions,
);
return response.dataPeriod;
}
async getSiteOverview(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeOverview> {
const response = await this.get<{ overview: SolarEdgeOverview }>(
`/site/${this.resolveSiteId(siteId)}/overview`,
{},
requestOptions,
);
return response.overview;
}
async getSiteEnergy(
{ siteId, ...query }: GetSolarEdgeEnergyOptions,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeTimeSeries> {
const response = await this.get<{ energy: SolarEdgeTimeSeries }>(
`/site/${this.resolveSiteId(siteId)}/energy`,
query,
requestOptions,
);
return response.energy;
}
async getSitePower(
{ siteId, ...query }: GetSolarEdgePowerOptions,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeTimeSeries> {
const response = await this.get<{ power: SolarEdgeTimeSeries }>(
`/site/${this.resolveSiteId(siteId)}/power`,
query,
requestOptions,
);
return response.power;
}
async getSiteEnergyDetails(
{ siteId, meters, ...query }: GetSolarEdgeMeterSeriesOptions,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeMeterTimeSeries> {
const response = await this.get<{ energyDetails: SolarEdgeMeterTimeSeries }>(
`/site/${this.resolveSiteId(siteId)}/energyDetails`,
{
...query,
meters: meters?.join(","),
},
requestOptions,
);
return response.energyDetails;
}
async getSitePowerDetails(
{ siteId, meters, ...query }: GetSolarEdgePowerDetailsOptions,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeMeterTimeSeries> {
const response = await this.get<{ powerDetails: SolarEdgeMeterTimeSeries }>(
`/site/${this.resolveSiteId(siteId)}/powerDetails`,
{
...query,
meters: meters?.join(","),
},
requestOptions,
);
return response.powerDetails;
}
async getCurrentPowerFlow(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeCurrentPowerFlow> {
const response = await this.get<{
siteCurrentPowerFlow: SolarEdgeCurrentPowerFlow;
}>(
`/site/${this.resolveSiteId(siteId)}/currentPowerFlow`,
{},
requestOptions,
);
return response.siteCurrentPowerFlow;
}
async getSiteInventory(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeInventory> {
const response = await this.get<{ Inventory: SolarEdgeInventory }>(
`/site/${this.resolveSiteId(siteId)}/inventory`,
{},
requestOptions,
);
return response.Inventory;
}
async listSiteEquipment(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeEquipmentList> {
const response = await this.get<{ reporters: SolarEdgeEquipmentList }>(
`/equipment/${this.resolveSiteId(siteId)}/list`,
{},
requestOptions,
);
return response.reporters;
}
async get<TResponse>(
path: string,
query: Record<string, SolarEdgeQueryValue> = {},
{ signal }: SolarEdgeRequestOptions = {},
): Promise<TResponse> {
const url = buildSolarEdgeApiUrl(this.baseUrl, path, query);
url.searchParams.set("api_key", this.apiKey);
let response: Response;
await this.acquireRequestSlot();
try {
try {
response = await this.fetchImplementation(url, {
headers: {
Accept: "application/json",
},
signal,
});
} catch (error) {
throw new SolarEdgeApiError("SolarEdge API network request failed", {
status: 0,
cause: error,
});
}
if (!response.ok) {
const payload = await parseJson<SolarEdgeErrorPayload>(response);
const message = payload?.error?.message ?? "SolarEdge API request failed";
throw new SolarEdgeApiError(`${message} (${response.status})`, {
status: response.status,
code: payload?.error?.code,
});
}
const payload = await parseJson<TResponse>(response);
if (payload === undefined) {
throw new SolarEdgeApiError("SolarEdge API returned invalid JSON", {
status: response.status,
});
}
return payload;
} finally {
this.releaseRequestSlot();
}
}
private resolveSiteId(siteId?: SolarEdgeSiteId): string {
const resolvedSiteId = siteId ?? this.defaultSiteId;
if (resolvedSiteId === undefined) {
throw new Error(
"Missing SolarEdge siteId; pass it to the request or configure SOLAREDGE_SITE_ID",
);
}
return encodeSiteId(resolvedSiteId);
}
private async acquireRequestSlot(): Promise<void> {
if (this.activeRequestCount < this.maxConcurrency) {
this.activeRequestCount += 1;
return;
}
await new Promise<void>((resolve) => {
this.requestQueue.push(() => {
this.activeRequestCount += 1;
resolve();
});
});
}
private releaseRequestSlot(): void {
this.activeRequestCount -= 1;
this.requestQueue.shift()?.();
}
}
export function createSolarEdgeClientFromEnv({
env = process.env,
...options
}: SolarEdgeClientFromEnvOptions = {}): SolarEdgeClient {
const apiKey = env[SOLAREDGE_API_KEY_ENV_NAME];
if (!apiKey) {
throw new Error(`Missing ${SOLAREDGE_API_KEY_ENV_NAME}`);
}
const siteId = env[SOLAREDGE_SITE_ID_ENV_NAME];
if (!siteId) {
throw new Error(`Missing ${SOLAREDGE_SITE_ID_ENV_NAME}`);
}
return new SolarEdgeClient({ apiKey, siteId, ...options });
}
export function buildSolarEdgeApiUrl(
baseUrl: string,
path: string,
query: Record<string, SolarEdgeQueryValue> = {},
): URL {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
const normalizedPath = path.replace(/^\/+/, "");
const url = new URL(`${normalizedBaseUrl}/${normalizedPath}`);
for (const [name, value] of Object.entries(query)) {
if (value !== undefined) {
url.searchParams.set(name, String(value));
}
}
return url;
}
function encodeSiteId(siteId: SolarEdgeSiteId): string {
const normalizedSiteId = String(siteId).trim();
if (!normalizedSiteId) {
throw new Error("SolarEdge siteId must not be empty");
}
return encodeURIComponent(normalizedSiteId);
}
async function parseJson<T>(response: Response): Promise<T | undefined> {
try {
return (await response.json()) as T;
} catch {
return undefined;
}
}
+38
View File
@@ -0,0 +1,38 @@
export {
SOLAREDGE_API_BASE_URL,
SOLAREDGE_API_KEY_ENV_NAME,
SOLAREDGE_SITE_ID_ENV_NAME,
SolarEdgeApiError,
SolarEdgeClient,
buildSolarEdgeApiUrl,
createSolarEdgeClientFromEnv,
} from "./client";
export type {
SolarEdgeClientOptions,
SolarEdgeClientFromEnvOptions,
SolarEdgeFetch,
SolarEdgeQueryValue,
SolarEdgeRequestOptions,
} from "./client";
export type * from "./types";
export {
SOLAREDGE_LOGIN_BASE_URL,
SOLAREDGE_PORTAL_BASE_URL,
SOLAREDGE_PORTAL_PASSWORD_ENV_NAME,
SOLAREDGE_PORTAL_USERNAME_ENV_NAME,
SolarEdgePortalApiError,
SolarEdgePortalAuthError,
SolarEdgePortalClient,
createSolarEdgePortalClientFromEnv,
} from "./portal";
export type {
SolarEdgePortalAuthErrorCode,
SolarEdgePortalClientFromEnvOptions,
SolarEdgePortalClientOptions,
} from "./portal";
export type * from "./portal-types";
+73
View File
@@ -0,0 +1,73 @@
import type { SolarEdgeSiteId } from "./types";
export type SolarEdgeOptimizerSerial = string;
export type SolarEdgeOptimizerMapping = {
serial: SolarEdgeOptimizerSerial;
inverterId?: string;
stringId?: string;
optimizerId?: string;
inverterOrder?: number;
stringOrder?: number;
optimizerOrder?: number;
};
export type SolarEdgeOptimizerBasicInformation = {
serial: SolarEdgeOptimizerSerial;
type_?: string;
model?: string;
bundleType?: string;
modules?: SolarEdgeOptimizerModule[];
multiOptimizers?: boolean;
[property: string]: unknown;
};
export type SolarEdgeOptimizerModule = {
orientation?: string;
tilt?: number;
azimuth?: number;
manufacturer?: string;
model?: string;
[property: string]: unknown;
};
export type SolarEdgeOptimizerLiveData = {
type_?: string;
lastMeasurement?: string;
current_A?: number | null;
optimizerVoltage_V?: number | null;
power_W?: number | null;
voltage_V?: number | null;
total_last_telemetry_energy_WH?: number | null;
[property: string]: unknown;
};
export type SolarEdgeOptimizerInformation = {
basicInformationList: SolarEdgeOptimizerBasicInformation[];
serialToLiveData: Record<
SolarEdgeOptimizerSerial,
SolarEdgeOptimizerLiveData
>;
};
export type SolarEdgeOptimizerEnergyBar = {
measurementTime: string;
energy: number | null;
};
export type SolarEdgeOptimizerEnergy = {
totalEnergy: number;
energyBars: SolarEdgeOptimizerEnergyBar[];
};
export type SolarEdgeOptimizerChartTimeUnit = "hours" | "days" | string;
export type GetSolarEdgeOptimizerEnergyOptions = {
siteId?: SolarEdgeSiteId;
startDate: string;
endDate: string;
optimizerSerials: SolarEdgeOptimizerSerial[];
chartTimeUnit?: SolarEdgeOptimizerChartTimeUnit;
};
export type SolarEdgeLogicalLayout = Record<string, unknown>;
+287
View File
@@ -0,0 +1,287 @@
import { describe, expect, it } from "bun:test";
import {
SolarEdgePortalAuthError,
SolarEdgePortalClient,
createSolarEdgePortalClientFromEnv,
} from "./index";
import type { SolarEdgeFetch } from "./index";
type RequestRecord = {
url: URL;
init?: RequestInit;
};
function createAuthenticatedPortalFetch(
requests: RequestRecord[],
): SolarEdgeFetch {
return async (input, init) => {
const url = new URL(String(input));
requests.push({ url, init });
if (url.origin === "https://login.test" && init?.method !== "POST") {
return new Response(
'<form><input value="csrf-value" name="csrf" type="hidden"></form>',
{
headers: {
"Content-Type": "text/html",
"Set-Cookie": "cognito=login-session; Path=/; Secure; HttpOnly",
},
},
);
}
if (url.origin === "https://login.test" && url.pathname === "/login") {
const form = new URLSearchParams(String(init?.body));
expect(form.get("csrf")).toBe("csrf-value");
expect(form.get("username")).toBe("owner@example.com");
expect(form.get("password")).toBe("portal-secret");
expect(new Headers(init?.headers).get("cookie")).toContain(
"cognito=login-session",
);
return new Response(null, {
status: 302,
headers: {
Location: "https://portal.test/mfe/auth/callback?code=auth-code",
},
});
}
if (
url.origin === "https://login.test" &&
url.pathname === "/oauth2/token"
) {
const form = new URLSearchParams(String(init?.body));
expect(form.get("grant_type")).toBe("authorization_code");
expect(form.get("code")).toBe("auth-code");
expect(form.get("code_verifier")?.length).toBeGreaterThanOrEqual(43);
return Response.json({
access_token: "access-token",
id_token: "id-token",
refresh_token: "refresh-token",
expires_in: 3600,
token_type: "Bearer",
});
}
if (url.pathname === "/services/auth/token") {
const payload = JSON.parse(String(init?.body)) as Record<string, unknown>;
expect(payload.access_token).toBe("access-token");
return new Response(null, {
status: 200,
headers: {
"Set-Cookie": "portal=session-cookie; Path=/; Secure; HttpOnly",
},
});
}
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer access-token");
expect(headers.get("cookie")).toContain("portal=session-cookie");
if (url.pathname.endsWith("/optimizers")) {
return Response.json({
totalEnergy: 123,
energyBars: [
{ measurementTime: "2026-08-09 12:00:00", energy: 12 },
],
});
}
return Response.json({
siteStructure: {
children: [
{
type: "INVERTER",
serial: "INV-1",
order: 1,
displayOrder: "1",
children: [
{
type: "STRING",
order: 1,
displayOrder: "1.1",
children: [
{
type: "OPTIMIZER",
serial: "OPT-1",
order: 1,
displayOrder: "1.1.1",
},
{
type: "OPTIMIZER",
serial: "OPT-2",
order: 2,
displayOrder: "1.1.2",
},
],
},
],
},
],
},
});
};
}
describe("SolarEdgePortalClient", () => {
it("logs in headlessly and calls authenticated optimizer endpoints", async () => {
const requests: RequestRecord[] = [];
const client = new SolarEdgePortalClient({
username: "owner@example.com",
password: "portal-secret",
siteId: "42",
loginBaseUrl: "https://login.test",
portalBaseUrl: "https://portal.test",
fetch: createAuthenticatedPortalFetch(requests),
});
await expect(client.listOptimizerSerials()).resolves.toEqual([
"OPT-1",
"OPT-2",
]);
await expect(client.listOptimizerMappings()).resolves.toEqual([
{
serial: "OPT-1",
inverterId: "1",
stringId: "1.1",
optimizerId: "1.1.1",
inverterOrder: 1,
stringOrder: 1,
optimizerOrder: 1,
},
{
serial: "OPT-2",
inverterId: "1",
stringId: "1.1",
optimizerId: "1.1.2",
inverterOrder: 1,
stringOrder: 1,
optimizerOrder: 2,
},
]);
await expect(
client.getOptimizerEnergy({
startDate: "2026-08-09",
endDate: "2026-08-09",
optimizerSerials: ["OPT-1", "OPT-1", "OPT-2"],
}),
).resolves.toEqual({
totalEnergy: 123,
energyBars: [
{ measurementTime: "2026-08-09 12:00:00", energy: 12 },
],
});
const energyRequest = requests.find((request) =>
request.url.pathname.endsWith("/optimizers"),
);
expect(energyRequest?.url.searchParams.get("optimizer-serials")).toBe(
"OPT-1,OPT-2",
);
expect(energyRequest?.url.searchParams.get("chart-time-unit")).toBe(
"hours",
);
expect(requests.filter((request) => request.url.pathname === "/login"))
.toHaveLength(2);
});
it("posts optimizer serials as JSON", async () => {
const requests: RequestRecord[] = [];
const client = new SolarEdgePortalClient({
username: "owner@example.com",
password: "portal-secret",
siteId: "42",
loginBaseUrl: "https://login.test",
portalBaseUrl: "https://portal.test",
fetch: createAuthenticatedPortalFetch(requests),
});
await client.getOptimizerInformation([" OPT-1 ", "OPT-2"]);
const request = requests.find(
(entry) => entry.url.pathname === "/services/layout/information/optimizers",
);
expect(request?.init?.method).toBe("POST");
expect(request?.init?.body).toBe('["OPT-1","OPT-2"]');
});
it("loads portal credentials from server-side environment variables", () => {
expect(() =>
createSolarEdgePortalClientFromEnv({
env: {
SOLAREDGE_SITE_ID: "42",
SOLAREDGE_USERNAME: "owner@example.com",
SOLAREDGE_PASSWORD: "portal-secret",
},
}),
).not.toThrow();
expect(() =>
createSolarEdgePortalClientFromEnv({
env: {
SOLAREDGE_SITE_ID: "42",
SOLAREDGE_USERNAME: "owner@example.com",
},
}),
).toThrow("Missing SOLAREDGE_PASSWORD");
});
it("reports unsupported MFA challenges without exposing credentials", async () => {
const client = new SolarEdgePortalClient({
username: "owner@example.com",
password: "do-not-expose",
loginBaseUrl: "https://login.test",
portalBaseUrl: "https://portal.test",
fetch: async (input, init) => {
const url = new URL(String(input));
if (init?.method !== "POST") {
return new Response('<input name="csrf" value="csrf-value">');
}
if (url.pathname === "/login") {
return new Response('<input name="code" value="">');
}
throw new Error("Unexpected request");
},
});
try {
await client.authenticate();
throw new Error("Expected authentication to fail");
} catch (error) {
expect(error).toBeInstanceOf(SolarEdgePortalAuthError);
expect(error).toMatchObject({ code: "AUTH_CHALLENGE_REQUIRED" });
expect(String(error)).not.toContain("do-not-expose");
}
});
it("rejects empty optimizer serial lists before making a request", async () => {
const client = new SolarEdgePortalClient({
username: "owner@example.com",
password: "portal-secret",
});
await expect(client.getOptimizerInformation([])).rejects.toThrow(
"At least one SolarEdge optimizer serial",
);
});
it("does not send bearer credentials to absolute URLs", async () => {
let requestCount = 0;
const client = new SolarEdgePortalClient({
username: "owner@example.com",
password: "portal-secret",
fetch: async () => {
requestCount += 1;
return Response.json({});
},
});
await expect(client.get("https://example.com/collect")).rejects.toThrow(
"paths must be relative",
);
expect(requestCount).toBe(0);
});
});
+823
View File
@@ -0,0 +1,823 @@
import { CookieJar } from "tough-cookie";
import type {
GetSolarEdgeOptimizerEnergyOptions,
SolarEdgeLogicalLayout,
SolarEdgeOptimizerEnergy,
SolarEdgeOptimizerInformation,
SolarEdgeOptimizerMapping,
SolarEdgeOptimizerSerial,
} from "./portal-types";
import type { SolarEdgeSiteId } from "./types";
import type { SolarEdgeFetch, SolarEdgeRequestOptions } from "./client";
export const SOLAREDGE_PORTAL_BASE_URL = "https://monitoring.solaredge.com";
export const SOLAREDGE_LOGIN_BASE_URL = "https://login.solaredge.com";
export const SOLAREDGE_PORTAL_USERNAME_ENV_NAME = "SOLAREDGE_USERNAME";
export const SOLAREDGE_PORTAL_PASSWORD_ENV_NAME = "SOLAREDGE_PASSWORD";
const SOLAREDGE_PORTAL_CLIENT_ID = "ugfnsujd3384sshcjehaphlh3";
const SOLAREDGE_PORTAL_REDIRECT_PATH = "/mfe/auth/callback";
const DEFAULT_TOKEN_EXPIRY_MARGIN_MS = 30_000;
const MAX_REDIRECTS = 10;
export type SolarEdgePortalClientOptions = {
username: string;
password: string;
siteId?: SolarEdgeSiteId;
portalBaseUrl?: string;
loginBaseUrl?: string;
fetch?: SolarEdgeFetch;
cookieJar?: CookieJar;
};
export type SolarEdgePortalClientFromEnvOptions = Omit<
SolarEdgePortalClientOptions,
"username" | "password" | "siteId"
> & {
env?: Record<string, string | undefined>;
};
export type SolarEdgePortalAuthErrorCode =
| "AUTH_FAILED"
| "AUTH_CHALLENGE_REQUIRED"
| "LOGIN_PAGE_FAILED"
| "SESSION_CREATION_FAILED"
| "TOKEN_EXCHANGE_FAILED";
export class SolarEdgePortalAuthError extends Error {
readonly status: number;
readonly code: SolarEdgePortalAuthErrorCode;
constructor(
message: string,
options: {
status?: number;
code: SolarEdgePortalAuthErrorCode;
cause?: unknown;
},
) {
super(message, { cause: options.cause });
this.name = "SolarEdgePortalAuthError";
this.status = options.status ?? 0;
this.code = options.code;
}
}
export class SolarEdgePortalApiError extends Error {
readonly status: number;
constructor(
message: string,
options: { status: number; cause?: unknown },
) {
super(message, { cause: options.cause });
this.name = "SolarEdgePortalApiError";
this.status = options.status;
}
}
type OAuthTokenResponse = {
access_token: string;
expires_in: number;
id_token?: string;
refresh_token?: string;
token_type?: string;
};
type PortalRefreshResponse = {
accessToken?: {
token?: string;
expiresIn?: number;
};
};
type CookieRequestResult = {
response: Response;
url: URL;
};
export class SolarEdgePortalClient {
private readonly username: string;
private readonly password: string;
private readonly defaultSiteId?: SolarEdgeSiteId;
private readonly portalBaseUrl: string;
private readonly loginBaseUrl: string;
private readonly fetchImplementation: SolarEdgeFetch;
private readonly cookieJar: CookieJar;
private accessToken?: string;
private accessTokenExpiresAt = 0;
private authenticationPromise?: Promise<void>;
constructor({
username,
password,
siteId,
portalBaseUrl = SOLAREDGE_PORTAL_BASE_URL,
loginBaseUrl = SOLAREDGE_LOGIN_BASE_URL,
fetch: fetchImplementation = globalThis.fetch,
cookieJar = new CookieJar(),
}: SolarEdgePortalClientOptions) {
if (!username.trim()) {
throw new Error("SolarEdge portal username must not be empty");
}
if (!password) {
throw new Error("SolarEdge portal password must not be empty");
}
this.username = username;
this.password = password;
this.defaultSiteId = siteId;
this.portalBaseUrl = portalBaseUrl.replace(/\/+$/, "");
this.loginBaseUrl = loginBaseUrl.replace(/\/+$/, "");
this.fetchImplementation = fetchImplementation;
this.cookieJar = cookieJar;
}
async authenticate(): Promise<void> {
await this.runSingleAuthentication(async () => {
await this.login();
});
}
async getLogicalLayout(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeLogicalLayout> {
return this.get<SolarEdgeLogicalLayout>(
`/services/layout/logical/generic/v2/site/${this.resolveSiteId(siteId)}`,
{ "include-optimizers": true },
requestOptions,
);
}
async listOptimizerSerials(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeOptimizerSerial[]> {
const mappings = await this.listOptimizerMappings(siteId, requestOptions);
return mappings.map((mapping) => mapping.serial);
}
async listOptimizerMappings(
siteId?: SolarEdgeSiteId,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeOptimizerMapping[]> {
const layout = await this.getLogicalLayout(siteId, requestOptions);
return collectOptimizerMappings(layout);
}
async getOptimizerInformation(
optimizerSerials: SolarEdgeOptimizerSerial[],
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeOptimizerInformation> {
const serials = normalizeOptimizerSerials(optimizerSerials);
return this.requestJson<SolarEdgeOptimizerInformation>(
"/services/layout/information/optimizers",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(serials),
signal: requestOptions?.signal,
},
);
}
async getOptimizerEnergy(
{
siteId,
startDate,
endDate,
optimizerSerials,
chartTimeUnit = "hours",
}: GetSolarEdgeOptimizerEnergyOptions,
requestOptions?: SolarEdgeRequestOptions,
): Promise<SolarEdgeOptimizerEnergy> {
const serials = normalizeOptimizerSerials(optimizerSerials);
return this.get<SolarEdgeOptimizerEnergy>(
`/services/layout/energy-graph/site/${this.resolveSiteId(siteId)}/optimizers`,
{
"chart-time-unit": chartTimeUnit,
"start-date": requireValue(startDate, "startDate"),
"end-date": requireValue(endDate, "endDate"),
"optimizer-serials": serials.join(","),
},
requestOptions,
);
}
async get<TResponse>(
path: string,
query: Record<string, string | number | boolean | undefined> = {},
requestOptions: SolarEdgeRequestOptions = {},
): Promise<TResponse> {
const url = this.buildPortalUrl(path);
for (const [name, value] of Object.entries(query)) {
if (value !== undefined) {
url.searchParams.set(name, String(value));
}
}
return this.requestJson<TResponse>(url, {
headers: { Accept: "application/json" },
signal: requestOptions.signal,
});
}
private async requestJson<TResponse>(
input: string | URL,
init: RequestInit,
retryAfterAuthentication = true,
): Promise<TResponse> {
const requestUrl =
input instanceof URL
? input
: this.buildPortalUrl(input);
if (requestUrl.origin !== new URL(this.portalBaseUrl).origin) {
throw new SolarEdgePortalApiError(
"SolarEdge portal requests must use the configured portal origin",
{ status: 0 },
);
}
await this.ensureAuthenticated();
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
headers.set("Authorization", `Bearer ${this.accessToken}`);
let result: CookieRequestResult;
try {
result = await this.requestWithCookies(requestUrl, { ...init, headers });
} catch (error) {
throw new SolarEdgePortalApiError("SolarEdge portal request failed", {
status: 0,
cause: error,
});
}
if (result.response.status === 401 && retryAfterAuthentication) {
await this.reauthenticate();
return this.requestJson<TResponse>(requestUrl, init, false);
}
if (!result.response.ok) {
throw new SolarEdgePortalApiError(
`SolarEdge portal request failed (${result.response.status})`,
{ status: result.response.status },
);
}
try {
return (await result.response.json()) as TResponse;
} catch (error) {
throw new SolarEdgePortalApiError(
"SolarEdge portal returned invalid JSON",
{ status: result.response.status, cause: error },
);
}
}
private async ensureAuthenticated(): Promise<void> {
if (
this.accessToken &&
Date.now() < this.accessTokenExpiresAt - DEFAULT_TOKEN_EXPIRY_MARGIN_MS
) {
return;
}
await this.runSingleAuthentication(async () => {
if (this.accessToken && (await this.refreshSession())) {
return;
}
await this.login();
});
}
private async reauthenticate(): Promise<void> {
this.accessTokenExpiresAt = 0;
await this.runSingleAuthentication(async () => {
if (await this.refreshSession()) {
return;
}
await this.login();
});
}
private async runSingleAuthentication(action: () => Promise<void>): Promise<void> {
if (!this.authenticationPromise) {
this.authenticationPromise = action().finally(() => {
this.authenticationPromise = undefined;
});
}
await this.authenticationPromise;
}
private async login(): Promise<void> {
const codeVerifier = createCodeVerifier();
const codeChallenge = await createCodeChallenge(codeVerifier);
const redirectUri = `${this.portalBaseUrl}${SOLAREDGE_PORTAL_REDIRECT_PATH}`;
const loginUrl = new URL("/login", `${this.loginBaseUrl}/`);
loginUrl.search = new URLSearchParams({
lang: "en",
response_type: "code",
client_id: SOLAREDGE_PORTAL_CLIENT_ID,
scope: "email openid",
redirect_uri: redirectUri,
code_challenge_method: "S256",
code_challenge: codeChallenge,
}).toString();
let loginPage: CookieRequestResult;
try {
loginPage = await this.requestWithCookies(loginUrl, {
headers: { Accept: "text/html" },
});
} catch (error) {
throw new SolarEdgePortalAuthError(
"Could not load the SolarEdge login page",
{ code: "LOGIN_PAGE_FAILED", cause: error },
);
}
if (!loginPage.response.ok) {
throw new SolarEdgePortalAuthError(
`Could not load the SolarEdge login page (${loginPage.response.status})`,
{
code: "LOGIN_PAGE_FAILED",
status: loginPage.response.status,
},
);
}
const csrf = extractHiddenInput(await loginPage.response.text(), "csrf");
if (!csrf) {
throw new SolarEdgePortalAuthError(
"SolarEdge login page did not contain a CSRF token",
{ code: "LOGIN_PAGE_FAILED" },
);
}
const loginForm = new URLSearchParams({
csrf,
username: this.username,
password: this.password,
cognitoAsfData: "",
});
let loginResult: CookieRequestResult;
try {
loginResult = await this.requestWithCookies(
loginUrl,
{
method: "POST",
headers: {
Accept: "text/html",
"Content-Type": "application/x-www-form-urlencoded",
Origin: this.loginBaseUrl,
Referer: loginUrl.toString(),
},
body: loginForm.toString(),
},
(nextUrl) =>
nextUrl.origin === new URL(this.portalBaseUrl).origin &&
nextUrl.pathname === SOLAREDGE_PORTAL_REDIRECT_PATH,
);
} catch (error) {
throw new SolarEdgePortalAuthError("SolarEdge login request failed", {
code: "AUTH_FAILED",
cause: error,
});
}
const authorizationCode = loginResult.url.searchParams.get("code");
if (!authorizationCode) {
const body = await loginResult.response.text().catch(() => "");
const challengeRequired =
/name=["']code["']|mfa|verify(?:Email|Sms|Totp|Password)/i.test(body);
throw new SolarEdgePortalAuthError(
challengeRequired
? "SolarEdge requires an additional authentication challenge"
: "SolarEdge portal authentication failed",
{
code: challengeRequired
? "AUTH_CHALLENGE_REQUIRED"
: "AUTH_FAILED",
status: loginResult.response.status,
},
);
}
const tokens = await this.exchangeAuthorizationCode(
authorizationCode,
codeVerifier,
redirectUri,
);
await this.createPortalSession(tokens);
this.setAccessToken(tokens.access_token, tokens.expires_in);
}
private async exchangeAuthorizationCode(
authorizationCode: string,
codeVerifier: string,
redirectUri: string,
): Promise<OAuthTokenResponse> {
const tokenUrl = new URL("/oauth2/token", `${this.loginBaseUrl}/`);
let result: CookieRequestResult;
try {
result = await this.requestWithCookies(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: SOLAREDGE_PORTAL_CLIENT_ID,
redirect_uri: redirectUri,
code: authorizationCode,
code_verifier: codeVerifier,
}).toString(),
});
} catch (error) {
throw new SolarEdgePortalAuthError(
"SolarEdge token exchange request failed",
{ code: "TOKEN_EXCHANGE_FAILED", cause: error },
);
}
if (!result.response.ok) {
throw new SolarEdgePortalAuthError(
`SolarEdge token exchange failed (${result.response.status})`,
{
code: "TOKEN_EXCHANGE_FAILED",
status: result.response.status,
},
);
}
const tokens = await parseJson<OAuthTokenResponse>(result.response);
if (
!tokens?.access_token ||
!Number.isFinite(tokens.expires_in) ||
tokens.expires_in <= 0
) {
throw new SolarEdgePortalAuthError(
"SolarEdge token exchange returned an invalid response",
{ code: "TOKEN_EXCHANGE_FAILED", status: result.response.status },
);
}
return tokens;
}
private async createPortalSession(tokens: OAuthTokenResponse): Promise<void> {
const sessionUrl = new URL(
"/services/auth/token?legacy=false",
`${this.portalBaseUrl}/`,
);
const result = await this.requestWithCookies(sessionUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id_token: tokens.id_token,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_in: tokens.expires_in,
token_type: tokens.token_type,
}),
});
if (!result.response.ok) {
throw new SolarEdgePortalAuthError(
`SolarEdge portal session creation failed (${result.response.status})`,
{
code: "SESSION_CREATION_FAILED",
status: result.response.status,
},
);
}
}
private async refreshSession(): Promise<boolean> {
const refreshUrl = new URL(
"/services/auth/refresh",
`${this.portalBaseUrl}/`,
);
try {
const result = await this.requestWithCookies(refreshUrl, {
method: "POST",
headers: { Accept: "application/json" },
});
if (!result.response.ok) {
return false;
}
const payload = await parseJson<PortalRefreshResponse>(result.response);
const token = payload?.accessToken?.token;
const expiresIn = payload?.accessToken?.expiresIn;
if (!token || !expiresIn || !Number.isFinite(expiresIn)) {
return false;
}
this.setAccessToken(token, expiresIn);
return true;
} catch {
return false;
}
}
private setAccessToken(token: string, expiresInSeconds: number): void {
this.accessToken = token;
this.accessTokenExpiresAt = Date.now() + expiresInSeconds * 1_000;
}
private async requestWithCookies(
input: string | URL,
init: RequestInit = {},
stopBeforeRedirect?: (nextUrl: URL) => boolean,
): Promise<CookieRequestResult> {
let currentUrl = new URL(String(input));
let method = (init.method ?? "GET").toUpperCase();
let body = init.body;
let headers = new Headers(init.headers);
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
const cookie = await this.cookieJar.getCookieString(currentUrl.toString());
if (cookie) {
headers.set("Cookie", cookie);
} else {
headers.delete("Cookie");
}
const response = await this.fetchImplementation(currentUrl, {
...init,
method,
body,
headers,
redirect: "manual",
});
await this.storeResponseCookies(response, currentUrl);
const location = response.headers.get("location");
if (!location || !isRedirectStatus(response.status)) {
return { response, url: currentUrl };
}
const nextUrl = new URL(location, currentUrl);
if (stopBeforeRedirect?.(nextUrl)) {
return { response, url: nextUrl };
}
if (redirectCount === MAX_REDIRECTS) {
throw new Error("SolarEdge request exceeded the redirect limit");
}
if (
response.status === 303 ||
((response.status === 301 || response.status === 302) &&
method !== "GET" &&
method !== "HEAD")
) {
method = "GET";
body = undefined;
headers = new Headers(headers);
headers.delete("Content-Type");
headers.delete("Content-Length");
}
currentUrl = nextUrl;
}
throw new Error("SolarEdge request exceeded the redirect limit");
}
private async storeResponseCookies(
response: Response,
requestUrl: URL,
): Promise<void> {
const headers = response.headers as Headers & {
getSetCookie?: () => string[];
};
const setCookies = headers.getSetCookie?.() ?? [];
if (setCookies.length === 0) {
const setCookie = headers.get("set-cookie");
if (setCookie) {
setCookies.push(setCookie);
}
}
for (const setCookie of setCookies) {
await this.cookieJar.setCookie(setCookie, requestUrl.toString());
}
}
private resolveSiteId(siteId?: SolarEdgeSiteId): string {
const resolvedSiteId = siteId ?? this.defaultSiteId;
if (resolvedSiteId === undefined) {
throw new Error(
"Missing SolarEdge siteId; pass it to the request or configure SOLAREDGE_SITE_ID",
);
}
const normalized = String(resolvedSiteId).trim();
if (!normalized) {
throw new Error("SolarEdge siteId must not be empty");
}
return encodeURIComponent(normalized);
}
private buildPortalUrl(path: string): URL {
if (/^[a-z][a-z\d+.-]*:/i.test(path)) {
throw new SolarEdgePortalApiError(
"SolarEdge portal request paths must be relative",
{ status: 0 },
);
}
return new URL(path.replace(/^\/+/, ""), `${this.portalBaseUrl}/`);
}
}
export function createSolarEdgePortalClientFromEnv({
env = process.env,
...options
}: SolarEdgePortalClientFromEnvOptions = {}): SolarEdgePortalClient {
const username = env[SOLAREDGE_PORTAL_USERNAME_ENV_NAME];
if (!username) {
throw new Error(`Missing ${SOLAREDGE_PORTAL_USERNAME_ENV_NAME}`);
}
const password = env[SOLAREDGE_PORTAL_PASSWORD_ENV_NAME];
if (!password) {
throw new Error(`Missing ${SOLAREDGE_PORTAL_PASSWORD_ENV_NAME}`);
}
const siteId = env.SOLAREDGE_SITE_ID;
if (!siteId) {
throw new Error("Missing SOLAREDGE_SITE_ID");
}
return new SolarEdgePortalClient({
username,
password,
siteId,
...options,
});
}
function createCodeVerifier(): string {
const alphabet =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
const random = crypto.getRandomValues(new Uint8Array(64));
let verifier = "";
for (const value of random) {
verifier += alphabet[value % alphabet.length];
}
return verifier;
}
async function createCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return bytesToBase64Url(new Uint8Array(digest));
}
function bytesToBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function extractHiddenInput(html: string, name: string): string | undefined {
const inputTags = html.match(/<input\b[^>]*>/gi) ?? [];
for (const inputTag of inputTags) {
const inputName = extractHtmlAttribute(inputTag, "name");
if (inputName === name) {
return extractHtmlAttribute(inputTag, "value");
}
}
return undefined;
}
function extractHtmlAttribute(tag: string, name: string): string | undefined {
const pattern = new RegExp(`${name}\\s*=\\s*(["'])(.*?)\\1`, "i");
const value = tag.match(pattern)?.[2];
return value === undefined ? undefined : decodeHtmlAttribute(value);
}
function decodeHtmlAttribute(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}
function normalizeOptimizerSerials(
optimizerSerials: SolarEdgeOptimizerSerial[],
): SolarEdgeOptimizerSerial[] {
const serials = [...new Set(optimizerSerials.map((serial) => serial.trim()))]
.filter(Boolean);
if (serials.length === 0) {
throw new Error("At least one SolarEdge optimizer serial is required");
}
return serials;
}
function collectOptimizerMappings(value: unknown): SolarEdgeOptimizerMapping[] {
const mappings: SolarEdgeOptimizerMapping[] = [];
const seenSerials = new Set<SolarEdgeOptimizerSerial>();
type LayoutPosition = {
displayOrder?: string;
order?: number;
};
type LayoutContext = {
inverter?: LayoutPosition;
string?: LayoutPosition;
};
const visit = (node: unknown, context: LayoutContext): void => {
if (!node || typeof node !== "object") {
return;
}
if (Array.isArray(node)) {
for (const child of node) {
visit(child, context);
}
return;
}
const record = node as Record<string, unknown>;
const position: LayoutPosition = {
displayOrder:
typeof record.displayOrder === "string"
? record.displayOrder
: undefined,
order: typeof record.order === "number" ? record.order : undefined,
};
const nextContext = { ...context };
if (record.type === "INVERTER") {
nextContext.inverter = position;
} else if (record.type === "STRING") {
nextContext.string = position;
} else if (
record.type === "OPTIMIZER" &&
typeof record.serial === "string"
) {
const serial = record.serial.trim();
if (serial && !seenSerials.has(serial)) {
seenSerials.add(serial);
mappings.push({
serial,
inverterId: nextContext.inverter?.displayOrder,
stringId: nextContext.string?.displayOrder,
optimizerId: position.displayOrder,
inverterOrder: nextContext.inverter?.order,
stringOrder: nextContext.string?.order,
optimizerOrder: position.order,
});
}
}
if (Array.isArray(record.children)) {
visit(record.children, nextContext);
return;
}
if (record.type === undefined) {
for (const child of Object.values(record)) {
visit(child, nextContext);
}
}
};
visit(value, {});
return mappings;
}
function requireValue(value: string, name: string): string {
if (!value.trim()) {
throw new Error(`SolarEdge ${name} must not be empty`);
}
return value;
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
async function parseJson<T>(response: Response): Promise<T | undefined> {
try {
return (await response.json()) as T;
} catch {
return undefined;
}
}
+225
View File
@@ -0,0 +1,225 @@
export type SolarEdgeSiteId = number | string;
export type SolarEdgeSiteStatus =
| "Active"
| "Pending"
| "Disabled"
| string;
export type SolarEdgeLocation = {
country?: string;
state?: string;
city?: string;
address?: string;
address2?: string;
zip?: string;
timeZone?: string;
countryCode?: string;
latitude?: string | number;
longitude?: string | number;
};
export type SolarEdgePrimaryModule = {
manufacturerName?: string;
modelName?: string;
maximumPower?: number;
temperatureCoef?: number;
};
export type SolarEdgeSiteUris = {
DETAILS?: string;
DATA_PERIOD?: string;
OVERVIEW?: string;
[name: string]: string | undefined;
};
export type SolarEdgeSite = {
id: number;
accountId?: number;
name: string;
status: SolarEdgeSiteStatus;
peakPower?: number;
currency?: string;
installationDate?: string;
ptoDate?: string;
lastUpdateTime?: string;
type?: string;
notes?: string;
alertQuantity?: number;
highestImpact?: number | string;
alertSeverity?: number | string;
location?: SolarEdgeLocation;
primaryModule?: SolarEdgePrimaryModule;
publicSettings?: {
name?: string;
isPublic?: boolean | null;
};
uris?: SolarEdgeSiteUris;
};
export type SolarEdgeSites = {
count: number;
site: SolarEdgeSite[];
};
export type SolarEdgeDataPeriod = {
startDate: string | null;
endDate: string | null;
};
export type SolarEdgeEnergySummary = {
energy: number;
revenue?: number;
};
export type SolarEdgeOverview = {
lastUpdateTime: string;
lifeTimeData: SolarEdgeEnergySummary;
lastYearData: SolarEdgeEnergySummary;
lastMonthData: SolarEdgeEnergySummary;
lastDayData: SolarEdgeEnergySummary;
currentPower: {
power: number;
};
measuredBy?: string;
};
export type SolarEdgeTimeUnit =
| "QUARTER_OF_AN_HOUR"
| "HOUR"
| "DAY"
| "WEEK"
| "MONTH"
| "YEAR";
export type SolarEdgeTimeSeriesValue = {
date: string;
value?: number | null;
};
export type SolarEdgeTimeSeries = {
timeUnit: SolarEdgeTimeUnit | string;
unit: string;
values: SolarEdgeTimeSeriesValue[];
};
export type SolarEdgeMeterType =
| "PRODUCTION"
| "CONSUMPTION"
| "SELFCONSUMPTION"
| "FEEDIN"
| "PURCHASED";
export type SolarEdgeMeterSeries = {
type: string;
values: SolarEdgeTimeSeriesValue[];
};
export type SolarEdgeMeterTimeSeries = {
timeUnit: SolarEdgeTimeUnit | string;
unit: string;
meters: SolarEdgeMeterSeries[];
};
export type SolarEdgePowerFlowComponent = {
status: string;
currentPower: number;
};
export type SolarEdgeStoragePowerFlowComponent = SolarEdgePowerFlowComponent & {
chargeLevel?: number;
critical?: boolean;
timeLeft?: number;
};
export type SolarEdgePowerFlowConnection = {
from: string;
to: string;
};
export type SolarEdgeCurrentPowerFlow = {
updateRefreshRate?: number;
unit?: string;
connections?: SolarEdgePowerFlowConnection[];
GRID?: SolarEdgePowerFlowComponent;
LOAD?: SolarEdgePowerFlowComponent;
PV?: SolarEdgePowerFlowComponent;
STORAGE?: SolarEdgeStoragePowerFlowComponent;
};
export type SolarEdgeInventoryDevice = {
name?: string;
manufacturer?: string;
model?: string;
SN?: string;
[property: string]: unknown;
};
export type SolarEdgeInventory = {
inverters?: SolarEdgeInventoryDevice[];
meters?: SolarEdgeInventoryDevice[];
sensors?: SolarEdgeInventoryDevice[];
gateways?: SolarEdgeInventoryDevice[];
batteries?: SolarEdgeInventoryDevice[];
[deviceType: string]: SolarEdgeInventoryDevice[] | undefined;
};
export type SolarEdgeEquipment = {
name?: string;
manufacturer?: string;
model?: string;
serialNumber: string;
};
export type SolarEdgeEquipmentList = {
count: number;
list: SolarEdgeEquipment[];
};
export type SolarEdgeSiteSortProperty =
| "name"
| "country"
| "state"
| "city"
| "address"
| "zip"
| "status"
| "peakPower"
| "installationDate"
| "amount"
| "maxSeverity";
export type ListSolarEdgeSitesOptions = {
size?: number;
startIndex?: number;
searchText?: string;
sortProperty?: SolarEdgeSiteSortProperty;
sortOrder?: "ASC" | "DESC";
status?: "Active" | "Pending" | "Disabled" | "All";
};
export type GetSolarEdgeEnergyOptions = {
siteId?: SolarEdgeSiteId;
startDate: string;
endDate: string;
timeUnit?: SolarEdgeTimeUnit;
};
export type GetSolarEdgePowerOptions = {
siteId?: SolarEdgeSiteId;
startTime: string;
endTime: string;
};
export type GetSolarEdgeMeterSeriesOptions = {
siteId?: SolarEdgeSiteId;
startTime: string;
endTime: string;
timeUnit?: SolarEdgeTimeUnit;
meters?: SolarEdgeMeterType[];
};
export type GetSolarEdgePowerDetailsOptions = Omit<
GetSolarEdgeMeterSeriesOptions,
"timeUnit"
>;
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": [
"bun-types"
]
},
"include": [
"src/**/*.ts"
]
}
+3
View File
@@ -0,0 +1,3 @@
name: SolarEdge Optimizer Apps
url: https://git.jensneuber.de/jens/solaredge-optimizers
maintainer: Jens Neuber
+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"]
}