From 07db300fbe20c92dd30a58bee39a300ec4eec676 Mon Sep 17 00:00:00 2001 From: Jeffrey Stone Date: Tue, 5 Apr 2022 11:22:07 -0400 Subject: [PATCH] Adding localtuya custom component --- .../custom_components/localtuya/__init__.py | 326 +++++++++ .../__pycache__/__init__.cpython-39.pyc | Bin 0 -> 9879 bytes .../__pycache__/binary_sensor.cpython-39.pyc | Bin 0 -> 2226 bytes .../__pycache__/climate.cpython-39.pyc | Bin 0 -> 10037 bytes .../__pycache__/common.cpython-39.pyc | Bin 0 -> 11056 bytes .../__pycache__/config_flow.cpython-39.pyc | Bin 0 -> 13950 bytes .../__pycache__/const.cpython-39.pyc | Bin 0 -> 3094 bytes .../__pycache__/cover.cpython-39.pyc | Bin 0 -> 6746 bytes .../__pycache__/discovery.cpython-39.pyc | Bin 0 -> 3377 bytes .../localtuya/__pycache__/fan.cpython-39.pyc | Bin 0 -> 6606 bytes .../__pycache__/light.cpython-39.pyc | Bin 0 -> 11556 bytes .../__pycache__/number.cpython-39.pyc | Bin 0 -> 2780 bytes .../__pycache__/select.cpython-39.pyc | Bin 0 -> 3274 bytes .../__pycache__/sensor.cpython-39.pyc | Bin 0 -> 2406 bytes .../__pycache__/switch.cpython-39.pyc | Bin 0 -> 2598 bytes .../__pycache__/vacuum.cpython-39.pyc | Bin 0 -> 7519 bytes .../localtuya/binary_sensor.py | 69 ++ config/custom_components/localtuya/climate.py | 394 ++++++++++ config/custom_components/localtuya/common.py | 369 ++++++++++ .../localtuya/config_flow.py | 478 ++++++++++++ config/custom_components/localtuya/const.py | 103 +++ config/custom_components/localtuya/cover.py | 232 ++++++ .../custom_components/localtuya/discovery.py | 90 +++ config/custom_components/localtuya/fan.py | 255 +++++++ config/custom_components/localtuya/light.py | 449 ++++++++++++ .../custom_components/localtuya/manifest.json | 14 + config/custom_components/localtuya/number.py | 84 +++ .../localtuya/pytuya/__init__.py | 682 ++++++++++++++++++ .../__pycache__/__init__.cpython-39.pyc | Bin 0 -> 19658 bytes config/custom_components/localtuya/select.py | 100 +++ config/custom_components/localtuya/sensor.py | 70 ++ .../custom_components/localtuya/services.yaml | 15 + .../custom_components/localtuya/strings.json | 43 ++ config/custom_components/localtuya/switch.py | 77 ++ .../localtuya/translations/en.json | 217 ++++++ config/custom_components/localtuya/vacuum.py | 258 +++++++ 36 files changed, 4325 insertions(+) create mode 100644 config/custom_components/localtuya/__init__.py create mode 100644 config/custom_components/localtuya/__pycache__/__init__.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/binary_sensor.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/climate.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/common.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/config_flow.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/const.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/cover.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/discovery.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/fan.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/light.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/number.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/select.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/sensor.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/switch.cpython-39.pyc create mode 100644 config/custom_components/localtuya/__pycache__/vacuum.cpython-39.pyc create mode 100644 config/custom_components/localtuya/binary_sensor.py create mode 100644 config/custom_components/localtuya/climate.py create mode 100644 config/custom_components/localtuya/common.py create mode 100644 config/custom_components/localtuya/config_flow.py create mode 100644 config/custom_components/localtuya/const.py create mode 100644 config/custom_components/localtuya/cover.py create mode 100644 config/custom_components/localtuya/discovery.py create mode 100644 config/custom_components/localtuya/fan.py create mode 100644 config/custom_components/localtuya/light.py create mode 100644 config/custom_components/localtuya/manifest.json create mode 100644 config/custom_components/localtuya/number.py create mode 100644 config/custom_components/localtuya/pytuya/__init__.py create mode 100644 config/custom_components/localtuya/pytuya/__pycache__/__init__.cpython-39.pyc create mode 100644 config/custom_components/localtuya/select.py create mode 100644 config/custom_components/localtuya/sensor.py create mode 100644 config/custom_components/localtuya/services.yaml create mode 100644 config/custom_components/localtuya/strings.json create mode 100644 config/custom_components/localtuya/switch.py create mode 100644 config/custom_components/localtuya/translations/en.json create mode 100644 config/custom_components/localtuya/vacuum.py 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 0000000000000000000000000000000000000000..f28ccb0488d8be2a19280a60560382d50f3fe2ad GIT binary patch literal 9879 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9bc9de96fa0a29f0ba9632685f69d711d473b39a GIT binary patch literal 2226 zcmZuxOK%%D5GMB_tz=1w9XoB3HrN%lxBebpd&&>!r9hF+aOFl$On|e)=YIU=n~B=(CV}?n;onhEC*&_2 z%r+MW524G~U>FGqqs&TaK;dhrR%Qn_t>&D-g*hj!W%Zz*d4ZQTf`(ah(`Ggo%zgLc*lI+SpFdWBy-qrp7$f(6zHuCXSYJ0Zd1Bf?s&eL`59w|V2(36{9cJKW<- zd=W++W(`QUGx_dW8mVC+GG7(`NN~w>rJ}k;Y^xncl z$X1u?`X~}AiPEmEm)85gJX!09Yn!WsL4Obq)*kntuIk$Q_S4lTTYBMVbHl(~(9cz( z#&CXqZF_4YoIUxL!qr_%U)e0;C`Am_W<4LtaUO?~t8x?qMlsf&RFUFgk?Y3P8xCVU zf8NlCh>$ht@=Gv^98hH;|G!(xHY11G=c7|s)y}A@CpIwSOqO@xUn2Kqyw9`9FC}Mw zlKXLy50gEAm==dU?d*z@BN2=bqcT<6-H1}jyPmdR7OD1j_=_?ToawfC>0oztw;yhA zXWnz=m8& zkuL>&tyHlBrcA6xV?Bph!|EAJaE&RBt&`HRV^9OxRF>{Vc2=N6B_GI}< zo}gHh3HQ}LzwAF7TU|%HvK(=ty)aDRKp5(JxVio4QGZ9*!bF1Hx~_I4Plvk3_{${b zx>ZG`l4amk%qL9Oo*zbHPrjkL5r%OZNg0MFkm3`7EpCCigO4z;^KR0~?MrFuog?{3 z+$iuhya5GsL=Ff%luj1Ug=-Yxo80JisrVc=y`kbZd@2;EDFa2j=3!sKSXy9+J-Kx` z6tnLm94HrqqXiD)IE6GjqXvyXMT3+Z4lv4UKaqaLUJr^#9h8h&rR_bgD)nPvKSbH8 zPE=HW0~^l4E?*9IcKZd)_(mk%U=F}hfcg}~asa50>8X82enUZ#14p?x2#%jyqG#k( z%O6Zv>i@4iW)=vomUrM5kJ)gpab|)19ALnm+*~&@F@Y$he+U(|95EN^ z7uwTIig}uzXOq$KGHx+>bpt#Ux$J=PsAIL}EZTIi3ta177vI81*VAHe&+sxV^H>!{ zDg`P-H=%ZyBUKiq6jxv(xW8Xy9MU&|m*uMWzU=lc6kAiW1qw@GfzsDLJeEV*$5hod zxFRcZft6S^aHavu-vm4m!xfzT42=mS#?Vx01AZn}@6*@&u_E;+EC>>hIuNFo<b?69g$ql& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..83b7e236aed88fff82cecd36a5f08b1f5e5e1d78 GIT binary patch literal 10037 zcmb_iO>o;tb_PHY{1HV_qW&dYv?Tw~Ka^+2nc2)gN1`lkY{`+R@h)a3D3|~x+5|~A zKzqc({SezkV%!bTlsD_qWggOaGm>1mWMQ5d1|@_!y7VmbXE?O7V3)V&TqP3(h zSs670o`?~xUb2?eWot!U;pJF$)w--+wyvmGtgGr(>zaDa%BorGx_aHZq291=syD4$ z>Mbj$=Bzb!&AP4Lw%$H#kz-B6vF$?ZrH|ES4W8R!KlIE0= zsDvt_`mV8{zGp0|@0;(bAD9=^56wmOBjbYlv2oE@dL^j&yMmE1F1-?rOXjjU@={hm zF=cbbjGLdBd6ZTRX)oTu`S2M-tv3~23ZktA? zRIOOLV`dEVS*2v!xgV1W>|{kZ($JYx(W_b6O>Go5^9T2e+Wq{uv|{1@g8*fF$D=44 zyZ7_kJMQSZ*I}XNRGi~%!cA^{nqSxM?`{;d{6TRySl%k+i|%N*es^=zP46E(c(A)y z)E?{=_6ztV8oKjsVllsW7etbwu$M0$>=oS1`E}Y}e&=q%9bZ4#+bir8HU6dTjeGqh zF2{)f!JXZmf*bb>h4o#xU6Fs7|8}RawR^vijku|NvAEYG&eZzu&SsFJ9qeou-4r@p z-`?Ne-O;x4_dYG`2Ps7ex3%w%b$K3c?Lx`~jZ<4M+}qziKs7II=C?4Gt-^Nk$5@s^ zR&vMg(d^QcdOCNSn)Kc!2pKm37SkoD+Q{Sc2brt8lQlyx!_J&bTF=CnH=Dtwy1cTnh#kE{+|jB#cRjLOtnvGiI~ue!3{bn2>d-!J8< z6nVhvhfKGMS-Cah@vRYCYs6z)Bd%7`FZj$qix*kbT-(&Ewz=ub^@dqdP zsXLY0p&GB%wKgtqfv83v>dZn_R%W+h0IfK+TjR}I6F%!&w^b`at=9ZTt!ZC`t$MX$ z=*MfmC~m|jxRH`swJS~AjhFQ!X4a0(igWmbzoOqid{iQnYySve#j}pbej6o6I29e~ zOvF2MBJj89$bIFoS9TO9;zSQaK3A-dE8bTg8QLn*$2ID6C7rR;)S2*7V?es9$)5E+^UvL%=Jxz$t1)tVC;)|dzRmS0~#n$p3mMW`d z?9!2G>6s?^g=dRDKx(cOkqsn_%>TZY`dYHBcn+_7Yj*MNRj;>HM`BA-I#pV+-8unKL+W_rnxiwD_Yo(^` z)Ge)4w;J^t{F=SS_caHe5BATuv0UTW9U+Bjo-XU?z`g?@gi>NkOo-9fQV%u@Dm`)j zPKXNXJ+AnFuuZfpQRCn*jv~v&K#17I@~+PtI85ea-{FZM?zp1>;TQEz~c5Dae9)0S11@P4O*?Cd!Wv zT9dS2^#vcfk{nQU7&tv6cvpa>|%bNu5!vacAN*?o-ldVnyIQlRgEG0X);r zOos;!!RMLtdEg4b14q!|fg|vFF8Vx6oG0U#E*TR4%JPYDy5gX&@jo2%zP9RIHbSRY zzRTzo>_r?%ujp%C8IEY+zE_olu8M0%!Io2DB-(rwOGIRrlyJmG9N++dZO<0=%{>y9he zbj!?!UD-CPWmoZncsJpx)rylpZ;7~jzNCPydZti(!qxf7kPw02jX(r9<`G*>t5o(?kfN*8G5o9kySEWS-|B$v(yB*kb}*dmR0v1C!o&pUPk_fevu(c>3~}h>$MVE8M_`t~u(78CE>$BT{so*i znWP{mu&(eY%VO)&z$Epz8_>gWE6Vv}_I-B-CkXg+_}5Iu%w^~daTCF3y?QfKF{&m_ z+wOUy$2y+=UrFY3&8fpSAYusZ_*7B2+I9hU(!gSNuv0${y~NT#6lZ7wNUd9e>7po> zrb~aOSE~A>su>sv?LBsGCmZ%w`zp!*E`e_mh!Gez6Y-9mwhJDc7M~D`!{@aY2DIPh z9T-%{{(c|r+xBOOXA=7!4T)P}3s@MBf+sR7JQ3J!2Vsg9>!VBT5hyVy3d`N=aKxNA z%8wibDV-LP8Tyv;L}slHJ+@CYKq&7?KzZ%CXU$p-r^yTrCZNAcx$8l5@p#7n?U-<= zNGLsmu4{P8nRZ5j9~Q3XI-Hx?k-!?c0yTI>6eVnjz@&9T0FxgI`k4s{uI!@@1Zz z86@Or4KY(M9g)0zAq93DH_Qed_}bHQZD3l?X*4j{9usnemtGJx z6BuM!N7}`7DA$%lT%6viJKI*H%J+WLC@@xML*K z>MdB-f~JSc6>ERdz8DZ}Fud!q^+-m0F`c=2{P7782kzvVz&^yj%u{>pLnnlATbLu$ zkr$BPctLw~_*B8E9NU;9&EcfHjc|So>z5oAww;B2)8PkGZ`yk7X9w|kng-Je%H1%l zH{oTq#0hduuUl6J zOl)Y=5_c5T6hFd;8`x~$fZG$75#{WgfU4HpCsHDfg;-~AjKo0Cm}{S@gk2KArrIrn@uQ3E0+1vlCzzq^LNJ$YDa9mgt0=c-m13Qw%+(1U8t2VQr4G_Za8V+whS>xK zUZIbn3w_j+it@%LtZ59`s6K_F@x+6&l55k6tZ0YI3VTH&y})V zKHsD_NUfepuXYTO3{~#+Je|6*Z=6bR{SeRO#UtFw;_MLNflpvEKBsX~WW;!k^le~m z%VR_($vi;$6gS|00)fs3DS@BuV0*K$J#KYB&jAG*4@@3;>~%ag9WL9aCx$%bsYIz1 zoS{N|V-JQ+xIL;^O)%!FY1#EOoii<-M zKyA)njqLgt=gLk8NcMdKq?L2T*1zCl7k{zXK4*{Ev^kPaxXP!|2dSJ4jVE%k1)kz; zhn&=PU}3^gr%ZxFi|{)5PPWPGfT1qN>nPDY!cI8{?zC8qa{vQ<6rQ{0AWf}})!sxo zbvk|~YzUucz9YThn=ihD`>YcQd|x0r4n9$MdiwMWCrvo-6Zcx|Cul3#fA36zI?a2U z3G@`AZ5%y;llSza6D%kV1AO|Er$2IL2}k=mzr`ZHE&DAf%}w9_>4^-kg{MFDH~sky z{tpF+9g%&rGtYH9(`*<>BpX;+i;NWLIXCX)JG;L(e>_XX!(x89#Se)*s^_N^o}uGw zkq#!_0gM);8?L~lZ7+>CMpdf>_+g(XF!}6qP z+M#d=;>qg%#hvs9rul>VjO^)MbRtMeDJ~^n%d!U*30F>1@@q+sBHTz~wJVZ>rJ{(P za!3hLO>594_I@LKk@EAD@kM?{(!(A5%^;Qe?s6-3H%v> zzW{JY{WLS~l&kGjGk21Q#6nJz-36dEYg1$w6=f;0Dv9wCJhK19{I`PlTpz8?!{5

?HpH*+{l4D@x2GuLSs1-V@;rf)@AZ9!66#GH?aVZ-i-U;2B6)!5A!Dqbv|*k$MVy!tiCr z{U>bz{3^z~m0hOd3IV>BE)zznm+n1r_f|M?AsoYn&OPYaWb7}A`!@*iyO<9M`-s5D z1o8wd0!0E}5crb7CIK>zz{2CELssKP-}j9FLo7SNE(6HOv`57R?WxK)lB@BQxRs70 qYbt*&g?r#@=|7cp52ee48VNfx{{B~bEk`*BANe($T zRriQyW0*xuAvTgt;LYu2vj7R?#c%lq36Oox4@f`cCCI}fz~(7HunSxH&Z+LXkuvfS zN?lW3Rb5?m>eP3>bE@5miM)cpf7t(T_x)ED<=^RM_%DN-Tet#4RTNLDDxT_T4YjKB zuU6IYt2gwfQAGpfjYg(vR?TL%nr&KDtC_3jn)zzJS*RA8#cGk;WEvCA$?7EM&Bj!7 zx;oAIY-6T5Tb*s5tDfU~t1;I+Up!a=y@*Z!T09nwP4VnwP7Wo6l6A zXP>qy_`T-Mdgt(a-JkU4yz`HhBeigk?S8x-_ z7s?i^;$y3)?kb_Wpe$ig5A~iN*q*+j^c0k83kquW1tl~VltZIu^zQA+Y#)k4Wgd;38g0QvntFg(iIQSZClq!t4fa+hr{0bz$Ro{N4)`fVRSchh(-9qc& zl|~!ONzz4=X0zQYcMjtG%|<;4snOdvXdEUIQ`6Mqx6z9oIW33hYg}{TWYZ520>huBcvY1fw)91XVTuCur1#gPtZtN z#kSm<&yy1i)b(Y0;KJF1yN0@85{aVaHS1e5W2lBUrwJ;r(cIF%A~Gb-U*HNtB%#vN zv8KwA{v++sJk0j8!Mvw#KpR4>ryZ%#2Wwk7%=NNM%Kf(v^F0f{mktZPLNEVF5wCk{ z&)n5}`Cef`xv4ySy_Y-2;`L1QnGZ7y==Vfbm4`3&GQHdpa@2Q8i~fGY4{d0SYj0w@ zeNC|+xB+mX4M?!1v`DAKK{=nd@wd5C4{ZE*?QOs1i+as&`r%I7v)z_w`)+M#&{43{ z?lwFdYqn>5?It8jjkp(<`GHewU{UP-dbne6bz8Nt-EIVB=yOI}Up(V?xn-|zy~G~RLXgy%o1*Zicun)N!ImHI0yzN)MMoYaB}}T)Ji?nLba!P+L0!TJ#80uud5&jSh=S_If|?ldbGR_6cMd- z<@fx91WAU>gUzMkjvLyZPl67lw$K*rw6Sl>l{g0>g;;T4l#Do25B+8kXR!s{Fcjhy zj4(E(=u5hIk#2Q#8fO6M-DJb{1ttg`HgPdRDB7Glr*c`1U@ZM35zw`WD-l78r}yU% zv5zE*0@(6BO~aHol!y6H$KFwYru-bc>~jzU>StWqPKU1=-4amY2h?<>kEsesc*q%O~Wl zkdU*YKOyNENLfP9s#7Rqa)z?$FR(|eGtja5I8Ttf#Nv#;zX;Uy?R`P0CD?9DCQ_zG zE5vlj43$TS1g&#QCV~!bLgh#~R!^YVfPTfnS|cU(Rve=aEpRfNnjV#Bx!mG3$7#N& z92Y_$LpZUZoM`Ap3nz(;+@C$77 z-C!@Cc0l%^4}+2^r`rm;5Bjt)&I4VYMtgf3gUC9oYj^K1uM1Lu!Da+58G+x}8el)~ zLeQF=BMp<|NL?hWFK!_rIR``B*i4%^Ar!@g4mJ@~aM-p9}QULn+#&wNh3Gd}9k+|ZEv4Ni@TG>XSW za7<$go<|CD3OhkLEqFfEK`fNx%!zVBdWAYB62G-5-#Qqljp4W2A)E@~yB-6h92O>E zie(!49ZKkZah5lDy&V^7XdU|LA~)SVwx#qW5K~m>i7|{!FejPS7ibaCJQVGM8qE() zyN@B%*~rwXIYH_XuHZc^&X>wDmg7rIoh$}zh3MaC;%N#0f>rUa2~M8noCThRr%o~} zil@DcS|^9{+;JXhf%q5q%S?>RizkX~Nu=A;Amq=(3C|!qgqD*%m7ZZ_%6R5u>rnIL zd+=tmSBKb)=m+2phN$ChGvO@mRq(-I(Qi1rpm^4vA$}E}qjtFF!t?!_+@Gub)P;|g zR>e@liLcayw^e`#kMp8OqvrYlOz{do?~fYeRKHTiTd2()&!;m0qts}=pDHcwRheI4 zTUv~+Zgd|6HBo=y6AhFuKx!?0n9?vPN7IS=kRgSp-v}zvJaz$W$|EWw00GF@gbseR zaL27T3zn>27256}Z zmg{-)Wj6xiF`oaRyDi>9e}a0KNW^Yu0$y+D%|kJ z3Azc$BT!UH@~m%+O!VfE>N;clVBiFu(Oo>#7jya|L9)QOvVo6n;ssG*XuC#Pdy55H zx+I8V4dy1MaT{looh7kzhKe%a&2A`2vb=w*v&5B08Mwr7GXeD9p~oiWjnnObCiB!A0{4L=JJAyz=yh}AXKV?sGhD?LcAOdE=Q1~Ni_c66IgZg$0 z(8E~4YrqJ;j=G=V3J7`>Z5FO37&iYas@iXg*-4=KG~L62D_YZF+}0B_V?EKd;Zn#qniOwd zN{l3oBARM!IIZ^n7!-;>K+Qi=?+k!oLD9nS;6{XcYOnN%b`L!;A)o;gE~yOu>aoVu z0%a1lKoFE&o$GH=?${Wh0q!$gvo4JV;R7@47Z+*OQo{yJXQvUdf{rcxmlIV3P_#Sc z%@IQpn>Dvp^BXJ?@rM|r)SAHt>(geg{b!N`DX^lMkVKxz@-)e0dPpX$LnEw8#}j?m zoD>REVcRV3MDvtC23YsB6AeO8X{|n19)1G&)DGT};E4%CDBxZy0nDeUeN}1AqSWwo z5=#h!K_-PQrxCrd2pHJ*ZFYO?_Ll9!x1e95Q#8`P-5&-%a2slNgNeZn@7I?qFtv&F zhS>t3w3L-L)+9dUbp&li;UEFo7NRZWekURchlF)2!#EpTQ?Fr+Suf=LS|q3OBHC2;q@f5&;4|Iq)!$px8q7ko=Op$)M*4<6^6$@xS64659oc zZBB}9_LSIyQ*tBST%;bzR~*QSHlo~|2^+g0-3s_`477=L1o6_v5}u4rSs^~cO#&!_ zv2&DKiTkMk4_p#Yh}sV0$taqS;0a*~VGs}YIx>VO(idmuLQyRs2DvG_#u2tjVt?cO zz;=}~QnEB@F+mBN3Yx&zQqaPs(y=kqocio;GTPP%vg1 zJ7e4%w7Ho@kYj~1aB1JRI$l<>%Qyh*pC(us@1Z~reekl{*wnw@5g{h8?E zkSY$hFU^uS*K!ejci^`<&9>KVP`>Cm54&z7smVHy*RH{Hx`qA`AQo--f#HEzqw#D| zLS~qamBewv6G6K}kPpvV$Eqn>xP|8@T`^#PBX!4kR;$#Q&HCHO(QMhOS`;{&)zrLU zWvtgM!zx%stBCuI1p_XqZ4KARAL$tV1z#a~T3mpcI7J4mYQ|3@1Mumy$a8c6c?)@t z5FnpJo{w;l@5c#H#>Y4)D<%gy6Y>}b@dAz#RHx*j%}}i1QarUv%$b&DnVDr|uloo= z<17P=n2adf4XRJbYq>^%p8gS0rTYRdraos!3PJ-`OFxq83tQ!w952X1l`RTR@9Mj` zFh69ywCM7f|35s0N~s`z4{wxukWIohQgbP21+N?y3x@@<3A;pOnfA6=p@f5Gv?}lw z*(RAX*uxPs@diC@CceG7x9^JWK(IQnV)UK$Z_zG|$Fph|rGn3oiSukPodAp^cSODX zEs|7fscnKofoLimAvj;VM&eFnm;}Lgky9t$MjLa;k<&ec9k~pi$nIKUE_LP5E*}<9 zT9ob_xqhBG-YT4sEs*LAo8*0GqswQySkkwuMD}gZBQPO$)M5HrZgR)`=maQldXu)!LzKX$JW1mZWM9lKVu zm`$PpfCZ+3#P_3{;|d_DF}r$nia>#?At!6JQ^ssT)TK1Z3Lc+3<8kRr-9sy}k0hRx zZ786N6OT|%Bc4i(DE2a)MtX90^tC^pl25f;IM`^mVFilRAs_@FVI7O|f$S9PYO(9BMsC(p)CM25`xDx`ZtJJrU!EPwt!FmI&GX9(bCC16qN5sN^+*u zuvk(Qq?H7&AI2h$)1j9*(Z(SUY$%^|bz60u8K!zQ(3P?v zGL3VxGZ?cU4ozTTT(bREBNSgpul?aaiyK;KayLD=J9KnIY_t6g ze$_pE7l0|eG7!9*;JS^oQ>+_K3~Zp7?UG}OlPGpC%aQi-4=Ad@it(p-L+bJwd?@!i zO^X$zuO)FFy(D%T4sIG-om8VsseZq>pyq*yOyBe>eLd@CT9(L2^qNra91#3d49&$ z@CDdE^73K#rJvuE;f<@wL~3*nl1QD^pU(@)nJ!nNmn4ZJgN#%3UmPSK86Y;a7tqn<{!SgA8XyV+)rW&Y-xf$}W7E@iH^3J#>4yyb zAp!M-QZdSaH~=LW3a}nwkZhIy6P4%(PhT$z6>yA%=hEhLUAbueEhheomWI|!{56tf z!K!plv*pl8<=TyHI|N_kw)>>Nb|#%|_SY^Gono=+Bd4|>x;TZ|{*pY&J%fqKuRKUv zYcPibh=@7bI9%gzH6T^`&O_pPpW4XaYJFbceHIgs# zMzoUfLwZ87<`I9#z>n(vwZF;vrLCsniXw4B07{j zq(o41ixT2-BB12YC?WeTenQD#Q1TTee@O|cnfMz@D1gI55FzCTI>ro<{iH4`euFEx zj)Z;ZeDMmrz6gQB1HH+}$rD(@Sw2JIk97F+GCl){XUCrdMjFNOgfeM!O(6aFYvZrI+l+hQ2MX`1- mAdv?dMeTa7h^XSUQPhg+Gge;5nq9TB7CtM==PvwMnfhNwleD$~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4900133ab812b0883a2d575fffee5e1ea257cc92 GIT binary patch literal 13950 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 aDxS`y5~<$Q(7?Xo&sm2DFAlnc?*9Q)rbxvA literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6b516eae14aa1a000d1a05f170c33f2629bff072 GIT binary patch literal 3094 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8d7efe8eb848c7c21974742463c95cc0b038545b GIT binary patch literal 3377 zcmZuzTW=f36`t8$E|(X{@+FSr1kJ{+3oxxIyG824a1CFi+D2X3i0lL*S}ayOBWji9 zE;Bo{D?)mbgZicafqe8+`ycu*=Cx0L4hj@`albPwQk0z~=FD(rXXl*nobQ~8mzJ6g z&%a*$H~t=@|InYs$HSl7Xyq#i!Fo(^;bgqWZSV9P+q*p%y_z%pJL{qe0GSQNJ z_w1qDJ15#=2`e86XOnf7$NyYQ%cOWN)m}I##3+;DcA>(jqrEs3@_CxbvfFH~=Q>p~ z+Y7hiQi`z1LyZ@%Bt?#mx;xwpw^cDXF?ple+|hbi-dJ7jr+R0!)lG`QYND?^9?Ja6 zW-(HUTwVOi>Q+{4tq$T+OSM`iDjn*wdO^%I#6itBfRAl-xN!&K3v{Q1+>S??j<(|D zTbYZFYy5lZa7QZBjI(~BaQL7!H5ez$as`|B?%xqvF)W^Zm5DodxAQ)Ik+BQ66JCw1 zB!5x6hgRN&Apg8%2V67FCysWfTziw+lxhEv9l8h3zB8#$*@3&y_uWab&!*HjrtCuo zzci-|XuIRj)?}jghB}nF?E&VqwI|`0Dq@kq^s;+G2~x;M+ax6}LF^-RG^FPH_^jX; z@c(<~BaBQ?V{T8gk!H3;M0S>H5u zm>K ztWM*PwWtzpT5Bz+bsiIC0KweAy}$#mKEyboiN%K$xs6udfRGH7WJE_$aY?wsd&zsA z0KGt(ny8Bay)PP~iM}pcqK!VN$Yhp?H*-Rbudf00zKRFoPMnKOsxV6t-g%n$k6AYN zeRyPJ+1*Gi#?Xcy8dI~8NG7ESfQa=;azq=8pe#9l{OQ9y)v3iUjKf^M2=9WgbA7rS zAV>X()utI$%1Bk3Mg*^C#2|!?SITVL1hbiU0S=-l#W7Lz3#d|(JDKBipO4R)4lEkuUv27G>2My!;nv;)`nov5IWl7LAp)I6D@os1px_vv{y2;#*zJ|A5BEmrtTmk~YUQnZ4g}eym^Z+F#nd2-b4bv{Z^>Ayh(N zT72HqOMQyvW6e}J|4U?0pFv!s!6k@}qkfJqZQ-1a@wG?uP&@$!C3i|m!vg624o`^C zrNQ-1-PGf9FHh3Ktn{Uh)F`J=jz+Pu(}?aBTX&w{PEWbbOmqO=JCUi5%**$Q3ObZ?`ORQ_E1+ zO4F`VPPAQ&P@gN0E4nQikD{DX5~X?Z4nF4eJeFq4`~jQFWeDc9xx?G|Ejvv<{`91$ z-`n?IKBdBB5xk87ut9Z4y@ubPbMSprRuYoTS>Y?ZjYK@Y_zo6!^AYxZqth@*;CVcdQG^l@Rd+s&+fnr0 zD9(;n>QN+$B#P9}@NIR41VyX*JqbE_K^wYKSW2o37*Wb8DZD{Pr|AbvfgiN&N`lGx zLz#@i_}^&udZpPRJ7D9_0WAsw5<7o@uqEiVH|4YcU&8@(>EbMJ&PG?sDZVX$S7spF zq@|=DseE>grNm}aD+;52QK)Z0+`3h)vhOl9F|{(2a%fJit^e`Pvqw*(rw{+U{`lF` z%6Re-5_Dr;Qx-t5DJht(d`VW(6qQDYFY`0bIj+`d|9N#Aoubp!Zy`)QEBbw;QB&WE z%bhIUQdI2CC-#0=Ae9bx_PRUqco6IE(H*y}?oPk_saJ+79pDo19QXF>mQ)^J#j49! z1M&6K6I8 aqPE{taLYhG2`f(3c;{=6~o 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 XaH$IT^whF8Bd!*7Su0n6Exh?ZAYGAI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5427d922bc4b1bae125f06c0fa562b89edd39135 GIT binary patch literal 11556 zcmb_iYit}xa-P@j4wuU}DLzDrtG8CRC{l0x1V=VSJcR_MFoB66{ z_DRx@WVA)eE-Q$)2DAL$`7f~{OLg9CLTAdDvD4nMW{l{ ztCq^Ynx)}a&+7%lGSr|hQiy7bsuW_D8B#i|IJk^_XCYxF3SCxLq1);%^jJNGUaPmz zXZ02Ot^UG*HBcC|1`9*h5Vwou4;6;3;lhYDQW&*HL5qr5eyniVI$RjH#<^_fj}(qt zM++0yL?LM<3&*Tuh2z%o!U^j{;iPr4aLPKRDvr8!+BvhWT4zOvl@f95oanS(5DDu= z(Pf<%-PWY&u`Y;SYfAK4)1u$HCGM`&n^^(GQoXa@wuKrb()m=}?|F53dC`7t@!p-g%k#5KOCe!q z;r_y+y*xYj!0%*^+1s~gXO{ggF5F$RnZb|VT9|wF-n`$x^u~h+3yaIm9d*@c0c)M5 zsq5|XLq1~N=oy4?vpr320=f5 zU)p_I%-F8umDgbOe?BddFIPJMNmhR2i^oHEc*LsxPx3CX$VpLIX?;OEQ zb06!9Vns8he5ow`NTKZJGJeF(I7P>gJXy_ojvt*VyI!f_N9L%~8lRe)a;5}e%O`W4 zim095r}mO_eQG*;8MPM=s09aVFYQx{cHH*r)aA=pFJDCuQ`fFfQIGV5pSGH*sdTNz zdN6f`Yp0pAUpv$s&?9QHQ|<3Uj~E9=@{8;7dsEkHGr%bK8{=ordhZx(yUhmbm%UuJoVU6kifdOfx9O|bv-nuu0GaHyMmdwcM5|e^zjXb|m4{gQkUz#MX-``r za0-%Sb>t*0e?*qbMd9m(ToEAsUMlKGxRDjl%2L73m!3E>#LMgJz)i0dQVC<4A1$O& zC!QAHrdt1NjW1KV=DHPwrFQK5dMfhUH? z^g5`1EiN>n3*#eg8#UnS^b()Pg?Du|_lh_62`<$ZI3%jvxAA|CBtQFc?G15_HhHGBKjH|{S7u|RX&p}(5X!=08KnQ?qQ zSM>bIB5AE0Y-z{ALQ^|=i25EPFhY#|wHM6QqX0SFT2T)jB&m`=R%_7|Uev+_<6)_k zmp#<`7yx9Zi*=V(JpeCK51k}XJCOA%2<~+NMekMVJGiR>3t(&6P&Is`pBRP)K0}S~ z8k+Va`AcYJ?6;N7B!@lN2qqL1+e@P0ACco*Xvgm$1@8DAqTn6Bhs0@d2EW4;z~gu1eZ^3$Q7H2{znh~? zvK$<`Rwfpmb?LY;Drr)@WGM@CN5NzzdXk*!nP!Fa|)?$g`qw^d1_7~eYMo!zFn)0xMpjNL2d=+Kg@ zz+0?3%~HKRze77Ql|lquhBxp{+s=X8w*44(^PM}0;iI;THF5k-`=O+esOY#Z3KT#~ z+j9!*U=3`Ludg^Bn*@0oz50EDV`*x~`(C~jRI;yWssw*PYIH466YJ32y~WvE_VPk_ z0>Bw+ercIagM677;x=5JUC2pCuw9T}38}eaNF}2nZxD5$Y2>za9y`*7X|h*cOvA}$ zos8!vLd4GJTn}W9#9TkVns&pPvi0x>`21wwzqhn(FW!0WE&Jk?D}jA%tFb9?_6OV9 znQHJ$j7QE>m(7tMCEp7l-Wjm-aV8b<4cEzM{b+C`^%H@_gJYQ$W9OR_e)Q3kv|MqY ztA2-VXYy&+we5yYlQR&&hj`qt0x0H~8V$e3Zd8w|1WmSIiCt|s3Ja#G0T$HNxZ|*F zCOujduv@JRw{GUiniB1C^W>9g5H9UrsH(!j&DhMUlX;ZPWt&tpnR5~3z@f=mus&Di z+xTg>(UF{Mmx_N&9cloIUKwl~2{Y^&2ECn!LFz@ra1(&4JdVgqSwqaRX5fiXCT26T zqzIk&(TULi(jIDT1+r8`3F062x;wk*b{?Tu7hWGIbD<<0F7gS}rXmMI@-|o{Er)!a zK!yMXU5!=qXD&?}>DQ*LnYnKucE=S?0CGgKtcdx>z(({Cb1 zV9dCwxMwkYbp;lLt%zP3YnuJ4i>L$JKZr*sdD8Q#h|Ho}`O5%)Kg`K`Nm6i;b*}GUKF%g_?U>#!GWk;9aKhOX(0!S#I;JZ7Bmc1s}{_sI^-L8 zbr2)15cYro4M*M~aE-t`fdv4BUZmcBZ==H?CflP54TgV41DE0##3Z`0+pQU&Xc0{{ zv|d%))jrjBjo2R5XtZt-8STpbcjn1((YaxgjJ+dyHIq1C;DNz)X^+w*_FdID_yQB` z{t!I1ZQ8E04X*$Hz|!wE4U9#+@20AI4EN*D8d%M*xCal6f>kI>&4}~^r)?9cOR8zx zWXe(^z)1#fCW$m8EC=jdU^?b;d?SMqocrqrgA-JN!B{*<%F&?5f2WB=0mwQ;)yhzt zWeB(qo(MIiiLg<=+%#})EOP1WYwu7~6N0%+@kg z+SocC-q^niM5r4G$imHisEQ-wf)Y}6Z1=OE7LUsiwaU5L zl1oZ5G`Tf$#@O%dyON}tlk-RzPuQ(Pv2BEZ&HgSyZ4r7pNAGM9+)8Zf30p# zl9`Ob47nWXBM_;KxAs4Y&Czh5Ut*SO>C4PAuywUrN)N;GgPHJIQa%Qu%`UU!>-xO0 zH>SR4>~*~+gWj@2YH6gZ2I-J5v_@<_YHOs2qelQQJ@-k@TMcKzq2<0aDPaHupEr|+ ziP|%hgLUo8tpc09+B~%8Rp5Yl-?6V_?6}VQYz=DI)0VNLStD%dN5P|Y;55`GcFH4z zq(8rtKn?7)o_C7AiL3#i2ZBt+K3>K;gh;p+0?L(#2-h>%Jz|0eNs?03%BeOb-@iqM zxv2#{gwrM=t#QWzJmr1meKkv0hT1dp19eTkg^FNLVx%0X^2p7lQ;5o7v;V(S?>lyH_)NS{>4uO zXQP=nmX;UhLZd1-@seO;{sv(*4r|P|4|>UQS_HD&3L@!2Rr|D(`oaTjktnH7eFN7_ zEJvJ!P$7-5CB?%TLVgpRDI;(joZ|X55M-Iae%jO9Zc)$Hhpd~s2~GQ?GO=gKtv0x2 zpyYwbDg6^3_d^Uhr)YaSY)3a@#nYmLd)kjTK8PNx2HqE|#_D4pr!jj+>SdOsca$Ze$$x|-CLjsIkkBgA ztbTxzCoy}~J36Xtm{pTUqCe8W1=(yJLz+ruyiGDlEY&2%Zz=vwC@D?;7E8)xIdOmg zXDM+XETdpt?Gid3N~oRh%Sh)0O9$=_(m4s~5Ee*BlRplnqpY0*opj|io--q~?y7f! z)-ovLC>ZEg6K$gdT*?|9i|Gkl;#k-b91Az%?J{iFzG}QK;}oqddO5fCLKW8=_i(hn z3nR-1?9R%OWlHW->QmPLk3)^4*9q`pm_la%)SmEhKUe985hHF@^R@Hws{7c+T@_MpL9!uAf9ObioGc7{ z@q6?tI?e=1huehlft+nE`OY8_!Kd>2F+4b*QxuP+Uzli%;qMO+iks>TT--ZU8|93s z>zlO`fn237k5HWsKkr94*7LPhS3aVeJb@SLfL&x)@b?P3(m@jI0-5%|28W^Y9#<7amAHqOZM+mI1ffR{!7HVQTW{|@kWA$K>A zC!76Uk}?fM(JDvUTxTn1%g{-aXgAE@asLo~z)aFchG$1co=m8|!4t8`2uE%z@;W%8 z9*&ocnS_C}1GRC~{wr!t4-OFSSHm$(CCXaDC3@GkBEJvEr_lw_Z))UHHsO$&qKinK zVlV|4LUju6jA8U*a6sl^Wd)P^ zxA-qxN!p-gH~A#(Cd=yxDIGC6zeCHoL+A4yGN7_b7@yfcpdwk;mQ^P`p;f1hhD(-? zZgJl3My3#1L7{AFzo)%R#{yM;7nS%SQwL@Ib^OQ_fwmnVzd?x_5o+g1Jli!B9mxr^*BYru_jwWiU9*V$=3K= zRAe*Fsaw-SnEgbi*L{OvU?^EkcZiV=h=EsN<4?Z8Pwg?~2L;{1wd?!T2PcYtg_=jD zQz+pgW}V37NK(-tAKFn*Zpc3%Mt!x6yBZg!en0Vcw+im}NyK;2jQ@QA+XoA(ku~jx z73>ecbcGS$P3ZW40_qZ4KWrMT>8@!Ij3SSg06!pV_E?P8_oc?@_N?K4etqW_!9HCqW@yAJ>>`V(f~zgmXu>FZBUfJ4tGbh%Wodvth{=?qQUda z_8;DJOVfVG#nGn#i)%2FpFmK}(I}yMKpX;ZBhW*`F-WE}odW30pcs~%QfN7r(iehq zSaB+0)v3a|NJ~L2tUL9v;WUWG$o>p#9uQ}iTFx9TJ0~b{PST2VidJcDPjgP+&}f}D z_B7gHb!I&@oiof}4OU`j*lAcbsD4{(&5Wp1pL- z_7dB(qcr@6@%A(DMC-MPER8%ad@pF}vbeN*v$NcjwVtx+M#2|6pr2h_?Ok`5x_6cr zyYAvj=k{$`zrEgB@46ekuX?Lr_nr|r3=C&h;=UK)=d!(W)=PGxzMC+Sj$DAlcVs#A zBX`RSQU+7+K{oaGe;`^PPnkS8$D}O6NN#};+C3t4VNmi|7uvoFqacb01oS1UQ-hk1 z^#l0jo+Zkn@@gv|16CMcUISb_vXlNM3q3nc7`6S#?#I!wZbAcg=! z`+l$e>txaaj}7n!g!iCTmBv;aNNaT@{5bLg*-%ltx!iN_bXGRHvbxm0-q~1LcR?kM zP7uiAV$67-$pU=J6SA=8MMEa7HFiJsIir&kI(J_>p>r328FZ99)%baU_2;__8R~_8 znus`b`*ApmBZzpi5MWLOfPbN(7=`}ot* z$IKVW7$!^u7HEzRHETT52EYqy>{*V<3am)YCxjMganF26oYJADL@lMU4mD+3QJOL< zWHk@=71$3||3s%Vw7F+GRXR)OfDN@rnyEQ;NZ^UQ-q5$KV#oFx%M0JbEZFe?DqfX; zX3*|Wz@raHYaCS=d5t}UD%&Ny`mQ1Lf$3|z=Fj9uLNsCQYOW~?Ad8~33sttI@vHdW zFN@LvL4Cfes8y8`fT)kpFGoJo%O5jaY_bdtFf)mHyVt78LXwUcmzL}LVCcHCjwF=TqSTK-9%C>x{vsz}I z6Vb|0_*53Z-S+q}c}is2b^C#rB(AHuxeeki7)cF;W}PENy+|sgp^wihzQ2fn zd*TIDXOSy%y+mpxwjwq92O-lSOk{j=LPZrBTR9EnXkD0&)=|S=OXu(5k!=utm13CSgLCBZZ!nT`ATrp% zTkRg)0n2a|f1%;Us~9-HF`_^r#lC*ZIW)yQvAvc76w1j_giFe$BVZgIxju!DtD;i~ zjwfBt9Nuc#?e!(%PyqGurD>O@Y>&ks_Z-Imv~px0ogoM0J8VOm3w>2Nac)UbSP>&l z>}|NpQ{@(gE@E&D0{;-jWe_=b=q;*uOrdX}Hci}oe-Ab!sw?U>GQKzs|G06hft@Py zLI$0x;<{l>(*Wl+*S(*5LHEakJd1Bf$N<_wi ztaHQOFpfCDr)wxxR=kvJ4TUX&f(rr`>(0yrro<;pC dcG3h@CSKdRMVh3kH}#5cLQG&7R%7mizX8+cw(9@@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8f89987ed1d98dbd42ad71791c143a0856464ea0 GIT binary patch literal 3274 zcma)8TW=$`6((m!qtVs!WiNKpWRN0gRm5_Vwm_N$X}7ksG2+Mz+ey#?(!o@u?;tAc!W`AMAW13H6vqedL}J&mRA9t71`seR~ms+h*0MIc|BaaocNC!s&d4cdn?n${cTv)x339XN^nZZQLiU$y%3$ zwRnp=7nXO6o4n1d{1)GUR+||oq}!gp{U8eDFcD){CT=Qt#$)LQ@x(pNCxN^7zzs5J zx;)OuTm&*q;@)?#57O^aRZRmS!yxJ!%HBKrL|Bk|BV7PLa41x$3Eyk+VAe+PkKjSh_eE=mU zUOUs1`$>v8GVjjywm%dhk6APs;GBye*6|2-p=5VKNHV9=xT4a8nk6e(3(*;L`r0w9 zCSo@mobhqs<{4*h7`uZc9)=@#7$xVuuC2@`Nu-=({x}Z>XR4{Uc=X`(;HZD1%uEX9 z9BG6>q;4&D`X3%2?DzK${{X%-S(-f~Vif@Wu)R~PurtUrnT-8GGES2i_MGiR=oJZG z*(vtgODC#PwDj?Zim#H`c$zr;G!2H0HwS!E+Tg9;}QdBigq^~^8H5Mq&;myT!g z3b&c{4P_N(Us@N`tAfVSnksX&rp7C*#_E>@dh1}_V9jp~)?)2T%WJR|)`7Tden~9i zwZNUL<(VS-A}Xe@AM-NAbR@aS&<%<}+h2;W!2&PBu9l?%-|P)IOi3=tLpq}~V`fTY zXoX~EeM`Thlt^<%e2Y<#l^J zLEEqC<2G~P3~JMt4&o4VG@No*p7FxlkmOk+dW!r&-FoCA;^b>bsSEgT?7HH>gyNnNmJd$BNLLw2tx%(uDa-L1!I!$B{xyPU>7rC`(J(mvm#-YIpR_@(Y69U45e!Ou1mhSMHE5GI|7lfjn^6C^Wo^;vUCrIk^Srn4C-9st zG^M^k$rx3dlF`!;!9)}AW=X&pse@by;J_k|dR;1B0?TJq{0u$?d?Y`;2h}c(%PoHd z9hm`wnA2Bog1c_|e*)WD0v3gtMCvL69Fe)Ol6Vt^hV&d)wG7fvpl<=_&P_n2%?n6O z7^Gc<0fsIr?k(*ocL|R^QUoN=AXSPW>2*zkhbn%80`I>c#n&vXi>NpTs|CoHZ-QJl z?Bj)BpcXqYXrNsU75pJ;B=EmM$$k$4kwc+#V@9v&CV6T|bB;7k?+|9(Au}jQT3}O| z+d%8&VfD&5Ak6%Tz=Owx{aVw_Pg4e|s447vU`#tFkT96zNBYuz*PUAK_6Kgi>xfrj zd3`vl0vX2tEH+W`8VE??GMj98VfZYCWM`=#GVq%YFiZxoS!@z>I_Edfc75?c!3R$n zc%Q?l;yO0g=xsXv=}kBO$e3&Aj-EvC>2v=DRAywZy>~^Q!X@?)y#wAWJgF}_yxMF= z!Ar(;Yfm$Ti?o$z?m4{Oa_u+P>vyZ71M`V@P`D_xcYcjE%q+15qHE~g1Dhf7-Q01z znAb1o`~n7)n{Cn!`eLSCH}AHQXyahafoSW#KTcR4VZG`5kMkfZd#b+Ak^wLj@{)2= zkpM-@iDpFc7V0caK=C$I#V=9(2E}{W(<=FGz@uovaXJ~d(L}S|2GlSLGORe%GMaA~ zv||;eZgp&d9o^gFxA0NbC>f0)wp4AH#{-!pQ6}(+R2|-cc`EZH&jddI)vIU8m;=wf=TEp^@o*EE7Xpt~t=}|_VLLiSi(3cAj+0n8IG~0DYH-UO0uV05`>51WjHIA1 r6aD7XsF%*`IR!cI7moe_8CO4mE$RTPJHY83qhr*8(=F3+);|6ZLz`Zq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2f63d31d891881d8ce5ac30ec5990f4c3f383ba5 GIT binary patch literal 2406 zcmb7F&2Jk;6rb5$+v|@cO`Eo%1-hbq8Hyu7syGx>%WFCaqVlaPj1OnnM(JGK(rw`now_$8Qg;&M{)D~ap7X1)|x zlbT;k>V7?G_zjpVhn09CS@ai^C4b3$yYZ!D*Qg2VY39Np)ou2o(i!)6$FU-EX!!UDnxd_xs(xF0b!AXm9nJwqEY+ z^fuW1Z@p@!_Iq14LXk77taGYt zqdB0Fvyv*CH&qq$c44lnYN$V|WAZ}#C+pmbYzUh(erFdDh}4sVL!Ja)E_vuhsW-^d zVRYaPSsKKm4CdOFN(khRwzg7X zgCgo?z8%N9+{w5Ya9x7^1Eov5L3+T!e>)t>XGE-m;oqOE6&ThAxl~!g23a!7QizmX zi!qi8V!1X==jd415h^RZxC-kf28!4h>;s$&VaB}`ZVED*w5S#$3iEo9} zx1W-s3Dv3VJG>M+Vd<2dP`?a1@Ym=S(A}_Vba%?t>QmwnzXqXgOmmJn`-XL6ZLtL}cRpN`I!5D0@Pf6H++V z8sFSXBcyaR=AJs_FCEZpmUKzxBQCVdSOkWQ=?dH4+1%{z3MhY~%S@_3aWHfwkB7P( z^2ZV6s!^n);Hq{D#8Ifr-yH?wKt7|o%Ge+dq-4xwP`m-#iMK%9h9Qk>?hQI#HKp(( zEUg)kSGbOR@8DWw)Py`D;2#~Y%ouBe>KU}HCKb29=ou9ryo&RYMg|19j4rj{TVhcW zdwlgG2xIXI0;GpQfNVkpMZFmukWEuL7-Qo%=2IO;(kmj?g3eI^+F~JT`+zG`4&npY z=_0;Hv7!jy=U^~{`Q}BK^M?Nm?cF)FIpiI|COnY2;0boh8FtX2J>Bsl;AVOZK8Pj) z{s;x8;kEd$;EpUCvbi0PK=yp%6 z2^4Ei#XVJ;P*qlyp><-xmy1=hLR9s{0()4&YSZetv2N&WXp>9t2yTUZ6aqWKRMVfSRl>KY&D>RU89S1{Px8#zPzMa$D&Sy zghNr+7)!D+hvS%TF!p^O#M3_&#=>j>MT(`V-I2(k9jIdiQGASG@1Xb$|1L~Rdcfm& zR@SDh-a`{p;5R|@V93iLTrQurrCRLUC>bGA;xbpLiR^rzr5wFdoch>qT2}?-e2b3LBNgd55~RVYzVWtd!y6 Nv1*oMyY)-&{|EcBW{Us- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a67891929a0c9a37cc3be0af1699d974766263f6 GIT binary patch literal 2598 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 BdZYjV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b00289efeebd3478b86de9f31bf6e7b5279b0aec GIT binary patch literal 19658 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 literal 0 HcmV?d00001 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)