From f2887a0ab4b89ea7126b6120733a58d09f52980c Mon Sep 17 00:00:00 2001 From: Jens Neuber Date: Mon, 10 Aug 2026 09:04:35 +0200 Subject: [PATCH] Initial SolarEdge Optimizer Home Assistant App --- .gitignore | 4 + README.md | 54 ++ bun.lock | 61 ++ package.json | 15 + packages/solaredgeapi/README.md | 67 ++ .../docs/internal-optimizer-api.md | 172 ++++ packages/solaredgeapi/docs/monitoring-api.md | 498 +++++++++++ packages/solaredgeapi/package.json | 22 + packages/solaredgeapi/src/client.test.ts | 241 +++++ packages/solaredgeapi/src/client.ts | 391 +++++++++ packages/solaredgeapi/src/index.ts | 38 + packages/solaredgeapi/src/portal-types.ts | 73 ++ packages/solaredgeapi/src/portal.test.ts | 287 ++++++ packages/solaredgeapi/src/portal.ts | 823 ++++++++++++++++++ packages/solaredgeapi/src/types.ts | 225 +++++ packages/solaredgeapi/tsconfig.json | 15 + repository.yaml | 3 + solaredge-optimizer-data/.dockerignore | 4 + solaredge-optimizer-data/CHANGELOG.md | 8 + solaredge-optimizer-data/DOCS.md | 132 +++ solaredge-optimizer-data/Dockerfile | 19 + solaredge-optimizer-data/README.md | 13 + solaredge-optimizer-data/config.yaml | 31 + solaredge-optimizer-data/dist/server.js | 194 +++++ solaredge-optimizer-data/package.json | 19 + solaredge-optimizer-data/run.sh | 4 + solaredge-optimizer-data/src/cache.test.ts | 69 ++ solaredge-optimizer-data/src/cache.ts | 104 +++ .../src/collector.test.ts | 103 +++ solaredge-optimizer-data/src/collector.ts | 179 ++++ solaredge-optimizer-data/src/config.test.ts | 49 ++ solaredge-optimizer-data/src/config.ts | 108 +++ solaredge-optimizer-data/src/http.test.ts | 41 + solaredge-optimizer-data/src/http.ts | 66 ++ solaredge-optimizer-data/src/index.ts | 84 ++ solaredge-optimizer-data/translations/de.yaml | 13 + solaredge-optimizer-data/translations/en.yaml | 13 + solaredge-optimizer-data/tsconfig.json | 11 + 38 files changed, 4253 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 packages/solaredgeapi/README.md create mode 100644 packages/solaredgeapi/docs/internal-optimizer-api.md create mode 100644 packages/solaredgeapi/docs/monitoring-api.md create mode 100644 packages/solaredgeapi/package.json create mode 100644 packages/solaredgeapi/src/client.test.ts create mode 100644 packages/solaredgeapi/src/client.ts create mode 100644 packages/solaredgeapi/src/index.ts create mode 100644 packages/solaredgeapi/src/portal-types.ts create mode 100644 packages/solaredgeapi/src/portal.test.ts create mode 100644 packages/solaredgeapi/src/portal.ts create mode 100644 packages/solaredgeapi/src/types.ts create mode 100644 packages/solaredgeapi/tsconfig.json create mode 100644 repository.yaml create mode 100644 solaredge-optimizer-data/.dockerignore create mode 100644 solaredge-optimizer-data/CHANGELOG.md create mode 100644 solaredge-optimizer-data/DOCS.md create mode 100644 solaredge-optimizer-data/Dockerfile create mode 100644 solaredge-optimizer-data/README.md create mode 100644 solaredge-optimizer-data/config.yaml create mode 100644 solaredge-optimizer-data/dist/server.js create mode 100644 solaredge-optimizer-data/package.json create mode 100644 solaredge-optimizer-data/run.sh create mode 100644 solaredge-optimizer-data/src/cache.test.ts create mode 100644 solaredge-optimizer-data/src/cache.ts create mode 100644 solaredge-optimizer-data/src/collector.test.ts create mode 100644 solaredge-optimizer-data/src/collector.ts create mode 100644 solaredge-optimizer-data/src/config.test.ts create mode 100644 solaredge-optimizer-data/src/config.ts create mode 100644 solaredge-optimizer-data/src/http.test.ts create mode 100644 solaredge-optimizer-data/src/http.ts create mode 100644 solaredge-optimizer-data/src/index.ts create mode 100644 solaredge-optimizer-data/translations/de.yaml create mode 100644 solaredge-optimizer-data/translations/en.yaml create mode 100644 solaredge-optimizer-data/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e6a169 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +coverage/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fa0b70 --- /dev/null +++ b/README.md @@ -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. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..094f0a6 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0532a6c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/solaredgeapi/README.md b/packages/solaredgeapi/README.md new file mode 100644 index 0000000..2bf491e --- /dev/null +++ b/packages/solaredgeapi/README.md @@ -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(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). diff --git a/packages/solaredgeapi/docs/internal-optimizer-api.md b/packages/solaredgeapi/docs/internal-optimizer-api.md new file mode 100644 index 0000000..89bcddb --- /dev/null +++ b/packages/solaredgeapi/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; +}; +``` + +```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. diff --git a/packages/solaredgeapi/docs/monitoring-api.md b/packages/solaredgeapi/docs/monitoring-api.md new file mode 100644 index 0000000..dbbf884 --- /dev/null +++ b/packages/solaredgeapi/docs/monitoring-api.md @@ -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()` | +| Energy | `GET /site/{siteId}/energy` | `getSiteEnergy()` | +| Energy | `GET /sites/{siteIds}/energy` | Use `get()` | +| Energy | `GET /site/{siteId}/timeFrameEnergy` | Use `get()` | +| Energy | `GET /sites/{siteIds}/timeFrameEnergy` | Use `get()` | +| Power | `GET /site/{siteId}/power` | `getSitePower()` | +| Power | `GET /sites/{siteIds}/power` | Use `get()` | +| Overview | `GET /site/{siteId}/overview` | `getSiteOverview()` | +| Overview | `GET /sites/{siteIds}/overview` | Use `get()` | +| 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()` | +| Environment | `GET /site/{siteId}/envBenefits` | Use `get()` | +| Equipment | `GET /equipment/{siteId}/list` | `listSiteEquipment()` | +| Equipment | `GET /site/{siteId}/inventory` | `getSiteInventory()` | +| Equipment | `GET /equipment/{siteId}/{serialNumber}/data` | Use `get()` | +| Meters | `GET /site/{siteId}/meters` | Use `get()` | + +## 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( + `/site/${siteId}/envBenefits`, + { systemUnits: "Metrics" }, +); +``` diff --git a/packages/solaredgeapi/package.json b/packages/solaredgeapi/package.json new file mode 100644 index 0000000..4e2b54d --- /dev/null +++ b/packages/solaredgeapi/package.json @@ -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" + } +} diff --git a/packages/solaredgeapi/src/client.test.ts b/packages/solaredgeapi/src/client.test.ts new file mode 100644 index 0000000..e9ea894 --- /dev/null +++ b/packages/solaredgeapi/src/client.test.ts @@ -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"); + }); +}); diff --git a/packages/solaredgeapi/src/client.ts b/packages/solaredgeapi/src/client.ts new file mode 100644 index 0000000..14666c4 --- /dev/null +++ b/packages/solaredgeapi/src/client.ts @@ -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; + +export type SolarEdgeClientOptions = { + apiKey: string; + siteId?: SolarEdgeSiteId; + baseUrl?: string; + fetch?: SolarEdgeFetch; + maxConcurrency?: number; +}; + +export type SolarEdgeClientFromEnvOptions = Omit< + SolarEdgeClientOptions, + "apiKey" | "siteId" +> & { + env?: Record; +}; + +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 { + 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 { + const response = await this.get<{ details: SolarEdgeSite }>( + `/site/${this.resolveSiteId(siteId)}/details`, + {}, + requestOptions, + ); + + return response.details; + } + + async getSiteDataPeriod( + siteId?: SolarEdgeSiteId, + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + const response = await this.get<{ dataPeriod: SolarEdgeDataPeriod }>( + `/site/${this.resolveSiteId(siteId)}/dataPeriod`, + {}, + requestOptions, + ); + + return response.dataPeriod; + } + + async getSiteOverview( + siteId?: SolarEdgeSiteId, + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + const response = await this.get<{ overview: SolarEdgeOverview }>( + `/site/${this.resolveSiteId(siteId)}/overview`, + {}, + requestOptions, + ); + + return response.overview; + } + + async getSiteEnergy( + { siteId, ...query }: GetSolarEdgeEnergyOptions, + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + 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 { + 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 { + 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 { + 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 { + const response = await this.get<{ + siteCurrentPowerFlow: SolarEdgeCurrentPowerFlow; + }>( + `/site/${this.resolveSiteId(siteId)}/currentPowerFlow`, + {}, + requestOptions, + ); + + return response.siteCurrentPowerFlow; + } + + async getSiteInventory( + siteId?: SolarEdgeSiteId, + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + const response = await this.get<{ Inventory: SolarEdgeInventory }>( + `/site/${this.resolveSiteId(siteId)}/inventory`, + {}, + requestOptions, + ); + + return response.Inventory; + } + + async listSiteEquipment( + siteId?: SolarEdgeSiteId, + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + const response = await this.get<{ reporters: SolarEdgeEquipmentList }>( + `/equipment/${this.resolveSiteId(siteId)}/list`, + {}, + requestOptions, + ); + + return response.reporters; + } + + async get( + path: string, + query: Record = {}, + { signal }: SolarEdgeRequestOptions = {}, + ): Promise { + 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(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(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 { + if (this.activeRequestCount < this.maxConcurrency) { + this.activeRequestCount += 1; + return; + } + + await new Promise((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 = {}, +): 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(response: Response): Promise { + try { + return (await response.json()) as T; + } catch { + return undefined; + } +} diff --git a/packages/solaredgeapi/src/index.ts b/packages/solaredgeapi/src/index.ts new file mode 100644 index 0000000..fb47abb --- /dev/null +++ b/packages/solaredgeapi/src/index.ts @@ -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"; diff --git a/packages/solaredgeapi/src/portal-types.ts b/packages/solaredgeapi/src/portal-types.ts new file mode 100644 index 0000000..4ba8365 --- /dev/null +++ b/packages/solaredgeapi/src/portal-types.ts @@ -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; diff --git a/packages/solaredgeapi/src/portal.test.ts b/packages/solaredgeapi/src/portal.test.ts new file mode 100644 index 0000000..d5544fb --- /dev/null +++ b/packages/solaredgeapi/src/portal.test.ts @@ -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( + '
', + { + 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; + 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(''); + } + if (url.pathname === "/login") { + return new Response(''); + } + 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); + }); +}); diff --git a/packages/solaredgeapi/src/portal.ts b/packages/solaredgeapi/src/portal.ts new file mode 100644 index 0000000..078a25a --- /dev/null +++ b/packages/solaredgeapi/src/portal.ts @@ -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; +}; + +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; + + 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 { + await this.runSingleAuthentication(async () => { + await this.login(); + }); + } + + async getLogicalLayout( + siteId?: SolarEdgeSiteId, + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + return this.get( + `/services/layout/logical/generic/v2/site/${this.resolveSiteId(siteId)}`, + { "include-optimizers": true }, + requestOptions, + ); + } + + async listOptimizerSerials( + siteId?: SolarEdgeSiteId, + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + const mappings = await this.listOptimizerMappings(siteId, requestOptions); + return mappings.map((mapping) => mapping.serial); + } + + async listOptimizerMappings( + siteId?: SolarEdgeSiteId, + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + const layout = await this.getLogicalLayout(siteId, requestOptions); + return collectOptimizerMappings(layout); + } + + async getOptimizerInformation( + optimizerSerials: SolarEdgeOptimizerSerial[], + requestOptions?: SolarEdgeRequestOptions, + ): Promise { + const serials = normalizeOptimizerSerials(optimizerSerials); + + return this.requestJson( + "/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 { + const serials = normalizeOptimizerSerials(optimizerSerials); + + return this.get( + `/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( + path: string, + query: Record = {}, + requestOptions: SolarEdgeRequestOptions = {}, + ): Promise { + 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(url, { + headers: { Accept: "application/json" }, + signal: requestOptions.signal, + }); + } + + private async requestJson( + input: string | URL, + init: RequestInit, + retryAfterAuthentication = true, + ): Promise { + 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(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 { + 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 { + this.accessTokenExpiresAt = 0; + await this.runSingleAuthentication(async () => { + if (await this.refreshSession()) { + return; + } + await this.login(); + }); + } + + private async runSingleAuthentication(action: () => Promise): Promise { + if (!this.authenticationPromise) { + this.authenticationPromise = action().finally(() => { + this.authenticationPromise = undefined; + }); + } + await this.authenticationPromise; + } + + private async login(): Promise { + 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 { + 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(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 { + 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 { + 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(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 { + 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 { + 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 { + 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(/]*>/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(); + 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; + 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(response: Response): Promise { + try { + return (await response.json()) as T; + } catch { + return undefined; + } +} diff --git a/packages/solaredgeapi/src/types.ts b/packages/solaredgeapi/src/types.ts new file mode 100644 index 0000000..ecc050f --- /dev/null +++ b/packages/solaredgeapi/src/types.ts @@ -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" +>; diff --git a/packages/solaredgeapi/tsconfig.json b/packages/solaredgeapi/tsconfig.json new file mode 100644 index 0000000..1e855b9 --- /dev/null +++ b/packages/solaredgeapi/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": [ + "bun-types" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..33f2338 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: SolarEdge Optimizer Apps +url: https://git.jensneuber.de/jens/solaredge-optimizers +maintainer: Jens Neuber diff --git a/solaredge-optimizer-data/.dockerignore b/solaredge-optimizer-data/.dockerignore new file mode 100644 index 0000000..18ddb1c --- /dev/null +++ b/solaredge-optimizer-data/.dockerignore @@ -0,0 +1,4 @@ +* +!Dockerfile +!dist/server.js +!run.sh diff --git a/solaredge-optimizer-data/CHANGELOG.md b/solaredge-optimizer-data/CHANGELOG.md new file mode 100644 index 0000000..432fb5c --- /dev/null +++ b/solaredge-optimizer-data/CHANGELOG.md @@ -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. diff --git a/solaredge-optimizer-data/DOCS.md b/solaredge-optimizer-data/DOCS.md new file mode 100644 index 0000000..7d76743 --- /dev/null +++ b/solaredge-optimizer-data/DOCS.md @@ -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. diff --git a/solaredge-optimizer-data/Dockerfile b/solaredge-optimizer-data/Dockerfile new file mode 100644 index 0000000..a77f20e --- /dev/null +++ b/solaredge-optimizer-data/Dockerfile @@ -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"] diff --git a/solaredge-optimizer-data/README.md b/solaredge-optimizer-data/README.md new file mode 100644 index 0000000..0aa086c --- /dev/null +++ b/solaredge-optimizer-data/README.md @@ -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:`. diff --git a/solaredge-optimizer-data/config.yaml b/solaredge-optimizer-data/config.yaml new file mode 100644 index 0000000..0cf6e2a --- /dev/null +++ b/solaredge-optimizer-data/config.yaml @@ -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)" diff --git a/solaredge-optimizer-data/dist/server.js b/solaredge-optimizer-data/dist/server.js new file mode 100644 index 0000000..746de7a --- /dev/null +++ b/solaredge-optimizer-data/dist/server.js @@ -0,0 +1,194 @@ +// @bun +var x=(a,o)=>()=>(o||a((o={exports:{}}).exports,o),o.exports);var Bo=import.meta.require;var $a=x((re,_a)=>{var Ko=/^xn--/,Ho=/[^\0-\x7F]/,Qo=/[\x2E\u3002\uFF0E\uFF61]/g,Wo={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},q=Math.floor,ia=String.fromCharCode;function F(a){throw RangeError(Wo[a])}function Yo(a,o){let i=[],s=a.length;while(s--)i[s]=o(a[s]);return i}function Oa(a,o){let i=a.split("@"),s="";if(i.length>1)s=i[0]+"@",a=i[1];a=a.replace(Qo,".");let e=a.split("."),n=Yo(e,o).join(".");return s+n}function Aa(a){let o=[],i=0,s=a.length;while(i=55296&&e<=56319&&iString.fromCodePoint(...a),Xo=function(a){if(a>=48&&a<58)return 26+(a-48);if(a>=65&&a<91)return a-65;if(a>=97&&a<123)return a-97;return 36},Sa=function(a,o){return a+22+75*(a<26)-((o!=0)<<5)},Ia=function(a,o,i){let s=0;a=i?q(a/700):a>>1,a+=q(a/o);for(;a>455;s+=36)a=q(a/35);return q(s+36*a/(a+38))},Ra=function(a){let o=[],i=a.length,s=0,e=128,n=72,t=a.lastIndexOf("-");if(t<0)t=0;for(let m=0;m=128)F("not-basic");o.push(a.charCodeAt(m))}for(let m=t>0?t+1:0;m=i)F("invalid-input");let h=Xo(a.charCodeAt(m++));if(h>=36)F("invalid-input");if(h>q((2147483647-s)/p))F("overflow");s+=h*p;let b=r<=n?1:r>=n+26?26:r-n;if(hq(2147483647/l))F("overflow");p*=l}let c=o.length+1;if(n=Ia(s-u,c,u==0),q(s/c)>2147483647-e)F("overflow");e+=q(s/c),s%=c,o.splice(s++,0,e)}return String.fromCodePoint(...o)},qa=function(a){let o=[];a=Aa(a);let i=a.length,s=128,e=0,n=72;for(let u of a)if(u<128)o.push(ia(u));let t=o.length,m=t;if(t)o.push("-");while(m=s&&pq((2147483647-e)/c))F("overflow");e+=(u-s)*c,s=u;for(let p of a){if(p2147483647)F("overflow");if(p===s){let r=e;for(let h=36;;h+=36){let b=h<=n?1:h>=n+26?26:h-n;if(r{Ta.exports=function(o,i){if(i=i.split(":")[0],o=+o,!o)return!1;switch(i){case"http":case"ws":return o!==80;case"https":case"wss":return o!==443;case"ftp":return o!==21;case"gopher":return o!==70;case"file":return!1}return o!==0}});var Fa=x((ss)=>{var Uo=Object.prototype.hasOwnProperty,Co;function Da(a){try{return decodeURIComponent(a.replace(/\+/g," "))}catch(o){return null}}function Ga(a){try{return encodeURIComponent(a)}catch(o){return null}}function as(a){var o=/([^=?#&]+)=?([^&]*)/g,i={},s;while(s=o.exec(a)){var e=Da(s[1]),n=Da(s[2]);if(e===null||n===null||e in i)continue;i[e]=n}return i}function os(a,o){o=o||"";var i=[],s,e;if(typeof o!=="string")o="?";for(e in a)if(Uo.call(a,e)){if(s=a[e],!s&&(s===null||s===Co||isNaN(s)))s="";if(e=Ga(e),s=Ga(s),e===null||s===null)continue;i.push(e+"="+s)}return i.length?o+i.join("&"):""}ss.stringify=os;ss.parse=as});var Ya=x((ce,Wa)=>{var Za=La(),J=Fa(),ns=/^[\x00-\x20\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/,Ba=/[\n\r\t]/g,ts=/^[A-Za-z][A-Za-z0-9+-.]*:\/\//,Ka=/:\d+$/,ms=/^([a-z][a-z0-9.+-]*:)?(\/\/)?([\\/]+)?([\S\s]*)/i,rs=/^[a-zA-Z]:/;function na(a){return(a?a:"").toString().replace(ns,"")}var ea=[["#","hash"],["?","query"],function(o,i){return _(i.protocol)?o.replace(/\\/g,"/"):o},["/","pathname"],["@","auth",1],[NaN,"host",void 0,1,1],[/:(\d*)$/,"port",void 0,1],[NaN,"hostname",void 0,1,1]],Na={hash:1,query:1};function Ha(a){var o;if(typeof window<"u")o=window;else if(typeof global<"u")o=global;else if(typeof self<"u")o=self;else o={};var i=o.location||{};a=a||i;var s={},e=typeof a,n;if(a.protocol==="blob:")s=new $(unescape(a.pathname),{});else if(e==="string"){s=new $(a,{});for(n in Na)delete s[n]}else if(e==="object"){for(n in a){if(n in Na)continue;s[n]=a[n]}if(s.slashes===void 0)s.slashes=ts.test(a.href)}return s}function _(a){return a==="file:"||a==="ftp:"||a==="http:"||a==="https:"||a==="ws:"||a==="wss:"}function Qa(a,o){a=na(a),a=a.replace(Ba,""),o=o||{};var i=ms.exec(a),s=i[1]?i[1].toLowerCase():"",e=!!i[2],n=!!i[3],t=0,m;if(e)if(n)m=i[2]+i[3]+i[4],t=i[2].length+i[3].length;else m=i[2]+i[4],t=i[2].length;else if(n)m=i[3]+i[4],t=i[3].length;else m=i[4];if(s==="file:"){if(t>=2)m=m.slice(2)}else if(_(s))m=i[4];else if(s){if(e)m=m.slice(2)}else if(t>=2&&_(o.protocol))m=i[4];return{protocol:s,slashes:e||_(s),slashesCount:t,rest:m}}function ps(a,o){if(a==="")return o;var i=(o||"/").split("/").slice(0,-1).concat(a.split("/")),s=i.length,e=i[s-1],n=!1,t=0;while(s--)if(i[s]===".")i.splice(s,1);else if(i[s]==="..")i.splice(s,1),t++;else if(t){if(s===0)n=!0;i.splice(s,1),t--}if(n)i.unshift("");if(e==="."||e==="..")i.push("");return i.join("/")}function $(a,o,i){if(a=na(a),a=a.replace(Ba,""),!(this instanceof $))return new $(a,o,i);var s,e,n,t,m,u,c=ea.slice(),p=typeof o,r=this,h=0;if(p!=="object"&&p!=="string")i=o,o=null;if(i&&typeof i!=="function")i=J.parse;if(o=Ha(o),e=Qa(a||"",o),s=!e.protocol&&!e.slashes,r.slashes=e.slashes||s&&o.slashes,r.protocol=e.protocol||o.protocol||"",a=e.rest,e.protocol==="file:"&&(e.slashesCount!==2||rs.test(a))||!e.slashes&&(e.protocol||e.slashesCount<2||!_(r.protocol)))c[3]=[/(.*)/,"pathname"];for(;h{Object.defineProperties(Pa,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});function gs(a){return a&&a.__esModule&&Object.prototype.hasOwnProperty.call(a,"default")?a.default:a}var ta,Ma;function ks(){if(Ma)return ta;Ma=1;let a=2147483647,o=36,i=1,s=26,e=38,n=700,t=72,m=128,u="-",c=/^xn--/,p=/[^\0-\x7F]/,r=/[\x2E\u3002\uFF0E\uFF61]/g,h={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},b=o-i,l=Math.floor,w=String.fromCharCode;function v(g){throw RangeError(h[g])}function K(g,d){let y=[],j=g.length;for(;j--;)y[j]=d(g[j]);return y}function ba(g,d){let y=g.split("@"),j="";y.length>1&&(j=y[0]+"@",g=y[1]),g=g.replace(r,".");let f=g.split("."),E=K(f,d).join(".");return j+E}function ya(g){let d=[],y=0,j=g.length;for(;y=55296&&f<=56319&&yString.fromCodePoint(...g),Zo=function(g){return g>=48&&g<58?26+(g-48):g>=65&&g<91?g-65:g>=97&&g<123?g-97:o},fa=function(g,d){return g+22+75*(g<26)-((d!=0)<<5)},za=function(g,d,y){let j=0;for(g=y?l(g/n):g>>1,g+=l(g/d);g>b*s>>1;j+=o)g=l(g/b);return l(j+(b+1)*g/(g+e))},va=function(g){let d=[],y=g.length,j=0,f=m,E=t,T=g.lastIndexOf(u);T<0&&(T=0);for(let S=0;S=128&&v("not-basic"),d.push(g.charCodeAt(S));for(let S=T>0?T+1:0;S=y&&v("invalid-input");let D=Zo(g.charCodeAt(S++));D>=o&&v("invalid-input"),D>l((a-j)/A)&&v("overflow"),j+=D*A;let Z=L<=E?i:L>=E+s?s:L-E;if(Dl(a/Q)&&v("overflow"),A*=Q}let G=d.length+1;E=za(j-O,G,O==0),l(j/G)>a-f&&v("overflow"),f+=l(j/G),j%=G,d.splice(j++,0,f)}return String.fromCodePoint(...d)},Ea=function(g){let d=[];g=ya(g);let y=g.length,j=m,f=0,E=t;for(let O of g)O<128&&d.push(w(O));let T=d.length,S=T;for(T&&d.push(u);S=j&&Al((a-f)/G)&&v("overflow"),f+=(O-j)*G,j=O;for(let A of g)if(Aa&&v("overflow"),A===j){let L=f;for(let D=o;;D+=o){let Z=D<=E?i:D>=E+s?s:D-E;if(L{let i=o.replace(/^(\*\.|\!)/,""),s=W.toASCII(i),e=o.charAt(0);if(a.has(s))throw Error(`Multiple rules found for ${o} (${s})`);return a.set(s,{rule:o,suffix:i,punySuffix:s,wildcard:e==="*",exception:e==="!"}),a},new Map),js=(a)=>{let o=W.toASCII(a).split(".");for(let i=0;i{let o=W.toASCII(a);if(o.length<1)return"DOMAIN_TOO_SHORT";if(o.length>255)return"DOMAIN_TOO_LONG";let i=o.split("."),s;for(let e=0;e63)return"LABEL_TOO_LONG";if(s.charAt(0)==="-")return"LABEL_STARTS_WITH_DASH";if(s.charAt(s.length-1)==="-")return"LABEL_ENDS_WITH_DASH";if(!/^[a-z0-9\-_]+$/.test(s))return"LABEL_INVALID_CHARS"}},P=(a)=>{if(typeof a!="string")throw TypeError("Domain name must be a string.");let o=a.slice(0).toLowerCase();o.charAt(o.length-1)==="."&&(o=o.slice(0,o.length-1));let i=ws(o);if(i)return{input:a,error:{message:Xa[i],code:i}};let s={input:a,tld:null,sld:null,domain:null,subdomain:null,listed:!1},e=o.split(".");if(e[e.length-1]==="local")return s;let n=()=>(/xn--/.test(o)&&(s.domain&&(s.domain=W.toASCII(s.domain)),s.subdomain&&(s.subdomain=W.toASCII(s.subdomain))),s),t=js(o);if(!t)return e.length<2?s:(s.tld=e.pop(),s.sld=e.pop(),s.domain=[s.sld,s.tld].join("."),e.length&&(s.subdomain=e.pop()),n());s.listed=!0;let m=t.suffix.split("."),u=e.slice(0,e.length-m.length);return t.exception&&u.push(m.shift()),s.tld=m.join("."),!u.length||(t.wildcard&&(m.unshift(u.pop()),s.tld=m.join(".")),!u.length)||(s.sld=u.pop(),s.domain=[s.sld,s.tld].join("."),u.length&&(s.subdomain=u.join("."))),n()},Va=(a)=>a&&P(a).domain||null,Ja=(a)=>{let o=P(a);return!!(o.domain&&o.listed)},bs={parse:P,get:Va,isValid:Ja};Pa.default=bs;Pa.errorCodes=Xa;Pa.get=Va;Pa.isValid=Ja;Pa.parse=P});var ma=x((As)=>{/*! + * Copyright (c) 2018, Salesforce.com, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of Salesforce.com nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */var xs=Ua(),Ca=["local","example","invalid","localhost","test"],Ss=["localhost","invalid"];function Os(a,o={}){let i=a.split("."),s=i[i.length-1],e=!!o.allowSpecialUseDomain,n=!!o.ignoreError;if(e&&Ca.includes(s)){if(i.length>1)return`${i[i.length-2]}.${s}`;else if(Ss.includes(s))return`${s}`}if(!n&&Ca.includes(s))throw Error(`Cookie has domain set to the public suffix "${s}" which is a special use domain. To allow this, configure your CookieJar with {allowSpecialUseDomain:true, rejectPublicSuffixes: false}.`);return xs.get(a)}As.getPublicSuffix=Os});var ra=x((Rs)=>{/*! + * Copyright (c) 2015, Salesforce.com, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of Salesforce.com nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */class ao{constructor(){this.synchronous=!1}findCookie(a,o,i,s){throw Error("findCookie is not implemented")}findCookies(a,o,i,s){throw Error("findCookies is not implemented")}putCookie(a,o){throw Error("putCookie is not implemented")}updateCookie(a,o,i){throw Error("updateCookie is not implemented")}removeCookie(a,o,i,s){throw Error("removeCookie is not implemented")}removeCookies(a,o,i){throw Error("removeCookies is not implemented")}removeAllCookies(a){throw Error("removeAllCookies is not implemented")}getAllCookies(a){throw Error("getAllCookies is not implemented (therefore jar cannot be serialized)")}}Rs.Store=ao});var pa=x((_s)=>{_s.fromCallback=function(a){return Object.defineProperty(function(){if(typeof arguments[arguments.length-1]==="function")a.apply(this,arguments);else return new Promise((o,i)=>{arguments[arguments.length]=(s,e)=>{if(s)return i(s);o(e)},arguments.length++,a.apply(this,arguments)})},"name",{value:a.name})};_s.fromPromise=function(a){return Object.defineProperty(function(){let o=arguments[arguments.length-1];if(typeof o!=="function")return a.apply(this,arguments);else delete arguments[arguments.length-1],arguments.length--,a.apply(this,arguments).then((i)=>o(null,i),o)},"name",{value:a.name})}});var ua=x((Gs)=>{/*! + * Copyright (c) 2015, Salesforce.com, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of Salesforce.com nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */var Ls=ma();function Ds(a,o){let i=Ls.getPublicSuffix(a,{allowSpecialUseDomain:o});if(!i)return null;if(i==a)return[a];if(a.slice(-1)==".")a=a.slice(0,-1);let e=a.slice(0,-(i.length+1)).split(".").reverse(),n=i,t=[n];while(e.length)n=`${e.shift()}.${n}`,t.push(n);return t}Gs.permuteDomain=Ds});var ca=x((Zs)=>{/*! + * Copyright (c) 2015, Salesforce.com, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of Salesforce.com nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */function Ns(a,o){if(o===a)return!0;if(a.indexOf(o)===0){if(o.substr(-1)==="/")return!0;if(a.substr(o.length,1)==="/")return!0}return!1}Zs.pathMatch=Ns});var ga=x((Qs)=>{function oo(){try{return Bo("util")}catch(a){return null}}function Ks(){return Symbol.for("nodejs.util.inspect.custom")}function Hs(a){let i=(a.requireUtil||oo)();return i?i.inspect.custom:null}Qs.getUtilInspect=function(o,i={}){let e=(i.requireUtil||oo)();return function(t,m,u){return e?e.inspect(t,m,u):o(t)}};Qs.getCustomInspectSymbol=function(o={}){return(o.lookupCustomInspectSymbol||Ks)()||Hs(o)}});var io=x((oi)=>{/*! + * Copyright (c) 2015, Salesforce.com, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of Salesforce.com nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */var{fromCallback:Ms}=pa(),Xs=ra().Store,Vs=ua().permuteDomain,Js=ca().pathMatch,{getCustomInspectSymbol:Ps,getUtilInspect:Us}=ga();class U extends Xs{constructor(){super();this.synchronous=!0,this.idx=Object.create(null);let a=Ps();if(a)this[a]=this.inspect}inspect(){return`{ idx: ${{inspect:Us(so)}.inspect(this.idx,!1,2)} }`}findCookie(a,o,i,s){if(!this.idx[a])return s(null,void 0);if(!this.idx[a][o])return s(null,void 0);return s(null,this.idx[a][o][i]||null)}findCookies(a,o,i,s){let e=[];if(typeof i==="function")s=i,i=!0;if(!a)return s(null,[]);let n;if(!o)n=function(c){for(let p in c){let r=c[p];for(let h in r)e.push(r[h])}};else n=function(c){Object.keys(c).forEach((p)=>{if(Js(o,p)){let r=c[p];for(let h in r)e.push(r[h])}})};let t=Vs(a,i)||[a],m=this.idx;t.forEach((u)=>{let c=m[u];if(!c)return;n(c)}),s(null,e)}putCookie(a,o){if(!this.idx[a.domain])this.idx[a.domain]=Object.create(null);if(!this.idx[a.domain][a.path])this.idx[a.domain][a.path]=Object.create(null);this.idx[a.domain][a.path][a.key]=a,o(null)}updateCookie(a,o,i){this.putCookie(o,i)}removeCookie(a,o,i,s){if(this.idx[a]&&this.idx[a][o]&&this.idx[a][o][i])delete this.idx[a][o][i];s(null)}removeCookies(a,o,i){if(this.idx[a])if(o)delete this.idx[a][o];else delete this.idx[a];return i(null)}removeAllCookies(a){return this.idx=Object.create(null),a(null)}getAllCookies(a){let o=[],i=this.idx;Object.keys(i).forEach((e)=>{Object.keys(i[e]).forEach((t)=>{Object.keys(i[e][t]).forEach((u)=>{if(u!==null)o.push(i[e][t][u])})})}),o.sort((e,n)=>{return(e.creationIndex||0)-(n.creationIndex||0)}),a(null,o)}}["findCookie","findCookies","putCookie","updateCookie","removeCookie","removeCookies","removeAllCookies","getAllCookies"].forEach((a)=>{U.prototype[a]=Ms(U.prototype[a])});oi.MemoryCookieStore=U;function so(a){let o=Object.keys(a);if(o.length===0)return"[Object: null prototype] {}";let i=`[Object: null prototype] { +`;return Object.keys(a).forEach((s,e)=>{if(i+=Cs(s,a[s]),e{if(s+=ai(e,o[e]),n{let m=o[e];if(s+=` ${e}: ${m.inspect()}`,n{var ei=Object.prototype.toString;function eo(a){return typeof a==="function"}function no(a){return to(a)&&a!==""}function ni(a){return mo(a,Date)&&ri(a.getTime())}function ti(a){return a===""||a instanceof String&&a.toString()===""}function to(a){return typeof a==="string"||a instanceof String}function ka(a){return ei.call(a)==="[object Object]"}function mo(a,o){try{return a instanceof o}catch(i){return!1}}function mi(a){return no(a)||ka(a)&&"hostname"in a&&"pathname"in a&&"protocol"in a||mo(a,URL)}function ri(a){return typeof a==="number"&&a%1===0}function pi(a,o,i){if(!eo(o))i=o,o=null;if(!ka(i))i={Error:"Failed Check"};if(!a)if(o)o(new C(i));else throw new C(i)}class C extends Error{constructor(...a){super(...a)}}ui.ParameterError=C;ui.isFunction=eo;ui.isNonEmptyString=no;ui.isDate=ni;ui.isEmptyString=ti;ui.isString=to;ui.isObject=ka;ui.isUrlStringOrObject=mi;ui.validate=pi});var uo=x((fe,po)=>{po.exports="4.1.4"});/*! + * Copyright (c) 2015-2020, Salesforce.com, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of Salesforce.com nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */var co=$a(),yi=Ya(),ha=ma(),fi=ra().Store,zi=io().MemoryCookieStore,vi=ca().pathMatch,k=ro(),Ei=uo(),{fromCallback:yo}=pa(),{getCustomInspectSymbol:xi}=ga(),Si=/^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/,go=/[\x00-\x1F]/,ko=[` +`,"\r","\x00"],Oi=/[\x20-\x3A\x3C-\x7E]+/,Ai=/[\x09\x20-\x2F\x3B-\x40\x5B-\x60\x7B-\x7E]/,Ii={jan:0,feb:1,mar:2,apr:3,may:4,jun:5,jul:6,aug:7,sep:8,oct:9,nov:10,dec:11};function lo(a){k.validate(k.isNonEmptyString(a),a);let o=String(a).toLowerCase();if(o==="none"||o==="lax"||o==="strict")return o;else return null}var H=Object.freeze({SILENT:"silent",STRICT:"strict",DISABLED:"unsafe-disabled"}),Ri=/(?:^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$)|(?:^(?:(?:[a-f\d]{1,4}:){7}(?:[a-f\d]{1,4}|:)|(?:[a-f\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-f\d]{1,4}|:)|(?:[a-f\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,2}|:)|(?:[a-f\d]{1,4}:){4}(?:(?::[a-f\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,3}|:)|(?:[a-f\d]{1,4}:){3}(?:(?::[a-f\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,4}|:)|(?:[a-f\d]{1,4}:){2}(?:(?::[a-f\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,5}|:)|(?:[a-f\d]{1,4}:){1}(?:(?::[a-f\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,6}|:)|(?::(?:(?::[a-f\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,7}|:)))$)/,qi=` +\\[?(?: +(?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)| +(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|:[a-fA-F\\d]{1,4}|:)| +(?:[a-fA-F\\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,2}|:)| +(?:[a-fA-F\\d]{1,4}:){4}(?:(?::[a-fA-F\\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,3}|:)| +(?:[a-fA-F\\d]{1,4}:){3}(?:(?::[a-fA-F\\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,4}|:)| +(?:[a-fA-F\\d]{1,4}:){2}(?:(?::[a-fA-F\\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,5}|:)| +(?:[a-fA-F\\d]{1,4}:){1}(?:(?::[a-fA-F\\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,6}|:)| +(?::(?:(?::[a-fA-F\\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,7}|:)) +)(?:%[0-9a-zA-Z]{1,})?\\]? +`.replace(/\s*\/\/.*$/gm,"").replace(/\n/g,"").trim(),fo=new RegExp(`^${qi}$`);function la(a,o,i,s){let e=0;while(e=58)break;e++}if(ei)return null;if(!s&&e!=a.length)return null;return parseInt(a.substr(0,e),10)}function _i(a){let o=a.split(":"),i=[0,0,0];if(o.length!==3)return null;for(let s=0;s<3;s++){let e=s==2,n=la(o[s],1,2,e);if(n===null)return null;i[s]=n}return i}function $i(a){a=String(a).substr(0,3).toLowerCase();let o=Ii[a];return o>=0?o:null}function aa(a){if(!a)return;let o=a.split(Ai);if(!o)return;let i=null,s=null,e=null,n=null,t=null,m=null;for(let u=0;u=70&&m<=99)m+=1900;else if(m>=0&&m<=69)m+=2000}}}if(n===null||t===null||m===null||e===null||n<1||n>31||m<1601||i>23||s>59||e>59)return;return new Date(Date.UTC(m,t,n,i,s,e))}function Ti(a){return k.validate(k.isDate(a),a),a.toUTCString()}function M(a){if(a==null)return null;if(a=a.trim().replace(/^\./,""),fo.test(a))a=a.replace("[","").replace("]","");if(co&&/[^\u0001-\u007f]/.test(a))a=co.toASCII(a);return a.toLowerCase()}function ho(a,o,i){if(a==null||o==null)return null;if(i!==!1)a=M(a),o=M(o);if(a==o)return!0;let s=a.lastIndexOf(o);if(s<=0)return!1;if(a.length!==o.length+s)return!1;if(a.substr(s-1,1)!==".")return!1;if(Ri.test(a))return!1;return!0}function Li(a){if(!a||a.substr(0,1)!=="/")return"/";if(a==="/")return a;let o=a.lastIndexOf("/");if(o===0)return"/";return a.slice(0,o)}function Di(a){if(k.isEmptyString(a))return a;for(let o=0;o{a+=`; ${o}`});return a}TTL(a){if(this.maxAge!=null)return this.maxAge<=0?0:this.maxAge*1000;let o=this.expires;if(o!=1/0){if(!(o instanceof Date))o=aa(o)||1/0;if(o==1/0)return 1/0;return o.getTime()-(a||Date.now())}return 1/0}expiryTime(a){if(this.maxAge!=null){let o=a||this.creation||new Date,i=this.maxAge<=0?-1/0:this.maxAge*1000;return o.getTime()+i}if(this.expires==1/0)return 1/0;return this.expires.getTime()}expiryDate(a){let o=this.expiryTime(a);if(o==1/0)return new Date(2147483647000);else if(o==-1/0)return new Date(0);else return new Date(o)}isPersistent(){return this.maxAge!=null||this.expires!=1/0}canonicalizedDomain(){if(this.domain==null)return null;return M(this.domain)}cdomain(){return this.canonicalizedDomain()}}z.cookiesCreated=0;z.parse=Fi;z.fromJSON=da;z.serializableProperties=Object.keys(Y);z.sameSiteLevel={strict:3,lax:2,none:1};z.sameSiteCanonical={strict:"Strict",lax:"Lax"};function bo(a){if(a!=null){let o=a.toLowerCase();switch(o){case H.STRICT:case H.SILENT:case H.DISABLED:return o}}return H.SILENT}class I{constructor(a,o={rejectPublicSuffixes:!0}){if(typeof o==="boolean")o={rejectPublicSuffixes:o};k.validate(k.isObject(o),o),this.rejectPublicSuffixes=o.rejectPublicSuffixes,this.enableLooseMode=!!o.looseMode,this.allowSpecialUseDomain=typeof o.allowSpecialUseDomain==="boolean"?o.allowSpecialUseDomain:!0,this.store=a||new zi,this.prefixSecurity=bo(o.prefixSecurity),this._cloneSync=N("clone"),this._importCookiesSync=N("_importCookies"),this.getCookiesSync=N("getCookies"),this.getCookieStringSync=N("getCookieString"),this.getSetCookieStringsSync=N("getSetCookieStrings"),this.removeAllCookiesSync=N("removeAllCookies"),this.setCookieSync=N("setCookie"),this.serializeSync=N("serialize")}setCookie(a,o,i,s){k.validate(k.isUrlStringOrObject(o),s,i);let e;if(k.isFunction(o))return s=o,s(Error("No URL was specified"));let n=wo(o);if(k.isFunction(i))s=i,i={};if(k.validate(k.isFunction(s),s),!k.isNonEmptyString(a)&&!k.isObject(a)&&a instanceof String&&a.length==0)return s(null);let t=M(n.hostname),m=i.loose||this.enableLooseMode,u=null;if(i.sameSiteContext){if(u=lo(i.sameSiteContext),!u)return s(Error('Invalid sameSiteContext option for getCookies(); expected one of "strict", "lax", or "none"'))}if(typeof a==="string"||a instanceof String){if(a=z.parse(a,{loose:m}),!a)return e=Error("Cookie failed to parse"),s(i.ignoreError?null:e)}else if(!(a instanceof z))return e=Error("First argument to setCookie must be a Cookie object or string"),s(i.ignoreError?null:e);let c=i.now||new Date;if(this.rejectPublicSuffixes&&a.domain){if(ha.getPublicSuffix(a.cdomain(),{allowSpecialUseDomain:this.allowSpecialUseDomain,ignoreError:i.ignoreError})==null&&!fo.test(a.domain))return e=Error("Cookie has domain set to a public suffix"),s(i.ignoreError?null:e)}if(a.domain){if(!ho(t,a.cdomain(),!1))return e=Error(`Cookie not in this host's domain. Cookie:${a.cdomain()} Request:${t}`),s(i.ignoreError?null:e);if(a.hostOnly==null)a.hostOnly=!1}else a.hostOnly=!0,a.domain=t;if(!a.path||a.path[0]!=="/")a.path=Li(n.pathname),a.pathIsDefault=!0;if(i.http===!1&&a.httpOnly)return e=Error("Cookie is HttpOnly and this isn't an HTTP API"),s(i.ignoreError?null:e);if(a.sameSite!=="none"&&a.sameSite!==void 0&&u){if(u==="none")return e=Error("Cookie is SameSite but this is a cross-origin request"),s(i.ignoreError?null:e)}let p=this.prefixSecurity===H.SILENT;if(this.prefixSecurity!==H.DISABLED){let l=!1,w;if(!Ni(a))l=!0,w="Cookie has __Secure prefix but Secure attribute is not set";else if(!Zi(a))l=!0,w="Cookie has __Host prefix but either Secure or HostOnly attribute is not set or Path is not '/'";if(l)return s(i.ignoreError||p?null:Error(w))}let h=this.store;if(!h.updateCookie)h.updateCookie=function(l,w,v){this.putCookie(w,v)};function b(l,w){if(l)return s(l);let v=function(K){if(K)return s(K);else s(null,a)};if(w){if(i.http===!1&&w.httpOnly)return l=Error("old Cookie is HttpOnly and this isn't an HTTP API"),s(i.ignoreError?null:l);a.creation=w.creation,a.creationIndex=w.creationIndex,a.lastAccessed=c,h.updateCookie(w,a,v)}else a.creation=a.lastAccessed=c,h.putCookie(a,v)}h.findCookie(a.domain,a.path,a.key,b)}getCookies(a,o,i){k.validate(k.isUrlStringOrObject(a),i,a);let s=wo(a);if(k.isFunction(o))i=o,o={};k.validate(k.isObject(o),i,o),k.validate(k.isFunction(i),i);let e=M(s.hostname),n=s.pathname||"/",t=o.secure;if(t==null&&s.protocol&&(s.protocol=="https:"||s.protocol=="wss:"))t=!0;let m=0;if(o.sameSiteContext){let l=lo(o.sameSiteContext);if(m=z.sameSiteLevel[l],!m)return i(Error('Invalid sameSiteContext option for getCookies(); expected one of "strict", "lax", or "none"'))}let u=o.http;if(u==null)u=!0;let c=o.now||Date.now(),p=o.expire!==!1,r=!!o.allPaths,h=this.store;function b(l){if(l.hostOnly){if(l.domain!=e)return!1}else if(!ho(e,l.domain,!1))return!1;if(!r&&!vi(n,l.path))return!1;if(l.secure&&!t)return!1;if(l.httpOnly&&!u)return!1;if(m){if(z.sameSiteLevel[l.sameSite||"none"]>m)return!1}if(p&&l.expiryTime()<=c)return h.removeCookie(l.domain,l.path,l.key,()=>{}),!1;return!0}h.findCookies(e,r?null:n,this.allowSpecialUseDomain,(l,w)=>{if(l)return i(l);if(w=w.filter(b),o.sort!==!1)w=w.sort(jo);let v=new Date;for(let K of w)K.lastAccessed=v;i(null,w)})}getCookieString(...a){let o=a.pop();k.validate(k.isFunction(o),o);let i=function(s,e){if(s)o(s);else o(null,e.sort(jo).map((n)=>n.cookieString()).join("; "))};a.push(i),this.getCookies.apply(this,a)}getSetCookieStrings(...a){let o=a.pop();k.validate(k.isFunction(o),o);let i=function(s,e){if(s)o(s);else o(null,e.map((n)=>{return n.toString()}))};a.push(i),this.getCookies.apply(this,a)}serialize(a){k.validate(k.isFunction(a),a);let o=this.store.constructor.name;if(k.isObject(o))o=null;let i={version:`tough-cookie@${Ei}`,storeType:o,rejectPublicSuffixes:!!this.rejectPublicSuffixes,enableLooseMode:!!this.enableLooseMode,allowSpecialUseDomain:!!this.allowSpecialUseDomain,prefixSecurity:bo(this.prefixSecurity),cookies:[]};if(!(this.store.getAllCookies&&typeof this.store.getAllCookies==="function"))return a(Error("store does not support getAllCookies and cannot be serialized"));this.store.getAllCookies((s,e)=>{if(s)return a(s);return i.cookies=e.map((n)=>{return n=n instanceof z?n.toJSON():n,delete n.creationIndex,n}),a(null,i)})}toJSON(){return this.serializeSync()}_importCookies(a,o){let i=a.cookies;if(!i||!Array.isArray(i))return o(Error("serialized jar has no cookies array"));i=i.slice();let s=(e)=>{if(e)return o(e);if(!i.length)return o(e,this);let n;try{n=da(i.shift())}catch(t){return o(t)}if(n===null)return s(null);this.store.putCookie(n,s)};s()}clone(a,o){if(arguments.length===1)o=a,a=null;this.serialize((i,s)=>{if(i)return o(i);I.deserialize(s,a,o)})}cloneSync(a){if(arguments.length===0)return this._cloneSync();if(!a.synchronous)throw Error("CookieJar clone destination store is not synchronous; use async API instead.");return this._cloneSync(a)}removeAllCookies(a){k.validate(k.isFunction(a),a);let o=this.store;if(typeof o.removeAllCookies==="function"&&o.removeAllCookies!==fi.prototype.removeAllCookies)return o.removeAllCookies(a);o.getAllCookies((i,s)=>{if(i)return a(i);if(s.length===0)return a(null);let e=0,n=[];function t(m){if(m)n.push(m);if(e++,e===s.length)return a(n.length?n[0]:null)}s.forEach((m)=>{o.removeCookie(m.domain,m.path,m.key,t)})})}static deserialize(a,o,i){if(arguments.length!==3)i=o,o=null;k.validate(k.isFunction(i),i);let s;if(typeof a==="string"){if(s=zo(a),s instanceof Error)return i(s)}else s=a;let e=new I(o,{rejectPublicSuffixes:s.rejectPublicSuffixes,looseMode:s.enableLooseMode,allowSpecialUseDomain:s.allowSpecialUseDomain,prefixSecurity:s.prefixSecurity});e._importCookies(s,(n)=>{if(n)return i(n);i(null,e)})}static deserializeSync(a,o){let i=typeof a==="string"?JSON.parse(a):a,s=new I(o,{rejectPublicSuffixes:i.rejectPublicSuffixes,looseMode:i.enableLooseMode});if(!s.store.synchronous)throw Error("CookieJar store is not synchronous; use async API instead.");return s._importCookiesSync(i),s}}I.fromJSON=I.deserializeSync;["_importCookies","clone","getCookies","getCookieString","getSetCookieStrings","removeAllCookies","serialize","setCookie"].forEach((a)=>{I.prototype[a]=yo(I.prototype[a])});I.deserialize=yo(I.deserialize);function N(a){return function(...o){if(!this.store.synchronous)throw Error("CookieJar store is not synchronous; use async API instead.");let i,s;if(this[a](...o,(e,n)=>{i=e,s=n}),i)throw i;return s}}var ja=I;var Bi=ha.getPublicSuffix;var Ki=ua().permuteDomain;var Hi=k.ParameterError;var Ro="https://monitoring.solaredge.com",qo="https://login.solaredge.com";var vo="ugfnsujd3384sshcjehaphlh3",Eo="/mfe/auth/callback",Qi=30000,xo=10;class R extends Error{status;code;constructor(a,o){super(a,{cause:o.cause});this.name="SolarEdgePortalAuthError",this.status=o.status??0,this.code=o.code}}class B extends Error{status;constructor(a,o){super(a,{cause:o.cause});this.name="SolarEdgePortalApiError",this.status=o.status}}class oa{username;password;defaultSiteId;portalBaseUrl;loginBaseUrl;fetchImplementation;cookieJar;accessToken;accessTokenExpiresAt=0;authenticationPromise;constructor({username:a,password:o,siteId:i,portalBaseUrl:s=Ro,loginBaseUrl:e=qo,fetch:n=globalThis.fetch,cookieJar:t=new ja}){if(!a.trim())throw Error("SolarEdge portal username must not be empty");if(!o)throw Error("SolarEdge portal password must not be empty");this.username=a,this.password=o,this.defaultSiteId=i,this.portalBaseUrl=s.replace(/\/+$/,""),this.loginBaseUrl=e.replace(/\/+$/,""),this.fetchImplementation=n,this.cookieJar=t}async authenticate(){await this.runSingleAuthentication(async()=>{await this.login()})}async getLogicalLayout(a,o){return this.get(`/services/layout/logical/generic/v2/site/${this.resolveSiteId(a)}`,{"include-optimizers":!0},o)}async listOptimizerSerials(a,o){return(await this.listOptimizerMappings(a,o)).map((s)=>s.serial)}async listOptimizerMappings(a,o){let i=await this.getLogicalLayout(a,o);return Ji(i)}async getOptimizerInformation(a,o){let i=Oo(a);return this.requestJson("/services/layout/information/optimizers",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i),signal:o?.signal})}async getOptimizerEnergy({siteId:a,startDate:o,endDate:i,optimizerSerials:s,chartTimeUnit:e="hours"},n){let t=Oo(s);return this.get(`/services/layout/energy-graph/site/${this.resolveSiteId(a)}/optimizers`,{"chart-time-unit":e,"start-date":Ao(o,"startDate"),"end-date":Ao(i,"endDate"),"optimizer-serials":t.join(",")},n)}async get(a,o={},i={}){let s=this.buildPortalUrl(a);for(let[e,n]of Object.entries(o))if(n!==void 0)s.searchParams.set(e,String(n));return this.requestJson(s,{headers:{Accept:"application/json"},signal:i.signal})}async requestJson(a,o,i=!0){let s=a instanceof URL?a:this.buildPortalUrl(a);if(s.origin!==new URL(this.portalBaseUrl).origin)throw new B("SolarEdge portal requests must use the configured portal origin",{status:0});await this.ensureAuthenticated();let e=new Headers(o.headers);e.set("Accept","application/json"),e.set("Authorization",`Bearer ${this.accessToken}`);let n;try{n=await this.requestWithCookies(s,{...o,headers:e})}catch(t){throw new B("SolarEdge portal request failed",{status:0,cause:t})}if(n.response.status===401&&i)return await this.reauthenticate(),this.requestJson(s,o,!1);if(!n.response.ok)throw new B(`SolarEdge portal request failed (${n.response.status})`,{status:n.response.status});try{return await n.response.json()}catch(t){throw new B("SolarEdge portal returned invalid JSON",{status:n.response.status,cause:t})}}async ensureAuthenticated(){if(this.accessToken&&Date.now(){if(this.accessToken&&await this.refreshSession())return;await this.login()})}async reauthenticate(){this.accessTokenExpiresAt=0,await this.runSingleAuthentication(async()=>{if(await this.refreshSession())return;await this.login()})}async runSingleAuthentication(a){if(!this.authenticationPromise)this.authenticationPromise=a().finally(()=>{this.authenticationPromise=void 0});await this.authenticationPromise}async login(){let a=Wi(),o=await Yi(a),i=`${this.portalBaseUrl}${Eo}`,s=new URL("/login",`${this.loginBaseUrl}/`);s.search=new URLSearchParams({lang:"en",response_type:"code",client_id:vo,scope:"email openid",redirect_uri:i,code_challenge_method:"S256",code_challenge:o}).toString();let e;try{e=await this.requestWithCookies(s,{headers:{Accept:"text/html"}})}catch(p){throw new R("Could not load the SolarEdge login page",{code:"LOGIN_PAGE_FAILED",cause:p})}if(!e.response.ok)throw new R(`Could not load the SolarEdge login page (${e.response.status})`,{code:"LOGIN_PAGE_FAILED",status:e.response.status});let n=Xi(await e.response.text(),"csrf");if(!n)throw new R("SolarEdge login page did not contain a CSRF token",{code:"LOGIN_PAGE_FAILED"});let t=new URLSearchParams({csrf:n,username:this.username,password:this.password,cognitoAsfData:""}),m;try{m=await this.requestWithCookies(s,{method:"POST",headers:{Accept:"text/html","Content-Type":"application/x-www-form-urlencoded",Origin:this.loginBaseUrl,Referer:s.toString()},body:t.toString()},(p)=>p.origin===new URL(this.portalBaseUrl).origin&&p.pathname===Eo)}catch(p){throw new R("SolarEdge login request failed",{code:"AUTH_FAILED",cause:p})}let u=m.url.searchParams.get("code");if(!u){let p=await m.response.text().catch(()=>""),r=/name=["']code["']|mfa|verify(?:Email|Sms|Totp|Password)/i.test(p);throw new R(r?"SolarEdge requires an additional authentication challenge":"SolarEdge portal authentication failed",{code:r?"AUTH_CHALLENGE_REQUIRED":"AUTH_FAILED",status:m.response.status})}let c=await this.exchangeAuthorizationCode(u,a,i);await this.createPortalSession(c),this.setAccessToken(c.access_token,c.expires_in)}async exchangeAuthorizationCode(a,o,i){let s=new URL("/oauth2/token",`${this.loginBaseUrl}/`),e;try{e=await this.requestWithCookies(s,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",client_id:vo,redirect_uri:i,code:a,code_verifier:o}).toString()})}catch(t){throw new R("SolarEdge token exchange request failed",{code:"TOKEN_EXCHANGE_FAILED",cause:t})}if(!e.response.ok)throw new R(`SolarEdge token exchange failed (${e.response.status})`,{code:"TOKEN_EXCHANGE_FAILED",status:e.response.status});let n=await Io(e.response);if(!n?.access_token||!Number.isFinite(n.expires_in)||n.expires_in<=0)throw new R("SolarEdge token exchange returned an invalid response",{code:"TOKEN_EXCHANGE_FAILED",status:e.response.status});return n}async createPortalSession(a){let o=new URL("/services/auth/token?legacy=false",`${this.portalBaseUrl}/`),i=await this.requestWithCookies(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id_token:a.id_token,access_token:a.access_token,refresh_token:a.refresh_token,expires_in:a.expires_in,token_type:a.token_type})});if(!i.response.ok)throw new R(`SolarEdge portal session creation failed (${i.response.status})`,{code:"SESSION_CREATION_FAILED",status:i.response.status})}async refreshSession(){let a=new URL("/services/auth/refresh",`${this.portalBaseUrl}/`);try{let o=await this.requestWithCookies(a,{method:"POST",headers:{Accept:"application/json"}});if(!o.response.ok)return!1;let i=await Io(o.response),s=i?.accessToken?.token,e=i?.accessToken?.expiresIn;if(!s||!e||!Number.isFinite(e))return!1;return this.setAccessToken(s,e),!0}catch{return!1}}setAccessToken(a,o){this.accessToken=a,this.accessTokenExpiresAt=Date.now()+o*1000}async requestWithCookies(a,o={},i){let s=new URL(String(a)),e=(o.method??"GET").toUpperCase(),n=o.body,t=new Headers(o.headers);for(let m=0;m<=xo;m+=1){let u=await this.cookieJar.getCookieString(s.toString());if(u)t.set("Cookie",u);else t.delete("Cookie");let c=await this.fetchImplementation(s,{...o,method:e,body:n,headers:t,redirect:"manual"});await this.storeResponseCookies(c,s);let p=c.headers.get("location");if(!p||!Pi(c.status))return{response:c,url:s};let r=new URL(p,s);if(i?.(r))return{response:c,url:r};if(m===xo)throw Error("SolarEdge request exceeded the redirect limit");if(c.status===303||(c.status===301||c.status===302)&&e!=="GET"&&e!=="HEAD")e="GET",n=void 0,t=new Headers(t),t.delete("Content-Type"),t.delete("Content-Length");s=r}throw Error("SolarEdge request exceeded the redirect limit")}async storeResponseCookies(a,o){let i=a.headers,s=i.getSetCookie?.()??[];if(s.length===0){let e=i.get("set-cookie");if(e)s.push(e)}for(let e of s)await this.cookieJar.setCookie(e,o.toString())}resolveSiteId(a){let o=a??this.defaultSiteId;if(o===void 0)throw Error("Missing SolarEdge siteId; pass it to the request or configure SOLAREDGE_SITE_ID");let i=String(o).trim();if(!i)throw Error("SolarEdge siteId must not be empty");return encodeURIComponent(i)}buildPortalUrl(a){if(/^[a-z][a-z\d+.-]*:/i.test(a))throw new B("SolarEdge portal request paths must be relative",{status:0});return new URL(a.replace(/^\/+/,""),`${this.portalBaseUrl}/`)}}function Wi(){let o=crypto.getRandomValues(new Uint8Array(64)),i="";for(let s of o)i+="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"[s%66];return i}async function Yi(a){let o=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(a));return Mi(new Uint8Array(o))}function Mi(a){let o="";for(let i of a)o+=String.fromCharCode(i);return btoa(o).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function Xi(a,o){let i=a.match(/]*>/gi)??[];for(let s of i)if(So(s,"name")===o)return So(s,"value");return}function So(a,o){let i=new RegExp(`${o}\\s*=\\s*(["'])(.*?)\\1`,"i"),s=a.match(i)?.[2];return s===void 0?void 0:Vi(s)}function Vi(a){return a.replace(/"/g,'"').replace(/'|'/g,"'").replace(/</g,"<").replace(/>/g,">").replace(/&/g,"&")}function Oo(a){let o=[...new Set(a.map((i)=>i.trim()))].filter(Boolean);if(o.length===0)throw Error("At least one SolarEdge optimizer serial is required");return o}function Ji(a){let o=[],i=new Set,s=(e,n)=>{if(!e||typeof e!=="object")return;if(Array.isArray(e)){for(let c of e)s(c,n);return}let t=e,m={displayOrder:typeof t.displayOrder==="string"?t.displayOrder:void 0,order:typeof t.order==="number"?t.order:void 0},u={...n};if(t.type==="INVERTER")u.inverter=m;else if(t.type==="STRING")u.string=m;else if(t.type==="OPTIMIZER"&&typeof t.serial==="string"){let c=t.serial.trim();if(c&&!i.has(c))i.add(c),o.push({serial:c,inverterId:u.inverter?.displayOrder,stringId:u.string?.displayOrder,optimizerId:m.displayOrder,inverterOrder:u.inverter?.order,stringOrder:u.string?.order,optimizerOrder:m.order})}if(Array.isArray(t.children)){s(t.children,u);return}if(t.type===void 0)for(let c of Object.values(t))s(c,u)};return s(a,{}),o}function Ao(a,o){if(!a.trim())throw Error(`SolarEdge ${o} must not be empty`);return a}function Pi(a){return a===301||a===302||a===303||a===307||a===308}async function Io(a){try{return await a.json()}catch{return}}class wa{loadSnapshot;pollIntervalMs;snapshot;nextRefreshAt;lastError;refreshPromise;timer;constructor({loadSnapshot:a,pollIntervalMs:o}){if(!Number.isFinite(o)||o<=0)throw Error("pollIntervalMs must be positive");this.loadSnapshot=a,this.pollIntervalMs=o}start(){this.refresh()}stop(){if(this.timer)clearTimeout(this.timer),this.timer=void 0}async refresh(){if(this.refreshPromise)return this.refreshPromise;return this.stop(),this.refreshPromise=this.performRefresh().finally(()=>{this.refreshPromise=void 0,this.scheduleNextRefresh()}),this.refreshPromise}getPayload(){if(!this.snapshot||!this.nextRefreshAt)return;return{status:this.lastError?"stale":this.snapshot.failedOptimizerCount>0?"partial":"ok",...this.snapshot,nextRefreshAt:this.nextRefreshAt,...this.lastError?{lastError:this.lastError}:{}}}getLastError(){return this.lastError}async performRefresh(){try{this.snapshot=await this.loadSnapshot(),this.lastError=void 0}catch(a){this.lastError=Ui(a)}}scheduleNextRefresh(){this.nextRefreshAt=new Date(Date.now()+this.pollIntervalMs).toISOString(),this.timer=setTimeout(()=>void this.refresh(),this.pollIntervalMs)}}function Ui(a){if(!a||typeof a!=="object")return"Unknown error";let o=a;return[o.name?String(o.name):"Error",o.code===void 0?void 0:`code=${String(o.code)}`,o.status===void 0?void 0:`status=${String(o.status)}`].filter(Boolean).join(" ")}async function $o({reader:a,siteId:o,timeZone:i,concurrency:s=3,now:e=new Date}){let n=Ci(e,i),t=await a.listOptimizerMappings(o);if(t.length===0)throw Error("SolarEdge returned no optimizer mappings");let m=await a.getOptimizerInformation(t.map((p)=>p.serial)),u=await ae(t,s,async(p)=>{let r=m.serialToLiveData[p.serial],h={serial:p.serial,inverterId:p.inverterId??null,stringId:p.stringId??null,optimizerId:p.optimizerId??null,currentPowerW:_o(r?.power_W),lastMeasurement:r?.lastMeasurement??null};try{let b=await a.getOptimizerEnergy({siteId:o,startDate:n,endDate:n,optimizerSerials:[p.serial],chartTimeUnit:"hours"});return{...h,dailyEnergyWh:_o(b.totalEnergy)}}catch(b){return{...h,dailyEnergyWh:null,error:oe(b)}}}),c=u.filter((p)=>p.dailyEnergyWh!==null);return{siteId:o,date:n,timeZone:i,fetchedAt:new Date().toISOString(),optimizerCount:u.length,successfulOptimizerCount:c.length,failedOptimizerCount:u.length-c.length,totalDailyEnergyWh:c.reduce((p,r)=>p+(r.dailyEnergyWh??0),0),totalCurrentPowerW:u.reduce((p,r)=>p+(r.currentPowerW??0),0),optimizers:u}}function Ci(a,o){let i=new Intl.DateTimeFormat("en",{timeZone:o,year:"numeric",month:"2-digit",day:"2-digit"}).formatToParts(a),s=Object.fromEntries(i.map((e)=>[e.type,e.value]));return`${s.year}-${s.month}-${s.day}`}async function ae(a,o,i){if(!Number.isInteger(o)||o<1)throw Error("concurrency must be a positive integer");let s=Array(a.length),e=0,n=async()=>{while(e1440)throw Error("poll_interval_minutes must be an integer from 10 to 1440");return{username:i,password:s,siteId:e,pollIntervalMinutes:n}}async function Do(a=process.env.SOLAREDGE_OPTIONS_PATH??"/data/options.json"){let o=Bun.file(a);if(!await o.exists())throw Error(`App options file does not exist: ${a}`);return se(await o.json())}async function Go({fetchImplementation:a=globalThis.fetch,supervisorToken:o=process.env.SUPERVISOR_TOKEN}={}){if(!o){let e=process.env.TZ;if(e)return To(e);throw Error("SUPERVISOR_TOKEN is missing and TZ is not configured")}let i=await a("http://supervisor/info",{headers:{Authorization:`Bearer ${o}`},signal:AbortSignal.timeout(1e4)});if(!i.ok)throw Error(`Could not read Home Assistant time zone (${i.status})`);let s=await i.json();return To(sa(s.data?.timezone,"Supervisor timezone"))}function sa(a,o,i=!0){if(typeof a!=="string")throw Error(`${o} must be a string`);let s=i?a.trim():a;if(!s)throw Error(`${o} must not be empty`);return s}function To(a){try{return new Intl.DateTimeFormat("en",{timeZone:a}).format(),a}catch{throw Error(`Invalid Home Assistant time zone: ${a}`)}}function Fo(a){return(o)=>{if(o.method!=="GET")return X({error:"Method not allowed"},405,{Allow:"GET"});let i=new URL(o.url).pathname.replace(/\/+$/,"")||"/";if(i==="/health"){let s=a.getPayload();return X(s?{status:s.status,fetchedAt:s.fetchedAt,nextRefreshAt:s.nextRefreshAt,optimizerCount:s.optimizerCount,failedOptimizerCount:s.failedOptimizerCount,...s.lastError?{lastError:s.lastError}:{}}:{status:a.getLastError()?"error":"starting",...a.getLastError()?{lastError:a.getLastError()}:{}},s?200:503)}if(i==="/"||i==="/api/optimizers"){let s=a.getPayload();return s?X(s):X({status:a.getLastError()?"error":"starting",message:"No optimizer data is available yet",...a.getLastError()?{lastError:a.getLastError()}:{}},503)}return X({error:"Not found"},404)}}function X(a,o=200,i={}){return Response.json(a,{status:o,headers:{"Cache-Control":"no-store",...i}})}async function ie(){let a=await Do(),o=await Go(),i=new oa({username:a.username,password:a.password,siteId:a.siteId}),s=new wa({pollIntervalMs:a.pollIntervalMinutes*60000,loadSnapshot:async()=>{V("info",`Refreshing optimizer data for site ${a.siteId}`);let m=await $o({reader:i,siteId:a.siteId,timeZone:o});return V(m.failedOptimizerCount===0?"info":"warning",`Refresh finished: ${m.successfulOptimizerCount}/${m.optimizerCount} optimizer energy values`),m}}),e=ee(process.env.PORT),n=Bun.serve({hostname:"0.0.0.0",port:e,fetch:Fo(s)}),t=()=>{V("info","Stopping SolarEdge Optimizer Data App"),s.stop(),n.stop(!0)};process.once("SIGTERM",t),process.once("SIGINT",t),V("info",`Listening on port ${e}; refresh interval ${a.pollIntervalMinutes} minutes; time zone ${o}`),s.start()}function ee(a){let o=a===void 0?Lo:Number(a);if(!Number.isInteger(o)||o<1||o>65535)throw Error("PORT must be an integer from 1 to 65535");return o}function V(a,o){console.log(`${new Date().toISOString()} [${a}] ${o}`)}if(import.meta.main)try{await ie()}catch(a){V("error",ne(a)),process.exit(1)}function ne(a){if(a instanceof Error)return`${a.name}: ${a.message}`;return"Unknown startup error"}export{ie as main}; diff --git a/solaredge-optimizer-data/package.json b/solaredge-optimizer-data/package.json new file mode 100644 index 0000000..68acd52 --- /dev/null +++ b/solaredge-optimizer-data/package.json @@ -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" + } +} diff --git a/solaredge-optimizer-data/run.sh b/solaredge-optimizer-data/run.sh new file mode 100644 index 0000000..699ebac --- /dev/null +++ b/solaredge-optimizer-data/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu + +exec bun /app/server.js diff --git a/solaredge-optimizer-data/src/cache.test.ts b/solaredge-optimizer-data/src/cache.test.ts new file mode 100644 index 0000000..3baa074 --- /dev/null +++ b/solaredge-optimizer-data/src/cache.test.ts @@ -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(); + }); +}); diff --git a/solaredge-optimizer-data/src/cache.ts b/solaredge-optimizer-data/src/cache.ts new file mode 100644 index 0000000..f58ca7f --- /dev/null +++ b/solaredge-optimizer-data/src/cache.ts @@ -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; + private readonly pollIntervalMs: number; + private snapshot?: OptimizerDataSnapshot; + private nextRefreshAt?: string; + private lastError?: string; + private refreshPromise?: Promise; + private timer?: ReturnType; + + constructor({ + loadSnapshot, + pollIntervalMs, + }: { + loadSnapshot: () => Promise; + 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 { + 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 { + 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(" "); +} diff --git a/solaredge-optimizer-data/src/collector.test.ts b/solaredge-optimizer-data/src/collector.test.ts new file mode 100644 index 0000000..fb1b1e0 --- /dev/null +++ b/solaredge-optimizer-data/src/collector.test.ts @@ -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"); + }); +}); diff --git a/solaredge-optimizer-data/src/collector.ts b/solaredge-optimizer-data/src/collector.ts new file mode 100644 index 0000000..e174f16 --- /dev/null +++ b/solaredge-optimizer-data/src/collector.ts @@ -0,0 +1,179 @@ +import type { + GetSolarEdgeOptimizerEnergyOptions, + SolarEdgeOptimizerEnergy, + SolarEdgeOptimizerInformation, + SolarEdgeOptimizerMapping, + SolarEdgeOptimizerSerial, + SolarEdgeSiteId, +} from "@solar-dash/solaredgeapi"; + +export type OptimizerDataReader = { + listOptimizerMappings( + siteId?: SolarEdgeSiteId, + ): Promise; + getOptimizerInformation( + optimizerSerials: SolarEdgeOptimizerSerial[], + ): Promise; + getOptimizerEnergy( + options: GetSolarEdgeOptimizerEnergyOptions, + ): Promise; +}; + +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 { + 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 => { + 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( + values: TInput[], + concurrency: number, + mapper: (value: TInput, index: number) => Promise, +): Promise { + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error("concurrency must be a positive integer"); + } + const results = new Array(values.length); + let nextIndex = 0; + const worker = async (): Promise => { + 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(" "); +} diff --git a/solaredge-optimizer-data/src/config.test.ts b/solaredge-optimizer-data/src/config.test.ts new file mode 100644 index 0000000..188b261 --- /dev/null +++ b/solaredge-optimizer-data/src/config.test.ts @@ -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"); + }); +}); diff --git a/solaredge-optimizer-data/src/config.ts b/solaredge-optimizer-data/src/config.ts new file mode 100644 index 0000000..62cc808 --- /dev/null +++ b/solaredge-optimizer-data/src/config.ts @@ -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; + +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; + 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 { + 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 { + 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}`); + } +} diff --git a/solaredge-optimizer-data/src/http.test.ts b/solaredge-optimizer-data/src/http.test.ts new file mode 100644 index 0000000..2a8a961 --- /dev/null +++ b/solaredge-optimizer-data/src/http.test.ts @@ -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(); + }); +}); diff --git a/solaredge-optimizer-data/src/http.ts b/solaredge-optimizer-data/src/http.ts new file mode 100644 index 0000000..9012133 --- /dev/null +++ b/solaredge-optimizer-data/src/http.ts @@ -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 = {}, +): Response { + return Response.json(value, { + status, + headers: { + "Cache-Control": "no-store", + ...additionalHeaders, + }, + }); +} diff --git a/solaredge-optimizer-data/src/index.ts b/solaredge-optimizer-data/src/index.ts new file mode 100644 index 0000000..78cc408 --- /dev/null +++ b/solaredge-optimizer-data/src/index.ts @@ -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 { + 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"; +} diff --git a/solaredge-optimizer-data/translations/de.yaml b/solaredge-optimizer-data/translations/de.yaml new file mode 100644 index 0000000..c399bcf --- /dev/null +++ b/solaredge-optimizer-data/translations/de.yaml @@ -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. diff --git a/solaredge-optimizer-data/translations/en.yaml b/solaredge-optimizer-data/translations/en.yaml new file mode 100644 index 0000000..be8c359 --- /dev/null +++ b/solaredge-optimizer-data/translations/en.yaml @@ -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. diff --git a/solaredge-optimizer-data/tsconfig.json b/solaredge-optimizer-data/tsconfig.json new file mode 100644 index 0000000..5950244 --- /dev/null +++ b/solaredge-optimizer-data/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"] +}