Initial SolarEdge Optimizer Home Assistant App
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"bun-types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
name: SolarEdge Optimizer Apps
|
||||
url: https://git.jensneuber.de/jens/solaredge-optimizers
|
||||
maintainer: Jens Neuber
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!dist/server.js
|
||||
!run.sh
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial Home Assistant App package.
|
||||
- Cached optimizer daily energy and current power API.
|
||||
- Headless SolarEdge portal authentication.
|
||||
- Ten-minute background refresh with stale-data fallback.
|
||||
@@ -0,0 +1,132 @@
|
||||
# SolarEdge Optimizer Data App
|
||||
|
||||
Diese Home Assistant App stellt die Optimierer-Daten einer SolarEdge-Anlage als
|
||||
lokale, gecachte HTTP-API bereit. Sie läuft unabhängig von einem Browser und
|
||||
aktualisiert die Daten standardmäßig alle zehn Minuten.
|
||||
|
||||
> Die verwendeten Optimierer-Endpunkte sind interne, nicht offiziell
|
||||
> dokumentierte SolarEdge-Portal-Endpunkte. SolarEdge kann sie jederzeit ändern.
|
||||
> Verwende die App nur für Anlagen, auf die du zugreifen darfst.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
```yaml
|
||||
username: owner@example.com
|
||||
password: dein-portal-passwort
|
||||
site_id: "4886699"
|
||||
poll_interval_minutes: 10
|
||||
```
|
||||
|
||||
- `username`: Benutzername des SolarEdge Monitoring Portals.
|
||||
- `password`: Portal-Passwort; wird weder geloggt noch von der API ausgegeben.
|
||||
- `site_id`: numerische SolarEdge Site-ID.
|
||||
- `poll_interval_minutes`: Aktualisierungsintervall, mindestens zehn Minuten.
|
||||
|
||||
Die Zeitzone liest die App automatisch aus Home Assistant über die
|
||||
Supervisor-API. Dadurch wird der Tageswechsel passend zur Home-Assistant-Zeit
|
||||
berechnet.
|
||||
|
||||
## HTTP API
|
||||
|
||||
### `GET /api/optimizers`
|
||||
|
||||
Liefert den letzten gecachten Datensatz:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"siteId": "4886699",
|
||||
"date": "2026-08-10",
|
||||
"timeZone": "Europe/Berlin",
|
||||
"fetchedAt": "2026-08-10T08:00:00.000Z",
|
||||
"nextRefreshAt": "2026-08-10T08:10:00.000Z",
|
||||
"optimizerCount": 34,
|
||||
"successfulOptimizerCount": 34,
|
||||
"failedOptimizerCount": 0,
|
||||
"totalDailyEnergyWh": 12345,
|
||||
"totalCurrentPowerW": 4567,
|
||||
"optimizers": [
|
||||
{
|
||||
"serial": "OPTIMIZER_SERIAL",
|
||||
"inverterId": "1",
|
||||
"stringId": "1.1",
|
||||
"optimizerId": "1.1.1",
|
||||
"dailyEnergyWh": 321.5,
|
||||
"currentPowerW": 123.4,
|
||||
"lastMeasurement": "2026-08-10T07:59:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status` ist:
|
||||
|
||||
- `ok`: alle Tagesenergie-Werte wurden gelesen.
|
||||
- `partial`: einzelne Optimierer-Abfragen sind fehlgeschlagen.
|
||||
- `stale`: der letzte vollständige Abruf ist fehlgeschlagen; die vorherigen
|
||||
Daten bleiben verfügbar.
|
||||
|
||||
Der API-Aufruf selbst löst keinen SolarEdge-Abruf aus. Er liefert immer den
|
||||
Cache und kann daher auch häufiger als alle zehn Minuten gelesen werden.
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Kompakter Status für Überwachung und Docker Healthchecks. Während des ersten
|
||||
Abrufs antworten `/health` und `/api/optimizers` mit HTTP 503. Danach liefern sie
|
||||
HTTP 200, solange mindestens ein gecachter Datensatz vorhanden ist.
|
||||
|
||||
### `GET /`
|
||||
|
||||
Liefert dieselben Daten wie `/api/optimizers` und ist über Home Assistant Ingress
|
||||
im App-Panel erreichbar.
|
||||
|
||||
## Zugriff aus Home Assistant
|
||||
|
||||
Die App veröffentlicht standardmäßig keinen ungeschützten Port im lokalen
|
||||
Netz. Home Assistant und andere Apps können sie über das interne App-Netzwerk
|
||||
erreichen. Bei einer lokalen Installation lautet der Hostname typischerweise
|
||||
`local-solaredge-optimizer-data`:
|
||||
|
||||
```text
|
||||
http://local-solaredge-optimizer-data:8099/api/optimizers
|
||||
```
|
||||
|
||||
Bei Installation aus einem App-Repository ersetzt dessen Repository-ID den
|
||||
Präfix `local`. Die JSON-Ansicht für Menschen ist unabhängig davon über Ingress
|
||||
im Home-Assistant-Seitenpanel verfügbar.
|
||||
|
||||
## Installation als App-Repository
|
||||
|
||||
Füge im Home-Assistant-App-Store unter **⋮ → Repositories** diese URL hinzu:
|
||||
|
||||
```text
|
||||
https://git.jensneuber.de/jens/solaredge-optimizers
|
||||
```
|
||||
|
||||
Installiere anschließend **SolarEdge Optimizer Data**, pflege Benutzername,
|
||||
Passwort und Site-ID im Tab **Konfiguration** und starte die App. Der Supervisor
|
||||
lädt das zur App-Version passende Multi-Arch-Image aus der Registry.
|
||||
|
||||
## Lokale Installation
|
||||
|
||||
Der Ordner ist ein eigenständig baubares Home-Assistant-App-Paket. Kopiere ihn
|
||||
für eine lokale Installation nach:
|
||||
|
||||
```text
|
||||
/addons/solaredge-optimizer-data
|
||||
```
|
||||
|
||||
Lade anschließend den App Store neu, installiere **SolarEdge Optimizer Data**,
|
||||
pflege Benutzername, Passwort und Site-ID im Tab **Konfiguration** und starte
|
||||
die App. Der Supervisor lädt dabei das zur App-Version passende Multi-Arch-Image
|
||||
`git.jensneuber.de/jens/solaredgeoptimizers:0.1.0`.
|
||||
|
||||
## Sicherheit und Betrieb
|
||||
|
||||
- Anmeldedaten werden ausschließlich aus `/data/options.json` gelesen.
|
||||
- Tokens, Cookies und Passwörter werden nicht persistiert oder geloggt.
|
||||
- SolarEdge-Sitzung und Cookies existieren nur im Arbeitsspeicher.
|
||||
- Gleichzeitige Energie-Abfragen sind auf drei begrenzt.
|
||||
- Das Mindestintervall von zehn Minuten reduziert die Last auf den internen,
|
||||
nicht dokumentierten SolarEdge-Endpunkten.
|
||||
- MFA-/OTP-Anmeldungen werden derzeit nicht unterstützt.
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM oven/bun:1.3.14-alpine
|
||||
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_ARCH=amd64
|
||||
|
||||
LABEL \
|
||||
io.hass.version="${BUILD_VERSION}" \
|
||||
io.hass.type="app" \
|
||||
io.hass.arch="${BUILD_ARCH}"
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
WORKDIR /app
|
||||
COPY dist/server.js /app/server.js
|
||||
COPY run.sh /run.sh
|
||||
RUN chmod 0755 /run.sh
|
||||
|
||||
EXPOSE 8099
|
||||
CMD ["/run.sh"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# SolarEdge Optimizer Data
|
||||
|
||||
Home Assistant App providing a cached HTTP API with today's energy and current
|
||||
power for every optimizer/module in a SolarEdge Monitoring site.
|
||||
|
||||
The App logs in headlessly with the configured portal account. It refreshes in
|
||||
the background every ten minutes by default, so API clients only read cached
|
||||
data and do not trigger more SolarEdge requests.
|
||||
|
||||
See [DOCS.md](DOCS.md) for configuration, endpoints and installation notes.
|
||||
|
||||
Published multi-architecture images are available as
|
||||
`git.jensneuber.de/jens/solaredgeoptimizers:<version>`.
|
||||
@@ -0,0 +1,31 @@
|
||||
name: "SolarEdge Optimizer Data"
|
||||
version: "0.1.0"
|
||||
slug: "solaredge_optimizer_data"
|
||||
description: >-
|
||||
Cached daily energy and current power data for every SolarEdge optimizer.
|
||||
arch:
|
||||
- aarch64
|
||||
- amd64
|
||||
image: git.jensneuber.de/jens/solaredgeoptimizers
|
||||
startup: application
|
||||
boot: auto
|
||||
init: false
|
||||
stage: experimental
|
||||
ingress: true
|
||||
ingress_port: 8099
|
||||
watchdog: "tcp://[HOST]:[PORT:8099]"
|
||||
panel_icon: mdi:solar-power
|
||||
panel_title: SolarEdge Optimizers
|
||||
panel_admin: true
|
||||
tmpfs: true
|
||||
backup: cold
|
||||
options:
|
||||
username: null
|
||||
password: null
|
||||
site_id: null
|
||||
poll_interval_minutes: 10
|
||||
schema:
|
||||
username: str
|
||||
password: password
|
||||
site_id: "match(^[0-9]+$)"
|
||||
poll_interval_minutes: "int(10,1440)"
|
||||
+194
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@solar-dash/solaredge-optimizer-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --target=bun --minify --outfile=dist/server.js",
|
||||
"start": "bun src/index.ts",
|
||||
"test": "bun test src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solar-dash/solaredgeapi": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "~6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
exec bun /app/server.js
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { OptimizerDataCache } from "./cache";
|
||||
import type { OptimizerDataSnapshot } from "./collector";
|
||||
|
||||
const snapshot: OptimizerDataSnapshot = {
|
||||
siteId: "42",
|
||||
date: "2026-08-10",
|
||||
timeZone: "Europe/Berlin",
|
||||
fetchedAt: "2026-08-10T08:00:00.000Z",
|
||||
optimizerCount: 1,
|
||||
successfulOptimizerCount: 1,
|
||||
failedOptimizerCount: 0,
|
||||
totalDailyEnergyWh: 100,
|
||||
totalCurrentPowerW: 50,
|
||||
optimizers: [
|
||||
{
|
||||
serial: "OPT-1",
|
||||
inverterId: "1",
|
||||
stringId: "1.1",
|
||||
optimizerId: "1.1.1",
|
||||
dailyEnergyWh: 100,
|
||||
currentPowerW: 50,
|
||||
lastMeasurement: "2026-08-10T07:59:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("optimizer data cache and HTTP API", () => {
|
||||
it("serves a successful cached snapshot", async () => {
|
||||
const cache = new OptimizerDataCache({
|
||||
pollIntervalMs: 600_000,
|
||||
loadSnapshot: async () => snapshot,
|
||||
});
|
||||
await cache.refresh();
|
||||
|
||||
expect(cache.getPayload()).toMatchObject({
|
||||
status: "ok",
|
||||
siteId: "42",
|
||||
optimizerCount: 1,
|
||||
optimizers: [{ optimizerId: "1.1.1", currentPowerW: 50 }],
|
||||
});
|
||||
cache.stop();
|
||||
});
|
||||
|
||||
it("keeps the previous data if a later refresh fails", async () => {
|
||||
let attempts = 0;
|
||||
const cache = new OptimizerDataCache({
|
||||
pollIntervalMs: 600_000,
|
||||
loadSnapshot: async () => {
|
||||
attempts += 1;
|
||||
if (attempts > 1) {
|
||||
throw new Error("credentials must not appear here");
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
});
|
||||
await cache.refresh();
|
||||
await cache.refresh();
|
||||
|
||||
expect(cache.getPayload()).toMatchObject({
|
||||
status: "stale",
|
||||
siteId: "42",
|
||||
lastError: "Error",
|
||||
});
|
||||
expect(JSON.stringify(cache.getPayload())).not.toContain("credentials");
|
||||
cache.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { OptimizerDataSnapshot } from "./collector";
|
||||
|
||||
export type OptimizerDataApiPayload = OptimizerDataSnapshot & {
|
||||
status: "ok" | "partial" | "stale";
|
||||
nextRefreshAt: string;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
export class OptimizerDataCache {
|
||||
private readonly loadSnapshot: () => Promise<OptimizerDataSnapshot>;
|
||||
private readonly pollIntervalMs: number;
|
||||
private snapshot?: OptimizerDataSnapshot;
|
||||
private nextRefreshAt?: string;
|
||||
private lastError?: string;
|
||||
private refreshPromise?: Promise<void>;
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor({
|
||||
loadSnapshot,
|
||||
pollIntervalMs,
|
||||
}: {
|
||||
loadSnapshot: () => Promise<OptimizerDataSnapshot>;
|
||||
pollIntervalMs: number;
|
||||
}) {
|
||||
if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
|
||||
throw new Error("pollIntervalMs must be positive");
|
||||
}
|
||||
this.loadSnapshot = loadSnapshot;
|
||||
this.pollIntervalMs = pollIntervalMs;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
this.stop();
|
||||
this.refreshPromise = this.performRefresh().finally(() => {
|
||||
this.refreshPromise = undefined;
|
||||
this.scheduleNextRefresh();
|
||||
});
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
getPayload(): OptimizerDataApiPayload | undefined {
|
||||
if (!this.snapshot || !this.nextRefreshAt) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
status: this.lastError
|
||||
? "stale"
|
||||
: this.snapshot.failedOptimizerCount > 0
|
||||
? "partial"
|
||||
: "ok",
|
||||
...this.snapshot,
|
||||
nextRefreshAt: this.nextRefreshAt,
|
||||
...(this.lastError ? { lastError: this.lastError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
getLastError(): string | undefined {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<void> {
|
||||
try {
|
||||
this.snapshot = await this.loadSnapshot();
|
||||
this.lastError = undefined;
|
||||
} catch (error) {
|
||||
this.lastError = formatSafeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNextRefresh(): void {
|
||||
this.nextRefreshAt = new Date(Date.now() + this.pollIntervalMs).toISOString();
|
||||
this.timer = setTimeout(() => void this.refresh(), this.pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSafeError(error: unknown): string {
|
||||
if (!error || typeof error !== "object") {
|
||||
return "Unknown error";
|
||||
}
|
||||
const metadata = error as { name?: unknown; code?: unknown; status?: unknown };
|
||||
return [
|
||||
metadata.name ? String(metadata.name) : "Error",
|
||||
metadata.code === undefined ? undefined : `code=${String(metadata.code)}`,
|
||||
metadata.status === undefined
|
||||
? undefined
|
||||
: `status=${String(metadata.status)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { collectOptimizerData } from "./collector";
|
||||
|
||||
describe("optimizer data collector", () => {
|
||||
it("collects daily energy and live power for every optimizer", async () => {
|
||||
let activeEnergyRequests = 0;
|
||||
let maximumActiveEnergyRequests = 0;
|
||||
const snapshot = await collectOptimizerData({
|
||||
siteId: "42",
|
||||
timeZone: "Europe/Berlin",
|
||||
now: new Date("2026-08-09T23:30:00Z"),
|
||||
concurrency: 2,
|
||||
reader: {
|
||||
listOptimizerMappings: async () => [
|
||||
{
|
||||
serial: "OPT-1",
|
||||
inverterId: "1",
|
||||
stringId: "1.1",
|
||||
optimizerId: "1.1.1",
|
||||
},
|
||||
{
|
||||
serial: "OPT-2",
|
||||
inverterId: "1",
|
||||
stringId: "1.1",
|
||||
optimizerId: "1.1.2",
|
||||
},
|
||||
{
|
||||
serial: "OPT-3",
|
||||
inverterId: "1",
|
||||
stringId: "1.1",
|
||||
optimizerId: "1.1.3",
|
||||
},
|
||||
],
|
||||
getOptimizerInformation: async () => ({
|
||||
basicInformationList: [],
|
||||
serialToLiveData: {
|
||||
"OPT-1": { power_W: 10, lastMeasurement: "measurement-1" },
|
||||
"OPT-2": { power_W: 20, lastMeasurement: "measurement-2" },
|
||||
"OPT-3": { power_W: null, lastMeasurement: "measurement-3" },
|
||||
},
|
||||
}),
|
||||
getOptimizerEnergy: async ({ optimizerSerials, startDate }) => {
|
||||
expect(startDate).toBe("2026-08-10");
|
||||
activeEnergyRequests += 1;
|
||||
maximumActiveEnergyRequests = Math.max(
|
||||
maximumActiveEnergyRequests,
|
||||
activeEnergyRequests,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
activeEnergyRequests -= 1;
|
||||
if (optimizerSerials[0] === "OPT-3") {
|
||||
const error = new Error("must not be exposed") as Error & {
|
||||
status: number;
|
||||
};
|
||||
error.status = 429;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
totalEnergy: optimizerSerials[0] === "OPT-1" ? 100 : 200,
|
||||
energyBars: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(maximumActiveEnergyRequests).toBe(2);
|
||||
expect(snapshot).toMatchObject({
|
||||
siteId: "42",
|
||||
date: "2026-08-10",
|
||||
timeZone: "Europe/Berlin",
|
||||
optimizerCount: 3,
|
||||
successfulOptimizerCount: 2,
|
||||
failedOptimizerCount: 1,
|
||||
totalDailyEnergyWh: 300,
|
||||
totalCurrentPowerW: 30,
|
||||
optimizers: [
|
||||
{
|
||||
serial: "OPT-1",
|
||||
optimizerId: "1.1.1",
|
||||
dailyEnergyWh: 100,
|
||||
currentPowerW: 10,
|
||||
lastMeasurement: "measurement-1",
|
||||
},
|
||||
{
|
||||
serial: "OPT-2",
|
||||
optimizerId: "1.1.2",
|
||||
dailyEnergyWh: 200,
|
||||
currentPowerW: 20,
|
||||
lastMeasurement: "measurement-2",
|
||||
},
|
||||
{
|
||||
serial: "OPT-3",
|
||||
optimizerId: "1.1.3",
|
||||
dailyEnergyWh: null,
|
||||
currentPowerW: null,
|
||||
error: "Error status=429",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain("must not be exposed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
GetSolarEdgeOptimizerEnergyOptions,
|
||||
SolarEdgeOptimizerEnergy,
|
||||
SolarEdgeOptimizerInformation,
|
||||
SolarEdgeOptimizerMapping,
|
||||
SolarEdgeOptimizerSerial,
|
||||
SolarEdgeSiteId,
|
||||
} from "@solar-dash/solaredgeapi";
|
||||
|
||||
export type OptimizerDataReader = {
|
||||
listOptimizerMappings(
|
||||
siteId?: SolarEdgeSiteId,
|
||||
): Promise<SolarEdgeOptimizerMapping[]>;
|
||||
getOptimizerInformation(
|
||||
optimizerSerials: SolarEdgeOptimizerSerial[],
|
||||
): Promise<SolarEdgeOptimizerInformation>;
|
||||
getOptimizerEnergy(
|
||||
options: GetSolarEdgeOptimizerEnergyOptions,
|
||||
): Promise<SolarEdgeOptimizerEnergy>;
|
||||
};
|
||||
|
||||
export type OptimizerModuleData = {
|
||||
serial: string;
|
||||
inverterId: string | null;
|
||||
stringId: string | null;
|
||||
optimizerId: string | null;
|
||||
dailyEnergyWh: number | null;
|
||||
currentPowerW: number | null;
|
||||
lastMeasurement: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type OptimizerDataSnapshot = {
|
||||
siteId: string;
|
||||
date: string;
|
||||
timeZone: string;
|
||||
fetchedAt: string;
|
||||
optimizerCount: number;
|
||||
successfulOptimizerCount: number;
|
||||
failedOptimizerCount: number;
|
||||
totalDailyEnergyWh: number;
|
||||
totalCurrentPowerW: number;
|
||||
optimizers: OptimizerModuleData[];
|
||||
};
|
||||
|
||||
export async function collectOptimizerData({
|
||||
reader,
|
||||
siteId,
|
||||
timeZone,
|
||||
concurrency = 3,
|
||||
now = new Date(),
|
||||
}: {
|
||||
reader: OptimizerDataReader;
|
||||
siteId: string;
|
||||
timeZone: string;
|
||||
concurrency?: number;
|
||||
now?: Date;
|
||||
}): Promise<OptimizerDataSnapshot> {
|
||||
const date = formatDateInTimeZone(now, timeZone);
|
||||
const mappings = await reader.listOptimizerMappings(siteId);
|
||||
if (mappings.length === 0) {
|
||||
throw new Error("SolarEdge returned no optimizer mappings");
|
||||
}
|
||||
|
||||
const information = await reader.getOptimizerInformation(
|
||||
mappings.map((mapping) => mapping.serial),
|
||||
);
|
||||
const optimizers = await mapWithConcurrency(
|
||||
mappings,
|
||||
concurrency,
|
||||
async (mapping): Promise<OptimizerModuleData> => {
|
||||
const liveData = information.serialToLiveData[mapping.serial];
|
||||
const base = {
|
||||
serial: mapping.serial,
|
||||
inverterId: mapping.inverterId ?? null,
|
||||
stringId: mapping.stringId ?? null,
|
||||
optimizerId: mapping.optimizerId ?? null,
|
||||
currentPowerW: finiteNumberOrNull(liveData?.power_W),
|
||||
lastMeasurement: liveData?.lastMeasurement ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
const energy = await reader.getOptimizerEnergy({
|
||||
siteId,
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
optimizerSerials: [mapping.serial],
|
||||
chartTimeUnit: "hours",
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
dailyEnergyWh: finiteNumberOrNull(energy.totalEnergy),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...base,
|
||||
dailyEnergyWh: null,
|
||||
error: formatSafeError(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
const successful = optimizers.filter(
|
||||
(optimizer) => optimizer.dailyEnergyWh !== null,
|
||||
);
|
||||
|
||||
return {
|
||||
siteId,
|
||||
date,
|
||||
timeZone,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
optimizerCount: optimizers.length,
|
||||
successfulOptimizerCount: successful.length,
|
||||
failedOptimizerCount: optimizers.length - successful.length,
|
||||
totalDailyEnergyWh: successful.reduce(
|
||||
(sum, optimizer) => sum + (optimizer.dailyEnergyWh ?? 0),
|
||||
0,
|
||||
),
|
||||
totalCurrentPowerW: optimizers.reduce(
|
||||
(sum, optimizer) => sum + (optimizer.currentPowerW ?? 0),
|
||||
0,
|
||||
),
|
||||
optimizers,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDateInTimeZone(date: Date, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.year}-${values.month}-${values.day}`;
|
||||
}
|
||||
|
||||
export async function mapWithConcurrency<TInput, TOutput>(
|
||||
values: TInput[],
|
||||
concurrency: number,
|
||||
mapper: (value: TInput, index: number) => Promise<TOutput>,
|
||||
): Promise<TOutput[]> {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
||||
throw new Error("concurrency must be a positive integer");
|
||||
}
|
||||
const results = new Array<TOutput>(values.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < values.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await mapper(values[index] as TInput, index);
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, values.length) }, worker),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
function finiteNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function formatSafeError(error: unknown): string {
|
||||
if (!error || typeof error !== "object") {
|
||||
return "Unknown error";
|
||||
}
|
||||
const name = "name" in error ? String(error.name) : "Error";
|
||||
const code = "code" in error ? String(error.code) : undefined;
|
||||
const status = "status" in error ? Number(error.status) : undefined;
|
||||
return [
|
||||
name,
|
||||
code ? `code=${code}` : undefined,
|
||||
Number.isFinite(status) ? `status=${status}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { parseAppOptions, resolveHomeAssistantTimeZone } from "./config";
|
||||
|
||||
describe("SolarEdge Optimizer Data App config", () => {
|
||||
it("parses the required Home Assistant App options", () => {
|
||||
expect(
|
||||
parseAppOptions({
|
||||
username: " owner@example.com ",
|
||||
password: "portal-secret",
|
||||
site_id: "4886699",
|
||||
poll_interval_minutes: 10,
|
||||
}),
|
||||
).toEqual({
|
||||
username: "owner@example.com",
|
||||
password: "portal-secret",
|
||||
siteId: "4886699",
|
||||
pollIntervalMinutes: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing credentials and unsafe polling intervals", () => {
|
||||
expect(() =>
|
||||
parseAppOptions({ password: "secret", site_id: "42" }),
|
||||
).toThrow("username");
|
||||
expect(() =>
|
||||
parseAppOptions({
|
||||
username: "owner@example.com",
|
||||
password: "secret",
|
||||
site_id: "42",
|
||||
poll_interval_minutes: 5,
|
||||
}),
|
||||
).toThrow("10 to 1440");
|
||||
});
|
||||
|
||||
it("reads the Home Assistant time zone from the Supervisor", async () => {
|
||||
const timeZone = await resolveHomeAssistantTimeZone({
|
||||
supervisorToken: "supervisor-token",
|
||||
fetchImplementation: async (input, init) => {
|
||||
expect(String(input)).toBe("http://supervisor/info");
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe(
|
||||
"Bearer supervisor-token",
|
||||
);
|
||||
return Response.json({ data: { timezone: "Europe/Berlin" } });
|
||||
},
|
||||
});
|
||||
expect(timeZone).toBe("Europe/Berlin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
export const DEFAULT_OPTIONS_PATH = "/data/options.json";
|
||||
export const DEFAULT_PORT = 8099;
|
||||
export const DEFAULT_POLL_INTERVAL_MINUTES = 10;
|
||||
|
||||
export type SolarEdgeOptimizerAppOptions = {
|
||||
username: string;
|
||||
password: string;
|
||||
siteId: string;
|
||||
pollIntervalMinutes: number;
|
||||
};
|
||||
|
||||
export type SupervisorFetch = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export function parseAppOptions(value: unknown): SolarEdgeOptimizerAppOptions {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("App options must be a JSON object");
|
||||
}
|
||||
|
||||
const options = value as Record<string, unknown>;
|
||||
const username = requireString(options.username, "username");
|
||||
const password = requireString(options.password, "password", false);
|
||||
const siteId = requireString(options.site_id, "site_id");
|
||||
if (!/^\d+$/.test(siteId)) {
|
||||
throw new Error("site_id must contain digits only");
|
||||
}
|
||||
|
||||
const pollIntervalMinutes =
|
||||
options.poll_interval_minutes === undefined
|
||||
? DEFAULT_POLL_INTERVAL_MINUTES
|
||||
: Number(options.poll_interval_minutes);
|
||||
if (
|
||||
!Number.isInteger(pollIntervalMinutes) ||
|
||||
pollIntervalMinutes < 10 ||
|
||||
pollIntervalMinutes > 1440
|
||||
) {
|
||||
throw new Error("poll_interval_minutes must be an integer from 10 to 1440");
|
||||
}
|
||||
|
||||
return { username, password, siteId, pollIntervalMinutes };
|
||||
}
|
||||
|
||||
export async function readAppOptions(
|
||||
path = process.env.SOLAREDGE_OPTIONS_PATH ?? DEFAULT_OPTIONS_PATH,
|
||||
): Promise<SolarEdgeOptimizerAppOptions> {
|
||||
const file = Bun.file(path);
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`App options file does not exist: ${path}`);
|
||||
}
|
||||
return parseAppOptions(await file.json());
|
||||
}
|
||||
|
||||
export async function resolveHomeAssistantTimeZone({
|
||||
fetchImplementation = globalThis.fetch,
|
||||
supervisorToken = process.env.SUPERVISOR_TOKEN,
|
||||
}: {
|
||||
fetchImplementation?: SupervisorFetch;
|
||||
supervisorToken?: string;
|
||||
} = {}): Promise<string> {
|
||||
if (!supervisorToken) {
|
||||
const fallback = process.env.TZ;
|
||||
if (fallback) {
|
||||
return validateTimeZone(fallback);
|
||||
}
|
||||
throw new Error("SUPERVISOR_TOKEN is missing and TZ is not configured");
|
||||
}
|
||||
|
||||
const response = await fetchImplementation("http://supervisor/info", {
|
||||
headers: { Authorization: `Bearer ${supervisorToken}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not read Home Assistant time zone (${response.status})`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data?: { timezone?: unknown };
|
||||
};
|
||||
return validateTimeZone(
|
||||
requireString(body.data?.timezone, "Supervisor timezone"),
|
||||
);
|
||||
}
|
||||
|
||||
function requireString(
|
||||
value: unknown,
|
||||
name: string,
|
||||
trim = true,
|
||||
): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${name} must be a string`);
|
||||
}
|
||||
const normalized = trim ? value.trim() : value;
|
||||
if (!normalized) {
|
||||
throw new Error(`${name} must not be empty`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateTimeZone(value: string): string {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en", { timeZone: value }).format();
|
||||
return value;
|
||||
} catch {
|
||||
throw new Error(`Invalid Home Assistant time zone: ${value}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { OptimizerDataCache } from "./cache";
|
||||
import type { OptimizerDataSnapshot } from "./collector";
|
||||
import { createRequestHandler } from "./http";
|
||||
|
||||
describe("optimizer data HTTP API", () => {
|
||||
it("returns 503 while starting and JSON after refresh", async () => {
|
||||
const snapshot: OptimizerDataSnapshot = {
|
||||
siteId: "42",
|
||||
date: "2026-08-10",
|
||||
timeZone: "Europe/Berlin",
|
||||
fetchedAt: "2026-08-10T08:00:00.000Z",
|
||||
optimizerCount: 0,
|
||||
successfulOptimizerCount: 0,
|
||||
failedOptimizerCount: 0,
|
||||
totalDailyEnergyWh: 0,
|
||||
totalCurrentPowerW: 0,
|
||||
optimizers: [],
|
||||
};
|
||||
const cache = new OptimizerDataCache({
|
||||
pollIntervalMs: 600_000,
|
||||
loadSnapshot: async () => snapshot,
|
||||
});
|
||||
const handle = createRequestHandler(cache);
|
||||
|
||||
const starting = handle(new Request("http://app/api/optimizers"));
|
||||
expect(starting.status).toBe(503);
|
||||
expect(await starting.json()).toMatchObject({ status: "starting" });
|
||||
|
||||
await cache.refresh();
|
||||
const response = handle(new Request("http://app/api/optimizers"));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toMatchObject({ status: "ok", siteId: "42" });
|
||||
|
||||
const missing = handle(new Request("http://app/unknown"));
|
||||
expect(missing.status).toBe(404);
|
||||
cache.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { OptimizerDataCache } from "./cache";
|
||||
|
||||
export function createRequestHandler(cache: OptimizerDataCache) {
|
||||
return (request: Request): Response => {
|
||||
if (request.method !== "GET") {
|
||||
return jsonResponse({ error: "Method not allowed" }, 405, {
|
||||
Allow: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
const path = new URL(request.url).pathname.replace(/\/+$/, "") || "/";
|
||||
if (path === "/health") {
|
||||
const payload = cache.getPayload();
|
||||
return jsonResponse(
|
||||
payload
|
||||
? {
|
||||
status: payload.status,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
nextRefreshAt: payload.nextRefreshAt,
|
||||
optimizerCount: payload.optimizerCount,
|
||||
failedOptimizerCount: payload.failedOptimizerCount,
|
||||
...(payload.lastError ? { lastError: payload.lastError } : {}),
|
||||
}
|
||||
: {
|
||||
status: cache.getLastError() ? "error" : "starting",
|
||||
...(cache.getLastError()
|
||||
? { lastError: cache.getLastError() }
|
||||
: {}),
|
||||
},
|
||||
payload ? 200 : 503,
|
||||
);
|
||||
}
|
||||
|
||||
if (path === "/" || path === "/api/optimizers") {
|
||||
const payload = cache.getPayload();
|
||||
return payload
|
||||
? jsonResponse(payload)
|
||||
: jsonResponse(
|
||||
{
|
||||
status: cache.getLastError() ? "error" : "starting",
|
||||
message: "No optimizer data is available yet",
|
||||
...(cache.getLastError()
|
||||
? { lastError: cache.getLastError() }
|
||||
: {}),
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
return jsonResponse({ error: "Not found" }, 404);
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(
|
||||
value: unknown,
|
||||
status = 200,
|
||||
additionalHeaders: Record<string, string> = {},
|
||||
): Response {
|
||||
return Response.json(value, {
|
||||
status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
...additionalHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SolarEdgePortalClient } from "@solar-dash/solaredgeapi";
|
||||
|
||||
import { OptimizerDataCache } from "./cache";
|
||||
import { collectOptimizerData } from "./collector";
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
readAppOptions,
|
||||
resolveHomeAssistantTimeZone,
|
||||
} from "./config";
|
||||
import { createRequestHandler } from "./http";
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const options = await readAppOptions();
|
||||
const timeZone = await resolveHomeAssistantTimeZone();
|
||||
const portalClient = new SolarEdgePortalClient({
|
||||
username: options.username,
|
||||
password: options.password,
|
||||
siteId: options.siteId,
|
||||
});
|
||||
const cache = new OptimizerDataCache({
|
||||
pollIntervalMs: options.pollIntervalMinutes * 60_000,
|
||||
loadSnapshot: async () => {
|
||||
log("info", `Refreshing optimizer data for site ${options.siteId}`);
|
||||
const snapshot = await collectOptimizerData({
|
||||
reader: portalClient,
|
||||
siteId: options.siteId,
|
||||
timeZone,
|
||||
});
|
||||
log(
|
||||
snapshot.failedOptimizerCount === 0 ? "info" : "warning",
|
||||
`Refresh finished: ${snapshot.successfulOptimizerCount}/${snapshot.optimizerCount} optimizer energy values`,
|
||||
);
|
||||
return snapshot;
|
||||
},
|
||||
});
|
||||
const port = readPort(process.env.PORT);
|
||||
const server = Bun.serve({
|
||||
hostname: "0.0.0.0",
|
||||
port,
|
||||
fetch: createRequestHandler(cache),
|
||||
});
|
||||
|
||||
const shutDown = (): void => {
|
||||
log("info", "Stopping SolarEdge Optimizer Data App");
|
||||
cache.stop();
|
||||
void server.stop(true);
|
||||
};
|
||||
process.once("SIGTERM", shutDown);
|
||||
process.once("SIGINT", shutDown);
|
||||
|
||||
log(
|
||||
"info",
|
||||
`Listening on port ${port}; refresh interval ${options.pollIntervalMinutes} minutes; time zone ${timeZone}`,
|
||||
);
|
||||
cache.start();
|
||||
}
|
||||
|
||||
function readPort(value: string | undefined): number {
|
||||
const port = value === undefined ? DEFAULT_PORT : Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error("PORT must be an integer from 1 to 65535");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function log(level: "info" | "warning" | "error", message: string): void {
|
||||
console.log(`${new Date().toISOString()} [${level}] ${message}`);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
log("error", formatStartupError(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function formatStartupError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return `${error.name}: ${error.message}`;
|
||||
}
|
||||
return "Unknown startup error";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
configuration:
|
||||
username:
|
||||
name: SolarEdge Benutzername
|
||||
description: E-Mail-Adresse oder Benutzername des SolarEdge Monitoring Portals.
|
||||
password:
|
||||
name: SolarEdge Passwort
|
||||
description: Passwort des SolarEdge Monitoring Portals. Es wird niemals ausgegeben.
|
||||
site_id:
|
||||
name: SolarEdge Site-ID
|
||||
description: Numerische ID der SolarEdge-Anlage.
|
||||
poll_interval_minutes:
|
||||
name: Aktualisierungsintervall
|
||||
description: Abstand zwischen SolarEdge-Abrufen in Minuten; mindestens 10 Minuten.
|
||||
@@ -0,0 +1,13 @@
|
||||
configuration:
|
||||
username:
|
||||
name: SolarEdge username
|
||||
description: Email address or username for the SolarEdge Monitoring Portal.
|
||||
password:
|
||||
name: SolarEdge password
|
||||
description: Password for the SolarEdge Monitoring Portal. It is never included in output.
|
||||
site_id:
|
||||
name: SolarEdge site ID
|
||||
description: Numeric ID of the SolarEdge site.
|
||||
poll_interval_minutes:
|
||||
name: Refresh interval
|
||||
description: Minutes between SolarEdge refreshes; the minimum is 10 minutes.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user