Init of Branch, First Version by ChatGPT
This commit is contained in:
commit
9675f26863
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"type": "panel",
|
||||||
|
"items": {
|
||||||
|
"z2mPrefix": { "type": "text", "label": "Z2M Instanz", "newLine": true },
|
||||||
|
"scheduleMode": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Zeitplan-Interaktion",
|
||||||
|
"options": [{"label":"strict","value":"strict"},{"label":"passive","value":"passive"}],
|
||||||
|
"newLine": true
|
||||||
|
},
|
||||||
|
"fallbackMode": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Fallback bei Sensor-Ausfall",
|
||||||
|
"options": [{"label":"setpoint","value":"setpoint"},{"label":"internal","value":"internal"}],
|
||||||
|
"newLine": false
|
||||||
|
},
|
||||||
|
"fallbackSetpoint": { "type": "number", "label": "Fallback-Setpoint (°C)", "newLine": false },
|
||||||
|
"staleMinutes": { "type": "number", "label": "Stale-Minuten Sensor", "newLine": false },
|
||||||
|
"globalHysteresis": { "type": "number", "label": "Hysterese (°C)", "newLine": true },
|
||||||
|
"globalMinIntervalSec": { "type": "number", "label": "Min. Intervall (s)", "newLine": false },
|
||||||
|
|
||||||
|
"rooms": {
|
||||||
|
"type": "array",
|
||||||
|
"label": "Räume",
|
||||||
|
"item": {
|
||||||
|
"type": "panel",
|
||||||
|
"items": {
|
||||||
|
"name": { "type": "text", "label": "Raumname", "newLine": true },
|
||||||
|
"trvBase": { "type": "state", "label": "TRV Base", "newLine": true },
|
||||||
|
"sensorTemp": { "type": "state", "label": "Sensor Temperatur", "newLine": false },
|
||||||
|
"windowContact": { "type": "state", "label": "Fenster-Kontakt", "newLine": false },
|
||||||
|
"openWhen": {
|
||||||
|
"type": "checkbox",
|
||||||
|
"label": "Kontakt offen bei Wert=true?",
|
||||||
|
"newLine": true
|
||||||
|
},
|
||||||
|
"openDelaySec": { "type": "number", "label": "Open-Delay (s)", "newLine": true },
|
||||||
|
"closeDelaySec": { "type": "number", "label": "Close-Delay (s)", "newLine": false },
|
||||||
|
"openSetpoint": { "type": "number", "label": "Open-Setpoint (°C)", "newLine": false },
|
||||||
|
"enableTempForward": {
|
||||||
|
"type": "checkbox",
|
||||||
|
"label": "Externe Temperatur an TRV weitergeben",
|
||||||
|
"newLine": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"name": "heating-helper",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"title": "Heating Helper",
|
||||||
|
"news": {},
|
||||||
|
"license": "MIT",
|
||||||
|
"platform": "Javascript/Node.js",
|
||||||
|
"icon": "admin/heating.png",
|
||||||
|
"enabled": true,
|
||||||
|
"mode": "daemon",
|
||||||
|
"loglevel": "info",
|
||||||
|
"type": "logic",
|
||||||
|
"compact": true,
|
||||||
|
"materialize": true,
|
||||||
|
"dependencies": [
|
||||||
|
{ "js-controller": ">=4.0.0" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"native": {
|
||||||
|
"z2mPrefix": "zigbee2mqtt.0",
|
||||||
|
"scheduleMode": "strict", // "strict" | "passive"
|
||||||
|
"globalHysteresis": 0.1,
|
||||||
|
"globalMinIntervalSec": 60,
|
||||||
|
"fallbackMode": "setpoint", // "setpoint" | "internal"
|
||||||
|
"fallbackSetpoint": 6.0,
|
||||||
|
"staleMinutes": 10,
|
||||||
|
"rooms": [
|
||||||
|
/*
|
||||||
|
Beispiel-Eintrag:
|
||||||
|
{
|
||||||
|
"name": "Wohnzimmer",
|
||||||
|
"trvBase": "zigbee2mqtt.0.0x08ddebfffef7825b",
|
||||||
|
"sensorTemp": "zigbee2mqtt.0.0x3425b4fffe12ecb0.temperature",
|
||||||
|
"windowContact": "zigbee2mqtt.0.<fenster>.contact",
|
||||||
|
"openWhen": false,
|
||||||
|
"openDelaySec": 60,
|
||||||
|
"closeDelaySec": 30,
|
||||||
|
"openSetpoint": 6.0,
|
||||||
|
"enableTempForward": true
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"instanceObjects": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,321 @@
|
||||||
|
'use strict';
|
||||||
|
const utils = require('@iobroker/adapter-core');
|
||||||
|
|
||||||
|
class HeatingHelper extends utils.Adapter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({ ...options, name: 'heating-helper' });
|
||||||
|
this.rooms = [];
|
||||||
|
this.handlers = [];
|
||||||
|
this.on('ready', this.onReady.bind(this));
|
||||||
|
this.on('stateChange', this.onStateChange.bind(this));
|
||||||
|
this.on('unload', this.onUnload.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onReady() {
|
||||||
|
try {
|
||||||
|
const n = this.config;
|
||||||
|
this.log.info('Heating Helper startet...');
|
||||||
|
this.z2m = n.z2mPrefix || 'zigbee2mqtt.0';
|
||||||
|
|
||||||
|
this.scheduleMode = n.scheduleMode || 'strict';
|
||||||
|
this.fallbackMode = n.fallbackMode || 'setpoint';
|
||||||
|
this.fallbackSetpoint = Number(n.fallbackSetpoint ?? 6.0);
|
||||||
|
this.staleMinutes = Number(n.staleMinutes ?? 10);
|
||||||
|
this.globalHysteresis = Number(n.globalHysteresis ?? 0.1);
|
||||||
|
this.globalMinIntervalSec = Number(n.globalMinIntervalSec ?? 60);
|
||||||
|
|
||||||
|
this.rooms = Array.isArray(n.rooms) ? n.rooms : [];
|
||||||
|
if (!this.rooms.length) {
|
||||||
|
this.log.warn('Keine Räume konfiguriert.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler je Raum erzeugen
|
||||||
|
for (const room of this.rooms) {
|
||||||
|
const h = this.createRoomHandler(room);
|
||||||
|
if (h) this.handlers.push(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodischer Stale-Check
|
||||||
|
this.staleTimer = this.setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const h of this.handlers) {
|
||||||
|
if (!h.enableTempForward) continue;
|
||||||
|
if (!h.lastGoodTs) continue;
|
||||||
|
const age = (now - h.lastGoodTs) / 1000;
|
||||||
|
if (!h.fallbackActive && age > (this.staleMinutes * 60)) {
|
||||||
|
h.fallbackActive = true;
|
||||||
|
this.log.warn(`[${h.name}] Fallback aktiviert (stale): Sensor ausgefallen oder keine neuen Werte`);
|
||||||
|
if (this.fallbackMode === 'internal') {
|
||||||
|
this.setStateAsync(h.trvBase + '.temperature_sensor_select', 'internal', true);
|
||||||
|
} else {
|
||||||
|
if (h.prevSetpoint == null) {
|
||||||
|
h.prevSetpoint = this.readNum(h.trvBase + '.occupied_heating_setpoint');
|
||||||
|
}
|
||||||
|
this.pushSetpoint(h, this.fallbackSetpoint);
|
||||||
|
h.lastReassertTs = Date.now();
|
||||||
|
this.log.warn(`[${h.name}] Fallback: Setpoint temporär auf ${this.fallbackSetpoint} °C gesetzt`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30 * 1000);
|
||||||
|
|
||||||
|
this.log.info(`Initialisiert: ${this.handlers.length} Raum-Handler aktiv.`);
|
||||||
|
} catch (e) {
|
||||||
|
this.log.error('onReady error: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoomHandler(room) {
|
||||||
|
const name = room.name || 'raum';
|
||||||
|
const trvBase = room.trvBase;
|
||||||
|
const sensorTemp = room.sensorTemp;
|
||||||
|
const windowContact = room.windowContact;
|
||||||
|
if (!trvBase || !windowContact) {
|
||||||
|
this.log.warn(`[${name}] unvollständige Konfiguration (trvBase/windowContact fehlt)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = {
|
||||||
|
name,
|
||||||
|
trvBase,
|
||||||
|
sensorTemp,
|
||||||
|
windowContact,
|
||||||
|
openWhen: !!room.openWhen,
|
||||||
|
openDelaySec: Number(room.openDelaySec ?? 60),
|
||||||
|
closeDelaySec: Number(room.closeDelaySec ?? 30),
|
||||||
|
openSetpoint: Number(room.openSetpoint ?? 6.0),
|
||||||
|
enableTempForward: !!room.enableTempForward,
|
||||||
|
// Zustände
|
||||||
|
lastSent: 0,
|
||||||
|
lastPushed: null,
|
||||||
|
lastGoodTs: 0,
|
||||||
|
lastGoodTemp: null,
|
||||||
|
fallbackActive: false,
|
||||||
|
prevSetpoint: null,
|
||||||
|
windowOpenActive: false,
|
||||||
|
weAreSetting: false,
|
||||||
|
lastReassertTs: 0,
|
||||||
|
openTimer: null,
|
||||||
|
closeTimer: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial: externen Sensor aktivieren (wenn gewünscht)
|
||||||
|
if (h.enableTempForward) {
|
||||||
|
this.setStateAsync(h.trvBase + '.temperature_sensor_select', 'external', true);
|
||||||
|
// initiale Temperatur pushen
|
||||||
|
const t = this.readNum(sensorTemp);
|
||||||
|
if (this.isPlausibleTemp(t)) {
|
||||||
|
h.lastGoodTemp = t;
|
||||||
|
h.lastGoodTs = Date.now();
|
||||||
|
this.pushExternalTemp(h, t, /*force*/ true);
|
||||||
|
} else {
|
||||||
|
this.log.warn(`[${h.name}] Keine gültige Starttemperatur vom Sensor gefunden`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
// Fensterkontakt
|
||||||
|
this.subscribeForeignStates(h.windowContact);
|
||||||
|
// Setpoint (Zeitplan/Benutzer)
|
||||||
|
this.subscribeForeignStates(h.trvBase + '.occupied_heating_setpoint');
|
||||||
|
// Sensor-Temp
|
||||||
|
if (h.enableTempForward && h.sensorTemp) {
|
||||||
|
this.subscribeForeignStates(h.sensorTemp);
|
||||||
|
}
|
||||||
|
// TRV Sensorquelle (Watchdog)
|
||||||
|
this.subscribeForeignStates(h.trvBase + '.temperature_sensor_select');
|
||||||
|
|
||||||
|
this.log.info(`[${h.name}] Handler aktiv. TRV=${h.trvBase}, Sensor=${h.sensorTemp || '—'}, Fenster=${h.windowContact}`);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStateChange(id, state) {
|
||||||
|
if (!state || state.ack === undefined) return;
|
||||||
|
|
||||||
|
for (const h of this.handlers) {
|
||||||
|
// Fensterkontakt
|
||||||
|
if (id === h.windowContact && state.ack !== undefined) {
|
||||||
|
this.onWindowChange(h, state.val === h.openWhen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Sensor-Temp
|
||||||
|
if (h.enableTempForward && id === h.sensorTemp && state.ack !== undefined) {
|
||||||
|
this.onTempChange(h, Number(state.val));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TRV Setpoint
|
||||||
|
if (id === h.trvBase + '.occupied_heating_setpoint') {
|
||||||
|
this.onSetpointChange(h, Number(state.val), state.from);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TRV Sensorquelle
|
||||||
|
if (id === h.trvBase + '.temperature_sensor_select') {
|
||||||
|
this.onSensorSelectChange(h, String(state.val || ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowChange(h, isOpen) {
|
||||||
|
// Debounce via Timer
|
||||||
|
if (h.openTimer) { clearTimeout(h.openTimer); h.openTimer = null; }
|
||||||
|
if (h.closeTimer) { clearTimeout(h.closeTimer); h.closeTimer = null; }
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
h.openTimer = setTimeout(() => {
|
||||||
|
h.openTimer = null;
|
||||||
|
// Zustand prüfen
|
||||||
|
this.getForeignState(h.windowContact, (err, st) => {
|
||||||
|
if (!st) return;
|
||||||
|
if (st.val === h.openWhen) this.handleWindowOpen(h);
|
||||||
|
});
|
||||||
|
}, h.openDelaySec * 1000);
|
||||||
|
this.log.info(`[${h.name}] Fenster offen erkannt, Reaktion in ${h.openDelaySec}s`);
|
||||||
|
} else {
|
||||||
|
h.closeTimer = setTimeout(() => {
|
||||||
|
h.closeTimer = null;
|
||||||
|
this.getForeignState(h.windowContact, (err, st) => {
|
||||||
|
if (!st) return;
|
||||||
|
if (st.val !== h.openWhen) this.handleWindowClose(h);
|
||||||
|
});
|
||||||
|
}, h.closeDelaySec * 1000);
|
||||||
|
this.log.info(`[${h.name}] Fenster geschlossen erkannt, Reaktion in ${h.closeDelaySec}s`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWindowOpen(h) {
|
||||||
|
if (h.windowOpenActive) return;
|
||||||
|
h.windowOpenActive = true;
|
||||||
|
if (h.prevSetpoint == null) {
|
||||||
|
h.prevSetpoint = this.readNum(h.trvBase + '.occupied_heating_setpoint');
|
||||||
|
this.log.info(`[${h.name}] Vorherigen Setpoint gesichert: ${h.prevSetpoint} °C`);
|
||||||
|
}
|
||||||
|
this.pushSetpoint(h, h.openSetpoint);
|
||||||
|
h.lastReassertTs = Date.now();
|
||||||
|
this.log.warn(`[${h.name}] Setpoint temporär auf ${h.openSetpoint} °C abgesenkt (Fenster offen)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWindowClose(h) {
|
||||||
|
if (!h.windowOpenActive) return;
|
||||||
|
h.windowOpenActive = false;
|
||||||
|
|
||||||
|
let restore = h.prevSetpoint;
|
||||||
|
if (this.config.scheduleMode === 'passive' && typeof h.lastDesiredSet === 'number') {
|
||||||
|
restore = h.lastDesiredSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof restore === 'number') {
|
||||||
|
this.pushSetpoint(h, restore);
|
||||||
|
this.log.info(`[${h.name}] Setpoint wiederhergestellt: ${restore} °C (Fenster geschlossen)`);
|
||||||
|
} else {
|
||||||
|
this.log.warn(`[${h.name}] Kein vorheriger Setpoint gespeichert, keine Wiederherstellung`);
|
||||||
|
}
|
||||||
|
|
||||||
|
h.prevSetpoint = null;
|
||||||
|
h.lastDesiredSet = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTempChange(h, t) {
|
||||||
|
if (!this.isPlausibleTemp(t)) {
|
||||||
|
this.log.warn(`[${h.name}] Ignoriere unplausible Temperatur: ${t}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
h.lastGoodTemp = t;
|
||||||
|
h.lastGoodTs = Date.now();
|
||||||
|
this.pushExternalTemp(h, t, false);
|
||||||
|
|
||||||
|
if (h.fallbackActive) {
|
||||||
|
this.log.info(`[${h.name}] Sensor wieder verfügbar - beende Fallback`);
|
||||||
|
if (this.fallbackMode === 'internal') {
|
||||||
|
this.setStateAsync(h.trvBase + '.temperature_sensor_select', 'external', true);
|
||||||
|
} else if (this.fallbackMode === 'setpoint' && h.prevSetpoint != null) {
|
||||||
|
this.pushSetpoint(h, h.prevSetpoint);
|
||||||
|
this.log.info(`[${h.name}] Setpoint wiederhergestellt: ${h.prevSetpoint} °C`);
|
||||||
|
}
|
||||||
|
h.fallbackActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSetpointChange(h, val, from) {
|
||||||
|
if (isNaN(val)) return;
|
||||||
|
h.lastDesiredSet = val;
|
||||||
|
|
||||||
|
if (!h.windowOpenActive) return;
|
||||||
|
if (this.config.scheduleMode !== 'strict') return;
|
||||||
|
if (h.weAreSetting) return;
|
||||||
|
|
||||||
|
// Strict: bei Abweichung wieder auf openSetpoint ziehen (rate-limited)
|
||||||
|
const diff = Math.abs(val - h.openSetpoint);
|
||||||
|
const age = (Date.now() - h.lastReassertTs) / 1000;
|
||||||
|
if (diff >= 0.3 && age >= 20) {
|
||||||
|
this.pushSetpoint(h, h.openSetpoint);
|
||||||
|
h.lastReassertTs = Date.now();
|
||||||
|
this.log.warn(`[${h.name}] Strict-Hold: Setpoint während offen wieder auf ${h.openSetpoint} °C gesetzt (abweichend: ${val})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSensorSelectChange(h, cur) {
|
||||||
|
if (cur !== 'external' && !h.fallbackActive && h.enableTempForward) {
|
||||||
|
this.log.warn(`[${h.name}] TRV hat Sensorquelle geändert (${cur}) - korrigiere auf external`);
|
||||||
|
this.setStateAsync(h.trvBase + '.temperature_sensor_select', 'external', true);
|
||||||
|
const t = h.lastGoodTemp ?? this.readNum(h.sensorTemp);
|
||||||
|
if (this.isPlausibleTemp(t)) this.pushExternalTemp(h, t, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
readNum(id) {
|
||||||
|
// sync read (best effort)
|
||||||
|
// (ioBroker Adapter-API hat keine sync reads; hier nur für init: wir holen letzten gespeicherten Wert)
|
||||||
|
// besser: getForeignStateAsync mit await; für Kürze:
|
||||||
|
return null; // optional implementieren via callbacks, wenn gewünscht
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlausibleTemp(t) {
|
||||||
|
return typeof t === 'number' && !isNaN(t) && t >= -10 && t <= 35;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushExternalTemp(h, t, force) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force) {
|
||||||
|
if ((now - h.lastSent) / 1000 < this.globalMinIntervalSec) return;
|
||||||
|
const val = Math.round(t * 10) / 10;
|
||||||
|
if (h.lastPushed != null && Math.abs(h.lastPushed - val) < this.globalHysteresis) return;
|
||||||
|
this.setStateAsync(h.trvBase + '.external_temperature_input', val, true);
|
||||||
|
h.lastPushed = val;
|
||||||
|
h.lastSent = now;
|
||||||
|
this.log.info(`[${h.name}] Temperatur an TRV gesendet: ${val} °C`);
|
||||||
|
} else {
|
||||||
|
const val = Math.round(t * 10) / 10;
|
||||||
|
this.setStateAsync(h.trvBase + '.external_temperature_input', val, true);
|
||||||
|
h.lastPushed = val;
|
||||||
|
h.lastSent = now;
|
||||||
|
this.log.info(`[${h.name}] Temperatur an TRV gesendet: ${val} °C (force)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSetpoint(h, val) {
|
||||||
|
h.weAreSetting = true;
|
||||||
|
this.setStateAsync(h.trvBase + '.occupied_heating_setpoint', val, false).then(() => {
|
||||||
|
setTimeout(() => { h.weAreSetting = false; }, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnload(callback) {
|
||||||
|
try {
|
||||||
|
if (this.staleTimer) this.clearInterval(this.staleTimer);
|
||||||
|
this.log.info('Adapter wird beendet.');
|
||||||
|
callback();
|
||||||
|
} catch (e) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (module.parent) {
|
||||||
|
module.exports = (options) => new HeatingHelper(options);
|
||||||
|
} else {
|
||||||
|
new HeatingHelper();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "iobroker.zigbeeheatingmanager",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Manager für Thermostate + externe Sensoren mit GUI",
|
||||||
|
"author": "Tobias Wahl",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "main.js",
|
||||||
|
"keywords": ["ioBroker", "heating", "zigbee2mqtt", "TRVZB", "thermostat"],
|
||||||
|
"dependencies": {
|
||||||
|
"@iobroker/adapter-core": "^3.1.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {},
|
||||||
|
"engines": { "node": ">=16" }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue