UNPKG

node-red-contrib-knx-ultimate

Version:

Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control and ETS group address importer. Easy to use and highly configurable.

297 lines (263 loc) 8.6 kB
import { EventEmitter } from 'events' import { Agent as HTTPSAgent, request as httpsRequest } from 'node:https' import { URL } from 'node:url' import { TextDecoder } from 'node:util' import { setTimeout as pleaseWait } from 'timers/promises' import * as http from './http.js' const createAbortError = () => { const err = new Error('The operation was aborted') err.name = 'AbortError' return err } const openEventStream = (url, { headers = {}, agent, signal } = {}) => { return new Promise((resolve, reject) => { let response = null let requestInstance = null let settled = false const safeResolve = (value) => { if (settled) return settled = true resolve(value) } const safeReject = (error) => { if (settled) return settled = true reject(error) } const cleanup = () => { if (signal) signal.removeEventListener('abort', onAbort) } const onAbort = () => { const abortErr = createAbortError() if (response && typeof response.destroy === 'function') { response.destroy(abortErr) } else if (requestInstance && typeof requestInstance.destroy === 'function') { requestInstance.destroy(abortErr) } } const target = new URL(url) const options = { protocol: target.protocol, hostname: target.hostname, port: target.port || (target.protocol === 'https:' ? 443 : 80), path: `${target.pathname}${target.search}`, method: 'GET', headers, agent } requestInstance = httpsRequest(options, (res) => { response = res res.once('close', cleanup) res.once('error', cleanup) safeResolve(res) }) requestInstance.on('error', (error) => { cleanup() safeReject(error) }) requestInstance.end() if (signal) { if (signal.aborted) { onAbort() cleanup() safeReject(createAbortError()) return } signal.addEventListener('abort', onAbort) } }) } class classHUE extends EventEmitter { constructor (_hueBridgeIP, _username, _clientkey, _bridgeid, _sysLogger) { super() this.HUEBridgeConnectionStatus = 'disconnected' this.exitAllQueues = false this.hueBridgeIP = _hueBridgeIP this.username = _username this.clientkey = _clientkey this.bridgeid = _bridgeid this.commandQueue = [] this.sysLogger = _sysLogger this.timerCheckConnected = null this.restartSSECounter = 0 this.handleQueue() this.eventStreamAbort = null } Connect = async () => { if (this.timerCheckConnected !== null) clearInterval(this.timerCheckConnected) if (this.eventStreamAbort) this.eventStreamAbort.abort() this.hueApiV2 = http.use({ key: this.username, prefix: `https://${this.hueBridgeIP}/clip/v2` }) const agent = new HTTPSAgent({ rejectUnauthorized: false }) const headers = { 'hue-application-key': this.username, Accept: 'text/event-stream', 'Cache-control': 'no-cache' } this.eventStreamAbort = new AbortController() const url = `https://${this.hueBridgeIP}/eventstream/clip/v2` try { const res = await openEventStream(url, { headers, agent, signal: this.eventStreamAbort.signal }) if (res.statusCode !== 200) { res.resume?.() this.emit('error', new Error(`Status ${res.statusCode}`)) return } this.emit('connected') this.HUEBridgeConnectionStatus = 'connected' this.sysLogger?.info('classHUE: connected to SSE') this.timerCheckConnected = setInterval(() => { (async () => { try { this.restartSSECounter += 1 if (this.restartSSECounter >= 4) { this.sysLogger?.debug('Restarted SSE Client, per sicurezza, altrimenti potrebbe addormentarsi') this.restartSSECounter = 0 this.eventStreamAbort.abort() await this.Connect() return } const jReturn = await this.hueApiV2.get('/resource/bridge') if (!Array.isArray(jReturn) || jReturn.length < 1) throw new Error('Bridge not found') this.HUEBridgeConnectionStatus = 'connected' } catch (error) { this.sysLogger?.error(`Ping ERROR: ${error.message}`) if (this.timerCheckConnected !== null) clearInterval(this.timerCheckConnected) this.commandQueue = [] try { await this.close() } catch (error) { } this.restartSSECounter = 0 this.emit('disconnected') } })() }, 120000) let buffer = '' const textDecoder = new TextDecoder() for await (const chunk of res) { buffer += textDecoder.decode(chunk, { stream: true }) let parts = buffer.split(/\r?\n\r?\n/) if (parts.length > 1) { buffer = parts.pop() } else { buffer = parts[0] parts = [] } for (const block of parts) { const parsed = this._parseEvent(block) if (parsed?.data && Array.isArray(parsed.data)) { parsed.data.forEach(ev => { for (let index = 0; index < ev.data.length; index++) { const element = ev.data[index] this.emit('event', element) } }) } } } } catch (err) { if (err.name !== 'AbortError' && this.sysLogger) { this.sysLogger.error(`EventStream error: ${err.message}`) this.commandQueue = [] try { await this.close() } catch (error) { } this.restartSSECounter = 0 this.emit('disconnected') }; } } _parseEvent = (block) => { const lines = block.split(/\r?\n/) let data = '' let event = 'message' for (const line of lines) { if (line.startsWith('data:')) { data += line.slice(5).trim() } else if (line.startsWith('event:')) { event = line.slice(6).trim() } } try { return { type: event, data: data ? JSON.parse(data) : null } } catch (e) { return { type: event, data: data || null } } } processQueueItem = async () => { try { const jRet = this.commandQueue.pop() switch (jRet._operation) { case 'setLight': await this.hueApiV2.put(`/resource/light/${jRet._lightID}`, jRet._state) break case 'setGroupedLight': await this.hueApiV2.put(`/resource/grouped_light/${jRet._lightID}`, jRet._state) break case 'setPlug': { const resourceType = jRet._resourceType || 'plug' await this.hueApiV2.put(`/resource/${resourceType}/${jRet._lightID}`, jRet._state) } break case 'setScene': await this.hueApiV2.put(`/resource/scene/${jRet._lightID}`, jRet._state) break case 'stopScene': const allResources = await this.hueApiV2.get('/resource') const jScene = allResources.find((res) => res.id === jRet._lightID) const linkedLight = allResources.find((res) => res.id === jScene.group.rid).children || [] linkedLight.forEach((light) => { this.writeHueQueueAdd(light.rid, jRet._state, 'setLight') }) break default: break } } catch (error) { this.sysLogger?.error(`processQueueItem: ${error.message}`) } } handleQueue = async () => { do { if (this.commandQueue && this.commandQueue.length > 0) { try { await this.processQueueItem() } catch (error) { } } await pleaseWait(150) } while (!this.exitAllQueues) } writeHueQueueAdd = async (_lightID, _state, _operation, _resourceType) => { this.commandQueue.unshift({ _lightID, _state, _operation, _resourceType }) } deleteHueQueue = async (_lightID) => { this.commandQueue = this.commandQueue.filter((el) => el._lightID !== _lightID) } close = async () => new Promise((resolve, reject) => { if (this.timerCheckConnected !== null) clearInterval(this.timerCheckConnected) try { this.exitAllQueues = true this.restartSSECounter = 0 try { if (this.eventStreamAbort) this.eventStreamAbort.abort() } catch (error) { } this.HUEBridgeConnectionStatus = 'disconnected' resolve(true) } catch (error) { reject(error) } }) } export { classHUE }