diff --git a/config/custom_components/localtuya/__init__.py b/config/custom_components/localtuya/__init__.py new file mode 100644 index 0000000..ad879b0 --- /dev/null +++ b/config/custom_components/localtuya/__init__.py @@ -0,0 +1,326 @@ +"""The LocalTuya integration integration. + +Sample YAML config with all supported entity types (default values +are pre-filled for optional fields): + +localtuya: + - host: 192.168.1.x + device_id: xxxxx + local_key: xxxxx + friendly_name: Tuya Device + protocol_version: "3.3" + entities: + - platform: binary_sensor + friendly_name: Plug Status + id: 1 + device_class: power + state_on: "true" # Optional + state_off: "false" # Optional + + - platform: cover + friendly_name: Device Cover + id: 2 + commands_set: # Optional, default: "on_off_stop" + ["on_off_stop","open_close_stop","fz_zz_stop","1_2_3"] + positioning_mode: ["none","position","timed"] # Optional, default: "none" + currpos_dp: 3 # Optional, required only for "position" mode + setpos_dp: 4 # Optional, required only for "position" mode + position_inverted: [True,False] # Optional, default: False + span_time: 25 # Full movement time: Optional, required only for "timed" mode + + - platform: fan + friendly_name: Device Fan + id: 3 + + - platform: light + friendly_name: Device Light + id: 4 + brightness: 20 + brightness_lower: 29 # Optional + brightness_upper: 1000 # Optional + color_temp: 21 + + - platform: sensor + friendly_name: Plug Voltage + id: 20 + scaling: 0.1 # Optional + device_class: voltage # Optional + unit_of_measurement: "V" # Optional + + - platform: switch + friendly_name: Plug + id: 1 + current: 18 # Optional + current_consumption: 19 # Optional + voltage: 20 # Optional + + - platform: vacuum + friendly_name: Vacuum + id: 28 + idle_status_value: "standby,sleep" + returning_status_value: "docking" + docked_status_value: "charging,chargecompleted" + battery_dp: 14 + mode_dp: 27 + modes: "smart,standby,chargego,wall_follow,spiral,single" + fan_speed_dp: 30 + fan_speeds: "low,normal,high" + clean_time_dp: 33 + clean_area_dp: 32 +""" +import asyncio +import logging +from datetime import timedelta + +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.entity_registry as er +import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_HOST, + CONF_ID, + CONF_PLATFORM, + EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.reload import async_integration_yaml_config + +from .common import TuyaDevice, async_config_entry_by_device_id +from .config_flow import config_schema +from .const import CONF_PRODUCT_KEY, DATA_DISCOVERY, DOMAIN, TUYA_DEVICE +from .discovery import TuyaDiscovery + +_LOGGER = logging.getLogger(__name__) + +UNSUB_LISTENER = "unsub_listener" + +RECONNECT_INTERVAL = timedelta(seconds=60) + +CONFIG_SCHEMA = config_schema() + +CONF_DP = "dp" +CONF_VALUE = "value" + +SERVICE_SET_DP = "set_dp" +SERVICE_SET_DP_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_DP): int, + vol.Required(CONF_VALUE): object, + } +) + + +@callback +def _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf): + """Update a config entry with the latest yaml.""" + device_id = conf[CONF_DEVICE_ID] + + if device_id in entries_by_id and entries_by_id[device_id].source == SOURCE_IMPORT: + entry = entries_by_id[device_id] + hass.config_entries.async_update_entry(entry, data=conf.copy()) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the LocalTuya integration component.""" + hass.data.setdefault(DOMAIN, {}) + + device_cache = {} + + async def _handle_reload(service): + """Handle reload service call.""" + config = await async_integration_yaml_config(hass, DOMAIN) + + if not config or DOMAIN not in config: + return + + current_entries = hass.config_entries.async_entries(DOMAIN) + entries_by_id = {entry.data[CONF_DEVICE_ID]: entry for entry in current_entries} + + for conf in config[DOMAIN]: + _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf) + + reload_tasks = [ + hass.config_entries.async_reload(entry.entry_id) + for entry in current_entries + ] + + await asyncio.gather(*reload_tasks) + + async def _handle_set_dp(event): + """Handle set_dp service call.""" + entry = async_config_entry_by_device_id(hass, event.data[CONF_DEVICE_ID]) + if not entry: + raise HomeAssistantError("unknown device id") + + if entry.entry_id not in hass.data[DOMAIN]: + raise HomeAssistantError("device has not been discovered") + + device = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE] + if not device.connected: + raise HomeAssistantError("not connected to device") + + await device.set_dp(event.data[CONF_VALUE], event.data[CONF_DP]) + + def _device_discovered(device): + """Update address of device if it has changed.""" + device_ip = device["ip"] + device_id = device["gwId"] + product_key = device["productKey"] + + # If device is not in cache, check if a config entry exists + if device_id not in device_cache: + entry = async_config_entry_by_device_id(hass, device_id) + if entry: + # Save address from config entry in cache to trigger + # potential update below + device_cache[device_id] = entry.data[CONF_HOST] + + if device_id not in device_cache: + return + + entry = async_config_entry_by_device_id(hass, device_id) + if entry is None: + return + + updates = {} + + if device_cache[device_id] != device_ip: + updates[CONF_HOST] = device_ip + device_cache[device_id] = device_ip + + if entry.data.get(CONF_PRODUCT_KEY) != product_key: + updates[CONF_PRODUCT_KEY] = product_key + + # Update settings if something changed, otherwise try to connect. Updating + # settings triggers a reload of the config entry, which tears down the device + # so no need to connect in that case. + if updates: + _LOGGER.debug("Update keys for device %s: %s", device_id, updates) + hass.config_entries.async_update_entry( + entry, data={**entry.data, **updates} + ) + elif entry.entry_id in hass.data[DOMAIN]: + _LOGGER.debug("Device %s found with IP %s", device_id, device_ip) + + device = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE] + device.async_connect() + + discovery = TuyaDiscovery(_device_discovered) + + def _shutdown(event): + """Clean up resources when shutting down.""" + discovery.close() + + try: + await discovery.start() + hass.data[DOMAIN][DATA_DISCOVERY] = discovery + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("failed to set up discovery") + + async def _async_reconnect(now): + """Try connecting to devices not already connected to.""" + for entry_id, value in hass.data[DOMAIN].items(): + if entry_id == DATA_DISCOVERY: + continue + + device = value[TUYA_DEVICE] + if not device.connected: + device.async_connect() + + async_track_time_interval(hass, _async_reconnect, RECONNECT_INTERVAL) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_RELOAD, + _handle_reload, + ) + + hass.helpers.service.async_register_admin_service( + DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA + ) + + for host_config in config.get(DOMAIN, []): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=host_config + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up LocalTuya integration from a config entry.""" + unsub_listener = entry.add_update_listener(update_listener) + + device = TuyaDevice(hass, entry.data) + + hass.data[DOMAIN][entry.entry_id] = { + UNSUB_LISTENER: unsub_listener, + TUYA_DEVICE: device, + } + + async def setup_entities(): + platforms = set(entity[CONF_PLATFORM] for entity in entry.data[CONF_ENTITIES]) + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_setup(entry, platform) + for platform in platforms + ] + ) + device.async_connect() + + await async_remove_orphan_entities(hass, entry) + + hass.async_create_task(setup_entities()) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in set( + entity[CONF_PLATFORM] for entity in entry.data[CONF_ENTITIES] + ) + ] + ) + ) + + hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]() + await hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE].close() + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return True + + +async def update_listener(hass, config_entry): + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_remove_orphan_entities(hass, entry): + """Remove entities associated with config entry that has been removed.""" + ent_reg = await er.async_get_registry(hass) + entities = { + int(ent.unique_id.split("_")[-1]): ent.entity_id + for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + } + + for entity in entry.data[CONF_ENTITIES]: + if entity[CONF_ID] in entities: + del entities[entity[CONF_ID]] + + for entity_id in entities.values(): + ent_reg.async_remove(entity_id) diff --git a/config/custom_components/localtuya/__pycache__/__init__.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..f28ccb0 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/__init__.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/binary_sensor.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/binary_sensor.cpython-39.pyc new file mode 100644 index 0000000..9bc9de9 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/binary_sensor.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/climate.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/climate.cpython-39.pyc new file mode 100644 index 0000000..83b7e23 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/climate.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/common.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/common.cpython-39.pyc new file mode 100644 index 0000000..94a046d Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/common.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/config_flow.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/config_flow.cpython-39.pyc new file mode 100644 index 0000000..4900133 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/config_flow.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/const.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/const.cpython-39.pyc new file mode 100644 index 0000000..6b516ea Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/const.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/cover.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/cover.cpython-39.pyc new file mode 100644 index 0000000..9b6e38b Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/cover.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/discovery.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/discovery.cpython-39.pyc new file mode 100644 index 0000000..8d7efe8 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/discovery.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/fan.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/fan.cpython-39.pyc new file mode 100644 index 0000000..150e9bc Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/fan.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/light.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/light.cpython-39.pyc new file mode 100644 index 0000000..5427d92 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/light.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/number.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/number.cpython-39.pyc new file mode 100644 index 0000000..606c952 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/number.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/select.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/select.cpython-39.pyc new file mode 100644 index 0000000..8f89987 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/select.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/sensor.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/sensor.cpython-39.pyc new file mode 100644 index 0000000..2f63d31 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/sensor.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/switch.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/switch.cpython-39.pyc new file mode 100644 index 0000000..a678919 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/switch.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/__pycache__/vacuum.cpython-39.pyc b/config/custom_components/localtuya/__pycache__/vacuum.cpython-39.pyc new file mode 100644 index 0000000..8f02308 Binary files /dev/null and b/config/custom_components/localtuya/__pycache__/vacuum.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/binary_sensor.py b/config/custom_components/localtuya/binary_sensor.py new file mode 100644 index 0000000..1a3d28a --- /dev/null +++ b/config/custom_components/localtuya/binary_sensor.py @@ -0,0 +1,69 @@ +"""Platform to present any Tuya DP as a binary sensor.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN, + BinarySensorEntity, +) +from homeassistant.const import CONF_DEVICE_CLASS + +from .common import LocalTuyaEntity, async_setup_entry + +_LOGGER = logging.getLogger(__name__) + +CONF_STATE_ON = "state_on" +CONF_STATE_OFF = "state_off" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Required(CONF_STATE_ON, default="True"): str, + vol.Required(CONF_STATE_OFF, default="False"): str, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } + + +class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): + """Representation of a Tuya binary sensor.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya binary sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._is_on = False + + @property + def is_on(self): + """Return sensor state.""" + return self._is_on + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS) + + def status_updated(self): + """Device status was updated.""" + state = str(self.dps(self._dp_id)).lower() + if state == self._config[CONF_STATE_ON].lower(): + self._is_on = True + elif state == self._config[CONF_STATE_OFF].lower(): + self._is_on = False + else: + self.warning( + "State for entity %s did not match state patterns", self.entity_id + ) + + +async_setup_entry = partial( + async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema +) diff --git a/config/custom_components/localtuya/climate.py b/config/custom_components/localtuya/climate.py new file mode 100644 index 0000000..7b03f31 --- /dev/null +++ b/config/custom_components/localtuya/climate.py @@ -0,0 +1,394 @@ +"""Platform to locally control Tuya-based climate devices.""" +import asyncio +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.climate import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + DOMAIN, + ClimateEntity, +) +from homeassistant.components.climate.const import ( + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_HEAT, + PRESET_NONE, + PRESET_ECO, + PRESET_AWAY, + PRESET_HOME, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_TEMPERATURE_UNIT, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_CURRENT_TEMPERATURE_DP, + CONF_MAX_TEMP_DP, + CONF_MIN_TEMP_DP, + CONF_PRECISION, + CONF_TARGET_PRECISION, + CONF_TARGET_TEMPERATURE_DP, + CONF_TEMPERATURE_STEP, + CONF_HVAC_MODE_DP, + CONF_HVAC_MODE_SET, + CONF_HEURISTIC_ACTION, + CONF_HVAC_ACTION_DP, + CONF_HVAC_ACTION_SET, + CONF_ECO_DP, + CONF_ECO_VALUE, + CONF_PRESET_DP, + CONF_PRESET_SET, +) + +_LOGGER = logging.getLogger(__name__) + +HVAC_MODE_SETS = { + "manual/auto": { + HVAC_MODE_HEAT: "manual", + HVAC_MODE_AUTO: "auto", + }, + "Manual/Auto": { + HVAC_MODE_HEAT: "Manual", + HVAC_MODE_AUTO: "Auto", + }, + "Manual/Program": { + HVAC_MODE_HEAT: "Manual", + HVAC_MODE_AUTO: "Program", + }, + "True/False": { + HVAC_MODE_HEAT: True, + }, +} +HVAC_ACTION_SETS = { + "True/False": { + CURRENT_HVAC_HEAT: True, + CURRENT_HVAC_IDLE: False, + }, + "open/close": { + CURRENT_HVAC_HEAT: "open", + CURRENT_HVAC_IDLE: "close", + }, + "heating/no_heating": { + CURRENT_HVAC_HEAT: "heating", + CURRENT_HVAC_IDLE: "no_heating", + }, + "Heat/Warming": { + CURRENT_HVAC_HEAT: "Heat", + CURRENT_HVAC_IDLE: "Warming", + }, +} +PRESET_SETS = { + "Manual/Holiday/Program": { + PRESET_AWAY: "Holiday", + PRESET_HOME: "Program", + PRESET_NONE: "Manual", + }, +} + +TEMPERATURE_CELSIUS = "celsius" +TEMPERATURE_FAHRENHEIT = "fahrenheit" +DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS +DEFAULT_PRECISION = PRECISION_TENTHS +DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES +# Empirically tested to work for AVATTO thermostat +MODE_WAIT = 0.1 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_TARGET_TEMPERATURE_DP): vol.In(dps), + vol.Optional(CONF_CURRENT_TEMPERATURE_DP): vol.In(dps), + vol.Optional(CONF_TEMPERATURE_STEP): vol.In( + [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + ), + vol.Optional(CONF_MAX_TEMP_DP): vol.In(dps), + vol.Optional(CONF_MIN_TEMP_DP): vol.In(dps), + vol.Optional(CONF_PRECISION): vol.In( + [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + ), + vol.Optional(CONF_HVAC_MODE_DP): vol.In(dps), + vol.Optional(CONF_HVAC_MODE_SET): vol.In(list(HVAC_MODE_SETS.keys())), + vol.Optional(CONF_HVAC_ACTION_DP): vol.In(dps), + vol.Optional(CONF_HVAC_ACTION_SET): vol.In(list(HVAC_ACTION_SETS.keys())), + vol.Optional(CONF_ECO_DP): vol.In(dps), + vol.Optional(CONF_ECO_VALUE): str, + vol.Optional(CONF_PRESET_DP): vol.In(dps), + vol.Optional(CONF_PRESET_SET): vol.In(list(PRESET_SETS.keys())), + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( + [TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT] + ), + vol.Optional(CONF_TARGET_PRECISION): vol.In( + [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + ), + vol.Optional(CONF_HEURISTIC_ACTION): bool, + } + + +class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity): + """Tuya climate device.""" + + def __init__( + self, + device, + config_entry, + switchid, + **kwargs, + ): + """Initialize a new LocaltuyaClimate.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + self._target_temperature = None + self._current_temperature = None + self._hvac_mode = None + self._preset_mode = None + self._hvac_action = None + self._precision = self._config.get(CONF_PRECISION, DEFAULT_PRECISION) + self._target_precision = self._config.get( + CONF_TARGET_PRECISION, self._precision + ) + self._conf_hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP) + self._conf_hvac_mode_set = HVAC_MODE_SETS.get( + self._config.get(CONF_HVAC_MODE_SET), {} + ) + self._conf_preset_dp = self._config.get(CONF_PRESET_DP) + self._conf_preset_set = PRESET_SETS.get(self._config.get(CONF_PRESET_SET), {}) + self._conf_hvac_action_dp = self._config.get(CONF_HVAC_ACTION_DP) + self._conf_hvac_action_set = HVAC_ACTION_SETS.get( + self._config.get(CONF_HVAC_ACTION_SET), {} + ) + self._conf_eco_dp = self._config.get(CONF_ECO_DP) + self._conf_eco_value = self._config.get(CONF_ECO_VALUE, "ECO") + self._has_presets = self.has_config(CONF_ECO_DP) or self.has_config( + CONF_PRESET_DP + ) + print("Initialized climate [{}]".format(self.name)) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = 0 + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + supported_features = supported_features | SUPPORT_TARGET_TEMPERATURE + if self.has_config(CONF_MAX_TEMP_DP): + supported_features = supported_features | SUPPORT_TARGET_TEMPERATURE_RANGE + if self.has_config(CONF_PRESET_DP) or self.has_config(CONF_ECO_DP): + supported_features = supported_features | SUPPORT_PRESET_MODE + return supported_features + + @property + def precision(self): + """Return the precision of the system.""" + return self._precision + + @property + def target_recision(self): + """Return the precision of the target.""" + return self._target_precision + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + if ( + self._config.get(CONF_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT) + == TEMPERATURE_FAHRENHEIT + ): + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + return self._hvac_mode + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + if not self.has_config(CONF_HVAC_MODE_DP): + return None + return list(self._conf_hvac_mode_set) + [HVAC_MODE_OFF] + + @property + def hvac_action(self): + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + if self._config.get(CONF_HEURISTIC_ACTION, False): + if self._hvac_mode == HVAC_MODE_HEAT: + if self._current_temperature < ( + self._target_temperature - self._precision + ): + self._hvac_action = CURRENT_HVAC_HEAT + if self._current_temperature == ( + self._target_temperature - self._precision + ): + if self._hvac_action == CURRENT_HVAC_HEAT: + self._hvac_action = CURRENT_HVAC_HEAT + if self._hvac_action == CURRENT_HVAC_IDLE: + self._hvac_action = CURRENT_HVAC_IDLE + if ( + self._current_temperature + self._precision + ) > self._target_temperature: + self._hvac_action = CURRENT_HVAC_IDLE + return self._hvac_action + return self._hvac_action + + @property + def preset_mode(self): + """Return current preset.""" + return self._preset_mode + + @property + def preset_modes(self): + """Return the list of available presets modes.""" + if not self._has_presets: + return None + presets = list(self._conf_preset_set) + if self._conf_eco_dp: + presets.append(PRESET_ECO) + return presets + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP) + + @property + def fan_mode(self): + """Return the fan setting.""" + return NotImplementedError() + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return NotImplementedError() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP): + temperature = round(kwargs[ATTR_TEMPERATURE] / self._target_precision) + await self._device.set_dp( + temperature, self._config[CONF_TARGET_TEMPERATURE_DP] + ) + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + return NotImplementedError() + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target operation mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._device.set_dp(False, self._dp_id) + return + if not self._state and self._conf_hvac_mode_dp != self._dp_id: + await self._device.set_dp(True, self._dp_id) + # Some thermostats need a small wait before sending another update + await asyncio.sleep(MODE_WAIT) + await self._device.set_dp( + self._conf_hvac_mode_set[hvac_mode], self._conf_hvac_mode_dp + ) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self._device.set_dp(True, self._dp_id) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self._device.set_dp(False, self._dp_id) + + async def async_set_preset_mode(self, preset_mode): + """Set new target preset mode.""" + if preset_mode == PRESET_ECO: + await self._device.set_dp(self._conf_eco_value, self._conf_eco_dp) + return + await self._device.set_dp( + self._conf_preset_set[preset_mode], self._conf_preset_dp + ) + + @property + def min_temp(self): + """Return the minimum temperature.""" + if self.has_config(CONF_MIN_TEMP_DP): + return self.dps_conf(CONF_MIN_TEMP_DP) + return DEFAULT_MIN_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self.has_config(CONF_MAX_TEMP_DP): + return self.dps_conf(CONF_MAX_TEMP_DP) + return DEFAULT_MAX_TEMP + + def status_updated(self): + """Device status was updated.""" + self._state = self.dps(self._dp_id) + + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + self._target_temperature = ( + self.dps_conf(CONF_TARGET_TEMPERATURE_DP) * self._target_precision + ) + + if self.has_config(CONF_CURRENT_TEMPERATURE_DP): + self._current_temperature = ( + self.dps_conf(CONF_CURRENT_TEMPERATURE_DP) * self._precision + ) + + if self._has_presets: + if ( + self.has_config(CONF_ECO_DP) + and self.dps_conf(CONF_ECO_DP) == self._conf_eco_value + ): + self._preset_mode = PRESET_ECO + else: + for preset, value in self._conf_preset_set.items(): # todo remove + if self.dps_conf(CONF_PRESET_DP) == value: + self._preset_mode = preset + break + else: + self._preset_mode = PRESET_NONE + + # Update the HVAC status + if self.has_config(CONF_HVAC_MODE_DP): + if not self._state: + self._hvac_mode = HVAC_MODE_OFF + else: + for mode, value in self._conf_hvac_mode_set.items(): + if self.dps_conf(CONF_HVAC_MODE_DP) == value: + self._hvac_mode = mode + break + else: + # in case hvac mode and preset share the same dp + self._hvac_mode = HVAC_MODE_AUTO + + # Update the current action + for action, value in self._conf_hvac_action_set.items(): + if self.dps_conf(CONF_HVAC_ACTION_DP) == value: + self._hvac_action = action + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaClimate, flow_schema) diff --git a/config/custom_components/localtuya/common.py b/config/custom_components/localtuya/common.py new file mode 100644 index 0000000..ea821a5 --- /dev/null +++ b/config/custom_components/localtuya/common.py @@ -0,0 +1,369 @@ +"""Code shared between all platforms.""" +import asyncio +import logging +from datetime import timedelta + +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ID, + CONF_PLATFORM, + CONF_SCAN_INTERVAL, +) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.restore_state import RestoreEntity + +from . import pytuya +from .const import ( + CONF_LOCAL_KEY, + CONF_PRODUCT_KEY, + CONF_PROTOCOL_VERSION, + DOMAIN, + TUYA_DEVICE, +) + +_LOGGER = logging.getLogger(__name__) + + +def prepare_setup_entities(hass, config_entry, platform): + """Prepare ro setup entities for a platform.""" + entities_to_setup = [ + entity + for entity in config_entry.data[CONF_ENTITIES] + if entity[CONF_PLATFORM] == platform + ] + if not entities_to_setup: + return None, None + + tuyainterface = hass.data[DOMAIN][config_entry.entry_id][TUYA_DEVICE] + + return tuyainterface, entities_to_setup + + +async def async_setup_entry( + domain, entity_class, flow_schema, hass, config_entry, async_add_entities +): + """Set up a Tuya platform based on a config entry. + + This is a generic method and each platform should lock domain and + entity_class with functools.partial. + """ + tuyainterface, entities_to_setup = prepare_setup_entities( + hass, config_entry, domain + ) + if not entities_to_setup: + return + + dps_config_fields = list(get_dps_for_platform(flow_schema)) + + entities = [] + for device_config in entities_to_setup: + # Add DPS used by this platform to the request list + for dp_conf in dps_config_fields: + if dp_conf in device_config: + tuyainterface.dps_to_request[device_config[dp_conf]] = None + + entities.append( + entity_class( + tuyainterface, + config_entry, + device_config[CONF_ID], + ) + ) + + async_add_entities(entities) + + +def get_dps_for_platform(flow_schema): + """Return config keys for all platform keys that depends on a datapoint.""" + for key, value in flow_schema(None).items(): + if hasattr(value, "container") and value.container is None: + yield key.schema + + +def get_entity_config(config_entry, dp_id): + """Return entity config for a given DPS id.""" + for entity in config_entry.data[CONF_ENTITIES]: + if entity[CONF_ID] == dp_id: + return entity + raise Exception(f"missing entity config for id {dp_id}") + + +@callback +def async_config_entry_by_device_id(hass, device_id): + """Look up config entry by device id.""" + current_entries = hass.config_entries.async_entries(DOMAIN) + for entry in current_entries: + if entry.data[CONF_DEVICE_ID] == device_id: + return entry + return None + + +class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): + """Cache wrapper for pytuya.TuyaInterface.""" + + def __init__(self, hass, config_entry): + """Initialize the cache.""" + super().__init__() + self._hass = hass + self._config_entry = config_entry + self._interface = None + self._status = {} + self.dps_to_request = {} + self._is_closing = False + self._connect_task = None + self._disconnect_task = None + self._unsub_interval = None + self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID]) + + # This has to be done in case the device type is type_0d + for entity in config_entry[CONF_ENTITIES]: + self.dps_to_request[entity[CONF_ID]] = None + + @property + def connected(self): + """Return if connected to device.""" + return self._interface is not None + + def async_connect(self): + """Connect to device if not already connected.""" + if not self._is_closing and self._connect_task is None and not self._interface: + self._connect_task = asyncio.create_task(self._make_connection()) + + async def _make_connection(self): + """Subscribe localtuya entity events.""" + self.debug("Connecting to %s", self._config_entry[CONF_HOST]) + + try: + self._interface = await pytuya.connect( + self._config_entry[CONF_HOST], + self._config_entry[CONF_DEVICE_ID], + self._config_entry[CONF_LOCAL_KEY], + float(self._config_entry[CONF_PROTOCOL_VERSION]), + self, + ) + self._interface.add_dps_to_request(self.dps_to_request) + + self.debug("Retrieving initial state") + status = await self._interface.status() + if status is None: + raise Exception("Failed to retrieve status") + + self.status_updated(status) + + def _new_entity_handler(entity_id): + self.debug( + "New entity %s was added to %s", + entity_id, + self._config_entry[CONF_HOST], + ) + self._dispatch_status() + + signal = f"localtuya_entity_{self._config_entry[CONF_DEVICE_ID]}" + self._disconnect_task = async_dispatcher_connect( + self._hass, signal, _new_entity_handler + ) + + if ( + CONF_SCAN_INTERVAL in self._config_entry + and self._config_entry[CONF_SCAN_INTERVAL] > 0 + ): + self._unsub_interval = async_track_time_interval( + self._hass, + self._async_refresh, + timedelta(seconds=self._config_entry[CONF_SCAN_INTERVAL]), + ) + except Exception: # pylint: disable=broad-except + self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed") + if self._interface is not None: + await self._interface.close() + self._interface = None + self._connect_task = None + + async def _async_refresh(self, _now): + if self._interface is not None: + await self._interface.update_dps() + + async def close(self): + """Close connection and stop re-connect loop.""" + self._is_closing = True + if self._connect_task is not None: + self._connect_task.cancel() + await self._connect_task + if self._interface is not None: + await self._interface.close() + if self._disconnect_task is not None: + self._disconnect_task() + + async def set_dp(self, state, dp_index): + """Change value of a DP of the Tuya device.""" + if self._interface is not None: + try: + await self._interface.set_dp(state, dp_index) + except Exception: # pylint: disable=broad-except + self.exception("Failed to set DP %d to %d", dp_index, state) + else: + self.error( + "Not connected to device %s", self._config_entry[CONF_FRIENDLY_NAME] + ) + + async def set_dps(self, states): + """Change value of a DPs of the Tuya device.""" + if self._interface is not None: + try: + await self._interface.set_dps(states) + except Exception: # pylint: disable=broad-except + self.exception("Failed to set DPs %r", states) + else: + self.error( + "Not connected to device %s", self._config_entry[CONF_FRIENDLY_NAME] + ) + + @callback + def status_updated(self, status): + """Device updated status.""" + self._status.update(status) + self._dispatch_status() + + def _dispatch_status(self): + signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}" + async_dispatcher_send(self._hass, signal, self._status) + + @callback + def disconnected(self): + """Device disconnected.""" + signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}" + async_dispatcher_send(self._hass, signal, None) + if self._unsub_interval is not None: + self._unsub_interval() + self._unsub_interval = None + self._interface = None + self.debug("Disconnected - waiting for discovery broadcast") + + +class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): + """Representation of a Tuya entity.""" + + def __init__(self, device, config_entry, dp_id, logger, **kwargs): + """Initialize the Tuya entity.""" + super().__init__() + self._device = device + self._config_entry = config_entry + self._config = get_entity_config(config_entry, dp_id) + self._dp_id = dp_id + self._status = {} + self.set_logger(logger, self._config_entry.data[CONF_DEVICE_ID]) + + async def async_added_to_hass(self): + """Subscribe localtuya events.""" + await super().async_added_to_hass() + + self.debug("Adding %s with configuration: %s", self.entity_id, self._config) + + state = await self.async_get_last_state() + if state: + self.status_restored(state) + + def _update_handler(status): + """Update entity state when status was updated.""" + if status is None: + status = {} + if self._status != status: + self._status = status.copy() + if status: + self.status_updated() + self.schedule_update_ha_state() + + signal = f"localtuya_{self._config_entry.data[CONF_DEVICE_ID]}" + + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, _update_handler) + ) + + signal = f"localtuya_entity_{self._config_entry.data[CONF_DEVICE_ID]}" + async_dispatcher_send(self.hass, signal, self.entity_id) + + @property + def device_info(self): + """Return device information for the device registry.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, f"local_{self._config_entry.data[CONF_DEVICE_ID]}") + }, + "name": self._config_entry.data[CONF_FRIENDLY_NAME], + "manufacturer": "Unknown", + "model": self._config_entry.data.get(CONF_PRODUCT_KEY, "Tuya generic"), + "sw_version": self._config_entry.data[CONF_PROTOCOL_VERSION], + } + + @property + def name(self): + """Get name of Tuya entity.""" + return self._config[CONF_FRIENDLY_NAME] + + @property + def should_poll(self): + """Return if platform should poll for updates.""" + return False + + @property + def unique_id(self): + """Return unique device identifier.""" + return f"local_{self._config_entry.data[CONF_DEVICE_ID]}_{self._dp_id}" + + def has_config(self, attr): + """Return if a config parameter has a valid value.""" + value = self._config.get(attr, "-1") + return value is not None and value != "-1" + + @property + def available(self): + """Return if device is available or not.""" + return str(self._dp_id) in self._status + + def dps(self, dp_index): + """Return cached value for DPS index.""" + value = self._status.get(str(dp_index)) + if value is None: + self.warning( + "Entity %s is requesting unknown DPS index %s", + self.entity_id, + dp_index, + ) + + return value + + def dps_conf(self, conf_item): + """Return value of datapoint for user specified config item. + + This method looks up which DP a certain config item uses based on + user configuration and returns its value. + """ + dp_index = self._config.get(conf_item) + if dp_index is None: + self.warning( + "Entity %s is requesting unset index for option %s", + self.entity_id, + conf_item, + ) + return self.dps(dp_index) + + def status_updated(self): + """Device status was updated. + + Override in subclasses and update entity specific state. + """ + + def status_restored(self, stored_state): + """Device status was restored. + + Override in subclasses and update entity specific state. + """ diff --git a/config/custom_components/localtuya/config_flow.py b/config/custom_components/localtuya/config_flow.py new file mode 100644 index 0000000..03bf80d --- /dev/null +++ b/config/custom_components/localtuya/config_flow.py @@ -0,0 +1,478 @@ +"""Config flow for LocalTuya integration integration.""" +import errno +import logging +from importlib import import_module + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant import config_entries, core, exceptions +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ID, + CONF_PLATFORM, + CONF_SCAN_INTERVAL, +) +from homeassistant.core import callback + +from .common import async_config_entry_by_device_id, pytuya +from .const import CONF_DPS_STRINGS # pylint: disable=unused-import +from .const import ( + CONF_LOCAL_KEY, + CONF_PRODUCT_KEY, + CONF_PROTOCOL_VERSION, + DATA_DISCOVERY, + DOMAIN, + PLATFORMS, +) +from .discovery import discover + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_TO_ADD = "platform_to_add" +NO_ADDITIONAL_PLATFORMS = "no_additional_platforms" +DISCOVERED_DEVICE = "discovered_device" + +CUSTOM_DEVICE = "..." + +BASIC_INFO_SCHEMA = vol.Schema( + { + vol.Required(CONF_FRIENDLY_NAME): str, + vol.Required(CONF_LOCAL_KEY): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), + vol.Optional(CONF_SCAN_INTERVAL): int, + } +) + + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_LOCAL_KEY): cv.string, + vol.Required(CONF_FRIENDLY_NAME): cv.string, + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), + vol.Optional(CONF_SCAN_INTERVAL): int, + } +) + +PICK_ENTITY_SCHEMA = vol.Schema( + {vol.Required(PLATFORM_TO_ADD, default=PLATFORMS[0]): vol.In(PLATFORMS)} +) + + +def user_schema(devices, entries): + """Create schema for user step.""" + devices = {dev_id: dev["ip"] for dev_id, dev in devices.items()} + devices.update( + { + ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME] + for ent in entries + if ent.source != SOURCE_IMPORT + } + ) + device_list = [f"{key} ({value})" for key, value in devices.items()] + return vol.Schema( + {vol.Required(DISCOVERED_DEVICE): vol.In(device_list + [CUSTOM_DEVICE])} + ) + + +def options_schema(entities): + """Create schema for options.""" + entity_names = [ + f"{entity[CONF_ID]} {entity[CONF_FRIENDLY_NAME]}" for entity in entities + ] + return vol.Schema( + { + vol.Required(CONF_FRIENDLY_NAME): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_LOCAL_KEY): str, + vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), + vol.Optional(CONF_SCAN_INTERVAL): int, + vol.Required( + CONF_ENTITIES, description={"suggested_value": entity_names} + ): cv.multi_select(entity_names), + } + ) + + +def schema_defaults(schema, dps_list=None, **defaults): + """Create a new schema with default values filled in.""" + copy = schema.extend({}) + for field, field_type in copy.schema.items(): + if isinstance(field_type, vol.In): + value = None + for dps in dps_list or []: + if dps.startswith(f"{defaults.get(field)} "): + value = dps + break + + if value in field_type.container: + field.default = vol.default_factory(value) + continue + + if field.schema in defaults: + field.default = vol.default_factory(defaults[field]) + return copy + + +def dps_string_list(dps_data): + """Return list of friendly DPS values.""" + return [f"{id} (value: {value})" for id, value in dps_data.items()] + + +def gen_dps_strings(): + """Generate list of DPS values.""" + return [f"{dp} (value: ?)" for dp in range(1, 256)] + + +def platform_schema(platform, dps_strings, allow_id=True, yaml=False): + """Generate input validation schema for a platform.""" + schema = {} + if yaml: + # In YAML mode we force the specified platform to match flow schema + schema[vol.Required(CONF_PLATFORM)] = vol.In([platform]) + if allow_id: + schema[vol.Required(CONF_ID)] = vol.In(dps_strings) + schema[vol.Required(CONF_FRIENDLY_NAME)] = str + return vol.Schema(schema).extend(flow_schema(platform, dps_strings)) + + +def flow_schema(platform, dps_strings): + """Return flow schema for a specific platform.""" + integration_module = ".".join(__name__.split(".")[:-1]) + return import_module("." + platform, integration_module).flow_schema(dps_strings) + + +def strip_dps_values(user_input, dps_strings): + """Remove values and keep only index for DPS config items.""" + stripped = {} + for field, value in user_input.items(): + if value in dps_strings: + stripped[field] = int(user_input[field].split(" ")[0]) + else: + stripped[field] = user_input[field] + return stripped + + +def config_schema(): + """Build schema used for setting up component.""" + entity_schemas = [ + platform_schema(platform, range(1, 256), yaml=True) for platform in PLATFORMS + ] + return vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + DEVICE_SCHEMA.extend( + {vol.Required(CONF_ENTITIES): [vol.Any(*entity_schemas)]} + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, + ) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + detected_dps = {} + + interface = None + try: + interface = await pytuya.connect( + data[CONF_HOST], + data[CONF_DEVICE_ID], + data[CONF_LOCAL_KEY], + float(data[CONF_PROTOCOL_VERSION]), + ) + + detected_dps = await interface.detect_available_dps() + except (ConnectionRefusedError, ConnectionResetError) as ex: + raise CannotConnect from ex + except ValueError as ex: + raise InvalidAuth from ex + finally: + if interface: + await interface.close() + + # Indicate an error if no datapoints found as the rest of the flow + # won't work in this case + if not detected_dps: + raise EmptyDpsList + + return dps_string_list(detected_dps) + + +class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for LocalTuya integration.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow for this handler.""" + return LocalTuyaOptionsFlowHandler(config_entry) + + def __init__(self): + """Initialize a new LocaltuyaConfigFlow.""" + self.basic_info = None + self.dps_strings = [] + self.platform = None + self.devices = {} + self.selected_device = None + self.entities = [] + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + if user_input[DISCOVERED_DEVICE] != CUSTOM_DEVICE: + self.selected_device = user_input[DISCOVERED_DEVICE].split(" ")[0] + return await self.async_step_basic_info() + + # Use cache if available or fallback to manual discovery + devices = {} + data = self.hass.data.get(DOMAIN) + if data and DATA_DISCOVERY in data: + devices = data[DATA_DISCOVERY].devices + else: + try: + devices = await discover() + except OSError as ex: + if ex.errno == errno.EADDRINUSE: + errors["base"] = "address_in_use" + else: + errors["base"] = "discovery_failed" + except Exception: # pylint: disable= broad-except + _LOGGER.exception("discovery failed") + errors["base"] = "discovery_failed" + + self.devices = { + ip: dev + for ip, dev in devices.items() + if dev["gwId"] not in self._async_current_ids() + } + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=user_schema(self.devices, self._async_current_entries()), + ) + + async def async_step_basic_info(self, user_input=None): + """Handle input of basic info.""" + errors = {} + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_DEVICE_ID]) + + try: + self.basic_info = user_input + if self.selected_device is not None: + self.basic_info[CONF_PRODUCT_KEY] = self.devices[ + self.selected_device + ]["productKey"] + self.dps_strings = await validate_input(self.hass, user_input) + return await self.async_step_pick_entity_type() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except EmptyDpsList: + errors["base"] = "empty_dps" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + # If selected device exists as a config entry, load config from it + if self.selected_device in self._async_current_ids(): + entry = async_config_entry_by_device_id(self.hass, self.selected_device) + await self.async_set_unique_id(entry.data[CONF_DEVICE_ID]) + self.basic_info = entry.data.copy() + self.dps_strings = self.basic_info.pop(CONF_DPS_STRINGS).copy() + self.entities = self.basic_info.pop(CONF_ENTITIES).copy() + return await self.async_step_pick_entity_type() + + # Insert default values from discovery if present + defaults = {} + defaults.update(user_input or {}) + if self.selected_device is not None: + device = self.devices[self.selected_device] + defaults[CONF_HOST] = device.get("ip") + defaults[CONF_DEVICE_ID] = device.get("gwId") + defaults[CONF_PROTOCOL_VERSION] = device.get("version") + + return self.async_show_form( + step_id="basic_info", + data_schema=schema_defaults(BASIC_INFO_SCHEMA, **defaults), + errors=errors, + ) + + async def async_step_pick_entity_type(self, user_input=None): + """Handle asking if user wants to add another entity.""" + if user_input is not None: + if user_input.get(NO_ADDITIONAL_PLATFORMS): + config = { + **self.basic_info, + CONF_DPS_STRINGS: self.dps_strings, + CONF_ENTITIES: self.entities, + } + entry = async_config_entry_by_device_id(self.hass, self.unique_id) + if entry: + self.hass.config_entries.async_update_entry(entry, data=config) + return self.async_abort(reason="device_updated") + return self.async_create_entry( + title=config[CONF_FRIENDLY_NAME], data=config + ) + + self.platform = user_input[PLATFORM_TO_ADD] + return await self.async_step_add_entity() + + # Add a checkbox that allows bailing out from config flow iff at least one + # entity has been added + schema = PICK_ENTITY_SCHEMA + if self.platform is not None: + schema = schema.extend( + {vol.Required(NO_ADDITIONAL_PLATFORMS, default=True): bool} + ) + + return self.async_show_form(step_id="pick_entity_type", data_schema=schema) + + async def async_step_add_entity(self, user_input=None): + """Handle adding a new entity.""" + errors = {} + if user_input is not None: + already_configured = any( + switch[CONF_ID] == int(user_input[CONF_ID].split(" ")[0]) + for switch in self.entities + ) + if not already_configured: + user_input[CONF_PLATFORM] = self.platform + self.entities.append(strip_dps_values(user_input, self.dps_strings)) + return await self.async_step_pick_entity_type() + + errors["base"] = "entity_already_configured" + + return self.async_show_form( + step_id="add_entity", + data_schema=platform_schema(self.platform, self.dps_strings), + errors=errors, + description_placeholders={"platform": self.platform}, + ) + + async def async_step_import(self, user_input): + """Handle import from YAML.""" + await self.async_set_unique_id(user_input[CONF_DEVICE_ID]) + self._abort_if_unique_id_configured(updates=user_input) + return self.async_create_entry( + title=f"{user_input[CONF_FRIENDLY_NAME]} (YAML)", data=user_input + ) + + +class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for LocalTuya integration.""" + + def __init__(self, config_entry): + """Initialize localtuya options flow.""" + self.config_entry = config_entry + self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings()) + self.entities = config_entry.data[CONF_ENTITIES] + self.data = None + + async def async_step_init(self, user_input=None): + """Manage basic options.""" + device_id = self.config_entry.data[CONF_DEVICE_ID] + if user_input is not None: + self.data = user_input.copy() + self.data.update( + { + CONF_DEVICE_ID: device_id, + CONF_DPS_STRINGS: self.dps_strings, + CONF_ENTITIES: [], + } + ) + if len(user_input[CONF_ENTITIES]) > 0: + entity_ids = [ + int(entity.split(" ")[0]) for entity in user_input[CONF_ENTITIES] + ] + self.entities = [ + entity + for entity in self.config_entry.data[CONF_ENTITIES] + if entity[CONF_ID] in entity_ids + ] + return await self.async_step_entity() + + # Not supported for YAML imports + if self.config_entry.source == config_entries.SOURCE_IMPORT: + return await self.async_step_yaml_import() + + return self.async_show_form( + step_id="init", + data_schema=schema_defaults( + options_schema(self.entities), **self.config_entry.data + ), + description_placeholders={"device_id": device_id}, + ) + + async def async_step_entity(self, user_input=None): + """Manage entity settings.""" + errors = {} + if user_input is not None: + entity = strip_dps_values(user_input, self.dps_strings) + entity[CONF_ID] = self.current_entity[CONF_ID] + entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM] + self.data[CONF_ENTITIES].append(entity) + + if len(self.entities) == len(self.data[CONF_ENTITIES]): + self.hass.config_entries.async_update_entry( + self.config_entry, + title=self.data[CONF_FRIENDLY_NAME], + data=self.data, + ) + return self.async_create_entry(title="", data={}) + + schema = platform_schema( + self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False + ) + return self.async_show_form( + step_id="entity", + errors=errors, + data_schema=schema_defaults( + schema, self.dps_strings, **self.current_entity + ), + description_placeholders={ + "id": self.current_entity[CONF_ID], + "platform": self.current_entity[CONF_PLATFORM], + }, + ) + + async def async_step_yaml_import(self, user_input=None): + """Manage YAML imports.""" + if user_input is not None: + return self.async_create_entry(title="", data={}) + return self.async_show_form(step_id="yaml_import") + + @property + def current_entity(self): + """Existing configuration for entity currently being edited.""" + return self.entities[len(self.data[CONF_ENTITIES])] + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class EmptyDpsList(exceptions.HomeAssistantError): + """Error to indicate no datapoints found.""" diff --git a/config/custom_components/localtuya/const.py b/config/custom_components/localtuya/const.py new file mode 100644 index 0000000..4148771 --- /dev/null +++ b/config/custom_components/localtuya/const.py @@ -0,0 +1,103 @@ +"""Constants for localtuya integration.""" + +ATTR_CURRENT = "current" +ATTR_CURRENT_CONSUMPTION = "current_consumption" +ATTR_VOLTAGE = "voltage" + +CONF_LOCAL_KEY = "local_key" +CONF_PROTOCOL_VERSION = "protocol_version" +CONF_DPS_STRINGS = "dps_strings" +CONF_PRODUCT_KEY = "product_key" + +# light +CONF_BRIGHTNESS_LOWER = "brightness_lower" +CONF_BRIGHTNESS_UPPER = "brightness_upper" +CONF_COLOR = "color" +CONF_COLOR_MODE = "color_mode" +CONF_COLOR_TEMP_MIN_KELVIN = "color_temp_min_kelvin" +CONF_COLOR_TEMP_MAX_KELVIN = "color_temp_max_kelvin" +CONF_COLOR_TEMP_REVERSE = "color_temp_reverse" +CONF_MUSIC_MODE = "music_mode" + +# switch +CONF_CURRENT = "current" +CONF_CURRENT_CONSUMPTION = "current_consumption" +CONF_VOLTAGE = "voltage" + +# cover +CONF_COMMANDS_SET = "commands_set" +CONF_POSITIONING_MODE = "positioning_mode" +CONF_CURRENT_POSITION_DP = "current_position_dp" +CONF_SET_POSITION_DP = "set_position_dp" +CONF_POSITION_INVERTED = "position_inverted" +CONF_SPAN_TIME = "span_time" + +# fan +CONF_FAN_SPEED_CONTROL = "fan_speed_control" +CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control" +CONF_FAN_SPEED_MIN = "fan_speed_min" +CONF_FAN_SPEED_MAX = "fan_speed_max" +CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list" +CONF_FAN_DIRECTION = "fan_direction" +CONF_FAN_DIRECTION_FWD = "fan_direction_forward" +CONF_FAN_DIRECTION_REV = "fan_direction_reverse" + +# sensor +CONF_SCALING = "scaling" + +# climate +CONF_TARGET_TEMPERATURE_DP = "target_temperature_dp" +CONF_CURRENT_TEMPERATURE_DP = "current_temperature_dp" +CONF_TEMPERATURE_STEP = "temperature_step" +CONF_MAX_TEMP_DP = "max_temperature_dp" +CONF_MIN_TEMP_DP = "min_temperature_dp" +CONF_PRECISION = "precision" +CONF_TARGET_PRECISION = "target_precision" +CONF_HVAC_MODE_DP = "hvac_mode_dp" +CONF_HVAC_MODE_SET = "hvac_mode_set" +CONF_PRESET_DP = "preset_dp" +CONF_PRESET_SET = "preset_set" +CONF_HEURISTIC_ACTION = "heuristic_action" +CONF_HVAC_ACTION_DP = "hvac_action_dp" +CONF_HVAC_ACTION_SET = "hvac_action_set" +CONF_ECO_DP = "eco_dp" +CONF_ECO_VALUE = "eco_value" + +# vacuum +CONF_POWERGO_DP = "powergo_dp" +CONF_IDLE_STATUS_VALUE = "idle_status_value" +CONF_RETURNING_STATUS_VALUE = "returning_status_value" +CONF_DOCKED_STATUS_VALUE = "docked_status_value" +CONF_BATTERY_DP = "battery_dp" +CONF_MODE_DP = "mode_dp" +CONF_MODES = "modes" +CONF_FAN_SPEED_DP = "fan_speed_dp" +CONF_FAN_SPEEDS = "fan_speeds" +CONF_CLEAN_TIME_DP = "clean_time_dp" +CONF_CLEAN_AREA_DP = "clean_area_dp" +CONF_CLEAN_RECORD_DP = "clean_record_dp" +CONF_LOCATE_DP = "locate_dp" +CONF_FAULT_DP = "fault_dp" +CONF_PAUSED_STATE = "paused_state" +CONF_RETURN_MODE = "return_mode" +CONF_STOP_STATUS = "stop_status" + +DATA_DISCOVERY = "discovery" + +DOMAIN = "localtuya" + +# Platforms in this list must support config flows +PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "fan", + "light", + "number", + "select", + "sensor", + "switch", + "vacuum", +] + +TUYA_DEVICE = "tuya_device" diff --git a/config/custom_components/localtuya/cover.py b/config/custom_components/localtuya/cover.py new file mode 100644 index 0000000..43f59a6 --- /dev/null +++ b/config/custom_components/localtuya/cover.py @@ -0,0 +1,232 @@ +"""Platform to locally control Tuya-based cover devices.""" +import asyncio +import logging +import time +from functools import partial + +import voluptuous as vol +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, + CoverEntity, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_COMMANDS_SET, + CONF_CURRENT_POSITION_DP, + CONF_POSITION_INVERTED, + CONF_POSITIONING_MODE, + CONF_SET_POSITION_DP, + CONF_SPAN_TIME, +) + +_LOGGER = logging.getLogger(__name__) + +COVER_ONOFF_CMDS = "on_off_stop" +COVER_OPENCLOSE_CMDS = "open_close_stop" +COVER_FZZZ_CMDS = "fz_zz_stop" +COVER_12_CMDS = "1_2_3" +COVER_MODE_NONE = "none" +COVER_MODE_POSITION = "position" +COVER_MODE_TIMED = "timed" +COVER_TIMEOUT_TOLERANCE = 3.0 + +DEFAULT_COMMANDS_SET = COVER_ONOFF_CMDS +DEFAULT_POSITIONING_MODE = COVER_MODE_NONE +DEFAULT_SPAN_TIME = 25.0 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_COMMANDS_SET): vol.In( + [COVER_ONOFF_CMDS, COVER_OPENCLOSE_CMDS, COVER_FZZZ_CMDS, COVER_12_CMDS] + ), + vol.Optional(CONF_POSITIONING_MODE, default=DEFAULT_POSITIONING_MODE): vol.In( + [COVER_MODE_NONE, COVER_MODE_POSITION, COVER_MODE_TIMED] + ), + vol.Optional(CONF_CURRENT_POSITION_DP): vol.In(dps), + vol.Optional(CONF_SET_POSITION_DP): vol.In(dps), + vol.Optional(CONF_POSITION_INVERTED, default=False): bool, + vol.Optional(CONF_SPAN_TIME, default=DEFAULT_SPAN_TIME): vol.All( + vol.Coerce(float), vol.Range(min=1.0, max=300.0) + ), + } + + +class LocaltuyaCover(LocalTuyaEntity, CoverEntity): + """Tuya cover device.""" + + def __init__(self, device, config_entry, switchid, **kwargs): + """Initialize a new LocaltuyaCover.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + commands_set = DEFAULT_COMMANDS_SET + if self.has_config(CONF_COMMANDS_SET): + commands_set = self._config[CONF_COMMANDS_SET] + self._open_cmd = commands_set.split("_")[0] + self._close_cmd = commands_set.split("_")[1] + self._stop_cmd = commands_set.split("_")[2] + self._timer_start = time.time() + self._state = self._stop_cmd + self._previous_state = self._state + self._current_cover_position = 0 + print("Initialized cover [{}]".format(self.name)) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + if self._config[CONF_POSITIONING_MODE] != COVER_MODE_NONE: + supported_features = supported_features | SUPPORT_SET_POSITION + return supported_features + + @property + def current_cover_position(self): + """Return current cover position in percent.""" + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: + return None + return self._current_cover_position + + @property + def is_opening(self): + """Return if cover is opening.""" + state = self._state + return state == self._open_cmd + + @property + def is_closing(self): + """Return if cover is closing.""" + state = self._state + return state == self._close_cmd + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: + return None + + if self._current_cover_position == 0: + return True + if self._current_cover_position == 100: + return False + return None + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self.debug("Setting cover position: %r", kwargs[ATTR_POSITION]) + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + newpos = float(kwargs[ATTR_POSITION]) + + currpos = self.current_cover_position + posdiff = abs(newpos - currpos) + mydelay = posdiff / 100.0 * self._config[CONF_SPAN_TIME] + if newpos > currpos: + self.debug("Opening to %f: delay %f", newpos, mydelay) + await self.async_open_cover() + else: + self.debug("Closing to %f: delay %f", newpos, mydelay) + await self.async_close_cover() + self.hass.async_create_task(self.async_stop_after_timeout(mydelay)) + self.debug("Done") + + elif self._config[CONF_POSITIONING_MODE] == COVER_MODE_POSITION: + converted_position = int(kwargs[ATTR_POSITION]) + if self._config[CONF_POSITION_INVERTED]: + converted_position = 100 - converted_position + + if 0 <= converted_position <= 100 and self.has_config(CONF_SET_POSITION_DP): + await self._device.set_dp( + converted_position, self._config[CONF_SET_POSITION_DP] + ) + + async def async_stop_after_timeout(self, delay_sec): + """Stop the cover if timeout (max movement span) occurred.""" + await asyncio.sleep(delay_sec) + await self.async_stop_cover() + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + self.debug("Launching command %s to cover ", self._open_cmd) + await self._device.set_dp(self._open_cmd, self._dp_id) + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + # for timed positioning, stop the cover after a full opening timespan + # instead of waiting the internal timeout + self.hass.async_create_task( + self.async_stop_after_timeout( + self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + ) + ) + + async def async_close_cover(self, **kwargs): + """Close cover.""" + self.debug("Launching command %s to cover ", self._close_cmd) + await self._device.set_dp(self._close_cmd, self._dp_id) + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + # for timed positioning, stop the cover after a full opening timespan + # instead of waiting the internal timeout + self.hass.async_create_task( + self.async_stop_after_timeout( + self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + ) + ) + + async def async_stop_cover(self, **kwargs): + """Stop the cover.""" + self.debug("Launching command %s to cover ", self._stop_cmd) + await self._device.set_dp(self._stop_cmd, self._dp_id) + + def status_restored(self, stored_state): + """Restore the last stored cover status.""" + if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: + stored_pos = stored_state.attributes.get("current_position") + if stored_pos is not None: + self._current_cover_position = stored_pos + self.debug("Restored cover position %s", self._current_cover_position) + + def status_updated(self): + """Device status was updated.""" + self._previous_state = self._state + self._state = self.dps(self._dp_id) + if self._state.isupper(): + self._open_cmd = self._open_cmd.upper() + self._close_cmd = self._close_cmd.upper() + self._stop_cmd = self._stop_cmd.upper() + + if self.has_config(CONF_CURRENT_POSITION_DP): + curr_pos = self.dps_conf(CONF_CURRENT_POSITION_DP) + if self._config[CONF_POSITION_INVERTED]: + self._current_cover_position = 100 - curr_pos + else: + self._current_cover_position = curr_pos + if ( + self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED + and self._state != self._previous_state + ): + if self._previous_state != self._stop_cmd: + # the state has changed, and the cover was moving + time_diff = time.time() - self._timer_start + pos_diff = round(time_diff / self._config[CONF_SPAN_TIME] * 100.0) + if self._previous_state == self._close_cmd: + pos_diff = -pos_diff + self._current_cover_position = min( + 100, max(0, self._current_cover_position + pos_diff) + ) + + change = "stopped" if self._state == self._stop_cmd else "inverted" + self.debug( + "Movement %s after %s sec., position difference %s", + change, + time_diff, + pos_diff, + ) + + # store the time of the last movement change + self._timer_start = time.time() + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema) diff --git a/config/custom_components/localtuya/discovery.py b/config/custom_components/localtuya/discovery.py new file mode 100644 index 0000000..a753c5f --- /dev/null +++ b/config/custom_components/localtuya/discovery.py @@ -0,0 +1,90 @@ +"""Discovery module for Tuya devices. + +Entirely based on tuya-convert.py from tuya-convert: + +https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py +""" +import asyncio +import json +import logging +from hashlib import md5 + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +_LOGGER = logging.getLogger(__name__) + +UDP_KEY = md5(b"yGAdlopoPVldABfn").digest() + +DEFAULT_TIMEOUT = 6.0 + + +def decrypt_udp(message): + """Decrypt encrypted UDP broadcasts.""" + + def _unpad(data): + return data[: -ord(data[len(data) - 1 :])] + + cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend()) + decryptor = cipher.decryptor() + return _unpad(decryptor.update(message) + decryptor.finalize()).decode() + + +class TuyaDiscovery(asyncio.DatagramProtocol): + """Datagram handler listening for Tuya broadcast messages.""" + + def __init__(self, callback=None): + """Initialize a new BaseDiscovery.""" + self.devices = {} + self._listeners = [] + self._callback = callback + + async def start(self): + """Start discovery by listening to broadcasts.""" + loop = asyncio.get_running_loop() + listener = loop.create_datagram_endpoint( + lambda: self, local_addr=("0.0.0.0", 6666) + ) + encrypted_listener = loop.create_datagram_endpoint( + lambda: self, local_addr=("0.0.0.0", 6667) + ) + + self._listeners = await asyncio.gather(listener, encrypted_listener) + _LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667") + + def close(self): + """Stop discovery.""" + self._callback = None + for transport, _ in self._listeners: + transport.close() + + def datagram_received(self, data, addr): + """Handle received broadcast message.""" + data = data[20:-8] + try: + data = decrypt_udp(data) + except Exception: # pylint: disable=broad-except + data = data.decode() + + decoded = json.loads(data) + self.device_found(decoded) + + def device_found(self, device): + """Discover a new device.""" + if device.get("ip") not in self.devices: + self.devices[device.get("gwId")] = device + _LOGGER.debug("Discovered device: %s", device) + + if self._callback: + self._callback(device) + + +async def discover(): + """Discover and return devices on local network.""" + discovery = TuyaDiscovery() + try: + await discovery.start() + await asyncio.sleep(DEFAULT_TIMEOUT) + finally: + discovery.close() + return discovery.devices diff --git a/config/custom_components/localtuya/fan.py b/config/custom_components/localtuya/fan.py new file mode 100644 index 0000000..d2b4583 --- /dev/null +++ b/config/custom_components/localtuya/fan.py @@ -0,0 +1,255 @@ +"""Platform to locally control Tuya-based fan devices.""" +import logging +import math +from functools import partial + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.util.percentage import ( + int_states_in_range, + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_FAN_DIRECTION, + CONF_FAN_DIRECTION_FWD, + CONF_FAN_DIRECTION_REV, + CONF_FAN_ORDERED_LIST, + CONF_FAN_OSCILLATING_CONTROL, + CONF_FAN_SPEED_CONTROL, + CONF_FAN_SPEED_MAX, + CONF_FAN_SPEED_MIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_FAN_SPEED_CONTROL): vol.In(dps), + vol.Optional(CONF_FAN_OSCILLATING_CONTROL): vol.In(dps), + vol.Optional(CONF_FAN_DIRECTION): vol.In(dps), + vol.Optional(CONF_FAN_DIRECTION_FWD, default="forward"): cv.string, + vol.Optional(CONF_FAN_DIRECTION_REV, default="reverse"): cv.string, + vol.Optional(CONF_FAN_SPEED_MIN, default=1): cv.positive_int, + vol.Optional(CONF_FAN_SPEED_MAX, default=9): cv.positive_int, + vol.Optional(CONF_FAN_ORDERED_LIST, default="disabled"): cv.string, + } + + +class LocaltuyaFan(LocalTuyaEntity, FanEntity): + """Representation of a Tuya fan.""" + + def __init__( + self, + device, + config_entry, + fanid, + **kwargs, + ): + """Initialize the entity.""" + super().__init__(device, config_entry, fanid, _LOGGER, **kwargs) + self._is_on = False + self._oscillating = None + self._direction = None + self._percentage = None + self._speed_range = ( + self._config.get(CONF_FAN_SPEED_MIN), + self._config.get(CONF_FAN_SPEED_MAX), + ) + self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",") + self._ordered_list_mode = None + + if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1: + self._use_ordered_list = True + _LOGGER.debug( + "Fan _use_ordered_list: %s > %s", + self._use_ordered_list, + self._ordered_list, + ) + else: + self._use_ordered_list = False + _LOGGER.debug("Fan _use_ordered_list: %s", self._use_ordered_list) + + @property + def oscillating(self): + """Return current oscillating status.""" + return self._oscillating + + @property + def current_direction(self): + """Return the current direction of the fan.""" + return self._direction + + @property + def is_on(self): + """Check if Tuya fan is on.""" + return self._is_on + + @property + def percentage(self): + """Return the current percentage.""" + return self._percentage + + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the entity.""" + _LOGGER.debug("Fan async_turn_on") + await self._device.set_dp(True, self._dp_id) + if percentage is not None: + await self.async_set_percentage(percentage) + else: + self.schedule_update_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + _LOGGER.debug("Fan async_turn_off") + + await self._device.set_dp(False, self._dp_id) + self.schedule_update_ha_state() + + async def async_set_percentage(self, percentage): + """Set the speed of the fan.""" + _LOGGER.debug("Fan async_set_percentage: %s", percentage) + + if percentage is not None: + if percentage == 0: + return await self.async_turn_off() + if not self.is_on: + await self.async_turn_on() + if self._use_ordered_list: + await self._device.set_dp( + str( + percentage_to_ordered_list_item(self._ordered_list, percentage) + ), + self._config.get(CONF_FAN_SPEED_CONTROL), + ) + _LOGGER.debug( + "Fan async_set_percentage: %s > %s", + percentage, + percentage_to_ordered_list_item(self._ordered_list, percentage), + ) + + else: + await self._device.set_dp( + str( + math.ceil( + percentage_to_ranged_value(self._speed_range, percentage) + ) + ), + self._config.get(CONF_FAN_SPEED_CONTROL), + ) + _LOGGER.debug( + "Fan async_set_percentage: %s > %s", + percentage, + percentage_to_ranged_value(self._speed_range, percentage), + ) + self.schedule_update_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + _LOGGER.debug("Fan async_oscillate: %s", oscillating) + await self._device.set_dp( + oscillating, self._config.get(CONF_FAN_OSCILLATING_CONTROL) + ) + self.schedule_update_ha_state() + + async def async_set_direction(self, direction): + """Set the direction of the fan.""" + _LOGGER.debug("Fan async_set_direction: %s", direction) + + if direction == DIRECTION_FORWARD: + value = self._config.get(CONF_FAN_DIRECTION_FWD) + + if direction == DIRECTION_REVERSE: + value = self._config.get(CONF_FAN_DIRECTION_REV) + await self._device.set_dp(value, self._config.get(CONF_FAN_DIRECTION)) + self.schedule_update_ha_state() + + @property + def supported_features(self) -> int: + """Flag supported features.""" + features = 0 + + if self.has_config(CONF_FAN_OSCILLATING_CONTROL): + features |= SUPPORT_OSCILLATE + + if self.has_config(CONF_FAN_SPEED_CONTROL): + features |= SUPPORT_SET_SPEED + + if self.has_config(CONF_FAN_DIRECTION): + features |= SUPPORT_DIRECTION + + return features + + @property + def speed_count(self) -> int: + """Speed count for the fan.""" + speed_count = int_states_in_range(self._speed_range) + _LOGGER.debug("Fan speed_count: %s", speed_count) + return speed_count + + def status_updated(self): + """Get state of Tuya fan.""" + self._is_on = self.dps(self._dp_id) + + current_speed = self.dps_conf(CONF_FAN_SPEED_CONTROL) + if self._use_ordered_list: + _LOGGER.debug( + "Fan current_speed ordered_list_item_to_percentage: %s from %s", + current_speed, + self._ordered_list, + ) + if current_speed is not None: + self._percentage = ordered_list_item_to_percentage( + self._ordered_list, current_speed + ) + + else: + _LOGGER.debug( + "Fan current_speed ranged_value_to_percentage: %s from %s", + current_speed, + self._speed_range, + ) + if current_speed is not None: + self._percentage = ranged_value_to_percentage( + self._speed_range, int(current_speed) + ) + + _LOGGER.debug("Fan current_percentage: %s", self._percentage) + + if self.has_config(CONF_FAN_OSCILLATING_CONTROL): + self._oscillating = self.dps_conf(CONF_FAN_OSCILLATING_CONTROL) + _LOGGER.debug("Fan current_oscillating : %s", self._oscillating) + + if self.has_config(CONF_FAN_DIRECTION): + value = self.dps_conf(CONF_FAN_DIRECTION) + if value is not None: + if value == self._config.get(CONF_FAN_DIRECTION_FWD): + self._direction = DIRECTION_FORWARD + + if value == self._config.get(CONF_FAN_DIRECTION_REV): + self._direction = DIRECTION_REVERSE + _LOGGER.debug("Fan current_direction : %s > %s", value, self._direction) + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema) diff --git a/config/custom_components/localtuya/light.py b/config/custom_components/localtuya/light.py new file mode 100644 index 0000000..e99414e --- /dev/null +++ b/config/custom_components/localtuya/light.py @@ -0,0 +1,449 @@ +"""Platform to locally control Tuya-based light devices.""" +import logging +import textwrap +from functools import partial + +import homeassistant.util.color as color_util +import voluptuous as vol +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_HS_COLOR, + DOMAIN, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + LightEntity, +) +from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_BRIGHTNESS_LOWER, + CONF_BRIGHTNESS_UPPER, + CONF_COLOR, + CONF_COLOR_MODE, + CONF_COLOR_TEMP_MAX_KELVIN, + CONF_COLOR_TEMP_MIN_KELVIN, + CONF_COLOR_TEMP_REVERSE, + CONF_MUSIC_MODE, +) + +_LOGGER = logging.getLogger(__name__) + +MIRED_TO_KELVIN_CONST = 1000000 +DEFAULT_MIN_KELVIN = 2700 # MIRED 370 +DEFAULT_MAX_KELVIN = 6500 # MIRED 153 + +DEFAULT_COLOR_TEMP_REVERSE = False + +DEFAULT_LOWER_BRIGHTNESS = 29 +DEFAULT_UPPER_BRIGHTNESS = 1000 + +MODE_COLOR = "colour" +MODE_MUSIC = "music" +MODE_SCENE = "scene" +MODE_WHITE = "white" + +SCENE_CUSTOM = "Custom" +SCENE_MUSIC = "Music" + +SCENE_LIST_RGBW_1000 = { + "Night": "000e0d0000000000000000c80000", + "Read": "010e0d0000000000000003e801f4", + "Meeting": "020e0d0000000000000003e803e8", + "Leasure": "030e0d0000000000000001f401f4", + "Soft": "04464602007803e803e800000000464602007803e8000a00000000", + "Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803" + + "e800000000", + "Shine": "06464601000003e803e800000000464601007803e803e80000000046460100f003e803e8" + + "00000000", + "Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8" + + "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80" + + "3e800000000", +} + +SCENE_LIST_RGBW_255 = { + "Night": "bd76000168ffff", + "Read": "fffcf70168ffff", + "Meeting": "cf38000168ffff", + "Leasure": "3855b40168ffff", + "Scenario 1": "scene_1", + "Scenario 2": "scene_2", + "Scenario 3": "scene_3", + "Scenario 4": "scene_4", +} + +SCENE_LIST_RGB_1000 = { + "Night": "000e0d00002e03e802cc00000000", + "Read": "010e0d000084000003e800000000", + "Working": "020e0d00001403e803e800000000", + "Leisure": "030e0d0000e80383031c00000000", + "Soft": "04464602007803e803e800000000464602007803e8000a00000000", + "Colorful": "05464601000003e803e800000000464601007803e803e80000000046460100f003e80" + + "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803" + + "e800000000", + "Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" + + "3e800000000", + "Music": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8" + + "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80" + + "0000000", +} + + +def map_range(value, from_lower, from_upper, to_lower, to_upper): + """Map a value in one range to another.""" + mapped = (value - from_lower) * (to_upper - to_lower) / ( + from_upper - from_lower + ) + to_lower + return round(min(max(mapped, to_lower), to_upper)) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_BRIGHTNESS): vol.In(dps), + vol.Optional(CONF_COLOR_TEMP): vol.In(dps), + vol.Optional(CONF_BRIGHTNESS_LOWER, default=DEFAULT_LOWER_BRIGHTNESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=10000) + ), + vol.Optional(CONF_BRIGHTNESS_UPPER, default=DEFAULT_UPPER_BRIGHTNESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=10000) + ), + vol.Optional(CONF_COLOR_MODE): vol.In(dps), + vol.Optional(CONF_COLOR): vol.In(dps), + vol.Optional(CONF_COLOR_TEMP_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All( + vol.Coerce(int), vol.Range(min=1500, max=8000) + ), + vol.Optional(CONF_COLOR_TEMP_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All( + vol.Coerce(int), vol.Range(min=1500, max=8000) + ), + vol.Optional( + CONF_COLOR_TEMP_REVERSE, + default=DEFAULT_COLOR_TEMP_REVERSE, + description={"suggested_value": DEFAULT_COLOR_TEMP_REVERSE}, + ): bool, + vol.Optional(CONF_SCENE): vol.In(dps), + vol.Optional( + CONF_MUSIC_MODE, default=False, description={"suggested_value": False} + ): bool, + } + + +class LocaltuyaLight(LocalTuyaEntity, LightEntity): + """Representation of a Tuya light.""" + + def __init__( + self, + device, + config_entry, + lightid, + **kwargs, + ): + """Initialize the Tuya light.""" + super().__init__(device, config_entry, lightid, _LOGGER, **kwargs) + self._state = False + self._brightness = None + self._color_temp = None + self._lower_brightness = self._config.get( + CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS + ) + self._upper_brightness = self._config.get( + CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS + ) + self._upper_color_temp = self._upper_brightness + self._max_mired = round( + MIRED_TO_KELVIN_CONST + / self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) + ) + self._min_mired = round( + MIRED_TO_KELVIN_CONST + / self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN) + ) + self._color_temp_reverse = self._config.get( + CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE + ) + self._hs = None + self._effect = None + self._effect_list = [] + self._scenes = None + if self.has_config(CONF_SCENE): + if self._config.get(CONF_SCENE) < 20: + self._scenes = SCENE_LIST_RGBW_255 + elif self._config.get(CONF_BRIGHTNESS) is None: + self._scenes = SCENE_LIST_RGB_1000 + else: + self._scenes = SCENE_LIST_RGBW_1000 + self._effect_list = list(self._scenes.keys()) + if self._config.get(CONF_MUSIC_MODE): + self._effect_list.append(SCENE_MUSIC) + + @property + def is_on(self): + """Check if Tuya light is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of the light.""" + if self.is_color_mode or self.is_white_mode: + return map_range( + self._brightness, self._lower_brightness, self._upper_brightness, 0, 255 + ) + return None + + @property + def hs_color(self): + """Return the hs color value.""" + if self.is_color_mode: + return self._hs + if ( + self.supported_features & SUPPORT_COLOR + and not self.supported_features & SUPPORT_COLOR_TEMP + ): + return [0, 0] + return None + + @property + def color_temp(self): + """Return the color_temp of the light.""" + if self.has_config(CONF_COLOR_TEMP) and self.is_white_mode: + color_temp_value = ( + self._upper_color_temp - self._color_temp + if self._color_temp_reverse + else self._color_temp + ) + return int( + self._max_mired + - ( + ((self._max_mired - self._min_mired) / self._upper_color_temp) + * color_temp_value + ) + ) + return None + + @property + def min_mireds(self): + """Return color temperature min mireds.""" + return self._min_mired + + @property + def max_mireds(self): + """Return color temperature max mireds.""" + return self._max_mired + + @property + def effect(self): + """Return the current effect for this light.""" + if self.is_scene_mode or self.is_music_mode: + return self._effect + return None + + @property + def effect_list(self): + """Return the list of supported effects for this light.""" + return self._effect_list + + @property + def supported_features(self): + """Flag supported features.""" + supports = 0 + if self.has_config(CONF_BRIGHTNESS): + supports |= SUPPORT_BRIGHTNESS + if self.has_config(CONF_COLOR_TEMP): + supports |= SUPPORT_COLOR_TEMP + if self.has_config(CONF_COLOR): + supports |= SUPPORT_COLOR | SUPPORT_BRIGHTNESS + if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE): + supports |= SUPPORT_EFFECT + return supports + + @property + def is_white_mode(self): + """Return true if the light is in white mode.""" + color_mode = self.__get_color_mode() + return color_mode is None or color_mode == MODE_WHITE + + @property + def is_color_mode(self): + """Return true if the light is in color mode.""" + color_mode = self.__get_color_mode() + return color_mode is not None and color_mode == MODE_COLOR + + @property + def is_scene_mode(self): + """Return true if the light is in scene mode.""" + color_mode = self.__get_color_mode() + return color_mode is not None and color_mode.startswith(MODE_SCENE) + + @property + def is_music_mode(self): + """Return true if the light is in music mode.""" + color_mode = self.__get_color_mode() + return color_mode is not None and color_mode == MODE_MUSIC + + def __is_color_rgb_encoded(self): + return len(self.dps_conf(CONF_COLOR)) > 12 + + def __find_scene_by_scene_data(self, data): + return next( + (item for item in self._effect_list if self._scenes.get(item) == data), + SCENE_CUSTOM, + ) + + def __get_color_mode(self): + return ( + self.dps_conf(CONF_COLOR_MODE) + if self.has_config(CONF_COLOR_MODE) + else MODE_WHITE + ) + + async def async_turn_on(self, **kwargs): + """Turn on or control the light.""" + states = {} + if not self.is_on: + states[self._dp_id] = True + features = self.supported_features + brightness = None + if ATTR_EFFECT in kwargs and (features & SUPPORT_EFFECT): + scene = self._scenes.get(kwargs[ATTR_EFFECT]) + if scene is not None: + if scene.startswith(MODE_SCENE): + states[self._config.get(CONF_COLOR_MODE)] = scene + else: + states[self._config.get(CONF_COLOR_MODE)] = MODE_SCENE + states[self._config.get(CONF_SCENE)] = scene + elif kwargs[ATTR_EFFECT] == SCENE_MUSIC: + states[self._config.get(CONF_COLOR_MODE)] = MODE_MUSIC + + if ATTR_BRIGHTNESS in kwargs and (features & SUPPORT_BRIGHTNESS): + brightness = map_range( + int(kwargs[ATTR_BRIGHTNESS]), + 0, + 255, + self._lower_brightness, + self._upper_brightness, + ) + if self.is_white_mode: + states[self._config.get(CONF_BRIGHTNESS)] = brightness + else: + if self.__is_color_rgb_encoded(): + rgb = color_util.color_hsv_to_RGB( + self._hs[0], + self._hs[1], + int(brightness * 100 / self._upper_brightness), + ) + color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( + round(rgb[0]), + round(rgb[1]), + round(rgb[2]), + round(self._hs[0]), + round(self._hs[1] * 255 / 100), + brightness, + ) + else: + color = "{:04x}{:04x}{:04x}".format( + round(self._hs[0]), round(self._hs[1] * 10.0), brightness + ) + states[self._config.get(CONF_COLOR)] = color + states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR + + if ATTR_HS_COLOR in kwargs and (features & SUPPORT_COLOR): + if brightness is None: + brightness = self._brightness + hs = kwargs[ATTR_HS_COLOR] + if hs[1] == 0 and self.has_config(CONF_BRIGHTNESS): + states[self._config.get(CONF_BRIGHTNESS)] = brightness + states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE + else: + if self.__is_color_rgb_encoded(): + rgb = color_util.color_hsv_to_RGB( + hs[0], hs[1], int(brightness * 100 / self._upper_brightness) + ) + color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( + round(rgb[0]), + round(rgb[1]), + round(rgb[2]), + round(hs[0]), + round(hs[1] * 255 / 100), + brightness, + ) + else: + color = "{:04x}{:04x}{:04x}".format( + round(hs[0]), round(hs[1] * 10.0), brightness + ) + states[self._config.get(CONF_COLOR)] = color + states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR + + if ATTR_COLOR_TEMP in kwargs and (features & SUPPORT_COLOR_TEMP): + if brightness is None: + brightness = self._brightness + color_temp_value = ( + (self._max_mired - self._min_mired) + - (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired) + if self._color_temp_reverse + else (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired) + ) + color_temp = int( + self._upper_color_temp + - (self._upper_color_temp / (self._max_mired - self._min_mired)) + * color_temp_value + ) + states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE + states[self._config.get(CONF_BRIGHTNESS)] = brightness + states[self._config.get(CONF_COLOR_TEMP)] = color_temp + await self._device.set_dps(states) + + async def async_turn_off(self, **kwargs): + """Turn Tuya light off.""" + await self._device.set_dp(False, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + self._state = self.dps(self._dp_id) + supported = self.supported_features + self._effect = None + if supported & SUPPORT_BRIGHTNESS and self.has_config(CONF_BRIGHTNESS): + self._brightness = self.dps_conf(CONF_BRIGHTNESS) + + if supported & SUPPORT_COLOR: + color = self.dps_conf(CONF_COLOR) + if color is not None and not self.is_white_mode: + if self.__is_color_rgb_encoded(): + hue = int(color[6:10], 16) + sat = int(color[10:12], 16) + value = int(color[12:14], 16) + self._hs = [hue, (sat * 100 / 255)] + self._brightness = value + else: + hue, sat, value = [ + int(value, 16) for value in textwrap.wrap(color, 4) + ] + self._hs = [hue, sat / 10.0] + self._brightness = value + + if supported & SUPPORT_COLOR_TEMP: + self._color_temp = self.dps_conf(CONF_COLOR_TEMP) + + if self.is_scene_mode and supported & SUPPORT_EFFECT: + if self.dps_conf(CONF_COLOR_MODE) != MODE_SCENE: + self._effect = self.__find_scene_by_scene_data( + self.dps_conf(CONF_COLOR_MODE) + ) + else: + self._effect = self.__find_scene_by_scene_data( + self.dps_conf(CONF_SCENE) + ) + if self._effect == SCENE_CUSTOM: + if SCENE_CUSTOM not in self._effect_list: + self._effect_list.append(SCENE_CUSTOM) + elif SCENE_CUSTOM in self._effect_list: + self._effect_list.remove(SCENE_CUSTOM) + + if self.is_music_mode and supported & SUPPORT_EFFECT: + self._effect = SCENE_MUSIC + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaLight, flow_schema) diff --git a/config/custom_components/localtuya/manifest.json b/config/custom_components/localtuya/manifest.json new file mode 100644 index 0000000..7f19f9f --- /dev/null +++ b/config/custom_components/localtuya/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "localtuya", + "name": "LocalTuya integration", + "version": "3.2.1", + "documentation": "https://github.com/rospogrigio/localtuya/", + "dependencies": [], + "codeowners": [ + "@rospogrigio", "@postlund" + ], + "issue_tracker": "https://github.com/rospogrigio/localtuya/issues", + "requirements": [], + "config_flow": true, + "iot_class": "local_push" +} diff --git a/config/custom_components/localtuya/number.py b/config/custom_components/localtuya/number.py new file mode 100644 index 0000000..596eb01 --- /dev/null +++ b/config/custom_components/localtuya/number.py @@ -0,0 +1,84 @@ +"""Platform to present any Tuya DP as a number.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.number import DOMAIN, NumberEntity +from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN + +from .common import LocalTuyaEntity, async_setup_entry + +_LOGGER = logging.getLogger(__name__) + +CONF_MIN_VALUE = "min_value" +CONF_MAX_VALUE = "max_value" + +DEFAULT_MIN = 0 +DEFAULT_MAX = 100000 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All( + vol.Coerce(float), + vol.Range(min=-1000000.0, max=1000000.0), + ), + vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All( + vol.Coerce(float), + vol.Range(min=-1000000.0, max=1000000.0), + ), + } + + +class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): + """Representation of a Tuya Number.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._state = STATE_UNKNOWN + + self._min_value = DEFAULT_MIN + if CONF_MIN_VALUE in self._config: + self._min_value = self._config.get(CONF_MIN_VALUE) + + self._max_value = self._config.get(CONF_MAX_VALUE) + + @property + def value(self) -> float: + """Return sensor state.""" + return self._state + + @property + def min_value(self) -> float: + """Return the minimum value.""" + return self._min_value + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return self._max_value + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS) + + async def async_set_value(self, value: float) -> None: + """Update the current value.""" + await self._device.set_dp(value, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + state = self.dps(self._dp_id) + self._state = state + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema) diff --git a/config/custom_components/localtuya/pytuya/__init__.py b/config/custom_components/localtuya/pytuya/__init__.py new file mode 100644 index 0000000..b7645ec --- /dev/null +++ b/config/custom_components/localtuya/pytuya/__init__.py @@ -0,0 +1,682 @@ +# PyTuya Module +# -*- coding: utf-8 -*- +""" +Python module to interface with Tuya WiFi smart devices. + +Mostly derived from Shenzhen Xenon ESP8266MOD WiFi smart devices +E.g. https://wikidevi.com/wiki/Xenon_SM-PW701U + +Author: clach04 +Maintained by: postlund + +For more information see https://github.com/clach04/python-tuya + +Classes + TuyaInterface(dev_id, address, local_key=None) + dev_id (str): Device ID e.g. 01234567891234567890 + address (str): Device Network IP Address e.g. 10.0.1.99 + local_key (str, optional): The encryption key. Defaults to None. + +Functions + json = status() # returns json payload + set_version(version) # 3.1 [default] or 3.3 + detect_available_dps() # returns a list of available dps provided by the device + update_dps(dps) # sends update dps command + add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the + # device (to be queried in the payload) + set_dp(on, dp_index) # Set value of any dps index. + + +Credits + * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes + For protocol reverse engineering + * PyTuya https://github.com/clach04/python-tuya by clach04 + The origin of this python module (now abandoned) + * LocalTuya https://github.com/rospogrigio/localtuya-homeassistant by rospogrigio + Updated pytuya to support devices with Device IDs of 22 characters +""" + +import asyncio +import base64 +import binascii +import json +import logging +import struct +import time +import weakref +from abc import ABC, abstractmethod +from collections import namedtuple +from hashlib import md5 + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +version_tuple = (9, 0, 0) +version = version_string = __version__ = "%d.%d.%d" % version_tuple +__author__ = "postlund" + +_LOGGER = logging.getLogger(__name__) + +TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc") + +SET = "set" +STATUS = "status" +HEARTBEAT = "heartbeat" +UPDATEDPS = "updatedps" # Request refresh of DPS + +PROTOCOL_VERSION_BYTES_31 = b"3.1" +PROTOCOL_VERSION_BYTES_33 = b"3.3" + +PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + 12 * b"\x00" + +MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length +MESSAGE_RECV_HEADER_FMT = ">5I" # 4*uint32: prefix, seqno, cmd, length, retcode +MESSAGE_END_FMT = ">2I" # 2*uint32: crc, suffix + +PREFIX_VALUE = 0x000055AA +SUFFIX_VALUE = 0x0000AA55 + +HEARTBEAT_INTERVAL = 10 + +# DPS that are known to be safe to use with update_dps (0x12) command +UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi) + +# This is intended to match requests.json payload at +# https://github.com/codetheweb/tuyapi : +# type_0a devices require the 0a command as the status request +# type_0d devices require the 0d command as the status request, and the list of +# dps used set to null in the request payload (see generate_payload method) + +# prefix: # Next byte is command byte ("hexByte") some zero padding, then length +# of remaining payload, i.e. command + suffix (unclear if multiple bytes used for +# length, zero padding implies could be more than one byte) +PAYLOAD_DICT = { + "type_0a": { + STATUS: {"hexByte": 0x0A, "command": {"gwId": "", "devId": ""}}, + SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, + HEARTBEAT: {"hexByte": 0x09, "command": {}}, + UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, + }, + "type_0d": { + STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}}, + SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, + HEARTBEAT: {"hexByte": 0x09, "command": {}}, + UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, + }, +} + + +class TuyaLoggingAdapter(logging.LoggerAdapter): + """Adapter that adds device id to all log points.""" + + def process(self, msg, kwargs): + """Process log point and return output.""" + dev_id = self.extra["device_id"] + return f"[{dev_id[0:3]}...{dev_id[-3:]}] {msg}", kwargs + + +class ContextualLogger: + """Contextual logger adding device id to log points.""" + + def __init__(self): + """Initialize a new ContextualLogger.""" + self._logger = None + + def set_logger(self, logger, device_id): + """Set base logger to use.""" + self._logger = TuyaLoggingAdapter(logger, {"device_id": device_id}) + + def debug(self, msg, *args): + """Debug level log.""" + return self._logger.log(logging.DEBUG, msg, *args) + + def info(self, msg, *args): + """Info level log.""" + return self._logger.log(logging.INFO, msg, *args) + + def warning(self, msg, *args): + """Warning method log.""" + return self._logger.log(logging.WARNING, msg, *args) + + def error(self, msg, *args): + """Error level log.""" + return self._logger.log(logging.ERROR, msg, *args) + + def exception(self, msg, *args): + """Exception level log.""" + return self._logger.exception(msg, *args) + + +def pack_message(msg): + """Pack a TuyaMessage into bytes.""" + # Create full message excluding CRC and suffix + buffer = ( + struct.pack( + MESSAGE_HEADER_FMT, + PREFIX_VALUE, + msg.seqno, + msg.cmd, + len(msg.payload) + struct.calcsize(MESSAGE_END_FMT), + ) + + msg.payload + ) + + # Calculate CRC, add it together with suffix + buffer += struct.pack(MESSAGE_END_FMT, binascii.crc32(buffer), SUFFIX_VALUE) + + return buffer + + +def unpack_message(data): + """Unpack bytes into a TuyaMessage.""" + header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT) + end_len = struct.calcsize(MESSAGE_END_FMT) + + _, seqno, cmd, _, retcode = struct.unpack( + MESSAGE_RECV_HEADER_FMT, data[:header_len] + ) + payload = data[header_len:-end_len] + crc, _ = struct.unpack(MESSAGE_END_FMT, data[-end_len:]) + return TuyaMessage(seqno, cmd, retcode, payload, crc) + + +class AESCipher: + """Cipher module for Tuya communication.""" + + def __init__(self, key): + """Initialize a new AESCipher.""" + self.block_size = 16 + self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) + + def encrypt(self, raw, use_base64=True): + """Encrypt data to be sent to device.""" + encryptor = self.cipher.encryptor() + crypted_text = encryptor.update(self._pad(raw)) + encryptor.finalize() + return base64.b64encode(crypted_text) if use_base64 else crypted_text + + def decrypt(self, enc, use_base64=True): + """Decrypt data from device.""" + if use_base64: + enc = base64.b64decode(enc) + + decryptor = self.cipher.decryptor() + return self._unpad(decryptor.update(enc) + decryptor.finalize()).decode() + + def _pad(self, data): + padnum = self.block_size - len(data) % self.block_size + return data + padnum * chr(padnum).encode() + + @staticmethod + def _unpad(data): + return data[: -ord(data[len(data) - 1 :])] + + +class MessageDispatcher(ContextualLogger): + """Buffer and dispatcher for Tuya messages.""" + + # Heartbeats always respond with sequence number 0, so they can't be waited for like + # other messages. This is a hack to allow waiting for heartbeats. + HEARTBEAT_SEQNO = -100 + + def __init__(self, dev_id, listener): + """Initialize a new MessageBuffer.""" + super().__init__() + self.buffer = b"" + self.listeners = {} + self.listener = listener + self.set_logger(_LOGGER, dev_id) + + def abort(self): + """Abort all waiting clients.""" + for key in self.listeners: + sem = self.listeners[key] + self.listeners[key] = None + + # TODO: Received data and semahore should be stored separately + if isinstance(sem, asyncio.Semaphore): + sem.release() + + async def wait_for(self, seqno, timeout=5): + """Wait for response to a sequence number to be received and return it.""" + if seqno in self.listeners: + raise Exception(f"listener exists for {seqno}") + + self.debug("Waiting for sequence number %d", seqno) + self.listeners[seqno] = asyncio.Semaphore(0) + try: + await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout) + except asyncio.TimeoutError: + del self.listeners[seqno] + raise + + return self.listeners.pop(seqno) + + def add_data(self, data): + """Add new data to the buffer and try to parse messages.""" + self.buffer += data + header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT) + + while self.buffer: + # Check if enough data for measage header + if len(self.buffer) < header_len: + break + + # Parse header and check if enough data according to length in header + _, seqno, cmd, length, retcode = struct.unpack_from( + MESSAGE_RECV_HEADER_FMT, self.buffer + ) + if len(self.buffer[header_len - 4 :]) < length: + break + + # length includes payload length, retcode, crc and suffix + if (retcode & 0xFFFFFF00) != 0: + payload_start = header_len - 4 + payload_length = length - struct.calcsize(MESSAGE_END_FMT) + else: + payload_start = header_len + payload_length = length - 4 - struct.calcsize(MESSAGE_END_FMT) + payload = self.buffer[payload_start : payload_start + payload_length] + + crc, _ = struct.unpack_from( + MESSAGE_END_FMT, + self.buffer[payload_start + payload_length : payload_start + length], + ) + + self.buffer = self.buffer[header_len - 4 + length :] + self._dispatch(TuyaMessage(seqno, cmd, retcode, payload, crc)) + + def _dispatch(self, msg): + """Dispatch a message to someone that is listening.""" + self.debug("Dispatching message %s", msg) + if msg.seqno in self.listeners: + self.debug("Dispatching sequence number %d", msg.seqno) + sem = self.listeners[msg.seqno] + self.listeners[msg.seqno] = msg + sem.release() + elif msg.cmd == 0x09: + self.debug("Got heartbeat response") + if self.HEARTBEAT_SEQNO in self.listeners: + sem = self.listeners[self.HEARTBEAT_SEQNO] + self.listeners[self.HEARTBEAT_SEQNO] = msg + sem.release() + elif msg.cmd == 0x12: + self.debug("Got normal updatedps response") + elif msg.cmd == 0x08: + self.debug("Got status update") + self.listener(msg) + else: + self.debug( + "Got message type %d for unknown listener %d: %s", + msg.cmd, + msg.seqno, + msg, + ) + + +class TuyaListener(ABC): + """Listener interface for Tuya device changes.""" + + @abstractmethod + def status_updated(self, status): + """Device updated status.""" + + @abstractmethod + def disconnected(self): + """Device disconnected.""" + + +class EmptyListener(TuyaListener): + """Listener doing nothing.""" + + def status_updated(self, status): + """Device updated status.""" + + def disconnected(self): + """Device disconnected.""" + + +class TuyaProtocol(asyncio.Protocol, ContextualLogger): + """Implementation of the Tuya protocol.""" + + def __init__(self, dev_id, local_key, protocol_version, on_connected, listener): + """ + Initialize a new TuyaInterface. + + Args: + dev_id (str): The device id. + address (str): The network address. + local_key (str, optional): The encryption key. Defaults to None. + + Attributes: + port (int): The port to connect to. + """ + super().__init__() + self.loop = asyncio.get_running_loop() + self.set_logger(_LOGGER, dev_id) + self.id = dev_id + self.local_key = local_key.encode("latin1") + self.version = protocol_version + self.dev_type = "type_0a" + self.dps_to_request = {} + self.cipher = AESCipher(self.local_key) + self.seqno = 0 + self.transport = None + self.listener = weakref.ref(listener) + self.dispatcher = self._setup_dispatcher() + self.on_connected = on_connected + self.heartbeater = None + self.dps_cache = {} + + def _setup_dispatcher(self): + def _status_update(msg): + decoded_message = self._decode_payload(msg.payload) + if "dps" in decoded_message: + self.dps_cache.update(decoded_message["dps"]) + + listener = self.listener and self.listener() + if listener is not None: + listener.status_updated(self.dps_cache) + + return MessageDispatcher(self.id, _status_update) + + def connection_made(self, transport): + """Did connect to the device.""" + + async def heartbeat_loop(): + """Continuously send heart beat updates.""" + self.debug("Started heartbeat loop") + while True: + try: + await self.heartbeat() + await asyncio.sleep(HEARTBEAT_INTERVAL) + except asyncio.CancelledError: + self.debug("Stopped heartbeat loop") + raise + except asyncio.TimeoutError: + self.debug("Heartbeat failed due to timeout, disconnecting") + break + except Exception as ex: # pylint: disable=broad-except + self.exception("Heartbeat failed (%s), disconnecting", ex) + break + + transport = self.transport + self.transport = None + transport.close() + + self.transport = transport + self.on_connected.set_result(True) + self.heartbeater = self.loop.create_task(heartbeat_loop()) + + def data_received(self, data): + """Received data from device.""" + self.dispatcher.add_data(data) + + def connection_lost(self, exc): + """Disconnected from device.""" + self.debug("Connection lost: %s", exc) + try: + listener = self.listener and self.listener() + if listener is not None: + listener.disconnected() + except Exception: # pylint: disable=broad-except + self.exception("Failed to call disconnected callback") + + async def close(self): + """Close connection and abort all outstanding listeners.""" + self.debug("Closing connection") + if self.heartbeater is not None: + self.heartbeater.cancel() + try: + await self.heartbeater + except asyncio.CancelledError: + pass + self.heartbeater = None + if self.dispatcher is not None: + self.dispatcher.abort() + self.dispatcher = None + if self.transport is not None: + transport = self.transport + self.transport = None + transport.close() + + async def exchange(self, command, dps=None): + """Send and receive a message, returning response from device.""" + self.debug( + "Sending command %s (device type: %s)", + command, + self.dev_type, + ) + payload = self._generate_payload(command, dps) + dev_type = self.dev_type + + # Wait for special sequence number if heartbeat + seqno = ( + MessageDispatcher.HEARTBEAT_SEQNO + if command == HEARTBEAT + else (self.seqno - 1) + ) + + self.transport.write(payload) + msg = await self.dispatcher.wait_for(seqno) + if msg is None: + self.debug("Wait was aborted for seqno %d", seqno) + return None + + # TODO: Verify stuff, e.g. CRC sequence number? + payload = self._decode_payload(msg.payload) + + # Perform a new exchange (once) if we switched device type + if dev_type != self.dev_type: + self.debug( + "Re-send %s due to device type change (%s -> %s)", + command, + dev_type, + self.dev_type, + ) + return await self.exchange(command, dps) + return payload + + async def status(self): + """Return device status.""" + status = await self.exchange(STATUS) + if status and "dps" in status: + self.dps_cache.update(status["dps"]) + return self.dps_cache + + async def heartbeat(self): + """Send a heartbeat message.""" + return await self.exchange(HEARTBEAT) + + async def update_dps(self, dps=None): + """ + Request device to update index. + + Args: + dps([int]): list of dps to update, default=detected&whitelisted + """ + if self.version == 3.3: + if dps is None: + if not self.dps_cache: + await self.detect_available_dps() + if self.dps_cache: + dps = [int(dp) for dp in self.dps_cache] + # filter non whitelisted dps + dps = list(set(dps).intersection(set(UPDATE_DPS_WHITELIST))) + self.debug("updatedps() entry (dps %s, dps_cache %s)", dps, self.dps_cache) + payload = self._generate_payload(UPDATEDPS, dps) + self.transport.write(payload) + return True + + async def set_dp(self, value, dp_index): + """ + Set value (may be any type: bool, int or string) of any dps index. + + Args: + dp_index(int): dps index to set + value: new value for the dps index + """ + return await self.exchange(SET, {str(dp_index): value}) + + async def set_dps(self, dps): + """Set values for a set of datapoints.""" + return await self.exchange(SET, dps) + + async def detect_available_dps(self): + """Return which datapoints are supported by the device.""" + # type_0d devices need a sort of bruteforce querying in order to detect the + # list of available dps experience shows that the dps available are usually + # in the ranges [1-25] and [100-110] need to split the bruteforcing in + # different steps due to request payload limitation (max. length = 255) + self.dps_cache = {} + ranges = [(2, 11), (11, 21), (21, 31), (100, 111)] + + for dps_range in ranges: + # dps 1 must always be sent, otherwise it might fail in case no dps is found + # in the requested range + self.dps_to_request = {"1": None} + self.add_dps_to_request(range(*dps_range)) + try: + data = await self.status() + except Exception as ex: + self.exception("Failed to get status: %s", ex) + raise + if "dps" in data: + self.dps_cache.update(data["dps"]) + + if self.dev_type == "type_0a": + return self.dps_cache + self.debug("Detected dps: %s", self.dps_cache) + return self.dps_cache + + def add_dps_to_request(self, dp_indicies): + """Add a datapoint (DP) to be included in requests.""" + if isinstance(dp_indicies, int): + self.dps_to_request[str(dp_indicies)] = None + else: + self.dps_to_request.update({str(index): None for index in dp_indicies}) + + def _decode_payload(self, payload): + if not payload: + payload = "{}" + elif payload.startswith(b"{"): + pass + elif payload.startswith(PROTOCOL_VERSION_BYTES_31): + payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] # remove version header + # remove (what I'm guessing, but not confirmed is) 16-bytes of MD5 + # hexdigest of payload + payload = self.cipher.decrypt(payload[16:]) + elif self.version == 3.3: + if self.dev_type != "type_0a" or payload.startswith( + PROTOCOL_VERSION_BYTES_33 + ): + payload = payload[len(PROTOCOL_33_HEADER) :] + payload = self.cipher.decrypt(payload, False) + + if "data unvalid" in payload: + self.dev_type = "type_0d" + self.debug( + "switching to dev_type %s", + self.dev_type, + ) + return None + else: + raise Exception(f"Unexpected payload={payload}") + + if not isinstance(payload, str): + payload = payload.decode() + self.debug("Decrypted payload: %s", payload) + return json.loads(payload) + + def _generate_payload(self, command, data=None): + """ + Generate the payload to send. + + Args: + command(str): The type of command. + This is one of the entries from payload_dict + data(dict, optional): The data to be send. + This is what will be passed via the 'dps' entry + """ + cmd_data = PAYLOAD_DICT[self.dev_type][command] + json_data = cmd_data["command"] + command_hb = cmd_data["hexByte"] + + if "gwId" in json_data: + json_data["gwId"] = self.id + if "devId" in json_data: + json_data["devId"] = self.id + if "uid" in json_data: + json_data["uid"] = self.id # still use id, no separate uid + if "t" in json_data: + json_data["t"] = str(int(time.time())) + + if data is not None: + if "dpId" in json_data: + json_data["dpId"] = data + else: + json_data["dps"] = data + elif command_hb == 0x0D: + json_data["dps"] = self.dps_to_request + + payload = json.dumps(json_data).replace(" ", "").encode("utf-8") + self.debug("Send payload: %s", payload) + + if self.version == 3.3: + payload = self.cipher.encrypt(payload, False) + if command_hb not in [0x0A, 0x12]: + # add the 3.3 header + payload = PROTOCOL_33_HEADER + payload + elif command == SET: + payload = self.cipher.encrypt(payload) + to_hash = ( + b"data=" + + payload + + b"||lpv=" + + PROTOCOL_VERSION_BYTES_31 + + b"||" + + self.local_key + ) + hasher = md5() + hasher.update(to_hash) + hexdigest = hasher.hexdigest() + payload = ( + PROTOCOL_VERSION_BYTES_31 + + hexdigest[8:][:16].encode("latin1") + + payload + ) + + msg = TuyaMessage(self.seqno, command_hb, 0, payload, 0) + self.seqno += 1 + return pack_message(msg) + + def __repr__(self): + """Return internal string representation of object.""" + return self.id + + +async def connect( + address, + device_id, + local_key, + protocol_version, + listener=None, + port=6668, + timeout=5, +): + """Connect to a device.""" + loop = asyncio.get_running_loop() + on_connected = loop.create_future() + _, protocol = await loop.create_connection( + lambda: TuyaProtocol( + device_id, + local_key, + protocol_version, + on_connected, + listener or EmptyListener(), + ), + address, + port, + ) + + await asyncio.wait_for(on_connected, timeout=timeout) + return protocol diff --git a/config/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-39.pyc b/config/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..b00289e Binary files /dev/null and b/config/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-39.pyc differ diff --git a/config/custom_components/localtuya/select.py b/config/custom_components/localtuya/select.py new file mode 100644 index 0000000..29d11c9 --- /dev/null +++ b/config/custom_components/localtuya/select.py @@ -0,0 +1,100 @@ +"""Platform to present any Tuya DP as an enumeration.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.select import DOMAIN, SelectEntity +from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN + +from .common import LocalTuyaEntity, async_setup_entry + +_LOGGER = logging.getLogger(__name__) + +CONF_OPTIONS = "select_options" +CONF_OPTIONS_FRIENDLY = "select_options_friendly" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Required(CONF_OPTIONS): str, + vol.Optional(CONF_OPTIONS_FRIENDLY): str, + } + + +class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): + """Representation of a Tuya Enumeration.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._state = STATE_UNKNOWN + self._state_friendly = "" + self._valid_options = self._config.get(CONF_OPTIONS).split(";") + + # Set Display options + self._display_options = [] + display_options_str = "" + if CONF_OPTIONS_FRIENDLY in self._config: + display_options_str = self._config.get(CONF_OPTIONS_FRIENDLY).strip() + _LOGGER.debug("Display Options Configured: %s", display_options_str) + + if display_options_str.find(";") >= 0: + self._display_options = display_options_str.split(";") + elif len(display_options_str.strip()) > 0: + self._display_options.append(display_options_str) + else: + # Default display string to raw string + _LOGGER.debug("No Display options configured - defaulting to raw values") + self._display_options = self._valid_options + + _LOGGER.debug( + "Total Raw Options: %s - Total Display Options: %s", + str(len(self._valid_options)), + str(len(self._display_options)), + ) + if len(self._valid_options) > len(self._display_options): + # If list of display items smaller than list of valid items, + # then default remaining items to be the raw value + _LOGGER.debug( + "Valid options is larger than display options - \ + filling up with raw values" + ) + for i in range(len(self._display_options), len(self._valid_options)): + self._display_options.append(self._valid_options[i]) + + @property + def current_option(self) -> str: + """Return the current value.""" + return self._state_friendly + + @property + def options(self) -> list: + """Return the list of values.""" + return self._display_options + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS) + + async def async_select_option(self, option: str) -> None: + """Update the current value.""" + option_value = self._valid_options[self._display_options.index(option)] + _LOGGER.debug("Sending Option: " + option + " -> " + option_value) + await self._device.set_dp(option_value, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + state = self.dps(self._dp_id) + self._state_friendly = self._display_options[self._valid_options.index(state)] + self._state = state + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema) diff --git a/config/custom_components/localtuya/sensor.py b/config/custom_components/localtuya/sensor.py new file mode 100644 index 0000000..c8b2ddb --- /dev/null +++ b/config/custom_components/localtuya/sensor.py @@ -0,0 +1,70 @@ +"""Platform to present any Tuya DP as a sensor.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, +) + +from .common import LocalTuyaEntity, async_setup_entry +from .const import CONF_SCALING + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PRECISION = 2 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, + vol.Optional(CONF_DEVICE_CLASS): vol.In(DEVICE_CLASSES), + vol.Optional(CONF_SCALING): vol.All( + vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) + ), + } + + +class LocaltuyaSensor(LocalTuyaEntity): + """Representation of a Tuya sensor.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._state = STATE_UNKNOWN + + @property + def state(self): + """Return sensor state.""" + return self._state + + @property + def device_class(self): + """Return the class of this device.""" + return self._config.get(CONF_DEVICE_CLASS) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._config.get(CONF_UNIT_OF_MEASUREMENT) + + def status_updated(self): + """Device status was updated.""" + state = self.dps(self._dp_id) + scale_factor = self._config.get(CONF_SCALING) + if scale_factor is not None and isinstance(state, (int, float)): + state = round(state * scale_factor, DEFAULT_PRECISION) + self._state = state + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema) diff --git a/config/custom_components/localtuya/services.yaml b/config/custom_components/localtuya/services.yaml new file mode 100644 index 0000000..b276d2f --- /dev/null +++ b/config/custom_components/localtuya/services.yaml @@ -0,0 +1,15 @@ +reload: + description: Reload localtuya and re-process yaml configuration. + +set_dp: + description: Change the value of a datapoint (DP) + fields: + device_id: + description: Device ID of device to change datapoint value for + example: 11100118278aab4de001 + dp: + description: Datapoint index + example: 1 + value: + description: New value to set + example: False diff --git a/config/custom_components/localtuya/strings.json b/config/custom_components/localtuya/strings.json new file mode 100644 index 0000000..b8bedc8 --- /dev/null +++ b/config/custom_components/localtuya/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Device has already been configured.", + "unsupported_device_type": "Unsupported device type!" + }, + "error": { + "cannot_connect": "Cannot connect to device. Verify that address is correct.", + "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", + "unknown": "An unknown error occurred. See log for details.", + "switch_already_configured": "Switch with this ID has already been configured." + }, + "step": { + "user": { + "title": "Add Tuya device", + "description": "Fill in the basic details and pick device type. The name entered here will be used to identify the integration itself (as seen in the `Integrations` page). You will name each sub-device in the following steps.", + "data": { + "name": "Name", + "host": "Host", + "device_id": "Device ID", + "local_key": "Local key", + "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)", + "device_type": "Device type" + } + }, + "power_outlet": { + "title": "Add subswitch", + "description": "You are about to add subswitch number `{number}`. If you want to add another, tick `Add another switch` before continuing.", + "data": { + "id": "ID", + "name": "Name", + "friendly_name": "Friendly name", + "current": "Current", + "current_consumption": "Current Consumption", + "voltage": "Voltage", + "add_another_switch": "Add another switch" + } + } + } + }, + "title": "LocalTuya" +} diff --git a/config/custom_components/localtuya/switch.py b/config/custom_components/localtuya/switch.py new file mode 100644 index 0000000..f43d910 --- /dev/null +++ b/config/custom_components/localtuya/switch.py @@ -0,0 +1,77 @@ +"""Platform to locally control Tuya-based switch devices.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.switch import DOMAIN, SwitchEntity + +from .common import LocalTuyaEntity, async_setup_entry +from .const import ( + ATTR_CURRENT, + ATTR_CURRENT_CONSUMPTION, + ATTR_VOLTAGE, + CONF_CURRENT, + CONF_CURRENT_CONSUMPTION, + CONF_VOLTAGE, +) + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_CURRENT): vol.In(dps), + vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps), + vol.Optional(CONF_VOLTAGE): vol.In(dps), + } + + +class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity): + """Representation of a Tuya switch.""" + + def __init__( + self, + device, + config_entry, + switchid, + **kwargs, + ): + """Initialize the Tuya switch.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + print("Initialized switch [{}]".format(self.name)) + + @property + def is_on(self): + """Check if Tuya switch is on.""" + return self._state + + @property + def extra_state_attributes(self): + """Return device state attributes.""" + attrs = {} + if self.has_config(CONF_CURRENT): + attrs[ATTR_CURRENT] = self.dps(self._config[CONF_CURRENT]) + if self.has_config(CONF_CURRENT_CONSUMPTION): + attrs[ATTR_CURRENT_CONSUMPTION] = ( + self.dps(self._config[CONF_CURRENT_CONSUMPTION]) / 10 + ) + if self.has_config(CONF_VOLTAGE): + attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10 + return attrs + + async def async_turn_on(self, **kwargs): + """Turn Tuya switch on.""" + await self._device.set_dp(True, self._dp_id) + + async def async_turn_off(self, **kwargs): + """Turn Tuya switch off.""" + await self._device.set_dp(False, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + self._state = self.dps(self._dp_id) + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema) diff --git a/config/custom_components/localtuya/translations/en.json b/config/custom_components/localtuya/translations/en.json new file mode 100644 index 0000000..ebd22ee --- /dev/null +++ b/config/custom_components/localtuya/translations/en.json @@ -0,0 +1,217 @@ +{ + "config": { + "abort": { + "already_configured": "Device has already been configured.", + "device_updated": "Device configuration has been updated!" + }, + "error": { + "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", + "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", + "unknown": "An unknown error occurred. See log for details.", + "entity_already_configured": "Entity with this ID has already been configured.", + "address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", + "discovery_failed": "Something failed when discovering devices. See log for details.", + "empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists." + }, + "step": { + "user": { + "title": "Device Discovery", + "description": "Pick one of the automatically discovered devices or `...` to manually to add a device.", + "data": { + "discovered_device": "Discovered Device" + } + }, + "basic_info": { + "title": "Add Tuya device", + "description": "Fill in the basic device details. The name entered here will be used to identify the integration itself (as seen in the `Integrations` page). You will add entities and give them names in the following steps.", + "data": { + "friendly_name": "Name", + "host": "Host", + "device_id": "Device ID", + "local_key": "Local key", + "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)" + } + }, + "pick_entity_type": { + "title": "Entity type selection", + "description": "Please pick the type of entity you want to add.", + "data": { + "platform_to_add": "Platform", + "no_additional_platforms": "Do not add any more entities" + } + }, + "add_entity": { + "title": "Add new entity", + "description": "Please fill out the details for an entity with type `{platform}`. All settings except for `ID` can be changed from the Options page later.", + "data": { + "id": "ID", + "friendly_name": "Friendly name", + "current": "Current", + "current_consumption": "Current Consumption", + "voltage": "Voltage", + "commands_set": "Open_Close_Stop Commands Set", + "positioning_mode": "Positioning mode", + "current_position_dp": "Current Position (for *position* mode only)", + "set_position_dp": "Set Position (for *position* mode only)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "Unit of Measurement", + "device_class": "Device Class", + "scaling": "Scaling Factor", + "state_on": "On Value", + "state_off": "Off Value", + "powergo_dp": "Power DP (Usually 25 or 2)", + "idle_status_value": "Idle Status (comma-separated)", + "returning_status_value": "Returning Status", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (Usually 11)", + "battery_dp": "Battery status DP (Usually 14)", + "mode_dp": "Mode DP (Usually 27)", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "Fan speeds DP (Usually 30)", + "fan_speeds": "Fan speeds list (comma-separated)", + "clean_time_dp": "Clean Time DP (Usually 33)", + "clean_area_dp": "Clean Area DP (Usually 32)", + "clean_record_dp": "Clean Record DP (Usually 34)", + "locate_dp": "Locate DP (Usually 31)", + "paused_state": "Pause state (pause, paused, etc)", + "stop_status": "Stop status", + "brightness": "Brightness (only for white color)", + "brightness_lower": "Brightness Lower Value", + "brightness_upper": "Brightness Upper Value", + "color_temp": "Color Temperature", + "color_temp_reverse": "Color Temperature Reverse", + "color": "Color", + "color_mode": "Color Mode", + "color_temp_min_kelvin": "Minimum Color Temperature in K", + "color_temp_max_kelvin": "Maximum Color Temperature in K", + "music_mode": "Music mode available", + "scene": "Scene", + "fan_speed_control": "Fan Speed Control dps", + "fan_oscillating_control": "Fan Oscillating Control dps", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", + "fan_direction":"fan direction dps", + "fan_direction_forward": "forward dps string", + "fan_direction_reverse": "reverse dps string", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "Temperature Step (optional)", + "max_temperature_dp": "Max Temperature (optional)", + "min_temperature_dp": "Min Temperature (optional)", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DPs values)", + "temperature_unit": "Temperature Unit (optional)", + "hvac_mode_dp": "HVAC Mode DP (optional)", + "hvac_mode_set": "HVAC Mode Set (optional)", + "hvac_action_dp": "HVAC Current Action DP (optional)", + "hvac_action_set": "HVAC Current Action Set (optional)", + "preset_dp": "Presets DP (optional)", + "preset_set": "Presets Set (optional)", + "eco_dp": "Eco DP (optional)", + "eco_value": "Eco value (optional)", + "heuristic_action": "Enable heuristic action (optional)" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Configure Tuya Device", + "description": "Basic configuration for device id `{device_id}`.", + "data": { + "friendly_name": "Friendly Name", + "host": "Host", + "local_key": "Local key", + "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)", + "entities": "Entities (uncheck an entity to remove it)" + } + }, + "entity": { + "title": "Entity Configuration", + "description": "Editing entity with DPS `{id}` and platform `{platform}`.", + "data": { + "id": "ID", + "friendly_name": "Friendly name", + "current": "Current", + "current_consumption": "Current Consumption", + "voltage": "Voltage", + "commands_set": "Open_Close_Stop Commands Set", + "positioning_mode": "Positioning mode", + "current_position_dp": "Current Position (for *position* mode only)", + "set_position_dp": "Set Position (for *position* mode only)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "Unit of Measurement", + "device_class": "Device Class", + "scaling": "Scaling Factor", + "state_on": "On Value", + "state_off": "Off Value", + "powergo_dp": "Power DP (Usually 25 or 2)", + "idle_status_value": "Idle Status (comma-separated)", + "returning_status_value": "Returning Status", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (Usually 11)", + "battery_dp": "Battery status DP (Usually 14)", + "mode_dp": "Mode DP (Usually 27)", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "Fan speeds DP (Usually 30)", + "fan_speeds": "Fan speeds list (comma-separated)", + "clean_time_dp": "Clean Time DP (Usually 33)", + "clean_area_dp": "Clean Area DP (Usually 32)", + "clean_record_dp": "Clean Record DP (Usually 34)", + "locate_dp": "Locate DP (Usually 31)", + "paused_state": "Pause state (pause, paused, etc)", + "stop_status": "Stop status", + "brightness": "Brightness (only for white color)", + "brightness_lower": "Brightness Lower Value", + "brightness_upper": "Brightness Upper Value", + "color_temp": "Color Temperature", + "color_temp_reverse": "Color Temperature Reverse", + "color": "Color", + "color_mode": "Color Mode", + "color_temp_min_kelvin": "Minimum Color Temperature in K", + "color_temp_max_kelvin": "Maximum Color Temperature in K", + "music_mode": "Music mode available", + "scene": "Scene", + "fan_speed_control": "Fan Speed Control dps", + "fan_oscillating_control": "Fan Oscillating Control dps", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", + "fan_direction":"fan direction dps", + "fan_direction_forward": "forward dps string", + "fan_direction_reverse": "reverse dps string", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "Temperature Step (optional)", + "max_temperature_dp": "Max Temperature (optional)", + "min_temperature_dp": "Min Temperature (optional)", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DPs values)", + "temperature_unit": "Temperature Unit (optional)", + "hvac_mode_dp": "HVAC Mode DP (optional)", + "hvac_mode_set": "HVAC Mode Set (optional)", + "hvac_action_dp": "HVAC Current Action DP (optional)", + "hvac_action_set": "HVAC Current Action Set (optional)", + "preset_dp": "Presets DP (optional)", + "preset_set": "Presets Set (optional)", + "eco_dp": "Eco DP (optional)", + "eco_value": "Eco value (optional)", + "heuristic_action": "Enable heuristic action (optional)" + } + }, + "yaml_import": { + "title": "Not Supported", + "description": "Options cannot be edited when configured via YAML." + } + } + }, + "title": "LocalTuya" +} diff --git a/config/custom_components/localtuya/vacuum.py b/config/custom_components/localtuya/vacuum.py new file mode 100644 index 0000000..9a14399 --- /dev/null +++ b/config/custom_components/localtuya/vacuum.py @@ -0,0 +1,258 @@ +"""Platform to locally control Tuya-based vacuum devices.""" +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.vacuum import ( + DOMAIN, + STATE_CLEANING, + STATE_DOCKED, + STATE_IDLE, + STATE_RETURNING, + STATE_PAUSED, + STATE_ERROR, + SUPPORT_BATTERY, + SUPPORT_FAN_SPEED, + SUPPORT_PAUSE, + SUPPORT_RETURN_HOME, + SUPPORT_START, + SUPPORT_STATE, + SUPPORT_STATUS, + SUPPORT_STOP, + SUPPORT_LOCATE, + StateVacuumEntity, +) + +from .common import LocalTuyaEntity, async_setup_entry + +from .const import ( + CONF_POWERGO_DP, + CONF_IDLE_STATUS_VALUE, + CONF_RETURNING_STATUS_VALUE, + CONF_DOCKED_STATUS_VALUE, + CONF_BATTERY_DP, + CONF_MODE_DP, + CONF_MODES, + CONF_FAN_SPEED_DP, + CONF_FAN_SPEEDS, + CONF_CLEAN_TIME_DP, + CONF_CLEAN_AREA_DP, + CONF_CLEAN_RECORD_DP, + CONF_LOCATE_DP, + CONF_FAULT_DP, + CONF_PAUSED_STATE, + CONF_RETURN_MODE, + CONF_STOP_STATUS, +) + +_LOGGER = logging.getLogger(__name__) + +CLEAN_TIME = "clean_time" +CLEAN_AREA = "clean_area" +CLEAN_RECORD = "clean_record" +MODES_LIST = "cleaning_mode_list" +MODE = "cleaning_mode" +FAULT = "fault" + +DEFAULT_IDLE_STATUS = "standby,sleep" +DEFAULT_RETURNING_STATUS = "docking" +DEFAULT_DOCKED_STATUS = "charging,chargecompleted" +DEFAULT_MODES = "smart,wall_follow,spiral,single" +DEFAULT_FAN_SPEEDS = "low,normal,high" +DEFAULT_PAUSED_STATE = "paused" +DEFAULT_RETURN_MODE = "chargego" +DEFAULT_STOP_STATUS = "standby" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Required(CONF_IDLE_STATUS_VALUE, default=DEFAULT_IDLE_STATUS): str, + vol.Required(CONF_POWERGO_DP): vol.In(dps), + vol.Required(CONF_DOCKED_STATUS_VALUE, default=DEFAULT_DOCKED_STATUS): str, + vol.Optional( + CONF_RETURNING_STATUS_VALUE, default=DEFAULT_RETURNING_STATUS + ): str, + vol.Optional(CONF_BATTERY_DP): vol.In(dps), + vol.Optional(CONF_MODE_DP): vol.In(dps), + vol.Optional(CONF_MODES, default=DEFAULT_MODES): str, + vol.Optional(CONF_RETURN_MODE, default=DEFAULT_RETURN_MODE): str, + vol.Optional(CONF_FAN_SPEED_DP): vol.In(dps), + vol.Optional(CONF_FAN_SPEEDS, default=DEFAULT_FAN_SPEEDS): str, + vol.Optional(CONF_CLEAN_TIME_DP): vol.In(dps), + vol.Optional(CONF_CLEAN_AREA_DP): vol.In(dps), + vol.Optional(CONF_CLEAN_RECORD_DP): vol.In(dps), + vol.Optional(CONF_LOCATE_DP): vol.In(dps), + vol.Optional(CONF_FAULT_DP): vol.In(dps), + vol.Optional(CONF_PAUSED_STATE, default=DEFAULT_PAUSED_STATE): str, + vol.Optional(CONF_STOP_STATUS, default=DEFAULT_STOP_STATUS): str, + } + + +class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity): + """Tuya vacuum device.""" + + def __init__(self, device, config_entry, switchid, **kwargs): + """Initialize a new LocaltuyaVacuum.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + self._battery_level = None + self._attrs = {} + + self._idle_status_list = [] + if self.has_config(CONF_IDLE_STATUS_VALUE): + self._idle_status_list = self._config[CONF_IDLE_STATUS_VALUE].split(",") + + self._modes_list = [] + if self.has_config(CONF_MODES): + self._modes_list = self._config[CONF_MODES].split(",") + self._attrs[MODES_LIST] = self._modes_list + + self._docked_status_list = [] + if self.has_config(CONF_DOCKED_STATUS_VALUE): + self._docked_status_list = self._config[CONF_DOCKED_STATUS_VALUE].split(",") + + self._fan_speed_list = [] + if self.has_config(CONF_FAN_SPEEDS): + self._fan_speed_list = self._config[CONF_FAN_SPEEDS].split(",") + + self._fan_speed = "" + self._cleaning_mode = "" + + print("Initialized vacuum [{}]".format(self.name)) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = ( + SUPPORT_START + | SUPPORT_PAUSE + | SUPPORT_STOP + | SUPPORT_STATUS + | SUPPORT_STATE + ) + + if self.has_config(CONF_RETURN_MODE): + supported_features = supported_features | SUPPORT_RETURN_HOME + if self.has_config(CONF_FAN_SPEED_DP): + supported_features = supported_features | SUPPORT_FAN_SPEED + if self.has_config(CONF_BATTERY_DP): + supported_features = supported_features | SUPPORT_BATTERY + if self.has_config(CONF_LOCATE_DP): + supported_features = supported_features | SUPPORT_LOCATE + + return supported_features + + @property + def state(self): + """Return the vacuum state.""" + return self._state + + @property + def battery_level(self): + """Return the current battery level.""" + return self._battery_level + + @property + def extra_state_attributes(self): + """Return the specific state attributes of this vacuum cleaner.""" + return self._attrs + + @property + def fan_speed(self): + """Return the current fan speed.""" + return self._fan_speed + + @property + def fan_speed_list(self) -> list: + """Return the list of available fan speeds.""" + return self._fan_speed_list + + async def async_start(self, **kwargs): + """Turn the vacuum on and start cleaning.""" + await self._device.set_dp(True, self._config[CONF_POWERGO_DP]) + + async def async_pause(self, **kwargs): + """Stop the vacuum cleaner, do not return to base.""" + await self._device.set_dp(False, self._config[CONF_POWERGO_DP]) + + async def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + if self.has_config(CONF_RETURN_MODE): + await self._device.set_dp( + self._config[CONF_RETURN_MODE], self._config[CONF_MODE_DP] + ) + else: + _LOGGER.error("Missing command for return home in commands set.") + + async def async_stop(self, **kwargs): + """Turn the vacuum off stopping the cleaning.""" + if self.has_config(CONF_STOP_STATUS): + await self._device.set_dp( + self._config[CONF_STOP_STATUS], self._config[CONF_MODE_DP] + ) + else: + _LOGGER.error("Missing command for stop in commands set.") + + async def async_clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + return None + + async def async_locate(self, **kwargs): + """Locate the vacuum cleaner.""" + if self.has_config(CONF_LOCATE_DP): + await self._device.set_dp("", self._config[CONF_LOCATE_DP]) + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set the fan speed.""" + await self._device.set_dp(fan_speed, self._config[CONF_FAN_SPEED_DP]) + + async def async_send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + if command == "set_mode" and "mode" in params: + mode = params["mode"] + await self._device.set_dp(mode, self._config[CONF_MODE_DP]) + + def status_updated(self): + """Device status was updated.""" + state_value = str(self.dps(self._dp_id)) + + if state_value in self._idle_status_list: + self._state = STATE_IDLE + elif state_value in self._docked_status_list: + self._state = STATE_DOCKED + elif state_value == self._config[CONF_RETURNING_STATUS_VALUE]: + self._state = STATE_RETURNING + elif state_value == self._config[CONF_PAUSED_STATE]: + self._state = STATE_PAUSED + else: + self._state = STATE_CLEANING + + if self.has_config(CONF_BATTERY_DP): + self._battery_level = self.dps_conf(CONF_BATTERY_DP) + + self._cleaning_mode = "" + if self.has_config(CONF_MODES): + self._cleaning_mode = self.dps_conf(CONF_MODE_DP) + self._attrs[MODE] = self._cleaning_mode + + self._fan_speed = "" + if self.has_config(CONF_FAN_SPEEDS): + self._fan_speed = self.dps_conf(CONF_FAN_SPEED_DP) + + if self.has_config(CONF_CLEAN_TIME_DP): + self._attrs[CLEAN_TIME] = self.dps_conf(CONF_CLEAN_TIME_DP) + + if self.has_config(CONF_CLEAN_AREA_DP): + self._attrs[CLEAN_AREA] = self.dps_conf(CONF_CLEAN_AREA_DP) + + if self.has_config(CONF_CLEAN_RECORD_DP): + self._attrs[CLEAN_RECORD] = self.dps_conf(CONF_CLEAN_RECORD_DP) + + if self.has_config(CONF_FAULT_DP): + self._attrs[FAULT] = self.dps_conf(CONF_FAULT_DP) + if self._attrs[FAULT] != 0: + self._state = STATE_ERROR + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaVacuum, flow_schema)