- ioBroker Community Home
- Deutsch
- Skripten / Logik
- JavaScript
- [TypeSkript] Wetter.com Forecast/Vorhersage
[TypeSkript] Wetter.com Forecast/Vorhersage
-
Hi, na' da habe ich mir jetzt einen geleistet .... natürlich ist das ein anderer Dienst!!!
Keine Ahnung, aber das kommt davon, wenn man zu viel parallel macht... :-(
Sorry für den Unsinn hier, ich gelobe Besserung :-)
@Pedder007 wenn du jetzt noh sagst, dass das Skript weiter funktioniert... wäre ich erleichtert....
weil ich nach sie vor nicht weiss woran es liegen könnte 🙈
-
@Pedder007 wenn du jetzt noh sagst, dass das Skript weiter funktioniert... wäre ich erleichtert....
weil ich nach sie vor nicht weiss woran es liegen könnte 🙈
@Schimi ENTWARNUNG
Das Script läuft einwandfrei und hat z. B. heute um kurz vor 04:00 und nochmal um kurz vor 16:00 aktualisiert.
Das Problem saß vor der Konsole - hatte den falschen Key, also den vom anderen Anbieter, genutzt.
Sorry war am WE unterwegs und hätte das evtl. oben klarer formulieren sollen.
Danke Dir für Dein vorbildliches Care-Taking ;-)Pedder
All @Proxmox/Trixie auf HP Elitedesk 800 G4; Zigbee: ZigStar (LAN), ~110Devices
Unifi, Motioneye/3Reolinks, PiHole, Bosch CS7800i via BBQKees/EMS-ESP, Fronius/BYD 11kWp via Modbus
Under construction: Smart-WoMo auf Raspi4 -
Habe mal denn Umgang mit dem Limit optimiert (und dabei etwas weiter gemacht), da ich gestern ins Limit gelaufen bin....
die neue Version findet sich im ersten Post im "Spoiler"
Falls sich Bugs eingeschlichen haben, solltet ihr vielleicht die letzte 1.9.2 Version erstmal irgendwo zwischenspeichern ;-)
Der API-KEY und die anzahl der Tage werden nun auch in Datenpunkten eingegeben...
Changelog: Wetter.com Forecast API (v1.9.2 ➔ v2.6.1)Dieser Bericht dokumentiert die Evolution des Skripts von einem einfachen API-Abruf hin zu einer performanten, typsicheren und ausfallsicheren ioBroker-Infrastruktur-Komponente.
⚙️ v2.6.x – Perfektionierung & Usability
2.6.1 (Hotfix): * Fix: Konfigurations-Datenpunkte (api_key, forecast_days) werden nun mit write: true angelegt.
Retroaktiver Fix: Ein extendObjectAsync entsperrt automatisch Bestandsdatenpunkte, die in Vorversionen als Read-Only angelegt wurden.
2.6.0 (Ultra-Performance): * RAM-Cache (ensuredPaths): Eliminiert hunderte synchrone existsObject()-Aufrufe an die JS-Engine. I/O-Overhead beim Struktur-Check sinkt nach dem ersten Lauf auf nahezu null.
Zero-Latency: Die künstliche Drosselung wcomWait() wurde vollständig entfernt, da das System durch Caching und Batching überlastungsfrei agiert.
Memory-Management: Timeout-Clearing im HTTP-Request integriert, um Speicherlecks zu verhindern.
Semantischer Fallback: wind_speed_max fällt auf wind.avg zurück, falls die API sporadisch den Maximalwert weglässt.
🚀 v2.5.x – Batching & Deadlock-Schutz
2.5.0:
State-Write-Batching: Schreibvorgänge (setStateChangedAsync) eines Tages werden in einem internen Puffer gebündelt und per Promise.all simultan abgesetzt.
Lock-Härtung: Der HTTP-Call ist nun in ein Promise.race mit einem 10-Sekunden-Timeout gekapselt. Verhindert unendliche Blockaden des isFetching-Locks bei fehlerhaften API-Servern.
Type-Safety: Optionale Verkettung (data.hourly ?? []) schützt vor unvollständigen JSON-Antworten.
API-Resilienz: Erweiterte isNaN-Prüfung in wcomExtractValue(), um korrupte Strings der API abzufangen.
✨ v2.3.x bis v2.4.x – Dynamisierung
2.4.0 (Dynamische Tage): * FORECAST_DAYS aus dem Code in den Datenpunkt info.forecast_days ausgelagert.
Auto-Trigger: Das Skript reagiert sofort auf Änderungen dieses Wertes, passt den Abruf an und löscht überschüssige Tagesordner (cleanupObsoleteDays) rekursiv.
2.3.0 (API-Key Trigger): * Neuer on()-Trigger für info.api_key. Bei Eingabe eines neuen Keys wird sofort ein Test-Abruf (source: 'key_update') erzwungen, der den regulären Restart-Blocker überspringt.
🛡️ v2.1.x bis v2.2.x – Systemhärtung & Controller-Entlastung
2.2.0:
Zeitzonen-Fix: new Date() statt toISOString() behebt falsche Tageswechsel-Berechnungen auf Systemen mit UTC-Offset.
I/O-Reduktion: Einführung von wcomEnsureState() zur Minimierung von createStateAsync-Spam.
2.1.0:
Zero-Churn (Hourly): Das destruktive, rekursive Löschen (deleteObjectAsync) von Stundenwerten wurde durch sauberes Überschreiben abgelöst. Verhindert massive Object-Events und Controller-Spikes.
Null-Safety: wcomExtractValue() gibt strikt 0 statt null zurück, was Typerrors in Number-States verhindert.
Koordinaten-Fix: Längen- und Breitengrade von exakt 0 (Äquator/Nullmeridian) werden nicht mehr als false verworfen.
Native Logs: Das Log-Level debug wird nun korrekt an die ioBroker-Engine durchgereicht.
🔒 v2.0.x – Security & Architektur-Refactoring
2.0.1: Standardisierung des Key-Speicherorts auf 0_userdata.0.wetter_com.info.api_key.
2.0.0: * Security: API-Key aus dem Klartext-Quellcode entfernt.
Semaphore (isFetching): Verhindert Race-Conditions und Doppel-Abrufe, wenn Timer und manuelle Trigger gleichzeitig feuern.
Async-HTTP: httpGet wurde in ein asynchrones Promise gekapselt, um Netzwerkfehler im zentralen try/catch sicher fangen zu können.
Daily-Reset: Fehleranfälliger Mitternachts-Cron für requests_today wurde durch datumsbasierte, asynchrone Prüfung vor jedem API-Call ersetzt.
📉 v1.9.3 – Budget-Stabilität
1.9.3: * Restart-Schutz: Skript-Neustarts lösen keinen API-Call mehr aus, wenn am selben Tag bereits Daten abgerufen wurden (schützt das 100-Calls/Monat Limit bei der Entwicklung).
Erweiterter Economy-Mode: Unterscheidung der Trigger-Quellen (morning, afternoon, start).
Notlauf-Modus: Fällt das Budget auf einen kritischen Wert, wird nur noch jeden zweiten Tag aktualisiert.
-
@schimi
Vielen Dank, was hast du am Limit genau geändert?
Bei mir läufts einwandfrei, aber gleich in dem Zuge noch eine Frage:
Wann wird eigentlich immer aktualisiert? Wir haben jetzt 8:22 Uhr und ich habe immer noch den Vortag drin:
Oder ist das genau wie bei dir der Fall das ich jetzt das Limit erreicht habe:

EDIT: Hat sich erledigt dir Frage, bei mir ist auch das Monatslimit erreicht:

