iobroker.nuasense 0.2.0 → 0.2.2
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 +10 -2
- package/io-package.json +27 -14
- package/lib/ecowitt.js +85 -90
- package/main.js +457 -465
- package/package.json +9 -5
package/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
4
|
* NuaSense for ioBroker.
|
|
@@ -18,512 +18,504 @@
|
|
|
18
18
|
* wind, rain and light.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
const utils = require(
|
|
22
|
-
const { toEcowitt, encodeForm, uploadUrl } = require(
|
|
21
|
+
const utils = require('@iobroker/adapter-core');
|
|
22
|
+
const { toEcowitt, encodeForm, uploadUrl } = require('./lib/ecowitt');
|
|
23
|
+
const { version } = require('./package.json');
|
|
23
24
|
|
|
24
25
|
const FORWARD_EVERY_MS = 60_000;
|
|
25
26
|
const MIN_POLL_MINUTES = 5;
|
|
26
27
|
// What the garden mostly is; the API returns watering for all three.
|
|
27
|
-
const PLANTS = [
|
|
28
|
+
const PLANTS = ['lawn', 'vegetables', 'beds'];
|
|
28
29
|
|
|
29
30
|
class Nuasense extends utils.Adapter {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
/** @param {Partial<utils.AdapterOptions>} [options] */
|
|
32
|
+
constructor(options) {
|
|
33
|
+
super({ ...options, name: 'nuasense' });
|
|
34
|
+
this.on('ready', this.onReady.bind(this));
|
|
35
|
+
this.on('unload', this.onUnload.bind(this));
|
|
36
|
+
this.pollTimer = null;
|
|
37
|
+
this.forwardTimer = null;
|
|
38
|
+
this.polling = false;
|
|
39
|
+
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
async onReady() {
|
|
42
|
+
await this.setStateAsync('info.connection', { val: false, ack: true });
|
|
43
|
+
const cfg = this.config;
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
const canRead = Boolean(cfg.apiKey);
|
|
46
|
+
const canSend = Boolean(cfg.forwardEnabled && cfg.forwardToken);
|
|
47
|
+
if (!canRead && !canSend) {
|
|
48
|
+
this.log.warn(
|
|
49
|
+
'Nothing configured: add an API key to read the station intelligence, or a station token to forward a station',
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
if (canRead) {
|
|
55
|
+
await this.poll();
|
|
56
|
+
const minutes = Math.max(MIN_POLL_MINUTES, Number(cfg.pollMinutes) || 15);
|
|
57
|
+
this.pollTimer = this.setInterval(() => this.poll(), minutes * 60_000);
|
|
58
|
+
this.log.info(`reading NuaSense every ${minutes} min`);
|
|
59
|
+
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
);
|
|
74
|
-
this.log.info(
|
|
75
|
-
`forwarding the mapped states to ${this.forwardUrl.replace(/\/station\/.*/, "/station/…")} every minute`,
|
|
76
|
-
);
|
|
61
|
+
if (canSend) {
|
|
62
|
+
try {
|
|
63
|
+
this.forwardUrl = uploadUrl(cfg.forwardServer, cfg.forwardToken);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
this.log.error(`forwarding is on but the station token looks wrong: ${e.message}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await this.forward();
|
|
69
|
+
this.forwardTimer = this.setInterval(() => this.forward(), FORWARD_EVERY_MS);
|
|
70
|
+
this.log.info(
|
|
71
|
+
`forwarding the mapped states to ${this.forwardUrl.replace(/\/station\/.*/, '/station/…')} every minute`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
77
74
|
}
|
|
78
|
-
}
|
|
79
75
|
|
|
80
|
-
|
|
76
|
+
// --- read ------------------------------------------------------------------
|
|
81
77
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
78
|
+
async api(path) {
|
|
79
|
+
const base = String(this.config.baseUrl || 'https://api.nuasense.com').replace(/\/+$/, '');
|
|
80
|
+
const res = await fetch(base + path, {
|
|
81
|
+
headers: {
|
|
82
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
83
|
+
Accept: 'application/json',
|
|
84
|
+
},
|
|
85
|
+
signal: AbortSignal.timeout(30_000),
|
|
86
|
+
});
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
let body;
|
|
89
|
+
try {
|
|
90
|
+
body = JSON.parse(text);
|
|
91
|
+
} catch {
|
|
92
|
+
body = { raw: text };
|
|
93
|
+
}
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const err = new Error(body.error || body.message || `HTTP ${res.status}`);
|
|
96
|
+
err.status = res.status;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
return body;
|
|
104
100
|
}
|
|
105
|
-
return body;
|
|
106
|
-
}
|
|
107
101
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
102
|
+
async poll() {
|
|
103
|
+
if (this.polling) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.polling = true;
|
|
107
|
+
try {
|
|
108
|
+
const list = await this.api('/api/nuasense/v1/ingest/stations');
|
|
109
|
+
await this.setStateAsync('info.connection', { val: true, ack: true });
|
|
110
|
+
const wanted = String(this.config.stationId || '').trim();
|
|
111
|
+
const stations = (list.stations || []).filter(s => !wanted || s.station_id === wanted);
|
|
112
|
+
if (stations.length === 0) {
|
|
113
|
+
this.log.warn(
|
|
114
|
+
wanted ? `station "${wanted}" is not on this account` : 'this account has no external stations yet',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
for (const station of stations) {
|
|
118
|
+
await this.updateStation(station);
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
await this.setStateAsync('info.connection', { val: false, ack: true });
|
|
122
|
+
this.log.warn(`NuaSense not read: ${e.message}`);
|
|
123
|
+
} finally {
|
|
124
|
+
this.polling = false;
|
|
125
|
+
}
|
|
111
126
|
}
|
|
112
|
-
this.polling = true;
|
|
113
|
-
try {
|
|
114
|
-
const list = await this.api("/api/nuasense/v1/ingest/stations");
|
|
115
|
-
await this.setStateAsync("info.connection", { val: true, ack: true });
|
|
116
|
-
const wanted = String(this.config.stationId || "").trim();
|
|
117
|
-
const stations = (list.stations || []).filter(
|
|
118
|
-
(s) => !wanted || s.station_id === wanted,
|
|
119
|
-
);
|
|
120
|
-
if (stations.length === 0) {
|
|
121
|
-
this.log.warn(
|
|
122
|
-
wanted
|
|
123
|
-
? `station "${wanted}" is not on this account`
|
|
124
|
-
: "this account has no external stations yet",
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
for (const station of stations) {
|
|
128
|
-
await this.updateStation(station);
|
|
129
|
-
}
|
|
130
|
-
} catch (e) {
|
|
131
|
-
await this.setStateAsync("info.connection", { val: false, ack: true });
|
|
132
|
-
this.log.warn(`NuaSense not read: ${e.message}`);
|
|
133
|
-
} finally {
|
|
134
|
-
this.polling = false;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
127
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
128
|
+
async updateStation(station) {
|
|
129
|
+
const id = `stations.${sanitize(station.station_id)}`;
|
|
130
|
+
await this.setObjectNotExistsAsync('stations', {
|
|
131
|
+
type: 'folder',
|
|
132
|
+
common: { name: 'Stations' },
|
|
133
|
+
native: {},
|
|
134
|
+
});
|
|
135
|
+
await this.extendObjectAsync(id, {
|
|
136
|
+
type: 'device',
|
|
137
|
+
common: { name: station.name || station.station_id },
|
|
138
|
+
native: { station_id: station.station_id },
|
|
139
|
+
});
|
|
140
|
+
await this.channel(`${id}.tonight`, 'Tonight');
|
|
141
|
+
await this.channel(`${id}.garden`, 'Garden');
|
|
142
|
+
await this.channel(`${id}.plausibility`, 'Plausibility');
|
|
145
143
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
144
|
+
const push = station.push || {};
|
|
145
|
+
await this.put(`${id}.lastUpload`, push.last_push_at || null, {
|
|
146
|
+
name: 'Last upload from the station',
|
|
147
|
+
type: 'string',
|
|
148
|
+
role: 'date',
|
|
149
|
+
});
|
|
150
|
+
await this.put(`${id}.device`, push.device || null, {
|
|
151
|
+
name: 'Device that uploads',
|
|
152
|
+
type: 'string',
|
|
153
|
+
role: 'text',
|
|
154
|
+
});
|
|
157
155
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
156
|
+
let look = null;
|
|
157
|
+
try {
|
|
158
|
+
look = await this.api(
|
|
159
|
+
`/api/nuasense/v1/ingest/first-look?station_id=${encodeURIComponent(station.station_id)}`,
|
|
160
|
+
);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (e.status !== 409) {
|
|
163
|
+
this.log.debug(`first look for ${station.station_id}: ${e.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const t = (look && look.tonight) || {};
|
|
167
|
+
const c = (look && (look.plausibility || look.station_check)) || {};
|
|
168
|
+
const w = (look && look.garden && look.garden.watering) || {};
|
|
171
169
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
170
|
+
// --- house & garden: every station ---------------------------------------
|
|
171
|
+
await this.put(`${id}.tonight.low`, numOrNull(t.low_c), {
|
|
172
|
+
name: "Tonight's low",
|
|
173
|
+
type: 'number',
|
|
174
|
+
role: 'value.temperature.min',
|
|
175
|
+
unit: '°C',
|
|
176
|
+
});
|
|
177
|
+
await this.put(`${id}.tonight.at`, t.at || null, {
|
|
178
|
+
name: 'Expected at (local)',
|
|
179
|
+
type: 'string',
|
|
180
|
+
role: 'text',
|
|
181
|
+
});
|
|
182
|
+
await this.put(`${id}.tonight.frost`, t.frost || null, {
|
|
183
|
+
name: 'Frost tonight: none, watch or frost',
|
|
184
|
+
type: 'string',
|
|
185
|
+
role: 'text',
|
|
186
|
+
});
|
|
187
|
+
await this.put(`${id}.tonight.grassLow`, numOrNull(t.grass_low_c), {
|
|
188
|
+
name: 'Estimated grass-level low',
|
|
189
|
+
type: 'number',
|
|
190
|
+
role: 'value.temperature',
|
|
191
|
+
unit: '°C',
|
|
192
|
+
});
|
|
195
193
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
await this.put(`${id}.garden.et0Source`, w.et0_source || null, {
|
|
239
|
-
name: "ET0 from: station or forecast",
|
|
240
|
-
type: "string",
|
|
241
|
-
role: "text",
|
|
242
|
-
});
|
|
194
|
+
const plant = PLANTS.includes(this.config.plant) ? this.config.plant : 'lawn';
|
|
195
|
+
const needMm = w.need_mm && typeof w.need_mm === 'object' ? numOrNull(w.need_mm[plant]) : null;
|
|
196
|
+
const rate = Number(this.config.sprinklerRate) || 0;
|
|
197
|
+
await this.put(`${id}.garden.wateringMm`, needMm, {
|
|
198
|
+
name: `Watering today (${plant})`,
|
|
199
|
+
type: 'number',
|
|
200
|
+
role: 'value',
|
|
201
|
+
unit: 'mm',
|
|
202
|
+
});
|
|
203
|
+
await this.put(
|
|
204
|
+
`${id}.garden.wateringMinutes`,
|
|
205
|
+
needMm !== null && rate > 0 ? Math.round((needMm / rate) * 60) : null,
|
|
206
|
+
{
|
|
207
|
+
name: 'Watering time today at the sprinkler rate',
|
|
208
|
+
type: 'number',
|
|
209
|
+
role: 'value',
|
|
210
|
+
unit: 'min',
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
await this.put(`${id}.garden.rainNext24h`, numOrNull(w.rain_next_24h_mm), {
|
|
214
|
+
name: 'Rain forecast, next 24 h',
|
|
215
|
+
type: 'number',
|
|
216
|
+
role: 'value.precipitation.forecast.0',
|
|
217
|
+
unit: 'mm',
|
|
218
|
+
});
|
|
219
|
+
await this.put(`${id}.garden.rainLast24h`, numOrNull(w.rain_last_24h_mm), {
|
|
220
|
+
name: 'Rain measured by the station, last 24 h',
|
|
221
|
+
type: 'number',
|
|
222
|
+
role: 'value.precipitation',
|
|
223
|
+
unit: 'mm',
|
|
224
|
+
});
|
|
225
|
+
await this.put(`${id}.garden.et0`, numOrNull(w.et0_mm), {
|
|
226
|
+
name: 'Evaporation today (ET0)',
|
|
227
|
+
type: 'number',
|
|
228
|
+
role: 'value',
|
|
229
|
+
unit: 'mm',
|
|
230
|
+
});
|
|
231
|
+
await this.put(`${id}.garden.et0Source`, w.et0_source || null, {
|
|
232
|
+
name: 'ET0 from: station or forecast',
|
|
233
|
+
type: 'string',
|
|
234
|
+
role: 'text',
|
|
235
|
+
});
|
|
243
236
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
237
|
+
await this.put(`${id}.plausibility.verdict`, c.verdict || null, {
|
|
238
|
+
name: 'Plausibility: plausible or implausible',
|
|
239
|
+
type: 'string',
|
|
240
|
+
role: 'text',
|
|
241
|
+
});
|
|
242
|
+
await this.put(`${id}.plausibility.reading`, numOrNull(c.reading_c), {
|
|
243
|
+
name: 'Latest reading',
|
|
244
|
+
type: 'number',
|
|
245
|
+
role: 'value.temperature',
|
|
246
|
+
unit: '°C',
|
|
247
|
+
});
|
|
248
|
+
await this.put(`${id}.plausibility.model`, numOrNull(c.model_c), {
|
|
249
|
+
name: 'Forecast model for that hour',
|
|
250
|
+
type: 'number',
|
|
251
|
+
role: 'value.temperature',
|
|
252
|
+
unit: '°C',
|
|
253
|
+
});
|
|
254
|
+
await this.put(`${id}.plausibility.difference`, numOrNull(c.diff_c), {
|
|
255
|
+
name: 'Reading minus model',
|
|
256
|
+
type: 'number',
|
|
257
|
+
role: 'value',
|
|
258
|
+
unit: 'K',
|
|
259
|
+
});
|
|
267
260
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
role: "text",
|
|
297
|
-
});
|
|
261
|
+
// --- agronomy: only with "advanced" ----------------------------------------
|
|
262
|
+
if (!this.config.advanced) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
await this.channel(`${id}.spray`, 'Spray window');
|
|
266
|
+
await this.channel(`${id}.hourly`, 'Hourly agronomy');
|
|
267
|
+
const s = (look && look.spray) || {};
|
|
268
|
+
await this.put(`${id}.spray.window`, s.window ? `${s.window.from}–${s.window.to}` : s.date ? 'none' : null, {
|
|
269
|
+
name: 'Next spray window',
|
|
270
|
+
type: 'string',
|
|
271
|
+
role: 'text',
|
|
272
|
+
});
|
|
273
|
+
await this.put(`${id}.spray.date`, s.date || null, {
|
|
274
|
+
name: 'Day of the spray window',
|
|
275
|
+
type: 'string',
|
|
276
|
+
role: 'text',
|
|
277
|
+
});
|
|
278
|
+
await this.put(`${id}.spray.windMax`, numOrNull(s.wind_max_ms), {
|
|
279
|
+
name: 'Highest wind in the window',
|
|
280
|
+
type: 'number',
|
|
281
|
+
role: 'value.speed.max.wind',
|
|
282
|
+
unit: 'm/s',
|
|
283
|
+
});
|
|
284
|
+
await this.put(`${id}.spray.reason`, s.reason || null, {
|
|
285
|
+
name: 'Why there is no window',
|
|
286
|
+
type: 'string',
|
|
287
|
+
role: 'text',
|
|
288
|
+
});
|
|
298
289
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
290
|
+
let row = null;
|
|
291
|
+
try {
|
|
292
|
+
const d = await this.api(
|
|
293
|
+
`/api/nuasense/v1/ingest/derived?station_id=${encodeURIComponent(station.station_id)}&start=-6h&limit=1`,
|
|
294
|
+
);
|
|
295
|
+
row = (d.data && d.data[0]) || null;
|
|
296
|
+
} catch (e) {
|
|
297
|
+
this.log.debug(`derived values for ${station.station_id}: ${e.message}`);
|
|
298
|
+
}
|
|
299
|
+
const r = row || {};
|
|
300
|
+
const hourly = [
|
|
301
|
+
['time', r.time || null, { name: 'Hour of these values', type: 'string', role: 'date' }],
|
|
302
|
+
['et0', numOrNull(r.et0_hour), { name: 'ET0 in the hour', type: 'number', role: 'value', unit: 'mm' }],
|
|
303
|
+
[
|
|
304
|
+
'leafWetnessHours',
|
|
305
|
+
numOrNull(r.lwd_consecutive_hours),
|
|
306
|
+
{
|
|
307
|
+
name: 'Consecutive wet-leaf hours',
|
|
308
|
+
type: 'number',
|
|
309
|
+
role: 'value',
|
|
310
|
+
unit: 'h',
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
['sprayQuality', r.spray_quality_label || null, { name: 'Spray quality', type: 'string', role: 'text' }],
|
|
314
|
+
[
|
|
315
|
+
'sprayIndex',
|
|
316
|
+
numOrNull(r.spray_quality_index),
|
|
317
|
+
{ name: 'Spray quality index', type: 'number', role: 'value' },
|
|
318
|
+
],
|
|
319
|
+
[
|
|
320
|
+
'sprayLimit',
|
|
321
|
+
r.spray_limiting_factor || null,
|
|
322
|
+
{ name: 'What limits spraying', type: 'string', role: 'text' },
|
|
323
|
+
],
|
|
324
|
+
[
|
|
325
|
+
'vpd',
|
|
326
|
+
numOrNull(r.vpd),
|
|
327
|
+
{
|
|
328
|
+
name: 'Vapour pressure deficit',
|
|
329
|
+
type: 'number',
|
|
330
|
+
role: 'value',
|
|
331
|
+
unit: 'kPa',
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
[
|
|
335
|
+
'deltaT',
|
|
336
|
+
numOrNull(r.delta_t),
|
|
337
|
+
{
|
|
338
|
+
name: 'Delta T',
|
|
339
|
+
type: 'number',
|
|
340
|
+
role: 'value.temperature',
|
|
341
|
+
unit: '°C',
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
[
|
|
345
|
+
'temperature',
|
|
346
|
+
numOrNull(r.temperature),
|
|
347
|
+
{
|
|
348
|
+
name: 'Temperature, hourly mean',
|
|
349
|
+
type: 'number',
|
|
350
|
+
role: 'value.temperature',
|
|
351
|
+
unit: '°C',
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
[
|
|
355
|
+
'humidity',
|
|
356
|
+
numOrNull(r.humidity),
|
|
357
|
+
{
|
|
358
|
+
name: 'Humidity, hourly mean',
|
|
359
|
+
type: 'number',
|
|
360
|
+
role: 'value.humidity',
|
|
361
|
+
unit: '%',
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
[
|
|
365
|
+
'pressure',
|
|
366
|
+
numOrNull(r.pressure),
|
|
367
|
+
{
|
|
368
|
+
name: 'Pressure, hourly mean',
|
|
369
|
+
type: 'number',
|
|
370
|
+
role: 'value.pressure',
|
|
371
|
+
unit: 'hPa',
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
];
|
|
375
|
+
for (const [key, value, common] of hourly) {
|
|
376
|
+
await this.put(`${id}.hourly.${key}`, value, common);
|
|
377
|
+
}
|
|
307
378
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
"et0",
|
|
317
|
-
numOrNull(r.et0_hour),
|
|
318
|
-
{ name: "ET0 in the hour", type: "number", role: "value", unit: "mm" },
|
|
319
|
-
],
|
|
320
|
-
[
|
|
321
|
-
"leafWetnessHours",
|
|
322
|
-
numOrNull(r.lwd_consecutive_hours),
|
|
323
|
-
{
|
|
324
|
-
name: "Consecutive wet-leaf hours",
|
|
325
|
-
type: "number",
|
|
326
|
-
role: "value",
|
|
327
|
-
unit: "h",
|
|
328
|
-
},
|
|
329
|
-
],
|
|
330
|
-
[
|
|
331
|
-
"sprayQuality",
|
|
332
|
-
r.spray_quality_label || null,
|
|
333
|
-
{ name: "Spray quality", type: "string", role: "text" },
|
|
334
|
-
],
|
|
335
|
-
[
|
|
336
|
-
"sprayIndex",
|
|
337
|
-
numOrNull(r.spray_quality_index),
|
|
338
|
-
{ name: "Spray quality index", type: "number", role: "value" },
|
|
339
|
-
],
|
|
340
|
-
[
|
|
341
|
-
"sprayLimit",
|
|
342
|
-
r.spray_limiting_factor || null,
|
|
343
|
-
{ name: "What limits spraying", type: "string", role: "text" },
|
|
344
|
-
],
|
|
345
|
-
[
|
|
346
|
-
"vpd",
|
|
347
|
-
numOrNull(r.vpd),
|
|
348
|
-
{
|
|
349
|
-
name: "Vapour pressure deficit",
|
|
350
|
-
type: "number",
|
|
351
|
-
role: "value",
|
|
352
|
-
unit: "kPa",
|
|
353
|
-
},
|
|
354
|
-
],
|
|
355
|
-
[
|
|
356
|
-
"deltaT",
|
|
357
|
-
numOrNull(r.delta_t),
|
|
358
|
-
{
|
|
359
|
-
name: "Delta T",
|
|
360
|
-
type: "number",
|
|
361
|
-
role: "value.temperature",
|
|
362
|
-
unit: "°C",
|
|
363
|
-
},
|
|
364
|
-
],
|
|
365
|
-
[
|
|
366
|
-
"temperature",
|
|
367
|
-
numOrNull(r.temperature),
|
|
368
|
-
{
|
|
369
|
-
name: "Temperature, hourly mean",
|
|
370
|
-
type: "number",
|
|
371
|
-
role: "value.temperature",
|
|
372
|
-
unit: "°C",
|
|
373
|
-
},
|
|
374
|
-
],
|
|
375
|
-
[
|
|
376
|
-
"humidity",
|
|
377
|
-
numOrNull(r.humidity),
|
|
378
|
-
{
|
|
379
|
-
name: "Humidity, hourly mean",
|
|
380
|
-
type: "number",
|
|
381
|
-
role: "value.humidity",
|
|
382
|
-
unit: "%",
|
|
383
|
-
},
|
|
384
|
-
],
|
|
385
|
-
[
|
|
386
|
-
"pressure",
|
|
387
|
-
numOrNull(r.pressure),
|
|
388
|
-
{
|
|
389
|
-
name: "Pressure, hourly mean",
|
|
390
|
-
type: "number",
|
|
391
|
-
role: "value.pressure",
|
|
392
|
-
unit: "hPa",
|
|
393
|
-
},
|
|
394
|
-
],
|
|
395
|
-
];
|
|
396
|
-
for (const [key, value, common] of hourly) {
|
|
397
|
-
await this.put(`${id}.hourly.${key}`, value, common);
|
|
379
|
+
|
|
380
|
+
/** A channel that groups the states below it. */
|
|
381
|
+
async channel(id, name) {
|
|
382
|
+
await this.setObjectNotExistsAsync(id, {
|
|
383
|
+
type: 'channel',
|
|
384
|
+
common: { name },
|
|
385
|
+
native: {},
|
|
386
|
+
});
|
|
398
387
|
}
|
|
399
|
-
}
|
|
400
388
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Create the state if needed, then write it acknowledged. With nothing to
|
|
391
|
+
* report the stored value goes away: null would break the declared type, and
|
|
392
|
+
* keeping the old value would show yesterday's answer as today's.
|
|
393
|
+
*/
|
|
394
|
+
async put(id, value, common) {
|
|
395
|
+
await this.setObjectNotExistsAsync(id, {
|
|
396
|
+
type: 'state',
|
|
397
|
+
common: { read: true, write: false, ...common },
|
|
398
|
+
native: {},
|
|
399
|
+
});
|
|
400
|
+
if (value === null || value === undefined) {
|
|
401
|
+
await this.delStateAsync(id);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await this.setStateAsync(id, { val: value, ack: true });
|
|
405
|
+
}
|
|
410
406
|
|
|
411
|
-
|
|
407
|
+
// --- send ------------------------------------------------------------------
|
|
412
408
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
409
|
+
async readMapped(stateId) {
|
|
410
|
+
if (!stateId) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const s = await this.getForeignStateAsync(stateId);
|
|
415
|
+
if (!s || s.val === null || s.val === undefined || s.val === '') {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
const n = Number(s.val);
|
|
419
|
+
return Number.isFinite(n) ? n : undefined;
|
|
420
|
+
} catch (e) {
|
|
421
|
+
this.log.debug(`cannot read ${stateId}: ${e.message}`);
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
427
424
|
}
|
|
428
|
-
}
|
|
429
425
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
ack: true,
|
|
494
|
-
});
|
|
495
|
-
this.log.debug(`upload failed: ${e.message}`);
|
|
426
|
+
async forward() {
|
|
427
|
+
const cfg = this.config;
|
|
428
|
+
let wind = await this.readMapped(cfg.windSpeedState);
|
|
429
|
+
let gust = await this.readMapped(cfg.windGustState);
|
|
430
|
+
if (cfg.windUnit === 'kmh') {
|
|
431
|
+
if (wind !== undefined) {
|
|
432
|
+
wind /= 3.6;
|
|
433
|
+
}
|
|
434
|
+
if (gust !== undefined) {
|
|
435
|
+
gust /= 3.6;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const reading = {
|
|
439
|
+
temperature: await this.readMapped(cfg.temperatureState),
|
|
440
|
+
humidity: await this.readMapped(cfg.humidityState),
|
|
441
|
+
pressure: await this.readMapped(cfg.pressureState),
|
|
442
|
+
wind_speed: wind,
|
|
443
|
+
wind_gusts: gust,
|
|
444
|
+
wind_direction: await this.readMapped(cfg.windDirectionState),
|
|
445
|
+
rain_total_mm: await this.readMapped(cfg.rainTotalState),
|
|
446
|
+
solar_wm2: await this.readMapped(cfg.solarState),
|
|
447
|
+
light_lux: await this.readMapped(cfg.luxState),
|
|
448
|
+
};
|
|
449
|
+
const fields = toEcowitt(reading, {
|
|
450
|
+
deviceId: cfg.forwardDeviceId || `iobroker-${this.namespace}`,
|
|
451
|
+
stationType: `iobroker.nuasense_V${version}`,
|
|
452
|
+
});
|
|
453
|
+
if (Object.keys(fields).length <= 3) {
|
|
454
|
+
// PASSKEY, stationtype, dateutc and nothing else: no mapped state had a value.
|
|
455
|
+
await this.setStateAsync('forward.lastStatus', {
|
|
456
|
+
val: 'no values to send (check the mapped states)',
|
|
457
|
+
ack: true,
|
|
458
|
+
});
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
const res = await fetch(this.forwardUrl, {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
465
|
+
body: encodeForm(fields),
|
|
466
|
+
signal: AbortSignal.timeout(10_000),
|
|
467
|
+
});
|
|
468
|
+
const text = (await res.text()).slice(0, 80);
|
|
469
|
+
await this.setStateAsync('forward.lastSentAt', {
|
|
470
|
+
val: new Date().toISOString(),
|
|
471
|
+
ack: true,
|
|
472
|
+
});
|
|
473
|
+
await this.setStateAsync('forward.lastStatus', {
|
|
474
|
+
val: `${res.status} ${text}`.trim(),
|
|
475
|
+
ack: true,
|
|
476
|
+
});
|
|
477
|
+
if (res.status === 404) {
|
|
478
|
+
this.log.warn('NuaSense does not know this station token (revoked, or mistyped)');
|
|
479
|
+
} else if (res.status === 409) {
|
|
480
|
+
this.log.warn('the station token is bound to another device; issue a new path in the console');
|
|
481
|
+
}
|
|
482
|
+
} catch (e) {
|
|
483
|
+
await this.setStateAsync('forward.lastStatus', {
|
|
484
|
+
val: `failed: ${e.message}`,
|
|
485
|
+
ack: true,
|
|
486
|
+
});
|
|
487
|
+
this.log.debug(`upload failed: ${e.message}`);
|
|
488
|
+
}
|
|
496
489
|
}
|
|
497
|
-
}
|
|
498
490
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
491
|
+
onUnload(callback) {
|
|
492
|
+
try {
|
|
493
|
+
if (this.pollTimer) {
|
|
494
|
+
this.clearInterval(this.pollTimer);
|
|
495
|
+
}
|
|
496
|
+
if (this.forwardTimer) {
|
|
497
|
+
this.clearInterval(this.forwardTimer);
|
|
498
|
+
}
|
|
499
|
+
} finally {
|
|
500
|
+
callback();
|
|
501
|
+
}
|
|
509
502
|
}
|
|
510
|
-
}
|
|
511
503
|
}
|
|
512
504
|
|
|
513
505
|
function sanitize(id) {
|
|
514
|
-
|
|
506
|
+
return String(id).replace(/[^A-Za-z0-9_-]/g, '_');
|
|
515
507
|
}
|
|
516
508
|
|
|
517
509
|
function numOrNull(v) {
|
|
518
|
-
|
|
519
|
-
|
|
510
|
+
const n = Number(v);
|
|
511
|
+
return v === null || v === undefined || !Number.isFinite(n) ? null : n;
|
|
520
512
|
}
|
|
521
513
|
|
|
522
514
|
if (require.main !== module) {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
515
|
+
// Export the constructor in compact mode
|
|
516
|
+
/** @param {Partial<utils.AdapterOptions>} [options] */
|
|
517
|
+
module.exports = options => new Nuasense(options);
|
|
526
518
|
} else {
|
|
527
|
-
|
|
528
|
-
|
|
519
|
+
// otherwise start the instance directly
|
|
520
|
+
new Nuasense();
|
|
529
521
|
}
|