iobroker.flowers 0.3.2 → 0.3.4

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.
package/README.md CHANGED
@@ -7,6 +7,14 @@
7
7
 
8
8
  Monitor indoor plants via soil moisture, temperature and battery sensors with Telegram notifications.
9
9
 
10
+ This adapter works with **any sensor** already integrated in ioBroker (Zigbee, Wi-Fi, Bluetooth, Z-Wave, etc.) — no specific hardware required. Popular compatible sensors:
11
+
12
+ - [Xiaomi Mi Flora / HHCC Plant Sensor](https://www.mi.com/global/mi-flora) — Bluetooth soil moisture + temperature + light
13
+ - [Zigbee soil moisture sensors](https://www.zigbee2mqtt.io/supported-devices/#s=soil) (e.g. Tuya TS0601, MOES) — via ioBroker Zigbee adapter
14
+ - Any sensor exposing humidity/temperature/battery states in ioBroker
15
+
16
+ Notifications are sent via the [ioBroker Telegram adapter](https://github.com/ioBroker/ioBroker.telegram).
17
+
10
18
  ## Features
11
19
 
12
20
  - Monitor multiple plants with individual sensor assignments
@@ -75,6 +83,16 @@ Only one watering cycle runs at a time per plant. Configure the duration in Sett
75
83
 
76
84
  ## Changelog
77
85
 
86
+ ### 0.3.4 (2026-03-31)
87
+ - (sadam6752-tech) Add unit tests for MonitorService, NotificationManager and messages (106 tests total)
88
+ - (sadam6752-tech) Update README with links to compatible devices and Telegram adapter
89
+ - (sadam6752-tech) Remove mocha from devDependencies (already included in @iobroker/testing)
90
+
91
+ ### 0.3.3 (2026-03-30)
92
+ - (sadam6752-tech) Fix object hierarchy: create device/channel parent objects before states
93
+ - (sadam6752-tech) Use correct state roles: value.humidity, value.temperature, value.battery
94
+ - (sadam6752-tech) Improve unload: null timers after clearing, guard monitor null check
95
+
78
96
  ### 0.3.2 (2026-03-30)
79
97
  - (sadam6752-tech) Custom profiles: users can create own plant profiles in Profiles tab
80
98
  - (sadam6752-tech) Custom profile field in Plants table for direct profile name entry
package/io-package.json CHANGED
@@ -1,8 +1,34 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "flowers",
4
- "version": "0.3.2",
4
+ "version": "0.3.4",
5
5
  "news": {
6
+ "0.3.4": {
7
+ "en": "Add unit tests for MonitorService, NotificationManager and messages; update README with device links; remove mocha from devDependencies",
8
+ "de": "Unit-Tests für MonitorService, NotificationManager und messages hinzugefügt; README mit Gerätelinks aktualisiert; mocha aus devDependencies entfernt",
9
+ "ru": "Добавлены unit-тесты для MonitorService, NotificationManager и messages; обновлён README со ссылками на устройства; удалён mocha из devDependencies",
10
+ "fr": "Tests unitaires ajoutés pour MonitorService, NotificationManager et messages; README mis à jour avec liens appareils; mocha supprimé des devDependencies",
11
+ "it": "Aggiunti unit test per MonitorService, NotificationManager e messages; README aggiornato con link dispositivi; rimosso mocha da devDependencies",
12
+ "es": "Tests unitarios añadidos para MonitorService, NotificationManager y messages; README actualizado con enlaces de dispositivos; mocha eliminado de devDependencies",
13
+ "pl": "Dodano testy jednostkowe dla MonitorService, NotificationManager i messages; zaktualizowano README z linkami do urządzeń; usunięto mocha z devDependencies",
14
+ "pt": "Testes unitários adicionados para MonitorService, NotificationManager e messages; README atualizado com links de dispositivos; mocha removido de devDependencies",
15
+ "nl": "Unit-tests toegevoegd voor MonitorService, NotificationManager en messages; README bijgewerkt met apparaatlinks; mocha verwijderd uit devDependencies",
16
+ "uk": "Додано unit-тести для MonitorService, NotificationManager та messages; оновлено README з посиланнями на пристрої; видалено mocha з devDependencies",
17
+ "zh-cn": "为MonitorService、NotificationManager和messages添加单元测试;更新README添加设备链接;从devDependencies中删除mocha"
18
+ },
19
+ "0.3.3": {
20
+ "en": "Fix object hierarchy (device/channel/state), correct state roles (value.humidity, value.temperature, value.battery), improve unload cleanup",
21
+ "de": "Objekthierarchie korrigiert (device/channel/state), korrekte State-Rollen, verbessertes Aufräumen beim Entladen",
22
+ "ru": "Исправлена иерархия объектов (device/channel/state), правильные роли состояний, улучшена очистка при выгрузке",
23
+ "fr": "Correction hiérarchie objets (device/channel/state), rôles d'état corrects, nettoyage amélioré au déchargement",
24
+ "it": "Corretta gerarchia oggetti (device/channel/state), ruoli stato corretti, pulizia migliorata allo scaricamento",
25
+ "es": "Corrección jerarquía objetos (device/channel/state), roles de estado correctos, limpieza mejorada al descargar",
26
+ "pl": "Poprawiono hierarchię obiektów (device/channel/state), poprawne role stanów, ulepszone czyszczenie przy wyładowaniu",
27
+ "pt": "Correção hierarquia objetos (device/channel/state), funções de estado corretas, limpeza melhorada no descarregamento",
28
+ "nl": "Objecthiërarchie gecorrigeerd (device/channel/state), correcte state-rollen, verbeterde opruiming bij ontladen",
29
+ "uk": "Виправлено ієрархію об'єктів (device/channel/state), правильні ролі станів, покращено очищення при вивантаженні",
30
+ "zh-cn": "修复对象层次结构(device/channel/state),正确的状态角色,改进卸载清理"
31
+ },
6
32
  "0.3.2": {
7
33
  "en": "Custom profiles: users can create own plant profiles in Profiles tab",
8
34
  "de": "Benutzerdefinierte Profile: eigene Pflanzenprofile in der Profiles-Registerkarte erstellen",
@@ -16,32 +42,6 @@
16
42
  "uk": "Користувацькі профілі: створення власних профілів рослин у вкладці Profiles",
17
43
  "zh-cn": "自定义配置文件:在配置文件选项卡中创建自己的植物配置文件"
18
44
  },
19
- "0.3.1": {
20
- "en": "Fixed all lint warnings: complete JSDoc descriptions",
21
- "de": "Alle Lint-Warnungen behoben: vollständige JSDoc-Beschreibungen",
22
- "ru": "Исправлены все lint-предупреждения: полные JSDoc-описания",
23
- "fr": "Tous les avertissements lint corrigés: descriptions JSDoc complètes",
24
- "it": "Tutti gli avvisi lint corretti: descrizioni JSDoc complete",
25
- "es": "Todas las advertencias lint corregidas: descripciones JSDoc completas",
26
- "pl": "Naprawiono wszystkie ostrzeżenia lint: pełne opisy JSDoc",
27
- "pt": "Todos os avisos lint corrigidos: descrições JSDoc completas",
28
- "nl": "Alle lint-waarschuwingen opgelost: volledige JSDoc-beschrijvingen",
29
- "uk": "Виправлено всі lint-попередження: повні JSDoc-описи",
30
- "zh-cn": "修复所有lint警告:完整的JSDoc描述"
31
- },
32
- "0.3.0": {
33
- "en": "Standard CI/CD workflow, dependabot, release-script, standard test structure",
34
- "de": "Standard CI/CD Workflow, Dependabot, Release-Script, Standard-Teststruktur",
35
- "ru": "Стандартный CI/CD workflow, dependabot, release-script, стандартная структура тестов",
36
- "fr": "Workflow CI/CD standard, dependabot, release-script, structure de tests standard",
37
- "it": "Workflow CI/CD standard, dependabot, release-script, struttura test standard",
38
- "es": "Workflow CI/CD estándar, dependabot, release-script, estructura de tests estándar",
39
- "pl": "Standardowy workflow CI/CD, dependabot, release-script, standardowa struktura testów",
40
- "pt": "Workflow CI/CD padrão, dependabot, release-script, estrutura de testes padrão",
41
- "nl": "Standaard CI/CD workflow, dependabot, release-script, standaard teststructuur",
42
- "uk": "Стандартний CI/CD workflow, dependabot, release-script, стандартна структура тестів",
43
- "zh-cn": "标准CI/CD工作流、dependabot、release-script、标准测试结构"
44
- },
45
45
  "0.2.9": {
46
46
  "en": "Automatic watering support; UI column width fixes",
47
47
  "de": "Automatische Bewässerung; UI-Spaltenbreiten korrigiert",
@@ -1,383 +1,409 @@
1
- "use strict";
2
-
3
- const profiles = require("./plant-profiles.json");
4
- const { msg } = require("./messages");
5
-
6
- /**
7
- * MonitorService — подписка на датчики, проверка порогов, генерация алертов.
8
- */
9
- class MonitorService {
10
- /**
11
- * @param {object} adapter - ioBroker adapter instance
12
- * @param {object} notificationManager - NotificationManager instance
13
- * @param {string} lang - system language code
14
- */
15
- constructor(adapter, notificationManager, lang) {
16
- this.adapter = adapter;
17
- this.notif = notificationManager;
18
- this.lang = lang || "en";
19
- this.sensorValues = {};
20
- this.lastSeen = {};
21
- // active watering timers: key = plant.name, value = true if watering in progress
22
- this._wateringActive = {};
23
- }
24
-
25
- /**
26
- * Get effective thresholds for a plant (plant overrides profile).
27
- *
28
- * @param {object} plant - plant configuration object
29
- * @returns {object} effective threshold values
30
- */
31
- _getThresholds(plant) {
32
- // First check custom profiles from config, then built-in profiles
33
- const customProfiles = this.adapter.config.customProfiles || [];
34
- const profileName = plant.customProfile || plant.profile;
35
- const customProfile = customProfiles.find((p) => p.name === profileName);
36
- const profile =
37
- customProfile || profiles[profileName] || profiles["Custom"];
38
- const override = (val, fallback) => {
39
- if (val === null || val === undefined || val === "") {
40
- return fallback;
41
- }
42
- const n = parseFloat(val);
43
- return isNaN(n) ? fallback : n;
44
- };
45
- return {
46
- humidityMin: override(plant.humidityMin, profile.humidityMin),
47
- humidityMax: override(plant.humidityMax, profile.humidityMax),
48
- temperatureMin: override(plant.temperatureMin, profile.temperatureMin),
49
- temperatureMax: override(plant.temperatureMax, profile.temperatureMax),
50
- batteryMin: override(plant.batteryMin, profile.batteryMin),
51
- };
52
- }
53
-
54
- /**
55
- * Subscribe to all sensor states for all enabled plants.
56
- */
57
- async subscribeAll() {
58
- const plants = this.adapter.config.plants || [];
59
- for (const plant of plants) {
60
- if (!plant.enabled) {
61
- continue;
62
- }
63
- for (const attr of [
64
- "sensorHumidity",
65
- "sensorTemperature",
66
- "sensorBattery",
67
- ]) {
68
- const stateId = plant[attr];
69
- if (stateId) {
70
- await this.adapter.subscribeForeignStatesAsync(stateId);
71
- this.adapter.log.debug(`MonitorService: subscribed to ${stateId}`);
72
- // Read initial value so checkAll() works immediately
73
- try {
74
- const state = await this.adapter.getForeignStateAsync(stateId);
75
- if (state && state.val !== null && state.val !== undefined) {
76
- this.sensorValues[stateId] = {
77
- val: state.val,
78
- ts: state.ts || Date.now(),
79
- };
80
- this.lastSeen[plant.name] = Date.now();
81
- this.adapter.log.debug(
82
- `MonitorService: initial value ${stateId} = ${state.val}`,
83
- );
84
- }
85
- } catch (err) {
86
- this.adapter.log.warn(
87
- `MonitorService: could not read initial value for ${stateId}: ${err.message}`,
88
- );
89
- }
90
- }
91
- }
92
- }
93
- }
94
-
95
- /**
96
- * Unsubscribe from all sensor states.
97
- */
98
- async unsubscribeAll() {
99
- const plants = this.adapter.config.plants || [];
100
- for (const plant of plants) {
101
- for (const attr of [
102
- "sensorHumidity",
103
- "sensorTemperature",
104
- "sensorBattery",
105
- ]) {
106
- const stateId = plant[attr];
107
- if (stateId) {
108
- await this.adapter.unsubscribeForeignStatesAsync(stateId);
109
- }
110
- }
111
- }
112
- }
113
-
114
- /**
115
- * Handle incoming state change from subscribed sensor.
116
- *
117
- * @param {string} id - state id
118
- * @param {object} state - ioBroker state object
119
- */
120
- async onStateChange(id, state) {
121
- if (!state || state.val === null || state.val === undefined) {
122
- return;
123
- }
124
-
125
- // Ensure numeric value (sensors may send strings)
126
- const numVal = parseFloat(state.val);
127
- if (isNaN(numVal)) {
128
- return;
129
- }
130
- this.sensorValues[id] = { val: numVal, ts: state.ts || Date.now() };
131
-
132
- const plants = this.adapter.config.plants || [];
133
- for (const plant of plants) {
134
- if (!plant.enabled) {
135
- continue;
136
- }
137
- if (
138
- plant.sensorHumidity === id ||
139
- plant.sensorTemperature === id ||
140
- plant.sensorBattery === id
141
- ) {
142
- this.lastSeen[plant.name] = Date.now();
143
- await this._checkPlant(plant);
144
- }
145
- }
146
- }
147
-
148
- /**
149
- * Check all plants against current sensor values (called on interval).
150
- */
151
- async checkAll() {
152
- const plants = this.adapter.config.plants || [];
153
- for (const plant of plants) {
154
- if (!plant.enabled) {
155
- continue;
156
- }
157
- await this._checkPlant(plant);
158
- await this._checkOffline(plant);
159
- }
160
- }
161
-
162
- /**
163
- * Check a single plant's thresholds and send alerts if needed.
164
- *
165
- * @param {object} plant - plant configuration object
166
- */
167
- async _checkPlant(plant) {
168
- const thresholds = this._getThresholds(plant);
169
- const label = `${plant.name}${plant.location ? ` (${plant.location})` : ""}`;
170
- this.adapter.log.debug(
171
- `MonitorService: checking plant "${plant.name}", thresholds: hum ${thresholds.humidityMin}-${thresholds.humidityMax}%`,
172
- );
173
-
174
- // Humidity
175
- if (plant.sensorHumidity) {
176
- const entry = this.sensorValues[plant.sensorHumidity];
177
- if (entry != null) {
178
- const val = entry.val;
179
- this.adapter.log.debug(
180
- `MonitorService: ${plant.name} humidity=${val}% (min=${thresholds.humidityMin}, max=${thresholds.humidityMax})`,
181
- );
182
- if (val < thresholds.humidityMin) {
183
- await this.notif.send(
184
- msg("humidity_low", this.lang, label, val, thresholds.humidityMin),
185
- `${plant.name}:humidity_low`,
186
- );
187
- await this._startWatering(plant);
188
- } else if (val > thresholds.humidityMax) {
189
- await this.notif.send(
190
- msg("humidity_high", this.lang, label, val, thresholds.humidityMax),
191
- `${plant.name}:humidity_high`,
192
- );
193
- }
194
- await this._updateState(
195
- `plants.${this._safeName(plant.name)}.humidity`,
196
- val,
197
- "%",
198
- );
199
- }
200
- }
201
-
202
- // Temperature
203
- if (plant.sensorTemperature) {
204
- const entry = this.sensorValues[plant.sensorTemperature];
205
- if (entry != null) {
206
- const val = entry.val;
207
- if (val < thresholds.temperatureMin) {
208
- await this.notif.send(
209
- msg("temp_low", this.lang, label, val, thresholds.temperatureMin),
210
- `${plant.name}:temp_low`,
211
- );
212
- } else if (val > thresholds.temperatureMax) {
213
- await this.notif.send(
214
- msg("temp_high", this.lang, label, val, thresholds.temperatureMax),
215
- `${plant.name}:temp_high`,
216
- );
217
- }
218
- await this._updateState(
219
- `plants.${this._safeName(plant.name)}.temperature`,
220
- val,
221
- "°C",
222
- );
223
- }
224
- }
225
-
226
- // Battery
227
- if (plant.sensorBattery) {
228
- const entry = this.sensorValues[plant.sensorBattery];
229
- if (entry != null) {
230
- const val = entry.val;
231
- if (val < thresholds.batteryMin) {
232
- await this.notif.send(
233
- msg("battery_low", this.lang, label, val, thresholds.batteryMin),
234
- `${plant.name}:battery_low`,
235
- );
236
- }
237
- await this._updateState(
238
- `plants.${this._safeName(plant.name)}.battery`,
239
- val,
240
- "%",
241
- );
242
- }
243
- }
244
- }
245
-
246
- /**
247
- * Check if a plant sensor has gone offline.
248
- *
249
- * @param {object} plant - plant configuration object
250
- */
251
- async _checkOffline(plant) {
252
- const offlineHours = parseInt(this.adapter.config.offlineThreshold) || 3;
253
- const last = this.lastSeen[plant.name];
254
- if (!last) {
255
- return;
256
- }
257
- const diffHours = (Date.now() - last) / (1000 * 60 * 60);
258
- if (diffHours >= offlineHours) {
259
- const label = `${plant.name}${plant.location ? ` (${plant.location})` : ""}`;
260
- await this.notif.send(
261
- msg("offline", this.lang, label, Math.round(diffHours)),
262
- `${plant.name}:offline`,
263
- );
264
- }
265
- }
266
-
267
- /**
268
- * Start automatic watering for a plant if configured and not already active.
269
- *
270
- * @param {object} plant - plant configuration object
271
- */
272
- async _startWatering(plant) {
273
- if (!plant.sensorWatering) {
274
- return;
275
- }
276
- if (this._wateringActive[plant.name]) {
277
- this.adapter.log.debug(
278
- `MonitorService: watering already active for "${plant.name}", skipping`,
279
- );
280
- return;
281
- }
282
-
283
- const durationMin = parseInt(this.adapter.config.wateringDuration) || 3;
284
- const durationMs = durationMin * 60 * 1000;
285
-
286
- try {
287
- await this.adapter.setForeignStateAsync(plant.sensorWatering, {
288
- val: true,
289
- ack: false,
290
- });
291
- this._wateringActive[plant.name] = true;
292
- this.adapter.log.info(
293
- `MonitorService: watering started for "${plant.name}" (${durationMin} min)`,
294
- );
295
-
296
- this.adapter.setTimeout(async () => {
297
- try {
298
- await this.adapter.setForeignStateAsync(plant.sensorWatering, {
299
- val: false,
300
- ack: false,
301
- });
302
- this.adapter.log.info(
303
- `MonitorService: watering stopped for "${plant.name}"`,
304
- );
305
- } catch (err) {
306
- this.adapter.log.warn(
307
- `MonitorService: failed to stop watering for "${plant.name}": ${err.message}`,
308
- );
309
- }
310
- this._wateringActive[plant.name] = false;
311
- }, durationMs);
312
- } catch (err) {
313
- this.adapter.log.warn(
314
- `MonitorService: failed to start watering for "${plant.name}": ${err.message}`,
315
- );
316
- this._wateringActive[plant.name] = false;
317
- }
318
- }
319
-
320
- /**
321
- * Update or create a state for a plant sensor value.
322
- *
323
- * @param {string} id - relative state id
324
- * @param {number} val - sensor value
325
- * @param {string} unit - unit of measurement
326
- */
327
- async _updateState(id, val, unit) {
328
- try {
329
- await this.adapter.extendObjectAsync(id, {
330
- type: "state",
331
- common: {
332
- name: id,
333
- type: "number",
334
- role: "value",
335
- unit,
336
- read: true,
337
- write: false,
338
- },
339
- native: {},
340
- });
341
- await this.adapter.setStateAsync(id, { val, ack: true });
342
- } catch (err) {
343
- this.adapter.log.warn(
344
- `MonitorService: failed to update state ${id}: ${err.message}`,
345
- );
346
- }
347
- }
348
-
349
- /**
350
- * Convert plant name to safe state id segment.
351
- *
352
- * @param {string} name - plant name
353
- * @returns {string} sanitized name safe for use as state id
354
- */
355
- _safeName(name) {
356
- return name.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase();
357
- }
358
-
359
- /**
360
- * Get current sensor values for all enabled plants (for reports).
361
- *
362
- * @returns {Array<object>} array of { plant, humidity, temperature, battery }
363
- */
364
- getPlantStates() {
365
- const plants = this.adapter.config.plants || [];
366
- return plants
367
- .filter((p) => p.enabled)
368
- .map((plant) => ({
369
- plant,
370
- humidity: plant.sensorHumidity
371
- ? (this.sensorValues[plant.sensorHumidity]?.val ?? null)
372
- : null,
373
- temperature: plant.sensorTemperature
374
- ? (this.sensorValues[plant.sensorTemperature]?.val ?? null)
375
- : null,
376
- battery: plant.sensorBattery
377
- ? (this.sensorValues[plant.sensorBattery]?.val ?? null)
378
- : null,
379
- }));
380
- }
381
- }
382
-
383
- module.exports = MonitorService;
1
+ "use strict";
2
+
3
+ const profiles = require("./plant-profiles.json");
4
+ const { msg } = require("./messages");
5
+
6
+ /**
7
+ * MonitorService — подписка на датчики, проверка порогов, генерация алертов.
8
+ */
9
+ class MonitorService {
10
+ /**
11
+ * @param {object} adapter - ioBroker adapter instance
12
+ * @param {object} notificationManager - NotificationManager instance
13
+ * @param {string} lang - system language code
14
+ */
15
+ constructor(adapter, notificationManager, lang) {
16
+ this.adapter = adapter;
17
+ this.notif = notificationManager;
18
+ this.lang = lang || "en";
19
+ this.sensorValues = {};
20
+ this.lastSeen = {};
21
+ // active watering timers: key = plant.name, value = true if watering in progress
22
+ this._wateringActive = {};
23
+ }
24
+
25
+ /**
26
+ * Get effective thresholds for a plant (plant overrides profile).
27
+ *
28
+ * @param {object} plant - plant configuration object
29
+ * @returns {object} effective threshold values
30
+ */
31
+ _getThresholds(plant) {
32
+ // First check custom profiles from config, then built-in profiles
33
+ const customProfiles = this.adapter.config.customProfiles || [];
34
+ const profileName = plant.customProfile || plant.profile;
35
+ const customProfile = customProfiles.find((p) => p.name === profileName);
36
+ const profile =
37
+ customProfile || profiles[profileName] || profiles["Custom"];
38
+ const override = (val, fallback) => {
39
+ if (val === null || val === undefined || val === "") {
40
+ return fallback;
41
+ }
42
+ const n = parseFloat(val);
43
+ return isNaN(n) ? fallback : n;
44
+ };
45
+ return {
46
+ humidityMin: override(plant.humidityMin, profile.humidityMin),
47
+ humidityMax: override(plant.humidityMax, profile.humidityMax),
48
+ temperatureMin: override(plant.temperatureMin, profile.temperatureMin),
49
+ temperatureMax: override(plant.temperatureMax, profile.temperatureMax),
50
+ batteryMin: override(plant.batteryMin, profile.batteryMin),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Subscribe to all sensor states for all enabled plants.
56
+ */
57
+ async subscribeAll() {
58
+ const plants = this.adapter.config.plants || [];
59
+ for (const plant of plants) {
60
+ if (!plant.enabled) {
61
+ continue;
62
+ }
63
+ for (const attr of [
64
+ "sensorHumidity",
65
+ "sensorTemperature",
66
+ "sensorBattery",
67
+ ]) {
68
+ const stateId = plant[attr];
69
+ if (stateId) {
70
+ await this.adapter.subscribeForeignStatesAsync(stateId);
71
+ this.adapter.log.debug(`MonitorService: subscribed to ${stateId}`);
72
+ // Read initial value so checkAll() works immediately
73
+ try {
74
+ const state = await this.adapter.getForeignStateAsync(stateId);
75
+ if (state && state.val !== null && state.val !== undefined) {
76
+ this.sensorValues[stateId] = {
77
+ val: state.val,
78
+ ts: state.ts || Date.now(),
79
+ };
80
+ this.lastSeen[plant.name] = Date.now();
81
+ this.adapter.log.debug(
82
+ `MonitorService: initial value ${stateId} = ${state.val}`,
83
+ );
84
+ }
85
+ } catch (err) {
86
+ this.adapter.log.warn(
87
+ `MonitorService: could not read initial value for ${stateId}: ${err.message}`,
88
+ );
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Unsubscribe from all sensor states.
97
+ */
98
+ async unsubscribeAll() {
99
+ const plants = this.adapter.config.plants || [];
100
+ for (const plant of plants) {
101
+ for (const attr of [
102
+ "sensorHumidity",
103
+ "sensorTemperature",
104
+ "sensorBattery",
105
+ ]) {
106
+ const stateId = plant[attr];
107
+ if (stateId) {
108
+ await this.adapter.unsubscribeForeignStatesAsync(stateId);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Handle incoming state change from subscribed sensor.
116
+ *
117
+ * @param {string} id - state id
118
+ * @param {object} state - ioBroker state object
119
+ */
120
+ async onStateChange(id, state) {
121
+ if (!state || state.val === null || state.val === undefined) {
122
+ return;
123
+ }
124
+
125
+ // Ensure numeric value (sensors may send strings)
126
+ const numVal = parseFloat(state.val);
127
+ if (isNaN(numVal)) {
128
+ return;
129
+ }
130
+ this.sensorValues[id] = { val: numVal, ts: state.ts || Date.now() };
131
+
132
+ const plants = this.adapter.config.plants || [];
133
+ for (const plant of plants) {
134
+ if (!plant.enabled) {
135
+ continue;
136
+ }
137
+ if (
138
+ plant.sensorHumidity === id ||
139
+ plant.sensorTemperature === id ||
140
+ plant.sensorBattery === id
141
+ ) {
142
+ this.lastSeen[plant.name] = Date.now();
143
+ await this._checkPlant(plant);
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Check all plants against current sensor values (called on interval).
150
+ */
151
+ async checkAll() {
152
+ const plants = this.adapter.config.plants || [];
153
+ for (const plant of plants) {
154
+ if (!plant.enabled) {
155
+ continue;
156
+ }
157
+ await this._checkPlant(plant);
158
+ await this._checkOffline(plant);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Check a single plant's thresholds and send alerts if needed.
164
+ *
165
+ * @param {object} plant - plant configuration object
166
+ */
167
+ async _checkPlant(plant) {
168
+ const thresholds = this._getThresholds(plant);
169
+ const label = `${plant.name}${plant.location ? ` (${plant.location})` : ""}`;
170
+ this.adapter.log.debug(
171
+ `MonitorService: checking plant "${plant.name}", thresholds: hum ${thresholds.humidityMin}-${thresholds.humidityMax}%`,
172
+ );
173
+
174
+ // Humidity
175
+ if (plant.sensorHumidity) {
176
+ const entry = this.sensorValues[plant.sensorHumidity];
177
+ if (entry != null) {
178
+ const val = entry.val;
179
+ this.adapter.log.debug(
180
+ `MonitorService: ${plant.name} humidity=${val}% (min=${thresholds.humidityMin}, max=${thresholds.humidityMax})`,
181
+ );
182
+ if (val < thresholds.humidityMin) {
183
+ await this.notif.send(
184
+ msg("humidity_low", this.lang, label, val, thresholds.humidityMin),
185
+ `${plant.name}:humidity_low`,
186
+ );
187
+ await this._startWatering(plant);
188
+ } else if (val > thresholds.humidityMax) {
189
+ await this.notif.send(
190
+ msg("humidity_high", this.lang, label, val, thresholds.humidityMax),
191
+ `${plant.name}:humidity_high`,
192
+ );
193
+ }
194
+ await this._updateState(
195
+ `plants.${this._safeName(plant.name)}.humidity`,
196
+ val,
197
+ "%",
198
+ );
199
+ }
200
+ }
201
+
202
+ // Temperature
203
+ if (plant.sensorTemperature) {
204
+ const entry = this.sensorValues[plant.sensorTemperature];
205
+ if (entry != null) {
206
+ const val = entry.val;
207
+ if (val < thresholds.temperatureMin) {
208
+ await this.notif.send(
209
+ msg("temp_low", this.lang, label, val, thresholds.temperatureMin),
210
+ `${plant.name}:temp_low`,
211
+ );
212
+ } else if (val > thresholds.temperatureMax) {
213
+ await this.notif.send(
214
+ msg("temp_high", this.lang, label, val, thresholds.temperatureMax),
215
+ `${plant.name}:temp_high`,
216
+ );
217
+ }
218
+ await this._updateState(
219
+ `plants.${this._safeName(plant.name)}.temperature`,
220
+ val,
221
+ "°C",
222
+ );
223
+ }
224
+ }
225
+
226
+ // Battery
227
+ if (plant.sensorBattery) {
228
+ const entry = this.sensorValues[plant.sensorBattery];
229
+ if (entry != null) {
230
+ const val = entry.val;
231
+ if (val < thresholds.batteryMin) {
232
+ await this.notif.send(
233
+ msg("battery_low", this.lang, label, val, thresholds.batteryMin),
234
+ `${plant.name}:battery_low`,
235
+ );
236
+ }
237
+ await this._updateState(
238
+ `plants.${this._safeName(plant.name)}.battery`,
239
+ val,
240
+ "%",
241
+ );
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Check if a plant sensor has gone offline.
248
+ *
249
+ * @param {object} plant - plant configuration object
250
+ */
251
+ async _checkOffline(plant) {
252
+ const offlineHours = parseInt(this.adapter.config.offlineThreshold) || 3;
253
+ const last = this.lastSeen[plant.name];
254
+ if (!last) {
255
+ return;
256
+ }
257
+ const diffHours = (Date.now() - last) / (1000 * 60 * 60);
258
+ if (diffHours >= offlineHours) {
259
+ const label = `${plant.name}${plant.location ? ` (${plant.location})` : ""}`;
260
+ await this.notif.send(
261
+ msg("offline", this.lang, label, Math.round(diffHours)),
262
+ `${plant.name}:offline`,
263
+ );
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Start automatic watering for a plant if configured and not already active.
269
+ *
270
+ * @param {object} plant - plant configuration object
271
+ */
272
+ async _startWatering(plant) {
273
+ if (!plant.sensorWatering) {
274
+ return;
275
+ }
276
+ if (this._wateringActive[plant.name]) {
277
+ this.adapter.log.debug(
278
+ `MonitorService: watering already active for "${plant.name}", skipping`,
279
+ );
280
+ return;
281
+ }
282
+
283
+ const durationMin = parseInt(this.adapter.config.wateringDuration) || 3;
284
+ const durationMs = durationMin * 60 * 1000;
285
+
286
+ try {
287
+ await this.adapter.setForeignStateAsync(plant.sensorWatering, {
288
+ val: true,
289
+ ack: false,
290
+ });
291
+ this._wateringActive[plant.name] = true;
292
+ this.adapter.log.info(
293
+ `MonitorService: watering started for "${plant.name}" (${durationMin} min)`,
294
+ );
295
+
296
+ this.adapter.setTimeout(async () => {
297
+ try {
298
+ await this.adapter.setForeignStateAsync(plant.sensorWatering, {
299
+ val: false,
300
+ ack: false,
301
+ });
302
+ this.adapter.log.info(
303
+ `MonitorService: watering stopped for "${plant.name}"`,
304
+ );
305
+ } catch (err) {
306
+ this.adapter.log.warn(
307
+ `MonitorService: failed to stop watering for "${plant.name}": ${err.message}`,
308
+ );
309
+ }
310
+ this._wateringActive[plant.name] = false;
311
+ }, durationMs);
312
+ } catch (err) {
313
+ this.adapter.log.warn(
314
+ `MonitorService: failed to start watering for "${plant.name}": ${err.message}`,
315
+ );
316
+ this._wateringActive[plant.name] = false;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Update or create a state for a plant sensor value.
322
+ * Ensures parent device and channel objects exist first.
323
+ *
324
+ * @param {string} id - relative state id (e.g. plants.ficus.humidity)
325
+ * @param {number} val - sensor value
326
+ * @param {string} unit - unit of measurement
327
+ */
328
+ async _updateState(id, val, unit) {
329
+ try {
330
+ // Ensure parent objects exist: plants (channel) → plants.<name> (device)
331
+ const parts = id.split(".");
332
+ if (parts.length === 3) {
333
+ // parts = ['plants', '<safeName>', '<sensor>']
334
+ await this.adapter.extendObjectAsync(parts[0], {
335
+ type: "channel",
336
+ common: { name: "Plants" },
337
+ native: {},
338
+ });
339
+ await this.adapter.extendObjectAsync(`${parts[0]}.${parts[1]}`, {
340
+ type: "device",
341
+ common: { name: parts[1] },
342
+ native: {},
343
+ });
344
+ }
345
+
346
+ // Determine correct role
347
+ const roleMap = {
348
+ humidity: "value.humidity",
349
+ temperature: "value.temperature",
350
+ battery: "value.battery",
351
+ };
352
+ const sensor = parts[parts.length - 1];
353
+ const role = roleMap[sensor] || "value";
354
+
355
+ await this.adapter.extendObjectAsync(id, {
356
+ type: "state",
357
+ common: {
358
+ name: sensor,
359
+ type: "number",
360
+ role,
361
+ unit,
362
+ read: true,
363
+ write: false,
364
+ },
365
+ native: {},
366
+ });
367
+ await this.adapter.setStateAsync(id, { val, ack: true });
368
+ } catch (err) {
369
+ this.adapter.log.warn(
370
+ `MonitorService: failed to update state ${id}: ${err.message}`,
371
+ );
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Convert plant name to safe state id segment.
377
+ *
378
+ * @param {string} name - plant name
379
+ * @returns {string} sanitized name safe for use as state id
380
+ */
381
+ _safeName(name) {
382
+ return name.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase();
383
+ }
384
+
385
+ /**
386
+ * Get current sensor values for all enabled plants (for reports).
387
+ *
388
+ * @returns {Array<object>} array of { plant, humidity, temperature, battery }
389
+ */
390
+ getPlantStates() {
391
+ const plants = this.adapter.config.plants || [];
392
+ return plants
393
+ .filter((p) => p.enabled)
394
+ .map((plant) => ({
395
+ plant,
396
+ humidity: plant.sensorHumidity
397
+ ? (this.sensorValues[plant.sensorHumidity]?.val ?? null)
398
+ : null,
399
+ temperature: plant.sensorTemperature
400
+ ? (this.sensorValues[plant.sensorTemperature]?.val ?? null)
401
+ : null,
402
+ battery: plant.sensorBattery
403
+ ? (this.sensorValues[plant.sensorBattery]?.val ?? null)
404
+ : null,
405
+ }));
406
+ }
407
+ }
408
+
409
+ module.exports = MonitorService;
package/main.js CHANGED
@@ -114,14 +114,20 @@ class FlowersAdapter extends utils.Adapter {
114
114
  try {
115
115
  if (this._checkTimer) {
116
116
  this.clearInterval(this._checkTimer);
117
+ this._checkTimer = null;
117
118
  }
118
119
  if (this._dailyReportTimer) {
119
120
  this.clearTimeout(this._dailyReportTimer);
121
+ this._dailyReportTimer = null;
120
122
  }
121
123
  if (this._weeklyReportTimer) {
122
124
  this.clearTimeout(this._weeklyReportTimer);
125
+ this._weeklyReportTimer = null;
126
+ }
127
+ if (this.monitor) {
128
+ this.monitor.unsubscribeAll().catch(() => {});
129
+ this.monitor = null;
123
130
  }
124
- this.monitor.unsubscribeAll().catch(() => {});
125
131
  this.setStateAsync("info.connection", { val: false, ack: true }).catch(
126
132
  () => {},
127
133
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.flowers",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "ioBroker adapter for monitoring indoor plants via soil moisture, temperature and battery sensors with Telegram notifications",
5
5
  "author": {
6
6
  "name": "sadam6752-tech",
@@ -35,8 +35,7 @@
35
35
  "@iobroker/dev-server": "^0.8.0",
36
36
  "@iobroker/eslint-config": "^2.2.0",
37
37
  "@iobroker/testing": "^5.2.2",
38
- "eslint": "^9.17.0",
39
- "mocha": "^11.7.5"
38
+ "eslint": "^9.17.0"
40
39
  },
41
40
  "main": "main.js",
42
41
  "files": [