iobroker.nuasense 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NuaSense
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # Temporary Holding Version
1
+ ![Logo](admin/nuasense.png)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # ioBroker.nuasense
4
+
5
+ [![NPM version](https://img.shields.io/npm/v/iobroker.nuasense.svg)](https://www.npmjs.com/package/iobroker.nuasense)
6
+ [![Downloads](https://img.shields.io/npm/dm/iobroker.nuasense.svg)](https://www.npmjs.com/package/iobroker.nuasense)
7
+
8
+ **Tests:** ![Test and Release](https://github.com/nuasense/ioBroker.nuasense/workflows/Test%20and%20Release/badge.svg)
9
+
10
+ ## NuaSense adapter for ioBroker
11
+
12
+ What your weather station means for your crop, as ioBroker states — and, if you like, your station forwarded from ioBroker to NuaSense.
13
+
14
+ [NuaSense](https://nuasense.com) computes the agronomy on a weather station of any brand: tonight's low and whether it is a frost night, the next calm dry daylight window for spraying, whether the latest reading is plausible against the model, and the hourly values — ET0, leaf wetness, spray quality, VPD, delta T. The first three stations on an account connect free.
15
+
16
+ ### Read
17
+
18
+ Make an API key in the NuaSense console under **API Keys** (scope `ingest:write`; despite the name it is what reads your own stations) and put it into the adapter. Every 15 minutes (configurable) the adapter writes, per station:
19
+
20
+ | state | meaning |
21
+ |---|---|
22
+ | `tonight.low`, `tonight.at`, `tonight.frost`, `tonight.grassLow` | the coming night: low in °C, when, `none` / `watch` / `frost`, grass-level estimate |
23
+ | `spray.window`, `spray.date`, `spray.windMax`, `spray.reason` | the next calm dry window, e.g. `07:00–14:00`, or why there is none |
24
+ | `check.verdict`, `check.reading`, `check.model`, `check.difference` | latest reading against the model for that hour: `plausible`, `check`, `off` |
25
+ | `hourly.et0`, `hourly.leafWetnessHours`, `hourly.sprayQuality`, `hourly.sprayIndex`, `hourly.sprayLimit`, `hourly.vpd`, `hourly.deltaT`, `hourly.temperature`, `hourly.humidity`, `hourly.pressure` | the latest hourly agronomy |
26
+ | `lastUpload`, `device` | when the station last sent, and from what |
27
+
28
+ `info.connection` says whether NuaSense answered with this key.
29
+
30
+ ### Send
31
+
32
+ Any weather station ioBroker already has — through the Ecowitt, Sainlogic, Bresser, Netatmo or MQTT adapters, or rtl_433 — can become a NuaSense station:
33
+
34
+ 1. In the NuaSense console: **External stations → Add a station**, pick *Home Assistant / weewx / Meteobridge / Node-RED*, give it a name and a position. The last screen shows a path like `/station/kw7n-4hpd-x2vt`; the part after `/station/` is the token.
35
+ 2. In the adapter's *Forward a station* tab: switch forwarding on, paste the token, and pick the states that hold temperature (°C), humidity (%), pressure at station level (hPa), wind (m/s or km/h), gusts, direction, the rain total (mm, a counter that only grows) and solar radiation or light.
36
+ 3. The adapter sends once a minute, exactly as an Ecowitt console would. `forward.lastStatus` shows the receiver's answer; the console shows the first upload within a minute.
37
+
38
+ Missing values are simply not sent. The station's path is bound to the first device id that uses it, so keep the device id stable.
39
+
40
+ ### Your data
41
+
42
+ When you connect a station, NuaSense stores its readings and computes your forecast, spray windows and risks from them. The readings are used aggregated and anonymised to improve forecasts and models for everyone; they are never passed on individually without your opt-in. You can disconnect the station in the console at any time and have all its data deleted.
43
+
44
+ ## Changelog
45
+ <!--
46
+ Placeholder for the next version (at the beginning of the line):
47
+ ### **WORK IN PROGRESS**
48
+ -->
49
+ ### 0.1.0 (2026-11-03)
50
+ * (NuaSense) first release: first look and hourly agronomy as states; optional forwarding of a station from ioBroker to NuaSense
51
+
52
+ ## License
53
+ MIT License
54
+
55
+ Copyright (c) 2026 NuaSense <manuel@nuasense.com>
56
+
57
+ Permission is hereby granted, free of charge, to any person obtaining a copy
58
+ of this software and associated documentation files (the "Software"), to deal
59
+ in the Software without restriction, including without limitation the rights
60
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
61
+ copies of the Software, and to permit persons to whom the Software is
62
+ furnished to do so, subject to the following conditions:
63
+
64
+ The above copyright notice and this permission notice shall be included in all
65
+ copies or substantial portions of the Software.
66
+
67
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
68
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
69
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
70
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
71
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
72
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
73
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ {
2
+ "i18n": false,
3
+ "type": "tabs",
4
+ "items": {
5
+ "read": {
6
+ "type": "panel",
7
+ "label": { "en": "Read from NuaSense", "de": "Von NuaSense lesen" },
8
+ "items": {
9
+ "intro": {
10
+ "type": "staticText",
11
+ "text": { "en": "An API key from platform.nuasense.com → API Keys, with the ingest:write scope (it is also what reads your own stations). Leave empty if you only want to forward a station.", "de": "Ein API-Key von platform.nuasense.com → API Keys mit dem Scope ingest:write (er liest auch die eigenen Stationen). Leer lassen, wenn nur eine Station weitergeleitet werden soll." },
12
+ "newLine": true
13
+ },
14
+ "apiKey": {
15
+ "type": "password",
16
+ "label": { "en": "API key", "de": "API-Key" },
17
+ "newLine": true,
18
+ "sm": 12, "md": 8
19
+ },
20
+ "stationId": {
21
+ "type": "text",
22
+ "label": { "en": "Station id (empty = all stations on the account)", "de": "Station-ID (leer = alle Stationen des Kontos)" },
23
+ "newLine": true,
24
+ "sm": 12, "md": 8
25
+ },
26
+ "pollMinutes": {
27
+ "type": "number",
28
+ "label": { "en": "Read every (minutes)", "de": "Lesen alle (Minuten)" },
29
+ "min": 5, "max": 240,
30
+ "newLine": true,
31
+ "sm": 6, "md": 3
32
+ },
33
+ "baseUrl": {
34
+ "type": "text",
35
+ "label": { "en": "API address", "de": "API-Adresse" },
36
+ "newLine": true,
37
+ "sm": 12, "md": 8
38
+ }
39
+ }
40
+ },
41
+ "send": {
42
+ "type": "panel",
43
+ "label": { "en": "Forward a station to NuaSense", "de": "Station an NuaSense weiterleiten" },
44
+ "items": {
45
+ "intro2": {
46
+ "type": "staticText",
47
+ "text": { "en": "In the NuaSense console: External stations → Add a station → pick 'Home Assistant / weewx / Meteobridge / Node-RED'. The last screen shows a path like /station/kw7n-4hpd-x2vt; the part after /station/ is the token. Then pick the ioBroker states that hold your station's values; missing ones are simply not sent.", "de": "In der NuaSense-Konsole: External stations → Add a station → «Home Assistant / weewx / Meteobridge / Node-RED» wählen. Der letzte Schritt zeigt einen Pfad wie /station/kw7n-4hpd-x2vt; der Teil nach /station/ ist der Token. Dann die ioBroker-States wählen, die die Werte der Station halten; fehlende werden einfach nicht gesendet." },
48
+ "newLine": true
49
+ },
50
+ "forwardEnabled": {
51
+ "type": "checkbox",
52
+ "label": { "en": "Forward a station", "de": "Station weiterleiten" },
53
+ "newLine": true
54
+ },
55
+ "forwardToken": {
56
+ "type": "password",
57
+ "label": { "en": "Station token", "de": "Station-Token" },
58
+ "newLine": true,
59
+ "sm": 12, "md": 6
60
+ },
61
+ "forwardDeviceId": {
62
+ "type": "text",
63
+ "label": { "en": "Device id (any text; keep it stable)", "de": "Geräte-ID (beliebiger Text; stabil lassen)" },
64
+ "sm": 12, "md": 6
65
+ },
66
+ "forwardServer": {
67
+ "type": "text",
68
+ "label": { "en": "Receiver", "de": "Empfänger" },
69
+ "newLine": true,
70
+ "sm": 12, "md": 6
71
+ },
72
+ "temperatureState": { "type": "objectId", "label": { "en": "Temperature (°C)", "de": "Temperatur (°C)" }, "newLine": true, "sm": 12, "md": 6 },
73
+ "humidityState": { "type": "objectId", "label": { "en": "Humidity (%)", "de": "Luftfeuchte (%)" }, "sm": 12, "md": 6 },
74
+ "pressureState": { "type": "objectId", "label": { "en": "Pressure, station level (hPa)", "de": "Luftdruck, Stationshöhe (hPa)" }, "newLine": true, "sm": 12, "md": 6 },
75
+ "windSpeedState": { "type": "objectId", "label": { "en": "Wind speed", "de": "Windgeschwindigkeit" }, "sm": 12, "md": 4 },
76
+ "windUnit": {
77
+ "type": "select",
78
+ "label": { "en": "Wind unit", "de": "Windeinheit" },
79
+ "options": [
80
+ { "label": "m/s", "value": "ms" },
81
+ { "label": "km/h", "value": "kmh" }
82
+ ],
83
+ "sm": 12, "md": 2
84
+ },
85
+ "windGustState": { "type": "objectId", "label": { "en": "Wind gusts", "de": "Windböen" }, "newLine": true, "sm": 12, "md": 6 },
86
+ "windDirectionState": { "type": "objectId", "label": { "en": "Wind direction (°)", "de": "Windrichtung (°)" }, "sm": 12, "md": 6 },
87
+ "rainTotalState": { "type": "objectId", "label": { "en": "Rain total (mm, a counter that only grows)", "de": "Regen gesamt (mm, ein Zähler, der nur wächst)" }, "newLine": true, "sm": 12, "md": 6 },
88
+ "solarState": { "type": "objectId", "label": { "en": "Solar radiation (W/m²)", "de": "Globalstrahlung (W/m²)" }, "sm": 12, "md": 6 },
89
+ "luxState": { "type": "objectId", "label": { "en": "Light (lux), if no W/m²", "de": "Licht (Lux), falls kein W/m²" }, "newLine": true, "sm": 12, "md": 6 }
90
+ }
91
+ }
92
+ }
93
+ }
Binary file
@@ -0,0 +1,158 @@
1
+ {
2
+ "common": {
3
+ "name": "nuasense",
4
+ "version": "0.1.0",
5
+ "news": {
6
+ "0.1.0": {
7
+ "en": "First release: first look and hourly agronomy as states; optional forwarding of a station from ioBroker to NuaSense",
8
+ "de": "Erste Version: Erster Blick und stündliche Agronomie als States; optional Weiterleitung einer Station von ioBroker zu NuaSense",
9
+ "ru": "Первый выпуск: прогноз на ночь и почасовая агрономия как состояния; опциональная пересылка станции из ioBroker в NuaSense",
10
+ "pt": "Primeira versão: primeira olhada e agronomia horária como estados; encaminhamento opcional de uma estação do ioBroker para o NuaSense",
11
+ "nl": "Eerste versie: eerste blik en uurlijkse agronomie als states; optioneel doorsturen van een station van ioBroker naar NuaSense",
12
+ "fr": "Première version : premier aperçu et agronomie horaire sous forme d'états ; transfert optionnel d'une station d'ioBroker vers NuaSense",
13
+ "it": "Prima versione: prima occhiata e agronomia oraria come stati; inoltro opzionale di una stazione da ioBroker a NuaSense",
14
+ "es": "Primera versión: primer vistazo y agronomía horaria como estados; reenvío opcional de una estación de ioBroker a NuaSense",
15
+ "pl": "Pierwsze wydanie: pierwszy rzut oka i godzinowa agronomia jako stany; opcjonalne przekazywanie stacji z ioBroker do NuaSense",
16
+ "uk": "Перший випуск: перший погляд і погодинна агрономія як стани; необов'язкове пересилання станції з ioBroker до NuaSense",
17
+ "zh-cn": "首个版本:夜间预览和每小时农艺数据作为状态;可选将气象站从 ioBroker 转发到 NuaSense"
18
+ }
19
+ },
20
+ "titleLang": {
21
+ "en": "NuaSense",
22
+ "de": "NuaSense",
23
+ "ru": "NuaSense",
24
+ "pt": "NuaSense",
25
+ "nl": "NuaSense",
26
+ "fr": "NuaSense",
27
+ "it": "NuaSense",
28
+ "es": "NuaSense",
29
+ "pl": "NuaSense",
30
+ "uk": "NuaSense",
31
+ "zh-cn": "NuaSense"
32
+ },
33
+ "desc": {
34
+ "en": "What your weather station means for your crop: tonight's low and frost, the next spray window, ET0, leaf wetness, spray quality. Optionally forwards a station from ioBroker to NuaSense.",
35
+ "de": "Was deine Wetterstation für deine Kultur bedeutet: Tief und Frost heute Nacht, das nächste Spritzfenster, ET0, Blattnässe, Spritzqualität. Optional Weiterleitung einer Station von ioBroker zu NuaSense.",
36
+ "ru": "Что ваша метеостанция значит для вашей культуры: ночной минимум и заморозки, следующее окно для опрыскивания, ET0, увлажнение листьев. Опционально пересылает станцию из ioBroker в NuaSense.",
37
+ "pt": "O que a sua estação meteorológica significa para a sua cultura: mínima e geada desta noite, próxima janela de pulverização, ET0, molhamento foliar. Opcionalmente encaminha uma estação do ioBroker para o NuaSense.",
38
+ "nl": "Wat je weerstation betekent voor je gewas: minimum en vorst vannacht, het volgende spuitvenster, ET0, bladnat. Optioneel doorsturen van een station van ioBroker naar NuaSense.",
39
+ "fr": "Ce que votre station météo signifie pour votre culture : minimum et gel de la nuit, prochaine fenêtre de pulvérisation, ET0, humectation foliaire. Transfert optionnel d'une station d'ioBroker vers NuaSense.",
40
+ "it": "Cosa significa la tua stazione meteo per la tua coltura: minima e gelo stanotte, prossima finestra di irrorazione, ET0, bagnatura fogliare. Inoltro opzionale di una stazione da ioBroker a NuaSense.",
41
+ "es": "Lo que su estación meteorológica significa para su cultivo: mínima y helada de esta noche, próxima ventana de pulverización, ET0, mojado foliar. Reenvío opcional de una estación de ioBroker a NuaSense.",
42
+ "pl": "Co Twoja stacja pogodowa oznacza dla Twojej uprawy: nocne minimum i przymrozek, następne okno oprysku, ET0, zwilżenie liści. Opcjonalne przekazywanie stacji z ioBroker do NuaSense.",
43
+ "uk": "Що ваша метеостанція означає для вашої культури: нічний мінімум і заморозки, наступне вікно обприскування, ET0, зволоження листя. Необов'язкове пересилання станції з ioBroker до NuaSense.",
44
+ "zh-cn": "您的气象站对作物意味着什么:今夜最低温与霜冻、下一个喷药窗口、ET0、叶面湿润。可选将气象站从 ioBroker 转发到 NuaSense。"
45
+ },
46
+ "authors": ["NuaSense <manuel@nuasense.com>"],
47
+ "keywords": ["weather", "weather station", "agriculture", "frost", "spray", "evapotranspiration", "nuasense"],
48
+ "licenseInformation": {
49
+ "type": "free",
50
+ "license": "MIT"
51
+ },
52
+ "platform": "Javascript/Node.js",
53
+ "icon": "nuasense.png",
54
+ "enabled": true,
55
+ "extIcon": "https://raw.githubusercontent.com/nuasense/ioBroker.nuasense/main/admin/nuasense.png",
56
+ "readme": "https://github.com/nuasense/ioBroker.nuasense/blob/main/README.md",
57
+ "loglevel": "info",
58
+ "tier": 3,
59
+ "mode": "daemon",
60
+ "type": "weather",
61
+ "compact": true,
62
+ "connectionType": "cloud",
63
+ "dataSource": "poll",
64
+ "adminUI": {
65
+ "config": "json"
66
+ },
67
+ "dependencies": [
68
+ {
69
+ "js-controller": ">=5.0.19"
70
+ }
71
+ ],
72
+ "globalDependencies": [
73
+ {
74
+ "admin": ">=6.17.14"
75
+ }
76
+ ]
77
+ },
78
+ "native": {
79
+ "apiKey": "",
80
+ "baseUrl": "https://api.nuasense.com",
81
+ "stationId": "",
82
+ "pollMinutes": 15,
83
+ "forwardEnabled": false,
84
+ "forwardToken": "",
85
+ "forwardServer": "ingest.nuasense.com",
86
+ "forwardDeviceId": "",
87
+ "temperatureState": "",
88
+ "humidityState": "",
89
+ "pressureState": "",
90
+ "windSpeedState": "",
91
+ "windUnit": "ms",
92
+ "windGustState": "",
93
+ "windDirectionState": "",
94
+ "rainTotalState": "",
95
+ "solarState": "",
96
+ "luxState": ""
97
+ },
98
+ "protectedNative": ["apiKey", "forwardToken"],
99
+ "encryptedNative": ["apiKey", "forwardToken"],
100
+ "objects": [],
101
+ "instanceObjects": [
102
+ {
103
+ "_id": "info",
104
+ "type": "channel",
105
+ "common": {
106
+ "name": "Information"
107
+ },
108
+ "native": {}
109
+ },
110
+ {
111
+ "_id": "info.connection",
112
+ "type": "state",
113
+ "common": {
114
+ "role": "indicator.connected",
115
+ "name": "NuaSense reachable with this API key",
116
+ "type": "boolean",
117
+ "read": true,
118
+ "write": false,
119
+ "def": false
120
+ },
121
+ "native": {}
122
+ },
123
+ {
124
+ "_id": "forward",
125
+ "type": "channel",
126
+ "common": {
127
+ "name": "Forwarding a station to NuaSense"
128
+ },
129
+ "native": {}
130
+ },
131
+ {
132
+ "_id": "forward.lastSentAt",
133
+ "type": "state",
134
+ "common": {
135
+ "role": "date",
136
+ "name": "Last upload sent to NuaSense",
137
+ "type": "string",
138
+ "read": true,
139
+ "write": false,
140
+ "def": ""
141
+ },
142
+ "native": {}
143
+ },
144
+ {
145
+ "_id": "forward.lastStatus",
146
+ "type": "state",
147
+ "common": {
148
+ "role": "text",
149
+ "name": "Receiver's answer to the last upload",
150
+ "type": "string",
151
+ "read": true,
152
+ "write": false,
153
+ "def": ""
154
+ },
155
+ "native": {}
156
+ }
157
+ ]
158
+ }
package/lib/ecowitt.js ADDED
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Metric readings in, an Ecowitt-format upload out.
5
+ *
6
+ * NuaSense's station receiver speaks what consumer consoles speak, and the
7
+ * Ecowitt format is the richest of them. So a Node-RED flow sends the same
8
+ * form a GW2000 would: imperial units, rain as a running yearly counter, and
9
+ * a PASSKEY that identifies the device so the receiver can bind the station's
10
+ * path to it. Everything here is pure, so it is tested without Node-RED.
11
+ */
12
+
13
+ const crypto = require("crypto");
14
+
15
+ const INHG_PER_HPA = 1 / 33.8639;
16
+ const MPH_PER_MS = 1 / 0.44704;
17
+ const IN_PER_MM = 1 / 25.4;
18
+ // Daylight: 120 lux per W/m² of global radiation — the receiver's own factor,
19
+ // used the other way, so a flow that only has lux lands on the same number.
20
+ const LUX_PER_WM2 = 120;
21
+
22
+ const num = (v) => {
23
+ if (v === null || v === undefined || v === "") return null;
24
+ const n = Number(v);
25
+ return Number.isFinite(n) ? n : null;
26
+ };
27
+ const fix = (n, digits) => (n === null ? null : Number(n.toFixed(digits)));
28
+
29
+ /** Ecowitt's `dateutc`: "YYYY-MM-DD HH:MM:SS", UTC. */
30
+ function dateUtc(when) {
31
+ const d = when instanceof Date ? when : when ? new Date(when) : new Date();
32
+ if (Number.isNaN(d.getTime())) return dateUtc(new Date());
33
+ return d.toISOString().slice(0, 19).replace("T", " ");
34
+ }
35
+
36
+ /** A stable 32-hex PASSKEY from whatever names the device in the flow. */
37
+ function passkeyFor(deviceId) {
38
+ return crypto.createHash("md5").update(String(deviceId || "node-red"), "utf8").digest("hex").toUpperCase();
39
+ }
40
+
41
+ /**
42
+ * @param reading metric: temperature (°C), humidity (%), pressure (hPa, station
43
+ * level), wind_speed and wind_gusts (m/s), wind_direction (°), rain_total_mm
44
+ * (a counter that only grows — the receiver turns it into increments),
45
+ * solar_wm2 (W/m²) or light_lux, time (ISO or Date; now when absent)
46
+ * @returns the form fields, as strings, in the order a console sends them
47
+ */
48
+ function toEcowitt(reading, { deviceId, stationType = "node-red-contrib-nuasense" } = {}) {
49
+ const r = reading && typeof reading === "object" ? reading : {};
50
+ const fields = {
51
+ PASSKEY: passkeyFor(deviceId),
52
+ stationtype: stationType,
53
+ dateutc: dateUtc(r.time),
54
+ };
55
+ const t = num(r.temperature);
56
+ if (t !== null) fields.tempf = String(fix(t * 9 / 5 + 32, 2));
57
+ const h = num(r.humidity);
58
+ if (h !== null) fields.humidity = String(Math.round(h));
59
+ const p = num(r.pressure);
60
+ if (p !== null) fields.baromabsin = String(fix(p * INHG_PER_HPA, 3));
61
+ const ws = num(r.wind_speed);
62
+ if (ws !== null) fields.windspeedmph = String(fix(ws * MPH_PER_MS, 2));
63
+ const wg = num(r.wind_gusts);
64
+ if (wg !== null) fields.windgustmph = String(fix(wg * MPH_PER_MS, 2));
65
+ const wd = num(r.wind_direction);
66
+ if (wd !== null) fields.winddir = String(Math.round(((wd % 360) + 360) % 360));
67
+ const rain = num(r.rain_total_mm);
68
+ if (rain !== null) fields.yearlyrainin = String(fix(rain * IN_PER_MM, 3));
69
+ const sol = num(r.solar_wm2);
70
+ const lux = num(r.light_lux);
71
+ if (sol !== null) fields.solarradiation = String(fix(sol, 2));
72
+ else if (lux !== null) fields.solarradiation = String(fix(lux / LUX_PER_WM2, 2));
73
+ const uv = num(r.uv);
74
+ if (uv !== null) fields.uv = String(Math.round(uv));
75
+ return fields;
76
+ }
77
+
78
+ function encodeForm(fields) {
79
+ return Object.entries(fields)
80
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
81
+ .join("&");
82
+ }
83
+
84
+ /** The upload URL for a token as it is shown: kw7n-4hpd-x2vt (hyphens are fine on the wire). */
85
+ function uploadUrl(server, token) {
86
+ const host = String(server || "ingest.nuasense.com").trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
87
+ const tok = String(token || "").trim().toLowerCase();
88
+ if (!/^[a-z0-9-]{12,29}$/.test(tok)) throw new Error("the station path token looks wrong — copy it from the console");
89
+ return `http://${host}/station/${tok}`;
90
+ }
91
+
92
+ module.exports = { toEcowitt, encodeForm, uploadUrl, passkeyFor, dateUtc, LUX_PER_WM2 };
package/main.js ADDED
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+
3
+ /*
4
+ * NuaSense for ioBroker.
5
+ *
6
+ * Two jobs, each optional:
7
+ *
8
+ * READ With an API key, poll NuaSense for the account's external stations
9
+ * and write what it computes into states: tonight's low and whether
10
+ * it is a frost night, the next calm dry window for spraying, the
11
+ * latest reading against the model, and the hourly agronomy (ET0,
12
+ * leaf wetness, spray quality, VPD, delta T).
13
+ *
14
+ * SEND With a station token, forward a weather station ioBroker already
15
+ * has (any adapter: Ecowitt, Sainlogic, Bresser, rtl_433 via MQTT …)
16
+ * to NuaSense once a minute, exactly as an Ecowitt console would.
17
+ * The user maps the states that hold temperature, humidity, pressure,
18
+ * wind, rain and light.
19
+ */
20
+
21
+ const utils = require("@iobroker/adapter-core");
22
+ const { toEcowitt, encodeForm, uploadUrl } = require("./lib/ecowitt");
23
+
24
+ const FORWARD_EVERY_MS = 60_000;
25
+ const MIN_POLL_MINUTES = 5;
26
+
27
+ class Nuasense extends utils.Adapter {
28
+ /** @param {Partial<utils.AdapterOptions>} [options] */
29
+ constructor(options) {
30
+ super({ ...options, name: "nuasense" });
31
+ this.on("ready", this.onReady.bind(this));
32
+ this.on("unload", this.onUnload.bind(this));
33
+ this.pollTimer = null;
34
+ this.forwardTimer = null;
35
+ this.polling = false;
36
+ }
37
+
38
+ async onReady() {
39
+ await this.setStateAsync("info.connection", { val: false, ack: true });
40
+ const cfg = this.config;
41
+
42
+ const canRead = Boolean(cfg.apiKey);
43
+ const canSend = Boolean(cfg.forwardEnabled && cfg.forwardToken);
44
+ if (!canRead && !canSend) {
45
+ this.log.warn("Nothing configured: add an API key to read the station intelligence, or a station token to forward a station");
46
+ return;
47
+ }
48
+
49
+ if (canRead) {
50
+ await this.poll();
51
+ const minutes = Math.max(MIN_POLL_MINUTES, Number(cfg.pollMinutes) || 15);
52
+ this.pollTimer = this.setInterval(() => this.poll(), minutes * 60_000);
53
+ this.log.info(`reading NuaSense every ${minutes} min`);
54
+ }
55
+
56
+ if (canSend) {
57
+ try {
58
+ this.forwardUrl = uploadUrl(cfg.forwardServer, cfg.forwardToken);
59
+ } catch (e) {
60
+ this.log.error(`forwarding is on but the station token looks wrong: ${e.message}`);
61
+ return;
62
+ }
63
+ await this.forward();
64
+ this.forwardTimer = this.setInterval(() => this.forward(), FORWARD_EVERY_MS);
65
+ this.log.info(`forwarding the mapped states to ${this.forwardUrl.replace(/\/station\/.*/, "/station/…")} every minute`);
66
+ }
67
+ }
68
+
69
+ // --- read ------------------------------------------------------------------
70
+
71
+ async api(path) {
72
+ const base = String(this.config.baseUrl || "https://api.nuasense.com").replace(/\/+$/, "");
73
+ const res = await fetch(base + path, {
74
+ headers: { Authorization: `Bearer ${this.config.apiKey}`, Accept: "application/json" },
75
+ signal: AbortSignal.timeout(30_000),
76
+ });
77
+ const text = await res.text();
78
+ let body;
79
+ try {
80
+ body = JSON.parse(text);
81
+ } catch {
82
+ body = { raw: text };
83
+ }
84
+ if (!res.ok) {
85
+ const err = new Error(body.error || body.message || `HTTP ${res.status}`);
86
+ err.status = res.status;
87
+ throw err;
88
+ }
89
+ return body;
90
+ }
91
+
92
+ async poll() {
93
+ if (this.polling) return;
94
+ this.polling = true;
95
+ try {
96
+ const list = await this.api("/api/nuasense/v1/ingest/stations");
97
+ await this.setStateAsync("info.connection", { val: true, ack: true });
98
+ const wanted = String(this.config.stationId || "").trim();
99
+ const stations = (list.stations || []).filter((s) => !wanted || s.station_id === wanted);
100
+ if (stations.length === 0) {
101
+ this.log.warn(wanted ? `station "${wanted}" is not on this account` : "this account has no external stations yet");
102
+ }
103
+ for (const station of stations) {
104
+ await this.updateStation(station);
105
+ }
106
+ } catch (e) {
107
+ await this.setStateAsync("info.connection", { val: false, ack: true });
108
+ this.log.warn(`NuaSense not read: ${e.message}`);
109
+ } finally {
110
+ this.polling = false;
111
+ }
112
+ }
113
+
114
+ async updateStation(station) {
115
+ const id = `stations.${sanitize(station.station_id)}`;
116
+ await this.extendObjectAsync(id, {
117
+ type: "channel",
118
+ common: { name: station.name || station.station_id },
119
+ native: { station_id: station.station_id },
120
+ });
121
+
122
+ const push = station.push || {};
123
+ await this.put(`${id}.lastUpload`, push.last_push_at || null, { name: "Last upload from the station", type: "string", role: "date" });
124
+ await this.put(`${id}.device`, push.device || null, { name: "Device that uploads", type: "string", role: "text" });
125
+
126
+ let look = null;
127
+ try {
128
+ look = await this.api(`/api/nuasense/v1/ingest/first-look?station_id=${encodeURIComponent(station.station_id)}`);
129
+ } catch (e) {
130
+ if (e.status !== 409) this.log.debug(`first look for ${station.station_id}: ${e.message}`);
131
+ }
132
+ const t = (look && look.tonight) || {};
133
+ const s = (look && look.spray) || {};
134
+ const c = (look && look.station_check) || {};
135
+ await this.put(`${id}.tonight.low`, numOrNull(t.low_c), { name: "Tonight's low", type: "number", role: "value.temperature.min", unit: "°C" });
136
+ await this.put(`${id}.tonight.at`, t.at || null, { name: "Expected at (local)", type: "string", role: "text" });
137
+ await this.put(`${id}.tonight.frost`, t.frost || null, { name: "Frost tonight: none, watch or frost", type: "string", role: "text" });
138
+ await this.put(`${id}.tonight.grassLow`, numOrNull(t.grass_low_c), { name: "Estimated grass-level low", type: "number", role: "value.temperature", unit: "°C" });
139
+ await this.put(`${id}.spray.window`, s.window ? `${s.window.from}–${s.window.to}` : (s.date ? "none" : null), { name: "Next spray window", type: "string", role: "text" });
140
+ await this.put(`${id}.spray.date`, s.date || null, { name: "Day of the spray window", type: "string", role: "text" });
141
+ await this.put(`${id}.spray.windMax`, numOrNull(s.wind_max_ms), { name: "Highest wind in the window", type: "number", role: "value.speed", unit: "m/s" });
142
+ await this.put(`${id}.spray.reason`, s.reason || null, { name: "Why there is no window", type: "string", role: "text" });
143
+ await this.put(`${id}.check.verdict`, c.verdict || null, { name: "Station check: plausible, check, off", type: "string", role: "text" });
144
+ await this.put(`${id}.check.reading`, numOrNull(c.reading_c), { name: "Latest reading", type: "number", role: "value.temperature", unit: "°C" });
145
+ await this.put(`${id}.check.model`, numOrNull(c.model_c), { name: "Model for that hour", type: "number", role: "value.temperature", unit: "°C" });
146
+ await this.put(`${id}.check.difference`, numOrNull(c.diff_c), { name: "Reading minus model", type: "number", role: "value", unit: "K" });
147
+
148
+ let row = null;
149
+ try {
150
+ const d = await this.api(`/api/nuasense/v1/ingest/derived?station_id=${encodeURIComponent(station.station_id)}&start=-6h&limit=1`);
151
+ row = (d.data && d.data[0]) || null;
152
+ } catch (e) {
153
+ this.log.debug(`derived values for ${station.station_id}: ${e.message}`);
154
+ }
155
+ const r = row || {};
156
+ await this.put(`${id}.hourly.time`, r.time || null, { name: "Hour of these values", type: "string", role: "date" });
157
+ await this.put(`${id}.hourly.et0`, numOrNull(r.et0_hour), { name: "ET0 in the hour", type: "number", role: "value", unit: "mm" });
158
+ await this.put(`${id}.hourly.leafWetnessHours`, numOrNull(r.lwd_consecutive_hours), { name: "Consecutive wet-leaf hours", type: "number", role: "value", unit: "h" });
159
+ await this.put(`${id}.hourly.sprayQuality`, r.spray_quality_label || null, { name: "Spray quality", type: "string", role: "text" });
160
+ await this.put(`${id}.hourly.sprayIndex`, numOrNull(r.spray_quality_index), { name: "Spray quality index", type: "number", role: "value" });
161
+ await this.put(`${id}.hourly.sprayLimit`, r.spray_limiting_factor || null, { name: "What limits spraying", type: "string", role: "text" });
162
+ await this.put(`${id}.hourly.vpd`, numOrNull(r.vpd), { name: "Vapour pressure deficit", type: "number", role: "value", unit: "kPa" });
163
+ await this.put(`${id}.hourly.deltaT`, numOrNull(r.delta_t), { name: "Delta T", type: "number", role: "value.temperature", unit: "°C" });
164
+ await this.put(`${id}.hourly.temperature`, numOrNull(r.temperature), { name: "Temperature, hourly mean", type: "number", role: "value.temperature", unit: "°C" });
165
+ await this.put(`${id}.hourly.humidity`, numOrNull(r.humidity), { name: "Humidity, hourly mean", type: "number", role: "value.humidity", unit: "%" });
166
+ await this.put(`${id}.hourly.pressure`, numOrNull(r.pressure), { name: "Pressure, hourly mean", type: "number", role: "value.pressure", unit: "hPa" });
167
+ }
168
+
169
+ /** Create the state if needed, then write it acknowledged. */
170
+ async put(id, value, common) {
171
+ await this.setObjectNotExistsAsync(id, {
172
+ type: "state",
173
+ common: { read: true, write: false, ...common },
174
+ native: {},
175
+ });
176
+ await this.setStateAsync(id, { val: value, ack: true });
177
+ }
178
+
179
+ // --- send ------------------------------------------------------------------
180
+
181
+ async readMapped(stateId) {
182
+ if (!stateId) return undefined;
183
+ try {
184
+ const s = await this.getForeignStateAsync(stateId);
185
+ if (!s || s.val === null || s.val === undefined || s.val === "") return undefined;
186
+ const n = Number(s.val);
187
+ return Number.isFinite(n) ? n : undefined;
188
+ } catch (e) {
189
+ this.log.debug(`cannot read ${stateId}: ${e.message}`);
190
+ return undefined;
191
+ }
192
+ }
193
+
194
+ async forward() {
195
+ const cfg = this.config;
196
+ let wind = await this.readMapped(cfg.windSpeedState);
197
+ let gust = await this.readMapped(cfg.windGustState);
198
+ if (cfg.windUnit === "kmh") {
199
+ if (wind !== undefined) wind /= 3.6;
200
+ if (gust !== undefined) gust /= 3.6;
201
+ }
202
+ const reading = {
203
+ temperature: await this.readMapped(cfg.temperatureState),
204
+ humidity: await this.readMapped(cfg.humidityState),
205
+ pressure: await this.readMapped(cfg.pressureState),
206
+ wind_speed: wind,
207
+ wind_gusts: gust,
208
+ wind_direction: await this.readMapped(cfg.windDirectionState),
209
+ rain_total_mm: await this.readMapped(cfg.rainTotalState),
210
+ solar_wm2: await this.readMapped(cfg.solarState),
211
+ light_lux: await this.readMapped(cfg.luxState),
212
+ };
213
+ const fields = toEcowitt(reading, {
214
+ deviceId: cfg.forwardDeviceId || `iobroker-${this.namespace}`,
215
+ stationType: "iobroker.nuasense_V0.1.0",
216
+ });
217
+ if (Object.keys(fields).length <= 3) {
218
+ // PASSKEY, stationtype, dateutc and nothing else: no mapped state had a value.
219
+ await this.setStateAsync("forward.lastStatus", { val: "no values to send (check the mapped states)", ack: true });
220
+ return;
221
+ }
222
+ try {
223
+ const res = await fetch(this.forwardUrl, {
224
+ method: "POST",
225
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
226
+ body: encodeForm(fields),
227
+ signal: AbortSignal.timeout(10_000),
228
+ });
229
+ const text = (await res.text()).slice(0, 80);
230
+ await this.setStateAsync("forward.lastSentAt", { val: new Date().toISOString(), ack: true });
231
+ await this.setStateAsync("forward.lastStatus", { val: `${res.status} ${text}`.trim(), ack: true });
232
+ if (res.status === 404) this.log.warn("NuaSense does not know this station token (revoked, or mistyped)");
233
+ else if (res.status === 409) this.log.warn("the station token is bound to another device; issue a new path in the console");
234
+ } catch (e) {
235
+ await this.setStateAsync("forward.lastStatus", { val: `failed: ${e.message}`, ack: true });
236
+ this.log.debug(`upload failed: ${e.message}`);
237
+ }
238
+ }
239
+
240
+ onUnload(callback) {
241
+ try {
242
+ if (this.pollTimer) this.clearInterval(this.pollTimer);
243
+ if (this.forwardTimer) this.clearInterval(this.forwardTimer);
244
+ } finally {
245
+ callback();
246
+ }
247
+ }
248
+ }
249
+
250
+ function sanitize(id) {
251
+ return String(id).replace(/[^A-Za-z0-9_-]/g, "_");
252
+ }
253
+
254
+ function numOrNull(v) {
255
+ const n = Number(v);
256
+ return v === null || v === undefined || !Number.isFinite(n) ? null : n;
257
+ }
258
+
259
+ if (require.main !== module) {
260
+ // Export the constructor in compact mode
261
+ /** @param {Partial<utils.AdapterOptions>} [options] */
262
+ module.exports = (options) => new Nuasense(options);
263
+ } else {
264
+ // otherwise start the instance directly
265
+ new Nuasense();
266
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "iobroker.nuasense",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "NuaSense for ioBroker: what your weather station means for your crop (tonight's low, frost, spray window, ET0, leaf wetness), and optionally forward a station from ioBroker to NuaSense",
5
+ "author": {
6
+ "name": "NuaSense",
7
+ "email": "manuel@nuasense.com"
8
+ },
9
+ "homepage": "https://github.com/nuasense/ioBroker.nuasense",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "ioBroker",
13
+ "nuasense",
14
+ "weather",
15
+ "weather-station",
16
+ "agriculture",
17
+ "frost",
18
+ "spray",
19
+ "evapotranspiration",
20
+ "Smart Home",
21
+ "home automation"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/nuasense/ioBroker.nuasense.git"
26
+ },
27
+ "engines": {
28
+ "node": ">= 18"
29
+ },
30
+ "dependencies": {
31
+ "@iobroker/adapter-core": "^3.2.3"
32
+ },
33
+ "devDependencies": {
34
+ "@iobroker/testing": "^5.0.4",
35
+ "chai": "^4.5.0",
36
+ "mocha": "^10.8.2"
37
+ },
38
+ "main": "main.js",
39
+ "files": [
40
+ "admin/",
41
+ "lib/",
42
+ "io-package.json",
43
+ "LICENSE",
44
+ "main.js"
45
+ ],
46
+ "scripts": {
47
+ "test:js": "mocha --config test/mocharc.custom.json \"{!(node_modules|test)/**/*.test.js,*.test.js,test/**/test!(PackageFiles|Startup).js}\"",
48
+ "test:package": "mocha test/package --exit",
49
+ "test:unit": "mocha test/unit --exit",
50
+ "test": "npm run test:js && npm run test:package",
51
+ "release": "echo release via GitHub"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/nuasense/ioBroker.nuasense/issues"
55
+ },
56
+ "readmeFilename": "README.md"
57
+ }