homebridge-rabbitair
Version:
Homebridge plugin for RabbitAir air purifiers
686 lines (685 loc) • 32.4 kB
JavaScript
import * as CryptoJS from 'crypto-js';
import { createSocket } from 'dgram';
export var RabbitAirMode;
(function (RabbitAirMode) {
RabbitAirMode[RabbitAirMode["Auto"] = 0] = "Auto";
RabbitAirMode[RabbitAirMode["Pollen"] = 1] = "Pollen";
RabbitAirMode[RabbitAirMode["Manual"] = 2] = "Manual";
})(RabbitAirMode || (RabbitAirMode = {}));
export var RabbitAirSpeed;
(function (RabbitAirSpeed) {
RabbitAirSpeed[RabbitAirSpeed["SuperSilent"] = 0] = "SuperSilent";
RabbitAirSpeed[RabbitAirSpeed["Silent"] = 1] = "Silent";
RabbitAirSpeed[RabbitAirSpeed["Low"] = 2] = "Low";
RabbitAirSpeed[RabbitAirSpeed["Medium"] = 3] = "Medium";
RabbitAirSpeed[RabbitAirSpeed["High"] = 4] = "High";
RabbitAirSpeed[RabbitAirSpeed["Turbo"] = 5] = "Turbo";
})(RabbitAirSpeed || (RabbitAirSpeed = {}));
export var RabbitAirQuality;
(function (RabbitAirQuality) {
RabbitAirQuality[RabbitAirQuality["Lowest"] = 0] = "Lowest";
RabbitAirQuality[RabbitAirQuality["Low"] = 1] = "Low";
RabbitAirQuality[RabbitAirQuality["Medium"] = 2] = "Medium";
RabbitAirQuality[RabbitAirQuality["High"] = 3] = "High";
RabbitAirQuality[RabbitAirQuality["Highest"] = 4] = "Highest";
})(RabbitAirQuality || (RabbitAirQuality = {}));
export var RabbitAirSensitivity;
(function (RabbitAirSensitivity) {
RabbitAirSensitivity[RabbitAirSensitivity["High"] = 0] = "High";
RabbitAirSensitivity[RabbitAirSensitivity["Medium"] = 1] = "Medium";
RabbitAirSensitivity[RabbitAirSensitivity["Low"] = 2] = "Low";
})(RabbitAirSensitivity || (RabbitAirSensitivity = {}));
export class RabbitAirClient {
socket = null;
host;
port;
token;
commandId = Math.floor(Math.random() * 0x1000000);
tsDiff = null;
isConnected = false;
logger;
TIMEOUT_MS = 10000; // Increased timeout to 10 seconds
MAX_RETRIES = 3;
connectionAttempts = 0;
MAX_CONNECTION_ATTEMPTS = 5;
constructor(config, logger) {
this.logger = logger;
this.host = config.host;
this.port = config.port || 9009;
this.logger.debug(`Initializing RabbitAirClient for host: ${this.host}:${this.port}`);
if (!config.token || config.token.length !== 32) {
this.logger.error('Invalid token length. Token must be 32 characters (16 bytes hex)');
throw new Error('Invalid token length');
}
this.token = Buffer.from(config.token, 'hex');
this.logger.debug(`Token loaded successfully (length: ${this.token.length} bytes)`);
this.logger.debug(`Initial command ID: ${this.commandId}`);
}
nextId() {
const newId = ++this.commandId;
this.logger.debug(`Generated new command ID: ${newId}`);
return newId;
}
getClock() {
const timestamp = Date.now() / 1000;
this.logger.debug(`Current clock time: ${timestamp}`);
return timestamp;
}
getTimestamp() {
if (this.tsDiff === null) {
this.logger.error('Attempting to get timestamp before synchronization');
throw new Error('Timestamp not synchronized');
}
const timestamp = Math.round(this.getClock() + this.tsDiff);
this.logger.debug(`Generated timestamp: ${timestamp} (tsDiff: ${this.tsDiff})`);
return timestamp;
}
encrypt(data) {
this.logger.debug(`Encrypting data (length: ${data.length} bytes)`);
// Convert to CryptoJS format
const key = CryptoJS.enc.Hex.parse(this.token.toString('hex'));
const iv = CryptoJS.lib.WordArray.random(16);
const message = CryptoJS.enc.Utf8.parse(data.toString());
const encrypted = CryptoJS.AES.encrypt(message, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
// Combine encrypted data with IV
const combined = encrypted.ciphertext.concat(iv);
const result = Buffer.from(combined.toString(CryptoJS.enc.Base64), 'base64');
this.logger.debug(`Encrypted data (length: ${result.length} bytes)`);
return result;
}
decrypt(data) {
this.logger.debug(`Decrypting data (length: ${data.length} bytes)`);
try {
// Extract IV from the end of the message
const iv = data.slice(-16);
const encrypted = data.slice(0, -16);
// Convert to CryptoJS format
const key = CryptoJS.enc.Hex.parse(this.token.toString('hex'));
const ivWords = CryptoJS.enc.Hex.parse(iv.toString('hex'));
const encryptedWords = CryptoJS.enc.Hex.parse(encrypted.toString('hex'));
const decrypted = CryptoJS.AES.decrypt({ ciphertext: encryptedWords }, key, { iv: ivWords, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 });
const result = Buffer.from(decrypted.toString(CryptoJS.enc.Utf8), 'utf8');
this.logger.debug(`Decrypted data (length: ${result.length} bytes): ${result.toString()}`);
return result;
}
catch (error) {
this.logger.error(`Decryption failed: ${error}`);
throw new Error(`Decryption failed: ${error}`);
}
}
async sendCommand(command) {
this.logger.debug(`Sending command: ${JSON.stringify(command)}`);
return new Promise((resolve, reject) => {
if (!this.socket) {
this.logger.error('Cannot send command: Socket not connected');
return reject(new Error('Socket not connected'));
}
const requestId = this.nextId();
command.id = requestId;
this.logger.debug(`Command assigned ID: ${requestId}`);
if (this.token) {
this.logger.debug('Using encrypted communication');
if (this.tsDiff === null) {
this.logger.debug('Timestamp not synchronized, initiating sync');
// First, synchronize timestamp
const tsRequest = { id: this.nextId(), cmd: 9 };
this.logger.debug(`Sending timestamp sync request: ${JSON.stringify(tsRequest)}`);
const tsData = JSON.stringify(tsRequest);
const encryptedTsData = this.encrypt(Buffer.from(tsData));
this.socket.send(encryptedTsData, this.port, this.host, (err) => {
if (err) {
this.logger.error(`Failed to send timestamp sync request: ${err.message}`);
reject(err);
}
else {
this.logger.debug('Timestamp sync request sent successfully');
}
});
// Wait for timestamp response
const onTsMessage = (msg) => {
this.logger.debug(`Received timestamp sync response (${msg.length} bytes)`);
try {
const decrypted = this.decrypt(msg);
const response = JSON.parse(decrypted.toString());
this.logger.debug(`Timestamp sync response: ${JSON.stringify(response)}`);
if (response.id === tsRequest.id) {
this.tsDiff = response.data.ts - this.getClock();
this.logger.debug(`Timestamp synchronized. Diff: ${this.tsDiff}`);
this.socket.removeListener('message', onTsMessage);
// Now send the actual command
command.ts = this.getTimestamp();
this.logger.debug(`Sending actual command with timestamp: ${JSON.stringify(command)}`);
const commandData = JSON.stringify(command);
const encryptedCommandData = this.encrypt(Buffer.from(commandData));
this.socket.send(encryptedCommandData, this.port, this.host, (err) => {
if (err) {
this.logger.error(`Failed to send command: ${err.message}`);
reject(err);
}
else {
this.logger.debug('Command sent successfully');
}
});
// Wait for command response
const onCommandMessage = (msg) => {
this.logger.debug(`Received command response (${msg.length} bytes)`);
try {
const decrypted = this.decrypt(msg);
const response = JSON.parse(decrypted.toString());
this.logger.debug(`Command response: ${JSON.stringify(response)}`);
if (response.id === requestId) {
this.socket.removeListener('message', onCommandMessage);
if (response.error) {
this.logger.error(`Command failed with protocol error: ${JSON.stringify(response.error)}`);
reject(new Error('Protocol error'));
}
else {
this.logger.debug('Command completed successfully');
resolve(response);
}
}
}
catch (error) {
this.logger.debug(`Ignoring parsing error for unexpected message: ${error}`);
// Ignore parsing errors for unexpected messages
}
};
this.socket.on('message', onCommandMessage);
// Set timeout
setTimeout(() => {
this.socket.removeListener('message', onCommandMessage);
this.logger.error(`Command timeout after ${this.TIMEOUT_MS}ms`);
reject(new Error('Command timeout'));
}, this.TIMEOUT_MS);
}
}
catch (error) {
this.logger.debug(`Ignoring parsing error for unexpected timestamp message: ${error}`);
// Ignore parsing errors for unexpected messages
}
};
this.socket.on('message', onTsMessage);
// Set timeout for timestamp sync
setTimeout(() => {
this.socket.removeListener('message', onTsMessage);
this.logger.error(`Timestamp sync timeout after ${this.TIMEOUT_MS}ms`);
reject(new Error('Timestamp sync timeout'));
}, this.TIMEOUT_MS);
}
else {
this.logger.debug('Timestamp already synchronized, sending command directly');
// Timestamp already synchronized
command.ts = this.getTimestamp();
this.logger.debug(`Sending command with timestamp: ${JSON.stringify(command)}`);
const commandData = JSON.stringify(command);
const encryptedCommandData = this.encrypt(Buffer.from(commandData));
this.socket.send(encryptedCommandData, this.port, this.host, (err) => {
if (err) {
this.logger.error(`Failed to send command: ${err.message}`);
reject(err);
}
else {
this.logger.debug('Command sent successfully');
}
});
// Wait for command response
const onMessage = (msg) => {
this.logger.debug(`Received command response (${msg.length} bytes)`);
try {
const decrypted = this.decrypt(msg);
const response = JSON.parse(decrypted.toString());
this.logger.debug(`Command response: ${JSON.stringify(response)}`);
if (response.id === requestId) {
this.socket.removeListener('message', onMessage);
if (response.error) {
this.logger.error(`Command failed with protocol error: ${JSON.stringify(response.error)}`);
reject(new Error('Protocol error'));
}
else {
this.logger.debug('Command completed successfully');
resolve(response);
}
}
}
catch (error) {
this.logger.debug(`Ignoring parsing error for unexpected message: ${error}`);
// Ignore parsing errors for unexpected messages
}
};
this.socket.on('message', onMessage);
// Set timeout
setTimeout(() => {
if (this.socket) {
this.socket.removeListener('message', onMessage);
}
this.logger.error(`Command timeout after ${this.TIMEOUT_MS}ms`);
reject(new Error('Command timeout'));
}, this.TIMEOUT_MS);
}
}
else {
this.logger.debug('Using unencrypted communication');
// No encryption
const commandData = JSON.stringify(command);
this.logger.debug(`Sending unencrypted command: ${commandData}`);
this.socket.send(commandData, this.port, this.host, (err) => {
if (err) {
this.logger.error(`Failed to send unencrypted command: ${err.message}`);
reject(err);
}
else {
this.logger.debug('Unencrypted command sent successfully');
}
});
// Wait for response
const onMessage = (msg) => {
this.logger.debug(`Received unencrypted response (${msg.length} bytes): ${msg.toString()}`);
try {
const response = JSON.parse(msg.toString());
this.logger.debug(`Unencrypted response: ${JSON.stringify(response)}`);
if (response.id === requestId) {
this.socket.removeListener('message', onMessage);
if (response.error) {
this.logger.error(`Unencrypted command failed with protocol error: ${JSON.stringify(response.error)}`);
reject(new Error('Protocol error'));
}
else {
this.logger.debug('Unencrypted command completed successfully');
resolve(response);
}
}
}
catch (error) {
this.logger.debug(`Ignoring parsing error for unexpected unencrypted message: ${error}`);
// Ignore parsing errors for unexpected messages
}
};
this.socket.on('message', onMessage);
// Set timeout
setTimeout(() => {
if (this.socket) {
this.socket.removeListener('message', onMessage);
}
this.logger.error(`Unencrypted command timeout after ${this.TIMEOUT_MS}ms`);
reject(new Error('Command timeout'));
}, this.TIMEOUT_MS);
}
});
}
async fallbackUnencryptedCommand(command) {
this.logger.warn('Attempting fallback to unencrypted communication');
return new Promise((resolve, reject) => {
if (!this.socket) {
this.logger.error('Cannot send fallback command: Socket not connected');
return reject(new Error('Socket not connected'));
}
const requestId = this.nextId();
command.id = requestId;
const commandData = JSON.stringify(command);
this.logger.debug(`Sending fallback unencrypted command: ${commandData}`);
this.socket.send(commandData, this.port, this.host, (err) => {
if (err) {
this.logger.error(`Failed to send fallback command: ${err.message}`);
reject(err);
}
else {
this.logger.debug('Fallback command sent successfully');
}
});
// Wait for response
const onMessage = (msg) => {
this.logger.debug(`Received fallback response (${msg.length} bytes): ${msg.toString()}`);
try {
const response = JSON.parse(msg.toString());
this.logger.debug(`Fallback response: ${JSON.stringify(response)}`);
if (response.id === requestId) {
this.socket.removeListener('message', onMessage);
if (response.error) {
this.logger.error(`Fallback command failed: ${JSON.stringify(response.error)}`);
reject(new Error('Protocol error'));
}
else {
this.logger.debug('Fallback command completed successfully');
resolve(response);
}
}
}
catch (error) {
this.logger.debug(`Ignoring parsing error for unexpected fallback message: ${error}`);
}
};
this.socket.on('message', onMessage);
// Set timeout
setTimeout(() => {
if (this.socket) {
this.socket.removeListener('message', onMessage);
}
this.logger.error(`Fallback command timeout after ${this.TIMEOUT_MS}ms`);
reject(new Error('Fallback command timeout'));
}, this.TIMEOUT_MS);
});
}
async testConnection() {
this.logger.debug('Testing connection to device');
if (!this.socket || !this.isConnected) {
this.logger.debug('Socket not connected, attempting to connect');
try {
await this.connect();
}
catch (error) {
this.logger.error(`Failed to connect during connection test: ${error}`);
return false;
}
}
try {
// Try a simple ping command
const testCommand = { cmd: 255 }; // Info command
const result = await this.sendCommandWithRetry(testCommand, 1);
this.logger.debug('Connection test successful');
return !!result;
}
catch (error) {
this.logger.error(`Connection test failed: ${error}`);
// Reset connection state on failure
this.isConnected = false;
this.tsDiff = null;
return false;
}
}
async sendCommandWithRetry(command, retries = this.MAX_RETRIES) {
let lastError = null;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
this.logger.debug(`Sending command attempt ${attempt}/${retries}`);
return await this.sendCommand(command);
}
catch (error) {
lastError = error;
this.logger.warn(`Command attempt ${attempt}/${retries} failed: ${lastError.message}`);
if (attempt < retries) {
// Reset timestamp diff on retry to force re-sync
if (lastError.message.includes('Timestamp sync timeout')) {
this.logger.debug('Resetting timestamp diff due to sync timeout');
this.tsDiff = null;
}
// Wait before retry with exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
this.logger.debug(`Waiting ${delay}ms before retry`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// If all encrypted attempts failed, try fallback unencrypted communication
if (this.token &&
lastError &&
lastError.message.includes('Timestamp sync timeout')) {
this.logger.warn('All encrypted attempts failed, trying fallback unencrypted communication');
try {
return await this.fallbackUnencryptedCommand(command);
}
catch (fallbackError) {
this.logger.error(`Fallback communication also failed: ${fallbackError}`);
throw lastError; // Throw the original error
}
}
throw lastError;
}
async connect() {
this.logger.debug(`Attempting to connect to ${this.host}:${this.port} (attempt ${this.connectionAttempts + 1}/${this.MAX_CONNECTION_ATTEMPTS})`);
if (this.isConnected) {
this.logger.debug('Already connected, skipping connection');
return;
}
if (this.connectionAttempts >= this.MAX_CONNECTION_ATTEMPTS) {
const error = new Error(`Max connection attempts (${this.MAX_CONNECTION_ATTEMPTS}) exceeded`);
this.logger.error(error.message);
throw error;
}
this.connectionAttempts++;
// Clean up any existing socket
if (this.socket) {
this.logger.debug('Cleaning up existing socket');
this.socket.removeAllListeners();
this.socket.close();
this.socket = null;
this.isConnected = false;
}
return new Promise((resolve, reject) => {
this.socket = createSocket('udp4');
this.logger.debug('Created UDP socket');
// Set up error handling
this.socket.on('error', (err) => {
this.logger.error(`Socket error: ${err.message}`);
this.isConnected = false;
reject(err);
});
this.socket.on('listening', () => {
try {
const address = this.socket.address();
this.logger.debug(`Socket listening on ${address.address}:${address.port}`);
this.isConnected = true;
this.connectionAttempts = 0; // Reset on successful connection
this.logger.info(`Successfully connected to RabbitAir device at ${this.host}:${this.port}`);
resolve();
}
catch (error) {
this.logger.error(`Error getting socket address: ${error}`);
this.isConnected = false;
reject(error);
}
});
// Set a timeout for binding
const bindTimeout = setTimeout(() => {
this.logger.error('Socket bind timeout');
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.isConnected = false;
reject(new Error('Socket bind timeout'));
}, 5000);
try {
this.socket.bind(() => {
clearTimeout(bindTimeout);
});
}
catch (error) {
clearTimeout(bindTimeout);
this.logger.error(`Failed to bind socket: ${error}`);
reject(error);
}
});
}
async disconnect() {
this.logger.debug('Attempting to disconnect');
if (this.socket) {
return new Promise((resolve) => {
this.socket.close(() => {
this.logger.debug('Socket closed successfully');
this.socket = null;
this.isConnected = false;
this.tsDiff = null;
this.logger.info('Disconnected from RabbitAir device');
resolve();
});
});
}
else {
this.logger.debug('No socket to disconnect');
}
}
async getState() {
this.logger.debug('Requesting device state');
try {
// Test connection first
const isConnected = await this.testConnection();
if (!isConnected) {
throw new Error('Device not reachable');
}
const response = await this.sendCommandWithRetry({ cmd: 4 });
const data = response.data;
if (!data) {
this.logger.error('Invalid response data: no data field found');
throw new Error('Invalid response data');
}
const state = {
power: data.power,
mode: data.mode,
speed: data.speed,
quality: data.quality,
sensitivity: data.sensitivity,
ionizer: data.ionizer,
filterLife: data.filter_life,
filterCleaning: data.filter_cleaning,
filterReplacement: data.filter_replacement,
error: data.error,
rssi: data.rssi
};
this.logger.debug(`Device state retrieved: ${JSON.stringify(state)}`);
return state;
}
catch (error) {
this.logger.error(`Failed to get device state: ${error}`);
// Don't disconnect on error - let it retry
throw error;
}
}
async setState(state) {
this.logger.debug(`Setting device state: ${JSON.stringify(state)}`);
try {
// Test connection first
const isConnected = await this.testConnection();
if (!isConnected) {
throw new Error('Device not reachable');
}
const data = {};
if (state.power !== undefined) {
data.power = state.power;
this.logger.debug(`Setting power: ${state.power}`);
}
if (state.mode !== undefined) {
data.mode = state.mode;
this.logger.debug(`Setting mode: ${state.mode} (${RabbitAirMode[state.mode]})`);
}
if (state.speed !== undefined) {
data.speed = state.speed;
this.logger.debug(`Setting speed: ${state.speed} (${RabbitAirSpeed[state.speed]})`);
}
if (state.sensitivity !== undefined) {
data.sensitivity = state.sensitivity;
this.logger.debug(`Setting sensitivity: ${state.sensitivity} (${RabbitAirSensitivity[state.sensitivity]})`);
}
if (state.ionizer !== undefined) {
data.ionizer = state.ionizer;
this.logger.debug(`Setting ionizer: ${state.ionizer}`);
}
this.logger.debug(`Command data to send: ${JSON.stringify(data)}`);
await this.sendCommandWithRetry({ cmd: 4, data: data });
this.logger.debug('Device state updated successfully');
}
catch (error) {
this.logger.error(`Failed to set device state: ${error}`);
// Don't disconnect on error - let it retry
throw error;
}
}
async getInfo() {
this.logger.debug('Requesting device info');
try {
// Test connection first
const isConnected = await this.testConnection();
if (!isConnected) {
throw new Error('Device not reachable');
}
const response = await this.sendCommandWithRetry({ cmd: 255 });
const data = response.data;
const info = {
name: data.name,
mac: data.mac,
model: data.model,
firmware: data.fv,
uptime: data.uptime
};
this.logger.debug(`Device info retrieved: ${JSON.stringify(info)}`);
return info;
}
catch (error) {
this.logger.error(`Failed to get device info: ${error}`);
// Don't disconnect on error - let it retry
throw error;
}
}
async checkDeviceCapabilities() {
this.logger.debug('Checking device capabilities');
let supportsEncryption = false;
let responsive = false;
try {
// First try unencrypted communication
this.logger.debug('Testing unencrypted communication');
const unencryptedResult = await this.fallbackUnencryptedCommand({
cmd: 255
});
if (unencryptedResult) {
responsive = true;
this.logger.debug('Device responds to unencrypted commands');
}
}
catch (error) {
this.logger.debug(`Unencrypted communication failed: ${error}`);
}
if (this.token) {
try {
// Try encrypted communication
this.logger.debug('Testing encrypted communication');
const encryptedResult = await this.sendCommand({ cmd: 255 });
if (encryptedResult) {
supportsEncryption = true;
responsive = true;
this.logger.debug('Device supports encrypted communication');
}
}
catch (error) {
this.logger.debug(`Encrypted communication failed: ${error}`);
}
}
this.logger.info(`Device capabilities: responsive=${responsive}, supportsEncryption=${supportsEncryption}`);
return { supportsEncryption, responsive };
}
async maintainConnection() {
this.logger.debug('Maintaining connection to device');
try {
if (!this.socket || !this.isConnected) {
this.logger.debug('Connection lost, attempting to reconnect');
await this.connect();
}
// Send a keep-alive ping
const response = await this.sendCommandWithRetry({ cmd: 255 }, 1);
if (response) {
this.logger.debug('Keep-alive successful');
}
}
catch (error) {
this.logger.warn(`Keep-alive failed: ${error}`);
// Reset connection state
this.isConnected = false;
this.tsDiff = null;
}
}
// Add graceful shutdown method
async shutdown() {
this.logger.debug('Shutting down RabbitAir client');
try {
if (this.socket) {
this.socket.removeAllListeners();
await this.disconnect();
}
}
catch (error) {
this.logger.error(`Error during shutdown: ${error}`);
}
}
}