diff --git a/.homeycompose/app.json b/.homeycompose/app.json index 6091ffe6..021293d9 100644 --- a/.homeycompose/app.json +++ b/.homeycompose/app.json @@ -1,7 +1,7 @@ { "id": "com.tuya", "version": "1.2.3", - "compatibility": ">=12.0.0", + "compatibility": ">=12.0.1", "brandColor": "#FF4800", "sdk": 3, "platforms": [ diff --git a/.homeycompose/capabilities/fan_swing_horizontal.json b/.homeycompose/capabilities/fan_swing_horizontal.json new file mode 100644 index 00000000..871e3b76 --- /dev/null +++ b/.homeycompose/capabilities/fan_swing_horizontal.json @@ -0,0 +1,9 @@ +{ + "type": "boolean", + "title": { + "en": "Fan Swing Horizontal" + }, + "getable": true, + "setable": true, + "uiComponent": "button" +} diff --git a/.homeycompose/capabilities/fan_swing_vertical.json b/.homeycompose/capabilities/fan_swing_vertical.json new file mode 100644 index 00000000..ed65846f --- /dev/null +++ b/.homeycompose/capabilities/fan_swing_vertical.json @@ -0,0 +1,9 @@ +{ + "type": "boolean", + "title": { + "en": "Fan Swing Vertical" + }, + "getable": true, + "setable": true, + "uiComponent": "button" +} diff --git a/.homeycompose/capabilities/legacy_fan_speed.json b/.homeycompose/capabilities/legacy_fan_speed.json new file mode 100644 index 00000000..862eb5bc --- /dev/null +++ b/.homeycompose/capabilities/legacy_fan_speed.json @@ -0,0 +1,35 @@ +{ + "type": "enum", + "title": { + "en": "Fan Speed" + }, + "getable": true, + "setable": true, + "uiComponent": "picker", + "values": [ + { + "id": "4", + "title": { + "en": "4" + } + }, + { + "id": "3", + "title": { + "en": "3" + } + }, + { + "id": "2", + "title": { + "en": "2" + } + }, + { + "id": "1", + "title": { + "en": "1" + } + } + ] +} diff --git a/app.json b/app.json index eb09dfdc..8bb8c285 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "_comment": "This file is generated. Please edit .homeycompose/app.json instead.", "id": "com.tuya", "version": "1.2.3", - "compatibility": ">=12.0.0", + "compatibility": ">=12.0.1", "brandColor": "#FF4800", "sdk": 3, "platforms": [ @@ -787,6 +787,55 @@ } ] }, + { + "id": "fan_light_onoff_true", + "title": { + "en": "Light turned on" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=fan&capabilities=onoff.light" + } + ] + }, + { + "id": "fan_light_onoff_false", + "title": { + "en": "Light turned off" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=fan&capabilities=onoff.light" + } + ] + }, + { + "id": "fan_light_dim_changed", + "title": { + "en": "Light dim-level changed" + }, + "tokens": [ + { + "name": "value", + "type": "number", + "title": { + "en": "Level" + }, + "example": 0.5 + } + ], + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=fan&capabilities=dim.light" + } + ] + }, { "id": "light_switch_led_turned_on", "highlight": true, @@ -1450,6 +1499,102 @@ } ] }, + { + "id": "fan_light_on", + "title": { + "en": "Turn light on" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=fan&capabilities=onoff.light" + } + ] + }, + { + "id": "fan_light_off", + "title": { + "en": "Turn light off" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=fan&capabilities=onoff.light" + } + ] + }, + { + "id": "fan_light_dim", + "title": { + "en": "Dim light" + }, + "titleFormatted": { + "en": "Dim light to [[value]]" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=fan&capabilities=dim.light" + }, + { + "name": "value", + "title": { + "en": "Level" + }, + "type": "range", + "min": 0, + "max": 1, + "step": 0.01, + "value": 0.5, + "label": "%", + "labelMultiplier": 100, + "labelDecimals": 0 + } + ] + }, + { + "id": "fan_fan_direction", + "title": { + "en": "Set fan direction" + }, + "titleFormatted": { + "en": "Set fan direction to [[value]]" + }, + "hint": { + "en": "CAUTION: This setting is not supported by every fan." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=fan" + }, + { + "name": "value", + "title": { + "en": "Direction" + }, + "type": "dropdown", + "values": [ + { + "id": "forward", + "title": { + "en": "Forward" + } + }, + { + "id": "backward", + "title": { + "en": "Backward" + } + } + ] + } + ] + }, { "id": "heater_set_child_lock", "title": { @@ -1777,6 +1922,19 @@ } ] }, + { + "id": "fan_light_is_on", + "title": { + "en": "Light is turned !{{on|off}}" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=fan&capabilities=onoff.light" + } + ] + }, { "id": "light_switch_led_is_on", "highlight": true, @@ -2452,6 +2610,39 @@ }, "id": "fan", "settings": [ + { + "id": "enable_light_support", + "type": "checkbox", + "label": { + "en": "Enable light support" + }, + "hints": { + "en": "Some fans have light capabilities (built-in lamp) and some don't. This is not always detected correctly: if the automatic detection failed you can adjust this setting." + }, + "value": false + }, + { + "id": "fan_direction", + "type": "dropdown", + "label": { + "en": "Fan direction" + }, + "value": "forward", + "values": [ + { + "id": "forward", + "label": { + "en": "Forward" + } + }, + { + "id": "backward", + "label": { + "en": "Backward" + } + } + ] + }, { "id": "deviceSpecification", "type": "label", @@ -3800,6 +3991,24 @@ "uiComponent": "button", "icon": "assets/capabilities/eco.svg" }, + "fan_swing_horizontal": { + "type": "boolean", + "title": { + "en": "Fan Swing Horizontal" + }, + "getable": true, + "setable": true, + "uiComponent": "button" + }, + "fan_swing_vertical": { + "type": "boolean", + "title": { + "en": "Fan Swing Vertical" + }, + "getable": true, + "setable": true, + "uiComponent": "button" + }, "fault": { "type": "string", "title": { @@ -3809,6 +4018,41 @@ "setable": false, "uiComponent": "sensor" }, + "legacy_fan_speed": { + "type": "enum", + "title": { + "en": "Fan Speed" + }, + "getable": true, + "setable": true, + "uiComponent": "picker", + "values": [ + { + "id": "4", + "title": { + "en": "4" + } + }, + { + "id": "3", + "title": { + "en": "3" + } + }, + { + "id": "2", + "title": { + "en": "2" + } + }, + { + "id": "1", + "title": { + "en": "1" + } + } + ] + }, "ptz_control_horizontal": { "type": "enum", "title": { diff --git a/drivers/fan/TuyaFanConstants.ts b/drivers/fan/TuyaFanConstants.ts new file mode 100644 index 00000000..ec59697c --- /dev/null +++ b/drivers/fan/TuyaFanConstants.ts @@ -0,0 +1,51 @@ +export const FAN_CAPABILITIES_MAPPING = { + switch: 'onoff', + fan_switch: 'onoff', + fan_speed_percent: 'dim', + // fan_speed can be both dim and legacy_fan_speed + switch_vertical: 'fan_swing_vertical', + switch_horizontal: 'fan_swing_horizontal', + child_lock: 'child_lock', + temp: 'target_temperature', + temp_current: 'measure_temperature', + // light - handled by superclass + light: 'onoff.light', + switch_led: 'onoff.light', +} as const; + +export const FAN_LIGHT_CAPABILITIES_MAPPING = { + light: 'onoff.light', + switch_led: 'onoff.light', + bright_value: 'dim.light', + temp_value: 'light_temperature', +} as const; + +export const FAN_CAPABILITIES = { + read_write: [ + 'switch', + 'fan_switch', + 'fan_speed_percent', + 'switch_horizontal', + 'switch_vertical', + 'child_lock', + 'temp', + // Light + 'light', + 'switch_led', + ], + read_only: ['temp_current'], + setting: ['fan_direction'], +} as const; + +export type HomeyFanSettings = { + enable_light_support: boolean; + fan_direction: 'forward' | 'backward'; +}; + +export type TuyaFanSettings = { + fan_direction: 'forward' | 'backward'; +}; + +export const FAN_SETTING_LABELS = { + fan_direction: 'Fan direction', +}; diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 7b8e4069..e9ec0739 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -1,53 +1,141 @@ -import TuyaOAuth2Device from '../../lib/TuyaOAuth2Device'; -import { TuyaStatus } from '../../types/TuyaTypes'; +import { SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; +import { + FAN_CAPABILITIES, + FAN_CAPABILITIES_MAPPING, + FAN_LIGHT_CAPABILITIES_MAPPING, + FAN_SETTING_LABELS, + HomeyFanSettings, + TuyaFanSettings, +} from './TuyaFanConstants'; +import * as TuyaOAuth2Util from '../../lib/TuyaOAuth2Util'; +import { constIncludes, getFromMap } from '../../lib/TuyaOAuth2Util'; +import * as TuyaFanMigrations from '../../lib/migrations/TuyaFanMigrations'; +import TuyaOAuth2DeviceWithLight from '../../lib/TuyaOAuth2DeviceWithLight'; -module.exports = class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(...props: any) { - super(...props); - - this.onCapabilityOnOff = this.onCapabilityOnOff.bind(this); - this.onCapabilityDim = this.onCapabilityDim.bind(this); - } +export default class TuyaOAuth2DeviceFan extends TuyaOAuth2DeviceWithLight { + LIGHT_DIM_CAPABILITY = 'dim.light'; async onOAuth2Init(): Promise { + // superclass handles light capabilities, except onoff.light await super.onOAuth2Init(); - // onoff - if (this.hasCapability('onoff')) { - this.registerCapabilityListener('onoff', this.onCapabilityOnOff); + for (const [tuyaCapability, capability] of Object.entries(FAN_CAPABILITIES_MAPPING)) { + if ( + constIncludes(FAN_CAPABILITIES.read_write, tuyaCapability) && + this.hasCapability(capability) && + this.hasTuyaCapability(tuyaCapability) + ) { + this.registerCapabilityListener(capability, value => this.sendCommand({ code: tuyaCapability, value })); + } + } + + // fan_speed + if (this.hasCapability('legacy_fan_speed')) { + this.registerCapabilityListener('legacy_fan_speed', value => this.sendCommand({ code: 'fan_speed', value })); } - // dim - if (this.hasCapability('dim')) { - this.registerCapabilityListener('dim', this.onCapabilityDim); + + if (this.hasCapability('dim') && this.getStoreValue('tuya_category') === 'fsd') { + this.registerCapabilityListener('dim', value => this.sendCommand({ code: 'fan_speed', value })); } } + async performMigrations(): Promise { + await super.performMigrations(); + await TuyaFanMigrations.performMigrations(this); + } + async onTuyaStatus(status: TuyaStatus, changedStatusCodes: string[]): Promise { + // superclass handles light capabilities, except onoff.light await super.onTuyaStatus(status, changedStatusCodes); - // onoff - if (typeof status['switch'] === 'boolean') { - this.setCapabilityValue('onoff', status['switch']).catch(this.error); + for (const tuyaCapability in status) { + const value = status[tuyaCapability]; + const homeyCapability = getFromMap(FAN_CAPABILITIES_MAPPING, tuyaCapability); + + if ( + (constIncludes(FAN_CAPABILITIES.read_write, tuyaCapability) || + constIncludes(FAN_CAPABILITIES.read_only, tuyaCapability)) && + homeyCapability + ) { + await this.safeSetCapabilityValue(homeyCapability, value); + } + + if (constIncludes(FAN_CAPABILITIES.setting, tuyaCapability)) { + await this.setSettings({ + [tuyaCapability]: value, + }); + } + + if (tuyaCapability === 'fan_speed') { + if (this.getStoreValue('tuya_category') === 'fsd') { + await this.safeSetCapabilityValue('dim', value); + } else { + await this.safeSetCapabilityValue('legacy_fan_speed', value); + } + } } - // dim - if (typeof status['fan_speed_percent'] === 'number') { - this.setCapabilityValue('dim', status['fan_speed_percent']).catch(this.error); + // flows + if (this.getSetting('enable_light_support')) { + if (changedStatusCodes.includes('bright_value')) { + await this.homey.flow + .getDeviceTriggerCard('fan_light_dim_changed') + .trigger(this, { value: status['bright_value'] }) + .catch(this.error); + } + + if (changedStatusCodes.includes('light')) { + await this.homey.flow + .getDeviceTriggerCard(`fan_light_onoff_${status['light']}`) + .trigger(this) + .catch(this.error); + } + + if (changedStatusCodes.includes('switch_led')) { + await this.homey.flow + .getDeviceTriggerCard(`fan_light_onoff_${status['switch_led']}`) + .trigger(this) + .catch(this.error); + } } } - async onCapabilityOnOff(value: boolean): Promise { - await this.sendCommand({ - code: 'switch', - value: value, - }); - } + async onSettings(event: SettingsEvent): Promise { + if (event.changedKeys.includes('enable_light_support')) { + if (event.newSettings['enable_light_support']) { + for (const [tuyaCapability, homeyCapability] of Object.entries(FAN_LIGHT_CAPABILITIES_MAPPING)) { + if (this.hasTuyaCapability(tuyaCapability) && !this.hasCapability(homeyCapability)) { + await this.addCapability(homeyCapability); + } + } + if (this.hasTuyaCapability('colour')) { + if (!this.hasCapability('light_hue')) await this.addCapability('light_hue'); + if (!this.hasCapability('light_saturation')) await this.addCapability('light_saturation'); + if (!this.hasCapability('dim.light')) await this.addCapability('dim.light'); + } + if (this.hasCapability('light_temperature') && this.hasCapability('light_hue')) { + if (!this.hasCapability('light_mode')) await this.addCapability('light_mode'); + } + } else { + for (const lightCapability of [ + 'onoff.light', + 'dim.light', + 'light_mode', + 'light_temperature', + 'light_hue', + 'light_saturation', + ]) { + if (this.hasCapability(lightCapability)) await this.removeCapability(lightCapability); + } + } + } - async onCapabilityDim(value: number): Promise { - await this.sendCommand({ - code: 'fan_speed_percent', - value: value, - }); + const tuyaSettingsEvent = TuyaOAuth2Util.filterTuyaSettings(event, [ + 'fan_direction', + ]); + + return TuyaOAuth2Util.onSettings(this, tuyaSettingsEvent, FAN_SETTING_LABELS); } -}; +} + +module.exports = TuyaOAuth2DeviceFan; diff --git a/drivers/fan/driver.flow.compose.json b/drivers/fan/driver.flow.compose.json new file mode 100644 index 00000000..bdfa0461 --- /dev/null +++ b/drivers/fan/driver.flow.compose.json @@ -0,0 +1,121 @@ +{ + "triggers": [ + { + "id": "fan_light_onoff_true", + "title": { + "en": "Light turned on" + }, + "$filter": "capabilities=onoff.light" + }, + { + "id": "fan_light_onoff_false", + "title": { + "en": "Light turned off" + }, + "$filter": "capabilities=onoff.light" + }, + { + "id": "fan_light_dim_changed", + "title": { + "en": "Light dim-level changed" + }, + "$filter": "capabilities=dim.light", + "tokens": [ + { + "name": "value", + "type": "number", + "title": { + "en": "Level" + }, + "example": 0.5 + } + ] + } + ], + "conditions": [ + { + "id": "fan_light_is_on", + "$filter": "capabilities=onoff.light", + "title": { + "en": "Light is turned !{{on|off}}" + } + } + ], + "actions": [ + { + "id": "fan_light_on", + "$filter": "capabilities=onoff.light", + "title": { + "en": "Turn light on" + } + }, + { + "id": "fan_light_off", + "$filter": "capabilities=onoff.light", + "title": { + "en": "Turn light off" + } + }, + { + "id": "fan_light_dim", + "$filter": "capabilities=dim.light", + "title": { + "en": "Dim light" + }, + "titleFormatted": { + "en": "Dim light to [[value]]" + }, + "args": [ + { + "name": "value", + "title": { + "en": "Level" + }, + "type": "range", + "min": 0, + "max": 1, + "step": 0.01, + "value": 0.5, + "label": "%", + "labelMultiplier": 100, + "labelDecimals": 0 + } + ] + }, + { + "id": "fan_fan_direction", + "title": { + "en": "Set fan direction" + }, + "titleFormatted": { + "en": "Set fan direction to [[value]]" + }, + "hint": { + "en": "CAUTION: This setting is not supported by every fan." + }, + "args": [ + { + "name": "value", + "title": { + "en": "Direction" + }, + "type": "dropdown", + "values": [ + { + "id": "forward", + "title": { + "en": "Forward" + } + }, + { + "id": "backward", + "title": { + "en": "Backward" + } + } + ] + } + ] + } + ] +} diff --git a/drivers/fan/driver.settings.compose.json b/drivers/fan/driver.settings.compose.json index dceebc0d..c3863bff 100644 --- a/drivers/fan/driver.settings.compose.json +++ b/drivers/fan/driver.settings.compose.json @@ -1,4 +1,37 @@ [ + { + "id": "enable_light_support", + "type": "checkbox", + "label": { + "en": "Enable light support" + }, + "hints": { + "en": "Some fans have light capabilities (built-in lamp) and some don't. This is not always detected correctly: if the automatic detection failed you can adjust this setting." + }, + "value": false + }, + { + "id": "fan_direction", + "type": "dropdown", + "label": { + "en": "Fan direction" + }, + "value": "forward", + "values": [ + { + "id": "forward", + "label": { + "en": "Forward" + } + }, + { + "id": "backward", + "label": { + "en": "Backward" + } + } + ] + }, { "$extends": "deviceSpecification" } diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index d8f478b9..5d9d8637 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -1,58 +1,163 @@ import { DEVICE_CATEGORIES } from '../../lib/TuyaOAuth2Constants'; -import TuyaOAuth2Driver, { ListDeviceProperties } from '../../lib/TuyaOAuth2Driver'; +import { ListDeviceProperties } from '../../lib/TuyaOAuth2Driver'; import { type TuyaDeviceDataPointResponse, TuyaDeviceResponse, TuyaDeviceSpecificationResponse, } from '../../types/TuyaApiTypes'; +import { getFromMap } from '../../lib/TuyaOAuth2Util'; +import { FAN_CAPABILITIES_MAPPING, FAN_SETTING_LABELS } from './TuyaFanConstants'; +import TuyaOAuth2DriverWithLight from '../../lib/TuyaOAuth2DriverWithLight'; +import { StandardDeviceFlowArgs, StandardFlowArgs } from '../../types/TuyaTypes'; -module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { +module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2DriverWithLight { TUYA_DEVICE_CATEGORIES = [ DEVICE_CATEGORIES.SMALL_HOME_APPLIANCES.FAN, - // TODO + DEVICE_CATEGORIES.LIGHTING.CEILING_FAN_LIGHT, ] as const; + async onInit(): Promise { + await super.onInit(); + + this.homey.flow.getActionCard('fan_light_on').registerRunListener(async (args: StandardDeviceFlowArgs) => { + await args.device.triggerCapabilityListener('onoff.light', true).catch(args.device.error); + }); + + this.homey.flow.getActionCard('fan_light_off').registerRunListener(async (args: StandardDeviceFlowArgs) => { + await args.device.triggerCapabilityListener('onoff.light', false).catch(args.device.error); + }); + + this.homey.flow.getActionCard('fan_light_dim').registerRunListener(async (args: StandardFlowArgs) => { + await args.device.triggerCapabilityListener('dim.light', args.value).catch(args.device.error); + }); + + this.homey.flow.getConditionCard('fan_light_is_on').registerRunListener((args: StandardDeviceFlowArgs) => { + return args.device.getCapabilityValue('onoff.light').catch(args.device.error); + }); + + this.addSettingFlowHandler('fan_direction', FAN_SETTING_LABELS); + } + onTuyaPairListDeviceProperties( device: TuyaDeviceResponse, specifications?: TuyaDeviceSpecificationResponse, dataPoints?: TuyaDeviceDataPointResponse, ): ListDeviceProperties { + // superclass handles light capabilities, except onoff.light const props = super.onTuyaPairListDeviceProperties(device, specifications, dataPoints); - // onoff - const hasSwitch = device.status.some(({ code }) => code === 'switch'); - if (hasSwitch) { - props.capabilities.push('onoff'); + props.store['_migrations'] = ['fan_tuya_capabilities']; + + for (const status of device.status) { + const tuyaCapability = status.code; + + const homeyCapability = getFromMap(FAN_CAPABILITIES_MAPPING, tuyaCapability); + if (homeyCapability) { + props.store.tuya_capabilities.push(tuyaCapability); + props.capabilities.push(homeyCapability); + } + + if (tuyaCapability === 'fan_speed') { + props.store.tuya_capabilities.push(tuyaCapability); + if (device.category === 'fsd') { + props.capabilities.push('dim'); + } else { + props.capabilities.push('legacy_fan_speed'); + } + } + + if (tuyaCapability === 'colour_data') { + props.store.tuya_capabilities.push(tuyaCapability); + props.capabilities.push('light_hue'); + props.capabilities.push('light_saturation'); + } } - // dim - const hasFanSpeedPercent = device.status.some(({ code }) => code === 'fan_speed_percent'); - if (hasFanSpeedPercent) { - props.capabilities.push('dim'); + // Fix onoff when light is present + if (props.capabilities.includes('onoff.light')) { + props.capabilitiesOptions['onoff'] = { + title: { + en: `Fan`, + }, + insightsTitleTrue: { + en: `Turned on (Fan)`, + }, + insightsTitleFalse: { + en: `Turned off (Fan)`, + }, + }; + + props.capabilitiesOptions['onoff.light'] = { + title: { + en: `Light`, + }, + insightsTitleTrue: { + en: `Turned on (Light)`, + }, + insightsTitleFalse: { + en: `Turned off (Light)`, + }, + }; + } + + // Fix dim when light is present + if (props.capabilities.includes('dim.light')) { props.capabilitiesOptions['dim'] = { - min: 1, - max: 6, - step: 1, + title: { + en: `Fan`, + }, + }; + + props.capabilitiesOptions['dim.light'] = { + title: { + en: `Light`, + }, }; } - if (!specifications || !specifications.functions) { + if (!specifications || !specifications.status) { return props; } - // Device Specifications - for (const functionSpecification of specifications.functions) { - const tuyaCapability = functionSpecification.code; - const values = JSON.parse(functionSpecification.values); + for (const statusSpecification of specifications.status) { + const tuyaCapability = statusSpecification.code; + const values = JSON.parse(statusSpecification.values); + // Fan if (tuyaCapability === 'fan_speed_percent') { - props.store.tuya_brightness = values; props.capabilitiesOptions['dim'] = { min: values.min ?? 1, max: values.max ?? 100, step: values.step ?? 1, }; } + + if (tuyaCapability === 'fan_speed') { + const legacyFanSpeedsEnum = []; + for (let i = values.range.length; i >= 1; i--) { + legacyFanSpeedsEnum.push({ + id: `${i}`, + title: `${i}`, + }); + } + props.capabilitiesOptions['legacy_fan_speed'] = { + values: legacyFanSpeedsEnum, + }; + } + + // Temperature + if (tuyaCapability === 'temp') { + props.capabilitiesOptions['target_temperature'] = { + min: values.min ?? 0, + max: values.max ?? 50, + }; + } + if (tuyaCapability === 'temp_current') { + props.capabilitiesOptions['measure_temperature'] = { + min: values.min ?? 0, + max: values.max ?? 50, + }; + } } return props; diff --git a/drivers/heater/driver.ts b/drivers/heater/driver.ts index e9277a1e..860208f9 100644 --- a/drivers/heater/driver.ts +++ b/drivers/heater/driver.ts @@ -44,7 +44,7 @@ module.exports = class TuyaOAuth2DriverHeater extends TuyaOAuth2Driver { } } - if (!specifications || !specifications.functions) { + if (!specifications || !specifications.status) { return props; } diff --git a/drivers/light/device.ts b/drivers/light/device.ts index 8730e3a2..b995dbd6 100644 --- a/drivers/light/device.ts +++ b/drivers/light/device.ts @@ -1,44 +1,35 @@ import * as TuyaLightMigrations from '../../lib/migrations/TuyaLightMigrations'; import { TUYA_PERCENTAGE_SCALING } from '../../lib/TuyaOAuth2Constants'; -import TuyaOAuth2Device from '../../lib/TuyaOAuth2Device'; import { TuyaCommand } from '../../types/TuyaApiTypes'; import { SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; import { LIGHT_SETTING_LABELS, LightSettingCommand, LightSettingKey, PIR_CAPABILITIES } from './TuyaLightConstants'; +import TuyaOAuth2DeviceWithLight from '../../lib/TuyaOAuth2DeviceWithLight'; -type ParsedColourData = { h: number; s: number; v: number }; - -export default class TuyaOAuth2DeviceLight extends TuyaOAuth2Device { - LIGHT_COLOUR_DATA_V1_HUE_MIN = this.store.tuya_colour?.h?.min; - LIGHT_COLOUR_DATA_V1_HUE_MAX = this.store.tuya_colour?.h?.max; - LIGHT_COLOUR_DATA_V1_SATURATION_MIN = this.store.tuya_colour?.s?.min; - LIGHT_COLOUR_DATA_V1_SATURATION_MAX = this.store.tuya_colour?.s?.max; - LIGHT_COLOUR_DATA_V1_VALUE_MIN = this.store.tuya_colour?.v?.min; - LIGHT_COLOUR_DATA_V1_VALUE_MAX = this.store.tuya_colour?.v?.max; - - LIGHT_COLOUR_DATA_V2_HUE_MIN = this.store.tuya_colour_v2?.h?.min; - LIGHT_COLOUR_DATA_V2_HUE_MAX = this.store.tuya_colour_v2?.h?.max; - LIGHT_COLOUR_DATA_V2_SATURATION_MIN = this.store.tuya_colour_v2?.s?.min; - LIGHT_COLOUR_DATA_V2_SATURATION_MAX = this.store.tuya_colour_v2?.s?.max; - LIGHT_COLOUR_DATA_V2_VALUE_MIN = this.store.tuya_colour_v2?.v?.min; - LIGHT_COLOUR_DATA_V2_VALUE_MAX = this.store.tuya_colour_v2?.v?.max; - - LIGHT_TEMP_VALUE_V1_MIN = this.store.tuya_temperature?.min; - LIGHT_TEMP_VALUE_V1_MAX = this.store.tuya_temperature?.max; - LIGHT_TEMP_VALUE_V2_MIN = this.store.tuya_temperature_v2?.min; - LIGHT_TEMP_VALUE_V2_MAX = this.store.tuya_temperature_v2?.max; - - LIGHT_BRIGHT_VALUE_V1_MIN = this.store.tuya_brightness?.min; - LIGHT_BRIGHT_VALUE_V1_MAX = this.store.tuya_brightness?.max; - - LIGHT_BRIGHT_VALUE_V2_MIN = this.store.tuya_brightness_v2?.min; - LIGHT_BRIGHT_VALUE_V2_MAX = this.store.tuya_brightness_v2?.max; - +export default class TuyaOAuth2DeviceLight extends TuyaOAuth2DeviceWithLight { async performMigrations(): Promise { await super.performMigrations(); await TuyaLightMigrations.performMigrations(this); } async onOAuth2Init(): Promise { + if (this.getStoreValue('tuya_category') === 'dj') { + // Check if we need to use v2 Tuya capabilities + if (this.hasTuyaCapability('bright_value_v2')) { + this.LIGHT_DIM_TUYA_CAPABILITY = 'bright_value_v2'; + this.LIGHT_DIM_TUYA_SPECS = 'tuya_brightness_v2'; + } + + if (this.hasTuyaCapability('temp_value_v2')) { + this.LIGHT_TEMP_TUYA_CAPABILITY = 'temp_value_v2'; + this.LIGHT_TEMP_TUYA_SPECS = 'tuya_temperature_v2'; + } + + if (this.hasTuyaCapability('colour_data_v2')) { + this.LIGHT_COLOR_TUYA_CAPABILITY = 'colour_data_v2'; + this.LIGHT_COLOR_TUYA_SPECS = 'tuya_colour_v2'; + } + } + // superclass handles all light capabilities, except for onoff await super.onOAuth2Init(); // onoff @@ -53,25 +44,10 @@ export default class TuyaOAuth2DeviceLight extends TuyaOAuth2Device { this.registerCapabilityListener(`onoff.${tuyaSwitch}`, value => this.switchOnOff(value, tuyaSwitch)); } } - - // light capabilities - const lightCapabilities = []; - if (this.hasCapability('dim')) lightCapabilities.push('dim'); - if (this.hasCapability('light_hue')) lightCapabilities.push('light_hue'); - if (this.hasCapability('light_saturation')) lightCapabilities.push('light_saturation'); - if (this.hasCapability('light_temperature')) lightCapabilities.push('light_temperature'); - if (this.hasCapability('light_mode')) lightCapabilities.push('light_mode'); - - if (lightCapabilities.length > 0) { - this.registerMultipleCapabilityListener( - lightCapabilities, - capabilityValues => this.onCapabilitiesLight(capabilityValues), - 150, - ); - } } async onTuyaStatus(status: TuyaStatus, changedStatusCodes: string[]): Promise { + // superclass handles all light capabilities, except for onoff await super.onTuyaStatus(status, changedStatusCodes); // onoff @@ -103,171 +79,6 @@ export default class TuyaOAuth2DeviceLight extends TuyaOAuth2Device { this.setCapabilityValue('onoff', anySwitchOn).catch(this.error); } - // light_temperature - if (typeof status['temp_value'] === 'number') { - const light_temperature = Math.min( - 1, - Math.max( - 0, - 1 - - (status['temp_value'] - this.LIGHT_TEMP_VALUE_V1_MIN) / - (this.LIGHT_TEMP_VALUE_V1_MAX - this.LIGHT_TEMP_VALUE_V1_MIN), - ), - ); - this.setCapabilityValue('light_temperature', light_temperature).catch(this.error); - } - - if (typeof status['temp_value_v2'] === 'number') { - const light_temperature = Math.min( - 1, - Math.max( - 0, - 1 - - (status['temp_value_v2'] - this.LIGHT_TEMP_VALUE_V2_MIN) / - (this.LIGHT_TEMP_VALUE_V2_MAX - this.LIGHT_TEMP_VALUE_V2_MIN), - ), - ); - this.setCapabilityValue('light_temperature', light_temperature).catch(this.error); - } - - // light_hue, light_saturation - const colourData = status['colour_data'] as ParsedColourData | undefined; - const colourDataV2 = status['colour_data_v2'] as ParsedColourData | undefined; - - if (colourData) { - const light_hue = Math.min( - 1, - Math.max( - 0, - (colourData['h'] - this.LIGHT_COLOUR_DATA_V1_HUE_MIN) / - (this.LIGHT_COLOUR_DATA_V1_HUE_MAX - this.LIGHT_COLOUR_DATA_V1_HUE_MIN), - ), - ); - this.setCapabilityValue('light_hue', light_hue).catch(this.error); - - const light_saturation = Math.min( - 1, - Math.max( - 0, - (colourData['s'] - this.LIGHT_COLOUR_DATA_V1_SATURATION_MIN) / - (this.LIGHT_COLOUR_DATA_V1_SATURATION_MAX - this.LIGHT_COLOUR_DATA_V1_SATURATION_MIN), - ), - ); - this.setCapabilityValue('light_saturation', light_saturation).catch(this.error); - } - - if (colourDataV2) { - const light_hue = Math.min( - 1, - Math.max( - 0, - (colourDataV2['h'] - this.LIGHT_COLOUR_DATA_V2_HUE_MIN) / - (this.LIGHT_COLOUR_DATA_V2_HUE_MAX - this.LIGHT_COLOUR_DATA_V2_HUE_MIN), - ), - ); - this.setCapabilityValue('light_hue', light_hue).catch(this.error); - - const light_saturation = Math.min( - 1, - Math.max( - 0, - (colourDataV2['s'] - this.LIGHT_COLOUR_DATA_V2_SATURATION_MIN) / - (this.LIGHT_COLOUR_DATA_V2_SATURATION_MAX - this.LIGHT_COLOUR_DATA_V2_SATURATION_MIN), - ), - ); - this.setCapabilityValue('light_saturation', light_saturation).catch(this.error); - } - - // light_mode - if (status['work_mode']) { - if (status['work_mode'] === 'colour') { - if (this.hasCapability('light_mode')) { - this.setCapabilityValue('light_mode', 'color').catch(this.error); - } - - // dim - if (colourData) { - const dim = Math.min( - 1, - Math.max( - 0, - (colourData['v'] - this.LIGHT_COLOUR_DATA_V1_VALUE_MIN) / - (this.LIGHT_COLOUR_DATA_V1_VALUE_MAX - this.LIGHT_COLOUR_DATA_V1_VALUE_MIN), - ), - ); - this.setCapabilityValue('dim', dim).catch(this.error); - } - - if (colourDataV2) { - const dim = Math.min( - 1, - Math.max( - 0, - (colourDataV2['v'] - this.LIGHT_COLOUR_DATA_V2_VALUE_MIN) / - (this.LIGHT_COLOUR_DATA_V2_VALUE_MAX - this.LIGHT_COLOUR_DATA_V2_VALUE_MIN), - ), - ); - this.setCapabilityValue('dim', dim).catch(this.error); - } - } - - if (status['work_mode'] === 'white') { - if (this.hasCapability('light_mode')) { - this.setCapabilityValue('light_mode', 'temperature').catch(this.error); - } - - // dim - if (typeof status['bright_value'] === 'number') { - const dim = Math.min( - 1, - Math.max( - 0, - (status['bright_value'] - this.LIGHT_BRIGHT_VALUE_V1_MIN) / - (this.LIGHT_BRIGHT_VALUE_V1_MAX - this.LIGHT_BRIGHT_VALUE_V1_MIN), - ), - ); - this.setCapabilityValue('dim', dim).catch(this.error); - } - - if (typeof status['bright_value_v2'] === 'number') { - const dim = Math.min( - 1, - Math.max( - 0, - (status['bright_value_v2'] - this.LIGHT_BRIGHT_VALUE_V2_MIN) / - (this.LIGHT_BRIGHT_VALUE_V2_MAX - this.LIGHT_BRIGHT_VALUE_V2_MIN), - ), - ); - this.setCapabilityValue('dim', dim).catch(this.error); - } - } - } else { - // dim - if (typeof status['bright_value'] === 'number') { - const dim = Math.min( - 1, - Math.max( - 0, - (status['bright_value'] - this.LIGHT_BRIGHT_VALUE_V1_MIN) / - (this.LIGHT_BRIGHT_VALUE_V1_MAX - this.LIGHT_BRIGHT_VALUE_V1_MIN), - ), - ); - this.setCapabilityValue('dim', dim).catch(this.error); - } - - if (typeof status['bright_value_v2'] === 'number') { - const dim = Math.min( - 1, - Math.max( - 0, - (status['bright_value_v2'] - this.LIGHT_BRIGHT_VALUE_V2_MIN) / - (this.LIGHT_BRIGHT_VALUE_V2_MAX - this.LIGHT_BRIGHT_VALUE_V2_MIN), - ), - ); - this.setCapabilityValue('dim', dim).catch(this.error); - } - } - // PIR for (const pirCapability of PIR_CAPABILITIES.setting) { const newValue = status[pirCapability]; @@ -333,216 +144,6 @@ export default class TuyaOAuth2DeviceLight extends TuyaOAuth2Device { }); } - async onCapabilitiesLight({ - dim = this.getCapabilityValue('dim'), - light_mode = this.getCapabilityValue('light_mode'), - light_hue = this.getCapabilityValue('light_hue'), - light_saturation = this.getCapabilityValue('light_saturation'), - light_temperature = this.getCapabilityValue('light_temperature'), - }): Promise { - const commands: TuyaCommand[] = []; - - // Light mode is not available when a light only has temperature or color - if (!this.hasCapability('light_mode')) { - if (this.hasCapability('light_hue')) { - light_mode = 'color'; - } else if (this.hasCapability('light_temperature')) { - light_mode = 'temperature'; - } - } - - if (this.hasTuyaCapability('work_mode')) { - commands.push({ - code: 'work_mode', - value: light_mode === 'color' ? 'colour' : 'white', - }); - } - - if (light_mode === 'color') { - if (this.hasTuyaCapability('colour_data')) { - commands.push({ - code: 'colour_data', - value: { - h: Math.min( - this.LIGHT_COLOUR_DATA_V1_HUE_MAX, - Math.max( - this.LIGHT_COLOUR_DATA_V1_HUE_MIN, - Math.round( - this.LIGHT_COLOUR_DATA_V1_HUE_MIN + - light_hue * (this.LIGHT_COLOUR_DATA_V1_HUE_MAX - this.LIGHT_COLOUR_DATA_V1_HUE_MIN), - ), - ), - ), - s: Math.min( - this.LIGHT_COLOUR_DATA_V1_SATURATION_MAX, - Math.max( - this.LIGHT_COLOUR_DATA_V1_SATURATION_MIN, - Math.round( - this.LIGHT_COLOUR_DATA_V1_SATURATION_MIN + - light_saturation * - (this.LIGHT_COLOUR_DATA_V1_SATURATION_MAX - this.LIGHT_COLOUR_DATA_V1_SATURATION_MIN), - ), - ), - ), - // Prevent a value of 0, which causes unwanted behavior - v: Math.min( - this.LIGHT_COLOUR_DATA_V1_VALUE_MAX, - Math.max( - 1, - this.LIGHT_COLOUR_DATA_V1_VALUE_MIN, - Math.round( - this.LIGHT_COLOUR_DATA_V1_VALUE_MIN + - dim * (this.LIGHT_COLOUR_DATA_V1_VALUE_MAX - this.LIGHT_COLOUR_DATA_V1_VALUE_MIN), - ), - ), - ), - }, - }); - } - - if (this.hasTuyaCapability('colour_data_v2')) { - commands.push({ - code: 'colour_data_v2', - value: { - h: Math.min( - this.LIGHT_COLOUR_DATA_V2_HUE_MAX, - Math.max( - this.LIGHT_COLOUR_DATA_V2_HUE_MIN, - Math.round( - this.LIGHT_COLOUR_DATA_V2_HUE_MIN + - light_hue * (this.LIGHT_COLOUR_DATA_V2_HUE_MAX - this.LIGHT_COLOUR_DATA_V2_HUE_MIN), - ), - ), - ), - s: Math.min( - this.LIGHT_COLOUR_DATA_V2_SATURATION_MAX, - Math.max( - this.LIGHT_COLOUR_DATA_V2_SATURATION_MIN, - Math.round( - this.LIGHT_COLOUR_DATA_V2_SATURATION_MIN + - light_saturation * - (this.LIGHT_COLOUR_DATA_V2_SATURATION_MAX - this.LIGHT_COLOUR_DATA_V2_SATURATION_MIN), - ), - ), - ), - // Prevent a value of 0, which causes unwanted behavior - v: Math.min( - this.LIGHT_COLOUR_DATA_V2_VALUE_MAX, - Math.max( - 1, - this.LIGHT_COLOUR_DATA_V2_VALUE_MIN, - Math.round( - this.LIGHT_COLOUR_DATA_V2_VALUE_MIN + - dim * (this.LIGHT_COLOUR_DATA_V2_VALUE_MAX - this.LIGHT_COLOUR_DATA_V2_VALUE_MIN), - ), - ), - ), - }, - }); - } - } else if (light_mode === 'temperature') { - if (this.hasTuyaCapability('bright_value')) { - commands.push({ - code: 'bright_value', - value: Math.min( - this.LIGHT_BRIGHT_VALUE_V1_MAX, - Math.max( - this.LIGHT_BRIGHT_VALUE_V1_MIN, - Math.round( - this.LIGHT_BRIGHT_VALUE_V1_MIN + - dim * (this.LIGHT_BRIGHT_VALUE_V1_MAX - this.LIGHT_BRIGHT_VALUE_V1_MIN), - ), - ), - ), - }); - } - - if (this.hasTuyaCapability('bright_value_v2')) { - commands.push({ - code: 'bright_value_v2', - value: Math.min( - this.LIGHT_BRIGHT_VALUE_V2_MAX, - Math.max( - this.LIGHT_BRIGHT_VALUE_V2_MIN, - Math.round( - this.LIGHT_BRIGHT_VALUE_V2_MIN + - dim * (this.LIGHT_BRIGHT_VALUE_V2_MAX - this.LIGHT_BRIGHT_VALUE_V2_MIN), - ), - ), - ), - }); - } - - if (this.hasTuyaCapability('temp_value')) { - commands.push({ - code: 'temp_value', - value: Math.min( - this.LIGHT_TEMP_VALUE_V1_MAX, - Math.max( - this.LIGHT_TEMP_VALUE_V1_MIN, - Math.round( - this.LIGHT_TEMP_VALUE_V1_MIN + - (1 - light_temperature) * (this.LIGHT_TEMP_VALUE_V1_MAX - this.LIGHT_TEMP_VALUE_V1_MIN), - ), - ), - ), - }); - } - - if (this.hasTuyaCapability('temp_value_v2')) { - commands.push({ - code: 'temp_value_v2', - value: Math.min( - this.LIGHT_TEMP_VALUE_V2_MAX, - Math.max( - this.LIGHT_TEMP_VALUE_V2_MIN, - Math.round( - this.LIGHT_TEMP_VALUE_V2_MIN + - (1 - light_temperature) * (this.LIGHT_TEMP_VALUE_V2_MAX - this.LIGHT_TEMP_VALUE_V2_MIN), - ), - ), - ), - }); - } - } else if (this.hasCapability('dim')) { - if (this.hasTuyaCapability('bright_value')) { - commands.push({ - code: 'bright_value', - value: Math.min( - this.LIGHT_BRIGHT_VALUE_V1_MAX, - Math.max( - this.LIGHT_BRIGHT_VALUE_V1_MIN, - Math.round( - this.LIGHT_BRIGHT_VALUE_V1_MIN + - dim * (this.LIGHT_BRIGHT_VALUE_V1_MAX - this.LIGHT_BRIGHT_VALUE_V1_MIN), - ), - ), - ), - }); - } - - if (this.hasTuyaCapability('bright_value_v2')) { - commands.push({ - code: 'bright_value_v2', - value: Math.min( - this.LIGHT_BRIGHT_VALUE_V2_MAX, - Math.max( - this.LIGHT_BRIGHT_VALUE_V2_MIN, - Math.round( - this.LIGHT_BRIGHT_VALUE_V2_MIN + - dim * (this.LIGHT_BRIGHT_VALUE_V2_MAX - this.LIGHT_BRIGHT_VALUE_V2_MIN), - ), - ), - ), - }); - } - } - - if (commands.length) { - await this.sendCommands(commands); - } - } - // TODO migrate to util sendSettingCommand async sendSettingCommand({ code, value }: LightSettingCommand): Promise { await this.sendCommand({ diff --git a/drivers/light/driver.ts b/drivers/light/driver.ts index a0aa508c..99eb67b5 100644 --- a/drivers/light/driver.ts +++ b/drivers/light/driver.ts @@ -1,5 +1,5 @@ import { DEVICE_CATEGORIES, TUYA_PERCENTAGE_SCALING } from '../../lib/TuyaOAuth2Constants'; -import TuyaOAuth2Driver, { ListDeviceProperties } from '../../lib/TuyaOAuth2Driver'; +import { ListDeviceProperties } from '../../lib/TuyaOAuth2Driver'; import { constIncludes } from '../../lib/TuyaOAuth2Util'; import { type TuyaDeviceDataPointResponse, @@ -9,11 +9,12 @@ import { import type { StandardDeviceFlowArgs, StandardFlowArgs } from '../../types/TuyaTypes'; import type TuyaOAuth2DeviceLight from './device'; import { LightSettingCommand, PIR_CAPABILITIES } from './TuyaLightConstants'; +import TuyaOAuth2DriverWithLight from '../../lib/TuyaOAuth2DriverWithLight'; type DeviceArgs = StandardDeviceFlowArgs; type FlowArgs = StandardFlowArgs; -module.exports = class TuyaOAuth2DriverLight extends TuyaOAuth2Driver { +module.exports = class TuyaOAuth2DriverLight extends TuyaOAuth2DriverWithLight { TUYA_DEVICE_CATEGORIES = [ DEVICE_CATEGORIES.LIGHTING.LIGHT, DEVICE_CATEGORIES.LIGHTING.CEILING_LIGHT, @@ -89,6 +90,7 @@ module.exports = class TuyaOAuth2DriverLight extends TuyaOAuth2Driver { specifications?: TuyaDeviceSpecificationResponse, dataPoints?: TuyaDeviceDataPointResponse, ): ListDeviceProperties { + // superclass handles light capabilities, except onoff const props = super.onTuyaPairListDeviceProperties(device, specifications, dataPoints); props.store.tuya_switches = []; @@ -159,31 +161,6 @@ module.exports = class TuyaOAuth2DriverLight extends TuyaOAuth2Driver { for (const status of device.status) { const tuyaCapability = status.code; - // dim - if (tuyaCapability === 'bright_value' || tuyaCapability === 'bright_value_v2') { - props.store.tuya_capabilities.push(tuyaCapability); - props.capabilities.push('dim'); - } - - // light temperature - if (tuyaCapability === 'temp_value' || tuyaCapability === 'temp_value_v2') { - props.store.tuya_capabilities.push(tuyaCapability); - props.capabilities.push('light_temperature'); - } - - // light hue and saturation - if (tuyaCapability === 'colour_data' || tuyaCapability === 'colour_data_v2') { - props.store.tuya_capabilities.push(tuyaCapability); - props.capabilities.push('light_hue'); - props.capabilities.push('light_saturation'); - props.capabilities.push('dim'); - } - - // light_mode - if (tuyaCapability === 'work_mode') { - props.store.tuya_capabilities.push(tuyaCapability); - } - // motion alarm if (tuyaCapability === 'pir_state') { props.store.tuya_capabilities.push(tuyaCapability); @@ -205,73 +182,6 @@ module.exports = class TuyaOAuth2DriverLight extends TuyaOAuth2Driver { // Remove duplicate capabilities props.capabilities = [...new Set(props.capabilities)]; - // Only add light mode capability when both temperature and colour data is available - if (props.capabilities.includes('light_temperature') && props.capabilities.includes('light_hue')) { - props.capabilities.push('light_mode'); - } - - // Category Specifications - // The main light category has both (0,255) and (0,1000) for backwards compatibility - // Other categories use only (0,1000) - if (device.category === 'dj') { - props.store.tuya_brightness = { min: 25, max: 255, scale: 0, step: 1 }; - props.store.tuya_temperature = { min: 0, max: 255, scale: 0, step: 1 }; - props.store.tuya_colour = { - h: { min: 0, max: 360, scale: 0, step: 1 }, - s: { min: 0, max: 255, scale: 0, step: 1 }, - v: { min: 0, max: 255, scale: 0, step: 1 }, - }; - props.store.tuya_brightness_v2 = { min: 10, max: 1000, scale: 0, step: 1 }; - props.store.tuya_temperature_v2 = { min: 0, max: 1000, scale: 0, step: 1 }; - props.store.tuya_colour_v2 = { - h: { min: 0, max: 360, scale: 0, step: 1 }, - s: { min: 0, max: 1000, scale: 0, step: 1 }, - v: { min: 0, max: 1000, scale: 0, step: 1 }, - }; - } else { - props.store.tuya_brightness = { min: 10, max: 1000, scale: 0, step: 1 }; - props.store.tuya_temperature = { min: 0, max: 1000, scale: 0, step: 1 }; - props.store.tuya_colour = { - h: { min: 0, max: 360, scale: 0, step: 1 }, - s: { min: 0, max: 1000, scale: 0, step: 1 }, - v: { min: 0, max: 1000, scale: 0, step: 1 }, - }; - } - - if (!specifications || !specifications.functions) { - return props; - } - - // Device Specifications - for (const functionSpecification of specifications.functions) { - const tuyaCapability = functionSpecification.code; - const values = JSON.parse(functionSpecification.values); - - if (tuyaCapability === 'bright_value') { - props.store.tuya_brightness = { ...props.store.tuya_brightness, ...values }; - } else if (tuyaCapability === 'bright_value_v2') { - props.store.tuya_brightness_v2 = { ...props.store.tuya_brightness_v2, ...values }; - } else if (tuyaCapability === 'temp_value') { - props.store.tuya_temperature = { ...props.store.tuya_temperature, ...values }; - } else if (tuyaCapability === 'temp_value_v2') { - props.store.tuya_temperature_v2 = { ...props.store.tuya_temperature_v2, ...values }; - } else if (tuyaCapability === 'colour_data') { - for (const index of ['h', 's', 'v']) { - props.store.tuya_colour[index] = { - ...props.store.tuya_colour[index], - ...values?.[index], - }; - } - } else if (tuyaCapability === 'colour_data_v2') { - for (const index of ['h', 's', 'v']) { - props.store.tuya_colour_v2[index] = { - ...props.store.tuya_colour_v2[index], - ...values?.[index], - }; - } - } - } - return props; } }; diff --git a/drivers/sensor_climate/driver.ts b/drivers/sensor_climate/driver.ts index bdd35a20..19b32d41 100644 --- a/drivers/sensor_climate/driver.ts +++ b/drivers/sensor_climate/driver.ts @@ -29,6 +29,10 @@ module.exports = class TuyaOAuth2DriverSensorClimate extends TuyaOAuth2DriverSen // Remove duplicate capabilities props.capabilities = [...new Set(props.capabilities)]; + if (!specifications || !specifications.status) { + return props; + } + for (const statusSpecifications of specifications.status) { const tuyaCapability = statusSpecifications.code; const values = JSON.parse(statusSpecifications.values); diff --git a/drivers/sensor_motion/driver.ts b/drivers/sensor_motion/driver.ts index cf71d06b..83f1c627 100644 --- a/drivers/sensor_motion/driver.ts +++ b/drivers/sensor_motion/driver.ts @@ -24,7 +24,7 @@ module.exports = class TuyaOAuth2DriverSensorMotion extends TuyaOAuth2DriverSens props.capabilities.push('alarm_motion'); } - if (!specifications) { + if (!specifications || !specifications.status) { return props; } diff --git a/drivers/sensor_smoke/driver.ts b/drivers/sensor_smoke/driver.ts index 38484c19..ba37af77 100644 --- a/drivers/sensor_smoke/driver.ts +++ b/drivers/sensor_smoke/driver.ts @@ -24,7 +24,7 @@ module.exports = class TuyaOAuth2DriverSensorSmoke extends TuyaOAuth2DriverSenso props.capabilities.push('alarm_smoke'); } - if (!specifications) { + if (!specifications || !specifications.status) { return props; } diff --git a/drivers/socket/device.ts b/drivers/socket/device.ts index 81752893..1aff3e72 100644 --- a/drivers/socket/device.ts +++ b/drivers/socket/device.ts @@ -139,20 +139,10 @@ export default class TuyaOAuth2DeviceSocket extends TuyaOAuth2Device { } async onSettings(event: SettingsEvent): Promise { - // Only some settings need to be sent to the device - function filterTuyaChangedKeys(changedKeys: (keyof HomeySocketSettings)[]): (keyof TuyaSocketSettings)[] { - return changedKeys.filter(key => ['child_lock', 'relay_status'].includes(key)) as (keyof TuyaSocketSettings)[]; - } - - const tuyaSettingsEvent: SettingsEvent = { - oldSettings: { - ...event.oldSettings, - }, - newSettings: { - ...event.newSettings, - }, - changedKeys: filterTuyaChangedKeys(event.changedKeys), - }; + const tuyaSettingsEvent = TuyaOAuth2Util.filterTuyaSettings(event, [ + 'child_lock', + 'relay_status', + ]); if (this.getStoreValue('tuya_category') === 'tdq') { const mappedNewSettings = { ...tuyaSettingsEvent.newSettings }; diff --git a/drivers/socket/driver.ts b/drivers/socket/driver.ts index 241c32c7..60ab171d 100644 --- a/drivers/socket/driver.ts +++ b/drivers/socket/driver.ts @@ -185,7 +185,7 @@ module.exports = class TuyaOAuth2DriverSocket extends TuyaOAuth2Driver { // TODO: USB sockets (?) - if (!specifications) { + if (!specifications || !specifications.status) { return props; } diff --git a/lib/TuyaOAuth2DeviceWithLight.ts b/lib/TuyaOAuth2DeviceWithLight.ts new file mode 100644 index 00000000..612c0f7a --- /dev/null +++ b/lib/TuyaOAuth2DeviceWithLight.ts @@ -0,0 +1,146 @@ +import TuyaOAuth2Device from './TuyaOAuth2Device'; +import { ParsedColourData, TuyaStatus } from '../types/TuyaTypes'; +import { TuyaCommand } from '../types/TuyaApiTypes'; + +/** + * Handles all light-related capabilities, except onoff + */ +export default class TuyaOAuth2DeviceWithLight extends TuyaOAuth2Device { + LIGHT_DIM_CAPABILITY = 'dim'; + + LIGHT_DIM_TUYA_CAPABILITY = 'bright_value'; + LIGHT_TEMP_TUYA_CAPABILITY = 'temp_value'; + LIGHT_COLOR_TUYA_CAPABILITY = 'colour_data'; + + LIGHT_DIM_TUYA_SPECS = 'tuya_brightness'; + LIGHT_TEMP_TUYA_SPECS = 'tuya_temperature'; + LIGHT_COLOR_TUYA_SPECS = 'tuya_colour'; + + async onOAuth2Init(): Promise { + await super.onOAuth2Init(); + + // light capabilities + const lightCapabilities = [ + this.LIGHT_DIM_CAPABILITY, + 'light_hue', + 'light_saturation', + 'light_temperature', + 'light_mode', + ].filter(lightCapability => this.hasCapability(lightCapability)); + + if (lightCapabilities.length > 0) { + this.registerMultipleCapabilityListener( + lightCapabilities, + capabilityValues => this.onCapabilitiesLight(capabilityValues), + 150, + ); + } + } + + async onTuyaStatus(status: TuyaStatus, changedStatusCodes: string[]): Promise { + await super.onTuyaStatus(status, changedStatusCodes); + + // Light + const workMode = status['work_mode'] as 'white' | 'colour' | string | undefined; + const lightTemp = status[this.LIGHT_TEMP_TUYA_CAPABILITY] as number | undefined; + const lightDim = status[this.LIGHT_DIM_TUYA_CAPABILITY] as number | undefined; + const lightColor = status[this.LIGHT_COLOR_TUYA_CAPABILITY] as ParsedColourData | undefined; + + if (workMode === 'white') { + await this.safeSetCapabilityValue('light_mode', 'temperature'); + } else if (workMode === 'colour') { + await this.safeSetCapabilityValue('light_mode', 'color'); + } else { + await this.safeSetCapabilityValue('light_mode', null); + } + + if (lightTemp) { + const specs = this.store[this.LIGHT_TEMP_TUYA_SPECS]; + const light_temperature = 1 - (lightTemp - specs.min) / (specs.max - specs.min); + await this.safeSetCapabilityValue('light_temperature', light_temperature); + } + + if (lightDim && (workMode === 'white' || workMode === undefined)) { + const specs = this.store[this.LIGHT_DIM_TUYA_SPECS]; + const dim = (lightDim - specs.min) / (specs.max - specs.min); + await this.safeSetCapabilityValue(this.LIGHT_DIM_CAPABILITY, dim); + } + + if (lightColor) { + const specs = this.store[this.LIGHT_COLOR_TUYA_SPECS]; + const h = (lightColor.h - specs.h.min) / (specs.h.max - specs.h.min); + const s = (lightColor.s - specs.s.min) / (specs.s.max - specs.s.min); + + await this.safeSetCapabilityValue('light_hue', h); + await this.safeSetCapabilityValue('light_saturation', s); + + if (workMode === 'colour') { + const v = (lightColor.v - specs.v.min) / (specs.v.max - specs.v.min); + await this.safeSetCapabilityValue(this.LIGHT_DIM_CAPABILITY, v); + } + } + } + + async onCapabilitiesLight(newValues: Record): Promise { + let light_mode = newValues['light_mode'] ?? this.getCapabilityValue('light_mode'); + const light_hue = newValues['light_hue'] ?? this.getCapabilityValue('light_hue'); + const light_saturation = newValues['light_saturation'] ?? this.getCapabilityValue('light_saturation'); + const light_temperature = newValues['light_temperature'] ?? this.getCapabilityValue('light_temperature'); + const light_dim = newValues[this.LIGHT_DIM_CAPABILITY] ?? this.getCapabilityValue(this.LIGHT_DIM_CAPABILITY); + + const commands: TuyaCommand[] = []; + + if (!light_mode) { + if (this.hasCapability('light_hue')) { + light_mode = 'color'; + } else { + light_mode = 'temperature'; + } + } + + if (this.hasTuyaCapability('work_mode')) { + commands.push({ + code: 'work_mode', + value: light_mode === 'color' ? 'colour' : 'white', + }); + } + + if (light_mode === 'color') { + const specs = this.store[this.LIGHT_COLOR_TUYA_SPECS]; + const h = Math.round(specs.h.min + light_hue * (specs.h.max - specs.h.min)); + const s = Math.round(specs.s.min + light_saturation * (specs.s.max - specs.s.min)); + const v = Math.round(specs.v.min + light_dim * (specs.v.max - specs.v.min)); + + commands.push({ + code: this.LIGHT_COLOR_TUYA_CAPABILITY, + value: { h, s, v }, + }); + } else { + // Dim + if (light_dim && this.hasTuyaCapability(this.LIGHT_DIM_TUYA_CAPABILITY)) { + const specs = this.store[this.LIGHT_DIM_TUYA_SPECS]; + const brightValue = Math.round(specs.min + light_dim * (specs.max - specs.min)); + + commands.push({ + code: this.LIGHT_DIM_TUYA_CAPABILITY, + value: brightValue, + }); + } + + // Temperature + if (light_temperature && this.hasTuyaCapability(this.LIGHT_TEMP_TUYA_CAPABILITY)) { + const specs = this.store[this.LIGHT_TEMP_TUYA_SPECS]; + const tempValue = Math.round(specs.min + (1 - light_temperature) * (specs.max - specs.min)); + + commands.push({ + code: this.LIGHT_TEMP_TUYA_CAPABILITY, + value: tempValue, + }); + } + } + + if (commands.length) { + await this.sendCommands(commands); + } + } +} diff --git a/lib/TuyaOAuth2DriverWithLight.ts b/lib/TuyaOAuth2DriverWithLight.ts new file mode 100644 index 00000000..821ed8f5 --- /dev/null +++ b/lib/TuyaOAuth2DriverWithLight.ts @@ -0,0 +1,122 @@ +import TuyaOAuth2Driver, { ListDeviceProperties } from './TuyaOAuth2Driver'; +import { + TuyaDeviceDataPointResponse, + TuyaDeviceResponse, + TuyaDeviceSpecificationResponse, +} from '../types/TuyaApiTypes'; + +/** + * Handles all light-related capabilities, except onoff + */ +export default class TuyaOAuth2DriverWithLight extends TuyaOAuth2Driver { + LIGHT_DIM_CAPABILITY = 'dim'; + + onTuyaPairListDeviceProperties( + device: TuyaDeviceResponse, + specifications?: TuyaDeviceSpecificationResponse, + dataPoints?: TuyaDeviceDataPointResponse, + ): ListDeviceProperties { + const props = super.onTuyaPairListDeviceProperties(device, specifications, dataPoints); + + for (const status of device.status) { + const tuyaCapability = status.code; + + // dim + if (tuyaCapability === 'bright_value' || tuyaCapability === 'bright_value_v2') { + props.store.tuya_capabilities.push(tuyaCapability); + props.capabilities.push(this.LIGHT_DIM_CAPABILITY); + } + + // light temperature + if (tuyaCapability === 'temp_value' || tuyaCapability === 'temp_value_v2') { + props.store.tuya_capabilities.push(tuyaCapability); + props.capabilities.push('light_temperature'); + } + + // light hue and saturation + if (tuyaCapability === 'colour_data' || tuyaCapability === 'colour_data_v2') { + props.store.tuya_capabilities.push(tuyaCapability); + props.capabilities.push('light_hue'); + props.capabilities.push('light_saturation'); + props.capabilities.push(this.LIGHT_DIM_CAPABILITY); + } + + // light_mode + if (tuyaCapability === 'work_mode') { + props.store.tuya_capabilities.push(tuyaCapability); + } + } + + // Only add light mode capability when both temperature and colour data is available + if (props.capabilities.includes('light_temperature') && props.capabilities.includes('light_hue')) { + props.capabilities.push('light_mode'); + } + + // Remove duplicate capabilities + props.capabilities = [...new Set(props.capabilities)]; + + // Category Specifications + // The main light category has both (0,255) and (0,1000) for backwards compatibility + // Other categories use only (0,1000) + if (device.category === 'dj') { + props.store.tuya_brightness = { min: 25, max: 255, scale: 0, step: 1 }; + props.store.tuya_temperature = { min: 0, max: 255, scale: 0, step: 1 }; + props.store.tuya_colour = { + h: { min: 0, max: 360, scale: 0, step: 1 }, + s: { min: 0, max: 255, scale: 0, step: 1 }, + v: { min: 0, max: 255, scale: 0, step: 1 }, + }; + props.store.tuya_brightness_v2 = { min: 10, max: 1000, scale: 0, step: 1 }; + props.store.tuya_temperature_v2 = { min: 0, max: 1000, scale: 0, step: 1 }; + props.store.tuya_colour_v2 = { + h: { min: 0, max: 360, scale: 0, step: 1 }, + s: { min: 0, max: 1000, scale: 0, step: 1 }, + v: { min: 0, max: 1000, scale: 0, step: 1 }, + }; + } else { + props.store.tuya_brightness = { min: 10, max: 1000, scale: 0, step: 1 }; + props.store.tuya_temperature = { min: 0, max: 1000, scale: 0, step: 1 }; + props.store.tuya_colour = { + h: { min: 0, max: 360, scale: 0, step: 1 }, + s: { min: 0, max: 1000, scale: 0, step: 1 }, + v: { min: 0, max: 1000, scale: 0, step: 1 }, + }; + } + + if (!specifications || !specifications.functions) { + return props; + } + + // Device Specifications + for (const functionSpecification of specifications.functions) { + const tuyaCapability = functionSpecification.code; + const values = JSON.parse(functionSpecification.values); + + if (tuyaCapability === 'bright_value') { + props.store.tuya_brightness = { ...props.store.tuya_brightness, ...values }; + } else if (tuyaCapability === 'bright_value_v2') { + props.store.tuya_brightness_v2 = { ...props.store.tuya_brightness_v2, ...values }; + } else if (tuyaCapability === 'temp_value') { + props.store.tuya_temperature = { ...props.store.tuya_temperature, ...values }; + } else if (tuyaCapability === 'temp_value_v2') { + props.store.tuya_temperature_v2 = { ...props.store.tuya_temperature_v2, ...values }; + } else if (tuyaCapability === 'colour_data') { + for (const index of ['h', 's', 'v']) { + props.store.tuya_colour[index] = { + ...props.store.tuya_colour[index], + ...values?.[index], + }; + } + } else if (tuyaCapability === 'colour_data_v2') { + for (const index of ['h', 's', 'v']) { + props.store.tuya_colour_v2[index] = { + ...props.store.tuya_colour_v2[index], + ...values?.[index], + }; + } + } + } + + return props; + } +} diff --git a/lib/TuyaOAuth2Util.ts b/lib/TuyaOAuth2Util.ts index b61ad5cd..f1128e54 100644 --- a/lib/TuyaOAuth2Util.ts +++ b/lib/TuyaOAuth2Util.ts @@ -231,6 +231,35 @@ export async function onSettings( ); } +/** + * Filters Tuya settings that map to Tuya capabilities from a Homey settings event + * @param homeySettingsEvent - The original settings event + * @param tuyaSettingsKeys - A list of settings that map to Tuya capabilities + * @returns A new settings event with only Tuya capabilities in the changedKeys + */ +export function filterTuyaSettings( + homeySettingsEvent: SettingsEvent, + tuyaSettingsKeys: (keyof T)[], +): SettingsEvent { + // only include settings that can be mapped one-to-one with a Tuya capability + function filterTuyaChangedKeys(changedKeys: (keyof H)[]): (keyof T)[] { + return changedKeys.filter(key => constIncludes(tuyaSettingsKeys, key)) as (keyof T)[]; + } + + // original settings event is immutable, so a copy is needed + const tuyaSettingsEvent: SettingsEvent = { + oldSettings: { + ...homeySettingsEvent.oldSettings, + }, + newSettings: { + ...homeySettingsEvent.newSettings, + }, + changedKeys: filterTuyaChangedKeys(homeySettingsEvent.changedKeys), + }; + + return tuyaSettingsEvent; +} + // The standard TypeScript definition of Array.includes does not work for const arrays. // This typing gives a boolean for an unknown S, and true if S is known to be in T from its type. export function constIncludes(array: ReadonlyArray, search: S): S extends T ? true : boolean { diff --git a/lib/migrations/TuyaFanMigrations.ts b/lib/migrations/TuyaFanMigrations.ts new file mode 100644 index 00000000..e29146d4 --- /dev/null +++ b/lib/migrations/TuyaFanMigrations.ts @@ -0,0 +1,25 @@ +import { executeMigration } from './MigrationStore'; +import TuyaOAuth2DeviceFan from '../../drivers/fan/device'; + +export async function performMigrations(device: TuyaOAuth2DeviceFan): Promise { + await tuyaCapabilitiesMigration(device).catch(device.error); +} + +async function tuyaCapabilitiesMigration(device: TuyaOAuth2DeviceFan): Promise { + await executeMigration(device, 'fan_tuya_capabilities', async () => { + device.log('Migrating Tuya capabilities...'); + + const tuyaCapabilities = []; + + const status = await device.getStatus(); + for (const tuyaCapability in status) { + if (tuyaCapability === 'switch' || tuyaCapability === 'fan_speed_percent') { + tuyaCapabilities.push(tuyaCapability); + } + } + + await device.setStoreValue('tuya_capabilities', tuyaCapabilities); + + device.log('Tuya capabilities added:', tuyaCapabilities); + }); +} diff --git a/types/TuyaApiTypes.ts b/types/TuyaApiTypes.ts index c4ff2891..63ef282f 100644 --- a/types/TuyaApiTypes.ts +++ b/types/TuyaApiTypes.ts @@ -89,8 +89,8 @@ type TuyaSpecificationDatum = { export type TuyaDeviceSpecificationResponse = { category: string; - functions: TuyaSpecificationDatum[]; - status: TuyaSpecificationDatum[]; + functions?: TuyaSpecificationDatum[]; + status?: TuyaSpecificationDatum[]; }; export type TuyaDeviceDataPointResponse = { diff --git a/types/TuyaTypes.ts b/types/TuyaTypes.ts index 5e4d772a..45fa60b2 100644 --- a/types/TuyaTypes.ts +++ b/types/TuyaTypes.ts @@ -27,3 +27,5 @@ export type StandardDeviceFlowArgs = { device: T }; export type StandardValueFlowArgs = { value: T }; export type StandardFlowArgs = StandardDeviceFlowArgs & StandardValueFlowArgs; + +export type ParsedColourData = { h: number; s: number; v: number };