Initial SolarEdge Optimizer Home Assistant App

This commit is contained in:
jens committed 2026-08-10 09:04:35 +02:00
commit f2887a0ab4
38 files changed
+4253

No files matched your search

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