UNPKG

lib-comfoair

Version:

Library to communicate with Zehnder ComfoAirQ ventilation unit through the ComfoControl gateway

455 lines (454 loc) 21.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ComfoControlClient = exports.OperationMode = exports.TemperatureProfile = exports.FanMode = void 0; const discoveryOperation_1 = require("./discoveryOperation"); const networkUtils_1 = require("./util/networkUtils"); const index_1 = require("./util/logging/index"); const comfoConnect_1 = require("./protocol/comfoConnect"); const deferredPromise_1 = require("./util/deferredPromise"); const opcodes_1 = require("./opcodes"); const comfoControlTransport_1 = require("./comfoControlTransport"); const consts_1 = require("./consts"); const deviceProperties_1 = require("./deviceProperties"); const arrayUtils_1 = require("./util/arrayUtils"); const asyncUtils_1 = require("./util/asyncUtils"); const rmiProperties_1 = require("./rmiProperties"); var FanMode; (function (FanMode) { FanMode[FanMode["Away"] = 0] = "Away"; FanMode[FanMode["Low"] = 1] = "Low"; FanMode[FanMode["Medium"] = 2] = "Medium"; FanMode[FanMode["High"] = 3] = "High"; })(FanMode || (exports.FanMode = FanMode = {})); var TemperatureProfile; (function (TemperatureProfile) { TemperatureProfile[TemperatureProfile["Normal"] = 0] = "Normal"; TemperatureProfile[TemperatureProfile["Cool"] = 1] = "Cool"; TemperatureProfile[TemperatureProfile["Warm"] = 2] = "Warm"; })(TemperatureProfile || (exports.TemperatureProfile = TemperatureProfile = {})); var OperationMode; (function (OperationMode) { OperationMode[OperationMode["Manual"] = 1] = "Manual"; OperationMode[OperationMode["Auto"] = 0] = "Auto"; })(OperationMode || (exports.OperationMode = OperationMode = {})); /** * Opcodes that are exempt from the session check. */ const SESSION_EXEMPT_OPCODES = [comfoConnect_1.Opcode.REGISTER_DEVICE_REQUEST, comfoConnect_1.Opcode.START_SESSION_REQUEST, comfoConnect_1.Opcode.KEEP_ALIVE]; /** * The state of the session with the device. */ var SessionState; (function (SessionState) { SessionState[SessionState["None"] = 0] = "None"; SessionState[SessionState["Registering"] = 1] = "Registering"; SessionState[SessionState["Active"] = 2] = "Active"; })(SessionState || (SessionState = {})); /** * Represents a client that manages a connection with a ComfoControl Gateway Device. * * Provides methods to discover devices, start and maintain sessions, * register property listeners, and interact with the device. * * @example * ```typescript * const client = new ComfoControlClient({ * address: '192.168.1.100', * uuid: '1234567890abcdef1234567890abcdef', * pin: 1234, * }); * * await client.startSession(); * console.log('Session started:', client.sessionActive); * ``` * @remarks * - Make sure to handle errors for production use. * - Register property listeners to receive real-time updates. * * @public */ class ComfoControlClient { options; logger; transport; pendingReplies = {}; sessionState = SessionState.None; nodes = {}; deviceName; deviceProperties = {}; /** * Defines handlers for specific opcodes that are received from the server without a preceding request. */ handlers = { [comfoConnect_1.Opcode.CLOSE_SESSION_REQUEST]: this.onSessionClosed.bind(this), [comfoConnect_1.Opcode.CN_NODE_NOTIFICATION]: this.onNodeNotification.bind(this), [comfoConnect_1.Opcode.CN_RPDO_NOTIFICATION]: this.onPropertyUpdateNotification.bind(this), [comfoConnect_1.Opcode.GATEWAY_NOTIFICATION]: this.onNotification.bind(this), [comfoConnect_1.Opcode.CN_ALARM_NOTIFICATION]: this.onNotification.bind(this), }; get sessionActive() { return this.sessionState === SessionState.Active; } /** * Create a new device instance with the specified details. * Use the static discover method to find devices on the network if you do not have the details. */ constructor(options, logger = new index_1.Logger('ComfoAirDevice')) { this.options = options; this.logger = logger; ComfoControlClient.wrapLogger(this.logger, options); this.deviceName = options.deviceName ?? networkUtils_1.NetworkUtils.getHostname() ?? 'ComfoControlClient'; this.transport = new comfoControlTransport_1.ComfoControlTransport(options, this.logger.createLogger('Transport')); this.transport.on('message', (message) => this.processMessage(message)); this.transport.on('disconnect', () => (this.sessionState = SessionState.None)); } /** * Discover devices on the network using a {@link DiscoveryOperation}. The discovery process will run for the specified timeout or until the limit of devices is reached. * If no timeout is specified, the default timeout is 30 seconds. If no limit is specified, all discovered devices will be returned. * The operation can be aborted using an AbortSignal, see {@link https://nodejs.org/api/globals.html#class-abortsignal} for details on how to use the AbortSignal. * @param options - The options for the discovery process. * @returns A {@link DiscoveryOperation} instance that can be used to listen for discovered devices. */ static discover(options) { return new discoveryOperation_1.DiscoveryOperation(options?.broadcastAddresses ?? networkUtils_1.NetworkUtils.getBroadcastAddresses(), options?.port, ComfoControlClient.wrapLogger(new index_1.Logger('DiscoveryOperation'), options)).discover({ timeout: options?.timeout ?? 30000, limit: options?.limit, }, options?.abortSignal); } /** * Starts a session with the ComfoControl device. Normally you should not need to call this method directly, * as it is called automatically when sending a request that requires an active session. * * Registers the device/app and starts a session if not already active. * Re-registers all properties that were registered before the session was closed. * * - When you get a `Failed to start session: NOT_ALLOWED` error the client UUID is not accepted by the server, * to fix this use the default UUID by not setting the `clientUuid` option. * - When you get a `Failed to register: NOT_ALLOWED` the device PIN code is incorrect. * * @returns {Promise<void>} A promise that resolves when the session is successfully started. * @throws Will throw an error if the session is already active or in the process of starting, or if the registration or session start fails. */ async startSession() { if (this.sessionState !== SessionState.None) { throw new Error('Session is already active or in the process of starting'); } this.logger.info(`Registering with server as: ${this.deviceName}`); this.sessionState = SessionState.Registering; try { const registerResponse = await this.send(comfoConnect_1.Opcode.REGISTER_DEVICE_REQUEST, { deviceName: this.deviceName, pin: this.options.pin ?? 0, uuid: Buffer.from(this.options.uuid, 'hex'), }); if (registerResponse.resultCode !== comfoConnect_1.Result.OK) { throw new Error(`Failed to register: ${registerResponse.resultName}`); } const sessionResponse = await this.send(comfoConnect_1.Opcode.START_SESSION_REQUEST, { takeover: true }); if (sessionResponse.resultCode !== comfoConnect_1.Result.OK) { throw new Error(`Failed to start session: ${sessionResponse.resultName}`); } } catch (err) { this.sessionState = SessionState.None; throw err; } this.logger.info('Session started with device'); this.sessionState = SessionState.Active; // Re-register all properties that were registered before the session was closed for (const info of Object.values(this.deviceProperties).filter((p) => p.registered)) { // Do not await re-registration to avoid blocking the session start this.requestPropertyUpdates(info).catch(() => { this.logger.warn(`Failed to re-register property: ${info.propertyName ?? 'UNKNOWN'} (${info.propertyId})`); }); } } /** * Call this method to stop the session with the ComfoControl Gateway. */ async stopSession() { if (this.sessionState === SessionState.None) { return; } this.logger.info('Closing session with device'); this.sessionState = SessionState.None; try { await this.send(comfoConnect_1.Opcode.CLOSE_SESSION_REQUEST); } catch (err) { this.logger.error('Failed to close session:', err); } } /** * Sends a request to the ComfoControl device and waits for a response. * Ensures the transport is connected and the session is active before sending the request. * * @template T - The type of the request opcode. * @template R - The type of the response message. * @template TRequest - The type of the request data. * @param {T} opcode - The opcode of the request. * @param {TRequest} [data] - The data to send with the request. * @returns {Promise<ComfoControlMessage<R>>} A promise that resolves to the response message. * @throws Will throw an error if the transport is already connecting, the session is not active, or the response opcode is unexpected. */ async send(opcode, data) { await this.ensureConnected(opcode); const responseOpcode = opcodes_1.requestMessages[opcode]; const requestId = await this.transport.send(opcode, data ?? {}); if (!responseOpcode || responseOpcode === comfoConnect_1.Opcode.NO_OPERATION) { return void 0; } this.pendingReplies[requestId] = new deferredPromise_1.DeferredPromise(); this.pendingReplies[requestId].finally(() => delete this.pendingReplies[requestId]); return (0, asyncUtils_1.timeout)(this.pendingReplies[requestId].then((response) => { if (response.opcode !== responseOpcode) { throw new Error(`Unexpected response opcode: ${comfoConnect_1.Opcode[response.opcode]} (expected: ${comfoConnect_1.Opcode[responseOpcode]})`); } return response; }), this.options.requestTimeout ?? 15000, 'Gateway did not response within the specified timeout period'); } async ensureConnected(opcode) { // Ensure the transport is connected if (!this.transport.isConnected) { if (this.transport.isConnecting) { throw new Error('Transport is already connecting'); } await this.transport.connect(); } // Ensure the session is active if (!this.sessionActive && !SESSION_EXEMPT_OPCODES.includes(opcode)) { await this.startSession(); } } async processMessage(message) { this.logger.verbose(`Recv ${message.opcodeName} (ID: ${message.id}) >> ${message.resultName}`); const responsePromise = this.pendingReplies[message.id]; if (responsePromise) { //throw new Error(`Received response for unknown request ID: ${message.id} (${message.opcodeName}})`); responsePromise.resolve(message); } else if (this.handlers[message.opcode]) { try { await this.handlers[message.opcode](message); } catch (err) { this.logger.error('Error processing message:', err); } } } onNodeNotification(message) { const notification = message.deserialize(); this.logger.info(`Found ${consts_1.NodeProductType[notification.productId]} (${notification.nodeId})`); this.nodes[notification.nodeId] = { id: notification.nodeId, productType: notification.productId, zoneId: notification.zoneId, mode: notification.mode, }; } onPropertyUpdateNotification(message) { const notification = message.deserialize(); const info = this.deviceProperties[notification.pdid]; if (!info) { this.logger.warn(`Received update for unregistered property: ${(0, deviceProperties_1.getPropertyName)(notification.pdid) ?? 'UNKNOWN'} (${notification.pdid})`); return; } const raw = Buffer.from(notification.data); const value = (0, deviceProperties_1.deserializePropertyValue)(info, raw); for (const listener of info.listners) { listener({ propertyId: info.propertyId, propertyName: info.propertyName, dataType: info.dataType, value: info.convert?.(value) ?? value, raw }); } } onNotification() { } onSessionClosed() { this.logger.info('Session closed by ComfoControl server'); this.sessionState = SessionState.None; this.transport.disconnect(); } /** * Retrieves the current server time from the ComfoControl device. * Sends a CN_TIME_REQUEST opcode to the device and processes the response to get the current time. * The time is returned as a Date object. * * @returns {Promise<Date>} A promise that resolves to the current server time as a Date object. * @throws Will throw an error if the request fails or the response is invalid. */ async getServerTime() { const response = await this.send(comfoConnect_1.Opcode.CN_TIME_REQUEST); const msg = response.deserialize(); return new Date(new Date(2000, 1, 1).getTime() + msg.currentTime * 1000); } /** * Registers a listener for updates to a specific device property. * Sends a CN_RPDO_REQUEST opcode to request updates for the specified property. * The listener will be called whenever the property value is updated. * * @param {T} property - The property to listen for updates on. * @param {DevicePropertyListner} listener - The listener function to call when the property is updated. * @returns {Promise<void>} A promise that resolves when the property listener is successfully registered. * @throws Will throw an error if the request to register the property updates fails. */ async registerPropertyListener(property, listener) { const info = this.getDevicePropertyInfo(property); info.listners.push(listener); try { await this.requestPropertyUpdates(property); } catch (err) { (0, arrayUtils_1.removeArrayElement)(info.listners, listener); throw err; } } async requestPropertyUpdates(property) { await this.send(comfoConnect_1.Opcode.CN_RPDO_REQUEST, { pdid: property.propertyId, zone: 1, type: property.dataType, timeout: 0, }); this.getDevicePropertyInfo(property).registered = true; } getDevicePropertyInfo(property) { return (this.deviceProperties[property.propertyId] ?? (this.deviceProperties[property.propertyId] = { ...property, propertyName: (0, deviceProperties_1.getPropertyName)(property.propertyId) ?? 'UNKNOWN', listners: [], registered: false, })); } static wrapLogger(logger, options) { if (options?.logger) { logger.addPrinter({ printLine: (_level, _name, message, args) => options.logger?.log(message, args), }); } if (options?.logLevel) { logger.setLogLevel(options.logLevel); } return logger; } /** * Reads an RMI property from the device. Predefined readable properties are available in the {@link VentilationUnitProperties} class. * * @example * ```typescript * const serial = await client.readProperty(VentilationUnitProperties.NODE.SERIAL_NUMBER); * console.log(`Serial number: ${serial}`); * ``` * * @param prop The property to read. * @returns A promise that resolves to the value of the property. */ async readProperty(prop) { return (0, deviceProperties_1.deserializePropertyValue)(prop, await this.readPropertyRawValue(prop)); } async readPropertyRawValue(prop) { const response = await this.send(comfoConnect_1.Opcode.CN_RMI_REQUEST, { nodeId: prop.node, message: Buffer.from([0x01, prop.unit, prop.subunit ?? 1, 0x10, prop.propertyId]), }); const responseMessage = response.deserialize(); if (responseMessage.result !== rmiProperties_1.ErrorCodes.NO_ERROR) { throw new Error(`Failed to read property: ${rmiProperties_1.ErrorCodes[responseMessage.result] ?? 'UNKNOWN'} (${responseMessage.result})`); } return Buffer.from(responseMessage.message); } // public async readProperties<T extends NodeProperty>(props: T[]) : Promise<PropertyNativeType<T>> { // if (props.length > 8) { // throw new Error('Cannot read more than 8 properties at once'); // } // const targets = props.map(prop => [prop.node, prop.unit, prop.subunit ?? 1].join(':')); // if (new Set(targets).size > 1) { // throw new Error('Properties must be from the same node, unit and subunit'); // } // const response = await this.send(Opcode.CN_RMI_REQUEST, { // nodeId: props[0].node, // message: Buffer.from([0x02, props[0].unit, props[0].subunit ?? 1, 0x10 | props.length, ...props.map(p => p.propertyId)]), // }); // const responseMessage = response.deserialize(); // } /** * Writes a property to the device. Predefined writable properties are available in the {@link VentilationUnitProperties} class. * * This methods executes a write operation on the device and waits for a comfirmation from the gateway that the operation was successful. * If the operation fails, an error will be thrown. See {@link ErrorCodes} for a list of possible error codes that can be thrown. * * @param prop The property to write. * @param value The value to write to the property. */ async writeProperty(prop, value) { if (prop.access === 'ro') { throw new Error(`Property ${prop.node}:${prop.unit}:${prop.subunit ?? 1}:${prop.propertyId} is read-only and cannot be written.`); } const message = Buffer.concat([ Buffer.from([0x03, prop.unit, prop.subunit ?? 1, prop.propertyId]), (0, deviceProperties_1.serializePropertyValue)(prop, value), ]); const response = await this.send(comfoConnect_1.Opcode.CN_RMI_REQUEST, { nodeId: prop.node, message }); const responseMessage = response.deserialize(); if (responseMessage.result !== rmiProperties_1.ErrorCodes.NO_ERROR) { throw new Error(`Failed to write property: ${rmiProperties_1.ErrorCodes[responseMessage.result] ?? 'UNKNOWN'} (${responseMessage.result})`); } } /** * Sets the fan mode of the ventilation unit. * @param mode The fan mode to set. */ setFanMode(mode) { if (mode < FanMode.Away || mode > FanMode.High) { throw new Error(`Invalid fan mode: ${mode}`); } return this.executeRmiCommand(0x84, 0x15, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, mode); } /** * Enables or disables bypass of the heat exchanger for the ventilation unit when true or * resets the bypass to automatic mode when false. * @param bypassEnabled True to enable bypass, false to set to automatic mode. */ enableBypass(bypassEnabled) { if (bypassEnabled === true) { return this.executeRmiCommand(0x84, 0x15, 2, 1, 0, 0, 0, 0, 0x10, 0x0e, 0, 0, 1); } return this.executeRmiCommand(0x84, 0x15, 2, 1); } /** * Set the temperature profile for the ventilation unit. * @param profile The temperature profile to set. */ setTempratureProfile(profile) { if (TemperatureProfile[profile] === undefined) { throw new Error(`Invalid temperature profile: ${profile}`); } return this.executeRmiCommand(0x84, 0x15, 3, 1, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff, profile); } /** * Sets the operating mode of the ventilation unit. * @param mode The operating mode to set. */ setOperatingMode(mode) { switch (mode) { case OperationMode.Auto: return this.executeRmiCommand(0x84, 0x15, 8, 0); case OperationMode.Manual: return this.executeRmiCommand(0x84, 0x15, 8, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1); default: throw new Error(`Invalid operation mode: ${mode}`); } } async executeRmiCommand(...bytes) { const message = Buffer.from(bytes); const response = await this.send(comfoConnect_1.Opcode.CN_RMI_REQUEST, { nodeId: 1, message }); const responseMessage = response.deserialize(); if (responseMessage.result !== rmiProperties_1.ErrorCodes.NO_ERROR) { throw new Error(`Failed to execute command: ${rmiProperties_1.ErrorCodes[responseMessage.result] ?? 'UNKNOWN'} (${responseMessage.result})`); } } } exports.ComfoControlClient = ComfoControlClient;