-
Das sieht nach dem Problem wie bei mir aus...
Das ist das wichtigste aus dem Changelog:
1.9.3: *
- Restart-Schutz: Skript-Neustarts lösen keinen API-Call mehr aus, wenn am selben Tag bereits Daten abgerufen wurden (schützt das 100-Calls/Monat Limit bei der Entwicklung).
- Erweiterter Economy-Mode: Unterscheidung der Trigger-Quellen (morning, afternoon, start).
- Notlauf-Modus: Fällt das Budget auf einen kritischen Wert, wird nur noch jeden zweiten Tag aktualisiert.
Wie gut es klappt sehen wir Ende März ;-)
Dazu kommt, dass die API und die Tage im Datenpunkt angegeben werden und wenn man dieses ändert, das Skript sich sofort aktualisiert und mit den daten weiterarbeitet.
-
Habe gerade auf die neue Version umgestellt, jetzt warten wir mal bis März und schauen weiter.
Aber das Script ist auf jedenfall echt Tip Top! -
Habe mal denn Umgang mit dem Limit optimiert (und dabei etwas weiter gemacht), da ich gestern ins Limit gelaufen bin....
die neue Version findet sich im ersten Post im "Spoiler"
Falls sich Bugs eingeschlichen haben, solltet ihr vielleicht die letzte 1.9.2 Version erstmal irgendwo zwischenspeichern ;-)
Der API-KEY und die anzahl der Tage werden nun auch in Datenpunkten eingegeben...
Changelog: Wetter.com Forecast API (v1.9.2 ➔ v2.6.1)Dieser Bericht dokumentiert die Evolution des Skripts von einem einfachen API-Abruf hin zu einer performanten, typsicheren und ausfallsicheren ioBroker-Infrastruktur-Komponente.
⚙️ v2.6.x – Perfektionierung & Usability
2.6.1 (Hotfix): * Fix: Konfigurations-Datenpunkte (api_key, forecast_days) werden nun mit write: true angelegt.
Retroaktiver Fix: Ein extendObjectAsync entsperrt automatisch Bestandsdatenpunkte, die in Vorversionen als Read-Only angelegt wurden.
2.6.0 (Ultra-Performance): * RAM-Cache (ensuredPaths): Eliminiert hunderte synchrone existsObject()-Aufrufe an die JS-Engine. I/O-Overhead beim Struktur-Check sinkt nach dem ersten Lauf auf nahezu null.
Zero-Latency: Die künstliche Drosselung wcomWait() wurde vollständig entfernt, da das System durch Caching und Batching überlastungsfrei agiert.
Memory-Management: Timeout-Clearing im HTTP-Request integriert, um Speicherlecks zu verhindern.
Semantischer Fallback: wind_speed_max fällt auf wind.avg zurück, falls die API sporadisch den Maximalwert weglässt.
🚀 v2.5.x – Batching & Deadlock-Schutz
2.5.0:
State-Write-Batching: Schreibvorgänge (setStateChangedAsync) eines Tages werden in einem internen Puffer gebündelt und per Promise.all simultan abgesetzt.
Lock-Härtung: Der HTTP-Call ist nun in ein Promise.race mit einem 10-Sekunden-Timeout gekapselt. Verhindert unendliche Blockaden des isFetching-Locks bei fehlerhaften API-Servern.
Type-Safety: Optionale Verkettung (data.hourly ?? []) schützt vor unvollständigen JSON-Antworten.
API-Resilienz: Erweiterte isNaN-Prüfung in wcomExtractValue(), um korrupte Strings der API abzufangen.
✨ v2.3.x bis v2.4.x – Dynamisierung
2.4.0 (Dynamische Tage): * FORECAST_DAYS aus dem Code in den Datenpunkt info.forecast_days ausgelagert.
Auto-Trigger: Das Skript reagiert sofort auf Änderungen dieses Wertes, passt den Abruf an und löscht überschüssige Tagesordner (cleanupObsoleteDays) rekursiv.
2.3.0 (API-Key Trigger): * Neuer on()-Trigger für info.api_key. Bei Eingabe eines neuen Keys wird sofort ein Test-Abruf (source: 'key_update') erzwungen, der den regulären Restart-Blocker überspringt.
🛡️ v2.1.x bis v2.2.x – Systemhärtung & Controller-Entlastung
2.2.0:
Zeitzonen-Fix: new Date() statt toISOString() behebt falsche Tageswechsel-Berechnungen auf Systemen mit UTC-Offset.
I/O-Reduktion: Einführung von wcomEnsureState() zur Minimierung von createStateAsync-Spam.
2.1.0:
Zero-Churn (Hourly): Das destruktive, rekursive Löschen (deleteObjectAsync) von Stundenwerten wurde durch sauberes Überschreiben abgelöst. Verhindert massive Object-Events und Controller-Spikes.
Null-Safety: wcomExtractValue() gibt strikt 0 statt null zurück, was Typerrors in Number-States verhindert.
Koordinaten-Fix: Längen- und Breitengrade von exakt 0 (Äquator/Nullmeridian) werden nicht mehr als false verworfen.
Native Logs: Das Log-Level debug wird nun korrekt an die ioBroker-Engine durchgereicht.
🔒 v2.0.x – Security & Architektur-Refactoring
2.0.1: Standardisierung des Key-Speicherorts auf 0_userdata.0.wetter_com.info.api_key.
2.0.0: * Security: API-Key aus dem Klartext-Quellcode entfernt.
Semaphore (isFetching): Verhindert Race-Conditions und Doppel-Abrufe, wenn Timer und manuelle Trigger gleichzeitig feuern.
Async-HTTP: httpGet wurde in ein asynchrones Promise gekapselt, um Netzwerkfehler im zentralen try/catch sicher fangen zu können.
Daily-Reset: Fehleranfälliger Mitternachts-Cron für requests_today wurde durch datumsbasierte, asynchrone Prüfung vor jedem API-Call ersetzt.
📉 v1.9.3 – Budget-Stabilität
1.9.3: * Restart-Schutz: Skript-Neustarts lösen keinen API-Call mehr aus, wenn am selben Tag bereits Daten abgerufen wurden (schützt das 100-Calls/Monat Limit bei der Entwicklung).
Erweiterter Economy-Mode: Unterscheidung der Trigger-Quellen (morning, afternoon, start).
Notlauf-Modus: Fällt das Budget auf einen kritischen Wert, wird nur noch jeden zweiten Tag aktualisiert.
@Schimi Danke, läuft nun auch bei mir :-)
Pedder
All @Proxmox/Trixie auf HP Elitedesk 800 G4; Zigbee: ZigStar (LAN), ~110Devices
Unifi, Motioneye/3Reolinks, PiHole, Bosch CS7800i via BBQKees/EMS-ESP, Fronius/BYD 11kWp via Modbus
Under construction: Smart-WoMo auf Raspi4 -
@schimi
Hi, hätte eine kurze Frage: Ich hab mit Iobroker wegen anderem Thema jetzt viel rumprobieren müssen und hab dazu immer wieder Iobroker neustarten müssen, deswegen hab ich leider jetzt schon einen "Monatslimit erreicht".
Gibts da eine Möglichkeit das zu unterbinden, auch wenn man mal mehr Neustarts macht? :-)
Danke im Voraus. -
@schimi
Hi, hätte eine kurze Frage: Ich hab mit Iobroker wegen anderem Thema jetzt viel rumprobieren müssen und hab dazu immer wieder Iobroker neustarten müssen, deswegen hab ich leider jetzt schon einen "Monatslimit erreicht".
Gibts da eine Möglichkeit das zu unterbinden, auch wenn man mal mehr Neustarts macht? :-)
Danke im Voraus.@nik82 sagte in [TypeSkript] Wetter.com Forecast/Vorhersage:
Gibts da eine Möglichkeit das zu unterbinden, auch wenn man mal mehr Neustarts macht?
hast du vorher die Instanz komplett deaktiviert?
kein Support per PN! - Fragen im Forum stellen - Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.
Das Forum freut sich über eine Spende. Benutzt dazu den Spendenbutton oben rechts. Danke!
der Installationsfixer: curl -fsL https://iobroker.net/fix.sh | bash - -
Nein da hab ich nicht dran gedacht ehrlich gesagt, aber die Scripts starten ja dann wieder wenn ich die Instanz starte oder?
Klar wenn ich mehrere Starts hintereinander mache dann macht das Sinn, aber ich habe mit meiner Testerei immer so dermaßen den iobroker zerschossen, dass ich einfach meine Proxmox VM jedes mal wieder hergestellt habe.
Ist auch nicht so wichtig, weil da wird ja nicht so oft so krass dran rumgefummelt, aber wenns im Script nur eine Kleinigkeit ist, dann könnte man das ja eventuell einbauen :-) -
Deswegen bin ich auch immer ins Limit... aber ausser das du das Skript deaktivierst und erst nach deinen experimenten wieder aktivierst, fällt mir nichts ein....
Falls du Skripte testest, könntest du eine zweite JavaSkript Instanz dafür nehmen, wenn diese dann neugestartet wird, bleiben die anderen Skripte unangetastet...
ansonsten bleibt nur einen weiteren API key zu beantragen...
-
Alles klar, passt, danke dir :-)
Ich glaube ich hab mir schonmal einen zweiten Key besorgt, aber der checkt wahrscheinlich die gleiche externe IP und sperrt dann auch wieder, kann das sein?EDIT
Hab jetzt grad mal mit einer anderen Emailadresse ein neuen Api Key angefordert und dann eingetragen, log sagt dann aber wieder Monatslimit erreicht.
Hab jetzt Fritzbox (aber leider erst nach neuen Api Key Eintrag) gestartet, jetzt hab ich neue externe IP, aber immer noch Limit. Kann das sein?
Muss ich evtl. neue externe IP haben und dann erst den neuen Api Key eintragen? -
wenn du einen eneun API key einträgst, musst du die Datenpunkte auch auf 0 setzen, er fragt die Datenpunkte ab...
Die API haut nun (ich meine) einen 404 oder so raus und sagt nicht speziell dass, das Limit voll ist (deswegen zähle ich selber)
-
@schimi
So sorry, da hät ich natürlich selber drauf kommen können das ich den Datenpunkt auf 0 setze.
Jetzt gehts wieder. Danke dir. :-) -
hallo, ich habe mich auch für den skript entschieden, bis 29.03 hat noch alles gut funktioniert und dann die Meldung "script.js.wetter_com: [Wetter.com] Monatslimit erreicht (100/100). Abruf gestoppt." seit dem funktioniert der Abruf vom Daten nicht. Was muss ich machen, das wieder Daten aktualisiert werden? Grüße
-
@Jogis schaue mal ob du die neuste Version hast....
Dann wäre es vielleicht mal nicht verkehrt, das Skript zu Stoppen > den "wetter_com" Ordner und userdata komplett löschen und das Skript neu starten damit es die Datenpunkte neu anlegt.
Der API-Key wird nun im entsprechenden Datenpunkt gespeichert.....
die aktuelle Version läuft bei mir nun schon "recht lange" fehlerfrei... diesen Monat habe ich auch den iobroker nicht so oft gestartet (dadurch war das 100 Limit kein Problem).
Falls dir immernoch zuviele abfragen angezeigt werden... entweder bis zum 01.05 warten oder einen neuen API key beantragen...
-
Ich habe den Zähler "0_userdata.0.wetter_com.info.requests_month 100" auf 0 gesetzt jetzt kommen Daten wieder, benutze skript 2.6.1, iobroker läuft schon paar Monate und wird nicht neu gestartet. Frage nur warum im Monat April der Zähler nicht auf 0 gestellt war und die Daten nicht aktualisiert wurden? API key ist der gleiche.
-
[Skript] Wetter.com Forecast API v4.0 (Meteonomiqs) - Update v3.2.1
Dieses performante TypeScript importiert Wetterdaten der neuen Wetter.com API (Meteonomiqs v4.0) ressourcenschonend in den ioBroker.
🌟 Funktionen
-
Intelligente Prioritäts-Staffelung (Neu in v3.1.0): Standardmäßig erfolgen 3 Abrufe pro Tag (Nacht, Mittag, Abend). Wird das Monatsbudget gegen Monatsende knapp, greift ein Tier-System: Unwichtigere Abrufe (z.B. Mittag) fallen automatisch weg, um die Grundversorgung (Morgen/Abend) bis zum Monatsende durchgehend zu sichern.
-
Tages-Zusammenfassung (Summary): Max/Min Temperaturen, Regenrisiko & -menge, Windböen, Bewölkung und Luftfeuchtigkeit (bis 16 Tage).
-
Tagesabschnitte (Spaces): Detaillierte Daten für Vormittag, Nachmittag, Abend und Nacht.
-
Stündliche Vorhersage (Hourly): Präzise Stundenwerte für heute und morgen (48h).
-
Limit- & Cooldown-Schutz: Ein auf 5 Stunden reduzierter Cooldown verhindert, dass fehlerhafte Skript-Neustarts dein Limit aufbrauchen. Ein erweiterter Startup-Check prüft beim Systemstart direkt, ob deine geplante Update-Taktung überhaupt ins Monatslimit passt.
-
Ultra-Performance & Auto-Cleanup: Ein RAM-Cache minimiert die Schreiblast auf deinem System (Zero-Call Rerendering). Bei der Reduzierung der Vorhersage-Tage in den Einstellungen werden veraltete Datenpunkte sofort rekursiv gelöscht.
🛠 Installation
Erstelle im ioBroker ein neues Skript (z.B. im Ordner common).
Wähle oben rechts als Typ zwingend TypeScript (NICHT JavaScript) aus.
Kopiere den Code hinein, speichere und starte das Skript.
⚙️ Konfiguration
1. Im Skript (CONFIG-Bereich):
UPDATES: Beliebig anpassbare Liste für die Abrufzeiten (inkl. der Prioritäts-Staffelung).
ENABLE_HOURLY / ENABLE_SPACES: Schaltet Detail-Ebenen ein/aus.
LOCATION: Holt Koordinaten standardmäßig aus ioBroker-Settings, kann hier manuell überschrieben werden.
2. Im ioBroker-Objektbaum (0_userdata.0.wetter.info):
api_key: Hier zwingend deinen Meteonomiqs-Key eintragen!
forecast_days: Anzahl der abzurufenden Tage (Standard: 7, max. 14).
force_reset: Button für einen manuellen Reset des Monatszählers.
📝 Hinweise zur VIS & Technik
-
Monitoring: Im Ordner info findest du Datenpunkte (Anfragen heute/Monat, letzter Sync), um deinen API-Verbrauch in der VIS zu überwachen.
-
Lokalisierung: Wochentage und Wetter-Texte (z.B. "Leicht bewölkt") passen sich dynamisch der ioBroker-Systemsprache an.
-
Icons: Zu jedem Wetterzustand wird direkt eine passende, von der API definierte Icon-URL als Datenpunkt ausgeliefert.
-
Free API-Key: Hier bei Meteonomiqs anfordern (Das 100 Calls/Monat Free-Tier reicht dank des neuen Tier-Systems völlig aus).
letztes Update: 15.08.2026 - 12:50 Uhr
/** * [Wetter.com / Meteonomiqs PWA v4.0 -> ioBroker] (TrueScript / TypeScript) * * CHANGELOG: * - 3.2.1: 2026-08-06 - HOTFIX: 'current' wurde beim Neustart nicht angelegt * BUGFIX: wcomUpdateCurrent() lief ausschliesslich am Ende von wcomProcessForecastData(). * Wurde der Start-Abruf uebersprungen (Cooldown, Budget, fehlender API-Key), * entstand der Ordner nie - im Extremfall bis zum naechsten planmaessigen Abruf. * Der Aufbau haengt jetzt nicht mehr am Abruf: 'current' wird beim Skriptstart * immer erzeugt und aus den bereits vorhandenen Stunden-Datenpunkten gefuellt. * * - 3.2.0: 2026-08-06 - CURRENT-ORDNER (aktuelle Stunde) * FEATURE: Neuer Zweig '<DP_PATH>.current' mit den Werten der laufenden Stunde - * analog zu den Adaptern 'daswetter' und 'open-meteo-weather'. * Wird STUENDLICH aktualisiert (Cron :01), nicht nur beim API-Abruf. * Quelle sind die bereits vorhandenen Stunden-Datenpunkte, nicht der RAM-Cache: * dadurch funktioniert 'current' auch nach einem Skript-Neustart sofort weiter * und kostet keinen einzigen zusaetzlichen API-Call. * Der Tageswechsel wird ueber 'date_iso' aufgeloest - zwischen 00:00 und dem * 01:10-Abruf liegt "heute" noch in day_1, danach in day_0. * HINWEIS: 'current' ist die VORHERSAGE fuer die laufende Stunde, keine Messung. * Echte Momentanwerte liefert /nowcast bzw. /stations - beides kostet je * Abfrage einen API-Call und passt damit nicht in ein 100er-Kontingent. * * - 3.1.0: 2026-08-06 - DREI ABRUFE PRO TAG MIT PRIORITAETS-STAFFELUNG * FEATURE: Abrufzeiten liegen jetzt als Liste in CONFIG.UPDATES - beliebig viele Eintraege. * 01:10 (Tagesplanung) | 11:40 (Korrektur Nachmittag) | 18:40 (Nacht + Folgetag) * FEATURE: Jeder Abruf hat eine 'tier'-Stufe. Wird das Budget knapp, fallen die Abrufe in * umgekehrter Wichtigkeit weg (3 -> 2 -> 1/Tag), statt dass das Skript gegen Ende * des Monats komplett stehen bleibt. * FEATURE: Retry verbraucht keine Notreserve mehr (CONFIG.RESERVE_CALLS). * FEATURE: Startup-Check prueft jetzt auch, ob die Taktung ueberhaupt ins Monatsbudget passt. * CHANGE: Cooldown 8 h -> 5 h (kuerzester Abstand der neuen Zeiten betraegt 6,5 h). * * - 3.0.0: 2026-08-06 - API-KONFORMITAET, BUGFIXES & DATENERWEITERUNG * BUGFIX (kritisch): Cooldown blockierte dauerhaft einen der beiden Abrufe. * 12 h Cooldown bei 11 h Cron-Abstand => Nachmittags-Abruf lief NIE. * Neu: 8 h Cooldown + automatische Plausibilitaetspruefung beim Start. * BUGFIX (kritisch): Icon wurde selbst gebaut (`d_<state>.svg`). Die API liefert das * korrekte Icon inkl. Nacht- (n_), Unwetter- (d_e_) und Wind-Variante * (d_w_) im Feld `weather.icon`. Wird jetzt uebernommen. * BUGFIX: Monats-/Tageszaehler wurden nur per Cron zurueckgesetzt. Lief ioBroker um * 00:00 am 1. nicht, blieb der Zaehler stehen -> Skript pausierte den ganzen Monat. * Neu: Perioden-Marker (info.requests_month_period / _today_date), Reset lazy. * BUGFIX: API-Calls wurden nur bei HTTP 200 gezaehlt. 4xx/5xx verbrauchen aber Kontingent. * Neu: jede beantwortete Anfrage wird gezaehlt. * BUGFIX: `spaces[i]` wurde per Index auf `summary[i]` gemappt. Neu: Zuordnung ueber `date`. * BUGFIX: Datums-Strings "YYYY-MM-DD" wurden als UTC-Mitternacht geparst -> in Zeitzonen * westlich von UTC um einen Tag verschoben. Neu: lokale Interpretation. * BUGFIX: Stunden-Zuordnung basierte auf der Server-Zeitzone. Neu: Zeitzone des Ortes * aus `location.timezone` der API (Intl), Fallback auf Serverzeit. * BUGFIX: Vergangene Stunden blieben mit veralteten Werten stehen. Neu: `valid`-Flag. * FEATURE: Aenderung von `forecast_days` rendert aus dem RAM-Cache - ohne API-Call. * FEATURE: Neue Datenpunkte aus der v4.0-Spec (alle aus DERSELBEN Antwort, 0 Extra-Calls): * Wetterwarnungen, Astronomie (Sonne/Mond), Luftdruck/Taupunkt/Windchill, Schnee. * FEATURE: JSON-Aggregate `forecast_json` / `hourly_json` fuer VIS/jarvis/Material-Widgets. * FEATURE: Ein Retry bei Netzwerkfehler/5xx, damit ein Abruffenster nicht verloren geht. * REFACTOR: Tabellengetriebene Feld-Definitionen statt duplizierter setState-Bloecke. * * KONTEXT: * - Endpoint: GET https://forecast.meteonomiqs.com/v4_0/forecast/{lat}/{lon} * - Auth: Header `x-api-key`, Sprache ueber `Accept-Language` * - Standort: automatisch aus system.config (Breiten-/Laengengrad des ioBroker-Systems) * * ZIELE: * - Zero-Churn (setStateChanged), Zero-I/O-Overhead (RAM-Cache fuer Objektpfade), * typsichere Auswertung, harte Budget-Einhaltung. */ // ===================================================================================== // KONFIGURATION // ===================================================================================== const CONFIG = { // --- Datenpunkte fuer Benutzereingaben --- DP_API_KEY: '0_userdata.0.wetter.info.api_key', DP_FORECAST_DAYS: '0_userdata.0.wetter.info.forecast_days', DP_FORCE_RESET: '0_userdata.0.wetter.info.force_reset', DP_FORCE_UPDATE: '0_userdata.0.wetter.info.force_update', /** * --- Zeitsteuerung --- * Feste Zeiten, keine Randomisierung. Jeder Eintrag hat eine Prioritaetsstufe: * * tier = "wie viele Abrufe pro Tag muss das Restbudget noch tragen, * damit dieser Abruf laufen darf?" * * Wird das Budget knapp, faellt zuerst tier 3 weg, dann tier 2. tier 1 laeuft * bis zuletzt (notfalls nur noch jeden zweiten Tag). Dadurch bricht die * Versorgung nie komplett ab, sie wird nur gröber. * * 01:10 tier 1 Tagesplanung - die vollstaendige Vorhersage fuer den Tag * 11:40 tier 2 Nachmittags-Korrektur - Gewitter, Boeen, Hitzespitze vor der kritischen Phase * 18:40 tier 3 Abend + Folgetag - Nachtfrost, Sturm, Vorschau auf morgen */ UPDATES: [ { cron: '10 1 * * *', id: 'nacht', tier: 1, name: 'Nacht (Tagesplanung)' }, { cron: '40 11 * * *', id: 'mittag', tier: 2, name: 'Mittag (Korrektur Nachmittag)' }, { cron: '40 18 * * *', id: 'abend', tier: 3, name: 'Abend (Nacht + Folgetag)' } ] as { cron: string; id: string; tier: number; name: string }[], // Cooldown MUSS kleiner sein als der kuerzeste Abstand zwischen zwei Abrufen. // 01:10 -> 11:40 = 10,5 h | 11:40 -> 18:40 = 7,0 h | 18:40 -> 01:10 = 6,5 h => 5 h ist sicher. // Der Startup-Check rechnet das nach und meldet sich, falls es nicht passt. MIN_HOURS_BETWEEN_UPDATES: 5, // Notreserve: so viele Calls bleiben fuer manuelle Aktionen frei. Automatische // Wiederholungsversuche (Retry) greifen die Reserve nicht an. RESERVE_CALLS: 5, // --- API --- BASE_URL: 'https://forecast.meteonomiqs.com/v4_0', ICON_BASE_URL: 'https://cs3.wettercomassets.com/wcomv5/images/icons/weather', HTTP_TIMEOUT_MS: 20000, MAX_RETRIES: 1, // zusaetzliche Versuche bei Netzwerkfehler / HTTP 5xx RETRY_DELAY_MS: 30000, // --- Budget --- MONTHLY_LIMIT: 100, // --- Objektbaum --- DP_PATH: '0_userdata.0.wetter', DEFAULT_LANGUAGE: 'de', MAX_FORECAST_DAYS: 14, // API liefert max. 14 Tage CLEANUP_UP_TO_DAY: 25, // Aufraeumgrenze fuer alte Strukturen // --- Umfang der Datenpunkte (alles aus einem einzigen API-Call) --- ENABLE_SPACES: true, // Tagesabschnitte morning/afternoon/evening/night ENABLE_HOURLY: true, // Stundenwerte HOURLY_DAYS: 2, // fuer wie viele Tage (0 = aus, 2 = heute + morgen) ENABLE_CURRENT: true, // Ordner 'current' mit den Werten der laufenden Stunde CURRENT_CRON: '1 * * * *', // stuendlich um :01 aus den Stunden-Datenpunkten uebernehmen ENABLE_WARNINGS: true, // highestWarning (Warngruppe, Schweregrad) ENABLE_ASTRO: true, // Sonnenauf-/untergang, Daemmerung, Mond ENABLE_EXTENDED: true, // Luftdruck, Taupunkt, Windchill ENABLE_SNOW: true, // Schneefallgrenze, Frischschnee, Schneemenge ENABLE_JSON: true, // forecast_json / hourly_json fuer VIS-Widgets LOG_LEVEL: 'info' as 'debug' | 'info' | 'warn' | 'error', LOCATION: { LAT: '', LON: '', FORCE_MANUAL: false } }; // ===================================================================================== // TYPEN // ===================================================================================== type FetchSource = 'scheduled' | 'start' | 'key_update' | 'days_update' | 'force_reset' | 'manual'; type FieldGroup = 'core' | 'warn' | 'astro' | 'extended' | 'snow'; interface Ctx { lang: string; tz: string; } interface FieldDef { id: string; name: string; type: iobJS.CommonType; role: string; unit?: string; digits?: number; group?: FieldGroup; get: (src: any, ctx: Ctx) => any; } interface SystemConfig { lat: string; lon: string; lang: string; } // ===================================================================================== // GLOBALER ZUSTAND // ===================================================================================== let isFetching = false; let lastPayload: any = null; // RAM-Cache der letzten API-Antwort let lastPayloadTs = 0; let daysDebounce: any = null; let authFailed = false; // Sperre nach HTTP 401/403, bis der Key geaendert wird const ensuredPaths = new Set<string>(); // ===================================================================================== // HILFSFUNKTIONEN // ===================================================================================== /** Filtert Log-Ausgaben anhand des konfigurierten Log-Levels. */ function wcomLog(msg: string, level: 'debug' | 'info' | 'warn' | 'error' = 'info'): void { const levels = { debug: 0, info: 1, warn: 2, error: 3 }; if (levels[level] >= levels[CONFIG.LOG_LEVEL]) log(`[Wetter.com] ${msg}`, level); } function wcomPad(n: number | string): string { return String(n).padStart(2, '0'); } function wcomSleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** Rundet und faengt NaN/Infinity ab (verhindert Float-Rauschen und damit unnoetige State-Writes). */ function wcomRound(v: number, digits: number = 1): number { if (typeof v !== 'number' || !isFinite(v)) return 0; const p = Math.pow(10, digits); return Math.round(v * p) / p; } /** * Extrahiert typsicher numerische Werte aus den verschachtelten API-Objekten * ({value}|{avg}|{sum}|{max}|{min}) und faengt korrupte Rueckgaben ab. */ function wcomExtractValue(val: any): number { if (val === null || val === undefined) return 0; if (typeof val === 'number') return isFinite(val) ? val : 0; if (typeof val === 'boolean') return val ? 1 : 0; if (typeof val === 'object') { for (const key of ['value', 'avg', 'sum', 'max', 'min']) { if (val[key] !== undefined && val[key] !== null) return wcomExtractValue(val[key]); } return 0; } const parsed = parseFloat(String(val)); if (isNaN(parsed)) { if (String(val).trim() !== '') wcomLog(`Unerwarteter Nicht-Zahlenwert von API: "${val}"`, 'debug'); return 0; } return parsed; } /** * Parst API-Datumsangaben. "YYYY-MM-DD" wird bewusst LOKAL interpretiert - * `new Date('2026-08-06')` waere UTC-Mitternacht und in Zeitzonen westlich von * UTC damit der Vortag. */ function wcomParseApiDate(input: string | Date): Date | null { if (!input) return null; if (input instanceof Date) return isNaN(input.getTime()) ? null : input; const s = String(input).trim(); const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); const d = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(s); return isNaN(d.getTime()) ? null : d; } /** Formatiert nach DD.MM.YYYY. */ function wcomFormatDate(input: string | Date): string { const d = wcomParseApiDate(input); if (!d) return ''; return `${wcomPad(d.getDate())}.${wcomPad(d.getMonth() + 1)}.${d.getFullYear()}`; } /** Ausgeschriebener Wochentag in der Systemsprache. */ function wcomGetDayName(dateStr: string, locale: string): string { const d = wcomParseApiDate(dateStr); if (!d) return ''; try { return d.toLocaleDateString(locale, { weekday: 'long' }); } catch (e) { return d.toLocaleDateString('de', { weekday: 'long' }); } } /** * Rechnet einen UTC-Zeitstempel in die Ortszeit des Vorhersageortes um. * Nutzt die von der API gelieferte `location.timezone` - dadurch stimmen die * Stunden-Datenpunkte auch dann, wenn der ioBroker-Server in einer anderen * Zeitzone laeuft als der abgefragte Ort. */ function wcomLocalParts(utcIso: string, tz: string): { date: string; hour: string } | null { const d = utcIso ? new Date(utcIso) : null; if (!d || isNaN(d.getTime())) return null; try { const parts: any = {}; const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hourCycle: 'h23' }); for (const p of fmt.formatToParts(d)) parts[p.type] = p.value; const hour = parts.hour === '24' ? '00' : parts.hour; return { date: `${parts.year}-${parts.month}-${parts.day}`, hour: hour }; } catch (e) { return { date: `${d.getFullYear()}-${wcomPad(d.getMonth() + 1)}-${wcomPad(d.getDate())}`, hour: wcomPad(d.getHours()) }; } } /** Zieht "HH:MM" aus einem ISO-String mit Offset (z. B. 2026-08-06T06:17:49+02:00). */ function wcomIsoTime(iso: any): string { const m = /T(\d{2}):(\d{2})/.exec(String(iso || '')); return m ? `${m[1]}:${m[2]}` : ''; } /** Tageslaenge in Stunden aus Sonnenauf- und -untergang. */ function wcomDayLength(sunrise: any, sunset: any): number { const a = new Date(String(sunrise || '')); const b = new Date(String(sunset || '')); if (isNaN(a.getTime()) || isNaN(b.getTime())) return 0; return wcomRound((b.getTime() - a.getTime()) / 3600000, 2); } /** * Liefert die vollstaendige Icon-URL. Bevorzugt das von der API gelieferte * Dateifeld - dieses beruecksichtigt Tag/Nacht (d_/n_), Unwetterwarnung (d_e_) * und Windwarnung (d_w_). Nur als Fallback wird aus dem State gebaut. */ function wcomIconUrl(weather: any, isNight?: boolean): string { const raw = weather && typeof weather.icon === 'string' ? weather.icon.trim() : ''; const file = raw !== '' ? raw : `${isNight ? 'n' : 'd'}_${weather && weather.state !== undefined && weather.state !== null ? weather.state : 999}.svg`; return `${CONFIG.ICON_BASE_URL}/${file}`; } /** Wolkenbedeckung in Prozent. Stuendlich liefert die API Oktas (`eights`). */ function wcomCloudsPercent(clouds: any): number { if (clouds === null || clouds === undefined) return 0; if (typeof clouds === 'number') return clouds; if (typeof clouds.avg === 'number') return clouds.avg; if (typeof clouds.eights === 'number') return wcomRound((clouds.eights / 8) * 100, 1); if (typeof clouds.low === 'number' || typeof clouds.middle === 'number' || typeof clouds.high === 'number') { return Math.max(Number(clouds.low) || 0, Number(clouds.middle) || 0, Number(clouds.high) || 0); } return wcomExtractValue(clouds); } const MOON_PHASE: Record<string, string[]> = { de: ['', 'Neumond', 'Zunehmender Sichelmond', 'Zunehmender Halbmond', 'Zunehmender Dreiviertelmond', 'Vollmond', 'Abnehmender Dreiviertelmond', 'Abnehmender Halbmond', 'Abnehmender Sichelmond'], en: ['', 'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'] }; const MOON_ZODIAC: Record<string, string[]> = { de: ['', 'Widder', 'Stier', 'Zwillinge', 'Krebs', 'Löwe', 'Jungfrau', 'Waage', 'Skorpion', 'Schütze', 'Steinbock', 'Wassermann', 'Fische'], en: ['', 'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] }; function wcomLookup(table: Record<string, string[]>, lang: string, idx: any): string { const list = table[String(lang).slice(0, 2).toLowerCase()] || table.de; const i = Number(idx); return (Number.isInteger(i) && i > 0 && i < list.length) ? list[i] : ''; } /** Prueft, ob eine Feldgruppe laut CONFIG angelegt werden soll. */ function wcomGroupEnabled(group?: FieldGroup): boolean { switch (group) { case 'warn': return CONFIG.ENABLE_WARNINGS; case 'astro': return CONFIG.ENABLE_ASTRO; case 'extended': return CONFIG.ENABLE_EXTENDED; case 'snow': return CONFIG.ENABLE_SNOW; default: return true; } } // ===================================================================================== // FELD-DEFINITIONEN (tabellengetrieben statt duplizierter setState-Bloecke) // ===================================================================================== const DAY_FIELDS: FieldDef[] = [ { id: 'date', name: 'Datum', type: 'string', role: 'text', get: d => wcomFormatDate(d.date) }, { id: 'date_iso', name: 'Datum (ISO)', type: 'string', role: 'date', get: d => d.date || '' }, { id: 'day_name', name: 'Wochentag', type: 'string', role: 'text', get: (d, c) => wcomGetDayName(d.date, c.lang) }, { id: 'temp_max', name: 'Max. Temperatur', type: 'number', role: 'value.temperature.max', unit: '°C', digits: 1, get: d => d.temperature?.max }, { id: 'temp_min', name: 'Min. Temperatur', type: 'number', role: 'value.temperature.min', unit: '°C', digits: 1, get: d => d.temperature?.min }, { id: 'temp_avg', name: 'Durchschnittstemperatur', type: 'number', role: 'value.temperature', unit: '°C', digits: 1, get: d => d.temperature?.avg }, { id: 'weather_state', name: 'Wetter-Code', type: 'number', role: 'value', digits: 0, get: d => d.weather?.state }, { id: 'weather_text', name: 'Wetter', type: 'string', role: 'weather.state', get: d => d.weather?.text }, { id: 'weather_icon', name: 'Icon URL', type: 'string', role: 'weather.icon', get: d => wcomIconUrl(d.weather, false) }, { id: 'prec_probability', name: 'Regenrisiko', type: 'number', role: 'value.precipitation.probability', unit: '%', digits: 0, get: d => d.prec?.probability }, { id: 'prec_sum', name: 'Niederschlagsmenge', type: 'number', role: 'value.precipitation', unit: 'mm', digits: 2, get: d => d.prec?.sum }, { id: 'prec_class', name: 'Niederschlagsklasse (0-3)', type: 'number', role: 'value', digits: 0, get: d => d.prec?.class }, { id: 'rainy_hours', name: 'Regenstunden', type: 'number', role: 'value', unit: 'h', digits: 1, get: d => d.rainyHours }, { id: 'sun_hours', name: 'Sonnenstunden', type: 'number', role: 'value.sun', unit: 'h', digits: 1, get: d => d.sunHours }, { id: 'clouds', name: 'Bewölkung', type: 'number', role: 'value.clouds', unit: '%', digits: 1, get: d => wcomCloudsPercent(d.clouds) }, { id: 'humidity', name: 'Relative Feuchte', type: 'number', role: 'value.humidity', unit: '%', digits: 0, get: d => d.relativeHumidity }, { id: 'wind_speed', name: 'Windgeschwindigkeit (Ø)', type: 'number', role: 'value.speed.wind', unit: 'km/h', digits: 1, get: d => d.wind?.avg }, { id: 'wind_speed_max', name: 'Max. Windgeschwindigkeit', type: 'number', role: 'value.speed.wind.max', unit: 'km/h', digits: 1, get: d => d.wind?.max ?? d.wind?.avg }, { id: 'wind_gusts', name: 'Windböen', type: 'number', role: 'value.speed.wind.gust', unit: 'km/h', digits: 1, get: d => d.wind?.gusts }, { id: 'wind_direction', name: 'Windrichtung', type: 'string', role: 'weather.direction', get: d => d.wind?.direction }, { id: 'wind_direction_short', name: 'Windrichtung (kurz)', type: 'string', role: 'weather.direction', get: d => d.wind?.text }, { id: 'wind_degree', name: 'Windrichtung (Grad)', type: 'number', role: 'value.direction.wind', unit: '°', digits: 0, get: d => d.wind?.degree }, // --- Erweiterte meteorologische Werte --- { id: 'pressure', name: 'Luftdruck', type: 'number', role: 'value.pressure', unit: 'hPa', digits: 0, group: 'extended', get: d => d.pressure }, { id: 'windchill_min', name: 'Gefühlt min.', type: 'number', role: 'value.temperature.min', unit: '°C', digits: 1, group: 'extended', get: d => d.windchill?.min }, { id: 'windchill_max', name: 'Gefühlt max.', type: 'number', role: 'value.temperature.max', unit: '°C', digits: 1, group: 'extended', get: d => d.windchill?.max }, // --- Schnee --- { id: 'snow_sum', name: 'Schnee (Wasseräquivalent)', type: 'number', role: 'value.precipitation', unit: 'mm', digits: 2, group: 'snow', get: d => d.prec?.snow }, { id: 'fresh_snow_min', name: 'Neuschnee min.', type: 'number', role: 'value', unit: 'cm', digits: 1, group: 'snow', get: d => d.prec?.freshSnowRangeMin }, { id: 'fresh_snow_max', name: 'Neuschnee max.', type: 'number', role: 'value', unit: 'cm', digits: 1, group: 'snow', get: d => d.prec?.freshSnowRangeMax }, { id: 'snowline_min', name: 'Schneefallgrenze min.', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: d => d.snowLine?.min }, { id: 'snowline_max', name: 'Schneefallgrenze max.', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: d => d.snowLine?.max }, // --- Wetterwarnung --- { id: 'warn_active', name: 'Warnung aktiv', type: 'boolean', role: 'indicator.alarm', group: 'warn', get: d => !!d.highestWarning }, { id: 'warn_group', name: 'Warnung Gruppe', type: 'string', role: 'text', group: 'warn', get: d => d.highestWarning?.group }, { id: 'warn_text', name: 'Warnung Text', type: 'string', role: 'text', group: 'warn', get: d => d.highestWarning?.text }, { id: 'warn_severity', name: 'Warnung Schweregrad', type: 'string', role: 'text', group: 'warn', get: d => d.highestWarning?.severity }, { id: 'warn_severity_int', name: 'Warnung Schweregrad (0-4)', type: 'number', role: 'value.severity', digits: 0, group: 'warn', get: d => d.highestWarning?.severityInt } ]; const ASTRO_FIELDS: FieldDef[] = [ { id: 'sunrise', name: 'Sonnenaufgang', type: 'string', role: 'date.sunrise', get: a => wcomIsoTime(a.sunrise) }, { id: 'sunset', name: 'Sonnenuntergang', type: 'string', role: 'date.sunset', get: a => wcomIsoTime(a.sunset) }, { id: 'dawn', name: 'Morgendämmerung', type: 'string', role: 'text', get: a => wcomIsoTime(a.dawn) }, { id: 'dusk', name: 'Abenddämmerung', type: 'string', role: 'text', get: a => wcomIsoTime(a.dusk) }, { id: 'suntransit', name: 'Sonnenhöchststand', type: 'string', role: 'text', get: a => wcomIsoTime(a.suntransit) }, { id: 'sunrise_iso', name: 'Sonnenaufgang (ISO)', type: 'string', role: 'date.sunrise', get: a => a.sunrise || '' }, { id: 'sunset_iso', name: 'Sonnenuntergang (ISO)', type: 'string', role: 'date.sunset', get: a => a.sunset || '' }, { id: 'day_length', name: 'Tageslänge', type: 'number', role: 'value', unit: 'h', digits: 2, get: a => wcomDayLength(a.sunrise, a.sunset) }, { id: 'moonrise', name: 'Mondaufgang', type: 'string', role: 'text', get: a => wcomIsoTime(a.moonrise) }, { id: 'moonset', name: 'Monduntergang', type: 'string', role: 'text', get: a => wcomIsoTime(a.moonset) }, { id: 'moonphase', name: 'Mondphase (1-8)', type: 'number', role: 'value', digits: 0, get: a => a.moonphase }, { id: 'moonphase_text', name: 'Mondphase', type: 'string', role: 'text', get: (a, c) => wcomLookup(MOON_PHASE, c.lang, a.moonphase) }, { id: 'moonzodiac', name: 'Mond-Tierkreiszeichen (1-12)', type: 'number', role: 'value', digits: 0, get: a => a.moonzodiac }, { id: 'moonzodiac_text', name: 'Mond-Tierkreiszeichen', type: 'string', role: 'text', get: (a, c) => wcomLookup(MOON_ZODIAC, c.lang, a.moonzodiac) } ]; const SPACE_FIELDS: FieldDef[] = [ { id: 'temp', name: 'Temperatur', type: 'number', role: 'value.temperature', unit: '°C', digits: 1, get: s => s.temperature?.avg ?? s.temperature }, { id: 'temp_min', name: 'Temperatur min.', type: 'number', role: 'value.temperature.min', unit: '°C', digits: 1, get: s => s.temperature?.min }, { id: 'temp_max', name: 'Temperatur max.', type: 'number', role: 'value.temperature.max', unit: '°C', digits: 1, get: s => s.temperature?.max }, { id: 'weather_state', name: 'Wetter-Code', type: 'number', role: 'value', digits: 0, get: s => s.weather?.state }, { id: 'text', name: 'Wetter', type: 'string', role: 'weather.state', get: s => s.weather?.text }, { id: 'icon', name: 'Icon URL', type: 'string', role: 'weather.icon', get: s => wcomIconUrl(s.weather, !!s.isNight) }, { id: 'is_night', name: 'Nacht', type: 'boolean', role: 'indicator', get: s => !!s.isNight }, { id: 'prec_prob', name: 'Regenrisiko', type: 'number', role: 'value.precipitation.probability', unit: '%', digits: 0, get: s => s.prec?.probability }, { id: 'prec_sum', name: 'Niederschlagsmenge', type: 'number', role: 'value.precipitation', unit: 'mm', digits: 2, get: s => s.prec?.sum }, { id: 'wind_speed', name: 'Windgeschwindigkeit', type: 'number', role: 'value.speed.wind', unit: 'km/h', digits: 1, get: s => s.wind?.avg }, { id: 'wind_gusts', name: 'Windböen', type: 'number', role: 'value.speed.wind.gust', unit: 'km/h', digits: 1, get: s => s.wind?.gusts }, { id: 'wind_direction_short', name: 'Windrichtung (kurz)', type: 'string', role: 'weather.direction', get: s => s.wind?.text }, { id: 'wind_degree', name: 'Windrichtung (Grad)', type: 'number', role: 'value.direction.wind', unit: '°', digits: 0, get: s => s.wind?.degree }, { id: 'clouds', name: 'Bewölkung', type: 'number', role: 'value.clouds', unit: '%', digits: 1, get: s => wcomCloudsPercent(s.clouds) }, { id: 'humidity', name: 'Relative Feuchte', type: 'number', role: 'value.humidity', unit: '%', digits: 0, get: s => s.relativeHumidity }, { id: 'sun_hours', name: 'Sonnenstunden', type: 'number', role: 'value.sun', unit: 'h', digits: 1, get: s => s.sunHours }, { id: 'pressure', name: 'Luftdruck', type: 'number', role: 'value.pressure', unit: 'hPa', digits: 0, group: 'extended', get: s => s.pressure }, { id: 'windchill_min', name: 'Gefühlt min.', type: 'number', role: 'value.temperature.min', unit: '°C', digits: 1, group: 'extended', get: s => s.windchill?.min }, { id: 'windchill_max', name: 'Gefühlt max.', type: 'number', role: 'value.temperature.max', unit: '°C', digits: 1, group: 'extended', get: s => s.windchill?.max }, { id: 'snowline_min', name: 'Schneefallgrenze min.', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: s => s.snowLine?.min }, { id: 'snowline_max', name: 'Schneefallgrenze max.', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: s => s.snowLine?.max }, { id: 'fresh_snow_max', name: 'Neuschnee max.', type: 'number', role: 'value', unit: 'cm', digits: 1, group: 'snow', get: s => s.prec?.freshSnowRangeMax }, { id: 'warn_active', name: 'Warnung aktiv', type: 'boolean', role: 'indicator.alarm', group: 'warn', get: s => !!s.highestWarning }, { id: 'warn_text', name: 'Warnung Text', type: 'string', role: 'text', group: 'warn', get: s => s.highestWarning?.text }, { id: 'warn_severity_int', name: 'Warnung Schweregrad (0-4)', type: 'number', role: 'value.severity', digits: 0, group: 'warn', get: s => s.highestWarning?.severityInt } ]; const HOUR_FIELDS: FieldDef[] = [ { id: 'time', name: 'Uhrzeit (Ortszeit)', type: 'string', role: 'text', get: h => h.__time }, { id: 'from', name: 'Zeitstempel (UTC)', type: 'string', role: 'date', get: h => h.from || h.dateWithTimezone || '' }, { id: 'valid', name: 'Wert aktuell', type: 'boolean', role: 'indicator', get: () => true }, { id: 'temp', name: 'Temperatur', type: 'number', role: 'value.temperature', unit: '°C', digits: 1, get: h => h.temperature }, { id: 'windchill', name: 'Gefühlt', type: 'number', role: 'value.temperature', unit: '°C', digits: 1, get: h => h.windchill }, { id: 'weather_state', name: 'Wetter-Code', type: 'number', role: 'value', digits: 0, get: h => h.weather?.state }, { id: 'weather_text', name: 'Wetter', type: 'string', role: 'weather.state', get: h => h.weather?.text }, { id: 'weather_icon', name: 'Icon URL', type: 'string', role: 'weather.icon', get: h => wcomIconUrl(h.weather, !!h.isNight) }, { id: 'is_night', name: 'Nacht', type: 'boolean', role: 'indicator', get: h => !!h.isNight }, { id: 'prec_prob', name: 'Regenwahrscheinlichkeit', type: 'number', role: 'value.precipitation.probability', unit: '%', digits: 0, get: h => h.prec?.probability }, { id: 'prec_sum', name: 'Niederschlagsmenge', type: 'number', role: 'value.precipitation', unit: 'mm', digits: 2, get: h => h.prec?.sum }, { id: 'wind_speed', name: 'Windgeschwindigkeit', type: 'number', role: 'value.speed.wind', unit: 'km/h', digits: 1, get: h => h.wind?.avg }, { id: 'wind_gusts', name: 'Windböen', type: 'number', role: 'value.speed.wind.gust', unit: 'km/h', digits: 1, get: h => h.wind?.gusts }, // ACHTUNG: 'wind_dir' bleibt bewusst die Langform ("Westwind") wie in v2.7.0 - // bestehende VIS-Bindings brechen dadurch nicht. Die Kurzform kommt zusaetzlich dazu. { id: 'wind_dir', name: 'Windrichtung', type: 'string', role: 'weather.direction', get: h => h.wind?.direction }, { id: 'wind_dir_short', name: 'Windrichtung (kurz)', type: 'string', role: 'weather.direction', get: h => h.wind?.text }, { id: 'wind_degree', name: 'Windrichtung (Grad)', type: 'number', role: 'value.direction.wind', unit: '°', digits: 0, get: h => h.wind?.degree }, { id: 'humidity', name: 'Relative Feuchte', type: 'number', role: 'value.humidity', unit: '%', digits: 0, get: h => h.relativeHumidity }, { id: 'clouds', name: 'Bewölkung', type: 'number', role: 'value.clouds', unit: '%', digits: 1, get: h => wcomCloudsPercent(h.clouds) }, { id: 'sun_hours', name: 'Sonnenstunden', type: 'number', role: 'value.sun', unit: 'h', digits: 2, get: h => h.sunHours }, { id: 'pressure', name: 'Luftdruck', type: 'number', role: 'value.pressure', unit: 'hPa', digits: 0, group: 'extended', get: h => h.pressure }, { id: 'dewpoint', name: 'Taupunkt', type: 'number', role: 'value.temperature.dewpoint', unit: '°C', digits: 1, group: 'extended', get: h => h.dewpoint }, { id: 'fresh_snow', name: 'Neuschnee', type: 'number', role: 'value', unit: 'cm', digits: 1, group: 'snow', get: h => h.freshSnow }, { id: 'snowline', name: 'Schneefallgrenze', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: h => h.prec?.snowLine }, { id: 'warn_active', name: 'Warnung aktiv', type: 'boolean', role: 'indicator.alarm', group: 'warn', get: h => !!h.highestWarning }, { id: 'warn_text', name: 'Warnung Text', type: 'string', role: 'text', group: 'warn', get: h => h.highestWarning?.text }, { id: 'warn_severity_int', name: 'Warnung Schweregrad (0-4)', type: 'number', role: 'value.severity', digits: 0, group: 'warn', get: h => h.highestWarning?.severityInt } ]; // ===================================================================================== // OBJEKTBAUM // ===================================================================================== /** Legt Ordner (device/channel) an. Nutzt den RAM-Cache, um I/O zu vermeiden. */ async function wcomEnsureSubStructure(path: string, name: string, type: 'device' | 'channel' = 'channel'): Promise<void> { if (!path || ensuredPaths.has(path)) return; if (!existsObject(path)) { await extendObjectAsync(path, { type: type, common: { name: name }, native: {} }); } ensuredPaths.add(path); } /** Legt einen Datenpunkt an. Nutzt den RAM-Cache, um I/O zu vermeiden. */ async function wcomEnsureState(path: string, init: any, type: iobJS.CommonType, name: string, role: string, unit?: string, writeable: boolean = false): Promise<void> { if (ensuredPaths.has(path)) return; if (!existsObject(path)) { await createStateAsync(path, init, false, { name, type, role, unit: unit || '', read: true, write: writeable } as any); } ensuredPaths.add(path); } function wcomInitValue(type: iobJS.CommonType): any { return type === 'number' ? 0 : (type === 'boolean' ? false : ''); } function wcomCoerce(raw: any, f: FieldDef): any { if (f.type === 'number') return wcomRound(wcomExtractValue(raw), f.digits === undefined ? 1 : f.digits); if (f.type === 'boolean') return !!raw; return raw === null || raw === undefined ? '' : String(raw); } /** * Legt alle Datenpunkte einer Feldtabelle an und puffert die Schreibvorgaenge. * Ein einziger Ort fuer Anlegen + Schreiben - dadurch koennen Definition und * Schreibvorgang nicht mehr auseinanderlaufen. */ async function wcomRenderFields(basePath: string, labelPrefix: string, fields: FieldDef[], src: any, ctx: Ctx, buffer: Promise<any>[]): Promise<void> { const active = fields.filter(f => wcomGroupEnabled(f.group)); await Promise.all(active.map(f => wcomEnsureState(`${basePath}.${f.id}`, wcomInitValue(f.type), f.type, `${labelPrefix}${f.name}`, f.role, f.unit) )); for (const f of active) { let raw: any; try { raw = f.get(src, ctx); } catch (e) { raw = null; wcomLog(`Feld '${f.id}' konnte nicht gelesen werden: ${e}`, 'debug'); } buffer.push(setStateChangedAsync(`${basePath}.${f.id}`, wcomCoerce(raw, f), true)); } } // ===================================================================================== // SYSTEM / STANDORT // ===================================================================================== /** Liest Koordinaten und Sprache aus den ioBroker-Systemeinstellungen. */ async function wcomGetSystemSettings(): Promise<SystemConfig | null> { if (CONFIG.LOCATION.FORCE_MANUAL) { const lat = parseFloat(CONFIG.LOCATION.LAT); const lon = parseFloat(CONFIG.LOCATION.LON); if (isNaN(lat) || isNaN(lon)) { wcomLog('FORCE_MANUAL aktiv, aber die manuellen Koordinaten sind ungültig.', 'error'); return null; } const sysLang = await wcomGetSystemLanguage(); return { lat: lat.toFixed(3), lon: lon.toFixed(3), lang: sysLang }; } const obj: any = await getObjectAsync('system.config'); const common = obj && obj.common ? obj.common : null; if (!common || common.latitude === undefined || common.latitude === null || common.longitude === undefined || common.longitude === null) { wcomLog('Kein Standort in den ioBroker-Systemeinstellungen hinterlegt (Einstellungen -> System -> Standort).', 'error'); return null; } const lat = parseFloat(String(common.latitude)); const lon = parseFloat(String(common.longitude)); if (isNaN(lat) || isNaN(lon)) { wcomLog('Standort in system.config ist nicht numerisch.', 'error'); return null; } return { lat: lat.toFixed(3), lon: lon.toFixed(3), lang: common.language || CONFIG.DEFAULT_LANGUAGE }; } async function wcomGetSystemLanguage(): Promise<string> { try { const obj: any = await getObjectAsync('system.config'); return (obj && obj.common && obj.common.language) || CONFIG.DEFAULT_LANGUAGE; } catch (e) { return CONFIG.DEFAULT_LANGUAGE; } } // ===================================================================================== // HTTP // ===================================================================================== /** HTTP GET mit hartem Timeout - verhindert einen Deadlock im isFetching-Lock. */ async function wcomHttpGetAsync(url: string, options: any): Promise<any> { let timeoutId: any = null; const fetchPromise = new Promise((resolve, reject) => { httpGet(url, options, (err: any, response: any) => { if (err) reject(err); else resolve(response); }); }); const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(`HTTP Timeout nach ${CONFIG.HTTP_TIMEOUT_MS / 1000} s`)), CONFIG.HTTP_TIMEOUT_MS + 2000); }); try { return await Promise.race([fetchPromise, timeoutPromise]); } finally { if (timeoutId) clearTimeout(timeoutId); } } // ===================================================================================== // BUDGET & ZAEHLER // ===================================================================================== function wcomMonthKey(d: Date): string { return `${d.getFullYear()}-${wcomPad(d.getMonth() + 1)}`; } function wcomDayKey(d: Date): string { return `${d.getFullYear()}-${wcomPad(d.getMonth() + 1)}-${wcomPad(d.getDate())}`; } async function wcomGetNum(id: string, fallback = 0): Promise<number> { const s = await getStateAsync(id); return s && s.val !== null && s.val !== undefined ? Number(s.val) : fallback; } /** * Setzt Monats-/Tageszaehler anhand von Perioden-Markern zurueck. * Robuster als ein reiner Cron: laeuft ioBroker zum Umschaltzeitpunkt nicht, * wird beim naechsten Aufruf trotzdem korrekt zurueckgesetzt. */ async function wcomRollPeriods(): Promise<void> { const now = new Date(); const monthKey = wcomMonthKey(now); const storedMonth = await getStateAsync(`${CONFIG.DP_PATH}.info.requests_month_period`); const storedMonthVal = storedMonth && storedMonth.val ? String(storedMonth.val) : ''; if (storedMonthVal !== monthKey) { if (storedMonthVal) wcomLog(`Monatswechsel erkannt (${storedMonthVal} -> ${monthKey}). Monatszähler zurückgesetzt.`, 'info'); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, 0, true); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month_period`, monthKey, true); } const dayKey = wcomDayKey(now); const storedDay = await getStateAsync(`${CONFIG.DP_PATH}.info.requests_today_date`); const storedDayVal = storedDay && storedDay.val ? String(storedDay.val) : ''; if (storedDayVal !== dayKey) { await setStateAsync(`${CONFIG.DP_PATH}.info.requests_today`, 0, true); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_today_date`, dayKey, true); } } /** Hoechste konfigurierte Prioritaetsstufe = Abrufe pro Tag im Vollbetrieb. */ function wcomMaxTier(): number { return CONFIG.UPDATES.reduce((m, u) => Math.max(m, u.tier), 1); } /** * Prueft Monatsbudget, Cooldown und die gestaffelte Sparlogik. * * @param source Ursprung des Abrufs. * @param tier Prioritaetsstufe (nur bei automatischen Abrufen relevant). * Ein Abruf der Stufe N laeuft nur, wenn das Restbudget noch * N Abrufe pro Tag bis Monatsende traegt. * @param label Klartextname fuer die Logausgabe. */ async function wcomCheckBudget(source: FetchSource, tier: number = 0, label: string = ''): Promise<boolean> { await wcomRollPeriods(); const currentUsage = await wcomGetNum(`${CONFIG.DP_PATH}.info.requests_month`, 0); if (currentUsage >= CONFIG.MONTHLY_LIMIT) { wcomLog(`Monatslimit erreicht (${currentUsage}/${CONFIG.MONTHLY_LIMIT}). Pausiert bis zum 01. des Folgemonats.`, 'warn'); await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Monatslimit erreicht', true); return false; } const automatic = (source === 'scheduled' || source === 'start'); if (!automatic) return true; // manuelle Aktionen: nur das harte Limit gilt // Cooldown schuetzt vor Skript-Neustart-Schleifen. const lastTs = await wcomGetNum(`${CONFIG.DP_PATH}.info.last_sync_ts`, 0); if (lastTs > 0) { const diffHours = (Date.now() - lastTs) / 3600000; if (diffHours < CONFIG.MIN_HOURS_BETWEEN_UPDATES) { wcomLog(`Abruf übersprungen (Cooldown). Letzter Abruf vor ${diffHours.toFixed(1)} h, Minimum ${CONFIG.MIN_HOURS_BETWEEN_UPDATES} h.`, 'debug'); return false; } } // Ein Skriptstart wird wie der niedrigst priorisierte Abruf behandelt: er darf // nie die Reserve anknabbern, die die planmaessigen Abrufe am Leben haelt. const effTier = tier > 0 ? tier : wcomMaxTier(); const now = new Date(); const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); const daysLeft = daysInMonth - now.getDate(); // volle Tage nach heute const needed = effTier * (daysLeft + 1); // effTier Abrufe/Tag inkl. heute if (currentUsage + needed <= CONFIG.MONTHLY_LIMIT) return true; if (effTier > 1) { wcomLog(`SPARMODUS: "${label || source}" übersprungen. Das Restbudget trägt keine ${effTier} Abrufe/Tag mehr bis Monatsende (Verbrauch ${currentUsage}/${CONFIG.MONTHLY_LIMIT}, ${daysLeft + 1} Tage offen).`, 'warn'); return false; } // Stufe 1 ist der letzte verbliebene Abruf: statt ganz auszusetzen nur noch // jeden zweiten Tag - lieber gröber als gar nicht. if (now.getDate() % 2 !== 0) { wcomLog(`NOTLAUF: "${label || source}" übersprungen (Budget kritisch, Abruf nur noch an geraden Tagen). Verbrauch ${currentUsage}/${CONFIG.MONTHLY_LIMIT}.`, 'warn'); return false; } return true; } /** * Zaehlt eine beantwortete API-Anfrage gegen das Kontingent. * Bewusst auch bei Fehlerantworten (ausser 401/403) - diese verbrauchen beim * Anbieter ebenfalls Kontingent. In v2.7.0 wurde nur HTTP 200 gezaehlt, dadurch * konnte der lokale Zaehler dauerhaft vom echten Verbrauch abweichen. */ async function wcomCountRequest(): Promise<void> { await setStateAsync(`${CONFIG.DP_PATH}.info.requests_today`, (await wcomGetNum(`${CONFIG.DP_PATH}.info.requests_today`)) + 1, true); const month = (await wcomGetNum(`${CONFIG.DP_PATH}.info.requests_month`)) + 1; await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, month, true); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_left`, Math.max(0, CONFIG.MONTHLY_LIMIT - month), true); } /** Schreibt die Sync-Zeitstempel nach einem erfolgreichen Abruf. */ async function wcomUpdateSyncInfo(): Promise<void> { const now = new Date(); const ts = `${wcomPad(now.getDate())}.${wcomPad(now.getMonth() + 1)}.${now.getFullYear()} ${wcomPad(now.getHours())}:${wcomPad(now.getMinutes())}`; await setStateAsync(`${CONFIG.DP_PATH}.info.last_sync`, ts, true); await setStateAsync(`${CONFIG.DP_PATH}.info.last_sync_ts`, now.getTime(), true); } // ===================================================================================== // INITIALISIERUNG DER INFO-DATENPUNKTE // ===================================================================================== async function wcomEnsureBaseStructure(): Promise<void> { await wcomEnsureSubStructure(CONFIG.DP_PATH, 'Wetter.com Forecast', 'device'); await wcomEnsureSubStructure(`${CONFIG.DP_PATH}.info`, 'Informationen'); await Promise.all([ wcomEnsureState(`${CONFIG.DP_PATH}.info.last_sync`, '', 'string', 'Letztes Update', 'text'), wcomEnsureState(`${CONFIG.DP_PATH}.info.last_sync_ts`, 0, 'number', 'Letztes Update (Timestamp)', 'value.time'), wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_month`, 0, 'number', 'Anfragen Monat', 'value'), wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_month_period`, '', 'string', 'Abrechnungsmonat', 'text'), wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_today`, 0, 'number', 'Anfragen heute', 'value'), wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_today_date`, '', 'string', 'Zähltag', 'text'), wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_left`, CONFIG.MONTHLY_LIMIT, 'number', 'Verbleibende Anfragen', 'value'), wcomEnsureState(`${CONFIG.DP_PATH}.info.status`, '', 'string', 'Status', 'text'), wcomEnsureState(`${CONFIG.DP_PATH}.info.last_error`, '', 'string', 'Letzter Fehler', 'text'), wcomEnsureState(`${CONFIG.DP_PATH}.info.forecast_date`, '', 'string', 'Vorhersage erstellt (API)', 'date'), wcomEnsureState(`${CONFIG.DP_PATH}.info.next_update`, '', 'string', 'Nächstes API-Update', 'date'), wcomEnsureState(`${CONFIG.DP_PATH}.info.location`, '', 'string', 'Verwendeter Standort', 'text'), wcomEnsureState(`${CONFIG.DP_PATH}.info.timezone`, '', 'string', 'Zeitzone des Standorts', 'text'), wcomEnsureState(`${CONFIG.DP_PATH}.info.elevation`, 0, 'number', 'Höhe über NN', 'value', 'm'), // Benutzereingaben wcomEnsureState(CONFIG.DP_API_KEY, '', 'string', 'Wetter.com API Key', 'text', '', true), wcomEnsureState(CONFIG.DP_FORECAST_DAYS, 7, 'number', 'Vorhersage Tage', 'value', '', true), wcomEnsureState(CONFIG.DP_FORCE_RESET, false, 'boolean', 'Monatszähler zurücksetzen', 'button', '', true), wcomEnsureState(CONFIG.DP_FORCE_UPDATE, false, 'boolean', 'Jetzt aktualisieren', 'button', '', true) ]); if (CONFIG.ENABLE_JSON) { await Promise.all([ wcomEnsureState(`${CONFIG.DP_PATH}.forecast_json`, '[]', 'string', 'Vorhersage (JSON)', 'json'), wcomEnsureState(`${CONFIG.DP_PATH}.hourly_json`, '[]', 'string', 'Stundenwerte (JSON)', 'json') ]); } } // ===================================================================================== // HAUPTLOGIK // ===================================================================================== async function wcomFetchWeatherData(source: FetchSource = 'start', tier: number = 0, label: string = ''): Promise<void> { if (isFetching) { wcomLog('Abruf läuft bereits (Lock aktiv). Abbruch.', 'debug'); return; } isFetching = true; try { await wcomEnsureBaseStructure(); // Schreibrechte auf den Eingabe-Datenpunkten sicherstellen (Migration alter Installationen) if (source === 'start') { await extendObjectAsync(CONFIG.DP_API_KEY, { common: { write: true } }); await extendObjectAsync(CONFIG.DP_FORECAST_DAYS, { common: { write: true } }); } const apiKeyState = await getStateAsync(CONFIG.DP_API_KEY); const apiKey = apiKeyState && apiKeyState.val ? String(apiKeyState.val).trim() : ''; if (apiKey.length < 10) { wcomLog(`Bitte einen gültigen API-Key in '${CONFIG.DP_API_KEY}' eintragen.`, 'error'); await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Kein API-Key', true); return; } if (authFailed && (source === 'start' || source === 'scheduled')) { wcomLog('Abruf übersprungen: der API-Key wurde zuletzt abgelehnt. Bitte Key korrigieren.', 'warn'); return; } const forecastDays = await wcomGetForecastDays(); if (!(await wcomCheckBudget(source, tier, label))) return; const settings = await wcomGetSystemSettings(); if (!settings) { await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Kein Standort', true); return; } const url = `${CONFIG.BASE_URL}/forecast/${settings.lat}/${settings.lon}`; const options = { timeout: CONFIG.HTTP_TIMEOUT_MS, headers: { 'x-api-key': apiKey, 'Accept-Language': settings.lang } }; wcomLog(`Abruf gestartet (${label || source}) für ${settings.lat}/${settings.lon}, ${forecastDays} Tage.`, 'info'); let data: any = null; // Wiederholungsversuche duerfen die Notreserve nicht aufbrauchen. const mayRetry = async () => (await wcomGetNum(`${CONFIG.DP_PATH}.info.requests_left`, CONFIG.MONTHLY_LIMIT)) > CONFIG.RESERVE_CALLS; for (let attempt = 0; attempt <= CONFIG.MAX_RETRIES; attempt++) { let response: any = null; let netError: any = null; try { response = await wcomHttpGetAsync(url, options); } catch (e) { netError = e; } if (netError) { const retry = attempt < CONFIG.MAX_RETRIES && await mayRetry(); wcomLog(`Netzwerkfehler: ${netError.message || netError}`, retry ? 'warn' : 'error'); await setStateAsync(`${CONFIG.DP_PATH}.info.last_error`, String(netError.message || netError), true); if (retry) { await wcomSleep(CONFIG.RETRY_DELAY_MS); continue; } await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Netzwerkfehler', true); return; } const code = response ? Number(response.statusCode) : 0; // 401/403 werden vom Gateway vor der Verrechnung abgewiesen und daher nicht gezaehlt. if (code !== 401 && code !== 403) await wcomCountRequest(); if (code === 429) { wcomLog('API-Kontingent erschöpft (HTTP 429). Zähler wird auf das Limit gesetzt.', 'error'); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, CONFIG.MONTHLY_LIMIT, true); await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'HTTP 429 - Limit erschöpft', true); return; } if (code === 401 || code === 403) { // Sperre setzen, damit wiederholte Skript-Neustarts nicht dauerhaft // gegen die API laufen. Wird beim Ändern des API-Keys aufgehoben. authFailed = true; wcomLog(`API-Key abgelehnt (HTTP ${code}). Automatische Abrufe pausiert, bis der Key geändert wird.`, 'error'); await setStateAsync(`${CONFIG.DP_PATH}.info.status`, `HTTP ${code} - Key ungültig`, true); return; } if (code >= 500) { const retry = attempt < CONFIG.MAX_RETRIES && await mayRetry(); wcomLog(`Serverfehler HTTP ${code}.`, retry ? 'warn' : 'error'); if (retry) { await wcomSleep(CONFIG.RETRY_DELAY_MS); continue; } await setStateAsync(`${CONFIG.DP_PATH}.info.status`, `HTTP ${code}`, true); return; } if (code !== 200) { wcomLog(`Unerwarteter Statuscode HTTP ${code}.`, 'error'); await setStateAsync(`${CONFIG.DP_PATH}.info.status`, `HTTP ${code}`, true); return; } try { data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; } catch (e) { wcomLog('API-Antwort konnte nicht als JSON gelesen werden.', 'error'); await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'JSON-Parsefehler', true); return; } break; } if (!data || !Array.isArray(data.summary) || data.summary.length === 0) { wcomLog('API-Antwort enthält keine Vorhersagedaten.', 'error'); await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Leere Antwort', true); return; } lastPayload = data; lastPayloadTs = Date.now(); authFailed = false; await wcomUpdateSyncInfo(); await wcomProcessForecastData(data, settings.lang, forecastDays); await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'OK', true); await setStateAsync(`${CONFIG.DP_PATH}.info.last_error`, '', true); } catch (e: any) { wcomLog(`Script-Fehler: ${e && e.message ? e.message : e}`, 'error'); try { await setStateAsync(`${CONFIG.DP_PATH}.info.last_error`, String(e && e.message ? e.message : e), true); } catch (ignore) { /* noop */ } } finally { isFetching = false; } } async function wcomGetForecastDays(): Promise<number> { const raw = await wcomGetNum(CONFIG.DP_FORECAST_DAYS, 7); return Math.max(1, Math.min(Math.round(raw) || 7, CONFIG.MAX_FORECAST_DAYS)); } // ===================================================================================== // VERARBEITUNG // ===================================================================================== async function wcomProcessForecastData(data: any, lang: string, forecastDays: number): Promise<void> { const tz = (data.location && data.location.timezone) ? String(data.location.timezone) : Intl.DateTimeFormat().resolvedOptions().timeZone; const ctx: Ctx = { lang: lang, tz: tz }; await wcomEnsureSubStructure(CONFIG.DP_PATH, 'Wetter.com Forecast', 'device'); // Metadaten aus der Antwort await Promise.all([ setStateChangedAsync(`${CONFIG.DP_PATH}.info.forecast_date`, String(data.forecastDate || ''), true), setStateChangedAsync(`${CONFIG.DP_PATH}.info.next_update`, String(data.nextUpdate || ''), true), setStateChangedAsync(`${CONFIG.DP_PATH}.info.timezone`, tz, true), setStateChangedAsync(`${CONFIG.DP_PATH}.info.location`, data.location && data.location.coordinates ? `${data.location.coordinates.latitude}, ${data.location.coordinates.longitude}` : '', true), setStateChangedAsync(`${CONFIG.DP_PATH}.info.elevation`, wcomExtractValue(data.location && data.location.coordinates ? data.location.coordinates.elevation : 0), true) ]); const summary: any[] = Array.isArray(data.summary) ? data.summary : []; const maxDays = Math.min(summary.length, forecastDays); // Tagesabschnitte ueber das Datum zuordnen statt ueber den Array-Index. const spacesByDate = new Map<string, any>(); if (Array.isArray(data.spaces)) { for (const s of data.spaces) if (s && s.date) spacesByDate.set(String(s.date), s); } // Stundenwerte nach Ortsdatum gruppieren. const hourlyByDate = new Map<string, any[]>(); if (Array.isArray(data.hourly)) { for (const h of data.hourly) { const parts = wcomLocalParts(h.from || h.dateWithTimezone || h.date, tz); const key = parts ? parts.date : String(h.date || ''); if (!key) continue; h.__hour = parts ? parts.hour : '00'; h.__time = `${h.__hour}:00`; if (!hourlyByDate.has(key)) hourlyByDate.set(key, []); hourlyByDate.get(key)!.push(h); } } const jsonDays: any[] = []; const jsonHours: any[] = []; let totalWrites = 0; for (let i = 0; i < maxDays; i++) { const day = summary[i]; if (!day) continue; const dayKey = String(day.date || ''); const dayPath = `${CONFIG.DP_PATH}.day_${i}`; const buffer: Promise<any>[] = []; await wcomEnsureSubStructure(dayPath, `Tag ${i}`); await wcomRenderFields(dayPath, `Tag ${i}: `, DAY_FIELDS, day, ctx, buffer); // --- Astronomie --- if (CONFIG.ENABLE_ASTRO && day.astronomy) { const astroPath = `${dayPath}.astro`; await wcomEnsureSubStructure(astroPath, 'Sonne & Mond'); await wcomRenderFields(astroPath, `Tag ${i}: `, ASTRO_FIELDS, day.astronomy, ctx, buffer); } // --- Tagesabschnitte --- if (CONFIG.ENABLE_SPACES) { const spaceDay = spacesByDate.get(dayKey); if (spaceDay) { const spacesPath = `${dayPath}.spaces`; await wcomEnsureSubStructure(spacesPath, 'Tagesabschnitte'); const labels: Record<string, string> = { morning: 'Morgen', afternoon: 'Mittag', evening: 'Abend', night: 'Nacht' }; for (const seg of ['morning', 'afternoon', 'evening', 'night']) { const sData = spaceDay[seg]; if (!sData) continue; const sPath = `${spacesPath}.${seg}`; await wcomEnsureSubStructure(sPath, labels[seg]); await wcomRenderFields(sPath, `Tag ${i} ${labels[seg]}: `, SPACE_FIELDS, sData, ctx, buffer); } } else { wcomLog(`Keine Tagesabschnitte für ${dayKey} in der API-Antwort.`, 'debug'); } } // --- Stundenwerte --- if (CONFIG.ENABLE_HOURLY && i < CONFIG.HOURLY_DAYS) { const hourlyPath = `${dayPath}.hourly`; await wcomEnsureSubStructure(hourlyPath, 'Stündlich'); const hours = hourlyByDate.get(dayKey) || []; const delivered = new Set<string>(); for (const h of hours) { const hPath = `${hourlyPath}.${h.__hour}`; delivered.add(h.__hour); await wcomEnsureSubStructure(hPath, `${h.__hour}:00 Uhr`); await wcomRenderFields(hPath, `Tag ${i} ${h.__hour}:00: `, HOUR_FIELDS, h, ctx, buffer); if (CONFIG.ENABLE_JSON && i === 0) { jsonHours.push({ date: dayKey, time: h.__time, from: h.from || '', temp: wcomRound(wcomExtractValue(h.temperature), 1), icon: wcomIconUrl(h.weather, !!h.isNight), text: h.weather?.text || '', precProb: wcomRound(wcomExtractValue(h.prec?.probability), 0), precSum: wcomRound(wcomExtractValue(h.prec?.sum), 2), wind: wcomRound(wcomExtractValue(h.wind?.avg), 1), gusts: wcomRound(wcomExtractValue(h.wind?.gusts), 1), humidity: wcomRound(wcomExtractValue(h.relativeHumidity), 0) }); } } // Bereits vergangene Stunden liefert die API nicht mehr mit: // Werte stehen lassen, aber als nicht mehr aktuell markieren. for (let hh = 0; hh < 24; hh++) { const label = wcomPad(hh); if (delivered.has(label)) continue; const validPath = `${hourlyPath}.${label}.valid`; if (existsObject(validPath)) buffer.push(setStateChangedAsync(validPath, false, true)); } } // --- JSON-Aggregat --- if (CONFIG.ENABLE_JSON) { jsonDays.push({ day: i, date: wcomFormatDate(day.date), dateIso: dayKey, dayName: wcomGetDayName(day.date, lang), tempMin: wcomRound(wcomExtractValue(day.temperature?.min), 1), tempMax: wcomRound(wcomExtractValue(day.temperature?.max), 1), text: day.weather?.text || '', icon: wcomIconUrl(day.weather, false), state: wcomRound(wcomExtractValue(day.weather?.state), 0), precProb: wcomRound(wcomExtractValue(day.prec?.probability), 0), precSum: wcomRound(wcomExtractValue(day.prec?.sum), 2), wind: wcomRound(wcomExtractValue(day.wind?.avg), 1), gusts: wcomRound(wcomExtractValue(day.wind?.gusts), 1), windDir: day.wind?.text || '', sunHours: wcomRound(wcomExtractValue(day.sunHours), 1), clouds: wcomRound(wcomCloudsPercent(day.clouds), 0), humidity: wcomRound(wcomExtractValue(day.relativeHumidity), 0), pressure: wcomRound(wcomExtractValue(day.pressure), 0), sunrise: wcomIsoTime(day.astronomy?.sunrise), sunset: wcomIsoTime(day.astronomy?.sunset), warnActive: !!day.highestWarning, warnText: day.highestWarning?.text || '', warnSeverity: wcomRound(wcomExtractValue(day.highestWarning?.severityInt), 0) }); } totalWrites += buffer.length; await Promise.all(buffer); } if (CONFIG.ENABLE_JSON) { await Promise.all([ setStateChangedAsync(`${CONFIG.DP_PATH}.forecast_json`, JSON.stringify(jsonDays), true), setStateChangedAsync(`${CONFIG.DP_PATH}.hourly_json`, JSON.stringify(jsonHours), true) ]); } await wcomCleanupObsoleteDays(maxDays); await wcomUpdateCurrent('nach Abruf'); wcomLog(`Update abgeschlossen: ${maxDays} Tage, ${totalWrites} Werte verarbeitet.`, 'info'); } // ===================================================================================== // CURRENT - Werte der laufenden Stunde // ===================================================================================== /** Zusatzfelder in 'current', die nicht aus den Stundendaten stammen. */ const CURRENT_EXTRA: { id: string; name: string; type: iobJS.CommonType; role: string; unit?: string; from: string; astro?: boolean }[] = [ { id: 'date', name: 'Datum', type: 'string', role: 'text', from: 'date' }, { id: 'day_name', name: 'Wochentag', type: 'string', role: 'text', from: 'day_name' }, { id: 'temp_min_today', name: 'Min. Temperatur heute', type: 'number', role: 'value.temperature.min', unit: '°C', from: 'temp_min' }, { id: 'temp_max_today', name: 'Max. Temperatur heute', type: 'number', role: 'value.temperature.max', unit: '°C', from: 'temp_max' }, { id: 'sunrise', name: 'Sonnenaufgang', type: 'string', role: 'date.sunrise', from: 'astro.sunrise', astro: true }, { id: 'sunset', name: 'Sonnenuntergang', type: 'string', role: 'date.sunset', from: 'astro.sunset', astro: true } ]; function wcomCurrentFields(): FieldDef[] { return HOUR_FIELDS.filter(f => wcomGroupEnabled(f.group)); } async function wcomEnsureCurrentStates(): Promise<void> { const base = `${CONFIG.DP_PATH}.current`; // Wurzel mit absichern: 'current' kann vor dem ersten erfolgreichen Abruf angelegt werden. await wcomEnsureSubStructure(CONFIG.DP_PATH, 'Wetter.com Forecast', 'device'); await wcomEnsureSubStructure(base, 'Aktuelle Stunde'); await Promise.all([ ...wcomCurrentFields().map(f => wcomEnsureState(`${base}.${f.id}`, wcomInitValue(f.type), f.type, `Jetzt: ${f.name}`, f.role, f.unit)), ...CURRENT_EXTRA.filter(e => !e.astro || CONFIG.ENABLE_ASTRO).map(e => wcomEnsureState(`${base}.${e.id}`, wcomInitValue(e.type), e.type, `Jetzt: ${e.name}`, e.role, e.unit)), wcomEnsureState(`${base}.source`, '', 'string', 'Jetzt: Quell-Datenpunkt', 'text'), wcomEnsureState(`${base}.updated`, '', 'string', 'Jetzt: zuletzt übernommen', 'text') ]); } /** Kopiert einen Datenpunktwert, sofern die Quelle existiert. */ async function wcomCopyState(from: string, to: string, buffer: Promise<any>[]): Promise<void> { if (!existsObject(from)) return; const s = await getStateAsync(from); if (!s || s.val === null || s.val === undefined) return; buffer.push(setStateChangedAsync(to, s.val as any, true)); } /** * Fuellt '<DP_PATH>.current' mit den Werten der laufenden Stunde. * * Quelle sind bewusst die bereits geschriebenen Stunden-Datenpunkte und nicht der * RAM-Cache: so liefert 'current' auch direkt nach einem Skript-Neustart wieder * Werte, ohne dass dafuer ein API-Call faellig wird. * * Die Tageszuordnung laeuft ueber 'date_iso' statt ueber einen festen Index - * zwischen 00:00 und dem 01:10-Abruf steht der heutige Tag noch in day_1. */ async function wcomUpdateCurrent(reason: string = 'stündlich'): Promise<void> { if (!CONFIG.ENABLE_CURRENT) return; if (!CONFIG.ENABLE_HOURLY || CONFIG.HOURLY_DAYS < 1) { wcomLog("ENABLE_CURRENT benötigt ENABLE_HOURLY mit HOURLY_DAYS >= 1. 'current' bleibt leer.", 'warn'); return; } try { await wcomEnsureCurrentStates(); const base = `${CONFIG.DP_PATH}.current`; const tzState = await getStateAsync(`${CONFIG.DP_PATH}.info.timezone`); const tz = tzState && tzState.val ? String(tzState.val) : Intl.DateTimeFormat().resolvedOptions().timeZone; const now = wcomLocalParts(new Date().toISOString(), tz); if (!now) return; // Tagesordner suchen, dessen Datum dem heutigen Ortsdatum entspricht let dayIdx = -1; for (let i = 0; i < CONFIG.HOURLY_DAYS; i++) { const st = await getStateAsync(`${CONFIG.DP_PATH}.day_${i}.date_iso`); if (st && String(st.val) === now.date) { dayIdx = i; break; } } const buffer: Promise<any>[] = []; const srcHour = dayIdx >= 0 ? `${CONFIG.DP_PATH}.day_${dayIdx}.hourly.${now.hour}` : ''; if (!srcHour || !existsObject(`${srcHour}.time`)) { buffer.push(setStateChangedAsync(`${base}.valid`, false, true)); await Promise.all(buffer); wcomLog(`current: keine Stundendaten für ${now.date} ${now.hour}:00 vorhanden.`, 'debug'); return; } const srcDay = `${CONFIG.DP_PATH}.day_${dayIdx}`; for (const f of wcomCurrentFields()) { await wcomCopyState(`${srcHour}.${f.id}`, `${base}.${f.id}`, buffer); } for (const e of CURRENT_EXTRA) { if (e.astro && !CONFIG.ENABLE_ASTRO) continue; await wcomCopyState(`${srcDay}.${e.from}`, `${base}.${e.id}`, buffer); } const ts = new Date(); buffer.push( setStateChangedAsync(`${base}.source`, srcHour, true), setStateAsync(`${base}.updated`, `${wcomPad(ts.getDate())}.${wcomPad(ts.getMonth() + 1)}.${ts.getFullYear()} ${wcomPad(ts.getHours())}:${wcomPad(ts.getMinutes())}`, true) ); await Promise.all(buffer); wcomLog(`current aktualisiert (${reason}): ${now.date} ${now.hour}:00 aus day_${dayIdx}.`, 'debug'); } catch (e: any) { wcomLog(`current konnte nicht aktualisiert werden: ${e && e.message ? e.message : e}`, 'error'); } } /** Entfernt Tagesordner, die durch eine reduzierte Vorhersagedauer obsolet sind. */ async function wcomCleanupObsoleteDays(activeDays: number): Promise<void> { for (let i = activeDays; i <= CONFIG.CLEANUP_UP_TO_DAY; i++) { const path = `${CONFIG.DP_PATH}.day_${i}`; if (existsObject(path)) { await deleteObjectAsync(path, true); // Cache-Eintraege des geloeschten Teilbaums verwerfen for (const p of Array.from(ensuredPaths)) if (p === path || p.indexOf(`${path}.`) === 0) ensuredPaths.delete(p); wcomLog(`Obsoleten Tagesordner entfernt: day_${i}`, 'debug'); } } } /** Rendert die zuletzt geholte Antwort neu - ohne API-Call. */ async function wcomRerenderFromCache(reason: string): Promise<boolean> { if (!lastPayload) return false; if (isFetching) return false; isFetching = true; try { const settings = await wcomGetSystemSettings(); const lang = settings ? settings.lang : CONFIG.DEFAULT_LANGUAGE; const days = await wcomGetForecastDays(); wcomLog(`${reason}: Neuaufbau aus dem Cache (Stand ${new Date(lastPayloadTs).toLocaleString()}) - kein API-Call nötig.`, 'info'); await wcomProcessForecastData(lastPayload, lang, days); return true; } catch (e: any) { wcomLog(`Neuaufbau aus dem Cache fehlgeschlagen: ${e && e.message ? e.message : e}`, 'error'); return false; } finally { isFetching = false; } } // ===================================================================================== // SELBSTPRUEFUNG DER ZEITPLANUNG // ===================================================================================== /** Liest "M H * * *" und liefert die Minuten seit Mitternacht, sonst null. */ function wcomCronMinutes(cron: string): number | null { const p = String(cron).trim().split(/\s+/); if (p.length < 5) return null; const m = Number(p[0]); const h = Number(p[1]); if (!Number.isInteger(m) || !Number.isInteger(h)) return null; return h * 60 + m; } /** * Prueft beim Start zwei Dinge, die sich sonst nur als "es kommen keine Daten" * bemerkbar machen: * 1. Ist der Cooldown kleiner als der kuerzeste Abstand zwischen zwei Abrufen? * (Genau das liess in v2.7.0 den Nachmittags-Abruf dauerhaft ausfallen.) * 2. Passt die gewaehlte Taktung ueberhaupt in das Monatsbudget? */ function wcomValidateSchedule(): void { const hhmm = (min: number) => `${wcomPad(Math.floor(min / 60))}:${wcomPad(min % 60)}`; const times = CONFIG.UPDATES .map(u => ({ min: wcomCronMinutes(u.cron), u })) .filter(x => x.min !== null) as { min: number; u: typeof CONFIG.UPDATES[0] }[]; if (times.length === 0) { wcomLog('Keine gültigen Abrufzeiten konfiguriert.', 'error'); return; } times.sort((x, y) => x.min - y.min); const plan = times.map(t => `${hhmm(t.min)} (${t.u.name}, Stufe ${t.u.tier})`).join(' | '); // Kleinster Abstand im 24-h-Kreis let minGap = 24 * 60; for (let i = 0; i < times.length; i++) { const next = times[(i + 1) % times.length]; const raw = next.min - times[i].min; minGap = Math.min(minGap, raw > 0 ? raw : raw + 1440); } const gapHours = times.length > 1 ? minGap / 60 : 24; wcomLog(`Zeitplan: ${plan}`, 'info'); if (CONFIG.MIN_HOURS_BETWEEN_UPDATES >= gapHours) { wcomLog(`KONFIGURATIONSFEHLER: Cooldown (${CONFIG.MIN_HOURS_BETWEEN_UPDATES} h) ist größer/gleich dem kürzesten Abstand der Abrufzeiten (${gapHours.toFixed(1)} h). Mindestens ein Abruf wird IMMER übersprungen. Bitte MIN_HOURS_BETWEEN_UPDATES verkleinern.`, 'error'); } else { wcomLog(`Kürzester Abstand ${gapHours.toFixed(1)} h, Cooldown ${CONFIG.MIN_HOURS_BETWEEN_UPDATES} h - passt.`, 'debug'); } // Budget-Plausibilitaet fuer den laengsten Monat const perDay = wcomMaxTier(); const worstCase = perDay * 31; if (worstCase > CONFIG.MONTHLY_LIMIT) { wcomLog(`BUDGET-WARNUNG: ${perDay} Abrufe/Tag ergeben in einem 31-Tage-Monat ${worstCase} Calls, das Limit liegt bei ${CONFIG.MONTHLY_LIMIT}. Die Sparstaffelung greift dann gegen Monatsende automatisch.`, 'warn'); } else { const spare = CONFIG.MONTHLY_LIMIT - worstCase; wcomLog(`Budget: ${perDay} Abrufe/Tag = max. ${worstCase} Calls/Monat von ${CONFIG.MONTHLY_LIMIT} (${spare} Reserve).`, 'info'); } } // ===================================================================================== // ZEITSTEUERUNG & TRIGGER // ===================================================================================== for (const upd of CONFIG.UPDATES) { schedule(upd.cron, () => wcomFetchWeatherData('scheduled', upd.tier, upd.name)); } // Perioden-Reset zusaetzlich taeglich anstossen, damit die Zaehler in der // Oberflaeche zeitnah umspringen. Der eigentliche Reset ist lazy abgesichert. schedule('1 0 * * *', () => wcomRollPeriods().catch(e => wcomLog(String(e), 'error'))); // 'current' stuendlich nachziehen - reine Umschichtung vorhandener Datenpunkte, kein API-Call. if (CONFIG.ENABLE_CURRENT) { schedule(CONFIG.CURRENT_CRON, () => wcomUpdateCurrent('stündlich')); } on({ id: CONFIG.DP_API_KEY, change: 'ne' }, (obj) => { if (obj.state && typeof obj.state.val === 'string' && obj.state.val.trim().length >= 10) { authFailed = false; // neue Chance nach einem zuvor abgelehnten Key wcomLog('API-Key geändert. Starte Test-Abruf.', 'info'); wcomFetchWeatherData('key_update'); } }); on({ id: CONFIG.DP_FORECAST_DAYS, change: 'ne' }, (obj) => { if (!obj.state || obj.state.val === null) return; // Entprellen: schnelle Aenderungen (Slider in VIS) zu einem Vorgang zusammenfassen. if (daysDebounce) clearTimeout(daysDebounce); daysDebounce = setTimeout(async () => { daysDebounce = null; wcomLog(`Vorhersage-Tage geändert auf ${obj.state.val}.`, 'info'); const done = await wcomRerenderFromCache('Änderung der Vorhersagedauer'); if (!done) wcomFetchWeatherData('days_update'); }, 3000); }); on({ id: CONFIG.DP_FORCE_RESET, change: 'any', val: true }, async () => { wcomLog('Manueller Reset: Monatszähler wird auf 0 gesetzt.', 'warn'); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, 0, true); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month_period`, wcomMonthKey(new Date()), true); await setStateAsync(CONFIG.DP_FORCE_RESET, false, true); }); on({ id: CONFIG.DP_FORCE_UPDATE, change: 'any', val: true }, async () => { await setStateAsync(CONFIG.DP_FORCE_UPDATE, false, true); authFailed = false; wcomLog('Manueller Abruf ausgelöst (umgeht den Cooldown, Monatslimit bleibt aktiv).', 'info'); wcomFetchWeatherData('manual'); }); // --- Skriptstart --- (async () => { wcomValidateSchedule(); await wcomFetchWeatherData('start'); // 'current' MUSS unabhaengig vom Start-Abruf aufgebaut werden. // Sonst entsteht genau die Luecke aus v3.2.0: greift beim Neustart der Cooldown // (oder fehlt der API-Key), laeuft wcomProcessForecastData() nicht - und damit // wurde der Ordner nie angelegt. Die Werte stammen ohnehin aus den bereits // vorhandenen Stunden-Datenpunkten, ein Abruf ist dafuer nicht noetig. await wcomUpdateCurrent('Skriptstart'); })();Hi @Schimi , danke für das Skript.
Ich glaube, es gibt einen kleinen Bug bei den stündlichen Daten. Die Werte für die ersten beiden Stunden des Tages werden falsch zugeordnet. Zum Debuggen habe ich das Skript angepasst und den Datenpunkt "from" mitschreiben lassen. Da ergibt sich folgendes Bild:
0_userdata.0.wetter_com.day_0.hourly.23.from = 2026-04-25T21:00:00Z 0_userdata.0.wetter_com.day_1.hourly.00.from = 2026-04-26T22:00:00Z 0_userdata.0.wetter_com.day_1.hourly.01.from = 2026-04-26T23:00:00Z 0_userdata.0.wetter_com.day_1.hourly.02.from = 2026-04-26T00:00:00ZDie TImestamps sind ja UTC, also 2h Versatz zu Deutschland. Die Uhrzeiten sind alle ok, aber bei 00:00 und 01:00 Uhr stimmt das Datum nicht. Da müsste als Tag der 25. drin stehen.
-
-
Hi @Schimi , danke für das Skript.
Ich glaube, es gibt einen kleinen Bug bei den stündlichen Daten. Die Werte für die ersten beiden Stunden des Tages werden falsch zugeordnet. Zum Debuggen habe ich das Skript angepasst und den Datenpunkt "from" mitschreiben lassen. Da ergibt sich folgendes Bild:
0_userdata.0.wetter_com.day_0.hourly.23.from = 2026-04-25T21:00:00Z 0_userdata.0.wetter_com.day_1.hourly.00.from = 2026-04-26T22:00:00Z 0_userdata.0.wetter_com.day_1.hourly.01.from = 2026-04-26T23:00:00Z 0_userdata.0.wetter_com.day_1.hourly.02.from = 2026-04-26T00:00:00ZDie TImestamps sind ja UTC, also 2h Versatz zu Deutschland. Die Uhrzeiten sind alle ok, aber bei 00:00 und 01:00 Uhr stimmt das Datum nicht. Da müsste als Tag der 25. drin stehen.
@pk68 Hi danke für die Info....
habe mal ne neue version erstellt, weil ich auch wieder ins Limit gelaufen bin... vielleicht hatte das sogar was miteinander zutun... falls nicht, bin ich langsam überfragt :-)
/** * [Wetter.com Forecast API v4.0 (TrueScript)] * * CHANGELOG: * - 2.6.4: 2026-04-27 - HOTFIX (Initialization Order) * - FIX: State "info.last_sync" not found. Die Initialisierung des last_sync-Datenpunkts wurde an den Anfang der Fetch-Funktion vorgezogen, damit wcomUpdateUsageInfo (Ghost-Call Fix) nicht ins Leere schreibt. * - 2.6.3: CRITICAL BUGFIX UPDATE (Ghost-Calls & UTC-Offset) * - 2.6.2: FEATURE: Manueller Reset & Log-Präzision. * - 2.6.1: FIX: Konfigurations-Datenpunkte beschreibbar gemacht. * - 2.6.0: ULTRA-PERFORMANCE (RAM-Cache, wcomWait entfernt). * * KONTEXT: * - Hardware: ioBroker Server | Schnittstellen: Meteonomiqs API v4.0 (HTTP) * * ZIELE: * - Maximale Effizienz (Zero-Churn, Zero-I/O Overhead) und 100% typsichere Ausfallsicherheit unter Budget-Einhaltung. */ // --- KONFIGURATION --- const CONFIG = { // SECURITY: Den API Key NIEMALS hier im Klartext speichern! DP_API_KEY: '0_userdata.0.wetter_com.info.api_key', DP_FORECAST_DAYS: '0_userdata.0.wetter_com.info.forecast_days', DP_FORCE_RESET: '0_userdata.0.wetter_com.info.force_reset', BASE_URL: 'https://forecast.meteonomiqs.com/v4_0', ICON_BASE_URL: 'https://cs3.wettercomassets.com/wcomv5/images/icons/weather', DP_PATH: '0_userdata.0.wetter_com', DEFAULT_LANGUAGE: 'de', ENABLE_HOURLY: true, ENABLE_SPACES: true, MONTHLY_LIMIT: 100, LOG_LEVEL: 'info' as 'debug' | 'info' | 'warn' | 'error', LOCATION: { LAT: '', LON: '', FORCE_MANUAL: false } }; // --- STATISCHE DEFINITIONEN --- const STATE_DEFS: Record<string, { name: string; type: iobJS.CommonType; role: string; unit?: string; init: any }> = { 'date': { name: 'Datum', type: 'string', role: 'text', init: '' }, 'day_name': { name: 'Wochentag', type: 'string', role: 'text', init: '' }, 'temp_max': { name: 'Max Temp', type: 'number', unit: '°C', role: 'value.temperature.max', init: 0 }, 'temp_min': { name: 'Min Temp', type: 'number', unit: '°C', role: 'value.temperature.min', init: 0 }, 'weather_text': { name: 'Wetter', type: 'string', role: 'weather.state', init: '' }, 'weather_icon': { name: 'Icon URL', type: 'string', role: 'weather.icon', init: '' }, 'prec_probability': { name: 'Regenrisiko', type: 'number', unit: '%', role: 'value.precipitation.probability', init: 0 }, 'prec_sum': { name: 'Regenmenge', type: 'number', unit: 'mm', role: 'value.precipitation', init: 0 }, 'wind_gusts': { name: 'Windböen', type: 'number', unit: 'km/h', role: 'value.speed.wind.gust', init: 0 }, 'wind_speed_max': { name: 'Max. Windgeschwindigkeit', type: 'number', unit: 'km/h', role: 'value.speed.wind.max', init: 0 }, 'sun_hours': { name: 'Sonnenstunden', type: 'number', unit: 'h', role: 'value.sun', init: 0 }, 'clouds': { name: 'Bewölkung', type: 'number', unit: '%', role: 'value', init: 0 }, 'humidity': { name: 'Relative Feuchte', type: 'number', unit: '%', role: 'value.humidity', init: 0 } }; // --- INTERFACES --- type FetchSource = 'morning' | 'afternoon' | 'start' | 'key_update' | 'days_update' | 'force_reset'; interface WetterComValue { avg?: number; value?: number; sum?: number; max?: number; min?: number; } interface WetterComWeather { state: number; text: string; icon?: string; } interface WetterComWind { avg?: number | WetterComValue; min?: number | WetterComValue; max?: number | WetterComValue; gusts?: number | WetterComValue | { value: number | null }; direction?: string; unit?: string; } interface WetterComPrec { probability: number; sum: number | WetterComValue; } interface ForecastSummary { date: string; weather: WetterComWeather; temperature: { min: number | WetterComValue; max: number | WetterComValue; avg?: number | WetterComValue }; wind: WetterComWind; prec: WetterComPrec; clouds: number | WetterComValue; relativeHumidity: number | WetterComValue; sunHours?: number; } interface ForecastSpaceSegment { temperature: number | WetterComValue; weather: WetterComWeather; prec: WetterComPrec; wind: WetterComWind; clouds: number | WetterComValue; relativeHumidity: number | WetterComValue; } interface ForecastSpace { morning?: ForecastSpaceSegment; afternoon?: ForecastSpaceSegment; evening?: ForecastSpaceSegment; night?: ForecastSpaceSegment; } interface ForecastHourly { from: string; date: string; weather: WetterComWeather; temperature: number | WetterComValue; windchill: number | WetterComValue; wind: WetterComWind; prec: WetterComPrec; relativeHumidity: number | WetterComValue; } interface WetterComResponse { summary: ForecastSummary[]; spaces: ForecastSpace[]; hourly: ForecastHourly[]; } interface SystemConfig { lat: string | null; lon: string | null; lang: string; } // --- GLOBALE VARIABLEN --- let isFetching: boolean = false; const ensuredPaths = new Set<string>(); // --- HILFSFUNKTIONEN --- /** * Filtert und gibt Log-Meldungen basierend auf dem konfigurierten Log-Level aus. * @param msg Die auszugebende Nachricht. * @param level Das Loglevel (debug, info, warn, error). */ function wcomLog(msg: string, level: 'debug' | 'info' | 'warn' | 'error' = 'info'): void { const levels = { debug: 0, info: 1, warn: 2, error: 3 }; if (levels[level] >= levels[CONFIG.LOG_LEVEL]) { log(`[Wetter.com] ${msg}`, level); } } /** * Extrahiert typsicher numerische Werte aus API-Objekten und fängt korrupte Rückgaben ab. * @KI_HINWEIS: Fängt null/undefined ab und loggt fehlerhaftes (NaN) API-Verhalten ohne zu crashen. * @param val Der rohe Wert aus der JSON-Antwort. * @returns Bereinigter numerischer Wert oder 0 als Fallback. */ function wcomExtractValue(val: any): number { if (val === null || val === undefined) return 0; if (typeof val === 'number') { if (isNaN(val)) { wcomLog('API lieferte explizites NaN als number-Typ', 'debug'); return 0; } return val; } if (typeof val === 'object') { if (val.value !== undefined && val.value !== null) return val.value; if (val.avg !== undefined && val.avg !== null) return val.avg; if (val.sum !== undefined && val.sum !== null) return val.sum; if (val.max !== undefined && val.max !== null) return val.max; if (val.min !== undefined && val.min !== null) return val.min; } const parsed = parseFloat(String(val)); if (isNaN(parsed)) { if (String(val).trim() !== '') { wcomLog(`Unerwarteter Nicht-Zahlenwert (NaN) von API empfangen: "${val}"`, 'debug'); } return 0; } return parsed; } /** * Formatiert einen Datumsstring oder ein Date-Objekt ins Format DD.MM.YYYY basierend auf der lokalen Zeit. * @param dateInput UTC-String oder Date Objekt. * @returns Formatiertes lokales Datum. */ function wcomFormatDate(dateInput: string | Date): string { if (!dateInput) return ''; const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; if (isNaN(date.getTime())) return String(dateInput); return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`; } /** * Ermittelt den ausgeschriebenen Wochentag basierend auf dem Datum und der Spracheinstellung. * @param dateStr Datumsstring. * @param locale Sprachcode (z.B. 'de'). * @returns Wochentag als String. */ function wcomGetDayName(dateStr: string, locale: string): string { if (!dateStr) return ''; const date = new Date(dateStr); if (isNaN(date.getTime())) return ''; return date.toLocaleDateString(locale, { weekday: 'long' }); } /** * Holt die Geokoordinaten und Sprache aus den globalen ioBroker-Systemeinstellungen. * @returns SystemConfig Objekt mit lat, lon und lang, oder null bei Fehlern. */ async function wcomGetSystemSettings(): Promise<SystemConfig | null> { let coords: { lat: string; lon: string } | null = null; if (CONFIG.LOCATION.FORCE_MANUAL && CONFIG.LOCATION.LAT && CONFIG.LOCATION.LON) { const lat = parseFloat(CONFIG.LOCATION.LAT); const lon = parseFloat(CONFIG.LOCATION.LON); if (!isNaN(lat) && !isNaN(lon)) { coords = { lat: lat.toFixed(3), lon: lon.toFixed(3) }; } else { wcomLog('Manuelle Koordinaten sind ungültig (NaN).', 'error'); return null; } } const systemConf: SystemConfig = await new Promise((resolve) => { getObject('system.config', (err, obj: any) => { if (!err && obj && obj.common) { const sysLat = obj.common.latitude !== undefined && obj.common.latitude !== null ? parseFloat(String(obj.common.latitude)).toFixed(3) : null; const sysLon = obj.common.longitude !== undefined && obj.common.longitude !== null ? parseFloat(String(obj.common.longitude)).toFixed(3) : null; resolve({ lat: sysLat, lon: sysLon, lang: obj.common.language || CONFIG.DEFAULT_LANGUAGE }); } else { resolve({ lat: null, lon: null, lang: CONFIG.DEFAULT_LANGUAGE }); } }); }); if (!coords && systemConf.lat && systemConf.lon) coords = { lat: systemConf.lat, lon: systemConf.lon }; return coords ? { ...coords, lang: systemConf.lang } : null; } /** * Erstellt asynchron Ordner-Strukturen (Devices/Channels) im ioBroker Objektbaum unter Nutzung des RAM-Caches. * @param path Zielpfad im Objektbaum. * @param name Anzeigename. * @param type Objekttyp (device oder channel). */ async function wcomEnsureSubStructure(path: string, name: string, type: 'device' | 'channel' = 'channel'): Promise<void> { if (!path || ensuredPaths.has(path)) return; if (!existsObject(path)) { await extendObjectAsync(path, { type: type, common: { name: name }, native: {} }); } ensuredPaths.add(path); } /** * Erstellt asynchron Datenpunkte im ioBroker Objektbaum unter Nutzung des RAM-Caches. * @param path Zielpfad des Datenpunkts. * @param init Initialwert. * @param type Datentyp. * @param name Anzeigename. * @param role ioBroker-Rolle. * @param unit Physikalische Einheit (optional). * @param writeable Definiert, ob der Wert vom User beschrieben werden darf. */ async function wcomEnsureState(path: string, init: any, type: iobJS.CommonType, name: string, role: string, unit?: string, writeable: boolean = false): Promise<void> { if (ensuredPaths.has(path)) return; if (!existsObject(path)) { await createStateAsync(path, init, false, { name, type, role, unit: unit || '', read: true, write: writeable } as any); } ensuredPaths.add(path); } /** * Iteriert über STATE_DEFS und legt die Basis-Datenpunkte für einen spezifischen Forecast-Tag an. * @param path Zielpfad des Tages-Ordners. * @param index Index des Tages (0 = heute). */ async function wcomEnsureDayStates(path: string, index: number): Promise<void> { const promises = Object.entries(STATE_DEFS).map(([id, cfg]) => { return wcomEnsureState(`${path}.${id}`, cfg.init, cfg.type, `Tag ${index}: ${cfg.name}`, cfg.role, cfg.unit); }); await Promise.all(promises); } /** * Führt einen asynchronen HTTP GET Request aus, abgesichert durch einen 10-Sekunden Timeout. * @KI_HINWEIS: Verhindert persistente Deadlocks im isFetching-Lock, falls die API oder das Netzwerk hängt. * @param url Die Ziel-URL. * @param options Header-Konfiguration. * @returns HTTP Response Objekt. */ async function wcomHttpGetAsync(url: string, options: any): Promise<any> { let timeoutId: NodeJS.Timeout; const fetchPromise = new Promise((resolve, reject) => { httpGet(url, options, (err, response) => { if (err) reject(err); else resolve(response); }); }); const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('HTTP Timeout nach 10 Sekunden')), 10000); }); try { return await Promise.race([fetchPromise, timeoutPromise]); } finally { if (timeoutId!) clearTimeout(timeoutId); } } // --- LOGIK --- /** * Prüft das verbleibende Monatsbudget und berechnet, ob ein Abruf zulässig ist. * @param source Ursprung des Triggers. * @returns True wenn Budget vorhanden, false wenn limitiert. */ async function wcomCheckBudget(source: FetchSource): Promise<boolean> { const requestState = await getStateAsync(`${CONFIG.DP_PATH}.info.requests_month`); const currentUsage = requestState && requestState.val !== null ? Number(requestState.val) : 0; if (currentUsage >= CONFIG.MONTHLY_LIMIT) { wcomLog(`Monatslimit erreicht (${currentUsage}/${CONFIG.MONTHLY_LIMIT}). Skript pausiert automatisch bis zum 01. des Folgemonats.`, 'warn'); return false; } if (source === 'start') { const todayState = await getStateAsync(`${CONFIG.DP_PATH}.info.requests_today`); if (todayState && todayState.val !== null && Number(todayState.val) > 0) { wcomLog(`Skript-Neustart erkannt. Abruf übersprungen, da heute bereits Daten geladen wurden.`, 'debug'); return false; } } const now = new Date(); const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); const daysLeft = daysInMonth - now.getDate(); if (source === 'afternoon' || (source === 'start' && now.getHours() >= 12)) { const callsNeededFor2xDaily = (daysLeft * 2) + 1; if (currentUsage + callsNeededFor2xDaily > CONFIG.MONTHLY_LIMIT) { wcomLog(`SPARMODUS AKTIV: Nachmittags-Abruf übersprungen (Budget-Schutz). Verbrauch: ${currentUsage}`, 'warn'); return false; } } if (source === 'morning' || (source === 'start' && now.getHours() < 12)) { const callsNeededFor1xDaily = daysLeft + 1; if (currentUsage + callsNeededFor1xDaily > CONFIG.MONTHLY_LIMIT) { if (now.getDate() % 2 !== 0) { wcomLog(`NOTLAUF AKTIV: Morgen-Abruf übersprungen (Budget extrem niedrig). Verbrauch: ${currentUsage}`, 'warn'); return false; } } } return true; } /** * Erkennt einen Tageswechsel lokal und setzt den Tageszähler asynchron zurück. */ async function wcomCheckDailyReset(): Promise<void> { const nowStr = wcomFormatDate(new Date()); const lastSyncState = await getStateAsync(`${CONFIG.DP_PATH}.info.last_sync`); if (lastSyncState && typeof lastSyncState.val === 'string') { const lastSyncDate = lastSyncState.val.split(' ')[0]; if (lastSyncDate && lastSyncDate !== nowStr) { wcomLog('Tageswechsel erkannt. Setze requests_today auf 0.', 'debug'); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_today`, 0, true); } } } /** * Hauptfunktion für den Abruf und die Validierung der Wetterdaten. * @param source Ursprung des Triggers zur Budget-Berechnung. */ async function wcomFetchWeatherData(source: FetchSource = 'start'): Promise<void> { if (isFetching) { wcomLog('Abruf läuft bereits (Lock aktiv). Abbruch.', 'debug'); return; } isFetching = true; try { await wcomEnsureSubStructure(CONFIG.DP_PATH, 'Wetter.com Forecast', 'device'); await wcomEnsureSubStructure(`${CONFIG.DP_PATH}.info`, 'Informationen'); // @KI_HINWEIS: Initialization Order Fix (2.6.4). last_sync muss zwingend hier gesichert werden, // bevor wcomUpdateUsageInfo aufgerufen wird. await wcomEnsureState(`${CONFIG.DP_PATH}.info.last_sync`, '', 'string', 'Letztes Update', 'text'); await wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_month`, 0, 'number', 'Anfragen Monat', 'value'); await wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_today`, 0, 'number', 'Anfragen heute', 'value'); await wcomEnsureState(CONFIG.DP_API_KEY, '', 'string', 'Wetter.com API Key', 'text', '', true); await wcomEnsureState(CONFIG.DP_FORECAST_DAYS, 7, 'number', 'Vorhersage Tage', 'value', '', true); await wcomEnsureState(CONFIG.DP_FORCE_RESET, false, 'boolean', 'Manueller Zähler-Reset', 'button', '', true); if (source === 'start') { await extendObjectAsync(CONFIG.DP_API_KEY, { common: { write: true } }); await extendObjectAsync(CONFIG.DP_FORECAST_DAYS, { common: { write: true } }); } const apiKeyObj = await getStateAsync(CONFIG.DP_API_KEY); const apiKeyValue = apiKeyObj ? String(apiKeyObj.val).trim() : ''; if (!apiKeyValue || apiKeyValue.length < 10) { wcomLog(`Bitte gültigen API-Key im beschreibbaren Datenpunkt '${CONFIG.DP_API_KEY}' eintragen!`, 'error'); return; } const daysObj = await getStateAsync(CONFIG.DP_FORECAST_DAYS); let forecastDays = daysObj && daysObj.val !== null ? Number(daysObj.val) : 7; forecastDays = Math.max(1, Math.min(forecastDays, 16)); await wcomCheckDailyReset(); const allowFetch = await wcomCheckBudget(source); if (!allowFetch) return; const settings = await wcomGetSystemSettings(); if (!settings) return; wcomLog(`Abruf gestartet für Lat: ${settings.lat}, Lon: ${settings.lon} (Trigger: ${source}, Tage: ${forecastDays})`, 'info'); const url: string = `${CONFIG.BASE_URL}/forecast/${settings.lat}/${settings.lon}`; const options = { headers: { 'x-api-key': apiKeyValue, 'Accept-Language': settings.lang } }; const response = await wcomHttpGetAsync(url, options); if (response && response.statusCode === 429) { wcomLog('Das Limit von 100 API-Calls im Monat ist ausgeschöpft (HTTP 429).', 'error'); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, CONFIG.MONTHLY_LIMIT, true); return; } if (response && response.statusCode !== 200) { wcomLog(`API-Fehler: HTTP ${response.statusCode}`, 'error'); return; } // @KI_HINWEIS: SOFORTIGES Inkrement. Sichert die Limits ab, selbst wenn danach JSON-Fehler // oder Datenbank-Latenzen im System zu einem unvollständigen Skriptdurchlauf führen (Ghost-Call Fix). await wcomUpdateUsageInfo(); let data: WetterComResponse; try { data = JSON.parse(response.data); } catch (e) { wcomLog('Konnte API-Antwort nicht parsen.', 'error'); return; } if (data && data.summary) { await wcomProcessForecastData(data, settings.lang, forecastDays); await wcomCleanupObsoleteDays(forecastDays); } } catch (e: any) { wcomLog(`Script-Fehler: ${e.message}`, 'error'); } finally { isFetching = false; } } /** * Schreibt das validierte JSON in die ioBroker-Datenpunkte mittels Promise-Batching. * @param data Parsed JSON von Meteonomiqs. * @param lang Verwendete Sprache. * @param forecastDays Limitierung der Zukunfts-Tage aus den Einstellungen. */ async function wcomProcessForecastData(data: WetterComResponse, lang: string, forecastDays: number): Promise<void> { await wcomEnsureSubStructure(CONFIG.DP_PATH, 'Wetter.com Forecast', 'device'); const maxDays: number = Math.min((data.summary ?? []).length, forecastDays); let totalWrites = 0; for (let i = 0; i < maxDays; i++) { const dayWriteBuffer: Promise<any>[] = []; const day: ForecastSummary = data.summary[i]; const dayPath: string = `${CONFIG.DP_PATH}.day_${i}`; await wcomEnsureSubStructure(dayPath, `Tag ${i}`); await wcomEnsureDayStates(dayPath, i); // @KI_HINWEIS: Referenzdatum in strikt lokaler Zeit berechnen (Zeitzonen-Fix) const dayDateStrLocal = wcomFormatDate(day.date); const iconName = `d_${day.weather?.state ?? 0}.svg`; dayWriteBuffer.push( setStateChangedAsync(`${dayPath}.date`, String(wcomFormatDate(day.date)), true), setStateChangedAsync(`${dayPath}.day_name`, String(wcomGetDayName(day.date, lang)), true), setStateChangedAsync(`${dayPath}.temp_max`, wcomExtractValue(day.temperature?.max), true), setStateChangedAsync(`${dayPath}.temp_min`, wcomExtractValue(day.temperature?.min), true), setStateChangedAsync(`${dayPath}.weather_text`, String(day.weather?.text || ''), true), setStateChangedAsync(`${dayPath}.weather_icon`, `${CONFIG.ICON_BASE_URL}/${iconName}`, true), setStateChangedAsync(`${dayPath}.prec_probability`, wcomExtractValue(day.prec?.probability), true), setStateChangedAsync(`${dayPath}.prec_sum`, wcomExtractValue(day.prec?.sum), true), setStateChangedAsync(`${dayPath}.wind_gusts`, wcomExtractValue(day.wind?.gusts), true), setStateChangedAsync(`${dayPath}.wind_speed_max`, wcomExtractValue(day.wind?.max ?? day.wind?.avg), true), setStateChangedAsync(`${dayPath}.sun_hours`, wcomExtractValue(day.sunHours), true), setStateChangedAsync(`${dayPath}.clouds`, wcomExtractValue(day.clouds), true), setStateChangedAsync(`${dayPath}.humidity`, wcomExtractValue(day.relativeHumidity), true) ); if (CONFIG.ENABLE_SPACES && data.spaces && data.spaces[i]) { const spacesPath: string = `${dayPath}.spaces`; await wcomEnsureSubStructure(spacesPath, 'Tagesabschnitte'); const segments: (keyof ForecastSpace)[] = ['morning', 'afternoon', 'evening', 'night']; for (const seg of segments) { const sData = data.spaces[i][seg]; if (!sData) continue; const sPath: string = `${spacesPath}.${seg}`; await wcomEnsureSubStructure(sPath, seg); await Promise.all([ wcomEnsureState(`${sPath}.temp`, 0, 'number', 'Temperatur', 'value.temperature', '°C'), wcomEnsureState(`${sPath}.text`, '', 'string', 'Wetter', 'weather.state'), wcomEnsureState(`${sPath}.prec_prob`, 0, 'number', 'Regenrisiko', 'value.precipitation.probability', '%'), wcomEnsureState(`${sPath}.prec_sum`, 0, 'number', 'Regenmenge', 'value.precipitation', 'mm'), wcomEnsureState(`${sPath}.wind_speed`, 0, 'number', 'Windgeschwindigkeit', 'value.speed.wind', 'km/h'), wcomEnsureState(`${sPath}.wind_gusts`, 0, 'number', 'Windböen', 'value.speed.wind.gust', 'km/h'), wcomEnsureState(`${sPath}.clouds`, 0, 'number', 'Bewölkung', 'value', '%'), wcomEnsureState(`${sPath}.humidity`, 0, 'number', 'Relative Feuchte', 'value.humidity', '%') ]); dayWriteBuffer.push( setStateChangedAsync(`${sPath}.temp`, wcomExtractValue(sData.temperature), true), setStateChangedAsync(`${sPath}.text`, String(sData.weather?.text || ''), true), setStateChangedAsync(`${sPath}.prec_prob`, wcomExtractValue(sData.prec?.probability), true), setStateChangedAsync(`${sPath}.prec_sum`, wcomExtractValue(sData.prec?.sum), true), setStateChangedAsync(`${sPath}.wind_speed`, wcomExtractValue(sData.wind?.avg), true), setStateChangedAsync(`${sPath}.wind_gusts`, wcomExtractValue(sData.wind?.gusts), true), setStateChangedAsync(`${sPath}.clouds`, wcomExtractValue(sData.clouds), true), setStateChangedAsync(`${sPath}.humidity`, wcomExtractValue(sData.relativeHumidity), true) ); } } if (CONFIG.ENABLE_HOURLY && i <= 1 && data.hourly) { const hourlyPath: string = `${dayPath}.hourly`; await wcomEnsureSubStructure(hourlyPath, 'Stündlich'); // @KI_HINWEIS: Filtern der Stunden über exaktes Matching des lokalen Datums-Strings zur Vermeidung von UTC-Versatz const dayHours = (data.hourly ?? []).filter((h: ForecastHourly) => { const hDateLocalStr = wcomFormatDate(h.from || h.date); return hDateLocalStr === dayDateStrLocal; }); for (const h of dayHours) { const hourDate: Date = new Date(h.from || h.date); const hourNum: number = hourDate.getHours(); const hourLabel: string = String(hourNum).padStart(2, '0'); const hPath: string = `${hourlyPath}.${hourLabel}`; await wcomEnsureSubStructure(hPath, `${hourLabel}:00 Uhr`); const hourIcon = (hourNum >= 18 || hourNum < 6) ? `n_${h.weather?.state ?? 0}.svg` : `d_${h.weather?.state ?? 0}.svg`; await Promise.all([ wcomEnsureState(`${hPath}.time`, '', 'string', 'Uhrzeit', 'text'), wcomEnsureState(`${hPath}.from`, '', 'string', 'Zeitstempel (UTC)', 'text'), wcomEnsureState(`${hPath}.temp`, 0, 'number', 'Temperatur', 'value.temperature', '°C'), wcomEnsureState(`${hPath}.windchill`, 0, 'number', 'Gefühlt', 'value.temperature', '°C'), wcomEnsureState(`${hPath}.weather_text`, '', 'string', 'Wetter', 'weather.state'), wcomEnsureState(`${hPath}.weather_icon`, '', 'string', 'Wetter Icon', 'weather.icon'), wcomEnsureState(`${hPath}.prec_prob`, 0, 'number', 'Regenwahrscheinlichkeit', 'value.precipitation.probability', '%'), wcomEnsureState(`${hPath}.prec_sum`, 0, 'number', 'Regenmenge', 'value.precipitation', 'mm'), wcomEnsureState(`${hPath}.wind_speed`, 0, 'number', 'Windgeschwindigkeit', 'value.speed.wind', 'km/h'), wcomEnsureState(`${hPath}.wind_dir`, '', 'string', 'Windrichtung', 'weather.direction'), wcomEnsureState(`${hPath}.wind_gusts`, 0, 'number', 'Windböen', 'value.speed.wind.gust', 'km/h'), wcomEnsureState(`${hPath}.humidity`, 0, 'number', 'Relative Feuchte', 'value.humidity', '%') ]); dayWriteBuffer.push( setStateChangedAsync(`${hPath}.time`, `${hourLabel}:00`, true), setStateChangedAsync(`${hPath}.from`, String(h.from || h.date), true), setStateChangedAsync(`${hPath}.temp`, wcomExtractValue(h.temperature), true), setStateChangedAsync(`${hPath}.windchill`, wcomExtractValue(h.windchill), true), setStateChangedAsync(`${hPath}.weather_text`, String(h.weather?.text || ''), true), setStateChangedAsync(`${hPath}.weather_icon`, `${CONFIG.ICON_BASE_URL}/${hourIcon}`, true), setStateChangedAsync(`${hPath}.prec_prob`, wcomExtractValue(h.prec?.probability), true), setStateChangedAsync(`${hPath}.prec_sum`, wcomExtractValue(h.prec?.sum), true), setStateChangedAsync(`${hPath}.wind_speed`, wcomExtractValue(h.wind?.avg), true), setStateChangedAsync(`${hPath}.wind_dir`, String(h.wind?.direction || ''), true), setStateChangedAsync(`${hPath}.wind_gusts`, wcomExtractValue(h.wind?.gusts), true), setStateChangedAsync(`${hPath}.humidity`, wcomExtractValue(h.relativeHumidity), true) ); } } totalWrites += dayWriteBuffer.length; await Promise.all(dayWriteBuffer); } wcomLog(`Update von ${maxDays} Tagen abgeschlossen (${totalWrites} Werte prozessiert).`, 'info'); } /** * Löscht obsolete Tagesordner, falls die Vorhersage-Dauer reduziert wurde. * @param forecastDays Aktuell konfigurierte Maximaldauer. */ async function wcomCleanupObsoleteDays(forecastDays: number): Promise<void> { for (let i = forecastDays; i <= 25; i++) { const path: string = `${CONFIG.DP_PATH}.day_${i}`; if (existsObject(path)) { await deleteObjectAsync(path, true); } } } /** * Aktualisiert den letzten Sync-Timestamp nach erfolgreichem HTTP 200. */ async function wcomUpdateUsageInfo(): Promise<void> { const now: Date = new Date(); const timestamp: string = `${String(now.getDate()).padStart(2,'0')}.${String(now.getMonth()+1).padStart(2,'0')}.${now.getFullYear()} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; await setStateAsync(`${CONFIG.DP_PATH}.info.last_sync`, String(timestamp), true); const countToday = await getStateAsync(`${CONFIG.DP_PATH}.info.requests_today`); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_today`, (countToday && countToday.val !== null ? Number(countToday.val) : 0) + 1, true); const countMonth = await getStateAsync(`${CONFIG.DP_PATH}.info.requests_month`); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, (countMonth && countMonth.val !== null ? Number(countMonth.val) : 0) + 1, true); } // --- ZEITSTEUERUNG & TRIGGER --- /** * Generiert einen Pseudo-Zufalls-Cronjob innerhalb eines definierten Stunden-Fensters zur Lastverteilung. * @param startHour Früheste Ausführungsstunde. * @param endHour Späteste Ausführungsstunde. * @param minMinute Minimale Minute (optional). * @returns Cron-String. */ function wcomGetRandomCron(startHour: number, endHour: number, minMinute: number = 0): string { const hour: number = Math.floor(Math.random() * (endHour - startHour + 1)) + startHour; let minute: number = (hour === startHour) ? Math.floor(Math.random() * (60 - minMinute)) + minMinute : (hour === endHour ? 0 : Math.floor(Math.random() * 60)); return `${minute} ${hour} * * *`; } schedule("0 0 1 * *", async () => { const path = `${CONFIG.DP_PATH}.info.requests_month`; if (existsState(path)) { await setStateAsync(path, 0, true); wcomLog('Monatszähler zurückgesetzt.', 'info'); } }); schedule(wcomGetRandomCron(0, 5, 2), () => wcomFetchWeatherData('morning')); schedule(wcomGetRandomCron(13, 17, 2), () => wcomFetchWeatherData('afternoon')); on({ id: CONFIG.DP_API_KEY, change: 'ne' }, (obj) => { if (obj.state && typeof obj.state.val === 'string' && obj.state.val.trim().length >= 10) { wcomLog('Änderung des API-Keys erkannt. Starte sofortigen Test-Abruf...', 'info'); wcomFetchWeatherData('key_update'); } }); on({ id: CONFIG.DP_FORECAST_DAYS, change: 'ne' }, (obj) => { if (obj.state && obj.state.val !== null) { wcomLog(`Änderung der Vorhersage-Tage auf ${obj.state.val} erkannt. Starte Aktualisierung & Bereinigung...`, 'info'); wcomFetchWeatherData('days_update'); } }); on({ id: CONFIG.DP_FORCE_RESET, change: 'any', val: true }, async () => { wcomLog('Manueller Reset ausgelöst. Setze Monatszähler auf 0.', 'warn'); await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, 0, true); await setStateAsync(CONFIG.DP_FORCE_RESET, false, true); wcomFetchWeatherData('force_reset'); }); // Initialer Aufruf beim Skriptstart wcomFetchWeatherData('start');
Hey! Du scheinst an dieser Unterhaltung interessiert zu sein, hast aber noch kein Konto.
Hast du es satt, bei jedem Besuch durch die gleichen Beiträge zu scrollen? Wenn du dich für ein Konto anmeldest, kommst du immer genau dorthin zurück, wo du zuvor warst, und kannst dich über neue Antworten benachrichtigen lassen (entweder per E-Mail oder Push-Benachrichtigung). Du kannst auch Lesezeichen speichern und Beiträge positiv bewerten, um anderen Community-Mitgliedern deine Wertschätzung zu zeigen.
Mit deinem Input könnte dieser Beitrag noch besser werden 💗
Registrieren Anmelden