iobroker.pv-notifications 1.2.31 → 1.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +32 -0
  2. package/io-package.json +27 -27
  3. package/main.js +363 -164
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ![Logo](admin/pv-notifications.png)
4
4
 
5
+ ![Stable](https://iobroker.live/badges/pv-notifications-stable.svg)
6
+ [![NPM version](https://img.shields.io/npm/v/iobroker.pv-notifications.svg)](https://www.npmjs.com/package/iobroker.pv-notifications)
7
+ [![Downloads](https://img.shields.io/npm/dm/iobroker.pv-notifications.svg)](https://www.npmjs.com/package/iobroker.pv-notifications)
8
+ [![License](https://img.shields.io/github/license/sadam6752-tech/ioBroker.pv-notifications.svg)](LICENSE)
9
+
10
+ [![NPM](https://nodei.co/npm/iobroker.pv-notifications.png?downloads=true)](https://nodei.co/npm/iobroker.pv-notifications/)
11
+
12
+ [![Test and Release](https://github.com/sadam6752-tech/ioBroker.pv-notifications/actions/workflows/test-and-release.yml/badge.svg)](https://github.com/sadam6752-tech/ioBroker.pv-notifications/actions/workflows/test-and-release.yml)
13
+ ![Number of Installations](https://iobroker.live/badges/pv-notifications-installed.svg)
14
+
5
15
  Sends Telegram notifications for PV battery status (full, empty, intermediate levels).
6
16
 
7
17
  ## Features
@@ -314,6 +324,28 @@ sadam6752@gmail.com
314
324
  ### **WORK IN PROGRESS**
315
325
  -->
316
326
 
327
+ ### 1.2.33 (2026-10-08)
328
+ * (FIX) Telegram messages were sent to **all** users of the bot instead of only the configured users (`user` instead of the ignored `users` parameter)
329
+ * (FIX) Bold text was shown with literal asterisks - messages are now sent with HTML formatting (values from other adapters are escaped)
330
+ * (CHANGE) Full/empty thresholds and intermediate steps are detected when reached or crossed (e.g. SOC jumps from 39 to 41, decimal SOC values, battery stopping at 99 % with a 98 % threshold); a jump over several steps sends one message
331
+ * (FIX) Statistics were not saved when the adapter was stopped (unload handler was not registered)
332
+ * (FIX) Last week/month values were overwritten with 0 on the first adapter start of a day
333
+ * (FIX) Daily statistics reset now happens at midnight; before it only ran if the daily stats time was a multiple of 5 minutes
334
+ * (FIX) Min/max SOC of the day are written immediately
335
+ * (FIX) Weekly/monthly auto-save runs once in the window 23:55-23:59, so a delayed timer tick no longer skips it; monthly cycles roll over even when monthly statistics are disabled
336
+ * (FIX) Sunset object may also contain a timestamp or ISO date; missing time settings no longer crash the scheduler
337
+ * (FIX) 0 °C was not shown; numeric weather values no longer break the weather block
338
+ * (FIX) Errors while processing state changes no longer stop the adapter; test button cannot get stuck
339
+
340
+ ### 1.2.32 (2026-10-08)
341
+ * (FIX) Daily, weekly and monthly statistics (cycles, min/max SOC) are no longer reset on every adapter restart
342
+ * (FIX) Monthly statistics reported the daily cycle counters; there are now real monthly counters (`statistics.fullCyclesMonth`, `statistics.emptyCyclesMonth`)
343
+ * (FIX) Weekly/monthly auto-save no longer fails with an unhandled error when the weekly/monthly data points are empty
344
+ * (FIX) SOC delivered as a string (e.g. "100") now triggers the full/empty/intermediate notifications
345
+ * (FIX) Test message: tomorrow's weather no longer breaks when only text or only temperature is configured
346
+ * (FIX) Weekly/monthly own consumption can no longer be negative
347
+ * (ADD) Unit tests (`npm run test:js`), README badges, `.gitattributes` (LF line endings)
348
+
317
349
  ### 1.2.31 (2026-09-21)
318
350
  * (ADD) CI: Node.js 26 in the test matrix and a new deploy job using npm Trusted Publishing (OIDC) with provenance
319
351
  * (FIX) Added the missing changelog entry for 1.2.30
package/io-package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "pv-notifications",
4
- "version": "1.2.31",
4
+ "version": "1.2.33",
5
5
  "titleLang": {
6
6
  "en": "PV Notifications",
7
7
  "de": "PV-Benachrichtigungen",
@@ -29,6 +29,32 @@
29
29
  "zh-cn": "发送 Telegram 通知用于 PV 电池状态"
30
30
  },
31
31
  "news": {
32
+ "1.2.33": {
33
+ "en": "Fix: Telegram messages were sent to all users of the bot instead of only the configured users. Fix: bold text was shown with literal asterisks (messages now use HTML formatting). Thresholds and intermediate steps are now detected when reached or crossed (e.g. SOC jumps 39 -> 41 or decimal values). Fix: statistics were not saved on adapter stop; last week/month values were reset on the first start of a day; daily reset now happens at midnight; weekly/monthly saving is more robust. Fix: 0 °C was not shown.",
34
+ "de": "Fix: Telegram-Nachrichten gingen an alle Benutzer des Bots statt nur an die konfigurierten Benutzer. Fix: Fettschrift wurde mit sichtbaren Sternchen angezeigt (Nachrichten nutzen jetzt HTML-Formatierung). Schwellwerte und Zwischenstufen werden jetzt erkannt, wenn sie erreicht oder ueberschritten werden (z. B. SOC springt von 39 auf 41 oder Dezimalwerte). Fix: Statistik wurde beim Stoppen nicht gespeichert; Werte der letzten Woche/des letzten Monats wurden beim ersten Start eines Tages zurueckgesetzt; Tagesreset jetzt um Mitternacht; Wochen-/Monatsspeicherung robuster. Fix: 0 °C wurde nicht angezeigt.",
35
+ "ru": "Исправлено: сообщения Telegram отправлялись всем пользователям бота, а не только настроенным. Исправлено: жирный текст отображался с видимыми звёздочками (теперь используется HTML-форматирование). Пороги и промежуточные ступени теперь срабатывают при достижении или пересечении (например, скачок SOC с 39 на 41 или дробные значения). Исправлено: статистика не сохранялась при остановке адаптера; значения прошлой недели/месяца сбрасывались при первом запуске за день; дневной сброс теперь в полночь; сохранение недели/месяца надёжнее. Исправлено: 0 °C не отображался.",
36
+ "fr": "Correction : les messages Telegram etaient envoyes a tous les utilisateurs du bot au lieu des seuls utilisateurs configures. Correction : le texte en gras s'affichait avec des asterisques visibles (les messages utilisent maintenant le format HTML). Les seuils et paliers intermediaires sont detectes lorsqu'ils sont atteints ou franchis (p. ex. SOC passant de 39 a 41 ou valeurs decimales). Correction : statistiques non enregistrees a l'arret ; valeurs de la semaine/du mois precedent reinitialisees au premier demarrage du jour ; remise a zero quotidienne a minuit ; enregistrement hebdomadaire/mensuel plus robuste. Correction : 0 °C n'etait pas affiche.",
37
+ "it": "Correzione: i messaggi Telegram venivano inviati a tutti gli utenti del bot invece che solo agli utenti configurati. Correzione: il grassetto veniva mostrato con asterischi visibili (ora i messaggi usano la formattazione HTML). Soglie e livelli intermedi vengono ora rilevati quando raggiunti o superati (ad es. SOC che salta da 39 a 41 o valori decimali). Correzione: statistiche non salvate all'arresto; valori della settimana/mese precedente azzerati al primo avvio del giorno; azzeramento giornaliero ora a mezzanotte; salvataggio settimanale/mensile piu robusto. Correzione: 0 °C non veniva mostrato.",
38
+ "es": "Correccion: los mensajes de Telegram se enviaban a todos los usuarios del bot en lugar de solo a los usuarios configurados. Correccion: la negrita se mostraba con asteriscos visibles (los mensajes ahora usan formato HTML). Los umbrales y niveles intermedios ahora se detectan al alcanzarse o superarse (p. ej. SOC que salta de 39 a 41 o valores decimales). Correccion: las estadisticas no se guardaban al detener el adaptador; los valores de la semana/mes anterior se reiniciaban en el primer inicio del dia; reinicio diario ahora a medianoche; guardado semanal/mensual mas robusto. Correccion: 0 °C no se mostraba.",
39
+ "pl": "Poprawka: wiadomosci Telegram byly wysylane do wszystkich uzytkownikow bota zamiast tylko do skonfigurowanych. Poprawka: pogrubienie wyswietlalo sie z widocznymi gwiazdkami (wiadomosci uzywaja teraz formatowania HTML). Progi i stopnie posrednie sa teraz wykrywane po osiagnieciu lub przekroczeniu (np. skok SOC z 39 na 41 lub wartosci dziesietne). Poprawka: statystyki nie byly zapisywane przy zatrzymaniu adaptera; wartosci z poprzedniego tygodnia/miesiaca byly zerowane przy pierwszym uruchomieniu w danym dniu; dzienny reset teraz o polnocy; zapis tygodniowy/miesieczny bardziej niezawodny. Poprawka: 0 °C nie bylo wyswietlane.",
40
+ "pt": "Correcao: as mensagens do Telegram eram enviadas a todos os utilizadores do bot em vez de apenas aos utilizadores configurados. Correcao: o negrito aparecia com asteriscos visiveis (as mensagens usam agora formatacao HTML). Os limites e niveis intermedios sao agora detetados quando atingidos ou ultrapassados (p. ex. SOC a saltar de 39 para 41 ou valores decimais). Correcao: as estatisticas nao eram guardadas ao parar o adaptador; os valores da semana/mes anterior eram repostos no primeiro arranque do dia; reposicao diaria agora a meia-noite; gravacao semanal/mensal mais robusta. Correcao: 0 °C nao era mostrado.",
41
+ "nl": "Fix: Telegram-berichten werden naar alle gebruikers van de bot gestuurd in plaats van alleen naar de geconfigureerde gebruikers. Fix: vetgedrukte tekst werd met zichtbare sterretjes getoond (berichten gebruiken nu HTML-opmaak). Drempels en tussenstappen worden nu herkend wanneer ze bereikt of overschreden worden (bijv. SOC springt van 39 naar 41 of decimale waarden). Fix: statistieken werden niet opgeslagen bij het stoppen van de adapter; waarden van vorige week/maand werden bij de eerste start van een dag gereset; dagelijkse reset nu om middernacht; week-/maandopslag robuuster. Fix: 0 °C werd niet getoond.",
42
+ "uk": "Виправлено: повідомлення Telegram надсилалися всім користувачам бота, а не лише налаштованим. Виправлено: жирний текст показувався з видимими зірочками (тепер використовується HTML-форматування). Пороги та проміжні рівні тепер спрацьовують при досягненні або перетині (наприклад, стрибок SOC з 39 на 41 або дробові значення). Виправлено: статистика не зберігалася під час зупинки адаптера; значення минулого тижня/місяця скидалися під час першого запуску за день; щоденне скидання тепер опівночі; збереження тижня/місяця надійніше. Виправлено: 0 °C не відображався.",
43
+ "zh-cn": "修复:Telegram 消息会发送给机器人的所有用户,而不仅是已配置的用户。修复:粗体文字显示为可见的星号(消息现在使用 HTML 格式)。阈值和中间级别现在在达到或越过时触发(例如 SOC 从 39 跳到 41 或小数值)。修复:停止适配器时未保存统计数据;上周/上月数值在当天首次启动时被重置;每日重置改为午夜;周/月保存更可靠。修复:0 °C 未显示。"
44
+ },
45
+ "1.2.32": {
46
+ "en": "Fix: daily, weekly and monthly statistics are no longer lost on adapter restart. Fix: monthly statistics now report real monthly cycle counts. Fix: weekly/monthly saving no longer fails with empty data points. Fix: SOC delivered as text is accepted. Fix: test message with partially configured weather. Added unit tests and README badges.",
47
+ "de": "Fix: Tages-, Wochen- und Monatsstatistik gehen beim Neustart des Adapters nicht mehr verloren. Fix: Die Monatsstatistik zeigt jetzt die echten Monatszyklen. Fix: Wochen-/Monatsspeicherung schlaegt bei leeren Datenpunkten nicht mehr fehl. Fix: SOC als Text wird akzeptiert. Fix: Testnachricht bei teilweise konfiguriertem Wetter. Unit-Tests und README-Badges hinzugefuegt.",
48
+ "ru": "Исправлено: дневная, недельная и месячная статистика больше не теряется при перезапуске адаптера. Исправлено: месячная статистика показывает реальное число месячных циклов. Исправлено: сохранение недельных/месячных данных больше не падает при пустых точках данных. Исправлено: SOC в виде текста принимается. Исправлено: тестовое сообщение при частично настроенной погоде. Добавлены юнит-тесты и бейджи в README.",
49
+ "fr": "Correction : les statistiques journalieres, hebdomadaires et mensuelles ne sont plus perdues au redemarrage de l'adaptateur. Correction : les statistiques mensuelles indiquent les vrais cycles mensuels. Correction : l'enregistrement hebdomadaire/mensuel n'echoue plus avec des points de donnees vides. Correction : SOC fourni en texte accepte. Correction : message de test avec meteo partiellement configuree. Tests unitaires et badges README ajoutes.",
50
+ "it": "Correzione: le statistiche giornaliere, settimanali e mensili non vengono piu perse al riavvio dell'adattatore. Correzione: le statistiche mensili mostrano i veri cicli mensili. Correzione: il salvataggio settimanale/mensile non fallisce piu con punti dati vuoti. Correzione: SOC fornito come testo accettato. Correzione: messaggio di test con meteo configurato parzialmente. Aggiunti test unitari e badge README.",
51
+ "es": "Correccion: las estadisticas diarias, semanales y mensuales ya no se pierden al reiniciar el adaptador. Correccion: las estadisticas mensuales muestran los ciclos mensuales reales. Correccion: el guardado semanal/mensual ya no falla con puntos de datos vacios. Correccion: se acepta SOC entregado como texto. Correccion: mensaje de prueba con clima configurado parcialmente. Anadidas pruebas unitarias e insignias en README.",
52
+ "pl": "Poprawka: statystyki dzienne, tygodniowe i miesieczne nie sa juz tracone po restarcie adaptera. Poprawka: statystyki miesieczne pokazuja prawdziwe cykle miesieczne. Poprawka: zapis tygodniowy/miesieczny nie konczy sie juz bledem przy pustych punktach danych. Poprawka: SOC dostarczony jako tekst jest akceptowany. Poprawka: wiadomosc testowa przy czesciowo skonfigurowanej pogodzie. Dodano testy jednostkowe i odznaki w README.",
53
+ "pt": "Correcao: as estatisticas diarias, semanais e mensais ja nao se perdem ao reiniciar o adaptador. Correcao: as estatisticas mensais mostram os ciclos mensais reais. Correcao: a gravacao semanal/mensal ja nao falha com pontos de dados vazios. Correcao: SOC entregue como texto e aceite. Correcao: mensagem de teste com meteorologia parcialmente configurada. Adicionados testes unitarios e emblemas no README.",
54
+ "nl": "Fix: dag-, week- en maandstatistieken gaan niet meer verloren bij het herstarten van de adapter. Fix: maandstatistieken tonen de echte maandcycli. Fix: week-/maandopslag mislukt niet meer bij lege datapunten. Fix: SOC als tekst wordt geaccepteerd. Fix: testbericht bij gedeeltelijk geconfigureerd weer. Unit-tests en README-badges toegevoegd.",
55
+ "uk": "Виправлено: денна, тижнева та місячна статистика більше не втрачається під час перезапуску адаптера. Виправлено: місячна статистика показує реальну кількість місячних циклів. Виправлено: збереження тижневих/місячних даних більше не падає з порожніми точками даних. Виправлено: SOC у вигляді тексту приймається. Виправлено: тестове повідомлення при частково налаштованій погоді. Додано модульні тести та бейджі в README.",
56
+ "zh-cn": "修复:适配器重启后日、周、月统计数据不再丢失。修复:月统计现在显示真实的月循环次数。修复:数据点为空时每周/每月保存不再失败。修复:接受文本形式的SOC。修复:天气部分配置时的测试消息。新增单元测试和README徽章。"
57
+ },
32
58
  "1.2.31": {
33
59
  "en": "CI: Node.js 26 added to the test matrix and a new deploy job using npm Trusted Publishing (OIDC) with provenance. Added the missing changelog entry for 1.2.30 and completed the short 1.2.29 news translations. Dependencies: @iobroker/testing 6.2.x, @alcalzone/release-script-plugin-license 5.2.2.",
34
60
  "de": "CI: Node.js 26 in die Test-Matrix aufgenommen und neuer Deploy-Job mit npm Trusted Publishing (OIDC) samt Provenance. Fehlender Changelog-Eintrag fuer 1.2.30 ergaenzt und zu kurze 1.2.29-News-Uebersetzungen vervollstaendigt. Abhaengigkeiten: @iobroker/testing 6.2.x, @alcalzone/release-script-plugin-license 5.2.2.",
@@ -93,32 +119,6 @@
93
119
  "nl": "Fix CI: package-lock.json bijgewerkt om alle eslint peer dependencies op te nemen",
94
120
  "uk": "Виправлено CI: оновлено package-lock.json для включення всіх peer-залежностей eslint",
95
121
  "zh-cn": "修复CI:更新package-lock.json以包含所有eslint对等依赖项"
96
- },
97
- "1.2.26": {
98
- "en": "Fix W5604: add missing i18n keys for sunset stats in 8 languages; move old changelog to CHANGELOG_OLD.md; fix README structure",
99
- "de": "W5604: fehlende i18n-Schluessel fuer Sonnenuntergangs-Statistik in 8 Sprachen ergaenzt; alten Changelog in CHANGELOG_OLD.md verschoben; README-Struktur korrigiert",
100
- "ru": "W5604: добавлены отсутствующие i18n ключи для статистики заката в 8 языках; старый changelog перенесён в CHANGELOG_OLD.md; исправлена структура README",
101
- "fr": "W5604: clés i18n manquantes pour stats coucher du soleil ajoutées dans 8 langues; ancien changelog déplacé dans CHANGELOG_OLD.md; structure README corrigée",
102
- "it": "W5604: chiavi i18n mancanti per stats tramonto aggiunte in 8 lingue; vecchio changelog spostato in CHANGELOG_OLD.md; struttura README corretta",
103
- "es": "W5604: claves i18n faltantes para stats atardecer añadidas en 8 idiomas; changelog antiguo movido a CHANGELOG_OLD.md; estructura README corregida",
104
- "pl": "W5604: brakujące klucze i18n dla statystyk zachodu słońca dodane w 8 językach; stary changelog przeniesiony do CHANGELOG_OLD.md; poprawiona struktura README",
105
- "pt": "W5604: chaves i18n em falta para stats pôr do sol adicionadas em 8 idiomas; changelog antigo movido para CHANGELOG_OLD.md; estrutura README corrigida",
106
- "nl": "W5604: ontbrekende i18n-sleutels voor zonsondergang-statistieken in 8 talen toegevoegd; oud changelog verplaatst naar CHANGELOG_OLD.md; README-structuur gecorrigeerd",
107
- "uk": "W5604: додано відсутні i18n ключі для статистики заходу сонця у 8 мовах; старий changelog перенесено до CHANGELOG_OLD.md; виправлено структуру README",
108
- "zh-cn": "修复W5604:在8种语言中添加日落统计缺失的i18n键;将旧changelog移至CHANGELOG_OLD.md;修复README结构"
109
- },
110
- "1.2.25": {
111
- "en": "Fix W5604: add missing weather i18n keys in 8 languages; update engines.node to >=22; fix workflow node versions; update devDependencies; add tsconfig.json, prettier.config.mjs",
112
- "de": "W5604 behoben: fehlende Wetter-i18n-Schlüssel in 8 Sprachen ergänzt; engines.node auf >=22 aktualisiert; Workflow-Node-Versionen korrigiert; devDependencies aktualisiert; tsconfig.json, prettier.config.mjs hinzugefügt",
113
- "ru": "Исправление W5604: добавлены отсутствующие weather i18n ключи в 8 языках; engines.node обновлён до >=22; исправлены версии node в workflow; обновлены devDependencies; добавлены tsconfig.json, prettier.config.mjs",
114
- "fr": "Fix W5604: clés i18n météo manquantes ajoutées dans 8 langues; engines.node mis à jour à >=22; versions node workflow corrigées; devDependencies mis à jour; tsconfig.json, prettier.config.mjs ajoutés",
115
- "it": "Fix W5604: chiavi i18n meteo mancanti aggiunte in 8 lingue; engines.node aggiornato a >=22; versioni node workflow corrette; devDependencies aggiornati; tsconfig.json, prettier.config.mjs aggiunti",
116
- "es": "Fix W5604: claves i18n clima faltantes añadidas en 8 idiomas; engines.node actualizado a >=22; versiones node workflow corregidas; devDependencies actualizados; tsconfig.json, prettier.config.mjs añadidos",
117
- "pl": "Naprawa W5604: dodano brakujące klucze i18n pogody w 8 językach; engines.node zaktualizowano do >=22; poprawiono wersje node w workflow; zaktualizowano devDependencies; dodano tsconfig.json, prettier.config.mjs",
118
- "pt": "Fix W5604: chaves i18n meteorologia em falta adicionadas em 8 idiomas; engines.node atualizado para >=22; versões node workflow corrigidas; devDependencies atualizados; tsconfig.json, prettier.config.mjs adicionados",
119
- "nl": "Fix W5604: ontbrekende weer-i18n-sleutels in 8 talen toegevoegd; engines.node bijgewerkt naar >=22; node-versies workflow gecorrigeerd; devDependencies bijgewerkt; tsconfig.json, prettier.config.mjs toegevoegd",
120
- "uk": "Виправлення W5604: додано відсутні weather i18n ключі у 8 мовах; engines.node оновлено до >=22; виправлено версії node у workflow; оновлено devDependencies; додано tsconfig.json, prettier.config.mjs",
121
- "zh-cn": "修复W5604:在8种语言中添加缺失的weather i18n键;将engines.node更新为>=22;修复workflow中的node版本;更新devDependencies;添加tsconfig.json、prettier.config.mjs"
122
122
  }
123
123
  },
124
124
  "authors": [
package/main.js CHANGED
@@ -42,6 +42,8 @@ class PvNotifications extends utils.Adapter {
42
42
  minSOC: 100,
43
43
  weekFullCycles: 0,
44
44
  weekEmptyCycles: 0,
45
+ monthFullCycles: 0,
46
+ monthEmptyCycles: 0,
45
47
  lastStatsReset: new Date().getDate(),
46
48
  lastWeekReset: new Date().getDay(),
47
49
  lastMonthReset: 0,
@@ -73,6 +75,9 @@ class PvNotifications extends utils.Adapter {
73
75
 
74
76
  // StateChange-Handler registrieren (für js-controller 7+)
75
77
  this.on('stateChange', this.onStateChange);
78
+
79
+ // Unload handler (saves statistics, resets connection state)
80
+ this.on('unload', this.onUnload);
76
81
  }
77
82
 
78
83
  /**
@@ -155,6 +160,32 @@ class PvNotifications extends utils.Adapter {
155
160
  type: 'state',
156
161
  common: { name: 'Empty cycles this week', type: 'number', role: 'value', read: true, write: false, def: 0 },
157
162
  });
163
+ await this.setObjectNotExists('statistics.fullCyclesMonth', {
164
+ type: 'state',
165
+ common: { name: 'Full cycles this month', type: 'number', role: 'value', read: true, write: false, def: 0 },
166
+ });
167
+ await this.setObjectNotExists('statistics.emptyCyclesMonth', {
168
+ type: 'state',
169
+ common: {
170
+ name: 'Empty cycles this month',
171
+ type: 'number',
172
+ role: 'value',
173
+ read: true,
174
+ write: false,
175
+ def: 0,
176
+ },
177
+ });
178
+ await this.setObjectNotExists('statistics.lastStatsReset', {
179
+ type: 'state',
180
+ common: {
181
+ name: 'Day of last daily reset',
182
+ type: 'number',
183
+ role: 'value',
184
+ read: true,
185
+ write: false,
186
+ def: 0,
187
+ },
188
+ });
158
189
  await this.extendObject('statistics.currentSOC', {
159
190
  type: 'state',
160
191
  common: {
@@ -516,15 +547,35 @@ class PvNotifications extends utils.Adapter {
516
547
  try {
517
548
  const today = new Date().getDate();
518
549
  const lastReset = await this.getStateAsync('statistics.lastStatsReset');
519
-
520
- if (!lastReset || lastReset.val !== today) {
521
- // New day - reset statistics
550
+ const num = async id => {
551
+ const st = await this.getStateAsync(id);
552
+ return st && typeof st.val === 'number' ? st.val : 0;
553
+ };
554
+
555
+ // Week/month counters survive restarts
556
+ this.stats.weekFullCycles = await num('statistics.fullCyclesWeek');
557
+ this.stats.weekEmptyCycles = await num('statistics.emptyCyclesWeek');
558
+ this.stats.monthFullCycles = await num('statistics.fullCyclesMonth');
559
+ this.stats.monthEmptyCycles = await num('statistics.emptyCyclesMonth');
560
+
561
+ if (lastReset && lastReset.val === today) {
562
+ // Same day - restore daily values (a restart must not wipe them)
563
+ this.stats.fullCycles = await num('statistics.fullCyclesToday');
564
+ this.stats.emptyCycles = await num('statistics.emptyCyclesToday');
565
+ const max = await this.getStateAsync('statistics.maxSOCToday');
566
+ const min = await this.getStateAsync('statistics.minSOCToday');
567
+ this.stats.maxSOC = max && typeof max.val === 'number' ? max.val : 0;
568
+ this.stats.minSOC = min && typeof min.val === 'number' ? min.val : 100;
569
+ this.stats.lastStatsReset = today;
570
+ }
571
+ const isNewDay = !(lastReset && lastReset.val === today);
572
+ if (isNewDay) {
573
+ // New day - reset statistics (saved below, after last week/month values are loaded)
522
574
  this.stats.fullCycles = 0;
523
575
  this.stats.emptyCycles = 0;
524
576
  this.stats.maxSOC = 0;
525
577
  this.stats.minSOC = 100;
526
578
  this.stats.lastStatsReset = today;
527
- await this.saveStatistics();
528
579
  }
529
580
 
530
581
  // Load saved last week data from states
@@ -567,6 +618,11 @@ class PvNotifications extends utils.Adapter {
567
618
  this.stats.lastMonthEmptyCycles =
568
619
  lastMonthEmptyCycles && lastMonthEmptyCycles.val !== null ? lastMonthEmptyCycles.val : 0;
569
620
 
621
+ if (isNewDay) {
622
+ // Must run after loading last week/month values, otherwise they are overwritten with 0
623
+ await this.saveStatistics();
624
+ }
625
+
570
626
  this.log.debug('Statistics loaded from states');
571
627
  } catch (e) {
572
628
  this.log.error(`Error loading statistics: ${e.message}`);
@@ -586,7 +642,7 @@ class PvNotifications extends utils.Adapter {
586
642
  const socState = await this.getForeignStateAsync(this.config.batterySOC);
587
643
  if (socState && socState.val !== null) {
588
644
  this.log.debug(`SOC read: ${socState.val}%`);
589
- this.onBatterySOCChange(socState.val);
645
+ await this.onBatterySOCChange(socState.val);
590
646
  } else {
591
647
  this.log.warn('SOC state is null or undefined');
592
648
  this.log.warn(`Please check: Does "${this.config.batterySOC}" exist in Objects?`);
@@ -617,6 +673,25 @@ class PvNotifications extends utils.Adapter {
617
673
  }
618
674
  }
619
675
 
676
+ /**
677
+ * Read a numeric foreign state; returns 0 for empty id, missing state or read error
678
+ *
679
+ * @param {string} id - Foreign state ID (may be empty)
680
+ */
681
+ async readForeignNumber(id) {
682
+ if (!id) {
683
+ return 0;
684
+ }
685
+ try {
686
+ const state = await this.getForeignStateAsync(id);
687
+ const val = state ? Number(state.val) : NaN;
688
+ return state && state.val !== null && !isNaN(val) ? val : 0;
689
+ } catch (e) {
690
+ this.log.warn(`Could not read "${id}": ${e.message}`);
691
+ return 0;
692
+ }
693
+ }
694
+
620
695
  /**
621
696
  * Save statistics to states
622
697
  */
@@ -628,6 +703,9 @@ class PvNotifications extends utils.Adapter {
628
703
  await this.setStateAsync('statistics.minSOCToday', this.stats.minSOC, true);
629
704
  await this.setStateAsync('statistics.fullCyclesWeek', this.stats.weekFullCycles, true);
630
705
  await this.setStateAsync('statistics.emptyCyclesWeek', this.stats.weekEmptyCycles, true);
706
+ await this.setStateAsync('statistics.fullCyclesMonth', this.stats.monthFullCycles, true);
707
+ await this.setStateAsync('statistics.emptyCyclesMonth', this.stats.monthEmptyCycles, true);
708
+ await this.setStateAsync('statistics.lastStatsReset', this.stats.lastStatsReset, true);
631
709
 
632
710
  // Save last month/week data
633
711
  await this.setStateAsync('statistics.lastMonthProduction', this.stats.lastMonthProduction, true);
@@ -654,6 +732,21 @@ class PvNotifications extends utils.Adapter {
654
732
  * @param {ioBroker.State | null | undefined} state - State object
655
733
  */
656
734
  async onStateChange(id, state) {
735
+ // Errors in an async event handler would become unhandled rejections and stop the adapter
736
+ try {
737
+ await this.handleStateChange(id, state);
738
+ } catch (e) {
739
+ this.log.error(`Error processing state change of ${id}: ${e.message}`);
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Process a state change (called by onStateChange)
745
+ *
746
+ * @param {string} id - State ID
747
+ * @param {ioBroker.State | null | undefined} state - State object
748
+ */
749
+ async handleStateChange(id, state) {
657
750
  if (state) {
658
751
  // Process test button (all states in own namespace)
659
752
  if (id.startsWith(`${this.namespace}.testButton`)) {
@@ -662,10 +755,13 @@ class PvNotifications extends utils.Adapter {
662
755
  this.status.testMessageRunning = true; // Set flag
663
756
  this.log.debug(`Test button state received: ${id}, val=${state.val}`);
664
757
  this.log.debug('Test button was pressed');
665
- await this.sendTestMessage();
666
- // Reset state
667
- await this.setStateAsync('testButton', false, true);
668
- this.status.testMessageRunning = false; // Reset flag
758
+ try {
759
+ await this.sendTestMessage();
760
+ } finally {
761
+ // Reset state and flag even if sending failed
762
+ this.status.testMessageRunning = false;
763
+ await this.setStateAsync('testButton', false, true);
764
+ }
669
765
  }
670
766
  return;
671
767
  }
@@ -713,22 +809,26 @@ class PvNotifications extends utils.Adapter {
713
809
  */
714
810
  async onBatterySOCChange(soc) {
715
811
  // Check for undefined/null values
716
- if (soc === null || soc === undefined || isNaN(soc)) {
812
+ if (soc === null || soc === undefined || soc === '' || isNaN(soc)) {
717
813
  this.log.warn(`Invalid SOC value received: ${soc}`);
718
814
  return;
719
815
  }
816
+ // Some adapters deliver the SOC as string - strict comparisons below need a number
817
+ soc = Number(soc);
720
818
 
721
819
  // Update current states
722
820
  await this.setStateAsync('statistics.currentSOC', soc, true);
723
821
  const currentKWh = this.round(((soc / 100) * this.config.batteryCapacityWh) / 1000, 1);
724
822
  await this.setStateAsync('statistics.currentEnergyKWh', currentKWh, true);
725
823
 
726
- // Update statistics
824
+ // Update statistics (persist immediately, otherwise the states stay stale until the next full/empty event)
727
825
  if (soc > this.stats.maxSOC) {
728
826
  this.stats.maxSOC = soc;
827
+ await this.setStateAsync('statistics.maxSOCToday', soc, true);
729
828
  }
730
829
  if (soc < this.stats.minSOC) {
731
830
  this.stats.minSOC = soc;
831
+ await this.setStateAsync('statistics.minSOCToday', soc, true);
732
832
  }
733
833
 
734
834
  this.log.debug(`Battery SOC: ${soc}% | Status: full=${this.status.full}, empty=${this.status.empty}`);
@@ -742,8 +842,14 @@ class PvNotifications extends utils.Adapter {
742
842
  : 'up';
743
843
 
744
844
  // Store previous SOC for next update
845
+ const previousSOC = this.status.previousSOC;
745
846
  this.status.previousSOC = soc;
746
847
 
848
+ // Thresholds are compared as "reached or crossed" - the SOC can jump (39 -> 41), be a decimal (40.3)
849
+ // or stop just below 100, so an exact comparison would miss notifications
850
+ const thresholdFull = Number(this.config.thresholdFull);
851
+ const thresholdEmpty = Number(this.config.thresholdEmpty);
852
+
747
853
  // === NIGHT-TIME check with configurable time ===
748
854
  const nightTime = this.isNightTime();
749
855
  const nightModeActive = this.config.nightModeEnabled !== false;
@@ -754,7 +860,7 @@ class PvNotifications extends utils.Adapter {
754
860
  const quietModeActive = this.config.quietModeEnabled !== false;
755
861
 
756
862
  // === Batterie VOLL (100%) - Nicht nachts (wenn Nachtmodus aktiv) und nicht in Ruhezeit ===
757
- if (soc === this.config.thresholdFull) {
863
+ if (soc >= thresholdFull) {
758
864
  // Prüfen ob Benachrichtigung erlaubt ist (nicht in Nachtzeit oder Ruhezeit)
759
865
  const allowNotification = (!nightTime || !nightModeActive) && (!quietTime || !quietModeActive);
760
866
 
@@ -765,6 +871,7 @@ class PvNotifications extends utils.Adapter {
765
871
  this.status.lastNotification.full = Date.now();
766
872
  this.stats.fullCycles++;
767
873
  this.stats.weekFullCycles++;
874
+ this.stats.monthFullCycles++;
768
875
  this.saveStatistics();
769
876
  this.log.info('Battery full - Telegram sent');
770
877
  } else if (this.status.full && !this.canNotify('full')) {
@@ -780,7 +887,7 @@ class PvNotifications extends utils.Adapter {
780
887
  }
781
888
 
782
889
  // === Battery EMPTY (0%) - Always allow if nightModeIgnoreEmpty is active ===
783
- if (soc === this.config.thresholdEmpty) {
890
+ if (soc <= thresholdEmpty) {
784
891
  if (!this.status.empty && this.canNotify('empty')) {
785
892
  // Always notify at 0% if nightModeIgnoreEmpty is active
786
893
  // But still respect quiet time (unless nightModeIgnoreEmpty is active)
@@ -794,6 +901,7 @@ class PvNotifications extends utils.Adapter {
794
901
  this.status.lastNotification.empty = Date.now();
795
902
  this.stats.emptyCycles++;
796
903
  this.stats.weekEmptyCycles++;
904
+ this.stats.monthEmptyCycles++;
797
905
  this.saveStatistics();
798
906
  this.log.info('Battery empty - Telegram sent');
799
907
  } else if (blockedByQuietTime) {
@@ -806,7 +914,7 @@ class PvNotifications extends utils.Adapter {
806
914
  }
807
915
  }
808
916
  // === Intermediate-Stufen (nur wenn nicht voll/leer und nicht nachts und nicht in Ruhezeit) ===
809
- if (soc !== this.config.thresholdFull && soc !== this.config.thresholdEmpty) {
917
+ if (soc < thresholdFull && soc > thresholdEmpty) {
810
918
  const intermediateSteps = (this.config.intermediateSteps || '20,40,60,80')
811
919
  .split(',')
812
920
  .map(s => parseInt(s.trim()))
@@ -816,22 +924,10 @@ class PvNotifications extends utils.Adapter {
816
924
  const allowIntermediate = (!nightTime || !nightModeActive) && (!quietTime || !quietModeActive);
817
925
 
818
926
  if (allowIntermediate) {
927
+ // Reset Intermediate-Flags wenn Stufe verlassen (±2% Toleranz) - vor der Prüfung, damit eine
928
+ // gerade überschrittene Stufe ihr Flag bis zum nächsten Wert behält (verhindert Flattern um die Stufe)
819
929
  for (const step of intermediateSteps) {
820
- if (soc === step && !this.status.intermediateNotified.includes(step)) {
821
- if (this.canNotify('intermediate')) {
822
- const message = await this.buildIntermediateMessage(soc, direction);
823
- this.sendTelegram(message);
824
- this.status.intermediateNotified.push(step);
825
- this.status.lastNotification.intermediate = Date.now();
826
- this.log.info(`Intermediate ${step}% - Telegram sent`);
827
- }
828
- break;
829
- }
830
- }
831
-
832
- // Reset Intermediate-Flags wenn Stufe verlassen (±2% Toleranz)
833
- for (const step of intermediateSteps) {
834
- if (soc !== step && Math.abs(soc - step) >= 2) {
930
+ if (Math.abs(soc - step) >= 2) {
835
931
  const idx = this.status.intermediateNotified.indexOf(step);
836
932
  if (idx > -1) {
837
933
  this.status.intermediateNotified.splice(idx, 1);
@@ -839,8 +935,28 @@ class PvNotifications extends utils.Adapter {
839
935
  }
840
936
  }
841
937
  }
842
- } else if (nightModeActive) {
843
- this.log.debug('Night time (00:00-08:00) - intermediate notifications suppressed');
938
+
939
+ // Steps reached or crossed since the last value; the step closest to the current SOC comes first.
940
+ // Without a previous value (adapter start) only an exact hit counts, to avoid messages on every restart.
941
+ const crossed = intermediateSteps
942
+ .filter(step => this.isStepCrossed(previousSOC, soc, step))
943
+ .filter(step => !this.status.intermediateNotified.includes(step))
944
+ .sort((a, b) => Math.abs(soc - a) - Math.abs(soc - b));
945
+
946
+ if (crossed.length > 0) {
947
+ if (this.canNotify('intermediate')) {
948
+ const message = await this.buildIntermediateMessage(soc, direction);
949
+ this.sendTelegram(message);
950
+ // A jump over several steps sends one message only
951
+ this.status.intermediateNotified.push(...crossed);
952
+ this.status.lastNotification.intermediate = Date.now();
953
+ this.log.info(`Intermediate ${crossed[0]}% (SOC ${soc}%) - Telegram sent`);
954
+ } else {
955
+ this.log.debug(`Intermediate ${crossed[0]}% reached, but interval not yet elapsed`);
956
+ }
957
+ }
958
+ } else {
959
+ this.log.debug('Night/quiet time - intermediate notifications suppressed');
844
960
  }
845
961
  }
846
962
 
@@ -857,6 +973,26 @@ class PvNotifications extends utils.Adapter {
857
973
  }
858
974
  }
859
975
 
976
+ /**
977
+ * Prüfe ob eine Stufe seit dem letzten Wert erreicht oder überschritten wurde (in beide Richtungen)
978
+ *
979
+ * @param {number | null} previousSOC - Previous SOC (null after adapter start)
980
+ * @param {number} soc - Current SOC
981
+ * @param {number} step - Intermediate step in percent
982
+ */
983
+ isStepCrossed(previousSOC, soc, step) {
984
+ if (previousSOC === null || previousSOC === undefined) {
985
+ return soc === step;
986
+ }
987
+ if (soc > previousSOC) {
988
+ return previousSOC < step && step <= soc;
989
+ }
990
+ if (soc < previousSOC) {
991
+ return previousSOC > step && step >= soc;
992
+ }
993
+ return false;
994
+ }
995
+
860
996
  /**
861
997
  * Prüfe ob Mindestintervall eingehalten
862
998
  *
@@ -920,6 +1056,22 @@ class PvNotifications extends utils.Adapter {
920
1056
  return currentTime >= startTime && currentTime < endTime;
921
1057
  }
922
1058
 
1059
+ /**
1060
+ * Nachricht für Telegram parse_mode HTML aufbereiten
1061
+ *
1062
+ * @param {string} message - Nachrichtentext mit *fett*-Markierungen
1063
+ * @returns {string} HTML-Text für Telegram
1064
+ */
1065
+ formatTelegramHtml(message) {
1066
+ // Escape HTML first, so values from foreign states (weather texts etc.) can never break the message,
1067
+ // then turn the *bold* markers of the message templates into <b> tags (per line, always balanced)
1068
+ return String(message)
1069
+ .replace(/&/g, '&amp;')
1070
+ .replace(/</g, '&lt;')
1071
+ .replace(/>/g, '&gt;')
1072
+ .replace(/\*([^*\n]+)\*/g, '<b>$1</b>');
1073
+ }
1074
+
923
1075
  /**
924
1076
  * Sende Telegram-Nachricht mit Zeitstempel
925
1077
  *
@@ -942,8 +1094,12 @@ class PvNotifications extends utils.Adapter {
942
1094
  this.config.telegramInstance,
943
1095
  'send',
944
1096
  {
945
- text: fullMessage,
946
- users: usersList.join(', '),
1097
+ text: this.formatTelegramHtml(fullMessage),
1098
+ // Without parse_mode Telegram shows the *bold* markers literally
1099
+ parse_mode: 'HTML',
1100
+ // The telegram adapter reads "user" (comma separated list) - without it the
1101
+ // message is broadcast to every user known to the bot
1102
+ user: usersList.join(','),
947
1103
  },
948
1104
  result => {
949
1105
  if (result && result.error) {
@@ -1041,9 +1197,9 @@ ${consumptionPowerLine}
1041
1197
  weatherTodayTextState && weatherTodayTextState.val !== null ? weatherTodayTextState.val : null;
1042
1198
  const weatherTodayTemp =
1043
1199
  weatherTodayTempState && weatherTodayTempState.val !== null ? weatherTodayTempState.val : null;
1044
- const todayTempText = weatherTodayTemp ? ` ${this.round(weatherTodayTemp, 1)}°C` : '';
1200
+ const todayTempText = weatherTodayTemp !== null ? ` ${this.round(weatherTodayTemp, 1)}°C` : '';
1045
1201
 
1046
- if (weatherTodayText || weatherTodayTemp) {
1202
+ if (weatherTodayText || weatherTodayTemp !== null) {
1047
1203
  const weatherDesc = weatherTodayText ? this.getWeatherDescription(weatherTodayText) : '🌡️';
1048
1204
  message += `\n🌤️ Heute: ${weatherDesc}${todayTempText}`;
1049
1205
  }
@@ -1069,7 +1225,7 @@ ${consumptionPowerLine}
1069
1225
  weatherTomorrowState && weatherTomorrowState.val !== null ? weatherTomorrowState.val : null;
1070
1226
  const tempTomorrow =
1071
1227
  tempTomorrowState && tempTomorrowState.val !== null ? tempTomorrowState.val : null;
1072
- const tempText = tempTomorrow ? ` ${this.round(tempTomorrow, 1)}°C` : '';
1228
+ const tempText = tempTomorrow !== null ? ` ${this.round(tempTomorrow, 1)}°C` : '';
1073
1229
 
1074
1230
  const weatherText = weatherTomorrowText || weatherTomorrow;
1075
1231
  if (weatherText) {
@@ -1138,9 +1294,9 @@ ${consumptionPowerLine}${separator}`;
1138
1294
  weatherTodayTextState && weatherTodayTextState.val !== null ? weatherTodayTextState.val : null;
1139
1295
  const weatherTodayTemp =
1140
1296
  weatherTodayTempState && weatherTodayTempState.val !== null ? weatherTodayTempState.val : null;
1141
- const todayTempText = weatherTodayTemp ? ` ${this.round(weatherTodayTemp, 1)}°C` : '';
1297
+ const todayTempText = weatherTodayTemp !== null ? ` ${this.round(weatherTodayTemp, 1)}°C` : '';
1142
1298
 
1143
- if (weatherTodayText || weatherTodayTemp) {
1299
+ if (weatherTodayText || weatherTodayTemp !== null) {
1144
1300
  const weatherDesc = weatherTodayText ? this.getWeatherDescription(weatherTodayText) : '🌡️';
1145
1301
  message += `\n🌤️ Heute: ${weatherDesc}${todayTempText}`;
1146
1302
  }
@@ -1166,7 +1322,7 @@ ${consumptionPowerLine}${separator}`;
1166
1322
  weatherTomorrowState && weatherTomorrowState.val !== null ? weatherTomorrowState.val : null;
1167
1323
  const tempTomorrow =
1168
1324
  tempTomorrowState && tempTomorrowState.val !== null ? tempTomorrowState.val : null;
1169
- const tempText = tempTomorrow ? ` ${this.round(tempTomorrow, 1)}°C` : '';
1325
+ const tempText = tempTomorrow !== null ? ` ${this.round(tempTomorrow, 1)}°C` : '';
1170
1326
 
1171
1327
  const weatherText = weatherTomorrowText || weatherTomorrow;
1172
1328
  if (weatherText) {
@@ -1263,9 +1419,9 @@ ${statusText}
1263
1419
  weatherTodayTextState && weatherTodayTextState.val !== null ? weatherTodayTextState.val : null;
1264
1420
  const weatherTodayTemp =
1265
1421
  weatherTodayTempState && weatherTodayTempState.val !== null ? weatherTodayTempState.val : null;
1266
- const todayTempText = weatherTodayTemp ? ` ${this.round(weatherTodayTemp, 1)}°C` : '';
1422
+ const todayTempText = weatherTodayTemp !== null ? ` ${this.round(weatherTodayTemp, 1)}°C` : '';
1267
1423
 
1268
- if (weatherTodayText || weatherTodayTemp) {
1424
+ if (weatherTodayText || weatherTodayTemp !== null) {
1269
1425
  const weatherDesc = weatherTodayText ? this.getWeatherDescription(weatherTodayText) : '🌡️';
1270
1426
  message += `\n🌤️ ${this.translate('Weather today')}: ${weatherDesc}${todayTempText}`;
1271
1427
  this.log.debug(`Weather today added to intermediate message: ${weatherDesc}${todayTempText}`);
@@ -1292,7 +1448,7 @@ ${statusText}
1292
1448
  weatherTomorrowState && weatherTomorrowState.val !== null ? weatherTomorrowState.val : null;
1293
1449
  const tempTomorrow =
1294
1450
  tempTomorrowState && tempTomorrowState.val !== null ? tempTomorrowState.val : null;
1295
- const tempText = tempTomorrow ? ` ${this.round(tempTomorrow, 1)}°C` : '';
1451
+ const tempText = tempTomorrow !== null ? ` ${this.round(tempTomorrow, 1)}°C` : '';
1296
1452
 
1297
1453
  const weatherText = weatherTomorrowText || weatherTomorrow;
1298
1454
  if (weatherText) {
@@ -1392,11 +1548,12 @@ ${statusText}
1392
1548
  const weatherTodayTemp =
1393
1549
  weatherTodayTempState && weatherTodayTempState.val !== null ? weatherTodayTempState.val : null;
1394
1550
 
1395
- if (weatherTodayText || weatherTodayTemp) {
1551
+ if (weatherTodayText || weatherTodayTemp !== null) {
1396
1552
  const weatherDesc = weatherTodayText ? this.getWeatherDescription(weatherTodayText) : '🌡️';
1397
- const todayTempSuffix = weatherTodayTemp
1398
- ? ` (${this.translate('Currently')}: ${this.round(weatherTodayTemp, 1)}°C)`
1399
- : '';
1553
+ const todayTempSuffix =
1554
+ weatherTodayTemp !== null
1555
+ ? ` (${this.translate('Currently')}: ${this.round(weatherTodayTemp, 1)}°C)`
1556
+ : '';
1400
1557
  message += `\n━━━━━━━━━━━━━━━━━━━━━━\n🌤️ *${this.translate('Weather today')}:* ${weatherDesc}${todayTempSuffix}`;
1401
1558
  weatherAdded = true;
1402
1559
  }
@@ -1422,7 +1579,7 @@ ${statusText}
1422
1579
  weatherTomorrowState && weatherTomorrowState.val !== null ? weatherTomorrowState.val : null;
1423
1580
  const tempTomorrow =
1424
1581
  tempTomorrowState && tempTomorrowState.val !== null ? tempTomorrowState.val : null;
1425
- const tempText = tempTomorrow ? ` ${this.round(tempTomorrow, 1)}°C` : '';
1582
+ const tempText = tempTomorrow !== null ? ` ${this.round(tempTomorrow, 1)}°C` : '';
1426
1583
 
1427
1584
  const weatherText = weatherTomorrowText || weatherTomorrow;
1428
1585
  if (weatherText) {
@@ -1469,7 +1626,7 @@ ${statusText}
1469
1626
  // const consumption = this.round(this.stats.lastWeekConsumption, 1); // ESLint: unused
1470
1627
  const feedIn = this.round(Math.abs(this.stats.lastWeekFeedIn), 1);
1471
1628
  const gridPower = this.round(this.stats.lastWeekGridPower, 1);
1472
- const selfConsumption = this.round(totalProd - feedIn, 1);
1629
+ const selfConsumption = this.round(Math.max(0, totalProd - feedIn), 1);
1473
1630
  const selfConsumptionRate = totalProd > 0 ? this.round((selfConsumption / totalProd) * 100, 1) : 0;
1474
1631
 
1475
1632
  return `📊 *${this.translate('Weekly statistics PV system')}*
@@ -1494,7 +1651,7 @@ ${statusText}
1494
1651
  // const consumption = this.round(this.stats.lastMonthConsumption, 1); // ESLint: unused
1495
1652
  const feedIn = this.round(Math.abs(this.stats.lastMonthFeedIn), 1);
1496
1653
  const gridPower = this.round(this.stats.lastMonthGridPower, 1);
1497
- const selfConsumption = this.round(totalProd - feedIn, 1);
1654
+ const selfConsumption = this.round(Math.max(0, totalProd - feedIn), 1);
1498
1655
  const selfConsumptionRate = totalProd > 0 ? this.round((selfConsumption / totalProd) * 100, 1) : 0;
1499
1656
 
1500
1657
  return `📊 *${this.translate('Monthly statistics PV system')}*
@@ -1519,7 +1676,7 @@ ${statusText}
1519
1676
  return '🌡️ unbekannt';
1520
1677
  }
1521
1678
 
1522
- const text = weatherText.toLowerCase();
1679
+ const text = String(weatherText).toLowerCase();
1523
1680
 
1524
1681
  if (text.includes('sonnig') || text.includes('klar')) {
1525
1682
  return '☀️ sonnig';
@@ -1562,7 +1719,7 @@ ${statusText}
1562
1719
  if (!weatherText) {
1563
1720
  return false;
1564
1721
  }
1565
- const text = weatherText.toLowerCase();
1722
+ const text = String(weatherText).toLowerCase();
1566
1723
  return (
1567
1724
  text.includes('sonnig') || text.includes('klar') || text.includes('clear') || text.includes('few clouds')
1568
1725
  );
@@ -1577,7 +1734,7 @@ ${statusText}
1577
1734
  if (!weatherText) {
1578
1735
  return false;
1579
1736
  }
1580
- const text = weatherText.toLowerCase();
1737
+ const text = String(weatherText).toLowerCase();
1581
1738
  return (
1582
1739
  text.includes('regen') ||
1583
1740
  text.includes('rain') ||
@@ -1603,65 +1760,60 @@ ${statusText}
1603
1760
  return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
1604
1761
  }
1605
1762
 
1763
+ /**
1764
+ * Parse a time string "HH:MM"
1765
+ *
1766
+ * @param {string} value - Time string
1767
+ * @returns {{ hours: number, minutes: number } | null} Parsed time or null if invalid
1768
+ */
1769
+ parseTime(value) {
1770
+ const match = String(value || '')
1771
+ .trim()
1772
+ .match(/^(\d{1,2}):(\d{2})/);
1773
+ if (!match) {
1774
+ return null;
1775
+ }
1776
+ const hours = parseInt(match[1], 10);
1777
+ const minutes = parseInt(match[2], 10);
1778
+ return hours < 24 && minutes < 60 ? { hours, minutes } : null;
1779
+ }
1780
+
1781
+ /**
1782
+ * Parse the value of the sunset object ("HH:MM", ISO date string or timestamp) into local hours/minutes
1783
+ *
1784
+ * @param {ioBroker.StateValue | undefined} value - State value
1785
+ * @returns {{ hours: number, minutes: number } | null} Parsed time or null if invalid
1786
+ */
1787
+ parseSunsetTime(value) {
1788
+ if (value === null || value === undefined || value === '') {
1789
+ return null;
1790
+ }
1791
+ const time = typeof value === 'string' ? this.parseTime(value) : null;
1792
+ if (time) {
1793
+ return time;
1794
+ }
1795
+ const date = new Date(typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value);
1796
+ return isNaN(date.getTime()) ? null : { hours: date.getHours(), minutes: date.getMinutes() };
1797
+ }
1798
+
1799
+ /**
1800
+ * Check whether the given time equals the configured time string
1801
+ *
1802
+ * @param {Date} now - Current time
1803
+ * @param {string} configTime - Configured time "HH:MM"
1804
+ */
1805
+ isTime(now, configTime) {
1806
+ const time = this.parseTime(configTime);
1807
+ return !!time && now.getHours() === time.hours && now.getMinutes() === time.minutes;
1808
+ }
1809
+
1606
1810
  /**
1607
1811
  * Start scheduled tasks
1608
1812
  */
1609
1813
  startScheduledTasks() {
1610
1814
  // Check every minute
1611
1815
  this.scheduledInterval = this.setInterval(() => {
1612
- const now = new Date();
1613
- const hours = now.getHours();
1614
- const minutes = now.getMinutes();
1615
- const jsDay = now.getDay(); // JavaScript: 0=So, 1=Mo, ..., 6=Sa
1616
- // Umwandeln in ioBroker-Format: 0=Mo, 1=Di, ..., 6=So
1617
- const day = jsDay === 0 ? 6 : jsDay - 1;
1618
- const date = now.getDate();
1619
-
1620
- // Alle 5 Minuten: Statistik prüfen (um :00, :05, :10, ...)
1621
- if (minutes % 5 === 0) {
1622
- this.resetDailyStats();
1623
- this.resetWeeklyStats();
1624
- this.resetMonthlyStats();
1625
- }
1626
-
1627
- // Tägliche Statistik — entweder zur konfigurierten Zeit oder zum Sonnenuntergang
1628
- if (this.config.statsUseSunset && this.config.statsSunsetObject) {
1629
- // Sonnenuntergangszeit aus dem konfigurierten Objekt lesen
1630
- this.getForeignStateAsync(this.config.statsSunsetObject)
1631
- .then(sunsetState => {
1632
- if (sunsetState && sunsetState.val) {
1633
- const sunsetTime = String(sunsetState.val).trim();
1634
- const match = sunsetTime.match(/^(\d{1,2}):(\d{2})/);
1635
- if (match) {
1636
- const sunsetHours = parseInt(match[1], 10);
1637
- const sunsetMinutes = parseInt(match[2], 10);
1638
- if (hours === sunsetHours && minutes === sunsetMinutes) {
1639
- this.sendDailyStatsMessage();
1640
- }
1641
- }
1642
- }
1643
- })
1644
- .catch(e => this.log.warn(`Could not read sunset object: ${e.message}`));
1645
- } else {
1646
- const [dayHours, dayMinutes] = this.config.statsDayTime.split(':').map(Number);
1647
- if (hours === dayHours && minutes === dayMinutes) {
1648
- this.sendDailyStatsMessage();
1649
- }
1650
- }
1651
-
1652
- // Wöchentliche Statistik am konfigurierten Tag und Zeit
1653
- const [weekHours, weekMinutes] = this.config.statsWeekTime.split(':').map(Number);
1654
- if (day === this.config.statsWeekDay && hours === weekHours && minutes === weekMinutes) {
1655
- this.sendTelegram(this.buildWeeklyStatsMessage());
1656
- }
1657
-
1658
- // Monatsstatistik am konfigurierten Tag und Zeit
1659
- if (this.config.monthlyStatsEnabled) {
1660
- const [monthHours, monthMinutes] = this.config.monthlyStatsTime.split(':').map(Number);
1661
- if (date === this.config.monthlyStatsDay && hours === monthHours && minutes === monthMinutes) {
1662
- this.sendTelegram(this.buildMonthlyStatsMessage());
1663
- }
1664
- }
1816
+ this.runScheduledTasks(new Date()).catch(e => this.log.error(`Scheduled task failed: ${e.message}`));
1665
1817
  }, 60000); // Jede Minute ausführen
1666
1818
 
1667
1819
  this.log.info(
@@ -1670,19 +1822,61 @@ ${statusText}
1670
1822
  }
1671
1823
 
1672
1824
  /**
1673
- * Tägliche Statistik zurücksetzen (zur konfigurierten Zeit)
1825
+ * Run the scheduled tasks for the given minute
1826
+ *
1827
+ * @param {Date} now - Current time
1674
1828
  */
1675
- resetDailyStats() {
1676
- const today = new Date().getDate();
1677
- const now = new Date();
1678
- const hours = now.getHours();
1679
- const minutes = now.getMinutes();
1829
+ async runScheduledTasks(now) {
1830
+ const jsDay = now.getDay(); // JavaScript: 0=So, 1=Mo, ..., 6=Sa
1831
+ // Umwandeln in ioBroker-Format: 0=Mo, 1=Di, ..., 6=So
1832
+ const day = jsDay === 0 ? 6 : jsDay - 1;
1833
+ const date = now.getDate();
1834
+
1835
+ // Statistik-Resets bei jedem Durchlauf prüfen (jeweils nur einmal pro Tag/Woche/Monat)
1836
+ this.resetDailyStats(now);
1837
+ await this.resetWeeklyStats(now);
1838
+ await this.resetMonthlyStats(now);
1839
+
1840
+ // Tägliche Statistik — entweder zur konfigurierten Zeit oder zum Sonnenuntergang
1841
+ if (this.config.statsUseSunset && this.config.statsSunsetObject) {
1842
+ // Sonnenuntergangszeit aus dem konfigurierten Objekt lesen
1843
+ try {
1844
+ const sunsetState = await this.getForeignStateAsync(this.config.statsSunsetObject);
1845
+ const sunset = this.parseSunsetTime(sunsetState ? sunsetState.val : null);
1846
+ if (sunset && now.getHours() === sunset.hours && now.getMinutes() === sunset.minutes) {
1847
+ await this.sendDailyStatsMessage();
1848
+ }
1849
+ } catch (e) {
1850
+ this.log.warn(`Could not read sunset object: ${e.message}`);
1851
+ }
1852
+ } else if (this.isTime(now, this.config.statsDayTime)) {
1853
+ await this.sendDailyStatsMessage();
1854
+ }
1855
+
1856
+ // Wöchentliche Statistik am konfigurierten Tag und Zeit
1857
+ if (day === Number(this.config.statsWeekDay) && this.isTime(now, this.config.statsWeekTime)) {
1858
+ this.sendTelegram(this.buildWeeklyStatsMessage());
1859
+ }
1680
1860
 
1681
- // Konfigurierte Zeit parsen
1682
- const [resetHours, resetMinutes] = this.config.statsDayTime.split(':').map(Number);
1861
+ // Monatsstatistik am konfigurierten Tag und Zeit
1862
+ if (
1863
+ this.config.monthlyStatsEnabled &&
1864
+ date === Number(this.config.monthlyStatsDay) &&
1865
+ this.isTime(now, this.config.monthlyStatsTime)
1866
+ ) {
1867
+ this.sendTelegram(this.buildMonthlyStatsMessage());
1868
+ }
1869
+ }
1683
1870
 
1684
- // Reset zur konfigurierten Zeit
1685
- if (today !== this.stats.lastStatsReset && hours === resetHours && minutes === resetMinutes) {
1871
+ /**
1872
+ * Tägliche Statistik zurücksetzen (bei Tageswechsel, passend zu loadStatistics())
1873
+ *
1874
+ * @param {Date} [now] - Current time
1875
+ */
1876
+ resetDailyStats(now = new Date()) {
1877
+ const today = now.getDate();
1878
+
1879
+ if (today !== this.stats.lastStatsReset) {
1686
1880
  this.log.info('Resetting daily statistics');
1687
1881
  this.stats.fullCycles = 0;
1688
1882
  this.stats.emptyCycles = 0;
@@ -1694,32 +1888,36 @@ ${statusText}
1694
1888
  }
1695
1889
 
1696
1890
  /**
1697
- * Reset weekly statistics - AUTOMATISCH am Sonntag um 23:55 (vor sourceanalytix Reset)
1891
+ * Check whether the "before midnight" save window (23:55-23:59) is active
1892
+ *
1893
+ * @param {Date} now - Current time
1698
1894
  */
1699
- async resetWeeklyStats() {
1700
- const now = new Date();
1895
+ isSaveWindow(now) {
1896
+ return now.getHours() === 23 && now.getMinutes() >= 55;
1897
+ }
1898
+
1899
+ /**
1900
+ * Reset weekly statistics - AUTOMATISCH am Sonntag ab 23:55 (vor sourceanalytix Reset)
1901
+ *
1902
+ * @param {Date} [now] - Current time
1903
+ */
1904
+ async resetWeeklyStats(now = new Date()) {
1701
1905
  const jsDay = now.getDay(); // JavaScript: 0=So, 1=Mo, ..., 6=Sa
1702
- const hours = now.getHours();
1703
- const minutes = now.getMinutes();
1906
+ const dateKey = now.toDateString();
1704
1907
 
1705
- // Automatische Speicherung: Jeden Sonntag um 23:55 (vor Mitternacht)
1706
- // JavaScript: 0 = Sonntag → genau das wollen wir!
1707
- if (jsDay === 0 && hours === 23 && minutes === 55) {
1908
+ // Automatische Speicherung: Jeden Sonntag 23:55-23:59 (vor Mitternacht), einmal pro Woche.
1909
+ // Ein Fenster statt einer exakten Minute, damit ein verzögerter Timer-Tick den Abschluss nicht verpasst.
1910
+ if (jsDay === 0 && this.isSaveWindow(now) && this.status.lastWeekSave !== dateKey) {
1911
+ this.status.lastWeekSave = dateKey;
1708
1912
  this.log.info('Auto-saving weekly statistics (Sunday 23:55, before sourceanalytix reset)');
1709
1913
 
1710
1914
  // Aktuelle Daten aus externen States lesen (direkter Zugriff)
1711
1915
  // WICHTIG: weeklyProduction/weeklyConsumption/etc. verwenden (sourceanalytix Wochenwerte)
1712
1916
  // NICHT totalProduction/consumption/etc. (das sind Tageswerte!)
1713
- const weeklyProd = await this.getForeignStateAsync(this.config.weeklyProduction);
1714
- const weeklyConsumption = await this.getForeignStateAsync(this.config.weeklyConsumption);
1715
- const weeklyFeedIn = await this.getForeignStateAsync(this.config.weeklyFeedIn);
1716
- const weeklyGridPower = await this.getForeignStateAsync(this.config.weeklyGridPower);
1717
-
1718
- this.stats.lastWeekProduction = weeklyProd && weeklyProd.val !== null ? weeklyProd.val : 0;
1719
- this.stats.lastWeekConsumption =
1720
- weeklyConsumption && weeklyConsumption.val !== null ? weeklyConsumption.val : 0;
1721
- this.stats.lastWeekFeedIn = weeklyFeedIn && weeklyFeedIn.val !== null ? weeklyFeedIn.val : 0;
1722
- this.stats.lastWeekGridPower = weeklyGridPower && weeklyGridPower.val !== null ? weeklyGridPower.val : 0;
1917
+ this.stats.lastWeekProduction = await this.readForeignNumber(this.config.weeklyProduction);
1918
+ this.stats.lastWeekConsumption = await this.readForeignNumber(this.config.weeklyConsumption);
1919
+ this.stats.lastWeekFeedIn = await this.readForeignNumber(this.config.weeklyFeedIn);
1920
+ this.stats.lastWeekGridPower = await this.readForeignNumber(this.config.weeklyGridPower);
1723
1921
  this.stats.lastWeekFullCycles = this.stats.weekFullCycles;
1724
1922
  this.stats.lastWeekEmptyCycles = this.stats.weekEmptyCycles;
1725
1923
 
@@ -1727,52 +1925,49 @@ ${statusText}
1727
1925
  this.stats.weekFullCycles = 0;
1728
1926
  this.stats.weekEmptyCycles = 0;
1729
1927
 
1730
- this.saveStatistics();
1928
+ await this.saveStatistics();
1731
1929
  this.log.info(
1732
1930
  `Weekly stats saved: Production=${this.stats.lastWeekProduction} kWh, FeedIn=${this.stats.lastWeekFeedIn} kWh`,
1733
1931
  );
1734
- // KEIN Senden hier - Senden erfolgt nur in startScheduledTasks() zur konfigurierten Zeit
1932
+ // KEIN Senden hier - Senden erfolgt nur in runScheduledTasks() zur konfigurierten Zeit
1735
1933
  }
1736
1934
  }
1737
1935
 
1738
1936
  /**
1739
- * Monatsstatistik zurücksetzen - AUTOMATISCH am letzten Tag des Monats um 23:55 (vor sourceanalytix Reset)
1937
+ * Monatsstatistik zurücksetzen - AUTOMATISCH am letzten Tag des Monats ab 23:55 (vor sourceanalytix Reset).
1938
+ * Die Monatszyklen werden auch bei deaktivierter Monatsstatistik zurückgesetzt.
1939
+ *
1940
+ * @param {Date} [now] - Current time
1740
1941
  */
1741
- async resetMonthlyStats() {
1742
- if (!this.config.monthlyStatsEnabled) {
1743
- return;
1744
- }
1745
-
1746
- const now = new Date();
1942
+ async resetMonthlyStats(now = new Date()) {
1747
1943
  const today = now.getDate();
1748
- const hours = now.getHours();
1749
- const minutes = now.getMinutes();
1944
+ const dateKey = now.toDateString();
1750
1945
 
1751
1946
  // Letzten Tag des aktuellen Monats berechnen
1752
1947
  // new Date(Jahr, Monat+1, 0) gibt den letzten Tag des aktuellen Monats zurück
1753
1948
  const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
1754
1949
 
1755
- // Automatische Speicherung: Letzter Tag des Monats um 23:55 (vor Mitternacht)
1756
- if (today === lastDayOfMonth && hours === 23 && minutes === 55) {
1950
+ // Automatische Speicherung: Letzter Tag des Monats 23:55-23:59 (vor Mitternacht), einmal pro Monat
1951
+ if (today === lastDayOfMonth && this.isSaveWindow(now) && this.status.lastMonthSave !== dateKey) {
1952
+ this.status.lastMonthSave = dateKey;
1757
1953
  this.log.info(
1758
1954
  `Auto-saving monthly statistics (last day of month ${today}. ${now.getMonth() + 1}.${now.getFullYear()} 23:55, before sourceanalytix reset)`,
1759
1955
  );
1760
1956
 
1761
1957
  // Aktuelle Daten aus externen States lesen (direkter Zugriff)
1762
- const totalProd = await this.getForeignStateAsync(this.config.monthlyProduction);
1763
- const consumption = await this.getForeignStateAsync(this.config.monthlyConsumption);
1764
- const feedIn = await this.getForeignStateAsync(this.config.monthlyFeedIn);
1765
- const gridPower = await this.getForeignStateAsync(this.config.monthlyGridPower);
1766
-
1767
- this.stats.lastMonthProduction = totalProd && totalProd.val !== null ? totalProd.val : 0;
1768
- this.stats.lastMonthConsumption = consumption && consumption.val !== null ? consumption.val : 0;
1769
- this.stats.lastMonthFeedIn = feedIn && feedIn.val !== null ? feedIn.val : 0;
1770
- this.stats.lastMonthGridPower = gridPower && gridPower.val !== null ? gridPower.val : 0;
1771
- this.stats.lastMonthFullCycles = this.stats.fullCycles;
1772
- this.stats.lastMonthEmptyCycles = this.stats.emptyCycles;
1958
+ if (this.config.monthlyStatsEnabled) {
1959
+ this.stats.lastMonthProduction = await this.readForeignNumber(this.config.monthlyProduction);
1960
+ this.stats.lastMonthConsumption = await this.readForeignNumber(this.config.monthlyConsumption);
1961
+ this.stats.lastMonthFeedIn = await this.readForeignNumber(this.config.monthlyFeedIn);
1962
+ this.stats.lastMonthGridPower = await this.readForeignNumber(this.config.monthlyGridPower);
1963
+ }
1964
+ this.stats.lastMonthFullCycles = this.stats.monthFullCycles;
1965
+ this.stats.lastMonthEmptyCycles = this.stats.monthEmptyCycles;
1966
+ this.stats.monthFullCycles = 0;
1967
+ this.stats.monthEmptyCycles = 0;
1773
1968
 
1774
1969
  this.stats.lastMonthReset = today;
1775
- this.saveStatistics();
1970
+ await this.saveStatistics();
1776
1971
  this.log.info(
1777
1972
  `Monthly stats saved: Production=${this.stats.lastMonthProduction} kWh, FeedIn=${this.stats.lastMonthFeedIn} kWh`,
1778
1973
  );
@@ -2055,9 +2250,9 @@ ${statusText}
2055
2250
  weatherTodayTextState && weatherTodayTextState.val !== null ? weatherTodayTextState.val : null;
2056
2251
  const weatherTodayTemp =
2057
2252
  weatherTodayState && weatherTodayState.val !== null ? weatherTodayState.val : null;
2058
- const tempText = weatherTodayTemp ? ` ${this.round(weatherTodayTemp, 1)}°C` : '';
2253
+ const tempText = weatherTodayTemp !== null ? ` ${this.round(weatherTodayTemp, 1)}°C` : '';
2059
2254
 
2060
- if (weatherTodayText || weatherTodayTemp) {
2255
+ if (weatherTodayText || weatherTodayTemp !== null) {
2061
2256
  const weatherDesc = weatherTodayText ? this.getWeatherDescription(weatherTodayText) : '🌡️';
2062
2257
  message += `\n\n🌤️ *${this.translate('Weather today')}:* ${weatherDesc}${tempText}`;
2063
2258
  }
@@ -2065,8 +2260,12 @@ ${statusText}
2065
2260
 
2066
2261
  // Wetter morgen
2067
2262
  if (this.config.weatherTomorrowText || this.config.weatherTomorrowTemp) {
2068
- const weatherTomorrowTextState = await this.getForeignStateAsync(this.config.weatherTomorrowText);
2069
- const weatherTomorrowState = await this.getForeignStateAsync(this.config.weatherTomorrowTemp);
2263
+ const weatherTomorrowTextState = this.config.weatherTomorrowText
2264
+ ? await this.getForeignStateAsync(this.config.weatherTomorrowText)
2265
+ : null;
2266
+ const weatherTomorrowState = this.config.weatherTomorrowTemp
2267
+ ? await this.getForeignStateAsync(this.config.weatherTomorrowTemp)
2268
+ : null;
2070
2269
 
2071
2270
  const weatherTomorrowText =
2072
2271
  weatherTomorrowTextState && weatherTomorrowTextState.val !== null
@@ -2074,9 +2273,9 @@ ${statusText}
2074
2273
  : null;
2075
2274
  const weatherTomorrowTemp =
2076
2275
  weatherTomorrowState && weatherTomorrowState.val !== null ? weatherTomorrowState.val : null;
2077
- const tempText = weatherTomorrowTemp ? ` ${this.round(weatherTomorrowTemp, 1)}°C` : '';
2276
+ const tempText = weatherTomorrowTemp !== null ? ` ${this.round(weatherTomorrowTemp, 1)}°C` : '';
2078
2277
 
2079
- if (weatherTomorrowText || weatherTomorrowTemp) {
2278
+ if (weatherTomorrowText || weatherTomorrowTemp !== null) {
2080
2279
  const weatherDesc = weatherTomorrowText
2081
2280
  ? this.getWeatherDescription(weatherTomorrowText)
2082
2281
  : '🌡️';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.pv-notifications",
3
- "version": "1.2.31",
3
+ "version": "1.2.33",
4
4
  "description": "PV-Anlage Batterie-Status Telegram Benachrichtigungen",
5
5
  "author": {
6
6
  "name": "Alex1808 via LLM: Qwen",
@@ -58,7 +58,7 @@
58
58
  "@iobroker/adapter-dev": "^1.5.0",
59
59
  "@iobroker/dev-server": "^0.8.0",
60
60
  "@iobroker/eslint-config": "^2.3.4",
61
- "@iobroker/testing": "^6.2.1",
61
+ "@iobroker/testing": "^6.3.0",
62
62
  "@tsconfig/node22": "^22.0.5",
63
63
  "@types/node": "^22.19.21",
64
64
  "typescript": "~5.9.3"