iobroker.faikout 0.0.5
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 +21 -0
- package/README.md +129 -0
- package/admin/faikout.png +0 -0
- package/admin/i18n/de/translations.json +25 -0
- package/admin/i18n/en/translations.json +25 -0
- package/admin/i18n/es/translations.json +25 -0
- package/admin/i18n/fr/translations.json +25 -0
- package/admin/i18n/it/translations.json +25 -0
- package/admin/i18n/nl/translations.json +25 -0
- package/admin/i18n/pl/translations.json +25 -0
- package/admin/i18n/pt/translations.json +25 -0
- package/admin/i18n/ru/translations.json +25 -0
- package/admin/i18n/uk/translations.json +25 -0
- package/admin/i18n/zh-cn/translations.json +25 -0
- package/admin/jsonConfig.json +187 -0
- package/io-package.json +185 -0
- package/lib/broker.js +129 -0
- package/lib/fields.js +266 -0
- package/lib/verbrauch.js +214 -0
- package/main.js +451 -0
- package/package.json +56 -0
- package/widgets/faikout/css/faikout.css +233 -0
- package/widgets/faikout/js/faikout.js +963 -0
- package/widgets/faikout.html +240 -0
package/lib/verbrauch.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Verbrauchsrechnung aus den Lebensdauer-Zaehlern der Anlage.
|
|
5
|
+
*
|
|
6
|
+
* Die Module liefern nur monoton steigende Gesamtzaehler in Wh (energy, energyheat,
|
|
7
|
+
* energycool). Verbrauch je Stunde und Tag entsteht erst durch Differenzbildung - und dabei
|
|
8
|
+
* gibt es vier Faelle, die man nicht naiv behandeln darf:
|
|
9
|
+
*
|
|
10
|
+
* 1. Der Zaehler springt zurueck (Modul-Neustart, Firmware-Reset). Eine negative Differenz
|
|
11
|
+
* darf NICHT als riesiger Verbrauch gebucht werden; stattdessen neu basieren.
|
|
12
|
+
* 2. Der Adapter war Stunden offline. Die aufgelaufene Differenz gehoert nicht komplett in
|
|
13
|
+
* die letzte Stunde - die uebersprungenen Stunden werden als Luecke (null) vermerkt.
|
|
14
|
+
* 3. Der Stundenwechsel passiert auch ohne eintreffende Nachricht. Deshalb muss der Aufrufer
|
|
15
|
+
* zusaetzlich zyklisch anstossen (siehe pruefen()).
|
|
16
|
+
* 4. Nach einem Adapter-Neustart muessen die Basiswerte noch da sein, sonst faengt jede
|
|
17
|
+
* Zaehlung wieder bei null an. Der Zustand gehoert deshalb persistiert.
|
|
18
|
+
*
|
|
19
|
+
* Dieses Modul ist bewusst frei von ioBroker-Abhaengigkeiten: rein Zustand rein, Zustand raus.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Wie viele abgeschlossene Stunden der Ringpuffer behaelt. */
|
|
23
|
+
const RING_STUNDEN = 48;
|
|
24
|
+
/* Zwei Monate Tageswerte reichen fuer jede Monatsansicht, auch am Monatsersten. */
|
|
25
|
+
const RING_TAGE = 62;
|
|
26
|
+
/* Zwei Jahre Monatswerte - so laesst sich das laufende Jahr mit dem Vorjahr vergleichen. */
|
|
27
|
+
const RING_MONATE = 24;
|
|
28
|
+
|
|
29
|
+
/** Wh -> kWh, auf 3 Stellen. */
|
|
30
|
+
function kwh(wh) {
|
|
31
|
+
return Math.round(wh) / 1000;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Stundenschluessel wie 2026-08-17T14 (lokale Zeit, denn Tageswechsel ist lokal gemeint). */
|
|
35
|
+
function stundeVon(d) {
|
|
36
|
+
const p = n => String(n).padStart(2, '0');
|
|
37
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function monatVon(d) {
|
|
41
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tagVon(d) {
|
|
45
|
+
return stundeVon(d).slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Wie viele volle Stunden liegen zwischen zwei Stundenschluesseln? */
|
|
49
|
+
function stundenDazwischen(a, b) {
|
|
50
|
+
const zeit = s => new Date(
|
|
51
|
+
Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)), Number(s.slice(11, 13))
|
|
52
|
+
).getTime();
|
|
53
|
+
return Math.round((zeit(b) - zeit(a)) / 3600000);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Leerer Anfangszustand eines Zaehlers. */
|
|
57
|
+
function neuerStand() {
|
|
58
|
+
return {
|
|
59
|
+
letzterWert: null, // zuletzt gesehener Gesamtzaehler in Wh
|
|
60
|
+
basisStunde: null, // Zaehlerstand zu Beginn der laufenden Stunde
|
|
61
|
+
basisTag: null, // Zaehlerstand zu Beginn des laufenden Tages
|
|
62
|
+
stunde: null, // Schluessel der laufenden Stunde
|
|
63
|
+
tag: null, // Schluessel des laufenden Tages
|
|
64
|
+
letzteStunde: null, // kWh der zuletzt abgeschlossenen Stunde
|
|
65
|
+
gestern: null, // kWh des Vortages
|
|
66
|
+
monat: null, // Schluessel des laufenden Monats
|
|
67
|
+
basisMonat: null, // Zaehlerstand zu Monatsbeginn
|
|
68
|
+
letzterMonat: null, // kWh des Vormonats
|
|
69
|
+
ring: [], // [{ stunde, kwh }] - kwh null = Luecke
|
|
70
|
+
ringTage: [], // [{ tag, kwh }] - Grundlage der Monatsansicht
|
|
71
|
+
ringMonate: [], // [{ monat, kwh }] - Grundlage der Jahresansicht
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Verarbeitet einen neuen Zaehlerstand.
|
|
77
|
+
*
|
|
78
|
+
* @param {object|null} stand bisheriger Zustand (aus der Persistenz) oder null
|
|
79
|
+
* @param {number} wert aktueller Gesamtzaehler in Wh
|
|
80
|
+
* @param {Date} [jetzt] Zeitpunkt (fuer Tests setzbar)
|
|
81
|
+
* @returns {{stand: object, werte: object, hinweise: string[]}}
|
|
82
|
+
*/
|
|
83
|
+
function verarbeiten(stand, wert, jetzt = new Date()) {
|
|
84
|
+
const hinweise = [];
|
|
85
|
+
// Ueber neuerStand() zusammenfuehren, nicht nur uebernehmen: ein Zustand aus einer aelteren
|
|
86
|
+
// Fassung kennt spaeter ergaenzte Felder nicht, und ein fehlender Ring liess das Update von
|
|
87
|
+
// 0.0.1 auf 0.0.2 bei jedem Zaehlerwert mit "reading 'push'" auflaufen.
|
|
88
|
+
const s = stand
|
|
89
|
+
? {
|
|
90
|
+
...neuerStand(),
|
|
91
|
+
...stand,
|
|
92
|
+
ring: (stand.ring || []).slice(),
|
|
93
|
+
ringTage: (stand.ringTage || []).slice(),
|
|
94
|
+
ringMonate: (stand.ringMonate || []).slice(),
|
|
95
|
+
}
|
|
96
|
+
: neuerStand();
|
|
97
|
+
const stunde = stundeVon(jetzt);
|
|
98
|
+
const tag = tagVon(jetzt);
|
|
99
|
+
const monat = monatVon(jetzt);
|
|
100
|
+
|
|
101
|
+
if (typeof wert !== 'number' || !isFinite(wert)) {
|
|
102
|
+
return { stand: s, werte: ausgabe(s, s.letzterWert), hinweise };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Erster Wert ueberhaupt -------------------------------------------------
|
|
106
|
+
if (s.letzterWert === null || s.basisStunde === null || s.basisTag === null) {
|
|
107
|
+
s.letzterWert = wert;
|
|
108
|
+
s.basisStunde = wert;
|
|
109
|
+
s.basisTag = wert;
|
|
110
|
+
s.basisMonat = wert;
|
|
111
|
+
s.stunde = stunde;
|
|
112
|
+
s.tag = tag;
|
|
113
|
+
s.monat = monat;
|
|
114
|
+
return { stand: s, werte: ausgabe(s, wert), hinweise };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Zaehler zurueckgesprungen ----------------------------------------------
|
|
118
|
+
// Kommt beim Neustart des Moduls vor. Der Verbrauch dazwischen ist nicht mehr
|
|
119
|
+
// ermittelbar; alles neu basieren, damit kein Fantasiewert entsteht.
|
|
120
|
+
if (wert < s.letzterWert) {
|
|
121
|
+
hinweise.push(`Zählerstand zurückgesprungen (${s.letzterWert} -> ${wert} Wh) - Zählung neu begonnen`);
|
|
122
|
+
s.letzterWert = wert;
|
|
123
|
+
s.basisStunde = wert;
|
|
124
|
+
s.basisTag = wert;
|
|
125
|
+
s.basisMonat = wert;
|
|
126
|
+
s.stunde = stunde;
|
|
127
|
+
s.tag = tag;
|
|
128
|
+
s.monat = monat;
|
|
129
|
+
return { stand: s, werte: ausgabe(s, wert), hinweise };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Stundenwechsel ---------------------------------------------------------
|
|
133
|
+
if (s.stunde !== stunde) {
|
|
134
|
+
const luecke = stundenDazwischen(s.stunde, stunde) - 1; // uebersprungene volle Stunden
|
|
135
|
+
s.letzteStunde = kwh(s.letzterWert - s.basisStunde);
|
|
136
|
+
s.ring.push({ stunde: s.stunde, kwh: s.letzteStunde });
|
|
137
|
+
if (luecke > 0) {
|
|
138
|
+
// Der Adapter war offline. Was in dieser Zeit verbraucht wurde, laesst sich nicht
|
|
139
|
+
// auf die einzelnen Stunden aufteilen - als Luecke kennzeichnen statt zu erfinden.
|
|
140
|
+
hinweise.push(`${luecke} Stunde(n) ohne Daten - als Lücke vermerkt`);
|
|
141
|
+
for (let i = 1; i <= luecke; i++) s.ring.push({ stunde: null, kwh: null });
|
|
142
|
+
}
|
|
143
|
+
if (s.ring.length > RING_STUNDEN) s.ring = s.ring.slice(-RING_STUNDEN);
|
|
144
|
+
s.basisStunde = s.letzterWert;
|
|
145
|
+
s.stunde = stunde;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Tageswechsel -----------------------------------------------------------
|
|
149
|
+
if (s.tag !== tag) {
|
|
150
|
+
s.gestern = kwh(s.letzterWert - s.basisTag);
|
|
151
|
+
s.ringTage.push({ tag: s.tag, kwh: s.gestern });
|
|
152
|
+
if (s.ringTage.length > RING_TAGE) s.ringTage = s.ringTage.slice(-RING_TAGE);
|
|
153
|
+
s.basisTag = s.letzterWert;
|
|
154
|
+
s.tag = tag;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Monatswechsel ----------------------------------------------------------
|
|
158
|
+
// Bewusst nach dem Tageswechsel: der letzte Tag des alten Monats gehoert noch dorthin.
|
|
159
|
+
if (s.monat !== monat) {
|
|
160
|
+
if (s.monat === null || s.basisMonat === null) {
|
|
161
|
+
// Erster Monat, den dieser Zaehler sieht - er hat keinen Vorgaenger. Ohne diese
|
|
162
|
+
// Ausnahme wurde bei einem Zustand aus 0.0.1 (kannte noch keinen Monat) der volle
|
|
163
|
+
// Zaehlerstand als Vormonatsverbrauch gebucht: 3783 kWh statt der echten Differenz.
|
|
164
|
+
s.basisMonat = s.letzterWert;
|
|
165
|
+
s.monat = monat;
|
|
166
|
+
} else {
|
|
167
|
+
s.letzterMonat = kwh(s.letzterWert - s.basisMonat);
|
|
168
|
+
s.ringMonate.push({ monat: s.monat, kwh: s.letzterMonat });
|
|
169
|
+
if (s.ringMonate.length > RING_MONATE) s.ringMonate = s.ringMonate.slice(-RING_MONATE);
|
|
170
|
+
s.basisMonat = s.letzterWert;
|
|
171
|
+
s.monat = monat;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
s.letzterWert = wert;
|
|
176
|
+
return { stand: s, werte: ausgabe(s, wert), hinweise };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Stossen ohne neuen Zaehlerstand - fuer den Timer auf die volle Stunde. Meldet einen
|
|
181
|
+
* Stunden- bzw. Tageswechsel auch dann, wenn gerade keine Nachricht hereinkam.
|
|
182
|
+
*/
|
|
183
|
+
function pruefen(stand, jetzt = new Date()) {
|
|
184
|
+
if (!stand || stand.letzterWert === null) return { stand, werte: null, hinweise: [] };
|
|
185
|
+
if (stand.stunde === stundeVon(jetzt) && stand.tag === tagVon(jetzt)
|
|
186
|
+
&& stand.monat === monatVon(jetzt)) {
|
|
187
|
+
return { stand, werte: null, hinweise: [] };
|
|
188
|
+
}
|
|
189
|
+
return verarbeiten(stand, stand.letzterWert, jetzt);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ausgabe(s, wert) {
|
|
193
|
+
return {
|
|
194
|
+
gesamt: wert === null ? null : kwh(wert),
|
|
195
|
+
stunde: s.basisStunde === null || wert === null ? null : kwh(wert - s.basisStunde),
|
|
196
|
+
letzteStunde: s.letzteStunde,
|
|
197
|
+
heute: s.basisTag === null || wert === null ? null : kwh(wert - s.basisTag),
|
|
198
|
+
gestern: s.gestern,
|
|
199
|
+
dieserMonat: s.basisMonat === null || wert === null ? null : kwh(wert - s.basisMonat),
|
|
200
|
+
letzterMonat: s.letzterMonat,
|
|
201
|
+
ring: s.ring,
|
|
202
|
+
// Der laufende Tag bzw. Monat steht noch nicht im Ring - fuer die Anzeige gehoert er
|
|
203
|
+
// aber ans Ende, sonst fehlt in der Monatsansicht immer der heutige Balken.
|
|
204
|
+
ringTage: s.ringTage.concat(
|
|
205
|
+
s.tag && wert !== null ? [{ tag: s.tag, kwh: kwh(wert - s.basisTag) }] : []),
|
|
206
|
+
ringMonate: s.ringMonate.concat(
|
|
207
|
+
s.monat && wert !== null ? [{ monat: s.monat, kwh: kwh(wert - s.basisMonat) }] : []),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
verarbeiten, pruefen, neuerStand, stundeVon, tagVon, monatVon, kwh,
|
|
213
|
+
RING_STUNDEN, RING_TAGE, RING_MONATE,
|
|
214
|
+
};
|
package/main.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* ioBroker.faikout
|
|
5
|
+
* Bindet Daikin-Klimaanlagen mit faikout-Modul rein ueber MQTT an - mit eigenem Broker.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const utils = require('@iobroker/adapter-core');
|
|
9
|
+
const { FaikoutBroker } = require('./lib/broker');
|
|
10
|
+
const felder = require('./lib/fields');
|
|
11
|
+
const verbrauch = require('./lib/verbrauch');
|
|
12
|
+
|
|
13
|
+
/** Topics, die der Adapter auswertet. Alles andere wird still verworfen. */
|
|
14
|
+
const BEKANNTE_ARTEN = new Set(['state', 'info', 'error', 'event']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Die drei Lebensdauer-Zaehler und ihr Zweig im Objektbaum. Die Gesamtzaehler bleiben
|
|
18
|
+
* erhalten (status.*), zusaetzlich entstehen daraus die Verbrauchswerte (verbrauch.*).
|
|
19
|
+
*/
|
|
20
|
+
const ZAEHLER = [
|
|
21
|
+
{ feld: 'energy', zweig: 'gesamt', name: { en: 'Total', de: 'Gesamt' } },
|
|
22
|
+
{ feld: 'energyheat', zweig: 'heizen', name: { en: 'Heating', de: 'Heizen' } },
|
|
23
|
+
{ feld: 'energycool', zweig: 'kuehlen', name: { en: 'Cooling', de: 'Kühlen' } },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Datenpunkte je Zaehlerzweig. */
|
|
27
|
+
const VERBRAUCH_FELDER = [
|
|
28
|
+
{ id: 'stunde', name: { en: 'Current hour', de: 'Laufende Stunde' } },
|
|
29
|
+
{ id: 'letzteStunde', name: { en: 'Last full hour', de: 'Letzte volle Stunde' } },
|
|
30
|
+
{ id: 'heute', name: { en: 'Today', de: 'Heute' } },
|
|
31
|
+
{ id: 'gestern', name: { en: 'Yesterday', de: 'Gestern' } },
|
|
32
|
+
{ id: 'dieserMonat', name: { en: 'This month', de: 'Dieser Monat' } },
|
|
33
|
+
{ id: 'letzterMonat', name: { en: 'Last month', de: 'Letzter Monat' } },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verlaufsreihen als JSON. Sie tragen die Diagramme im VIS-Baustein und werden hier gefuehrt,
|
|
38
|
+
* damit der Adapter ohne Fremdskript auskommt - andernfalls blieben Monats- und Jahresansicht
|
|
39
|
+
* bei jedem leer, der nichts weiter einrichtet.
|
|
40
|
+
*/
|
|
41
|
+
const VERBRAUCH_REIHEN = [
|
|
42
|
+
{ id: 'stundenJson', quelle: 'ring',
|
|
43
|
+
name: { en: 'Last 48 hours (JSON)', de: 'Letzte 48 Stunden (JSON)' } },
|
|
44
|
+
{ id: 'tageJson', quelle: 'ringTage',
|
|
45
|
+
name: { en: 'Last 62 days (JSON)', de: 'Letzte 62 Tage (JSON)' } },
|
|
46
|
+
{ id: 'monateJson', quelle: 'ringMonate',
|
|
47
|
+
name: { en: 'Last 24 months (JSON)', de: 'Letzte 24 Monate (JSON)' } },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
class Faikout extends utils.Adapter {
|
|
51
|
+
constructor(options) {
|
|
52
|
+
super({ ...options, name: 'faikout' });
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Geraete, die sich gemeldet haben.
|
|
56
|
+
* Schluessel ist die ioBroker-taugliche ID, Wert enthaelt den ECHTEN Geraetenamen -
|
|
57
|
+
* der enthaelt bei diesen Modulen ein Leerzeichen ("Wohnzimmer AC"), waehrend die
|
|
58
|
+
* Objekt-ID einen Unterstrich braucht. Kommandos muessen den echten Namen treffen,
|
|
59
|
+
* sonst hoert das Geraet nicht zu.
|
|
60
|
+
* @type {Map<string, {name:string, angelegt:Set<string>}>}
|
|
61
|
+
*/
|
|
62
|
+
this.geraete = new Map();
|
|
63
|
+
|
|
64
|
+
this.broker = null;
|
|
65
|
+
this.stopping = false;
|
|
66
|
+
this.stundenTimer = null;
|
|
67
|
+
|
|
68
|
+
this.on('ready', this.onReady.bind(this));
|
|
69
|
+
this.on('stateChange', this.onStateChange.bind(this));
|
|
70
|
+
this.on('unload', this.onUnload.bind(this));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------- Start
|
|
74
|
+
|
|
75
|
+
async onReady() {
|
|
76
|
+
await this.setState('info.connection', { val: false, ack: true });
|
|
77
|
+
|
|
78
|
+
const port = Number(this.config.port) || 1888;
|
|
79
|
+
this.broker = new FaikoutBroker({
|
|
80
|
+
port,
|
|
81
|
+
user: this.config.user,
|
|
82
|
+
pass: this.config.pass,
|
|
83
|
+
log: this.log,
|
|
84
|
+
onMessage: (topic, payload) => this.onMqtt(topic, payload),
|
|
85
|
+
onClient: () => this.verbindungPruefen(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await this.broker.start();
|
|
90
|
+
} catch (e) {
|
|
91
|
+
this.log.error(`MQTT-Broker konnte nicht starten: ${e.message}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.log.info(
|
|
96
|
+
`Bereit. Die faikout-Module müssen als MQTT-Host <IP des ioBroker>:${port} eingetragen sein.`,
|
|
97
|
+
);
|
|
98
|
+
this.subscribeStates('*.control.*');
|
|
99
|
+
this.stundenTaktStarten();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Weckt die Verbrauchsrechnung zur vollen Stunde.
|
|
104
|
+
*
|
|
105
|
+
* Noetig, weil die Module nur zyklisch senden (Einstellung "reporting", ab Werk 60 s):
|
|
106
|
+
* ohne eigenen Takt wuerde ein Stundenwechsel erst mit der naechsten Nachricht bemerkt
|
|
107
|
+
* und die Stunde bekaeme Verbrauch aus der falschen Periode zugerechnet.
|
|
108
|
+
*/
|
|
109
|
+
stundenTaktStarten() {
|
|
110
|
+
const jetzt = new Date();
|
|
111
|
+
const naechste = new Date(jetzt);
|
|
112
|
+
naechste.setHours(jetzt.getHours() + 1, 0, 5, 0); // 5 s nach der vollen Stunde
|
|
113
|
+
this.stundenTimer = this.setTimeout(() => {
|
|
114
|
+
this.stundenwechsel().catch(e => this.log.warn(`Stundenwechsel: ${e.message}`));
|
|
115
|
+
this.stundenTaktStarten();
|
|
116
|
+
}, naechste - jetzt);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async stundenwechsel() {
|
|
120
|
+
for (const [id, g] of this.geraete) {
|
|
121
|
+
for (const z of ZAEHLER) {
|
|
122
|
+
const ergebnis = verbrauch.pruefen(g.zaehler[z.feld], new Date());
|
|
123
|
+
if (!ergebnis.werte) continue;
|
|
124
|
+
g.zaehler[z.feld] = ergebnis.stand;
|
|
125
|
+
ergebnis.hinweise.forEach(h => this.log.info(`${g.name} ${z.zweig}: ${h}`));
|
|
126
|
+
await this.verbrauchSchreiben(id, g, z, ergebnis.werte);
|
|
127
|
+
}
|
|
128
|
+
await this.zaehlerstandSichern(id, g);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
verbindungPruefen() {
|
|
133
|
+
// Verbunden heisst hier: mindestens ein faikout-Modul haengt am Broker.
|
|
134
|
+
this.setState('info.connection', { val: this.geraete.size > 0, ack: true });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------- MQTT hinein
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Zerlegt ein Topic in Art, Geraet und Unterpfad.
|
|
141
|
+
* `state/Wohnzimmer AC` -> {art:'state', geraet:'Wohnzimmer AC', unter:''}
|
|
142
|
+
* `info/Wohnzimmer AC/upgrade` -> {art:'info', geraet:'Wohnzimmer AC', unter:'upgrade'}
|
|
143
|
+
*/
|
|
144
|
+
topicZerlegen(topic) {
|
|
145
|
+
const teile = topic.split('/');
|
|
146
|
+
if (teile.length < 2) return null;
|
|
147
|
+
const [art, geraet, ...rest] = teile;
|
|
148
|
+
if (!BEKANNTE_ARTEN.has(art)) return null;
|
|
149
|
+
return { art, geraet, unter: rest.join('/') };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onMqtt(topic, payload) {
|
|
153
|
+
const text = payload ? payload.toString('utf8') : '';
|
|
154
|
+
if (!text) return;
|
|
155
|
+
|
|
156
|
+
// Die Module veroeffentlichen ihre Faehigkeiten selbst als Home-Assistant-Discovery.
|
|
157
|
+
// Daraus kommen Bereich und Schrittweite der Solltemperatur - die unterscheiden sich
|
|
158
|
+
// je Anlage (eine nimmt halbe Grad, die andere nur ganze, Einstellung "ha1c").
|
|
159
|
+
if (topic.startsWith('homeassistant/climate/') && topic.endsWith('/config')) {
|
|
160
|
+
this.klimaConfig(text).catch(e => this.log.debug(`HA-Config: ${e.message}`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const t = this.topicZerlegen(topic);
|
|
165
|
+
if (!t) return; // command/…, Faikout/… - nicht ausgewertet
|
|
166
|
+
|
|
167
|
+
if (t.art === 'error') {
|
|
168
|
+
this.log.warn(`${t.geraet}: ${text}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let daten;
|
|
173
|
+
try {
|
|
174
|
+
daten = JSON.parse(text);
|
|
175
|
+
} catch {
|
|
176
|
+
this.log.debug(`Kein JSON auf "${topic}": ${text.slice(0, 80)}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!daten || typeof daten !== 'object') return;
|
|
180
|
+
|
|
181
|
+
// state/<Name> ist die Hauptmeldung im Klartext. state/<Name>/status liefert dasselbe
|
|
182
|
+
// im nativen Format (mode "C" statt "cool") - das waere doppelt und wird uebergangen.
|
|
183
|
+
if (t.art === 'state' && !t.unter) {
|
|
184
|
+
this.werteUebernehmen(t.geraet, daten).catch(e => this.log.warn(`${t.geraet}: ${e.message}`));
|
|
185
|
+
} else if (t.art === 'info' && t.unter === 'upgrade') {
|
|
186
|
+
this.werteUebernehmen(t.geraet, { version: daten.version, build: daten.build }).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Wertet die HA-Discovery einer Klimaeinheit aus und uebernimmt Bereich und Schrittweite
|
|
192
|
+
* der Solltemperatur ins Objekt `control.target`.
|
|
193
|
+
*/
|
|
194
|
+
async klimaConfig(text) {
|
|
195
|
+
const c = JSON.parse(text);
|
|
196
|
+
// Der Geraetename steckt im Verfuegbarkeits-Topic: "state/Wohnzimmer AC".
|
|
197
|
+
const geraetName = String(c.avty_t || '').replace(/^state\//, '');
|
|
198
|
+
if (!geraetName) return;
|
|
199
|
+
|
|
200
|
+
const id = this.geraetId(geraetName);
|
|
201
|
+
const grenzen = {
|
|
202
|
+
min: Number(c.min_temp),
|
|
203
|
+
max: Number(c.max_temp),
|
|
204
|
+
step: Number(c.temp_step),
|
|
205
|
+
};
|
|
206
|
+
if (!isFinite(grenzen.min) || !isFinite(grenzen.max) || !isFinite(grenzen.step)) return;
|
|
207
|
+
|
|
208
|
+
const g = this.geraete.get(id);
|
|
209
|
+
if (g && g.grenzen && g.grenzen.step === grenzen.step &&
|
|
210
|
+
g.grenzen.min === grenzen.min && g.grenzen.max === grenzen.max) {
|
|
211
|
+
return; // unveraendert - die Discovery wird regelmaessig wiederholt
|
|
212
|
+
}
|
|
213
|
+
if (g) g.grenzen = grenzen;
|
|
214
|
+
|
|
215
|
+
const stateId = `${id}.control.target`;
|
|
216
|
+
const vorhanden = await this.getObjectAsync(stateId);
|
|
217
|
+
if (vorhanden) {
|
|
218
|
+
await this.extendObject(stateId, { common: grenzen });
|
|
219
|
+
this.log.info(`${geraetName}: Sollwertbereich ${grenzen.min}–${grenzen.max} °C in ${grenzen.step}er-Schritten übernommen.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Legt fehlende Objekte an und schreibt die Werte. */
|
|
224
|
+
async werteUebernehmen(geraetName, daten) {
|
|
225
|
+
const id = this.geraetId(geraetName);
|
|
226
|
+
let g = this.geraete.get(id);
|
|
227
|
+
if (!g) {
|
|
228
|
+
// Frueheren Stand uebernehmen, falls es das Geraet aus einem vorherigen Lauf gibt -
|
|
229
|
+
// vor allem den Merker, ob die Luftfeuchte echt ist.
|
|
230
|
+
const alt = await this.getObjectAsync(id);
|
|
231
|
+
g = {
|
|
232
|
+
name: geraetName,
|
|
233
|
+
angelegt: new Set(),
|
|
234
|
+
feuchteEcht: !!(alt && alt.native && alt.native.feuchteEcht),
|
|
235
|
+
// Die Zaehlerstaende muessen den Adapter-Neustart ueberleben, sonst faengt
|
|
236
|
+
// jede Zaehlung wieder bei null an.
|
|
237
|
+
zaehler: (alt && alt.native && alt.native.zaehler) || {},
|
|
238
|
+
grenzen: (alt && alt.native && alt.native.grenzen) || null,
|
|
239
|
+
};
|
|
240
|
+
this.geraete.set(id, g);
|
|
241
|
+
await this.extendObject(id, {
|
|
242
|
+
type: 'device',
|
|
243
|
+
common: { name: geraetName },
|
|
244
|
+
native: { faikoutName: geraetName },
|
|
245
|
+
});
|
|
246
|
+
this.log.info(`Gerät erkannt: "${geraetName}" -> ${this.namespace}.${id}`);
|
|
247
|
+
this.verbindungPruefen();
|
|
248
|
+
}
|
|
249
|
+
// Der Name kann sich aendern, wenn der Hostname im Modul umgestellt wird.
|
|
250
|
+
g.name = geraetName;
|
|
251
|
+
|
|
252
|
+
for (const [feld, wert] of Object.entries(daten)) {
|
|
253
|
+
if (wert === null || wert === undefined) continue;
|
|
254
|
+
if (felder.UEBERSPRINGEN.has(feld)) continue;
|
|
255
|
+
if (typeof wert === 'object') continue; // verschachteltes (ble) - eigener Zweig, spaeter
|
|
256
|
+
|
|
257
|
+
// Anlagen ohne Feuchtesensor melden dauerhaft 50 - das ist ein Platzhalter, kein
|
|
258
|
+
// Messwert. Erst wenn einmal etwas anderes kam, gibt es wirklich einen Sensor.
|
|
259
|
+
if (feld === 'hum') {
|
|
260
|
+
const urteil = felder.feuchteBewerten(wert, g.feuchteEcht);
|
|
261
|
+
if (urteil.echtAbJetzt && !g.feuchteEcht) {
|
|
262
|
+
g.feuchteEcht = true;
|
|
263
|
+
await this.extendObject(id, { native: { feuchteEcht: true } });
|
|
264
|
+
this.log.info(`${geraetName}: Luftfeuchte-Sensor erkannt (${wert} %) - Datenpunkt wird ab jetzt geführt.`);
|
|
265
|
+
}
|
|
266
|
+
if (!urteil.nehmen) continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const def = felder.beschreibe(feld, wert);
|
|
270
|
+
const stateId = `${id}.${def.kanal}.${felder.objektId(feld)}`;
|
|
271
|
+
|
|
272
|
+
if (!g.angelegt.has(stateId)) {
|
|
273
|
+
await this.objektAnlegen(id, def, feld, stateId);
|
|
274
|
+
g.angelegt.add(stateId);
|
|
275
|
+
}
|
|
276
|
+
await this.setState(stateId, { val: felder.umrechnen(def, wert), ack: true });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await this.zaehlerVerarbeiten(id, g, daten);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Bildet aus den Lebensdauer-Zaehlern die Stunden- und Tagesverbraeuche. */
|
|
283
|
+
async zaehlerVerarbeiten(id, g, daten) {
|
|
284
|
+
let veraendert = false;
|
|
285
|
+
for (const z of ZAEHLER) {
|
|
286
|
+
const roh = daten[z.feld];
|
|
287
|
+
if (typeof roh !== 'number') continue;
|
|
288
|
+
const ergebnis = verbrauch.verarbeiten(g.zaehler[z.feld] || null, roh, new Date());
|
|
289
|
+
g.zaehler[z.feld] = ergebnis.stand;
|
|
290
|
+
veraendert = true;
|
|
291
|
+
ergebnis.hinweise.forEach(h => this.log.info(`${g.name} ${z.zweig}: ${h}`));
|
|
292
|
+
await this.verbrauchSchreiben(id, g, z, ergebnis.werte);
|
|
293
|
+
}
|
|
294
|
+
if (veraendert) await this.zaehlerstandSichern(id, g);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async verbrauchSchreiben(id, g, z, werte) {
|
|
298
|
+
const basis = `${id}.verbrauch.${z.zweig}`;
|
|
299
|
+
if (!g.angelegt.has(basis)) {
|
|
300
|
+
await this.extendObject(`${id}.verbrauch`, {
|
|
301
|
+
type: 'channel',
|
|
302
|
+
common: { name: { en: 'Consumption', de: 'Verbrauch' } },
|
|
303
|
+
native: {},
|
|
304
|
+
});
|
|
305
|
+
await this.extendObject(basis, { type: 'channel', common: { name: z.name }, native: {} });
|
|
306
|
+
for (const f of VERBRAUCH_FELDER) {
|
|
307
|
+
await this.zaehlerObjekt(`${basis}.${f.id}`, f.name, 'kWh', 'value.energy.consumed');
|
|
308
|
+
}
|
|
309
|
+
for (const r of VERBRAUCH_REIHEN) {
|
|
310
|
+
await this.extendObject(`${basis}.${r.id}`, {
|
|
311
|
+
type: 'state',
|
|
312
|
+
common: { name: r.name, type: 'string', role: 'json', read: true, write: false },
|
|
313
|
+
native: {},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
g.angelegt.add(basis);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const f of VERBRAUCH_FELDER) {
|
|
320
|
+
const wert = werte[f.id];
|
|
321
|
+
if (wert === null || wert === undefined) continue;
|
|
322
|
+
await this.setState(`${basis}.${f.id}`, { val: wert, ack: true });
|
|
323
|
+
}
|
|
324
|
+
for (const r of VERBRAUCH_REIHEN) {
|
|
325
|
+
await this.setState(`${basis}.${r.id}`, {
|
|
326
|
+
val: JSON.stringify(werte[r.quelle] || []), ack: true,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Legt einen Verbrauchs-Datenpunkt an und meldet ihn - wenn gewuenscht - gleich beim
|
|
333
|
+
* History-Adapter zur Aufzeichnung an. Das erspart es, jeden Punkt von Hand im Admin
|
|
334
|
+
* einzuschalten.
|
|
335
|
+
*/
|
|
336
|
+
async zaehlerObjekt(stateId, name, unit, role) {
|
|
337
|
+
const common = { name, type: 'number', role, unit, read: true, write: false };
|
|
338
|
+
const instanz = (this.config.historyInstanz || '').trim();
|
|
339
|
+
if (this.config.historyAnmelden && instanz) {
|
|
340
|
+
common.custom = {
|
|
341
|
+
[instanz]: { enabled: true, changesOnly: false, debounce: 0, retention: 0 },
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
await this.extendObject(stateId, { type: 'state', common, native: {} });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async zaehlerstandSichern(id, g) {
|
|
348
|
+
await this.extendObject(id, { native: { zaehler: g.zaehler } });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async objektAnlegen(geraetId, def, feld, stateId) {
|
|
352
|
+
await this.extendObject(`${geraetId}.${def.kanal}`, {
|
|
353
|
+
type: 'channel',
|
|
354
|
+
common: { name: this.kanalName(def.kanal) },
|
|
355
|
+
native: {},
|
|
356
|
+
});
|
|
357
|
+
const common = {
|
|
358
|
+
name: def.name,
|
|
359
|
+
type: def.type,
|
|
360
|
+
role: def.role,
|
|
361
|
+
read: true,
|
|
362
|
+
write: !!def.w,
|
|
363
|
+
};
|
|
364
|
+
if (def.unit) common.unit = def.unit;
|
|
365
|
+
if (def.states) common.states = def.states;
|
|
366
|
+
if (def.min !== undefined) common.min = def.min;
|
|
367
|
+
if (def.max !== undefined) common.max = def.max;
|
|
368
|
+
if (def.step !== undefined) common.step = def.step;
|
|
369
|
+
|
|
370
|
+
await this.extendObject(stateId, { type: 'state', common, native: { feld } });
|
|
371
|
+
if (def.unbekannt) {
|
|
372
|
+
this.log.info(`Unbekanntes Feld "${feld}" angelegt (${def.type}) - bitte melden, damit es sauber beschrieben wird.`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
kanalName(kanal) {
|
|
377
|
+
return {
|
|
378
|
+
control: { en: 'Control', de: 'Steuerung' },
|
|
379
|
+
status: { en: 'Readings', de: 'Messwerte' },
|
|
380
|
+
info: { en: 'Device info', de: 'Geräteinfo' },
|
|
381
|
+
}[kanal] || kanal;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Objekttaugliche ID aus dem Geraetenamen - Leerzeichen und Sonderzeichen raus. */
|
|
385
|
+
geraetId(name) {
|
|
386
|
+
return name.replace(/[\s.\][*,;'"`<>\\?]+/g, '_');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------- MQTT hinaus
|
|
390
|
+
|
|
391
|
+
async onStateChange(id, state) {
|
|
392
|
+
if (!state || state.ack) return; // nur eigene Befehle, nicht die eigenen Rueckmeldungen
|
|
393
|
+
|
|
394
|
+
const obj = await this.getObjectAsync(id);
|
|
395
|
+
if (!obj || !obj.common || !obj.common.write) return;
|
|
396
|
+
|
|
397
|
+
// faikout.0.<GeraetId>.control.<feld>
|
|
398
|
+
const teile = id.split('.');
|
|
399
|
+
const geraetId = teile[2];
|
|
400
|
+
const feld = (obj.native && obj.native.feld) || teile[teile.length - 1];
|
|
401
|
+
const g = this.geraete.get(geraetId);
|
|
402
|
+
if (!g) {
|
|
403
|
+
this.log.warn(`Befehl für unbekanntes Gerät ${geraetId} verworfen.`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Manche Felder heissen beim Schreiben anders als beim Lesen: Der Sollwert kommt als
|
|
408
|
+
// `target` herein, das Kommandotopic dafuer heisst aber `temp`.
|
|
409
|
+
const def = felder.FELDER[feld];
|
|
410
|
+
const suffix = (def && def.cmd) || feld;
|
|
411
|
+
|
|
412
|
+
// Der ECHTE Geraetename gehoert ins Topic, nicht die bereinigte Objekt-ID.
|
|
413
|
+
const topic = `command/${g.name}/${suffix}`;
|
|
414
|
+
const nutzlast = this.nutzlast(feld, state.val);
|
|
415
|
+
const ok = await this.broker.publish(topic, nutzlast);
|
|
416
|
+
if (ok) {
|
|
417
|
+
this.log.debug(`-> ${topic} = ${nutzlast}`);
|
|
418
|
+
// Das Modul meldet seinen Zustand zyklisch (Einstellung "reporting", ab Werk 60 s)
|
|
419
|
+
// und nicht sofort nach dem Befehl. Der Wert wird deshalb erst mit der naechsten
|
|
420
|
+
// Statusmeldung bestaetigt - hier bewusst kein vorschnelles ack.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Wandelt einen ioBroker-Wert in die Nutzlast, die das Modul erwartet. */
|
|
425
|
+
nutzlast(feld, wert) {
|
|
426
|
+
if (typeof wert === 'boolean') return wert ? 'true' : 'false';
|
|
427
|
+
return String(wert);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------------------------------------------------------------- Ende
|
|
431
|
+
|
|
432
|
+
async onUnload(callback) {
|
|
433
|
+
this.stopping = true;
|
|
434
|
+
try {
|
|
435
|
+
if (this.stundenTimer) this.clearTimeout(this.stundenTimer);
|
|
436
|
+
// Zaehlerstaende sichern, damit nach dem Neustart weitergezaehlt wird.
|
|
437
|
+
for (const [id, g] of this.geraete) await this.zaehlerstandSichern(id, g);
|
|
438
|
+
if (this.broker) await this.broker.stop();
|
|
439
|
+
await this.setState('info.connection', { val: false, ack: true });
|
|
440
|
+
} catch {
|
|
441
|
+
// beim Herunterfahren nicht weiter stoeren
|
|
442
|
+
}
|
|
443
|
+
callback();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (require.main !== module) {
|
|
448
|
+
module.exports = options => new Faikout(options);
|
|
449
|
+
} else {
|
|
450
|
+
new Faikout();
|
|
451
|
+
}
|