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