From 25ce675ea77716b907d4b6230de57b3f08488542 Mon Sep 17 00:00:00 2001 From: Bob van de Vijver Date: Wed, 28 Aug 2024 10:05:41 +0200 Subject: [PATCH 01/22] Add fan light support setting checkbox --- app.json | 11 +++++++++++ drivers/fan/driver.settings.compose.json | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/app.json b/app.json index eb09dfdc..9553fe53 100644 --- a/app.json +++ b/app.json @@ -2452,6 +2452,17 @@ }, "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": "deviceSpecification", "type": "label", diff --git a/drivers/fan/driver.settings.compose.json b/drivers/fan/driver.settings.compose.json index dceebc0d..161f57bc 100644 --- a/drivers/fan/driver.settings.compose.json +++ b/drivers/fan/driver.settings.compose.json @@ -1,4 +1,15 @@ [ + { + "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 + }, { "$extends": "deviceSpecification" } From 4aa0954c2a3b78b7cad0b5cbec1c32c6c079c00a Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Thu, 29 Aug 2024 15:00:48 +0200 Subject: [PATCH 02/22] Expand fan capabilities --- .homeycompose/app.json | 2 +- .../capabilities/fan_swing_horizontal.json | 9 +++ .../capabilities/fan_swing_vertical.json | 9 +++ .../capabilities/legacy_fan_speed.json | 35 ++++++++++ app.json | 55 ++++++++++++++- drivers/fan/TuyaFanConstants.ts | 23 ++++++ drivers/fan/device.ts | 64 +++++++---------- drivers/fan/driver.ts | 70 ++++++++++++------- lib/migrations/TuyaFanMigrations.ts | 25 +++++++ 9 files changed, 227 insertions(+), 65 deletions(-) create mode 100644 .homeycompose/capabilities/fan_swing_horizontal.json create mode 100644 .homeycompose/capabilities/fan_swing_vertical.json create mode 100644 .homeycompose/capabilities/legacy_fan_speed.json create mode 100644 drivers/fan/TuyaFanConstants.ts create mode 100644 lib/migrations/TuyaFanMigrations.ts 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 9553fe53..68464312 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": [ @@ -3811,6 +3811,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": { @@ -3820,6 +3838,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..2ef6b8fa --- /dev/null +++ b/drivers/fan/TuyaFanConstants.ts @@ -0,0 +1,23 @@ +export const FAN_CAPABILITIES_MAPPING = { + switch: 'onoff', + fan_speed_percent: 'dim', + fan_speed: 'legacy_fan_speed', + switch_vertical: 'fan_swing_vertical', + switch_horizontal: 'fan_swing_horizontal', + child_lock: 'child_lock', + temp: 'target_temperature', + temp_current: 'measure_temperature', +} as const; + +export const FAN_CAPABILITIES = { + read_write: [ + 'switch', + 'fan_speed_percent', + 'switch_horizontal', + 'switch_vertical', + 'child_lock', + 'temp', + 'fan_speed', + ], + read_only: ['temp_current'], +} as const; diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 7b8e4069..49b4c2bd 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -1,53 +1,41 @@ import TuyaOAuth2Device from '../../lib/TuyaOAuth2Device'; import { TuyaStatus } from '../../types/TuyaTypes'; +import { FAN_CAPABILITIES, FAN_CAPABILITIES_MAPPING } from './TuyaFanConstants'; +import { constIncludes, getFromMap } from '../../lib/TuyaOAuth2Util'; +import * as TuyaFanMigrations from '../../lib/migrations/TuyaFanMigrations'; -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 TuyaOAuth2Device { async onOAuth2Init(): Promise { await super.onOAuth2Init(); - // onoff - if (this.hasCapability('onoff')) { - this.registerCapabilityListener('onoff', this.onCapabilityOnOff); - } - // dim - if (this.hasCapability('dim')) { - this.registerCapabilityListener('dim', this.onCapabilityDim); + for (const [tuyaCapability, capability] of Object.entries(FAN_CAPABILITIES_MAPPING)) { + if (constIncludes(FAN_CAPABILITIES.read_write, tuyaCapability) && this.hasCapability(capability)) { + this.registerCapabilityListener(capability, value => this.sendCommand({ code: tuyaCapability, value })); + } } } + async performMigrations(): Promise { + await super.performMigrations(); + await TuyaFanMigrations.performMigrations(this); + } + async onTuyaStatus(status: TuyaStatus, changedStatusCodes: string[]): Promise { await super.onTuyaStatus(status, changedStatusCodes); - // onoff - if (typeof status['switch'] === 'boolean') { - this.setCapabilityValue('onoff', status['switch']).catch(this.error); - } - - // dim - if (typeof status['fan_speed_percent'] === 'number') { - this.setCapabilityValue('dim', status['fan_speed_percent']).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); + } } } +} - async onCapabilityOnOff(value: boolean): Promise { - await this.sendCommand({ - code: 'switch', - value: value, - }); - } - - async onCapabilityDim(value: number): Promise { - await this.sendCommand({ - code: 'fan_speed_percent', - value: value, - }); - } -}; +module.exports = TuyaOAuth2DeviceFan; diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index d8f478b9..71190a92 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -5,12 +5,11 @@ import { TuyaDeviceResponse, TuyaDeviceSpecificationResponse, } from '../../types/TuyaApiTypes'; +import { getFromMap } from '../../lib/TuyaOAuth2Util'; +import { FAN_CAPABILITIES_MAPPING } from './TuyaFanConstants'; module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { - TUYA_DEVICE_CATEGORIES = [ - DEVICE_CATEGORIES.SMALL_HOME_APPLIANCES.FAN, - // TODO - ] as const; + TUYA_DEVICE_CATEGORIES = [DEVICE_CATEGORIES.SMALL_HOME_APPLIANCES.FAN] as const; onTuyaPairListDeviceProperties( device: TuyaDeviceResponse, @@ -19,38 +18,59 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { ): ListDeviceProperties { 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; - // dim - const hasFanSpeedPercent = device.status.some(({ code }) => code === 'fan_speed_percent'); - if (hasFanSpeedPercent) { - props.capabilities.push('dim'); - props.capabilitiesOptions['dim'] = { - min: 1, - max: 6, - step: 1, - }; + const homeyCapability = getFromMap(FAN_CAPABILITIES_MAPPING, tuyaCapability); + if (homeyCapability) { + props.store.tuya_capabilities.push(tuyaCapability); + props.capabilities.push(homeyCapability); + } } - if (!specifications || !specifications.functions) { + if (!specifications) { 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, + step: values.step ?? 0, + }; + } + + 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, }; } } 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); + }); +} From 2f684dd22a9c122f2ae9e27ce8c077e71d7fd246 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 14:26:38 +0200 Subject: [PATCH 03/22] Add light to fan (fs) --- drivers/fan/TuyaFanConstants.ts | 7 ++ drivers/fan/device.ts | 119 +++++++++++++++++++++++++++++++- drivers/fan/driver.ts | 78 +++++++++++++++++++++ drivers/light/device.ts | 4 +- types/TuyaTypes.ts | 2 + 5 files changed, 206 insertions(+), 4 deletions(-) diff --git a/drivers/fan/TuyaFanConstants.ts b/drivers/fan/TuyaFanConstants.ts index 2ef6b8fa..e45b0ee3 100644 --- a/drivers/fan/TuyaFanConstants.ts +++ b/drivers/fan/TuyaFanConstants.ts @@ -7,6 +7,11 @@ export const FAN_CAPABILITIES_MAPPING = { child_lock: 'child_lock', temp: 'target_temperature', temp_current: 'measure_temperature', + // light + work_mode: 'light_mode', + light: 'onoff.light', + bright_value: 'dim.light', + temp_value: 'light_temperature', } as const; export const FAN_CAPABILITIES = { @@ -18,6 +23,8 @@ export const FAN_CAPABILITIES = { 'child_lock', 'temp', 'fan_speed', + // Light + 'light', ], read_only: ['temp_current'], } as const; diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 49b4c2bd..cbe54857 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -1,8 +1,9 @@ import TuyaOAuth2Device from '../../lib/TuyaOAuth2Device'; -import { TuyaStatus } from '../../types/TuyaTypes'; +import { ParsedColourData, TuyaStatus } from '../../types/TuyaTypes'; import { FAN_CAPABILITIES, FAN_CAPABILITIES_MAPPING } from './TuyaFanConstants'; import { constIncludes, getFromMap } from '../../lib/TuyaOAuth2Util'; import * as TuyaFanMigrations from '../../lib/migrations/TuyaFanMigrations'; +import { TuyaCommand } from '../../types/TuyaApiTypes'; export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { async onOAuth2Init(): Promise { @@ -13,6 +14,19 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { this.registerCapabilityListener(capability, value => this.sendCommand({ code: tuyaCapability, value })); } } + + // light capabilities + const lightCapabilities = ['dim.light', '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 performMigrations(): Promise { @@ -35,6 +49,109 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { await this.safeSetCapabilityValue(homeyCapability, value); } } + + // Light + const workMode = status['work_mode'] as 'white' | 'colour' | 'colourful' | undefined; + const lightTemp = status['temp_value'] as number | undefined; + const lightDim = status['bright_value'] as number | undefined; + const lightColor = status['colour_data'] 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.tuya_temperature; + const light_temperature = (lightTemp - specs.min) / (specs.max - specs.min); + await this.safeSetCapabilityValue('light_temperature', light_temperature); + } + + if (lightDim && (workMode === 'white' || workMode === undefined)) { + const specs = this.store.tuya_brightness; + const dim = (lightDim - specs.min) / (specs.max - specs.min); + await this.safeSetCapabilityValue('dim.light', dim); + } + + if (lightColor) { + const specs = this.store.tuya_colour; + 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('dim.light', v); + } + } + } + + async onCapabilitiesLight({ + light_dim = this.getCapabilityValue('dim.light'), + 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[] = []; + + 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.tuya_colour; + const h = specs.h.min + light_hue * (specs.h.max - specs.h.min); + const s = specs.s.min + light_saturation * (specs.s.max - specs.s.min); + const v = specs.v.min + light_dim * (specs.v.max - specs.v.min); + + commands.push({ + code: 'colour_data', + value: { h, s, v }, + }); + } else { + // Dim + if (light_dim && this.hasTuyaCapability('bright_value')) { + const specs = this.store.tuya_brightness; + const brightValue = specs.min + light_dim * (specs.max - specs.min); + + commands.push({ + code: 'bright_value', + value: brightValue, + }); + } + + // Temperature + if (light_temperature && this.hasTuyaCapability('temp_value')) { + const specs = this.store.tuya_brightness; + const tempValue = specs.min + light_temperature * (specs.max - specs.min); + + commands.push({ + code: 'temp_value', + value: tempValue, + }); + } + } + + if (commands.length) { + await this.sendCommands(commands); + } } } diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index 71190a92..d4492107 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -30,6 +30,66 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { } } + // 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.includes('light_mode') + ) { + props.capabilities.push('light_mode'); + } + + // 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'] = { + title: { + en: `Fan`, + }, + }; + + props.capabilitiesOptions['dim.light'] = { + title: { + en: `Light`, + }, + }; + } + + // Default light specifications + 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) { return props; } @@ -73,6 +133,24 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { max: values.max ?? 50, }; } + + // Light + if (tuyaCapability === 'bright_value') { + props.store.tuya_brightness = { ...props.store.tuya_brightness, ...values }; + } + + if (tuyaCapability === 'temp_value') { + props.store.tuya_temperature = { ...props.store.tuya_temperature, ...values }; + } + + if (tuyaCapability === 'colour_data') { + for (const index of ['h', 's', 'v']) { + props.store.tuya_colour[index] = { + ...props.store.tuya_colour[index], + ...values?.[index], + }; + } + } } return props; diff --git a/drivers/light/device.ts b/drivers/light/device.ts index 8730e3a2..2536e3e9 100644 --- a/drivers/light/device.ts +++ b/drivers/light/device.ts @@ -2,11 +2,9 @@ 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 { ParsedColourData, SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; import { LIGHT_SETTING_LABELS, LightSettingCommand, LightSettingKey, PIR_CAPABILITIES } from './TuyaLightConstants'; -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; 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 }; From fda6c4680326ccbc34f5a82dad848e4bd2cb8d44 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 15:13:30 +0200 Subject: [PATCH 04/22] Add ceiling fan light (fsd) --- drivers/fan/TuyaFanConstants.ts | 7 +++++-- drivers/fan/device.ts | 19 ++++++++++++++++++- drivers/fan/driver.ts | 14 +++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/drivers/fan/TuyaFanConstants.ts b/drivers/fan/TuyaFanConstants.ts index e45b0ee3..80258070 100644 --- a/drivers/fan/TuyaFanConstants.ts +++ b/drivers/fan/TuyaFanConstants.ts @@ -1,7 +1,8 @@ export const FAN_CAPABILITIES_MAPPING = { switch: 'onoff', + fan_switch: 'onoff', fan_speed_percent: 'dim', - fan_speed: 'legacy_fan_speed', + // fan_speed can be both dim and legacy_fan_speed switch_vertical: 'fan_swing_vertical', switch_horizontal: 'fan_swing_horizontal', child_lock: 'child_lock', @@ -10,6 +11,7 @@ export const FAN_CAPABILITIES_MAPPING = { // light work_mode: 'light_mode', light: 'onoff.light', + switch_led: 'onoff.light', bright_value: 'dim.light', temp_value: 'light_temperature', } as const; @@ -17,14 +19,15 @@ export const FAN_CAPABILITIES_MAPPING = { export const FAN_CAPABILITIES = { read_write: [ 'switch', + 'fan_switch', 'fan_speed_percent', 'switch_horizontal', 'switch_vertical', 'child_lock', 'temp', - 'fan_speed', // Light 'light', + 'switch_led', ], read_only: ['temp_current'], } as const; diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index cbe54857..d38fa8d4 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -15,6 +15,15 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { } } + // fan_speed + if (this.hasCapability('legacy_fan_speed')) { + this.registerCapabilityListener('legacy_fan_speed', value => this.sendCommand({ code: 'fan_speed', value })); + } + + if (this.hasCapability('dim') && this.getStoreValue('tuya_category') === 'fsd') { + this.registerCapabilityListener('dim', value => this.sendCommand({ code: 'fan_speed', value })); + } + // light capabilities const lightCapabilities = ['dim.light', 'light_hue', 'light_saturation', 'light_temperature', 'light_mode'].filter( lightCapability => this.hasCapability(lightCapability), @@ -48,10 +57,18 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { ) { await this.safeSetCapabilityValue(homeyCapability, value); } + + if (tuyaCapability === 'fan_speed') { + if (this.getStoreValue('tuya_category') === 'fsd') { + await this.safeSetCapabilityValue('dim', value); + } else { + await this.safeSetCapabilityValue('legacy_fan_speed', value); + } + } } // Light - const workMode = status['work_mode'] as 'white' | 'colour' | 'colourful' | undefined; + const workMode = status['work_mode'] as 'white' | 'colour' | 'colourful' | 'scene' | 'music' | undefined; const lightTemp = status['temp_value'] as number | undefined; const lightDim = status['bright_value'] as number | undefined; const lightColor = status['colour_data'] as ParsedColourData | undefined; diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index d4492107..9df6e32b 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -9,7 +9,10 @@ import { getFromMap } from '../../lib/TuyaOAuth2Util'; import { FAN_CAPABILITIES_MAPPING } from './TuyaFanConstants'; module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { - TUYA_DEVICE_CATEGORIES = [DEVICE_CATEGORIES.SMALL_HOME_APPLIANCES.FAN] as const; + TUYA_DEVICE_CATEGORIES = [ + DEVICE_CATEGORIES.SMALL_HOME_APPLIANCES.FAN, + DEVICE_CATEGORIES.LIGHTING.CEILING_FAN_LIGHT, + ] as const; onTuyaPairListDeviceProperties( device: TuyaDeviceResponse, @@ -28,6 +31,15 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { 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'); + } + } } // Only add light mode capability when both temperature and colour data is available From b535a89a53bffe07559534c22bd3ece3620e024a Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 15:33:51 +0200 Subject: [PATCH 05/22] Fix fan color light detection --- drivers/fan/TuyaFanConstants.ts | 1 + drivers/fan/driver.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/drivers/fan/TuyaFanConstants.ts b/drivers/fan/TuyaFanConstants.ts index 80258070..af902523 100644 --- a/drivers/fan/TuyaFanConstants.ts +++ b/drivers/fan/TuyaFanConstants.ts @@ -14,6 +14,7 @@ export const FAN_CAPABILITIES_MAPPING = { switch_led: 'onoff.light', bright_value: 'dim.light', temp_value: 'light_temperature', + // colour_data is split between light_hue, light_saturation and dim.light } as const; export const FAN_CAPABILITIES = { diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index 9df6e32b..45a50e4a 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -40,6 +40,16 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { 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'); + } + } + + if (props.store.tuya_capabilities.includes('colour_data') && !props.capabilities.includes('dim.light')) { + props.capabilities.push('dim.light'); } // Only add light mode capability when both temperature and colour data is available From 10d47b69bedb8eb04a79aafa78dea985f8a64afe Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 15:47:35 +0200 Subject: [PATCH 06/22] Implement fan light setting --- drivers/fan/TuyaFanConstants.ts | 6 ++++++ drivers/fan/device.ts | 36 +++++++++++++++++++++++++++++++-- drivers/fan/driver.ts | 4 ++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/drivers/fan/TuyaFanConstants.ts b/drivers/fan/TuyaFanConstants.ts index af902523..e3103a1f 100644 --- a/drivers/fan/TuyaFanConstants.ts +++ b/drivers/fan/TuyaFanConstants.ts @@ -32,3 +32,9 @@ export const FAN_CAPABILITIES = { ], read_only: ['temp_current'], } as const; + +export type HomeyFanSettings = { + enable_light_support: boolean; +}; + +export type TuyaFanSettings = Record; diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index d38fa8d4..0f13aaae 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -1,6 +1,6 @@ import TuyaOAuth2Device from '../../lib/TuyaOAuth2Device'; -import { ParsedColourData, TuyaStatus } from '../../types/TuyaTypes'; -import { FAN_CAPABILITIES, FAN_CAPABILITIES_MAPPING } from './TuyaFanConstants'; +import { ParsedColourData, SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; +import { FAN_CAPABILITIES, FAN_CAPABILITIES_MAPPING, HomeyFanSettings } from './TuyaFanConstants'; import { constIncludes, getFromMap } from '../../lib/TuyaOAuth2Util'; import * as TuyaFanMigrations from '../../lib/migrations/TuyaFanMigrations'; import { TuyaCommand } from '../../types/TuyaApiTypes'; @@ -170,6 +170,38 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { await this.sendCommands(commands); } } + + async onSettings(event: SettingsEvent): Promise { + if (event.changedKeys.includes('enable_light_support')) { + if (event.newSettings['enable_light_support']) { + for (const lightTuyaCapability of ['light', 'switch_led', 'bright_value', 'temp_value'] as const) { + if (this.hasTuyaCapability(lightTuyaCapability)) { + const homeyCapability = FAN_CAPABILITIES_MAPPING[lightTuyaCapability]; + if (!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); + } + } + } + } } module.exports = TuyaOAuth2DeviceFan; diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index 45a50e4a..16660cbf 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -48,6 +48,10 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { } } + if (props.store.tuya_capabilities.includes('light') || props.store.tuya_capabilities.includes('switch_led')) { + props.settings['enable_light_support'] = true; + } + if (props.store.tuya_capabilities.includes('colour_data') && !props.capabilities.includes('dim.light')) { props.capabilities.push('dim.light'); } From b8b658b382e1856273223316fbee07733ec66859 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 16:16:25 +0200 Subject: [PATCH 07/22] Fix fan capability listener registration --- drivers/fan/device.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 0f13aaae..3dd4e24f 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -10,7 +10,11 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { await super.onOAuth2Init(); for (const [tuyaCapability, capability] of Object.entries(FAN_CAPABILITIES_MAPPING)) { - if (constIncludes(FAN_CAPABILITIES.read_write, tuyaCapability) && this.hasCapability(capability)) { + if ( + constIncludes(FAN_CAPABILITIES.read_write, tuyaCapability) && + this.hasCapability(capability) && + this.hasTuyaCapability(tuyaCapability) + ) { this.registerCapabilityListener(capability, value => this.sendCommand({ code: tuyaCapability, value })); } } From a0accd48cacbcefbe5b8db01727085ab14469c4c Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 16:17:48 +0200 Subject: [PATCH 08/22] Fix fan light temperature scaling --- drivers/fan/device.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 3dd4e24f..17ae06e2 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -160,7 +160,7 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { // Temperature if (light_temperature && this.hasTuyaCapability('temp_value')) { - const specs = this.store.tuya_brightness; + const specs = this.store.tuya_temperature; const tempValue = specs.min + light_temperature * (specs.max - specs.min); commands.push({ From d95bfc1a9c5c01c120dd86791b66793cc17a5d35 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 16:20:28 +0200 Subject: [PATCH 09/22] Refactor fan light device to generic class --- drivers/fan/device.ts | 126 ++------------------------ lib/TuyaOAuth2DeviceWithLight.ts | 146 +++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 120 deletions(-) create mode 100644 lib/TuyaOAuth2DeviceWithLight.ts diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 17ae06e2..596d1e5c 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -1,12 +1,13 @@ -import TuyaOAuth2Device from '../../lib/TuyaOAuth2Device'; -import { ParsedColourData, SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; +import { SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; import { FAN_CAPABILITIES, FAN_CAPABILITIES_MAPPING, HomeyFanSettings } from './TuyaFanConstants'; import { constIncludes, getFromMap } from '../../lib/TuyaOAuth2Util'; import * as TuyaFanMigrations from '../../lib/migrations/TuyaFanMigrations'; -import { TuyaCommand } from '../../types/TuyaApiTypes'; +import TuyaOAuth2DeviceWithLight from '../../lib/TuyaOAuth2DeviceWithLight'; -export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { +export default class TuyaOAuth2DeviceFan extends TuyaOAuth2DeviceWithLight { async onOAuth2Init(): Promise { + this.LIGHT_DIM_CAPABILITY = 'dim.light'; + // superclass handles light capabilities, except onoff.light await super.onOAuth2Init(); for (const [tuyaCapability, capability] of Object.entries(FAN_CAPABILITIES_MAPPING)) { @@ -27,19 +28,6 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { if (this.hasCapability('dim') && this.getStoreValue('tuya_category') === 'fsd') { this.registerCapabilityListener('dim', value => this.sendCommand({ code: 'fan_speed', value })); } - - // light capabilities - const lightCapabilities = ['dim.light', '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 performMigrations(): Promise { @@ -48,6 +36,7 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { } async onTuyaStatus(status: TuyaStatus, changedStatusCodes: string[]): Promise { + // superclass handles light capabilities, except onoff.light await super.onTuyaStatus(status, changedStatusCodes); for (const tuyaCapability in status) { @@ -70,109 +59,6 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2Device { } } } - - // Light - const workMode = status['work_mode'] as 'white' | 'colour' | 'colourful' | 'scene' | 'music' | undefined; - const lightTemp = status['temp_value'] as number | undefined; - const lightDim = status['bright_value'] as number | undefined; - const lightColor = status['colour_data'] 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.tuya_temperature; - const light_temperature = (lightTemp - specs.min) / (specs.max - specs.min); - await this.safeSetCapabilityValue('light_temperature', light_temperature); - } - - if (lightDim && (workMode === 'white' || workMode === undefined)) { - const specs = this.store.tuya_brightness; - const dim = (lightDim - specs.min) / (specs.max - specs.min); - await this.safeSetCapabilityValue('dim.light', dim); - } - - if (lightColor) { - const specs = this.store.tuya_colour; - 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('dim.light', v); - } - } - } - - async onCapabilitiesLight({ - light_dim = this.getCapabilityValue('dim.light'), - 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[] = []; - - 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.tuya_colour; - const h = specs.h.min + light_hue * (specs.h.max - specs.h.min); - const s = specs.s.min + light_saturation * (specs.s.max - specs.s.min); - const v = specs.v.min + light_dim * (specs.v.max - specs.v.min); - - commands.push({ - code: 'colour_data', - value: { h, s, v }, - }); - } else { - // Dim - if (light_dim && this.hasTuyaCapability('bright_value')) { - const specs = this.store.tuya_brightness; - const brightValue = specs.min + light_dim * (specs.max - specs.min); - - commands.push({ - code: 'bright_value', - value: brightValue, - }); - } - - // Temperature - if (light_temperature && this.hasTuyaCapability('temp_value')) { - const specs = this.store.tuya_temperature; - const tempValue = specs.min + light_temperature * (specs.max - specs.min); - - commands.push({ - code: 'temp_value', - value: tempValue, - }); - } - } - - if (commands.length) { - await this.sendCommands(commands); - } } async onSettings(event: SettingsEvent): Promise { diff --git a/lib/TuyaOAuth2DeviceWithLight.ts b/lib/TuyaOAuth2DeviceWithLight.ts new file mode 100644 index 00000000..0369eef0 --- /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 = (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({ + light_dim = this.getCapabilityValue(this.LIGHT_DIM_CAPABILITY), + 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[] = []; + + 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 = specs.h.min + light_hue * (specs.h.max - specs.h.min); + const s = specs.s.min + light_saturation * (specs.s.max - specs.s.min); + const v = 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 = 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 = specs.min + light_temperature * (specs.max - specs.min); + + commands.push({ + code: this.LIGHT_TEMP_TUYA_CAPABILITY, + value: tempValue, + }); + } + } + + if (commands.length) { + await this.sendCommands(commands); + } + } +} From 52d12a7ef0500052afacce3507bc0cb120be9547 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 16:50:37 +0200 Subject: [PATCH 10/22] Fix light value conversions --- lib/TuyaOAuth2DeviceWithLight.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/TuyaOAuth2DeviceWithLight.ts b/lib/TuyaOAuth2DeviceWithLight.ts index 0369eef0..e12d6f5b 100644 --- a/lib/TuyaOAuth2DeviceWithLight.ts +++ b/lib/TuyaOAuth2DeviceWithLight.ts @@ -56,7 +56,7 @@ export default class TuyaOAuth2DeviceWithLight extends TuyaOAuth2Device { if (lightTemp) { const specs = this.store[this.LIGHT_TEMP_TUYA_SPECS]; - const light_temperature = (lightTemp - specs.min) / (specs.max - specs.min); + const light_temperature = 1 - (lightTemp - specs.min) / (specs.max - specs.min); await this.safeSetCapabilityValue('light_temperature', light_temperature); } @@ -107,9 +107,9 @@ export default class TuyaOAuth2DeviceWithLight extends TuyaOAuth2Device { if (light_mode === 'color') { const specs = this.store[this.LIGHT_COLOR_TUYA_SPECS]; - const h = specs.h.min + light_hue * (specs.h.max - specs.h.min); - const s = specs.s.min + light_saturation * (specs.s.max - specs.s.min); - const v = specs.v.min + light_dim * (specs.v.max - specs.v.min); + 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, @@ -119,7 +119,7 @@ export default class TuyaOAuth2DeviceWithLight extends TuyaOAuth2Device { // Dim if (light_dim && this.hasTuyaCapability(this.LIGHT_DIM_TUYA_CAPABILITY)) { const specs = this.store[this.LIGHT_DIM_TUYA_SPECS]; - const brightValue = specs.min + light_dim * (specs.max - specs.min); + const brightValue = Math.round(specs.min + light_dim * (specs.max - specs.min)); commands.push({ code: this.LIGHT_DIM_TUYA_CAPABILITY, @@ -130,7 +130,7 @@ export default class TuyaOAuth2DeviceWithLight extends TuyaOAuth2Device { // Temperature if (light_temperature && this.hasTuyaCapability(this.LIGHT_TEMP_TUYA_CAPABILITY)) { const specs = this.store[this.LIGHT_TEMP_TUYA_SPECS]; - const tempValue = specs.min + light_temperature * (specs.max - specs.min); + const tempValue = Math.round(specs.min + (1 - light_temperature) * (specs.max - specs.min)); commands.push({ code: this.LIGHT_TEMP_TUYA_CAPABILITY, From 831f926fe425ff5314a735a0f796edf21725eb57 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 17:08:59 +0200 Subject: [PATCH 11/22] Fix onCapabilitiesLight arguments --- lib/TuyaOAuth2DeviceWithLight.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/TuyaOAuth2DeviceWithLight.ts b/lib/TuyaOAuth2DeviceWithLight.ts index e12d6f5b..612c0f7a 100644 --- a/lib/TuyaOAuth2DeviceWithLight.ts +++ b/lib/TuyaOAuth2DeviceWithLight.ts @@ -81,13 +81,13 @@ export default class TuyaOAuth2DeviceWithLight extends TuyaOAuth2Device { } } - async onCapabilitiesLight({ - light_dim = this.getCapabilityValue(this.LIGHT_DIM_CAPABILITY), - 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 { + 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) { From 6b7320a7236fc09dc8d8dfbbd11296678a3bd454 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 17:10:07 +0200 Subject: [PATCH 12/22] Refactor light device to generic class --- drivers/light/device.ts | 441 ++-------------------------------------- 1 file changed, 22 insertions(+), 419 deletions(-) diff --git a/drivers/light/device.ts b/drivers/light/device.ts index 2536e3e9..b995dbd6 100644 --- a/drivers/light/device.ts +++ b/drivers/light/device.ts @@ -1,42 +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 { ParsedColourData, SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; +import { SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; import { LIGHT_SETTING_LABELS, LightSettingCommand, LightSettingKey, PIR_CAPABILITIES } from './TuyaLightConstants'; +import TuyaOAuth2DeviceWithLight from '../../lib/TuyaOAuth2DeviceWithLight'; -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 @@ -51,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 @@ -101,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]; @@ -331,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({ From 4e4bae25bdf21cb59d73da72e79caba83e1971b5 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 17:18:19 +0200 Subject: [PATCH 13/22] Improve fan capability definition --- drivers/fan/TuyaFanConstants.ts | 6 +----- drivers/fan/device.ts | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/drivers/fan/TuyaFanConstants.ts b/drivers/fan/TuyaFanConstants.ts index e3103a1f..f06c1e44 100644 --- a/drivers/fan/TuyaFanConstants.ts +++ b/drivers/fan/TuyaFanConstants.ts @@ -8,13 +8,9 @@ export const FAN_CAPABILITIES_MAPPING = { child_lock: 'child_lock', temp: 'target_temperature', temp_current: 'measure_temperature', - // light - work_mode: 'light_mode', + // light - handled by superclass light: 'onoff.light', switch_led: 'onoff.light', - bright_value: 'dim.light', - temp_value: 'light_temperature', - // colour_data is split between light_hue, light_saturation and dim.light } as const; export const FAN_CAPABILITIES = { diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 596d1e5c..32fb64d1 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -5,8 +5,9 @@ import * as TuyaFanMigrations from '../../lib/migrations/TuyaFanMigrations'; import TuyaOAuth2DeviceWithLight from '../../lib/TuyaOAuth2DeviceWithLight'; export default class TuyaOAuth2DeviceFan extends TuyaOAuth2DeviceWithLight { + LIGHT_DIM_CAPABILITY = 'dim.light'; + async onOAuth2Init(): Promise { - this.LIGHT_DIM_CAPABILITY = 'dim.light'; // superclass handles light capabilities, except onoff.light await super.onOAuth2Init(); From a196cf447e16ede7aa2e82469612897adf1e7cc0 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 17:28:21 +0200 Subject: [PATCH 14/22] Fix fan capability definition --- drivers/fan/TuyaFanConstants.ts | 7 +++++++ drivers/fan/device.ts | 14 +++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/drivers/fan/TuyaFanConstants.ts b/drivers/fan/TuyaFanConstants.ts index f06c1e44..b5620fdf 100644 --- a/drivers/fan/TuyaFanConstants.ts +++ b/drivers/fan/TuyaFanConstants.ts @@ -13,6 +13,13 @@ export const FAN_CAPABILITIES_MAPPING = { 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', diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 32fb64d1..5240b1cd 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -1,5 +1,10 @@ import { SettingsEvent, TuyaStatus } from '../../types/TuyaTypes'; -import { FAN_CAPABILITIES, FAN_CAPABILITIES_MAPPING, HomeyFanSettings } from './TuyaFanConstants'; +import { + FAN_CAPABILITIES, + FAN_CAPABILITIES_MAPPING, + FAN_LIGHT_CAPABILITIES_MAPPING, + HomeyFanSettings, +} from './TuyaFanConstants'; import { constIncludes, getFromMap } from '../../lib/TuyaOAuth2Util'; import * as TuyaFanMigrations from '../../lib/migrations/TuyaFanMigrations'; import TuyaOAuth2DeviceWithLight from '../../lib/TuyaOAuth2DeviceWithLight'; @@ -65,10 +70,9 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2DeviceWithLight { async onSettings(event: SettingsEvent): Promise { if (event.changedKeys.includes('enable_light_support')) { if (event.newSettings['enable_light_support']) { - for (const lightTuyaCapability of ['light', 'switch_led', 'bright_value', 'temp_value'] as const) { - if (this.hasTuyaCapability(lightTuyaCapability)) { - const homeyCapability = FAN_CAPABILITIES_MAPPING[lightTuyaCapability]; - if (!this.hasCapability(homeyCapability)) await this.addCapability(homeyCapability); + 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')) { From 03647e87a750d35f12630b5af4178853b14e7ec3 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 17:29:12 +0200 Subject: [PATCH 15/22] Refactor fan light driver to generic class --- drivers/fan/driver.ts | 50 +------------ lib/TuyaOAuth2DriverWithLight.ts | 122 +++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 46 deletions(-) create mode 100644 lib/TuyaOAuth2DriverWithLight.ts diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index 16660cbf..ccf7b259 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -1,5 +1,5 @@ import { DEVICE_CATEGORIES } from '../../lib/TuyaOAuth2Constants'; -import TuyaOAuth2Driver, { ListDeviceProperties } from '../../lib/TuyaOAuth2Driver'; +import { ListDeviceProperties } from '../../lib/TuyaOAuth2Driver'; import { type TuyaDeviceDataPointResponse, TuyaDeviceResponse, @@ -7,8 +7,9 @@ import { } from '../../types/TuyaApiTypes'; import { getFromMap } from '../../lib/TuyaOAuth2Util'; import { FAN_CAPABILITIES_MAPPING } from './TuyaFanConstants'; +import TuyaOAuth2DriverWithLight from '../../lib/TuyaOAuth2DriverWithLight'; -module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { +module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2DriverWithLight { TUYA_DEVICE_CATEGORIES = [ DEVICE_CATEGORIES.SMALL_HOME_APPLIANCES.FAN, DEVICE_CATEGORIES.LIGHTING.CEILING_FAN_LIGHT, @@ -19,6 +20,7 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { specifications?: TuyaDeviceSpecificationResponse, dataPoints?: TuyaDeviceDataPointResponse, ): ListDeviceProperties { + // superclass handles light capabilities, except onoff.light const props = super.onTuyaPairListDeviceProperties(device, specifications, dataPoints); props.store['_migrations'] = ['fan_tuya_capabilities']; @@ -48,23 +50,6 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { } } - if (props.store.tuya_capabilities.includes('light') || props.store.tuya_capabilities.includes('switch_led')) { - props.settings['enable_light_support'] = true; - } - - if (props.store.tuya_capabilities.includes('colour_data') && !props.capabilities.includes('dim.light')) { - props.capabilities.push('dim.light'); - } - - // 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.includes('light_mode') - ) { - props.capabilities.push('light_mode'); - } - // Fix onoff when light is present if (props.capabilities.includes('onoff.light')) { props.capabilitiesOptions['onoff'] = { @@ -107,15 +92,6 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { }; } - // Default light specifications - 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) { return props; } @@ -159,24 +135,6 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2Driver { max: values.max ?? 50, }; } - - // Light - if (tuyaCapability === 'bright_value') { - props.store.tuya_brightness = { ...props.store.tuya_brightness, ...values }; - } - - if (tuyaCapability === 'temp_value') { - props.store.tuya_temperature = { ...props.store.tuya_temperature, ...values }; - } - - if (tuyaCapability === 'colour_data') { - for (const index of ['h', 's', 'v']) { - props.store.tuya_colour[index] = { - ...props.store.tuya_colour[index], - ...values?.[index], - }; - } - } } return props; 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; + } +} From bb98078c7f39de613a33034e5e096b880ead2d07 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Fri, 30 Aug 2024 17:34:41 +0200 Subject: [PATCH 16/22] Refactor light driver to generic class --- drivers/light/driver.ts | 98 ++--------------------------------------- 1 file changed, 4 insertions(+), 94 deletions(-) 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; } }; From 6ee313eeae299c997c9387763b514c2ae1479da0 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Mon, 2 Sep 2024 13:29:01 +0200 Subject: [PATCH 17/22] Add fan light flows --- app.json | 118 +++++++++++++++++++++++++++ drivers/fan/device.ts | 19 +++++ drivers/fan/driver.flow.compose.json | 86 +++++++++++++++++++ drivers/fan/driver.ts | 21 +++++ 4 files changed, 244 insertions(+) create mode 100644 drivers/fan/driver.flow.compose.json diff --git a/app.json b/app.json index 68464312..bc2b9a94 100644 --- a/app.json +++ b/app.json @@ -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,62 @@ } ] }, + { + "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": "heater_set_child_lock", "title": { @@ -1777,6 +1882,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, diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 5240b1cd..670dbedc 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -65,6 +65,25 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2DeviceWithLight { } } } + + // flows + 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 onSettings(event: SettingsEvent): Promise { diff --git a/drivers/fan/driver.flow.compose.json b/drivers/fan/driver.flow.compose.json new file mode 100644 index 00000000..7beb3e2e --- /dev/null +++ b/drivers/fan/driver.flow.compose.json @@ -0,0 +1,86 @@ +{ + "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 + } + ] + } + ] +} diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index ccf7b259..9b11d4d6 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -8,6 +8,7 @@ import { import { getFromMap } from '../../lib/TuyaOAuth2Util'; import { FAN_CAPABILITIES_MAPPING } from './TuyaFanConstants'; import TuyaOAuth2DriverWithLight from '../../lib/TuyaOAuth2DriverWithLight'; +import { StandardDeviceFlowArgs, StandardFlowArgs } from '../../types/TuyaTypes'; module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2DriverWithLight { TUYA_DEVICE_CATEGORIES = [ @@ -15,6 +16,26 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2DriverWithLight { 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); + }); + } + onTuyaPairListDeviceProperties( device: TuyaDeviceResponse, specifications?: TuyaDeviceSpecificationResponse, From 61e7dd67cdb4b105d4b9233cb05108febd290f72 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Mon, 2 Sep 2024 14:20:18 +0200 Subject: [PATCH 18/22] Add helper function for splitting setting events --- drivers/socket/device.ts | 18 ++++-------------- lib/TuyaOAuth2Util.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 14 deletions(-) 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/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 { From 4cc75324e1137504eedcb80b29397d5e3010303f Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Mon, 2 Sep 2024 14:23:27 +0200 Subject: [PATCH 19/22] Add fan direction setting --- app.json | 62 ++++++++++++++++++++++++ drivers/fan/TuyaFanConstants.ts | 10 +++- drivers/fan/device.ts | 15 ++++++ drivers/fan/driver.flow.compose.json | 35 +++++++++++++ drivers/fan/driver.settings.compose.json | 22 +++++++++ drivers/fan/driver.ts | 4 +- 6 files changed, 146 insertions(+), 2 deletions(-) diff --git a/app.json b/app.json index bc2b9a94..8bb8c285 100644 --- a/app.json +++ b/app.json @@ -1555,6 +1555,46 @@ } ] }, + { + "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": { @@ -2581,6 +2621,28 @@ }, "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", diff --git a/drivers/fan/TuyaFanConstants.ts b/drivers/fan/TuyaFanConstants.ts index b5620fdf..ec59697c 100644 --- a/drivers/fan/TuyaFanConstants.ts +++ b/drivers/fan/TuyaFanConstants.ts @@ -34,10 +34,18 @@ export const FAN_CAPABILITIES = { '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 = Record; +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 670dbedc..2bb2f5e7 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -3,8 +3,11 @@ 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'; @@ -57,6 +60,12 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2DeviceWithLight { 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); @@ -115,6 +124,12 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2DeviceWithLight { } } } + + const tuyaSettingsEvent = TuyaOAuth2Util.filterTuyaSettings(event, [ + 'fan_direction', + ]); + + return TuyaOAuth2Util.onSettings(this, tuyaSettingsEvent, FAN_SETTING_LABELS); } } diff --git a/drivers/fan/driver.flow.compose.json b/drivers/fan/driver.flow.compose.json index 7beb3e2e..bdfa0461 100644 --- a/drivers/fan/driver.flow.compose.json +++ b/drivers/fan/driver.flow.compose.json @@ -81,6 +81,41 @@ "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 161f57bc..c3863bff 100644 --- a/drivers/fan/driver.settings.compose.json +++ b/drivers/fan/driver.settings.compose.json @@ -10,6 +10,28 @@ }, "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 9b11d4d6..68871974 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -6,7 +6,7 @@ import { TuyaDeviceSpecificationResponse, } from '../../types/TuyaApiTypes'; import { getFromMap } from '../../lib/TuyaOAuth2Util'; -import { FAN_CAPABILITIES_MAPPING } from './TuyaFanConstants'; +import { FAN_CAPABILITIES_MAPPING, FAN_SETTING_LABELS } from './TuyaFanConstants'; import TuyaOAuth2DriverWithLight from '../../lib/TuyaOAuth2DriverWithLight'; import { StandardDeviceFlowArgs, StandardFlowArgs } from '../../types/TuyaTypes'; @@ -34,6 +34,8 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2DriverWithLight { 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( From 3698bb255162d33719f28c348964da2a54d705af Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Mon, 2 Sep 2024 16:11:26 +0200 Subject: [PATCH 20/22] Prevent fan light flows triggering if disabled in settings --- drivers/fan/device.ts | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/drivers/fan/device.ts b/drivers/fan/device.ts index 2bb2f5e7..e9ec0739 100644 --- a/drivers/fan/device.ts +++ b/drivers/fan/device.ts @@ -76,22 +76,27 @@ export default class TuyaOAuth2DeviceFan extends TuyaOAuth2DeviceWithLight { } // flows - if (changedStatusCodes.includes('bright_value')) { - await this.homey.flow - .getDeviceTriggerCard('fan_light_dim_changed') - .trigger(this, { value: status['bright_value'] }) - .catch(this.error); - } + 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('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); + if (changedStatusCodes.includes('switch_led')) { + await this.homey.flow + .getDeviceTriggerCard(`fan_light_onoff_${status['switch_led']}`) + .trigger(this) + .catch(this.error); + } } } From 60ab15c49873d2f000c14ba2996459ae94003b22 Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Mon, 2 Sep 2024 16:15:32 +0200 Subject: [PATCH 21/22] Fix assumption of specifications being defined --- drivers/fan/driver.ts | 2 +- drivers/heater/driver.ts | 2 +- drivers/sensor_climate/driver.ts | 4 ++++ drivers/sensor_motion/driver.ts | 2 +- drivers/sensor_smoke/driver.ts | 2 +- drivers/socket/driver.ts | 2 +- types/TuyaApiTypes.ts | 4 ++-- 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index 68871974..f28d9af8 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -115,7 +115,7 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2DriverWithLight { }; } - if (!specifications) { + if (!specifications || !specifications.status) { 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/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/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/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 = { From 0f06ef3f194710dc89a8bf6ba9b6b6add450e84e Mon Sep 17 00:00:00 2001 From: Joost Loohuis Date: Mon, 2 Sep 2024 16:18:26 +0200 Subject: [PATCH 22/22] Fix fan_speed_percent default step --- drivers/fan/driver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/fan/driver.ts b/drivers/fan/driver.ts index f28d9af8..5d9d8637 100644 --- a/drivers/fan/driver.ts +++ b/drivers/fan/driver.ts @@ -128,7 +128,7 @@ module.exports = class TuyaOAuth2DriverFan extends TuyaOAuth2DriverWithLight { props.capabilitiesOptions['dim'] = { min: values.min ?? 1, max: values.max ?? 100, - step: values.step ?? 0, + step: values.step ?? 1, }; }