lib-comfoair
Version:
Library to communicate with Zehnder ComfoAirQ ventilation unit through the ComfoControl gateway
455 lines (454 loc) • 21.5 kB
JavaScript
"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;