homebridge-localtuya
Version:
Homebridge plugin for local control of Tuya devices
376 lines • 15.8 kB
JavaScript
import TuyAPI from 'tuyapi';
// Known initialization DPS codes that may appear during power-up
const INIT_DPS = ['33', '35'];
function isValidResponse(response, isReconnecting = false) {
if (!response || typeof response !== 'object') {
return false;
}
const dps = response.dps;
if (!dps || typeof dps !== 'object') {
return false;
}
// During reconnection or if only initialization DPS codes are present, wait for next update
if (isReconnecting || Object.keys(dps).every(key => INIT_DPS.includes(key))) {
return true;
}
// During normal operation, validate expected properties
return ((('51' in dps && typeof dps['51'] === 'boolean') ||
('53' in dps && typeof dps['53'] === 'number') ||
('20' in dps && typeof dps['20'] === 'boolean') ||
('22' in dps && typeof dps['22'] === 'number')));
}
function parseDpsValue(dps, key, defaultValue) {
if (!(key in dps)) {
return defaultValue;
}
const value = dps[key];
switch (key) {
case '51': // Fan active
case '20': // Light on
return value === true;
case '53': // Fan speed
return typeof value === 'number' ? Math.max(1, Math.min(6, value)) : 1;
case '22': // Light brightness
return typeof value === 'number' ? Math.max(10, Math.min(1000, value)) : 10;
default:
return defaultValue;
}
}
const MAX_RETRIES = 3;
const BASE_RETRY_DELAY = 5000; // 5 seconds initial delay
const REFRESH_INTERVAL = 10000; // 10 seconds between refreshes
const MAX_RETRY_DELAY = 300000; // 5 minutes maximum retry delay
const OPERATION_TIMEOUT = 1000; // 1 second timeout for device operations
export class TuyaAccessory {
platform;
accessory;
fanService;
lightService;
device;
state = {
fanActive: false,
fanSpeed: 0,
lightOn: false,
lightBrightness: 0,
lastUpdate: 0,
isOnline: true,
retryCount: 0,
lastConnectionAttempt: 0,
consecutiveTimeouts: 0,
};
cacheTimeout = 500; // Cache timeout in milliseconds
refreshInterval;
retryTimeout = null;
constructor(platform, accessory) {
this.platform = platform;
this.accessory = accessory;
const deviceInfo = accessory.context.device;
// Initialize Tuya device
this.device = new TuyAPI({
id: deviceInfo.id,
ip: deviceInfo.ip,
key: deviceInfo.key,
version: 3.3,
});
// Set up event handlers
this.device.on('error', this.handleDeviceError.bind(this));
this.device.on('connected', this.handleDeviceConnected.bind(this));
this.device.on('disconnected', this.handleDeviceDisconnected.bind(this));
// Start periodic state refresh
this.refreshInterval = setInterval(this.refreshState.bind(this), REFRESH_INTERVAL);
// Set up status reporting characteristic
const infoService = this.accessory.getService(this.platform.Service.AccessoryInformation);
if (!infoService.testCharacteristic(this.platform.Characteristic.StatusActive)) {
infoService.addCharacteristic(this.platform.Characteristic.StatusActive);
}
infoService.updateCharacteristic(this.platform.Characteristic.StatusActive, this.state.isOnline);
// Set accessory information
this.accessory.getService(this.platform.Service.AccessoryInformation)
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'Designers Fountain')
.setCharacteristic(this.platform.Characteristic.Model, 'Ceiling Fan DF')
.setCharacteristic(this.platform.Characteristic.SerialNumber, deviceInfo.id);
// Fan service
this.fanService = this.accessory.getService(this.platform.Service.Fanv2) ||
this.accessory.addService(this.platform.Service.Fanv2);
// Light service
this.lightService = this.accessory.getService(this.platform.Service.Lightbulb) ||
this.accessory.addService(this.platform.Service.Lightbulb);
// Set service names
this.fanService.setCharacteristic(this.platform.Characteristic.Name, deviceInfo.name + ' Fan');
this.lightService.setCharacteristic(this.platform.Characteristic.Name, deviceInfo.name + ' Light');
// Fan characteristics
this.fanService.getCharacteristic(this.platform.Characteristic.Active)
.onSet(this.setFanActive.bind(this))
.onGet(this.getFanActive.bind(this));
this.fanService.getCharacteristic(this.platform.Characteristic.RotationSpeed)
.onSet(this.setFanSpeed.bind(this))
.onGet(this.getFanSpeed.bind(this));
// Light characteristics
this.lightService.getCharacteristic(this.platform.Characteristic.On)
.onSet(this.setLightOn.bind(this))
.onGet(this.getLightOn.bind(this));
this.lightService.getCharacteristic(this.platform.Characteristic.Brightness)
.onSet(this.setLightBrightness.bind(this))
.onGet(this.getLightBrightness.bind(this));
}
isCacheValid() {
return Date.now() - this.state.lastUpdate < this.cacheTimeout;
}
handleDeviceError(error) {
if (this.platform.config.debug || (!error.message.includes('EHOSTUNREACH') && !error.message.includes('ETIMEDOUT'))) {
this.platform.log.error(`Device ${this.accessory.displayName} error:`, error.message);
}
if (error.message.includes('EHOSTUNREACH') || error.message.includes('ETIMEDOUT') || error.message.includes('ECONNREFUSED')) {
this.handleDeviceDisconnected();
}
}
handleDeviceConnected() {
if (!this.state.isOnline) {
this.platform.log.info(`Device ${this.accessory.displayName} is back online`);
this.state.isOnline = true;
this.state.retryCount = 0;
// Update HomeKit status
this.accessory.getService(this.platform.Service.AccessoryInformation)
.updateCharacteristic(this.platform.Characteristic.StatusActive, true);
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
this.retryTimeout = null;
}
}
}
handleDeviceDisconnected() {
if (this.state.isOnline) {
this.platform.log.warn(`Device ${this.accessory.displayName} is offline`);
this.state.isOnline = false;
// Update HomeKit status
this.accessory.getService(this.platform.Service.AccessoryInformation)
.updateCharacteristic(this.platform.Characteristic.StatusActive, false);
this.scheduleRetry();
}
}
scheduleRetry() {
if (this.state.retryCount >= MAX_RETRIES) {
this.platform.log.warn(`Device ${this.accessory.displayName} offline - will retry in 5 minutes`);
// Reset retry count and schedule a retry with maximum delay
this.state.retryCount = 0;
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
this.retryTimeout = setTimeout(() => this.refreshState(), MAX_RETRY_DELAY);
return;
}
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
// Calculate exponential backoff delay
const delay = Math.min(BASE_RETRY_DELAY * Math.pow(2, this.state.retryCount), MAX_RETRY_DELAY);
this.retryTimeout = setTimeout(async () => {
try {
this.state.retryCount++;
if (this.platform.config.debug) {
this.platform.log.debug(`Attempting to reconnect to ${this.accessory.displayName} (attempt ${this.state.retryCount})`);
}
await this.refreshState();
}
catch (error) {
if (this.platform.config.debug) {
this.platform.log.debug(`Retry attempt ${this.state.retryCount} failed:`, error);
}
if (this.state.retryCount < MAX_RETRIES) {
this.scheduleRetry();
}
}
}, delay);
}
async safeDeviceOperation(operation, defaultValue) {
// Create a timeout promise
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Operation timed out'));
}, OPERATION_TIMEOUT);
});
// If device is offline or has recent timeouts, return default value immediately
if (!this.state.isOnline ||
(this.state.consecutiveTimeouts >= 3 &&
Date.now() - this.state.lastConnectionAttempt < 5000)) {
return defaultValue;
}
try {
// Race between the operation and the timeout
const result = await Promise.race([
operation(),
timeoutPromise,
]);
// Successful operation, reset timeout counter
this.state.consecutiveTimeouts = 0;
return result;
}
catch (error) {
this.handleDeviceError(error);
return defaultValue;
}
}
async refreshState() {
// Skip refresh if device is offline and max retries reached
if (!this.state.isOnline && this.state.retryCount >= MAX_RETRIES) {
return;
}
// Skip refresh if cache is still valid
if (this.isCacheValid()) {
return;
}
try {
const response = await this.device.get({ schema: true });
if (!isValidResponse(response, this.state.consecutiveTimeouts > 0)) {
if (this.platform.config.debug) {
this.platform.log.debug(`Invalid device response: ${JSON.stringify(response)}`);
}
throw new Error('Invalid device response format');
}
const dps = response.dps;
// Safely parse each value with validation
const fanSpeed = parseDpsValue(dps, '53', this.state.fanSpeed === 0 ? 1 : Math.round((this.state.fanSpeed / 100) * 5) + 1);
const brightness = parseDpsValue(dps, '22', this.state.lightBrightness === 0 ? 10 : Math.round((this.state.lightBrightness / 100) * 990) + 10);
this.state = {
...this.state,
fanActive: parseDpsValue(dps, '51', this.state.fanActive),
fanSpeed: ((fanSpeed - 1) / 5) * 100,
lightOn: parseDpsValue(dps, '20', this.state.lightOn),
lightBrightness: ((brightness - 10) / 990) * 100,
lastUpdate: Date.now(),
isOnline: true,
retryCount: 0,
lastError: undefined,
};
this.handleDeviceConnected();
if (this.platform.config.debug) {
this.platform.log.debug('State refreshed:', this.state);
}
}
catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
this.state.lastError = err.message;
this.handleDeviceError(err);
}
}
// Fan control methods
async setFanActive(value) {
try {
await this.safeDeviceOperation(async () => {
await this.device.set({ dps: 51, set: value === 1 }); // Toggle fan
this.state.fanActive = value === 1;
this.state.lastUpdate = Date.now();
this.platform.log.debug('Set Fan Active ->', value);
}, undefined);
}
catch (error) {
// Update state anyway to maintain consistency with HomeKit
this.state.fanActive = value === 1;
throw error;
}
}
async getFanActive() {
// Always check cache first
if (this.isCacheValid() || !this.state.isOnline) {
return this.state.fanActive ? 1 : 0;
}
const result = await this.safeDeviceOperation(async () => {
await this.refreshState();
return this.state.fanActive ? 1 : 0;
}, this.state.fanActive ? 1 : 0);
return result;
}
async setFanSpeed(value) {
try {
await this.safeDeviceOperation(async () => {
// Convert 0-100 to 1-6 range
const speed = Math.round((value / 100) * 5) + 1;
await this.device.set({ dps: 53, set: speed }); // Set fan speed
this.state.fanSpeed = value;
this.state.lastUpdate = Date.now();
this.platform.log.debug('Set Fan Speed ->', value, 'Tuya Speed ->', speed);
}, undefined);
}
catch (error) {
// Update state anyway to maintain consistency with HomeKit
this.state.fanSpeed = value;
throw error;
}
}
async getFanSpeed() {
// Always check cache first
if (this.isCacheValid() || !this.state.isOnline) {
return this.state.fanSpeed;
}
const result = await this.safeDeviceOperation(async () => {
await this.refreshState();
return this.state.fanSpeed;
}, this.state.fanSpeed);
return result;
}
// Light control methods
async setLightOn(value) {
try {
await this.safeDeviceOperation(async () => {
await this.device.set({ dps: 20, set: value }); // Toggle light
this.state.lightOn = value;
this.state.lastUpdate = Date.now();
this.platform.log.debug('Set Light On ->', value);
}, undefined);
}
catch (error) {
// Update state anyway to maintain consistency with HomeKit
this.state.lightOn = value;
throw error;
}
}
async getLightOn() {
// Always check cache first
if (this.isCacheValid() || !this.state.isOnline) {
return this.state.lightOn;
}
const result = await this.safeDeviceOperation(async () => {
await this.refreshState();
return this.state.lightOn;
}, this.state.lightOn);
return result;
}
async setLightBrightness(value) {
try {
await this.safeDeviceOperation(async () => {
// Convert 0-100 to 10-1000 range
const brightness = Math.round((value / 100) * 990) + 10;
await this.device.set({ dps: 22, set: brightness }); // Set brightness
this.state.lightBrightness = value;
this.state.lastUpdate = Date.now();
this.platform.log.debug('Set Light Brightness ->', value, 'Tuya Brightness ->', brightness);
}, undefined);
}
catch (error) {
// Update state anyway to maintain consistency with HomeKit
this.state.lightBrightness = value;
throw error;
}
}
async getLightBrightness() {
// Always check cache first
if (this.isCacheValid() || !this.state.isOnline) {
return this.state.lightBrightness;
}
const result = await this.safeDeviceOperation(async () => {
await this.refreshState();
return this.state.lightBrightness;
}, this.state.lightBrightness);
return result;
}
// Cleanup method
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
}
}
//# sourceMappingURL=platformAccessory.js.map