homebridge-systemair-ventilator
Version:
A Homebridge 2 plugin for controlling Systemair Ventilators.
324 lines (262 loc) • 10.1 kB
JavaScript
'use strict';
const axios = require('axios');
let Service;
let Characteristic;
const PLUGIN_NAME = 'homebridge-systemair-ventilator';
const ACCESSORY_NAME = 'SystemairVentilator';
class SystemairVentilator {
constructor(log, config, api) {
this.log = log;
this.config = config || {};
this.api = api;
this.name = this.config.name || 'Systemair Ventilator';
this.ip = this.config.ip;
this.timeout = Number(this.config.timeout || 20000);
this.lastFanSpeed = 2;
this.cachedActive = Characteristic.Active.INACTIVE;
this.cachedRotationSpeed = 16;
this.cachedTimer = 0;
if (!this.ip) {
this.log.error('Missing required config value: ip');
throw new Error('Missing required config value: ip');
}
this.axiosInstance = axios.create({
baseURL: `http://${this.ip}`,
timeout: this.timeout,
});
this.informationService = new Service.AccessoryInformation()
.setCharacteristic(Characteristic.Manufacturer, 'Systemair')
.setCharacteristic(Characteristic.Model, 'Ventilator')
.setCharacteristic(Characteristic.Name, this.name)
.setCharacteristic(Characteristic.SerialNumber, this.ip);
this.fanService = new Service.Fanv2(`${this.name} Fan`);
this.refreshService = new Service.Switch(`${this.name} Refresh`);
const BatteryService = Service.Battery || Service.BatteryService;
if (BatteryService) {
this.timerService = new BatteryService(`${this.name} Timer`);
} else {
this.log.warn('Battery service is not available in this Homebridge/HAP version. Timer service disabled.');
this.timerService = null;
}
this.setupCharacteristics();
this.log(`SystemairVentilator loaded for ${this.name} at ${this.ip}`);
}
setupCharacteristics() {
this.fanService
.getCharacteristic(Characteristic.Active)
.onSet(async (value) => {
try {
await this.setActive(value);
} catch (error) {
this.log.error(`SetActive failed: ${error.message}`);
}
})
.onGet(async () => {
try {
return await this.getActive();
} catch (error) {
this.log.error(`GetActive failed: ${error.message}`);
return this.cachedActive;
}
});
this.fanService
.getCharacteristic(Characteristic.RotationSpeed)
.setProps({
minValue: 0,
maxValue: 100,
minStep: 1,
})
.onSet(async (value) => {
try {
await this.setRotationSpeed(value);
} catch (error) {
this.log.error(`SetRotationSpeed failed: ${error.message}`);
}
})
.onGet(async () => {
try {
return await this.getRotationSpeed();
} catch (error) {
this.log.error(`GetRotationSpeed failed: ${error.message}`);
return this.cachedRotationSpeed;
}
});
this.refreshService
.getCharacteristic(Characteristic.On)
.onSet(async (value) => {
try {
await this.setRefresh(value);
} catch (error) {
this.log.error(`Refresh failed: ${error.message}`);
this.refreshService.getCharacteristic(Characteristic.On).updateValue(false);
}
})
.onGet(async () => false);
if (this.timerService) {
this.timerService
.getCharacteristic(Characteristic.BatteryLevel)
.onGet(async () => {
try {
return await this.getTimer();
} catch (error) {
this.log.error(`Timer read failed: ${error.message}`);
return this.cachedTimer;
}
});
this.timerService
.getCharacteristic(Characteristic.StatusLowBattery)
.onGet(async () => Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL);
}
}
async retryRequest(path, retries = 3) {
let lastError;
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
return await this.axiosInstance.get(path);
} catch (error) {
lastError = error;
this.log.warn(`Request failed (${attempt}/${retries}): ${error.message}`);
if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
throw lastError;
}
async mread(address, count = 1) {
const response = await this.retryRequest(`/mread?{"${address}":${count}}`);
const value = response.data[String(address)];
if (value === undefined || value === null) {
throw new Error(`No value returned for address ${address}`);
}
return value;
}
async mwrite(values) {
const payload = JSON.stringify(values);
await this.retryRequest(`/mwrite?${payload}`);
}
fanSpeedToPercentage(speed) {
switch (Number(speed)) {
case 2:
return 16;
case 3:
return 50;
case 4:
return 83;
default:
return 0;
}
}
percentageToFanSpeed(value) {
const percentage = Number(value);
if (percentage <= 0) {
return 0;
}
if (percentage <= 16) {
return 2;
}
if (percentage <= 50) {
return 3;
}
return 4;
}
async setActive(value) {
const speed = value ? this.lastFanSpeed || 2 : 0;
this.log(`SetActive: ${value ? 'ON' : 'OFF'} using Systemair speed ${speed}`);
await this.mwrite({ 1130: speed });
this.cachedActive = speed > 0 ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE;
this.cachedRotationSpeed = this.fanSpeedToPercentage(speed);
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(this.cachedRotationSpeed);
}
async getActive() {
const speed = Number(await this.mread(1130, 1));
const isActive = speed > 0;
if (isActive) {
this.lastFanSpeed = speed;
}
this.cachedActive = isActive ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE;
this.log(`GetActive: ${isActive ? 'ON' : 'OFF'} with Systemair speed ${speed}`);
return this.cachedActive;
}
async setRotationSpeed(value) {
const speed = this.percentageToFanSpeed(value);
if (speed > 0) {
this.lastFanSpeed = speed;
}
this.log(`SetRotationSpeed: ${value}% -> Systemair speed ${speed}`);
await this.mwrite({ 1130: speed });
this.cachedActive = speed > 0 ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE;
this.cachedRotationSpeed = this.fanSpeedToPercentage(speed);
this.fanService.getCharacteristic(Characteristic.Active).updateValue(this.cachedActive);
}
async getRotationSpeed() {
const speed = Number(await this.mread(1130, 1));
const percentage = this.fanSpeedToPercentage(speed);
if (speed > 0) {
this.lastFanSpeed = speed;
}
this.cachedRotationSpeed = percentage;
this.cachedActive = speed > 0 ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE;
this.log(`GetRotationSpeed: Systemair speed ${speed} -> ${percentage}%`);
return percentage;
}
async setRefresh(value) {
if (!value) {
return;
}
const previousSpeed = this.lastFanSpeed || 2;
this.log('Refresh: starting refresh mode');
await this.mwrite({
1130: 2,
1161: 4,
2000: 180,
2504: 0,
16100: 0,
});
setTimeout(() => {
this.refreshService.getCharacteristic(Characteristic.On).updateValue(false);
}, 1000);
setTimeout(async () => {
if (previousSpeed === 2) {
return;
}
try {
this.log(`Refresh: restoring previous Systemair speed ${previousSpeed}`);
await this.mwrite({ 1130: previousSpeed });
this.cachedRotationSpeed = this.fanSpeedToPercentage(previousSpeed);
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(this.cachedRotationSpeed);
} catch (error) {
this.log.error(`Refresh: failed to restore speed: ${error.message}`);
}
}, 2000);
}
async getTimer() {
let timerValue = Number(await this.mread(1110, 2));
if (Number.isNaN(timerValue)) {
timerValue = 0;
}
timerValue = Math.min(100, Math.max(0, timerValue));
this.cachedTimer = timerValue;
this.log(`Timer: ${timerValue} minutes remaining`);
return timerValue;
}
identify(callback) {
this.log(`Identify requested for ${this.name}`);
if (typeof callback === 'function') {
callback();
}
}
getServices() {
return [
this.informationService,
this.fanService,
this.refreshService,
this.timerService,
].filter(Boolean);
}
}
module.exports = (api) => {
Service = api.hap.Service;
Characteristic = api.hap.Characteristic;
api.registerAccessory(PLUGIN_NAME, ACCESSORY_NAME, SystemairVentilator);
};