UNPKG

iobroker.controme

Version:

Connect to local Controme mini server that controls you home heating system

1,168 lines (1,093 loc) 74.3 kB
'use strict'; // The adapter-core module gives you access to the core ioBroker functions // you need to create an adapter const utils = require('@iobroker/adapter-core'); // Load your modules here, e.g.: const axios = require('axios').default; const dayjs = require('dayjs'); const formData = require('form-data'); const { isObject } = require('iobroker.controme/lib/tools'); function roundTo(number, decimals = 0) { return Math.round(number * Math.pow(10, decimals)) / Math.pow(10, decimals); } function safeStringify(obj, indent = 2) { const cache = new Set(); return JSON.stringify( obj, (key, value) => { if (typeof value === 'object' && value !== null) { if (cache.has(value)) { // Zirkuläre Referenz gefunden, diesen Schlüssel überspringen return; } cache.add(value); } return value; }, indent, ); } class Controme extends utils.Adapter { /** * Creates a new Controme adapter instance. * * @param options - Partial adapter options for configuration */ constructor(options = {}) { super({ ...options, name: 'controme', }); this.delayPolling = false; // is set when a connection error is detected and prevents polling this.delayPollingCounter = 0; // controls the duration, for which polling is delayed this.on('ready', this.onReady.bind(this)); this.on('stateChange', this.onStateChange.bind(this)); // this.on("objectChange", this.onObjectChange.bind(this)); // this.on("message", this.onMessage.bind(this)); // this.on("message", obj => { // this.log.debug(`Message received: ${JSON.stringify(obj)}`) // }); this.on('unload', this.onUnload.bind(this)); } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { // Initialize adapter connection state await this.setState('info.connection', { val: false, ack: true }); // Set up poll interval and logging const pollInterval = Math.min( Number.isInteger(this.config.interval) && this.config.interval > 15 ? this.config.interval : 15, 3600, ); // pollInterval determines how often the server is polled; we set it to at least once per hour in seconds to also keep in line with technical limitations. this.temporary_mode_default_duration = Number.isInteger(this.config.temp_duration) ? this.config.temp_duration : 60; this.log.debug( `Controme URL: ${this.config.url}; houseID: ${this.config.houseID}; user: ${this.config.user}; update interval: ${pollInterval}; warnOnNull: ${this.config.warnOnNull}`, ); // Re-initialize object structure if needed if (this.config.forceReInit) { await this._initializeObjectStructure(); } // Start polling and subscribe to states this._startPolling(pollInterval); this._subscribeToStates(); // Set connection state to true after setup await this.setState('info.connection', { val: true, ack: true }); } // Function to re-initialize the adapter’s object structure if forceReInit is set async _initializeObjectStructure() { this.log.debug(`Fresh install of adapter or forceReInit selected, initializing object structure.`); try { const objects = await this.getAdapterObjectsAsync(); for (const object in objects) { if (Object.prototype.hasOwnProperty.call(objects, object)) { this.log.silly(`Purging: ${object}`); if (!object.endsWith('info.connection')) { await this.delForeignObjectAsync(object); } } } this.log.debug(`Purging object structure finished successfully`); } catch (error) { this.log.error(`Purging object structure failed with ${error}`); } try { await this._createObjects(); await this.setState('info.connection', { val: true, ack: true }); this.log.debug(`Creating object structure finished successfully`); } catch (error) { this.log.error(`Creating object structure failed with ${error}`); } // Set forceReInit to false to prevent re-initialization on next restart try { await this.extendForeignObjectAsync(`system.adapter.${this.namespace}`, { native: { forceReInit: false } }); this.log.debug(`Initializing object structure finished successfully`); } catch (error) { this.log.error(`Could not set forceReInit to false: ${error}`); } } // Function to start polling data from the server at the specified interval _startPolling(pollInterval) { // Poll data from the server immediately this._updateIntervalFunction(); // Set up periodic polling this.updateInterval = this.setInterval(this._updateIntervalFunction.bind(this), pollInterval * 1000); } // Function to subscribe to all relevant states for updates _subscribeToStates() { this.subscribeStates('*.setpointTemperature'); this.subscribeStates('*.setpointTemperaturePerm'); this.subscribeStates('*.temporary_mode_remaining'); this.subscribeStates('*.sensors.*.actualTemperature'); this.subscribeStates('*.offsets.*'); } /* async onMessage(obj) { if (isObject(obj)) { this.log.debug(`onMessage called with obj: ${JSON.stringify(obj)}`); } // if (obj) { // switch (obj.command) { // case "command": // if (obj.callback) { // this.log.debug(`onMessage called with obj: ${JSON.stringify(obj)}`); // } // break; // } // } } */ _updateIntervalFunction() { // Poll API, unless server is in error mode if (!this.delayPolling) { this._pollRoomTemps(); this._pollOuts(); if (this.config.gateways != null && typeof this.config.gateways[Symbol.iterator] === 'function') { this._pollGateways(); } } } async _delayPollingFunction() { this.delayPolling = true; this.delayPollingCounter = Math.min(this.delayPollingCounter + 1, 10); // Limit to max 10 this.delayPollingTimeout = this.setTimeout( () => { this.delayPolling = false; }, this.delayPollingCounter * 60 * 1000, ); await this.setState('info.connection', { val: false, ack: true }); this.log.error( `Error in polling data from Controme mini server; will retry in ${this.delayPollingCounter} minutes.`, ); } async _resetPollingDelay() { this.delayPolling = false; this.delayPollingCounter = 0; await this.setState('info.connection', { val: true, ack: true }); } _logAxiosError(error, contextMessage = '') { if (error.response) { // Server responded with an error status (z.B. 404, 500) this.log.error( `${contextMessage} - Axios request failed with status ${error.response.status}: ${safeStringify(error.response.data)}`, ); } else if (error.request) { // Request was sent but no response received (z.B. timeout, network issue) // Log additional info from error.config const configDetails = error.config ? { url: error.config.url, method: error.config.method, timeout: error.config.timeout, // ... any other fields you want to log } : {}; this.log.error(`${contextMessage} - No response received. Config details: ${safeStringify(configDetails)}`); } else { // If neither response nor request exists (e.g. DNS errors) // or general errosr (e.g., invalid configuration, unexpected axios behavior) const code = error.code ? ` (Code: ${error.code})` : ''; this.log.error(`${contextMessage} - Axios request failed: ${error.toString()} ${code}`); } } async _processTempsAPIforCreation(body) { this.log.silly(`Temps response from Controme mini server for creation of objects: "${JSON.stringify(body)}"`); this.log.debug(`~ Creating room objects (incl. offsets and sensors)`); const floorPromises = []; for (const floor in body) { if (Object.prototype.hasOwnProperty.call(body[floor], 'raeume')) { floorPromises.push(this._processRooms(body[floor].raeume)); } } await Promise.all(floorPromises); } // Function to process all rooms on a given floor async _processRooms(rooms) { const promises = []; for (const roomKey in rooms) { if (Object.prototype.hasOwnProperty.call(rooms, roomKey)) { const room = rooms[roomKey]; promises.push( Promise.all([ this._createObjectsForRoom(room), this._createOffsetsForRoom(room), this._processSensorsForRoom(room), ]), ); } } await Promise.all(promises); } // Function to process all sensors for a given room async _processSensorsForRoom(room) { if (Object.prototype.hasOwnProperty.call(room, 'sensoren')) { const sensorPromises = []; for (const sensorKey in room.sensoren) { if (Object.prototype.hasOwnProperty.call(room.sensoren, sensorKey)) { sensorPromises.push(this._createSensorsForRoom(room.id, room.sensoren[sensorKey])); } } await Promise.all(sensorPromises); } } async _processOutsAPIforCreation(body) { // response JSON contains hierarchical information starting with floors and rooms on these floors // followed by outputs for each room // we need to iterate through the floors and rooms and add the required output structures to the already created rooms this.log.silly(`Outs response from Controme mini server for creation of objects: "${JSON.stringify(body)}"`); const outputPromises = []; for (const floor in body) { if (Object.prototype.hasOwnProperty.call(body[floor], 'raeume')) { const rooms = body[floor].raeume; for (const room in rooms) { if (Object.prototype.hasOwnProperty.call(rooms, room)) { outputPromises.push(this._createOutputsForRoom(rooms[room])); } } } } await Promise.all(outputPromises); this.log.debug(`~ Creating gateway objects`); if (this.config.gateways != null && typeof this.config.gateways[Symbol.iterator] === 'function') { const gateways = Array.isArray(this.config.gateways) ? this.config.gateways : [this.config.gateways]; const gatewayPromises = gateways.map(async gateway => { try { await Promise.all(this._createGatewayObjects(gateway)); await this._setGatewayObjects(gateway); } catch (error) { this.log.error(`Setting newly created gateway states failed with error ${error}`); } }); await Promise.all(gatewayPromises); if (this.config.gatewayOuts != null && typeof this.config.gatewayOuts[Symbol.iterator] === 'function') { const gatewayOuts = Array.isArray(this.config.gatewayOuts) ? this.config.gatewayOuts : [this.config.gatewayOuts]; this.log.debug(`~ Creating output objects for gateways`); const outsPromises = gatewayOuts.map(gatewayOutput => this._createGatewayOutputObjects(gatewayOutput)); await Promise.all(outsPromises); } } } async _createObjects() { this.log.debug(`Creating object structure`); // Read temp API and create all required objects (rooms and sensors) let body; try { const url = `http://${this.config.url}/get/json/v1/${this.config.houseID}/temps/`; const response = await axios.get(url, { timeout: 15000 }); body = response.data; await this._processTempsAPIforCreation(body); } catch (error) { await this._delayPollingFunction(); this.log.error( `Polling temps API from mini server to create data objects for rooms, offsets and sensor failed with error ${error instanceof Error ? error.message : String(error)}`, ); } this.log.debug(`~ Creating output objects for rooms`); try { const url = `http://${this.config.url}/get/json/v1/${this.config.houseID}/outs/`; const response = await axios.get(url, { timeout: 15000 }); body = response.data; await this._processOutsAPIforCreation(body); } catch (error) { await this._delayPollingFunction(); this.log.error( `Polling outs API from mini server to create data objects for outputs failed with error ${error instanceof Error ? error.message : String(error)}`, ); } } _createObjectsForRoom(room) { const promises = []; this.log.silly(`Creating objects for room ${room.id} (${room.name}): Basic object structure`); promises.push( this.setObjectNotExistsAsync(room.id.toString(), { type: 'device', common: { name: room.name }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.actualTemperature`, { type: 'state', common: { name: `${room.name} actual temperature`, type: 'number', unit: '°C', role: 'value.temperature', read: true, write: false, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.setpointTemperature`, { type: 'state', common: { name: `${room.name} setpoint temperature`, type: 'number', unit: '°C', role: 'level.temperature', read: true, write: true, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.temperatureOffset`, { type: 'state', common: { name: `${room.name} temperature offset`, type: 'number', unit: '°C', role: 'value', read: true, write: false, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.setpointTemperaturePerm`, { type: 'state', common: { name: `${room.name} permanent setpoint temperature`, type: 'number', unit: '°C', role: 'level.temperature', read: true, write: true, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.is_temporary_mode`, { type: 'state', common: { name: `${room.name} is in temporary mode`, type: 'boolean', unit: '', role: 'indicator', read: true, write: false, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.temporary_mode_remaining`, { type: 'state', common: { name: `${room.name} temporary mode remaining time `, type: 'number', unit: 's', role: 'level.timer', read: true, write: true, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.temporary_mode_end`, { type: 'state', common: { name: `${room.name} temporary mode end time `, type: 'number', unit: '', role: 'value.datetime', read: true, write: false, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.humidity`, { type: 'state', common: { name: `${room.name} humidity`, type: 'number', unit: '%', role: 'value.humidity', read: true, write: false, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.mode`, { type: 'state', common: { name: `${room.name} operating mode`, type: 'string', unit: '', role: 'state', read: true, write: false, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.sensors`, { type: 'channel', common: { name: `${room.name} sensors` }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.offsets`, { type: 'channel', common: { name: `${room.name} offsets` }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.outputs`, { type: 'channel', common: { name: `${room.name} outputs` }, native: {}, }), ); return Promise.all(promises); } _createGatewayObjects(gw) { const promises = []; this.log.silly(`Creating gateway objects for ${gw.gatewayMAC}`); promises.push( this.setObjectNotExistsAsync(gw.gatewayMAC, { type: 'device', common: { name: gw.gatewayName }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${gw.gatewayMAC}.gatewayType`, { type: 'state', common: { name: `${gw.gatewayName} type`, type: 'string', unit: '', role: 'state', read: true, write: false, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${gw.gatewayMAC}.isUniversal`, { type: 'state', common: { name: `${gw.gatewayName} isUniversal`, type: 'boolean', unit: '', role: 'state', read: true, write: false, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${gw.gatewayMAC}.outputs`, { type: 'channel', common: { name: `${gw.gatewayName} outputs` }, native: {}, }), ); return promises; } _setGatewayObjects(gw) { const promises = []; this.log.silly(`Setting gateway objects for ${gw.gatewayMAC}`); promises.push(this.setStateAsync(`${gw.gatewayMAC}.gatewayType`, gw.gatewayType, true)); promises.push( this.setStateAsync( `${gw.gatewayMAC}.isUniversal`, gw.gatewayType == 'gwUniMini' || gw.gatewayType == 'gwUniPro', true, ), ); return Promise.all(promises); } _createGatewayOutputObjects(gwo) { const promises = []; this.log.silly(`Creating gateway output object for ${gwo.gatewayOutsMAC} Output ${gwo.gatewayOutsID}`); promises.push( this.setObjectNotExistsAsync(`${gwo.gatewayOutsMAC}.outputs.${gwo.gatewayOutsID}`, { type: 'state', common: { name: gwo.gatewayOutsName, type: 'number', unit: '', min: 0, max: 1, role: 'state', read: true, write: false, }, native: {}, }), ); return Promise.all(promises); } isEmpty(object) { for (const i in object) { return false; } return true; } objSafeName(name) { // Some characters are not allowed to be used as part of an object id. Replace chars from constant adapter.FORBIDDEN_CHARS. return (name || '').replace(this.FORBIDDEN_CHARS, '_'); } _createOffsetsForRoom(room) { const promises = []; for (const offset in room.offsets) { if (Object.prototype.hasOwnProperty.call(room.offsets, offset)) { if (typeof room.offsets[offset] === 'object') { if (!this.isEmpty(room.offsets[offset])) { // if offset object is not empty, we create the relevant object structure promises.push( this.setObjectNotExistsAsync(`${room.id}.offsets.${offset}`, { type: 'channel', common: { name: `${room.name} offset ${offset}` }, native: {}, }), ); for (const offset_item in room.offsets[offset]) { // offset_item might contain unsafe characters if (Object.prototype.hasOwnProperty.call(room.offsets[offset], offset_item)) { this.log.silly( `Creating offset objects for room ${room.id}: Offset ${offset}.${this.objSafeName(offset_item)}`, ); // all states for offset channel "api" should be read-write, else read-only promises.push( this.setObjectNotExistsAsync( `${room.id}.offsets.${this.objSafeName(offset)}.${this.objSafeName( offset_item, )}`, { type: 'state', common: { name: `${room.name} offset ${offset} ${offset_item}`, type: 'number', unit: '°C', role: offset == 'api' ? 'level' : 'value', read: true, write: offset == 'api', }, native: {}, }, ), ); } } } else if (offset == 'api') { // for an empty offset object "api", we create a dedicated structure, since it can be used to set api offsets, so needs to be read/write // this.log.silly(`Creating object structure for offset object ${offset}`); this.log.silly(`Creating offset objects for room ${room.id}: Offset ${offset}.api`); promises.push( this.setObjectNotExistsAsync(`${room.id}.offsets.api`, { type: 'channel', common: { name: `${room.name} offset api` }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.offsets.api.raum`, { type: 'state', common: { name: `${room.name} offset api raum`, type: 'number', unit: '°C', role: 'level', read: true, write: true, }, native: {}, }), ); } } } } // Some servers do not include an api module, so the read/write states are not created. Check if "api" exists, if not, create it if (typeof room.offsets['api'] === 'undefined') { // for the offset object api, we create a dedicated structure, since it can be used to set api offsets, so needs to be read/write // this.log.silly(`Creating object structure for offset object ${offset}`); this.log.info(`Creating objects for room ${room.id}: Offset api.api`); promises.push( this.setObjectNotExistsAsync(`${room.id}.offsets.api`, { type: 'channel', common: { name: `${room.name} offset api` }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${room.id}.offsets.api.raum`, { type: 'state', common: { name: `${room.name} offset api raum`, type: 'number', unit: '°C', role: 'level', read: true, write: true, }, native: {}, }), ); } return Promise.all(promises); } _createSensorsForRoom(roomID, sensor) { const promises = []; this.log.silly( `Creating sensor objects for room ${roomID}: Sensor ${this.objSafeName(sensor.name)} (${sensor.beschreibung})`, ); promises.push( this.setObjectNotExistsAsync(`${roomID}.sensors.${this.objSafeName(sensor.name)}`, { type: 'device', common: { name: `${sensor.beschreibung}`, }, native: {}, }), ); promises.push( this.setObjectNotExistsAsync(`${roomID}.sensors.${this.objSafeName(sensor.name)}.isRoomTemperatureSensor`, { type: 'state', common: { name: `${sensor.beschreibung} is room temperature sensor`, type: 'boolean', role: 'indicator', read: true, write: false, }, native: {}, }), ); // sensor.wert can be either single value or object if (isObject(sensor.wert)) { for (const [key, value] of Object.entries(sensor.wert)) { this.log.silly(`Creating individual sensor value objects for room ${roomID}: Output ${key}:${value}`); // currently known object values are Helligkeit (brightness), Relative Luftfeuchtigkeit (humidity), Bewegung (motion), Temperatur (temperatur) switch (key) { case 'Helligkeit': promises.push( this.setObjectNotExistsAsync( `${roomID}.sensors.${this.objSafeName(sensor.name)}.brightness`, { type: 'state', common: { name: `${sensor.beschreibung} brightness`, type: 'number', unit: 'lux', role: 'value.brightness', read: true, write: false, }, native: {}, }, ), ); break; case 'Relative Luftfeuchte': promises.push( this.setObjectNotExistsAsync( `${roomID}.sensors.${this.objSafeName(sensor.name)}.humidity`, { type: 'state', common: { name: `${sensor.beschreibung} humidity`, type: 'number', unit: '%', role: 'value.humidity', read: true, write: false, }, native: {}, }, ), ); break; case 'Bewegung': promises.push( this.setObjectNotExistsAsync(`${roomID}.sensors.${this.objSafeName(sensor.name)}.motion`, { type: 'state', common: { name: `${sensor.beschreibung} motion`, type: 'boolean', role: 'sensor.motion', read: true, write: false, }, native: {}, }), ); break; case 'Temperatur': promises.push( this.setObjectNotExistsAsync( `${roomID}.sensors.${this.objSafeName(sensor.name)}.actualTemperature`, { type: 'state', common: { name: `${sensor.beschreibung} actual temperature`, type: 'number', unit: '°C', role: 'level.temperature', read: true, write: true, }, native: {}, }, ), ); break; default: promises.push( this.setObjectNotExistsAsync(`${roomID}.sensors.${this.objSafeName(sensor.name)}.${key}`, { type: 'state', common: { name: `${sensor.beschreibung} ${key}`, type: 'number', unit: '', role: 'level', read: true, write: true, }, native: {}, }), ); } } } else { promises.push( this.setObjectNotExistsAsync(`${roomID}.sensors.${this.objSafeName(sensor.name)}.actualTemperature`, { type: 'state', common: { name: `${sensor.beschreibung} actual temperature`, type: 'number', unit: '°C', role: 'level.temperature', read: true, write: true, }, native: {}, }), ); } return Promise.all(promises); } _createOutputsForRoom(room) { const promises = []; if (Object.prototype.hasOwnProperty.call(room, 'ausgang')) { const outputs = room.ausgang; for (const [key, value] of Object.entries(outputs)) { this.log.silly(`Creating output objects for room ${room.id}: Output ${key}:${value}`); promises.push( this.setObjectNotExistsAsync(`${room.id}.outputs.${key}`, { type: 'state', common: { name: `${room.name} outputs ${key}`, type: 'number', role: 'value', read: true, write: false, }, native: {}, }), ); } } return Promise.all(promises); } async _updateOffsetStatesForRoom(room) { // Controme deletes offsets that have been set via the api; we need to check which offsets still exist and delete those that do no longer exist // Get all offset states (states in channel offset) of the respective room and check if these are still included in the temps API response try { // Wrapper für getStatesOf, damit wir ein Promise zurückbekommen const obj = await new Promise((resolve, reject) => { this.getStatesOf(`${this.namespace}.${room.id}`, 'offsets', (err, states) => { if (err) { reject(err); } else { resolve(states); } }); }); this.log.silly(`_updateOffsetStatesForRoom: getStatesOf returned obj: ${JSON.stringify(obj)}`); const promises = []; for (const offset in obj) { if (Object.prototype.hasOwnProperty.call(obj, offset)) { const id = obj[offset]._id; const roomIdMatch = id.match(/^controme\.\d\.(\d+)/); const offsetChannelMatch = id.match(/offsets\.([^.]+)\./); const offsetStateMatch = id.match(/offsets\.[^.]+\.([^.]+)/); if (!roomIdMatch || !offsetChannelMatch || !offsetStateMatch) { this.log.warn(`Skipping offset with invalid format: ${id}`); continue; } const roomID = roomIdMatch[1]; const offsetChannel = offsetChannelMatch[1]; const offsetState = offsetStateMatch[1]; // offsetChannels are created by API, FPS, KI, and several other modules if (offsetChannel in room.offsets) { this.log.silly(`Offset channel ${roomID}.${offsetChannel} still exists in API response`); if (offsetState in room.offsets[offsetChannel]) { this.log.silly( `Offset state ${roomID}.${offsetChannel}.${offsetState} still exists in API response`, ); } else { // the previously existing offsetState does not exist anymore if (offsetChannel === 'api') { // if the previously existing offsetState was created in the api channel, we leave it, but set it to 0°C this.log.silly( `Offset state ${offsetState} no longer exists in API response, but is in API channel, so setting it to 0°C`, ); promises.push(this.setStateAsync(obj[offset]._id, 0, true)); } else { // if the previously existing offsetState was created in any other channel than api, we delete it this.log.debug( `Deleting offset state ${obj[offset]._id}, since it does not exist in API response`, ); promises.push(this.delObjectAsync(obj[offset]._id)); } } } else { // if the previously existing offsetChannel does no longer exist in API response, we delete it if (offsetChannel !== 'api') { this.log.debug( `Deleting offset channel ${this.namespace}.${roomID}.${offsetChannel}, since it does not appear in API response`, ); promises.push(this.delObjectAsync(`${this.namespace}.${roomID}.${offsetChannel}`)); } } } } await Promise.all(promises); } catch (error) { this.log.error(`Error updating offset states for room ${room.id}: ${error}`); } } async _processTempsAPIforUpdate(body) { this.log.silly(`Temps response from Controme mini server: "${JSON.stringify(body)}"`); const roomPromises = []; for (const floor in body) { if (Object.prototype.hasOwnProperty.call(body, floor)) { for (const room in body[floor].raeume) { if (Object.prototype.hasOwnProperty.call(body[floor].raeume, room)) { const roomObj = body[floor].raeume[room]; this.log.silly(`Processing temps API for room ${body[floor].raeume[room].id}`); // Alle asynchronen Operationen für den Raum in einem Promise zusammenfassen roomPromises.push( (async () => { await this._updateRoom(roomObj); await this._updateOffsetStatesForRoom(roomObj); await this._updateOffsetsForRoom(roomObj); await this._updateSensorsForRoom(roomObj); this.log.silly(`Finished processing temps API for room ${roomObj.id}`); })(), ); } } } } await Promise.all(roomPromises); } async _pollRoomTemps() { this.log.debug('Polling temperatures from Controme mini server'); try { const url = `http://${this.config.url}/get/json/v1/${this.config.houseID}/temps/`; const response = await axios.get(url, { timeout: 30000 }); if (!response.data || typeof response.data !== 'object') { throw new Error(`Unexpected response format: ${JSON.stringify(response.data)}`); } await this._processTempsAPIforUpdate(response.data); await this._resetPollingDelay(); } catch (error) { await this._delayPollingFunction(); this._logAxiosError(error, 'Polling temperature data failed'); } } async _processOutsAPIforUpdate(body) { this.log.silly(`Outs response from Controme mini server: "${JSON.stringify(body)}"`); const promises = []; for (const floor in body) { if (Object.prototype.hasOwnProperty.call(body[floor], 'raeume')) { for (const room in body[floor].raeume) { if (Object.prototype.hasOwnProperty.call(body[floor].raeume, room)) { const roomObj = body[floor].raeume[room]; this.log.silly(`Processing outs API for room ${roomObj.id}`); promises.push(this._updateOutputsForRoom(roomObj)); this.log.silly(`Finished processing outs API for room ${roomObj.id}`); } } } } await Promise.all(promises); } async _pollOuts() { this.log.debug('Polling outputs from Controme mini server'); try { const url = `http://${this.config.url}/get/json/v1/${this.config.houseID}/outs/`; const response = await axios.get(url, { timeout: 15000 }); if (!response.data || typeof response.data !== 'object') { throw new Error(`Unexpected response format: ${JSON.stringify(response.data)}`); } await this._processOutsAPIforUpdate(response.data); await this._resetPollingDelay(); } catch (error) { await this._delayPollingFunction(); this._logAxiosError(error, 'Polling output data failed'); } } async _pollGateways() { const gateways = Array.isArray(this.config.gateways) ? this.config.gateways : [this.config.gateways]; for (const gateway of gateways) { try { if (gateway.gatewayType === 'gwUniPro') { await this._pollIndividualGatewayOutputs(gateway); } else { await this._pollAllGatewayOutputs(gateway); } await this._resetPollingDelay(); } catch (error) { await this._delayPollingFunction(); this.log.error(`Error polling gateway ${gateway.gatewayMAC} from mini server: ${error}`); } } } // Polls each output of a "gwUniPro" gateway individually async _pollIndividualGatewayOutputs(gateway) { this.log.debug(`Polling individual outputs for gateway ${gateway.gatewayName}`); const outputs = await this.getStatesOfAsync(`${this.namespace}.${gateway.gatewayMAC}`, 'outputs'); for (const output of outputs) { const outputID = this._extractOutputID(output._id); const url = `http://${this.config.url}/get/${gateway.gatewayMAC}/${outputID}/`; try { const response = await axios.get(url, { timeout: 15000 }); this.log.silly( `_pollIndividualGatewayOutputs: ${url}: Response: ${JSON.stringify(response.data)} - typeof ${typeof response.data}`, ); // For each output, a response in the format "<0>" or "<1>" is returned if (!response.data || typeof response.data !== 'string') { throw new Error(`Unexpected response format: ${JSON.stringify(response.data)}`); } await this._setGatewayOutputState(gateway.gatewayMAC, output._id, response.data); } catch (error) { this._logAxiosError(error, `Failed to poll data for output ${gateway.gatewayMAC}:${outputID}`); } } } // Polls all outputs of a non-"gwUniPro" gateway in a single request async _pollAllGatewayOutputs(gateway) { this.log.debug(`Polling all outputs for gateway ${gateway.gatewayName} from mini server`); const url = `http://${this.config.url}/get/${gateway.gatewayMAC}/all/`; const outputs = await this.getStatesOfAsync(`${this.namespace}.${gateway.gatewayMAC}`, 'outputs'); try { const response = await axios.get(url, { timeout: 15000 }); const outputValues = this._parseGatewayOutputValues(response.data); for (const [, output] of outputs.entries()) { const outputID = this._extractOutputID(output._id); const value = parseFloat(outputValues[parseInt(outputID) - 1]); await this.setState(output._id, value, true); this.log.silly(`Setting gateway output ${gateway.gatewayMAC}:${outputID} to ${value}`); } } catch (error) { this.log.error(`Error polling outputs for gateway ${gateway.gatewayMAC}: ${error}`); } } _parseGatewayOutputValues(responseData) { if (typeof responseData === 'string') { return responseData.trim().split(';'); // Axios might return a string, so we ensure it's formatted correctly } if (Array.isArray(responseData)) { return responseData; // If already an array, return as-is } this.log.warn(`Unexpected response format from gateway outputs API: ${JSON.stringify(responseData)}`); return []; } // Sets the state for a specific gateway output async _setGatewayOutputState(gatewayMAC, outputId, data) { // 1) If data is a string in the format "<0>" or "<1>", remove the angle brackets if (typeof data === 'string') { if (data.startsWith('<') && data.endsWith('>')) { data = data.slice(1, -1); } else { // Try to parse the string as JSON try { data = JSON.parse(data); } catch (e) { const errMsg = e instanceof Error ? e.message : String(e); this.log.error(`Failed to parse response from gateway ${gatewayMAC}: ${data}. Error: ${errMsg}`); return; } } } // 2) Determine the numericValue based on the processed data let numericValue; // If data is a string, parse it as a float if (typeof data === 'string') { numericValue = parseFloat(data); } else if (data && typeof data === 'object') { // If data is an object, it might look like { value: 0.75 } or [0.75] const obj = data; // Attempt to extract either obj.value or obj[0] const maybeValue = obj.value ?? obj[0]; if (typeof maybeValue === 'string' || typeof maybeValue === 'number') { numericValue = parseFloat(String(maybeValue)); } } // 3) Check if numericValue is a valid number if (typeof numericValue === 'number' && !isNaN(numericValue)) { // At this point, numericValue is guaranteed to be a valid number await this.setState(outputId, numericValue, true); this.log.silly( `Setting gateway output ${gatewayMAC}:${this._extractOutputID(outputId)} to ${numericValue}`, ); } else { // numericValue is undefined or NaN this.log.error(`Received unexpected data format from gateway ${gatewayMAC}: ${JSON.stringify(data)}`); } } // Extracts the output ID from a full state ID _extractOutputID(fullId) { return fullId.substring(fullId.lastIndexOf('.') + 1); } // Extended helper: Parses the value from room[key] using the provided parser. // Checks for existence and validity. If the parsed value is valid, // it is rounded to the specified number of decimal places (if decimals is 0, returns an integer). // If the value is missing or invalid, logs an appropriate message and returns null. _safeParseValue(room, key, fieldLabel, parser = parseFloat, decimals = 2, suppressWarning = false) { const raw = room[key]; if (raw != null) { const value = parser(raw); if (isFinite(value)) { return roundTo(value, decimals); } if (this.config.warnOnNull) { if (!suppressWarning) { this.log.warn(`Room ${room.id} (${room.name}): Invalid ${fieldLabel} value: ${raw}`); } } } else { this.log.debug(`Room ${room.id} (${room.name}): Value "${fieldLabel}" missing in API response`); } return null; } async _updateRoom(room) { const promises = []; this.log.silly( `Updating room