iobroker.luxtronik2
Version:
Connects to Luxtronik 2 heatpump controllers over LAN and WebSocket
745 lines • 30.3 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/*
* Created with @iobroker/create-adapter v1.30.1
*/
const utils = __importStar(require("@iobroker/adapter-core"));
const SentryNode = __importStar(require("@sentry/node"));
const luxtronik2_1 = __importDefault(require("luxtronik2"));
const ws_1 = __importDefault(require("ws"));
const xml2js_1 = require("xml2js");
const lux_meta_1 = require("./lux-meta");
const WATCHDOG_RETRIES = 3;
class Luxtronik2 extends utils.Adapter {
constructor(options = {}) {
super({
dirname: __dirname.indexOf('node_modules') !== -1 ? undefined : __dirname + '/../',
...options,
name: 'luxtronik2',
});
this.wsFailCounter = 0;
this.luxFailCounter = 0;
this.closing = false;
this.navigationSections = [];
this.currentNavigationSection = 0;
this.handlers = {};
this.requestedUpdates = [];
this.isSaving = false;
this.reportedUnknownData = new Set();
this.on('ready', this.onReady.bind(this));
this.on('stateChange', this.onStateChange.bind(this));
this.on('unload', this.onUnload.bind(this));
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
// Initialize your adapter here
// Reset the connection indicator during startup
this.setState('info.connection', false, true);
if (!this.config.host) {
this.log.error(`No host is configured, will not start anything!`);
return;
}
await this.cleanupObjects();
this.createWebSocket();
if (this.config.luxPort) {
await this.createLuxTreeAsync();
this.createLuxtronikConnection(this.config.host, this.config.luxPort);
}
this.watchdogInterval = setInterval(() => this.handleWatchdog(), this.config.refreshInterval * 1000);
}
async cleanupObjects() {
const allObjects = await this.getAdapterObjectsAsync();
// remove all timestamp entries that were created before version 0.2 (Fehlerspeicher and Abschaltungen)
await Promise.all(Object.keys(allObjects)
.filter((id) => !!id.match(/\.\d\d-\d\d-\d\d-\d\d:\d\d:\d\d$/))
.map((id) => this.delForeignObjectAsync(id)));
}
createWebSocket() {
var _a;
if (!this.config.port) {
return;
}
const uri = `ws://${this.config.host}:${this.config.port}`;
const login = `LOGIN;${this.config.password}`;
this.webSocket = new ws_1.default(uri, 'Lux_WS');
this.log.info('Connecting to ' + uri);
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.addBreadcrumb({ type: 'http', category: 'ws', data: { url: uri } });
this.webSocket.on('open', () => {
var _a, _b, _c, _d;
try {
this.log.info('Connected to ' + uri);
(_a = this.webSocket) === null || _a === void 0 ? void 0 : _a.send(login);
(_b = this.webSocket) === null || _b === void 0 ? void 0 : _b.send('REFRESH');
this.setState('info.connection', true, true);
}
catch (e) {
this.log.error(`Couldn't send login, ${e}`);
(_c = this.getSentry()) === null || _c === void 0 ? void 0 : _c.captureException(e);
(_d = this.webSocket) === null || _d === void 0 ? void 0 : _d.close();
}
});
this.webSocket.on('message', (msg) => this.handleWsMessage(msg));
this.webSocket.on('error', (err) => {
var _a;
if (this.closing) {
return;
}
this.log.error(`Got WebSocket error ${err}`);
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.captureException(err);
// not available in unit tests
if (this.restart) {
this.restart();
}
});
this.webSocket.on('close', () => {
var _a;
if (this.closing) {
return;
}
this.log.error('Got unexpected close event');
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.captureMessage('Got unexpected close event', SentryNode.Severity.Warning);
// not available in unit tests
if (this.restart) {
this.restart();
}
});
}
async createLuxTreeAsync() {
for (const sectionName in lux_meta_1.luxMeta) {
const section = lux_meta_1.luxMeta[sectionName];
await this.extendObjectAsync(sectionName, {
type: 'channel',
common: {
name: sectionName,
},
});
for (const itemName in section) {
const item = section[itemName];
if (!item) {
// ignore it
continue;
}
const id = `${sectionName}.${itemName}`;
await this.extendObjectAsync(id, {
type: 'state',
common: {
name: itemName,
read: true,
write: !!item.writeName,
type: item.type,
role: item.role,
unit: item.unit,
min: item.min,
max: item.max,
states: item.states,
},
});
if (item.writeName) {
this.subscribeStates(id);
}
}
}
}
createLuxtronikConnection(host, port) {
if (!port) {
return;
}
this.log.info(`Connecting to ${host}:${port}`);
this.luxtronik = new luxtronik2_1.default.createConnection(host, port);
this.requestLuxtronikData();
}
requestLuxtronikData() {
this.luxFailCounter = 0;
this.luxtronik.read((err, data) => {
var _a;
if (err) {
if (err.message === 'heatpump busy') {
this.log.info('Heatpump busy, will retry later');
}
else {
this.log.error(`Luxtronik read error, will retry later: ${err}`);
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.captureException(err);
}
this.luxRefreshTimeout = setTimeout(() => this.requestLuxtronikData(), this.config.refreshInterval * 1000);
return;
}
this.setState('info.connection', true, true);
this.handleLuxtronikDataAsync(data).catch((e) => {
var _a;
this.log.error(`Couldn't handle luxtronik data ${e}`);
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.captureException(e);
});
});
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
*/
onUnload(callback) {
var _a, _b, _c;
try {
this.closing = true;
clearInterval(this.watchdogInterval);
if (this.wsRefreshTimeout) {
clearTimeout(this.wsRefreshTimeout);
}
if (this.luxRefreshTimeout) {
clearTimeout(this.luxRefreshTimeout);
}
(_a = this.webSocket) === null || _a === void 0 ? void 0 : _a.close();
(_c = (_b = this.luxtronik) === null || _b === void 0 ? void 0 : _b.client) === null || _c === void 0 ? void 0 : _c.destroy();
callback();
}
catch (e) {
callback();
}
}
handleWatchdog() {
var _a, _b;
if (this.config.port) {
if (this.wsFailCounter >= WATCHDOG_RETRIES) {
const msg = `Didn't receive data from WebSocket after ${this.wsFailCounter} retries, restarting adapter`;
this.log.error(msg);
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.captureMessage(msg, SentryNode.Severity.Error);
this.restart();
return;
}
this.wsFailCounter++;
}
if (this.config.luxPort) {
if (this.luxFailCounter >= WATCHDOG_RETRIES) {
const msg = `Didn't receive data from Lux port after ${this.luxFailCounter} retries, restarting adapter`;
this.log.error(msg);
(_b = this.getSentry()) === null || _b === void 0 ? void 0 : _b.captureMessage(msg, SentryNode.Severity.Error);
this.restart();
return;
}
this.luxFailCounter++;
}
}
/**
* Is called if a subscribed state changes
*/
onStateChange(id, state) {
var _a;
if (!state || state.ack) {
return;
}
// The state was changed from the outside
this.log.debug(`state ${id} changed: ${JSON.stringify(state.val)}`);
const idParts = id.split('.');
idParts.shift(); // remove adapter name
idParts.shift(); // remove instance number
const luxSection = lux_meta_1.luxMeta[idParts[0]];
if (luxSection && luxSection[idParts[1]]) {
const meta = luxSection[idParts[1]];
if (!(meta === null || meta === void 0 ? void 0 : meta.writeName)) {
return;
}
if (this.luxRefreshTimeout) {
clearTimeout(this.luxRefreshTimeout);
}
this.log.debug(`Setting ${meta.writeName} to ${state.val}`);
(_a = this.luxtronik) === null || _a === void 0 ? void 0 : _a.write(meta.writeName, state.val, (err, _result) => {
var _a;
if (err) {
this.log.error(`Coudln't set ${id}: ${err}`);
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.captureException(err, { extra: { id } });
}
this.requestLuxtronikData();
});
return;
}
this.requestedUpdates.push({ id: idParts.join('.'), value: state.val });
if (this.requestedUpdates.length === 1) {
this.handleNextUpdate();
}
}
async handleLuxtronikDataAsync(data) {
try {
for (const sectionName in data) {
const metaSection = lux_meta_1.luxMeta[sectionName];
const section = data[sectionName];
if (!metaSection) {
const msg = `Unknown section ${sectionName}`;
this.log.warn(msg);
if (!this.reportedUnknownData.has(msg)) {
this.reportedUnknownData.add(msg);
const sentry = this.getSentry();
sentry === null || sentry === void 0 ? void 0 : sentry.withScope((scope) => {
scope.setExtra('section', JSON.stringify(section, null, 2));
sentry.captureMessage(msg, SentryNode.Severity.Warning);
});
}
continue;
}
for (const itemName in section) {
const meta = metaSection[itemName];
const value = section[itemName];
if (!meta) {
if (metaSection.hasOwnProperty(itemName)) {
// item was explicitly excluded (set to undefined in the meta-data)
continue;
}
const msg = `Unknown data item ${sectionName}.${itemName}`;
this.log.warn(msg);
if (!this.reportedUnknownData.has(msg)) {
this.reportedUnknownData.add(msg);
const sentry = this.getSentry();
sentry === null || sentry === void 0 ? void 0 : sentry.withScope((scope) => {
scope.setExtra('value', value);
sentry.captureMessage(msg, SentryNode.Severity.Warning);
});
}
continue;
}
let stateValue;
if (meta.type === 'number') {
stateValue = value === 'no' ? null : value;
}
else if (meta.type === 'boolean') {
switch (value) {
case 'on':
stateValue = true;
break;
case 'off':
stateValue = false;
break;
default:
stateValue = null;
break;
}
}
else {
stateValue = value;
}
await this.setStateValueAsync(`${sectionName}.${itemName}`, stateValue);
}
}
}
finally {
this.luxRefreshTimeout = setTimeout(() => this.requestLuxtronikData(), this.config.refreshInterval * 1000);
}
}
handleNextUpdate() {
if (this.requestedUpdates.length === 0) {
return false;
}
const id = this.requestedUpdates[0].id;
const idParts = id.split('.');
const navigationSection = this.navigationSections.findIndex((i) => this.getItemId(i) === idParts[0]);
if (navigationSection === -1) {
this.requestedUpdates.shift();
const msg = `Section not found for state ${id}`;
this.log.warn(msg);
if (!this.reportedUnknownData.has(msg)) {
this.reportedUnknownData.add(msg);
const sentry = this.getSentry();
sentry === null || sentry === void 0 ? void 0 : sentry.withScope((scope) => {
scope.setExtra('id', id);
sentry.captureMessage(msg, SentryNode.Severity.Warning);
});
}
return this.handleNextUpdate();
}
// request the section so we have the right id to update
if (this.wsRefreshTimeout) {
clearTimeout(this.wsRefreshTimeout);
}
this.currentNavigationSection = navigationSection - 1;
this.requestNextContent();
return true;
}
handleWsMessage(message) {
this.handleWsMessageAsync(message).catch((error) => {
var _a;
this.log.error(`Couldn't handle message: ${error} ${error.stack}`);
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.captureException(error, { extra: { message } });
});
}
async handleWsMessageAsync(msg) {
var _a, _b, _c, _d, _e;
const message = await (0, xml2js_1.parseStringPromise)(msg);
this.log.debug(JSON.stringify(message));
if ('Navigation' in message) {
if (this.navigationSections.length > 0) {
return;
}
// Reply to the REFRESH command, gives us the structure but no actual data
for (let i = 0; i < message.Navigation.item.length && i < 2; i++) {
// only look at the first two items ("Informationen" and "Einstellungen")
const item = message.Navigation.item[i];
await this.extendObjectAsync(this.getItemId(item), {
type: 'device',
common: {
name: item.name[0],
},
native: item,
});
this.navigationSections.push(item);
}
this.requestAllContent();
}
else if ('Content' in message) {
if (this.isSaving) {
// the SAVE command gives us the latest "Content", thus we need to ignore this message
this.isSaving = false;
if (!this.handleNextUpdate()) {
this.requestAllContent();
}
return;
}
const navigationItem = this.navigationSections[this.currentNavigationSection];
const navigationId = this.getItemId(navigationItem);
const sectionIds = [];
let shouldSave = false;
for (let i = 0; i < message.Content.item.length; i++) {
const section = message.Content.item[i];
const sectionHandler = this.createHandler(section, navigationId, sectionIds);
if (!sectionHandler) {
continue;
}
if (!this.handlers[sectionHandler.id]) {
this.handlers[sectionHandler.id] = sectionHandler;
await sectionHandler.extendObjectAsync();
}
if (sectionHandler instanceof TimeLogSectionHandler) {
// time log sections are actually states
await sectionHandler.setStateAsync();
continue;
}
const itemIds = [];
for (let j = 0; j < section.item.length; j++) {
const item = section.item[j];
try {
const itemHandler = this.createHandler(item, sectionHandler.id, itemIds);
if (!itemHandler) {
continue;
}
if (!this.handlers[itemHandler.id]) {
this.log.silly(`Creating ${itemHandler.id}`);
await itemHandler.extendObjectAsync();
this.handlers[itemHandler.id] = itemHandler;
}
if (this.requestedUpdates.length === 0) {
this.log.silly(`Setting state of ${itemHandler.id}`);
await itemHandler.setStateAsync();
}
else {
const updateIndex = this.requestedUpdates.findIndex((ch) => ch.id === itemHandler.id);
if (updateIndex >= 0) {
const cmd = itemHandler.createSetCommand(this.requestedUpdates[updateIndex].value);
this.log.debug(`Sending ${cmd}`);
(_a = this.getSentry()) === null || _a === void 0 ? void 0 : _a.addBreadcrumb({ type: 'http', category: 'ws', data: { url: cmd } });
(_b = this.webSocket) === null || _b === void 0 ? void 0 : _b.send(cmd);
this.requestedUpdates.splice(updateIndex);
shouldSave = true;
}
}
}
catch (error) {
this.log.error(`Couldn't handle '${sectionHandler.id}' -> '${item.name[0]}': ${error}`);
(_c = this.getSentry()) === null || _c === void 0 ? void 0 : _c.captureException(error, { extra: { section: sectionHandler.id, item } });
}
}
}
if (shouldSave) {
this.log.debug('Saving');
(_d = this.getSentry()) === null || _d === void 0 ? void 0 : _d.addBreadcrumb({ type: 'http', category: 'ws', data: { url: 'SAVE;1' } });
(_e = this.webSocket) === null || _e === void 0 ? void 0 : _e.send('SAVE;1');
this.isSaving = true;
}
else {
this.requestNextContent();
}
}
}
requestAllContent() {
this.wsFailCounter = 0;
this.currentNavigationSection = -1;
this.requestNextContent();
}
requestNextContent() {
var _a;
this.currentNavigationSection++;
if (this.currentNavigationSection >= this.navigationSections.length) {
this.wsRefreshTimeout = setTimeout(() => this.requestAllContent(), this.config.refreshInterval * 1000);
return;
}
const id = this.navigationSections[this.currentNavigationSection].$.id;
this.log.debug('Getting ' + id);
(_a = this.webSocket) === null || _a === void 0 ? void 0 : _a.send('GET;' + id);
}
getItemId(item) {
return item.name[0].replace(/[\][*,;'"`<>\\?/._ \-]+/g, '-').replace(/(^-+|-+$)/g, '');
}
createHandler(item, parentId, existingIds) {
if (item.name[0] === '---') {
return undefined;
}
const baseId = `${parentId}.${this.getItemId(item)}`;
if (baseId.endsWith('.')) {
// item has no name
const msg = `No name for handler: ${baseId}`;
if (!this.reportedUnknownData.has(msg)) {
this.reportedUnknownData.add(msg);
const sentry = this.getSentry();
sentry === null || sentry === void 0 ? void 0 : sentry.withScope((scope) => {
scope.setExtra('item', JSON.stringify(item, null, 2));
sentry.captureMessage(msg, SentryNode.Severity.Warning);
});
}
return undefined;
}
let id = baseId;
for (let i = 1; existingIds.includes(id); i++) {
id = `${baseId}_${i}`;
}
existingIds.push(id);
if ('item' in item) {
if (item.item.every((i) => i.name.every((n) => !!n.match(/^\d\d\.\d\d\.\d\d \d\d:\d\d:\d\d$/)))) {
// this is a section with timestamps, use a special handler
return new TimeLogSectionHandler(id, item, this);
}
else {
return new SectionHandler(id, item, this);
}
}
if ('option' in item) {
return new SelectHandler(id, item, this);
}
if ('min' in item) {
return new NumberHandler(id, item, this);
}
return new ReadOnlyHandler(id, item, this);
}
async setStateValueAsync(id, value) {
await this.setStateChangedAsync(id, value, true);
}
getSentry() {
if (this.supportsFeature && this.supportsFeature('PLUGINS')) {
const sentryInstance = this.getPluginInstance('sentry');
if (sentryInstance) {
return sentryInstance.getSentryObject();
}
}
}
}
class ItemHandler {
constructor(id, item, adapter) {
this.id = id;
this.item = item;
this.adapter = adapter;
}
unit2role(unit, readOnly) {
const kind = readOnly ? 'value' : 'level';
switch (unit) {
case '°C':
case 'K':
return `${kind}.temperature`;
case 'bar':
return `${kind}.pressure`;
case 'V':
return `${kind}.voltage`;
case 'kWh':
return `${kind}.power.consumption`;
case 'kW':
return `${kind}.power`;
default:
return kind;
}
}
}
class SectionHandler extends ItemHandler {
async extendObjectAsync() {
await this.adapter.extendObjectAsync(this.id, {
type: 'channel',
common: {
name: this.item.name[0],
},
native: this.item,
});
}
setStateAsync() {
throw new Error('setStateAsync() not supported on section.');
}
createSetCommand(_value) {
throw new Error('createSetCommand() not supported on section.');
}
}
class TimeLogSectionHandler extends SectionHandler {
async extendObjectAsync() {
await this.adapter.extendObjectAsync(this.id, {
type: 'state',
common: {
name: this.item.name[0],
type: 'object',
role: 'json',
read: true,
write: false,
},
native: this.item,
});
}
async setStateAsync() {
const value = this.item.item.reduce((old, item) => ({ ...old, [item.name[0]]: item.value[0] }), {});
await this.adapter.setStateValueAsync(this.id, JSON.stringify(value));
}
}
class ReadOnlyHandler extends ItemHandler {
constructor() {
super(...arguments);
this.numberUnitMatch = /^(-?\d+(\.\d+)?|-+) ?(\D*)$/;
}
async extendObjectAsync() {
const common = {
name: this.item.name[0],
read: true,
write: false,
};
const value = this.item.value[0];
const match = value.match(this.numberUnitMatch);
if (match) {
common.type = 'number';
if (match[3]) {
common.unit = match[3];
}
common.role = this.unit2role(common.unit, true);
}
else if (value === 'Ein' || value === 'Aus' || value === 'On' || value === 'Off') {
common.type = 'boolean';
common.role = 'sensor';
}
else {
common.type = 'string';
common.role = 'text';
}
await this.adapter.extendObjectAsync(this.id, {
type: 'state',
common: common,
native: this.item,
});
}
async setStateAsync() {
const value = this.item.value[0];
const match = value.match(this.numberUnitMatch);
if (match) {
const numberValue = match[1];
if (numberValue.endsWith('-')) {
// something like '---'
await this.adapter.setStateValueAsync(this.id, null);
}
else {
await this.adapter.setStateValueAsync(this.id, parseFloat(numberValue));
}
}
else if (value === 'Ein' || value === 'Aus' || value === 'On' || value === 'Off') {
const flag = value === 'Ein' || value === 'On';
await this.adapter.setStateValueAsync(this.id, flag);
}
else {
await this.adapter.setStateValueAsync(this.id, value);
}
}
createSetCommand(_value) {
throw new Error('createSetCommand() not supported on read-only value.');
}
}
class SelectHandler extends ItemHandler {
async extendObjectAsync() {
const states = {};
this.item.option.forEach((option) => (states[parseInt(option.$.value)] = option._));
await this.adapter.extendObjectAsync(this.id, {
type: 'state',
common: {
name: this.item.name[0],
read: true,
write: true,
type: 'number',
role: 'level',
states: states,
},
native: this.item,
});
this.adapter.subscribeStates(this.id);
}
async setStateAsync() {
const value = parseInt(this.item.raw[0]);
await this.adapter.setStateValueAsync(this.id, value);
}
createSetCommand(value) {
return `SET;set_${this.item.$.id};${value}`;
}
}
class NumberHandler extends ItemHandler {
async extendObjectAsync() {
const unit = this.item.unit[0].trim();
const min = parseInt(this.item.min[0]);
const max = parseInt(this.item.max[0]);
const div = parseInt(this.item.div[0]);
await this.adapter.extendObjectAsync(this.id, {
type: 'state',
common: {
name: this.item.name[0],
read: true,
write: true,
type: 'number',
role: this.unit2role(unit, false),
unit: unit,
min: min / div,
max: max / div,
},
native: this.item,
});
this.adapter.subscribeStates(this.id);
}
async setStateAsync() {
const div = parseInt(this.item.div[0]);
const raw = parseInt(this.item.raw[0]);
await this.adapter.setStateValueAsync(this.id, raw / div);
}
createSetCommand(value) {
if (typeof value === 'number') {
const div = parseInt(this.item.div[0]);
const min = parseInt(this.item.min[0]);
const max = parseInt(this.item.max[0]);
let setValue = Math.round(value * div);
setValue = Math.max(setValue, min);
setValue = Math.min(setValue, max);
return `SET;set_${this.item.$.id};${setValue}`;
}
throw new Error('createSetCommand() supports only number value.');
}
}
if (module.parent) {
// Export the constructor in compact mode
module.exports = (options) => new Luxtronik2(options);
}
else {
// otherwise start the instance directly
(() => new Luxtronik2())();
}
//# sourceMappingURL=main.js.map