iobroker.flowers 0.3.2 → 0.3.3
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 +5 -0
- package/io-package.json +14 -1
- package/lib/monitor-service.js +409 -383
- package/main.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,11 @@ Only one watering cycle runs at a time per plant. Configure the duration in Sett
|
|
|
75
75
|
|
|
76
76
|
## Changelog
|
|
77
77
|
|
|
78
|
+
### 0.3.3 (2026-03-30)
|
|
79
|
+
- (sadam6752-tech) Fix object hierarchy: create device/channel parent objects before states
|
|
80
|
+
- (sadam6752-tech) Use correct state roles: value.humidity, value.temperature, value.battery
|
|
81
|
+
- (sadam6752-tech) Improve unload: null timers after clearing, guard monitor null check
|
|
82
|
+
|
|
78
83
|
### 0.3.2 (2026-03-30)
|
|
79
84
|
- (sadam6752-tech) Custom profiles: users can create own plant profiles in Profiles tab
|
|
80
85
|
- (sadam6752-tech) Custom profile field in Plants table for direct profile name entry
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "flowers",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.3",
|
|
5
5
|
"news": {
|
|
6
|
+
"0.3.3": {
|
|
7
|
+
"en": "Fix object hierarchy (device/channel/state), correct state roles (value.humidity, value.temperature, value.battery), improve unload cleanup",
|
|
8
|
+
"de": "Objekthierarchie korrigiert (device/channel/state), korrekte State-Rollen, verbessertes Aufräumen beim Entladen",
|
|
9
|
+
"ru": "Исправлена иерархия объектов (device/channel/state), правильные роли состояний, улучшена очистка при выгрузке",
|
|
10
|
+
"fr": "Correction hiérarchie objets (device/channel/state), rôles d'état corrects, nettoyage amélioré au déchargement",
|
|
11
|
+
"it": "Corretta gerarchia oggetti (device/channel/state), ruoli stato corretti, pulizia migliorata allo scaricamento",
|
|
12
|
+
"es": "Corrección jerarquía objetos (device/channel/state), roles de estado correctos, limpieza mejorada al descargar",
|
|
13
|
+
"pl": "Poprawiono hierarchię obiektów (device/channel/state), poprawne role stanów, ulepszone czyszczenie przy wyładowaniu",
|
|
14
|
+
"pt": "Correção hierarquia objetos (device/channel/state), funções de estado corretas, limpeza melhorada no descarregamento",
|
|
15
|
+
"nl": "Objecthiërarchie gecorrigeerd (device/channel/state), correcte state-rollen, verbeterde opruiming bij ontladen",
|
|
16
|
+
"uk": "Виправлено ієрархію об'єктів (device/channel/state), правильні ролі станів, покращено очищення при вивантаженні",
|
|
17
|
+
"zh-cn": "修复对象层次结构(device/channel/state),正确的状态角色,改进卸载清理"
|
|
18
|
+
},
|
|
6
19
|
"0.3.2": {
|
|
7
20
|
"en": "Custom profiles: users can create own plant profiles in Profiles tab",
|
|
8
21
|
"de": "Benutzerdefinierte Profile: eigene Pflanzenprofile in der Profiles-Registerkarte erstellen",
|
package/lib/monitor-service.js
CHANGED
|
@@ -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
|
-
*
|
|
324
|
-
* @param {
|
|
325
|
-
* @param {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
.
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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